diff --git a/Backend/ERPCore/Controllers/BranchConfigController.cs b/Backend/ERPCore/Controllers/BranchConfigController.cs
index 8ea5430..945be2a 100644
--- a/Backend/ERPCore/Controllers/BranchConfigController.cs
+++ b/Backend/ERPCore/Controllers/BranchConfigController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,6 +16,7 @@ namespace ERPCore.Controllers;
///
///
[Route("api/v1/branch-config")]
+[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class BranchConfigController : ApiControllerBase
{
private readonly IBranchConfigService _config;
diff --git a/Backend/ERPCore/Controllers/BrandsController.cs b/Backend/ERPCore/Controllers/BrandsController.cs
index 7d4c5b3..014cd13 100644
--- a/Backend/ERPCore/Controllers/BrandsController.cs
+++ b/Backend/ERPCore/Controllers/BrandsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Brands;
using ERPCore.Dtos.Common;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).
[Route("api/v1/brands")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class BrandsController : ApiControllerBase
{
private readonly IBrandService _brands;
diff --git a/Backend/ERPCore/Controllers/BundleSalesController.cs b/Backend/ERPCore/Controllers/BundleSalesController.cs
index d768c97..95ed07c 100644
--- a/Backend/ERPCore/Controllers/BundleSalesController.cs
+++ b/Backend/ERPCore/Controllers/BundleSalesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/bundle-sales")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class BundleSalesController : ApiControllerBase
{
private readonly IBundleSaleService _bundles;
diff --git a/Backend/ERPCore/Controllers/CategoriesController.cs b/Backend/ERPCore/Controllers/CategoriesController.cs
index 3fcd0e2..767b7de 100644
--- a/Backend/ERPCore/Controllers/CategoriesController.cs
+++ b/Backend/ERPCore/Controllers/CategoriesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
/// ?tree=true parameter is gone along with the self-nesting model.
///
[Route("api/v1/categories")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class CategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
diff --git a/Backend/ERPCore/Controllers/CustomersController.cs b/Backend/ERPCore/Controllers/CustomersController.cs
index ff7cda4..e05bb54 100644
--- a/Backend/ERPCore/Controllers/CustomersController.cs
+++ b/Backend/ERPCore/Controllers/CustomersController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Customers;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Customer master endpoints for Phase 1 sales.
[Route("api/v1/customers")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class CustomersController : ApiControllerBase
{
private readonly ICustomerService _customers;
diff --git a/Backend/ERPCore/Controllers/DashboardController.cs b/Backend/ERPCore/Controllers/DashboardController.cs
index eeaf72d..a979c63 100644
--- a/Backend/ERPCore/Controllers/DashboardController.cs
+++ b/Backend/ERPCore/Controllers/DashboardController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Dashboard;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -6,6 +8,7 @@ namespace ERPCore.Controllers;
/// Dashboard overview stats — cross-domain counts, not a stored entity.
[Route("api/v1/dashboard")]
+[Authorize(Policy = NavPolicies.Prefix + "dashboard")]
public sealed class DashboardController : ApiControllerBase
{
private readonly IDashboardService _dashboard;
diff --git a/Backend/ERPCore/Controllers/FreeIssuesController.cs b/Backend/ERPCore/Controllers/FreeIssuesController.cs
index 93057e8..9e9b6d8 100644
--- a/Backend/ERPCore/Controllers/FreeIssuesController.cs
+++ b/Backend/ERPCore/Controllers/FreeIssuesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
/// the existing sales-slip CRUD surface under a more business-friendly route.
///
[Route("api/v1/free-issues")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class FreeIssuesController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
diff --git a/Backend/ERPCore/Controllers/GeneralLedgerController.cs b/Backend/ERPCore/Controllers/GeneralLedgerController.cs
index 2f0bf12..837a942 100644
--- a/Backend/ERPCore/Controllers/GeneralLedgerController.cs
+++ b/Backend/ERPCore/Controllers/GeneralLedgerController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,6 +16,7 @@ namespace ERPCore.Controllers;
/// only and is never exposed to the frontend.
///
[Route("api/v1/gl")]
+[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
public sealed class GeneralLedgerController : ApiControllerBase
{
private readonly IGeneralLedgerService _gl;
diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs
index 1ba4861..3c45ea4 100644
--- a/Backend/ERPCore/Controllers/GrnsController.cs
+++ b/Backend/ERPCore/Controllers/GrnsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Goods-receipt endpoints (docs/11 §4).
[Route("api/v1/grns")]
+[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
diff --git a/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs b/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs
index bde2285..674744f 100644
--- a/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs
+++ b/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs
@@ -94,13 +94,13 @@ public sealed class EmployeesController : ApiControllerBase
/// Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.
[HttpDelete("{employeeId:int}")]
- [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
- public async Task Delete(int employeeId, CancellationToken ct)
+ public async Task> Delete(int employeeId, [FromQuery] bool deleteUserAccount, CancellationToken ct)
{
- await _employees.DeleteAsync(employeeId, ct);
- return NoContent();
+ var result = await _employees.DeleteAsync(employeeId, deleteUserAccount, ct);
+ return Ok(result);
}
[HttpPost("{employeeId:int}/link-user")]
diff --git a/Backend/ERPCore/Controllers/ItemTypesController.cs b/Backend/ERPCore/Controllers/ItemTypesController.cs
index efbc4f7..a4457de 100644
--- a/Backend/ERPCore/Controllers/ItemTypesController.cs
+++ b/Backend/ERPCore/Controllers/ItemTypesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.ItemTypes;
@@ -13,6 +15,7 @@ namespace ERPCore.Controllers;
/// into the client-generated SKU (docs/10 Part C.9).
///
[Route("api/v1/item-types")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemTypesController : ApiControllerBase
{
private readonly IItemTypeService _itemTypes;
diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs
index f792529..5dcb584 100644
--- a/Backend/ERPCore/Controllers/ItemsController.cs
+++ b/Backend/ERPCore/Controllers/ItemsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).
[Route("api/v1/items")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemsController : ApiControllerBase
{
private readonly IItemService _items;
@@ -27,6 +30,17 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
+ /// Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
+ /// barcode/no (optionally scoped to first).
+ [HttpGet("scan")]
+ [ProducesResponseType(typeof(ItemScanResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> Scan([FromQuery] string code, [FromQuery] int? itemId, CancellationToken ct)
+ {
+ var result = await _items.ScanAsync(code, itemId, ct);
+ return result is null ? NotFound() : Ok(result);
+ }
+
/// Get a single item; returns an ETag for optimistic concurrency.
[HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/JournalEntriesController.cs b/Backend/ERPCore/Controllers/JournalEntriesController.cs
index 54789c9..d999987 100644
--- a/Backend/ERPCore/Controllers/JournalEntriesController.cs
+++ b/Backend/ERPCore/Controllers/JournalEntriesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// Data only — no posting in Phase 1.
///
[Route("api/v1/journal-entries")]
+[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
public sealed class JournalEntriesController : ApiControllerBase
{
private readonly IAuditService _audit;
diff --git a/Backend/ERPCore/Controllers/NotificationConfigController.cs b/Backend/ERPCore/Controllers/NotificationConfigController.cs
index 13b035b..95413cb 100644
--- a/Backend/ERPCore/Controllers/NotificationConfigController.cs
+++ b/Backend/ERPCore/Controllers/NotificationConfigController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Notifications;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notification-config")]
+[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class NotificationConfigController : ApiControllerBase
{
private readonly INotificationConfigService _config;
diff --git a/Backend/ERPCore/Controllers/NotificationsController.cs b/Backend/ERPCore/Controllers/NotificationsController.cs
new file mode 100644
index 0000000..73b1ae9
--- /dev/null
+++ b/Backend/ERPCore/Controllers/NotificationsController.cs
@@ -0,0 +1,32 @@
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+[Route("api/v1/notifications")]
+public sealed class NotificationsController : ApiControllerBase
+{
+ private readonly INotificationService _notifications;
+ private readonly ICurrentUser _currentUser;
+
+ public NotificationsController(INotificationService notifications, ICurrentUser currentUser)
+ {
+ _notifications = notifications;
+ _currentUser = currentUser;
+ }
+
+ [HttpGet]
+ public async Task Get([FromQuery] int take = 20, CancellationToken ct = default)
+ {
+ var feed = await _notifications.GetFeedAsync(_currentUser.AuditUserId, take, ct);
+ return Ok(feed);
+ }
+
+ [HttpPost("mark-all-read")]
+ public async Task MarkAllRead(CancellationToken ct)
+ {
+ await _notifications.MarkAllReadAsync(_currentUser.AuditUserId, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/ProductConfigController.cs b/Backend/ERPCore/Controllers/ProductConfigController.cs
index 300f446..e20261a 100644
--- a/Backend/ERPCore/Controllers/ProductConfigController.cs
+++ b/Backend/ERPCore/Controllers/ProductConfigController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -16,6 +18,7 @@ namespace ERPCore.Controllers;
///
///
[Route("api/v1/product-config")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ProductConfigController : ApiControllerBase
{
private readonly IProductConfigService _config;
diff --git a/Backend/ERPCore/Controllers/ProductionRunsController.cs b/Backend/ERPCore/Controllers/ProductionRunsController.cs
index 3937f86..97c41f5 100644
--- a/Backend/ERPCore/Controllers/ProductionRunsController.cs
+++ b/Backend/ERPCore/Controllers/ProductionRunsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).
[Route("api/v1/production-runs")]
+[Authorize(Policy = NavPolicies.Prefix + "production")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
diff --git a/Backend/ERPCore/Controllers/ProductionTemplatesController.cs b/Backend/ERPCore/Controllers/ProductionTemplatesController.cs
index c637741..9333ae4 100644
--- a/Backend/ERPCore/Controllers/ProductionTemplatesController.cs
+++ b/Backend/ERPCore/Controllers/ProductionTemplatesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).
[Route("api/v1/production-templates")]
+[Authorize(Policy = NavPolicies.Prefix + "production")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
diff --git a/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs b/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs
index 35a8696..3561463 100644
--- a/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs
+++ b/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
@@ -9,6 +11,7 @@ namespace ERPCore.Controllers;
/// Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.
[Route("api/v1/purchase-invoices")]
+[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseInvoicesController : ApiControllerBase
{
private readonly IPurchaseInvoiceService _invoices;
diff --git a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
index ff9dc71..3d674e6 100644
--- a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
+++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Purchase-order endpoints (docs/11 §3.3).
[Route("api/v1/purchase-orders")]
+[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseOrdersController : ApiControllerBase
{
private readonly IPurchaseOrderService _pos;
@@ -32,7 +35,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
- /// Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side.
+ /// Create a PO — non-draft is issued straight into PendingApproval (FR-PROC-04). Totals computed server-side.
[HttpPost]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -57,7 +60,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
- /// Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.
+ /// Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.
[HttpPost("{poId:int}/submit")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -67,6 +70,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
/// Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).
[HttpDelete("{poId:int}")]
+ [Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -76,13 +80,28 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return NoContent();
}
- /// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.
+ /// Approve — PendingApproval → Approved. 403 PO_APPROVAL_FORBIDDEN if the caller
+ /// isn't an Admin or is the PO's own creator; 409 PO_NOT_PENDING_APPROVAL otherwise.
[HttpPost("{poId:int}/approve")]
+ [Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task> Approve(int poId, CancellationToken ct)
=> Ok(await _pos.ApproveAsync(poId, ct));
+ /// Reject — PendingApproval → Draft, so the requester can revise and resubmit.
+ /// Same caller restrictions as .
+ [HttpPost("{poId:int}/reject")]
+ [Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
+ [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Reject(int poId, [FromBody] RejectPurchaseOrderRequest request, CancellationToken ct)
+ => Ok(await _pos.RejectAsync(poId, request.Reason, ct));
+
/// Cancel — 409 if any goods have been received against the PO.
[HttpPost("{poId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs
index 40ce9a2..2b634cf 100644
--- a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs
+++ b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// Purchase-return endpoints (docs/11 §3.4).
[Route("api/v1/purchase-returns")]
+[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseReturnsController : ApiControllerBase
{
private readonly IPurchaseReturnService _returns;
diff --git a/Backend/ERPCore/Controllers/RequisitionsController.cs b/Backend/ERPCore/Controllers/RequisitionsController.cs
index e16d2e1..439c869 100644
--- a/Backend/ERPCore/Controllers/RequisitionsController.cs
+++ b/Backend/ERPCore/Controllers/RequisitionsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Purchase-requisition endpoints (docs/11 §3.1).
[Route("api/v1/requisitions")]
+[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class RequisitionsController : ApiControllerBase
{
private readonly IRequisitionService _requisitions;
diff --git a/Backend/ERPCore/Controllers/RfqsController.cs b/Backend/ERPCore/Controllers/RfqsController.cs
index c917479..92715a5 100644
--- a/Backend/ERPCore/Controllers/RfqsController.cs
+++ b/Backend/ERPCore/Controllers/RfqsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// RFQ & quotation endpoints (docs/11 §3.2).
[Route("api/v1/rfqs")]
+[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class RfqsController : ApiControllerBase
{
private readonly IRfqService _rfqs;
diff --git a/Backend/ERPCore/Controllers/RolesController.cs b/Backend/ERPCore/Controllers/RolesController.cs
index 69db895..5a2c374 100644
--- a/Backend/ERPCore/Controllers/RolesController.cs
+++ b/Backend/ERPCore/Controllers/RolesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).
[Route("api/v1/roles")]
+[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class RolesController : ApiControllerBase
{
private readonly IRoleService _roles;
@@ -64,6 +67,7 @@ public sealed class RolesController : ApiControllerBase
}
[HttpDelete("{roleId:int}")]
+ [Authorize(Policy = NavPolicies.Prefix + "settings" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
diff --git a/Backend/ERPCore/Controllers/SalesDayEndController.cs b/Backend/ERPCore/Controllers/SalesDayEndController.cs
index 4695c04..c2653bc 100644
--- a/Backend/ERPCore/Controllers/SalesDayEndController.cs
+++ b/Backend/ERPCore/Controllers/SalesDayEndController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Cashier day-end close-out — locks a cashier's Posted sales slips for a business
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).
[Route("api/v1/sales-day-end")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesDayEndController : ApiControllerBase
{
private readonly ISalesDayEndService _dayEnds;
diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
index 3720f0d..c1f524b 100644
--- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs
+++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-invoices")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
diff --git a/Backend/ERPCore/Controllers/SalesReportsController.cs b/Backend/ERPCore/Controllers/SalesReportsController.cs
index b6f18c7..9e35bd6 100644
--- a/Backend/ERPCore/Controllers/SalesReportsController.cs
+++ b/Backend/ERPCore/Controllers/SalesReportsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/reports/sales")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesReportsController : ApiControllerBase
{
private readonly ISalesReportService _reports;
diff --git a/Backend/ERPCore/Controllers/SalesReturnsController.cs b/Backend/ERPCore/Controllers/SalesReturnsController.cs
index a550da6..7135dbc 100644
--- a/Backend/ERPCore/Controllers/SalesReturnsController.cs
+++ b/Backend/ERPCore/Controllers/SalesReturnsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// Sales-return endpoints — customer returns of previously sold goods.
[Route("api/v1/sales-returns")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesReturnsController : ApiControllerBase
{
private readonly ISalesReturnService _returns;
diff --git a/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs b/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs
index f5874dc..bc742c6 100644
--- a/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs
+++ b/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesSlipPromotionsController : ApiControllerBase
{
private readonly ISalesPromotionSuggestionService _suggestions;
diff --git a/Backend/ERPCore/Controllers/SalesSlipsController.cs b/Backend/ERPCore/Controllers/SalesSlipsController.cs
index a59f93e..66eac99 100644
--- a/Backend/ERPCore/Controllers/SalesSlipsController.cs
+++ b/Backend/ERPCore/Controllers/SalesSlipsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesSlipsController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
diff --git a/Backend/ERPCore/Controllers/SearchController.cs b/Backend/ERPCore/Controllers/SearchController.cs
new file mode 100644
index 0000000..4538352
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SearchController.cs
@@ -0,0 +1,20 @@
+using ERPCore.Dtos.Search;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+[Route("api/v1/search")]
+public sealed class SearchController : ApiControllerBase
+{
+ private readonly IGlobalSearchService _search;
+
+ public SearchController(IGlobalSearchService search) => _search = search;
+
+ [HttpGet]
+ public async Task>> Get([FromQuery] string q, CancellationToken ct)
+ {
+ var results = await _search.SearchAsync(q, 6, ct);
+ return Ok(results);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
index f8a1b52..1b10b37 100644
--- a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
+++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// Stock-adjustment endpoints (docs/11 §5.5).
[Route("api/v1/stock-adjustments")]
+[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockAdjustmentsController : ApiControllerBase
{
private readonly IAdjustmentService _adjustments;
diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs
index 609ac08..7e4436a 100644
--- a/Backend/ERPCore/Controllers/StockController.cs
+++ b/Backend/ERPCore/Controllers/StockController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Stock;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).
[Route("api/v1/stock")]
+[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockController : ApiControllerBase
{
private readonly IStockService _stock;
diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs
index 3802f1f..b0fee7b 100644
--- a/Backend/ERPCore/Controllers/StockCountsController.cs
+++ b/Backend/ERPCore/Controllers/StockCountsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Stock-count endpoints (docs/11 §5.6).
[Route("api/v1/stock-counts")]
+[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockCountsController : ApiControllerBase
{
private readonly ICountService _counts;
diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs
index 67d9525..a5767fa 100644
--- a/Backend/ERPCore/Controllers/StockTransfersController.cs
+++ b/Backend/ERPCore/Controllers/StockTransfersController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Stock-transfer endpoints (docs/11 §5.4).
[Route("api/v1/stock-transfers")]
+[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockTransfersController : ApiControllerBase
{
private readonly ITransferService _transfers;
diff --git a/Backend/ERPCore/Controllers/SubCategoriesController.cs b/Backend/ERPCore/Controllers/SubCategoriesController.cs
index 162a89e..985a668 100644
--- a/Backend/ERPCore/Controllers/SubCategoriesController.cs
+++ b/Backend/ERPCore/Controllers/SubCategoriesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Categories;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// since a subcategory only exists in the context of one.
///
[Route("api/v1/subcategories")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class SubCategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
diff --git a/Backend/ERPCore/Controllers/SupportTicketsController.cs b/Backend/ERPCore/Controllers/SupportTicketsController.cs
new file mode 100644
index 0000000..91f5211
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SupportTicketsController.cs
@@ -0,0 +1,70 @@
+using ERPCore.Dtos.Support;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+[Route("api/v1/support-tickets")]
+public sealed class SupportTicketsController : ApiControllerBase
+{
+ private readonly ISupportTicketService _tickets;
+ private readonly ICurrentUser _currentUser;
+
+ public SupportTicketsController(ISupportTicketService tickets, ICurrentUser currentUser)
+ {
+ _tickets = tickets;
+ _currentUser = currentUser;
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status201Created)]
+ public async Task> Create([FromBody] CreateSupportTicketRequest request, CancellationToken ct)
+ {
+ var result = await _tickets.CreateAsync(_currentUser.AuditUserId, request, ct);
+ SetETag(result.RowVersion);
+ return CreatedAtAction(nameof(GetById), new { id = result.Value.TicketId }, result.Value);
+ }
+
+ [HttpGet]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> GetList(CancellationToken ct)
+ {
+ var list = await _tickets.GetListAsync(_currentUser.AuditUserId, _currentUser.IsAdmin, ct);
+ return Ok(list);
+ }
+
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int id, CancellationToken ct)
+ {
+ var result = await _tickets.GetByIdAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost("{id:int}/messages")]
+ [ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> AddMessage(int id, [FromBody] AddSupportTicketMessageRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _tickets.AddMessageAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPut("{id:int}/status")]
+ [ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> UpdateStatus(int id, [FromBody] UpdateSupportTicketStatusRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _tickets.UpdateStatusAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/TaxRatesController.cs b/Backend/ERPCore/Controllers/TaxRatesController.cs
new file mode 100644
index 0000000..56fa62f
--- /dev/null
+++ b/Backend/ERPCore/Controllers/TaxRatesController.cs
@@ -0,0 +1,70 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.TaxRates;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Tax rate master endpoints — see .
+[Route("api/v1/tax-rates")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
+public sealed class TaxRatesController : ApiControllerBase
+{
+ private readonly ITaxRateService _taxRates;
+
+ public TaxRatesController(ITaxRateService taxRates) => _taxRates = taxRates;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
+ => Ok(await _taxRates.ListAsync(query, status, ct));
+
+ [HttpGet("{taxRateId:int}")]
+ [ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int taxRateId, CancellationToken ct)
+ {
+ var result = await _taxRates.GetAsync(taxRateId, ct);
+ if (result is null) return NotFound();
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Create([FromBody] CreateTaxRateRequest request, CancellationToken ct)
+ {
+ var result = await _taxRates.CreateAsync(request, ct);
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/tax-rates/{result.Value.TaxRateId}", result.Value);
+ }
+
+ [HttpPut("{taxRateId:int}")]
+ [ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> Update(int taxRateId, [FromBody] UpdateTaxRateRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _taxRates.UpdateAsync(taxRateId, request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).
+ [HttpPatch("{taxRateId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(int taxRateId, [FromBody] UpdateTaxRateStatusRequest request, CancellationToken ct)
+ {
+ await _taxRates.SetStatusAsync(taxRateId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/UomsController.cs b/Backend/ERPCore/Controllers/UomsController.cs
index 2c13c5c..85193fb 100644
--- a/Backend/ERPCore/Controllers/UomsController.cs
+++ b/Backend/ERPCore/Controllers/UomsController.cs
@@ -1,4 +1,6 @@
using ERPCore.Domain.Enums;
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).
[Route("api/v1/uoms")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class UomsController : ApiControllerBase
{
private readonly IUomService _uoms;
diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs
index e71bf7d..171157a 100644
--- a/Backend/ERPCore/Controllers/UsersController.cs
+++ b/Backend/ERPCore/Controllers/UsersController.cs
@@ -1,10 +1,13 @@
+using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
+using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -14,6 +17,7 @@ namespace ERPCore.Controllers;
/// account creation orchestrated against AuthHex (see ).
///
[Route("api/v1/users")]
+[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
@@ -83,4 +87,20 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
+
+ ///
+ /// Admin-only escape hatch: permanently deletes a stale AuthHex identity (by email/mobile/NIC/
+ /// username) that is blocking new registrations. Does not touch ERPCore's own `users` table.
+ /// See .
+ ///
+ [HttpPost("purge-authhex-identity")]
+ [ProducesResponseType(typeof(DeleteAuthHexUserResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task> PurgeAuthHexIdentity([FromBody] PurgeAuthHexIdentityRequest request, CancellationToken ct)
+ {
+ if (!_currentUser.IsAdmin)
+ throw new DomainException("FORBIDDEN", "Only an administrator can purge an AuthHex identity.", 403);
+
+ return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
+ }
}
diff --git a/Backend/ERPCore/Controllers/VendorsController.cs b/Backend/ERPCore/Controllers/VendorsController.cs
index ee1021d..29f3175 100644
--- a/Backend/ERPCore/Controllers/VendorsController.cs
+++ b/Backend/ERPCore/Controllers/VendorsController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).
[Route("api/v1/vendors")]
+[Authorize(Policy = NavPolicies.Prefix + "vendors")]
public sealed class VendorsController : ApiControllerBase
{
private readonly IVendorService _vendors;
diff --git a/Backend/ERPCore/Controllers/WarehousesController.cs b/Backend/ERPCore/Controllers/WarehousesController.cs
index 939bc94..9a6bf93 100644
--- a/Backend/ERPCore/Controllers/WarehousesController.cs
+++ b/Backend/ERPCore/Controllers/WarehousesController.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// Warehouse & bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).
[Route("api/v1/warehouses")]
+[Authorize(Policy = NavPolicies.Prefix + "warehouses")]
public sealed class WarehousesController : ApiControllerBase
{
private readonly IWarehouseService _warehouses;
@@ -37,6 +40,24 @@ public sealed class WarehousesController : ApiControllerBase
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
}
+ [HttpPut("{warehouseId:int}")]
+ [ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Update(int warehouseId, [FromBody] UpdateWarehouseRequest request, CancellationToken ct)
+ => Ok(await _warehouses.UpdateAsync(warehouseId, request, ct));
+
+ [HttpDelete("{warehouseId:int}")]
+ [Authorize(Policy = NavPolicies.Prefix + "warehouses" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int warehouseId, CancellationToken ct)
+ {
+ await _warehouses.DeleteAsync(warehouseId, ct);
+ return NoContent();
+ }
+
[HttpGet("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
diff --git a/Backend/ERPCore/Domain/Entities/Batch.cs b/Backend/ERPCore/Domain/Entities/Batch.cs
index 03ba570..41b1aea 100644
--- a/Backend/ERPCore/Domain/Entities/Batch.cs
+++ b/Backend/ERPCore/Domain/Entities/Batch.cs
@@ -13,4 +13,7 @@ public class Batch
public string BatchNo { get; set; } = string.Empty;
public DateOnly? ExpiryDate { get; set; }
+
+ /// Optional scan code for barcode/RFID lookup (unique when set).
+ public string? Barcode { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/BranchConfig.cs b/Backend/ERPCore/Domain/Entities/BranchConfig.cs
index 37d83c0..7fb2cee 100644
--- a/Backend/ERPCore/Domain/Entities/BranchConfig.cs
+++ b/Backend/ERPCore/Domain/Entities/BranchConfig.cs
@@ -17,6 +17,12 @@ public class BranchConfig
public bool BranchingEnabled { get; set; } = true;
+ /// Independent sub-toggle: when the global is on
+ /// but this is off, Warehouses stop asking for/showing a branch while Employees etc.
+ /// still do. Meaningless when is off (everything is
+ /// single-branch already).
+ public bool WarehouseBranchingEnabled { get; set; } = true;
+
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Customer.cs b/Backend/ERPCore/Domain/Entities/Customer.cs
index 916a1b9..cf34fdf 100644
--- a/Backend/ERPCore/Domain/Entities/Customer.cs
+++ b/Backend/ERPCore/Domain/Entities/Customer.cs
@@ -29,6 +29,9 @@ public class Customer
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs
index 2a6fa07..292345c 100644
--- a/Backend/ERPCore/Domain/Entities/Item.cs
+++ b/Backend/ERPCore/Domain/Entities/Item.cs
@@ -14,6 +14,9 @@ public class Item
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
+ /// Optional scan code for barcode/RFID lookup (unique when set).
+ public string? Barcode { get; set; }
+
public int CategoryId { get; set; }
public Category? Category { get; set; }
@@ -41,7 +44,14 @@ public class Item
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// Coverage length in months (see ) when is Warranty; null otherwise.
public int? WarrantyPeriodMonths { get; set; }
- public string? TaxClass { get; set; }
+
+ ///
+ /// The tax rate a sales/purchase line for this item defaults to (see
+ /// ) — replaces the old free-text "tax class" label that nothing
+ /// ever read. Null means no default; a line must be given an explicit tax percentage.
+ ///
+ public int? TaxRateId { get; set; }
+ public TaxRate? TaxRate { get; set; }
///
/// Optional selling price range and flat price used by Sales only.
diff --git a/Backend/ERPCore/Domain/Entities/Notification.cs b/Backend/ERPCore/Domain/Entities/Notification.cs
new file mode 100644
index 0000000..bdebf06
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Notification.cs
@@ -0,0 +1,22 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A single raised notification event (e.g. "GRN Confirmed"). Broadcast to
+/// active users via rows, gated by
+/// whether the module's InApp channel is enabled in .
+///
+public class Notification
+{
+ public int NotificationId { get; set; }
+
+ public string ModuleKey { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public string Message { get; set; } = string.Empty;
+
+ public string? RelatedEntityType { get; set; }
+ public int? RelatedEntityId { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ public ICollection Recipients { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/NotificationRecipient.cs b/Backend/ERPCore/Domain/Entities/NotificationRecipient.cs
new file mode 100644
index 0000000..dead367
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/NotificationRecipient.cs
@@ -0,0 +1,18 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Per-user read state for a broadcast .
+///
+public class NotificationRecipient
+{
+ public int NotificationRecipientId { get; set; }
+
+ public int NotificationId { get; set; }
+ public Notification? Notification { get; set; }
+
+ public int UserId { get; set; }
+ public User? User { get; set; }
+
+ public bool IsRead { get; set; }
+ public DateTime? ReadAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/NumberSequence.cs b/Backend/ERPCore/Domain/Entities/NumberSequence.cs
index c4f7ffc..c1b6e8e 100644
--- a/Backend/ERPCore/Domain/Entities/NumberSequence.cs
+++ b/Backend/ERPCore/Domain/Entities/NumberSequence.cs
@@ -10,6 +10,10 @@ public class NumberSequence
{
public int SequenceId { get; set; }
public string DocType { get; set; } = string.Empty;
+ /// Per-branch counter key (docs/BRANCH_ISOLATION_PLAN.md §5) — backfilled to the
+ /// deployment's default branch for rows that predate per-branch numbering.
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
public int Year { get; set; }
public int LastNumber { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs b/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs
index 2e73d7f..c419867 100644
--- a/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs
+++ b/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs
@@ -20,6 +20,9 @@ public class PurchaseInvoice
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public string VendorSnapshotName { get; set; } = string.Empty;
public string? VendorSnapshotTaxNo { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs
index a94c0a1..90aa55a 100644
--- a/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs
+++ b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs
@@ -5,9 +5,10 @@ namespace ERPCore.Domain.Entities;
///
/// Purchase order header (FR-PROC-03..06). Mutable aggregate with a
/// ETag token; editable while open (FR-PROC-05).
-/// Phase 1 auto-approves on creation; is retained
-/// for the future approval workflow. Totals are computed server-side from lines
-/// (not stored). Model: docs/10 Part C.2.
+/// A submitted/issued PO goes to and
+/// needs a separate Approve action (an Admin who isn't ) before it
+/// reaches . Totals are computed server-side
+/// from lines (not stored). Model: docs/10 Part C.2.
///
public class PurchaseOrder
{
@@ -17,15 +18,25 @@ public class PurchaseOrder
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public int? RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
+ /// True once the PO has been (or needs to be) routed through approval —
+ /// i.e. it is not a still-editable Draft.
public bool ApprovalRequired { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
+ /// Who approved this PO — set only when reaches Approved.
+ public int? ApprovedBy { get; set; }
+ public User? Approver { get; set; }
+ public DateTime? ApprovedAt { get; set; }
+
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Requisition.cs b/Backend/ERPCore/Domain/Entities/Requisition.cs
index 29103ca..79298eb 100644
--- a/Backend/ERPCore/Domain/Entities/Requisition.cs
+++ b/Backend/ERPCore/Domain/Entities/Requisition.cs
@@ -14,6 +14,9 @@ public class Requisition
public int RequestedBy { get; set; }
public User? Requester { get; set; }
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft;
public DateTime CreatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Rfq.cs b/Backend/ERPCore/Domain/Entities/Rfq.cs
index 5a4e5c1..3ef9138 100644
--- a/Backend/ERPCore/Domain/Entities/Rfq.cs
+++ b/Backend/ERPCore/Domain/Entities/Rfq.cs
@@ -14,6 +14,9 @@ public class Rfq
public int RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public RfqStatus Status { get; set; } = RfqStatus.Open;
public DateTime CreatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/RolePermission.cs b/Backend/ERPCore/Domain/Entities/RolePermission.cs
index 75734d4..7005b57 100644
--- a/Backend/ERPCore/Domain/Entities/RolePermission.cs
+++ b/Backend/ERPCore/Domain/Entities/RolePermission.cs
@@ -1,3 +1,5 @@
+using ERPCore.Domain.Enums;
+
namespace ERPCore.Domain.Entities;
/// Join row granting a visibility of a (nav node).
@@ -6,6 +8,13 @@ public class RolePermission
public int RoleId { get; set; }
public int PermissionId { get; set; }
+ ///
+ /// Which mutating actions the role may perform within this permission's module
+ /// (see ); defaults to
+ /// so existing grants keep today's behavior until an admin narrows them.
+ ///
+ public RolePermissionAction Actions { get; set; } = RolePermissionAction.All;
+
public Role? Role { get; set; }
public Permission? Permission { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/SalesDayEnd.cs b/Backend/ERPCore/Domain/Entities/SalesDayEnd.cs
index 63ff8f6..c731f96 100644
--- a/Backend/ERPCore/Domain/Entities/SalesDayEnd.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesDayEnd.cs
@@ -27,6 +27,16 @@ public class SalesDayEnd
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
+ ///
+ /// Sum of across the day's slips (bundle sales have no
+ /// partial-payment field and are always counted as fully paid) — what actually gets debited
+ /// to Cash on close. is the rest (GrandTotal - PaidTotal),
+ /// debited to Accounts Receivable instead. Before partial payments existed these were always
+ /// equal to GrandTotal/0 respectively (every sale was assumed fully cash).
+ ///
+ public decimal PaidTotal { get; set; }
+ public decimal BalanceTotal { get; set; }
+
/// Sum of the FIFO cost consumed for these slips (from ), for the COGS GL lines.
public decimal CostOfGoodsSold { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
index 11302d2..5497afe 100644
--- a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
@@ -19,6 +19,12 @@ public class SalesSlipLine
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
+ public int? BatchId { get; set; }
+ public Batch? Batch { get; set; }
+
+ public int? SerialId { get; set; }
+ public Serial? Serial { get; set; }
+
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
diff --git a/Backend/ERPCore/Domain/Entities/Serial.cs b/Backend/ERPCore/Domain/Entities/Serial.cs
index a4d1876..dbbf7a2 100644
--- a/Backend/ERPCore/Domain/Entities/Serial.cs
+++ b/Backend/ERPCore/Domain/Entities/Serial.cs
@@ -13,4 +13,7 @@ public class Serial
public string SerialNo { get; set; } = string.Empty;
public string Status { get; set; } = "InStock";
+
+ /// Optional scan code for barcode/RFID lookup (unique when set).
+ public string? Barcode { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/StockCountLine.cs b/Backend/ERPCore/Domain/Entities/StockCountLine.cs
index debcfb7..62b9ea7 100644
--- a/Backend/ERPCore/Domain/Entities/StockCountLine.cs
+++ b/Backend/ERPCore/Domain/Entities/StockCountLine.cs
@@ -16,6 +16,12 @@ public class StockCountLine
public int? BinId { get; set; }
+ public int? BatchId { get; set; }
+ public Batch? Batch { get; set; }
+
+ public int? SerialId { get; set; }
+ public Serial? Serial { get; set; }
+
public decimal SystemQty { get; set; }
public decimal? CountedQty { get; set; }
public decimal? Variance { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/SupportTicket.cs b/Backend/ERPCore/Domain/Entities/SupportTicket.cs
new file mode 100644
index 0000000..cb1660b
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SupportTicket.cs
@@ -0,0 +1,25 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// A help-desk ticket raised by a user from the Help page. Status is
+/// admin-managed; the conversation is a flat thread of rows.
+/// Field/concurrency shape follows the same conventions as
+/// (CreatedBy/UpdatedBy actor stamps, RowVersion for optimistic concurrency).
+///
+public class SupportTicket
+{
+ public int TicketId { get; set; }
+
+ public string Subject { get; set; } = string.Empty;
+ public TicketStatus Status { get; set; } = TicketStatus.Open;
+
+ public int CreatedBy { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public int? UpdatedBy { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+ public uint RowVersion { get; set; }
+
+ public ICollection Messages { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/SupportTicketMessage.cs b/Backend/ERPCore/Domain/Entities/SupportTicketMessage.cs
new file mode 100644
index 0000000..324f0f3
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SupportTicketMessage.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Domain.Entities;
+
+/// One message in a thread — from the submitter or an admin.
+public class SupportTicketMessage
+{
+ public int MessageId { get; set; }
+
+ public int TicketId { get; set; }
+ public SupportTicket? Ticket { get; set; }
+
+ public int SenderUserId { get; set; }
+ public bool IsFromAdmin { get; set; }
+ public string Message { get; set; } = string.Empty;
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/Backend/ERPCore/Domain/Entities/TaxRate.cs b/Backend/ERPCore/Domain/Entities/TaxRate.cs
new file mode 100644
index 0000000..617169a
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/TaxRate.cs
@@ -0,0 +1,27 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// A named tax rate (e.g. "Standard" 15%, "Zero-Rated" 0%, "Exempt" 0%) — the real backing
+/// for . Before this existed, Item.TaxClass was a free-text
+/// label nothing ever read, and every sales/purchase line carried its own hand-typed tax
+/// percentage with no link back to the item at all. Now a line's tax defaults to whatever
+/// percentage the item's assigned TaxRate carries at the moment the line is created — change
+/// the rate here and every new document picks it up; a line already created keeps the percentage
+/// it was given (SalesSlipLine.TaxPct etc. are snapshots, never recomputed from this table).
+///
+public class TaxRate
+{
+ public int TaxRateId { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public decimal Percentage { get; set; }
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Vendor.cs b/Backend/ERPCore/Domain/Entities/Vendor.cs
index 05a85d3..aad62de 100644
--- a/Backend/ERPCore/Domain/Entities/Vendor.cs
+++ b/Backend/ERPCore/Domain/Entities/Vendor.cs
@@ -17,6 +17,9 @@ public class Vendor
public string Currency { get; set; } = "LKR";
public EntityStatus Status { get; set; } = EntityStatus.Active;
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Warehouse.cs b/Backend/ERPCore/Domain/Entities/Warehouse.cs
index 2c6a3ab..94200dc 100644
--- a/Backend/ERPCore/Domain/Entities/Warehouse.cs
+++ b/Backend/ERPCore/Domain/Entities/Warehouse.cs
@@ -9,6 +9,8 @@ public class Warehouse
public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
+ public string? Address { get; set; }
+ public string? Phone { get; set; }
public int BranchId { get; set; }
public Branch? Branch { get; set; }
diff --git a/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs b/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs
index 9cb7c49..88cc4f5 100644
--- a/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs
+++ b/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs
@@ -1,9 +1,10 @@
namespace ERPCore.Domain.Enums;
///
-/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Phase 1 auto-approves on
-/// creation, so is reserved (not entered) until the
-/// approval workflow is enabled (FR-PROC-04). Stored as a string.
+/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Issuing a PO (create
+/// non-draft, or submit a Draft) enters ; an Admin who
+/// isn't the creator must Approve it to reach , or Reject it back
+/// to (FR-PROC-04). Stored as a string.
///
public enum PurchaseOrderStatus
{
diff --git a/Backend/ERPCore/Domain/Enums/RolePermissionAction.cs b/Backend/ERPCore/Domain/Enums/RolePermissionAction.cs
new file mode 100644
index 0000000..2584a90
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/RolePermissionAction.cs
@@ -0,0 +1,21 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Fine-grained actions a can grant within a module
+/// it already has nav-level access to (see ). Nav-level
+/// access answers "can this role reach this module"; this answers "which mutations within
+/// it can this role actually perform" — e.g. a role that can see Procurement but should not
+/// be able to delete or approve purchase orders.
+///
+[Flags]
+public enum RolePermissionAction
+{
+ None = 0,
+ View = 1,
+ Create = 2,
+ Edit = 4,
+ Delete = 8,
+ Approve = 16,
+
+ All = View | Create | Edit | Delete | Approve
+}
diff --git a/Backend/ERPCore/Domain/Enums/TicketStatus.cs b/Backend/ERPCore/Domain/Enums/TicketStatus.cs
new file mode 100644
index 0000000..69b2315
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/TicketStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+public enum TicketStatus
+{
+ Open,
+ InProgress,
+ Resolved
+}
diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
index a8ca585..750eb67 100644
--- a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
+++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
@@ -29,6 +29,21 @@ public sealed class AuthSessionResponse
public int ExpiresIn { get; set; }
}
+/// Result of AuthHex's admin-only deleteUser function.
+public sealed class DeleteAuthHexUserResult
+{
+ public int DeletedCount { get; set; }
+ public List? Deleted { get; set; }
+}
+
+public sealed class DeletedAuthHexUser
+{
+ public Guid Id { get; set; }
+ public string? FullName { get; set; }
+ public string? UserName { get; set; }
+ public string? Email { get; set; }
+}
+
public sealed class RegisterRequest
{
/// Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.
diff --git a/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs b/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs
index 26976c8..aa5b229 100644
--- a/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs
+++ b/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs
@@ -4,7 +4,7 @@ namespace ERPCore.Dtos.Config;
/// Branch configuration resource — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.
-public sealed record BranchConfigDto(bool BranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
+public sealed record BranchConfigDto(bool BranchingEnabled, bool WarehouseBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
///
/// Full replacement of the flag. UpdatedBy is derived from the token, never posted.
@@ -17,4 +17,5 @@ public sealed record BranchConfigDto(bool BranchingEnabled, DateTime? UpdatedAt,
public sealed class UpdateBranchConfigRequest
{
[Required] public bool? BranchingEnabled { get; set; }
+ [Required] public bool? WarehouseBranchingEnabled { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
index 3b7175d..8176c60 100644
--- a/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
+++ b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
@@ -21,6 +21,7 @@ public sealed record CustomerDto(
int CreditDays,
int? DefaultWarehouseId,
EntityStatus Status,
+ int BranchId,
DateTime CreatedAt,
DateTime? UpdatedAt);
@@ -40,6 +41,7 @@ public sealed class CreateCustomerRequest
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
+ public int BranchId { get; set; }
}
public sealed class UpdateCustomerRequest
@@ -58,6 +60,7 @@ public sealed class UpdateCustomerRequest
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
+ public int BranchId { get; set; }
}
public sealed class UpdateCustomerStatusRequest
diff --git a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs
index 857ba9a..07e97ad 100644
--- a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs
+++ b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs
@@ -4,9 +4,12 @@ namespace ERPCore.Dtos.Dashboard;
/// Aggregate counts for the dashboard overview — mirrors the widget set in
/// docs/dashboard-implementation.pdf: reorder alerts, on-hand summary, stock valuation,
/// pending-approval POs, pending GRNs, open requisitions, open counts awaiting posting,
-/// and open RFQs. 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).
+/// open RFQs, pending leave requests, and overdue sales invoices. Also reused as small
+/// KPI tiles on individual list pages (Purchase Orders, GRN, Requisitions, RFQs, Stock
+/// Counts, Reorder Alerts, HRM Leave, Sales Invoices), not just the Dashboard home page.
+/// 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).
///
public sealed record DashboardStatsDto(
int LowStockAlerts,
@@ -18,7 +21,9 @@ public sealed record DashboardStatsDto(
int PendingGrns,
int OpenRequisitions,
int PendingCounts,
- int OpenRfqs);
+ int OpenRfqs,
+ int PendingLeaveRequests,
+ int OverdueSalesInvoices);
/// One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
index 6cb0c87..b05e626 100644
--- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
+++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
@@ -63,8 +63,9 @@ public sealed class CreateGrnLineInput
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
/// Trade discount percentage (0–100). Reduces the inventory cost.
[Range(0, 100)] public decimal DiscountPct { get; set; }
- /// VAT percentage (0–100). Recoverable — does not affect stock value.
- [Range(0, 100)] public decimal VatPct { get; set; }
+ /// VAT percentage (0–100). Recoverable — does not affect stock value. Explicit
+ /// override; omit (null) to use the item's assigned TaxRate percentage instead.
+ [Range(0, 100)] public decimal? VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
///
diff --git a/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
index 8c9e445..fa81489 100644
--- a/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
+++ b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
@@ -3,6 +3,8 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
+public sealed record EmployeeDeleteResultDto(bool UserAccountDeleted, bool UserAccountDeactivated, bool AuthHexIdentityPurged = false);
+
public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName,
diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
index 32a584d..e1ab0ff 100644
--- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs
+++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
@@ -10,10 +10,11 @@ public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
- string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
+ int? TaxRateId, string? TaxRateName, decimal? TaxRatePercentage,
+ decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
- EntityStatus Status);
+ EntityStatus Status, string? Barcode);
/// A single per-warehouse reorder policy row.
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
@@ -31,10 +32,11 @@ public sealed record ItemDetailDto(
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
- string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
+ int? TaxRateId, string? TaxRateName, decimal? TaxRatePercentage,
+ decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
- EntityStatus Status, IReadOnlyList Reorder,
+ EntityStatus Status, string? Barcode, IReadOnlyList Reorder,
DateTime CreatedAt, DateTime? UpdatedAt);
/// Response body for PUT /items/{id}/reorder.
@@ -64,7 +66,8 @@ public sealed class CreateItemRequest
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// Required (one of ) when is Warranty; ignored otherwise.
public int? WarrantyPeriodMonths { get; set; }
- [StringLength(20)] public string? TaxClass { get; set; }
+ /// The tax rate sales/purchase lines for this item default to — see .
+ public int? TaxRateId { get; set; }
/// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
@@ -77,6 +80,8 @@ public sealed class CreateItemRequest
///
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
+ /// Optional scan code for barcode/RFID lookup. Unlike , not required.
+ [StringLength(100)] public string? Barcode { get; set; }
}
public sealed class UpdateItemRequest
@@ -96,7 +101,8 @@ public sealed class UpdateItemRequest
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// Required (one of ) when is Warranty; ignored otherwise.
public int? WarrantyPeriodMonths { get; set; }
- [StringLength(20)] public string? TaxClass { get; set; }
+ /// The tax rate sales/purchase lines for this item default to — see .
+ public int? TaxRateId { get; set; }
/// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
@@ -109,6 +115,8 @@ public sealed class UpdateItemRequest
///
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
+ /// Optional scan code for barcode/RFID lookup. Unlike , not required.
+ [StringLength(100)] public string? Barcode { get; set; }
}
public sealed class UpdateItemStatusRequest
diff --git a/Backend/ERPCore/Dtos/Items/ItemScanDtos.cs b/Backend/ERPCore/Dtos/Items/ItemScanDtos.cs
new file mode 100644
index 0000000..182b99d
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Items/ItemScanDtos.cs
@@ -0,0 +1,11 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Items;
+
+/// Result of a barcode/RFID scan lookup — an item, or an item resolved via a
+/// specific batch/serial. See .
+public sealed record ItemScanResultDto(
+ int ItemId, string Sku, string Name, TrackingMode TrackingMode, int BaseUomId,
+ string MatchedBy, // "Item" | "Batch" | "Serial"
+ int? BatchId, string? BatchNo, DateOnly? ExpiryDate,
+ int? SerialId, string? SerialNo, string? SerialStatus);
diff --git a/Backend/ERPCore/Dtos/Notifications/NotificationDtos.cs b/Backend/ERPCore/Dtos/Notifications/NotificationDtos.cs
new file mode 100644
index 0000000..1b2f573
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Notifications/NotificationDtos.cs
@@ -0,0 +1,15 @@
+namespace ERPCore.Dtos.Notifications;
+
+public sealed record NotificationDto(
+ int NotificationId,
+ string ModuleKey,
+ string Title,
+ string Message,
+ string? RelatedEntityType,
+ int? RelatedEntityId,
+ DateTime CreatedAt,
+ bool IsRead);
+
+public sealed record NotificationFeedDto(
+ IReadOnlyList Notifications,
+ int UnreadCount);
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
index d53b078..483b8db 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
@@ -12,14 +12,14 @@ public sealed record PurchaseInvoiceTotalsDto(
public sealed record PurchaseInvoiceDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
- int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
+ int VendorId, int BranchId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
PurchaseInvoiceStatus Status, int GrnCount, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
string? GlJournalNo, DateTime? GlPostedAt,
PurchaseInvoiceTotalsDto Totals, IReadOnlyList Lines);
public sealed record PurchaseInvoiceSummaryDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
- int VendorId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
+ int VendorId, int BranchId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record PurchaseInvoicePaymentDto(
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
index 01177ff..59a1a26 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
@@ -12,13 +12,14 @@ public sealed record PoLineDto(
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
public sealed record PurchaseOrderDto(
- int PoId, string DocNo, int VendorId, int? RequisitionId, PurchaseOrderStatus Status,
- bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
+ int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status,
+ bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt,
+ DateTime CreatedAt, DateTime? UpdatedAt,
PoTotalsDto Totals, IReadOnlyList Lines);
public sealed record PurchaseOrderSummaryDto(
- int PoId, string DocNo, int VendorId, PurchaseOrderStatus Status,
- bool ApprovalRequired, DateTime CreatedAt, PoTotalsDto Totals);
+ int PoId, string DocNo, int VendorId, int BranchId, PurchaseOrderStatus Status,
+ bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals);
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
@@ -35,12 +36,13 @@ public sealed class CreatePurchaseOrderRequest
{
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
+ public int BranchId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
///
/// When true the PO is created in Draft (editable/deletable, not yet issued).
- /// When false (default) it auto-approves on creation, preserving the Requisition→PO
- /// and RFQ→PO flows unchanged (docs/11 §3.3).
+ /// When false (default) it is issued immediately into PendingApproval, same as
+ /// submitting a Draft (docs/11 §3.3).
///
public bool SaveAsDraft { get; set; }
}
@@ -49,6 +51,7 @@ public sealed class UpdatePurchaseOrderRequest
{
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
+ public int BranchId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
@@ -56,3 +59,8 @@ public sealed class CancelPurchaseOrderRequest
{
[StringLength(500)] public string? Reason { get; set; }
}
+
+public sealed class RejectPurchaseOrderRequest
+{
+ [StringLength(500)] public string? Reason { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
index ef8cae1..7ed215f 100644
--- a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
@@ -8,11 +8,11 @@ namespace ERPCore.Dtos.Procurement;
public sealed record RequisitionLineDto(int ReqLineId, int ItemId, decimal Qty, DateOnly? RequiredBy);
public sealed record RequisitionDto(
- int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy,
+ int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, int BranchId,
DateTime CreatedAt, IReadOnlyList Lines);
public sealed record RequisitionSummaryDto(
- int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
+ int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, int BranchId, DateTime CreatedAt, int LineCount);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
@@ -26,4 +26,7 @@ public sealed class CreateRequisitionLineInput
public sealed class CreateRequisitionRequest
{
[Required, MinLength(1)] public List Lines { get; set; } = new();
+ /// Optional — resolved via
+ /// when branching is enabled; ignored (defaulted) otherwise.
+ public int BranchId { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
index f9a5635..55d0687 100644
--- a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
@@ -8,11 +8,11 @@ namespace ERPCore.Dtos.Procurement;
public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
public sealed record RfqDto(
- int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList Lines);
+ int RfqId, string DocNo, int RequisitionId, int BranchId, RfqStatus Status, IReadOnlyList Lines);
/// Row shape for GET /rfqs — line/quotation counts instead of the lines themselves.
public sealed record RfqSummaryDto(
- int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount);
+ int RfqId, string DocNo, int RequisitionId, int BranchId, RfqStatus Status, int LineCount, int QuotationCount);
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
diff --git a/Backend/ERPCore/Dtos/Rbac/MeDtos.cs b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
index f56c32f..93bbbf4 100644
--- a/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
+++ b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
@@ -11,4 +11,4 @@ namespace ERPCore.Dtos.Rbac;
/// entirely rather than merely restricting them by role.
public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList NavCodes,
- bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled);
+ bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
diff --git a/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs b/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs
index 2c0d26b..77a7cc7 100644
--- a/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs
+++ b/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs
@@ -29,6 +29,22 @@ public sealed class AssignRolePermissionsRequest
{
public List NavItemIds { get; set; } = new();
public List SubNavItemIds { get; set; } = new();
+
+ ///
+ /// Optional per-nav/sub-nav-item action override, keyed by NavItemId/
+ /// SubNavItemId and valued with a bitmask
+ /// (e.g. View|Create|Edit = 7 to grant a module without Delete/Approve). An id
+ /// present in / but absent here
+ /// keeps today's default of — this only narrows,
+ /// never grants access to an id that isn't also in those lists.
+ ///
+ public Dictionary NavItemActions { get; set; } = new();
+ public Dictionary SubNavItemActions { get; set; } = new();
}
-public sealed record RolePermissionsDto(int RoleId, List NavItemIds, List SubNavItemIds);
+public sealed record RolePermissionsDto(
+ int RoleId,
+ List NavItemIds,
+ List SubNavItemIds,
+ Dictionary NavItemActions,
+ Dictionary SubNavItemActions);
diff --git a/Backend/ERPCore/Dtos/Sales/SalesDayEndDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesDayEndDtos.cs
index 398e266..5b04012 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesDayEndDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesDayEndDtos.cs
@@ -20,19 +20,21 @@ public sealed record SalesDayEndInvoiceSummaryDto(
public sealed record SalesDayEndDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
+ decimal PaidTotal, decimal BalanceTotal,
decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt,
IReadOnlyList SlipNumbers, IReadOnlyList BundleNumbers, IReadOnlyList ItemBreakdown,
SalesDayEndInvoiceSummaryDto Invoices);
public sealed record SalesDayEndSummaryDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
- int SlipCount, int BundleCount, decimal GrandTotal, string? GlJournalNo, DateTime ClosedAt);
+ int SlipCount, int BundleCount, decimal GrandTotal, decimal PaidTotal, decimal BalanceTotal, string? GlJournalNo, DateTime ClosedAt);
/// What closing right now would include — call before POST /sales-day-end so the
/// cashier can see the day's totals, and any still-Draft slips/bundles blocking the close.
public sealed record SalesDayEndPreviewDto(
int CashierUserId, DateOnly BusinessDate, bool AlreadyClosed,
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
+ decimal PaidTotal, decimal BalanceTotal,
IReadOnlyList SlipNumbers, IReadOnlyList BundleNumbers,
IReadOnlyList DraftSlipsBlockingClose,
IReadOnlyList DraftBundleSalesBlockingClose,
diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
index cd037f8..9f3ebad 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
@@ -56,7 +56,9 @@ public sealed class CreateSalesInvoiceLineRequest
[Range(0, 100)] public decimal DiscountPct { get; set; }
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
[Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
- [Range(0, 100)] public decimal TaxPct { get; set; }
+ /// Explicit override. Omit (null) to use the item's assigned TaxRate — see
+ /// .
+ [Range(0, 100)] public decimal? TaxPct { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs
index 629bb0d..a65bd4e 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs
@@ -7,7 +7,8 @@ public sealed record SalesSlipLineDto(
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
- decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
+ decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId,
+ int? BatchId, int? SerialId);
public sealed record SalesSlipTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
@@ -54,9 +55,15 @@ public sealed class CreateSalesSlipLineRequest
[Range(0, 100)] public decimal DiscountPct { get; set; }
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
[Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
- [Range(0, 100)] public decimal TaxPct { get; set; }
+ /// Explicit override. Omit (null) to use the item's assigned TaxRate — see
+ /// .
+ [Range(0, 100)] public decimal? TaxPct { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
+ /// Pins the line to a specific batch (must belong to ), consumed via FIFO.
+ public int? BatchId { get; set; }
+ /// Pins the line to a specific serial (must belong to ); marked Sold on posting.
+ public int? SerialId { get; set; }
}
public sealed class CreateSalesSlipRequest
@@ -65,6 +72,16 @@ public sealed class CreateSalesSlipRequest
[Required] public int WarehouseId { get; set; }
[Required] public int CashierUserId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
+
+ ///
+ /// How much the customer actually paid at ring-up. Omit (or send null) for the default —
+ /// fully paid, i.e. PaidAmount == GrandTotal and BalanceAmount == 0 — matching
+ /// every slip's behavior before partial payment existed. Must be between 0 and the computed
+ /// GrandTotal; anything less than GrandTotal leaves the difference as BalanceAmount owed by
+ /// the customer "on account," which Sales Day-End then splits between Cash and Accounts
+ /// Receivable when it posts (see SalesDayEndService.CloseAsync).
+ ///
+ [Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; }
}
public sealed class UpdateSalesSlipRequest
@@ -73,4 +90,7 @@ public sealed class UpdateSalesSlipRequest
[Required] public int WarehouseId { get; set; }
[Required] public int CashierUserId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
+
+ /// See .
+ [Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Search/GlobalSearchDtos.cs b/Backend/ERPCore/Dtos/Search/GlobalSearchDtos.cs
new file mode 100644
index 0000000..eb7fefc
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Search/GlobalSearchDtos.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Dtos.Search;
+
+public enum GlobalSearchResultType
+{
+ Item,
+ PurchaseOrder,
+ Invoice,
+ Employee
+}
+
+public sealed record GlobalSearchResultDto(
+ GlobalSearchResultType Type,
+ int Id,
+ string Number,
+ string Title,
+ string? Subtitle);
diff --git a/Backend/ERPCore/Dtos/Stock/CountDtos.cs b/Backend/ERPCore/Dtos/Stock/CountDtos.cs
index f330163..ab4a238 100644
--- a/Backend/ERPCore/Dtos/Stock/CountDtos.cs
+++ b/Backend/ERPCore/Dtos/Stock/CountDtos.cs
@@ -5,7 +5,9 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.6) ------------------------------------------------------
-public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
+public sealed record CountLineDto(
+ int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance,
+ int? BatchId, string? BatchNo, int? SerialId, string? SerialNo);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
diff --git a/Backend/ERPCore/Dtos/Support/SupportTicketDtos.cs b/Backend/ERPCore/Dtos/Support/SupportTicketDtos.cs
new file mode 100644
index 0000000..7c44ce9
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Support/SupportTicketDtos.cs
@@ -0,0 +1,56 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Support;
+
+public sealed class CreateSupportTicketRequest
+{
+ [Required, MaxLength(200)]
+ public string Subject { get; set; } = string.Empty;
+
+ [Required, MaxLength(2000)]
+ public string Message { get; set; } = string.Empty;
+}
+
+public sealed class AddSupportTicketMessageRequest
+{
+ [Required, MaxLength(2000)]
+ public string Message { get; set; } = string.Empty;
+}
+
+public sealed class UpdateSupportTicketStatusRequest
+{
+ [Required]
+ public TicketStatus Status { get; set; }
+}
+
+public sealed record SupportTicketMessageDto(
+ int MessageId,
+ int SenderUserId,
+ string SenderName,
+ bool IsFromAdmin,
+ string Message,
+ DateTime CreatedAt);
+
+public sealed record SupportTicketSummaryDto(
+ int TicketId,
+ string Subject,
+ TicketStatus Status,
+ int CreatedBy,
+ string CreatedByName,
+ DateTime CreatedAt,
+ int? UpdatedBy,
+ DateTime? UpdatedAt,
+ int MessageCount);
+
+public sealed record SupportTicketDto(
+ int TicketId,
+ string Subject,
+ TicketStatus Status,
+ int CreatedBy,
+ string CreatedByName,
+ DateTime CreatedAt,
+ int? UpdatedBy,
+ string? UpdatedByName,
+ DateTime? UpdatedAt,
+ IReadOnlyList Messages);
diff --git a/Backend/ERPCore/Dtos/TaxRates/TaxRateDtos.cs b/Backend/ERPCore/Dtos/TaxRates/TaxRateDtos.cs
new file mode 100644
index 0000000..0f29b4e
--- /dev/null
+++ b/Backend/ERPCore/Dtos/TaxRates/TaxRateDtos.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.TaxRates;
+
+public sealed record TaxRateDto(
+ int TaxRateId, string Code, string Name, decimal Percentage, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
+
+public sealed class CreateTaxRateRequest
+{
+ [Required, StringLength(20)] public string Code { get; set; } = string.Empty;
+ [Required, StringLength(100)] public string Name { get; set; } = string.Empty;
+ [Range(0, 100)] public decimal Percentage { get; set; }
+}
+
+public sealed class UpdateTaxRateRequest
+{
+ [Required, StringLength(20)] public string Code { get; set; } = string.Empty;
+ [Required, StringLength(100)] public string Name { get; set; } = string.Empty;
+ [Range(0, 100)] public decimal Percentage { get; set; }
+}
+
+public sealed class UpdateTaxRateStatusRequest
+{
+ [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs
index c8d9ead..40fe0e2 100644
--- a/Backend/ERPCore/Dtos/Users/UserDtos.cs
+++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs
@@ -7,6 +7,15 @@ public sealed record ManagedUserDto(
int UserId, string Username, string DisplayName, string? Email, EntityStatus Status,
int? RoleId, string? RoleCode, string? RoleName);
+/// At least one identifier must be supplied — matches AuthHex's own OR-across-fields dedupe.
+public sealed class PurgeAuthHexIdentityRequest
+{
+ public string? Email { get; set; }
+ public string? MobileNumber { get; set; }
+ public string? Nic { get; set; }
+ public string? UserName { get; set; }
+}
+
///
/// Creates a user in both backends: forwards to AuthHex's `registerUser` (source
/// of truth for credentials), then mirrors the account into ERPCore's local
diff --git a/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs b/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs
index 36c0145..b646dd1 100644
--- a/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs
+++ b/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Vendors;
/// Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).
public sealed record VendorDto(
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
- string Currency, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
+ string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest
{
@@ -15,6 +15,9 @@ public sealed class CreateVendorRequest
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
+ /// Optional — resolved via
+ /// when branching is enabled; ignored (defaulted) otherwise.
+ public int BranchId { get; set; }
}
public sealed class UpdateVendorRequest
@@ -24,6 +27,7 @@ public sealed class UpdateVendorRequest
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
+ public int BranchId { get; set; }
}
public sealed class UpdateVendorStatusRequest
diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
index 83e15f9..f9fd12d 100644
--- a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
+++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Warehouses;
/// Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). is
/// the anchor `docs/BRANCH_ISOLATION_PLAN.md` uses to branch-scope everything that carries
/// a `warehouseId` (Sales, GRN, stock, ...) without a column on every one of those entities.
-public sealed record WarehouseDto(int WarehouseId, string Code, string Name, int BranchId, string? BranchName);
+public sealed record WarehouseDto(int WarehouseId, string Code, string Name, string? Address, string? Phone, int BranchId, string? BranchName);
/// Bin/location resource (docs/11 §2.5).
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
@@ -14,6 +14,17 @@ public sealed class CreateWarehouseRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+ [StringLength(300)] public string? Address { get; set; }
+ [StringLength(30)] public string? Phone { get; set; }
+ [Required] public int BranchId { get; set; }
+}
+
+public sealed class UpdateWarehouseRequest
+{
+ [Required, StringLength(50)] public string Code { get; set; } = string.Empty;
+ [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+ [StringLength(300)] public string? Address { get; set; }
+ [StringLength(30)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; }
}
diff --git a/Backend/ERPCore/ERPCore.csproj b/Backend/ERPCore/ERPCore.csproj
index 77137ac..e6214e4 100644
--- a/Backend/ERPCore/ERPCore.csproj
+++ b/Backend/ERPCore/ERPCore.csproj
@@ -1,9 +1,10 @@
-
+
net10.0
enable
enable
+ 48915044-7652-476f-ac84-0c82c718098a
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
index ef6b616..5a4789e 100644
--- a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
@@ -28,8 +28,13 @@ public sealed class AuthHexClient : IAuthHexClient
};
private readonly HttpClient _http;
+ private readonly IConfiguration _config;
- public AuthHexClient(HttpClient http) => _http = http;
+ public AuthHexClient(HttpClient http, IConfiguration config)
+ {
+ _http = http;
+ _config = config;
+ }
// ---- UserManager --------------------------------------------------
@@ -87,6 +92,19 @@ public sealed class AuthHexClient : IAuthHexClient
public Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct)
=> CallAsync("user", "getTwoFAStatus", new { }, bearerToken, ct);
+ public Task DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
+ {
+ var adminApiKey = _config["AuthHex:AdminApiKey"];
+ if (string.IsNullOrEmpty(adminApiKey))
+ throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
+
+ return CallAsync(
+ "user", "deleteUser",
+ new { email, mobileNumber, nic, userName = username },
+ null, ct,
+ extraHeaders: new Dictionary { ["X-Admin-Api-Key"] = adminApiKey });
+ }
+
// ---- RecoveryManager ------------------------------------------------
public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
@@ -134,11 +152,14 @@ public sealed class AuthHexClient : IAuthHexClient
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
=> await CallAsync(routeGroup, functionName, payload, bearerToken, ct);
- private async Task CallAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
+ private async Task CallAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct, Dictionary? extraHeaders = null)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/{routeGroup}");
if (!string.IsNullOrEmpty(bearerToken))
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
+ if (extraHeaders is not null)
+ foreach (var (key, value) in extraHeaders)
+ httpRequest.Headers.Add(key, value);
httpRequest.Content = JsonContent.Create(
new AuthHexRequestBody { FunctionName = functionName, Payload = payload, Reference = string.Empty },
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
index cd0f614..db2e1fa 100644
--- a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
@@ -30,6 +30,14 @@ public interface IAuthHexClient
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct);
Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct);
+ ///
+ /// Admin-only: permanently deletes every AuthHex identity matching any of the given
+ /// identifiers, so a stale email/NIC/username/mobile no longer blocks re-registration
+ /// (CanRegisterUser's duplicate check has no notion of "deleted" — this is the only way
+ /// to actually clear one). Gated by AuthHex:AdminApiKey — throws if unset or rejected.
+ ///
+ Task DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
+
// RecoveryManager (POST /api/recovery)
Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
diff --git a/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs b/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs
index e35400a..325aaa4 100644
--- a/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs
+++ b/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs
@@ -14,4 +14,12 @@ public static class AuthHexClaims
public const string RoleId = "RoleId";
public const string RoleCode = "RoleCode";
public const string Nic = "NIC";
+
+ ///
+ /// Local (non-AuthHex) claim injected by —
+ /// the resolved local user's , used by
+ /// / for
+ /// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md).
+ ///
+ public const string BranchId = "erp:BranchId";
}
diff --git a/Backend/ERPCore/Infra/Auth/CurrentUser.cs b/Backend/ERPCore/Infra/Auth/CurrentUser.cs
index 1d7340a..676e1fb 100644
--- a/Backend/ERPCore/Infra/Auth/CurrentUser.cs
+++ b/Backend/ERPCore/Infra/Auth/CurrentUser.cs
@@ -35,4 +35,13 @@ public sealed class CurrentUser : ICurrentUser
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
+
+ public int? BranchId
+ {
+ get
+ {
+ var raw = _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.BranchId);
+ return int.TryParse(raw, out var id) ? id : null;
+ }
+ }
}
diff --git a/Backend/ERPCore/Infra/Auth/ICurrentBranchContext.cs b/Backend/ERPCore/Infra/Auth/ICurrentBranchContext.cs
new file mode 100644
index 0000000..04baab7
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/ICurrentBranchContext.cs
@@ -0,0 +1,34 @@
+namespace ERPCore.Infra.Auth;
+
+///
+/// Scoped accessor feeding 's global query filters
+/// (docs/BRANCH_ISOLATION_PLAN.md — "What this doesn't solve by itself" / enforcement
+/// layer). EF Core query filters can't read ambient HTTP state directly, so this is
+/// resolved once per request/scope and injected into the DbContext constructor instead.
+///
+public interface ICurrentBranchContext
+{
+ /// The branch to scope reads to. Null means "don't filter" — used for
+ /// admins, unauthenticated/system contexts, and any caller with no resolved branch.
+ int? BranchId { get; }
+
+ /// False disables filtering entirely (e.g. background jobs, migrations,
+ /// admin callers) regardless of .
+ bool IsActive { get; }
+}
+
+///
+/// Default implementation: derives from . Admins and users with
+/// no resolved branch are never filtered — the same bypass
+/// already applies for write-path checks.
+///
+public sealed class CurrentBranchContext : ICurrentBranchContext
+{
+ private readonly ICurrentUser _currentUser;
+
+ public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser;
+
+ public int? BranchId => _currentUser.IsAdmin ? null : _currentUser.BranchId;
+
+ public bool IsActive => _currentUser.IsAuthenticated && !_currentUser.IsAdmin && _currentUser.BranchId is not null;
+}
diff --git a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
index 78da05e..904df01 100644
--- a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
+++ b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
@@ -29,4 +29,12 @@ public interface ICurrentUser
/// the only role that bypasses branch-scoping (see ).
///
bool IsAdmin { get; }
+
+ ///
+ /// The resolved local user's branch (), read from
+ /// the local claim injected by . Null when the
+ /// user has no linked employee, is unauthenticated, or is an admin (admins are never scoped —
+ /// see and ).
+ ///
+ int? BranchId { get; }
}
diff --git a/Backend/ERPCore/Infra/Auth/NavAuthorization.cs b/Backend/ERPCore/Infra/Auth/NavAuthorization.cs
new file mode 100644
index 0000000..4be04f4
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/NavAuthorization.cs
@@ -0,0 +1,125 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Options;
+
+namespace ERPCore.Infra.Auth;
+
+///
+/// Per-endpoint RBAC (docs/02-SECURITY.md AR-01): "can this role reach this module at
+/// all", reusing the exact RolePermission data the sidebar already filters on
+/// () instead of leaving every controller open to
+/// any authenticated user. Apply via [Authorize(Policy = NavPolicies.For("..."))]
+/// alongside the base already on
+/// — multiple [Authorize] attributes
+/// combine as AND, so both must pass.
+///
+/// A second, optional layer narrows within a module: appending
+/// ":{RolePermissionAction}" (e.g. NavPolicies.Prefix + "procurement:" +
+/// NavPolicies.DeleteAction) checks the role's granted RolePermission.Actions
+/// bitmask instead of only "can reach this module" — put this on the specific actions
+/// (delete, approve, …) a module-level viewer role should not get. Without the suffix the
+/// policy stays module-only, matching existing controller-level attributes.
+///
+public static class NavPolicies
+{
+ ///
+ /// Prepend to a nav-item/sub-nav-item Code (e.g. "procurement", not
+ /// "NAV:procurement" — the "NAV:" prefix lives on Permission.Code, not here)
+ /// to get the policy name: NavPolicies.Prefix + "procurement". An attribute
+ /// argument must be a compile-time constant, so this can't be a method — write the
+ /// concatenation out at each call site instead of introducing a For(...) helper.
+ ///
+ public const string Prefix = "Nav:";
+
+ /// Separator between the nav code and the action suffix, e.g. "Nav:procurement:Delete".
+ public const string ActionSeparator = ":";
+
+ // One const per RolePermissionAction flag, spelled to match Enum.Parse — kept as
+ // consts (not the enum itself) so call sites stay compile-time-constant attribute args.
+ public const string CreateAction = "Create";
+ public const string EditAction = "Edit";
+ public const string DeleteAction = "Delete";
+ public const string ApproveAction = "Approve";
+}
+
+/// The nav module code a request must be permitted for, and optionally the specific
+/// action within it — see .
+public sealed class NavRequirement : IAuthorizationRequirement
+{
+ public string NavCode { get; }
+ public RolePermissionAction? Action { get; }
+
+ public NavRequirement(string navCode, RolePermissionAction? action = null)
+ {
+ NavCode = navCode;
+ Action = action;
+ }
+}
+
+/// Admin always passes (mirrors every other Admin-bypasses-scoping rule in this
+/// codebase, e.g. BranchAccessService); everyone else needs the role permission,
+/// and — when the policy names one — the specific action within it too.
+public sealed class NavAuthorizationHandler : AuthorizationHandler
+{
+ private readonly ICurrentUser _currentUser;
+ private readonly INavPermissionService _navPermissions;
+
+ public NavAuthorizationHandler(ICurrentUser currentUser, INavPermissionService navPermissions)
+ {
+ _currentUser = currentUser;
+ _navPermissions = navPermissions;
+ }
+
+ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, NavRequirement requirement)
+ {
+ if (!_currentUser.IsAuthenticated) return;
+
+ if (_currentUser.IsAdmin)
+ {
+ context.Succeed(requirement);
+ return;
+ }
+
+ if (!await _navPermissions.HasAccessAsync(_currentUser.RoleCode, requirement.NavCode)) return;
+
+ if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleCode, requirement.NavCode, requirement.Action.Value))
+ context.Succeed(requirement);
+ }
+}
+
+///
+/// Resolves "Nav:{code}" and "Nav:{code}:{Action}" policy names to a
+/// on demand (so a policy doesn't need pre-registering per
+/// nav code in AddAuthorization); everything else (notably
+/// ) falls through to the framework
+/// default provider unchanged.
+///
+public sealed class NavPolicyProvider : IAuthorizationPolicyProvider
+{
+ private readonly DefaultAuthorizationPolicyProvider _fallback;
+
+ public NavPolicyProvider(IOptions options) => _fallback = new DefaultAuthorizationPolicyProvider(options);
+
+ public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
+ public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
+
+ public Task GetPolicyAsync(string policyName)
+ {
+ if (policyName.StartsWith(NavPolicies.Prefix, StringComparison.Ordinal))
+ {
+ var rest = policyName[NavPolicies.Prefix.Length..];
+ var sepIndex = rest.LastIndexOf(NavPolicies.ActionSeparator, StringComparison.Ordinal);
+
+ NavRequirement requirement;
+ if (sepIndex >= 0 && Enum.TryParse(rest[(sepIndex + 1)..], out var action))
+ requirement = new NavRequirement(rest[..sepIndex], action);
+ else
+ requirement = new NavRequirement(rest);
+
+ var policy = new AuthorizationPolicyBuilder().AddRequirements(requirement).Build();
+ return Task.FromResult(policy);
+ }
+ return _fallback.GetPolicyAsync(policyName);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs b/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs
index e163895..c98e049 100644
--- a/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs
+++ b/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs
@@ -37,6 +37,14 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
var localId = await ResolveOrProvisionAsync(authUserId, nic);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
+
+ var branchId = await _db.Employees.AsNoTracking()
+ .Where(e => e.UserId == localId)
+ .Select(e => (int?)e.BranchId)
+ .FirstOrDefaultAsync();
+ if (branchId is not null)
+ identity.AddClaim(new Claim(AuthHexClaims.BranchId, branchId.Value.ToString()));
+
return principal;
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs
index 8de6adf..db9ea87 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs
@@ -13,6 +13,9 @@ public sealed class BatchConfiguration : IEntityTypeConfiguration
builder.Property(b => b.BatchNo).IsRequired().HasMaxLength(50);
+ builder.Property(b => b.Barcode).HasMaxLength(100);
+ builder.HasIndex(b => b.Barcode).IsUnique();
+
builder.HasOne(b => b.Item)
.WithMany()
.HasForeignKey(b => b.ItemId)
@@ -33,6 +36,9 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
builder.Property(s => s.Status).IsRequired().HasMaxLength(20);
+ builder.Property(s => s.Barcode).HasMaxLength(100);
+ builder.HasIndex(s => s.Barcode).IsUnique();
+
builder.HasOne(s => s.Item)
.WithMany()
.HasForeignKey(s => s.ItemId)
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs
index a52b327..9b3a8c8 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs
@@ -46,5 +46,9 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.CustomerType);
+
+ builder.HasOne(c => c.Branch).WithMany()
+ .HasForeignKey(c => c.BranchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(c => c.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
index 79d409e..ada84e7 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
@@ -15,9 +15,13 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration-
builder.Property(i => i.Sku).IsRequired().HasMaxLength(50);
builder.HasIndex(i => i.Sku).IsUnique();
+ // Postgres unique indexes treat multiple NULLs as distinct, so plain IsUnique() is
+ // correct here — no filter needed for the optional scan code.
+ builder.Property(i => i.Barcode).HasMaxLength(100);
+ builder.HasIndex(i => i.Barcode).IsUnique();
+
builder.Property(i => i.Name).IsRequired().HasMaxLength(200);
builder.Property(i => i.Description).HasMaxLength(1000);
- builder.Property(i => i.TaxClass).HasMaxLength(20);
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SellingMinPrice).HasPrecision(18, 4);
@@ -75,6 +79,11 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration
-
.HasForeignKey(i => i.DefaultVendorId)
.OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(i => i.TaxRate)
+ .WithMany()
+ .HasForeignKey(i => i.TaxRateId)
+ .OnDelete(DeleteBehavior.Restrict);
+
builder.HasIndex(i => i.Status);
builder.HasIndex(i => i.CategoryId);
builder.HasIndex(i => i.BrandId);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfiguration.cs
new file mode 100644
index 0000000..23776c3
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfiguration.cs
@@ -0,0 +1,26 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class NotificationConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("notifications");
+ builder.HasKey(n => n.NotificationId);
+
+ builder.Property(n => n.ModuleKey).IsRequired().HasMaxLength(100);
+ builder.Property(n => n.Title).IsRequired().HasMaxLength(200);
+ builder.Property(n => n.Message).IsRequired().HasMaxLength(1000);
+ builder.Property(n => n.RelatedEntityType).HasMaxLength(100);
+
+ builder.HasIndex(n => n.CreatedAt);
+
+ builder.HasMany(n => n.Recipients)
+ .WithOne(r => r.Notification)
+ .HasForeignKey(r => r.NotificationId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NotificationRecipientConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationRecipientConfiguration.cs
new file mode 100644
index 0000000..bca8e11
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationRecipientConfiguration.cs
@@ -0,0 +1,21 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class NotificationRecipientConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("notification_recipients");
+ builder.HasKey(r => r.NotificationRecipientId);
+
+ builder.HasIndex(r => new { r.UserId, r.IsRead, r.NotificationId });
+
+ builder.HasOne(r => r.User)
+ .WithMany()
+ .HasForeignKey(r => r.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs
index 45282a2..881b2fe 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs
@@ -12,10 +12,14 @@ public sealed class NumberSequenceConfiguration : IEntityTypeConfiguration s.SequenceId);
builder.Property(s => s.DocType).HasColumnName("doc_type").IsRequired().HasMaxLength(10);
+ builder.Property(s => s.BranchId).HasColumnName("branch_id").IsRequired();
builder.Property(s => s.Year).HasColumnName("year").IsRequired();
builder.Property(s => s.LastNumber).HasColumnName("last_number").IsRequired();
- // One counter per (doc type, year); also the ON CONFLICT target for atomic issue.
- builder.HasIndex(s => new { s.DocType, s.Year }).IsUnique();
+ builder.HasOne(s => s.Branch).WithMany()
+ .HasForeignKey(s => s.BranchId).OnDelete(DeleteBehavior.Restrict);
+
+ // One counter per (doc type, branch, year); also the ON CONFLICT target for atomic issue.
+ builder.HasIndex(s => new { s.DocType, s.BranchId, s.Year }).IsUnique();
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
index 2b1bd42..843a3b8 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
@@ -83,7 +83,8 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration x.Status);
builder.HasIndex(x => x.VendorId);
+ builder.HasOne(x => x.Branch).WithMany()
+ .HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(x => x.BranchId);
+
builder.HasMany(x => x.Lines)
.WithOne(x => x.PurchaseInvoice)
.HasForeignKey(x => x.PurchaseInvoiceId)
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs
index d5f3cd3..9aa8588 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs
@@ -38,6 +38,10 @@ public sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration p.Status);
builder.HasIndex(p => p.VendorId);
+
+ builder.HasOne(p => p.Branch).WithMany()
+ .HasForeignKey(p => p.BranchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(p => p.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs
index 492d786..06564e2 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs
@@ -24,6 +24,10 @@ public sealed class RequisitionConfiguration : IEntityTypeConfiguration r.Status);
+
+ builder.HasOne(r => r.Branch).WithMany()
+ .HasForeignKey(r => r.BranchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(r => r.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs
index 6a95ef3..5a65a05 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs
@@ -22,6 +22,10 @@ public sealed class RfqConfiguration : IEntityTypeConfiguration
.WithMany()
.HasForeignKey(r => r.RequisitionId)
.OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(r => r.Branch).WithMany()
+ .HasForeignKey(r => r.BranchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(r => r.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs
index 62c903f..101fb96 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -11,6 +12,11 @@ public sealed class RolePermissionConfiguration : IEntityTypeConfiguration new { rp.RoleId, rp.PermissionId });
+ builder.Property(rp => rp.Actions)
+ .HasConversion()
+ .IsRequired()
+ .HasDefaultValue(RolePermissionAction.All);
+
builder.HasOne(rp => rp.Role).WithMany()
.HasForeignKey(rp => rp.RoleId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(rp => rp.Permission).WithMany()
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesDayEndConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesDayEndConfiguration.cs
index 89211e4..301cfc7 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesDayEndConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesDayEndConfiguration.cs
@@ -16,7 +16,10 @@ public sealed class SalesDayEndConfiguration : IEntityTypeConfiguration x.BusinessDate).IsRequired();
- foreach (var p in new[] { nameof(SalesDayEnd.Subtotal), nameof(SalesDayEnd.DiscountTotal), nameof(SalesDayEnd.TaxTotal), nameof(SalesDayEnd.GrandTotal), nameof(SalesDayEnd.CostOfGoodsSold) })
+ foreach (var p in new[] {
+ nameof(SalesDayEnd.Subtotal), nameof(SalesDayEnd.DiscountTotal), nameof(SalesDayEnd.TaxTotal), nameof(SalesDayEnd.GrandTotal),
+ nameof(SalesDayEnd.PaidTotal), nameof(SalesDayEnd.BalanceTotal), nameof(SalesDayEnd.CostOfGoodsSold)
+ })
builder.Property(p).HasPrecision(18, 4);
builder.Property(x => x.BundleCount).HasDefaultValue(0);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs
index 3d25507..d86c132 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs
@@ -84,6 +84,16 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(x => x.Batch)
+ .WithMany()
+ .HasForeignKey(x => x.BatchId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(x => x.Serial)
+ .WithMany()
+ .HasForeignKey(x => x.SerialId)
+ .OnDelete(DeleteBehavior.Restrict);
+
builder.Property(x => x.PriceSource).HasMaxLength(50);
builder.Property(x => x.RowVersion).IsRowVersion();
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs
index 1aa2979..9f8b870 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs
@@ -42,5 +42,7 @@ public sealed class StockCountLineConfiguration : IEntityTypeConfiguration l.Count).WithMany(c => c.Lines).HasForeignKey(l => l.CountId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
index 8eca2f4..5ce6c9e 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
@@ -31,6 +31,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("support_tickets");
+ builder.HasKey(t => t.TicketId);
+
+ builder.Property(t => t.Subject).IsRequired().HasMaxLength(200);
+ builder.Property(t => t.Status).IsRequired().HasConversion().HasMaxLength(20);
+ builder.Property(t => t.RowVersion).IsRowVersion();
+
+ builder.HasIndex(t => t.CreatedBy);
+ builder.HasIndex(t => t.Status);
+
+ builder.HasMany(t => t.Messages)
+ .WithOne(m => m.Ticket)
+ .HasForeignKey(m => m.TicketId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SupportTicketMessageConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SupportTicketMessageConfiguration.cs
new file mode 100644
index 0000000..af7a65d
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SupportTicketMessageConfiguration.cs
@@ -0,0 +1,18 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class SupportTicketMessageConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("support_ticket_messages");
+ builder.HasKey(m => m.MessageId);
+
+ builder.Property(m => m.Message).IsRequired().HasMaxLength(2000);
+
+ builder.HasIndex(m => new { m.TicketId, m.CreatedAt });
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/TaxRateConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/TaxRateConfiguration.cs
new file mode 100644
index 0000000..1c75659
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/TaxRateConfiguration.cs
@@ -0,0 +1,42 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class TaxRateConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tax_rates");
+ builder.HasKey(t => t.TaxRateId);
+
+ builder.Property(t => t.Code).IsRequired().HasMaxLength(20);
+ builder.HasIndex(t => t.Code).IsUnique();
+
+ builder.Property(t => t.Name).IsRequired().HasMaxLength(100);
+ builder.Property(t => t.Percentage).HasPrecision(5, 2);
+
+ builder.Property(t => t.Status)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(EntityStatus.Active);
+
+ builder.Property(t => t.CreatedAt).IsRequired();
+
+ // PostgreSQL xmin system column as the optimistic concurrency token (ETag).
+ builder.Property(t => t.RowVersion).IsRowVersion();
+
+ builder.HasIndex(t => t.Status);
+
+ // Seeded so Item.TaxRateId has something real to point at immediately — codes
+ // match common VAT/GST categories; admins can rename, add, or retire rates
+ // afterward (percentages are typically jurisdiction-specific).
+ var seededAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ builder.HasData(
+ new TaxRate { TaxRateId = 1, Code = "STANDARD", Name = "Standard", Percentage = 15m, CreatedAt = seededAt },
+ new TaxRate { TaxRateId = 2, Code = "ZERO", Name = "Zero-Rated", Percentage = 0m, CreatedAt = seededAt },
+ new TaxRate { TaxRateId = 3, Code = "EXEMPT", Name = "Exempt", Percentage = 0m, CreatedAt = seededAt }
+ );
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/VendorConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/VendorConfiguration.cs
index b8dffed..3dc19b5 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/VendorConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/VendorConfiguration.cs
@@ -30,5 +30,9 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration
builder.Property(v => v.RowVersion).IsRowVersion();
builder.HasIndex(v => v.Status);
+
+ builder.HasOne(v => v.Branch).WithMany()
+ .HasForeignKey(v => v.BranchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(v => v.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index cf5832b..8f1c0df 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -15,11 +15,13 @@ namespace ERPCore.Infra.Persistence;
public class ErpDbContext : DbContext
{
private readonly ICurrentUser _currentUser;
+ private readonly ICurrentBranchContext _currentBranch;
private bool _writingAuditLogs;
- public ErpDbContext(DbContextOptions options, ICurrentUser currentUser) : base(options)
+ public ErpDbContext(DbContextOptions options, ICurrentUser currentUser, ICurrentBranchContext currentBranch) : base(options)
{
_currentUser = currentUser;
+ _currentBranch = currentBranch;
}
// --- Master Data (docs/10 Part C.1) ---
@@ -30,6 +32,7 @@ public class ErpDbContext : DbContext
/// Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).
public DbSet ItemTypes => Set();
public DbSet Uoms => Set();
+ public DbSet TaxRates => Set();
public DbSet
- Items => Set
- ();
public DbSet ItemReorders => Set();
public DbSet Vendors => Set();
@@ -43,6 +46,12 @@ public class ErpDbContext : DbContext
/// Singleton row for notification delivery preferences.
public DbSet NotificationConfig => Set();
public DbSet NotificationConfigModules => Set();
+ public DbSet Notifications => Set();
+ public DbSet NotificationRecipients => Set();
+
+ /// Help-desk tickets raised from the dashboard Help page.
+ public DbSet SupportTickets => Set();
+ public DbSet SupportTicketMessages => Set();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet Users => Set();
@@ -176,6 +185,19 @@ public class ErpDbContext : DbContext
// (Infra/Persistence/Configurations/*).
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
+ // Global read-side branch scoping (docs/BRANCH_ISOLATION_PLAN.md). Only for entities
+ // that carry an explicit BranchId directly — warehouse-inherited entities (Grn,
+ // StockLayer, StockTransfer, ...) are NOT covered here (would need an extra join in
+ // the filter) and remain a follow-up. !IsActive short-circuits for admins/system
+ // contexts so the filter becomes a no-op rather than excluding everything.
+ modelBuilder.Entity().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
+ modelBuilder.Entity().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
+ modelBuilder.Entity().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
+ modelBuilder.Entity().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
+ modelBuilder.Entity().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
+ modelBuilder.Entity().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
+ modelBuilder.Entity().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
+
// Npgsql requires DateTime values written to `timestamp with time zone` columns
// to have Kind=Utc; dates deserialized from a JSON request body (hire date,
// salary-structure effective date, etc.) come in as Kind=Unspecified and would
diff --git a/Backend/ERPCore/Migrations/20260914061147_AddNotificationFeed.Designer.cs b/Backend/ERPCore/Migrations/20260914061147_AddNotificationFeed.Designer.cs
new file mode 100644
index 0000000..d9e9544
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20260914061147_AddNotificationFeed.Designer.cs
@@ -0,0 +1,8324 @@
+//
+using System;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ERPCore.Migrations
+{
+ [DbContext(typeof(ErpDbContext))]
+ [Migration("20260914061147_AddNotificationFeed")]
+ partial class AddNotificationFeed
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
+ {
+ b.Property("AttendanceRecordId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceRecordId"));
+
+ b.Property("AttendanceDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AttendanceStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("AttendanceUploadBatchId")
+ .HasColumnType("integer");
+
+ b.Property("CheckIn")
+ .HasColumnType("interval");
+
+ b.Property("CheckOut")
+ .HasColumnType("interval");
+
+ b.Property("DuplicateOfAttendanceRecordId")
+ .HasColumnType("integer");
+
+ b.Property("EarlyLeaveMinutes")
+ .HasColumnType("integer");
+
+ b.Property("EditedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EditedBy")
+ .HasColumnType("integer");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("IsManualOverride")
+ .HasColumnType("boolean");
+
+ b.Property("LateMinutes")
+ .HasColumnType("integer");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("OvertimeMinutes")
+ .HasColumnType("integer");
+
+ b.Property("RowValidationStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("integer");
+
+ b.Property("WorkingMinutes")
+ .HasColumnType("integer");
+
+ b.HasKey("AttendanceRecordId");
+
+ b.HasIndex("AttendanceUploadBatchId");
+
+ b.HasIndex("WorkShiftId");
+
+ b.HasIndex("EmployeeId", "AttendanceDate");
+
+ b.ToTable("hr_attendance_records", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b =>
+ {
+ b.Property("AttendanceUploadBatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceUploadBatchId"));
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConfirmedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(260)
+ .HasColumnType("character varying(260)");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PeriodStart")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowCountDuplicate")
+ .HasColumnType("integer");
+
+ b.Property("RowCountError")
+ .HasColumnType("integer");
+
+ b.Property("RowCountTotal")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UploadedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UploadedBy")
+ .HasColumnType("integer");
+
+ b.HasKey("AttendanceUploadBatchId");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("PeriodStart", "PeriodEnd");
+
+ b.ToTable("hr_attendance_upload_batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b =>
+ {
+ b.Property("AuditId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("ChangeSet")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EntityId")
+ .HasColumnType("integer");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("AuditId");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_logs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
+ {
+ b.Property("BatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId"));
+
+ b.Property("BatchNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("date");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.HasKey("BatchId");
+
+ b.HasIndex("ItemId", "BatchNo")
+ .IsUnique();
+
+ b.ToTable("batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
+ {
+ b.Property("BinId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId"));
+
+ b.Property("BinType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BinId");
+
+ b.HasIndex("WarehouseId", "Code")
+ .IsUnique();
+
+ b.ToTable("bins", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Branch", b =>
+ {
+ b.Property("BranchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BranchId"));
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BranchId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_branches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b =>
+ {
+ b.Property("ConfigId")
+ .HasColumnType("integer");
+
+ b.Property("BranchingEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("integer");
+
+ b.HasKey("ConfigId");
+
+ b.HasIndex("UpdatedBy");
+
+ b.ToTable("branch_config", null, t =>
+ {
+ t.HasCheckConstraint("ck_branch_config_singleton", "\"ConfigId\" = 1");
+ });
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
+ {
+ b.Property("BrandId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BrandId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("brands", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
+ {
+ b.Property("BundleSaleId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleId"));
+
+ b.Property("BundleCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BundleDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("BundleName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BundleNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BundlePrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("integer");
+
+ b.Property("CashierUserId")
+ .HasColumnType("integer");
+
+ b.Property("ComponentSubtotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer");
+
+ b.Property("CustomerSnapshotName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DayEndId")
+ .HasColumnType("integer");
+
+ b.Property("DiscountTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("GrandTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("MarginAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Draft");
+
+ b.Property("TaxTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BundleSaleId");
+
+ b.HasIndex("BundleNo")
+ .IsUnique();
+
+ b.HasIndex("BundleSaleTemplateId");
+
+ b.HasIndex("CashierUserId");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("DayEndId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sales", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
+ {
+ b.Property("BundleSaleLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleLineId"));
+
+ b.Property("BundleSaleId")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IncludeInBundle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("IsComponent")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("LineTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ParentLineId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BundleSaleLineId");
+
+ b.HasIndex("BundleSaleId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sale_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
+ {
+ b.Property("BundleSaleTemplateId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleTemplateId"));
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TemplateCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("TemplateName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BundleSaleTemplateId");
+
+ b.HasIndex("TemplateCode")
+ .IsUnique();
+
+ b.ToTable("bundle_sale_templates", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
+ {
+ b.Property("BundleSaleTemplateLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleTemplateLineId"));
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("integer");
+
+ b.Property("IncludeInBundle")
+ .HasColumnType("boolean");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("SortOrder")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property