Files
ERP-core/Backend/PROGRESS.md
Sasanka b12adebaa0 feat: add dashboard overview stats endpoint and UI integration
- Implemented `GET /dashboard/stats` in `DashboardController` to provide aggregate counts for stock, GRN, and procurement.
- Created `DashboardStatsDto` and `WarehouseValuationDto` to structure the response data.
- Developed `DashboardService` to fetch and compute necessary statistics from the database.
- Added `IDashboardService` interface for service abstraction.
- Introduced API client methods in `dashboard.ts` for frontend consumption of the new endpoint.
- Defined TypeScript types for dashboard data in `dashboard.ts` to ensure type safety in the frontend.
- Updated UI components in the frontend to reflect changes in the dashboard, including styling adjustments and removal of unused icons.
2026-07-28 14:59:09 +05:30

67 KiB
Raw Permalink Blame History

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

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

0. Bootstrap

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

1. Master Data

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

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

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

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

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

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

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

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

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

2. Procurement

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

  • Requisition (+ lines) + submit (POST /requisitions, /{id}/submit, list, get)
  • RFQ + quotations + comparison (POST /rfqs, /{id}/quotations [one per vendor], GET /{id}/comparison matrix)
  • Purchase Order: create (auto-approve or saveAsDraft, approvalRequired flag), 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-returns auto-posts an outbound FIFO consume via shared StockMutator; mandatory Return-context reason (REASON_CODE_REQUIRED→400, wrong context→422), references the GRN line for traceability, over-return→409 STOCK_NEGATIVE_BLOCKED. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)

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

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

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

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

3. Goods Receipt

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

  • GRN create (against PO / direct), over-receipt tolerance — unitCost defaults to the PO price but is now overridable per line (variance recorded vs poUnitPrice snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires vendorId + entered cost (AR-04); over-receipt → 422 OVER_RECEIPT_TOLERANCE (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. 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: null on a PO-based GRN is received like a direct line (entered unitCost, no over-receipt check, PO balances untouched). No code change was neededGrnService.CreateAsync already branches per-line on input.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-Key accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
  • Inspection hold release / reject — Release fully verified (OnHold excluded from available, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).

4. Stock Core

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

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

5. Stock Transactions

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

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

6. Cross-cutting

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

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

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

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

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 DateTime values written to a timestamp with time zone column to have Kind=Utc. Every Phase-1 DateTime was 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 produces Kind=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 a 500 (DbUpdateExceptionArgumentException: Cannot write DateTime with Kind=Unspecified...). Confirmed via logs/erpcore-20260723.log.
  • Fix: a global EF Core ValueConverter<DateTime, DateTime>/ValueConverter<DateTime?, DateTime?> registered once in ErpDbContext.OnModelCreating (applied to every entity property of type DateTime/DateTime? via modelBuilder.Model.GetEntityTypes()), forcing Kind=Utc on 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/Down both no-ops), then removing it. The converter doesn't change the store type (timestamp with time zone throughout), only how the CLR value's Kind is normalized before Npgsql sees it.
  • Verified: dotnet build clean (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-try POST /employees and POST /payroll-statutory-settings to confirm the 500 is gone.

Code complete + migration applied (2026-07-23). dotnet build clean (0/0); migration AddHrmPhase1Foundation generated + applied to the local Postgres DB (new tables only + one nullable users.Email column — the scaffolder's "possible data loss" warning is just the benign UpdateData setting the seeded system user's Email to null, 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.Email column + unique index (Postgres allows multiple NULLs natively, same pattern as AuthUserId — no explicit filter needed); UsersController.Create now persists it locally (previously silently dropped despite CreateUserRequest.Email being required) and returns it on ManagedUserDto.
  • EmployeeUserLinkService + email-lookup endpoints (GET /employees/email-lookup, GET /users/email-lookup, both advisory/non-mutating) + POST/DELETE /employees/{id}/link-user + linkUserId/linkEmployeeId on the two create endpoints. Employee.UserId has a unique index (DB-level one-User-per-Employee guarantee) backed by service-level EMPLOYEE_ALREADY_LINKED/USER_ALREADY_LINKED (409) checks.
  • HrDocumentType master (CRUD, deactivate-not-delete) — /hr-document-types
  • Infra/Storage/IFileStorageService + LocalFileStorageService (root App_Data/hr-documents outside wwwroot, year/month bucketing, path-escape guard, registered as a singleton — stateless aside from the configured root)
  • EmployeeDocument entity + 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 from FileStorage:MaxSizeBytes (appsettings, default 10MB)

Deviations (recorded, not silently skipped):

  • JIT-provisioning Email backfill not implemented. ShadowUserClaimsTransformation still only sets Username/DisplayName from the AuthHex token — the token carries no Email claim (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 collects Email in the request body) covers the common case of an ERPCore-driven user creation.
  • DOCUMENT_TYPE_IN_USE error code is defined but not wired to anything — there is no hard-DELETE endpoint for HrDocumentType (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 unused MASTER_IN_USE before transaction tables existed.
  • EmployeeDocumentService reads Stream.Length for the size-cap check rather than IFormFile.Length directly — works because ASP.NET Core's default IFormFile.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 build clean (0 errors); migration AddHrmAttendanceAndLeave generated (purely additive, no data-loss warning) + applied to the local Postgres DB. Packages added: ClosedXML 0.105.0, CsvHelper 33.1.0. Runtime smoke-test not yet run.

  • LeaveType (master CRUD, /leave-types), LeaveRequest (Draft/Submitted/Approved/Rejected/Cancelled, /leave-requests, DocNo via NumberSequenceService "LV"), LeaveBalance (GET/PUT /employees/{id}/leave-balances) — approving a request increments LeaveBalance.TakenDays via ILeaveBalanceService.IncrementTakenDaysAsync
  • WorkShift-based AttendanceComputationService (Working/Late/Early/OT minutes; overnight-shift handling; derives Present/HalfDay/Absent/Holiday/WeekOff/OnLeave) — the analog of FifoCostingService
  • AttendanceUploadBatch + AttendanceRecord entities/config (WorkShiftId snapshotted at ingestion per docs A.3)
  • Excel/CSV parsing (ClosedXML for .xlsx, CsvHelper for .csv) + GET /attendance-batches/template.xlsx/?format=csv — both share AttendanceUploadService.ColumnNames so 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 once UsedInPayroll)
  • Leave→Attendance OnLeave classification wired in — AttendanceUploadService calls ILeaveRequestService.FindApprovedLeaveCoveringAsync per 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 Holiday calendar entity exists yetAttendanceComputationService.Compute always receives isHoliday: false; only WeekOff (derived from WorkShift.WorkingDaysMask) and OnLeave are currently distinguishable from a plain Absent. 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 confirmed AttendanceRecord this 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 build clean (0 errors); migration AddHrmPayroll generated + 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 (EffectiveTo set the day before the new EffectiveFrom), 409 SALARY_STRUCTURE_OVERLAP if 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.GetDueInstallmentsAsync is what PayrollCalculationService consumes
  • 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) — PayrollCalculationService computes taxable income as Basic + taxable allowance lines + Overtime and applies the configurable TaxSlab table 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 Absent days, not unpaid-leave days — AttendanceRecord doesn't yet carry which LeaveType covered an OnLeave day (or whether it's paid), so all OnLeave days 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 to LeaveRequest/LeaveType during payroll calculation.
  • OT/Late per-minute rate model: dailyRate = Basic ÷ daysInMonth, perMinuteRate = dailyRate ÷ WorkShift.StandardWorkingMinutes — a simplification flagged in 12-BACKEND-HRM.md B.4 (tiered late/OT policies are a future improvement, not built now).
  • PayrollRunService.GenerateAsync skips 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-UsedInPayroll batch for the run's period, any installment whose PayrollRunId matches). 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 build clean (0 errors). No new entities/migration — pure read-only aggregation over Attendance/Payroll/Leave/Document tables (IHrReportService/HrReportsController, /reports/hrm/*), same posture as StockController'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=) — TotalDeductions computed as GrossSalary NetSalary (informational EPF-employer/ETF already excluded since they were never subtracted from Net)
  • Employee salary history (.../salary-history?employeeId=) — full EmployeeSalaryStructure revision history, ordered newest first
  • Leave balance report (.../leave-balances?year=), document expiry report (.../document-expiry?withinDays=)

Done

2026-07-28 — Dashboard overview endpoint (GET /dashboard/stats)

  • New cross-domain aggregate for the frontend dashboardDtos/Dashboard/DashboardDtos.cs, IDashboardService/DashboardService, DashboardController (GET /api/v1/dashboard/stats). Mirrors docs/dashboard-implementation.pdf's widget list: low-stock alerts (reuses IReorderService.GetAlertsAsync, PageSize:1 since it computes the full count before paging), on-hand total/warehouse-count and stock-valuation total/by-warehouse (all SQL-side SUM/GROUP BY over StockLayer, 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 in Program.cs. docs/11-BACKEND-PHASE1.md §5.8.
  • Not covered: GRN inspection-hold counts (HoldStatus lives on GRN lines, no list/count endpoint exposes it) and recent stock movements (frontend calls GET /stock/ledger directly — no aggregation needed for a small pageSize).
  • Bug found + fixed during this work — WarehouseValuationDto construction inside GroupBy().Select() doesn't translate. EF Core 10 can't turn a record's constructor call into SQL inside a grouped projection (InvalidOperationException, confirmed live via logs/erpcore-20260728.log). Fixed by projecting to an anonymous type first (Select(g => new { g.Key, Total = ... })), materializing with ToListAsync, then mapping to the DTO record client-side.
  • Unrelated bug found while testing this — GET /items 500s on every call: column i.SalePrice does not exist. ItemConfiguration.cs maps Item.SalePrice, but the AddItemSalePrice migration (2026-07-22 entry above) was never actually applied to this dev database — despite that entry claiming "Applied to the local DB". Confirmed via logs/erpcore-20260727.log; dotnet ef migrations add against the current model produces an empty migration (no Up/Down ops), meaning the model snapshot already believes SalePrice exists even though the column doesn't — the snapshot and the real schema have drifted. Not yet fixed — needs a hand-written AddColumn migration (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 build clean (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), the GroupBy bug 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 through ItemListItemDto/ItemDetailDto/CreateItemRequest/UpdateItemRequest ([Range(0, …)]) and mapped in ItemService (create/update/ToDetail/list projection). Sales-only — it never touches GrnService, 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 (no price_mode enum). 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 changeGrnService.CreateAsync already 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_id nullable) + 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 build clean (compile succeeded; the only earlier failure was the running dev exe holding a file lock, resolved by stopping it). Migration Up applied. Frontend tsc --noEmit clean. End-to-end runtime smoke (Swagger/UI) still to be run by the user.
  • PO draft lifecycle (FR-PROC-05 revised). CreatePurchaseOrderRequest.SaveAsDraft (default false → auto-approve unchanged; trueDraft). New POST /purchase-orders/{id}/submit (Draft→Approved, else 409 PO_NOT_EDITABLE) and DELETE /purchase-orders/{id} (Draft-only, else 409). IsEditable narrowed from "not FullyReceived/Closed/Cancelled" to Draft only — so PUT now 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 is Approved and therefore now uneditable/undeletable — intended, not a regression. No schema change (reuses the existing Draft enum value).
  • GRN discount + VAT + price override. GrnLine gained PoUnitPrice(nullable snapshot), DiscountPct, NetUnitCost, VatPct, VatAmount, LineTotal. All derived figures server-computed, never client-supplied. FIFO layer + ledger now cost at NetUnitCost (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, unitCost defaults to the PO price but an entered override wins and a variance is recorded against PoUnitPrice (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 via openQty/QtyReceived and 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; PoUnitPrice left NULL for historical rows (no retroactive variance). Down() drops the six columns cleanly.
  • Verified: dotnet build clean (0/0); migration Up and Down exercised against the live DB (rollback to AddRolesNavPermissions then re-apply — both Done). 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 SubNavItem rows + GET /auth/me navCodes; only Products/Settings had children, so Purchase Orders had no sidebar section. Added 4 SubNavItems (ids 912, NavItemId 4) + 4 Permissions (ids 1922) for Requisitions/RFQs/Purchase Orders/Purchase Returns via AddProcurementSubNav. The migration also grants the 4 to any role already holding the parent NAV:procurement (raw SQL, ON CONFLICT DO NOTHING); Down() removes the grants then the rows.
  • Found: the Admin role (RoleId 2) was never granted NAV:procurement at all (nor Vendors), so its whole Procurement branch was hidden — granted the parent + 4 children directly. Verified: /auth/me for Admin returns procurement + all 4 children; frontend tsc/eslint clean.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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