diff --git a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs index 07e97ad..0b39522 100644 --- a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs +++ b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs @@ -23,7 +23,12 @@ public sealed record DashboardStatsDto( int PendingCounts, int OpenRfqs, int PendingLeaveRequests, - int OverdueSalesInvoices); + int OverdueSalesInvoices, + IReadOnlyList MonthlyPurchasesVsSales); /// One bar in the Stock Valuation chart — total FIFO layer value for a warehouse. public sealed record WarehouseValuationDto(int WarehouseId, decimal Total); + +/// One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal +/// of Posted purchase invoices vs Posted sales invoices. Month is "yyyy-MM". +public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales); diff --git a/Backend/ERPCore/Services/DashboardService.cs b/Backend/ERPCore/Services/DashboardService.cs index 3ae9c92..48faf7f 100644 --- a/Backend/ERPCore/Services/DashboardService.cs +++ b/Backend/ERPCore/Services/DashboardService.cs @@ -26,6 +26,7 @@ public sealed class DashboardService : IDashboardService private readonly IRepository _rfqs; private readonly IRepository _leaveRequests; private readonly IRepository _salesInvoices; + private readonly IRepository _purchaseInvoices; private readonly IReorderService _reorder; // Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema @@ -33,11 +34,14 @@ public sealed class DashboardService : IDashboardService // balance older than this many days from its invoice date. private const int OverdueAgeDays = 30; + // Monthly Purchases chart window: the current month plus the previous 11. + private const int ChartMonths = 12; + public DashboardService( IRepository layers, IRepository grns, IRepository pos, IRepository requisitions, IRepository counts, IRepository rfqs, IRepository leaveRequests, IRepository salesInvoices, - IReorderService reorder) + IRepository purchaseInvoices, IReorderService reorder) { _layers = layers; _grns = grns; @@ -47,6 +51,7 @@ public sealed class DashboardService : IDashboardService _rfqs = rfqs; _leaveRequests = leaveRequests; _salesInvoices = salesInvoices; + _purchaseInvoices = purchaseInvoices; _reorder = reorder; } @@ -103,9 +108,50 @@ public sealed class DashboardService : IDashboardService var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct)) .Pagination.TotalItems; + var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct); + return new DashboardStatsDto( lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse, pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs, - pendingLeaveRequests, overdueSalesInvoices); + pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales); + } + + /// + /// Posted purchase vs sales invoice GrandTotals per calendar month (UTC) over the last + /// months, oldest first. Months with neither are omitted, so an + /// empty list means "no posted invoices yet". Purchases are dated by VendorInvoiceDate, + /// sales by InvoiceDate. + /// + private async Task> GetMonthlyPurchasesVsSalesAsync( + int? branchId, IQueryable salesInvoices, CancellationToken ct) + { + var now = DateTime.UtcNow; + var since = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-(ChartMonths - 1)); + + var purchaseInvoices = _purchaseInvoices.Query().AsNoTracking(); + if (branchId is not null) purchaseInvoices = purchaseInvoices.Where(i => i.BranchId == branchId); + var purchases = await purchaseInvoices + .Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.VendorInvoiceDate >= since) + .GroupBy(i => new { i.VendorInvoiceDate.Year, i.VendorInvoiceDate.Month }) + .Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) }) + .ToListAsync(ct); + + var sales = await salesInvoices + .Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= since) + .GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month }) + .Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) }) + .ToListAsync(ct); + + var purchasesByMonth = purchases.ToDictionary(p => (p.Year, p.Month), p => p.Total); + var salesByMonth = sales.ToDictionary(s => (s.Year, s.Month), s => s.Total); + + return Enumerable.Range(0, ChartMonths) + .Select(offset => since.AddMonths(offset)) + .Select(m => new MonthlyPurchasesVsSalesDto( + $"{m.Year:D4}-{m.Month:D2}", + purchasesByMonth.GetValueOrDefault((m.Year, m.Month)), + salesByMonth.GetValueOrDefault((m.Year, m.Month)))) + .Where(m => m.Purchases != 0m || m.Sales != 0m) + .ToList(); } } diff --git a/Frontend/erp-system/types/dashboard.ts b/Frontend/erp-system/types/dashboard.ts index da56e47..2a207aa 100644 --- a/Frontend/erp-system/types/dashboard.ts +++ b/Frontend/erp-system/types/dashboard.ts @@ -19,4 +19,13 @@ export interface DashboardStats { openRfqs: number pendingLeaveRequests: number overdueSalesInvoices: number + monthlyPurchasesVsSales: MonthlyPurchasesVsSales[] +} + +// One month of Posted purchase vs sales invoice GrandTotals; `month` is "yyyy-MM". +// Only months with activity are returned (last 12, oldest first). +export interface MonthlyPurchasesVsSales { + month: string + purchases: number + sales: number }