Compare commits

..

1 Commits

Author SHA1 Message Date
ashan_rusiru f053dbdd9d Merge pull request 'Dev' (#118) from Dev into production
Reviewed-on: #118
2026-10-21 11:10:56 +05:30
102 changed files with 2334 additions and 18420 deletions
@@ -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()
+4 -1
View File
@@ -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 }
);
}
}
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"
});
});
-25
View File
@@ -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);
}
}
+39 -36
View File
@@ -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);
+145 -120
View File
@@ -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);
+75 -11
View File
@@ -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);
}
}
+92 -75
View File
@@ -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)
{
+86 -45
View File
@@ -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;
+166 -99
View File
@@ -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();
}
}
+31 -293
View File
@@ -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>
+102 -26
View File
@@ -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 &amp; 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();
}
+83 -40
View File
@@ -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) =>
+55 -53
View File
@@ -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);
}
+157 -48
View File
@@ -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);
}
+40 -58
View File
@@ -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);
}
+303 -166
View File
@@ -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;
+46 -41
View File
@@ -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);
}
}
+36 -14
View File
@@ -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);
+144 -80
View File
@@ -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)
+292 -183
View File
@@ -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>
)
}
-3
View File
@@ -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 })),
},
]
}
-372
View File
@@ -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,
}
+16 -18
View File
@@ -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} />}
+2 -8
View File
@@ -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)}`)
},
}
+1 -331
View File
@@ -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