using Distribution.Features.Scheme; using Distribution.Features.Stock; 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) { private const int GlobalScopeRoleLevel = 50; private const int CreditApproverRoleLevel = 20; private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel; private async Task 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> 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(page, pageSize, total, stores.Select(ToResponse).ToList()); } public async Task 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 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 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 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 UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default) { if (currentUser.RoleLevel < 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 UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default) { if (currentUser.RoleLevel < CreditApproverRoleLevel) return null; var store = await repo.GetByIdAsync(id); if (store is null || !store.IsActive) return null; if (!await HasStoreScopeAsync(id)) return null; store.OutstandingBalance = request.NewBalance; await uow.BeginAsync(ct); try { repo.Update(store); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(store); } public async Task GetOutstandingBalanceAsync(Guid id) { var store = await repo.GetByIdAsync(id); if (store is null || !store.IsActive) return null; if (!await HasStoreScopeAsync(id)) return null; return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance); } public async Task 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 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?> 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 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 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 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 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 { 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); } }