- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases. - Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation. - Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only. - Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities. - Implemented ItemContent service to validate and normalize content sizes. - Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations. - Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
40 KiB
10 · BACKEND — Phase 1 Spec (Inventory & Supply Chain)
Authoritative for: backend architecture, business rules, and the data model (the 42-entity schema). Navigation: you arrived here from
00-CORE.md. API request/response contracts are in11-BACKEND-PHASE1.md. Frontend rules are in20-FRONTEND.md. Record work inBackend/PROGRESS.md. Scope basis: SRS v1.1. Costing = FIFO · Multi-warehouse · Single-tenant · RBAC deferred (user identity stamped) · approvals auto/config-gated · vendor invoice + 3-way match deferred to Accounting. Authentication: identity is owned by the external AuthHex identity provider (separate service), but as of 2026-07-16 the frontend no longer calls AuthHex directly — all login/registration/recovery/2FA/session traffic is proxied through ERPCore's ownAuthController(Controllers/AuthController.cs,Services/Auth/*), which forwards to AuthHex and delivers the resulting session as httpOnly Secure cookies (docs/02-SECURITY.md §B.2). ERPCore still does not mint or sign tokens itself — it only forwards to and validates AuthHex's RS256 JWTs. See A.4 (Authentication / Audit actor) and11-BACKEND-PHASE1.md §2.0for the endpoint list. RBAC (per-endpoint) still deferred.
Part A — Architecture & Layer Rules
The high-level layering is introduced in 00-CORE.md §4; this part is the authoritative detail the implementation must follow.
A.1 Layered flow (one direction only)
HTTP ─► Controller ─► Service ─► Repository ─► UnitOfWork / ErpDbContext ─► PostgreSQL
(DTOs) (logic, (entities) (transaction boundary)
returns DTOs)
Controller
- HTTP concerns only: routing, model binding, status codes,
[ProducesResponseType]. - Accepts and returns DTOs only. Never references EF entities or
ErpDbContext. - No business logic, no data access.
Service
- Owns business logic and orchestration. Returns DTOs.
- Maps entity ⇄ DTO (manual mapping is fine and dependency-free; keep mapping in the service or a dedicated mapper class).
- Opens the UnitOfWork transaction for any operation that spans more than one write.
Repository
- EF Core data access over entities. One repository per aggregate (Item, PurchaseOrder, Grn, Stock…).
- Query + persistence only. No business rules. Returns entities or projections to services.
UnitOfWork
- The transaction boundary. Wraps a single
ErpDbContext; exposesSaveChangesAsync()and an explicit transaction scope (BeginTransactionAsync) for multi-step stock operations. - Every stock-affecting operation (GRN confirm, transfer dispatch/receive, adjustment, purchase return, count post) runs inside one UoW transaction so it commits or rolls back atomically (NFR-02, NFR-05).
A.2 FIFO placement (critical)
- FIFO cost-layer consumption and valuation live in a domain service:
Services/Stock/FifoCostingService. - It is invoked by stock services inside the UoW transaction — never from a controller or repository.
- On any issue (transfer-out, adjustment-out, purchase return, future goods issue), it consumes open layers oldest-first, decrements
qtyRemaining, and returns the costed movement for the ledger. - Layer rows being consumed must be locked for the duration of the transaction to stay concurrency-safe (NFR-02). Use
SELECT … FOR UPDATEsemantics (EF: query within the transaction with appropriate locking) so two concurrent issues can't consume the same remaining quantity.
A.3 DTO boundary
- Every request body and response is a DTO in
Dtos/. Group by module (Dtos/Items,Dtos/Procurement,Dtos/Grn,Dtos/Stock,Dtos/Common). Dtos/Commonholds shared shapes:PagedResult<T>,PaginationMeta, and the error/problem shapes.- Entities in
Domain/Entitiesnever leave the service layer.
A.4 Cross-cutting
- Errors: RFC 7807
ProblemDetails(framework default). Domain exceptions inSystem/Errorscarry a stablecode; a middleware maps them toProblemDetails. Catalog in11-BACKEND-PHASE1.md §7. - Authentication: ERPCore validates JWTs issued by the external AuthHex IdP — algorithm RS256 (asymmetric RSA), issuer
AuthHex, audienceAuthHexClient. AuthHex exposes no JWKS/OIDC discovery, so ERPCore is configured with AuthHex's RSA public key statically (rotation is a manual config update). Tokens live ~1000 min (prod) / 60 min (dev). A single door authorization policy requires an ERPUserTypeCode/RoleCodeclaim (AuthHex is a shared IdP, so a valid token alone is not enough); per-endpoint RBAC stays deferred. - Auth proxy:
AuthController(Controllers/AuthController.cs) is ERPCore's only endpoint group that talks to AuthHex over HTTP — viaIAuthHexClient(Infra/Auth/AuthHex/AuthHexClient.cs,AuthHex:BaseUrlconfig) — and the only place that issues httpOnlyerp_at/erp_rtsession cookies (Infra/Auth/AuthCookieWriter.cs) plus theXSRF-TOKENdouble-submit cookie checked byValidateCsrfAttributeon mutating actions. The JWT bearer handler also accepts theerp_atcookie as a fallback (JwtAuthExtensions'sOnMessageReceived) when noAuthorizationheader is present, so every other controller keeps working unchanged whether a caller sends a Bearer header or relies on the cookie session. - Audit actor: the token carries no
sub/nameid; identity is AuthHex's customUserId(GUID) claim. AnICurrentUserabstraction (Infra/Auth) resolves the acting user from a local shadow user — the GUID is mapped (JIT-provisioned) to a localintusers.user_idthat all FKs reference (see C.7). Services stamp mutations with it. Never trust acreatedByfrom the request body. - Concurrency: mutable resources carry a
RowVersion([Timestamp] byte[]), surfaced asETag;PUT/PATCHrequireIf-Match→412on mismatch. - Numbering: document numbers come from
NumberSequence(per doc type, per year), issued inside the same transaction as the document.
A.5 DI registration (lifetimes)
ErpDbContext: scoped (default).IUnitOfWork, repositories, services,ICurrentUser,FifoCostingService: scoped.- Register in
Program.cs(or anAddApplication()extension) afterAddDbContext. - Auth wiring: JWT bearer validation is built from AuthHex's RSA public key (config XML →
RsaSecurityKey) withValidIssuer=AuthHex,ValidAudience=AuthHexClient. A scopedIClaimsTransformationprovisions/looks up the local shadow user (byauth_user_id= tokenUserIdGUID) and injects the resolved localintid as aClaimTypes.NameIdentifier(nameid) claim, soICurrentUser/AuditUserIdresolve a real user unchanged (falling back to the seeded system user only when unauthenticated).
Part B — Software Requirements Specification (v1.1)
Basis: ISO/IEC/IEEE 29148. Requirement IDs
FR-<AREA>-<n>; priority M/S/C (Must/Should/Could).
B.1 Introduction
B.1.1 Purpose
Specifies Phase 1 — the Inventory & Supply Chain subsystem: master data, procurement, goods receipt, stock and warehouse management, plus the integration seams reserved for later phases.
B.1.2 Scope
In scope: master data (Item, UOM & conversions, Category, Vendor, Warehouse/Bin); procurement (Requisition → RFQ → PO with approval + amendments → Purchase Return); GRN with inspection hold and partial receipt; stock management (count, transfer with in-transit, adjustment, ledger, FIFO valuation, reorder alerts); warehouse management (multi-warehouse, bin/location, batch/expiry and serial, basic putaway/pick); cross-cutting services (user identity + audit, document numbering, reason codes).
Out of scope (deferred, hooks retained): vendor invoice & three-way match → Accounting (GRN retains PO ref, received qty, received value per line); GL posting (Phase 1 emits GL-ready journal entries as data, no ledger); sales orders / reservation fulfilment / manufacturing consumption / formal QC dispositions (interfaces stubbed, §B.7); landed-cost apportionment (decision §B.1.2.1).
B.1.2.1 Landed cost decision (open)
Imports carry freight + duty + VAT, so true unit cost ≠ PO price.
- (A) Defer to Accounting — layers valued at PO price + directly-attributable line charges only. Simpler; valuation understates true cost.
- (B) Include apportionment at GRN — distribute additional charges across received lines into the FIFO layer cost. More accurate; one extra workflow.
Affects FR-GRN-06 and FR-STK-14. Recommendation: B if import duties are material; else A.
B.1.3 Definitions
GRN = Goods Receipt Note · PO = Purchase Order · RFQ = Request for Quotation · PR = Purchase Requisition · UOM = Unit of Measure · FIFO = First-In-First-Out costing · FEFO = First-Expiry-First-Out picking · Cost layer = quantity received at a specific unit cost, consumed FIFO · In-transit = stock left source, not yet confirmed at destination · ROP = reorder point · RBAC = role-based access control · GL = general ledger.
B.1.4 Overview
§B.2 overall description; §B.3 functional requirements; §B.4 data model; §B.5 external interfaces; §B.6 NFRs; §B.7 future seams; §B.8 appendices. The entity model is expanded in Part C.
B.2 Overall Description
B.2.1 Product perspective
Foundation of a modular ERP. Single-tenant, multi-warehouse. All later modules depend on the Item master, stock ledger, and vendor master defined here. Module boundaries are isolated behind services (Part A).
B.2.2 Product functions
Maintain master data; raise/approve procurement through PO and return; receive goods with inspection hold; track stock movements in a costed FIFO ledger across warehouses; perform counts/transfers/adjustments with audit; track batch/expiry/serial and locate by bin; raise reorder alerts.
B.2.3 User classes
Phase-1 note: role-based permissions are deferred (FR-X-01). Identity is supplied by the external AuthHex IdP (see A.4); a single door policy admits only ERP
UserType/Roleholders, but beyond that any admitted user may perform any action, with each action stamped with the authenticated user's identity for audit. The roles below are the functional blueprint for future per-endpoint RBAC, not enforced boundaries.
Storekeeper/Warehouse operator (receive, count, transfer, pick) · Procurement officer (requisitions, POs, vendors) · Approver/Manager (authorizes once approvals enabled) · Inventory controller (valuation, adjustments, reorder policy) · Auditor (read-only) · System administrator (users, numbering, config).
B.2.4 Operating environment
Web application; responsive UI incl. handheld/scanner. Relational DB with transactions + row-level locking (FIFO consumption needs it). Barcode/QR capable (designed-for).
B.2.5 Constraints
FIFO is a system-wide constraint; ledger must track cost layers. Multi-warehouse mandatory day 1; multi-tenancy out of scope. Single base currency (LKR) in Phase 1. Every stock transaction atomic + immutable ledger entry.
B.2.6 Assumptions & dependencies
One base currency; invoicing/3-way match in Accounting (GRN carries data); users/warehouses configured before transactions; landed-cost scope (§B.1.2.1) resolved before GRN valuation finalized.
B.3 Functional Requirements
B.3.1 Master Data (FR-MD)
| ID | Requirement | Pri |
|---|---|---|
| FR-MD-01 | Maintain Item master: SKU (unique), name, description, category (+ optional subcategory), optional brand, stock nature (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor, optional fixed sale price (nullable; Sales-only — never enters costing/GRN/FIFO; null ⇒ item is sold at its stock/FIFO value). |
M |
| FR-MD-02 | Maintain UOM master, with one base UOM per item — the pack it is stocked and counted in. An item may also carry an optional content size (how much one pack holds: 500 ml, 50 kg), entered in ml/L/g/kg and stored normalised to ml or g. (Revised 2026-08-11: per-item conversion factors are gone — see the note below.) | M |
| FR-MD-03 | Record every transaction quantity as a count of the item's base UOM. There is no conversion: a differently sized pack is a different item. The one exception is a production stage input, which may be written in the item's content unit and is divided by the content size to get the pack count (FR-MFG-04). | M |
| FR-MD-04 | Maintain item categories with one optional subcategory level. An item references a category (required) and a subcategory (optional) that must belong to it. Deeper nesting is not supported. | S |
| FR-MD-05 | Hold reorder point and reorder quantity per item, optionally per warehouse. | M |
| FR-MD-06 | Maintain Vendor master: code, name, contact, terms, tax reg, status, currency. | M |
| FR-MD-07 | Maintain Warehouse master and, within each, a bin/location structure. | M |
| FR-MD-08 | Prevent deletion of any master referenced by a transaction; deactivate instead. | M |
| FR-MD-09 | Maintain Brand master (name, status); optionally referenced by an item. | S |
| FR-MD-10 | Maintain Item Type master (name, status — e.g. Color, Size, Material) as a selectable list only: it feeds the item builder's dropdown and is not referenced by any item. Chosen values are encoded into the client-generated SKU, not stored (Part C.9). No product-variation model. | S |
| FR-MD-11 | Maintain a singleton Product Configuration gating optional features. subcategoriesEnabled/brandsEnabled are enforced server-side — an item write carrying a gated field while its flag is off is rejected (CONFIG_DISABLED). itemTypesEnabled is advisory (frontend-honoured) since items hold no item-type reference. Reads are never gated. |
S |
B.3.2 Procurement (FR-PROC)
| ID | Requirement | Pri |
|---|---|---|
| FR-PROC-01 | Create Purchase Requisition with lines (item, qty, required-by, requester). | M |
| FR-PROC-02 | Optional RFQ: issue to vendors, record quotations for comparison. | S |
| FR-PROC-03 | Generate PO from PR/RFQ or directly (item, UOM, qty, price, tax, delivery date, warehouse). | M |
| FR-PROC-04 | [Phase 1: auto-approve] Auto-approve PO on creation (status Approved). Config flag approvalRequired (default off) gates a future approval workflow (authorization matrix); when on, PO cannot issue until approved. PendingApproval state + approval fields retained in schema (no migration to enable). |
M |
| FR-PROC-05 | [Phase 1: Option B superseded 2026-07-20 — draft-lock] A PO is editable and deletable only while Draft; submitting locks it (Draft → Approved) and no further edit/delete/add-line is allowed — an issued PO is corrected by Cancel-with-reason (blocked once receipts exist) or a reversing document, never edited. Create takes saveAsDraft (default false → auto-approve, preserving the Requisition→PO / RFQ→PO flows). Why the reversal: Option B ("freely edit while open") let an already-issued, vendor-facing PO change silently after the fact; the draft/submit boundary makes "issued to vendor" a real, immutable commitment. Versioned amendments still deferred; schema unchanged (reuses the existing Draft enum value). |
S |
| FR-PROC-06 | PO lifecycle: Draft → (PendingApproval →) Approved → PartiallyReceived → FullyReceived → Closed/Cancelled. Phase 1 bypasses PendingApproval via auto-approve. | M |
| FR-PROC-07 | Support partial receipt; PO stays open until fully received or manually closed. | M |
| FR-PROC-08 | Support Purchase Return referencing original GRN/PO line; generates outbound movement. | M |
| FR-PROC-09 | Retain data sufficient for future three-way match without schema change. | M |
B.3.3 Goods Receipt (FR-GRN)
| ID | Requirement | Pri |
|---|---|---|
| FR-GRN-01 | Create GRN against an approved PO, defaulting lines/quantities from open PO lines. Additional lines for items not on the PO are permitted — a line with no po_line_id is received like a direct receipt (entered cost, no over-receipt check) and does not affect PO line balances. Off-PO lines are a review surface (see 02-SECURITY C.3). |
M |
| FR-GRN-02 | Support GRN without PO (direct/emergency) by permission, flagged for review. | S |
| FR-GRN-03 | Support over/under-receipt tolerances (per item or global); warn or block beyond tolerance. | S |
| FR-GRN-04 | Capture batch + expiry and/or serial numbers for tracked items on receipt. | M |
| FR-GRN-05 | Allow receipt into inspection/quarantine hold (not issuable) pending QC, before QC module exists. | M |
| FR-GRN-06 | On confirm, each line creates a FIFO cost layer at the after-discount net unit cost (unitCost × (1 − discountPct/100)) and posts an inbound ledger entry. VAT never enters stock value — it is recoverable input tax (revised 2026-07-20). PO price is the default unit cost; a per-line override is permitted and recorded as a variance (see 02-SECURITY C.3, revised). |
M |
| FR-GRN-07 | Record received value per line and PO reference for downstream matching. | M |
| FR-GRN-08 | Assign received stock to a bin/location (putaway). | S |
B.3.4 Stock Management (FR-STK)
| ID | Requirement | Pri |
|---|---|---|
| FR-STK-01 | Maintain an immutable, append-only stock ledger: item, warehouse, bin, batch/serial, qty (base UOM), unit cost, value, running balance, source doc, user, timestamp. | M |
| FR-STK-02 | Maintain FIFO cost layers per item per warehouse (received qty, remaining qty, unit cost, receipt date). | M |
| FR-STK-03 | On any issue, consume oldest layers first, posting cost at each layer's unit cost. | M |
| FR-STK-04 | Compute valuation = Σ(remaining qty × unit cost) over open layers, per item/warehouse and total. | M |
| FR-STK-05 | Support transfer between warehouses/bins with in-transit: out consumes source layers; in confirms and creates destination layer. | M |
| FR-STK-06 | Transfers are cost-preserving: destination layer inherits consumed source cost. | M |
| FR-STK-07 | [Phase 1: auto-post] Adjustments (increase/decrease/write-off) with mandatory reason code post immediately; a config flag (default off) gates threshold approval later. Reason code + user stamp always mandatory. | M |
| FR-STK-08 | Support cycle count and full physical count workflows; post variance adjustments on confirmation. | M |
| FR-STK-09 | Define negative-stock policy (default: block issues that would drive on-hand negative; configurable per item). | M |
| FR-STK-10 | Provide reorder alerts when available ≤ reorder point; optionally suggest a requisition. | M |
| FR-STK-11 | Expose a reservation/allocation status on stock (stubbed Phase 1; consumed by Sales later) to distinguish on-hand vs available. | S |
| FR-STK-12 | Stock enquiry by item/warehouse/bin/batch/serial showing on-hand, in-transit, on-hold, available. | M |
| FR-STK-13 | Every stock transaction generates a GL-ready journal entry as data (no posting in Phase 1). | S |
| FR-STK-14 | With landed cost enabled (Option B), apportion additional charges into FIFO layer costs at GRN by configurable basis. | C |
B.3.5 Warehouse Management (FR-WH)
| ID | Requirement | Pri |
|---|---|---|
| FR-WH-01 | Support multiple warehouses, each with a bin/location hierarchy. | M |
| FR-WH-02 | Track stock to bin level; support bin-to-bin movement within a warehouse. | M |
| FR-WH-03 | Support batch/lot tracking with expiry; enforce FEFO picking for perishables (distinct from FIFO costing). | M |
| FR-WH-04 | Support serial-number tracking across lifecycle (receipt → issue). | M |
| FR-WH-05 | Provide basic putaway and pick steps. | S |
| FR-WH-06 | Be barcode/QR-scan ready for item, bin, batch, serial. | C |
| FR-WH-07 | Block issue/pick of on-hold/quarantine or expired stock. | M |
B.3.6 Cross-cutting (FR-X)
| ID | Requirement | Pri |
|---|---|---|
| FR-X-01 | [Phase 1: external IdP, user identity only] Authentication is delegated to the external AuthHex IdP (ERPCore validates its RS256 tokens; no local login). ERPCore provisions a local shadow user (auth_user_id GUID → local int) and stamps every transaction with the acting user's identity for audit. AuthHex now also supplies RoleCode/UserTypeCode claims, used only for the door gate; full per-endpoint RBAC (role→permission matrix) remains deferred — reserve role/permission structures for no-migration enablement. |
M |
| FR-X-02 | Maintain an immutable audit trail for every create/update/delete and stock movement (who/when/old→new/reason). | M |
| FR-X-03 | Generate document numbers from configurable sequences (per type, per year), unique and gap-controlled. | M |
| FR-X-04 | Maintain configurable reason-code lists (adjustments, returns, count variances). | M |
| FR-X-05 | No editing/deleting confirmed/posted transactions; corrections via reversing entries. | M |
B.4 Data Model (summary)
Key entities and relationships are enumerated in Part C. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and RBAC for sidebar visibility (Role, NavItem, SubNavItem, Permission, RolePermission — see C.8; per-endpoint enforcement still deferred).
B.5 External Interfaces
UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-field validation. Hardware: barcode/QR (designed-for). Software: relational DB with transactional integrity + row locking; internal service interfaces/events for Phase-2+ modules.
B.6 Non-Functional Requirements
| ID | Category | Requirement |
|---|---|---|
| NFR-01 | Performance | Single item/warehouse enquiry + valuation < 2s under normal load; ledger posting transactional, < 1s per line. |
| NFR-02 | Integrity | FIFO layer consumption atomic and concurrency-safe; no double-consumption of remaining qty. |
| NFR-03 | Security | Users authenticated via the external AuthHex IdP, proxied through ERPCore's AuthController (docs/11 §2.0); password hashing (BCrypt) is AuthHex's responsibility — ERPCore forwards credentials and validates the resulting tokens only, never storing or hashing passwords itself. Every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) |
| NFR-04 | Auditability | Audit trail immutable, retained per policy; ledger append-only. |
| NFR-05 | Reliability | No stock transaction partially commits; full rollback on failure. |
| NFR-06 | Scalability | Growth in items/warehouses/ledger without redesign; ledger indexed for time-series queries. |
| NFR-07 | Usability | Receive/count/transfer achievable with minimal training; scan-first where hardware present. |
| NFR-08 | Maintainability | Modular service boundaries; Phase-2+ integrates without altering Phase-1 schema. |
| NFR-09 | Availability | Not a Phase-1 engineering commitment. Uptime SLA to be set for production; HA infra (failover, redundancy) deferred to a later hardening phase. |
| NFR-10 | Configurability | Numbering, tolerances, reason codes, negative-stock and reorder policies configurable without code change. |
B.7 Future Modules & Integration Seams
| Module | Seam in Phase 1 |
|---|---|
| Accounting | GL-ready journal entries per movement (FR-STK-13); GRN retains PO ref + received value for 3-way match (FR-PROC-09, FR-GRN-07). |
| Sales & CRM | Reservation/allocation status distinguishing on-hand vs available (FR-STK-11). |
| Manufacturing | Generic goods-issue/consumption movement type BOM will consume through (extends FR-STK-03). |
| QC / QA | GRN inspection/quarantine hold (FR-GRN-05); hold blocks issue (FR-WH-07). |
| HRM | User identity foundation (FR-X-01) reusable for employee-linked permissions. Now underway — see 12-BACKEND-HRM.md. |
| RBAC & Approvals | Config flags + retained PendingApproval/role structures enable PO & adjustment approvals with no schema change. |
B.8 Appendices
B.8.1 Status lifecycles PO: Draft → (PendingApproval →) Approved → PartiallyReceived → FullyReceived → Closed/Cancelled · GRN: Draft → Confirmed → Closed · Transfer: Draft → InTransit → Received → Closed · Adjustment/Count: Draft → (PendingApproval →) Posted.
B.8.2 Document numbering (examples)
PR-YYYY-#####, PO-YYYY-#####, GRN-YYYY-#####, TRF-YYYY-#####, ADJ-YYYY-#####, CNT-YYYY-#####, PRET-YYYY-##### (FR-X-03).
B.8.3 Reason codes (seed) Adjustment: Damage, Theft/Loss, Count Variance, Expiry Write-off, System Correction · Return: Defective, Wrong Item, Over-supply, Quality Reject.
B.8.4 Open decisions log
| # | Decision | Status |
|---|---|---|
| 1 | Landed-cost scope (§B.1.2.1, A vs B) | Open |
| 2 | Negative-stock: global block vs per-item (FR-STK-09) | Proposed: block |
| 3 | GRN-without-PO permission scope (FR-GRN-02) | Open |
| 4 | Availability/uptime SLA target (NFR-09) | Deferred to production |
| 5 | PO & adjustment approvals | Resolved: deferred — auto-approve, config-gated off |
| 6 | RBAC | Resolved: deferred — user identity only |
| 7 | PO amendments | Resolved: Option B, edit-while-open |
| 8 | Costing method | Resolved: FIFO |
| 9 | Tenancy | Resolved: single-tenant |
| 10 | Authentication | Resolved: external AuthHex IdP (RS256; ERPCore validates only), shadow-user provisioning (auth_user_id GUID → local int), door-gated by ERP UserType/Role; per-endpoint RBAC deferred. Open sub-item: exact ERP UserTypeCode/RoleCode + RSA-key rotation process. |
| 11 | Category hierarchy depth | Resolved: dedicated SUBCATEGORY table, exactly two levels; CATEGORY.parent_id dropped. Item carries both FKs (subcategory nullable). Arbitrary nesting is not coming back. |
| 12 | Item types / variants | Resolved: the ItemType enum was replaced by an unreferenced master list; Stocked/NonStocked/Service survives as stock_nature. Values are SKU-encoded only — no value table, no item link, no product-variation model (Part C.9 records the accepted trade-off). |
| 13 | Product-config authorization | Open: PUT /product-config is gated by the door policy only, like every other endpoint. A CONFIG_MANAGE permission is reserved for when per-endpoint RBAC lands (decision #6). Until then any ERP-admitted user can flip the flags. |
| 14 | Item sale price (fixed vs stock value) | Resolved (2026-07-22): a single nullable ITEM.sale_price — NULL ⇒ sell at stock/FIFO value, a value ⇒ fixed price. Sales-only (never touches GRN/FIFO/ledger). No price_mode enum; the create-time fixed/stock toggle is frontend UX that requires a price per generated variant when "fixed" is chosen (Part C.9). |
| 15 | Off-PO lines on a PO-based GRN | Resolved (2026-07-22): allowed. GRN_LINE.po_line_id is nullable; a null line on a PO-based GRN is received like a direct receipt (entered cost, no over-receipt check) and does not touch PO balances. Same cost-entry/fraud surface as GRN-without-PO (AR-04) — flagged for review, not blocked (02-SECURITY C.3). |
Part C — ER Model (42 entities)
Costing: FIFO · Multi-warehouse · Single-tenant. Legend: PK primary key · FK foreign key. Companion visual diagrams (Mermaid / draw.io ERD) accompany this repo; this part is the authoritative textual model.
C.1 Master Data
CATEGORY(category_id PK, name, status) -- top level; no self-nesting
SUBCATEGORY(subcategory_id PK, category_id FK→CATEGORY, name, status)
BRAND(brand_id PK, name, status)
ITEM_TYPE(item_type_id PK, name, status) -- Color, Size, Material — standalone
UOM(uom_id PK, name)
ITEM(item_id PK, sku, name, category_id FK→CATEGORY, subcategory_id FK→SUBCATEGORY [nullable],
brand_id FK→BRAND [nullable], base_uom_id FK→UOM,
content_qty [nullable], content_unit [nullable], -- as entered (Ml|L|G|Kg)
content_base_qty [nullable], content_base_unit [nullable], -- normalised, Ml|G only
default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class,
sale_price [nullable], status) -- sale_price: Sales-only selling price; NULL ⇒ sell at stock (FIFO) value
ITEM_REORDER(reorder_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, reorder_point, reorder_qty)
VENDOR(vendor_id PK, code, name, terms, tax_reg, currency, status)
WAREHOUSE(warehouse_id PK, code, name)
BIN(bin_id PK, warehouse_id FK→WAREHOUSE, code, bin_type)
PRODUCT_CONFIG(config_id PK [singleton = 1], subcategories_enabled, brands_enabled,
item_types_enabled, updated_at, updated_by FK→USER) -- FR-MD-11 feature gate
Category hierarchy is exactly two levels. CATEGORY.parent_id was removed (migration AddBrandsSubcategoriesItemTypesAndProductConfig); the optional level below a category is now SUBCATEGORY. An item carries both FKs — category_id required, subcategory_id nullable — and the service rejects a subcategory that does not belong to the given category (422).
ITEM_TYPE is deliberately unreferenced — see C.9.
stock_nature (Stocked/NonStocked/Service) is the former item_type column, renamed so the name could be taken by the ITEM_TYPE master. The two are unrelated concepts.
C.2 Procurement
REQUISITION(requisition_id PK, doc_no, requested_by FK→USER, status, created_at)
REQUISITION_LINE(req_line_id PK, requisition_id FK→REQUISITION, item_id FK→ITEM, qty, required_by)
RFQ(rfq_id PK, doc_no, requisition_id FK→REQUISITION, status)
RFQ_LINE(rfq_line_id PK, rfq_id FK→RFQ, item_id FK→ITEM, qty)
VENDOR_QUOTATION(quotation_id PK, rfq_id FK→RFQ, vendor_id FK→VENDOR, unit_price, lead_days)
PURCHASE_ORDER(po_id PK, doc_no, vendor_id FK→VENDOR, requisition_id FK→REQUISITION,
status, approval_required, created_by FK→USER, created_at)
PO_LINE(po_line_id PK, po_id FK→PURCHASE_ORDER, item_id FK→ITEM, uom_id FK→UOM,
warehouse_id FK→WAREHOUSE, qty, unit_price, tax, qty_received)
PURCHASE_RETURN(return_id PK, doc_no, vendor_id FK→VENDOR, warehouse_id FK→WAREHOUSE,
reason_code_id FK→REASON_CODE, created_by FK→USER)
PURCHASE_RETURN_LINE(return_line_id PK, return_id FK→PURCHASE_RETURN,
grn_line_id FK→GRN_LINE, item_id FK→ITEM, qty)
C.3 Goods Receipt
GRN(grn_id PK, doc_no, po_id FK→PURCHASE_ORDER, vendor_id FK→VENDOR,
warehouse_id FK→WAREHOUSE, status, created_by FK→USER, created_at)
GRN_LINE(grn_line_id PK, grn_id FK→GRN, po_line_id FK→PO_LINE [nullable], item_id FK→ITEM, uom_id FK→UOM,
bin_id FK→BIN, batch_id FK→BATCH, qty, unit_cost, received_value, hold_status)
-- po_line_id nullable: NULL for a direct receipt OR an off-PO line added to a PO-based GRN (FR-GRN-01)
C.4 Batch / Serial
BATCH(batch_id PK, item_id FK→ITEM, batch_no, expiry_date)
SERIAL(serial_id PK, item_id FK→ITEM, serial_no, status)
C.5 Stock Core (FIFO + Ledger)
STOCK_LAYER(layer_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, batch_id FK→BATCH,
serial_id FK→SERIAL, grn_line_id FK→GRN_LINE,
qty_received, qty_remaining, unit_cost, receipt_date) -- FIFO layers
STOCK_LEDGER(ledger_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, bin_id FK→BIN,
batch_id FK→BATCH, serial_id FK→SERIAL, user_id FK→USER,
direction, qty_base, unit_cost, value, running_balance,
source_doc_type, source_doc_id, created_at) -- immutable journal
C.6 Stock Transactions
STOCK_TRANSFER(transfer_id PK, doc_no, src_warehouse_id FK→WAREHOUSE,
dest_warehouse_id FK→WAREHOUSE, status, created_by FK→USER)
STOCK_TRANSFER_LINE(transfer_line_id PK, transfer_id FK→STOCK_TRANSFER, item_id FK→ITEM,
src_bin_id FK→BIN, dest_bin_id FK→BIN, batch_id FK→BATCH, serial_id FK→SERIAL, qty)
STOCK_ADJUSTMENT(adjustment_id PK, doc_no, warehouse_id FK→WAREHOUSE,
reason_code_id FK→REASON_CODE, created_by FK→USER, created_at)
STOCK_ADJUSTMENT_LINE(adj_line_id PK, adjustment_id FK→STOCK_ADJUSTMENT, item_id FK→ITEM,
bin_id FK→BIN, batch_id FK→BATCH, serial_id FK→SERIAL, qty_delta)
STOCK_COUNT(count_id PK, doc_no, warehouse_id FK→WAREHOUSE, count_type, status, created_by FK→USER)
STOCK_COUNT_LINE(count_line_id PK, count_id FK→STOCK_COUNT, item_id FK→ITEM, bin_id FK→BIN,
system_qty, counted_qty, variance)
C.7 Cross-cutting
USER(user_id PK, username, display_name, status, auth_user_id [GUID, unique] → AuthHex identity) -- local shadow/projection of AuthHex users; user_id (int) is what all FKs reference
REASON_CODE(reason_code_id PK, code, description, context)
NUMBER_SEQUENCE(sequence_id PK, doc_type, year, last_number)
AUDIT_LOG(audit_id PK, user_id FK→USER, entity_type, entity_id, action, change_set, created_at)
JOURNAL_ENTRY_STUB(journal_id PK, source_doc_type, source_doc_id, debit_account, credit_account, amount)
C.8 RBAC — sidebar-visibility only (implemented 2026-07-18); per-endpoint enforcement still deferred
ROLE(role_id PK, auth_role_id [GUID, unique] → AuthHex Role, code, name, is_system_role, status, created_at, updated_at, row_version) -- local shadow/projection of AuthHex's Role, same pattern as USER
NAV_ITEM(nav_item_id PK, code, label, icon, href, sort_order, status) -- top-level sidebar entry; seeded to match the frontend
SUB_NAV_ITEM(sub_nav_item_id PK, nav_item_id FK→NAV_ITEM, code, label, icon, href, sort_order, status)
PERMISSION(permission_id PK, code, nav_item_id FK→NAV_ITEM [nullable], sub_nav_item_id FK→SUB_NAV_ITEM [nullable]) -- exactly one of the two FKs is set
ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION)
USER(..., role_id FK→ROLE [nullable]) -- added to the existing USER shadow (see C.7)
Note: USER_ROLE from the original placeholder sketch was dropped — a user has at most one role (USER.role_id), matching AuthHex's own User.RoleId being a single scalar FK, not a many-to-many.
C.9 Modeling notes (load-bearing)
- Item types are a dropdown, not a relationship.
ITEM_TYPE(Color, Size, Material) exists only to populate the frontend item-builder's dropdown viaGET /item-types. Nothing references it and it references nothing — there is no value table and no join toITEM. The builder cross-products the checked types into one standalone item per combination; the chosen values (Red, S, M) are encoded by the client into the generated SKU (BL-0002for one type,BL-100-0003for two) and the server only checks that SKU for uniqueness. The item list is the record of what was built. This is not a product-variation model: there is no parent-product entity and no variant hierarchy.- Accepted trade-off (a decision, not an oversight): the backend cannot answer "list all blue items", cannot filter or report by colour/size, and cannot validate that a SKU's segments correspond to real item types. Renaming an item type (
Color→Colour) does not touch existing SKUs, which keep their old segments — the two are permanently decoupled the moment an item is created. If value-level querying is ever needed, anITEM_TYPE_VALUEtable plus a link table can be added additively, but existing SKUs will not be back-fillable without parsing them by hand.
- Accepted trade-off (a decision, not an oversight): the backend cannot answer "list all blue items", cannot filter or report by colour/size, and cannot validate that a SKU's segments correspond to real item types. Renaming an item type (
- Sale price is a per-item scalar, not a variant/price table. Because each "variant" is its own
ITEMrow (above), the optional selling price lives directly onITEM.sale_price(nullable).NULLmeans "use stock value" — Sales values the item at its FIFO stock cost at sale time (FR-STK-04 /STOCK_LAYER); a value is a fixed selling price. It is Sales-only: it never participates in GRN, FIFO layering, or the stock ledger, so receipt/costing behaviour is identical whether the item is fixed-priced or not. The create-time "fixed price vs use stock value" choice is a frontend UX toggle — the contract is simply the nullable column, and the item builder requires a price on every generated variant when the user picks fixed pricing. - Two-level categories.
CATEGORYno longer self-nests;SUBCATEGORYis the single optional level below it. An item stores both FKs rather than pointing only at the deepest node, so the parent is never inferred or lost. A subcategory cannot be reparented (it would silently invalidate the category of every item referencing it) — deactivate and recreate instead. - Product config is a singleton, and only two of its flags are enforceable.
subcategories_enabled/brands_enabledgate item writes (CONFIG_DISABLED, 422).item_types_enabledis advisory only — since items carry no item-type reference, there is nothing on a write to reject; the frontend honours it by hiding the builder's type section. Reads are never gated, so existing data stays readable after a flag is switched off. - FIFO = two structures.
STOCK_LAYERanswers valuation ("what's on hand and at what cost");STOCK_LEDGERanswers history ("what moved, when, by whom"). Layers are keyed per item per warehouse. - Polymorphic source.
STOCK_LEDGER.source_doc_type/source_doc_id(andAUDIT_LOG,JOURNAL_ENTRY_STUB) reference the originating document without a hard FK per type — new transaction types (Sales, Manufacturing) write to the ledger without a schema change. - In-transit + cost-preserving transfer.
STOCK_TRANSFERholdssrc/destwarehouse; dispatch consumes source layers into in-transit, receive creates the destination layer at the inherited source cost. - FEFO ≠ FIFO. FIFO governs costing; FEFO governs physical picking of perishables via
BATCH.expiry_date. - External IdP + shadow user. Authentication is delegated to AuthHex (RS256, issuer
AuthHex/audienceAuthHexClient, static public key).USERis a local shadow of AuthHex identities:auth_user_id(GUID from the token'sUserIdclaim) is JIT-mapped to the localintuser_idthat everycreated_by/requested_by/AUDIT_LOG.user_id/STOCK_LEDGER.user_idFK references — no FK type change. A door policy admits only ERPUserType/Roleholders. - RBAC — sidebar visibility, not endpoint enforcement (2026-07-18).
Role/NavItem/SubNavItem/Permission/RolePermissionare now live tables backing Role CRUD (RolesController) and a permission-assignment UI. AuthHex remains the source of truth forRoleidentity (Guid PK, referenced by its JWTRoleId/RoleCodeclaims); ERPCore'sRoleis a local shadow synced on write —RolesControllercalls AuthHex's new/api/rolefunctions first, then mirrors the result into the local int-keyed row (auth_role_idmaps the two), exactly likeUSER/auth_user_id.GET /api/v1/auth/meresolves the caller'sRoleCodeclaim to its localRole, joinsRolePermission, and returns the permittedNavItem/SubNavItemcodes for the frontend to filter its sidebar by. This is deliberately UI-only: no endpoint in this API (including the new Role/User/Nav ones) gained an authorization check from this work — AR-01 in02-SECURITY.mdis unchanged, and per-endpoint RBAC remains future work (Part D there). - Reorder alerts are a query, not an entity — computed from
ITEM_REORDERvs available. Add a table only if alert history is required.
C.10 Entity → implementation mapping
- Entities →
Domain/Entities; enums (StockNature,TrackingMode,HoldStatus,Direction,*Status,CountType) →Domain/Enums. Note:ItemTypeinDomain/Entitiesis the master entity; the oldItemTypeenum is nowStockNature— there is no enum by that name. - EF configurations (
IEntityTypeConfiguration<T>, one per entity) →Infra/Persistence/Configurations. - FIFO logic →
Services/Stock/FifoCostingService(Part A.2). Ledger writes only via stock services inside the UoW transaction. RowVersion(concurrency) on mutable aggregates: Item, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, PurchaseOrder, GRN, transfers/adjustments/counts headers.
End of 10-BACKEND-PHASE1.md. API contracts: 11-BACKEND-PHASE1.md. Record work: Backend/PROGRESS.md.