Files
ERP-core/Backend/PROGRESS.md
T
2026-07-17 14:27:51 +05:30

45 KiB
Raw Blame History

Backend — PROGRESS (Phase 1: Inventory & Supply Chain)

Legend: [ ] not started · [~] in progress · [x] done Spec: docs/10-BACKEND-PHASE1.md (model + rules) · docs/11-BACKEND-PHASE1.md (API) Convention: docs/01-DOC-GUIDE.md §6. Update this file in the same commit as the code. When ticking [x], append a short note + any deviation.

0. Bootstrap

  • Solution + Web API project (net10.0), packages restored (00-CORE §5.4)
  • Folder structure per 00-CORE §5.3
  • ErpDbContext + Npgsql wired; InitialCreate migration created and applied (2026-07-10, 8 master-data tables). /healthHealthy.
  • Serilog, JWT, Swagger, HealthChecks, ProblemDetails in Program.cs — JWT bearer now validates RS256 tokens from the external AuthHex IdP (issuer AuthHex / audience AuthHexClient / static RSA public key). v1 endpoints [Authorize]-gated via the ErpAccess door policy (§6); /health, /api/meta, Swagger stay anonymous.
  • IUnitOfWork + UnitOfWork (transaction boundary)
  • Generic repository base + interfaces
  • ICurrentUser (audit stamp from token identity claim nameid/sub) — with AuthHex the actor comes from the UserId GUID → local shadow user (nameid injected by the §6 provisioning step)
  • ProblemDetails middleware + domain exception → code mapping (System/Errors; full §7 catalog added to ErrorCodes)

1. Master Data

Code complete for all items below (2026-07-09). Live smoke test PASSED against Postgres (2026-07-10): create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category ?tree=true nesting (that endpoint was removed on 2026-07-16 — categories no longer nest; see the entry at the end of this section); pageSize=9999 clamped to 200; deactivate via PATCH status→204. Flipped [x] on 2026-07-14 — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + [Authorize] door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical.

  • Item: entity + config + enums (StockNature [ex-ItemType], TrackingMode; EntityStatus added) — xmin/RowVersion concurrency token (Npgsql), unique SKU
  • Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
  • UOM + UOM conversions (GET/POST /uoms, PUT /items/{id}/uom-conversions full-replace upsert)
  • Category — rebuilt 2026-07-16 as a two-level Category/SubCategory model (was a self-nesting tree); full CRUD + status + ETag, which it previously lacked entirely
  • Vendor (CRUD, ETag/If-Match, unique code, deactivate via PATCH /vendors/{id}/status)
  • Warehouse + Bin (/warehouses, nested /warehouses/{id}/bins, bin code unique per warehouse)
  • Item reorder settings (PUT /items/{id}/reorder full-replace upsert, warehouse-exists validation)
  • Brand master (FR-MD-09) — CRUD + status + ETag; Item.brandId nullable FK
  • Item Type master (FR-MD-10) — CRUD + status + ETag; unreferenced by design, feeds the builder dropdown only
  • SubCategory (FR-MD-04) — nested list/create under a category, PUT/PATCH status by id; Item.subCategoryId nullable FK, validated to belong to categoryId
  • Product Configuration (FR-MD-11) — singleton GET/PUT /product-config; CONFIG_DISABLED gating on item writes

2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2)

Makes real three concepts the frontend had been faking on mock data (Frontend/erp-system/lib/api/mock-data.ts), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8.

Deviations / decisions (all recorded in docs/10 §B.8.4 #1113):

  • ItemType enum → StockNature. The Stocked/NonStocked/Service enum was renamed to free the name ItemType for the new master entity. FR-MD-01 stays satisfied; the DB column was renamed in-place (RenameColumn, data preserved). Blast radius was 4 files — nothing in stock/GRN/costing branches on it.
  • CATEGORY.parent_id removed. Arbitrary nesting is gone, replaced by a dedicated SUBCATEGORY table (exactly two levels). Items now carry both FKs; previously the frontend collapsed them (effectiveCategoryId = subCategoryId ?? categoryId), losing the parent.
  • Item types are unlinked to items, deliberately. No value table, no join — values live only in the client-generated SKU (BL-100-0003) and are never parsed server-side. Accepted trade-off (no query-by-colour/size; renaming a type doesn't touch existing SKUs) written up in docs/10 Part C.9. This is not a product-variation model; none was requested.
  • itemTypesEnabled is advisory, not enforced. With no item-type reference on an item there is nothing on a write to reject; only subcategoriesEnabled/brandsEnabled produce CONFIG_DISABLED. Stated plainly in docs/11 §2.8 so it isn't mistaken for a backend guarantee.
  • PUT /product-config is door-policy-gated only — any ERP-admitted user can flip the flags. A CONFIG_MANAGE permission is reserved for when RBAC lands (open decision #13).
  • brandId is now a documented field, no longer the undocumented frontend-only extra it was.

Migration #2 (AddBrandsSubcategoriesItemTypesAndProductConfig) carries data, not just DDL. The scaffolded version dropped parent_id outright, which would have silently flattened every child category into a root and stranded items on the wrong one. Hand-added: backfill of child categories into subcategories, repoint of items onto the correct (category, subcategory) pair, delete of the migrated rows, and the config singleton insert. A recursive CTE maps categories at any depth to their root ancestor, since the old model allowed unlimited nesting but the new one is two levels — a grandchild becomes a subcategory of its root, not of its (now-nonexistent) parent category. Down() was likewise hand-written to restore the tree instead of dropping subcategories and losing it.

Also fixed while writing it: the ck_product_config_singleton check constraint was scaffolded as config_id = 1, but the column is created quoted-PascalCase ("ConfigId") — unquoted, Postgres folds it to a column that doesn't exist. And UpdateProductConfigRequest's flags are bool? on purpose: [Required] on a non-nullable bool is a no-op, so a body of {} would have bound all three to false and silently switched every feature off.

Schema/migration verified: dotnet build clean. Migration Up and Down exercised against a purpose-seeded 3-level tree (Hardware → Fasteners → Bolts, plus items on each level and a childless root) — 9/9 forward assertions and 7/7 rollback assertions passed, including the grandchild depth-collapse and StockNature data preservation; the fixture was then removed. DataSeeder seeds Color/Size + the config singleton idempotently (it needed restructuring — an early return in the reason-code path would otherwise have skipped the new seeds on every start after the first).

Live smoke test PASSED (2026-07-16), all 24 checks, against Postgres + a real AuthHex session. Auth note: a token is obtainable despite the loginUser blocker — POST /api/v1/auth/register succeeds and issues the erp_at session cookie directly, and the JWT handler's cookie fallback means that session authenticates every other controller. (loginUser still 500s "Invalid credentials" for that same freshly-registered user, by username or email, with or without userTypeId — the §6 blocker is real and reproduces, but it is not a barrier to testing.) Registration needs AuthHex-internal roleId/userTypeId GUIDs, supplied by the user; Admin = role 08de6a11-9e9f-4401-8a10-6859860b41ec / userType 00000000-0000-0000-0000-000000000004.

Covered: brand/category/subcategory/item-type create; case-insensitive duplicate name → 409 (brand, and subcategory scoped per-parent); subcategory under a missing category → 404; item create carrying all three new FKs with SKU BL-100-0003 → 201 and full round-trip on GET /items/{id}; new brandId/subCategoryId list filters; cross-FK guard → 422 ("Subcategory 3 belongs to category 7, not 8"); missing/inactive brand → 422; PUT /product-config {}400 (proving the bool? fix — an empty body no longer silently disables everything); subcategoriesEnabled:false + subCategoryId422 CONFIG_DISABLED, same item without it → 201, and pre-existing items with a subcategory still read back fine; brandsEnabled:false + brandId → 422; itemTypesEnabled:false correctly does NOT block item writes (advisory, as documented); ETag round-trip 200 / stale-but-well-formed → 412 CONCURRENCY_CONFLICT (brand + subcategory) / absent → 428; PATCH /status → 204 then inactive-brand reference → 422; and renaming an item type left existing SKUs untouched, confirming the intended decoupling. Audit stamp confirmed live: product_config.updatedBy resolved to a JIT-provisioned shadow user (SMOKE001) from the AuthHex UserId/NIC claims.

Test data was removed afterwards (masters back to empty, config flags restored to all-true with the audit stamp cleared). Two artifacts left behind on purpose: the AuthHex user smoketest_admin / NIC SMOKE001 in AuthHex's own MySQL store, and its ERPCore shadow user (users.UserId = 3) — referenced by nothing, kept so the session can be reused for future testing. Delete both if unwanted.

2. Procurement

Requisition/RFQ/PO implemented 2026-07-10. Live smoke test PASSED (docNo PR/RFQ/PO-2026-##### gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example 112100/20178/132278, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same [~] reason as §1: the §6 security gate (auth + audit) is not yet wired.

  • Requisition (+ lines) + submit (POST /requisitions, /{id}/submit, list, get)
  • RFQ + quotations + comparison (POST /rfqs, /{id}/quotations [one per vendor], GET /{id}/comparison matrix)
  • Purchase Order: create (auto-approve, approvalRequired flag), edit-while-open (If-Match), approve (no-op), cancel
  • Purchase Return (outbound movement, reason code) — POST /purchase-returns auto-posts an outbound FIFO consume via shared StockMutator; mandatory Return-context reason (REASON_CODE_REQUIRED→400, wrong context→422), references the GRN line for traceability, over-return→409 STOCK_NEGATIVE_BLOCKED. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)

Deviation (recorded): VendorQuotation is modelled as header + VendorQuotationLine (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar VENDOR_QUOTATION(unit_price, lead_days) with no item ref cannot represent it. Update the ER model doc to match. RFQ vendorIds are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. Consequence surfaced 2026-07-17: no UI can show "invited but not yet quoted" — the RFQ screens now report quotations received instead. Persisting the invite list would need a new table.

2026-07-17 — read endpoints added so the frontend could be connected

The frontend rewire (see Frontend/PROGRESS.md) needed reads that did not exist. stock-adjustments and purchase-returns had no GET at all — a UI could not re-display a record it had just created.

  • New: GET /grns, GET /rfqs, GET /stock-transfers, GET /stock-counts, GET /stock-adjustments + /{id}, GET /purchase-returns + /{id}, GET /stock/on-hand/list. All follow ItemService.ListAsync (ILike on q, filters, PagedResponse<T>.Create) with matching *SummaryDtos carrying a lineCount.
  • GET /stock/on-hand/list is deliberately set-based — four grouped queries regardless of page size — rather than calling GetOnHandAsync per row (N+1). It replaces a client-side loop the mock used to do.
  • GET /stock/ledger gained sourceDocType/sourceDocId. The ledger's document reference is polymorphic with no FK to follow, so this is the only way to ask "what did document X post?". Needed by the wastage report to cost its lines; also useful for any document's movement history.
  • ItemDetailDto gained conversions (+ .Include(i => i.UomConversions)): they could only be written (PUT /items/{id}/uom-conversions returns them; nothing read them back), so the item detail screen could never show current state before editing. Closes a deviation Frontend/PROGRESS.md had flagged.
  • DTOs gained fields the entities already had and the UI needed: createdBy/createdAt on transfers + counts, createdAt on purchase returns, lineCount + a status filter on requisitions. Cheaper and more honest than deleting working columns from the screens.
  • Bug fixed — POST /auth/logout made userId optional. AuthHex returns user.userId: null on login, so a browser could never supply the id the endpoint required; the call was skipped and the session cookies survived, making logout cosmetic. The controller now resolves the id from the token's UserId claim and always clears the cookies, even if the upstream revoke fails. Found by driving the real logout in a browser.
  • Verified: dotnet build clean; every new endpoint returns a correct PagedResponse against a live cookie session; conversions round-trips; CONFIG_DISABLED (422), CONCURRENCY_CONFLICT (412) and the cross-FK 422 ("Subcategory 5 belongs to category 10, not 11") all confirmed through the browser. Test data removed afterwards.
  • Not done — serial numbers (FR-GRN-04, priority M): CreateGrnLineInput carries batch but has no serial field, so serials cannot be captured on receipt as the requirement mandates. The frontend does not collect them rather than silently discarding them. SERIAL/StockLayer.serial_id already exist in the model, so this is a service+DTO gap, not a schema one. Recorded in docs/11 §4.

3. Goods Receipt

Implemented + smoke test PASSED 2026-07-13 (see §4 note for the shared stock verification). Same [~] reason as §1/§2: the §6 auth+audit gate.

  • GRN create (against PO / direct), over-receipt tolerance — unitCost PO-derived server-side (client 999 verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires vendorId + entered cost (AR-04); over-receipt → 422 OVER_RECEIPT_TOLERANCE (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred.
  • GRN confirm → FIFO layer + ledger + PO qtyReceived (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). Idempotent re-confirm verified (no double-post). Note: Idempotency-Key accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
  • Inspection hold release / reject — Release fully verified (OnHold excluded from available, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).

4. Stock Core

Implemented + live smoke test PASSED 2026-07-13: receive→confirm creates FIFO layers + inbound ledger (qtyBase/unitCost/value/runningBalance correct), on-hand/valuation/ledger queries correct, OnHold excluded from available, UOM→base conversion applied. Same [~] gate (§6 auth+audit).

  • StockLayer + StockLedger entities/config — ledger append-only at the app level (never updated/deleted); DB-role UPDATE/DELETE revoke is deferred hardening (02-SECURITY B.3). Layers keyed per item per warehouse, base-UOM qty + unit cost; ledger polymorphic source (sourceDocType/sourceDocId), time-series indexes.
  • FifoCostingService — inbound layer + ledger posting + valuation and oldest-first consume with row lock (SELECT … FOR UPDATE, on-hold/expired exclusion, negative-stock block) all implemented + verified 2026-07-13 via §5. Blended cost on multi-layer consume verified (700@10 + 100@12 → 10.25).
  • Stock enquiry (onHand / available / onHold / inTransit) — onHand/available/onHold and inTransit now live + verified (inTransit = outstanding InTransit-transfer qty out of this warehouse). reserved stays a 0 stub until Sales.
  • Ledger query · Valuation query — GET /stock/ledger (item/warehouse/from/to + paging), GET /stock/valuation (open layers, totals, FIFO); both verified.

5. Stock Transactions

All four §5 features implemented + live smoke test PASSED 2026-07-13 (Adjustment, Transfer, Count, Reorder alerts); Purchase Return (§3.4) also done this pass. Same [~] gate (§6 auth+audit).

  • Transfer: create → dispatch (consume source FIFO row-locked → In-Transit) → receive (dest layer, cost-preserving) — verified: dispatch reduces source onHand + reports inTransit; receive creates dest layer at inherited cost (300 @12 → dest value 3600); destWarehouseId != srcWarehouseId→422; dispatch short→409 STOCK_NEGATIVE_BLOCKED. Partial receive supported (QtyReceived).
  • Adjustment (auto-post, mandatory reason code) — highest-risk feature (02-SECURITY C.5): REASON_CODE_REQUIRED→400, non-Adjustment reason→422, decrease FIFO-consumes (blended cost, negative→409), increase creates a layer at last cost. All verified.
  • Count (cycle/full → enter counts → variance → post) — create snapshots systemQty (immutable), enter sets counted+variance→Counted, post emits a variance StockAdjustment via shared StockMutator + closes the count. Verified: variance 15 (post→on-hand 485) and +10 increase; re-post→409.
  • Reorder alerts (query) + suggest requisition — GET /stock/reorder-alerts (available ≤ ROP, computed on read) + POST …/{itemId}/requisition (draft PR at suggested qty). Verified.

6. Cross-cutting

Status: COMPLETE. Audit trail, doc numbering, reason codes, JournalEntryStub, negative-stock block, and now authentication (external AuthHex IdP integration) are all done + verified. The §6 security gate (NFR-03 auth + AR-01 audit) is met — §1–§5 flipped [~][x] (2026-07-14). FEFO pick-ordering is the only intentional deferral.

  • Audit log on every mutation (who/when/old→new) — AuditLog (jsonb changeSet), written by an ErpDbContext.SaveChanges override (AuditScribe): Create captures the field set, Update captures only changed fields as {old,new}, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from ICurrentUser (system=1 until auth). Read via GET /audit-logs. Verified (Item create+update old→new; StockAdjustment create). This is the AR-01 compensating control (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred.
  • Document numbering sequences (per type, per year) — NumberSequence + NumberSequenceService (atomic INSERT … ON CONFLICT … RETURNING inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO.
  • Auth: external AuthHex IdP integration (2026-07-14) — ERPCore is a resource server. JwtAuthExtensions validates RS256 against AuthHex's RSA public key (config Auth:RsaPublicKeyXmlRsaSecurityKey; MapInboundClaims=false), issuer AuthHex, audience AuthHexClient (no JWKS → static key). [Authorize(ErpAccess)] on ApiControllerBase gates every v1 endpoint; the ErpAccess policy RequireAuthenticatedUser + optional RequireClaim(UserTypeCode/RoleCode) from Auth:RequiredUserTypeCode/RequiredRoleCode (empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). Shadow-user JIT provisioning: ShadowUserClaimsTransformation (IClaimsTransformation) maps the token's UserId GUID → a local users row (auth_user_id unique; Username/DisplayName = NIC), idempotent, and injects the local int id as nameid so ICurrentUser.AuditUserId resolves the real actor. Migration AddAuthUserId. Verified: no token→401; /health,/api/meta,Swagger anonymous; valid token→200; shadow user provisioned (User 2, Username=NIC, AuthUserId=GUID); item Create audited as the shadow user (id 2, not system); re-request reuses the same user; door gate → 403 on UserType mismatch, 200 on match.
  • Auth proxy: AuthController fronting AuthHex (2026-07-16) — the frontend no longer calls AuthHex directly; Controllers/AuthController.cs + Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService} proxy all 24 AuthHex functions (register/login/OTP-login/refresh/sessions/status/lock/change-password/verify-password/logout/update/2FA×5/recovery×4/alt×3) via Infra/Auth/AuthHex/{IAuthHexClient,AuthHexClient} (AuthHex:BaseUrl config). Sessions delivered as httpOnly Secure erp_at/erp_rt cookies + XSRF-TOKEN double-submit cookie (Infra/Auth/AuthCookieWriter.cs, 02-SECURITY §B.2); ValidateCsrfAttribute guards every mutating action; the JWT bearer handler now also accepts erp_at as a fallback (JwtAuthExtensions's OnMessageReceived) so every other v1 controller keeps working unchanged. See docs/11-BACKEND-PHASE1.md §2.0 for the full route table and docs/02-SECURITY.md AR-07/AR-08 for the two carried-over exposures (anonymous getUserDetails/LogoutUser, no rate limiting yet). Not done this pass: CORS (needed once a browser frontend calls these endpoints cross-origin), rate limiting, and the frontend wiring itself (lib/api/auth.ts + login/OTP/reset pages) — all deliberately deferred follow-ups.
  • JournalEntryStub emitted per stock movement (data only) — JournalEntryStub written in FifoCostingService.PostLedgerAsync for every ledger entry (In → Dr Inventory 1300 / Cr Clearing 2100; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via GET /journal-entries. Verified (GRN In 700, ADJ Out 70).
  • Negative-stock policy enforcement (default block) — enforced in FifoCostingService.ConsumeAsync409 STOCK_NEGATIVE_BLOCKED (verified). Per-item override still a config stub.
  • [~] FEFO picking for perishables; block expired / on-hold issue — issue-block done + verified (ONHOLD_NOT_ISSUABLE, EXPIRED_BATCH_BLOCKED; on-hold/expired layers excluded from consume). FEFO pick ordering (oldest-expiry first) not yet built.
  • Reason codes (FR-X-04) — ReasonCode entity + GET/POST /reason-codes; standard set (docs/10 §B.8.3) seeded idempotently at startup (DataSeeder). Verified.

Deferred (Phase 2+ — do NOT build now, hooks only)

  • Vendor invoice + three-way match
  • Reservation/allocation fulfilment
  • RBAC policy enforcement + approval workflow activation

Done

2026-07-09 — Bootstrap verified + Master Data (§1) implemented

  • Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages, Program.cs wiring, UoW, generic repo, ICurrentUser, ProblemDetails handler). Added enum-as-string JSON (JsonStringEnumConverter) and registered the 5 master-data services.
  • Domain: 3 enums (ItemType, TrackingMode, EntityStatus) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with one IEntityTypeConfiguration each; FKs Restrict (masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision, xmin concurrency token on Item/Vendor.
  • API: 5 controllers, lowercase routes matching docs/11 §2 exactly (verified via generated swagger.json). ETag/If-Match (428 if missing, 412 on mismatch), narrow request DTOs (no over-posting), PagedResponse<T> list envelope (§1.4), PageQuery with pageSize clamp ≤200 (B.6).
  • Migration InitialCreate generated (xmin correctly produces no DDL — uses the PG system column).
  • Verified: dotnet build clean (0 warn/0 err); app boots (Now listening… Application started); /api/meta 200; swagger.json 200 with all 13 master-data paths; DI resolves controller→service→repo→DbContext (a DB-backed call reaches Npgsql, failing only on creds).
  • Blocked / follow-ups: (1) auth enforcement + audit trail — §6 (the remaining security gate for [x]); (2) no DELETE master endpoints — MASTER_IN_USE code reserved until transaction tables exist (deactivate-only per FR-MD-08); (3) minor: bad-enum bind error leaks the CLR type name in detail (02-SECURITY B.5) — fine in Dev, tidy before prod.

2026-07-10 — Migration applied + live smoke test PASSED

  • dotnet ef database update applied InitialCreate to local Postgres; /healthHealthy.
  • End-to-end curl smoke across all 5 controllers — all green: warehouse/bin create+list; uom create; category + child + ?tree=true nesting; vendor create + PUT (If-Match 200 / stale 412 / missing 428); item create (201, referencing category/uom/vendor) + GET (ETag header) + list/filter q + pageSize=9999→clamped 200; reorder PUT; uom-conversions PUT; full item PUT with fresh ETag→200; PATCH status Inactive→204; duplicate SKU→400 SKU_DUPLICATE; bad reference→422; missing required→400 ValidationProblemDetails; bad enum→400. Concurrency token (xmin) confirmed incrementing per mutation.
  • Note: local dev DB now holds smoke-test rows (warehouse/bin/uom×2/category×2/vendor/item, item left Inactive). Reset any time with dotnet ef database drop -f && dotnet ef database update.

2026-07-10 — Procurement (§2, minus returns) + cross-cutting foundations

  • Cross-cutting: User entity (+ seeded system user via HasData), ICurrentUser.AuditUserId (numeric actor, system fallback), NumberSequence + NumberSequenceService (atomic per-type/per-year doc numbers issued inside the UoW txn).
  • Procurement: enums (RequisitionStatus, RfqStatus, PurchaseOrderStatus); 8 entities (Requisition/Line, Rfq/Line, VendorQuotation/Line, PurchaseOrder/Line) + configs; DTOs; 3 services; 3 controllers (/requisitions, /rfqs, /purchase-orders). PO carries the xmin ETag token; totals computed server-side; create/edit wrapped in IUnitOfWork.ExecuteInTransactionAsync so the reserved doc number rolls back with the doc.
  • Migration AddProcurement generated + applied (10 tables incl. users/number_sequences; system-user seed; PO xmin emits no DDL).
  • Verified: build clean; app boots; full procurement smoke green (see §2 note) — requisition→submit, RFQ→quotation→comparison, PO create/get/edit/approve/cancel, numbering increment, error paths 409/412/422.
  • Deferred/next: Purchase Return (needs GRN+stock), then §3 GRN, §4 Stock Core (FIFO/ledger), §5 stock transactions. Auth/audit (§6) still the gate for flipping §1/§2 to [x].
  • Housekeeping: an empty user-created migration 20260709124415_initial sits between InitialCreate and AddProcurement (applied, harmless no-op; emits a cosmetic CS8981 lowercase-name warning).

2026-07-13 — Stock Core (§4) + Goods Receipt (§3, minus purchase return)

  • Entities: Batch, Serial, StockLayer (FIFO), StockLedger (append-only), Grn/GrnLine + configs; enums Direction, HoldStatus, GrnStatus. DbSets + migration AddStockAndGrn (6 tables) applied.
  • Services: FifoCostingService (Services/Stock — inbound layer, ledger post, on-hand, valuation), StockService (enquiry/ledger/valuation), GrnService (create with PO-derived cost + over-receipt + batch resolve + UOM→base; confirm in ExecuteInTransactionAsync; release/reject). Controllers /stock, /grns.
  • Verified against Postgres: PO→GRN receive (client cost ignored, PO price used) → confirm → 2 FIFO layers + 2 ledger rows (running balance) → PO FullyReceived; on-hand 5000 / valuation 62500 / ledger In; idempotent re-confirm (on-hand stayed 5000); over-receipt 422 at the open-qty boundary, exact-fill 201; batch receive OnHold → excluded from availablerelease → available; UOM conversion 10×Box-12 → 120 base units @10 (value 1200).
  • Deferred (next): §5 stock transactions — Transfer (dispatch/receive, in-transit, cost-preserving), Adjustment (auto-post + reason code), Count, Reorder alerts — which is where the FIFO oldest-first consume + row lock lands; then Purchase Return (§3.4) and GRN reject→return linkage. ReasonCode (§6) needed for adjustments/returns. Auth/audit (§6) remains the [x] gate.

2026-07-13 (2) — Stock Transactions (§5: Adjustment, Transfer, Reorder) + FIFO consume + Reason codes

  • FIFO consume engine in FifoCostingService.ConsumeAsync: oldest-first, row-locked via SELECT … FOR UPDATE (raw SQL, no LINQ composed on top so the lock reaches PG; {batchId}::bigint cast avoids a 42P18 null-param error), excludes on-hold + expired layers, throws STOCK_NEGATIVE_BLOCKED/ONHOLD_NOT_ISSUABLE/EXPIRED_BATCH_BLOCKED, returns consumed segments for cost-preserving moves.
  • Entities: ReasonCode, StockAdjustment/Line, StockTransfer/Line (+ UnitCost/QtyReceived extension on the transfer line for cost-preservation) + configs; enums TransferStatus, AdjustmentStatus, ReasonContext. Migration AddStockTransactions (5 tables) applied.
  • Services/controllers: ReasonCodeService (/reason-codes, startup seed via DataSeeder), AdjustmentService (/stock-adjustments), TransferService (/stock-transfers create/dispatch/receive), ReorderService (/stock/reorder-alerts + suggest-requisition). StockService.GetOnHandAsync now computes real inTransit.
  • Verified against Postgres: adjustment decrease FIFO-consume with blended cost 10.25 across two layers, negative→409, REASON_CODE_REQUIRED→400, wrong-context reason→422, increase-at-last-cost; transfer create→dispatch (source onHand↓, inTransit↑, consumedLayers)→receive (dest layer cost-preserved @12, value 3600), dest==src→422, dispatch-short→409; reorder alerts list + draft-PR suggestion; reason codes seeded (5 Adjustment + 4 Return).
  • Deferred (next): §5 Count (create snapshot → enter counts → post variance via the Adjustment engine), Purchase Return (§3.4, + GRN reject→return linkage), FEFO pick ordering, JournalEntryStub. Auth/audit (§6) remains the [x] gate.

2026-07-13 (3) — Count (§5.6) + Purchase Return (§3.4) + shared StockMutator

  • StockMutator (Services/Stock): shared signed-delta poster (negative → FIFO consume; positive → layer at last cost) + ledger, run inside the caller's txn. Adjustment/Count-post/Return all delegate to it → one code path for stock-affecting postings.
  • Refactored AdjustmentService onto StockMutator and added an intermediate SaveChanges so the header id is flushed before ledger posting — fixes a latent bug where new-in-txn documents wrote sourceDocId=0 (verified: ADJ ledger now sourceDocId=5). Also fixed ledgerRefs:[0] by mapping ledger ids after commit (Count + Return).
  • Entities: StockCount/Line (+ CountType, CountStatus), PurchaseReturn/Line (+ ReturnStatus) + configs. Migration AddCountsAndReturns (4 tables) applied.
  • Services/controllers: CountService (/stock-counts create/enter/post), PurchaseReturnService (/purchase-returns).
  • Verified against Postgres: count snapshot 500 → counted 485 → post variance 15 (on-hand→485, adjustmentId+ledgerRefs), positive variance +10, re-post→409; return 100 outbound (on-hand→385), REASON_CODE_REQUIRED→400, non-Return reason→422, over-return→409 STOCK_NEGATIVE_BLOCKED.
  • §5 is now complete. Remaining Phase-1 backend: §6 (auth/login + [Authorize], audit trail, JournalEntryStub, negative-stock per-item override, FEFO pick ordering). Auth/audit is still the gate for flipping §1–§5 [~][x].

2026-07-13 (4) — §6 audit trail + JournalEntryStub (auth deferred by request)

  • Audit trail (FR-X-02): AuditLog (jsonb changeSet) written by an ErpDbContext.SaveChanges/Async override via AuditScribe — captures before save (accurate old→new), writes rows after inserts get their keys. Create = field set, Update = only changed fields {old,new}, Delete = prior row; excludes PK/RowVersion and the ledger/layer/sequence/journal/self tables. Actor from ICurrentUser (system=1). ErpDbContext now takes ICurrentUser (design-time migration still works via DI).
  • JournalEntryStub (FR-STK-13): emitted for every ledger entry in FifoCostingService.PostLedgerAsync (In → Dr 1300/Cr 2100; Out reverses; amount = value). Placeholder GL accounts.
  • Read endpoints (auditor role, beyond documented §11): GET /audit-logs (entityType/entityId/userId/from/to), GET /journal-entries (sourceDocType/sourceDocId). AuditService. Migration AddAuditAndJournal (2 tables, jsonb) applied.
  • Verified against Postgres: item Create logged full field set (userId 1); item Update logged only Name + UpdatedAt as {old,new}; GRN confirm → journal In Dr1300/Cr2100 amount 700; adjustment decrease → journal Out Dr2100/Cr1300 amount 70; StockAdjustment Create audited.
  • Only auth remains for Phase 1. Everything else in §6 is done. Auth (POST /auth/login + global [Authorize]superseded 2026-07-14, now external AuthHex IdP integration; see the next entry) is intentionally deferred per request; wiring it is what flips §1–§5 [~][x]. FEFO pick-ordering left as a documented deferral (would conflict with FIFO-costing integrity without a physical/cost layer split); negative-stock stays the resolved global block (open-decision #2).

2026-07-14 — Auth architecture change: external AuthHex IdP (docs-only pass)

  • Plan changed: auth is no longer a local POST /auth/login inside ERPCore. A separate AuthHex IdP (runs on :5011, source at c:\Users\WAS\Documents\Developments\ERP_Auth_Service\) owns login/registration/recovery; ERPCore becomes a resource server that only validates AuthHex tokens. Updated docs/10-BACKEND-PHASE1.md (header, A.4 auth/audit-actor, A.5 DI, B.2.3, FR-X-01, NFR-03, C.7 USER, C.9, B.8.4 decision #10) and this file. No code changed this pass.
  • Decisions (confirmed): (1) identity = shadow-user JIT provisioning — add auth_user_id GUID (unique) to users, keep all int FKs; (2) authorization = door-gate to an ERP UserType/Role, per-endpoint RBAC still deferred; (3) scope = docs only now, code integration is a follow-up.
  • Confirmed AuthHex facts: RS256 (RSA 2048; ERPCore needs the static public key — no JWKS), issuer AuthHex, audience AuthHexClient, lifetime 1000 min prod / 60 min dev; claims UserId(GUID)/UserTypeCode/RoleCode/NIC/jti/iat (no sub/nameid); BCrypt password hashing; login POST /api/loginUser {identifier,password}.
  • Open blockers (resolve before the code phase): exact ERP UserTypeCode/RoleCode for the door gate (must exist in AuthHex); RSA public-key distribution + rotation process (no JWKS); shadow-user Username/DisplayName source (token has no name); secrets hygiene in AuthHex config (private key/SMTP/DB in plaintext); docs/11 §2.0 still documents /auth/login (now AuthHex-owned) — recommend a follow-up annotation.

2026-07-14 (2) — Auth code integration: AuthHex resource server (§6 COMPLETE → §1–§5 flipped [x])

  • RS256 validation: JwtAuthExtensions rewritten — RsaSecurityKey from Auth:RsaPublicKeyXml (AuthHex public key), ValidIssuer=AuthHex, ValidAudience=AuthHexClient, ValidAlgorithms=[RS256], MapInboundClaims=false (keeps UserId/UserTypeCode/RoleCode verbatim). appsettings.json JwtAuth (public key + issuer/audience + RequiredUserTypeCode/RequiredRoleCode); removed the HS256 dev signing key.
  • Door policy ErpAccess: RequireAuthenticatedUser + optional RequireClaim(UserTypeCode/RoleCode) when configured (AuthHex is ERP-dedicated → empty default = any valid token). [Authorize(ErpAccess)] on ApiControllerBase; MetaController/health/Swagger stay anonymous.
  • Shadow-user provisioning: ShadowUserClaimsTransformation (IClaimsTransformation, scoped) maps token UserId GUID → local users row (auth_user_id unique, Username/DisplayName=NIC), idempotent w/ race-safe re-read, injects local int id as nameid. User.AuthUserId (Guid?) + AuthHexClaims consts + migration AddAuthUserId.
  • Verified (minted AuthHex-shaped RS256 token, signed with AuthHex's real private key): no token→401; /health,/api/meta,Swagger→200 anon; valid token→200; POST item→201 audited as shadow user id 2 (Username=NIC, AuthUserId=GUID), not system; repeat request reuses user (1 provision); door gate RequiredUserTypeCode=WAREHOUSE → ERP-type token 403, WAREHOUSE-type token 200. Build clean; migration applied.
  • §6 COMPLETE. Only intentional deferral left in Phase 1: FEFO pick-ordering (§6, [~]). Follow-ups: set the real ERP Auth:RequiredUserTypeCode/RoleCode for production; secure the RSA key rotation process.

2026-07-16 — Auth proxy: AuthController fronting AuthHex (frontend no longer calls AuthHex directly)

  • Architecture reversal: the 2026-07-14 "resource-server-only, no login proxy" decision (docs/10, docs/11 §2.0) is reversed — the frontend was found to have zero existing AuthHex integration (login/OTP/reset screens were UI-only mocks with no network calls), so this was greenfield backend work, not a migration. docs/10-BACKEND-PHASE1.md (header, A.4, NFR-03) and docs/11-BACKEND-PHASE1.md §2.0 updated in place; docs/02-SECURITY.md gained AR-07/AR-08 and ticked 3 of 5 B.2 boxes.
  • Infra/Auth/AuthHex/IAuthHexClient/AuthHexClient (typed HttpClient, AuthHex:BaseUrl config = http://localhost:5011 dev), one C# method per AuthHex functionName, hides the {functionName,payload,reference}/{statusCode,success,message,data} dispatcher envelope entirely; upstream failures → DomainException (AUTH_UPSTREAM_ERROR/AUTH_SERVICE_UNAVAILABLE).
  • Dtos/Auth/* — REST-shaped request/response DTOs per function (not a functionName-dispatcher passthrough), matching ERPCore's existing DTO-at-boundary convention. Session-issuing responses (AuthSessionResponse, OtpLoginVerifiedResponse) deliberately omit tokens.
  • Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService} — orchestrate IAuthHexClient calls; AuthSessionResult/OtpAuthSessionResult (Services/Auth/AuthSessionResult.cs) carry tokens from service → controller only, never serialized.
  • Controllers/AuthController.csapi/v1/auth/*, 24 actions (see docs/11 §2.0 table); inherits ControllerBase directly (not ApiControllerBase) since most actions need [AllowAnonymous] and its ETag/If-Match handling doesn't apply here.
  • Cookie/CSRF (Infra/Auth/AuthCookieWriter.cs, ValidateCsrfAttribute.cs, JwtAuthExtensions.cs): erp_at (Path /), erp_rt (Path /api/v1/auth/refresh-token, scoped so it's only sent to the refresh call), XSRF-TOKEN (non-httpOnly) — all HttpOnly(except CSRF)/Secure/SameSite=Strict. ValidateCsrfAttribute double-submit-checks X-XSRF-TOKEN against the cookie on every mutating action, exempting Bearer-header callers. JwtAuthExtensions's OnMessageReceived falls back to the erp_at cookie when no Authorization header is present — every existing v1 controller keeps working unchanged under either auth mode.
  • Verified: dotnet build clean (0 warn/0 err) after two passes — first pass hit CS0051 (a public interface/constructor can't expose an internal parameter type) on IAuthHexClient and its supporting AuthHex* wire types, fixed by making them public; second pass caught a cookie-path bug (erp_rt's Path was written as /api/auth/refresh-token, not matching the actual /api/v1/auth/refresh-token route — the browser would never have sent the cookie back on refresh) before it shipped.
  • Not done this pass (tracked as follow-ups, not silently skipped): CORS (needed once a browser frontend calls cross-origin — docs/02-SECURITY.md §B.2 left unticked), rate limiting on the anonymous endpoints (docs/02-SECURITY.md AR-08), and the frontend wiring itself (lib/api/auth.ts + wiring app/login/**'s mock pages to these endpoints) — deliberately out of scope per user decision.
  • Live-verified against the running AuthHex instance (:5011) and ERPCore (:5224, dev): register200 with Set-Cookie: erp_at(httpOnly/Secure/Strict/maxAge=3600) + erp_rt(httpOnly/Secure/Strict/Path=/api/v1/auth/refresh-token/30d) + XSRF-TOKEN(Secure/Strict, JS-readable), body carries user+expiresIn only, no tokens; the erp_at cookie alone (zero Authorization header) authenticated GET /api/v1/items — confirms the OnMessageReceived cookie fallback works for every existing v1 controller unchanged; GET /api/v1/auth/sessions (protected, cookie-authenticated) → 200; mutating POST /api/v1/auth/change-password without X-XSRF-TOKEN403 CSRF_TOKEN_MISMATCH, with the matching header → 204 + all three cookies cleared, exactly as designed.
  • Found + fixed a real bug during live testing: AuthHexClient trusted the envelope's success flag alone; AuthHex was observed returning HTTP 500 with "success": true, "data": null on a business failure (invalid-credentials login), which slipped past the !envelope.Success check and null-derefed inside AuthUserService.ToSessionResult (NullReferenceException → bare unhandled 500, no code). Fixed AuthHexClient.CallAsync to also fail on !httpResponse.IsSuccessStatusCode regardless of envelope.Success, plus added result is null guards in ToSessionResult/ToOtpSessionResult/AuthAltService.VerifyOtpAsync as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean 500 AUTH_UPSTREAM_ERROR ProblemDetails instead of crashing.
  • RESOLVED 2026-07-17 — login works. The AuthHex fix below was applied (ERP_Auth_Service, uncommenting the PasswordHash assignment) and verified: POST /api/v1/auth/login now returns 200 + Set-Cookie: erp_at for a freshly-registered user, where it previously returned 500 "Invalid credentials". This unblocked the §1–§5 live verification that had been pending for two sessions. Users registered before the fix have a null hash and can never log in — they must be re-registered (the session's smoketest_admin among them).
  • Historical: loginUser used to fail with {"statusCode":500,"success":true,"message":"Invalid credentials","data":null} even immediately after a fresh registration with the exact same credentials, confirmed by calling AuthHex's /api/user directly with the identical payload (bypassing ERPCore entirely) — a bug in AuthHex's own loginUser/password-verification path, not in this proxy.
    • ROOT CAUSE (2026-07-16), in ERP_Auth_Service — two independent bugs, both one-liners. Bug 1 fixed 2026-07-17; bug 2 left alone (out of scope, and email login is what the UI uses). Re-confirmed the failure against a user registered this session (smoketest_admin/SMOKE001), by username and email, with and without userTypeId.
      1. The password was never stored — FIXED 2026-07-17. Services/UserManager/UserManagerService.cs:97 computes var PasswordHash = PasswordHasher.Hash(...), but the assignment in the new User { … } initializer at line 116 was commented out (//PasswordHash = PasswordHash). Every registered user landed in MySQL with a null PasswordHash, so loginUser's if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...)) (line 230) always threw "Invalid credentials". Uncommenting that line fixed login outright — verified end-to-end. Pre-fix users are unrecoverable (their hashes were never written) and need re-registration or a password reset (ChangeUserPassword/UpdateUser do persist the hash correctly, and UpdateUser even handles the null-hash case at line 836).
      2. Username is not a valid login identifier. Repos/UserManageRepository.cs:46 GetUserByIdentifierAndType matches only Email/MobileNumber/Nicnot UserName — and ignores its userTypeId argument entirely (that filtering sits commented out at lines 5665, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, identifier: "<username>" will not resolve a user; only email/mobile/NIC will.
    • Workaround meanwhile: POST /api/v1/auth/register issues a working erp_at session cookie directly, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed.