Compare commits
1 Commits
Dev
..
production
| Author | SHA1 | Date | |
|---|---|---|---|
| f053dbdd9d |
@@ -16,7 +16,9 @@ namespace ERPCore.Tests;
|
||||
public class CustomerFieldSwitchTests
|
||||
{
|
||||
private static CustomerService Svc(ErpDbContext db) =>
|
||||
new(new CustomerRepository(db), new FakeBranchConfig(), new PassThroughUnitOfWork(db));
|
||||
new(new Repository<Customer>(db), new Repository<Warehouse>(db), new FakeBranchConfig(),
|
||||
new Repository<SalesConfig>(db), new Repository<SalesInvoice>(db), new Repository<SalesInvoicePayment>(db),
|
||||
new PassThroughUnitOfWork(db));
|
||||
|
||||
private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak)
|
||||
{
|
||||
|
||||
@@ -237,8 +237,13 @@ public class DayEndReportServiceTests
|
||||
.Build();
|
||||
var clock = new BusinessDayClock(config);
|
||||
var branch = new CurrentBranchContext(new TestCurrentUser());
|
||||
var cash = new CashInHandService(new CashInHandRepository(db), branch, clock, config);
|
||||
return new DayEndReportService(new DayEndReportRepository(db), cash, branch, clock);
|
||||
var cash = new CashInHandService(
|
||||
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
|
||||
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db), branch, clock, config);
|
||||
return new DayEndReportService(
|
||||
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoice>(db),
|
||||
new Repository<SalesInvoicePayment>(db), new Repository<StockLedger>(db),
|
||||
new Repository<Expense>(db), new Repository<BankDeposit>(db), cash, branch, clock);
|
||||
}
|
||||
|
||||
private static ErpDbContext CreateContext()
|
||||
|
||||
@@ -396,7 +396,10 @@ public class DualApprovalTests
|
||||
(GlAccountRole.InventoryLoss, "5900")),
|
||||
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
||||
new Repository<ProductConfig>(db), user));
|
||||
var prices = new PriceAdjustmentService(new PriceAdjustmentRepository(db), approvals, notifications, numbers, user, uow);
|
||||
var prices = new PriceAdjustmentService(
|
||||
new Repository<PriceAdjustment>(db), new Repository<PriceAdjustmentLine>(db), new Repository<Item>(db),
|
||||
new Repository<ItemPriceHistory>(db), new Repository<User>(db), new Repository<ApprovalConfig>(db),
|
||||
approvals, notifications, numbers, user, uow);
|
||||
|
||||
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
|
||||
}
|
||||
|
||||
@@ -157,14 +157,6 @@ public class PermissionCoverageTests
|
||||
[InlineData("hrm.payroll")]
|
||||
[InlineData("sales-returns")]
|
||||
[InlineData("settings.audit-logs")]
|
||||
[InlineData("dashboard.stock")]
|
||||
[InlineData("dashboard.procurement")]
|
||||
[InlineData("dashboard.approvals")]
|
||||
[InlineData("dashboard.sales")]
|
||||
[InlineData("dashboard.accounts")]
|
||||
[InlineData("dashboard.hrm")]
|
||||
[InlineData("dashboard.production")]
|
||||
[InlineData("dashboard.service")]
|
||||
public void NewPermissions_AreSeeded(string code)
|
||||
{
|
||||
using var db = CreateContext();
|
||||
|
||||
@@ -45,7 +45,7 @@ public class ProcurementAndOpeningBalanceTests
|
||||
}
|
||||
|
||||
private static ProcurementConfigService ConfigService(ErpDbContext db) =>
|
||||
new(new ProcurementConfigRepository(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
||||
new(new Repository<ProcurementConfig>(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
||||
|
||||
private static VendorService VendorSvc(ErpDbContext db) =>
|
||||
new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db));
|
||||
@@ -214,7 +214,8 @@ public class ProcurementAndOpeningBalanceTests
|
||||
// ---------------------------------------------------------------- purchase-order tax
|
||||
|
||||
private static PurchaseOrderService PoSvc(ErpDbContext db) =>
|
||||
new(new PurchaseOrderRepository(db),
|
||||
new(new Repository<PurchaseOrder>(db), new Repository<Vendor>(db), new Repository<Requisition>(db),
|
||||
new Repository<Item>(db), new Repository<Uom>(db), new Repository<Warehouse>(db),
|
||||
new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(),
|
||||
ConfigService(db), new PassThroughUnitOfWork(db));
|
||||
|
||||
@@ -560,7 +561,9 @@ public class ProcurementAndOpeningBalanceTests
|
||||
var fifo = new FifoCostingService(
|
||||
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
|
||||
return new OpeningStockService(
|
||||
new OpeningStockRepository(db), fifo, gl, glAccounts, new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
||||
new Repository<OpeningBalanceRun>(db), new Repository<Item>(db), new Repository<Warehouse>(db),
|
||||
new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), fifo, gl, glAccounts,
|
||||
new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
||||
}
|
||||
|
||||
private static Item StockedItem(int id, Action<Item>? tweak = null)
|
||||
|
||||
@@ -145,7 +145,9 @@ internal static class BankDepositTestSupport
|
||||
}
|
||||
|
||||
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
|
||||
=> new(new CashInHandRepository(db), new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
|
||||
=> new(new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
|
||||
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db),
|
||||
new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
|
||||
|
||||
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
|
||||
{
|
||||
|
||||
@@ -12,25 +12,12 @@ namespace ERPCore.Controllers;
|
||||
public sealed class DashboardController : ApiControllerBase
|
||||
{
|
||||
private readonly IDashboardService _dashboard;
|
||||
private readonly IDashboardChartsService _charts;
|
||||
|
||||
public DashboardController(IDashboardService dashboard, IDashboardChartsService charts)
|
||||
{
|
||||
_dashboard = dashboard;
|
||||
_charts = charts;
|
||||
}
|
||||
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
|
||||
|
||||
/// <summary>Aggregate counts for stock, GRN, procurement, sales, accounts, service, production and HR.</summary>
|
||||
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
||||
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));
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,9 +10,6 @@ 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,
|
||||
@@ -27,37 +24,7 @@ public sealed record DashboardStatsDto(
|
||||
int OpenRfqs,
|
||||
int PendingLeaveRequests,
|
||||
int OverdueSalesInvoices,
|
||||
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);
|
||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
|
||||
|
||||
/// <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,15 +103,7 @@ 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 = 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 }
|
||||
new Permission { PermissionId = 71, Code = "NAV:settings.notification-config", SubNavItemId = 24 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,17 +103,7 @@ 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 },
|
||||
// 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 }
|
||||
new SubNavItem { SubNavItemId = 58, NavItemId = 9, Code = "settings.audit-logs", Label = "Audit Log", Href = "/dashboard/settings/audit-logs", SortOrder = 5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-11502
File diff suppressed because it is too large
Load Diff
@@ -1,227 +0,0 @@
|
||||
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,54 +4633,6 @@ 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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8324,86 +8276,6 @@ 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"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -116,30 +116,6 @@ builder.Services.AddScoped<IClaimsTransformation, ShadowUserClaimsTransformation
|
||||
// Unit of work + generic repository base
|
||||
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
// Aggregate-specific repositories (bespoke queries on top of the generic base)
|
||||
builder.Services.AddScoped<IBrandRepository, BrandRepository>();
|
||||
builder.Services.AddScoped<IBundleSaleRepository, BundleSaleRepository>();
|
||||
builder.Services.AddScoped<ICashInHandRepository, CashInHandRepository>();
|
||||
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||
builder.Services.AddScoped<ICountRepository, CountRepository>();
|
||||
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
|
||||
builder.Services.AddScoped<IDayEndReportRepository, DayEndReportRepository>();
|
||||
builder.Services.AddScoped<IExpensePaymentRepository, ExpensePaymentRepository>();
|
||||
builder.Services.AddScoped<IExpenseRepository, ExpenseRepository>();
|
||||
builder.Services.AddScoped<IExpenseSetupRepository, ExpenseSetupRepository>();
|
||||
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
|
||||
builder.Services.AddScoped<IItemImageRepository, ItemImageRepository>();
|
||||
builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
||||
builder.Services.AddScoped<IItemTypeRepository, ItemTypeRepository>();
|
||||
builder.Services.AddScoped<INumberSequenceRepository, NumberSequenceRepository>();
|
||||
builder.Services.AddScoped<IOpeningStockRepository, OpeningStockRepository>();
|
||||
builder.Services.AddScoped<IPriceAdjustmentRepository, PriceAdjustmentRepository>();
|
||||
builder.Services.AddScoped<IProcurementConfigRepository, ProcurementConfigRepository>();
|
||||
builder.Services.AddScoped<IProductConfigRepository, ProductConfigRepository>();
|
||||
builder.Services.AddScoped<IPurchaseInvoicePaymentRepository, PurchaseInvoicePaymentRepository>();
|
||||
builder.Services.AddScoped<IPurchaseInvoiceRepository, PurchaseInvoiceRepository>();
|
||||
builder.Services.AddScoped<IPurchaseOrderRepository, PurchaseOrderRepository>();
|
||||
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
|
||||
|
||||
// Master-data services (docs/11 §2)
|
||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
@@ -255,7 +231,6 @@ 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>();
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IBrandRepository"/>.</summary>
|
||||
public sealed class BrandRepository : Repository<Brand>, IBrandRepository
|
||||
{
|
||||
public BrandRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public async Task<(IReadOnlyList<Brand> Rows, int Total)> ListPagedAsync(
|
||||
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(b => EF.Functions.Like(b.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(b => b.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(b => b.Name).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<Brand?> GetForReadAsync(int brandId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||
|
||||
public Task<bool> NameExistsAsync(string name, int? excludeBrandId = null, CancellationToken ct = default)
|
||||
{
|
||||
var lowered = name.ToLower();
|
||||
return Set.AnyAsync(b => b.Name.ToLower() == lowered && (excludeBrandId == null || b.BrandId != excludeBrandId), ct);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IBundleSaleRepository"/>.</summary>
|
||||
public sealed class BundleSaleRepository : Repository<BundleSale>, IBundleSaleRepository
|
||||
{
|
||||
public BundleSaleRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private DbSet<BundleSaleTemplate> Templates => Db.Set<BundleSaleTemplate>();
|
||||
|
||||
// --- Templates -------------------------------------------------------------------------------
|
||||
|
||||
public async Task<(IReadOnlyList<BundleSaleTemplate> Rows, int Total)> ListTemplatesPagedAsync(
|
||||
string? search, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<BundleSaleTemplate> q = Templates.AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(x => EF.Functions.Like(x.TemplateCode, $"%{term}%")
|
||||
|| EF.Functions.Like(x.TemplateName, $"%{term}%")
|
||||
|| EF.Functions.Like(x.Description ?? "", $"%{term}%"));
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<BundleSaleTemplate?> GetTemplateForReadAsync(int bundleSaleTemplateId, CancellationToken ct = default)
|
||||
=> Templates.AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct);
|
||||
|
||||
public async Task AddTemplateAsync(BundleSaleTemplate template, CancellationToken ct = default)
|
||||
=> await Templates.AddAsync(template, ct);
|
||||
|
||||
// --- Bundle sales ----------------------------------------------------------------------------
|
||||
|
||||
public async Task<(IReadOnlyList<BundleSale> Rows, int Total)> ListPagedAsync(
|
||||
string? search, BundleSaleStatus? status, int? customerId, int? warehouseId, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<BundleSale> q = Set.AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(x => EF.Functions.Like(x.BundleNo, $"%{term}%")
|
||||
|| EF.Functions.Like(x.BundleName, $"%{term}%")
|
||||
|| EF.Functions.Like(x.BundleCode, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<BundleSale?> GetForReadAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
||||
|
||||
public Task<BundleSale?> GetForUpdateAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
=> Set.Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
||||
|
||||
// --- Lookups ---------------------------------------------------------------------------------
|
||||
|
||||
public Task<bool> IsDayClosedAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
|
||||
=> Db.Set<SalesDayEnd>().AsNoTracking().AnyAsync(x => x.CashierUserId == cashierUserId && x.BusinessDate == businessDate, ct);
|
||||
|
||||
public Task<string> GetCustomerSnapshotNameAsync(int customerId, CancellationToken ct = default)
|
||||
=> Db.Set<Customer>().AsNoTracking().Where(x => x.CustomerId == customerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
|
||||
public Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default)
|
||||
=> Db.Set<Item>().AsNoTracking().AnyAsync(x => x.ItemId == itemId, ct);
|
||||
|
||||
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> Db.Set<Warehouse>().AsNoTracking().AnyAsync(x => x.WarehouseId == warehouseId, ct);
|
||||
|
||||
public Task<string> GetItemNameAsync(int itemId, CancellationToken ct = default)
|
||||
=> Db.Set<Item>().AsNoTracking().Where(x => x.ItemId == itemId).Select(x => x.Name).FirstAsync(ct);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="ICashInHandRepository"/>. Every query is read-only (no tracking).</summary>
|
||||
public sealed class CashInHandRepository : ICashInHandRepository
|
||||
{
|
||||
private readonly ErpDbContext _db;
|
||||
|
||||
public CashInHandRepository(ErpDbContext db) => _db = db;
|
||||
|
||||
public async Task<decimal> SumCashInAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default)
|
||||
=> await Slips(lo, hi, branchId).SumAsync(s => s.CashAmount, ct)
|
||||
+ await Bundles(lo, hi, branchId).SumAsync(b => b.CashAmount, ct)
|
||||
+ await InvoicePayments(lo, hi, branchId).SumAsync(p => p.Amount, ct);
|
||||
|
||||
public Task<decimal> SumCashOutAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default)
|
||||
=> ExpensePayments(lo, hi, branchId).SumAsync(p => p.Amount, ct);
|
||||
|
||||
public Task<decimal> SumDepositsAsync(DateOnly? lo, DateOnly hi, int? branchId, CancellationToken ct = default)
|
||||
=> Deposits(lo, hi, branchId).SumAsync(d => d.Amount, ct);
|
||||
|
||||
public async Task<IReadOnlyList<(DateTime At, decimal Amount)>> ListCashInAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var rows = new List<(DateTime At, decimal Amount)>();
|
||||
rows.AddRange((await Slips(lo, hi, branchId).Select(s => new { s.SlipDate, s.CashAmount }).ToListAsync(ct))
|
||||
.Select(r => (r.SlipDate, r.CashAmount)));
|
||||
rows.AddRange((await Bundles(lo, hi, branchId).Select(b => new { b.BundleDate, b.CashAmount }).ToListAsync(ct))
|
||||
.Select(r => (r.BundleDate, r.CashAmount)));
|
||||
rows.AddRange((await InvoicePayments(lo, hi, branchId).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct))
|
||||
.Select(r => (r.PaymentDate, r.Amount)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<(DateTime At, decimal Amount)>> ListCashOutAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default)
|
||||
=> (await ExpensePayments(lo, hi, branchId).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct))
|
||||
.Select(r => (r.PaymentDate, r.Amount)).ToList();
|
||||
|
||||
public async Task<IReadOnlyList<(DateOnly BusinessDate, decimal Amount)>> ListDepositsAsync(DateOnly lo, DateOnly hi, int? branchId, CancellationToken ct = default)
|
||||
=> (await Deposits(lo, hi, branchId).Select(d => new { d.BusinessDate, d.Amount }).ToListAsync(ct))
|
||||
.Select(r => (r.BusinessDate, r.Amount)).ToList();
|
||||
|
||||
// --- Sources. lo is inclusive (null = no lower bound), hi exclusive. ---
|
||||
|
||||
private IQueryable<SalesSlip> Slips(DateTime? lo, DateTime hi, int? branch)
|
||||
{
|
||||
var q = _db.Set<SalesSlip>().AsNoTracking()
|
||||
.Where(s => s.Status == SalesSlipStatus.Posted && s.CashAmount != 0 && s.SlipDate < hi);
|
||||
if (lo is not null) q = q.Where(s => s.SlipDate >= lo);
|
||||
return branch is null ? q : q.Where(s => s.Warehouse!.BranchId == branch);
|
||||
}
|
||||
|
||||
private IQueryable<BundleSale> Bundles(DateTime? lo, DateTime hi, int? branch)
|
||||
{
|
||||
var q = _db.Set<BundleSale>().AsNoTracking()
|
||||
.Where(b => b.Status == BundleSaleStatus.Posted && b.CashAmount != 0 && b.BundleDate < hi);
|
||||
if (lo is not null) q = q.Where(b => b.BundleDate >= lo);
|
||||
return branch is null ? q : q.Where(b => b.Warehouse!.BranchId == branch);
|
||||
}
|
||||
|
||||
/// <summary>Cash received against Posted invoices — whatever day the invoice itself is from.</summary>
|
||||
private IQueryable<SalesInvoicePayment> InvoicePayments(DateTime? lo, DateTime hi, int? branch)
|
||||
{
|
||||
var q = _db.Set<SalesInvoicePayment>().AsNoTracking()
|
||||
.Where(p => p.PaymentMethod == SalesPaymentMethod.Cash && p.SalesInvoice!.Status == SalesInvoiceStatus.Posted && p.PaymentDate < hi);
|
||||
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
|
||||
return branch is null ? q : q.Where(p => p.SalesInvoice!.BranchId == branch);
|
||||
}
|
||||
|
||||
private IQueryable<ExpensePayment> ExpensePayments(DateTime? lo, DateTime hi, int? branch)
|
||||
{
|
||||
var q = _db.Set<ExpensePayment>().AsNoTracking()
|
||||
.Where(p => p.PaymentMethod == ExpensePaymentMethod.Cash && p.Expense!.Status != ExpenseStatus.Cancelled && p.PaymentDate < hi);
|
||||
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
|
||||
return branch is null ? q : q.Where(p => p.Expense!.BranchId == branch);
|
||||
}
|
||||
|
||||
/// <summary>Posted deposits by business date; <paramref name="lo"/> inclusive (null = no bound), <paramref name="hi"/> exclusive.</summary>
|
||||
private IQueryable<BankDeposit> Deposits(DateOnly? lo, DateOnly hi, int? branch)
|
||||
{
|
||||
var q = _db.Set<BankDeposit>().AsNoTracking()
|
||||
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate < hi);
|
||||
if (lo is not null) q = q.Where(d => d.BusinessDate >= lo);
|
||||
return branch is null ? q : q.Where(d => d.BranchId == branch);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="ICategoryRepository"/>.</summary>
|
||||
public sealed class CategoryRepository : Repository<Category>, ICategoryRepository
|
||||
{
|
||||
public CategoryRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private DbSet<SubCategory> SubCategories => Db.Set<SubCategory>();
|
||||
|
||||
// --- Categories ------------------------------------------------------------------------------
|
||||
|
||||
public async Task<(IReadOnlyList<Category> Rows, int Total)> ListPagedAsync(
|
||||
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(c => c.Name).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<Category?> GetForReadAsync(int categoryId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
|
||||
|
||||
public Task<bool> ExistsAsync(int categoryId, CancellationToken ct = default)
|
||||
=> Set.AnyAsync(c => c.CategoryId == categoryId, ct);
|
||||
|
||||
public Task<bool> NameExistsAsync(string name, int? excludeCategoryId = null, CancellationToken ct = default)
|
||||
{
|
||||
var lowered = name.ToLower();
|
||||
return Set.AnyAsync(c => c.Name.ToLower() == lowered && (excludeCategoryId == null || c.CategoryId != excludeCategoryId), ct);
|
||||
}
|
||||
|
||||
// --- Subcategories ---------------------------------------------------------------------------
|
||||
|
||||
public async Task<(IReadOnlyList<SubCategory> Rows, int Total)> ListSubCategoriesPagedAsync(
|
||||
int categoryId, string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = SubCategories.AsNoTracking().Where(s => s.CategoryId == categoryId);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(s => EF.Functions.Like(s.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(s => s.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(s => s.Name).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<SubCategory?> GetSubCategoryForReadAsync(int subCategoryId, CancellationToken ct = default)
|
||||
=> SubCategories.AsNoTracking().FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||
|
||||
public async Task<SubCategory?> GetSubCategoryForUpdateAsync(int subCategoryId, CancellationToken ct = default)
|
||||
=> await SubCategories.FindAsync(new object[] { subCategoryId }, ct);
|
||||
|
||||
public Task<bool> SubCategoryNameExistsAsync(int categoryId, string name, int? excludeSubCategoryId = null, CancellationToken ct = default)
|
||||
{
|
||||
var lowered = name.ToLower();
|
||||
return SubCategories.AnyAsync(s => s.CategoryId == categoryId && s.Name.ToLower() == lowered
|
||||
&& (excludeSubCategoryId == null || s.SubCategoryId != excludeSubCategoryId), ct);
|
||||
}
|
||||
|
||||
public async Task AddSubCategoryAsync(SubCategory subCategory, CancellationToken ct = default)
|
||||
=> await SubCategories.AddAsync(subCategory, ct);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="ICountRepository"/>.</summary>
|
||||
public sealed class CountRepository : Repository<StockCount>, ICountRepository
|
||||
{
|
||||
public CountRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private IQueryable<StockLayer> Layers(int itemId, int warehouseId)
|
||||
=> Db.Set<StockLayer>().AsNoTracking().Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId);
|
||||
|
||||
// --- Counts ----------------------------------------------------------------------------------
|
||||
|
||||
public async Task<(IReadOnlyList<(StockCount Count, int LineCount)> Rows, int Total)> ListPagedAsync(
|
||||
string? search, CountStatus? status, int? warehouseId, int? branchId, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(c => EF.Functions.Like(c.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
|
||||
// Warehouses are shared across branches — the count carries its own owning BranchId.
|
||||
if (branchId is not null) q = q.Where(c => c.BranchId == branchId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(c => c.CountId).Skip(skip).Take(take)
|
||||
.Select(c => new { Count = c, LineCount = c.Lines.Count })
|
||||
.ToListAsync(ct);
|
||||
return (rows.Select(r => (r.Count, r.LineCount)).ToList(), total);
|
||||
}
|
||||
|
||||
public Task<StockCount?> GetForReadAsync(int countId, CancellationToken ct = default)
|
||||
=> WithLines(Set.AsNoTracking()).FirstOrDefaultAsync(c => c.CountId == countId, ct);
|
||||
|
||||
public Task<StockCount?> GetForUpdateAsync(int countId, CancellationToken ct = default)
|
||||
=> WithLines(Set).FirstOrDefaultAsync(c => c.CountId == countId, ct);
|
||||
|
||||
private static IQueryable<StockCount> WithLines(IQueryable<StockCount> q)
|
||||
=> q.Include(c => c.Lines).ThenInclude(l => l.Batch)
|
||||
.Include(c => c.Lines).ThenInclude(l => l.Serial);
|
||||
|
||||
// --- Lookups ---------------------------------------------------------------------------------
|
||||
|
||||
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
|
||||
|
||||
public Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default)
|
||||
=> Db.Set<Item>().AnyAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
public Task<Item> GetItemAsync(int itemId, CancellationToken ct = default)
|
||||
=> Db.Set<Item>().AsNoTracking().FirstAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
public Task<ReasonCode?> GetAdjustmentReasonAsync(string code, CancellationToken ct = default)
|
||||
=> Db.Set<ReasonCode>().AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == code, ct);
|
||||
|
||||
// --- Stock snapshot --------------------------------------------------------------------------
|
||||
|
||||
public async Task<IReadOnlyList<Batch>> ListBatchesAsync(int itemId, CancellationToken ct = default)
|
||||
=> await Db.Set<Batch>().AsNoTracking().Where(b => b.ItemId == itemId).ToListAsync(ct);
|
||||
|
||||
public async Task<decimal> SumBatchQtyAsync(int itemId, int warehouseId, int? batchId, CancellationToken ct = default)
|
||||
=> await Layers(itemId, warehouseId).Where(l => l.BatchId == batchId).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
|
||||
public async Task<decimal> SumUnserializedQtyAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
=> await Layers(itemId, warehouseId).Where(l => l.SerialId == null).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
|
||||
public async Task<IReadOnlyList<Serial>> ListCountableSerialsAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
=> await Db.Set<Serial>().AsNoTracking()
|
||||
.Where(s => s.ItemId == itemId && s.WarehouseId == warehouseId
|
||||
&& (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged))
|
||||
.OrderBy(s => s.SerialNo).ToListAsync(ct);
|
||||
|
||||
public Task<bool> HasOpenSerialLayerAsync(int itemId, int warehouseId, int serialId, CancellationToken ct = default)
|
||||
=> Layers(itemId, warehouseId).AnyAsync(l => l.SerialId == serialId && l.QtyRemaining > 0, ct);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="ICustomerRepository"/>.</summary>
|
||||
public sealed class CustomerRepository : Repository<Customer>, ICustomerRepository
|
||||
{
|
||||
public CustomerRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public async Task<(IReadOnlyList<Customer> Rows, int Total)> ListPagedAsync(
|
||||
CustomerListCriteria criteria, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
var cashCode = criteria.CashCustomerCode;
|
||||
var cashCustomerId = await Db.Set<SalesConfig>().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered
|
||||
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names).
|
||||
if (!criteria.IncludeCashCustomer)
|
||||
q = q.Where(c => c.CustomerCode != cashCode && (cashCustomerId == null || c.CustomerId != cashCustomerId));
|
||||
// Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created
|
||||
// under. The cash customer is shared by every branch, so it is never filtered out here.
|
||||
if (criteria.BranchId is { } branchId)
|
||||
q = q.Where(c => c.BranchId == branchId
|
||||
|| c.CustomerCode == cashCode
|
||||
|| (cashCustomerId != null && c.CustomerId == cashCustomerId));
|
||||
if (!string.IsNullOrWhiteSpace(criteria.Search))
|
||||
{
|
||||
var term = criteria.Search.Trim();
|
||||
q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%")
|
||||
|| EF.Functions.Like(c.CustomerCode, $"%{term}%")
|
||||
|| (c.DisplayName != null && EF.Functions.Like(c.DisplayName, $"%{term}%")));
|
||||
}
|
||||
if (criteria.Status is not null) q = q.Where(c => c.Status == criteria.Status);
|
||||
if (criteria.CustomerType is not null) q = q.Where(c => c.CustomerType == criteria.CustomerType);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(c => c.Name).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, CustomerReceivable>> GetReceivablesAsync(
|
||||
IReadOnlyCollection<int> customerIds, CancellationToken ct = default)
|
||||
{
|
||||
if (customerIds.Count == 0) return new Dictionary<int, CustomerReceivable>();
|
||||
var ids = customerIds.ToList();
|
||||
|
||||
var dues = await Db.Set<SalesInvoice>().AsNoTracking()
|
||||
.Where(i => ids.Contains(i.CustomerId) && i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0)
|
||||
.GroupBy(i => i.CustomerId)
|
||||
.Select(g => new { CustomerId = g.Key, Amount = g.Sum(i => i.BalanceAmount), Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.CustomerId, ct);
|
||||
|
||||
var lastPaid = await Db.Set<SalesInvoicePayment>().AsNoTracking()
|
||||
.Where(p => ids.Contains(p.SalesInvoice!.CustomerId))
|
||||
.GroupBy(p => p.SalesInvoice!.CustomerId)
|
||||
.Select(g => new { CustomerId = g.Key, Last = g.Max(p => p.PaymentDate) })
|
||||
.ToDictionaryAsync(x => x.CustomerId, x => x.Last, ct);
|
||||
|
||||
return dues.Keys.Union(lastPaid.Keys).ToDictionary(
|
||||
id => id,
|
||||
id => new CustomerReceivable(
|
||||
dues.TryGetValue(id, out var due) ? due.Amount : 0m,
|
||||
due?.Count ?? 0,
|
||||
lastPaid.TryGetValue(id, out var last) ? last : null));
|
||||
}
|
||||
|
||||
public Task<Customer?> GetForReadAsync(int customerId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().FirstOrDefaultAsync(c => c.CustomerId == customerId, ct);
|
||||
|
||||
public Task<bool> CodeExistsAsync(string code, int? excludeCustomerId = null, CancellationToken ct = default)
|
||||
{
|
||||
var lowered = code.ToLower();
|
||||
return Set.AnyAsync(c => c.CustomerCode.ToLower() == lowered && (excludeCustomerId == null || c.CustomerId != excludeCustomerId), ct);
|
||||
}
|
||||
|
||||
public Task<bool> EmailExistsAsync(string email, int? excludeCustomerId = null, CancellationToken ct = default)
|
||||
{
|
||||
var lowered = email.ToLower();
|
||||
return Set.AnyAsync(c => c.Email != null && c.Email.ToLower() == lowered
|
||||
&& (excludeCustomerId == null || c.CustomerId != excludeCustomerId), ct);
|
||||
}
|
||||
|
||||
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
|
||||
|
||||
public Task<CustomerFieldSwitches?> GetFieldSwitchesAsync(CancellationToken ct = default)
|
||||
=> Db.Set<SalesConfig>().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => new CustomerFieldSwitches(
|
||||
c.CustomerPhoneEnabled, c.CustomerEmailEnabled, c.CustomerAddressEnabled, c.CustomerCityEnabled,
|
||||
c.CustomerCountryEnabled, c.CustomerCreditLimitEnabled, c.CustomerCreditDaysEnabled))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IDayEndReportRepository"/>. Every query is read-only (no tracking).</summary>
|
||||
public sealed class DayEndReportRepository : IDayEndReportRepository
|
||||
{
|
||||
/// <summary>Ids per <c>IN (...)</c> when summing ledger cost, to keep the statement a sane size.</summary>
|
||||
private const int IdChunk = 1000;
|
||||
|
||||
private readonly ErpDbContext _db;
|
||||
|
||||
public DayEndReportRepository(ErpDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<DayEndSlipRow>> ListPostedSlipsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Set<SalesSlip>().AsNoTracking()
|
||||
.Where(s => s.Status == SalesSlipStatus.Posted && s.SlipDate >= start && s.SlipDate < end);
|
||||
if (branchId is not null) q = q.Where(s => s.Warehouse!.BranchId == branchId);
|
||||
return await q
|
||||
.Select(s => new DayEndSlipRow(s.SalesSlipId, s.SlipNo, s.SlipDate, s.CustomerSnapshotName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DayEndBundleRow>> ListPostedBundlesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Set<BundleSale>().AsNoTracking()
|
||||
.Where(b => b.Status == BundleSaleStatus.Posted && b.BundleDate >= start && b.BundleDate < end);
|
||||
if (branchId is not null) q = q.Where(b => b.Warehouse!.BranchId == branchId);
|
||||
return await q
|
||||
.Select(b => new DayEndBundleRow(b.BundleSaleId, b.BundleNo, b.BundleDate, b.CustomerSnapshotName, b.GrandTotal, b.CashAmount, b.CardAmount))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DayEndInvoiceRow>> ListPostedInvoicesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Set<SalesInvoice>().AsNoTracking()
|
||||
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= start && i.InvoiceDate < end);
|
||||
if (branchId is not null) q = q.Where(i => i.BranchId == branchId);
|
||||
return await q
|
||||
.Select(i => new DayEndInvoiceRow(i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, i.CustomerSnapshotName, i.NetPayable, i.PaidAmount))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DayEndPaymentRow>> ListInvoicePaymentsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Set<SalesInvoicePayment>().AsNoTracking()
|
||||
.Where(p => p.SalesInvoice!.Status == SalesInvoiceStatus.Posted
|
||||
&& ((p.SalesInvoice.InvoiceDate >= start && p.SalesInvoice.InvoiceDate < end)
|
||||
|| (p.PaymentDate >= start && p.PaymentDate < end)));
|
||||
if (branchId is not null) q = q.Where(p => p.SalesInvoice!.BranchId == branchId);
|
||||
return await q
|
||||
.Select(p => new DayEndPaymentRow(p.SalesInvoiceId, p.SalesInvoice!.InvoiceDate, p.PaymentDate, p.PaymentMethod, p.Amount))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, decimal>> GetCostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct = default)
|
||||
{
|
||||
var result = new Dictionary<int, decimal>();
|
||||
foreach (var chunk in docIds.Distinct().Chunk(IdChunk))
|
||||
{
|
||||
var rows = await _db.Set<StockLedger>().AsNoTracking()
|
||||
.Where(l => l.SourceDocType == docType && l.Direction == Direction.Out && chunk.Contains(l.SourceDocId))
|
||||
.GroupBy(l => l.SourceDocId)
|
||||
.Select(g => new { DocId = g.Key, Cost = g.Sum(l => l.Value) })
|
||||
.ToListAsync(ct);
|
||||
foreach (var r in rows) result[r.DocId] = r.Cost;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DayEndExpenseRow>> ListPostedExpensesAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Set<Expense>().AsNoTracking()
|
||||
.Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= from && e.ExpenseDate <= to);
|
||||
if (branchId is not null) q = q.Where(e => e.BranchId == branchId);
|
||||
return await q
|
||||
.OrderBy(e => e.ExpenseId)
|
||||
.Select(e => new DayEndExpenseRow(e.ExpenseId, e.DocNo, e.ExpenseDate, e.Category!.Name, e.Title!.Name, e.Payee, e.Amount, e.PaidAmount))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DayEndDepositRow>> ListPostedDepositsAsync(DateOnly businessDate, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Set<BankDeposit>().AsNoTracking()
|
||||
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate == businessDate);
|
||||
if (branchId is not null) q = q.Where(d => d.BranchId == branchId);
|
||||
return await q
|
||||
.OrderBy(d => d.BankDepositId)
|
||||
.Select(d => new DayEndDepositRow(d.BankDepositId, d.DocNo, d.ToBankAccountName, d.ToBankName, d.ToAccountNumber, d.DepositSlipNo, d.DepositDate, d.Amount))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IExpensePaymentRepository"/>.</summary>
|
||||
public sealed class ExpensePaymentRepository : Repository<ExpensePayment>, IExpensePaymentRepository
|
||||
{
|
||||
public ExpensePaymentRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public Task<bool> ExpenseExistsAsync(int expenseId, CancellationToken ct = default)
|
||||
=> Db.Set<Expense>().AnyAsync(x => x.ExpenseId == expenseId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<ExpensePayment>> ListForExpenseAsync(int expenseId, CancellationToken ct = default)
|
||||
=> await Set.AsNoTracking()
|
||||
.Where(p => p.ExpenseId == expenseId)
|
||||
.OrderByDescending(p => p.ExpensePaymentId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Expenses;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IExpenseRepository"/>.</summary>
|
||||
public sealed class ExpenseRepository : Repository<Expense>, IExpenseRepository
|
||||
{
|
||||
public ExpenseRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public async Task<(IReadOnlyList<Expense> Rows, int Total)> ListPagedAsync(
|
||||
string? search, ExpenseListFilter filter, DateOnly today, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<Expense> q = Set.AsNoTracking().Include(x => x.Category).Include(x => x.Title);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|
||||
|| (x.Payee != null && EF.Functions.Like(x.Payee, $"%{term}%"))
|
||||
|| (x.BillReference != null && EF.Functions.Like(x.BillReference, $"%{term}%")));
|
||||
}
|
||||
if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status);
|
||||
if (filter.PaymentStatus is not null) q = q.Where(x => x.Status == ExpenseStatus.Posted && x.PaymentStatus == filter.PaymentStatus);
|
||||
if (filter.CategoryId is not null) q = q.Where(x => x.ExpenseCategoryId == filter.CategoryId);
|
||||
if (filter.TitleId is not null) q = q.Where(x => x.ExpenseTitleId == filter.TitleId);
|
||||
if (filter.From is not null) q = q.Where(x => x.ExpenseDate >= filter.From);
|
||||
if (filter.To is not null) q = q.Where(x => x.ExpenseDate <= filter.To);
|
||||
if (filter.OverdueOnly)
|
||||
q = q.Where(x => x.Status == ExpenseStatus.Posted && x.BalanceAmount > 0 && x.DueDate != null && x.DueDate < today);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.ExpenseDate).ThenByDescending(x => x.ExpenseId)
|
||||
.Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<Expense?> GetWithDetailsAsync(int expenseId, bool tracking, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set
|
||||
.Include(x => x.Category)
|
||||
.Include(x => x.Title)
|
||||
.Include(x => x.Vendor)
|
||||
.Include(x => x.Payments);
|
||||
return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct);
|
||||
}
|
||||
|
||||
public Task<ExpenseTitle?> GetTitleWithCategoryAsync(int expenseTitleId, CancellationToken ct = default)
|
||||
=> Db.Set<ExpenseTitle>().Include(t => t.Category).FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct);
|
||||
|
||||
public Task<string?> GetVendorNameAsync(int vendorId, CancellationToken ct = default)
|
||||
=> Db.Set<Vendor>().AsNoTracking().Where(v => v.VendorId == vendorId).Select(v => (string?)v.Name).FirstOrDefaultAsync(ct);
|
||||
|
||||
// --- Summary report --------------------------------------------------------------------------
|
||||
|
||||
private IQueryable<Expense> Posted => Set.AsNoTracking().Where(x => x.Status == ExpenseStatus.Posted);
|
||||
|
||||
public async Task<IReadOnlyList<ExpenseSummaryRow>> ListPostedInRangeAsync(DateOnly from, DateOnly to, CancellationToken ct = default)
|
||||
=> await Posted
|
||||
.Where(x => x.ExpenseDate >= from && x.ExpenseDate <= to)
|
||||
.Select(x => new ExpenseSummaryRow(
|
||||
x.ExpenseCategoryId, x.Category!.Name, x.Category.SortOrder,
|
||||
x.ExpenseTitleId, x.Title!.Name, x.Amount, x.PaidAmount, x.BalanceAmount))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<(decimal BalanceAmount, DateOnly? DueDate)>> ListOutstandingAsync(CancellationToken ct = default)
|
||||
=> (await Posted.Where(x => x.BalanceAmount > 0).Select(x => new { x.BalanceAmount, x.DueDate }).ToListAsync(ct))
|
||||
.Select(x => (x.BalanceAmount, x.DueDate)).ToList();
|
||||
|
||||
public Task<int> CountDraftsAsync(CancellationToken ct = default)
|
||||
=> Set.CountAsync(x => x.Status == ExpenseStatus.Draft, ct);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IExpenseSetupRepository"/>.</summary>
|
||||
public sealed class ExpenseSetupRepository : Repository<ExpenseCategory>, IExpenseSetupRepository
|
||||
{
|
||||
public ExpenseSetupRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private DbSet<ExpenseTitle> Titles => Db.Set<ExpenseTitle>();
|
||||
|
||||
// --- Categories ------------------------------------------------------------------------------
|
||||
|
||||
public async Task<IReadOnlyList<ExpenseCategory>> ListWithTitlesAsync(bool includeInactive, CancellationToken ct = default)
|
||||
=> await Set.AsNoTracking()
|
||||
.Include(c => c.Titles)
|
||||
.Where(c => includeInactive || c.Status == EntityStatus.Active)
|
||||
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<ExpenseCategory?> GetWithTitlesForUpdateAsync(int expenseCategoryId, CancellationToken ct = default)
|
||||
=> Set.Include(c => c.Titles).FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct);
|
||||
|
||||
public Task<bool> ExistsAsync(int expenseCategoryId, CancellationToken ct = default)
|
||||
=> Set.AnyAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct);
|
||||
|
||||
public Task<bool> CategoryNameExistsAsync(string name, int excludeCategoryId, CancellationToken ct = default)
|
||||
=> Set.AnyAsync(c => c.Name == name && c.ExpenseCategoryId != excludeCategoryId, ct);
|
||||
|
||||
// --- Titles ----------------------------------------------------------------------------------
|
||||
|
||||
public Task<ExpenseTitle?> GetTitleForUpdateAsync(int expenseTitleId, CancellationToken ct = default)
|
||||
=> Titles.FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct);
|
||||
|
||||
public Task<bool> TitleNameExistsAsync(int expenseCategoryId, string name, int? excludeTitleId, CancellationToken ct = default)
|
||||
=> Titles.AnyAsync(t => t.ExpenseCategoryId == expenseCategoryId && t.Name == name
|
||||
&& (excludeTitleId == null || t.ExpenseTitleId != excludeTitleId), ct);
|
||||
|
||||
public async Task<int> MaxTitleSortOrderAsync(int expenseCategoryId, CancellationToken ct = default)
|
||||
=> await Titles.Where(t => t.ExpenseCategoryId == expenseCategoryId).MaxAsync(t => (int?)t.SortOrder, ct) ?? 0;
|
||||
|
||||
public async Task AddTitleAsync(ExpenseTitle title, CancellationToken ct = default)
|
||||
=> await Titles.AddAsync(title, ct);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IGrnRepository"/>.</summary>
|
||||
public sealed class GrnRepository : Repository<Grn>, IGrnRepository
|
||||
{
|
||||
public GrnRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private DbSet<PurchaseOrder> Pos => Db.Set<PurchaseOrder>();
|
||||
private DbSet<Vendor> Vendors => Db.Set<Vendor>();
|
||||
|
||||
// --- GRNs ------------------------------------------------------------------------------------
|
||||
|
||||
public async Task<(IReadOnlyList<GrnListRow> Rows, int Total)> ListPagedAsync(
|
||||
GrnListCriteria criteria, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.Search))
|
||||
{
|
||||
var term = criteria.Search.Trim();
|
||||
// Doc no., the PO's doc no., or the vendor's name / code (the vendor is the table's first column).
|
||||
var vendorIdsMatching = Vendors
|
||||
.Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%"))
|
||||
.Select(v => v.VendorId);
|
||||
var poIdsMatching = Pos
|
||||
.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%"))
|
||||
.Select(p => p.PoId);
|
||||
q = q.Where(g => EF.Functions.Like(g.DocNo, $"%{term}%")
|
||||
|| vendorIdsMatching.Contains(g.VendorId)
|
||||
|| (g.PoId != null && poIdsMatching.Contains(g.PoId.Value)));
|
||||
}
|
||||
if (criteria.Status is not null) q = q.Where(g => g.Status == criteria.Status);
|
||||
if (criteria.PoId is not null) q = q.Where(g => g.PoId == criteria.PoId);
|
||||
if (criteria.VendorId is not null) q = q.Where(g => g.VendorId == criteria.VendorId);
|
||||
if (criteria.WarehouseId is not null) q = q.Where(g => g.WarehouseId == criteria.WarehouseId);
|
||||
// Warehouses are shared across branches — GRN carries its own BranchId, set at creation.
|
||||
if (criteria.BranchId is not null) q = q.Where(g => g.BranchId == criteria.BranchId);
|
||||
if (criteria.CreatedFrom is { } start) q = q.Where(g => g.CreatedAt >= start);
|
||||
if (criteria.CreatedBefore is { } end) q = q.Where(g => g.CreatedAt < end);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await ApplySort(q, criteria.Sort)
|
||||
.Skip(skip).Take(take)
|
||||
.Select(g => new GrnListRow(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
|
||||
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
|
||||
.ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
private IQueryable<Grn> ApplySort(IQueryable<Grn> q, string? sort)
|
||||
{
|
||||
var key = (sort ?? "-created").Trim();
|
||||
var desc = key.StartsWith('-');
|
||||
key = key.TrimStart('-', '+').ToLowerInvariant();
|
||||
|
||||
IOrderedQueryable<Grn> ordered = key switch
|
||||
{
|
||||
"vendor" => desc
|
||||
? q.OrderByDescending(g => Vendors.Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault())
|
||||
: q.OrderBy(g => Vendors.Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault()),
|
||||
"total" => desc ? q.OrderByDescending(g => g.Lines.Sum(l => l.LineTotal)) : q.OrderBy(g => g.Lines.Sum(l => l.LineTotal)),
|
||||
"status" => desc ? q.OrderByDescending(g => g.Status) : q.OrderBy(g => g.Status),
|
||||
"created" => desc ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
|
||||
_ => q.OrderByDescending(g => g.CreatedAt)
|
||||
};
|
||||
return ordered.ThenByDescending(g => g.GrnId);
|
||||
}
|
||||
|
||||
public Task<Grn?> GetWithDetailsAsync(int grnId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking()
|
||||
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.Serials)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.Batch)
|
||||
.Include(g => g.PurchaseInvoice)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
|
||||
public Task<Grn?> GetForConfirmAsync(int grnId, CancellationToken ct = default)
|
||||
=> Set.Include(g => g.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
|
||||
public Task<Grn?> GetWithLinesForUpdateAsync(int grnId, CancellationToken ct = default)
|
||||
=> Set.Include(g => g.Lines).FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
|
||||
// --- Purchase orders -------------------------------------------------------------------------
|
||||
|
||||
public Task<PurchaseOrder?> GetPoWithLinesAsync(int poId, CancellationToken ct = default)
|
||||
=> Pos.AsNoTracking().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
|
||||
public Task<PurchaseOrder?> GetPoWithLinesForUpdateAsync(int poId, CancellationToken ct = default)
|
||||
=> Pos.Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
|
||||
public async Task<PoLine?> GetPoLineForUpdateAsync(int poLineId, CancellationToken ct = default)
|
||||
=> await Db.Set<PoLine>().FindAsync(new object[] { poLineId }, ct);
|
||||
|
||||
public Task<PurchaseOrderStatus?> GetPoStatusAsync(int poId, CancellationToken ct = default)
|
||||
=> Pos.AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, string>> GetPoDocNosAsync(IReadOnlyCollection<int> poIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = poIds.ToList();
|
||||
return await Pos.AsNoTracking().Where(p => ids.Contains(p.PoId)).ToDictionaryAsync(p => p.PoId, p => p.DocNo, ct);
|
||||
}
|
||||
|
||||
// --- Lookups ---------------------------------------------------------------------------------
|
||||
|
||||
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
|
||||
|
||||
public Task<bool> VendorExistsAsync(int vendorId, CancellationToken ct = default)
|
||||
=> Vendors.AnyAsync(v => v.VendorId == vendorId, ct);
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, Vendor>> GetVendorsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = vendorIds.ToList();
|
||||
return await Vendors.AsNoTracking().Where(v => ids.Contains(v.VendorId)).ToDictionaryAsync(v => v.VendorId, ct);
|
||||
}
|
||||
|
||||
public Task<Item?> GetItemAsync(int itemId, CancellationToken ct = default)
|
||||
=> Db.Set<Item>().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
public Task<bool> BinIsInWarehouseAsync(int binId, int warehouseId, CancellationToken ct = default)
|
||||
=> Db.Set<Bin>().AnyAsync(b => b.BinId == binId && b.WarehouseId == warehouseId, ct);
|
||||
|
||||
public Task<decimal> GetTaxPercentageAsync(int taxRateId, CancellationToken ct = default)
|
||||
=> Db.Set<TaxRate>().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct);
|
||||
|
||||
// --- Batches & serials -----------------------------------------------------------------------
|
||||
|
||||
public Task<Batch?> FindBatchAsync(int itemId, string batchNo, CancellationToken ct = default)
|
||||
=> Db.Set<Batch>().FirstOrDefaultAsync(b => b.ItemId == itemId && b.BatchNo == batchNo, ct);
|
||||
|
||||
public async Task AddBatchAsync(Batch batch, CancellationToken ct = default)
|
||||
=> await Db.Set<Batch>().AddAsync(batch, ct);
|
||||
|
||||
public async Task<IReadOnlyList<string>> ListTakenSerialsAsync(
|
||||
int itemId, IReadOnlyCollection<string> serialNos, int take, CancellationToken ct = default)
|
||||
{
|
||||
var numbers = serialNos.ToList();
|
||||
return await Db.Set<Serial>().AsNoTracking()
|
||||
.Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo))
|
||||
.Select(s => s.SerialNo).Take(take).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task AddSerialAsync(Serial serial, CancellationToken ct = default)
|
||||
=> await Db.Set<Serial>().AddAsync(serial, ct);
|
||||
|
||||
public async Task AddSerialMovementAsync(SerialMovement movement, CancellationToken ct = default)
|
||||
=> await Db.Set<SerialMovement>().AddAsync(movement, ct);
|
||||
|
||||
// --- Confirm result (idempotent replay) ------------------------------------------------------
|
||||
|
||||
public async Task<IReadOnlyList<StockLayer>> ListLayersForLinesAsync(IReadOnlyCollection<int> grnLineIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = grnLineIds.ToList();
|
||||
return await Db.Set<StockLayer>().AsNoTracking()
|
||||
.Where(l => l.GrnLineId != null && ids.Contains(l.GrnLineId.Value)).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<int>> ListInboundLedgerIdsAsync(int grnId, CancellationToken ct = default)
|
||||
=> await Db.Set<StockLedger>().AsNoTracking()
|
||||
.Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grnId && l.Direction == Direction.In)
|
||||
.Select(l => l.LedgerId).ToListAsync(ct);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Brand data access (FR-MD-09). Owns every query <see cref="Services.BrandService"/> runs, so the
|
||||
/// service holds only the business rules (uniqueness, concurrency) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IBrandRepository : IRepository<Brand>
|
||||
{
|
||||
/// <summary>One page of brands, name-ordered, optionally filtered by a name search and status. Read-only (no tracking).</summary>
|
||||
Task<(IReadOnlyList<Brand> Rows, int Total)> ListPagedAsync(
|
||||
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A brand for display, not tracked — use <see cref="IRepository{T}.GetByIdAsync"/> to change one.</summary>
|
||||
Task<Brand?> GetForReadAsync(int brandId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when another brand already has this name (case-insensitive), ignoring <paramref name="excludeBrandId"/>.</summary>
|
||||
Task<bool> NameExistsAsync(string name, int? excludeBrandId = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle sale data access — bundle sales, their templates, and the few lookups a bundle sale needs.
|
||||
/// Owns every query <see cref="Services.BundleSaleService"/> runs, so the service holds only the
|
||||
/// business rules (pricing, day-end guard, draft-only edits) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IBundleSaleRepository : IRepository<BundleSale>
|
||||
{
|
||||
// --- Templates -------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>One page of templates (with lines), newest first, optionally filtered by code / name / description. Read-only.</summary>
|
||||
Task<(IReadOnlyList<BundleSaleTemplate> Rows, int Total)> ListTemplatesPagedAsync(
|
||||
string? search, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A template with its lines, not tracked; null when it doesn't exist.</summary>
|
||||
Task<BundleSaleTemplate?> GetTemplateForReadAsync(int bundleSaleTemplateId, CancellationToken ct = default);
|
||||
|
||||
Task AddTemplateAsync(BundleSaleTemplate template, CancellationToken ct = default);
|
||||
|
||||
// --- Bundle sales ----------------------------------------------------------------------------
|
||||
|
||||
/// <summary>One page of bundle sales (with lines), newest first, with the list filters applied. Read-only.</summary>
|
||||
Task<(IReadOnlyList<BundleSale> Rows, int Total)> ListPagedAsync(
|
||||
string? search, BundleSaleStatus? status, int? customerId, int? warehouseId, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A bundle sale with its lines, not tracked; null when it doesn't exist.</summary>
|
||||
Task<BundleSale?> GetForReadAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A bundle sale with its lines, tracked so changes are saved; null when it doesn't exist.</summary>
|
||||
Task<BundleSale?> GetForUpdateAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
|
||||
// --- Lookups ---------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>True when the cashier has already posted day-end for <paramref name="businessDate"/>.</summary>
|
||||
Task<bool> IsDayClosedAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The customer's display name (falling back to the legal name), as snapshotted onto the sale.</summary>
|
||||
Task<string> GetCustomerSnapshotNameAsync(int customerId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The item's name, used as the bundle line description.</summary>
|
||||
Task<string> GetItemNameAsync(int itemId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only cash movement queries behind <see cref="Services.CashInHandService"/>. Cash comes in from Posted
|
||||
/// slips and bundle sales (their <c>CashAmount</c>) and cash payments on Posted invoices; it goes out as cash
|
||||
/// payments on non-cancelled expenses and as Posted bank deposits. The service owns the running-balance rules
|
||||
/// (tracking start, carry-forward, local-day bucketing) — 00-CORE §4.
|
||||
/// <para>
|
||||
/// Instant ranges are UTC: <c>lo</c> inclusive (null = no lower bound), <c>hi</c> exclusive. Deposits are
|
||||
/// by business date, same bounds. <c>branchId</c> null = every branch.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ICashInHandRepository
|
||||
{
|
||||
/// <summary>Total cash received (slips + bundle sales + cash invoice payments) in [lo, hi).</summary>
|
||||
Task<decimal> SumCashInAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Total cash paid out on expenses in [lo, hi).</summary>
|
||||
Task<decimal> SumCashOutAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Total Posted bank deposits with a business date in [lo, hi).</summary>
|
||||
Task<decimal> SumDepositsAsync(DateOnly? lo, DateOnly hi, int? branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Every cash receipt in [lo, hi) as (when, amount), for bucketing by local day.</summary>
|
||||
Task<IReadOnlyList<(DateTime At, decimal Amount)>> ListCashInAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Every cash expense payment in [lo, hi) as (when, amount).</summary>
|
||||
Task<IReadOnlyList<(DateTime At, decimal Amount)>> ListCashOutAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Every Posted bank deposit with a business date in [lo, hi) as (business date, amount).</summary>
|
||||
Task<IReadOnlyList<(DateOnly BusinessDate, decimal Amount)>> ListDepositsAsync(DateOnly lo, DateOnly hi, int? branchId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Category + subcategory data access (FR-MD-04). Owns every query <see cref="Services.CategoryService"/> runs,
|
||||
/// so the service holds only the business rules (uniqueness, active parent, concurrency) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface ICategoryRepository : IRepository<Category>
|
||||
{
|
||||
// --- Categories ------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>One page of categories, name-ordered, optionally filtered by a name search and status. Read-only.</summary>
|
||||
Task<(IReadOnlyList<Category> Rows, int Total)> ListPagedAsync(
|
||||
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A category for display, not tracked — use <see cref="IRepository{T}.GetByIdAsync"/> to change one.</summary>
|
||||
Task<Category?> GetForReadAsync(int categoryId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> ExistsAsync(int categoryId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when another category already has this name (case-insensitive), ignoring <paramref name="excludeCategoryId"/>.</summary>
|
||||
Task<bool> NameExistsAsync(string name, int? excludeCategoryId = null, CancellationToken ct = default);
|
||||
|
||||
// --- Subcategories ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>One page of a category's subcategories, name-ordered, with the search / status filters. Read-only.</summary>
|
||||
Task<(IReadOnlyList<SubCategory> Rows, int Total)> ListSubCategoriesPagedAsync(
|
||||
int categoryId, string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A subcategory for display, not tracked.</summary>
|
||||
Task<SubCategory?> GetSubCategoryForReadAsync(int subCategoryId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A subcategory tracked for changes.</summary>
|
||||
Task<SubCategory?> GetSubCategoryForUpdateAsync(int subCategoryId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when the category already has a subcategory with this name (case-insensitive), ignoring <paramref name="excludeSubCategoryId"/>.</summary>
|
||||
Task<bool> SubCategoryNameExistsAsync(int categoryId, string name, int? excludeSubCategoryId = null, CancellationToken ct = default);
|
||||
|
||||
Task AddSubCategoryAsync(SubCategory subCategory, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Stock-count data access (FR-STK-08). Owns every query <see cref="Services.CountService"/> runs — the counts
|
||||
/// themselves plus the stock snapshot reads (batches, serials, FIFO layers) a new count takes — so the service
|
||||
/// holds only the counting rules (one line per lot, serials count 0/1, variances become an adjustment) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface ICountRepository : IRepository<StockCount>
|
||||
{
|
||||
// --- Counts ----------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>One page of counts (newest first) with each count's line total, after the list filters. Read-only.</summary>
|
||||
Task<(IReadOnlyList<(StockCount Count, int LineCount)> Rows, int Total)> ListPagedAsync(
|
||||
string? search, CountStatus? status, int? warehouseId, int? branchId, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A count with its lines (and their batch / serial), not tracked.</summary>
|
||||
Task<StockCount?> GetForReadAsync(int countId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A count with its lines (and their batch / serial), tracked so changes are saved.</summary>
|
||||
Task<StockCount?> GetForUpdateAsync(int countId, CancellationToken ct = default);
|
||||
|
||||
// --- Lookups ---------------------------------------------------------------------------------
|
||||
|
||||
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>An item, not tracked; throws when it doesn't exist (callers check <see cref="ItemExistsAsync"/> first).</summary>
|
||||
Task<Item> GetItemAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The Adjustment-context reason code with this code, or null when it isn't configured.</summary>
|
||||
Task<ReasonCode?> GetAdjustmentReasonAsync(string code, CancellationToken ct = default);
|
||||
|
||||
// --- Stock snapshot --------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Every batch of the item.</summary>
|
||||
Task<IReadOnlyList<Batch>> ListBatchesAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Quantity left in the item's FIFO layers at the warehouse for one batch — or, with <paramref name="batchId"/> null, stock with no batch.</summary>
|
||||
Task<decimal> SumBatchQtyAsync(int itemId, int warehouseId, int? batchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Quantity left in the item's FIFO layers at the warehouse that carry no serial (pre-tracking stock).</summary>
|
||||
Task<decimal> SumUnserializedQtyAsync(int itemId, int warehouseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The item's serials that should be at the warehouse (InStock or Damaged), by serial number.</summary>
|
||||
Task<IReadOnlyList<Serial>> ListCountableSerialsAsync(int itemId, int warehouseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when the serial still has stock in an open FIFO layer at the warehouse.</summary>
|
||||
Task<bool> HasOpenSerialLayerAsync(int itemId, int warehouseId, int serialId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>Filters for <see cref="ICustomerRepository.ListPagedAsync"/>.</summary>
|
||||
/// <param name="IncludeCashCustomer">False keeps the shared walk-in "Cash Customer" out of the list.</param>
|
||||
/// <param name="BranchId">Only customers created under this branch (the cash customer is shared, so it is never filtered out by branch).</param>
|
||||
/// <param name="CashCustomerCode">Code of the system cash-customer row, recognised alongside SalesConfig's CashCustomerId.</param>
|
||||
public sealed record CustomerListCriteria(
|
||||
string? Search, EntityStatus? Status, CustomerType? CustomerType,
|
||||
bool IncludeCashCustomer, int? BranchId, string CashCustomerCode);
|
||||
|
||||
/// <summary>Which optional customer fields Sales Configuration has switched on.</summary>
|
||||
public sealed record CustomerFieldSwitches(bool Phone, bool Email, bool Address, bool City, bool Country, bool CreditLimit, bool CreditDays);
|
||||
|
||||
/// <summary>A customer's receivable position: unpaid balance and count of Posted invoices, and their latest payment.</summary>
|
||||
public sealed record CustomerReceivable(decimal OutstandingAmount, int OutstandingInvoiceCount, DateTime? LastPaymentDate);
|
||||
|
||||
/// <summary>
|
||||
/// Customer data access. Owns every query <see cref="Services.CustomerService"/> runs — customers, their
|
||||
/// receivables, and the Sales Configuration switches for optional fields — so the service holds only the
|
||||
/// business rules (unique code / email, field gating, concurrency) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface ICustomerRepository : IRepository<Customer>
|
||||
{
|
||||
/// <summary>One page of customers, name-ordered, after <paramref name="criteria"/>. Read-only.</summary>
|
||||
Task<(IReadOnlyList<Customer> Rows, int Total)> ListPagedAsync(
|
||||
CustomerListCriteria criteria, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Receivable position for each of these customers; customers with nothing owed / paid are absent.</summary>
|
||||
Task<IReadOnlyDictionary<int, CustomerReceivable>> GetReceivablesAsync(IReadOnlyCollection<int> customerIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A customer for display, not tracked — use <see cref="IRepository{T}.GetByIdAsync"/> to change one.</summary>
|
||||
Task<Customer?> GetForReadAsync(int customerId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when another customer already has this code (case-insensitive), ignoring <paramref name="excludeCustomerId"/>.</summary>
|
||||
Task<bool> CodeExistsAsync(string code, int? excludeCustomerId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when another customer already has this email (case-insensitive), ignoring <paramref name="excludeCustomerId"/>.</summary>
|
||||
Task<bool> EmailExistsAsync(string email, int? excludeCustomerId = null, CancellationToken ct = default);
|
||||
|
||||
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The optional-field switches from Sales Configuration, or null when its row is missing.</summary>
|
||||
Task<CustomerFieldSwitches?> GetFieldSwitchesAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>A Posted sales slip in the window (slim projection).</summary>
|
||||
public sealed record DayEndSlipRow(int SalesSlipId, string SlipNo, DateTime SlipDate, string CustomerName, decimal GrandTotal, decimal PaidAmount, decimal CashAmount, decimal CardAmount);
|
||||
|
||||
/// <summary>A Posted bundle sale in the window (slim projection). Bundle sales are always fully paid.</summary>
|
||||
public sealed record DayEndBundleRow(int BundleSaleId, string BundleNo, DateTime BundleDate, string CustomerName, decimal GrandTotal, decimal CashAmount, decimal CardAmount);
|
||||
|
||||
/// <summary>A Posted sales invoice in the window (slim projection).</summary>
|
||||
public sealed record DayEndInvoiceRow(int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, string CustomerName, decimal NetPayable, decimal PaidAmount);
|
||||
|
||||
/// <summary>A payment on a Posted invoice, with that invoice's date.</summary>
|
||||
public sealed record DayEndPaymentRow(int SalesInvoiceId, DateTime InvoiceDate, DateTime PaymentDate, SalesPaymentMethod PaymentMethod, decimal Amount);
|
||||
|
||||
/// <summary>A Posted expense, with its category and title names.</summary>
|
||||
public sealed record DayEndExpenseRow(int ExpenseId, string DocNo, DateOnly ExpenseDate, string CategoryName, string TitleName, string? Payee, decimal Amount, decimal PaidAmount);
|
||||
|
||||
/// <summary>A Posted bank deposit.</summary>
|
||||
public sealed record DayEndDepositRow(int BankDepositId, string DocNo, string ToBankAccountName, string? ToBankName, string? ToAccountNumber, string? DepositSlipNo, DateOnly DepositDate, decimal Amount);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only queries behind <see cref="Services.DayEndReportService"/>. Every method returns slim rows; the service
|
||||
/// buckets them into local business days and works out the totals and profit (00-CORE §4).
|
||||
/// <para>Instant windows are UTC <c>[start, end)</c>; date ranges are inclusive. <c>branchId</c> null = every branch.</para>
|
||||
/// </summary>
|
||||
public interface IDayEndReportRepository
|
||||
{
|
||||
Task<IReadOnlyList<DayEndSlipRow>> ListPostedSlipsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<DayEndBundleRow>> ListPostedBundlesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<DayEndInvoiceRow>> ListPostedInvoicesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Every payment that matters to the window: those against invoices dated inside it (the method split of Paid),
|
||||
/// and those received inside it against earlier invoices (collections / net cash).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DayEndPaymentRow>> ListInvoicePaymentsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sum of the stock ledger's Out value per document of <paramref name="docType"/> — the FIFO cost each sale consumed.</summary>
|
||||
Task<IReadOnlyDictionary<int, decimal>> GetCostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Posted expenses dated <paramref name="from"/>–<paramref name="to"/>, by expense id.</summary>
|
||||
Task<IReadOnlyList<DayEndExpenseRow>> ListPostedExpensesAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Posted bank deposits for one business date, by deposit id.</summary>
|
||||
Task<IReadOnlyList<DayEndDepositRow>> ListPostedDepositsAsync(DateOnly businessDate, int? branchId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Expense payment data access (docs/EXPENSE_MODULE_PLAN.md §4). Owns the queries
|
||||
/// <see cref="Services.ExpensePaymentService"/> runs itself; loading the expense being paid stays with
|
||||
/// <see cref="Services.ExpenseService.LoadAsync"/>, which the payment service shares.
|
||||
/// </summary>
|
||||
public interface IExpensePaymentRepository : IRepository<ExpensePayment>
|
||||
{
|
||||
Task<bool> ExpenseExistsAsync(int expenseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The expense's payment rows, newest first. Read-only.</summary>
|
||||
Task<IReadOnlyList<ExpensePayment>> ListForExpenseAsync(int expenseId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Expenses;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>A Posted expense in the summary window, with its category and title names.</summary>
|
||||
public sealed record ExpenseSummaryRow(
|
||||
int ExpenseCategoryId, string CategoryName, int CategorySort,
|
||||
int ExpenseTitleId, string TitleName, decimal Amount, decimal PaidAmount, decimal BalanceAmount);
|
||||
|
||||
/// <summary>
|
||||
/// Expense data access (docs/EXPENSE_MODULE_PLAN.md §4). Owns every query <see cref="Services.ExpenseService"/>
|
||||
/// runs, so the service holds only the business rules (draft-only edits, title/category checks, GL accrual and
|
||||
/// reversal) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IExpenseRepository : IRepository<Expense>
|
||||
{
|
||||
/// <summary>
|
||||
/// One page of expenses (with category and title), newest expense date first, after the search and
|
||||
/// <paramref name="filter"/>. "Overdue" means Posted, with a balance, due before <paramref name="today"/>. Read-only.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<Expense> Rows, int Total)> ListPagedAsync(
|
||||
string? search, ExpenseListFilter filter, DateOnly today, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>An expense with its category, title, vendor and payments; tracked or read-only.</summary>
|
||||
Task<Expense?> GetWithDetailsAsync(int expenseId, bool tracking, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// An expense title with its category, tracked on purpose: an update assigns it to the tracked expense's
|
||||
/// navigations, and identity resolution hands back the instance the expense already loaded.
|
||||
/// </summary>
|
||||
Task<ExpenseTitle?> GetTitleWithCategoryAsync(int expenseTitleId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The vendor's name, or null when it doesn't exist.</summary>
|
||||
Task<string?> GetVendorNameAsync(int vendorId, CancellationToken ct = default);
|
||||
|
||||
// --- Summary report --------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Posted expenses dated <paramref name="from"/>–<paramref name="to"/> (inclusive).</summary>
|
||||
Task<IReadOnlyList<ExpenseSummaryRow>> ListPostedInRangeAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Every Posted expense with a balance still outstanding, as (balance, due date).</summary>
|
||||
Task<IReadOnlyList<(decimal BalanceAmount, DateOnly? DueDate)>> ListOutstandingAsync(CancellationToken ct = default);
|
||||
|
||||
Task<int> CountDraftsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Expense category + title data access. Owns every query <see cref="Services.ExpenseSetupService"/> runs,
|
||||
/// so the service holds only the setup rules (system rows keep their names, unique names, next sort order) —
|
||||
/// 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IExpenseSetupRepository : IRepository<ExpenseCategory>
|
||||
{
|
||||
// --- Categories ------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Categories with their titles, by sort order then name; only Active categories unless <paramref name="includeInactive"/>. Read-only.</summary>
|
||||
Task<IReadOnlyList<ExpenseCategory>> ListWithTitlesAsync(bool includeInactive, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A category with its titles, tracked so changes are saved.</summary>
|
||||
Task<ExpenseCategory?> GetWithTitlesForUpdateAsync(int expenseCategoryId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> ExistsAsync(int expenseCategoryId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when another category already has exactly this name.</summary>
|
||||
Task<bool> CategoryNameExistsAsync(string name, int excludeCategoryId, CancellationToken ct = default);
|
||||
|
||||
// --- Titles ----------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>A title, tracked so changes are saved.</summary>
|
||||
Task<ExpenseTitle?> GetTitleForUpdateAsync(int expenseTitleId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when the category already has a title with exactly this name, ignoring <paramref name="excludeTitleId"/>.</summary>
|
||||
Task<bool> TitleNameExistsAsync(int expenseCategoryId, string name, int? excludeTitleId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The highest title sort order in the category, or 0 when it has none.</summary>
|
||||
Task<int> MaxTitleSortOrderAsync(int expenseCategoryId, CancellationToken ct = default);
|
||||
|
||||
Task AddTitleAsync(ExpenseTitle title, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>Filters for <see cref="IGrnRepository.ListPagedAsync"/>. Created bounds are UTC: <c>CreatedFrom</c> inclusive, <c>CreatedBefore</c> exclusive.</summary>
|
||||
public sealed record GrnListCriteria(
|
||||
string? Search, GrnStatus? Status, int? PoId, int? VendorId, int? WarehouseId, int? BranchId,
|
||||
DateTime? CreatedFrom, DateTime? CreatedBefore, string? Sort);
|
||||
|
||||
/// <summary>One GRN list row (slim projection) with its line count, total and linked purchase invoice.</summary>
|
||||
public sealed record GrnListRow(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
||||
|
||||
/// <summary>
|
||||
/// Goods-receipt data access. Owns every query <see cref="Services.GrnService"/> runs — GRNs, the PO they
|
||||
/// receive against, and the item / batch / serial / stock reads a receipt needs — so the service holds only the
|
||||
/// receiving rules (tolerance, costing, tracking, GL posting) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IGrnRepository : IRepository<Grn>
|
||||
{
|
||||
// --- GRNs ------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// One page of GRNs after <paramref name="criteria"/>, sorted server-side so paging stays correct:
|
||||
/// <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading <c>-</c> for descending; default
|
||||
/// (and any unknown key) is newest first, GrnId the tiebreaker. The search matches the doc no., the PO's doc no.,
|
||||
/// or the vendor's name / code. Read-only.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<GrnListRow> Rows, int Total)> ListPagedAsync(GrnListCriteria criteria, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A GRN with its lines (warranty numbers, serials, batch) and purchase invoice, not tracked.</summary>
|
||||
Task<Grn?> GetWithDetailsAsync(int grnId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A GRN with its lines and their captured serials, tracked — for confirming.</summary>
|
||||
Task<Grn?> GetForConfirmAsync(int grnId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A GRN with its lines, tracked — for releasing holds.</summary>
|
||||
Task<Grn?> GetWithLinesForUpdateAsync(int grnId, CancellationToken ct = default);
|
||||
|
||||
// --- Purchase orders -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>A PO with its lines, not tracked; null when it doesn't exist.</summary>
|
||||
Task<PurchaseOrder?> GetPoWithLinesAsync(int poId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A PO with its lines, tracked so its status can be updated.</summary>
|
||||
Task<PurchaseOrder?> GetPoWithLinesForUpdateAsync(int poId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A PO line, tracked so its received quantity can be updated.</summary>
|
||||
Task<PoLine?> GetPoLineForUpdateAsync(int poLineId, CancellationToken ct = default);
|
||||
|
||||
Task<PurchaseOrderStatus?> GetPoStatusAsync(int poId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Doc no. of each of these POs.</summary>
|
||||
Task<IReadOnlyDictionary<int, string>> GetPoDocNosAsync(IReadOnlyCollection<int> poIds, CancellationToken ct = default);
|
||||
|
||||
// --- Lookups ---------------------------------------------------------------------------------
|
||||
|
||||
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> VendorExistsAsync(int vendorId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Each of these vendors, by id.</summary>
|
||||
Task<IReadOnlyDictionary<int, Vendor>> GetVendorsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>An item, not tracked; null when it doesn't exist.</summary>
|
||||
Task<Item?> GetItemAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> BinIsInWarehouseAsync(int binId, int warehouseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The tax rate's percentage; throws when it doesn't exist (an item only points at real rates).</summary>
|
||||
Task<decimal> GetTaxPercentageAsync(int taxRateId, CancellationToken ct = default);
|
||||
|
||||
// --- Batches & serials -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>The item's batch with this number, tracked; null when there is none yet.</summary>
|
||||
Task<Batch?> FindBatchAsync(int itemId, string batchNo, CancellationToken ct = default);
|
||||
|
||||
Task AddBatchAsync(Batch batch, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Up to <paramref name="take"/> of these serial numbers that already exist for the item.</summary>
|
||||
Task<IReadOnlyList<string>> ListTakenSerialsAsync(int itemId, IReadOnlyCollection<string> serialNos, int take, CancellationToken ct = default);
|
||||
|
||||
Task AddSerialAsync(Serial serial, CancellationToken ct = default);
|
||||
|
||||
Task AddSerialMovementAsync(SerialMovement movement, CancellationToken ct = default);
|
||||
|
||||
// --- Confirm result (idempotent replay) ------------------------------------------------------
|
||||
|
||||
/// <summary>The FIFO layers created for these GRN lines.</summary>
|
||||
Task<IReadOnlyList<StockLayer>> ListLayersForLinesAsync(IReadOnlyCollection<int> grnLineIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Ids of the GRN's inbound stock-ledger rows.</summary>
|
||||
Task<IReadOnlyList<int>> ListInboundLedgerIdsAsync(int grnId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Item photo data access. Owns every query <see cref="Services.ItemImageService"/> runs, so the service holds only
|
||||
/// the upload rules (allowed types, size and count caps, config gate) and the file storage calls — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IItemImageRepository : IRepository<ItemImage>
|
||||
{
|
||||
Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The item's images in display order (sort order, then id). Read-only.</summary>
|
||||
Task<IReadOnlyList<ItemImage>> ListForItemAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The sort order of each of the item's images (one per image), for the count cap and the next slot.</summary>
|
||||
Task<IReadOnlyList<int>> ListSortOrdersAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The image, only when it belongs to this item; tracked when it is to be changed or removed.</summary>
|
||||
Task<ItemImage?> GetForItemAsync(int itemId, int itemImageId, bool tracking, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>Filters shared by the item list and the item overview. <c>Search</c> matches SKU or name.</summary>
|
||||
public sealed record ItemListCriteria(
|
||||
string? Search, EntityStatus? Status, int? CategoryId, int? SubCategoryId, int? BrandId,
|
||||
TrackingMode? TrackingMode, StockNature? StockNature = null, ItemStockFilter? Stock = null);
|
||||
|
||||
/// <summary>A confirmed GRN line for the item: when it was posted and from which vendor.</summary>
|
||||
public sealed record ItemGrnPurchaseRow(int ItemId, DateTime PostedAt, int VendorId, string VendorName);
|
||||
|
||||
/// <summary>One confirmed purchase of the item, newest first, for the purchase-price history.</summary>
|
||||
public sealed record ItemPurchasePriceRow(
|
||||
int GrnId, string GrnDocNo, DateTime PostedAt, int VendorId, string? VendorName, string? WarehouseCode,
|
||||
decimal Qty, decimal UnitCost, decimal DiscountPct, decimal NetUnitCost, decimal VatPct);
|
||||
|
||||
/// <summary>
|
||||
/// Item master data access. Owns every query <see cref="Services.ItemService"/> runs — items, their stock and
|
||||
/// purchase / sales figures, the masters an item points at, and code scanning — so the service holds only the
|
||||
/// item rules (uniqueness, config gating, warranty, frozen base UOM, price approval) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IItemRepository : IRepository<Item>
|
||||
{
|
||||
// --- Lists -----------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>One page of items (with tax rate) by SKU, after <paramref name="criteria"/>. Read-only.</summary>
|
||||
Task<(IReadOnlyList<Item> Rows, int Total)> ListPagedAsync(ItemListCriteria criteria, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// One page of items (with reorder settings) by SKU, after <paramref name="criteria"/> — including the stock-state
|
||||
/// filter, applied as a subquery before paging so totals and pages stay correct. Read-only.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<Item> Rows, int Total)> ListOverviewPagedAsync(ItemListCriteria criteria, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Current FIFO stock (qty, value) per item over every open layer and warehouse (negative = sold beyond stock).</summary>
|
||||
Task<IReadOnlyDictionary<int, (decimal Qty, decimal Value)>> GetStockByItemAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>On-hand per (item, warehouse).</summary>
|
||||
Task<IReadOnlyDictionary<(int ItemId, int WarehouseId), decimal>> GetOnHandByItemWarehouseAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Every confirmed GRN line of these items, with its post time and vendor.</summary>
|
||||
Task<IReadOnlyList<ItemGrnPurchaseRow>> ListConfirmedPurchasesAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Qty that left stock on these document types since <paramref name="since"/>, per item (stock ledger Out rows).</summary>
|
||||
Task<IReadOnlyDictionary<int, decimal>> GetQtyOutSinceAsync(
|
||||
IReadOnlyCollection<int> itemIds, DateTime since, IReadOnlyCollection<string> docTypes, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The item's latest confirmed purchases, newest first.</summary>
|
||||
Task<IReadOnlyList<ItemPurchasePriceRow>> ListPurchasePricesAsync(int itemId, int take, CancellationToken ct = default);
|
||||
|
||||
// --- Single item -----------------------------------------------------------------------------
|
||||
|
||||
Task<bool> ExistsAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>An item with its reorder settings and tax rate; tracked when it is to be changed.</summary>
|
||||
Task<Item?> GetWithDetailsAsync(int itemId, bool tracking, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when another item already has this SKU, ignoring <paramref name="excludeItemId"/>.</summary>
|
||||
Task<bool> SkuExistsAsync(string sku, int? excludeItemId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when another item already has this barcode, ignoring <paramref name="excludeItemId"/>.</summary>
|
||||
Task<bool> BarcodeExistsAsync(string barcode, int? excludeItemId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Whether anything has ever been recorded against this item's stock — live layers or the ledger, because a
|
||||
/// fully consumed item has no layer left but its history still reads in the old unit.
|
||||
/// </summary>
|
||||
Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Settings → Approvals price-adjustment levels, or null when that row is missing.</summary>
|
||||
Task<int?> GetPriceAdjustmentLevelsAsync(CancellationToken ct = default);
|
||||
|
||||
// --- Referenced masters ----------------------------------------------------------------------
|
||||
|
||||
Task<bool> CategoryExistsAsync(int categoryId, CancellationToken ct = default);
|
||||
|
||||
Task<SubCategory?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default);
|
||||
|
||||
Task<Brand?> GetBrandAsync(int brandId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> UomExistsAsync(int uomId, CancellationToken ct = default);
|
||||
|
||||
Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default);
|
||||
|
||||
Task<TaxRate?> GetTaxRateAsync(int taxRateId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
// --- Scanning (each result carries its Item) -------------------------------------------------
|
||||
|
||||
/// <summary>An item whose barcode or SKU is <paramref name="code"/>.</summary>
|
||||
Task<Item?> FindItemByCodeAsync(string code, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A batch whose barcode or batch no. is <paramref name="code"/>, limited to one item when <paramref name="itemId"/> is given.</summary>
|
||||
Task<Batch?> FindBatchByCodeAsync(string code, int? itemId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A serial whose barcode or serial no. is <paramref name="code"/>, limited to one item when <paramref name="itemId"/> is given.</summary>
|
||||
Task<Serial?> FindSerialByCodeAsync(string code, int? itemId = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Item type data access (FR-MD-10). Owns every query <see cref="Services.ItemTypeService"/> runs, so the service
|
||||
/// holds only the business rules (name uniqueness, concurrency) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IItemTypeRepository : IRepository<ItemType>
|
||||
{
|
||||
/// <summary>One page of item types, name-ordered, optionally filtered by a name search and status. Read-only.</summary>
|
||||
Task<(IReadOnlyList<ItemType> Rows, int Total)> ListPagedAsync(
|
||||
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>An item type for display, not tracked — use <see cref="IRepository{T}.GetByIdAsync"/> to change one.</summary>
|
||||
Task<ItemType?> GetForReadAsync(int itemTypeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when another item type already has this name (case-insensitive), ignoring <paramref name="excludeItemTypeId"/>.</summary>
|
||||
Task<bool> NameExistsAsync(string name, int? excludeItemTypeId = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Document-number data access behind <see cref="Services.NumberSequenceService"/>: the branch code that goes into
|
||||
/// a number, and the atomic counter bump itself. The service owns the number format — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface INumberSequenceRepository
|
||||
{
|
||||
/// <summary>The branch's code, or null when the branch doesn't exist.</summary>
|
||||
Task<string?> GetBranchCodeAsync(int branchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically issues the next number for (<paramref name="docType"/>, <paramref name="branchId"/>,
|
||||
/// <paramref name="year"/>) — 1 for a new key — enlisted in the DbContext's current transaction when there is one,
|
||||
/// so it commits or rolls back with the document. Safe to call outside a transaction as well.
|
||||
/// </summary>
|
||||
Task<long> IncrementAsync(string docType, int branchId, int year, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Opening-stock data access. Owns every query <see cref="Services.OpeningStockService"/> runs and the rows it
|
||||
/// writes directly (stock ledger, journal stubs) — the service keeps the opening rules and the GL posting — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IOpeningStockRepository : IRepository<OpeningBalanceRun>
|
||||
{
|
||||
/// <summary>Every opening-stock run (newest first) with its line count. Read-only.</summary>
|
||||
Task<IReadOnlyList<(OpeningBalanceRun Run, int LineCount)>> ListStockRunsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>An opening-stock run with its lines (and their item / warehouse), not tracked; null when not found or not a stock run.</summary>
|
||||
Task<OpeningBalanceRun?> GetStockRunAsync(int runId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Each of these items, by id. Read-only.</summary>
|
||||
Task<IReadOnlyDictionary<int, Item>> GetItemsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Each of these warehouses, by id. Read-only.</summary>
|
||||
Task<IReadOnlyDictionary<int, Warehouse>> GetWarehousesAsync(IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The distinct (item, warehouse) pairs among these that already have any stock-ledger movement.</summary>
|
||||
Task<IReadOnlyList<(int ItemId, int WarehouseId)>> ListMovedPairsAsync(
|
||||
IReadOnlyCollection<int> itemIds, IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default);
|
||||
|
||||
Task AddLedgerAsync(StockLedger row, CancellationToken ct = default);
|
||||
|
||||
Task AddJournalStubAsync(JournalEntryStub stub, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>One price-adjustment list row (slim projection) with its line count and creator's name.</summary>
|
||||
public sealed record PriceAdjustmentListRow(
|
||||
int PriceAdjustmentId, string DocNo, string Reason, PriceAdjustmentStatus Status, int RequiredApprovals,
|
||||
int LineCount, int CreatedBy, string? CreatorName, DateTime CreatedAt, DateTime? AppliedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Price-adjustment data access (docs/DUAL_APPROVAL_PLAN.md §6). Owns every query
|
||||
/// <see cref="Services.PriceAdjustmentService"/> runs — requests, the items they reprice, price history and the
|
||||
/// names shown beside it — so the service holds only the approval and repricing rules — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IPriceAdjustmentRepository : IRepository<PriceAdjustment>
|
||||
{
|
||||
/// <summary>One page of requests, newest first, optionally filtered by doc no. / reason, status and an item on them. Read-only.</summary>
|
||||
Task<(IReadOnlyList<PriceAdjustmentListRow> Rows, int Total)> ListPagedAsync(
|
||||
string? search, PriceAdjustmentStatus? status, int? itemId, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A request with its lines (and their items) and creator, not tracked.</summary>
|
||||
Task<PriceAdjustment?> GetWithDetailsAsync(int priceAdjustmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A request with its lines, tracked — for deciding on it.</summary>
|
||||
Task<PriceAdjustment?> GetForDecisionAsync(int priceAdjustmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Each of these items, by id, tracked so their prices can be changed.</summary>
|
||||
Task<IReadOnlyDictionary<int, Item>> GetItemsForUpdateAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>For each of these items that sits on a pending (level 1 or 2) request: the item and that request's doc no.</summary>
|
||||
Task<IReadOnlyList<(int ItemId, string DocNo)>> ListPendingRequestsForItemsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
|
||||
|
||||
Task AddHistoryAsync(ItemPriceHistory history, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The item's applied price changes, newest first. Read-only.</summary>
|
||||
Task<IReadOnlyList<ItemPriceHistory>> ListItemHistoryAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Display name of each of these users.</summary>
|
||||
Task<IReadOnlyDictionary<int, string>> GetUserNamesAsync(IReadOnlyCollection<int> userIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Doc no. of each of these requests.</summary>
|
||||
Task<IReadOnlyDictionary<int, string>> GetDocNosAsync(IReadOnlyCollection<int> priceAdjustmentIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Settings → Approvals price-adjustment levels, or null when that row is missing.</summary>
|
||||
Task<int?> GetPriceAdjustmentLevelsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Procurement configuration data access — the singleton row seeded by DataSeeder. Owns the queries
|
||||
/// <see cref="Services.ProcurementConfigService"/> runs (00-CORE §4).
|
||||
/// </summary>
|
||||
public interface IProcurementConfigRepository : IRepository<ProcurementConfig>
|
||||
{
|
||||
/// <summary>The singleton row, not tracked; null when it hasn't been seeded. Use <see cref="IRepository{T}.GetByIdAsync"/> to change it.</summary>
|
||||
Task<ProcurementConfig?> GetForReadAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration data access (FR-MD-11) — the singleton row seeded by DataSeeder. Owns the queries
|
||||
/// <see cref="Services.ProductConfigService"/> runs (00-CORE §4).
|
||||
/// </summary>
|
||||
public interface IProductConfigRepository : IRepository<ProductConfig>
|
||||
{
|
||||
/// <summary>The singleton row, not tracked; null when it hasn't been seeded. Use <see cref="IRepository{T}.GetByIdAsync"/> to change it.</summary>
|
||||
Task<ProductConfig?> GetForReadAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor payment data access. Owns every query <see cref="Services.PurchaseInvoicePaymentService"/> runs, so the
|
||||
/// service holds only the payment rules and the GL posting — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IPurchaseInvoicePaymentRepository : IRepository<PurchaseInvoicePayment>
|
||||
{
|
||||
Task<bool> InvoiceExistsAsync(int purchaseInvoiceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The purchase invoice, tracked so its paid / balance amounts can be updated.</summary>
|
||||
Task<PurchaseInvoice?> GetInvoiceForUpdateAsync(int purchaseInvoiceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The invoice's payments, newest first. Read-only.</summary>
|
||||
Task<IReadOnlyList<PurchaseInvoicePayment>> ListForInvoiceAsync(int purchaseInvoiceId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>A confirmed, not-yet-invoiced GRN of a vendor, with its line totals.</summary>
|
||||
public sealed record EligibleGrnRow(
|
||||
int GrnId, string DocNo, DateTime ReceivedAt, int WarehouseId, int LineCount,
|
||||
decimal Subtotal, decimal VatTotal, decimal GrandTotal);
|
||||
|
||||
/// <summary>
|
||||
/// Purchase invoice data access. Owns every query <see cref="Services.PurchaseInvoiceService"/> runs — invoices,
|
||||
/// the GRNs they consolidate, and the vendor — so the service holds only the invoicing rules and the GL posting
|
||||
/// (00-CORE §4).
|
||||
/// </summary>
|
||||
public interface IPurchaseInvoiceRepository : IRepository<PurchaseInvoice>
|
||||
{
|
||||
/// <summary>One page of invoices (with lines), newest first, optionally filtered by doc no. / vendor invoice no. / vendor name, status and vendor. Read-only.</summary>
|
||||
Task<(IReadOnlyList<PurchaseInvoice> Rows, int Total)> ListPagedAsync(
|
||||
string? search, PurchaseInvoiceStatus? status, int? vendorId, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>An invoice with its lines (and their GRNs); tracked when it is to be changed.</summary>
|
||||
Task<PurchaseInvoice?> GetWithLinesAsync(int purchaseInvoiceId, bool tracking, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The vendor, not tracked; null when it doesn't exist.</summary>
|
||||
Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when a non-cancelled invoice of this vendor already carries this vendor invoice no., ignoring <paramref name="excludePurchaseInvoiceId"/>.</summary>
|
||||
Task<bool> VendorInvoiceNoExistsAsync(int vendorId, string vendorInvoiceNo, int? excludePurchaseInvoiceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>These GRNs with their lines, tracked so they can be linked to / freed from an invoice.</summary>
|
||||
Task<IReadOnlyList<Grn>> ListGrnsForInvoicingAsync(IReadOnlyCollection<int> grnIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The vendor's confirmed GRNs not yet on an invoice, oldest receipt first.</summary>
|
||||
Task<IReadOnlyList<EligibleGrnRow>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>Filters for <see cref="IPurchaseOrderRepository.ListPagedAsync"/>. Created bounds are UTC: <c>CreatedFrom</c> inclusive, <c>CreatedBefore</c> exclusive.</summary>
|
||||
public sealed record PurchaseOrderListCriteria(
|
||||
string? Search, PurchaseOrderStatus? Status, int? VendorId, DateTime? CreatedFrom, DateTime? CreatedBefore, string? Sort);
|
||||
|
||||
/// <summary>
|
||||
/// Purchase-order data access. Owns every query <see cref="Services.PurchaseOrderService"/> runs — POs, their
|
||||
/// vendor, and the references a PO line points at — so the service holds only the PO rules (approval flow,
|
||||
/// editability, one branch per PO, tax gating) — 00-CORE §4.
|
||||
/// </summary>
|
||||
public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
|
||||
{
|
||||
/// <summary>
|
||||
/// One page of POs (with lines) after <paramref name="criteria"/>, sorted server-side so paging stays correct:
|
||||
/// <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading <c>-</c> for descending; default (and any
|
||||
/// unknown key) is newest first, PoId the tiebreaker. The search matches the doc no. or the vendor's name / code. Read-only.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<PurchaseOrder> Rows, int Total)> ListPagedAsync(PurchaseOrderListCriteria criteria, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A PO with its lines; tracked when it is to be changed.</summary>
|
||||
Task<PurchaseOrder?> GetWithLinesAsync(int poId, bool tracking, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The vendor, not tracked; null when it doesn't exist.</summary>
|
||||
Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Each of these vendors, by id. Read-only.</summary>
|
||||
Task<IReadOnlyDictionary<int, Vendor>> GetVendorsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken ct = default);
|
||||
|
||||
Task<bool> RequisitionExistsAsync(int requisitionId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Which of these item ids exist.</summary>
|
||||
Task<IReadOnlyList<int>> ListExistingItemIdsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Which of these warehouse ids exist.</summary>
|
||||
Task<IReadOnlyList<int>> ListExistingWarehouseIdsAsync(IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Which of these warehouses belong to a branch other than <paramref name="branchId"/>.</summary>
|
||||
Task<IReadOnlyList<int>> ListWarehousesOutsideBranchAsync(IReadOnlyCollection<int> warehouseIds, int branchId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Repositories.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase-return data access (FR-PROC-08). Owns every query <see cref="Services.PurchaseReturnService"/> runs —
|
||||
/// returns, their ledger rows, the references a return names, and the rejected GRN-line stock it can release —
|
||||
/// so the service holds only the return rules, the stock movement and the GL posting (00-CORE §4).
|
||||
/// </summary>
|
||||
public interface IPurchaseReturnRepository : IRepository<PurchaseReturn>
|
||||
{
|
||||
/// <summary>One page of returns (newest first) with each one's line count, optionally filtered by doc no., vendor and warehouse. Read-only.</summary>
|
||||
Task<(IReadOnlyList<(PurchaseReturn Return, int LineCount)> Rows, int Total)> ListPagedAsync(
|
||||
string? search, int? vendorId, int? warehouseId, int skip, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A return with its lines, not tracked.</summary>
|
||||
Task<PurchaseReturn?> GetWithLinesAsync(int returnId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The return's stock-ledger row ids, in posting order (polymorphic source-doc reference, docs/10 C.9).</summary>
|
||||
Task<IReadOnlyList<int>> ListLedgerIdsAsync(int returnId, CancellationToken ct = default);
|
||||
|
||||
// --- References ------------------------------------------------------------------------------
|
||||
|
||||
Task<bool> VendorExistsAsync(int vendorId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<ReasonCode?> GetReasonCodeAsync(int reasonCodeId, CancellationToken ct = default);
|
||||
|
||||
Task<Item?> GetItemAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>A GRN line, not tracked; null when it doesn't exist.</summary>
|
||||
Task<GrnLine?> GetGrnLineAsync(int grnLineId, CancellationToken ct = default);
|
||||
|
||||
// --- Rejected GRN-line stock -----------------------------------------------------------------
|
||||
|
||||
/// <summary>Quantity still sitting in the GRN line's own FIFO layer(s).</summary>
|
||||
Task<decimal> SumRemainingOnGrnLineAsync(int grnLineId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The GRN line's layers with stock left, oldest first, tracked so they can be consumed.</summary>
|
||||
Task<List<StockLayer>> ListOpenLayersForGrnLineAsync(int grnLineId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>These serials, tracked so their status can be moved.</summary>
|
||||
Task<IReadOnlyList<Serial>> ListSerialsAsync(IReadOnlyCollection<int> serialIds, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IItemImageRepository"/>.</summary>
|
||||
public sealed class ItemImageRepository : Repository<ItemImage>, IItemImageRepository
|
||||
{
|
||||
public ItemImageRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default)
|
||||
=> Db.Set<Item>().AnyAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<ItemImage>> ListForItemAsync(int itemId, CancellationToken ct = default)
|
||||
=> await Set.AsNoTracking()
|
||||
.Where(i => i.ItemId == itemId)
|
||||
.OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<int>> ListSortOrdersAsync(int itemId, CancellationToken ct = default)
|
||||
=> await Set.Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
|
||||
|
||||
public Task<ItemImage?> GetForItemAsync(int itemId, int itemImageId, bool tracking, CancellationToken ct = default)
|
||||
{
|
||||
var q = tracking ? Set : Set.AsNoTracking();
|
||||
return q.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IItemRepository"/>.</summary>
|
||||
public sealed class ItemRepository : Repository<Item>, IItemRepository
|
||||
{
|
||||
public ItemRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private DbSet<StockLayer> Layers => Db.Set<StockLayer>();
|
||||
|
||||
// --- Lists -----------------------------------------------------------------------------------
|
||||
|
||||
private IQueryable<Item> Filtered(ItemListCriteria c)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(c.Search))
|
||||
{
|
||||
var term = c.Search.Trim();
|
||||
q = q.Where(i => EF.Functions.Like(i.Sku, $"%{term}%") || EF.Functions.Like(i.Name, $"%{term}%"));
|
||||
}
|
||||
if (c.Status is not null) q = q.Where(i => i.Status == c.Status);
|
||||
if (c.CategoryId is not null) q = q.Where(i => i.CategoryId == c.CategoryId);
|
||||
if (c.SubCategoryId is not null) q = q.Where(i => i.SubCategoryId == c.SubCategoryId);
|
||||
if (c.BrandId is not null) q = q.Where(i => i.BrandId == c.BrandId);
|
||||
if (c.TrackingMode is not null) q = q.Where(i => i.TrackingMode == c.TrackingMode);
|
||||
if (c.StockNature is not null) q = q.Where(i => i.StockNature == c.StockNature);
|
||||
|
||||
var layers = Layers;
|
||||
switch (c.Stock)
|
||||
{
|
||||
case ItemStockFilter.InStock:
|
||||
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
|
||||
break;
|
||||
case ItemStockFilter.OutOfStock:
|
||||
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
|
||||
break;
|
||||
case ItemStockFilter.LowStock:
|
||||
q = q.Where(i => i.ReorderSettings.Any(rs =>
|
||||
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
|
||||
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
|
||||
break;
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<Item> Rows, int Total)> ListPagedAsync(
|
||||
ItemListCriteria criteria, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Filtered(criteria);
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.Include(i => i.TaxRate).OrderBy(i => i.Sku).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<Item> Rows, int Total)> ListOverviewPagedAsync(
|
||||
ItemListCriteria criteria, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Filtered(criteria);
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.Include(i => i.ReorderSettings).OrderBy(i => i.Sku).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, (decimal Qty, decimal Value)>> GetStockByItemAsync(
|
||||
IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
return (await Layers.AsNoTracking()
|
||||
.Where(l => ids.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => x.ItemId, x => (x.Qty, x.Value));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<(int ItemId, int WarehouseId), decimal>> GetOnHandByItemWarehouseAsync(
|
||||
IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
return (await Layers.AsNoTracking()
|
||||
.Where(l => ids.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ItemGrnPurchaseRow>> ListConfirmedPurchasesAsync(
|
||||
IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
return await Db.Set<GrnLine>().AsNoTracking()
|
||||
.Where(l => ids.Contains(l.ItemId) && l.Grn!.PostedAt != null)
|
||||
.Select(l => new ItemGrnPurchaseRow(l.ItemId, l.Grn!.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, decimal>> GetQtyOutSinceAsync(
|
||||
IReadOnlyCollection<int> itemIds, DateTime since, IReadOnlyCollection<string> docTypes, CancellationToken ct = default)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
var types = docTypes.ToList();
|
||||
return await Db.Set<StockLedger>().AsNoTracking()
|
||||
.Where(l => ids.Contains(l.ItemId) && l.Direction == Direction.Out
|
||||
&& l.CreatedAt >= since && types.Contains(l.SourceDocType))
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ItemPurchasePriceRow>> ListPurchasePricesAsync(int itemId, int take, CancellationToken ct = default)
|
||||
=> await Db.Set<GrnLine>().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.Grn!.PostedAt != null)
|
||||
.OrderByDescending(l => l.Grn!.PostedAt).ThenByDescending(l => l.GrnLineId)
|
||||
.Take(take)
|
||||
.Select(l => new ItemPurchasePriceRow(
|
||||
l.GrnId, l.Grn!.DocNo, l.Grn.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name, l.Grn.Warehouse!.Code,
|
||||
l.Qty, l.UnitCost, l.DiscountPct, l.NetUnitCost, l.VatPct))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// --- Single item -----------------------------------------------------------------------------
|
||||
|
||||
public Task<bool> ExistsAsync(int itemId, CancellationToken ct = default)
|
||||
=> Set.AnyAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
public Task<Item?> GetWithDetailsAsync(int itemId, bool tracking, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.Include(i => i.ReorderSettings).Include(i => i.TaxRate);
|
||||
return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||
}
|
||||
|
||||
public Task<bool> SkuExistsAsync(string sku, int? excludeItemId = null, CancellationToken ct = default)
|
||||
=> Set.AnyAsync(i => i.Sku == sku && (excludeItemId == null || i.ItemId != excludeItemId), ct);
|
||||
|
||||
public Task<bool> BarcodeExistsAsync(string barcode, int? excludeItemId = null, CancellationToken ct = default)
|
||||
=> Set.AnyAsync(i => i.Barcode == barcode && (excludeItemId == null || i.ItemId != excludeItemId), ct);
|
||||
|
||||
public async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct = default)
|
||||
=> await Layers.AnyAsync(l => l.ItemId == itemId, ct)
|
||||
|| await Db.Set<StockLedger>().AnyAsync(l => l.ItemId == itemId, ct);
|
||||
|
||||
public Task<int?> GetPriceAdjustmentLevelsAsync(CancellationToken ct = default)
|
||||
=> Db.Set<ApprovalConfig>().AsNoTracking()
|
||||
.Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
|
||||
.Select(c => (int?)c.PriceAdjustmentLevels)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// --- Referenced masters ----------------------------------------------------------------------
|
||||
|
||||
public Task<bool> CategoryExistsAsync(int categoryId, CancellationToken ct = default)
|
||||
=> Db.Set<Category>().AnyAsync(c => c.CategoryId == categoryId, ct);
|
||||
|
||||
public Task<SubCategory?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default)
|
||||
=> Db.Set<SubCategory>().AsNoTracking().FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||
|
||||
public Task<Brand?> GetBrandAsync(int brandId, CancellationToken ct = default)
|
||||
=> Db.Set<Brand>().AsNoTracking().FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||
|
||||
public Task<bool> UomExistsAsync(int uomId, CancellationToken ct = default)
|
||||
=> Db.Set<Uom>().AnyAsync(u => u.UomId == uomId, ct);
|
||||
|
||||
public Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default)
|
||||
=> Db.Set<Vendor>().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
|
||||
|
||||
public Task<TaxRate?> GetTaxRateAsync(int taxRateId, CancellationToken ct = default)
|
||||
=> Db.Set<TaxRate>().AsNoTracking().FirstOrDefaultAsync(t => t.TaxRateId == taxRateId, ct);
|
||||
|
||||
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
|
||||
|
||||
// --- Scanning --------------------------------------------------------------------------------
|
||||
|
||||
public Task<Item?> FindItemByCodeAsync(string code, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().FirstOrDefaultAsync(i => i.Barcode == code || i.Sku == code, ct);
|
||||
|
||||
public Task<Batch?> FindBatchByCodeAsync(string code, int? itemId = null, CancellationToken ct = default)
|
||||
=> Db.Set<Batch>().AsNoTracking().Include(b => b.Item)
|
||||
.FirstOrDefaultAsync(b => (itemId == null || b.ItemId == itemId) && (b.Barcode == code || b.BatchNo == code), ct);
|
||||
|
||||
public Task<Serial?> FindSerialByCodeAsync(string code, int? itemId = null, CancellationToken ct = default)
|
||||
=> Db.Set<Serial>().AsNoTracking().Include(s => s.Item)
|
||||
.FirstOrDefaultAsync(s => (itemId == null || s.ItemId == itemId) && (s.Barcode == code || s.SerialNo == code), ct);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IItemTypeRepository"/>.</summary>
|
||||
public sealed class ItemTypeRepository : Repository<ItemType>, IItemTypeRepository
|
||||
{
|
||||
public ItemTypeRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public async Task<(IReadOnlyList<ItemType> Rows, int Total)> ListPagedAsync(
|
||||
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(t => EF.Functions.Like(t.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(t => t.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(t => t.Name).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<ItemType?> GetForReadAsync(int itemTypeId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct);
|
||||
|
||||
public Task<bool> NameExistsAsync(string name, int? excludeItemTypeId = null, CancellationToken ct = default)
|
||||
{
|
||||
var lowered = name.ToLower();
|
||||
return Set.AnyAsync(t => t.Name.ToLower() == lowered && (excludeItemTypeId == null || t.ItemTypeId != excludeItemTypeId), ct);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using System.Data;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF / ADO.NET implementation of <see cref="INumberSequenceRepository"/>. The bump is a single
|
||||
/// <c>INSERT … ON DUPLICATE KEY UPDATE … RETURNING</c> so concurrent issues for the same (docType, branchId, year)
|
||||
/// cannot get the same number (the upsert holds an exclusive lock on the row until commit). It runs as a direct
|
||||
/// ADO.NET command enlisted in the DbContext's current transaction; EF's <c>SqlQuery</c> is avoided because it
|
||||
/// wraps the statement in a subquery, which is not valid for a data-modifying statement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MariaDB 10.5+ supports <c>RETURNING</c> on an upsert, so this stays a single atomic
|
||||
/// statement exactly as the PostgreSQL version was — which is what keeps the callers that
|
||||
/// run outside a UoW transaction safe. Do NOT "optimise" this into the one-statement
|
||||
/// <c>LAST_INSERT_ID()</c> trick: this table has an AUTO_INCREMENT surrogate key, so on the
|
||||
/// insert branch the engine's own auto-increment value clobbers the explicit one and the
|
||||
/// first document of a (docType, branch, year) would be numbered with the sequence table's
|
||||
/// PK. Splitting it into INSERT-then-SELECT is also wrong here, because the non-transacted
|
||||
/// callers would then race. Side note: the surrogate AUTO_INCREMENT burns a value on the
|
||||
/// duplicate path, so <c>sequence_id</c> has gaps — harmless, do not "fix" it.
|
||||
/// <c>ON DUPLICATE KEY UPDATE</c> fires on any unique-key collision; today the only unique
|
||||
/// keys are the never-supplied PK and IX(doc_type, branch_id, year), so it is unambiguous.
|
||||
/// Adding another unique index to this table would silently change which row gets updated.
|
||||
/// </remarks>
|
||||
public sealed class NumberSequenceRepository : INumberSequenceRepository
|
||||
{
|
||||
private readonly ErpDbContext _db;
|
||||
|
||||
public NumberSequenceRepository(ErpDbContext db) => _db = db;
|
||||
|
||||
public Task<string?> GetBranchCodeAsync(int branchId, CancellationToken ct = default)
|
||||
=> _db.Set<Branch>().AsNoTracking()
|
||||
.Where(b => b.BranchId == branchId).Select(b => (string?)b.Code).FirstOrDefaultAsync(ct);
|
||||
|
||||
public async Task<long> IncrementAsync(string docType, int branchId, int year, CancellationToken ct = default)
|
||||
{
|
||||
var conn = _db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open)
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO `number_sequences` (`doc_type`, `branch_id`, `year`, `last_number`)
|
||||
VALUES (@docType, @branchId, @year, 1)
|
||||
ON DUPLICATE KEY UPDATE `last_number` = `last_number` + 1
|
||||
RETURNING `last_number`;
|
||||
""";
|
||||
AddParam(cmd, "docType", docType);
|
||||
AddParam(cmd, "branchId", branchId);
|
||||
AddParam(cmd, "year", year);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct)
|
||||
?? throw new InvalidOperationException($"Failed to issue a document number for '{docType}'.");
|
||||
return Convert.ToInt64(result);
|
||||
}
|
||||
|
||||
private static void AddParam(IDbCommand cmd, string name, object value)
|
||||
{
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value;
|
||||
cmd.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IOpeningStockRepository"/>.</summary>
|
||||
public sealed class OpeningStockRepository : Repository<OpeningBalanceRun>, IOpeningStockRepository
|
||||
{
|
||||
public OpeningStockRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public async Task<IReadOnlyList<(OpeningBalanceRun Run, int LineCount)>> ListStockRunsAsync(CancellationToken ct = default)
|
||||
=> (await Set.AsNoTracking()
|
||||
.Where(r => r.Kind == OpeningBalanceKind.Stock)
|
||||
.OrderByDescending(r => r.OpeningBalanceRunId)
|
||||
.Select(r => new { Run = r, LineCount = r.StockLines.Count })
|
||||
.ToListAsync(ct))
|
||||
.Select(x => (x.Run, x.LineCount)).ToList();
|
||||
|
||||
public Task<OpeningBalanceRun?> GetStockRunAsync(int runId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking()
|
||||
.Include(r => r.StockLines).ThenInclude(l => l.Item)
|
||||
.Include(r => r.StockLines).ThenInclude(l => l.Warehouse)
|
||||
.FirstOrDefaultAsync(r => r.OpeningBalanceRunId == runId && r.Kind == OpeningBalanceKind.Stock, ct);
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, Item>> GetItemsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
return await Db.Set<Item>().AsNoTracking().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, Warehouse>> GetWarehousesAsync(IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = warehouseIds.ToList();
|
||||
return await Db.Set<Warehouse>().AsNoTracking().Where(w => ids.Contains(w.WarehouseId)).ToDictionaryAsync(w => w.WarehouseId, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<(int ItemId, int WarehouseId)>> ListMovedPairsAsync(
|
||||
IReadOnlyCollection<int> itemIds, IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default)
|
||||
{
|
||||
var items = itemIds.ToList();
|
||||
var warehouses = warehouseIds.ToList();
|
||||
return (await Db.Set<StockLedger>().AsNoTracking()
|
||||
.Where(l => items.Contains(l.ItemId) && warehouses.Contains(l.WarehouseId))
|
||||
.Select(l => new { l.ItemId, l.WarehouseId })
|
||||
.Distinct()
|
||||
.ToListAsync(ct))
|
||||
.Select(x => (x.ItemId, x.WarehouseId)).ToList();
|
||||
}
|
||||
|
||||
public async Task AddLedgerAsync(StockLedger row, CancellationToken ct = default)
|
||||
=> await Db.Set<StockLedger>().AddAsync(row, ct);
|
||||
|
||||
public async Task AddJournalStubAsync(JournalEntryStub stub, CancellationToken ct = default)
|
||||
=> await Db.Set<JournalEntryStub>().AddAsync(stub, ct);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IPriceAdjustmentRepository"/>.</summary>
|
||||
public sealed class PriceAdjustmentRepository : Repository<PriceAdjustment>, IPriceAdjustmentRepository
|
||||
{
|
||||
public PriceAdjustmentRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public async Task<(IReadOnlyList<PriceAdjustmentListRow> Rows, int Total)> ListPagedAsync(
|
||||
string? search, PriceAdjustmentStatus? status, int? itemId, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || EF.Functions.Like(p.Reason, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(p => p.Status == status);
|
||||
if (itemId is not null) q = q.Where(p => p.Lines.Any(l => l.ItemId == itemId));
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(p => p.PriceAdjustmentId)
|
||||
.Skip(skip).Take(take)
|
||||
.Select(p => new PriceAdjustmentListRow(
|
||||
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals,
|
||||
p.Lines.Count, p.CreatedBy, p.Creator!.DisplayName, p.CreatedAt, p.AppliedAt))
|
||||
.ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<PriceAdjustment?> GetWithDetailsAsync(int priceAdjustmentId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking()
|
||||
.Include(p => p.Lines).ThenInclude(l => l.Item)
|
||||
.Include(p => p.Creator)
|
||||
.FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct);
|
||||
|
||||
public Task<PriceAdjustment?> GetForDecisionAsync(int priceAdjustmentId, CancellationToken ct = default)
|
||||
=> Set.Include(p => p.Lines).FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct);
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, Item>> GetItemsForUpdateAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
return await Db.Set<Item>().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<(int ItemId, string DocNo)>> ListPendingRequestsForItemsAsync(
|
||||
IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
// Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a
|
||||
// plain join — a SelectMany over a to-many navigation generates a CROSS APPLY that
|
||||
// Pomelo/MySQL can't translate (it works fine against PostgreSQL, which is what this
|
||||
// predates — 2026-10-16 MySQL-port regression).
|
||||
return (await Db.Set<PriceAdjustmentLine>().AsNoTracking()
|
||||
.Where(l => ids.Contains(l.ItemId)
|
||||
&& (l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingApproval
|
||||
|| l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingSecondApproval))
|
||||
.Select(l => new { l.ItemId, DocNo = l.PriceAdjustment!.DocNo })
|
||||
.ToListAsync(ct))
|
||||
.Select(x => (x.ItemId, x.DocNo)).ToList();
|
||||
}
|
||||
|
||||
public async Task AddHistoryAsync(ItemPriceHistory history, CancellationToken ct = default)
|
||||
=> await Db.Set<ItemPriceHistory>().AddAsync(history, ct);
|
||||
|
||||
public async Task<IReadOnlyList<ItemPriceHistory>> ListItemHistoryAsync(int itemId, CancellationToken ct = default)
|
||||
=> await Db.Set<ItemPriceHistory>().AsNoTracking()
|
||||
.Where(h => h.ItemId == itemId)
|
||||
.OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, string>> GetUserNamesAsync(IReadOnlyCollection<int> userIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = userIds.ToList();
|
||||
return await Db.Set<User>().AsNoTracking().Where(u => ids.Contains(u.UserId)).ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, string>> GetDocNosAsync(IReadOnlyCollection<int> priceAdjustmentIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = priceAdjustmentIds.ToList();
|
||||
return await Set.AsNoTracking().Where(p => ids.Contains(p.PriceAdjustmentId)).ToDictionaryAsync(p => p.PriceAdjustmentId, p => p.DocNo, ct);
|
||||
}
|
||||
|
||||
public Task<int?> GetPriceAdjustmentLevelsAsync(CancellationToken ct = default)
|
||||
=> Db.Set<ApprovalConfig>().AsNoTracking()
|
||||
.Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
|
||||
.Select(c => (int?)c.PriceAdjustmentLevels)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IProcurementConfigRepository"/>.</summary>
|
||||
public sealed class ProcurementConfigRepository : Repository<ProcurementConfig>, IProcurementConfigRepository
|
||||
{
|
||||
public ProcurementConfigRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public Task<ProcurementConfig?> GetForReadAsync(CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IProductConfigRepository"/>.</summary>
|
||||
public sealed class ProductConfigRepository : Repository<ProductConfig>, IProductConfigRepository
|
||||
{
|
||||
public ProductConfigRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public Task<ProductConfig?> GetForReadAsync(CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IPurchaseInvoicePaymentRepository"/>.</summary>
|
||||
public sealed class PurchaseInvoicePaymentRepository : Repository<PurchaseInvoicePayment>, IPurchaseInvoicePaymentRepository
|
||||
{
|
||||
public PurchaseInvoicePaymentRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
public Task<bool> InvoiceExistsAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
=> Db.Set<PurchaseInvoice>().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct);
|
||||
|
||||
public Task<PurchaseInvoice?> GetInvoiceForUpdateAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
=> Db.Set<PurchaseInvoice>().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<PurchaseInvoicePayment>> ListForInvoiceAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
=> await Set.AsNoTracking()
|
||||
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
|
||||
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IPurchaseInvoiceRepository"/>.</summary>
|
||||
public sealed class PurchaseInvoiceRepository : Repository<PurchaseInvoice>, IPurchaseInvoiceRepository
|
||||
{
|
||||
public PurchaseInvoiceRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private DbSet<Grn> Grns => Db.Set<Grn>();
|
||||
|
||||
public async Task<(IReadOnlyList<PurchaseInvoice> Rows, int Total)> ListPagedAsync(
|
||||
string? search, PurchaseInvoiceStatus? status, int? vendorId, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<PurchaseInvoice> q = Set.AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|
||||
|| EF.Functions.Like(x.VendorInvoiceNo, $"%{term}%")
|
||||
|| EF.Functions.Like(x.VendorSnapshotName, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<PurchaseInvoice?> GetWithLinesAsync(int purchaseInvoiceId, bool tracking, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.Include(x => x.Lines).ThenInclude(l => l.Grn);
|
||||
return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
|
||||
}
|
||||
|
||||
public Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default)
|
||||
=> Db.Set<Vendor>().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
|
||||
|
||||
public Task<bool> VendorInvoiceNoExistsAsync(int vendorId, string vendorInvoiceNo, int? excludePurchaseInvoiceId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().AnyAsync(x =>
|
||||
x.VendorId == vendorId
|
||||
&& x.Status != PurchaseInvoiceStatus.Cancelled
|
||||
&& x.VendorInvoiceNo == vendorInvoiceNo
|
||||
&& (excludePurchaseInvoiceId == null || x.PurchaseInvoiceId != excludePurchaseInvoiceId), ct);
|
||||
|
||||
public async Task<IReadOnlyList<Grn>> ListGrnsForInvoicingAsync(IReadOnlyCollection<int> grnIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = grnIds.ToList();
|
||||
return await Grns.Include(g => g.Lines).Where(g => ids.Contains(g.GrnId)).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EligibleGrnRow>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
|
||||
=> await Grns.AsNoTracking()
|
||||
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
|
||||
.OrderBy(g => g.PostedAt)
|
||||
.Select(g => new EligibleGrnRow(
|
||||
g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count,
|
||||
g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal)))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IPurchaseOrderRepository"/>.</summary>
|
||||
public sealed class PurchaseOrderRepository : Repository<PurchaseOrder>, IPurchaseOrderRepository
|
||||
{
|
||||
public PurchaseOrderRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private DbSet<Vendor> Vendors => Db.Set<Vendor>();
|
||||
private DbSet<Warehouse> Warehouses => Db.Set<Warehouse>();
|
||||
|
||||
public async Task<(IReadOnlyList<PurchaseOrder> Rows, int Total)> ListPagedAsync(
|
||||
PurchaseOrderListCriteria criteria, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking().Include(p => p.Lines).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(criteria.Search))
|
||||
{
|
||||
var term = criteria.Search.Trim();
|
||||
// Doc no., or the vendor's name / code (the vendor is the table's first column).
|
||||
var vendorIdsMatching = Vendors
|
||||
.Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%"))
|
||||
.Select(v => v.VendorId);
|
||||
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || vendorIdsMatching.Contains(p.VendorId));
|
||||
}
|
||||
if (criteria.Status is not null) q = q.Where(p => p.Status == criteria.Status);
|
||||
if (criteria.VendorId is not null) q = q.Where(p => p.VendorId == criteria.VendorId);
|
||||
if (criteria.CreatedFrom is { } start) q = q.Where(p => p.CreatedAt >= start);
|
||||
if (criteria.CreatedBefore is { } end) q = q.Where(p => p.CreatedAt < end);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await ApplySort(q, criteria.Sort).Skip(skip).Take(take).ToListAsync(ct);
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
private IQueryable<PurchaseOrder> ApplySort(IQueryable<PurchaseOrder> q, string? sort)
|
||||
{
|
||||
var key = (sort ?? "-created").Trim();
|
||||
var desc = key.StartsWith('-');
|
||||
key = key.TrimStart('-', '+').ToLowerInvariant();
|
||||
|
||||
IOrderedQueryable<PurchaseOrder> ordered = key switch
|
||||
{
|
||||
"vendor" => desc
|
||||
? q.OrderByDescending(p => Vendors.Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault())
|
||||
: q.OrderBy(p => Vendors.Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault()),
|
||||
"total" => desc
|
||||
? q.OrderByDescending(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount))
|
||||
: q.OrderBy(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount)),
|
||||
"status" => desc ? q.OrderByDescending(p => p.Status) : q.OrderBy(p => p.Status),
|
||||
"created" => desc ? q.OrderByDescending(p => p.CreatedAt) : q.OrderBy(p => p.CreatedAt),
|
||||
_ => q.OrderByDescending(p => p.CreatedAt)
|
||||
};
|
||||
return ordered.ThenByDescending(p => p.PoId);
|
||||
}
|
||||
|
||||
public Task<PurchaseOrder?> GetWithLinesAsync(int poId, bool tracking, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.Include(p => p.Lines);
|
||||
return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
}
|
||||
|
||||
public Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default)
|
||||
=> Vendors.AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, Vendor>> GetVendorsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = vendorIds.ToList();
|
||||
return await Vendors.AsNoTracking().Where(v => ids.Contains(v.VendorId)).ToDictionaryAsync(v => v.VendorId, ct);
|
||||
}
|
||||
|
||||
public Task<bool> RequisitionExistsAsync(int requisitionId, CancellationToken ct = default)
|
||||
=> Db.Set<Requisition>().AnyAsync(r => r.RequisitionId == requisitionId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<int>> ListExistingItemIdsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
return await Db.Set<Item>().Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<int>> ListExistingWarehouseIdsAsync(IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = warehouseIds.ToList();
|
||||
return await Warehouses.Where(w => ids.Contains(w.WarehouseId)).Select(w => w.WarehouseId).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<int>> ListWarehousesOutsideBranchAsync(
|
||||
IReadOnlyCollection<int> warehouseIds, int branchId, CancellationToken ct = default)
|
||||
{
|
||||
var ids = warehouseIds.ToList();
|
||||
return await Warehouses.AsNoTracking()
|
||||
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
|
||||
.Select(w => w.WarehouseId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Repositories;
|
||||
|
||||
/// <summary>EF implementation of <see cref="IPurchaseReturnRepository"/>.</summary>
|
||||
public sealed class PurchaseReturnRepository : Repository<PurchaseReturn>, IPurchaseReturnRepository
|
||||
{
|
||||
public PurchaseReturnRepository(ErpDbContext db) : base(db) { }
|
||||
|
||||
private DbSet<StockLayer> Layers => Db.Set<StockLayer>();
|
||||
|
||||
public async Task<(IReadOnlyList<(PurchaseReturn Return, int LineCount)> Rows, int Total)> ListPagedAsync(
|
||||
string? search, int? vendorId, int? warehouseId, int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
var q = Set.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId);
|
||||
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(r => r.ReturnId).Skip(skip).Take(take)
|
||||
.Select(r => new { Return = r, LineCount = r.Lines.Count })
|
||||
.ToListAsync(ct);
|
||||
return (rows.Select(x => (x.Return, x.LineCount)).ToList(), total);
|
||||
}
|
||||
|
||||
public Task<PurchaseReturn?> GetWithLinesAsync(int returnId, CancellationToken ct = default)
|
||||
=> Set.AsNoTracking().Include(r => r.Lines).FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<int>> ListLedgerIdsAsync(int returnId, CancellationToken ct = default)
|
||||
=> await Db.Set<StockLedger>().AsNoTracking()
|
||||
.Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId)
|
||||
.OrderBy(l => l.LedgerId)
|
||||
.Select(l => l.LedgerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// --- References ------------------------------------------------------------------------------
|
||||
|
||||
public Task<bool> VendorExistsAsync(int vendorId, CancellationToken ct = default)
|
||||
=> Db.Set<Vendor>().AnyAsync(v => v.VendorId == vendorId, ct);
|
||||
|
||||
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
|
||||
|
||||
public Task<ReasonCode?> GetReasonCodeAsync(int reasonCodeId, CancellationToken ct = default)
|
||||
=> Db.Set<ReasonCode>().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == reasonCodeId, ct);
|
||||
|
||||
public Task<Item?> GetItemAsync(int itemId, CancellationToken ct = default)
|
||||
=> Db.Set<Item>().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
public Task<GrnLine?> GetGrnLineAsync(int grnLineId, CancellationToken ct = default)
|
||||
=> Db.Set<GrnLine>().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == grnLineId, ct);
|
||||
|
||||
// --- Rejected GRN-line stock -----------------------------------------------------------------
|
||||
|
||||
public async Task<decimal> SumRemainingOnGrnLineAsync(int grnLineId, CancellationToken ct = default)
|
||||
=> await Layers.AsNoTracking().Where(l => l.GrnLineId == grnLineId).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
|
||||
public Task<List<StockLayer>> ListOpenLayersForGrnLineAsync(int grnLineId, CancellationToken ct = default)
|
||||
=> Layers
|
||||
.Where(l => l.GrnLineId == grnLineId && l.QtyRemaining > 0)
|
||||
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<Serial>> ListSerialsAsync(IReadOnlyCollection<int> serialIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = serialIds.ToList();
|
||||
return await Db.Set<Serial>().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IBrandRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -17,10 +17,10 @@ namespace ERPCore.Services;
|
||||
/// </summary>
|
||||
public sealed class BrandService : IBrandService
|
||||
{
|
||||
private readonly IBrandRepository _brands;
|
||||
private readonly IRepository<Brand> _brands;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public BrandService(IBrandRepository brands, IUnitOfWork uow)
|
||||
public BrandService(IRepository<Brand> brands, IUnitOfWork uow)
|
||||
{
|
||||
_brands = brands;
|
||||
_uow = uow;
|
||||
@@ -28,36 +28,45 @@ public sealed class BrandService : IBrandService
|
||||
|
||||
public async Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _brands.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
|
||||
return PagedResponse<BrandDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
|
||||
var q = _brands.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(b => EF.Functions.Like(b.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(b => b.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(b => b.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(b => new BrandDto(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<BrandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default)
|
||||
{
|
||||
var brand = await _brands.GetForReadAsync(brandId, ct);
|
||||
var brand = await _brands.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||
return brand is null ? null : new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = request.Name.Trim();
|
||||
if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||
|
||||
// Same shape as BankDepositService.CreateAsync: check, build and add inside one transaction
|
||||
// (ExecuteInTransactionAsync saves and commits on success), then map the returned entity.
|
||||
var brand = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var brand = new Brand
|
||||
{
|
||||
if (await _brands.NameExistsAsync(name, ct: token))
|
||||
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var entity = new Brand
|
||||
{
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _brands.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
await _brands.AddAsync(brand, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||
}
|
||||
@@ -72,19 +81,16 @@ public sealed class BrandService : IBrandService
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(brand.Name, name, StringComparison.Ordinal)
|
||||
&& await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct))
|
||||
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||
|
||||
brand.Name = name;
|
||||
brand.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
if (!string.Equals(brand.Name, name, StringComparison.Ordinal)
|
||||
&& await _brands.NameExistsAsync(name, brandId, token))
|
||||
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||
|
||||
brand.Name = name;
|
||||
brand.UpdatedAt = DateTime.UtcNow;
|
||||
return 0;
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
@@ -99,12 +105,9 @@ public sealed class BrandService : IBrandService
|
||||
var brand = await _brands.GetByIdAsync(brandId, ct)
|
||||
?? throw new NotFoundException($"Brand {brandId} was not found.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(token =>
|
||||
{
|
||||
brand.Status = status;
|
||||
brand.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
brand.Status = status;
|
||||
brand.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt);
|
||||
|
||||
@@ -3,55 +3,101 @@ using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle sales and bundle templates. Every query goes through <see cref="IBundleSaleRepository"/>;
|
||||
/// every write runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> (which saves and commits
|
||||
/// on success), the same shape as <see cref="BankDepositService"/> — build/change inside, map after.
|
||||
/// </summary>
|
||||
public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
private readonly IBundleSaleRepository _bundles;
|
||||
private readonly IRepository<BundleSaleTemplate> _templates;
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<SalesDayEnd> _dayEnds;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public BundleSaleService(
|
||||
IBundleSaleRepository bundles,
|
||||
IRepository<BundleSale> bundles,
|
||||
IRepository<BundleSaleTemplate> templates,
|
||||
IRepository<SalesDayEnd> dayEnds,
|
||||
IRepository<Customer> customers,
|
||||
IRepository<Item> items,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
ISalesDomainService sales,
|
||||
ISalesPostingService posting,
|
||||
ICurrentUser currentUser,
|
||||
INumberSequenceService numbers,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_templates = templates;
|
||||
_bundles = bundles;
|
||||
_dayEnds = dayEnds;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_sales = sales;
|
||||
_posting = posting;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _bundles.ListTemplatesPagedAsync(query.Q, query.Skip, query.PageSize, ct);
|
||||
return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(MapTemplateSummary).ToList(), query.Page, query.PageSize, total);
|
||||
IQueryable<BundleSaleTemplate> q = _templates.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.Like(x.TemplateCode, $"%{term}%") || EF.Functions.Like(x.TemplateName, $"%{term}%") || EF.Functions.Like(x.Description ?? "", $"%{term}%"));
|
||||
}
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto(
|
||||
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.BundlePrice, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default)
|
||||
{
|
||||
var template = await _bundles.GetTemplateForReadAsync(bundleSaleTemplateId, ct);
|
||||
return template is null ? null : MapTemplate(template);
|
||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct);
|
||||
return template is null ? null : new BundleSaleTemplateDto(
|
||||
template.BundleSaleTemplateId,
|
||||
template.TemplateCode,
|
||||
template.TemplateName,
|
||||
template.Description,
|
||||
template.BundlePrice,
|
||||
template.Status,
|
||||
template.CreatedAt,
|
||||
template.UpdatedAt,
|
||||
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
}
|
||||
|
||||
public async Task<BundleSaleTemplateDto> CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var template = new BundleSaleTemplate
|
||||
{
|
||||
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct),
|
||||
TemplateName = request.TemplateName,
|
||||
Description = request.Description,
|
||||
BundlePrice = request.BundlePrice ?? 0m,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (request.BundlePrice is { } bundlePrice)
|
||||
{
|
||||
if (!request.Lines.Any(l => l.IncludeInBundle))
|
||||
@@ -59,41 +105,32 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
AllocateBundlePrice(request.Lines, bundlePrice);
|
||||
}
|
||||
|
||||
var template = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
var entity = new BundleSaleTemplate
|
||||
if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct))
|
||||
throw new NotFoundException($"Item {line.ItemId} was not found.");
|
||||
if (!await _warehouses.Query().AsNoTracking().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
|
||||
|
||||
template.Lines.Add(new BundleSaleTemplateLine
|
||||
{
|
||||
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, token),
|
||||
TemplateName = request.TemplateName,
|
||||
Description = request.Description,
|
||||
BundlePrice = request.BundlePrice ?? 0m,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
ItemId = line.ItemId,
|
||||
WarehouseId = line.WarehouseId,
|
||||
Qty = line.Qty,
|
||||
UnitPrice = line.UnitPrice,
|
||||
IncludeInBundle = line.IncludeInBundle,
|
||||
SortOrder = line.SortOrder
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (!await _bundles.ItemExistsAsync(line.ItemId, token))
|
||||
throw new NotFoundException($"Item {line.ItemId} was not found.");
|
||||
if (!await _bundles.WarehouseExistsAsync(line.WarehouseId, token))
|
||||
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
|
||||
await _templates.AddAsync(template, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
entity.Lines.Add(new BundleSaleTemplateLine
|
||||
{
|
||||
ItemId = line.ItemId,
|
||||
WarehouseId = line.WarehouseId,
|
||||
Qty = line.Qty,
|
||||
UnitPrice = line.UnitPrice,
|
||||
IncludeInBundle = line.IncludeInBundle,
|
||||
SortOrder = line.SortOrder
|
||||
});
|
||||
}
|
||||
|
||||
await _bundles.AddTemplateAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return MapTemplate(template);
|
||||
return new BundleSaleTemplateDto(
|
||||
template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description, template.BundlePrice,
|
||||
template.Status, template.CreatedAt, template.UpdatedAt,
|
||||
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -124,13 +161,23 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
|
||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _bundles.ListPagedAsync(query.Q, status, customerId, warehouseId, query.Skip, query.PageSize, ct);
|
||||
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.Like(x.BundleNo, $"%{term}%") || EF.Functions.Like(x.BundleName, $"%{term}%") || EF.Functions.Like(x.BundleCode, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
{
|
||||
var bundle = await _bundles.GetForReadAsync(bundleSaleId, ct);
|
||||
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
||||
return bundle is null ? null : Map(bundle);
|
||||
}
|
||||
|
||||
@@ -141,96 +188,82 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
|
||||
var bundle = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
// Same guard as SalesSlipService.CreateAsync — a bundle sale is a cashier document
|
||||
// exactly like a sales slip, so it's blocked by the same closed day (docs/14 Sales Day End).
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var alreadyClosed = await _dayEnds.Query().AsNoTracking()
|
||||
.AnyAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == today, ct);
|
||||
if (alreadyClosed)
|
||||
throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted.");
|
||||
|
||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||
var bundle = new BundleSale
|
||||
{
|
||||
// Same guard as SalesSlipService.CreateAsync — a bundle sale is a cashier document
|
||||
// exactly like a sales slip, so it's blocked by the same closed day (docs/14 Sales Day End).
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (await _bundles.IsDayClosedAsync(request.CashierUserId, today, token))
|
||||
throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted.");
|
||||
|
||||
var template = await RequireTemplateAsync(request.BundleSaleTemplateId, token);
|
||||
var entity = new BundleSale
|
||||
{
|
||||
BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, token),
|
||||
BundleDate = DateTime.UtcNow,
|
||||
CustomerId = request.CustomerId,
|
||||
WarehouseId = request.WarehouseId,
|
||||
CashierUserId = request.CashierUserId,
|
||||
BundleSaleTemplateId = request.BundleSaleTemplateId,
|
||||
BundleName = request.BundleName,
|
||||
BundleCode = string.Empty,
|
||||
Status = BundleSaleStatus.Draft,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
entity.CustomerSnapshotName = await _bundles.GetCustomerSnapshotNameAsync(request.CustomerId, token);
|
||||
entity.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, token);
|
||||
Recalculate(entity, request.BundlePrice, request.CardAmount);
|
||||
entity.BundleCode = $"{entity.BundleNo}-B";
|
||||
await _bundles.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct),
|
||||
BundleDate = DateTime.UtcNow,
|
||||
CustomerId = request.CustomerId,
|
||||
WarehouseId = request.WarehouseId,
|
||||
CashierUserId = request.CashierUserId,
|
||||
BundleSaleTemplateId = request.BundleSaleTemplateId,
|
||||
BundleName = request.BundleName,
|
||||
BundleCode = string.Empty,
|
||||
Status = BundleSaleStatus.Draft,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
|
||||
Recalculate(bundle, request.BundlePrice, request.CardAmount);
|
||||
bundle.BundleCode = $"{bundle.BundleNo}-B";
|
||||
await _bundles.AddAsync(bundle, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(bundle);
|
||||
}
|
||||
|
||||
public async Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var bundle = await _bundles.GetForUpdateAsync(bundleSaleId, ct)
|
||||
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||
if (bundle.Status != BundleSaleStatus.Draft)
|
||||
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
|
||||
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var template = await RequireTemplateAsync(request.BundleSaleTemplateId, token);
|
||||
bundle.CustomerId = request.CustomerId;
|
||||
bundle.WarehouseId = request.WarehouseId;
|
||||
bundle.CashierUserId = request.CashierUserId;
|
||||
bundle.BundleSaleTemplateId = request.BundleSaleTemplateId;
|
||||
bundle.BundleName = request.BundleName;
|
||||
bundle.CustomerSnapshotName = await _bundles.GetCustomerSnapshotNameAsync(request.CustomerId, token);
|
||||
bundle.Lines.Clear();
|
||||
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, token)) bundle.Lines.Add(line);
|
||||
Recalculate(bundle, request.BundlePrice, request.CardAmount);
|
||||
bundle.UpdatedAt = DateTime.UtcNow;
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||
bundle.CustomerId = request.CustomerId;
|
||||
bundle.WarehouseId = request.WarehouseId;
|
||||
bundle.CashierUserId = request.CashierUserId;
|
||||
bundle.BundleSaleTemplateId = request.BundleSaleTemplateId;
|
||||
bundle.BundleName = request.BundleName;
|
||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
bundle.Lines.Clear();
|
||||
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
|
||||
Recalculate(bundle, request.BundlePrice, request.CardAmount);
|
||||
bundle.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(bundle);
|
||||
}
|
||||
|
||||
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
{
|
||||
await _posting.PostBundleAsync(bundleSaleId, ct);
|
||||
var bundle = await _bundles.GetForReadAsync(bundleSaleId, ct)
|
||||
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||
return Map(bundle);
|
||||
}
|
||||
{
|
||||
await _posting.PostBundleAsync(bundleSaleId, ct);
|
||||
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
||||
return Map(bundle);
|
||||
}
|
||||
|
||||
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
{
|
||||
var bundle = await _bundles.GetForUpdateAsync(bundleSaleId, ct)
|
||||
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||
if (bundle.Status != BundleSaleStatus.Draft)
|
||||
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(token =>
|
||||
{
|
||||
bundle.Status = BundleSaleStatus.Cancelled;
|
||||
bundle.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
|
||||
bundle.Status = BundleSaleStatus.Cancelled;
|
||||
bundle.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(bundle);
|
||||
}
|
||||
|
||||
private async Task<BundleSaleTemplate> RequireTemplateAsync(int bundleSaleTemplateId, CancellationToken ct)
|
||||
=> await _bundles.GetTemplateForReadAsync(bundleSaleTemplateId, ct)
|
||||
?? throw new NotFoundException($"Bundle template {bundleSaleTemplateId} was not found.");
|
||||
|
||||
private async Task<List<BundleSaleLine>> BuildLinesAsync(
|
||||
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
|
||||
{
|
||||
@@ -256,13 +289,13 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
// Keep any per-line warehouse input from drifting away from the header.
|
||||
var lineWarehouseId = warehouseId;
|
||||
|
||||
var itemName = await _bundles.GetItemNameAsync(r.ItemId, ct);
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, lineWarehouseId, r.Qty, 0m, null, ct);
|
||||
var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
lines.Add(new BundleSaleLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
Description = itemName,
|
||||
Description = item.Name,
|
||||
Qty = r.Qty,
|
||||
WarehouseId = lineWarehouseId,
|
||||
UnitPrice = r.UnitPrice,
|
||||
@@ -293,14 +326,6 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
bundle.CashAmount = bundle.GrandTotal - card;
|
||||
}
|
||||
|
||||
private static BundleSaleTemplateSummaryDto MapTemplateSummary(BundleSaleTemplate x) => new(
|
||||
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.BundlePrice, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt);
|
||||
|
||||
private static BundleSaleTemplateDto MapTemplate(BundleSaleTemplate t) => new(
|
||||
t.BundleSaleTemplateId, t.TemplateCode, t.TemplateName, t.Description, t.BundlePrice, t.Status, t.CreatedAt, t.UpdatedAt,
|
||||
t.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
|
||||
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
|
||||
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.GrandTotal, x.CreatedAt);
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Globalization;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Accounts;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -14,21 +17,30 @@ namespace ERPCore.Services;
|
||||
/// <c>BankDeposit:CashTrackingStartDate</c>; the opening balance before a window is a handful of
|
||||
/// database sums, and the days inside it are bucketed by local day here (same reason as
|
||||
/// <see cref="DayEndReportService"/>: MariaDB can't portably group by a zone-shifted date).
|
||||
/// Every query lives in <see cref="ICashInHandRepository"/>; this service keeps the balance rules.
|
||||
/// </summary>
|
||||
public sealed class CashInHandService : ICashInHandService
|
||||
{
|
||||
private const int MaxRangeDays = 92;
|
||||
|
||||
private readonly ICashInHandRepository _cash;
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
|
||||
private readonly IRepository<ExpensePayment> _expensePayments;
|
||||
private readonly IRepository<BankDeposit> _deposits;
|
||||
private readonly ICurrentBranchContext _currentBranch;
|
||||
private readonly BusinessDayClock _clock;
|
||||
private readonly string? _defaultCashAccountCode;
|
||||
|
||||
public CashInHandService(
|
||||
ICashInHandRepository cash, ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration)
|
||||
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoicePayment> invoicePayments,
|
||||
IRepository<ExpensePayment> expensePayments, IRepository<BankDeposit> deposits,
|
||||
ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration)
|
||||
{
|
||||
_cash = cash;
|
||||
_slips = slips;
|
||||
_bundles = bundles;
|
||||
_invoicePayments = invoicePayments;
|
||||
_expensePayments = expensePayments;
|
||||
_deposits = deposits;
|
||||
_currentBranch = currentBranch;
|
||||
_clock = clock;
|
||||
_defaultCashAccountCode = configuration["BankDeposit:DefaultCashAccountCode"];
|
||||
@@ -93,21 +105,29 @@ public sealed class CashInHandService : ICashInHandService
|
||||
{
|
||||
DateTime? lo = TrackingStartDate is { } s ? _clock.ToUtc(s) : null;
|
||||
var hi = _clock.ToUtc(from);
|
||||
var cashIn = await _cash.SumCashInAsync(lo, hi, branch, ct);
|
||||
var cashOut = await _cash.SumCashOutAsync(lo, hi, branch, ct);
|
||||
var deposited = await _cash.SumDepositsAsync(TrackingStartDate, from, branch, ct);
|
||||
var cashIn = await Slips(lo, hi, branch).SumAsync(s => s.CashAmount, ct)
|
||||
+ await Bundles(lo, hi, branch).SumAsync(b => b.CashAmount, ct)
|
||||
+ await InvoicePayments(lo, hi, branch).SumAsync(p => p.Amount, ct);
|
||||
var cashOut = await ExpensePayments(lo, hi, branch).SumAsync(p => p.Amount, ct);
|
||||
var deposited = await Deposits(TrackingStartDate, from, branch).SumAsync(d => d.Amount, ct);
|
||||
opening = cashIn - cashOut - deposited;
|
||||
}
|
||||
|
||||
// The window itself, bucketed by local day.
|
||||
var start = _clock.ToUtc(from);
|
||||
var end = _clock.ToUtc(to.AddDays(1));
|
||||
var inRows = await _cash.ListCashInAsync(start, end, branch, ct);
|
||||
var outRows = await _cash.ListCashOutAsync(start, end, branch, ct);
|
||||
var depositRows = await _cash.ListDepositsAsync(from, to.AddDays(1), branch, ct);
|
||||
var inRows = new List<(DateTime At, decimal Amount)>();
|
||||
inRows.AddRange((await Slips(start, end, branch).Select(s => new { s.SlipDate, s.CashAmount }).ToListAsync(ct))
|
||||
.Select(r => (r.SlipDate, r.CashAmount)));
|
||||
inRows.AddRange((await Bundles(start, end, branch).Select(b => new { b.BundleDate, b.CashAmount }).ToListAsync(ct))
|
||||
.Select(r => (r.BundleDate, r.CashAmount)));
|
||||
inRows.AddRange((await InvoicePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct))
|
||||
.Select(r => (r.PaymentDate, r.Amount)));
|
||||
var outRows = await ExpensePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct);
|
||||
var depositRows = await Deposits(from, to.AddDays(1), branch).Select(d => new { d.BusinessDate, d.Amount }).ToListAsync(ct);
|
||||
|
||||
var inByDay = inRows.GroupBy(r => _clock.LocalDay(r.At)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
|
||||
var outByDay = outRows.GroupBy(r => _clock.LocalDay(r.At)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
|
||||
var outByDay = outRows.GroupBy(r => _clock.LocalDay(r.PaymentDate)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
|
||||
var depositByDay = depositRows.GroupBy(r => r.BusinessDate).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
|
||||
|
||||
var days = new List<CashPositionDto>(to.DayNumber - from.DayNumber + 1);
|
||||
@@ -129,4 +149,48 @@ public sealed class CashInHandService : ICashInHandService
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
// --- Sources. lo is inclusive (null = no lower bound), hi exclusive. ---
|
||||
|
||||
private IQueryable<SalesSlip> Slips(DateTime? lo, DateTime hi, int? branch)
|
||||
{
|
||||
var q = _slips.Query().AsNoTracking()
|
||||
.Where(s => s.Status == SalesSlipStatus.Posted && s.CashAmount != 0 && s.SlipDate < hi);
|
||||
if (lo is not null) q = q.Where(s => s.SlipDate >= lo);
|
||||
return branch is null ? q : q.Where(s => s.Warehouse!.BranchId == branch);
|
||||
}
|
||||
|
||||
private IQueryable<BundleSale> Bundles(DateTime? lo, DateTime hi, int? branch)
|
||||
{
|
||||
var q = _bundles.Query().AsNoTracking()
|
||||
.Where(b => b.Status == BundleSaleStatus.Posted && b.CashAmount != 0 && b.BundleDate < hi);
|
||||
if (lo is not null) q = q.Where(b => b.BundleDate >= lo);
|
||||
return branch is null ? q : q.Where(b => b.Warehouse!.BranchId == branch);
|
||||
}
|
||||
|
||||
/// <summary>Cash received against Posted invoices — whatever day the invoice itself is from.</summary>
|
||||
private IQueryable<SalesInvoicePayment> InvoicePayments(DateTime? lo, DateTime hi, int? branch)
|
||||
{
|
||||
var q = _invoicePayments.Query().AsNoTracking()
|
||||
.Where(p => p.PaymentMethod == SalesPaymentMethod.Cash && p.SalesInvoice!.Status == SalesInvoiceStatus.Posted && p.PaymentDate < hi);
|
||||
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
|
||||
return branch is null ? q : q.Where(p => p.SalesInvoice!.BranchId == branch);
|
||||
}
|
||||
|
||||
private IQueryable<ExpensePayment> ExpensePayments(DateTime? lo, DateTime hi, int? branch)
|
||||
{
|
||||
var q = _expensePayments.Query().AsNoTracking()
|
||||
.Where(p => p.PaymentMethod == ExpensePaymentMethod.Cash && p.Expense!.Status != ExpenseStatus.Cancelled && p.PaymentDate < hi);
|
||||
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
|
||||
return branch is null ? q : q.Where(p => p.Expense!.BranchId == branch);
|
||||
}
|
||||
|
||||
/// <summary>Posted deposits by business date; <paramref name="lo"/> inclusive (null = no bound), <paramref name="hi"/> exclusive.</summary>
|
||||
private IQueryable<BankDeposit> Deposits(DateOnly? lo, DateOnly hi, int? branch)
|
||||
{
|
||||
var q = _deposits.Query().AsNoTracking()
|
||||
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate < hi);
|
||||
if (lo is not null) q = q.Where(d => d.BusinessDate >= lo);
|
||||
return branch is null ? q : q.Where(d => d.BranchId == branch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,28 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in ICategoryRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels:
|
||||
/// categories no longer self-nest, so there is no cycle to detect and no tree to build
|
||||
/// (docs/11-BACKEND-PHASE1.md §2.3). Queries go through <see cref="ICategoryRepository"/>; writes run
|
||||
/// inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// (docs/11-BACKEND-PHASE1.md §2.3).
|
||||
/// </summary>
|
||||
public sealed class CategoryService : ICategoryService
|
||||
{
|
||||
private readonly ICategoryRepository _categories;
|
||||
private readonly IRepository<Category> _categories;
|
||||
private readonly IRepository<SubCategory> _subCategories;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public CategoryService(ICategoryRepository categories, IUnitOfWork uow)
|
||||
public CategoryService(
|
||||
IRepository<Category> categories,
|
||||
IRepository<SubCategory> subCategories,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_categories = categories;
|
||||
_subCategories = subCategories;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
@@ -33,34 +37,45 @@ public sealed class CategoryService : ICategoryService
|
||||
public async Task<PagedResponse<CategoryDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _categories.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
|
||||
return PagedResponse<CategoryDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
|
||||
var q = _categories.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(c => c.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _categories.GetForReadAsync(categoryId, ct);
|
||||
var category = await _categories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
|
||||
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = request.Name.Trim();
|
||||
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A category named '{name}' already exists.");
|
||||
|
||||
var category = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var category = new Category
|
||||
{
|
||||
if (await _categories.NameExistsAsync(name, ct: token))
|
||||
throw new ConflictException($"A category named '{name}' already exists.");
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var entity = new Category
|
||||
{
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _categories.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
await _categories.AddAsync(category, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
@@ -75,17 +90,14 @@ public sealed class CategoryService : ICategoryService
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(category.Name, name, StringComparison.Ordinal)
|
||||
&& await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct))
|
||||
throw new ConflictException($"A category named '{name}' already exists.");
|
||||
|
||||
await InTransactionGuardingConcurrencyAsync("category", async token =>
|
||||
{
|
||||
if (!string.Equals(category.Name, name, StringComparison.Ordinal)
|
||||
&& await _categories.NameExistsAsync(name, categoryId, token))
|
||||
throw new ConflictException($"A category named '{name}' already exists.");
|
||||
|
||||
category.Name = name;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
category.Name = name;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveGuardingConcurrencyAsync("category", ct);
|
||||
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
|
||||
@@ -94,12 +106,9 @@ public sealed class CategoryService : ICategoryService
|
||||
var category = await _categories.GetByIdAsync(categoryId, ct)
|
||||
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
category.Status = status;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
category.Status = status;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Subcategories ------------------------------------------------------------
|
||||
@@ -107,45 +116,59 @@ public sealed class CategoryService : ICategoryService
|
||||
public async Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
|
||||
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _categories.ExistsAsync(categoryId, ct))
|
||||
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||
throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
var (rows, total) = await _categories.ListSubCategoriesPagedAsync(categoryId, query.Q, status, query.Skip, query.PageSize, ct);
|
||||
return PagedResponse<SubCategoryDto>.Create(rows.Select(MapSub).ToList(), query.Page, query.PageSize, total);
|
||||
var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(s => EF.Functions.Like(s.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(s => s.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(s => s.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(s => new SubCategoryDto(
|
||||
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<SubCategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _categories.GetSubCategoryForReadAsync(subCategoryId, ct);
|
||||
var sub = await _subCategories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||
return sub is null ? null : new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(
|
||||
int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _categories.GetForReadAsync(categoryId, ct)
|
||||
var category = await _categories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct)
|
||||
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
if (category.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (await _subCategories.Query().AnyAsync(
|
||||
s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}.");
|
||||
|
||||
var sub = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var sub = new SubCategory
|
||||
{
|
||||
if (await _categories.SubCategoryNameExistsAsync(categoryId, name, ct: token))
|
||||
throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}.");
|
||||
CategoryId = categoryId,
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var entity = new SubCategory
|
||||
{
|
||||
CategoryId = categoryId,
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _categories.AddSubCategoryAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
await _subCategories.AddAsync(sub, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
@@ -153,50 +176,44 @@ public sealed class CategoryService : ICategoryService
|
||||
public async Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(
|
||||
int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _categories.GetSubCategoryForUpdateAsync(subCategoryId, ct)
|
||||
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
|
||||
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
|
||||
|
||||
if (sub.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(sub.Name, name, StringComparison.Ordinal)
|
||||
&& await _subCategories.Query().AnyAsync(
|
||||
s => s.CategoryId == sub.CategoryId && s.Name.ToLower() == name.ToLower() && s.SubCategoryId != subCategoryId, ct))
|
||||
throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}.");
|
||||
|
||||
await InTransactionGuardingConcurrencyAsync("subcategory", async token =>
|
||||
{
|
||||
if (!string.Equals(sub.Name, name, StringComparison.Ordinal)
|
||||
&& await _categories.SubCategoryNameExistsAsync(sub.CategoryId, name, subCategoryId, token))
|
||||
throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}.");
|
||||
|
||||
// Name only — reparenting is not offered, since it would silently invalidate the
|
||||
// category of every item pointing at this subcategory.
|
||||
sub.Name = name;
|
||||
sub.UpdatedAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
// Name only — reparenting is not offered, since it would silently invalidate the
|
||||
// category of every item pointing at this subcategory.
|
||||
sub.Name = name;
|
||||
sub.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveGuardingConcurrencyAsync("subcategory", ct);
|
||||
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _categories.GetSubCategoryForUpdateAsync(subCategoryId, ct)
|
||||
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
|
||||
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
sub.Status = status;
|
||||
sub.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
sub.Status = status;
|
||||
sub.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Runs <paramref name="change"/> in a transaction; a lost optimistic-concurrency race becomes a 412.</summary>
|
||||
private async Task InTransactionGuardingConcurrencyAsync(string label, Func<CancellationToken, Task> change, CancellationToken ct)
|
||||
private async Task SaveGuardingConcurrencyAsync(string label, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(change, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -20,62 +21,94 @@ namespace ERPCore.Services;
|
||||
/// C.7). That adjustment follows the same approval rules as any other
|
||||
/// (docs/DUAL_APPROVAL_PLAN.md §8, decision 3) — a count is not a way around approval — and
|
||||
/// moves stock when it is approved, or at once when approvals are switched off.
|
||||
/// Queries go through <see cref="ICountRepository"/>; writes run inside
|
||||
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// </summary>
|
||||
public sealed class CountService : ICountService
|
||||
{
|
||||
private const string VarianceReasonCode = "VAR";
|
||||
|
||||
private readonly ICountRepository _counts;
|
||||
private readonly IRepository<StockCount> _counts;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IRepository<StockLayer> _stockLayers;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<Serial> _serials;
|
||||
private readonly ISerialTrackingService _serialTracking;
|
||||
private readonly AdjustmentService _adjustments;
|
||||
|
||||
public CountService(
|
||||
ICountRepository counts, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
|
||||
IBranchConfigService branchConfig, IUnitOfWork uow, ISerialTrackingService serialTracking, AdjustmentService adjustments)
|
||||
IRepository<StockCount> counts, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
|
||||
IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
|
||||
IRepository<Serial> serials, ISerialTrackingService serialTracking, AdjustmentService adjustments)
|
||||
{
|
||||
_serialTracking = serialTracking;
|
||||
_counts = counts;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
_reasonCodes = reasonCodes;
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_uow = uow;
|
||||
_serialTracking = serialTracking;
|
||||
_stockLayers = stockLayers;
|
||||
_batches = batches;
|
||||
_serials = serials;
|
||||
_adjustments = adjustments;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
|
||||
PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _counts.ListPagedAsync(query.Q, status, warehouseId, branchId, query.Skip, query.PageSize, ct);
|
||||
var items = rows.Select(r => new CountSummaryDto(
|
||||
r.Count.CountId, r.Count.DocNo, r.Count.WarehouseId, r.Count.BranchId, r.Count.CountType, r.Count.Status,
|
||||
r.Count.CreatedBy, r.Count.CreatedAt, r.LineCount)).ToList();
|
||||
return PagedResponse<CountSummaryDto>.Create(items, query.Page, query.PageSize, total);
|
||||
var q = _counts.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(c => EF.Functions.Like(c.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
|
||||
// Warehouses are shared across branches — the count carries its own owning BranchId.
|
||||
if (branchId is not null) q = q.Where(c => c.BranchId == branchId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(c => c.CountId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(c => new CountSummaryDto(
|
||||
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status,
|
||||
c.CreatedBy, c.CreatedAt, c.Lines.Count))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<CountSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
|
||||
{
|
||||
var count = await _counts.GetForReadAsync(countId, ct);
|
||||
var count = await _counts.Query().AsNoTracking()
|
||||
.Include(c => c.Lines).ThenInclude(l => l.Batch)
|
||||
.Include(c => c.Lines).ThenInclude(l => l.Serial)
|
||||
.FirstOrDefaultAsync(c => c.CountId == countId, ct);
|
||||
return count is null ? null : Map(count);
|
||||
}
|
||||
|
||||
public async Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _counts.WarehouseExistsAsync(request.WarehouseId, ct))
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
|
||||
var itemIds = request.ItemIds.Distinct().ToList();
|
||||
foreach (var id in itemIds)
|
||||
if (!await _counts.ItemExistsAsync(id, ct))
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {id} does not exist.", 422);
|
||||
|
||||
// Snapshot system quantities now (immutable once opened). Batch/serial-tracked items
|
||||
@@ -83,10 +116,10 @@ public sealed class CountService : ICountService
|
||||
var lines = new List<StockCountLine>();
|
||||
foreach (var id in itemIds)
|
||||
{
|
||||
var item = await _counts.GetItemAsync(id, ct);
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == id, ct);
|
||||
if (item.TrackingMode == TrackingMode.Batch)
|
||||
{
|
||||
var batches = await _counts.ListBatchesAsync(id, ct);
|
||||
var batches = await _batches.Query().AsNoTracking().Where(b => b.ItemId == id).ToListAsync(ct);
|
||||
if (batches.Count == 0)
|
||||
{
|
||||
lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = 0 });
|
||||
@@ -94,11 +127,15 @@ public sealed class CountService : ICountService
|
||||
}
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
var systemQty = await _counts.SumBatchQtyAsync(id, request.WarehouseId, batch.BatchId, ct);
|
||||
var systemQty = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == batch.BatchId)
|
||||
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty });
|
||||
}
|
||||
// Stock that reached the item without a batch (e.g. an adjustment) is counted on its own line.
|
||||
var unbatched = await _counts.SumBatchQtyAsync(id, request.WarehouseId, null, ct);
|
||||
var unbatched = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == null)
|
||||
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
if (unbatched != 0)
|
||||
lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = unbatched });
|
||||
}
|
||||
@@ -106,13 +143,19 @@ public sealed class CountService : ICountService
|
||||
{
|
||||
// Serials that should be at this warehouse (one line each, counted 0 or 1), plus
|
||||
// one aggregate line for any pre-tracking serial-less stock still here (D6).
|
||||
var serials = await _counts.ListCountableSerialsAsync(id, request.WarehouseId, ct);
|
||||
var serials = await _serials.Query().AsNoTracking()
|
||||
.Where(s => s.ItemId == id && s.WarehouseId == request.WarehouseId
|
||||
&& (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged))
|
||||
.OrderBy(s => s.SerialNo).ToListAsync(ct);
|
||||
foreach (var serial in serials)
|
||||
{
|
||||
var open = await _counts.HasOpenSerialLayerAsync(id, request.WarehouseId, serial.SerialId, ct);
|
||||
var open = await _stockLayers.Query().AsNoTracking().AnyAsync(
|
||||
l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct);
|
||||
lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 });
|
||||
}
|
||||
var untracked = await _counts.SumUnserializedQtyAsync(id, request.WarehouseId, ct);
|
||||
var untracked = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == null)
|
||||
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
if (untracked != 0 || serials.Count == 0)
|
||||
lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked });
|
||||
}
|
||||
@@ -146,33 +189,35 @@ public sealed class CountService : ICountService
|
||||
|
||||
public async Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var count = await _counts.GetForUpdateAsync(countId, ct)
|
||||
var count = await _counts.Query()
|
||||
.Include(c => c.Lines).ThenInclude(l => l.Batch)
|
||||
.Include(c => c.Lines).ThenInclude(l => l.Serial)
|
||||
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
|
||||
?? throw new NotFoundException($"Count {countId} was not found.");
|
||||
if (count.Status == CountStatus.Posted)
|
||||
throw new ConflictException($"Count {countId} is already posted.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422);
|
||||
if (line.SerialId is not null && input.CountedQty is not (0m or 1m))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422);
|
||||
line.CountedQty = input.CountedQty;
|
||||
line.Variance = input.CountedQty - line.SystemQty;
|
||||
}
|
||||
|
||||
count.Status = CountStatus.Counted;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422);
|
||||
if (line.SerialId is not null && input.CountedQty is not (0m or 1m))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422);
|
||||
line.CountedQty = input.CountedQty;
|
||||
line.Variance = input.CountedQty - line.SystemQty;
|
||||
}
|
||||
|
||||
count.Status = CountStatus.Counted;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(count);
|
||||
}
|
||||
|
||||
public async Task<CountPostResultDto> PostAsync(int countId, CancellationToken ct = default)
|
||||
{
|
||||
var count = await _counts.GetForUpdateAsync(countId, ct)
|
||||
var count = await _counts.Query()
|
||||
.Include(c => c.Lines).ThenInclude(l => l.Batch)
|
||||
.Include(c => c.Lines).ThenInclude(l => l.Serial)
|
||||
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
|
||||
?? throw new NotFoundException($"Count {countId} was not found.");
|
||||
if (count.Status == CountStatus.Posted)
|
||||
throw new ConflictException($"Count {countId} is already posted.");
|
||||
@@ -185,15 +230,13 @@ public sealed class CountService : ICountService
|
||||
// No variances → just close the count, no adjustment.
|
||||
if (varianceLines.Count == 0)
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
count.Status = CountStatus.Posted;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
count.Status = CountStatus.Posted;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new CountPostResultDto(count.CountId, count.Status, null, null, Array.Empty<int>());
|
||||
}
|
||||
|
||||
var reason = await _counts.GetAdjustmentReasonAsync(VarianceReasonCode, ct)
|
||||
var reason = await _reasonCodes.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
|
||||
|
||||
// A serial counted missing is written off (Scrapped); one found is brought back InStock —
|
||||
@@ -214,8 +257,6 @@ public sealed class CountService : ICountService
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// AdjustmentService runs its own transaction; closing the count is done inside it, so the
|
||||
// count is Posted exactly when its adjustment is saved.
|
||||
var (adjustment, refs) = await _adjustments.SubmitAsync(request, _ =>
|
||||
{
|
||||
count.Status = CountStatus.Posted;
|
||||
|
||||
@@ -7,53 +7,114 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in ICustomerRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Customer master service. Queries go through <see cref="ICustomerRepository"/>; writes run inside
|
||||
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// </summary>
|
||||
public sealed class CustomerService : ICustomerService
|
||||
{
|
||||
private readonly ICustomerRepository _customers;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
private readonly IRepository<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
|
||||
private const string CashCustomerCode = "CUST-CASH";
|
||||
|
||||
public CustomerService(ICustomerRepository customers, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
public CustomerService(
|
||||
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
|
||||
IRepository<SalesConfig> salesConfig, IRepository<SalesInvoice> invoices,
|
||||
IRepository<SalesInvoicePayment> invoicePayments, IUnitOfWork uow)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_branchConfig = branchConfig;
|
||||
_salesConfig = salesConfig;
|
||||
_invoices = invoices;
|
||||
_invoicePayments = invoicePayments;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CustomerDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default)
|
||||
{
|
||||
var criteria = new CustomerListCriteria(query.Q, status, customerType, includeCashCustomer, branchId, CashCustomerCode);
|
||||
var (rows, total) = await _customers.ListPagedAsync(criteria, query.Skip, query.PageSize, ct);
|
||||
|
||||
// Each customer's receivable position — the unpaid balance and count of their Posted sales
|
||||
// invoices, and their latest invoice payment — for just the customers on this page.
|
||||
var receivables = await _customers.GetReceivablesAsync(rows.Select(r => r.CustomerId).ToList(), ct);
|
||||
var items = rows.Select(c =>
|
||||
var q = _customers.Query().AsNoTracking();
|
||||
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered
|
||||
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names).
|
||||
if (!includeCashCustomer)
|
||||
q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId));
|
||||
// Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created
|
||||
// under. The cash customer is shared by every branch, so it is never filtered out here.
|
||||
if (branchId is not null)
|
||||
q = q.Where(c => c.BranchId == branchId
|
||||
|| c.CustomerCode == CashCustomerCode
|
||||
|| (cashCustomerId != null && c.CustomerId == cashCustomerId));
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var dto = Map(c);
|
||||
return receivables.TryGetValue(c.CustomerId, out var r)
|
||||
? dto with { OutstandingAmount = r.OutstandingAmount, OutstandingInvoiceCount = r.OutstandingInvoiceCount, LastPaymentDate = r.LastPaymentDate }
|
||||
: dto;
|
||||
}).ToList();
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%")
|
||||
|| EF.Functions.Like(c.CustomerCode, $"%{term}%")
|
||||
|| (c.DisplayName != null && EF.Functions.Like(c.DisplayName, $"%{term}%")));
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
if (customerType is not null) q = q.Where(c => c.CustomerType == customerType);
|
||||
|
||||
return PagedResponse<CustomerDto>.Create(items, query.Page, query.PageSize, total);
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(c => c.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(c => new CustomerDto(
|
||||
c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
|
||||
c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
|
||||
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<CustomerDto>.Create(await WithReceivablesAsync(rows, ct), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds each customer's receivable position — the unpaid balance and count of their Posted sales
|
||||
/// invoices, and their latest invoice payment — for just the customers on this page.
|
||||
/// </summary>
|
||||
private async Task<List<CustomerDto>> WithReceivablesAsync(List<CustomerDto> rows, CancellationToken ct)
|
||||
{
|
||||
if (rows.Count == 0) return rows;
|
||||
var ids = rows.Select(r => r.CustomerId).ToList();
|
||||
|
||||
var dues = await _invoices.Query().AsNoTracking()
|
||||
.Where(i => ids.Contains(i.CustomerId) && i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0)
|
||||
.GroupBy(i => i.CustomerId)
|
||||
.Select(g => new { CustomerId = g.Key, Amount = g.Sum(i => i.BalanceAmount), Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.CustomerId, ct);
|
||||
|
||||
var lastPaid = await _invoicePayments.Query().AsNoTracking()
|
||||
.Where(p => ids.Contains(p.SalesInvoice!.CustomerId))
|
||||
.GroupBy(p => p.SalesInvoice!.CustomerId)
|
||||
.Select(g => new { CustomerId = g.Key, Last = g.Max(p => p.PaymentDate) })
|
||||
.ToDictionaryAsync(x => x.CustomerId, x => x.Last, ct);
|
||||
|
||||
return rows.Select(r =>
|
||||
{
|
||||
var due = dues.GetValueOrDefault(r.CustomerId);
|
||||
return r with
|
||||
{
|
||||
OutstandingAmount = due?.Amount ?? 0m,
|
||||
OutstandingInvoiceCount = due?.Count ?? 0,
|
||||
LastPaymentDate = lastPaid.TryGetValue(r.CustomerId, out var last) ? last : null
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default)
|
||||
{
|
||||
var customer = await _customers.GetForReadAsync(customerId, ct);
|
||||
var customer = await _customers.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CustomerId == customerId, ct);
|
||||
return customer is null ? null : new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
|
||||
}
|
||||
|
||||
@@ -61,47 +122,48 @@ public sealed class CustomerService : ICustomerService
|
||||
{
|
||||
var code = request.CustomerCode.Trim();
|
||||
var name = request.Name.Trim();
|
||||
|
||||
if (await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower(), ct))
|
||||
throw new ConflictException($"A customer code '{code}' already exists.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Email))
|
||||
{
|
||||
var email = request.Email.Trim();
|
||||
if (await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == email.ToLower(), ct))
|
||||
throw new ConflictException($"A customer with email '{email}' already exists.");
|
||||
}
|
||||
|
||||
if (request.DefaultWarehouseId is not null)
|
||||
{
|
||||
var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
|
||||
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
|
||||
}
|
||||
|
||||
var fields = await GetFieldSwitchesAsync(ct);
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
|
||||
var customer = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var customer = new Customer
|
||||
{
|
||||
if (await _customers.CodeExistsAsync(code, ct: token))
|
||||
throw new ConflictException($"A customer code '{code}' already exists.");
|
||||
CustomerCode = code,
|
||||
CustomerType = request.CustomerType,
|
||||
Name = name,
|
||||
DisplayName = Normalize(request.DisplayName),
|
||||
Phone = Gate(fields.Phone, request.Phone, null, "phone numbers"),
|
||||
Email = Gate(fields.Email, request.Email, null, "email addresses"),
|
||||
AddressLine1 = Gate(fields.Address, request.AddressLine1, null, "addresses"),
|
||||
AddressLine2 = Gate(fields.Address, request.AddressLine2, null, "addresses"),
|
||||
City = Gate(fields.City, request.City, null, "cities"),
|
||||
Country = Gate(fields.Country, request.Country, null, "countries"),
|
||||
TaxRegistrationNo = Normalize(request.TaxRegistrationNo),
|
||||
CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, 0m, "credit limits"),
|
||||
CreditDays = Gate(fields.CreditDays, request.CreditDays, 0, "credit days"),
|
||||
DefaultWarehouseId = request.DefaultWarehouseId,
|
||||
Status = EntityStatus.Active,
|
||||
BranchId = branchId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Email))
|
||||
{
|
||||
var email = request.Email.Trim();
|
||||
if (await _customers.EmailExistsAsync(email, ct: token))
|
||||
throw new ConflictException($"A customer with email '{email}' already exists.");
|
||||
}
|
||||
|
||||
if (request.DefaultWarehouseId is { } warehouseId && !await _customers.WarehouseExistsAsync(warehouseId, token))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
|
||||
var entity = new Customer
|
||||
{
|
||||
CustomerCode = code,
|
||||
CustomerType = request.CustomerType,
|
||||
Name = name,
|
||||
DisplayName = Normalize(request.DisplayName),
|
||||
Phone = Gate(fields.Phone, request.Phone, null, "phone numbers"),
|
||||
Email = Gate(fields.Email, request.Email, null, "email addresses"),
|
||||
AddressLine1 = Gate(fields.Address, request.AddressLine1, null, "addresses"),
|
||||
AddressLine2 = Gate(fields.Address, request.AddressLine2, null, "addresses"),
|
||||
City = Gate(fields.City, request.City, null, "cities"),
|
||||
Country = Gate(fields.Country, request.Country, null, "countries"),
|
||||
TaxRegistrationNo = Normalize(request.TaxRegistrationNo),
|
||||
CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, 0m, "credit limits"),
|
||||
CreditDays = Gate(fields.CreditDays, request.CreditDays, 0, "credit days"),
|
||||
DefaultWarehouseId = request.DefaultWarehouseId,
|
||||
Status = EntityStatus.Active,
|
||||
BranchId = branchId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _customers.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
await _customers.AddAsync(customer, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
|
||||
}
|
||||
@@ -116,43 +178,44 @@ public sealed class CustomerService : ICustomerService
|
||||
|
||||
var code = request.CustomerCode.Trim();
|
||||
var name = request.Name.Trim();
|
||||
|
||||
if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal)
|
||||
&& await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower() && c.CustomerId != customerId, ct))
|
||||
throw new ConflictException($"A customer code '{code}' already exists.");
|
||||
|
||||
if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrWhiteSpace(request.Email)
|
||||
&& await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == request.Email!.Trim().ToLower() && c.CustomerId != customerId, ct))
|
||||
throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists.");
|
||||
|
||||
if (request.DefaultWarehouseId is not null)
|
||||
{
|
||||
var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
|
||||
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
|
||||
}
|
||||
|
||||
customer.CustomerCode = code;
|
||||
customer.CustomerType = request.CustomerType;
|
||||
customer.Name = name;
|
||||
customer.DisplayName = Normalize(request.DisplayName);
|
||||
// A field switched off in Sales Configuration keeps whatever is stored; only a *different* value is refused.
|
||||
var fields = await GetFieldSwitchesAsync(ct);
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
customer.Phone = Gate(fields.Phone, request.Phone, customer.Phone, "phone numbers");
|
||||
customer.Email = Gate(fields.Email, request.Email, customer.Email, "email addresses");
|
||||
customer.AddressLine1 = Gate(fields.Address, request.AddressLine1, customer.AddressLine1, "addresses");
|
||||
customer.AddressLine2 = Gate(fields.Address, request.AddressLine2, customer.AddressLine2, "addresses");
|
||||
customer.City = Gate(fields.City, request.City, customer.City, "cities");
|
||||
customer.Country = Gate(fields.Country, request.Country, customer.Country, "countries");
|
||||
customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo);
|
||||
customer.CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, customer.CreditLimit, "credit limits");
|
||||
customer.CreditDays = Gate(fields.CreditDays, request.CreditDays, customer.CreditDays, "credit days");
|
||||
customer.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||
customer.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal)
|
||||
&& await _customers.CodeExistsAsync(code, customerId, token))
|
||||
throw new ConflictException($"A customer code '{code}' already exists.");
|
||||
|
||||
if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrWhiteSpace(request.Email)
|
||||
&& await _customers.EmailExistsAsync(request.Email.Trim(), customerId, token))
|
||||
throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists.");
|
||||
|
||||
if (request.DefaultWarehouseId is { } warehouseId && !await _customers.WarehouseExistsAsync(warehouseId, token))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
|
||||
customer.CustomerCode = code;
|
||||
customer.CustomerType = request.CustomerType;
|
||||
customer.Name = name;
|
||||
customer.DisplayName = Normalize(request.DisplayName);
|
||||
customer.Phone = Gate(fields.Phone, request.Phone, customer.Phone, "phone numbers");
|
||||
customer.Email = Gate(fields.Email, request.Email, customer.Email, "email addresses");
|
||||
customer.AddressLine1 = Gate(fields.Address, request.AddressLine1, customer.AddressLine1, "addresses");
|
||||
customer.AddressLine2 = Gate(fields.Address, request.AddressLine2, customer.AddressLine2, "addresses");
|
||||
customer.City = Gate(fields.City, request.City, customer.City, "cities");
|
||||
customer.Country = Gate(fields.Country, request.Country, customer.Country, "countries");
|
||||
customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo);
|
||||
customer.CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, customer.CreditLimit, "credit limits");
|
||||
customer.CreditDays = Gate(fields.CreditDays, request.CreditDays, customer.CreditDays, "credit days");
|
||||
customer.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||
customer.BranchId = branchId;
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
@@ -167,12 +230,9 @@ public sealed class CustomerService : ICustomerService
|
||||
var customer = await _customers.GetByIdAsync(customerId, ct)
|
||||
?? throw new NotFoundException($"Customer {customerId} was not found.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
customer.Status = status;
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
customer.Status = status;
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static CustomerDto Map(Customer c) => new(
|
||||
@@ -183,9 +243,16 @@ public sealed class CustomerService : ICustomerService
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
/// <summary>Which optional customer fields Sales Configuration has switched on (all on when the row is missing).</summary>
|
||||
private async Task<CustomerFieldSwitches> GetFieldSwitchesAsync(CancellationToken ct) =>
|
||||
await _customers.GetFieldSwitchesAsync(ct)
|
||||
?? new CustomerFieldSwitches(true, true, true, true, true, true, true);
|
||||
private sealed record FieldSwitches(bool Phone, bool Email, bool Address, bool City, bool Country, bool CreditLimit, bool CreditDays);
|
||||
|
||||
private async Task<FieldSwitches> GetFieldSwitchesAsync(CancellationToken ct) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => new FieldSwitches(
|
||||
c.CustomerPhoneEnabled, c.CustomerEmailEnabled, c.CustomerAddressEnabled, c.CustomerCityEnabled,
|
||||
c.CustomerCountryEnabled, c.CustomerCreditLimitEnabled, c.CustomerCreditDaysEnabled))
|
||||
.FirstOrDefaultAsync(ct)
|
||||
?? new FieldSwitches(true, true, true, true, true, true, true);
|
||||
|
||||
/// <summary>On: the (trimmed) requested value. Off: the stored value is kept; a different value is a 422 CONFIG_DISABLED
|
||||
/// (the UI hides the field, so only a stale or hand-built request gets here).</summary>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
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,13 +1,10 @@
|
||||
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;
|
||||
|
||||
@@ -30,32 +27,7 @@ public sealed class DashboardService : IDashboardService
|
||||
private readonly IRepository<LeaveRequest> _leaveRequests;
|
||||
private readonly IRepository<SalesInvoice> _salesInvoices;
|
||||
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
|
||||
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;
|
||||
private readonly IReorderService _reorder;
|
||||
|
||||
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
|
||||
// has none), so "overdue" is approximated as a Posted invoice with an outstanding
|
||||
@@ -69,15 +41,7 @@ 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, 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)
|
||||
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
|
||||
{
|
||||
_layers = layers;
|
||||
_grns = grns;
|
||||
@@ -88,294 +52,68 @@ public sealed class DashboardService : IDashboardService
|
||||
_leaveRequests = leaveRequests;
|
||||
_salesInvoices = salesInvoices;
|
||||
_purchaseInvoices = purchaseInvoices;
|
||||
_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;
|
||||
_reorder = reorder;
|
||||
}
|
||||
|
||||
// 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)
|
||||
public async Task<DashboardStatsDto> GetStatsAsync(int? branchId, 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 = 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;
|
||||
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;
|
||||
// EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
|
||||
// Select — project to an anonymous type first, then materialize into the record.
|
||||
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 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 grns = _grns.Query().AsNoTracking();
|
||||
if (branchId is not null) grns = grns.Where(g => g.BranchId == branchId);
|
||||
var pendingGrns = canReceiving ? await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct) : 0;
|
||||
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct);
|
||||
|
||||
var counts = _counts.Query().AsNoTracking();
|
||||
if (branchId is not null) counts = counts.Where(c => c.BranchId == branchId);
|
||||
var pendingCounts = canCounts ? await counts.CountAsync(c => c.Status == CountStatus.Counted, ct) : 0;
|
||||
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
|
||||
|
||||
// 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 = 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;
|
||||
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);
|
||||
|
||||
// LeaveRequest carries no BranchId (like PO/Requisition/RFQ) — global regardless
|
||||
// of the selected branch.
|
||||
var pendingLeaveRequests = canLeave
|
||||
? await _leaveRequests.Query().AsNoTracking().CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct)
|
||||
: 0;
|
||||
var pendingLeaveRequests = await _leaveRequests.Query().AsNoTracking()
|
||||
.CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct);
|
||||
|
||||
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 = canSalesInvoices
|
||||
? await salesInvoices.CountAsync(i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0 && i.InvoiceDate < overdueSince, ct)
|
||||
: 0;
|
||||
var overdueSalesInvoices = await salesInvoices.CountAsync(
|
||||
i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0 && i.InvoiceDate < overdueSince, ct);
|
||||
|
||||
var lowStockAlerts = await lowStockTask;
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
|
||||
|
||||
return new DashboardStatsDto(
|
||||
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
|
||||
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||
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);
|
||||
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Accounts;
|
||||
using ERPCore.Dtos.Reports;
|
||||
@@ -6,32 +7,49 @@ using ERPCore.Infra.Auth;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Sales documents are stamped in
|
||||
/// UTC, so each business day is the <see cref="BusinessDayClock"/> calendar day converted to a UTC
|
||||
/// <c>[start, end)</c> window. The database does the filtering and returns slim projections
|
||||
/// (<see cref="IDayEndReportRepository"/>); the rows are bucketed into local days here, since MariaDB
|
||||
/// can't group by a time-zone-shifted date portably and a ≤ 92-day window of slim rows is small. Cost of
|
||||
/// goods sold is the stock ledger's Out value per sale document — the same source <see cref="SalesDayEndService"/>
|
||||
/// posts COGS from. The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's
|
||||
/// <c>[start, end)</c> window. The database does the filtering and returns slim projections; the
|
||||
/// rows are bucketed into local days here, since MariaDB can't group by a time-zone-shifted date
|
||||
/// portably and a ≤ 92-day window of slim rows is small. Cost of goods sold is the stock ledger's
|
||||
/// Out value per sale document — the same source <see cref="SalesDayEndService"/> posts COGS from.
|
||||
/// The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's
|
||||
/// <see cref="ICashInHandService"/> (docs/BANK_DEPOSIT_MODULE_PLAN.md §9); this report owns none of them.
|
||||
/// </summary>
|
||||
public sealed class DayEndReportService : IDayEndReportService
|
||||
{
|
||||
private const int MaxRangeDays = 92;
|
||||
private const int IdChunk = 1000;
|
||||
|
||||
private readonly IDayEndReportRepository _report;
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<Expense> _expenses;
|
||||
private readonly IRepository<BankDeposit> _deposits;
|
||||
private readonly ICashInHandService _cash;
|
||||
private readonly ICurrentBranchContext _currentBranch;
|
||||
private readonly BusinessDayClock _clock;
|
||||
|
||||
public DayEndReportService(
|
||||
IDayEndReportRepository report, ICashInHandService cash, ICurrentBranchContext currentBranch, BusinessDayClock clock)
|
||||
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoice> invoices,
|
||||
IRepository<SalesInvoicePayment> invoicePayments, IRepository<StockLedger> ledger,
|
||||
IRepository<Expense> expenses, IRepository<BankDeposit> deposits, ICashInHandService cash,
|
||||
ICurrentBranchContext currentBranch, BusinessDayClock clock)
|
||||
{
|
||||
_report = report;
|
||||
_slips = slips;
|
||||
_bundles = bundles;
|
||||
_invoices = invoices;
|
||||
_invoicePayments = invoicePayments;
|
||||
_ledger = ledger;
|
||||
_expenses = expenses;
|
||||
_deposits = deposits;
|
||||
_cash = cash;
|
||||
_currentBranch = currentBranch;
|
||||
_clock = clock;
|
||||
@@ -49,14 +67,20 @@ public sealed class DayEndReportService : IDayEndReportService
|
||||
s.DocType, s.DocId, s.DocNo, s.At, s.Customer, s.Total, s.Paid, s.Total - s.Paid, s.PaidBy, s.Cost, s.Total - s.Cost))
|
||||
.ToList();
|
||||
|
||||
var expenses = data.Expenses
|
||||
.Select(e => new DayEndReportExpenseDto(e.ExpenseId, e.DocNo, e.CategoryName, e.TitleName, e.Payee, e.Amount, e.PaidAmount))
|
||||
.ToList();
|
||||
var expenses = await PostedExpenses(date, date, branch)
|
||||
.OrderBy(e => e.ExpenseId)
|
||||
.Select(e => new DayEndReportExpenseDto(
|
||||
e.ExpenseId, e.DocNo, e.Category!.Name, e.Title!.Name, e.Payee, e.Amount, e.PaidAmount))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var deposits = (await _report.ListPostedDepositsAsync(date, branch, ct))
|
||||
var depositsQuery = _deposits.Query().AsNoTracking()
|
||||
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate == date);
|
||||
if (branch is not null) depositsQuery = depositsQuery.Where(d => d.BranchId == branch);
|
||||
var deposits = await depositsQuery
|
||||
.OrderBy(d => d.BankDepositId)
|
||||
.Select(d => new DayEndReportDepositDto(
|
||||
d.BankDepositId, d.DocNo, d.ToBankAccountName, d.ToBankName, d.ToAccountNumber, d.DepositSlipNo, d.DepositDate, d.Amount))
|
||||
.ToList();
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new DayEndReportDto(data.Days[0], sales, expenses, deposits, branch, _clock.Zone.Id);
|
||||
}
|
||||
@@ -81,23 +105,52 @@ public sealed class DayEndReportService : IDayEndReportService
|
||||
string DocType, int DocId, string DocNo, DateTime At, DateOnly Day, string Customer,
|
||||
decimal Total, decimal Paid, decimal Cash, decimal Card, decimal Other, string PaidBy, decimal Cost);
|
||||
|
||||
private sealed record Loaded(List<DayEndReportDayDto> Days, List<Sale> Sales, IReadOnlyList<DayEndExpenseRow> Expenses);
|
||||
private sealed record Loaded(List<DayEndReportDayDto> Days, List<Sale> Sales);
|
||||
|
||||
private async Task<Loaded> LoadAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct)
|
||||
{
|
||||
var start = _clock.ToUtc(from);
|
||||
var end = _clock.ToUtc(to.AddDays(1));
|
||||
|
||||
var slips = await _report.ListPostedSlipsAsync(start, end, branch, ct);
|
||||
var bundles = await _report.ListPostedBundlesAsync(start, end, branch, ct);
|
||||
var invoices = await _report.ListPostedInvoicesAsync(start, end, branch, ct);
|
||||
var payments = await _report.ListInvoicePaymentsAsync(start, end, branch, ct);
|
||||
var slipsQuery = _slips.Query().AsNoTracking()
|
||||
.Where(s => s.Status == SalesSlipStatus.Posted && s.SlipDate >= start && s.SlipDate < end);
|
||||
if (branch is not null) slipsQuery = slipsQuery.Where(s => s.Warehouse!.BranchId == branch);
|
||||
var slips = await slipsQuery
|
||||
.Select(s => new { s.SalesSlipId, s.SlipNo, s.SlipDate, s.CustomerSnapshotName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var slipCost = await _report.GetCostByDocAsync(DocumentTypes.SalesSlip, slips.Select(s => s.SalesSlipId), ct);
|
||||
var bundleCost = await _report.GetCostByDocAsync(DocumentTypes.BundleSale, bundles.Select(b => b.BundleSaleId), ct);
|
||||
var invoiceCost = await _report.GetCostByDocAsync(DocumentTypes.SalesInvoice, invoices.Select(i => i.SalesInvoiceId), ct);
|
||||
var bundlesQuery = _bundles.Query().AsNoTracking()
|
||||
.Where(b => b.Status == BundleSaleStatus.Posted && b.BundleDate >= start && b.BundleDate < end);
|
||||
if (branch is not null) bundlesQuery = bundlesQuery.Where(b => b.Warehouse!.BranchId == branch);
|
||||
var bundles = await bundlesQuery
|
||||
.Select(b => new { b.BundleSaleId, b.BundleNo, b.BundleDate, b.CustomerSnapshotName, b.GrandTotal, b.CashAmount, b.CardAmount })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var expenses = await _report.ListPostedExpensesAsync(from, to, branch, ct);
|
||||
var invoicesQuery = _invoices.Query().AsNoTracking()
|
||||
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= start && i.InvoiceDate < end);
|
||||
if (branch is not null) invoicesQuery = invoicesQuery.Where(i => i.BranchId == branch);
|
||||
var invoices = await invoicesQuery
|
||||
.Select(i => new { i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, i.CustomerSnapshotName, i.NetPayable, i.PaidAmount })
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Every payment that matters to the window: those against its invoices (the method split of
|
||||
// Paid), and those received inside it against earlier invoices (collections / net cash).
|
||||
var paymentsQuery = _invoicePayments.Query().AsNoTracking()
|
||||
.Where(p => p.SalesInvoice!.Status == SalesInvoiceStatus.Posted
|
||||
&& ((p.SalesInvoice.InvoiceDate >= start && p.SalesInvoice.InvoiceDate < end)
|
||||
|| (p.PaymentDate >= start && p.PaymentDate < end)));
|
||||
if (branch is not null) paymentsQuery = paymentsQuery.Where(p => p.SalesInvoice!.BranchId == branch);
|
||||
var payments = await paymentsQuery
|
||||
.Select(p => new { p.SalesInvoiceId, InvoiceDate = p.SalesInvoice!.InvoiceDate, p.PaymentDate, p.PaymentMethod, p.Amount })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var slipCost = await CostByDocAsync(DocumentTypes.SalesSlip, slips.Select(s => s.SalesSlipId), ct);
|
||||
var bundleCost = await CostByDocAsync(DocumentTypes.BundleSale, bundles.Select(b => b.BundleSaleId), ct);
|
||||
var invoiceCost = await CostByDocAsync(DocumentTypes.SalesInvoice, invoices.Select(i => i.SalesInvoiceId), ct);
|
||||
|
||||
var expenses = await PostedExpenses(from, to, branch)
|
||||
.Select(e => new { e.ExpenseDate, e.Amount })
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Branch is already the effective one, so the cash service scopes it the same way.
|
||||
var cashByDay = (await _cash.GetRangeAsync(from, to, branch, ct)).ToDictionary(c => c.Date);
|
||||
@@ -106,11 +159,11 @@ public sealed class DayEndReportService : IDayEndReportService
|
||||
|
||||
var sales = new List<Sale>(slips.Count + bundles.Count + invoices.Count);
|
||||
sales.AddRange(slips.Select(s => new Sale(
|
||||
"Slip", s.SalesSlipId, s.SlipNo, s.SlipDate, LocalDay(s.SlipDate), s.CustomerName,
|
||||
"Slip", s.SalesSlipId, s.SlipNo, s.SlipDate, LocalDay(s.SlipDate), s.CustomerSnapshotName,
|
||||
s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount, 0m, PaidBy(s.CashAmount, s.CardAmount, 0m, null),
|
||||
slipCost.GetValueOrDefault(s.SalesSlipId))));
|
||||
sales.AddRange(bundles.Select(b => new Sale(
|
||||
"Bundle", b.BundleSaleId, b.BundleNo, b.BundleDate, LocalDay(b.BundleDate), b.CustomerName,
|
||||
"Bundle", b.BundleSaleId, b.BundleNo, b.BundleDate, LocalDay(b.BundleDate), b.CustomerSnapshotName,
|
||||
b.GrandTotal, b.GrandTotal, b.CashAmount, b.CardAmount, 0m, PaidBy(b.CashAmount, b.CardAmount, 0m, null),
|
||||
bundleCost.GetValueOrDefault(b.BundleSaleId))));
|
||||
foreach (var i in invoices)
|
||||
@@ -122,7 +175,7 @@ public sealed class DayEndReportService : IDayEndReportService
|
||||
var other = others.Sum(p => p.Amount);
|
||||
var otherMethods = others.Select(p => p.PaymentMethod).Distinct().ToList();
|
||||
sales.Add(new Sale(
|
||||
"Invoice", i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, LocalDay(i.InvoiceDate), i.CustomerName,
|
||||
"Invoice", i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, LocalDay(i.InvoiceDate), i.CustomerSnapshotName,
|
||||
i.NetPayable, i.PaidAmount, cash, card, other,
|
||||
PaidBy(cash, card, other, otherMethods.Count == 1 ? otherMethods[0] : null),
|
||||
invoiceCost.GetValueOrDefault(i.SalesInvoiceId)));
|
||||
@@ -142,7 +195,23 @@ public sealed class DayEndReportService : IDayEndReportService
|
||||
|
||||
days.Add(acc.ToDto(d));
|
||||
}
|
||||
return new Loaded(days, sales, expenses);
|
||||
return new Loaded(days, sales);
|
||||
}
|
||||
|
||||
/// <summary>Sum of the stock ledger's Out value per document — the FIFO cost the sale consumed.</summary>
|
||||
private async Task<Dictionary<int, decimal>> CostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<int, decimal>();
|
||||
foreach (var chunk in docIds.Distinct().Chunk(IdChunk))
|
||||
{
|
||||
var rows = await _ledger.Query().AsNoTracking()
|
||||
.Where(l => l.SourceDocType == docType && l.Direction == Direction.Out && chunk.Contains(l.SourceDocId))
|
||||
.GroupBy(l => l.SourceDocId)
|
||||
.Select(g => new { DocId = g.Key, Cost = g.Sum(l => l.Value) })
|
||||
.ToListAsync(ct);
|
||||
foreach (var r in rows) result[r.DocId] = r.Cost;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string PaidBy(decimal cash, decimal card, decimal other, SalesPaymentMethod? singleOther)
|
||||
@@ -155,6 +224,13 @@ public sealed class DayEndReportService : IDayEndReportService
|
||||
return singleOther == SalesPaymentMethod.Cheque ? "Cheque" : "Bank transfer";
|
||||
}
|
||||
|
||||
private IQueryable<Expense> PostedExpenses(DateOnly from, DateOnly to, int? branch)
|
||||
{
|
||||
var q = _expenses.Query().AsNoTracking()
|
||||
.Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= from && e.ExpenseDate <= to);
|
||||
return branch is null ? q : q.Where(e => e.BranchId == branch);
|
||||
}
|
||||
|
||||
private DateOnly LocalDay(DateTime utc) => _clock.LocalDay(utc);
|
||||
|
||||
/// <summary>Flows are summed; cash in hand is a balance, so the range opens with its first day and closes with its last.</summary>
|
||||
|
||||
@@ -7,6 +7,7 @@ using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -17,11 +18,11 @@ namespace ERPCore.Services;
|
||||
/// all sharing that journal number. Same call-GL-before-commit pattern as
|
||||
/// <see cref="PurchaseInvoicePaymentService"/>, so a rejected/unreachable GL post rolls the
|
||||
/// whole payment back. Post & Pay runs the accrual and a full or partial payment in one transaction.
|
||||
/// Its own queries go through <see cref="IExpensePaymentRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class ExpensePaymentService : IExpensePaymentService
|
||||
{
|
||||
private readonly IExpensePaymentRepository _payments;
|
||||
private readonly IRepository<Expense> _expenses;
|
||||
private readonly IRepository<ExpensePayment> _payments;
|
||||
private readonly ExpenseService _expenseService;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -29,9 +30,10 @@ public sealed class ExpensePaymentService : IExpensePaymentService
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public ExpensePaymentService(
|
||||
IExpensePaymentRepository payments, ExpenseService expenseService,
|
||||
IRepository<Expense> expenses, IRepository<ExpensePayment> payments, ExpenseService expenseService,
|
||||
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_expenses = expenses;
|
||||
_payments = payments;
|
||||
_expenseService = expenseService;
|
||||
_gl = gl;
|
||||
@@ -91,10 +93,13 @@ public sealed class ExpensePaymentService : IExpensePaymentService
|
||||
|
||||
public async Task<IReadOnlyList<ExpensePaymentDto>> ListAsync(int expenseId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _payments.ExpenseExistsAsync(expenseId, ct))
|
||||
if (!await _expenses.Query().AnyAsync(x => x.ExpenseId == expenseId, ct))
|
||||
throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||
|
||||
var payments = await _payments.ListForExpenseAsync(expenseId, ct);
|
||||
var payments = await _payments.Query().AsNoTracking()
|
||||
.Where(p => p.ExpenseId == expenseId)
|
||||
.OrderByDescending(p => p.ExpensePaymentId)
|
||||
.ToListAsync(ct);
|
||||
return payments.Select(ExpenseService.MapPayment).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IExpenseRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -20,12 +20,13 @@ namespace ERPCore.Services;
|
||||
/// real GL journal entry posted before commit — same atomicity approach as
|
||||
/// <see cref="PurchaseInvoiceService.PostAsync"/>, so a GL rejection rolls the post back.
|
||||
/// Payments settle the accrual (<see cref="ExpensePaymentService"/>). Cancelling a Posted
|
||||
/// expense with no payments posts the reversing entry. Queries go through <see cref="IExpenseRepository"/>;
|
||||
/// every write runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// expense with no payments posts the reversing entry.
|
||||
/// </summary>
|
||||
public sealed class ExpenseService : IExpenseService
|
||||
{
|
||||
private readonly IExpenseRepository _expenses;
|
||||
private readonly IRepository<Expense> _expenses;
|
||||
private readonly IRepository<ExpenseTitle> _titles;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
@@ -34,10 +35,13 @@ public sealed class ExpenseService : IExpenseService
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public ExpenseService(
|
||||
IExpenseRepository expenses, ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig,
|
||||
IRepository<Expense> expenses, IRepository<ExpenseTitle> titles, IRepository<Vendor> vendors,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_expenses = expenses;
|
||||
_titles = titles;
|
||||
_vendors = vendors;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
_branchConfig = branchConfig;
|
||||
@@ -48,8 +52,30 @@ public sealed class ExpenseService : IExpenseService
|
||||
|
||||
public async Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var (rows, total) = await _expenses.ListPagedAsync(query.Q, filter, today, query.Skip, query.PageSize, ct);
|
||||
IQueryable<Expense> q = _expenses.Query().AsNoTracking().Include(x => x.Category).Include(x => x.Title);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|
||||
|| (x.Payee != null && EF.Functions.Like(x.Payee, $"%{term}%"))
|
||||
|| (x.BillReference != null && EF.Functions.Like(x.BillReference, $"%{term}%")));
|
||||
}
|
||||
if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status);
|
||||
if (filter.PaymentStatus is not null) q = q.Where(x => x.Status == ExpenseStatus.Posted && x.PaymentStatus == filter.PaymentStatus);
|
||||
if (filter.CategoryId is not null) q = q.Where(x => x.ExpenseCategoryId == filter.CategoryId);
|
||||
if (filter.TitleId is not null) q = q.Where(x => x.ExpenseTitleId == filter.TitleId);
|
||||
if (filter.From is not null) q = q.Where(x => x.ExpenseDate >= filter.From);
|
||||
if (filter.To is not null) q = q.Where(x => x.ExpenseDate <= filter.To);
|
||||
if (filter.OverdueOnly)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
q = q.Where(x => x.Status == ExpenseStatus.Posted && x.BalanceAmount > 0 && x.DueDate != null && x.DueDate < today);
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.ExpenseDate).ThenByDescending(x => x.ExpenseId)
|
||||
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ExpenseSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
@@ -103,41 +129,26 @@ public sealed class ExpenseService : IExpenseService
|
||||
var vendorName = await ValidateVendorAsync(request.VendorId, ct);
|
||||
ValidateDates(request);
|
||||
|
||||
var branchId = request.BranchId > 0 && request.BranchId != expense.BranchId
|
||||
? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct)
|
||||
: expense.BranchId;
|
||||
if (request.BranchId > 0 && request.BranchId != expense.BranchId)
|
||||
expense.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
expense.BranchId = branchId;
|
||||
Apply(expense, request, vendorName);
|
||||
expense.Category = title.Category;
|
||||
expense.Title = title;
|
||||
expense.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412);
|
||||
}
|
||||
Apply(expense, request, vendorName);
|
||||
expense.Category = title.Category;
|
||||
expense.Title = title;
|
||||
expense.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveAsync(ct);
|
||||
return new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int expenseId, CancellationToken ct = default)
|
||||
{
|
||||
var expense = await _expenses.GetByIdAsync(expenseId, ct)
|
||||
var expense = await _expenses.Query().FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct)
|
||||
?? throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||
EnsureDraft(expense, "deleted");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
_expenses.Remove(expense);
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
_expenses.Remove(expense);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default)
|
||||
@@ -240,10 +251,22 @@ public sealed class ExpenseService : IExpenseService
|
||||
if (end < start)
|
||||
throw new DomainException(ErrorCodes.Validation, "'to' must not be before 'from'.", 400);
|
||||
|
||||
var inRange = await _expenses.ListPostedInRangeAsync(start, end, ct);
|
||||
var outstanding = await _expenses.ListOutstandingAsync(ct);
|
||||
var posted = _expenses.Query().AsNoTracking().Where(x => x.Status == ExpenseStatus.Posted);
|
||||
|
||||
var inRange = await posted
|
||||
.Where(x => x.ExpenseDate >= start && x.ExpenseDate <= end)
|
||||
.Select(x => new
|
||||
{
|
||||
x.ExpenseCategoryId, CategoryName = x.Category!.Name, CategorySort = x.Category.SortOrder,
|
||||
x.ExpenseTitleId, TitleName = x.Title!.Name, x.Amount, x.PaidAmount, x.BalanceAmount
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var outstanding = await posted.Where(x => x.BalanceAmount > 0)
|
||||
.Select(x => new { x.BalanceAmount, x.DueDate })
|
||||
.ToListAsync(ct);
|
||||
var overdue = outstanding.Where(x => x.DueDate != null && x.DueDate < today).ToList();
|
||||
var draftCount = await _expenses.CountDraftsAsync(ct);
|
||||
var draftCount = await _expenses.Query().CountAsync(x => x.Status == ExpenseStatus.Draft, ct);
|
||||
|
||||
var categories = inRange
|
||||
.GroupBy(x => new { x.ExpenseCategoryId, x.CategoryName, x.CategorySort })
|
||||
@@ -263,14 +286,22 @@ public sealed class ExpenseService : IExpenseService
|
||||
categories);
|
||||
}
|
||||
|
||||
/// <summary>An expense with its category, title, vendor and payments. Shared with <see cref="ExpensePaymentService"/>.</summary>
|
||||
internal Task<Expense?> LoadAsync(int expenseId, bool tracking, CancellationToken ct)
|
||||
=> _expenses.GetWithDetailsAsync(expenseId, tracking, ct);
|
||||
{
|
||||
var q = _expenses.Query()
|
||||
.Include(x => x.Category)
|
||||
.Include(x => x.Title)
|
||||
.Include(x => x.Vendor)
|
||||
.Include(x => x.Payments);
|
||||
return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct);
|
||||
}
|
||||
|
||||
private async Task<ExpenseTitle> ValidateTitleAsync(ExpenseRequestBase request, CancellationToken ct)
|
||||
{
|
||||
// Tracked on purpose (see IExpenseRepository.GetTitleWithCategoryAsync).
|
||||
var title = await _expenses.GetTitleWithCategoryAsync(request.ExpenseTitleId, ct)
|
||||
// Tracked on purpose: UpdateAsync assigns this to the tracked expense's navigations, and
|
||||
// identity resolution hands back the instance the expense already loaded.
|
||||
var title = await _titles.Query().Include(t => t.Category)
|
||||
.FirstOrDefaultAsync(t => t.ExpenseTitleId == request.ExpenseTitleId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Expense title {request.ExpenseTitleId} does not exist.", 422);
|
||||
|
||||
if (title.ExpenseCategoryId != request.ExpenseCategoryId)
|
||||
@@ -284,7 +315,7 @@ public sealed class ExpenseService : IExpenseService
|
||||
private async Task<string?> ValidateVendorAsync(int? vendorId, CancellationToken ct)
|
||||
{
|
||||
if (vendorId is null) return null;
|
||||
return await _expenses.GetVendorNameAsync(vendorId.Value, ct)
|
||||
return await _vendors.Query().AsNoTracking().Where(v => v.VendorId == vendorId).Select(v => v.Name).FirstOrDefaultAsync(ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
|
||||
}
|
||||
|
||||
@@ -321,6 +352,18 @@ public sealed class ExpenseService : IExpenseService
|
||||
throw new DomainException(ErrorCodes.ExpenseNotDraft, $"Expense {expense.DocNo} is {expense.Status} and cannot be {verb}.", 409);
|
||||
}
|
||||
|
||||
private async Task SaveAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
internal static ExpensePaymentStatus DerivePaymentStatus(decimal paidAmount, decimal balanceAmount) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -14,108 +15,109 @@ namespace ERPCore.Services;
|
||||
/// deactivated, and a title's GL account code can be corrected — while user-added titles
|
||||
/// are fully editable. Nothing is hard-deleted: expenses reference titles, so retiring one
|
||||
/// means setting it Inactive (it stops being offered on new expenses).
|
||||
/// Queries go through <see cref="IExpenseSetupRepository"/>; writes run inside
|
||||
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// </summary>
|
||||
public sealed class ExpenseSetupService : IExpenseSetupService
|
||||
{
|
||||
private readonly IExpenseSetupRepository _setup;
|
||||
private readonly IRepository<ExpenseCategory> _categories;
|
||||
private readonly IRepository<ExpenseTitle> _titles;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ExpenseSetupService(IExpenseSetupRepository setup, IUnitOfWork uow)
|
||||
public ExpenseSetupService(IRepository<ExpenseCategory> categories, IRepository<ExpenseTitle> titles, IUnitOfWork uow)
|
||||
{
|
||||
_setup = setup;
|
||||
_categories = categories;
|
||||
_titles = titles;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default)
|
||||
{
|
||||
var categories = await _setup.ListWithTitlesAsync(includeInactive, ct);
|
||||
var categories = await _categories.Query().AsNoTracking()
|
||||
.Include(c => c.Titles)
|
||||
.Where(c => includeInactive || c.Status == EntityStatus.Active)
|
||||
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return categories.Select(c => Map(c, includeInactive)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _setup.GetWithTitlesForUpdateAsync(expenseCategoryId, ct)
|
||||
var category = await _categories.Query().Include(c => c.Titles)
|
||||
.FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct)
|
||||
?? throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (name != category.Name && category.IsSystem)
|
||||
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{category.Name}' is a built-in category and cannot be renamed.", 409);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
if (name != category.Name)
|
||||
{
|
||||
if (name != category.Name)
|
||||
{
|
||||
if (await _setup.CategoryNameExistsAsync(name, expenseCategoryId, token))
|
||||
throw new DomainException(ErrorCodes.DuplicateExpenseCategory, $"An expense category named '{name}' already exists.", 409);
|
||||
category.Name = name;
|
||||
}
|
||||
if (category.IsSystem)
|
||||
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{category.Name}' is a built-in category and cannot be renamed.", 409);
|
||||
if (await _categories.Query().AnyAsync(c => c.Name == name && c.ExpenseCategoryId != expenseCategoryId, ct))
|
||||
throw new DomainException(ErrorCodes.DuplicateExpenseCategory, $"An expense category named '{name}' already exists.", 409);
|
||||
category.Name = name;
|
||||
}
|
||||
|
||||
category.Status = request.Status;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
category.Status = request.Status;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(category, includeInactive: true);
|
||||
}
|
||||
|
||||
public async Task<ExpenseTitleDto> CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _setup.ExistsAsync(expenseCategoryId, ct))
|
||||
if (!await _categories.Query().AnyAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct))
|
||||
throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
|
||||
|
||||
var name = request.Name.Trim();
|
||||
await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, ct);
|
||||
|
||||
var title = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var nextSort = (await _titles.Query().Where(t => t.ExpenseCategoryId == expenseCategoryId)
|
||||
.MaxAsync(t => (int?)t.SortOrder, ct) ?? 0) + 1;
|
||||
var title = new ExpenseTitle
|
||||
{
|
||||
await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, token);
|
||||
|
||||
var entity = new ExpenseTitle
|
||||
{
|
||||
ExpenseCategoryId = expenseCategoryId,
|
||||
Name = name,
|
||||
GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(),
|
||||
IsSystem = false,
|
||||
SortOrder = await _setup.MaxTitleSortOrderAsync(expenseCategoryId, token) + 1,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _setup.AddTitleAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
ExpenseCategoryId = expenseCategoryId,
|
||||
Name = name,
|
||||
GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(),
|
||||
IsSystem = false,
|
||||
SortOrder = nextSort,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _titles.AddAsync(title, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(title);
|
||||
}
|
||||
|
||||
public async Task<ExpenseTitleDto> UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var title = await _setup.GetTitleForUpdateAsync(expenseTitleId, ct)
|
||||
var title = await _titles.Query().FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct)
|
||||
?? throw new NotFoundException($"Expense title {expenseTitleId} was not found.");
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (name != title.Name && title.IsSystem)
|
||||
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{title.Name}' is a built-in title and cannot be renamed.", 409);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
if (name != title.Name)
|
||||
{
|
||||
if (name != title.Name)
|
||||
{
|
||||
await EnsureTitleNameIsUniqueAsync(title.ExpenseCategoryId, name, excludingTitleId: expenseTitleId, token);
|
||||
title.Name = name;
|
||||
}
|
||||
if (title.IsSystem)
|
||||
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{title.Name}' is a built-in title and cannot be renamed.", 409);
|
||||
await EnsureTitleNameIsUniqueAsync(title.ExpenseCategoryId, name, excludingTitleId: expenseTitleId, ct);
|
||||
title.Name = name;
|
||||
}
|
||||
|
||||
// The GL code is editable on system rows too — the seeded codes are placeholders.
|
||||
// Already-posted expenses keep the code they snapshotted at post time.
|
||||
title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim();
|
||||
title.Status = request.Status;
|
||||
title.UpdatedAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
// The GL code is editable on system rows too — the seeded codes are placeholders.
|
||||
// Already-posted expenses keep the code they snapshotted at post time.
|
||||
title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim();
|
||||
title.Status = request.Status;
|
||||
title.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(title);
|
||||
}
|
||||
|
||||
private async Task EnsureTitleNameIsUniqueAsync(int expenseCategoryId, string name, int? excludingTitleId, CancellationToken ct)
|
||||
{
|
||||
if (await _setup.TitleNameExistsAsync(expenseCategoryId, name, excludingTitleId, ct))
|
||||
var duplicate = await _titles.Query().AnyAsync(t =>
|
||||
t.ExpenseCategoryId == expenseCategoryId && t.Name == name
|
||||
&& (excludingTitleId == null || t.ExpenseTitleId != excludingTitleId), ct);
|
||||
if (duplicate)
|
||||
throw new DomainException(ErrorCodes.DuplicateExpenseTitle, $"A title named '{name}' already exists in this category.", 409);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -18,16 +19,27 @@ namespace ERPCore.Services;
|
||||
/// for PO-based receipts — 02-SECURITY C.3; over-receipt tolerance enforced).
|
||||
/// Confirm posts FIFO layers + inbound ledger and updates PO receipts inside one
|
||||
/// UoW transaction (NFR-02/05). Quantities are converted to the item's base UOM
|
||||
/// for the ledger/layers (FR-MD-03). Queries go through <see cref="IGrnRepository"/>; every write runs
|
||||
/// inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// for the ledger/layers (FR-MD-03).
|
||||
/// </summary>
|
||||
public sealed class GrnService : IGrnService
|
||||
{
|
||||
// Phase 1: block any receipt beyond the PO line's open quantity (configurable later, NFR-10).
|
||||
private const decimal OverReceiptTolerance = 0m;
|
||||
|
||||
private readonly IGrnRepository _grns;
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly IRepository<PurchaseOrder> _pos;
|
||||
private readonly IRepository<PoLine> _poLines;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Bin> _bins;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<Serial> _serials;
|
||||
private readonly IRepository<SerialMovement> _serialMovements;
|
||||
private readonly ISerialTrackingService _serialTracking;
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<TaxRate> _taxRates;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -40,12 +52,28 @@ public sealed class GrnService : IGrnService
|
||||
private readonly IProcurementConfigService _procurementConfig;
|
||||
|
||||
public GrnService(
|
||||
IGrnRepository grns, ISerialTrackingService serialTracking, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, IRepository<Serial> serials, IRepository<SerialMovement> serialMovements, ISerialTrackingService serialTracking,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts,
|
||||
IProcurementConfigService procurementConfig)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
_poLines = poLines;
|
||||
_items = items;
|
||||
_warehouses = warehouses;
|
||||
_bins = bins;
|
||||
_vendors = vendors;
|
||||
_batches = batches;
|
||||
_serials = serials;
|
||||
_serialMovements = serialMovements;
|
||||
_serialTracking = serialTracking;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_taxRates = taxRates;
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
@@ -62,17 +90,60 @@ public sealed class GrnService : IGrnService
|
||||
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId,
|
||||
DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
||||
{
|
||||
var q = _grns.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
// Doc no., the PO's doc no., or the vendor's name / code (the vendor is the table's first column).
|
||||
var vendorIdsMatching = _vendors.Query()
|
||||
.Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%"))
|
||||
.Select(v => v.VendorId);
|
||||
var poIdsMatching = _pos.Query()
|
||||
.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%"))
|
||||
.Select(p => p.PoId);
|
||||
q = q.Where(g => EF.Functions.Like(g.DocNo, $"%{term}%")
|
||||
|| vendorIdsMatching.Contains(g.VendorId)
|
||||
|| (g.PoId != null && poIdsMatching.Contains(g.PoId.Value)));
|
||||
}
|
||||
if (status is not null) q = q.Where(g => g.Status == status);
|
||||
if (poId is not null) q = q.Where(g => g.PoId == poId);
|
||||
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
|
||||
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
|
||||
// Warehouses are shared across branches — GRN carries its own BranchId, set at creation.
|
||||
if (branchId is not null) q = q.Where(g => g.BranchId == branchId);
|
||||
// Created date, as a business (Colombo) day: [start of `from`, start of the day after `to`).
|
||||
var criteria = new GrnListCriteria(
|
||||
query.Q, status, poId, vendorId, warehouseId, branchId,
|
||||
from is DateOnly fromDay ? BusinessClock.StartOfDayUtc(fromDay) : null,
|
||||
to is DateOnly toDay ? BusinessClock.StartOfDayUtc(toDay.AddDays(1)) : null,
|
||||
query.Sort);
|
||||
var (rows, total) = await _grns.ListPagedAsync(criteria, query.Skip, query.PageSize, ct);
|
||||
if (from is DateOnly fromDay)
|
||||
{
|
||||
var start = BusinessClock.StartOfDayUtc(fromDay);
|
||||
q = q.Where(g => g.CreatedAt >= start);
|
||||
}
|
||||
if (to is DateOnly toDay)
|
||||
{
|
||||
var end = BusinessClock.StartOfDayUtc(toDay.AddDays(1));
|
||||
q = q.Where(g => g.CreatedAt < end);
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await ApplySort(q, query.Sort)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(g => new
|
||||
{
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, LineCount = g.Lines.Count, TotalValue = g.Lines.Sum(l => l.LineTotal),
|
||||
g.PurchaseInvoiceId, PurchaseInvoiceNo = g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var flags = await _procurementConfig.GetFlagsAsync(ct);
|
||||
var vendorMap = await _grns.GetVendorsAsync(rows.Select(r => r.VendorId).Distinct().ToList(), ct);
|
||||
var poDocNos = await _grns.GetPoDocNosAsync(rows.Where(r => r.PoId != null).Select(r => r.PoId!.Value).Distinct().ToList(), ct);
|
||||
var vendorIds = rows.Select(r => r.VendorId).Distinct().ToList();
|
||||
var vendorMap = await _vendors.Query().AsNoTracking()
|
||||
.Where(v => vendorIds.Contains(v.VendorId))
|
||||
.ToDictionaryAsync(v => v.VendorId, ct);
|
||||
var poIds = rows.Where(r => r.PoId != null).Select(r => r.PoId!.Value).Distinct().ToList();
|
||||
var poDocNos = await _pos.Query().AsNoTracking()
|
||||
.Where(p => poIds.Contains(p.PoId))
|
||||
.ToDictionaryAsync(p => p.PoId, p => p.DocNo, ct);
|
||||
|
||||
var dtos = rows.Select(r => new GrnSummaryDto(
|
||||
r.GrnId, r.DocNo, r.PoId, r.VendorId, r.WarehouseId, r.BranchId, r.Status,
|
||||
@@ -90,9 +161,37 @@ public sealed class GrnService : IGrnService
|
||||
return PagedResponse<GrnSummaryDto>.Create(dtos, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side sort so paging stays correct: <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading
|
||||
/// <c>-</c> for descending. Default (and any unknown key) is newest first; GrnId is the tiebreaker.
|
||||
/// </summary>
|
||||
private IQueryable<Grn> ApplySort(IQueryable<Grn> q, string? sort)
|
||||
{
|
||||
var key = (sort ?? "-created").Trim();
|
||||
var desc = key.StartsWith('-');
|
||||
key = key.TrimStart('-', '+').ToLowerInvariant();
|
||||
|
||||
IOrderedQueryable<Grn> ordered = key switch
|
||||
{
|
||||
"vendor" => desc
|
||||
? q.OrderByDescending(g => _vendors.Query().Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault())
|
||||
: q.OrderBy(g => _vendors.Query().Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault()),
|
||||
"total" => desc ? q.OrderByDescending(g => g.Lines.Sum(l => l.LineTotal)) : q.OrderBy(g => g.Lines.Sum(l => l.LineTotal)),
|
||||
"status" => desc ? q.OrderByDescending(g => g.Status) : q.OrderBy(g => g.Status),
|
||||
"created" => desc ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
|
||||
_ => q.OrderByDescending(g => g.CreatedAt)
|
||||
};
|
||||
return ordered.ThenByDescending(g => g.GrnId);
|
||||
}
|
||||
|
||||
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.GetWithDetailsAsync(grnId, ct);
|
||||
var grn = await _grns.Query().AsNoTracking()
|
||||
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.Serials)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.Batch)
|
||||
.Include(g => g.PurchaseInvoice)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
return grn is null ? null : Map(grn);
|
||||
}
|
||||
|
||||
@@ -102,7 +201,7 @@ public sealed class GrnService : IGrnService
|
||||
if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId)
|
||||
request.WarehouseId = forcedWarehouseId;
|
||||
|
||||
if (!await _grns.WarehouseExistsAsync(request.WarehouseId, ct))
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
// Resolve vendor + PO context.
|
||||
@@ -110,7 +209,8 @@ public sealed class GrnService : IGrnService
|
||||
int vendorId;
|
||||
if (request.PoId is not null)
|
||||
{
|
||||
po = await _grns.GetPoWithLinesAsync(request.PoId.Value, ct)
|
||||
po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
|
||||
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
|
||||
throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
|
||||
@@ -120,7 +220,7 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
if (request.VendorId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
|
||||
if (!await _grns.VendorExistsAsync(request.VendorId.Value, ct))
|
||||
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
vendorId = request.VendorId.Value;
|
||||
}
|
||||
@@ -141,9 +241,9 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
var item = await _grns.GetItemAsync(input.ItemId, ct)
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||
if (input.BinId is int binId && !await _grns.BinIsInWarehouseAsync(binId, request.WarehouseId, ct))
|
||||
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||
|
||||
// Cost: for a PO line, the PO price is used unless an override is entered (then it
|
||||
@@ -181,7 +281,7 @@ public sealed class GrnService : IGrnService
|
||||
// equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule.
|
||||
// GRN Configuration "Allow VAT" off: every line is 0% — the item's tax rate is not applied either.
|
||||
var vatPct = !allowVat ? 0m : input.VatPct ?? (item.TaxRateId is int taxRateId
|
||||
? await _grns.GetTaxPercentageAsync(taxRateId, ct)
|
||||
? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct)
|
||||
: 0m);
|
||||
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
@@ -237,7 +337,9 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
public async Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.GetForConfirmAsync(grnId, ct)
|
||||
var grn = await _grns.Query()
|
||||
.Include(g => g.Lines).ThenInclude(l => l.Serials)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
// Idempotent replay: an already-confirmed GRN returns its existing result.
|
||||
@@ -292,7 +394,7 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
if (line.PoLineId is not null)
|
||||
{
|
||||
var poLine = await _grns.GetPoLineForUpdateAsync(line.PoLineId.Value, token);
|
||||
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
|
||||
if (poLine is not null) poLine.QtyReceived += line.Qty;
|
||||
}
|
||||
}
|
||||
@@ -346,7 +448,9 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
public async Task<ReleaseLineResultDto> ReleaseLineAsync(int grnId, int grnLineId, string action, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.GetWithLinesForUpdateAsync(grnId, ct)
|
||||
var grn = await _grns.Query()
|
||||
.Include(g => g.Lines)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId)
|
||||
@@ -357,23 +461,23 @@ public sealed class GrnService : IGrnService
|
||||
if (line.HoldStatus != HoldStatus.OnHold)
|
||||
throw new ConflictException($"GRN line {grnLineId} is {line.HoldStatus}, not OnHold.");
|
||||
|
||||
// Release: the stock becomes issuable. Reject: the held stock stays exactly where it is —
|
||||
// still on the layer, still excluded from issuable on-hand (FifoCostingService.ConsumeAsync
|
||||
// excludes both OnHold and Rejected lines) — until a Purchase Return actually ships it back
|
||||
// to the vendor. That return (not this action) posts the real outbound ledger entry, scoped
|
||||
// to this line's own layer(s), so the movement is attributable to a real document instead of
|
||||
// vanishing against the GRN itself (§3.4).
|
||||
var next = string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase)
|
||||
? HoldStatus.Available
|
||||
: HoldStatus.Rejected;
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
if (string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
line.HoldStatus = next;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
line.HoldStatus = HoldStatus.Available;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new ReleaseLineResultDto(grnLineId, HoldStatus.Available);
|
||||
}
|
||||
|
||||
return new ReleaseLineResultDto(grnLineId, next);
|
||||
// Reject: the held stock stays exactly where it is — still on the layer, still
|
||||
// excluded from issuable on-hand (FifoCostingService.ConsumeAsync excludes both
|
||||
// OnHold and Rejected lines) — until a Purchase Return actually ships it back to
|
||||
// the vendor. That return (not this action) posts the real outbound ledger entry,
|
||||
// scoped to this line's own layer(s), so the movement is attributable to a real
|
||||
// document instead of vanishing against the GRN itself (§3.4).
|
||||
line.HoldStatus = HoldStatus.Rejected;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
|
||||
}
|
||||
|
||||
private async Task<Batch?> ResolveBatchAsync(
|
||||
@@ -386,7 +490,7 @@ public sealed class GrnService : IGrnService
|
||||
var key = (item.ItemId, batch.BatchNo.Trim());
|
||||
if (cache.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
var existing = await _grns.FindBatchAsync(item.ItemId, key.Item2, ct);
|
||||
var existing = await _batches.Query().FirstOrDefaultAsync(b => b.ItemId == item.ItemId && b.BatchNo == key.Item2, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
cache[key] = existing;
|
||||
@@ -394,7 +498,7 @@ public sealed class GrnService : IGrnService
|
||||
}
|
||||
|
||||
var created = new Batch { ItemId = item.ItemId, BatchNo = key.Item2, ExpiryDate = batch.ExpiryDate };
|
||||
await _grns.AddBatchAsync(created, ct);
|
||||
await _batches.AddAsync(created, ct);
|
||||
cache[key] = created;
|
||||
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||
}
|
||||
@@ -463,7 +567,9 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
private async Task EnsureSerialsFreeAsync(int itemId, string sku, List<string> numbers, CancellationToken ct)
|
||||
{
|
||||
var taken = await _grns.ListTakenSerialsAsync(itemId, numbers, take: 5, ct);
|
||||
var taken = await _serials.Query().AsNoTracking()
|
||||
.Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo))
|
||||
.Select(s => s.SerialNo).Take(5).ToListAsync(ct);
|
||||
if (taken.Count > 0)
|
||||
throw new DomainException(ErrorCodes.SerialDuplicate,
|
||||
$"Serial number(s) already exist for item {sku}: {string.Join(", ", taken)}.", 409);
|
||||
@@ -476,8 +582,7 @@ public sealed class GrnService : IGrnService
|
||||
/// </summary>
|
||||
private async Task<List<StockLayer>> ReceiveSerialsAsync(Grn grn, GrnLine line, decimal unitCost, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var item = await _grns.GetItemAsync(line.ItemId, ct)
|
||||
?? throw new NotFoundException($"Item {line.ItemId} was not found.");
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct);
|
||||
await EnsureSerialsFreeAsync(item.ItemId, item.Sku, line.Serials.Select(s => s.SerialNo).ToList(), ct);
|
||||
|
||||
var warrantyMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null;
|
||||
@@ -496,9 +601,9 @@ public sealed class GrnService : IGrnService
|
||||
CreatedAt = now,
|
||||
WarrantyPeriodMonths = warrantyMonths
|
||||
};
|
||||
await _grns.AddSerialAsync(serial, ct);
|
||||
await _serials.AddAsync(serial, ct);
|
||||
captured.Serial = serial; // FK fixed up on SaveChanges
|
||||
await _grns.AddSerialMovementAsync(SerialMovement.Of(serial, null, DocumentTypes.Grn, grn.GrnId, _currentUser.AuditUserId, now), ct);
|
||||
await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.Grn, grn.GrnId, _currentUser.AuditUserId, now), ct);
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, 1m, unitCost, now, ct);
|
||||
@@ -511,7 +616,7 @@ public sealed class GrnService : IGrnService
|
||||
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||
{
|
||||
if (poId is null) return;
|
||||
var po = await _grns.GetPoWithLinesForUpdateAsync(poId.Value, ct);
|
||||
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
if (po is null) return;
|
||||
|
||||
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
|
||||
@@ -523,13 +628,17 @@ public sealed class GrnService : IGrnService
|
||||
private async Task<PurchaseOrderStatus?> GetPoStatusAsync(int? poId, CancellationToken ct)
|
||||
{
|
||||
if (poId is null) return null;
|
||||
return await _grns.GetPoStatusAsync(poId.Value, ct);
|
||||
return await _pos.Query().AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<GrnConfirmResultDto> BuildConfirmResultAsync(Grn grn, CancellationToken ct)
|
||||
{
|
||||
var layers = await _grns.ListLayersForLinesAsync(grn.Lines.Select(l => l.GrnLineId).ToList(), ct);
|
||||
var ledgerRefs = await _grns.ListInboundLedgerIdsAsync(grn.GrnId, ct);
|
||||
var lineIds = grn.Lines.Select(l => l.GrnLineId).ToList();
|
||||
var layers = await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.GrnLineId != null && lineIds.Contains(l.GrnLineId.Value)).ToListAsync(ct);
|
||||
var ledgerRefs = await _ledger.Query().AsNoTracking()
|
||||
.Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grn.GrnId && l.Direction == Direction.In)
|
||||
.Select(l => l.LedgerId).ToListAsync(ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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,7 +5,5 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
|
||||
public interface IDashboardService
|
||||
{
|
||||
/// <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);
|
||||
Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -13,8 +14,7 @@ namespace ERPCore.Services;
|
||||
/// content-type cross-check and a size cap, server-authoritative — narrowed to web image
|
||||
/// formats, plus a per-item count cap. Uploads are refused with <c>CONFIG_DISABLED</c> while
|
||||
/// <c>ProductConfig.ItemImagesEnabled</c> is off; listing, viewing and deleting stay open so
|
||||
/// images added earlier remain manageable. Queries go through <see cref="IItemImageRepository"/>;
|
||||
/// writes run inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// images added earlier remain manageable.
|
||||
/// </summary>
|
||||
public sealed class ItemImageService : IItemImageService
|
||||
{
|
||||
@@ -29,14 +29,18 @@ public sealed class ItemImageService : IItemImageService
|
||||
[".webp"] = "image/webp"
|
||||
};
|
||||
|
||||
private readonly IItemImageRepository _images;
|
||||
private readonly IRepository<ItemImage> _images;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly IFileStorageService _storage;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemImageService(IItemImageRepository images, IProductConfigService config, IFileStorageService storage, IUnitOfWork uow)
|
||||
public ItemImageService(
|
||||
IRepository<ItemImage> images, IRepository<Item> items, IProductConfigService config,
|
||||
IFileStorageService storage, IUnitOfWork uow)
|
||||
{
|
||||
_images = images;
|
||||
_items = items;
|
||||
_config = config;
|
||||
_storage = storage;
|
||||
_uow = uow;
|
||||
@@ -45,7 +49,11 @@ public sealed class ItemImageService : IItemImageService
|
||||
public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
return (await _images.ListForItemAsync(itemId, ct)).Select(Map).ToList();
|
||||
return await _images.Query().AsNoTracking()
|
||||
.Where(i => i.ItemId == itemId)
|
||||
.OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId)
|
||||
.Select(i => new ItemImageDto(i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ItemImageDto> UploadAsync(
|
||||
@@ -67,47 +75,37 @@ public sealed class ItemImageService : IItemImageService
|
||||
throw new DomainException(ErrorCodes.FileTooLarge,
|
||||
$"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413);
|
||||
|
||||
// Fail fast on a full item before writing the file; re-checked inside the transaction below.
|
||||
await EnsureRoomAsync(itemId, ct);
|
||||
var existing = await _images.Query().Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
|
||||
if (existing.Count >= MaxImagesPerItem)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"An item can have at most {MaxImagesPerItem} images.", 422);
|
||||
|
||||
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct);
|
||||
|
||||
ItemImage image;
|
||||
try
|
||||
var image = new ItemImage
|
||||
{
|
||||
image = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var sortOrders = await EnsureRoomAsync(itemId, token);
|
||||
var entity = new ItemImage
|
||||
{
|
||||
ItemId = itemId,
|
||||
OriginalFileName = Path.GetFileName(fileName),
|
||||
StoredFileName = storedFileName,
|
||||
RelativePath = relativePath,
|
||||
ContentType = expectedContentType,
|
||||
SizeBytes = sizeBytes,
|
||||
SortOrder = sortOrders.Count == 0 ? 0 : sortOrders.Max() + 1,
|
||||
UploadedBy = actorUserId,
|
||||
UploadedAt = DateTime.UtcNow
|
||||
};
|
||||
await _images.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The row never landed, so the file just written would be an orphan — remove it (best effort,
|
||||
// so a cleanup failure never hides the real error), then rethrow.
|
||||
try { await _storage.DeleteAsync(relativePath, CancellationToken.None); } catch { /* left for manual cleanup */ }
|
||||
throw;
|
||||
}
|
||||
ItemId = itemId,
|
||||
OriginalFileName = Path.GetFileName(fileName),
|
||||
StoredFileName = storedFileName,
|
||||
RelativePath = relativePath,
|
||||
ContentType = expectedContentType,
|
||||
SizeBytes = sizeBytes,
|
||||
SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1,
|
||||
UploadedBy = actorUserId,
|
||||
UploadedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return Map(image);
|
||||
await _images.AddAsync(image, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType,
|
||||
image.SizeBytes, image.SortOrder, image.UploadedAt);
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.GetForItemAsync(itemId, itemImageId, tracking: false, ct)
|
||||
var image = await _images.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType);
|
||||
@@ -115,35 +113,19 @@ public sealed class ItemImageService : IItemImageService
|
||||
|
||||
public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.GetForItemAsync(itemId, itemImageId, tracking: true, ct)
|
||||
var image = await _images.Query()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
_images.Remove(image);
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
|
||||
_images.Remove(image);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
// File removed only after the row is gone, so a failed save never orphans a row.
|
||||
await _storage.DeleteAsync(image.RelativePath, ct);
|
||||
}
|
||||
|
||||
private async Task EnsureItemExistsAsync(int itemId, CancellationToken ct)
|
||||
{
|
||||
if (!await _images.ItemExistsAsync(itemId, ct))
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
|
||||
throw new NotFoundException($"Item {itemId} was not found.");
|
||||
}
|
||||
|
||||
/// <summary>The item's current sort orders; throws when it already has the maximum number of images.</summary>
|
||||
private async Task<IReadOnlyList<int>> EnsureRoomAsync(int itemId, CancellationToken ct)
|
||||
{
|
||||
var sortOrders = await _images.ListSortOrdersAsync(itemId, ct);
|
||||
if (sortOrders.Count >= MaxImagesPerItem)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"An item can have at most {MaxImagesPerItem} images.", 422);
|
||||
return sortOrders;
|
||||
}
|
||||
|
||||
private static ItemImageDto Map(ItemImage i) => new(
|
||||
i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IItemRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -23,45 +23,101 @@ namespace ERPCore.Services;
|
||||
/// e.g. "BL-100-0003"); this service only checks that it is unique. Items hold no
|
||||
/// item-type reference at all — see docs/10 Part C.9.
|
||||
/// </para>
|
||||
/// Queries go through <see cref="IItemRepository"/>; every write runs inside
|
||||
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// </summary>
|
||||
public sealed class ItemService : IItemService
|
||||
{
|
||||
private readonly IItemRepository _items;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Category> _categories;
|
||||
private readonly IRepository<SubCategory> _subCategories;
|
||||
private readonly IRepository<Brand> _brands;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<TaxRate> _taxRates;
|
||||
private readonly IRepository<ApprovalConfig> _approvalConfig;
|
||||
|
||||
/// <summary>Price approvals on (the default) unless Settings → Approvals set price levels to 0.</summary>
|
||||
private async Task<bool> PricesNeedApprovalAsync(CancellationToken ct)
|
||||
=> (await _approvalConfig.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
|
||||
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<StockLayer> _stockLayers;
|
||||
private readonly IRepository<StockLedger> _stockLedger;
|
||||
private readonly IRepository<GrnLine> _grnLines;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<Serial> _serials;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemService(IItemRepository items, IProductConfigService config, INotificationService notifications, IUnitOfWork uow)
|
||||
public ItemService(
|
||||
IRepository<Item> items,
|
||||
IRepository<Category> categories,
|
||||
IRepository<SubCategory> subCategories,
|
||||
IRepository<Brand> brands,
|
||||
IRepository<Uom> uoms,
|
||||
IRepository<TaxRate> taxRates,
|
||||
IRepository<Vendor> vendors,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<StockLayer> stockLayers,
|
||||
IRepository<StockLedger> stockLedger,
|
||||
IRepository<GrnLine> grnLines,
|
||||
IRepository<Batch> batches,
|
||||
IRepository<Serial> serials,
|
||||
IProductConfigService config,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow,
|
||||
IRepository<ApprovalConfig> approvalConfig)
|
||||
{
|
||||
_approvalConfig = approvalConfig;
|
||||
_items = items;
|
||||
_categories = categories;
|
||||
_subCategories = subCategories;
|
||||
_brands = brands;
|
||||
_uoms = uoms;
|
||||
_taxRates = taxRates;
|
||||
_vendors = vendors;
|
||||
_warehouses = warehouses;
|
||||
_stockLayers = stockLayers;
|
||||
_stockLedger = stockLedger;
|
||||
_grnLines = grnLines;
|
||||
_batches = batches;
|
||||
_serials = serials;
|
||||
_config = config;
|
||||
_notifications = notifications;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
/// <summary>Price approvals on (the default) unless Settings → Approvals set price levels to 0.</summary>
|
||||
private async Task<bool> PricesNeedApprovalAsync(CancellationToken ct)
|
||||
=> (await _items.GetPriceAdjustmentLevelsAsync(ct) ?? 2) > 0;
|
||||
|
||||
public async Task<PagedResponse<ItemListItemDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, CancellationToken ct = default)
|
||||
{
|
||||
var criteria = new ItemListCriteria(query.Q, status, categoryId, subCategoryId, brandId, trackingMode);
|
||||
var (rows, total) = await _items.ListPagedAsync(criteria, query.Skip, query.PageSize, ct);
|
||||
var q = _items.Query().AsNoTracking();
|
||||
|
||||
var items = rows.Select(i => new ItemListItemDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths,
|
||||
i.TaxRateId, i.TaxRate?.Name, i.TaxRate?.Percentage,
|
||||
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status, i.Barcode)).ToList();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(i => EF.Functions.Like(i.Sku, $"%{term}%") || EF.Functions.Like(i.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(i => i.Status == status);
|
||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
|
||||
|
||||
return PagedResponse<ItemListItemDto>.Create(items, query.Page, query.PageSize, total);
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(i => i.Sku)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(i => new ItemListItemDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths,
|
||||
i.TaxRateId, i.TaxRate == null ? null : i.TaxRate.Name, i.TaxRate == null ? null : i.TaxRate.Percentage,
|
||||
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status, i.Barcode))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
@@ -69,38 +125,89 @@ public sealed class ItemService : IItemService
|
||||
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var criteria = new ItemListCriteria(query.Q, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock);
|
||||
var (page, total) = await _items.ListOverviewPagedAsync(criteria, query.Skip, query.PageSize, ct);
|
||||
var q = _items.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(i => EF.Functions.Like(i.Sku, $"%{term}%") || EF.Functions.Like(i.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(i => i.Status == status);
|
||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
|
||||
if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature);
|
||||
// Stock-state filters run as subqueries before paging, so totals and pages stay correct.
|
||||
var layers = _stockLayers.Query();
|
||||
switch (stock)
|
||||
{
|
||||
case ItemStockFilter.InStock:
|
||||
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
|
||||
break;
|
||||
case ItemStockFilter.OutOfStock:
|
||||
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
|
||||
break;
|
||||
case ItemStockFilter.LowStock:
|
||||
q = q.Where(i => i.ReorderSettings.Any(rs =>
|
||||
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
|
||||
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
|
||||
break;
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var page = await q.OrderBy(i => i.Sku)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(i => new { i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, i.ReorderSettings })
|
||||
.ToListAsync(ct);
|
||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
||||
|
||||
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
|
||||
var stockByItem = await _items.GetStockByItemAsync(itemIds, ct);
|
||||
var stockByItem = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.ToDictionaryAsync(x => x.ItemId, ct);
|
||||
|
||||
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
||||
var onHandByItemWarehouse = await _items.GetOnHandByItemWarehouseAsync(itemIds, ct);
|
||||
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
||||
|
||||
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and
|
||||
// reduced in memory rather than a "first per group" query EF can't always translate.
|
||||
var lastPurchaseByItem = (await _items.ListConfirmedPurchasesAsync(itemIds, ct))
|
||||
var grnCandidates = await _grnLines.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null)
|
||||
.Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name })
|
||||
.ToListAsync(ct);
|
||||
var lastPurchaseByItem = grnCandidates
|
||||
.GroupBy(x => x.ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First());
|
||||
|
||||
// Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle),
|
||||
// read off the stock ledger — the one place every sale channel posts uniformly.
|
||||
var salesByItem = await _items.GetQtyOutSinceAsync(
|
||||
itemIds, DateTime.UtcNow.AddDays(-30),
|
||||
[DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale], ct);
|
||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale };
|
||||
var salesByItem = await _stockLedger.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out
|
||||
&& l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType))
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
||||
|
||||
var rows = page.Select(i =>
|
||||
{
|
||||
var hasStock = stockByItem.TryGetValue(i.ItemId, out var stockFigures);
|
||||
var stock = stockByItem.GetValueOrDefault(i.ItemId);
|
||||
var isLowStock = i.ReorderSettings.Any(rs =>
|
||||
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
|
||||
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
|
||||
|
||||
return new ItemOverviewDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status,
|
||||
hasStock ? stockFigures.Qty : 0m, hasStock ? stockFigures.Value : 0m, isLowStock,
|
||||
stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock,
|
||||
lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
|
||||
salesByItem.GetValueOrDefault(i.ItemId));
|
||||
}).ToList();
|
||||
@@ -110,25 +217,37 @@ public sealed class ItemService : IItemService
|
||||
|
||||
public async Task<IReadOnlyList<ItemPurchasePriceDto>> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _items.ExistsAsync(itemId, ct))
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
|
||||
throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
return (await _items.ListPurchasePricesAsync(itemId, Math.Clamp(take, 1, 50), ct))
|
||||
.Select(p => new ItemPurchasePriceDto(
|
||||
p.GrnId, p.GrnDocNo, p.PostedAt, p.VendorId, p.VendorName, p.WarehouseCode,
|
||||
p.Qty, p.UnitCost, p.DiscountPct, p.NetUnitCost, p.VatPct))
|
||||
.ToList();
|
||||
return await _grnLines.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.Grn!.PostedAt != null)
|
||||
.OrderByDescending(l => l.Grn!.PostedAt).ThenByDescending(l => l.GrnLineId)
|
||||
.Take(Math.Clamp(take, 1, 50))
|
||||
.Select(l => new ItemPurchasePriceDto(
|
||||
l.GrnId, l.Grn!.DocNo, l.Grn.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name, l.Grn.Warehouse!.Code,
|
||||
l.Qty, l.UnitCost, l.DiscountPct, l.NetUnitCost, l.VatPct))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
var item = await _items.GetWithDetailsAsync(itemId, tracking: false, ct);
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Include(i => i.ReorderSettings)
|
||||
.Include(i => i.TaxRate)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
|
||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||
|
||||
var barcode = NormalizeBarcode(request.Barcode);
|
||||
if (barcode is not null && await _items.Query().AnyAsync(i => i.Barcode == barcode, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"An item with barcode '{barcode}' already exists.", 422);
|
||||
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
await ValidateReferencesAsync(
|
||||
@@ -140,45 +259,37 @@ public sealed class ItemService : IItemService
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
|
||||
|
||||
var item = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var item = new Item
|
||||
{
|
||||
if (await _items.SkuExistsAsync(request.Sku, ct: token))
|
||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||
if (barcode is not null && await _items.BarcodeExistsAsync(barcode, ct: token))
|
||||
throw new DomainException(ErrorCodes.Validation, $"An item with barcode '{barcode}' already exists.", 422);
|
||||
Sku = request.Sku.Trim(),
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description,
|
||||
CategoryId = request.CategoryId,
|
||||
SubCategoryId = request.SubCategoryId,
|
||||
BrandId = request.BrandId,
|
||||
BaseUomId = request.BaseUomId,
|
||||
DefaultVendorId = request.DefaultVendorId,
|
||||
StockNature = request.StockNature,
|
||||
TrackingMode = request.TrackingMode,
|
||||
Warranty = request.Warranty,
|
||||
WarrantyPeriodMonths = warrantyPeriodMonths,
|
||||
TaxRateId = request.TaxRateId,
|
||||
SellingMinPrice = request.SellingMinPrice,
|
||||
SellingMaxPrice = request.SellingMaxPrice,
|
||||
SellingFlatPrice = request.SellingFlatPrice,
|
||||
ContentQty = request.ContentQty,
|
||||
ContentUnit = request.ContentUnit,
|
||||
ContentBaseQty = contentBaseQty,
|
||||
ContentBaseUnit = contentBaseUnit,
|
||||
Barcode = barcode,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var entity = new Item
|
||||
{
|
||||
Sku = request.Sku.Trim(),
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description,
|
||||
CategoryId = request.CategoryId,
|
||||
SubCategoryId = request.SubCategoryId,
|
||||
BrandId = request.BrandId,
|
||||
BaseUomId = request.BaseUomId,
|
||||
DefaultVendorId = request.DefaultVendorId,
|
||||
StockNature = request.StockNature,
|
||||
TrackingMode = request.TrackingMode,
|
||||
Warranty = request.Warranty,
|
||||
WarrantyPeriodMonths = warrantyPeriodMonths,
|
||||
TaxRateId = request.TaxRateId,
|
||||
SellingMinPrice = request.SellingMinPrice,
|
||||
SellingMaxPrice = request.SellingMaxPrice,
|
||||
SellingFlatPrice = request.SellingFlatPrice,
|
||||
ContentQty = request.ContentQty,
|
||||
ContentUnit = request.ContentUnit,
|
||||
ContentBaseQty = contentBaseQty,
|
||||
ContentBaseUnit = contentBaseUnit,
|
||||
Barcode = barcode,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _items.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
if (item.TaxRateId is int taxRateId)
|
||||
item.TaxRate = await _items.GetTaxRateAsync(taxRateId, ct);
|
||||
await _items.AddAsync(item, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
if (item.TaxRateId is not null)
|
||||
item.TaxRate = await _taxRates.Query().AsNoTracking().FirstAsync(t => t.TaxRateId == item.TaxRateId, ct);
|
||||
|
||||
await _notifications.RaiseAsync("item.created", "Item Created", $"Item {item.Sku} ({item.Name}) was created.", nameof(Item), item.ItemId, ct);
|
||||
|
||||
@@ -188,13 +299,23 @@ public sealed class ItemService : IItemService
|
||||
public async Task<ETagged<ItemDetailDto>> UpdateAsync(
|
||||
int itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var item = await _items.GetWithDetailsAsync(itemId, tracking: true, ct)
|
||||
var item = await _items.Query()
|
||||
.Include(i => i.ReorderSettings)
|
||||
.Include(i => i.TaxRate)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
if (item.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412);
|
||||
|
||||
if (!string.Equals(item.Sku, request.Sku, StringComparison.Ordinal)
|
||||
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
|
||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||
|
||||
var barcode = NormalizeBarcode(request.Barcode);
|
||||
if (barcode is not null && !string.Equals(item.Barcode, barcode, StringComparison.Ordinal)
|
||||
&& await _items.Query().AnyAsync(i => i.Barcode == barcode && i.ItemId != itemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"An item with barcode '{barcode}' already exists.", 422);
|
||||
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
await ValidateReferencesAsync(
|
||||
@@ -206,7 +327,7 @@ public sealed class ItemService : IItemService
|
||||
// stock layers, ledger rows and document lines are all plain counts of it. Once any
|
||||
// of that history exists, changing it would silently reinterpret every one of those
|
||||
// numbers (240 bottles becoming 240 cases), so it is frozen instead.
|
||||
if (item.BaseUomId != request.BaseUomId && await _items.HasStockHistoryAsync(itemId, ct))
|
||||
if (item.BaseUomId != request.BaseUomId && await HasStockHistoryAsync(itemId, ct))
|
||||
throw new DomainException(
|
||||
ErrorCodes.MasterInUse,
|
||||
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
|
||||
@@ -225,47 +346,33 @@ public sealed class ItemService : IItemService
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(
|
||||
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
if (!string.Equals(item.Sku, request.Sku, StringComparison.Ordinal)
|
||||
&& await _items.SkuExistsAsync(request.Sku, itemId, token))
|
||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||
if (barcode is not null && !string.Equals(item.Barcode, barcode, StringComparison.Ordinal)
|
||||
&& await _items.BarcodeExistsAsync(barcode, itemId, token))
|
||||
throw new DomainException(ErrorCodes.Validation, $"An item with barcode '{barcode}' already exists.", 422);
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
item.Description = request.Description;
|
||||
item.CategoryId = request.CategoryId;
|
||||
item.SubCategoryId = request.SubCategoryId;
|
||||
item.BrandId = request.BrandId;
|
||||
item.BaseUomId = request.BaseUomId;
|
||||
item.DefaultVendorId = request.DefaultVendorId;
|
||||
item.StockNature = request.StockNature;
|
||||
item.TrackingMode = request.TrackingMode;
|
||||
item.Warranty = request.Warranty;
|
||||
item.WarrantyPeriodMonths = warrantyPeriodMonths;
|
||||
item.TaxRateId = request.TaxRateId;
|
||||
item.SellingMinPrice = request.SellingMinPrice;
|
||||
item.SellingMaxPrice = request.SellingMaxPrice;
|
||||
item.SellingFlatPrice = request.SellingFlatPrice;
|
||||
item.ContentQty = request.ContentQty;
|
||||
item.ContentUnit = request.ContentUnit;
|
||||
item.ContentBaseQty = contentBaseQty;
|
||||
item.ContentBaseUnit = contentBaseUnit;
|
||||
item.Barcode = barcode;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
item.Description = request.Description;
|
||||
item.CategoryId = request.CategoryId;
|
||||
item.SubCategoryId = request.SubCategoryId;
|
||||
item.BrandId = request.BrandId;
|
||||
item.BaseUomId = request.BaseUomId;
|
||||
item.DefaultVendorId = request.DefaultVendorId;
|
||||
item.StockNature = request.StockNature;
|
||||
item.TrackingMode = request.TrackingMode;
|
||||
item.Warranty = request.Warranty;
|
||||
item.WarrantyPeriodMonths = warrantyPeriodMonths;
|
||||
item.TaxRateId = request.TaxRateId;
|
||||
item.SellingMinPrice = request.SellingMinPrice;
|
||||
item.SellingMaxPrice = request.SellingMaxPrice;
|
||||
item.SellingFlatPrice = request.SellingFlatPrice;
|
||||
item.ContentQty = request.ContentQty;
|
||||
item.ContentUnit = request.ContentUnit;
|
||||
item.ContentBaseQty = contentBaseQty;
|
||||
item.ContentBaseUnit = contentBaseUnit;
|
||||
item.Barcode = barcode;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412);
|
||||
}
|
||||
|
||||
item.TaxRate = item.TaxRateId is int taxRateId ? await _items.GetTaxRateAsync(taxRateId, ct) : null;
|
||||
await SaveGuardingConcurrencyAsync(ct);
|
||||
item.TaxRate = item.TaxRateId is null
|
||||
? null
|
||||
: await _taxRates.Query().AsNoTracking().FirstAsync(t => t.TaxRateId == item.TaxRateId, ct);
|
||||
await _notifications.RaiseAsync("item.updated", "Item Updated", $"Item {item.Sku} ({item.Name}) was updated.", nameof(Item), item.ItemId, ct);
|
||||
return new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
|
||||
}
|
||||
@@ -275,12 +382,9 @@ public sealed class ItemService : IItemService
|
||||
var item = await _items.GetByIdAsync(itemId, ct)
|
||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
item.Status = status;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
item.Status = status;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ItemReorderSettingsDto> UpdateReorderAsync(
|
||||
@@ -289,41 +393,40 @@ public sealed class ItemService : IItemService
|
||||
if (request.Settings.Select(s => s.WarehouseId).Distinct().Count() != request.Settings.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, "Duplicate warehouseId in reorder settings.", 400);
|
||||
|
||||
var item = await _items.GetWithDetailsAsync(itemId, tracking: true, ct)
|
||||
var item = await _items.Query()
|
||||
.Include(i => i.ReorderSettings)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
foreach (var warehouseId in request.Settings.Select(s => s.WarehouseId))
|
||||
if (!await _items.WarehouseExistsAsync(warehouseId, ct))
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
// Full-replacement upsert (avoids delete+insert clashes on the unique index).
|
||||
foreach (var stale in item.ReorderSettings.Where(r => request.Settings.All(s => s.WarehouseId != r.WarehouseId)).ToList())
|
||||
item.ReorderSettings.Remove(stale);
|
||||
foreach (var input in request.Settings)
|
||||
{
|
||||
// Full-replacement upsert (avoids delete+insert clashes on the unique index).
|
||||
foreach (var stale in item.ReorderSettings.Where(r => request.Settings.All(s => s.WarehouseId != r.WarehouseId)).ToList())
|
||||
item.ReorderSettings.Remove(stale);
|
||||
foreach (var input in request.Settings)
|
||||
var existing = item.ReorderSettings.FirstOrDefault(r => r.WarehouseId == input.WarehouseId);
|
||||
if (existing is null)
|
||||
{
|
||||
var existing = item.ReorderSettings.FirstOrDefault(r => r.WarehouseId == input.WarehouseId);
|
||||
if (existing is null)
|
||||
item.ReorderSettings.Add(new ItemReorder
|
||||
{
|
||||
item.ReorderSettings.Add(new ItemReorder
|
||||
{
|
||||
ItemId = itemId,
|
||||
WarehouseId = input.WarehouseId,
|
||||
ReorderPoint = input.ReorderPoint,
|
||||
ReorderQty = input.ReorderQty
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ReorderPoint = input.ReorderPoint;
|
||||
existing.ReorderQty = input.ReorderQty;
|
||||
}
|
||||
ItemId = itemId,
|
||||
WarehouseId = input.WarehouseId,
|
||||
ReorderPoint = input.ReorderPoint,
|
||||
ReorderQty = input.ReorderQty
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ReorderPoint = input.ReorderPoint;
|
||||
existing.ReorderQty = input.ReorderQty;
|
||||
}
|
||||
}
|
||||
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
var settings = item.ReorderSettings
|
||||
.OrderBy(r => r.WarehouseId)
|
||||
@@ -332,6 +435,15 @@ public sealed class ItemService : IItemService
|
||||
return new ItemReorderSettingsDto(settings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether anything has ever been recorded against this item's stock. Checks the ledger
|
||||
/// as well as live layers, because a fully consumed item has no layer left but its
|
||||
/// history still reads in the old unit.
|
||||
/// </summary>
|
||||
private async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct)
|
||||
=> await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct)
|
||||
|| await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Validates every FK on an item write, and gates the optional ones on the product
|
||||
/// configuration (FR-MD-11). Note there is no item-type check: nothing on an item
|
||||
@@ -342,18 +454,20 @@ public sealed class ItemService : IItemService
|
||||
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, int? taxRateId,
|
||||
ProductConfigDto config, CancellationToken ct)
|
||||
{
|
||||
if (!await _items.CategoryExistsAsync(categoryId, ct))
|
||||
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
|
||||
|
||||
if (subCategoryId is int subId)
|
||||
if (subCategoryId is not null)
|
||||
{
|
||||
if (!config.SubcategoriesEnabled)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Subcategories are disabled in the product configuration; subCategoryId must be null.", 422);
|
||||
|
||||
var sub = await _items.GetSubCategoryAsync(subId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422);
|
||||
var sub = await _subCategories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||
if (sub is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422);
|
||||
if (sub.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422);
|
||||
// The two FKs must agree, or the item would claim a category its subcategory
|
||||
@@ -364,34 +478,40 @@ public sealed class ItemService : IItemService
|
||||
$"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422);
|
||||
}
|
||||
|
||||
if (brandId is int bId)
|
||||
if (brandId is not null)
|
||||
{
|
||||
if (!config.BrandsEnabled)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Brands are disabled in the product configuration; brandId must be null.", 422);
|
||||
|
||||
var brand = await _items.GetBrandAsync(bId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422);
|
||||
var brand = await _brands.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||
if (brand is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422);
|
||||
if (brand.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422);
|
||||
}
|
||||
|
||||
if (!await _items.UomExistsAsync(baseUomId, ct))
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
|
||||
|
||||
if (defaultVendorId is int vId)
|
||||
if (defaultVendorId is not null)
|
||||
{
|
||||
var vendor = await _items.GetVendorAsync(vId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} does not exist.", 422);
|
||||
var vendor = await _vendors.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.VendorId == defaultVendorId, ct);
|
||||
if (vendor is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} does not exist.", 422);
|
||||
if (vendor.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} is inactive.", 422);
|
||||
}
|
||||
|
||||
if (taxRateId is int tId)
|
||||
if (taxRateId is not null)
|
||||
{
|
||||
var taxRate = await _items.GetTaxRateAsync(tId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} does not exist.", 422);
|
||||
var taxRate = await _taxRates.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.TaxRateId == taxRateId, ct);
|
||||
if (taxRate is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} does not exist.", 422);
|
||||
if (taxRate.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} is inactive.", 422);
|
||||
}
|
||||
@@ -490,6 +610,18 @@ public sealed class ItemService : IItemService
|
||||
return warrantyPeriodMonths;
|
||||
}
|
||||
|
||||
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trims and blank-collapses a barcode, mirroring <c>Sku.Trim()</c> — a stray
|
||||
/// space or an empty string would otherwise save as a value that never matches a real
|
||||
/// scan, or collide with another item's blank barcode under the unique index.</summary>
|
||||
@@ -507,20 +639,25 @@ public sealed class ItemService : IItemService
|
||||
|
||||
if (itemId is not null)
|
||||
{
|
||||
var batchForItem = await _items.FindBatchByCodeAsync(code, itemId, ct);
|
||||
var batchForItem = await _batches.Query().AsNoTracking().Include(b => b.Item)
|
||||
.FirstOrDefaultAsync(b => b.ItemId == itemId && (b.Barcode == code || b.BatchNo == code), ct);
|
||||
if (batchForItem is not null) return ToScanResult(batchForItem);
|
||||
|
||||
var serialForItem = await _items.FindSerialByCodeAsync(code, itemId, ct);
|
||||
var serialForItem = await _serials.Query().AsNoTracking().Include(s => s.Item)
|
||||
.FirstOrDefaultAsync(s => s.ItemId == itemId && (s.Barcode == code || s.SerialNo == code), ct);
|
||||
if (serialForItem is not null) return ToScanResult(serialForItem);
|
||||
}
|
||||
|
||||
var item = await _items.FindItemByCodeAsync(code, ct);
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Barcode == code || i.Sku == code, ct);
|
||||
if (item is not null) return ToScanResult(item);
|
||||
|
||||
var serial = await _items.FindSerialByCodeAsync(code, ct: ct);
|
||||
var serial = await _serials.Query().AsNoTracking().Include(s => s.Item)
|
||||
.FirstOrDefaultAsync(s => s.Barcode == code || s.SerialNo == code, ct);
|
||||
if (serial is not null) return ToScanResult(serial);
|
||||
|
||||
var batch = await _items.FindBatchByCodeAsync(code, ct: ct);
|
||||
var batch = await _batches.Query().AsNoTracking().Include(b => b.Item)
|
||||
.FirstOrDefaultAsync(b => b.Barcode == code || b.BatchNo == code, ct);
|
||||
if (batch is not null) return ToScanResult(batch);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -7,7 +7,7 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IItemTypeRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -15,15 +15,14 @@ namespace ERPCore.Services;
|
||||
/// Item type master service (FR-MD-10) — maintains the Color/Size/Material list that
|
||||
/// <c>GET /item-types</c> serves to the frontend builder's dropdown. Nothing references
|
||||
/// these rows, so there is no in-use check to make and no cascade to worry about
|
||||
/// (docs/11-BACKEND-PHASE1.md §2.7, docs/10 Part C.9). Queries go through <see cref="IItemTypeRepository"/>;
|
||||
/// writes run inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// (docs/11-BACKEND-PHASE1.md §2.7, docs/10 Part C.9).
|
||||
/// </summary>
|
||||
public sealed class ItemTypeService : IItemTypeService
|
||||
{
|
||||
private readonly IItemTypeRepository _itemTypes;
|
||||
private readonly IRepository<ItemType> _itemTypes;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemTypeService(IItemTypeRepository itemTypes, IUnitOfWork uow)
|
||||
public ItemTypeService(IRepository<ItemType> itemTypes, IUnitOfWork uow)
|
||||
{
|
||||
_itemTypes = itemTypes;
|
||||
_uow = uow;
|
||||
@@ -31,35 +30,46 @@ public sealed class ItemTypeService : IItemTypeService
|
||||
|
||||
public async Task<PagedResponse<ItemTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _itemTypes.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
|
||||
return PagedResponse<ItemTypeDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
|
||||
var q = _itemTypes.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(t => EF.Functions.Like(t.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(t => t.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(t => t.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ItemTypeDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemTypeDto>?> GetAsync(int itemTypeId, CancellationToken ct = default)
|
||||
{
|
||||
var itemType = await _itemTypes.GetForReadAsync(itemTypeId, ct);
|
||||
var itemType = await _itemTypes.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct);
|
||||
return itemType is null ? null : new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemTypeDto>> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = request.Name.Trim();
|
||||
if (await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"An item type named '{name}' already exists.");
|
||||
|
||||
var itemType = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var itemType = new ItemType
|
||||
{
|
||||
if (await _itemTypes.NameExistsAsync(name, ct: token))
|
||||
throw new ConflictException($"An item type named '{name}' already exists.");
|
||||
Name = name,
|
||||
IsMeasurable = request.IsMeasurable,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var entity = new ItemType
|
||||
{
|
||||
Name = name,
|
||||
IsMeasurable = request.IsMeasurable,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _itemTypes.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
await _itemTypes.AddAsync(itemType, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion);
|
||||
}
|
||||
@@ -74,23 +84,21 @@ public sealed class ItemTypeService : IItemTypeService
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(itemType.Name, name, StringComparison.Ordinal)
|
||||
&& await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower() && t.ItemTypeId != itemTypeId, ct))
|
||||
throw new ConflictException($"An item type named '{name}' already exists.");
|
||||
|
||||
// Renaming does not touch existing items: their SKUs already encode the values that
|
||||
// were chosen, and nothing joins back to this row (docs/10 Part C.9).
|
||||
itemType.Name = name;
|
||||
// Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and
|
||||
// so let a name-only PUT silently clear the flag on every rename.
|
||||
itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable;
|
||||
itemType.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
if (!string.Equals(itemType.Name, name, StringComparison.Ordinal)
|
||||
&& await _itemTypes.NameExistsAsync(name, itemTypeId, token))
|
||||
throw new ConflictException($"An item type named '{name}' already exists.");
|
||||
|
||||
// Renaming does not touch existing items: their SKUs already encode the values that
|
||||
// were chosen, and nothing joins back to this row (docs/10 Part C.9).
|
||||
itemType.Name = name;
|
||||
// Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and
|
||||
// so let a name-only PUT silently clear the flag on every rename.
|
||||
itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable;
|
||||
itemType.UpdatedAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
@@ -105,12 +113,9 @@ public sealed class ItemTypeService : IItemTypeService
|
||||
var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct)
|
||||
?? throw new NotFoundException($"Item type {itemTypeId} was not found.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
itemType.Status = status;
|
||||
itemType.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
itemType.Status = status;
|
||||
itemType.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt);
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
using System.Data;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Atomic document-number issuer (FR-X-03). The counter bump is one atomic upsert in
|
||||
/// <see cref="INumberSequenceRepository.IncrementAsync"/>, enlisted in the caller's current transaction so the
|
||||
/// number commits or rolls back with the document. Numbering is per-branch (docs/BRANCH_ISOLATION_PLAN.md §5):
|
||||
/// the sequence key is (doc_type, branch_id, year) and the format includes the branch code, e.g.
|
||||
/// Atomic document-number issuer. Uses a single <c>INSERT … ON DUPLICATE KEY UPDATE …
|
||||
/// RETURNING</c> so concurrent issues for the same (docType, branchId, year) cannot get
|
||||
/// the same number (the upsert holds an exclusive lock on the row until commit). Runs as a
|
||||
/// direct ADO.NET command enlisted in the DbContext's current transaction so it commits or
|
||||
/// rolls back with the document (FR-X-03). EF's <c>SqlQuery</c> is avoided here because it
|
||||
/// wraps the statement in a subquery, which is not valid for a data-modifying statement.
|
||||
/// Numbering is per-branch (docs/BRANCH_ISOLATION_PLAN.md §5): the sequence key is
|
||||
/// (doc_type, branch_id, year) and the format includes the branch code, e.g.
|
||||
/// <c>PO-BR-001-2026-00042</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike the other services this one deliberately does NOT open its own
|
||||
/// <c>IUnitOfWork.ExecuteInTransactionAsync</c>: it is called from inside other services' transactions
|
||||
/// (bank deposits, GRNs, expenses, …), so it joins whatever transaction is current instead of starting one.
|
||||
/// MariaDB 10.5+ supports <c>RETURNING</c> on an upsert, so this stays a single atomic
|
||||
/// statement exactly as the PostgreSQL version was — which is what keeps the callers that
|
||||
/// run outside a UoW transaction safe. Do NOT "optimise" this into the one-statement
|
||||
/// <c>LAST_INSERT_ID()</c> trick: this table has an AUTO_INCREMENT surrogate key, so on the
|
||||
/// insert branch the engine's own auto-increment value clobbers the explicit one and the
|
||||
/// first document of a (docType, branch, year) would be numbered with the sequence table's
|
||||
/// PK. Splitting it into INSERT-then-SELECT is also wrong here, because the non-transacted
|
||||
/// callers would then race. Side note: the surrogate AUTO_INCREMENT burns a value on the
|
||||
/// duplicate path, so <c>sequence_id</c> has gaps — harmless, do not "fix" it.
|
||||
/// <c>ON DUPLICATE KEY UPDATE</c> fires on any unique-key collision; today the only unique
|
||||
/// keys are the never-supplied PK and IX(doc_type, branch_id, year), so it is unambiguous.
|
||||
/// Adding another unique index to this table would silently change which row gets updated.
|
||||
/// </remarks>
|
||||
public sealed class NumberSequenceService : INumberSequenceService
|
||||
{
|
||||
private readonly INumberSequenceRepository _sequences;
|
||||
private readonly ErpDbContext _db;
|
||||
private readonly IRepository<Branch> _branches;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
|
||||
public NumberSequenceService(INumberSequenceRepository sequences, IBranchConfigService branchConfig)
|
||||
public NumberSequenceService(ErpDbContext db, IRepository<Branch> branches, IBranchConfigService branchConfig)
|
||||
{
|
||||
_sequences = sequences;
|
||||
_db = db;
|
||||
_branches = branches;
|
||||
_branchConfig = branchConfig;
|
||||
}
|
||||
|
||||
@@ -36,10 +56,38 @@ public sealed class NumberSequenceService : INumberSequenceService
|
||||
{
|
||||
var year = DateTime.UtcNow.Year;
|
||||
|
||||
var branchCode = await _sequences.GetBranchCodeAsync(branchId, ct)
|
||||
var branchCode = await _branches.Query().AsNoTracking()
|
||||
.Where(b => b.BranchId == branchId).Select(b => b.Code).FirstOrDefaultAsync(ct)
|
||||
?? throw new InvalidOperationException($"Branch {branchId} was not found — cannot issue a document number.");
|
||||
|
||||
var next = await _sequences.IncrementAsync(docType, branchId, year, ct);
|
||||
var conn = _db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open)
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO `number_sequences` (`doc_type`, `branch_id`, `year`, `last_number`)
|
||||
VALUES (@docType, @branchId, @year, 1)
|
||||
ON DUPLICATE KEY UPDATE `last_number` = `last_number` + 1
|
||||
RETURNING `last_number`;
|
||||
""";
|
||||
AddParam(cmd, "docType", docType);
|
||||
AddParam(cmd, "branchId", branchId);
|
||||
AddParam(cmd, "year", year);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct)
|
||||
?? throw new InvalidOperationException($"Failed to issue a document number for '{docType}'.");
|
||||
var next = Convert.ToInt64(result);
|
||||
|
||||
return $"{docType}-{branchCode}-{year}-{next:D5}";
|
||||
}
|
||||
|
||||
private static void AddParam(IDbCommand cmd, string name, object value)
|
||||
{
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value;
|
||||
cmd.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -29,8 +30,6 @@ namespace ERPCore.Services;
|
||||
/// check; this is an admin-only, rare operation and is not serialised.
|
||||
/// </para>
|
||||
/// Untracked items only: serial- and batch-tracked items need per-unit identities, which a plain quantity cannot give.
|
||||
/// Queries and direct row writes go through <see cref="IOpeningStockRepository"/>; posting runs inside
|
||||
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// </summary>
|
||||
public sealed class OpeningStockService : IOpeningStockService
|
||||
{
|
||||
@@ -39,7 +38,11 @@ public sealed class OpeningStockService : IOpeningStockService
|
||||
private const string SourceModule = "OPENING";
|
||||
private const string JournalType = "OPENING_BALANCE";
|
||||
|
||||
private readonly IOpeningStockRepository _runs;
|
||||
private readonly IRepository<OpeningBalanceRun> _runs;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<JournalEntryStub> _journal;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
@@ -47,10 +50,15 @@ public sealed class OpeningStockService : IOpeningStockService
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public OpeningStockService(
|
||||
IOpeningStockRepository runs, IFifoCostingService fifo,
|
||||
IRepository<OpeningBalanceRun> runs, IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<StockLedger> ledger, IRepository<JournalEntryStub> journal, IFifoCostingService fifo,
|
||||
IGeneralLedgerService gl, IGlAccountConfigService glAccounts, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_runs = runs;
|
||||
_items = items;
|
||||
_warehouses = warehouses;
|
||||
_ledger = ledger;
|
||||
_journal = journal;
|
||||
_fifo = fifo;
|
||||
_gl = gl;
|
||||
_glAccounts = glAccounts;
|
||||
@@ -60,15 +68,25 @@ public sealed class OpeningStockService : IOpeningStockService
|
||||
|
||||
public async Task<IReadOnlyList<OpeningStockRunSummaryDto>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _runs.ListStockRunsAsync(ct);
|
||||
return rows.Select(x => new OpeningStockRunSummaryDto(
|
||||
x.Run.OpeningBalanceRunId, x.Run.CutoverDate, x.Run.GlJournalNo, x.Run.TotalAmount, x.LineCount,
|
||||
x.Run.PostedAt, x.Run.PostedBy)).ToList();
|
||||
var rows = await _runs.Query().AsNoTracking()
|
||||
.Where(r => r.Kind == OpeningBalanceKind.Stock)
|
||||
.OrderByDescending(r => r.OpeningBalanceRunId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.OpeningBalanceRunId, r.CutoverDate, r.GlJournalNo, r.TotalAmount, r.PostedAt, r.PostedBy,
|
||||
LineCount = r.StockLines.Count
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(r => new OpeningStockRunSummaryDto(
|
||||
r.OpeningBalanceRunId, r.CutoverDate, r.GlJournalNo, r.TotalAmount, r.LineCount, r.PostedAt, r.PostedBy)).ToList();
|
||||
}
|
||||
|
||||
public async Task<OpeningStockRunDto?> GetAsync(int runId, CancellationToken ct = default)
|
||||
{
|
||||
var run = await _runs.GetStockRunAsync(runId, ct);
|
||||
var run = await _runs.Query().AsNoTracking()
|
||||
.Include(r => r.StockLines).ThenInclude(l => l.Item)
|
||||
.Include(r => r.StockLines).ThenInclude(l => l.Warehouse)
|
||||
.FirstOrDefaultAsync(r => r.OpeningBalanceRunId == runId && r.Kind == OpeningBalanceKind.Stock, ct);
|
||||
return run is null ? null : Map(run);
|
||||
}
|
||||
|
||||
@@ -81,8 +99,8 @@ public sealed class OpeningStockService : IOpeningStockService
|
||||
// ---- validate every line before anything is written
|
||||
var itemIds = request.Lines.Select(l => l.ItemId).Distinct().ToList();
|
||||
var warehouseIds = request.Lines.Select(l => l.WarehouseId).Distinct().ToList();
|
||||
var items = await _runs.GetItemsAsync(itemIds, ct);
|
||||
var warehouses = await _runs.GetWarehousesAsync(warehouseIds, ct);
|
||||
var items = await _items.Query().AsNoTracking().Where(i => itemIds.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
|
||||
var warehouses = await _warehouses.Query().AsNoTracking().Where(w => warehouseIds.Contains(w.WarehouseId)).ToDictionaryAsync(w => w.WarehouseId, ct);
|
||||
|
||||
var seen = new HashSet<(int, int)>();
|
||||
foreach (var line in request.Lines)
|
||||
@@ -107,7 +125,11 @@ public sealed class OpeningStockService : IOpeningStockService
|
||||
}
|
||||
|
||||
// ---- an item/warehouse that already has movements is not "opening" any more
|
||||
var existing = await _runs.ListMovedPairsAsync(itemIds, warehouseIds, ct);
|
||||
var existing = await _ledger.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId))
|
||||
.Select(l => new { l.ItemId, l.WarehouseId })
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
var clashes = existing.Where(e => seen.Contains((e.ItemId, e.WarehouseId))).ToList();
|
||||
if (clashes.Count > 0)
|
||||
{
|
||||
@@ -147,7 +169,7 @@ public sealed class OpeningStockService : IOpeningStockService
|
||||
total += value;
|
||||
|
||||
await _fifo.CreateInboundLayerAsync(line.ItemId, line.WarehouseId, null, null, null, line.Qty, line.UnitCost, receiptDate, token);
|
||||
await _runs.AddLedgerAsync(new StockLedger
|
||||
await _ledger.AddAsync(new StockLedger
|
||||
{
|
||||
ItemId = line.ItemId,
|
||||
WarehouseId = line.WarehouseId,
|
||||
@@ -161,7 +183,7 @@ public sealed class OpeningStockService : IOpeningStockService
|
||||
SourceDocId = entity.OpeningBalanceRunId,
|
||||
CreatedAt = receiptDate
|
||||
}, token);
|
||||
await _runs.AddJournalStubAsync(new JournalEntryStub
|
||||
await _journal.AddAsync(new JournalEntryStub
|
||||
{
|
||||
SourceDocType = SourceDocType,
|
||||
SourceDocId = entity.OpeningBalanceRunId,
|
||||
|
||||
@@ -8,7 +8,7 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPriceAdjustmentRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -18,15 +18,18 @@ namespace ERPCore.Services;
|
||||
/// <see cref="IApprovalService"/>). Approval then writes the new prices to the items and records an
|
||||
/// <see cref="ItemPriceHistory"/> row — unless an item was repriced since the request was made, in
|
||||
/// which case the approval is refused as stale. One pending request per item at a time.
|
||||
/// Queries go through <see cref="IPriceAdjustmentRepository"/>; every write runs inside
|
||||
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// </summary>
|
||||
public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
{
|
||||
public const string ApprovalDocType = "PADJ";
|
||||
public const string ApprovalNavCode = "products.price-adjustments";
|
||||
|
||||
private readonly IPriceAdjustmentRepository _adjustments;
|
||||
private readonly IRepository<PriceAdjustment> _adjustments;
|
||||
private readonly IRepository<PriceAdjustmentLine> _lines;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ItemPriceHistory> _history;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<ApprovalConfig> _config;
|
||||
private readonly IApprovalService _approvals;
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
@@ -34,10 +37,17 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PriceAdjustmentService(
|
||||
IPriceAdjustmentRepository adjustments, IApprovalService approvals, INotificationService notifications,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
IRepository<PriceAdjustment> adjustments, IRepository<PriceAdjustmentLine> lines, IRepository<Item> items,
|
||||
IRepository<ItemPriceHistory> history, IRepository<User> users, IRepository<ApprovalConfig> config,
|
||||
IApprovalService approvals, INotificationService notifications, INumberSequenceService numbers,
|
||||
ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_adjustments = adjustments;
|
||||
_lines = lines;
|
||||
_items = items;
|
||||
_history = history;
|
||||
_users = users;
|
||||
_config = config;
|
||||
_approvals = approvals;
|
||||
_notifications = notifications;
|
||||
_numbers = numbers;
|
||||
@@ -47,16 +57,29 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
|
||||
public async Task<PagedResponse<PriceAdjustmentSummaryDto>> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _adjustments.ListPagedAsync(query.Q, status, itemId, query.Skip, query.PageSize, ct);
|
||||
var items = rows.Select(p => new PriceAdjustmentSummaryDto(
|
||||
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals, PendingLevel(p.Status),
|
||||
p.LineCount, p.CreatedBy, p.CreatorName, p.CreatedAt, p.AppliedAt)).ToList();
|
||||
return PagedResponse<PriceAdjustmentSummaryDto>.Create(items, query.Page, query.PageSize, total);
|
||||
var q = _adjustments.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || EF.Functions.Like(p.Reason, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(p => p.Status == status);
|
||||
if (itemId is not null) q = q.Where(p => p.Lines.Any(l => l.ItemId == itemId));
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(p => p.PriceAdjustmentId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(p => new PriceAdjustmentSummaryDto(
|
||||
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals,
|
||||
p.Status == PriceAdjustmentStatus.PendingApproval ? 1 : p.Status == PriceAdjustmentStatus.PendingSecondApproval ? 2 : null,
|
||||
p.Lines.Count, p.CreatedBy, p.Creator!.DisplayName, p.CreatedAt, p.AppliedAt))
|
||||
.ToListAsync(ct);
|
||||
return PagedResponse<PriceAdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<PriceAdjustmentDto?> GetAsync(int priceAdjustmentId, CancellationToken ct = default)
|
||||
{
|
||||
var adjustment = await _adjustments.GetWithDetailsAsync(priceAdjustmentId, ct);
|
||||
var adjustment = await Load(tracking: false).FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct);
|
||||
return adjustment is null ? null : await ToDtoAsync(adjustment, ct);
|
||||
}
|
||||
|
||||
@@ -71,8 +94,16 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
throw new DomainException(ErrorCodes.Validation, "Each item can appear only once in a request.", 400);
|
||||
|
||||
var ids = request.Lines.Select(l => l.ItemId).ToList();
|
||||
var items = await _adjustments.GetItemsForUpdateAsync(ids, ct);
|
||||
var pendingItemIds = await _adjustments.ListPendingRequestsForItemsAsync(ids, ct);
|
||||
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
|
||||
// Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a
|
||||
// plain join — a SelectMany over a to-many navigation generates a CROSS APPLY that
|
||||
// Pomelo/MySQL can't translate (it works fine against PostgreSQL, which is what this
|
||||
// predates — 2026-10-16 MySQL-port regression).
|
||||
var pendingItemIds = await _lines.Query().AsNoTracking()
|
||||
.Where(l => ids.Contains(l.ItemId)
|
||||
&& (l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingApproval || l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingSecondApproval))
|
||||
.Select(l => new { l.ItemId, DocNo = l.PriceAdjustment!.DocNo })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = new List<PriceAdjustmentLine>();
|
||||
var largestChange = 0m;
|
||||
@@ -81,7 +112,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
if (!items.TryGetValue(input.ItemId, out var item))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||
var clash = pendingItemIds.FirstOrDefault(x => x.ItemId == input.ItemId);
|
||||
if (clash.DocNo is not null)
|
||||
if (clash is not null)
|
||||
throw new DomainException(ErrorCodes.PriceAdjustmentItemPending,
|
||||
$"{item.Sku} already has a pending price change ({clash.DocNo}) — approve, reject or withdraw that first.", 409);
|
||||
ValidatePrices(item.Sku, input.NewMinPrice, input.NewMaxPrice, input.NewFlatPrice);
|
||||
@@ -153,7 +184,8 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
adjustment.Status = PriceAdjustmentStatus.PendingSecondApproval;
|
||||
return 0;
|
||||
}
|
||||
var items = await _adjustments.GetItemsForUpdateAsync(adjustment.Lines.Select(l => l.ItemId).ToList(), token);
|
||||
var ids = adjustment.Lines.Select(l => l.ItemId).ToList();
|
||||
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, token);
|
||||
adjustment.Status = PriceAdjustmentStatus.Approved;
|
||||
await ApplyInCurrentTransactionAsync(adjustment, items, now, token);
|
||||
return 0;
|
||||
@@ -179,13 +211,11 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
|
||||
await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct);
|
||||
|
||||
await SaveDecisionAsync(adjustment, () =>
|
||||
{
|
||||
adjustment.Status = PriceAdjustmentStatus.Rejected;
|
||||
adjustment.RejectedBy = _currentUser.AuditUserId;
|
||||
adjustment.RejectedAt = DateTime.UtcNow;
|
||||
adjustment.RejectReason = reason.Trim();
|
||||
}, ct);
|
||||
adjustment.Status = PriceAdjustmentStatus.Rejected;
|
||||
adjustment.RejectedBy = _currentUser.AuditUserId;
|
||||
adjustment.RejectedAt = DateTime.UtcNow;
|
||||
adjustment.RejectReason = reason.Trim();
|
||||
await SaveDecisionAsync(adjustment, ct);
|
||||
|
||||
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected",
|
||||
$"Rejected: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}",
|
||||
@@ -199,22 +229,23 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
if (adjustment.CreatedBy != _currentUser.AuditUserId)
|
||||
throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403);
|
||||
|
||||
await SaveDecisionAsync(adjustment, () =>
|
||||
{
|
||||
adjustment.Status = PriceAdjustmentStatus.Cancelled;
|
||||
adjustment.CancelledAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
adjustment.Status = PriceAdjustmentStatus.Cancelled;
|
||||
adjustment.CancelledAt = DateTime.UtcNow;
|
||||
await SaveDecisionAsync(adjustment, ct);
|
||||
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _adjustments.ListItemHistoryAsync(itemId, ct);
|
||||
var rows = await _history.Query().AsNoTracking()
|
||||
.Where(h => h.ItemId == itemId)
|
||||
.OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var userIds = rows.SelectMany(r => new int?[] { r.RequestedBy, r.ApprovedLevel1By, r.ApprovedLevel2By }).OfType<int>().Distinct().ToList();
|
||||
var names = await _adjustments.GetUserNamesAsync(userIds, ct);
|
||||
var names = await _users.Query().AsNoTracking().Where(u => userIds.Contains(u.UserId)).ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct);
|
||||
var docIds = rows.Select(r => r.PriceAdjustmentId).OfType<int>().Distinct().ToList();
|
||||
var docNos = await _adjustments.GetDocNosAsync(docIds, ct);
|
||||
var docNos = await _adjustments.Query().AsNoTracking().Where(p => docIds.Contains(p.PriceAdjustmentId)).ToDictionaryAsync(p => p.PriceAdjustmentId, p => p.DocNo, ct);
|
||||
|
||||
string? Name(int? id) => id is int v ? names.GetValueOrDefault(v) : null;
|
||||
return rows.Select(h => new ItemPriceHistoryDto(
|
||||
@@ -225,7 +256,8 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
}
|
||||
|
||||
public async Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default)
|
||||
=> (await _adjustments.GetPriceAdjustmentLevelsAsync(ct) ?? 2) > 0;
|
||||
=> (await _config.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
|
||||
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------------------------
|
||||
|
||||
@@ -245,7 +277,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
item.SellingFlatPrice = line.NewFlatPrice;
|
||||
item.UpdatedAt = now;
|
||||
|
||||
await _adjustments.AddHistoryAsync(new ItemPriceHistory
|
||||
await _history.AddAsync(new ItemPriceHistory
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
PriceAdjustment = adjustment, // FK set by EF, also when the request is new
|
||||
@@ -278,25 +310,27 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
return Math.Abs(newPrice.Value - oldPrice.Value) / oldPrice.Value * 100m;
|
||||
}
|
||||
|
||||
private IQueryable<PriceAdjustment> Load(bool tracking)
|
||||
{
|
||||
var q = _adjustments.Query().Include(p => p.Lines).ThenInclude(l => l.Item).Include(p => p.Creator);
|
||||
return tracking ? q : q.AsNoTracking();
|
||||
}
|
||||
|
||||
private async Task<PriceAdjustment> LoadPendingAsync(int priceAdjustmentId, CancellationToken ct)
|
||||
{
|
||||
var adjustment = await _adjustments.GetForDecisionAsync(priceAdjustmentId, ct)
|
||||
var adjustment = await _adjustments.Query().Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct)
|
||||
?? throw new NotFoundException($"Price adjustment {priceAdjustmentId} was not found.");
|
||||
if (adjustment.Status is not (PriceAdjustmentStatus.PendingApproval or PriceAdjustmentStatus.PendingSecondApproval))
|
||||
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409);
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
/// <summary>Applies <paramref name="decide"/> in a transaction; a lost race on the request becomes a 409.</summary>
|
||||
private async Task SaveDecisionAsync(PriceAdjustment adjustment, Action decide, CancellationToken ct)
|
||||
private async Task SaveDecisionAsync(PriceAdjustment adjustment, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
decide();
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
@@ -304,9 +338,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||
}
|
||||
}
|
||||
|
||||
private static int? PendingLevel(PriceAdjustment p) => PendingLevel(p.Status);
|
||||
|
||||
private static int? PendingLevel(PriceAdjustmentStatus status) => status switch
|
||||
private static int? PendingLevel(PriceAdjustment p) => p.Status switch
|
||||
{
|
||||
PriceAdjustmentStatus.PendingApproval => 1,
|
||||
PriceAdjustmentStatus.PendingSecondApproval => 2,
|
||||
|
||||
@@ -8,22 +8,18 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IProcurementConfigRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Procurement configuration service over the singleton row seeded by DataSeeder. Queries go through
|
||||
/// <see cref="IProcurementConfigRepository"/>; the update runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>
|
||||
/// like <see cref="BankDepositService"/>.
|
||||
/// </summary>
|
||||
/// <summary>Procurement configuration service over the singleton row seeded by DataSeeder.</summary>
|
||||
public sealed class ProcurementConfigService : IProcurementConfigService
|
||||
{
|
||||
private readonly IProcurementConfigRepository _config;
|
||||
private readonly IRepository<ProcurementConfig> _config;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ProcurementConfigService(IProcurementConfigRepository config, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
public ProcurementConfigService(IRepository<ProcurementConfig> config, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_currentUser = currentUser;
|
||||
@@ -32,7 +28,8 @@ public sealed class ProcurementConfigService : IProcurementConfigService
|
||||
|
||||
public async Task<ETagged<ProcurementConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetForReadAsync(ct)
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Procurement configuration has not been seeded.");
|
||||
|
||||
return new ETagged<ProcurementConfigDto>(Map(config), config.RowVersion);
|
||||
@@ -47,19 +44,17 @@ public sealed class ProcurementConfigService : IProcurementConfigService
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
config.VendorContactMode = request.VendorContactMode!.Value;
|
||||
config.VendorAddressEnabled = request.VendorAddressEnabled!.Value;
|
||||
config.PoTaxEnabled = request.PoTaxEnabled!.Value;
|
||||
config.VendorPaymentTermsEnabled = request.VendorPaymentTermsEnabled!.Value;
|
||||
config.VendorTaxRegEnabled = request.VendorTaxRegEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
config.VendorContactMode = request.VendorContactMode!.Value;
|
||||
config.VendorAddressEnabled = request.VendorAddressEnabled!.Value;
|
||||
config.PoTaxEnabled = request.PoTaxEnabled!.Value;
|
||||
config.VendorPaymentTermsEnabled = request.VendorPaymentTermsEnabled!.Value;
|
||||
config.VendorTaxRegEnabled = request.VendorTaxRegEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
@@ -71,7 +66,8 @@ public sealed class ProcurementConfigService : IProcurementConfigService
|
||||
|
||||
public async Task<ProcurementConfigDto> GetFlagsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetForReadAsync(ct);
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct);
|
||||
// No row (not seeded yet): contact/address/tax off, the long-standing terms and tax-reg fields on.
|
||||
return config is null ? new ProcurementConfigDto(VendorContactMode.None, false, false, null, null, true, true) : Map(config);
|
||||
}
|
||||
|
||||
@@ -6,24 +6,22 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IProductConfigRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder.
|
||||
/// Reads are never gated - only writes consult the flags - so switching a feature off
|
||||
/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8). Queries go through
|
||||
/// <see cref="IProductConfigRepository"/>; the update runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>
|
||||
/// like <see cref="BankDepositService"/>.
|
||||
/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8).
|
||||
/// </summary>
|
||||
public sealed class ProductConfigService : IProductConfigService
|
||||
{
|
||||
private readonly IProductConfigRepository _config;
|
||||
private readonly IRepository<ProductConfig> _config;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ProductConfigService(IProductConfigRepository config, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
public ProductConfigService(IRepository<ProductConfig> config, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_currentUser = currentUser;
|
||||
@@ -32,7 +30,8 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
|
||||
public async Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetForReadAsync(ct)
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Product configuration has not been seeded.");
|
||||
|
||||
return new ETagged<ProductConfigDto>(Map(config), config.RowVersion);
|
||||
@@ -47,36 +46,34 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
// Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the
|
||||
// model binder has already rejected anything that would reach here with a null.
|
||||
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
|
||||
config.BrandsEnabled = request.BrandsEnabled!.Value;
|
||||
config.ItemTypesEnabled = request.ItemTypesEnabled!.Value;
|
||||
config.SellingPricesEnabled = request.SellingPricesEnabled!.Value;
|
||||
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
|
||||
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
|
||||
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
|
||||
// Custom variants live in the item-variants section - switching product variants
|
||||
// off switches this off too, so it can never be left on by itself.
|
||||
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
|
||||
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
|
||||
// Custom warranty periods depend on warranty periods - switching the parent off
|
||||
// switches this off too, so it can never be left on by itself.
|
||||
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
|
||||
config.StockNatureEnabled = request.StockNatureEnabled!.Value;
|
||||
config.TrackingModeEnabled = request.TrackingModeEnabled!.Value;
|
||||
config.LowStockAlertsEnabled = request.LowStockAlertsEnabled!.Value;
|
||||
// The warehouse picker lives in the low-stock alert section - switching alerts off
|
||||
// switches this off too, so it can never be left on by itself.
|
||||
config.AlertWarehouseEnabled = config.LowStockAlertsEnabled && request.AlertWarehouseEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
// Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the
|
||||
// model binder has already rejected anything that would reach here with a null.
|
||||
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
|
||||
config.BrandsEnabled = request.BrandsEnabled!.Value;
|
||||
config.ItemTypesEnabled = request.ItemTypesEnabled!.Value;
|
||||
config.SellingPricesEnabled = request.SellingPricesEnabled!.Value;
|
||||
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
|
||||
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
|
||||
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
|
||||
// Custom variants live in the item-variants section - switching product variants
|
||||
// off switches this off too, so it can never be left on by itself.
|
||||
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
|
||||
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
|
||||
// Custom warranty periods depend on warranty periods - switching the parent off
|
||||
// switches this off too, so it can never be left on by itself.
|
||||
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
|
||||
config.StockNatureEnabled = request.StockNatureEnabled!.Value;
|
||||
config.TrackingModeEnabled = request.TrackingModeEnabled!.Value;
|
||||
config.LowStockAlertsEnabled = request.LowStockAlertsEnabled!.Value;
|
||||
// The warehouse picker lives in the low-stock alert section - switching alerts off
|
||||
// switches this off too, so it can never be left on by itself.
|
||||
config.AlertWarehouseEnabled = config.LowStockAlertsEnabled && request.AlertWarehouseEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -18,22 +19,22 @@ namespace ERPCore.Services;
|
||||
/// account) — the mirror image of <see cref="SalesInvoicePaymentService"/> (that one
|
||||
/// credits Accounts Receivable when the customer pays; this one debits Accounts Payable
|
||||
/// when the vendor is paid) — using the same call-GL-before-commit pattern so a
|
||||
/// rejected/unreachable GL post rolls back the whole payment atomically (inside
|
||||
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>, like <see cref="BankDepositService"/>).
|
||||
/// Queries go through <see cref="IPurchaseInvoicePaymentRepository"/>.
|
||||
/// rejected/unreachable GL post rolls back the whole payment atomically.
|
||||
/// </summary>
|
||||
public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService
|
||||
{
|
||||
private readonly IPurchaseInvoicePaymentRepository _payments;
|
||||
private readonly IRepository<PurchaseInvoice> _invoices;
|
||||
private readonly IRepository<PurchaseInvoicePayment> _payments;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public PurchaseInvoicePaymentService(
|
||||
IPurchaseInvoicePaymentRepository payments, IGeneralLedgerService gl,
|
||||
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
@@ -43,7 +44,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
||||
|
||||
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _payments.GetInvoiceForUpdateAsync(purchaseInvoiceId, ct)
|
||||
var invoice = await _invoices.Query().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
if (invoice.Status != PurchaseInvoiceStatus.Posted)
|
||||
@@ -119,10 +120,16 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
||||
|
||||
public async Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _payments.InvoiceExistsAsync(purchaseInvoiceId, ct))
|
||||
if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct))
|
||||
throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
return (await _payments.ListForInvoiceAsync(purchaseInvoiceId, ct)).Select(Map).ToList();
|
||||
return await _payments.Query().AsNoTracking()
|
||||
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
|
||||
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
|
||||
.Select(p => new PurchaseInvoicePaymentDto(
|
||||
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new(
|
||||
|
||||
@@ -12,7 +12,7 @@ using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPurchaseInvoiceRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -24,13 +24,13 @@ namespace ERPCore.Services;
|
||||
/// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a
|
||||
/// real GL journal entry (mirrors <see cref="SalesInvoiceService"/>'s AR recognition);
|
||||
/// vendor payments are then recorded against this aggregate (see
|
||||
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN. Queries go through
|
||||
/// <see cref="IPurchaseInvoiceRepository"/>; every write runs inside
|
||||
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN.
|
||||
/// </summary>
|
||||
public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
{
|
||||
private readonly IPurchaseInvoiceRepository _invoices;
|
||||
private readonly IRepository<PurchaseInvoice> _invoices;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
@@ -38,10 +38,13 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public PurchaseInvoiceService(
|
||||
IPurchaseInvoiceRepository invoices, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
|
||||
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_vendors = vendors;
|
||||
_grns = grns;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
@@ -51,30 +54,45 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
|
||||
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _invoices.ListPagedAsync(query.Q, status, vendorId, query.Skip, query.PageSize, ct);
|
||||
IQueryable<PurchaseInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|
||||
|| EF.Functions.Like(x.VendorInvoiceNo, $"%{term}%")
|
||||
|| EF.Functions.Like(x.VendorSnapshotName, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
|
||||
return PagedResponse<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: false, ct);
|
||||
var invoice = await _invoices.Query().AsNoTracking()
|
||||
.Include(x => x.Lines).ThenInclude(l => l.Grn)
|
||||
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
|
||||
return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var vendor = await _invoices.GetVendorAsync(request.VendorId, ct)
|
||||
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
|
||||
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
|
||||
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, ct);
|
||||
|
||||
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct);
|
||||
|
||||
var branchId = grns[0].BranchId;
|
||||
|
||||
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, token);
|
||||
|
||||
var entity = new PurchaseInvoice
|
||||
{
|
||||
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, branchId, token),
|
||||
@@ -102,7 +120,9 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
|
||||
public async Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct)
|
||||
var invoice = await _invoices.Query()
|
||||
.Include(x => x.Lines).ThenInclude(l => l.Grn)
|
||||
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
if (invoice.RowVersion != expectedRowVersion)
|
||||
@@ -110,35 +130,34 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
if (invoice.Status != PurchaseInvoiceStatus.Draft)
|
||||
throw new DomainException(ErrorCodes.PurchaseInvoiceNotEditable, $"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be edited.", 409);
|
||||
|
||||
var vendor = await _invoices.GetVendorAsync(request.VendorId, ct)
|
||||
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
|
||||
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
|
||||
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
|
||||
|
||||
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
|
||||
|
||||
// Free GRNs that were linked to this invoice but are no longer selected.
|
||||
var newGrnIds = grns.Select(g => g.GrnId).ToHashSet();
|
||||
foreach (var line in invoice.Lines)
|
||||
if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null;
|
||||
|
||||
invoice.VendorId = request.VendorId;
|
||||
invoice.BranchId = grns[0].BranchId;
|
||||
invoice.VendorInvoiceNo = vendorInvoiceNo;
|
||||
invoice.VendorInvoiceDate = request.VendorInvoiceDate;
|
||||
invoice.VendorSnapshotName = vendor.Name;
|
||||
invoice.VendorSnapshotTaxNo = vendor.TaxReg;
|
||||
invoice.Lines.Clear();
|
||||
foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line);
|
||||
foreach (var grn in grns) grn.PurchaseInvoice = invoice;
|
||||
Recalculate(invoice);
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, token);
|
||||
|
||||
// Free GRNs that were linked to this invoice but are no longer selected.
|
||||
var newGrnIds = grns.Select(g => g.GrnId).ToHashSet();
|
||||
foreach (var line in invoice.Lines)
|
||||
if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null;
|
||||
|
||||
invoice.VendorId = request.VendorId;
|
||||
invoice.BranchId = grns[0].BranchId;
|
||||
invoice.VendorInvoiceNo = vendorInvoiceNo;
|
||||
invoice.VendorInvoiceDate = request.VendorInvoiceDate;
|
||||
invoice.VendorSnapshotName = vendor.Name;
|
||||
invoice.VendorSnapshotTaxNo = vendor.TaxReg;
|
||||
invoice.Lines.Clear();
|
||||
foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line);
|
||||
foreach (var grn in grns) grn.PurchaseInvoice = invoice;
|
||||
Recalculate(invoice);
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
@@ -150,7 +169,9 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
|
||||
public async Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct)
|
||||
var invoice = await _invoices.Query()
|
||||
.Include(x => x.Lines).ThenInclude(l => l.Grn)
|
||||
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
if (invoice.Status != PurchaseInvoiceStatus.Draft)
|
||||
@@ -191,7 +212,9 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
|
||||
public async Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct)
|
||||
var invoice = await _invoices.Query()
|
||||
.Include(x => x.Lines).ThenInclude(l => l.Grn)
|
||||
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
if (invoice.Status != PurchaseInvoiceStatus.Draft)
|
||||
@@ -199,41 +222,47 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
|
||||
var dto = MapInvoice(invoice);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
// Cancelling frees the consolidated GRNs so they can be picked up by a later invoice —
|
||||
// the link rows are removed (the unique index on GrnId only tracks the active link),
|
||||
// while the Cancelled header row itself remains for audit history.
|
||||
foreach (var line in invoice.Lines)
|
||||
if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null;
|
||||
invoice.Lines.Clear();
|
||||
invoice.Status = PurchaseInvoiceStatus.Cancelled;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
// Cancelling frees the consolidated GRNs so they can be picked up by a later invoice —
|
||||
// the link rows are removed (the unique index on GrnId only tracks the active link),
|
||||
// while the Cancelled header row itself remains for audit history.
|
||||
foreach (var line in invoice.Lines)
|
||||
if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null;
|
||||
invoice.Lines.Clear();
|
||||
invoice.Status = PurchaseInvoiceStatus.Cancelled;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt };
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
|
||||
{
|
||||
return (await _invoices.ListEligibleGrnsAsync(vendorId, ct))
|
||||
return await _grns.Query().AsNoTracking()
|
||||
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
|
||||
.OrderBy(g => g.PostedAt)
|
||||
.Select(g => new GrnEligibleForInvoiceDto(
|
||||
g.GrnId, g.DocNo, g.ReceivedAt, g.WarehouseId, g.LineCount, g.Subtotal, g.VatTotal, g.GrandTotal))
|
||||
.ToList();
|
||||
g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count,
|
||||
g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal)))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct)
|
||||
{
|
||||
if (await _invoices.VendorInvoiceNoExistsAsync(vendorId, vendorInvoiceNo, excludingPurchaseInvoiceId, ct))
|
||||
var duplicate = await _invoices.Query().AsNoTracking().AnyAsync(x =>
|
||||
x.VendorId == vendorId
|
||||
&& x.Status != PurchaseInvoiceStatus.Cancelled
|
||||
&& x.VendorInvoiceNo == vendorInvoiceNo
|
||||
&& (excludingPurchaseInvoiceId == null || x.PurchaseInvoiceId != excludingPurchaseInvoiceId), ct);
|
||||
if (duplicate)
|
||||
throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo,
|
||||
$"Vendor invoice number '{vendorInvoiceNo}' already exists for this vendor.", 409);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
|
||||
private async Task<List<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
|
||||
{
|
||||
var distinctIds = grnIds.Distinct().ToList();
|
||||
var grns = await _invoices.ListGrnsForInvoicingAsync(distinctIds, ct);
|
||||
var grns = await _grns.Query().Include(g => g.Lines)
|
||||
.Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct);
|
||||
|
||||
if (grns.Count != distinctIds.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422);
|
||||
|
||||
@@ -10,7 +10,7 @@ using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPurchaseOrderRepository
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -18,14 +18,18 @@ namespace ERPCore.Services;
|
||||
/// Purchase-order service. Issuing a PO (create non-draft, or submit a Draft) routes it
|
||||
/// through PendingApproval → Approved (FR-PROC-04); a Draft is freely editable
|
||||
/// (FR-PROC-05); totals are computed server-side (02-SECURITY C.2); cancel is blocked
|
||||
/// once any receipt exists. Queries go through <see cref="IPurchaseOrderRepository"/>; every write runs
|
||||
/// inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
|
||||
/// once any receipt exists.
|
||||
/// </summary>
|
||||
public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
{
|
||||
private const string BaseCurrency = "LKR";
|
||||
|
||||
private readonly IPurchaseOrderRepository _pos;
|
||||
private readonly IRepository<PurchaseOrder> _pos;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Requisition> _requisitions;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INotificationService _notifications;
|
||||
@@ -34,15 +38,22 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PurchaseOrderService(
|
||||
IPurchaseOrderRepository pos, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
|
||||
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
|
||||
IBranchConfigService branchConfig, IProcurementConfigService procurementConfig, IUnitOfWork uow)
|
||||
{
|
||||
_procurementConfig = procurementConfig;
|
||||
_pos = pos;
|
||||
_vendors = vendors;
|
||||
_requisitions = requisitions;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_notifications = notifications;
|
||||
_branchConfig = branchConfig;
|
||||
_procurementConfig = procurementConfig;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
@@ -50,16 +61,40 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
PageQuery query, PurchaseOrderStatus? status, int? vendorId, DateOnly? from = null, DateOnly? to = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _pos.Query().AsNoTracking().Include(p => p.Lines).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
// Doc no., or the vendor's name / code (the vendor is the table's first column).
|
||||
var vendorIdsMatching = _vendors.Query()
|
||||
.Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%"))
|
||||
.Select(v => v.VendorId);
|
||||
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || vendorIdsMatching.Contains(p.VendorId));
|
||||
}
|
||||
if (status is not null) q = q.Where(p => p.Status == status);
|
||||
if (vendorId is not null) q = q.Where(p => p.VendorId == vendorId);
|
||||
// Created date, as a business (Colombo) day: [start of `from`, start of the day after `to`).
|
||||
var criteria = new PurchaseOrderListCriteria(
|
||||
query.Q, status, vendorId,
|
||||
from is DateOnly fromDay ? BusinessClock.StartOfDayUtc(fromDay) : null,
|
||||
to is DateOnly toDay ? BusinessClock.StartOfDayUtc(toDay.AddDays(1)) : null,
|
||||
query.Sort);
|
||||
var (rows, total) = await _pos.ListPagedAsync(criteria, query.Skip, query.PageSize, ct);
|
||||
if (from is DateOnly fromDay)
|
||||
{
|
||||
var start = BusinessClock.StartOfDayUtc(fromDay);
|
||||
q = q.Where(p => p.CreatedAt >= start);
|
||||
}
|
||||
if (to is DateOnly toDay)
|
||||
{
|
||||
var end = BusinessClock.StartOfDayUtc(toDay.AddDays(1));
|
||||
q = q.Where(p => p.CreatedAt < end);
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await ApplySort(q, query.Sort)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var flags = await _procurementConfig.GetFlagsAsync(ct);
|
||||
var vendorMap = await _pos.GetVendorsAsync(rows.Select(p => p.VendorId).Distinct().ToList(), ct);
|
||||
var vendorIds = rows.Select(p => p.VendorId).Distinct().ToList();
|
||||
var vendorMap = await _vendors.Query().AsNoTracking()
|
||||
.Where(v => vendorIds.Contains(v.VendorId))
|
||||
.ToDictionaryAsync(v => v.VendorId, ct);
|
||||
|
||||
var summaries = rows.Select(p => new PurchaseOrderSummaryDto(
|
||||
p.PoId, p.DocNo, p.VendorId, p.BranchId, p.Status, p.ApprovalRequired, p.CreatedBy, p.CreatedAt, ComputeTotals(p.Lines),
|
||||
@@ -68,9 +103,37 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
return PagedResponse<PurchaseOrderSummaryDto>.Create(summaries, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side sort so paging stays correct: <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading
|
||||
/// <c>-</c> for descending. Default (and any unknown key) is newest first. PoId is the tiebreaker, so a page
|
||||
/// boundary never repeats or skips a row.
|
||||
/// </summary>
|
||||
private IQueryable<PurchaseOrder> ApplySort(IQueryable<PurchaseOrder> q, string? sort)
|
||||
{
|
||||
var key = (sort ?? "-created").Trim();
|
||||
var desc = key.StartsWith('-');
|
||||
key = key.TrimStart('-', '+').ToLowerInvariant();
|
||||
|
||||
IOrderedQueryable<PurchaseOrder> ordered = key switch
|
||||
{
|
||||
"vendor" => desc
|
||||
? q.OrderByDescending(p => _vendors.Query().Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault())
|
||||
: q.OrderBy(p => _vendors.Query().Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault()),
|
||||
"total" => desc
|
||||
? q.OrderByDescending(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount))
|
||||
: q.OrderBy(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount)),
|
||||
"status" => desc ? q.OrderByDescending(p => p.Status) : q.OrderBy(p => p.Status),
|
||||
"created" => desc ? q.OrderByDescending(p => p.CreatedAt) : q.OrderBy(p => p.CreatedAt),
|
||||
_ => q.OrderByDescending(p => p.CreatedAt)
|
||||
};
|
||||
return ordered.ThenByDescending(p => p.PoId);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseOrderDto>?> GetAsync(int poId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await _pos.GetWithLinesAsync(poId, tracking: false, ct);
|
||||
var po = await _pos.Query().AsNoTracking()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
if (po is null) return null;
|
||||
return new ETagged<PurchaseOrderDto>(await ToDtoAsync(po, ct), po.RowVersion);
|
||||
}
|
||||
@@ -112,7 +175,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
public async Task<ETagged<PurchaseOrderDto>> UpdateAsync(
|
||||
int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var po = await LoadForUpdateAsync(poId, ct);
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (po.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412);
|
||||
@@ -123,20 +189,18 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
|
||||
await EnsureLinesInBranchAsync(po.BranchId, request.Lines.Select(l => l.WarehouseId), ct);
|
||||
|
||||
po.VendorId = request.VendorId;
|
||||
po.RequisitionId = request.RequisitionId;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line).
|
||||
po.Lines.Clear();
|
||||
foreach (var input in request.Lines)
|
||||
po.Lines.Add(ToLine(input));
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
po.VendorId = request.VendorId;
|
||||
po.RequisitionId = request.RequisitionId;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line).
|
||||
po.Lines.Clear();
|
||||
foreach (var input in request.Lines)
|
||||
po.Lines.Add(ToLine(input));
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
@@ -150,14 +214,11 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
{
|
||||
var po = await LoadForApprovalDecisionAsync(poId, ct);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
po.Status = PurchaseOrderStatus.Approved;
|
||||
po.ApprovedBy = _currentUser.AuditUserId;
|
||||
po.ApprovedAt = DateTime.UtcNow;
|
||||
po.UpdatedAt = po.ApprovedAt;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
po.Status = PurchaseOrderStatus.Approved;
|
||||
po.ApprovedBy = _currentUser.AuditUserId;
|
||||
po.ApprovedAt = DateTime.UtcNow;
|
||||
po.UpdatedAt = po.ApprovedAt;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
await _notifications.RaiseAsync("purchase.order.approved", "Purchase Order Approved", $"Purchase order {po.DocNo} was approved.", nameof(PurchaseOrder), po.PoId, ct);
|
||||
|
||||
return await ToDtoAsync(po, ct);
|
||||
@@ -167,14 +228,11 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
{
|
||||
var po = await LoadForApprovalDecisionAsync(poId, ct);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
// Rejected, not Cancelled — a reject is "revise and resubmit", not "abandon" - and not Draft either, so the
|
||||
// list and the creator can tell it was turned down.
|
||||
po.Status = PurchaseOrderStatus.Rejected;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
// Rejected, not Cancelled — a reject is "revise and resubmit", not "abandon" - and not Draft either, so the
|
||||
// list and the creator can tell it was turned down.
|
||||
po.Status = PurchaseOrderStatus.Rejected;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
var suffix = string.IsNullOrWhiteSpace(reason) ? "." : $": {reason}";
|
||||
await _notifications.RaiseAsync("purchase.order.rejected", "Purchase Order Rejected", $"Purchase order {po.DocNo} was rejected{suffix}", nameof(PurchaseOrder), po.PoId, ct);
|
||||
|
||||
@@ -185,7 +243,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
/// caller must be an Admin who isn't the PO's own creator (self-approval is blocked).</summary>
|
||||
private async Task<PurchaseOrder> LoadForApprovalDecisionAsync(int poId, CancellationToken ct)
|
||||
{
|
||||
var po = await LoadForUpdateAsync(poId, ct);
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.PendingApproval)
|
||||
throw new DomainException(ErrorCodes.PoNotPendingApproval, $"Purchase order {poId} is {po.Status} and is not awaiting approval.", 409);
|
||||
@@ -201,19 +262,19 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
|
||||
public async Task<PurchaseOrderDto> CancelAsync(int poId, string? reason, CancellationToken ct = default)
|
||||
{
|
||||
var po = await LoadForUpdateAsync(poId, ct);
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (po.Lines.Any(l => l.QtyReceived > 0))
|
||||
throw new ConflictException($"Purchase order {poId} cannot be cancelled because goods have been received against it.");
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.Cancelled)
|
||||
{
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
po.Status = PurchaseOrderStatus.Cancelled;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
po.Status = PurchaseOrderStatus.Cancelled;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return await ToDtoAsync(po, ct);
|
||||
@@ -221,18 +282,18 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
|
||||
public async Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await LoadForUpdateAsync(poId, ct);
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (!IsEditable(po.Status))
|
||||
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
po.Status = PurchaseOrderStatus.PendingApproval;
|
||||
po.ApprovalRequired = true;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
po.Status = PurchaseOrderStatus.PendingApproval;
|
||||
po.ApprovalRequired = true;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
await _notifications.RaiseAsync("purchase.order.pending_approval", "Purchase Order Awaiting Approval", $"Purchase order {po.DocNo} is awaiting approval.", nameof(PurchaseOrder), po.PoId, ct);
|
||||
|
||||
return await ToDtoAsync(po, ct);
|
||||
@@ -240,22 +301,18 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
|
||||
public async Task DeleteAsync(int poId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await LoadForUpdateAsync(poId, ct);
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (!IsEditable(po.Status))
|
||||
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft or Rejected order can be deleted.", 409);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(_ =>
|
||||
{
|
||||
_pos.Remove(po);
|
||||
return Task.CompletedTask;
|
||||
}, ct);
|
||||
_pos.Remove(po);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<PurchaseOrder> LoadForUpdateAsync(int poId, CancellationToken ct)
|
||||
=> await _pos.GetWithLinesAsync(poId, tracking: true, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
// FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it.
|
||||
// Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05.
|
||||
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft or PurchaseOrderStatus.Rejected;
|
||||
@@ -291,12 +348,14 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
private async Task ValidateReferencesAsync(
|
||||
int vendorId, int? requisitionId, IReadOnlyCollection<CreatePoLineInput> lines, CancellationToken ct)
|
||||
{
|
||||
var vendor = await _pos.GetVendorAsync(vendorId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
|
||||
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
|
||||
if (vendor is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
|
||||
if (vendor.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} is inactive.", 422);
|
||||
|
||||
if (requisitionId is int reqId && !await _pos.RequisitionExistsAsync(reqId, ct))
|
||||
if (requisitionId is not null
|
||||
&& !await _requisitions.Query().AnyAsync(r => r.RequisitionId == requisitionId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422);
|
||||
|
||||
// Tax is a Procurement-configuration option: with it off, the form hides the field, so a
|
||||
@@ -304,25 +363,30 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
if (!(await _procurementConfig.GetFlagsAsync(ct)).PoTaxEnabled && lines.Any(l => l.Tax != 0))
|
||||
throw new DomainException(ErrorCodes.ConfigDisabled, "Tax on purchase orders is turned off in Procurement configuration.", 422);
|
||||
|
||||
var itemIds = lines.Select(l => l.ItemId).Distinct().ToList();
|
||||
EnsureAllExist(itemIds, await _pos.ListExistingItemIdsAsync(itemIds, ct), "Item");
|
||||
var warehouseIds = lines.Select(l => l.WarehouseId).Distinct().ToList();
|
||||
EnsureAllExist(warehouseIds, await _pos.ListExistingWarehouseIdsAsync(warehouseIds, ct), "Warehouse");
|
||||
await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct);
|
||||
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
|
||||
}
|
||||
|
||||
/// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open
|
||||
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.</summary>
|
||||
private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
|
||||
{
|
||||
var mismatched = await _pos.ListWarehousesOutsideBranchAsync(warehouseIds.Distinct().ToList(), branchId, ct);
|
||||
var ids = warehouseIds.Distinct().ToList();
|
||||
var mismatched = await _warehouses.Query().AsNoTracking()
|
||||
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
|
||||
.Select(w => w.WarehouseId)
|
||||
.ToListAsync(ct);
|
||||
if (mismatched.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Warehouse(s) {string.Join(", ", mismatched)} do not belong to this purchase order's branch.", 422);
|
||||
}
|
||||
|
||||
private static void EnsureAllExist(IReadOnlyCollection<int> requested, IReadOnlyCollection<int> found, string label)
|
||||
private static async Task EnsureAllExistAsync(
|
||||
IQueryable<int> keySource, IEnumerable<int> requested, string label, CancellationToken ct)
|
||||
{
|
||||
var missing = requested.Except(found).ToList();
|
||||
var ids = requested.Distinct().ToList();
|
||||
var found = await keySource.Where(k => ids.Contains(k)).ToListAsync(ct);
|
||||
var missing = ids.Except(found).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"{label}(s) not found: {string.Join(", ", missing)}.", 422);
|
||||
}
|
||||
@@ -336,7 +400,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
|
||||
private async Task<PurchaseOrderDto> ToDtoAsync(PurchaseOrder p, CancellationToken ct)
|
||||
{
|
||||
var vendor = await _pos.GetVendorAsync(p.VendorId, ct);
|
||||
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == p.VendorId, ct);
|
||||
var flags = await _procurementConfig.GetFlagsAsync(ct);
|
||||
return Map(p, vendor is null ? null : ToVendorDto(vendor, flags));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
@@ -23,15 +24,21 @@ namespace ERPCore.Services;
|
||||
/// (FIFO consume, row-locked; over-return beyond available → STOCK_NEGATIVE_BLOCKED).
|
||||
/// Single UoW transaction. Also posts a real GL journal entry reversing the
|
||||
/// Inventory/Clearing lines the original <see cref="Grn"/> posted — reuses
|
||||
/// <c>Grn</c>'s own account config since it's reversing that posting. Queries go through
|
||||
/// <see cref="IPurchaseReturnRepository"/>; the post runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>
|
||||
/// like <see cref="BankDepositService"/>.
|
||||
/// <c>Grn</c>'s own account config since it's reversing that posting.
|
||||
/// </summary>
|
||||
public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
{
|
||||
private readonly IPurchaseReturnRepository _returns;
|
||||
private readonly IRepository<PurchaseReturn> _returns;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IRepository<GrnLine> _grnLines;
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IStockMutator _mutator;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly IRepository<Serial> _serials;
|
||||
private readonly ISerialTrackingService _serialTracking;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -41,12 +48,23 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public PurchaseReturnService(
|
||||
IPurchaseReturnRepository returns, IStockMutator mutator, IFifoCostingService fifo, ISerialTrackingService serialTracking,
|
||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
|
||||
IRepository<Serial> serials, ISerialTrackingService serialTracking,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_returns = returns;
|
||||
_serials = serials;
|
||||
_serialTracking = serialTracking;
|
||||
_returns = returns;
|
||||
_vendors = vendors;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
_reasonCodes = reasonCodes;
|
||||
_grnLines = grnLines;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_mutator = mutator;
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
@@ -60,20 +78,42 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
|
||||
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var (rows, total) = await _returns.ListPagedAsync(query.Q, vendorId, warehouseId, query.Skip, query.PageSize, ct);
|
||||
var items = rows.Select(x => new PurchaseReturnSummaryDto(
|
||||
x.Return.ReturnId, x.Return.DocNo, x.Return.VendorId, x.Return.WarehouseId, x.Return.ReasonCodeId, x.Return.Status,
|
||||
x.Return.CreatedBy, x.Return.CreatedAt, x.LineCount, x.Return.GlJournalNo)).ToList();
|
||||
return PagedResponse<PurchaseReturnSummaryDto>.Create(items, query.Page, query.PageSize, total);
|
||||
var q = _returns.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId);
|
||||
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(r => r.ReturnId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(r => new PurchaseReturnSummaryDto(
|
||||
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
|
||||
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.GlJournalNo))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<PurchaseReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
|
||||
{
|
||||
var ret = await _returns.GetWithLinesAsync(returnId, ct);
|
||||
var ret = await _returns.Query().AsNoTracking()
|
||||
.Include(r => r.Lines)
|
||||
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
|
||||
if (ret is null) return null;
|
||||
|
||||
// Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup.
|
||||
return ToDto(ret, await _returns.ListLedgerIdsAsync(returnId, ct));
|
||||
var ledgerRefs = await _ledger.Query().AsNoTracking()
|
||||
.Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId)
|
||||
.OrderBy(l => l.LedgerId)
|
||||
.Select(l => l.LedgerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return ToDto(ret, ledgerRefs);
|
||||
}
|
||||
|
||||
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
|
||||
@@ -81,12 +121,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
if (request.ReasonCodeId is null)
|
||||
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a purchase return.", 400);
|
||||
|
||||
if (!await _returns.VendorExistsAsync(request.VendorId, ct))
|
||||
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
if (!await _returns.WarehouseExistsAsync(request.WarehouseId, ct))
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
var reason = await _returns.GetReasonCodeAsync(request.ReasonCodeId.Value, ct)
|
||||
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
|
||||
if (reason.Context != ReasonContext.Return)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
|
||||
@@ -100,13 +140,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
var item = await _returns.GetItemAsync(line.ItemId, ct)
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
|
||||
// Rejected-line serials are checked against that line's own layers below; anything
|
||||
// else must name serials actually on hand (D6: or leave only pre-tracking stock).
|
||||
var grnLine = line.GrnLineId is int grnLineId ? await _returns.GetGrnLineAsync(grnLineId, ct) : null;
|
||||
var isRejectedLine = grnLine?.HoldStatus == HoldStatus.Rejected;
|
||||
var isRejectedLine = line.GrnLineId is not null && await _grnLines.Query().AnyAsync(g => g.GrnLineId == line.GrnLineId && g.HoldStatus == HoldStatus.Rejected, ct);
|
||||
if (!isRejectedLine || line.SerialIds is { Count: > 0 })
|
||||
serialsByLine[line] = await _serialTracking.ResolveOutboundAsync(
|
||||
item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials,
|
||||
@@ -116,14 +155,15 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
|
||||
if (line.GrnLineId is not null)
|
||||
{
|
||||
if (grnLine is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422);
|
||||
var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422);
|
||||
if (grnLine.ItemId != line.ItemId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} is for a different item.", 422);
|
||||
|
||||
if (grnLine.HoldStatus == HoldStatus.Rejected)
|
||||
{
|
||||
var remaining = await _returns.SumRemainingOnGrnLineAsync(grnLine.GrnLineId, ct);
|
||||
var remaining = await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.GrnLineId == line.GrnLineId).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
if (line.Qty > remaining)
|
||||
throw new DomainException(ErrorCodes.ReturnQtyExceedsRejected,
|
||||
$"GRN line {line.GrnLineId} has {remaining} rejected unit(s) left to return, but {line.Qty} was requested.", 422);
|
||||
@@ -170,7 +210,10 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
var balances = new Dictionary<int, decimal>();
|
||||
foreach (var line in rejectedLines)
|
||||
{
|
||||
var layers = await _returns.ListOpenLayersForGrnLineAsync(line.GrnLineId!.Value, token);
|
||||
var layers = await _layers.Query()
|
||||
.Where(l => l.GrnLineId == line.GrnLineId && l.QtyRemaining > 0)
|
||||
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||
.ToListAsync(token);
|
||||
|
||||
// A serial line's layers are one per unit: named serials take exactly theirs;
|
||||
// with none named, the whole remaining line may go back (every serial on it).
|
||||
@@ -187,7 +230,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
if (line.Qty != layers.Sum(l => l.QtyRemaining))
|
||||
throw new DomainException(ErrorCodes.TrackingRequired,
|
||||
$"GRN line {line.GrnLineId} is serial-tracked; select which {line.Qty} serial(s) go back.", 422);
|
||||
named = await _returns.ListSerialsAsync(layers.Where(l => l.SerialId != null).Select(l => l.SerialId!.Value).ToList(), token);
|
||||
named = await _serials.Query()
|
||||
.Where(s => layers.Select(l => l.SerialId).Contains(s.SerialId)).ToListAsync(token);
|
||||
serialsByLine[line] = named;
|
||||
}
|
||||
|
||||
@@ -216,7 +260,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
// Serial numbers switched off: serial units went back oldest-first without being
|
||||
// named — still record them as returned so the register doesn't show them in stock.
|
||||
if (named.Count == 0 && takenSerialIds.Count > 0)
|
||||
serialsByLine[line] = await _returns.ListSerialsAsync(takenSerialIds, token);
|
||||
serialsByLine[line] = await _serials.Query().Where(s => takenSerialIds.Contains(s.SerialId)).ToListAsync(token);
|
||||
}
|
||||
|
||||
foreach (var serials in serialsByLine.Values)
|
||||
|
||||
@@ -2,53 +2,74 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowDownLeft, ArrowUpRight, ScrollText } from "lucide-react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
BadgeDollarSign,
|
||||
Boxes,
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
ScrollText,
|
||||
Send,
|
||||
} from "lucide-react"
|
||||
|
||||
import { dashboardApi } from "@/lib/api/dashboard"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
|
||||
import { DashboardStats } from "@/types/dashboard"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { DashboardDateFilter, DashboardPeriodValue, periodBounds } from "@/components/dashboard/DashboardDateFilter"
|
||||
import { DashboardCharts } from "@/components/dashboard/DashboardCharts"
|
||||
import { DashboardStatGroups } from "@/components/dashboard/DashboardStatGroups"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
|
||||
const TREND_DAYS = 14
|
||||
const MOBILE_TREND_DAYS = 5
|
||||
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
|
||||
const CHART_MIN_SLOTS = 5
|
||||
|
||||
function isoDate(d: Date) {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { activeBranchId, branchingEnabled, can } = useAuth()
|
||||
// The recent-movements table belongs to the Stock area of the dashboard (Settings -> Roles -> Dashboard).
|
||||
const canStock = can("dashboard.stock")
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [charts, setCharts] = useState<DashboardChartsData | null>(null)
|
||||
const [chartsFailed, setChartsFailed] = useState(false)
|
||||
const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
|
||||
const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [period, setPeriod] = useState<DashboardPeriodValue>({ period: "today" })
|
||||
const bounds = useMemo(() => periodBounds(period), [period])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const from = new Date()
|
||||
from.setDate(from.getDate() - (TREND_DAYS - 1))
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
Promise.all([
|
||||
canStock ? stockApi.ledger({ page: 1, pageSize: 5, branchId }) : Promise.resolve(null),
|
||||
// Only used to label warehouses; a role without the Warehouses screen just sees ids instead of an error.
|
||||
warehousesApi.list().catch(() => null),
|
||||
dashboardApi.stats({ branchId }),
|
||||
stockApi.ledger({ page: 1, pageSize: 5, branchId }),
|
||||
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200, branchId }),
|
||||
warehousesApi.list(),
|
||||
])
|
||||
.then(([ledger, warehousesRes]) => {
|
||||
.then(([statsRes, ledger, trendRes, warehousesRes]) => {
|
||||
if (cancelled) return
|
||||
setMovements(ledger?.items ?? [])
|
||||
setWarehouses(warehousesRes?.items ?? [])
|
||||
setStats(statsRes)
|
||||
setMovements(ledger.items)
|
||||
setTrend(trendRes.items)
|
||||
setWarehouses(warehousesRes.items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
@@ -57,62 +78,61 @@ export default function DashboardPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeBranchId, branchingEnabled, canStock])
|
||||
|
||||
// Stats follow the date filter: the activity counts use its from–to period, the rest are snapshots.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
dashboardApi
|
||||
.stats({ branchId, from: bounds.from, to: bounds.to })
|
||||
.then((res) => {
|
||||
if (!cancelled) setStats(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeBranchId, branchingEnabled, bounds.from, bounds.to])
|
||||
|
||||
// Graph datasets follow the same date filter. A failure only blanks the graphs, not the whole dashboard.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
dashboardApi
|
||||
.charts({ branchId, from: bounds.from, to: bounds.to })
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setCharts(res)
|
||||
setChartsFailed(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setChartsFailed(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeBranchId, branchingEnabled, bounds.from, bounds.to])
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
|
||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||
|
||||
const warehouseCodes = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w.code])), [warehouses])
|
||||
// Last TREND_DAYS days, oldest first, each bucket summing In/Out qty for that date.
|
||||
const movementTrend = useMemo(() => {
|
||||
const days: string[] = []
|
||||
const cursor = new Date()
|
||||
cursor.setDate(cursor.getDate() - (TREND_DAYS - 1))
|
||||
for (let i = 0; i < TREND_DAYS; i++) {
|
||||
days.push(isoDate(cursor))
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
|
||||
const loaded = stats && movements && warehouses
|
||||
const labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" }))
|
||||
|
||||
const inByDay = new Map(days.map((d) => [d, 0]))
|
||||
const outByDay = new Map(days.map((d) => [d, 0]))
|
||||
for (const entry of trend ?? []) {
|
||||
const day = entry.createdAt.slice(0, 10)
|
||||
const bucket = entry.direction === "In" ? inByDay : outByDay
|
||||
if (bucket.has(day)) bucket.set(day, (bucket.get(day) ?? 0) + entry.qtyBase)
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
inData: days.map((d) => inByDay.get(d) ?? 0),
|
||||
outData: days.map((d) => outByDay.get(d) ?? 0),
|
||||
}
|
||||
}, [trend])
|
||||
|
||||
// Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS
|
||||
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
|
||||
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
|
||||
const isMobile = useIsMobile()
|
||||
// Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width.
|
||||
const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS
|
||||
const shownTrend = useMemo(
|
||||
() => ({
|
||||
labels: movementTrend.labels.slice(-trendDays),
|
||||
inData: movementTrend.inData.slice(-trendDays),
|
||||
outData: movementTrend.outData.slice(-trendDays),
|
||||
}),
|
||||
[movementTrend, trendDays]
|
||||
)
|
||||
const valuationRows = stats?.stockValuationByWarehouse ?? []
|
||||
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
|
||||
|
||||
const loaded = stats && movements && trend && warehouses
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="shrink-0">
|
||||
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
|
||||
</div>
|
||||
<DashboardDateFilter value={period} onChange={setPeriod} />
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -121,143 +141,232 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DashboardStatGroups from={bounds.from} to={bounds.to} stats={stats} />
|
||||
{/* Three across until there is room for six — at six-up the cards get too narrow and switch to the
|
||||
compact stacked layout, so they would not match the stat cards on every other screen. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
|
||||
{loaded ? (
|
||||
<>
|
||||
<Link href="/dashboard/stock/reorder-alerts" className="block h-full rounded-2xl">
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/enquiry" className="block h-full rounded-2xl">
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/receiving/grn" className="block h-full rounded-2xl">
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/requisitions" className="block h-full rounded-2xl">
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/counts" className="block h-full rounded-2xl">
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/rfqs" className="block h-full rounded-2xl">
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} className="h-full" />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DashboardCharts stats={stats} charts={charts} warehouseCodes={warehouseCodes} error={chartsFailed} />
|
||||
|
||||
{canStock && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
Recent Stock Movements
|
||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||
Stock Valuation by Warehouse
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View all
|
||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
movements.length > 0 ? (
|
||||
<>
|
||||
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{movements.map((entry) => (
|
||||
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
|
||||
)}
|
||||
>
|
||||
{entry.direction === "In" ? (
|
||||
<ArrowDownLeft className="size-4 text-success" />
|
||||
) : (
|
||||
<ArrowUpRight className="size-4 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||
<div className="h-64 sm:h-72">
|
||||
<BarChart
|
||||
labels={[
|
||||
...valuationRows.map((w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`),
|
||||
...Array<string>(valuationPad).fill(""),
|
||||
]}
|
||||
datasets={[
|
||||
{
|
||||
label: "Stock Value (LKR)",
|
||||
data: [...valuationRows.map((w) => w.total), ...Array<null>(valuationPad).fill(null)],
|
||||
backgroundColor: "#E11D48",
|
||||
},
|
||||
]}
|
||||
barPercentage={0.6}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
Stock Movement Trend (last {trendDays} days)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
|
||||
<div className="h-64 sm:h-72">
|
||||
<LineChart
|
||||
labels={shownTrend.labels}
|
||||
datasets={[
|
||||
{ label: "In", data: shownTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||
{ label: "Out", data: shownTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {trendDays} days.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
Recent Stock Movements
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
movements.length > 0 ? (
|
||||
<>
|
||||
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{movements.map((entry) => (
|
||||
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
|
||||
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Qty</p>
|
||||
<p className="font-medium text-foreground">{entry.qtyBase}</p>
|
||||
{entry.direction === "In" ? (
|
||||
<ArrowDownLeft className="size-4 text-success" />
|
||||
) : (
|
||||
<ArrowUpRight className="size-4 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date</p>
|
||||
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Source</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
|
||||
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-160">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{movements.map((entry) => (
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
|
||||
<TableCell>
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center text-xs",
|
||||
entry.direction === "In"
|
||||
? "border-transparent bg-success/10 text-success"
|
||||
: "border-transparent bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{entry.qtyBase}</TableCell>
|
||||
<TableCell>
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Qty</p>
|
||||
<p className="font-medium text-foreground">{entry.qtyBase}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date</p>
|
||||
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Source</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-160">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{movements.map((entry) => (
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
|
||||
<TableCell>
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center text-xs",
|
||||
entry.direction === "In"
|
||||
? "border-transparent bg-success/10 text-success"
|
||||
: "border-transparent bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{entry.qtyBase}</TableCell>
|
||||
<TableCell>
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,8 +81,6 @@
|
||||
--chart-3: oklch(0.582 0.183 142.495);
|
||||
--chart-4: oklch(0.787 0.141 72.031);
|
||||
--chart-5: oklch(0.577 0.245 27.325);
|
||||
/* Dashboard charts: one slate ink, tinted lighter for extra series, so every graph matches. */
|
||||
--chart-ink: #5b6b6e;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.98 0 0);
|
||||
--sidebar-foreground: oklch(0.15 0 0);
|
||||
@@ -122,7 +120,6 @@
|
||||
--chart-3: oklch(0.65 0.15 142.495);
|
||||
--chart-4: oklch(0.85 0.12 72.031);
|
||||
--chart-5: oklch(0.704 0.191 22.216);
|
||||
--chart-ink: #a3b3b6;
|
||||
--sidebar: oklch(0.125 0 0);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-primary: oklch(0.645 0.215 16.439);
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
|
||||
"use client"
|
||||
|
||||
import { Fragment } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
|
||||
// Per-segment display labels. Deliberately separate from Header.tsx's
|
||||
// titleFromPath (which resolves one *full* path to a page title) — a
|
||||
// breadcrumb needs a label per *segment*, including grouping segments that
|
||||
// have no page of their own (e.g. "receiving", "grn").
|
||||
const SEGMENT_LABELS: Record<string, string> = {
|
||||
dashboard: "Dashboard",
|
||||
products: "Items",
|
||||
@@ -50,81 +48,55 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
"owner-report": "Day-End Owner Report",
|
||||
}
|
||||
|
||||
function labelFor(segment: string): string {
|
||||
if (SEGMENT_LABELS[segment]) return SEGMENT_LABELS[segment]
|
||||
if (/^[0-9]+$/.test(segment)) return `#${segment}`
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouping paths with no page of their own (no page.tsx), mapped to where their menu lands — the
|
||||
* same `landingHref`s as the sidebar. Linking the raw path would 404. (HRM has its own hub page now.)
|
||||
*/
|
||||
const GROUP_LANDING: Record<string, string> = {
|
||||
"/dashboard/production": "/dashboard/production/runs",
|
||||
"/dashboard/receiving": "/dashboard/receiving/grn",
|
||||
"/dashboard/sales/bundles":
|
||||
"/dashboard/sales/bundles/templates",
|
||||
"/dashboard/sales/bundles": "/dashboard/sales/bundles/templates",
|
||||
"/dashboard/help/tickets": "/dashboard/help",
|
||||
}
|
||||
|
||||
function labelFor(segment: string): string {
|
||||
if (SEGMENT_LABELS[segment]) {
|
||||
return SEGMENT_LABELS[segment]
|
||||
}
|
||||
|
||||
if (/^[0-9]+$/.test(segment)) {
|
||||
return `#${segment}`
|
||||
}
|
||||
|
||||
return segment
|
||||
.split("-")
|
||||
.map(
|
||||
(word) =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
)
|
||||
.join(" ")
|
||||
}
|
||||
const linkClass =
|
||||
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname() || "/dashboard"
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
|
||||
if (segments.length <= 1) {
|
||||
return null
|
||||
}
|
||||
// Nothing to show above the dashboard root itself.
|
||||
if (segments.length <= 1) return null
|
||||
|
||||
const crumbs = segments.map((segment, index) => {
|
||||
const path = `/${segments
|
||||
.slice(0, index + 1)
|
||||
.join("/")}`
|
||||
|
||||
return {
|
||||
key: path,
|
||||
href: GROUP_LANDING[path] ?? path,
|
||||
label: labelFor(segment),
|
||||
}
|
||||
const crumbs = segments.map((segment, i) => {
|
||||
const path = "/" + segments.slice(0, i + 1).join("/")
|
||||
return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) }
|
||||
})
|
||||
|
||||
// Remove consecutive duplicate labels, e.g. Sales > Sales.
|
||||
const uniqueCrumbs = crumbs.filter(
|
||||
(crumb, index, array) =>
|
||||
index === 0 ||
|
||||
crumb.label.toLowerCase() !==
|
||||
array[index - 1].label.toLowerCase()
|
||||
)
|
||||
|
||||
return (
|
||||
<Breadcrumb className="mb-4 print:hidden">
|
||||
<BreadcrumbList>
|
||||
{uniqueCrumbs.map((crumb, index) => {
|
||||
const isLast = index === uniqueCrumbs.length - 1
|
||||
|
||||
{crumbs.map((crumb, i) => {
|
||||
const isLast = i === crumbs.length - 1
|
||||
return (
|
||||
<Fragment key={crumb.key}>
|
||||
{index > 0 && <BreadcrumbSeparator />}
|
||||
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>
|
||||
{crumb.label}
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<span className="truncate text-muted-foreground">
|
||||
<Link href={crumb.href} className={linkClass}>
|
||||
{crumb.label}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, XAxis, YAxis } from "recharts"
|
||||
|
||||
import { useId, useMemo } from "react"
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { buildChartSpecs, canSeeChart, CHART_SECTIONS, isChartEmpty, type BarChartProps, type ChartSpec, type LineChartProps, type Series } from "@/components/dashboard/dashboard-chart-data"
|
||||
|
||||
// The dashboard palette is the theme red (--primary) paired with a slate ash (--chart-ink in globals.css), plus
|
||||
// lighter tints of each, so every chart reads as one set in the brand colours.
|
||||
const tint = (base: string, pct: number) => `color-mix(in oklab, ${base} ${pct}%, var(--background))`
|
||||
const RED = "var(--primary)"
|
||||
const ASH = "var(--chart-ink)"
|
||||
// Several series: ash and red side by side (clearly different), then a light ash for a third.
|
||||
const SERIES_SHADES = [ASH, RED, tint(ASH, 35)]
|
||||
// Donuts alternate red and ash, fading lighter, so neighbouring slices never look alike.
|
||||
const SLICE_SHADES = [RED, ASH, tint(RED, 60), tint(ASH, 55), tint(ASH, 30)]
|
||||
|
||||
// A chart with one series is the brand red; with several it uses the ash / red pairing above.
|
||||
const seriesColor = (i: number, count: number) => (count === 1 ? RED : SERIES_SHADES[i % SERIES_SHADES.length])
|
||||
const sliceColor = (i: number) => SLICE_SHADES[i % SLICE_SHADES.length]
|
||||
|
||||
function seriesConfig(series: Series[]): ChartConfig {
|
||||
return Object.fromEntries(series.map((s, i) => [s.key, { label: s.label, color: s.color ?? seriesColor(i, series.length) }]))
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 })
|
||||
const formatTick = (v: number | string) => (typeof v === "number" ? compact.format(v) : v)
|
||||
|
||||
export function BarChartView({ spec }: { spec: BarChartProps }) {
|
||||
const config = seriesConfig(spec.series)
|
||||
const horizontal = spec.horizontal === true
|
||||
return (
|
||||
<ChartContainer config={config} className="h-64 w-full">
|
||||
<BarChart accessibilityLayer data={spec.data} layout={horizontal ? "vertical" : "horizontal"} margin={{ left: horizontal ? 8 : 0 }}>
|
||||
<CartesianGrid horizontal={!horizontal} vertical={horizontal} />
|
||||
{horizontal ? (
|
||||
<>
|
||||
<YAxis dataKey={spec.xKey} type="category" tickLine={false} axisLine={false} width={72} tickMargin={4} />
|
||||
<XAxis type="number" hide />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis dataKey={spec.xKey} tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={formatTick} />
|
||||
</>
|
||||
)}
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
{spec.series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
||||
{spec.series.map((s) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
fill={`var(--color-${s.key})`}
|
||||
stackId={spec.stacked ? "a" : undefined}
|
||||
maxBarSize={56}
|
||||
radius={spec.stacked ? 0 : horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function LineChartView({ spec }: { spec: LineChartProps }) {
|
||||
// Gradient ids must be unique per chart on the page, or every chart would share the first one's fill.
|
||||
const gid = useId().replace(/:/g, "")
|
||||
const config = seriesConfig(spec.series)
|
||||
const common = (
|
||||
<>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey={spec.xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={16} />
|
||||
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={formatTick} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
{spec.series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<ChartContainer config={config} className="h-64 w-full">
|
||||
{spec.area ? (
|
||||
<AreaChart accessibilityLayer data={spec.data}>
|
||||
<defs>
|
||||
{spec.series.map((s) => (
|
||||
<linearGradient key={s.key} id={`${gid}-${s.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={`var(--color-${s.key})`} stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor={`var(--color-${s.key})`} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{common}
|
||||
{spec.series.map((s) => (
|
||||
<Area key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} fill={`url(#${gid}-${s.key})`} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
|
||||
))}
|
||||
</AreaChart>
|
||||
) : (
|
||||
<LineChart accessibilityLayer data={spec.data}>
|
||||
{common}
|
||||
{spec.series.map((s) => (
|
||||
<Line key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} dot={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function DonutSpec({ spec }: { spec: Extract<ChartSpec, { kind: "donut" }> }) {
|
||||
const config: ChartConfig = Object.fromEntries(spec.data.map((d, i) => [d.key, { label: d.label, color: sliceColor(i) }]))
|
||||
return (
|
||||
<ChartContainer config={config} className="mx-auto h-64 w-full">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="key" />} />
|
||||
<Pie data={spec.data} dataKey="value" nameKey="key" innerRadius={52} outerRadius={84} strokeWidth={2}>
|
||||
{spec.data.map((d) => (
|
||||
<Cell key={d.key} fill={`var(--color-${d.key})`} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartBody({ spec }: { spec: ChartSpec }) {
|
||||
switch (spec.kind) {
|
||||
case "bar":
|
||||
return <BarChartView spec={spec} />
|
||||
case "line":
|
||||
return <LineChartView spec={spec} />
|
||||
case "donut":
|
||||
return <DonutSpec spec={spec} />
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardCharts({
|
||||
stats,
|
||||
charts,
|
||||
warehouseCodes,
|
||||
error,
|
||||
}: {
|
||||
stats: DashboardStats | null
|
||||
charts: DashboardChartsData | null
|
||||
warehouseCodes: Map<number, string>
|
||||
error?: boolean
|
||||
}) {
|
||||
const { can } = useAuth()
|
||||
// A role only sees the graphs for modules it can open; the API withholds the data too (DashboardAccess).
|
||||
const specs = useMemo(
|
||||
() => buildChartSpecs(stats, charts, warehouseCodes).filter((spec) => canSeeChart(spec, can)),
|
||||
[stats, charts, warehouseCodes, can]
|
||||
)
|
||||
const loading = !charts && !error
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{CHART_SECTIONS.map((section) => {
|
||||
const sectionSpecs = specs.filter((c) => c.section === section)
|
||||
if (sectionSpecs.length === 0) return null
|
||||
return (
|
||||
<section key={section} className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">{section}</h2>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{sectionSpecs.map((spec) => (
|
||||
<Card key={spec.id} className="min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm sm:text-base">{spec.title}</CardTitle>
|
||||
<CardDescription>{spec.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-64 rounded-lg" />
|
||||
) : isChartEmpty(spec) ? (
|
||||
<p className="flex h-64 items-center justify-center text-sm text-muted-foreground">No data for this period.</p>
|
||||
) : (
|
||||
<ChartBody spec={spec} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarDays, X } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DateRangePicker } from "@/components/ui/date-picker"
|
||||
import type { CustomDateRange } from "@/components/ui/date-range-filter"
|
||||
|
||||
export type DashboardPeriod = "today" | "7d" | "14d" | "1m" | "2m" | "3m" | "6m" | "1y" | "custom"
|
||||
|
||||
export interface DashboardPeriodValue {
|
||||
period: DashboardPeriod
|
||||
custom?: CustomDateRange
|
||||
}
|
||||
|
||||
const PERIODS: { value: DashboardPeriod; label: string }[] = [
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "7d", label: "7 Days" },
|
||||
{ value: "14d", label: "14 Days" },
|
||||
{ value: "1m", label: "1 Month" },
|
||||
{ value: "2m", label: "2 Months" },
|
||||
{ value: "3m", label: "3 Months" },
|
||||
{ value: "6m", label: "6 Months" },
|
||||
{ value: "1y", label: "1 Year" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
]
|
||||
|
||||
const isoDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
|
||||
/** Inclusive `YYYY-MM-DD` bounds for the chosen period; empty while a custom range has no start date yet. */
|
||||
export function periodBounds(value: DashboardPeriodValue): { from?: string; to?: string } {
|
||||
const today = new Date()
|
||||
const from = new Date(today)
|
||||
switch (value.period) {
|
||||
case "custom":
|
||||
return value.custom?.from ? { from: isoDate(value.custom.from), to: isoDate(value.custom.to ?? value.custom.from) } : {}
|
||||
case "today":
|
||||
break
|
||||
case "7d":
|
||||
from.setDate(today.getDate() - 6)
|
||||
break
|
||||
case "14d":
|
||||
from.setDate(today.getDate() - 13)
|
||||
break
|
||||
case "1m":
|
||||
from.setMonth(today.getMonth() - 1)
|
||||
break
|
||||
case "2m":
|
||||
from.setMonth(today.getMonth() - 2)
|
||||
break
|
||||
case "3m":
|
||||
from.setMonth(today.getMonth() - 3)
|
||||
break
|
||||
case "6m":
|
||||
from.setMonth(today.getMonth() - 6)
|
||||
break
|
||||
case "1y":
|
||||
from.setFullYear(today.getFullYear() - 1)
|
||||
break
|
||||
}
|
||||
return { from: isoDate(from), to: isoDate(today) }
|
||||
}
|
||||
|
||||
/** Appends the dashboard period to a link as ?from=&to= so the page it opens can apply the same dates. */
|
||||
export function withPeriod(href: string, from?: string, to?: string): string {
|
||||
if (!from) return href
|
||||
return `${href}${href.includes("?") ? "&" : "?"}from=${from}&to=${to ?? from}`
|
||||
}
|
||||
|
||||
/** One-line row of period buttons for the dashboard; "Custom" reveals a single-date / from–to picker. */
|
||||
export function DashboardDateFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: DashboardPeriodValue
|
||||
onChange: (next: DashboardPeriodValue) => void
|
||||
}) {
|
||||
// In custom mode the preset buttons step aside so only Custom, its picker and a way back remain.
|
||||
const isCustom = value.period === "custom"
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
|
||||
{/* A single line that scrolls sideways on narrow screens instead of wrapping. */}
|
||||
<div role="group" aria-label="Dashboard period" className="-mx-1 flex gap-1.5 overflow-x-auto px-1 py-1">
|
||||
{(isCustom ? PERIODS.filter((p) => p.value === "custom") : PERIODS).map((p) => (
|
||||
<Button
|
||||
key={p.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={value.period === p.value ? "default" : "outline"}
|
||||
aria-pressed={value.period === p.value}
|
||||
className="shrink-0 rounded-full"
|
||||
onClick={() => onChange({ ...value, period: p.value })}
|
||||
>
|
||||
{p.value === "custom" && <CalendarDays className="size-4" />}
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{isCustom && (
|
||||
<>
|
||||
<DateRangePicker
|
||||
value={value.custom}
|
||||
onChange={(custom) => onChange({ ...value, custom })}
|
||||
placeholder="Pick a date or from – to"
|
||||
className="sm:w-72"
|
||||
/>
|
||||
<Button type="button" size="sm" variant="ghost" className="shrink-0 rounded-full" onClick={() => onChange({ ...value, period: "today" })}>
|
||||
<X className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
Send,
|
||||
ArrowLeftRight,
|
||||
BadgeDollarSign,
|
||||
Banknote,
|
||||
CalendarClock,
|
||||
CalendarX,
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
FileText,
|
||||
FileWarning,
|
||||
Gift,
|
||||
Hammer,
|
||||
HandCoins,
|
||||
LifeBuoy,
|
||||
PackageX,
|
||||
PiggyBank,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShoppingBag,
|
||||
Tag,
|
||||
Timer,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
Users,
|
||||
Wallet,
|
||||
Warehouse,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import Link from "next/link"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { withPeriod } from "@/components/dashboard/DashboardDateFilter"
|
||||
import { DashboardStats } from "@/types/dashboard"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
|
||||
// UI only: no data is connected yet, so every value shows a dash. Nothing here calls the API.
|
||||
type NumericStat = Exclude<keyof DashboardStats, "stockValuationByWarehouse" | "monthlyPurchasesVsSales">
|
||||
|
||||
interface Card {
|
||||
label: string
|
||||
/** The Dashboard area (Settings -> Roles -> Dashboard) that shows this card; a role without it never sees the card. */
|
||||
code?: string
|
||||
/** Page the card opens; the dashboard period is appended as ?from=&to=. */
|
||||
href: string
|
||||
value: number | string
|
||||
icon: LucideIcon
|
||||
hint?: string
|
||||
/** Live number from /dashboard/stats; replaces `value` once the stats have loaded. */
|
||||
stat?: NumericStat
|
||||
/** Show the value and icon in the destructive colour. */
|
||||
alert?: boolean
|
||||
}
|
||||
|
||||
interface Group {
|
||||
title?: string
|
||||
cards: Card[]
|
||||
}
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{
|
||||
// The original six cards: no heading, live numbers from /dashboard/stats.
|
||||
cards: [
|
||||
{ label: "Low Stock Alerts", code: "dashboard.stock", href: "/dashboard/stock/reorder-alerts", value: "—", stat: "lowStockAlerts", icon: AlertTriangle },
|
||||
{ label: "Stock On-Hand", code: "dashboard.stock", href: "/dashboard/stock/enquiry", value: "—", stat: "onHandTotal", icon: Boxes },
|
||||
{ label: "Pending GRNs", code: "dashboard.procurement", href: "/dashboard/receiving/grn", value: "—", stat: "pendingGrns", icon: PackageCheck },
|
||||
{ label: "Open Requisitions", code: "dashboard.procurement", href: "/dashboard/procurement/requisitions", value: "—", stat: "openRequisitions", icon: ClipboardList },
|
||||
{ label: "Open Counts", code: "dashboard.stock", href: "/dashboard/stock/counts", value: "—", stat: "pendingCounts", icon: ListChecks },
|
||||
{ label: "Active RFQs", code: "dashboard.procurement", href: "/dashboard/procurement/rfqs", value: "—", stat: "openRfqs", icon: Send },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Overview",
|
||||
cards: [
|
||||
{ label: "Stock Value", code: "dashboard.stock", stat: "stockValuationTotal", href: "/dashboard/stock/valuation", value: "—", icon: BadgeDollarSign, hint: "LKR" },
|
||||
{ label: "Overdue Sales Invoices", code: "dashboard.sales", stat: "overdueSalesInvoices", href: "/dashboard/sales/invoices", value: "—", icon: FileWarning },
|
||||
{ label: "POs Awaiting Approval", code: "dashboard.approvals", stat: "pendingApprovalPurchaseOrders", href: "/dashboard/procurement/purchase-orders/approvals", value: "—", icon: ClipboardCheck },
|
||||
{ label: "Pending Leave Requests", code: "dashboard.hrm", stat: "pendingLeaveRequests", href: "/dashboard/hrm/leave", value: "—", icon: CalendarClock },
|
||||
{ label: "Warehouses Holding Stock", code: "dashboard.stock", stat: "onHandWarehouses", href: "/dashboard/stock/enquiry", value: "—", icon: Warehouse },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Stock & Warehouse",
|
||||
cards: [
|
||||
{ label: "Batches Expiring Soon", code: "dashboard.stock", stat: "batchesExpiringSoon", href: "/dashboard/stock/batches", value: "—", icon: CalendarX, hint: "Next 30 days" },
|
||||
{ label: "Transfers In Transit", code: "dashboard.stock", stat: "transfersInTransit", href: "/dashboard/stock/transfers", value: "—", icon: ArrowLeftRight },
|
||||
{ label: "Draft Stock Adjustments", code: "dashboard.stock", stat: "draftStockAdjustments", href: "/dashboard/stock/adjustments", value: "—", icon: ClipboardList },
|
||||
{ label: "Wastage Pending Approval", code: "dashboard.approvals", stat: "wastagePendingApproval", href: "/dashboard/stock/wastage", value: "—", icon: Trash2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Approvals",
|
||||
cards: [
|
||||
{ label: "Pending Approvals", code: "dashboard.approvals", stat: "pendingApprovals", href: "/dashboard/approvals", value: "—", icon: ClipboardCheck },
|
||||
{ label: "Price Adjustments Pending", code: "dashboard.approvals", stat: "priceAdjustmentsPending", href: "/dashboard/products/price-adjustments", value: "—", icon: Tag },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Sales",
|
||||
cards: [
|
||||
{ label: "Draft Sales Invoices", code: "dashboard.sales", stat: "draftSalesInvoices", href: "/dashboard/sales/invoices", value: "—", icon: FileText },
|
||||
{ label: "Draft Sales Slips", code: "dashboard.sales", stat: "draftSalesSlips", href: "/dashboard/sales/invoices", value: "—", icon: Receipt },
|
||||
{ label: "Sales Returns", code: "dashboard.sales", stat: "salesReturns", href: "/dashboard/sales/sales-returns", value: "—", icon: RotateCcw },
|
||||
{ label: "Bundle Sales", code: "dashboard.sales", stat: "bundleSales", href: "/dashboard/sales/bundles", value: "—", icon: Gift },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Purchasing",
|
||||
cards: [
|
||||
{ label: "Purchase Returns Pending", code: "dashboard.procurement", stat: "purchaseReturnsPending", href: "/dashboard/procurement/purchase-returns", value: "—", icon: PackageX },
|
||||
{ label: "Unpaid Purchase Invoices", code: "dashboard.procurement", stat: "unpaidPurchaseInvoices", href: "/dashboard/procurement/purchase-invoices", value: "—", icon: FileWarning },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Money",
|
||||
cards: [
|
||||
{ label: "Cash Not Yet Deposited", code: "dashboard.accounts", stat: "cashInHand", href: "/dashboard/accounts/bank-deposits", value: "—", icon: Wallet, hint: "LKR" },
|
||||
{ label: "Bank Deposits", code: "dashboard.accounts", stat: "bankDeposits", href: "/dashboard/accounts/bank-deposits", value: "—", icon: PiggyBank, hint: "LKR" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Service & Warranty",
|
||||
cards: [
|
||||
{ label: "Open Support Tickets", code: "dashboard.service", stat: "openSupportTickets", href: "/dashboard/help", value: "—", icon: LifeBuoy },
|
||||
{ label: "Warranties Expiring Soon", code: "dashboard.service", stat: "warrantiesExpiringSoon", href: "/dashboard/sales/warranties", value: "—", icon: ShieldAlert, hint: "Next 30 days" },
|
||||
{ label: "Warranty Claims Open", code: "dashboard.service", stat: "warrantyClaimsOpen", href: "/dashboard/sales/warranties", value: "—", icon: ShieldCheck },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Production",
|
||||
cards: [{ label: "Production Runs In Progress", code: "dashboard.production", stat: "productionRunsInProgress", href: "/dashboard/production/runs", value: "—", icon: Hammer }],
|
||||
},
|
||||
{
|
||||
title: "HR",
|
||||
cards: [
|
||||
{ label: "Payroll Run Pending", code: "dashboard.hrm", stat: "payrollRunsPending", href: "/dashboard/hrm/payroll", value: "—", icon: Timer },
|
||||
{ label: "Attendance Batches Pending", code: "dashboard.hrm", stat: "attendanceBatchesPending", href: "/dashboard/hrm/attendance", value: "—", icon: AlertTriangle },
|
||||
{ label: "Employees Present", code: "dashboard.hrm", stat: "employeesPresent", href: "/dashboard/hrm/attendance", value: "—", icon: UserCheck },
|
||||
{ label: "Total Active Employees", code: "dashboard.hrm", stat: "totalActiveEmployees", href: "/dashboard/hrm/employees", value: "—", icon: Users },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function DashboardStatGroups({ from, to, stats }: { from?: string; to?: string; stats: DashboardStats | null }) {
|
||||
const { can } = useAuth()
|
||||
|
||||
// A role only sees the cards of the dashboard areas it was given; the API withholds the figures too (DashboardAccess).
|
||||
const groups = GROUPS.map((group) => ({ ...group, cards: group.cards.filter((card) => !card.code || can(card.code)) })).filter(
|
||||
(group) => group.cards.length > 0
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{groups.map((group) => (
|
||||
<section key={group.title ?? "main"} className="flex flex-col gap-3">
|
||||
{group.title && <h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">{group.title}</h2>}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
|
||||
{group.cards.map((card) => (
|
||||
<Link key={card.label} href={withPeriod(card.href, from, to)} className="block h-full rounded-2xl">
|
||||
<StatCard
|
||||
label={card.label}
|
||||
value={card.stat && stats ? (stats[card.stat] ?? 0) : card.value}
|
||||
hint={card.hint}
|
||||
icon={card.icon}
|
||||
compact
|
||||
className="h-full"
|
||||
valueClassName={card.alert ? "text-destructive" : undefined}
|
||||
iconClassName={card.alert ? "bg-destructive/10 text-destructive" : undefined}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
// Turns the dashboard's real data (GET /dashboard/charts, plus the monthly and per-warehouse figures that already
|
||||
// come with GET /dashboard/stats) into the chart specs DashboardCharts draws. No numbers are made up here: an empty
|
||||
// response draws an empty chart.
|
||||
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
|
||||
|
||||
export interface Series {
|
||||
key: string
|
||||
label: string
|
||||
/** CSS colour; defaults to the dashboard palette (ash and red), so leave it unset to keep every chart matching. */
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** Which Dashboard areas (Settings -> Roles -> Dashboard) a role needs to see a graph. No rule = open to everyone. */
|
||||
export interface ChartAccess {
|
||||
/** Every one of these is required. */
|
||||
all?: string[]
|
||||
/** At least one of these is required. */
|
||||
any?: string[]
|
||||
}
|
||||
|
||||
interface BaseChart {
|
||||
id: string
|
||||
access?: ChartAccess
|
||||
title: string
|
||||
description: string
|
||||
section: ChartSection
|
||||
}
|
||||
|
||||
type Row = Record<string, string | number>
|
||||
|
||||
/** What the bar and line views need, so live charts on the page can reuse them with their own data. */
|
||||
export interface BarChartProps {
|
||||
xKey: string
|
||||
series: Series[]
|
||||
data: Row[]
|
||||
stacked?: boolean
|
||||
horizontal?: boolean
|
||||
}
|
||||
export interface LineChartProps {
|
||||
xKey: string
|
||||
series: Series[]
|
||||
data: Row[]
|
||||
area?: boolean
|
||||
}
|
||||
|
||||
export type ChartSection = "Sales" | "Money" | "Purchasing" | "Stock" | "HR" | "Service & Production"
|
||||
|
||||
export type ChartSpec = BaseChart &
|
||||
(
|
||||
| ({ kind: "bar" } & BarChartProps)
|
||||
| ({ kind: "line" } & LineChartProps)
|
||||
| { kind: "donut"; data: { key: string; label: string; value: number }[] }
|
||||
)
|
||||
|
||||
export const CHART_SECTIONS: ChartSection[] = ["Sales", "Money", "Purchasing", "Stock", "HR", "Service & Production"]
|
||||
|
||||
export function canSeeChart(spec: ChartSpec, can: (navCode: string) => boolean): boolean {
|
||||
const { all, any } = spec.access ?? {}
|
||||
return (all ?? []).every(can) && (!any || any.some(can))
|
||||
}
|
||||
|
||||
/** True when a chart has nothing to draw: no rows, or every value is zero. */
|
||||
export function isChartEmpty(spec: ChartSpec): boolean {
|
||||
if (spec.kind === "donut") return spec.data.every((d) => d.value === 0)
|
||||
return spec.data.every((row) => spec.series.every((s) => Number(row[s.key] ?? 0) === 0))
|
||||
}
|
||||
|
||||
const slug = (label: string) => label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "x"
|
||||
|
||||
/** "2026-03" -> "Mar 26" (a mid-month day is added so the date parses the same in every time zone). */
|
||||
function monthLabel(yearMonth: string): string {
|
||||
const d = new Date(`${yearMonth}-15T00:00:00`)
|
||||
return Number.isNaN(d.getTime()) ? yearMonth : d.toLocaleDateString(undefined, { month: "short", year: "2-digit" })
|
||||
}
|
||||
|
||||
export function buildChartSpecs(
|
||||
stats: DashboardStats | null,
|
||||
charts: DashboardChartsData | null,
|
||||
warehouseCodes: Map<number, string>
|
||||
): ChartSpec[] {
|
||||
const slices = (rows: { label: string; value: number }[]) => rows.map((r) => ({ key: slug(r.label), label: r.label, value: r.value }))
|
||||
const c = charts
|
||||
|
||||
return [
|
||||
// ---------------------------------------------------------------- Sales
|
||||
{
|
||||
id: "purchases-vs-sales",
|
||||
access: { all: ["dashboard.sales", "dashboard.procurement"] },
|
||||
section: "Sales",
|
||||
title: "Purchases vs Sales by Month",
|
||||
description: "Posted invoice totals, last 12 months (LKR)",
|
||||
kind: "bar",
|
||||
xKey: "month",
|
||||
series: [
|
||||
{ key: "purchases", label: "Purchases" },
|
||||
{ key: "sales", label: "Sales" },
|
||||
],
|
||||
data: (stats?.monthlyPurchasesVsSales ?? []).map((m) => ({ month: monthLabel(m.month), purchases: m.purchases, sales: m.sales })),
|
||||
},
|
||||
{
|
||||
id: "daily-sales",
|
||||
access: { all: ["dashboard.sales"] },
|
||||
section: "Sales",
|
||||
title: "Daily Sales, Gross Profit and Expenses",
|
||||
description: "Day by day over the selected period (LKR)",
|
||||
kind: "line",
|
||||
xKey: "day",
|
||||
series: [
|
||||
{ key: "sales", label: "Sales" },
|
||||
{ key: "profit", label: "Gross profit" },
|
||||
{ key: "expenses", label: "Expenses" },
|
||||
],
|
||||
data: (c?.dailySales ?? []).map((d) => ({ day: d.label, sales: d.sales, profit: d.grossProfit, expenses: d.expenses })),
|
||||
},
|
||||
{
|
||||
id: "payment-split",
|
||||
access: { all: ["dashboard.sales"] },
|
||||
section: "Sales",
|
||||
title: "Payment Method Split",
|
||||
description: "How customers paid in the period (LKR)",
|
||||
kind: "donut",
|
||||
data: slices(c?.paymentSplit ?? []),
|
||||
},
|
||||
{
|
||||
id: "top-items",
|
||||
access: { all: ["dashboard.sales"] },
|
||||
section: "Sales",
|
||||
title: "Top 10 Selling Items",
|
||||
description: "By sales value in the period (LKR)",
|
||||
kind: "bar",
|
||||
horizontal: true,
|
||||
xKey: "item",
|
||||
series: [{ key: "value", label: "Sales" }],
|
||||
data: (c?.topItems ?? []).map((p) => ({ item: p.label, value: p.value })),
|
||||
},
|
||||
{
|
||||
id: "sales-by-category",
|
||||
access: { all: ["dashboard.sales"] },
|
||||
section: "Sales",
|
||||
title: "Sales by Category",
|
||||
description: "Share of sales value in the period",
|
||||
kind: "donut",
|
||||
data: slices(c?.salesByCategory ?? []),
|
||||
},
|
||||
{
|
||||
id: "sales-by-customer",
|
||||
access: { all: ["dashboard.sales"] },
|
||||
section: "Sales",
|
||||
title: "Sales by Customer (Top 5)",
|
||||
description: "By invoice value in the period (LKR)",
|
||||
kind: "bar",
|
||||
horizontal: true,
|
||||
xKey: "customer",
|
||||
series: [{ key: "value", label: "Sales" }],
|
||||
data: (c?.salesByCustomer ?? []).map((p) => ({ customer: p.label, value: p.value })),
|
||||
},
|
||||
{
|
||||
id: "document-status",
|
||||
access: { any: ["dashboard.sales", "dashboard.procurement", "dashboard.accounts"] },
|
||||
section: "Sales",
|
||||
title: "Document Status Mix",
|
||||
description: "Documents in the period by status",
|
||||
kind: "bar",
|
||||
stacked: true,
|
||||
xKey: "doc",
|
||||
series: [
|
||||
{ key: "draft", label: "Draft" },
|
||||
{ key: "posted", label: "Posted" },
|
||||
{ key: "cancelled", label: "Cancelled" },
|
||||
],
|
||||
data: (c?.documentStatus ?? []).map((d) => ({ doc: d.doc, draft: d.draft, posted: d.posted, cancelled: d.cancelled })),
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- Money
|
||||
{
|
||||
id: "cash-in-hand",
|
||||
access: { all: ["dashboard.accounts"] },
|
||||
section: "Money",
|
||||
title: "Cash in Hand by Day",
|
||||
description: "Closing till cash each day (LKR)",
|
||||
kind: "line",
|
||||
area: true,
|
||||
xKey: "day",
|
||||
series: [{ key: "cash", label: "Closing cash" }],
|
||||
data: (c?.cashInHandByDay ?? []).map((p) => ({ day: p.label, cash: p.value })),
|
||||
},
|
||||
{
|
||||
id: "receivables-ageing",
|
||||
access: { all: ["dashboard.sales"] },
|
||||
section: "Money",
|
||||
title: "Receivables Ageing",
|
||||
description: "Unpaid sales invoices by age (LKR)",
|
||||
kind: "bar",
|
||||
xKey: "bucket",
|
||||
series: [{ key: "amount", label: "Outstanding" }],
|
||||
data: (c?.receivablesAgeing ?? []).map((p) => ({ bucket: p.label, amount: p.value })),
|
||||
},
|
||||
{
|
||||
id: "payables-ageing",
|
||||
access: { all: ["dashboard.procurement"] },
|
||||
section: "Money",
|
||||
title: "Payables Ageing",
|
||||
description: "Unpaid purchase invoices by age (LKR)",
|
||||
kind: "bar",
|
||||
xKey: "bucket",
|
||||
series: [{ key: "amount", label: "Outstanding" }],
|
||||
data: (c?.payablesAgeing ?? []).map((p) => ({ bucket: p.label, amount: p.value })),
|
||||
},
|
||||
{
|
||||
id: "expense-breakdown",
|
||||
access: { all: ["dashboard.accounts"] },
|
||||
section: "Money",
|
||||
title: "Expense Breakdown by Category",
|
||||
description: "Share of posted expenses in the period",
|
||||
kind: "donut",
|
||||
data: slices(c?.expenseBreakdown ?? []),
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- Purchasing
|
||||
{
|
||||
id: "top-vendors",
|
||||
access: { all: ["dashboard.procurement"] },
|
||||
section: "Purchasing",
|
||||
title: "Top Vendors by Purchase Value",
|
||||
description: "Posted purchase invoices in the period (LKR)",
|
||||
kind: "bar",
|
||||
horizontal: true,
|
||||
xKey: "vendor",
|
||||
series: [{ key: "value", label: "Purchases" }],
|
||||
data: (c?.topVendors ?? []).map((p) => ({ vendor: p.label, value: p.value })),
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- Stock
|
||||
{
|
||||
id: "stock-value-share",
|
||||
access: { all: ["dashboard.stock"] },
|
||||
section: "Stock",
|
||||
title: "Stock Value Share by Warehouse",
|
||||
description: "Share of total stock value",
|
||||
kind: "donut",
|
||||
data: slices(
|
||||
(stats?.stockValuationByWarehouse ?? []).map((w) => ({
|
||||
label: warehouseCodes.get(w.warehouseId) ?? `#${w.warehouseId}`,
|
||||
value: w.total,
|
||||
}))
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "in-vs-out",
|
||||
access: { all: ["dashboard.stock"] },
|
||||
section: "Stock",
|
||||
title: "Stock In vs Out by Warehouse",
|
||||
description: "Quantity moved in the period",
|
||||
kind: "bar",
|
||||
xKey: "warehouse",
|
||||
series: [
|
||||
{ key: "in", label: "In" },
|
||||
{ key: "out", label: "Out" },
|
||||
],
|
||||
data: (c?.stockInVsOut ?? []).map((p) => ({ warehouse: p.label, in: p.first, out: p.second })),
|
||||
},
|
||||
{
|
||||
id: "batches-expiring",
|
||||
access: { all: ["dashboard.stock"] },
|
||||
section: "Stock",
|
||||
title: "Batches Expiring Soon",
|
||||
description: "Batches by days until expiry",
|
||||
kind: "bar",
|
||||
xKey: "window",
|
||||
series: [{ key: "batches", label: "Batches" }],
|
||||
data: (c?.batchesExpiring ?? []).map((p) => ({ window: p.label, batches: p.value })),
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- HR
|
||||
{
|
||||
id: "attendance-mix",
|
||||
access: { all: ["dashboard.hrm"] },
|
||||
section: "HR",
|
||||
title: "Attendance Status Mix",
|
||||
description: "Attendance records in the period by status",
|
||||
kind: "donut",
|
||||
data: slices(c?.attendanceMix ?? []),
|
||||
},
|
||||
{
|
||||
id: "payroll-cost",
|
||||
access: { all: ["dashboard.hrm"] },
|
||||
section: "HR",
|
||||
title: "Payroll Cost by Month",
|
||||
description: "Net salary of approved payroll runs, last 12 months (LKR)",
|
||||
kind: "bar",
|
||||
xKey: "month",
|
||||
series: [{ key: "cost", label: "Payroll" }],
|
||||
data: (c?.payrollCostByMonth ?? []).map((p) => ({ month: p.label, cost: p.value })),
|
||||
},
|
||||
{
|
||||
id: "leave-status",
|
||||
access: { all: ["dashboard.hrm"] },
|
||||
section: "HR",
|
||||
title: "Leave Requests by Status",
|
||||
description: "Requests raised in the period",
|
||||
kind: "donut",
|
||||
data: slices(c?.leaveStatus ?? []),
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- Service & Production
|
||||
{
|
||||
id: "tickets-over-time",
|
||||
access: { all: ["dashboard.service"] },
|
||||
section: "Service & Production",
|
||||
title: "Support Tickets: Opened vs Resolved",
|
||||
description: "Per day over the selected period",
|
||||
kind: "line",
|
||||
xKey: "day",
|
||||
series: [
|
||||
{ key: "opened", label: "Opened" },
|
||||
{ key: "resolved", label: "Resolved" },
|
||||
],
|
||||
data: (c?.ticketsOverTime ?? []).map((p) => ({ day: p.label, opened: p.first, resolved: p.second })),
|
||||
},
|
||||
{
|
||||
id: "production-status",
|
||||
access: { all: ["dashboard.production"] },
|
||||
section: "Service & Production",
|
||||
title: "Production Runs by Status",
|
||||
description: "Runs started in the period",
|
||||
kind: "donut",
|
||||
data: slices(c?.productionStatus ?? []),
|
||||
},
|
||||
{
|
||||
id: "warranty-expiry",
|
||||
access: { all: ["dashboard.service"] },
|
||||
section: "Service & Production",
|
||||
title: "Warranties Expiring by Month",
|
||||
description: "Active warranty registrations by end month, next 6 months",
|
||||
kind: "bar",
|
||||
xKey: "month",
|
||||
series: [{ key: "warranties", label: "Warranties" }],
|
||||
data: (c?.warrantyExpiryByMonth ?? []).map((p) => ({ month: p.label, warranties: p.value })),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -58,8 +58,10 @@ export interface StatCardProps {
|
||||
valueClassName?: string
|
||||
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
|
||||
iconClassName?: string
|
||||
/** Tighter padding and a smaller icon / text, for dense rows such as the dashboard. */
|
||||
compact?: boolean
|
||||
/** Makes the card a toggle button that filters the list below it (click again to clear). */
|
||||
onClick?: () => void
|
||||
/** The card's filter is the one currently applied — highlighted and announced as pressed. */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +80,8 @@ export function StatCard({
|
||||
className,
|
||||
valueClassName,
|
||||
iconClassName,
|
||||
compact = false,
|
||||
onClick,
|
||||
active = false,
|
||||
}: StatCardProps) {
|
||||
const hasDelta = typeof delta === "number" && delta !== 0
|
||||
const isPositive = hasDelta && delta > 0
|
||||
@@ -95,36 +98,31 @@ export function StatCard({
|
||||
? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" }
|
||||
: {})}
|
||||
className={cn(
|
||||
"@container min-w-0 rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
compact ? "px-3 py-2.5" : "px-4 py-5",
|
||||
className
|
||||
"@container min-w-0 rounded-2xl bg-card px-4 py-5 text-left shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
className,
|
||||
// After className so the "selected" look wins over a card's own tint.
|
||||
active && "bg-primary/5 ring-2 ring-primary"
|
||||
)}
|
||||
>
|
||||
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
|
||||
value keeps the full width instead of being cut off. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
// Compact cards keep the icon beside the text at every width; the regular card stacks it above when narrow.
|
||||
compact ? "flex-row items-center gap-2.5" : "flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5">
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary",
|
||||
compact ? "size-9" : "size-9 @[12.5rem]:size-11",
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<Icon className={compact ? "size-4" : "size-4.5 @[12.5rem]:size-5"} aria-hidden />
|
||||
<Icon className="size-4.5 @[12.5rem]:size-5" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className={cn("text-muted-foreground", compact ? "text-xs leading-tight" : "truncate text-sm")}>{label}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{label}</p>
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className={cn("leading-tight font-semibold tracking-tight wrap-break-word text-foreground", compact ? "text-base" : "text-base @[12.5rem]:text-lg", valueClassName)}>
|
||||
<p className={cn("text-base leading-tight font-semibold tracking-tight wrap-break-word text-foreground @[12.5rem]:text-lg", valueClassName)}>
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
|
||||
import { DashboardStats } from "@/types/dashboard"
|
||||
|
||||
export const dashboardApi = {
|
||||
/** `from` / `to` (YYYY-MM-DD) set the period for the activity counts; omitted means today. */
|
||||
stats(params: { branchId?: number; from?: string; to?: string } = {}): Promise<DashboardStats> {
|
||||
stats(params: { branchId?: number } = {}): Promise<DashboardStats> {
|
||||
return apiRequest<DashboardStats>(`/dashboard/stats${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
/** Datasets for the dashboard graphs; the time-based ones follow `from` / `to` (YYYY-MM-DD, default today). */
|
||||
charts(params: { branchId?: number; from?: string; to?: string } = {}): Promise<DashboardChartsData> {
|
||||
return apiRequest<DashboardChartsData>(`/dashboard/charts${buildQuery(params)}`)
|
||||
},
|
||||
}
|
||||
|
||||
Generated
+1
-331
@@ -16,7 +16,6 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cn": "^0.5.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -29,7 +28,6 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -1911,42 +1909,6 @@
|
||||
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
|
||||
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.2.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.3.tgz",
|
||||
"integrity": "sha512-FbR9W6jf7+tvOCuDmOLJy3yStLCvoRDODRARzfeaWmn75+1fW4hFbMv5X5cWgEkxgWcC/eC5Nva00ZV1iv+0EA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1972,12 +1934,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -2442,12 +2398,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
@@ -2463,12 +2413,6 @@
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
@@ -2478,48 +2422,12 @@
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
@@ -2610,12 +2518,6 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -4014,18 +3916,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cn": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cn/-/cn-0.5.0.tgz",
|
||||
"integrity": "sha512-JGEixZEpfFXm1P5uXwvEobOFLlHwIWi5v4tjd1ZwfiYmcEilkuD2l+054rvJcYCR1B+uVThHeUbgmNz1FwuxuA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cn": "bin/cn.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/code-block-writer": {
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||
@@ -4296,18 +4186,6 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
@@ -4348,15 +4226,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
@@ -4369,31 +4238,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
@@ -4403,42 +4247,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
@@ -4586,12 +4394,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -5059,18 +4861,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types",
|
||||
"tests/browser-compat"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -5527,12 +5317,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -6309,16 +6093,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -6366,15 +6140,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
@@ -8719,6 +8484,7 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-loading-skeleton": {
|
||||
@@ -8730,29 +8496,6 @@
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
"version": "0.23.12",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz",
|
||||
@@ -8769,57 +8512,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -10398,28 +10090,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user