- Updated purchase order descriptions for clarity on draft and submission processes. - Implemented submit and delete functionalities for draft purchase orders, allowing users to manage their orders more effectively. - Added discount and VAT fields to GRN lines, enabling better cost tracking and reporting. - Enhanced validation for GRN lines to ensure discount and VAT percentages are within acceptable ranges. - Updated API to support new functionalities, including submitting and deleting purchase orders. - Improved UI components for better user experience in managing purchase orders and GRNs. - Documented changes in security and backend phase documentation to reflect new processes and requirements.
48 KiB
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;InitialCreatemigration created and applied (2026-07-10, 8 master-data tables)./health→Healthy.- Serilog, JWT, Swagger, HealthChecks, ProblemDetails in
Program.cs— JWT bearer now validates RS256 tokens from the external AuthHex IdP (issuerAuthHex/ audienceAuthHexClient/ static RSA public key). v1 endpoints[Authorize]-gated via theErpAccessdoor policy (§6);/health,/api/meta, Swagger stay anonymous. IUnitOfWork+UnitOfWork(transaction boundary)- Generic repository base + interfaces
ICurrentUser(audit stamp from token identity claimnameid/sub) — with AuthHex the actor comes from theUserIdGUID → local shadow user (nameidinjected by the §6 provisioning step)- ProblemDetails middleware + domain exception →
codemapping (System/Errors; full §7 catalog added toErrorCodes)
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=truenesting (that endpoint was removed on 2026-07-16 — categories no longer nest; see the entry at the end of this section);pageSize=9999clamped 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-conversionsfull-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}/reorderfull-replace upsert, warehouse-exists validation) - Brand master (FR-MD-09) — CRUD + status + ETag;
Item.brandIdnullable 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 statusby id;Item.subCategoryIdnullable FK, validated to belong tocategoryId - Product Configuration (FR-MD-11) — singleton
GET/PUT /product-config;CONFIG_DISABLEDgating 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 #11–13):
ItemTypeenum →StockNature. The Stocked/NonStocked/Service enum was renamed to free the nameItemTypefor 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_idremoved. Arbitrary nesting is gone, replaced by a dedicatedSUBCATEGORYtable (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.itemTypesEnabledis advisory, not enforced. With no item-type reference on an item there is nothing on a write to reject; onlysubcategoriesEnabled/brandsEnabledproduceCONFIG_DISABLED. Stated plainly in docs/11 §2.8 so it isn't mistaken for a backend guarantee.PUT /product-configis door-policy-gated only — any ERP-admitted user can flip the flags. ACONFIG_MANAGEpermission is reserved for when RBAC lands (open decision #13).brandIdis now a documented field, no longer the undocumented frontend-only extra it was.Migration #2 (
AddBrandsSubcategoriesItemTypesAndProductConfig) carries data, not just DDL. The scaffolded version droppedparent_idoutright, which would have silently flattened every child category into a root and stranded items on the wrong one. Hand-added: backfill of child categories intosubcategories, 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 droppingsubcategoriesand losing it.Also fixed while writing it: the
ck_product_config_singletoncheck constraint was scaffolded asconfig_id = 1, but the column is created quoted-PascalCase ("ConfigId") — unquoted, Postgres folds it to a column that doesn't exist. AndUpdateProductConfigRequest's flags arebool?on purpose:[Required]on a non-nullableboolis a no-op, so a body of{}would have bound all three tofalseand silently switched every feature off.Schema/migration verified:
dotnet buildclean. MigrationUpandDownexercised 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 andStockNaturedata preservation; the fixture was then removed.DataSeederseedsColor/Size+ the config singleton idempotently (it needed restructuring — an earlyreturnin 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
loginUserblocker —POST /api/v1/auth/registersucceeds and issues theerp_atsession cookie directly, and the JWT handler's cookie fallback means that session authenticates every other controller. (loginUserstill500s "Invalid credentials" for that same freshly-registered user, by username or email, with or withoutuserTypeId— the §6 blocker is real and reproduces, but it is not a barrier to testing.) Registration needs AuthHex-internalroleId/userTypeIdGUIDs, supplied by the user; Admin = role08de6a11-9e9f-4401-8a10-6859860b41ec/ userType00000000-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 onGET /items/{id}; newbrandId/subCategoryIdlist filters; cross-FK guard → 422 ("Subcategory 3 belongs to category 7, not 8"); missing/inactive brand → 422;PUT /product-config {}→ 400 (proving thebool?fix — an empty body no longer silently disables everything);subcategoriesEnabled:false+subCategoryId→ 422 CONFIG_DISABLED, same item without it → 201, and pre-existing items with a subcategory still read back fine;brandsEnabled:false+brandId→ 422;itemTypesEnabled:falsecorrectly 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.updatedByresolved to a JIT-provisioned shadow user (SMOKE001) from the AuthHexUserId/NICclaims.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/ NICSMOKE001in 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 example112100/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}/comparisonmatrix) - Purchase Order: create (auto-approve or
saveAsDraft,approvalRequiredflag), edit Draft-only (If-Match), submit (Draft→Approved), delete (Draft-only), approve (no-op), cancel — see the 2026-07-20 entry (FR-PROC-05 revised: draft-lock supersedes edit-while-open) - Purchase Return (outbound movement, reason code) —
POST /purchase-returnsauto-posts an outbound FIFO consume via sharedStockMutator; 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):
VendorQuotationis modelled as header +VendorQuotationLine(per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalarVENDOR_QUOTATION(unit_price, lead_days)with no item ref cannot represent it. Update the ER model doc to match. RFQvendorIdsare 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-adjustmentsandpurchase-returnshad 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 followItemService.ListAsync(ILike onq, filters,PagedResponse<T>.Create) with matching*SummaryDtos carrying alineCount.GET /stock/on-hand/listis deliberately set-based — four grouped queries regardless of page size — rather than callingGetOnHandAsyncper row (N+1). It replaces a client-side loop the mock used to do.GET /stock/ledgergainedsourceDocType/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.ItemDetailDtogainedconversions(+.Include(i => i.UomConversions)): they could only be written (PUT /items/{id}/uom-conversionsreturns them; nothing read them back), so the item detail screen could never show current state before editing. Closes a deviationFrontend/PROGRESS.mdhad flagged.- DTOs gained fields the entities already had and the UI needed:
createdBy/createdAton transfers + counts,createdAton purchase returns,lineCount+ astatusfilter on requisitions. Cheaper and more honest than deleting working columns from the screens.- Bug fixed —
POST /auth/logoutmadeuserIdoptional. AuthHex returnsuser.userId: nullon 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'sUserIdclaim and always clears the cookies, even if the upstream revoke fails. Found by driving the real logout in a browser.- Verified:
dotnet buildclean; every new endpoint returns a correctPagedResponseagainst a live cookie session;conversionsround-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):
CreateGrnLineInputcarriesbatchbut 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_idalready 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 —
unitCostdefaults to the PO price but is now overridable per line (variance recorded vspoUnitPricesnapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requiresvendorId+ entered cost (AR-04); over-receipt →422 OVER_RECEIPT_TOLERANCE(verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. Discount/VAT added — see the 2026-07-20 entry. - 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-Keyaccepted 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/DELETErevoke 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).
reservedstays 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
StockAdjustmentvia sharedStockMutator+ 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(jsonbchangeSet), written by anErpDbContext.SaveChangesoverride (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 fromICurrentUser(system=1 until auth). Read viaGET /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(atomicINSERT … ON CONFLICT … RETURNINGinside 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.
JwtAuthExtensionsvalidates RS256 against AuthHex's RSA public key (configAuth:RsaPublicKeyXml→RsaSecurityKey;MapInboundClaims=false), issuerAuthHex, audienceAuthHexClient(no JWKS → static key).[Authorize(ErpAccess)]onApiControllerBasegates every v1 endpoint; theErpAccesspolicyRequireAuthenticatedUser+ optionalRequireClaim(UserTypeCode/RoleCode)fromAuth:RequiredUserTypeCode/RequiredRoleCode(empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). Shadow-user JIT provisioning:ShadowUserClaimsTransformation(IClaimsTransformation) maps the token'sUserIdGUID → a localusersrow (auth_user_idunique; Username/DisplayName =NIC), idempotent, and injects the localintid asnameidsoICurrentUser.AuditUserIdresolves the real actor. MigrationAddAuthUserId. 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:
AuthControllerfronting 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) viaInfra/Auth/AuthHex/{IAuthHexClient,AuthHexClient}(AuthHex:BaseUrlconfig). Sessions delivered as httpOnly Secureerp_at/erp_rtcookies +XSRF-TOKENdouble-submit cookie (Infra/Auth/AuthCookieWriter.cs, 02-SECURITY §B.2);ValidateCsrfAttributeguards every mutating action; the JWT bearer handler now also acceptserp_atas a fallback (JwtAuthExtensions'sOnMessageReceived) so every other v1 controller keeps working unchanged. Seedocs/11-BACKEND-PHASE1.md §2.0for the full route table anddocs/02-SECURITY.mdAR-07/AR-08 for the two carried-over exposures (anonymousgetUserDetails/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) —
JournalEntryStubwritten inFifoCostingService.PostLedgerAsyncfor every ledger entry (In → Dr Inventory1300/ Cr Clearing2100; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read viaGET /journal-entries. Verified (GRN In 700, ADJ Out 70). - Negative-stock policy enforcement (default block) — enforced in
FifoCostingService.ConsumeAsync→409 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) —
ReasonCodeentity +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-20 — PO draft lifecycle + GRN discount/VAT/price-override (migration AddGrnPricingAndPoDraft)
- PO draft lifecycle (FR-PROC-05 revised).
CreatePurchaseOrderRequest.SaveAsDraft(defaultfalse→ auto-approve unchanged;true→Draft). NewPOST /purchase-orders/{id}/submit(Draft→Approved, else409 PO_NOT_EDITABLE) andDELETE /purchase-orders/{id}(Draft-only, else 409).IsEditablenarrowed from "not FullyReceived/Closed/Cancelled" toDraftonly — soPUTnow 409s on any submitted PO. Option B ("freely edit while open") is superseded; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ Every pre-existing PO isApprovedand therefore now uneditable/undeletable — intended, not a regression. No schema change (reuses the existingDraftenum value). - GRN discount + VAT + price override.
GrnLinegainedPoUnitPrice(nullable snapshot),DiscountPct,NetUnitCost,VatPct,VatAmount,LineTotal. All derived figures server-computed, never client-supplied. FIFO layer + ledger now cost atNetUnitCost(after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line,unitCostdefaults to the PO price but an entered override wins and a variance is recorded againstPoUnitPrice(02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked viaopenQty/QtyReceivedand is untouched. - Migration
AddGrnPricingAndPoDraft— hand-added a data backfill (UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue) so existing GRN lines stay consistent with their already-posted FIFO layers;PoUnitPriceleft NULL for historical rows (no retroactive variance).Down()drops the six columns cleanly. - Verified:
dotnet buildclean (0/0); migration Up and Down exercised against the live DB (rollback toAddRolesNavPermissionsthen re-apply — bothDone). Runtime end-to-end PASSED — 22/22 assertions (Node script, register→cookie session): PO draft→edit→submit→edit/delete-locked (409), draft delete (204→404), plain create still auto-approves; costing proof (100 @10, 10% disc, 18% VAT → net 9.00, receivedValue 900, VAT 162, lineTotal 1062, FIFO layer @9.00, valuation 900 — VAT absent from stock); multi-GRN 20@10/50@11/30@12 → variances +50/+60, PO FullyReceived, blended valuation 2010.
2026-07-20 (2) — Procurement sidebar submenu (migration AddProcurementSubNav)
- The sidebar submenu is driven by seeded
SubNavItemrows +GET /auth/menavCodes; only Products/Settings had children, so Purchase Orders had no sidebar section. Added 4SubNavItems (ids 9–12,NavItemId 4) + 4Permissions (ids 19–22) for Requisitions/RFQs/Purchase Orders/Purchase Returns viaAddProcurementSubNav. The migration also grants the 4 to any role already holding the parentNAV:procurement(raw SQL,ON CONFLICT DO NOTHING);Down()removes the grants then the rows. - Found: the
Adminrole (RoleId 2) was never grantedNAV:procurementat all (nor Vendors), so its whole Procurement branch was hidden — granted the parent + 4 children directly. Verified:/auth/mefor Admin returnsprocurement+ all 4 children; frontendtsc/eslintclean.
2026-07-09 — Bootstrap verified + Master Data (§1) implemented
- Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages,
Program.cswiring, 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 oneIEntityTypeConfigurationeach; FKsRestrict(masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision,xminconcurrency token on Item/Vendor. - API: 5 controllers, lowercase routes matching
docs/11 §2exactly (verified via generatedswagger.json). ETag/If-Match (428 if missing, 412 on mismatch), narrow request DTOs (no over-posting),PagedResponse<T>list envelope (§1.4),PageQuerywith pageSize clamp ≤200 (B.6). - Migration
InitialCreategenerated (xmincorrectly produces no DDL — uses the PG system column). - Verified:
dotnet buildclean (0 warn/0 err); app boots (Now listening… Application started);/api/meta200;swagger.json200 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) noDELETEmaster endpoints —MASTER_IN_USEcode reserved until transaction tables exist (deactivate-only per FR-MD-08); (3) minor: bad-enum bind error leaks the CLR type name indetail(02-SECURITY B.5) — fine in Dev, tidy before prod.
2026-07-10 — Migration applied + live smoke test PASSED
dotnet ef database updateappliedInitialCreateto local Postgres;/health→Healthy.- End-to-end curl smoke across all 5 controllers — all green: warehouse/bin create+list; uom create; category + child +
?tree=truenesting; vendor create + PUT (If-Match 200 / stale 412 / missing 428); item create (201, referencing category/uom/vendor) + GET (ETag header) + list/filterq+pageSize=9999→clamped 200; reorder PUT; uom-conversions PUT; full item PUT with fresh ETag→200; PATCH status Inactive→204; duplicate SKU→400SKU_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:
Userentity (+ seededsystemuser viaHasData),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 thexminETag token; totals computed server-side; create/edit wrapped inIUnitOfWork.ExecuteInTransactionAsyncso the reserved doc number rolls back with the doc. - Migration
AddProcurementgenerated + applied (10 tables incl. users/number_sequences; system-user seed; POxminemits 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_initialsits 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 inExecuteInTransactionAsync; 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
422at the open-qty boundary, exact-fill201; batch receive OnHold → excluded fromavailable→ release → 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 viaSELECT … FOR UPDATE(raw SQL, no LINQ composed on top so the lock reaches PG;{batchId}::bigintcast avoids a 42P18 null-param error), excludes on-hold + expired layers, throwsSTOCK_NEGATIVE_BLOCKED/ONHOLD_NOT_ISSUABLE/EXPIRED_BATCH_BLOCKED, returns consumed segments for cost-preserving moves. - Entities: ReasonCode, StockAdjustment/Line, StockTransfer/Line (+
UnitCost/QtyReceivedextension on the transfer line for cost-preservation) + configs; enums TransferStatus, AdjustmentStatus, ReasonContext. MigrationAddStockTransactions(5 tables) applied. - Services/controllers:
ReasonCodeService(/reason-codes, startup seed viaDataSeeder),AdjustmentService(/stock-adjustments),TransferService(/stock-transferscreate/dispatch/receive),ReorderService(/stock/reorder-alerts+ suggest-requisition).StockService.GetOnHandAsyncnow 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
AdjustmentServiceontoStockMutatorand added an intermediateSaveChangesso the header id is flushed before ledger posting — fixes a latent bug where new-in-txn documents wrotesourceDocId=0(verified:ADJledger nowsourceDocId=5). Also fixedledgerRefs:[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-countscreate/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→409STOCK_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(jsonbchangeSet) written by anErpDbContext.SaveChanges/Asyncoverride viaAuditScribe— 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 fromICurrentUser(system=1).ErpDbContextnow takesICurrentUser(design-time migration still works via DI). - JournalEntryStub (FR-STK-13): emitted for every ledger entry in
FifoCostingService.PostLedgerAsync(In → Dr1300/Cr2100; 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. MigrationAddAuditAndJournal(2 tables, jsonb) applied. - Verified against Postgres: item Create logged full field set (userId 1); item Update logged only
Name+UpdatedAtas{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 (
— superseded 2026-07-14, now external AuthHex IdP integration; see the next entry) is intentionally deferred per request; wiring it is what flips §1–§5POST /auth/login+ global[Authorize][~]→[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/logininside ERPCore. A separate AuthHex IdP (runs on:5011, source atc:\Users\WAS\Documents\Developments\ERP_Auth_Service\) owns login/registration/recovery; ERPCore becomes a resource server that only validates AuthHex tokens. Updateddocs/10-BACKEND-PHASE1.md(header, A.4 auth/audit-actor, A.5 DI, B.2.3, FR-X-01, NFR-03, C.7USER, 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_idGUID (unique) tousers, keep allintFKs; (2) authorization = door-gate to an ERPUserType/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, audienceAuthHexClient, lifetime 1000 min prod / 60 min dev; claimsUserId(GUID)/UserTypeCode/RoleCode/NIC/jti/iat(nosub/nameid); BCrypt password hashing; loginPOST /api/loginUser {identifier,password}. - Open blockers (resolve before the code phase): exact ERP
UserTypeCode/RoleCodefor the door gate (must exist in AuthHex); RSA public-key distribution + rotation process (no JWKS); shadow-userUsername/DisplayNamesource (token has no name); secrets hygiene in AuthHex config (private key/SMTP/DB in plaintext);docs/11 §2.0still 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:
JwtAuthExtensionsrewritten —RsaSecurityKeyfromAuth:RsaPublicKeyXml(AuthHex public key),ValidIssuer=AuthHex,ValidAudience=AuthHexClient,ValidAlgorithms=[RS256],MapInboundClaims=false(keepsUserId/UserTypeCode/RoleCodeverbatim).appsettings.jsonJwt→Auth(public key + issuer/audience +RequiredUserTypeCode/RequiredRoleCode); removed the HS256 dev signing key. - Door policy
ErpAccess:RequireAuthenticatedUser+ optionalRequireClaim(UserTypeCode/RoleCode)when configured (AuthHex is ERP-dedicated → empty default = any valid token).[Authorize(ErpAccess)]onApiControllerBase;MetaController/health/Swagger stay anonymous. - Shadow-user provisioning:
ShadowUserClaimsTransformation(IClaimsTransformation, scoped) maps tokenUserIdGUID → localusersrow (auth_user_idunique, Username/DisplayName=NIC), idempotent w/ race-safe re-read, injects localintid asnameid.User.AuthUserId(Guid?) +AuthHexClaimsconsts + migrationAddAuthUserId. - 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 gateRequiredUserTypeCode=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 ERPAuth:RequiredUserTypeCode/RoleCodefor 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) anddocs/11-BACKEND-PHASE1.md §2.0updated in place;docs/02-SECURITY.mdgained AR-07/AR-08 and ticked 3 of 5 B.2 boxes. Infra/Auth/AuthHex/—IAuthHexClient/AuthHexClient(typedHttpClient,AuthHex:BaseUrlconfig =http://localhost:5011dev), one C# method per AuthHexfunctionName, 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}— orchestrateIAuthHexClientcalls;AuthSessionResult/OtpAuthSessionResult(Services/Auth/AuthSessionResult.cs) carry tokens from service → controller only, never serialized.Controllers/AuthController.cs—api/v1/auth/*, 24 actions (seedocs/11 §2.0table); inheritsControllerBasedirectly (notApiControllerBase) 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) — allHttpOnly(except CSRF)/Secure/SameSite=Strict.ValidateCsrfAttributedouble-submit-checksX-XSRF-TOKENagainst the cookie on every mutating action, exempting Bearer-header callers.JwtAuthExtensions'sOnMessageReceivedfalls back to theerp_atcookie when noAuthorizationheader is present — every existing v1 controller keeps working unchanged under either auth mode. - Verified:
dotnet buildclean (0 warn/0 err) after two passes — first pass hitCS0051(a public interface/constructor can't expose aninternalparameter type) onIAuthHexClientand its supportingAuthHex*wire types, fixed by making thempublic; second pass caught a cookie-path bug (erp_rt'sPathwas written as/api/auth/refresh-token, not matching the actual/api/v1/auth/refresh-tokenroute — 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.2left unticked), rate limiting on the anonymous endpoints (docs/02-SECURITY.mdAR-08), and the frontend wiring itself (lib/api/auth.ts+ wiringapp/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):register→200withSet-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 carriesuser+expiresInonly, no tokens; theerp_atcookie alone (zeroAuthorizationheader) authenticatedGET /api/v1/items— confirms theOnMessageReceivedcookie fallback works for every existing v1 controller unchanged;GET /api/v1/auth/sessions(protected, cookie-authenticated) →200; mutatingPOST /api/v1/auth/change-passwordwithoutX-XSRF-TOKEN→403 CSRF_TOKEN_MISMATCH, with the matching header →204+ all three cookies cleared, exactly as designed. - Found + fixed a real bug during live testing:
AuthHexClienttrusted the envelope'ssuccessflag alone; AuthHex was observed returningHTTP 500with"success": true, "data": nullon a business failure (invalid-credentials login), which slipped past the!envelope.Successcheck and null-derefed insideAuthUserService.ToSessionResult(NullReferenceException→ bare unhandled500, nocode). FixedAuthHexClient.CallAsyncto also fail on!httpResponse.IsSuccessStatusCoderegardless ofenvelope.Success, plus addedresult is nullguards inToSessionResult/ToOtpSessionResult/AuthAltService.VerifyOtpAsyncas defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean500 AUTH_UPSTREAM_ERRORProblemDetails instead of crashing. - ✅ RESOLVED 2026-07-17 — login works. The AuthHex fix below was applied (
ERP_Auth_Service, uncommenting thePasswordHashassignment) and verified:POST /api/v1/auth/loginnow returns 200 +Set-Cookie: erp_atfor a freshly-registered user, where it previously returned500 "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'ssmoketest_adminamong them). - Historical:
loginUserused 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/userdirectly with the identical payload (bypassing ERPCore entirely) — a bug in AuthHex's ownloginUser/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 withoutuserTypeId.- The password was never stored — FIXED 2026-07-17.
Services/UserManager/UserManagerService.cs:97computesvar PasswordHash = PasswordHasher.Hash(...), but the assignment in thenew User { … }initializer at line 116 was commented out (//PasswordHash = PasswordHash). Every registered user landed in MySQL with a nullPasswordHash, sologinUser'sif (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/UpdateUserdo persist the hash correctly, andUpdateUsereven handles the null-hash case at line 836). - Username is not a valid login identifier.
Repos/UserManageRepository.cs:46GetUserByIdentifierAndTypematches onlyEmail/MobileNumber/Nic— notUserName— and ignores itsuserTypeIdargument entirely (that filtering sits commented out at lines 56–65, 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.
- The password was never stored — FIXED 2026-07-17.
- Workaround meanwhile:
POST /api/v1/auth/registerissues a workingerp_atsession 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.
- ROOT CAUSE (2026-07-16), in