Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev
This commit is contained in:
@@ -12,12 +12,25 @@ namespace ERPCore.Controllers;
|
||||
public sealed class DashboardController : ApiControllerBase
|
||||
{
|
||||
private readonly IDashboardService _dashboard;
|
||||
private readonly IDashboardChartsService _charts;
|
||||
|
||||
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
|
||||
public DashboardController(IDashboardService dashboard, IDashboardChartsService charts)
|
||||
{
|
||||
_dashboard = dashboard;
|
||||
_charts = charts;
|
||||
}
|
||||
|
||||
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
|
||||
/// <summary>Aggregate counts for stock, GRN, procurement, sales, accounts, service, production and HR.</summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats(
|
||||
[FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
=> Ok(await _dashboard.GetStatsAsync(branchId, from, to, ct));
|
||||
|
||||
/// <summary>Datasets for the dashboard graphs; the time-based ones follow the optional from / to period.</summary>
|
||||
[HttpGet("charts")]
|
||||
[ProducesResponseType(typeof(DashboardChartsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<DashboardChartsDto>> GetCharts(
|
||||
[FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
=> Ok(await _charts.GetChartsAsync(branchId, from, to, ct));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ERPCore.Dtos.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Every dataset behind the dashboard graphs, in one computed-on-read object (same idea as
|
||||
/// <see cref="DashboardStatsDto"/>). Time-based sets follow the From-To period the dashboard filter sends;
|
||||
/// "snapshot" sets (ageing, batches expiring, low stock, warranties) describe what is open now.
|
||||
/// Purchases-vs-sales by month and stock value by warehouse are already in <see cref="DashboardStatsDto"/>.
|
||||
/// </summary>
|
||||
public sealed record DashboardChartsDto(
|
||||
// Sales
|
||||
IReadOnlyList<DailySalesPointDto> DailySales,
|
||||
IReadOnlyList<ChartPointDto> PaymentSplit,
|
||||
IReadOnlyList<ChartPointDto> TopItems,
|
||||
IReadOnlyList<ChartPointDto> SalesByCategory,
|
||||
IReadOnlyList<ChartPointDto> SalesByCustomer,
|
||||
IReadOnlyList<DocumentStatusDto> DocumentStatus,
|
||||
// Money
|
||||
IReadOnlyList<ChartPointDto> CashInHandByDay,
|
||||
IReadOnlyList<ChartPointDto> ReceivablesAgeing,
|
||||
IReadOnlyList<ChartPointDto> PayablesAgeing,
|
||||
IReadOnlyList<ChartPointDto> ExpenseBreakdown,
|
||||
// Purchasing
|
||||
IReadOnlyList<ChartPointDto> TopVendors,
|
||||
// Stock
|
||||
IReadOnlyList<ChartPairDto> StockInVsOut,
|
||||
IReadOnlyList<ChartPointDto> BatchesExpiring,
|
||||
// HR
|
||||
IReadOnlyList<ChartPointDto> AttendanceMix,
|
||||
IReadOnlyList<ChartPointDto> PayrollCostByMonth,
|
||||
IReadOnlyList<ChartPointDto> LeaveStatus,
|
||||
// Service and production
|
||||
IReadOnlyList<ChartPairDto> TicketsOverTime,
|
||||
IReadOnlyList<ChartPointDto> ProductionStatus,
|
||||
IReadOnlyList<ChartPointDto> WarrantyExpiryByMonth);
|
||||
|
||||
/// <summary>One labelled value: a bar, a donut slice or a point on a line.</summary>
|
||||
public sealed record ChartPointDto(string Label, decimal Value);
|
||||
|
||||
/// <summary>Two values under one label (stock in/out by warehouse, tickets opened/resolved by day).</summary>
|
||||
public sealed record ChartPairDto(string Label, decimal First, decimal Second);
|
||||
|
||||
/// <summary>One day of the Daily Sales, Gross Profit and Expenses line chart.</summary>
|
||||
public sealed record DailySalesPointDto(string Label, decimal Sales, decimal GrossProfit, decimal Expenses);
|
||||
|
||||
/// <summary>Draft / Posted / Cancelled counts for one kind of document.</summary>
|
||||
public sealed record DocumentStatusDto(string Doc, int Draft, int Posted, int Cancelled);
|
||||
@@ -10,6 +10,9 @@ namespace ERPCore.Dtos.Dashboard;
|
||||
/// Recent movements isn't here — it's just GET /stock/ledger with a small pageSize, no
|
||||
/// aggregation needed. A single computed-on-read object, not a stored entity — same as
|
||||
/// reorder alerts (docs/11 §5.7).
|
||||
/// Most counts are a snapshot of what is open right now. The few that describe activity
|
||||
/// (sales returns, bundle sales, bank deposits, employees present) are counted inside the
|
||||
/// optional From-To period the dashboard filter sends (default: today).
|
||||
/// </summary>
|
||||
public sealed record DashboardStatsDto(
|
||||
int LowStockAlerts,
|
||||
@@ -24,7 +27,37 @@ public sealed record DashboardStatsDto(
|
||||
int OpenRfqs,
|
||||
int PendingLeaveRequests,
|
||||
int OverdueSalesInvoices,
|
||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
|
||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales,
|
||||
// Stock & warehouse
|
||||
int BatchesExpiringSoon,
|
||||
int TransfersInTransit,
|
||||
int DraftStockAdjustments,
|
||||
int WastagePendingApproval,
|
||||
// Approvals
|
||||
int PendingApprovals,
|
||||
int PriceAdjustmentsPending,
|
||||
// Sales (SalesReturns and BundleSales are counted inside the From-To period)
|
||||
int DraftSalesInvoices,
|
||||
int DraftSalesSlips,
|
||||
int SalesReturns,
|
||||
int BundleSales,
|
||||
// Purchasing
|
||||
int PurchaseReturnsPending,
|
||||
int UnpaidPurchaseInvoices,
|
||||
// Money
|
||||
decimal CashInHand,
|
||||
decimal BankDeposits,
|
||||
// Service & warranty
|
||||
int OpenSupportTickets,
|
||||
int WarrantiesExpiringSoon,
|
||||
int WarrantyClaimsOpen,
|
||||
// Production
|
||||
int ProductionRunsInProgress,
|
||||
// HR
|
||||
int PayrollRunsPending,
|
||||
int AttendanceBatchesPending,
|
||||
int EmployeesPresent,
|
||||
int TotalActiveEmployees);
|
||||
|
||||
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
||||
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
||||
|
||||
@@ -103,7 +103,15 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 69, Code = "NAV:hrm.settings", SubNavItemId = 57 },
|
||||
new Permission { PermissionId = 70, Code = "NAV:settings.audit-logs", SubNavItemId = 58 },
|
||||
// SubNavItem 24 (Notification Config) never had a permission row, so it could not be granted in Settings → Roles.
|
||||
new Permission { PermissionId = 71, Code = "NAV:settings.notification-config", SubNavItemId = 24 }
|
||||
new Permission { PermissionId = 71, Code = "NAV:settings.notification-config", SubNavItemId = 24 },
|
||||
new Permission { PermissionId = 72, Code = "NAV:dashboard.stock", SubNavItemId = 59 },
|
||||
new Permission { PermissionId = 73, Code = "NAV:dashboard.procurement", SubNavItemId = 60 },
|
||||
new Permission { PermissionId = 74, Code = "NAV:dashboard.approvals", SubNavItemId = 61 },
|
||||
new Permission { PermissionId = 75, Code = "NAV:dashboard.sales", SubNavItemId = 62 },
|
||||
new Permission { PermissionId = 76, Code = "NAV:dashboard.accounts", SubNavItemId = 63 },
|
||||
new Permission { PermissionId = 77, Code = "NAV:dashboard.hrm", SubNavItemId = 64 },
|
||||
new Permission { PermissionId = 78, Code = "NAV:dashboard.production", SubNavItemId = 65 },
|
||||
new Permission { PermissionId = 79, Code = "NAV:dashboard.service", SubNavItemId = 66 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,17 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 56, NavItemId = 15, Code = "hrm.reports", Label = "Reports", Href = "/dashboard/hrm/reports", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 57, NavItemId = 15, Code = "hrm.settings", Label = "HR Settings", Href = "/dashboard/hrm/settings", SortOrder = 6 },
|
||||
// Who changed what (including salary changes) is its own Settings entry, not part of "Settings" at large.
|
||||
new SubNavItem { SubNavItemId = 58, NavItemId = 9, Code = "settings.audit-logs", Label = "Audit Log", Href = "/dashboard/settings/audit-logs", SortOrder = 5 }
|
||||
new SubNavItem { SubNavItemId = 58, NavItemId = 9, Code = "settings.audit-logs", Label = "Audit Log", Href = "/dashboard/settings/audit-logs", SortOrder = 5 },
|
||||
// Dashboard (NavItemId 1) children - one per area. Each switches that area's stat cards and graphs on the
|
||||
// dashboard (they are not pages, so Href is the dashboard itself).
|
||||
new SubNavItem { SubNavItemId = 59, NavItemId = 1, Code = "dashboard.stock", Label = "Stock", Href = "/dashboard", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 60, NavItemId = 1, Code = "dashboard.procurement", Label = "Procurement", Href = "/dashboard", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 61, NavItemId = 1, Code = "dashboard.approvals", Label = "Approvals", Href = "/dashboard", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 62, NavItemId = 1, Code = "dashboard.sales", Label = "Sales", Href = "/dashboard", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 63, NavItemId = 1, Code = "dashboard.accounts", Label = "Accounts", Href = "/dashboard", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 64, NavItemId = 1, Code = "dashboard.hrm", Label = "Human Resources", Href = "/dashboard", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 65, NavItemId = 1, Code = "dashboard.production", Label = "Production", Href = "/dashboard", SortOrder = 7 },
|
||||
new SubNavItem { SubNavItemId = 66, NavItemId = 1, Code = "dashboard.service", Label = "Service & Warranty", Href = "/dashboard", SortOrder = 8 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11502
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDashboardPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 59, "dashboard.stock", "/dashboard", null, "Stock", 1, 1 },
|
||||
{ 60, "dashboard.procurement", "/dashboard", null, "Procurement", 1, 2 },
|
||||
{ 61, "dashboard.approvals", "/dashboard", null, "Approvals", 1, 3 },
|
||||
{ 62, "dashboard.sales", "/dashboard", null, "Sales", 1, 4 },
|
||||
{ 63, "dashboard.accounts", "/dashboard", null, "Accounts", 1, 5 },
|
||||
{ 64, "dashboard.hrm", "/dashboard", null, "Human Resources", 1, 6 },
|
||||
{ 65, "dashboard.production", "/dashboard", null, "Production", 1, 7 },
|
||||
{ 66, "dashboard.service", "/dashboard", null, "Service & Warranty", 1, 8 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 72, "NAV:dashboard.stock", null, 59 },
|
||||
{ 73, "NAV:dashboard.procurement", null, 60 },
|
||||
{ 74, "NAV:dashboard.approvals", null, 61 },
|
||||
{ 75, "NAV:dashboard.sales", null, 62 },
|
||||
{ 76, "NAV:dashboard.accounts", null, 63 },
|
||||
{ 77, "NAV:dashboard.hrm", null, 64 },
|
||||
{ 78, "NAV:dashboard.production", null, 65 },
|
||||
{ 79, "NAV:dashboard.service", null, 66 }
|
||||
});
|
||||
|
||||
// Admin gets every new dashboard area. Any other role keeps what it could already see: it is granted
|
||||
// view on an area when it already holds a matching module permission (idempotent, like AddPoApprovalsNav).
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT r.`RoleId`, p.`PermissionId`, 31
|
||||
FROM `roles` r
|
||||
JOIN `permissions` p ON p.`PermissionId` BETWEEN 72 AND 79
|
||||
WHERE LOWER(r.`Code`) = 'admin';
|
||||
""");
|
||||
|
||||
// stock: view for any role that could already reach the matching module.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT DISTINCT rp.`RoleId`, 72, 1
|
||||
FROM `role_permissions` rp
|
||||
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
|
||||
WHERE src.`Code` = 'NAV:stock'
|
||||
OR src.`Code` LIKE 'NAV:stock.%';
|
||||
""");
|
||||
|
||||
// procurement: view for any role that could already reach the matching module.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT DISTINCT rp.`RoleId`, 73, 1
|
||||
FROM `role_permissions` rp
|
||||
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
|
||||
WHERE src.`Code` = 'NAV:procurement'
|
||||
OR src.`Code` LIKE 'NAV:procurement.%'
|
||||
OR src.`Code` = 'NAV:receiving';
|
||||
""");
|
||||
|
||||
// approvals: view for any role that could already reach the matching module.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT DISTINCT rp.`RoleId`, 74, 1
|
||||
FROM `role_permissions` rp
|
||||
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
|
||||
WHERE src.`Code` = 'NAV:settings.approvals'
|
||||
OR src.`Code` = 'NAV:procurement.po-approvals'
|
||||
OR src.`Code` = 'NAV:products.price-adjustments'
|
||||
OR src.`Code` = 'NAV:stock.wastage';
|
||||
""");
|
||||
|
||||
// sales: view for any role that could already reach the matching module.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT DISTINCT rp.`RoleId`, 75, 1
|
||||
FROM `role_permissions` rp
|
||||
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
|
||||
WHERE src.`Code` = 'NAV:sales'
|
||||
OR src.`Code` LIKE 'NAV:sales.%'
|
||||
OR src.`Code` = 'NAV:sales-returns';
|
||||
""");
|
||||
|
||||
// accounts: view for any role that could already reach the matching module.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT DISTINCT rp.`RoleId`, 76, 1
|
||||
FROM `role_permissions` rp
|
||||
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
|
||||
WHERE src.`Code` = 'NAV:accounts.bank-deposits'
|
||||
OR src.`Code` = 'NAV:accounts.expenses';
|
||||
""");
|
||||
|
||||
// hrm: view for any role that could already reach the matching module.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT DISTINCT rp.`RoleId`, 77, 1
|
||||
FROM `role_permissions` rp
|
||||
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
|
||||
WHERE src.`Code` = 'NAV:hrm'
|
||||
OR src.`Code` LIKE 'NAV:hrm.%';
|
||||
""");
|
||||
|
||||
// production: view for any role that could already reach the matching module.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT DISTINCT rp.`RoleId`, 78, 1
|
||||
FROM `role_permissions` rp
|
||||
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
|
||||
WHERE src.`Code` = 'NAV:production'
|
||||
OR src.`Code` LIKE 'NAV:production.%';
|
||||
""");
|
||||
|
||||
// service: view for any role that could already reach the matching module.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||
SELECT DISTINCT rp.`RoleId`, 79, 1
|
||||
FROM `role_permissions` rp
|
||||
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
|
||||
WHERE src.`Code` = 'NAV:help'
|
||||
OR src.`Code` = 'NAV:sales'
|
||||
OR src.`Code` LIKE 'NAV:sales.%';
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` BETWEEN 72 AND 79;");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 72);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 73);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 74);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 75);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 76);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 77);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 78);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 79);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 59);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 60);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 61);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 62);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 63);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 64);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 65);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 66);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4633,6 +4633,54 @@ namespace ERPCore.Migrations
|
||||
PermissionId = 71,
|
||||
Code = "NAV:settings.notification-config",
|
||||
SubNavItemId = 24
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 72,
|
||||
Code = "NAV:dashboard.stock",
|
||||
SubNavItemId = 59
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 73,
|
||||
Code = "NAV:dashboard.procurement",
|
||||
SubNavItemId = 60
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 74,
|
||||
Code = "NAV:dashboard.approvals",
|
||||
SubNavItemId = 61
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 75,
|
||||
Code = "NAV:dashboard.sales",
|
||||
SubNavItemId = 62
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 76,
|
||||
Code = "NAV:dashboard.accounts",
|
||||
SubNavItemId = 63
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 77,
|
||||
Code = "NAV:dashboard.hrm",
|
||||
SubNavItemId = 64
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 78,
|
||||
Code = "NAV:dashboard.production",
|
||||
SubNavItemId = 65
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 79,
|
||||
Code = "NAV:dashboard.service",
|
||||
SubNavItemId = 66
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8276,6 +8324,86 @@ namespace ERPCore.Migrations
|
||||
NavItemId = 9,
|
||||
SortOrder = 5,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 59,
|
||||
Code = "dashboard.stock",
|
||||
Href = "/dashboard",
|
||||
Label = "Stock",
|
||||
NavItemId = 1,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 60,
|
||||
Code = "dashboard.procurement",
|
||||
Href = "/dashboard",
|
||||
Label = "Procurement",
|
||||
NavItemId = 1,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 61,
|
||||
Code = "dashboard.approvals",
|
||||
Href = "/dashboard",
|
||||
Label = "Approvals",
|
||||
NavItemId = 1,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 62,
|
||||
Code = "dashboard.sales",
|
||||
Href = "/dashboard",
|
||||
Label = "Sales",
|
||||
NavItemId = 1,
|
||||
SortOrder = 4,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 63,
|
||||
Code = "dashboard.accounts",
|
||||
Href = "/dashboard",
|
||||
Label = "Accounts",
|
||||
NavItemId = 1,
|
||||
SortOrder = 5,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 64,
|
||||
Code = "dashboard.hrm",
|
||||
Href = "/dashboard",
|
||||
Label = "Human Resources",
|
||||
NavItemId = 1,
|
||||
SortOrder = 6,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 65,
|
||||
Code = "dashboard.production",
|
||||
Href = "/dashboard",
|
||||
Label = "Production",
|
||||
NavItemId = 1,
|
||||
SortOrder = 7,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 66,
|
||||
Code = "dashboard.service",
|
||||
Href = "/dashboard",
|
||||
Label = "Service & Warranty",
|
||||
NavItemId = 1,
|
||||
SortOrder = 8,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -255,6 +255,7 @@ builder.Services.AddScoped<IAuditService, AuditService>();
|
||||
|
||||
// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement)
|
||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
builder.Services.AddScoped<IDashboardChartsService, DashboardChartsService>();
|
||||
|
||||
// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents
|
||||
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard shows figures from many modules, but a role only holds some of them. Settings -> Roles has a
|
||||
/// "Dashboard" row with one switch per area (<c>dashboard.stock</c>, <c>dashboard.sales</c>, ...), seeded like every
|
||||
/// other nav permission. Each stat card and graph belongs to one area, and its figure is only computed when the
|
||||
/// caller's role holds that area - so a role never receives a number it was not given, and the queries for it are
|
||||
/// never run. Same rule as <see cref="NavAuthorizationHandler"/>: administrators pass everything, everyone else
|
||||
/// needs the role's permission.
|
||||
/// </summary>
|
||||
internal static class DashboardAccess
|
||||
{
|
||||
public static async Task<bool> CanAsync(this INavPermissionService nav, ICurrentUser user, string navCode, CancellationToken ct = default)
|
||||
=> user.IsAdmin || await nav.HasAccessAsync(user.RoleId, navCode, ct);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
using System.Globalization;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The datasets behind the dashboard graphs, pulled straight from each domain's repository like
|
||||
/// <see cref="DashboardService"/> - grouped and summed in SQL, only the small result comes back. Daily sales and
|
||||
/// cash reuse the day-end report and cash-in-hand services so the graphs agree with those screens.
|
||||
/// </summary>
|
||||
public sealed class DashboardChartsService : IDashboardChartsService
|
||||
{
|
||||
private readonly IRepository<SalesInvoice> _salesInvoices;
|
||||
private readonly IRepository<SalesInvoiceLine> _salesLines;
|
||||
private readonly IRepository<SalesSlip> _salesSlips;
|
||||
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
|
||||
private readonly IRepository<Expense> _expenses;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<AttendanceRecord> _attendance;
|
||||
private readonly IRepository<PayrollLine> _payrollLines;
|
||||
private readonly IRepository<LeaveRequest> _leaveRequests;
|
||||
private readonly IRepository<SupportTicket> _tickets;
|
||||
private readonly IRepository<ProductionRun> _productionRuns;
|
||||
private readonly IRepository<WarrantyRegistration> _warranties;
|
||||
private readonly IDayEndReportService _dayEnd;
|
||||
private readonly ICashInHandService _cash;
|
||||
private readonly BusinessDayClock _clock;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ICurrentUser _user;
|
||||
private readonly INavPermissionService _nav;
|
||||
|
||||
// Bars / slices shown before the rest are folded into "Other".
|
||||
private const int TopN = 5;
|
||||
private const int TopItemsN = 10;
|
||||
private const int PayrollMonths = 12;
|
||||
// The day-end and cash services cap a range at 92 days; daily graphs never need fewer than a week to read.
|
||||
private const int MaxDailyDays = 92;
|
||||
private const int MinDailyDays = 7;
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
|
||||
|
||||
public DashboardChartsService(
|
||||
IRepository<SalesInvoice> salesInvoices, IRepository<SalesInvoiceLine> salesLines, IRepository<SalesSlip> salesSlips,
|
||||
IRepository<PurchaseInvoice> purchaseInvoices, IRepository<Expense> expenses, IRepository<StockLedger> ledger,
|
||||
IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<Batch> batches,
|
||||
IRepository<AttendanceRecord> attendance, IRepository<PayrollLine> payrollLines, IRepository<LeaveRequest> leaveRequests,
|
||||
IRepository<SupportTicket> tickets, IRepository<ProductionRun> productionRuns, IRepository<WarrantyRegistration> warranties,
|
||||
IDayEndReportService dayEnd, ICashInHandService cash, BusinessDayClock clock, IMemoryCache cache, ICurrentUser user, INavPermissionService nav)
|
||||
{
|
||||
_salesInvoices = salesInvoices;
|
||||
_salesLines = salesLines;
|
||||
_salesSlips = salesSlips;
|
||||
_purchaseInvoices = purchaseInvoices;
|
||||
_expenses = expenses;
|
||||
_ledger = ledger;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
_batches = batches;
|
||||
_attendance = attendance;
|
||||
_payrollLines = payrollLines;
|
||||
_leaveRequests = leaveRequests;
|
||||
_tickets = tickets;
|
||||
_productionRuns = productionRuns;
|
||||
_warranties = warranties;
|
||||
_dayEnd = dayEnd;
|
||||
_cash = cash;
|
||||
_clock = clock;
|
||||
_cache = cache;
|
||||
_user = user;
|
||||
_nav = nav;
|
||||
}
|
||||
|
||||
public async Task<DashboardChartsDto> GetChartsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
||||
{
|
||||
// Same short per-user cache as the stat cards (see DashboardService): repeat loads and filter flips are instant.
|
||||
var key = $"dashboard-charts:{_user.UserId}:{branchId}:{from}:{to}";
|
||||
if (_cache.TryGetValue(key, out DashboardChartsDto? cached) && cached is not null) return cached;
|
||||
|
||||
var charts = await ComputeAsync(branchId, from, to, ct);
|
||||
_cache.Set(key, charts, CacheTtl);
|
||||
return charts;
|
||||
}
|
||||
|
||||
/// <summary>Whether the caller's role may reach the screen a graph belongs to; see <see cref="DashboardAccess"/>.</summary>
|
||||
private Task<bool> CanAsync(string navCode, CancellationToken ct) => _nav.CanAsync(_user, navCode, ct);
|
||||
|
||||
private async Task<DashboardChartsDto> ComputeAsync(int? branchId, DateOnly? from, DateOnly? to, CancellationToken ct)
|
||||
{
|
||||
// Each graph belongs to a dashboard area (Settings -> Roles -> Dashboard: stock, procurement, sales, accounts, hrm,
|
||||
// production, service). A role without the area's permission gets an empty set and the queries are not run
|
||||
// (administrators pass every check) - see DashboardAccess.
|
||||
var canDayEnd = await CanAsync("dashboard.sales", ct);
|
||||
var canSalesInvoices = await CanAsync("dashboard.sales", ct);
|
||||
var canService = await CanAsync("dashboard.service", ct);
|
||||
var canBankDeposits = await CanAsync("dashboard.accounts", ct);
|
||||
var canExpenses = await CanAsync("dashboard.accounts", ct);
|
||||
var canProcurement = await CanAsync("dashboard.procurement", ct);
|
||||
var canLedger = await CanAsync("dashboard.stock", ct);
|
||||
var canStock = await CanAsync("dashboard.stock", ct);
|
||||
var canAttendance = await CanAsync("dashboard.hrm", ct);
|
||||
var canPayroll = await CanAsync("dashboard.hrm", ct);
|
||||
var canLeave = await CanAsync("dashboard.hrm", ct);
|
||||
var canProduction = await CanAsync("dashboard.production", ct);
|
||||
|
||||
// Sequential awaits: these repositories share one scoped DbContext, which cannot run concurrent operations.
|
||||
// "Today" and day boundaries follow the business time zone, like the day-end report and cash in hand.
|
||||
var today = _clock.Today;
|
||||
var periodFrom = from ?? to ?? today;
|
||||
var periodTo = to ?? periodFrom;
|
||||
if (periodTo < periodFrom) (periodFrom, periodTo) = (periodTo, periodFrom);
|
||||
// Timestamps are compared against the business day's UTC bounds; plain document / attendance dates against
|
||||
// midnight-to-midnight.
|
||||
var fromAt = _clock.ToUtc(periodFrom);
|
||||
var toBefore = _clock.ToUtc(periodTo.AddDays(1));
|
||||
var fromDay = periodFrom.ToDateTime(TimeOnly.MinValue);
|
||||
var toDayEnd = periodTo.AddDays(1).ToDateTime(TimeOnly.MinValue);
|
||||
|
||||
// Day-by-day graphs: the period itself, widened to a readable week and capped at what the services allow.
|
||||
var dailyTo = periodTo;
|
||||
var dailyFrom = periodFrom;
|
||||
if (dailyTo.DayNumber - dailyFrom.DayNumber + 1 < MinDailyDays) dailyFrom = dailyTo.AddDays(-(MinDailyDays - 1));
|
||||
if (dailyTo.DayNumber - dailyFrom.DayNumber + 1 > MaxDailyDays) dailyFrom = dailyTo.AddDays(-(MaxDailyDays - 1));
|
||||
|
||||
// ---- Sales: daily figures and payment split come from the day-end report.
|
||||
var dailySales = new List<DailySalesPointDto>();
|
||||
var paymentSplit = new List<ChartPointDto>();
|
||||
if (canDayEnd)
|
||||
{
|
||||
var daily = await _dayEnd.GetDailyAsync(dailyFrom, dailyTo, branchId, ct);
|
||||
dailySales = daily.Days
|
||||
.Select(d => new DailySalesPointDto(DayLabel(d.Date), d.TotalSales, d.GrossProfit, d.ExpenseTotal))
|
||||
.ToList();
|
||||
paymentSplit = new List<ChartPointDto>
|
||||
{
|
||||
new("Cash", daily.Totals.PaidByCash),
|
||||
new("Card", daily.Totals.PaidByCard),
|
||||
new("Bank / cheque", daily.Totals.PaidByOther),
|
||||
};
|
||||
}
|
||||
|
||||
var topItems = new List<ChartPointDto>();
|
||||
IReadOnlyList<ChartPointDto> salesByCategory = new List<ChartPointDto>();
|
||||
var salesByCustomer = new List<ChartPointDto>();
|
||||
if (canSalesInvoices)
|
||||
{
|
||||
var invoices = _salesInvoices.Query().AsNoTracking()
|
||||
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= fromAt && i.InvoiceDate < toBefore);
|
||||
if (branchId is not null) invoices = invoices.Where(i => i.BranchId == branchId);
|
||||
|
||||
var lines = _salesLines.Query().AsNoTracking()
|
||||
.Where(l => l.SalesInvoice!.Status == SalesInvoiceStatus.Posted
|
||||
&& l.SalesInvoice.InvoiceDate >= fromAt && l.SalesInvoice.InvoiceDate < toBefore);
|
||||
if (branchId is not null) lines = lines.Where(l => l.SalesInvoice!.BranchId == branchId);
|
||||
|
||||
var topItemRows = await lines
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Total = g.Sum(x => x.LineTotal) })
|
||||
.OrderByDescending(x => x.Total)
|
||||
.Take(TopItemsN)
|
||||
.ToListAsync(ct);
|
||||
var itemIds = topItemRows.Select(r => r.ItemId).ToList();
|
||||
var itemNames = await _items.Query().AsNoTracking()
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.ToDictionaryAsync(i => i.ItemId, i => i.Name, ct);
|
||||
topItems = topItemRows.Select(r => new ChartPointDto(itemNames.GetValueOrDefault(r.ItemId, $"#{r.ItemId}"), r.Total)).ToList();
|
||||
|
||||
var categoryRows = await lines
|
||||
.GroupBy(l => l.Item!.Category!.Name)
|
||||
.Select(g => new { Name = g.Key, Total = g.Sum(x => x.LineTotal) })
|
||||
.OrderByDescending(x => x.Total)
|
||||
.ToListAsync(ct);
|
||||
salesByCategory = TopWithOther(categoryRows.Select(r => new ChartPointDto(r.Name, r.Total)), TopN);
|
||||
|
||||
var customerRows = await invoices
|
||||
.GroupBy(i => i.CustomerSnapshotName)
|
||||
.Select(g => new { Name = g.Key, Total = g.Sum(x => x.GrandTotal) })
|
||||
.OrderByDescending(x => x.Total)
|
||||
.Take(TopN)
|
||||
.ToListAsync(ct);
|
||||
salesByCustomer = customerRows.Select(r => new ChartPointDto(r.Name, r.Total)).ToList();
|
||||
}
|
||||
|
||||
var documentStatus = await GetDocumentStatusAsync(branchId, fromAt, toBefore, fromDay, toDayEnd, periodFrom, periodTo, ct);
|
||||
|
||||
// ---- Money
|
||||
var cashInHandByDay = canBankDeposits
|
||||
? (await _cash.GetRangeAsync(dailyFrom, dailyTo, branchId, ct)).Select(c => new ChartPointDto(DayLabel(c.Date), c.Closing)).ToList()
|
||||
: new List<ChartPointDto>();
|
||||
var receivablesAgeing = canSalesInvoices ? await GetReceivablesAgeingAsync(branchId, ct) : new List<ChartPointDto>();
|
||||
var payablesAgeing = canProcurement ? await GetPayablesAgeingAsync(branchId, ct) : new List<ChartPointDto>();
|
||||
|
||||
IReadOnlyList<ChartPointDto> expenseBreakdown = new List<ChartPointDto>();
|
||||
if (canExpenses)
|
||||
{
|
||||
var expenses = _expenses.Query().AsNoTracking()
|
||||
.Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= periodFrom && e.ExpenseDate <= periodTo);
|
||||
if (branchId is not null) expenses = expenses.Where(e => e.BranchId == branchId);
|
||||
var expenseRows = await expenses
|
||||
.GroupBy(e => e.Category!.Name)
|
||||
.Select(g => new { Name = g.Key, Total = g.Sum(x => x.Amount) })
|
||||
.OrderByDescending(x => x.Total)
|
||||
.ToListAsync(ct);
|
||||
expenseBreakdown = TopWithOther(expenseRows.Select(r => new ChartPointDto(r.Name, r.Total)), TopN);
|
||||
}
|
||||
|
||||
// ---- Purchasing
|
||||
var topVendors = new List<ChartPointDto>();
|
||||
if (canProcurement)
|
||||
{
|
||||
var purchases = _purchaseInvoices.Query().AsNoTracking()
|
||||
.Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.VendorInvoiceDate >= fromDay && i.VendorInvoiceDate < toDayEnd);
|
||||
if (branchId is not null) purchases = purchases.Where(i => i.BranchId == branchId);
|
||||
var vendorRows = await purchases
|
||||
.GroupBy(i => i.VendorSnapshotName)
|
||||
.Select(g => new { Name = g.Key, Total = g.Sum(x => x.GrandTotal) })
|
||||
.OrderByDescending(x => x.Total)
|
||||
.Take(TopN)
|
||||
.ToListAsync(ct);
|
||||
topVendors = vendorRows.Select(r => new ChartPointDto(r.Name, r.Total)).ToList();
|
||||
}
|
||||
|
||||
// ---- Stock
|
||||
var stockInVsOut = canLedger ? await GetStockInVsOutAsync(branchId, fromAt, toBefore, ct) : new List<ChartPairDto>();
|
||||
var batchesExpiring = canStock ? await GetBatchesExpiringAsync(today, ct) : new List<ChartPointDto>();
|
||||
|
||||
// ---- HR
|
||||
var attendanceMix = new List<ChartPointDto>();
|
||||
if (canAttendance)
|
||||
{
|
||||
var attendance = _attendance.Query().AsNoTracking()
|
||||
.Where(a => a.AttendanceDate >= fromDay && a.AttendanceDate < toDayEnd);
|
||||
if (branchId is not null) attendance = attendance.Where(a => a.Employee!.BranchId == branchId);
|
||||
var attendanceRows = await attendance
|
||||
.GroupBy(a => a.AttendanceStatus)
|
||||
.Select(g => new { Status = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
attendanceMix = attendanceRows.Select(r => new ChartPointDto(Spaced(r.Status.ToString()), r.Count)).ToList();
|
||||
}
|
||||
|
||||
var payrollCostByMonth = canPayroll ? await GetPayrollCostAsync(branchId, today, ct) : new List<ChartPointDto>();
|
||||
|
||||
var leaveStatus = new List<ChartPointDto>();
|
||||
if (canLeave)
|
||||
{
|
||||
var leaves = _leaveRequests.Query().AsNoTracking().Where(l => l.CreatedAt >= fromAt && l.CreatedAt < toBefore);
|
||||
var leaveRows = await leaves.GroupBy(l => l.Status).Select(g => new { Status = g.Key, Count = g.Count() }).ToListAsync(ct);
|
||||
leaveStatus = leaveRows.Select(r => new ChartPointDto(Spaced(r.Status.ToString()), r.Count)).ToList();
|
||||
}
|
||||
|
||||
// ---- Service and production
|
||||
var ticketsOverTime = canService ? await GetTicketsOverTimeAsync(dailyFrom, dailyTo, ct) : new List<ChartPairDto>();
|
||||
|
||||
var productionStatus = new List<ChartPointDto>();
|
||||
if (canProduction)
|
||||
{
|
||||
var runs = _productionRuns.Query().AsNoTracking().Where(r => r.CreatedAt >= fromAt && r.CreatedAt < toBefore);
|
||||
if (branchId is not null) runs = runs.Where(r => r.Warehouse!.BranchId == branchId);
|
||||
var runRows = await runs.GroupBy(r => r.Status).Select(g => new { Status = g.Key, Count = g.Count() }).ToListAsync(ct);
|
||||
productionStatus = runRows.Select(r => new ChartPointDto(Spaced(r.Status.ToString()), r.Count)).ToList();
|
||||
}
|
||||
|
||||
var warrantyExpiryByMonth = canService ? await GetWarrantyExpiryAsync(today, ct) : new List<ChartPointDto>();
|
||||
|
||||
return new DashboardChartsDto(
|
||||
dailySales, paymentSplit, topItems, salesByCategory, salesByCustomer, documentStatus,
|
||||
cashInHandByDay, receivablesAgeing, payablesAgeing, expenseBreakdown,
|
||||
topVendors,
|
||||
stockInVsOut, batchesExpiring,
|
||||
attendanceMix, payrollCostByMonth, leaveStatus,
|
||||
ticketsOverTime, productionStatus, warrantyExpiryByMonth);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
private static string DayLabel(DateOnly? d) => d is null ? "" : d.Value.ToString("d MMM", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>"InProgress" -> "In progress", "HalfDay" -> "Half day".</summary>
|
||||
private static string Spaced(string name)
|
||||
{
|
||||
var chars = new List<char>();
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (i > 0 && char.IsUpper(name[i])) chars.Add(' ');
|
||||
chars.Add(i > 0 && char.IsUpper(name[i]) ? char.ToLowerInvariant(name[i]) : name[i]);
|
||||
}
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>The biggest <paramref name="n"/> rows as they are, everything smaller folded into one "Other" slice.</summary>
|
||||
private static IReadOnlyList<ChartPointDto> TopWithOther(IEnumerable<ChartPointDto> sortedDescending, int n)
|
||||
{
|
||||
var all = sortedDescending.ToList();
|
||||
if (all.Count <= n) return all;
|
||||
var top = all.Take(n).ToList();
|
||||
top.Add(new ChartPointDto("Other", all.Skip(n).Sum(p => p.Value)));
|
||||
return top;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DocumentStatusDto>> GetDocumentStatusAsync(
|
||||
int? branchId, DateTime fromAt, DateTime toBefore, DateTime fromDay, DateTime toDayEnd, DateOnly periodFrom, DateOnly periodTo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// One row per kind of document, and only for the modules the caller may reach.
|
||||
var canSalesInvoices = await CanAsync("dashboard.sales", ct);
|
||||
var canProcurement = await CanAsync("dashboard.procurement", ct);
|
||||
var canExpenses = await CanAsync("dashboard.accounts", ct);
|
||||
|
||||
// Sales invoices and slips number their statuses from 1 (Draft=1, Posted=2, Cancelled=3); purchase invoices
|
||||
// and expenses from 0. Each is mapped through its own enum, never by position.
|
||||
int Count<T>(IEnumerable<(int Status, int N)> rows, T status) where T : struct, Enum
|
||||
=> rows.Where(r => r.Status == Convert.ToInt32(status)).Sum(r => r.N);
|
||||
|
||||
var result = new List<DocumentStatusDto>();
|
||||
|
||||
if (canSalesInvoices)
|
||||
{
|
||||
var invoices = _salesInvoices.Query().AsNoTracking().Where(i => i.InvoiceDate >= fromAt && i.InvoiceDate < toBefore);
|
||||
if (branchId is not null) invoices = invoices.Where(i => i.BranchId == branchId);
|
||||
var inv = (await invoices.GroupBy(i => i.Status).Select(g => new { Status = (int)g.Key, N = g.Count() }).ToListAsync(ct))
|
||||
.Select(r => (r.Status, r.N)).ToList();
|
||||
|
||||
var slips = _salesSlips.Query().AsNoTracking().Where(s => s.SlipDate >= fromAt && s.SlipDate < toBefore);
|
||||
if (branchId is not null) slips = slips.Where(s => s.Warehouse!.BranchId == branchId);
|
||||
var slp = (await slips.GroupBy(s => s.Status).Select(g => new { Status = (int)g.Key, N = g.Count() }).ToListAsync(ct))
|
||||
.Select(r => (r.Status, r.N)).ToList();
|
||||
|
||||
result.Add(new("Sales invoices", Count(inv, SalesInvoiceStatus.Draft), Count(inv, SalesInvoiceStatus.Posted), Count(inv, SalesInvoiceStatus.Cancelled)));
|
||||
result.Add(new("Sales slips", Count(slp, SalesSlipStatus.Draft), Count(slp, SalesSlipStatus.Posted), Count(slp, SalesSlipStatus.Cancelled)));
|
||||
}
|
||||
|
||||
if (canProcurement)
|
||||
{
|
||||
var purchases = _purchaseInvoices.Query().AsNoTracking().Where(i => i.VendorInvoiceDate >= fromDay && i.VendorInvoiceDate < toDayEnd);
|
||||
if (branchId is not null) purchases = purchases.Where(i => i.BranchId == branchId);
|
||||
var pur = (await purchases.GroupBy(i => i.Status).Select(g => new { Status = (int)g.Key, N = g.Count() }).ToListAsync(ct))
|
||||
.Select(r => (r.Status, r.N)).ToList();
|
||||
|
||||
result.Add(new("Purchase invoices", Count(pur, PurchaseInvoiceStatus.Draft), Count(pur, PurchaseInvoiceStatus.Posted), Count(pur, PurchaseInvoiceStatus.Cancelled)));
|
||||
}
|
||||
|
||||
if (canExpenses)
|
||||
{
|
||||
var expenses = _expenses.Query().AsNoTracking().Where(e => e.ExpenseDate >= periodFrom && e.ExpenseDate <= periodTo);
|
||||
if (branchId is not null) expenses = expenses.Where(e => e.BranchId == branchId);
|
||||
var exp = (await expenses.GroupBy(e => e.Status).Select(g => new { Status = (int)g.Key, N = g.Count() }).ToListAsync(ct))
|
||||
.Select(r => (r.Status, r.N)).ToList();
|
||||
|
||||
result.Add(new("Expenses", Count(exp, ExpenseStatus.Draft), Count(exp, ExpenseStatus.Posted), Count(exp, ExpenseStatus.Cancelled)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Unpaid Posted invoices bucketed by how old the invoice is. Neither invoice type carries a due date,
|
||||
/// so age runs from the invoice date (the same approach DashboardService uses for "overdue").</summary>
|
||||
private async Task<IReadOnlyList<ChartPointDto>> GetReceivablesAgeingAsync(int? branchId, CancellationToken ct)
|
||||
{
|
||||
var open = _salesInvoices.Query().AsNoTracking().Where(i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0);
|
||||
if (branchId is not null) open = open.Where(i => i.BranchId == branchId);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var d30 = now.AddDays(-30);
|
||||
var d60 = now.AddDays(-60);
|
||||
var d90 = now.AddDays(-90);
|
||||
return new List<ChartPointDto>
|
||||
{
|
||||
new("0-30 days", await open.Where(i => i.InvoiceDate >= d30).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
|
||||
new("31-60 days", await open.Where(i => i.InvoiceDate < d30 && i.InvoiceDate >= d60).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
|
||||
new("61-90 days", await open.Where(i => i.InvoiceDate < d60 && i.InvoiceDate >= d90).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
|
||||
new("90+ days", await open.Where(i => i.InvoiceDate < d90).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ChartPointDto>> GetPayablesAgeingAsync(int? branchId, CancellationToken ct)
|
||||
{
|
||||
var open = _purchaseInvoices.Query().AsNoTracking().Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.BalanceAmount > 0);
|
||||
if (branchId is not null) open = open.Where(i => i.BranchId == branchId);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var d30 = now.AddDays(-30);
|
||||
var d60 = now.AddDays(-60);
|
||||
var d90 = now.AddDays(-90);
|
||||
return new List<ChartPointDto>
|
||||
{
|
||||
new("0-30 days", await open.Where(i => i.VendorInvoiceDate >= d30).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
|
||||
new("31-60 days", await open.Where(i => i.VendorInvoiceDate < d30 && i.VendorInvoiceDate >= d60).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
|
||||
new("61-90 days", await open.Where(i => i.VendorInvoiceDate < d60 && i.VendorInvoiceDate >= d90).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
|
||||
new("90+ days", await open.Where(i => i.VendorInvoiceDate < d90).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ChartPairDto>> GetStockInVsOutAsync(int? branchId, DateTime fromAt, DateTime toBefore, CancellationToken ct)
|
||||
{
|
||||
var ledger = _ledger.Query().AsNoTracking().Where(l => l.CreatedAt >= fromAt && l.CreatedAt < toBefore);
|
||||
if (branchId is not null)
|
||||
{
|
||||
var branchWarehouses = _warehouses.Query().Where(w => w.BranchId == branchId).Select(w => w.WarehouseId);
|
||||
ledger = ledger.Where(l => branchWarehouses.Contains(l.WarehouseId));
|
||||
}
|
||||
|
||||
var rows = await ledger
|
||||
.GroupBy(l => new { l.WarehouseId, l.Direction })
|
||||
.Select(g => new { g.Key.WarehouseId, g.Key.Direction, Qty = g.Sum(x => x.QtyBase) })
|
||||
.ToListAsync(ct);
|
||||
var codes = await _warehouses.Query().AsNoTracking().ToDictionaryAsync(w => w.WarehouseId, w => w.Code, ct);
|
||||
|
||||
return rows
|
||||
.GroupBy(r => r.WarehouseId)
|
||||
.Select(g => new ChartPairDto(
|
||||
codes.GetValueOrDefault(g.Key, $"#{g.Key}"),
|
||||
g.Where(r => r.Direction == Direction.In).Sum(r => r.Qty),
|
||||
g.Where(r => r.Direction == Direction.Out).Sum(r => r.Qty)))
|
||||
.OrderByDescending(p => p.First + p.Second)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ChartPointDto>> GetBatchesExpiringAsync(DateOnly today, CancellationToken ct)
|
||||
{
|
||||
var batches = _batches.Query().AsNoTracking().Where(b => b.ExpiryDate != null && b.ExpiryDate >= today);
|
||||
var d30 = today.AddDays(30);
|
||||
var d60 = today.AddDays(60);
|
||||
var d90 = today.AddDays(90);
|
||||
return new List<ChartPointDto>
|
||||
{
|
||||
new("Next 30 days", await batches.CountAsync(b => b.ExpiryDate <= d30, ct)),
|
||||
new("31-60 days", await batches.CountAsync(b => b.ExpiryDate > d30 && b.ExpiryDate <= d60, ct)),
|
||||
new("61-90 days", await batches.CountAsync(b => b.ExpiryDate > d60 && b.ExpiryDate <= d90, ct)),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Net salary of Approved / Locked payroll runs for each of the last <see cref="PayrollMonths"/> months.</summary>
|
||||
private async Task<IReadOnlyList<ChartPointDto>> GetPayrollCostAsync(int? branchId, DateOnly today, CancellationToken ct)
|
||||
{
|
||||
var start = new DateOnly(today.Year, today.Month, 1).AddMonths(-(PayrollMonths - 1));
|
||||
var startKey = start.Year * 100 + start.Month;
|
||||
|
||||
var lines = _payrollLines.Query().AsNoTracking()
|
||||
.Where(l => (l.PayrollRun!.Status == PayrollRunStatus.Approved || l.PayrollRun.Status == PayrollRunStatus.Locked)
|
||||
&& l.PayrollRun.PeriodYear * 100 + l.PayrollRun.PeriodMonth >= startKey);
|
||||
if (branchId is not null) lines = lines.Where(l => l.PayrollRun!.BranchId == branchId);
|
||||
|
||||
var rows = await lines
|
||||
.GroupBy(l => new { l.PayrollRun!.PeriodYear, l.PayrollRun.PeriodMonth })
|
||||
.Select(g => new { g.Key.PeriodYear, g.Key.PeriodMonth, Total = g.Sum(x => x.NetSalary) })
|
||||
.ToListAsync(ct);
|
||||
var byMonth = rows.ToDictionary(r => (r.PeriodYear, r.PeriodMonth), r => r.Total);
|
||||
|
||||
return Enumerable.Range(0, PayrollMonths)
|
||||
.Select(i => start.AddMonths(i))
|
||||
.Select(m => new ChartPointDto(m.ToString("MMM", CultureInfo.InvariantCulture), byMonth.GetValueOrDefault((m.Year, m.Month))))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Tickets opened and tickets resolved on each day of the window. A ticket carries no resolved date, so
|
||||
/// the time it was last updated while Resolved stands in for it.</summary>
|
||||
private async Task<IReadOnlyList<ChartPairDto>> GetTicketsOverTimeAsync(DateOnly from, DateOnly to, CancellationToken ct)
|
||||
{
|
||||
var fromAt = _clock.ToUtc(from);
|
||||
var toBefore = _clock.ToUtc(to.AddDays(1));
|
||||
|
||||
var opened = await _tickets.Query().AsNoTracking()
|
||||
.Where(t => t.CreatedAt >= fromAt && t.CreatedAt < toBefore)
|
||||
.Select(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
var resolved = await _tickets.Query().AsNoTracking()
|
||||
.Where(t => t.Status == TicketStatus.Resolved && t.UpdatedAt != null && t.UpdatedAt >= fromAt && t.UpdatedAt < toBefore)
|
||||
.Select(t => t.UpdatedAt!.Value)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var openedByDay = opened.GroupBy(d => _clock.LocalDay(d)).ToDictionary(g => g.Key, g => g.Count());
|
||||
var resolvedByDay = resolved.GroupBy(d => _clock.LocalDay(d)).ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return Enumerable.Range(0, to.DayNumber - from.DayNumber + 1)
|
||||
.Select(i => from.AddDays(i))
|
||||
.Select(d => new ChartPairDto(DayLabel(d), openedByDay.GetValueOrDefault(d), resolvedByDay.GetValueOrDefault(d)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Active warranty registrations by the month they end, for the next six months.</summary>
|
||||
private async Task<IReadOnlyList<ChartPointDto>> GetWarrantyExpiryAsync(DateOnly today, CancellationToken ct)
|
||||
{
|
||||
var start = new DateOnly(today.Year, today.Month, 1);
|
||||
var end = start.AddMonths(6);
|
||||
var ends = await _warranties.Query().AsNoTracking()
|
||||
.Where(w => w.Status == WarrantyStatus.Active && w.EndDate >= start && w.EndDate < end)
|
||||
.Select(w => w.EndDate)
|
||||
.ToListAsync(ct);
|
||||
var byMonth = ends.GroupBy(d => (d.Year, d.Month)).ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return Enumerable.Range(0, 6)
|
||||
.Select(i => start.AddMonths(i))
|
||||
.Select(m => new ChartPointDto(m.ToString("MMM", CultureInfo.InvariantCulture), byMonth.GetValueOrDefault((m.Year, m.Month))))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Infra.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -27,7 +30,32 @@ public sealed class DashboardService : IDashboardService
|
||||
private readonly IRepository<LeaveRequest> _leaveRequests;
|
||||
private readonly IRepository<SalesInvoice> _salesInvoices;
|
||||
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
|
||||
private readonly IReorderService _reorder;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ICurrentUser _user;
|
||||
private readonly INavPermissionService _nav;
|
||||
private readonly ICashInHandService _cash;
|
||||
private readonly BusinessDayClock _clock;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<StockTransfer> _transfers;
|
||||
private readonly IRepository<StockAdjustment> _adjustments;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IRepository<PriceAdjustment> _priceAdjustments;
|
||||
private readonly IRepository<SalesSlip> _salesSlips;
|
||||
private readonly IRepository<SalesReturn> _salesReturns;
|
||||
private readonly IRepository<BundleSale> _bundleSales;
|
||||
private readonly IRepository<PurchaseReturn> _purchaseReturns;
|
||||
private readonly IRepository<BankDeposit> _bankDeposits;
|
||||
private readonly IRepository<SupportTicket> _tickets;
|
||||
private readonly IRepository<WarrantyRegistration> _warranties;
|
||||
private readonly IRepository<ProductionRun> _productionRuns;
|
||||
private readonly IRepository<PayrollRun> _payrollRuns;
|
||||
private readonly IRepository<AttendanceUploadBatch> _attendanceBatches;
|
||||
private readonly IRepository<AttendanceRecord> _attendance;
|
||||
private readonly IRepository<Employee> _employees;
|
||||
|
||||
// "Soon" for batch expiry and warranty end dates.
|
||||
private const int ExpiringSoonDays = 30;
|
||||
|
||||
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
|
||||
// has none), so "overdue" is approximated as a Posted invoice with an outstanding
|
||||
@@ -41,7 +69,15 @@ public sealed class DashboardService : IDashboardService
|
||||
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
|
||||
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
|
||||
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
|
||||
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
|
||||
IRepository<PurchaseInvoice> purchaseInvoices, IServiceScopeFactory scopes, IMemoryCache cache, ICurrentUser user, INavPermissionService nav,
|
||||
IRepository<Batch> batches, IRepository<StockTransfer> transfers, IRepository<StockAdjustment> adjustments,
|
||||
IRepository<ReasonCode> reasonCodes, IRepository<PriceAdjustment> priceAdjustments,
|
||||
IRepository<SalesSlip> salesSlips, IRepository<SalesReturn> salesReturns, IRepository<BundleSale> bundleSales,
|
||||
IRepository<PurchaseReturn> purchaseReturns, IRepository<BankDeposit> bankDeposits,
|
||||
IRepository<SupportTicket> tickets, IRepository<WarrantyRegistration> warranties,
|
||||
IRepository<ProductionRun> productionRuns, IRepository<PayrollRun> payrollRuns,
|
||||
IRepository<AttendanceUploadBatch> attendanceBatches, IRepository<AttendanceRecord> attendance,
|
||||
IRepository<Employee> employees, ICashInHandService cash, BusinessDayClock clock)
|
||||
{
|
||||
_layers = layers;
|
||||
_grns = grns;
|
||||
@@ -52,68 +88,294 @@ public sealed class DashboardService : IDashboardService
|
||||
_leaveRequests = leaveRequests;
|
||||
_salesInvoices = salesInvoices;
|
||||
_purchaseInvoices = purchaseInvoices;
|
||||
_reorder = reorder;
|
||||
_scopes = scopes;
|
||||
_cache = cache;
|
||||
_user = user;
|
||||
_nav = nav;
|
||||
_cash = cash;
|
||||
_clock = clock;
|
||||
_batches = batches;
|
||||
_transfers = transfers;
|
||||
_adjustments = adjustments;
|
||||
_reasonCodes = reasonCodes;
|
||||
_priceAdjustments = priceAdjustments;
|
||||
_salesSlips = salesSlips;
|
||||
_salesReturns = salesReturns;
|
||||
_bundleSales = bundleSales;
|
||||
_purchaseReturns = purchaseReturns;
|
||||
_bankDeposits = bankDeposits;
|
||||
_tickets = tickets;
|
||||
_warranties = warranties;
|
||||
_productionRuns = productionRuns;
|
||||
_payrollRuns = payrollRuns;
|
||||
_attendanceBatches = attendanceBatches;
|
||||
_attendance = attendance;
|
||||
_employees = employees;
|
||||
}
|
||||
|
||||
public async Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default)
|
||||
// The dashboard is opened often and every card needs a query, so a short cache makes repeat loads and
|
||||
// filter flips instant. Keyed by user as well as the requested branch / period: services narrow the branch
|
||||
// per caller, so two users must never share an entry. 30 seconds keeps the numbers effectively live.
|
||||
private static readonly TimeSpan StatsCacheTtl = TimeSpan.FromSeconds(30);
|
||||
|
||||
public async Task<DashboardStatsDto> GetStatsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
||||
{
|
||||
var key = $"dashboard-stats:{_user.UserId}:{branchId}:{from}:{to}";
|
||||
if (_cache.TryGetValue(key, out DashboardStatsDto? cached) && cached is not null) return cached;
|
||||
|
||||
var stats = await ComputeStatsAsync(branchId, from, to, ct);
|
||||
_cache.Set(key, stats, StatsCacheTtl);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorder alerts are the slowest single piece (a live availability lookup per item), and they use services
|
||||
/// that share nothing with the counts below. They run in their own scope - their own DbContext - so they
|
||||
/// overlap with the sequential counts instead of adding to them.
|
||||
/// </summary>
|
||||
/// <summary>Whether the caller's role may reach the screen a card links to; see <see cref="DashboardAccess"/>.</summary>
|
||||
private Task<bool> CanAsync(string navCode, CancellationToken ct) => _nav.CanAsync(_user, navCode, ct);
|
||||
|
||||
private async Task<int> CountLowStockAlertsAsync(int? branchId, CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopes.CreateScope();
|
||||
var reorder = scope.ServiceProvider.GetRequiredService<IReorderService>();
|
||||
// PageSize:1 is enough - GetAlertsAsync computes the full alert count before paging.
|
||||
return (await reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct)).Pagination.TotalItems;
|
||||
}
|
||||
|
||||
private async Task<DashboardStatsDto> ComputeStatsAsync(int? branchId, DateOnly? from, DateOnly? to, CancellationToken ct)
|
||||
{
|
||||
// Each figure belongs to a dashboard area (Settings -> Roles -> Dashboard: stock, procurement, approvals, sales,
|
||||
// accounts, hrm, production, service). A role without the area's permission gets 0 and the query is not run
|
||||
// (administrators pass every check) - see DashboardAccess.
|
||||
var canReorder = await CanAsync("dashboard.stock", ct);
|
||||
var canEnquiry = await CanAsync("dashboard.stock", ct);
|
||||
var canValuation = await CanAsync("dashboard.stock", ct);
|
||||
var canCounts = await CanAsync("dashboard.stock", ct);
|
||||
var canStock = await CanAsync("dashboard.stock", ct);
|
||||
var canTransfers = await CanAsync("dashboard.stock", ct);
|
||||
var canAdjustments = await CanAsync("dashboard.stock", ct);
|
||||
var canWastage = await CanAsync("dashboard.approvals", ct);
|
||||
var canReceiving = await CanAsync("dashboard.procurement", ct);
|
||||
var canProcurement = await CanAsync("dashboard.procurement", ct);
|
||||
var canPoApprovals = await CanAsync("dashboard.approvals", ct);
|
||||
var canPurchaseReturns = await CanAsync("dashboard.procurement", ct);
|
||||
var canSalesInvoices = await CanAsync("dashboard.sales", ct);
|
||||
var canSales = await CanAsync("dashboard.sales", ct);
|
||||
var canSalesReturns = await CanAsync("dashboard.sales", ct);
|
||||
var canApprovals = await CanAsync("dashboard.approvals", ct);
|
||||
var canPriceAdjustments = await CanAsync("dashboard.approvals", ct);
|
||||
var canBankDeposits = await CanAsync("dashboard.accounts", ct);
|
||||
var canProduction = await CanAsync("dashboard.production", ct);
|
||||
var canLeave = await CanAsync("dashboard.hrm", ct);
|
||||
var canPayroll = await CanAsync("dashboard.hrm", ct);
|
||||
var canAttendance = await CanAsync("dashboard.hrm", ct);
|
||||
var canEmployees = await CanAsync("dashboard.hrm", ct);
|
||||
var canService = await CanAsync("dashboard.service", ct);
|
||||
|
||||
var lowStockTask = canReorder ? CountLowStockAlertsAsync(branchId, ct) : Task.FromResult(0);
|
||||
|
||||
// The period only drives the activity counts; everything else is a snapshot of what is open now.
|
||||
// "Today" and day boundaries follow the business time zone, like the day-end report and cash in hand, so a
|
||||
// sale rung up just after local midnight lands on the same day everywhere.
|
||||
var today = _clock.Today;
|
||||
var periodFrom = from ?? to ?? today;
|
||||
var periodTo = to ?? periodFrom;
|
||||
if (periodTo < periodFrom) (periodFrom, periodTo) = (periodTo, periodFrom);
|
||||
// Timestamps (created / posted times) are compared against the business day's UTC bounds; plain dates
|
||||
// (attendance days) against midnight-to-midnight.
|
||||
var fromAt = _clock.ToUtc(periodFrom);
|
||||
var toBefore = _clock.ToUtc(periodTo.AddDays(1));
|
||||
var fromDay = periodFrom.ToDateTime(TimeOnly.MinValue);
|
||||
var toDayEnd = periodTo.AddDays(1).ToDateTime(TimeOnly.MinValue);
|
||||
|
||||
// Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
|
||||
// which cannot run concurrent operations.
|
||||
var layers = _layers.Query().AsNoTracking();
|
||||
if (branchId is not null) layers = layers.Where(l => l.Warehouse!.BranchId == branchId);
|
||||
|
||||
var onHandTotal = await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
var onHandWarehouses = await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
|
||||
var stockValuationTotal = await layers.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
|
||||
var onHandTotal = canEnquiry ? await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m : 0m;
|
||||
var onHandWarehouses = canEnquiry ? await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct) : 0;
|
||||
var stockValuationTotal = canValuation ? await layers.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m : 0m;
|
||||
// EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
|
||||
// Select — project to an anonymous type first, then materialize into the record.
|
||||
var stockValuationByWarehouseRaw = await layers
|
||||
.GroupBy(l => l.WarehouseId)
|
||||
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.OrderByDescending(w => w.Total)
|
||||
.ToListAsync(ct);
|
||||
var stockValuationByWarehouse = stockValuationByWarehouseRaw
|
||||
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
|
||||
.ToList();
|
||||
var stockValuationByWarehouse = new List<WarehouseValuationDto>();
|
||||
if (canValuation)
|
||||
{
|
||||
var stockValuationByWarehouseRaw = await layers
|
||||
.GroupBy(l => l.WarehouseId)
|
||||
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.OrderByDescending(w => w.Total)
|
||||
.ToListAsync(ct);
|
||||
stockValuationByWarehouse = stockValuationByWarehouseRaw
|
||||
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var grns = _grns.Query().AsNoTracking();
|
||||
if (branchId is not null) grns = grns.Where(g => g.BranchId == branchId);
|
||||
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct);
|
||||
var pendingGrns = canReceiving ? await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct) : 0;
|
||||
|
||||
var counts = _counts.Query().AsNoTracking();
|
||||
if (branchId is not null) counts = counts.Where(c => c.BranchId == branchId);
|
||||
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
|
||||
var pendingCounts = canCounts ? await counts.CountAsync(c => c.Status == CountStatus.Counted, ct) : 0;
|
||||
|
||||
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
|
||||
// (docs/BRANCH_ISOLATION_PLAN.md classifies them as needing a new BranchId column,
|
||||
// not yet added) — these three stay global regardless of the selected branch.
|
||||
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
|
||||
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
|
||||
var openRequisitions = await _requisitions.Query().AsNoTracking()
|
||||
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
|
||||
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
|
||||
var pendingApprovalPOs = canPoApprovals
|
||||
? await _pos.Query().AsNoTracking().CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct)
|
||||
: 0;
|
||||
var openRequisitions = canProcurement
|
||||
? await _requisitions.Query().AsNoTracking().CountAsync(r => r.Status == RequisitionStatus.Submitted, ct)
|
||||
: 0;
|
||||
var openRfqs = canProcurement ? await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct) : 0;
|
||||
|
||||
// LeaveRequest carries no BranchId (like PO/Requisition/RFQ) — global regardless
|
||||
// of the selected branch.
|
||||
var pendingLeaveRequests = await _leaveRequests.Query().AsNoTracking()
|
||||
.CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct);
|
||||
var pendingLeaveRequests = canLeave
|
||||
? await _leaveRequests.Query().AsNoTracking().CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct)
|
||||
: 0;
|
||||
|
||||
var overdueSince = DateTime.UtcNow.AddDays(-OverdueAgeDays);
|
||||
var salesInvoices = _salesInvoices.Query().AsNoTracking();
|
||||
if (branchId is not null) salesInvoices = salesInvoices.Where(i => i.BranchId == branchId);
|
||||
var overdueSalesInvoices = await salesInvoices.CountAsync(
|
||||
i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0 && i.InvoiceDate < overdueSince, ct);
|
||||
var overdueSalesInvoices = canSalesInvoices
|
||||
? await salesInvoices.CountAsync(i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0 && i.InvoiceDate < overdueSince, ct)
|
||||
: 0;
|
||||
|
||||
// PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging.
|
||||
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
|
||||
.Pagination.TotalItems;
|
||||
var lowStockAlerts = await lowStockTask;
|
||||
|
||||
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
|
||||
// The purchases-vs-sales chart puts both modules side by side, so it needs both.
|
||||
var monthlyPurchasesVsSales = canSalesInvoices && canProcurement
|
||||
? await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct)
|
||||
: new List<MonthlyPurchasesVsSalesDto>();
|
||||
|
||||
// ---- Stock & warehouse
|
||||
var soon = today.AddDays(ExpiringSoonDays);
|
||||
var batchesExpiringSoon = canStock
|
||||
? await _batches.Query().AsNoTracking().CountAsync(b => b.ExpiryDate != null && b.ExpiryDate >= today && b.ExpiryDate <= soon, ct)
|
||||
: 0;
|
||||
|
||||
var transfers = _transfers.Query().AsNoTracking();
|
||||
if (branchId is not null) transfers = transfers.Where(t => t.BranchId == branchId);
|
||||
var transfersInTransit = canTransfers ? await transfers.CountAsync(t => t.Status == TransferStatus.InTransit, ct) : 0;
|
||||
|
||||
var adjustments = _adjustments.Query().AsNoTracking();
|
||||
if (branchId is not null) adjustments = adjustments.Where(a => a.BranchId == branchId);
|
||||
var draftStockAdjustments = canAdjustments ? await adjustments.CountAsync(a => a.Status == AdjustmentStatus.Draft, ct) : 0;
|
||||
var awaitingApproval = adjustments.Where(a =>
|
||||
a.Status == AdjustmentStatus.PendingApproval || a.Status == AdjustmentStatus.PendingSecondApproval);
|
||||
var stockAdjustmentsAwaiting = canApprovals ? await awaitingApproval.CountAsync(ct) : 0;
|
||||
// Wastage = an adjustment with a damage / theft / expiry reason code (same rule AdjustmentService uses).
|
||||
var wastageCodes = WastageReasonCodes.Codes.Select(c => c.ToUpper()).ToList();
|
||||
var wastageReasonIds = _reasonCodes.Query().Where(r => wastageCodes.Contains(r.Code.ToUpper())).Select(r => r.ReasonCodeId);
|
||||
var wastagePendingApproval = canWastage ? await awaitingApproval.CountAsync(a => wastageReasonIds.Contains(a.ReasonCodeId), ct) : 0;
|
||||
|
||||
// ---- Approvals: every document waiting on an approver (stock adjustments + price adjustments).
|
||||
// Purchase orders have their own card, so they are not counted here. The price-adjustment count feeds both the
|
||||
// approval queue card and its own card, so it is read when either is allowed and handed out per card below.
|
||||
var priceAdjustmentsRaw = canApprovals || canPriceAdjustments
|
||||
? await _priceAdjustments.Query().AsNoTracking().CountAsync(
|
||||
p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval, ct)
|
||||
: 0;
|
||||
var priceAdjustmentsPending = canPriceAdjustments ? priceAdjustmentsRaw : 0;
|
||||
var pendingApprovals = canApprovals ? stockAdjustmentsAwaiting + priceAdjustmentsRaw : 0;
|
||||
|
||||
// ---- Sales
|
||||
var draftSalesInvoices = canSalesInvoices ? await salesInvoices.CountAsync(i => i.Status == SalesInvoiceStatus.Draft, ct) : 0;
|
||||
|
||||
// Slips, returns and bundle sales carry a warehouse rather than a branch, so the branch comes via the warehouse.
|
||||
var slips = _salesSlips.Query().AsNoTracking();
|
||||
if (branchId is not null) slips = slips.Where(x => x.Warehouse!.BranchId == branchId);
|
||||
var draftSalesSlips = canSalesInvoices ? await slips.CountAsync(x => x.Status == SalesSlipStatus.Draft, ct) : 0;
|
||||
|
||||
var salesReturns = _salesReturns.Query().AsNoTracking();
|
||||
if (branchId is not null) salesReturns = salesReturns.Where(x => x.Warehouse!.BranchId == branchId);
|
||||
var salesReturnsInPeriod = canSalesReturns
|
||||
? await salesReturns.CountAsync(x => x.Status == ReturnStatus.Posted && x.CreatedAt >= fromAt && x.CreatedAt < toBefore, ct)
|
||||
: 0;
|
||||
|
||||
var bundleSales = _bundleSales.Query().AsNoTracking();
|
||||
if (branchId is not null) bundleSales = bundleSales.Where(x => x.Warehouse!.BranchId == branchId);
|
||||
var bundleSalesInPeriod = canSales
|
||||
? await bundleSales.CountAsync(x => x.Status == BundleSaleStatus.Posted && x.BundleDate >= fromAt && x.BundleDate < toBefore, ct)
|
||||
: 0;
|
||||
|
||||
// ---- Purchasing
|
||||
var purchaseReturns = _purchaseReturns.Query().AsNoTracking();
|
||||
if (branchId is not null) purchaseReturns = purchaseReturns.Where(x => x.Warehouse!.BranchId == branchId);
|
||||
var purchaseReturnsPending = canPurchaseReturns ? await purchaseReturns.CountAsync(x => x.Status == ReturnStatus.Draft, ct) : 0;
|
||||
|
||||
var unpaidPurchases = _purchaseInvoices.Query().AsNoTracking();
|
||||
if (branchId is not null) unpaidPurchases = unpaidPurchases.Where(i => i.BranchId == branchId);
|
||||
var unpaidPurchaseInvoices = canProcurement
|
||||
? await unpaidPurchases.CountAsync(i => i.Status == PurchaseInvoiceStatus.Posted && i.BalanceAmount > 0, ct)
|
||||
: 0;
|
||||
|
||||
// ---- Money: cash still in hand as of the end of the period (the same closing figure the day-end report shows),
|
||||
// and the value of bank deposits made inside the period.
|
||||
var cashInHand = canBankDeposits ? (await _cash.GetPositionAsync(periodTo, branchId, ct)).Position.Closing : 0m;
|
||||
var deposits = _bankDeposits.Query().AsNoTracking();
|
||||
if (branchId is not null) deposits = deposits.Where(d => d.BranchId == branchId);
|
||||
var bankDeposits = canBankDeposits
|
||||
? await deposits
|
||||
.Where(d => d.Status == BankDepositStatus.Posted && d.DepositDate >= periodFrom && d.DepositDate <= periodTo)
|
||||
.SumAsync(d => (decimal?)d.Amount, ct) ?? 0m
|
||||
: 0m;
|
||||
|
||||
// ---- Service & warranty (tickets and warranties have no branch)
|
||||
var openSupportTickets = canService
|
||||
? await _tickets.Query().AsNoTracking().CountAsync(t => t.Status == TicketStatus.Open || t.Status == TicketStatus.InProgress, ct)
|
||||
: 0;
|
||||
var warrantiesExpiringSoon = canService
|
||||
? await _warranties.Query().AsNoTracking().CountAsync(w => w.Status == WarrantyStatus.Active && w.EndDate >= today && w.EndDate <= soon, ct)
|
||||
: 0;
|
||||
var warrantyClaimsOpen = canService
|
||||
? await _warranties.Query().AsNoTracking().CountAsync(w => w.Status == WarrantyStatus.Claimed, ct)
|
||||
: 0;
|
||||
|
||||
// ---- Production
|
||||
var productionRuns = _productionRuns.Query().AsNoTracking();
|
||||
if (branchId is not null) productionRuns = productionRuns.Where(r => r.Warehouse!.BranchId == branchId);
|
||||
var productionRunsInProgress = canProduction ? await productionRuns.CountAsync(r => r.Status == ProductionRunStatus.InProgress, ct) : 0;
|
||||
|
||||
// ---- HR
|
||||
var payrollRuns = _payrollRuns.Query().AsNoTracking();
|
||||
if (branchId is not null) payrollRuns = payrollRuns.Where(r => r.BranchId == branchId);
|
||||
var payrollRunsPending = canPayroll ? await payrollRuns.CountAsync(r => r.Status == PayrollRunStatus.Draft, ct) : 0;
|
||||
|
||||
// Attendance upload batches carry no branch - global.
|
||||
var attendanceBatchesPending = canAttendance
|
||||
? await _attendanceBatches.Query().AsNoTracking().CountAsync(
|
||||
b => b.Status == AttendanceBatchStatus.Draft || b.Status == AttendanceBatchStatus.Validated, ct)
|
||||
: 0;
|
||||
|
||||
var attendance = _attendance.Query().AsNoTracking();
|
||||
if (branchId is not null) attendance = attendance.Where(a => a.Employee!.BranchId == branchId);
|
||||
var employeesPresent = canAttendance
|
||||
? await attendance
|
||||
.Where(a => a.AttendanceStatus == AttendanceStatus.Present && a.AttendanceDate >= fromDay && a.AttendanceDate < toDayEnd)
|
||||
.Select(a => a.EmployeeId).Distinct().CountAsync(ct)
|
||||
: 0;
|
||||
|
||||
var employees = _employees.Query().AsNoTracking();
|
||||
if (branchId is not null) employees = employees.Where(e => e.BranchId == branchId);
|
||||
var totalActiveEmployees = canEmployees ? await employees.CountAsync(e => e.Status == EmployeeStatus.Active, ct) : 0;
|
||||
|
||||
return new DashboardStatsDto(
|
||||
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
|
||||
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales);
|
||||
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales,
|
||||
batchesExpiringSoon, transfersInTransit, draftStockAdjustments, wastagePendingApproval,
|
||||
pendingApprovals, priceAdjustmentsPending,
|
||||
draftSalesInvoices, draftSalesSlips, salesReturnsInPeriod, bundleSalesInPeriod,
|
||||
purchaseReturnsPending, unpaidPurchaseInvoices,
|
||||
cashInHand, bankDeposits,
|
||||
openSupportTickets, warrantiesExpiringSoon, warrantyClaimsOpen,
|
||||
productionRunsInProgress,
|
||||
payrollRunsPending, attendanceBatchesPending, employeesPresent, totalActiveEmployees);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Datasets for the dashboard graphs.</summary>
|
||||
public interface IDashboardChartsService
|
||||
{
|
||||
/// <param name="from">Start of the period for the time-based graphs (default: today).</param>
|
||||
/// <param name="to">End of the period, inclusive (default: <paramref name="from"/>, else today).</param>
|
||||
Task<DashboardChartsDto> GetChartsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -5,5 +5,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default);
|
||||
/// <param name="from">Start of the period for the activity counts (default: today).</param>
|
||||
/// <param name="to">End of the period, inclusive (default: <paramref name="from"/>, else today).</param>
|
||||
Task<DashboardStatsDto> GetStatsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user