102 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.
Per-variant content size (2026-08-11) — follow-up to the UOM re-model below
The UOM re-model put ContentQty/ContentUnit on Item correctly, but the item create page
is a variant builder and collected one form-level pair, copying it into every generated
variant. Building "Coca-Cola in 500 ml / 1 L / 250 ml" produced three items all recorded as the
same size — the exact case the builder exists for. The item contract already accepted a
per-item pair, so the whole fix is in how values are captured.
ItemType.IsMeasurable(bool, defaultfalse) — set on Products → Item Types. A flagged dimension's values are entered as a number + unit; the chip label, the SKU segment, the item name and the stored content size all derive from that one pair. Unflagged dimensions are unchanged free text, which is what an apparelSize(S/M/L) needs.UpdateItemTypeRequest.IsMeasurableisbool?and preserved when omitted. A plainboolbinds an absent property asfalse, so the admin screen's name-only PUT would have cleared the flag on every rename — the same bug class already recorded forproduct-configfurther down.- The
BUILDER_ITEM_TYPES = ["color","size"]hardcode is gone. It had one consumer and had become a live bug: a user-created "Pack Size" would be flagged measurable and then never appear. Removal is behaviour-preserving on any current database (the seeder seeds exactly those two names, and the fetch was alreadystatus: Active) and restores the documented contract that users add their own types. Every Active item type is now offered; deactivation is the intended remedy and the admin page already says so. - SKU collision fixed before it could bite.
skuSegmentstrips the decimal point and truncates to 3, so derived labels collided —1.5L/15L→15L,500ml/500g→500,2.5ml/25ml→25M. Since the create loop is sequential and non-transactional, that would have failed partway withSKU_DUPLICATEafter creating some rows. Measurement segments now usemeasureKey, which mirrorsItemContent.Normalize(L/Kg ×1000) and renders the point asP. - Values dedupe on the normalised size, not the label —
500 mland0.5 Lread differently but store identically, andItemContent.Normalizeis the server's notion of equality. - At most one measurable dimension per product: unchecked measurable types are disabled once one is checked, re-checked at submit.
- The form-level pair survives as a fallback — correct when the varying dimension isn't size — and is hidden and cleared whenever a measurable dimension is active, so the two can never disagree. Its validation is skipped in that mode, since its error message would otherwise be invisible inside the hidden block.
- The item edit page is untouched: one item, one size.
UOM re-model (2026-08-11) — supersedes every "UOM conversion" note below
Per-item UOM conversion is gone. Entries further down this file that describe
IUomConverter, PUT /items/{id}/uom-conversions, uom_conversions, or a line-level
uomId are historical and no longer describe the code.
What replaced it:
- One unit per item.
Item.BaseUomIdis the pack an item is stocked and counted in, and every quantity in the system — stock layers, ledger rows, GRN/PO/sales/bundle/transfer lines — is a plain count of it. A differently sized pack is a different item.UomIdwas dropped from all six document-line entities;Uomitself survives as the lookup. - Optional content size on
Item:ContentQty+ContentUnitas entered (Ml|L|G|Kg), normalised on write intoContentBaseQty+ContentBaseUnit(only everMlorG, L/Kg ×1000). All four null ⇒ nothing measurable to hold.ItemContentis the pure normaliser. - Production is the only content consumer.
StageInput.QtyUnit(Pack|Content) says whatQtyPerBatchmeans;IItemMeasuredivides aContentquantity by the item's content size to get packs. Fractional packs are legal — 300 ml of a 500 ml bottle consumes 0.6000. Outputs are always pack counts, so scrap stays in whole broken bottles. - WIP keeps a label.
StageOutput.UomId/RunStageOutput.UomIdare now nullable and required only whenItemIdis null (422 WIP_UNIT_REQUIRED), since an item-bearing output takes its unit from the item. WIP never touches stock, so the label is never converted. - Two live defects fixed as a consequence.
SalesPostingServiceinjectedIUomConverterand never called it, so a sales line in a non-base UOM consumed the wrong quantity outright;GrnServiceaccruedpoLine.QtyReceived += line.Qtyand range-checked over-receipt across the same unit boundary. Both are now like-for-like by construction. BaseUomIdis frozen once an item has stock history (409 MASTER_IN_USE) — it is the sole meaning of every recorded quantity, so changing it would silently reinterpret all of it.- Smoke:
m4b_uom_conversion.pydeleted;m4c_content_units.pyadded (whole packs, fractional packs, and the contentless-item guard).
8. Sales
- Sales bootstrap data seeded locally for development: warehouses, UOMs, categories, items, customers, current-year
SI/SSLsequences, plus sample invoice/slip headers and lines. Existing data is preserved. - Sales report API consolidated into
GET /api/v1/reports/sales(catalog),GET /api/v1/reports/sales/{reportId}(report metadata), andPOST /api/v1/reports/sales/query(filtered data). Legacy per-report GET routes removed; invalid report/filter combinations now fail validation. - Free-issue CRUD exposed as
api/v1/free-issuesas a thin alias over sales slips. Free issue remains a line-levelIsFreeIssue/FreeQtybehavior, not a separate table.
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's dimension list, and since 2026-08-11 carries
isMeasurable, which decides whether its values are captured as free text or as a number + unit that becomes each item's content size (see the entry at the top of this file) - 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 - Item sale price (FR-MD-01, 2026-07-22) — nullable
Item.SalePrice(numeric(18,4)); on all Item DTOs (list/detail/create/update), validated>= 0. Sales-only — never enters GRN/FIFO/ledger.null⇒ sell at stock value. MigrationAddItemSalePrice. See the 2026-07-22 Done entry.
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. - Off-PO lines on a PO-based GRN (FR-GRN-01, 2026-07-22) — a line with
poLineId: nullon a PO-based GRN is received like a direct line (enteredunitCost, no over-receipt check, PO balances untouched). No code change was needed —GrnService.CreateAsyncalready branches per-line oninput.PoLineId is not null; documented + frontend-enabled. Same review/audit surface as AR-04 (02-SECURITY C.3). - 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.
7. External Integrations
General Ledger service (separate microservice, own repo/DB) — connected 2026-07-20 as a generic reverse-proxy only; no ERPCore business logic posts to it yet. Full contract + progress detail:
docs/12-GENERAL-LEDGER-INTEGRATION.md.
- [~] Generic proxy
GET|POST|PUT /api/v1/gl/{**path}(GeneralLedgerController→IGeneralLedgerService→IGeneralLedgerClient) — forwards method/path/query/body/content-type verbatim to the GL service with a server-attachedX-Api-Key; GL's response (status + body) returned unchanged. ErpAccess-door-policy-gated like every other v1 endpoint. Config:GeneralLedgerService:BaseUrl/ApiKeyinappsettings.json. Build verified clean; not yet live-smoke-tested (no running GL instance this pass). - Internal wiring — ERPCore services (GRN confirm, adjustments, etc.) calling
IGeneralLedgerServicedirectly to post real journal entries. Deliberately deferred.
2026-07-20 — RBAC nav seed for the frontend's new "Ledgers" section
The
Frontend/PROGRESS.md§8 "Ledgers" sidebar section (docs/21-GENERAL-LEDGER-FRONTEND.md) needs a matchingNavItem/SubNavItem/Permissionrow for every entry, or the sidebar filters it out for every role regardless of the frontend change (docs/10 C.8,GET /auth/me'snavCodes). Added viaNavItemConfiguration.cs/SubNavItemConfiguration.cs/PermissionConfiguration.csHasData:NavItemledgers(id 11), 7SubNavItemrows (ids 9–15,ledgers.trial-balance…ledgers.bank-accounts), 8Permissionrows (ids 19–26) — same one-Permission-per-nav-entry convention as every existing nav row. MigrationAddLedgersNavSeed. Build note: a locally runningERPCore.exe(PID 29692) held the defaultbin/Debugoutput locked for the whole session, sodotnet ef migrations addtwice produced an empty no-op migration off a stale assembly (--no-buildsilently reused pre-edit code) before the real cause was found. Fixed by building to a scratch output directory (unaffected by the lock), copying the freshERPCore.dllover the lockedbin/Debugcopy (the running process only locks the.exe, not the.dll), then re-scaffolding — the resulting migration'sUp/Downwere verified by inspection against the identical, already-appliedAddRolesNavPermissionsmigration'sInsertData/DeleteDatashape. The stray process was left running rather than killed, since it wasn't started by this work and may be in active use elsewhere. Not yet applied to a live database — no Postgres instance was available in this pass to rundotnet ef database updateagainst.dotnet buildis clean (0 warnings/0 errors). Operational step still needed post-deploy (not code): a newNavItem/SubNavItemcarries noRolePermissiongrants by default — an administrator must check the new Ledgers permissions for the relevant role(s) via Settings → Roles before anyone sees the sidebar entry, same as every previous nav addition.
2026-07-30 — RBAC nav seed: 8th sub-item for the new "Tax Report" screen
The frontend's GL-revision pass (
docs/21-GENERAL-LEDGER-FRONTEND.md, Frontend/PROGRESS.md §8) added a Tax Report screen to the Ledgers sidebar section — needs the same nav-seed treatment as every other entry (docs/10 C.8). AddedSubNavItemid 16 (ledgers.tax-report,/dashboard/ledgers/tax-report, sort order 7) andPermissionid 27 (NAV:ledgers.tax-report); re-sequenced the existingledgers.bank-accountsrow'sSortOrderfrom 7→8 so Tax Report sits before it, matching the sidebar array's actual order. MigrationAddTaxReportNavSeed— no locked-process issue this time (confirmed no strayERPCore.exerunning before scaffolding), generated cleanly on the first attempt with realInsertData/UpdateData/DeleteData(Down()correctly restoresbank-accounts'SortOrderto 7).dotnet buildclean (0 warnings/0 errors). Not yet applied to a live database — same open item as the originalAddLedgersNavSeedmigration; both are still pendingdotnet ef database updateagainst a real Postgres instance.
2026-07-30 (2) — Fixed a real
SubNavItemId/PermissionIdcollision between Procurement and Ledgers seed dataRoot cause: when the 2026-07-20
AddLedgersNavSeedmigration was authored, itsSubNavItem/PermissionIDs were picked by looking at the actual DB row count, not the config source — butSubNavItemConfiguration.cs/PermissionConfiguration.csalready hadHasDataentries for Procurement's 4 sub-items (procurement.requisitions/.rfqs/.purchase-orders/.purchase-returns, ids 9–12/19–22) that had never actually been migrated into any database (no migrationUp()anywhere ever inserts them — confirmed by grep across every migration file). Ledgers then claimed the same ids (9–12 sub-nav, 19–22 permission) for its own rows, so the config ended up with twoHasDataentries sharing the same primary key per table.ErpDbContextModelSnapshot.cshad silently absorbed both (dotnet ef migrations adddoesn't hard-fail on this at scaffold time), but EF's runtime model validator does —dotnet ef migrations addfor anything touching these tables, and by extension normal app startup/firstDbContextuse, throwsInvalidOperationException: A seed entity ... has the same key value as another seed entity mapped to the same table. This is very likely the crash the user was hitting. Fix: moved Procurement's 4 sub-nav rows off the colliding ids onto 17–20 (SubNavItemId) and 28–31 (PermissionId), past every id already claimed by Ledgers/Tax-Report (max 16/27). Removed the phantom duplicate Procurement entries fromErpDbContextModelSnapshot.cs(they never reflected real DB state) so the differ could compute a clean diff, then generated migrationFixProcurementNavIdCollision— pureInsertDatafor the 4 sub-nav rows + 4 permission rows at their new ids (this is also the first migration that actually creates Procurement's sub-nav-item/permission rows in the database at all).Down()is a cleanDeleteDatareversal. Verified: runningdotnet ef migrations addagainst the pre-fix config reproduced the exactInvalidOperationExceptionabove (scaffold failed outright, no migration file produced), confirming this was a real, reproducible crash and not a false alarm; after the fix, the same command succeeded anddotnet ef migrations listbuilds the full model with no error, listing all 7 migrations (the last 2 —AddTaxReportNavSeed,FixProcurementNavIdCollision— still(Pending), no Postgres instance available this session);dotnet buildclean (0 warnings/0 errors). Not yet applied to a live database — same standing blocker as the two prior nav-seed migrations. Also fixed, same pass:Frontend/erp-system/components/Layouts/AppSidebar.tsx's auto-expand-active-parent logic trippedreact-hooks/set-state-in-effect(setExpandedcalled synchronously inside auseEffect) — converted to the same "adjust state during render" pattern used for the Ledgers report pages, keyed on apathname + item-codescomposite key (tracked via alastAutoExpandKeystate var) so it still re-fires onceitemspopulates after the RBACnavCodesfetch resolves.npx eslint components/Layouts/AppSidebar.tsxclean.
2026-07-31 — RBAC nav seed: new "Accounts" nav item (Cheque Management screens + Cash/Bank Accounts moved off Ledgers)
The frontend added a new "Accounts" sidebar section (
Frontend/PROGRESS.md§8) for the new Cheque Management screens and to hold Cash/Bank Accounts, which moved out of Ledgers into it (user-requested — Cheque Books/Received Cheques/Cash-Bank Accounts are all the same kind of operational account bookkeeping, not a statutory report). MigrationAddAccountsNavSeed:InsertDataforNavItemaccounts(id 12) and two newSubNavItem/Permissionpairs (accounts.cheque-booksid 21/33,accounts.received-chequesid 22/34);UpdateData, not delete-and-recreate, for the existing Cash/Bank AccountsSubNavItem/Permission(ids 15/26) — same ids, just newCode/Href/NavItemId— so a role that had already been granted this permission under its oldledgers.bank-accountscode doesn't silently lose it just because the section changed.Down()correctly reverses both the inserts and the renamed-row update back to its Ledgers-era values. No locked-process issue avoided this time —ERPCore.exewas found running twice during this pass (the user had restarted it between turns to test the Tax Report fix); confirmed with the user before killing it each time, per this session's standing caution around stopping their dev server.dotnet buildclean (0 warnings/0 errors);dotnet ef migrations listshows all 8 migrations with none pending. Applied to the live database this session (dotnet ef database update) — unlike every prior nav-seed migration this session, this one did not have to wait for a live Postgres instance to become available. Operational step still needed post-deploy (not code): same as every previous nav addition — an administrator must grant the newNAV:accounts/NAV:accounts.cheque-books/NAV:accounts.received-chequespermissions to the relevant role(s) via Settings → Roles before anyone sees the new sidebar entries (the re-homedNAV:accounts.bank-accountskeeps whatever grants it already had).
2026-07-31 (2) — Root-caused and fixed a repo-wide bug: 36 tables (all of HRM + all of Manufacturing) existed in the EF model but not in the actual database, and no
dotnet ef migrations addcould ever surface itUser-reported: after rebasing
feat/general-ledger-serviceontoorigin/Dev, some tables from the other branch weren't being created bymigrations add+database update. Ground-truthed against the live Postgres instance (queriedpg_tables/__EFMigrationsHistorydirectly, since EF's own diff tooling only ever compares the compiled model againstErpDbContextModelSnapshot.cs— never the real database — so it's structurally blind to this class of bug): the database had 45 tables; the current model/snapshot expects 80. All 25hr_*tables and all 11production_runs/production_templates/run_*/stage_*/template_stagestables were completely absent, despiteErpDbContext/Infra/Persistence/Configurationsfully describing them andErpDbContextModelSnapshot.csalready listing them. Root cause: a.gitignorerule (**/Migrations/, added early on to stop new EF migrations from being committed) combined disastrously withErpDbContextModelSnapshot.csstaying tracked (.gitignoredoesn't retroactively untrack already-tracked files, and the snapshot was one of the original 4 tracked migrations). Everydotnet ef migrations addafter that point updated the snapshot (which did get committed normally, since it was already tracked) but wrote its actual migration.cs/.Designer.cspair as new, gitignored, never-committed files. Confirmed viagit show --staton every historical commit touching the snapshot: several — including the commit that added the entire HRM module and the one that added Manufacturing (7d6e597) — show large snapshot insertions with zero migration files in the same commit. Net effect: the snapshot has been silently lying about the applied-migration history for a long time;dotnet ef migrations addnever detects a "missing" table because, as far as the (already-tracked, already-correct-looking) snapshot is concerned, nothing has changed — the actualCreateTablemigration simply never existed anywhere in git, on any machine that didn't happen to still have it sitting locally, ungitignored-but-untracked. Fix, in order:
- Confirmed the exact 36-table gap by comparing
pg_tablesagainst everyb.ToTable(...)call in the snapshot (script, not archaeology — this is the only way to get ground truth once the snapshot itself is suspect).- Temporarily removed just those 36 entities' blocks from
ErpDbContextModelSnapshot.cs(verified 2–3 balanced-brace occurrences per entity removed cleanly, nothing else touched), sodotnet ef migrations addwould have something real to diff against.- Generated
AddMissingHrmAndManufacturingTables— verified itsUp()contains exactly 36CreateTablecalls (matching the missing-table list precisely, no more/fewer) and itsDown()exactly 36 matchingDropTablecalls; noAlterColumn/DropColumn/RenameColumnagainst any pre-existing table, confirming this was a pure addition with zero collateral schema drift.- Applied it (
dotnet ef database update); re-queriedpg_tableslive — all 81 tables (80 +__EFMigrationsHistory) now present. Confirmed fully settled by scaffolding one more throwaway migration afterward and checking it came back empty (no remaining model/snapshot drift), then removing it.- Fixed the actual root cause, not just this one symptom: reverted the
.gitignorerule — EF Core migrations are now tracked like any other source file, so this can't recur the same way. Every migration created since the rule was added (AddLedgersNavSeed,AddTaxReportNavSeed,FixProcurementNavIdCollision,AddAccountsNavSeed, the emptyproductionmigration, and this pass'sAddMissingHrmAndManufacturingTables) was sitting on disk ungitignored-but-uncommitted the whole time — now staged to actually join the repo. Verified:dotnet buildclean (0 errors, pre-existingCS8981naming warning on the already-presentproductionmigration class only);dotnet ef migrations listshows all 10 migrations, none pending. A locally runningERPCore.exehad to be stopped mid-session (user's explicit approval obtained first) to free the build lock, same recurring issue as every previous migration pass this week. Left as-is, deliberately: the emptyproductionmigration (20260731123720_production.cs) — it's a harmless no-op (it was the user's own prior attempt to fix this exact bug, which came back empty for the reason explained above) and renaming/removing it now would just be churn; the real fix landed in the next migration. Action needed from the user: the.gitignorefix means these migration files are no longer excluded, but nothing has beengit added or committed yet — per standing instruction, commits only happen when explicitly asked.
Deferred (Phase 2+ — do NOT build now, hooks only)
- Vendor invoice + three-way match
- Reservation/allocation fulfilment
- RBAC policy enforcement + approval workflow activation
HRM (Phase 2)
Spec: docs/12-BACKEND-HRM.md (model + rules) · docs/13-BACKEND-HRM-API.md (API). Security: docs/02-SECURITY.md §C.8 (run before ticking any HRM feature [x] — salary/PII data, see AR-09/AR-10).
2026-07-23 — Bug fix: DateTime Kind=Unspecified 500 on every user-supplied date (Employee create, Statutory Settings create, etc.)
- Root cause: Postgres/Npgsql requires
DateTimevalues written to atimestamp with time zonecolumn to haveKind=Utc. Every Phase-1DateTimewas always server-generated (DateTime.UtcNow), so this never surfaced before. HRM is the first place user-supplied dates (hire date, salary-structure/statutory-setting effective date, leave/attendance period dates, document issue/expiry dates, …) get deserialized straight from a JSON request body — which producesKind=Unspecified— and then persisted, so any create involving a date (POST /employees,POST /payroll-statutory-settings,POST /tax-slabs,POST /employees/{id}/salary-structure,POST /leave-requests, attendance upload, …) threw a500(DbUpdateException→ArgumentException: Cannot write DateTime with Kind=Unspecified...). Confirmed vialogs/erpcore-20260723.log. - Fix: a global EF Core
ValueConverter<DateTime, DateTime>/ValueConverter<DateTime?, DateTime?>registered once inErpDbContext.OnModelCreating(applied to every entity property of typeDateTime/DateTime?viamodelBuilder.Model.GetEntityTypes()), forcingKind=Utcon write. Fixes the bug for every current and future HRM (and Phase-1) entity in one place, rather than patching each service call site individually. - No migration needed — confirmed by scaffolding a migration and finding it empty (
Up/Downboth no-ops), then removing it. The converter doesn't change the store type (timestamp with time zonethroughout), only how the CLR value'sKindis normalized before Npgsql sees it. - Verified:
dotnet buildclean (0/0). Not yet re-verified end-to-end against a live AuthHex session (same blocker as the rest of this phase's runtime testing) — the next person to get a token should re-tryPOST /employeesandPOST /payroll-statutory-settingsto confirm the500is gone.
7. Sub-phase 2.1 — Employee + User-link + Documents
Code complete + migration applied (2026-07-23).
dotnet buildclean (0/0); migrationAddHrmPhase1Foundationgenerated + applied to the local Postgres DB (new tables only + one nullableusers.Emailcolumn — the scaffolder's "possible data loss" warning is just the benignUpdateDatasetting the seeded system user'snull, not a drop). Runtime smoke-test (Swagger/browser) not yet run — do that before ticking[x], per the §6 security-gate convention §1's note established for Phase 1.
- Org masters: Branch, Department (self-nesting + cycle guard), Designation, EmploymentType, WorkShift — entities + configs + services + controllers (CRUD, ETag, deactivate-not-delete). Routes:
/branches,/departments,/designations,/employment-types,/work-shifts. - Employee entity + config (EmployeeStatus enum, EmployeeCode uniqueness, all FKs) + service + controller (
/employees) - EmployeeBankDetail (one-to-many, IsPrimary) —
GET/PUT /employees/{id}/bank-details(full-replace) User.Emailcolumn + unique index (Postgres allows multiple NULLs natively, same pattern asAuthUserId— no explicit filter needed);UsersController.Createnow persists it locally (previously silently dropped despiteCreateUserRequest.Emailbeing required) and returns it onManagedUserDto.EmployeeUserLinkService+ email-lookup endpoints (GET /employees/email-lookup,GET /users/email-lookup, both advisory/non-mutating) +POST/DELETE /employees/{id}/link-user+linkUserId/linkEmployeeIdon the two create endpoints.Employee.UserIdhas a unique index (DB-level one-User-per-Employee guarantee) backed by service-levelEMPLOYEE_ALREADY_LINKED/USER_ALREADY_LINKED(409) checks.HrDocumentTypemaster (CRUD, deactivate-not-delete) —/hr-document-typesInfra/Storage/IFileStorageService+LocalFileStorageService(rootApp_Data/hr-documentsoutside wwwroot, year/month bucketing, path-escape guard, registered as a singleton — stateless aside from the configured root)EmployeeDocumententity + upload/list/download/status endpoints (POST/GET /employees/{id}/documents,GET .../documents/{docId}/download,PATCH .../documents/{docId}/status) — extension allowlist (.pdf/.jpg/.jpeg/.png/.docx) cross-checked against declared content-type, size cap fromFileStorage:MaxSizeBytes(appsettings, default 10MB)
Deviations (recorded, not silently skipped):
- JIT-provisioning Email backfill not implemented.
ShadowUserClaimsTransformationstill only sets Username/DisplayName from the AuthHex token — the token carries noEmailclaim (confirmed set:UserId/UserTypeCode/RoleCode/NIC/jti/iat), so backfilling it would require an extra AuthHex API call (getUserDetails) inside the claims-transformation hot path. Deferred as a follow-up; the primary path (UsersController.Create, which already collectsEmailin the request body) covers the common case of an ERPCore-driven user creation. DOCUMENT_TYPE_IN_USEerror code is defined but not wired to anything — there is no hard-DELETEendpoint forHrDocumentType(same deactivate-only convention as every Phase-1 master; FR-MD-08), so nothing currently triggers it. Reserved for consistency with the doc, same posture as Phase 1's unusedMASTER_IN_USEbefore transaction tables existed.EmployeeDocumentServicereadsStream.Lengthfor the size-cap check rather thanIFormFile.Lengthdirectly — works because ASP.NET Core's defaultIFormFile.OpenReadStream()returns a seekable buffered stream, but would need revisiting if a non-seekable upload path is ever added.
8. Sub-phase 2.2 — Attendance + Leave
Code complete + migration applied (2026-07-23).
dotnet buildclean (0 errors); migrationAddHrmAttendanceAndLeavegenerated (purely additive, no data-loss warning) + applied to the local Postgres DB. Packages added:ClosedXML0.105.0,CsvHelper33.1.0. Runtime smoke-test not yet run.
- LeaveType (master CRUD,
/leave-types), LeaveRequest (Draft/Submitted/Approved/Rejected/Cancelled,/leave-requests, DocNo viaNumberSequenceService"LV"), LeaveBalance (GET/PUT /employees/{id}/leave-balances) — approving a request incrementsLeaveBalance.TakenDaysviaILeaveBalanceService.IncrementTakenDaysAsync - WorkShift-based
AttendanceComputationService(Working/Late/Early/OT minutes; overnight-shift handling; derives Present/HalfDay/Absent/Holiday/WeekOff/OnLeave) — the analog ofFifoCostingService - AttendanceUploadBatch + AttendanceRecord entities/config (
WorkShiftIdsnapshotted at ingestion per docs A.3) - Excel/CSV parsing (ClosedXML for
.xlsx, CsvHelper for.csv) +GET /attendance-batches/template.xlsx/?format=csv— both shareAttendanceUploadService.ColumnNamesso template and parser can't drift - Upload → validate (employee-code resolution against Active employees, date/time parse, within-batch + cross-batch-confirmed duplicate detection) → confirm pipeline, exact status flow Draft→Validated→Confirmed→UsedInPayroll (
/attendance-batches,.../validate,.../confirm) - Manual record edit (
PUT .../records/{id}, blocked once Confirmed/UsedInPayroll →409 ATTENDANCE_BATCH_LOCKED) + duplicate resolution (POST .../resolve-duplicate, keep/discard/supersede) + unlock (POST .../unlock, mandatory reason, blocked onceUsedInPayroll) - Leave→Attendance OnLeave classification wired in —
AttendanceUploadServicecallsILeaveRequestService.FindApprovedLeaveCoveringAsyncper record during upload and re-computation
Deviations (recorded):
- LeaveRequest.DaysCount is a calendar-day count (
EndDate − StartDate + 1), not business-day/holiday-aware. Flagged as a simplification in the service's own doc comment — a real deployment will want to exclude weekends/holidays from paid-leave day counts before this feeds Payroll. - No
Holidaycalendar entity exists yet —AttendanceComputationService.Computealways receivesisHoliday: false; onlyWeekOff(derived fromWorkShift.WorkingDaysMask) andOnLeaveare currently distinguishable from a plainAbsent. A company-holiday calendar is a natural near-term addition, not built in this pass. ResolveDuplicateAsync's "supersede" action does not yet locate/mutate the prior confirmed record — it currently just accepts the new row as Valid. The prior confirmedAttendanceRecordthis is meant to supersede is not looked up or flagged; this needs a follow-up pass before "supersede" is safe to expose to non-admin users in the UI.
9. Sub-phase 2.3 — Payroll
Code complete + migration applied (2026-07-23).
dotnet buildclean (0 errors); migrationAddHrmPayrollgenerated + applied to the local Postgres DB. Runtime smoke-test not yet run.
- SalaryComponent master (
/salary-components) — Earning/Deduction, IsTaxable, IsEpfEtfApplicable - EmployeeSalaryStructure (+Lines), effective-dated (
GET/POST /employees/{id}/salary-structure) — creating a new structure automatically supersedes the previous open-ended one (EffectiveToset the day before the newEffectiveFrom),409 SALARY_STRUCTURE_OVERLAPif the new date isn't after the current one - EmployeeLoan (+Installments) ledger (
GET/POST /employees/{id}/loans) — creating a loan generates its full installment schedule up front;IEmployeeLoanService.GetDueInstallmentsAsyncis whatPayrollCalculationServiceconsumes - PayrollStatutorySetting (
/payroll-statutory-settings), TaxSlab (/tax-slabs) — both effective-dated; creating a new statutory setting supersedes the prior open-ended one; tax slab creation validates no gap/overlap for the same effective date (422 TAX_SLAB_GAP_INVALID) PayrollCalculationService— Gross = Basic + allowance lines + OT; Net = Gross − Late − NoPay − Loan − EPF(employee) − Tax − OtherDeductions; EPF-employer/ETF are informational-only, never subtracted, matching the spec's "Company Contribution" framing; Tax via standard ascending marginal-slab computation over taxable earnings- PayrollRun/PayrollLine/PayrollLineComponent + Draft→Approved→Locked workflow (
/payroll-runs, generate/approve/lock/unlock/generate-payslips) — Generate blocked (422 ATTENDANCE_NOT_CONFIRMED) if any attendance batch for the period is still Draft/Validated; loan-installment (Pending→Deducted, balance decremented) and attendance-batch (Confirmed→UsedInPayroll) stamping deferred to Lock, not Generate/Approve, per docs A.4 - Unlock (
POST .../unlock, mandatory reason) — reverses both the loan-installment and attendance-batch stamps made at Lock, back to Approved - Payslip generation (Locked-only, idempotent) + HTML print view (
GET /payslips/{id}/view) — no PDF dependency, per the confirmed decision
Deviations / open items (recorded, not silently assumed):
- The exact APIT taxable-income base is a real compliance question, not resolved here (docs/12-BACKEND-HRM.md B.4 flags this explicitly) —
PayrollCalculationServicecomputes taxable income asBasic + taxable allowance lines + Overtimeand applies the configurableTaxSlabtable as a standard ascending marginal calculation; whether EPF-employee should reduce taxable income first, or whether OT should be taxable at all, needs finance/statutory sign-off before go-live. - NoPayAmount currently only counts plain
Absentdays, not unpaid-leave days —AttendanceRecorddoesn't yet carry whichLeaveTypecovered anOnLeaveday (or whether it's paid), so allOnLeavedays are currently treated as paid. A follow-up should either stamp the record with the leave's paid/unpaid flag at attendance-computation time, or join back toLeaveRequest/LeaveTypeduring payroll calculation. - OT/Late per-minute rate model:
dailyRate = Basic ÷ daysInMonth,perMinuteRate = dailyRate ÷ WorkShift.StandardWorkingMinutes— a simplification flagged in12-BACKEND-HRM.md B.4(tiered late/OT policies are a future improvement, not built now). PayrollRunService.GenerateAsyncskips employees with no effective salary structure for the period rather than failing the whole run — intentional (a partially-onboarded workforce shouldn't block payroll for everyone else), but means a run's employee count can silently be less than total active headcount; worth surfacing in the frontend as a warning list.- Loan-installment/attendance-batch reversal on Unlock re-derives "what this run touched" by period/status query, not from a stored per-run link table (e.g. any
Confirmed-turned-UsedInPayrollbatch for the run's period, any installment whosePayrollRunIdmatches). This is correct for the common case but would need a real link if multiple concurrent runs ever target overlapping periods/branches — not expected in this phase (one run per period/branch).
10. Sub-phase 2.4 — Reports
Code complete (2026-07-23).
dotnet buildclean (0 errors). No new entities/migration — pure read-only aggregation over Attendance/Payroll/Leave/Document tables (IHrReportService/HrReportsController,/reports/hrm/*), same posture asStockController's on-hand/ledger queries. Runtime smoke-test not yet run.
- Attendance summary (
GET /reports/hrm/attendance-summary?periodYear=&periodMonth=&departmentId=), OT report (.../overtime), late-arrival report (.../late-arrivals) - Payroll register (
.../payroll-register?payrollRunId=) —TotalDeductionscomputed asGrossSalary − NetSalary(informational EPF-employer/ETF already excluded since they were never subtracted from Net) - Employee salary history (
.../salary-history?employeeId=) — fullEmployeeSalaryStructurerevision history, ordered newest first - Leave balance report (
.../leave-balances?year=), document expiry report (.../document-expiry?withinDays=)
Manufacturing — Production Lines (Phase 2)
Spec: docs/30-BACKEND-PHASE2.md (model + rules and API — one doc, unlike Phase 1). Frontend consumption: docs/21-FRONTEND-PHASE2.md. Security: docs/02-SECURITY.md §B.6 (narrow DTOs — statuses are never client-settable) + §B.7 (FIFO row-locking inside the UoW txn).
§11–§16 code complete, migration applied, and live smoke-tested (2026-07-30).
dotnet buildclean (0 errors; the only warnings are the two pre-existingCS8981from the badly-namedchagesmigration). MigrationAddManufacturingPhase2— 11CreateTable, zeroAlterColumn, applied and verified againstinformation_schema. 312 smoke assertions, all green, via re-runnable scripts inBackend/smoke/against local Postgres + a real AuthHex session.
11. Sub-phase 2.1 — Schema + enums
- 11 entities (
ProductionTemplate,TemplateStage,StageEdge,StageInput,StageOutput,ProductionRun,RunStage,RunEdge,RunStageInput,RunStageOutput,RunStageEvent) +ProductionConfiguration.cs(all 11 configs in one file, per theStockConfiguration.csprecedent). snake_case tables, PascalCase columns, enums asvarchar(20), qty/value(18,4), unit cost + scale factor(18,6),xminRowVersion on template/run/run-stage - Enums
ProductionRunStatus,ProductionStageStatus,StageInputSource,RunStageEventType,CustomFieldType;ReasonContext+=Production;DocumentTypes.Production = "PRD"; newDomain/LedgerSourceTypes.cs - 4 Production reason codes seeded idempotently (
PRD-SCRAP,PRD-LEFTOVER,PRD-CANCEL,PRD-REWORK-LOSS) — verified live viaGET /reason-codes?context=Production - jsonb (
field_defs,field_values,payload) as CLRstring+HasColumnType("jsonb"), always written throughProductionJsonso a column can only hold canonical JSON. Follows theAuditLog.ChangeSetprecedent; a typed/owned mapping would makeAuditScribeemit spurious audit rows for the nested entries
12. Sub-phase 2.2 — Templates + graph validation (FR-MFG-01..07)
Smoke: 38/38 (
Backend/smoke/m2_templates.py). Zero stock touched.
ProductionGraphValidator— apublic static class, deliberately not an injected service (pure, synchronous, no DI). Kahn toposort →GRAPH_CYCLE; terminal count →GRAPH_TERMINAL_COUNT; one combined bidirectional-reachability check →GRAPH_DISCONNECTED; direct-parent check →GRAPH_INPUT_SOURCE_INVALID; terminal output →TERMINAL_OUTPUT_ITEM_REQUIRED. Works in keys, not ids, so identical code serves POST and PUT/production-templateslist/get/create/update/status, ETag +If-Match(428 missing, 412 stale),409 TEMPLATE_IN_USEon PUT while a run is InProgress- Full-graph PUT reconciliation: stages diffed (a run references them), inputs/outputs replaced, edges diffed (unique
(parent, child)index). Rebuilt through navigation properties so EF resolves generated keys in oneSaveChanges
13. Sub-phase 2.3 — Run creation, board, detail, quantities (FR-MFG-08/09/18)
Smoke: 54/54 (
m3_runs.py). Zero stock touched.
POST /production-runs— copies stages/inputs/outputs/edges in three passes, scales from the unrounded ratio (rounding each quantity once, so a repeating scale factor doesn't compound),PRD-2026-0000NfromNumberSequenceServiceinside the transaction, entry stagesReadyGET /production-runswithstageSummarycomputed server-side; projected to an anonymous type first then mapped client-side (EF Core 10 cannot translate a record ctor alongside aggregates — same failure asWarehouseValuationDto, 2026-07-28)GET /production-runs/{id}full graph incl. events, derivedisTerminal/isEntry/availableToTransfer/actualMinutes/costPoolPUT .../stages/{sid}/quantities—409 STAGE_NOT_EDITABLEonce started, and re-evaluates readiness (raising an upstream planned qty demotes a Ready stage back to Waiting)
14. Sub-phase 2.4 — Stage execution (FR-MFG-10/11/12) · first stock-touching
Smoke: 66/66 (
m4_stage_actions.py) + 14/14 (m4b_uom_conversion.py). IsolatedSMOKE-PRDwarehouse.
…/start— FIFO-consumes Stock inputs viaIFifoCostingService.ConsumeAsync,PRDIledger,actualStartAt. Consumesmax(0, plannedBase − consumedQty)so a rework restart draws only the delta…/complete— produced/scrapped per output + custom field values;400 REQUIRED_FIELD_MISSING,400 REASON_CODE_REQUIRED, Production-context reason enforced. Overwrites on a re-complete…/approve(non-terminal) +…/transfer— default full transfer, optional partial,422 TRANSFER_EXCEEDS_AVAILABLE, child readiness recomputed. Routes byfromRunOutputId, not by edgeIUomConverterextracted fromGrnService.ToBaseAsyncintoServices/Stock/UomConverter.cs;GrnServicedelegates to it, behaviour unchanged. Verified: a stage input declared in a 12× UOM consumes 360 base units, not 30; the ledger records base; an undefined conversion is422, never assumed 1:1
15. Sub-phase 2.5 — Terminal receipt + cost pool (FR-MFG-13)
Smoke: 36/36 (
m5_receipt.py).
- Terminal approve creates the finished layer at
costPool / goodQty, postsPRDR, completes the run and closes the pool (409 RUN_COST_CLOSED) decimal? valueOverrideadded toIFifoCostingService.PostLedgerAsync(default keepsround(qty × unitCost, 4); every existing call site unaffected). Empirically necessary, not theoretical: at 300 units the 6 dp unit cost gives a naive value of3405.5553against a pool of3405.5552— a real 0.0001 drift. The smoke test asserts the naive product would have drifted, so the fixture cannot silently go blind- Batch/serial-tracked finished goods refused with
422(this phase defines no batch creation on receipt). Untested — no tracked item exists in the dev DB; noted in the script
16. Sub-phase 2.6 — Leftover return, rework, cancel (FR-MFG-14..17)
Smoke: 104/104 (
m6_m7_leftover_rework_cancel.py).
…/return-leftover— inbound at the input's consumed weighted cost,PRDL, bin null (raw material, not the finished-goods bin). Value computed from the unrounded weighted cost and rounded once; a full return takes the exact residual soreturnedValue == consumedValueprecisely.422 LEFTOVER_EXCEEDS_CONSUMED,409 RUN_COST_CLOSED…/reject-intake— parenttransferredQtydecremented (not zeroed, so a parent that also fed another child stays consistent), parentApproved → InProgresswithActualStartAtpreserved, rejecting stage →Waiting. Allowed fromReadyorWaitingwith delivered intake…/reject(terminal) — whole-run reset with one snapshot event per pass;plannedQtyandconsumed*/returned*preserved, stock untouched. Verified across two consecutive rework passesPOST .../cancel— returnsconsumed − returnedper input at the consumed weighted cost (PRDC),balancesdictionary accumulated per item (layers created in-transaction are invisible toGetOnHandAsyncuntilSaveChanges), scrapped output qty recorded asscrappedWrittenOff.409 RUN_NOT_CANCELLABLE- Event history + estimated-vs-actual (FR-MFG-19) — every action writes one
RUN_STAGE_EVENT; failed actions write none (the write rolls back with the change)
Bugs found and fixed during this phase (not silently patched):
- Template PUT 500 — deleting a
TemplateStagewhile aStageEdgestill referenced it severed a required EF relationship. Edges are now removed before stages; any edge touching a deleted stage is by construction absent from the payload, so nothing the caller wanted is orphaned. receipt.layerIdreturned 0 — theReceiptDtowas built inside the transaction, beforeSaveChangesgenerated the id. Now mapped after the commit (same fix as theledgerRefs:[0]issue recorded 2026-07-13).- Runtime messages carried U+2212 (typographic minus) and broke console/log encoding on Windows cp1252. Exception strings now use ASCII hyphens; comments keep the typographic form, matching the rest of the codebase.
Deviations / decisions (recorded, not silently assumed) — all mirrored into docs/30:
- §A.1 was a no-op.
StockLayer.GrnLineIdwas already nullable in entity, config, snapshot and database. Phase-1 schema was not altered at all; NFR-08 holds without exception. - Ledger codes are
PRDI/PRDR/PRDL/PRDC, not the doc's 15–22-char names —SourceDocTypeisvarchar(10)on bothstock_ledgerandjournal_entry_stubs, and widening it would have been a second Phase-1 schema change. - Three additions to Part C:
RUN_EDGE(a run must own its edges or a later template edit rewrites completed-run history),RUN_STAGE.pos_x/pos_y(the run canvas renders from them),RUN_STAGE_EVENT.run_id+ nullablerun_stage_id(run-level events, single-query timeline).RUN_STAGE.template_stage_idmade nullable +SET NULLso a template stays editable after runs complete. - UOM conversion is unspecified in docs/30 but essential. Contract consequence:
plannedQtyis in the input's declared UOM whileconsumed*/returned*are in the item's base UOM. Idempotency-Keyaccepted and ignored, matchingGrnService.ConfirmAsync. Status guards are the replay story;RunStage.RowVersion(xmin) prevents two concurrent terminal approves double-posting a receipt.- FR-MFG-17's "− scrapped" is not computable at the input level (scrap lives on outputs, in output UOM). Scrap never entered stock, so nothing is deducted; scrapped quantities are recorded on the cancel event instead.
GRAPH_DISCONNECTEDis unreachable once cycle + terminal-count pass; kept as defence in depth. An isolated stage surfaces asGRAPH_TERMINAL_COUNT.- Edit-lock TOCTOU accepted — checked inside the transaction, but under READ COMMITTED a run could still be created against a template mid-edit. Benign: runs copy everything at creation and never re-read the template.
Not done this pass (tracked, not silently skipped):
- Frontend wiring (
docs/21-FRONTEND-PHASE2.md§8) — done in the same session; seeFrontend/PROGRESS.md§§11–13. Not browser-verified (same AuthHex blocker). - Batch/serial-tracked finished goods — guarded with a 422, and that guard is unexercised (no tracked item in the dev DB).
NAV:productionpermission is not seeded; the sidebar still relies on thebypassCodesstopgap (same asprocurement/hrm).- No automated test project — verification is the
Backend/smoke/scripts, per house practice.
2026-07-30 — Dev-database repair + a drift audit worth repeating
users."Email"was missing from the database while present in the entity and the model snapshot, soShadowUserClaimsTransformation's JIT insert failed with42703on every authenticated request — surfacing to callers as a confusingInvalidOperationException: Sequence contains no elements.GET /itemsand everything else 500'd. Fixed by the hand-written migrationRepairUserEmailColumn(idempotentADD COLUMN IF NOT EXISTS+ the unique index, matchingUserConfiguration'sHasMaxLength(320)).Root cause — four migrations recorded as applied with zero operations:
ini2,initial2,chages,chages1each advanced the model snapshot without emitting any DDL. Anything added to the model in those windows exists in the snapshot but never reached the database.Method note (this is the reusable part): a scaffolded probe migration coming back empty proves only
model == snapshot, neversnapshot == database— which is exactly how this hid. The real audit wasdotnet ef dbcontext script(which renders the current model) diffed againstinformation_schema.columns.Still outstanding — not fixed here, deliberately: the same four empty migrations mean all 25 HRM tables (
hr_*) exist in the model and snapshot but not in this database, so every HRM endpoint fails. Creating 25 tables of another module as a side effect of manufacturing work would be worse than reporting it; it needs its own repair migration and its own verification.Also worth knowing:
.gitignore:38is**/Migrations/, so no migration in this repo is version-controlled —AddManufacturingPhase2andRepairUserEmailColumnexist only on the machine that created them. Anyone else must regenerate them.
2026-07-30 (later) — Three server-side additions the frontend wiring needed
All three are amended into
docs/30as built. None changes an existing endpoint's behaviour.
TemplateGraphDto.activeRunCount— the builder derives its edit-locked state from it. Counted with its own scalar query rather than anInclude, because the graph query already fans out over four collections and addingRunswould multiply those rows again for one integer.production_templates."Annotations"(jsonb) +SaveTemplateRequest.Annotations, migrationAddTemplateCanvasAnnotations(exactly oneAddColumn, applied and verified). The builder canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so every save would have silently discarded the user's layout. Stored throughProductionJsonlike every other jsonb column, so the column can only ever hold canonical JSON;List<CanvasAnnotationDto>capped at 200 by[MaxLength], andKindvalidated tobox/lineinValidateAsyncbecause nothing else constrains free-form client state going into jsonb. Deliberately invisible toProductionGraphValidator— annotations carry no graph semantics.- Wholesale replacement is the flip side and is now pinned by an assertion: a PUT that omits
annotationsclears them.m2_templates.pyasserts preserve → clear → restore explicitly, because silent data loss is worse than an error.Smoke suite: extended but NOT re-run.
m2_templates.pygained 10 assertions (annotation round-trip incl. geometry/label/rotation,activeRunCounton the graph, unknown-kind rejection, and the clear/restore pair). These are unverified. AuthHex cannot issue a token — its configured MySQL host187.127.102.190:3306is unreachable from this machine (MySqlConnector … Connect Timeout expiredonPOST /api/user), and the localhost alternative in itsappsettings.jsonis commented out.dotnet buildis clean and the migration applied cleanly, but the last full green run of the suite (312/312) predates these additions.HRM schema gap CLOSED (by the repo owner, not this work): migrations
production(another empty one — the fifth) andAddHrmTablesnow exist, the latter creating all 25hr_*tables. The "still outstanding" note in the entry above is resolved; the underlying lesson about empty migrations is not.
Done
2026-07-28 — Dashboard overview endpoint (GET /dashboard/stats)
- New cross-domain aggregate for the frontend dashboard —
Dtos/Dashboard/DashboardDtos.cs,IDashboardService/DashboardService,DashboardController(GET /api/v1/dashboard/stats). Mirrorsdocs/dashboard-implementation.pdf's widget list: low-stock alerts (reusesIReorderService.GetAlertsAsync,PageSize:1since it computes the full count before paging), on-hand total/warehouse-count and stock-valuation total/by-warehouse (all SQL-sideSUM/GROUP BYoverStockLayer, cheap unlike reorder alerts since they need no per-item live lookup), pending-approval POs, pending (Draft) GRNs, open (Submitted) requisitions, pending (Counted) stock counts, open RFQs. Registered inProgram.cs. docs/11-BACKEND-PHASE1.md §5.8. - Not covered: GRN inspection-hold counts (
HoldStatuslives on GRN lines, no list/count endpoint exposes it) and recent stock movements (frontend callsGET /stock/ledgerdirectly — no aggregation needed for a smallpageSize). - Bug found + fixed during this work —
WarehouseValuationDtoconstruction insideGroupBy().Select()doesn't translate. EF Core 10 can't turn a record's constructor call into SQL inside a grouped projection (InvalidOperationException, confirmed live vialogs/erpcore-20260728.log). Fixed by projecting to an anonymous type first (Select(g => new { g.Key, Total = ... })), materializing withToListAsync, then mapping to the DTO record client-side. - Unrelated bug found while testing this —
GET /items500s on every call:column i.SalePrice does not exist.ItemConfiguration.csmapsItem.SalePrice, but theAddItemSalePricemigration (2026-07-22 entry above) was never actually applied to this dev database — despite that entry claiming "Applied to the local DB". Confirmed vialogs/erpcore-20260727.log;dotnet ef migrations addagainst the current model produces an empty migration (noUp/Downops), meaning the model snapshot already believesSalePriceexists even though the column doesn't — the snapshot and the real schema have drifted. Not yet fixed — needs a hand-writtenAddColumnmigration (the auto-diff can't see the gap) run against this specific database; blocks the dashboard's on-hand/valuation widgets from ever showing item names, and blocks the entire Products page and every item picker (GRN/PO/ledger/valuation). - Verified:
dotnet buildclean (isolated output directory, to avoid the Visual-Studio-debugger file lock that repeatedly blocked rebuilding the live dev instance this session). Runtime-verified against the live log after a VS restart — confirmed reaching real code (not 404), theGroupBybug above was caught this way. Full 200-response verification still pending the next VS restart.
2026-07-22 — Item fixed sale price + GRN off-PO items (migration AddItemSalePrice)
- Item sale price (FR-MD-01). New nullable
Item.SalePrice(numeric(18,4),ItemConfiguration.HasPrecision(18,4)), threaded throughItemListItemDto/ItemDetailDto/CreateItemRequest/UpdateItemRequest([Range(0, …)]) and mapped inItemService(create/update/ToDetail/list projection). Sales-only — it never touchesGrnService, FIFO,StockLayer, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged.null⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (noprice_modeenum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1. - GRN off-PO items (FR-GRN-01). A PO-based GRN may now carry lines with
poLineId: null(item not on the PO). No backend change —GrnService.CreateAsyncalready routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (po_line_idnullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3. - Migration
AddItemSalePrice— single nullable column add; no backfill (Down()drops it). Applied to the local DB (dotnet ef database update→ Done). - Verified:
dotnet buildclean (compile succeeded; the only earlier failure was the running dev exe holding a file lock, resolved by stopping it). Migration Up applied. Frontendtsc --noEmitclean. End-to-end runtime smoke (Swagger/UI) still to be run by the user. - 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