361 lines
14 KiB
C#
361 lines
14 KiB
C#
using Distribution.Features.Notifications;
|
|
using Distribution.Features.Scheme;
|
|
using Distribution.Features.Stock;
|
|
using Distribution.Features.StoreCredit;
|
|
using Distribution.Infra.Authorization;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
|
|
namespace Distribution.Features.Store;
|
|
|
|
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
|
|
{
|
|
private const int GlobalScopeRoleLevel = 50;
|
|
private const int CreditApproverRoleLevel = 20;
|
|
|
|
private async Task<bool> HasGlobalScopeAsync() => await currentUser.GetRoleLevelAsync() >= GlobalScopeRoleLevel;
|
|
|
|
private async Task<bool> HasStoreScopeAsync(Guid storeId)
|
|
{
|
|
//if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
|
|
//return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
|
return true;
|
|
}
|
|
|
|
private static StoreResponse ToResponse(Models.Store s) =>
|
|
new(s.Id, s.FK_RouteId, s.OwnerName, s.ContactNumber, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
|
|
s.CreditLimit, s.OutstandingBalance, s.Segment, s.IsActive, s.CreatedAt);
|
|
|
|
public async Task<PagedResponse<StoreResponse>> GetAllAsync(
|
|
Guid? routeId, string? search, int page, int pageSize)
|
|
{
|
|
// Deactivated stores stay visible in the list showing Inactive — deactivate never
|
|
// removes the row, only marks it (same rule as Orders, see testbug.md #42).
|
|
var total = await repo.GetCountAsync(routeId, search);
|
|
var stores = await repo.GetAllAsync(routeId, search, page, pageSize);
|
|
return new PagedResponse<StoreResponse>(page, pageSize, total, stores.Select(ToResponse).ToList());
|
|
}
|
|
|
|
public async Task<StoreResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null) return null;
|
|
//if (!await HasStoreScopeAsync(id)) return null;
|
|
return ToResponse(store);
|
|
}
|
|
|
|
public async Task<StoreResponse> CreateAsync(CreateStoreRequest request, CancellationToken ct = default)
|
|
{
|
|
var store = new Models.Store
|
|
{
|
|
FK_RouteId = request.RouteId,
|
|
OwnerName = request.OwnerName,
|
|
ContactNumber = request.ContactNumber,
|
|
StoreName = request.StoreName,
|
|
StoreAddress = request.StoreAddress,
|
|
Latitude = request.Latitude,
|
|
Longitude = request.Longitude,
|
|
OneTimeAmount = request.OneTimeAmount,
|
|
CreditAmount = request.CreditAmount,
|
|
CreditLimit = request.CreditLimit
|
|
};
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.AddAsync(store);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return ToResponse(store);
|
|
}
|
|
|
|
public async Task<StoreResponse?> UpdateAsync(Guid id, UpdateStoreRequest request, CancellationToken ct = default)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
store.FK_RouteId = request.RouteId;
|
|
store.OwnerName = request.OwnerName;
|
|
store.ContactNumber = request.ContactNumber;
|
|
store.StoreName = request.StoreName;
|
|
store.StoreAddress = request.StoreAddress;
|
|
store.Latitude = request.Latitude;
|
|
store.Longitude = request.Longitude;
|
|
store.OneTimeAmount = request.OneTimeAmount;
|
|
store.CreditAmount = request.CreditAmount;
|
|
if (request.CreditLimit is not null) store.CreditLimit = request.CreditLimit.Value;
|
|
store.IsActive = request.IsActive;
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(store);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return ToResponse(store);
|
|
}
|
|
|
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null || !store.IsActive) return false;
|
|
if (!await HasStoreScopeAsync(id)) return false;
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Remove(store);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public async Task<StoreResponse?> UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default)
|
|
{
|
|
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null || !store.IsActive) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
store.CreditLimit = request.NewLimit;
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(store);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return ToResponse(store);
|
|
}
|
|
|
|
public async Task<StoreResponse?> UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default)
|
|
{
|
|
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null || !store.IsActive) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
|
|
store.OutstandingBalance = request.NewBalance;
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(store);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
|
|
// Only on the crossing, so repeated updates while over the limit don't re-alert.
|
|
if (store.CreditLimit > 0 && wasWithinLimit && store.OutstandingBalance > store.CreditLimit)
|
|
await notifications.NotifyAsync(
|
|
NotificationTypes.CreditLimitBreached,
|
|
[
|
|
RecipientRule.RouteOwner(ScopeKind.Store, store.Id),
|
|
RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, store.Id),
|
|
],
|
|
new NotificationArgs(new { store.StoreName }, "Store", store.Id, $"/stores/{store.Id}", currentUser.UserId),
|
|
ct);
|
|
|
|
return ToResponse(store);
|
|
}
|
|
|
|
public async Task<OutstandingBalanceResponse?> GetOutstandingBalanceAsync(Guid id)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null || !store.IsActive) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
var outstanding = await creditRepo.GetOutstandingForStoreAsync(id);
|
|
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, outstanding, store.CreditLimit - outstanding);
|
|
}
|
|
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null || !store.IsActive) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
store.Segment = request.Segment;
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(store);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return ToResponse(store);
|
|
}
|
|
|
|
public async Task<StoreResponse?> SetActiveStatusAsync(Guid id, bool isActive, CancellationToken ct = default)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
|
|
store.IsActive = isActive;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(store);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return ToResponse(store);
|
|
}
|
|
|
|
public async Task<List<StoreVisitHistoryItem>?> GetVisitHistoryAsync(Guid id)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
|
|
var visits = await routeRepo.GetVisitsByStoreIdAsync(id);
|
|
return visits
|
|
.OrderByDescending(v => v.CheckInAt)
|
|
.Select(v => new StoreVisitHistoryItem(v.Id, v.CheckInAt, v.FK_UserId, v.CheckOutAt is null ? "In Progress" : "Completed"))
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<SchemeEligibilityResponse?> CheckSchemeEligibilityAsync(Guid id, Guid schemeId)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null || !store.IsActive) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
|
|
var result = await schemeService.CheckStoreScopeEligibilityAsync(id, schemeId);
|
|
if (result is null) return null;
|
|
return new SchemeEligibilityResponse(result.Value.Eligible, result.Value.Reason);
|
|
}
|
|
|
|
public async Task<StoreDashboardResponse?> GetDashboardAsync(Guid id, string period)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null || !store.IsActive) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
|
|
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
|
|
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
|
|
var periodEndExclusive = periodStart.AddMonths(1);
|
|
|
|
var orders = await repo.GetOrdersForDashboardAsync(id, periodStart, periodEndExclusive);
|
|
return new StoreDashboardResponse(
|
|
id,
|
|
periodStart.ToString("yyyy-MM"),
|
|
orders.Count,
|
|
orders.Sum(o => o.TotalAmount),
|
|
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt));
|
|
}
|
|
|
|
public async Task<StoreAnalyticsResponse?> GetAnalyticsAsync(Guid id, int months, int overdueDays)
|
|
{
|
|
var store = await repo.GetByIdAsync(id);
|
|
if (store is null) return null;
|
|
if (!await HasStoreScopeAsync(id)) return null;
|
|
|
|
months = Math.Clamp(months, 1, 36);
|
|
overdueDays = Math.Clamp(overdueDays, 0, 365);
|
|
var now = DateTime.Now;
|
|
var from = new DateTime(now.Year, now.Month, 1).AddMonths(-(months - 1));
|
|
|
|
var invoices = (await repo.GetInvoicesForAnalyticsAsync(id))
|
|
.Select(i =>
|
|
{
|
|
var date = i.FinalizedAt ?? i.CreatedAt;
|
|
var paid = i.Payments.Where(p => p.IsActive).Sum(p => p.Amount);
|
|
var balance = Math.Max(0, i.TotalAmount - paid);
|
|
var due = date.AddDays(overdueDays);
|
|
var daysOverdue = balance > 0 && now > due ? (int)(now - due).TotalDays : 0;
|
|
return (Invoice: i, Date: date, Due: due, Paid: paid, Balance: balance, DaysOverdue: daysOverdue);
|
|
})
|
|
.ToList();
|
|
|
|
var inRange = invoices.Where(x => x.Date >= from).ToList();
|
|
var totalSales = inRange.Sum(x => x.Invoice.TotalAmount);
|
|
var overdue = invoices.Where(x => x.DaysOverdue > 0).ToList();
|
|
|
|
var summary = new StoreAnalyticsSummary(
|
|
totalSales,
|
|
inRange.Sum(x => x.Paid),
|
|
invoices.Sum(x => x.Balance),
|
|
overdue.Sum(x => x.Balance),
|
|
overdue.Count,
|
|
inRange.Count,
|
|
inRange.Count == 0 ? 0 : Math.Round(totalSales / inRange.Count, 2),
|
|
invoices.Count == 0 ? null : invoices.Max(x => x.Date),
|
|
store.CreditLimit,
|
|
store.CreditLimit > 0 ? Math.Round(store.OutstandingBalance / store.CreditLimit * 100, 1) : 0);
|
|
|
|
var monthly = Enumerable.Range(0, months)
|
|
.Select(n => from.AddMonths(n))
|
|
.Select(m => new StoreMonthlySales(
|
|
m.ToString("yyyy-MM"),
|
|
inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Invoice.TotalAmount),
|
|
inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Paid),
|
|
invoices
|
|
.Where(x => x.Date < m.AddMonths(1))
|
|
.Sum(x => Math.Max(0, x.Invoice.TotalAmount - x.Invoice.Payments.Where(p => p.IsActive && p.CollectedAt < m.AddMonths(1)).Sum(p => p.Amount)))))
|
|
.ToList();
|
|
|
|
var topProducts = inRange
|
|
.SelectMany(x => x.Invoice.ShopPO.Items.Select(it => (it, x.Date)))
|
|
.GroupBy(t => t.it.FK_ItemId)
|
|
.Select(g => new StoreTopProduct(
|
|
g.Key,
|
|
g.First().it.Item?.Name ?? "Unknown",
|
|
g.Sum(t => t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity),
|
|
g.Sum(t => (t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity) * t.it.UnitPrice),
|
|
g.Max(t => t.Date)))
|
|
.OrderByDescending(p => p.Revenue)
|
|
.Take(10)
|
|
.ToList();
|
|
|
|
var unpaid = invoices.Where(x => x.Balance > 0).ToList();
|
|
StoreAgeingBucket Bucket(string label, Func<int, bool> match)
|
|
{
|
|
var items = unpaid.Where(x => match(x.DaysOverdue)).ToList();
|
|
return new StoreAgeingBucket(label, items.Sum(x => x.Balance), items.Count);
|
|
}
|
|
var ageing = new List<StoreAgeingBucket>
|
|
{
|
|
Bucket("Current", d => d <= 0),
|
|
Bucket("1-30", d => d is >= 1 and <= 30),
|
|
Bucket("31-60", d => d is >= 31 and <= 60),
|
|
Bucket("61-90", d => d is >= 61 and <= 90),
|
|
Bucket("90+", d => d > 90)
|
|
};
|
|
|
|
var list = invoices
|
|
.OrderByDescending(x => x.Date)
|
|
.Select(x => new StoreAnalyticsInvoice(
|
|
x.Invoice.Id, x.Date, x.Due, x.Invoice.TotalAmount, x.Paid, x.Balance,
|
|
x.Invoice.PaymentStatus.ToString(), x.DaysOverdue))
|
|
.ToList();
|
|
|
|
return new StoreAnalyticsResponse(id, overdueDays, summary, monthly, topProducts, ageing, list);
|
|
}
|
|
}
|