Files
ERP-core/docs/10-BACKEND-PHASE1.md
2026-07-23 19:54:56 +05:30

389 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 in `11-BACKEND-PHASE1.md`. Frontend rules are in `20-FRONTEND.md`. Record work in `Backend/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 own `AuthController` (`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) and `11-BACKEND-PHASE1.md §2.0` for 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`; exposes `SaveChangesAsync()` 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 UPDATE` semantics (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/Common` holds shared shapes: `PagedResult<T>`, `PaginationMeta`, and the error/problem shapes.
- Entities in `Domain/Entities` never leave the service layer.
## A.4 Cross-cutting
- **Errors:** RFC 7807 `ProblemDetails` (framework default). Domain exceptions in `System/Errors` carry a stable `code`; a middleware maps them to `ProblemDetails`. Catalog in `11-BACKEND-PHASE1.md §7`.
- **Authentication:** ERPCore validates JWTs issued by the **external AuthHex IdP** — algorithm **RS256** (asymmetric RSA), issuer `AuthHex`, audience `AuthHexClient`. 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 ERP `UserTypeCode`/`RoleCode` claim (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 — via `IAuthHexClient` (`Infra/Auth/AuthHex/AuthHexClient.cs`, `AuthHex:BaseUrl` config) — and the only place that issues httpOnly `erp_at`/`erp_rt` session cookies (`Infra/Auth/AuthCookieWriter.cs`) plus the `XSRF-TOKEN` double-submit cookie checked by `ValidateCsrfAttribute` on mutating actions. The JWT bearer handler also accepts the `erp_at` cookie as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) when no `Authorization` header 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 custom **`UserId` (GUID)** claim. An `ICurrentUser` abstraction (`Infra/Auth`) resolves the acting user from a **local shadow user** — the GUID is mapped (JIT-provisioned) to a local `int` `users.user_id` that all FKs reference (see C.7). Services stamp mutations with it. **Never** trust a `createdBy` from the request body.
- **Concurrency:** mutable resources carry a `RowVersion` (`[Timestamp] byte[]`), surfaced as `ETag`; `PUT`/`PATCH` require `If-Match``412` on 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 an `AddApplication()` extension) after `AddDbContext`.
- **Auth wiring:** JWT bearer validation is built from AuthHex's **RSA public key** (config XML → `RsaSecurityKey`) with `ValidIssuer=AuthHex`, `ValidAudience=AuthHexClient`. A scoped **`IClaimsTransformation`** provisions/looks up the local shadow user (by `auth_user_id` = token `UserId` GUID) and injects the resolved local `int` id as a `ClaimTypes.NameIdentifier` (`nameid`) claim, so `ICurrentUser`/`AuditUserId` resolve 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`/`Role` holders, 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 base UOM per item and **conversion factors** (purchase→stock→base). | M |
| FR-MD-03 | Convert quantities between UOMs on every transaction; store base-UOM quantity in the ledger. | 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)
UOM_CONVERSION(conversion_id PK, item_id FK→ITEM, from_uom FK→UOM, to_uom FK→UOM, factor)
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,
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 via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. 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-0002` for one type, `BL-100-0003` for 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, an `ITEM_TYPE_VALUE` table plus a link table can be added additively, but existing SKUs will not be back-fillable without parsing them by hand.
- **Sale price is a per-item scalar, not a variant/price table.** Because each "variant" is its own `ITEM` row (above), the optional selling price lives directly on `ITEM.sale_price` (nullable). `NULL` means "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.** `CATEGORY` no longer self-nests; `SUBCATEGORY` is 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_enabled` gate item writes (`CONFIG_DISABLED`, 422). `item_types_enabled` is **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_LAYER` answers valuation ("what's on hand and at what cost"); `STOCK_LEDGER` answers history ("what moved, when, by whom"). Layers are keyed per item **per warehouse**.
- **Polymorphic source.** `STOCK_LEDGER.source_doc_type/source_doc_id` (and `AUDIT_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_TRANSFER` holds `src`/`dest` warehouse; 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`/audience `AuthHexClient`, static public key). `USER` is a **local shadow** of AuthHex identities: `auth_user_id` (GUID from the token's `UserId` claim) is JIT-mapped to the local `int` `user_id` that every `created_by`/`requested_by`/`AUDIT_LOG.user_id`/`STOCK_LEDGER.user_id` FK references — no FK type change. A door policy admits only ERP `UserType`/`Role` holders.
- **RBAC — sidebar visibility, not endpoint enforcement (2026-07-18).** `Role`/`NavItem`/`SubNavItem`/`Permission`/`RolePermission` are now live tables backing Role CRUD (`RolesController`) and a permission-assignment UI. AuthHex remains the source of truth for `Role` identity (Guid PK, referenced by its JWT `RoleId`/`RoleCode` claims); ERPCore's `Role` is a **local shadow synced on write**`RolesController` calls AuthHex's new `/api/role` functions first, then mirrors the result into the local int-keyed row (`auth_role_id` maps the two), exactly like `USER`/`auth_user_id`. `GET /api/v1/auth/me` resolves the caller's `RoleCode` claim to its local `Role`, joins `RolePermission`, and returns the permitted `NavItem`/`SubNavItem` codes 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 in `02-SECURITY.md` is unchanged, and per-endpoint RBAC remains future work (Part D there).
- **Reorder alerts are a query**, not an entity — computed from `ITEM_REORDER` vs 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:** `ItemType` in `Domain/Entities` is the master entity; the old `ItemType` **enum** is now `StockNature` — 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`.*