feat: add vendor optional fields and configuration hooks

- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
This commit is contained in:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
+52
View File
@@ -591,3 +591,55 @@ Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike
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`/`Nic` — **not `UserName`** — and ignores its `userTypeId` argument 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.
- **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.
### 2026-10-17 — Procurement configuration, PO tax/PDF/approval queue, item edit, opening balances
- **Procurement configuration (`procurement_config`, singleton):** `VendorContactMode` (None/Mobile/Email), `VendorAddressEnabled`, `PoTaxEnabled`. `GET /api/v1/procurement-config` is open to any signed-in user (the vendor and PO forms need it); `PUT` needs the `settings` nav policy (ETag/If-Match like the other configs). UI: Settings → Configuration → Procurement Configuration.
- **Vendors:** nullable `Mobile`/`Email`/`Address`. A value for a switched-off field is `422 CONFIG_DISABLED`; format-checked when on; an update leaves a switched-off field as stored.
- **Purchase orders:** `po_lines.TaxAmount` stores round(qty × price × rate, 2) (half away from zero); totals sum the stored amounts. A non-zero rate with `PoTaxEnabled` off is `422 CONFIG_DISABLED`. List/detail DTOs carry a `vendor` block (contact/address only when enabled). PO table: vendor name / contact / code / address first, PDF download per PO (`lib/pdf/purchase-order-pdf.ts`, jsPDF). Create / submit / approve / reject / cancel return to the PO table. List search also matches vendor name/code.
- **Sidebar (DB):** `SubNavItem` 50 + `Permission` 60 `procurement.po-approvals` (PO Approval Queue); `SubNavItem` 51 + `Permission` 61 `accounts.opening-balances`. Admin is granted both. Note `procurement` is still in the sidebar's `bypassCodes`, so the UI shows the queue entry to everyone; the API still enforces approval rules.
- **Price adjustment:** no new flag — `ApprovalConfig.PriceAdjustmentLevels` (0/1/2) already is the dual-approval switch; only the wording and the "Confirm and apply" button at level 0 were added. **Item edit** now has the Warranty section and gates the selling-price fields on `sellingPricesEnabled`.
- **Opening balances (`opening_balance_runs`, `opening_balance_lines`, `opening_stock_lines`):** `POST /api/v1/opening-balances/cash-bank` posts ONE journal (journal type `OPENING_BALANCE`): Dr each cash/bank account, Cr "Opening Balance Equity" (created in the GL on first use). Once only — `OnceKey` unique index; the GL itself would accept a second one. `POST /opening-balances/stock` creates FIFO layers + `OPN` ledger rows and one Dr Inventory / Cr Opening Balance Equity journal; an item/warehouse with any prior stock movement is refused; untracked stocked items only.
- **Opening stock deliberately bypasses `StockMutator`/`FifoCostingService.PostLedgerAsync`**, because that path posts its own `STOCK` journal (Dr Inventory / Cr Clearing) per ledger row. **OPEN QUESTION (not verified against live data):** GRN and stock adjustments call that path *and* post their own journal, which looks like Inventory is debited twice per receipt. Check a posted GRN's journals in the GL before trusting inventory balances.
- **Migrations (additive, NOT yet applied anywhere):** `AddProcurementConfigAndVendorContact`, `AddPoLineTaxAmount` (backfills `TaxAmount` for lines that already had a rate), `AddPoApprovalsNav`, `AddOpeningBalances`. They apply on the next start of the backend (`MigrateAsync`) — back up the production DB first.
- **Tests:** `ProcurementAndOpeningBalanceTests` (22 tests). `DualApprovalTests.cs` does not compile (stale `PriceAdjustmentService` constructor call) — pre-existing, untouched.
### 2026-10-17 (2) — Roles and permissions owned by the ERP; AuthHex = authentication only (Phase A)
- **Why:** the role behind every permission check came from the AuthHex token's `RoleCode`; `users.RoleId` was a decorative copy, so changing a user's role changed nothing (and a stale token kept old rights for up to ~1000 min).
- **New model:** `users.RoleId` -> `roles` (now with `IsAdmin`, `AuthRoleId` nullable) -> `role_permissions`. "Admin" is the `IsAdmin` role flag, never the role's code/name. `access_config` (singleton) holds the **default role** for first sign-ins and role-less people; the seeder creates an `Admin` role and a permission-free `NoAccess` role and points the default at it (first time only).
- **Per request:** `ShadowUserClaimsTransformation` loads user + role + branch in ONE query and injects `erp:RoleId/RoleCode/IsAdmin` (and `erp:Blocked` for an inactive user, refused by the `ErpAccess` policy). `ICurrentUser` reads those; **no AuthHex role claim is trusted**. A role-less user is assigned (and saved) the role named by a still-present legacy token claim (`Auth:LegacyRoleClaimFallback`, default on, turn off after the backfill), else the default role.
- **Permissions:** `NavPermissionService` is keyed by role id and evicted on any role/permission change (`Invalidate`), so edits apply on the next request. `/auth/me` returns `roleId`, `hasRole` and `permissions` (nav code -> actions bitmask); an administrator always receives every code. The UI uses `can(navCode, action)`; people with no role see "Waiting for access".
- **Role/user management:** role CRUD no longer calls AuthHex; the Admin/system/default roles can't be deactivated or deleted; the last active administrator can't be demoted/deactivated, nobody can remove their own admin access or deactivate themselves; the default role can never be an admin role; user detail page can now change the role. `Level2RoleCodes` became `Level2RoleIds` (migration converts); the PO approve/reject rule follows the `procurement:Approve` permission instead of a hard-coded Admin check. Self-registration no longer accepts a role id.
- **One-time backfill:** admin-only `POST /api/v1/users/backfill-roles` copies each role-less user's role from AuthHex (reports the ones it cannot map).
- **AuthHex (`ERP_Auth_Service`):** `/api/role`, the role entity/repo/service, the token's `RoleId`/`RoleCode` claims and every `roleId` in its payloads are gone; `registerUser` ignores a sent `roleId`. Run `Docs/migrations/2026-10-remove-roles.sql` (PART 1) BEFORE deploying it. Not touched: its anonymous `registerUser`/`getUserDetails`/`LogoutUser`, and `alt/VerifyOTP` trusting a caller-supplied identifier (pre-existing; recommended follow-ups).
- **ROLLOUT ORDER:** (1) back up both DBs; (2) deploy ERPCore (applies migration `RolesOwnedByErp`, seeds roles) with AuthHex still old; (3) as an admin run the backfill and fix anything it reports; (4) run AuthHex SQL PART 1, deploy new AuthHex; (5) set `Auth:LegacyRoleClaimFallback=false`. New roles created in the ERP have no AuthHex role, so until step 4 set `AuthHex:LegacyRoleId` to any existing AuthHex role GUID so registration keeps working.
- **Tests:** `AccessControlTests` (29), `DualApprovalTests` repaired (it did not compile) and moved to role ids. Phase B follows below.
### 2026-10-18 — Permission coverage (Phase B)
- **Permissions seeded** (migration `PermissionCoverage`, **not applied**): nav `hrm` (15) with `hrm.employees/attendance/leave/payroll/reports/settings` (52–57), nav `sales-returns` (16), `settings.audit-logs` (58) and the missing permission row for `settings.notification-config` (24); permissions 62–71. Grants are idempotent `INSERT IGNORE`, keyed on `roles.IsAdmin`: administrator roles get all ten; roles that could reach Sales keep Sales Returns (same actions). **Nobody else gets HRM or the audit log** — HRM used to be open to every signed-in user, so non-admin HR staff need a grant under Settings → Roles after deploy.
- **Controllers:** all 16 HRM controllers gated (masters: read = any HR role, write = `hrm.settings`; employees = `hrm.employees` + Create/Edit/Delete; salary structure, loans, bank details, payslips, payroll runs and the payroll/salary-history reports need `hrm.payroll`; attendance confirm/unlock, leave approve/reject and payroll approve/lock/unlock need Approve). `AuditLogsController` -> `settings.audit-logs`; `SalesReturnsController` -> `sales-returns`; `ReasonCodesController` writes -> `settings`; `BranchesController` writes -> `settings.branches` (reads stay open for the pickers); `VendorsController` fixed (`Nav:vendors` matched nothing, so only admins reached it): read = `procurement`, create/edit/delete = `procurement.vendors`. `Edit` is enforced on HRM and vendors; `View` is not enforced (stored only).
- **Global search** (`GlobalSearchService`) searches items / purchase orders / invoices / employees only when the role may open `products` / `procurement` / `sales` / `hrm.employees`.
- **CSRF:** `CsrfProtectionFilter` is registered globally; every POST/PUT/PATCH/DELETE that rides on the `erp_at` cookie must carry `X-XSRF-TOKEN` (skipped for Bearer callers, `[AllowAnonymous]` actions, actions with their own `[ValidateCsrf]`, and requests with no session cookie). The frontend api-client now sends the header on every state-changing call and, on a `CSRF_TOKEN_MISMATCH`, renews the session once and retries (old sessions have no XSRF cookie).
- **Branch hole closed:** a signed-in non-admin with no linked employee used to fall through the branch query filters and read every branch (writes already refused them). In a branching deployment they are now scoped to `CurrentBranchContext.NoBranchId` (0, matches nothing); with branching off nobody is scoped. `erp:BranchingEnabled` rides on the claims query (still one round trip). Bank deposits refuse a branch-less user with a clear message.
- **Frontend:** `bypassCodes` removed from the sidebar; `Audit Log` menu entry + page; route guard (`lib/nav-routes.ts`, built from the sidebar list, enforced in `AccessGate`); `api-client` dispatches `erp:forbidden` on any 403 and `AuthProvider` re-reads permissions; employee detail hides the Bank Details / Salary & Loans tabs without `hrm.payroll`.
- **Tests:** `PermissionCoverageTests` (29 cases; suite 196/196): every data-changing endpoint has a Nav policy (allow-list for personal endpoints), every named code is seeded, no duplicate codes / every sub item has a permission, HR pay data needs payroll, policy decisions per role, search per role, CSRF filter matrix, branch context and query filters.
- **Not done:** `Edit`/`View` on the older modules; reading masters still needs only module access; `TrackingController`, notifications and support tickets stay login-only; AuthHex hardening (anonymous `registerUser`/`getUserDetails`/`LogoutUser`, `alt/VerifyOTP`) unchanged; Playwright/smoke suites not re-run; the migration SQL was not run against a real MariaDB.
### 2026-10-18 (2) — GRN confirm fix, GRN table/actions, PO "Rejected" status
- **GRN confirm failed with `GL_REQUEST_FAILED` (my regression):** the opening-balance work added `JournalType` to the GL journal request and it was serialised as `"journalType": null` for every other posting. GL's `[ApiController]` model validation treats its non-nullable `JournalType` as required, so every GRN / sales / expense post got a 400 with a ProblemDetails body (and ERPCore then showed only the generic message). Now nulls are never sent, and a GL validation error is reported with its reason. **Restart ERPCore**, then confirm the draft GRN again. Opening-balance posts were unaffected.
- **GRN against a PO never asks for a branch:** the GRN takes the PO's branch (server-side too: `CreateAsync` ignores the request branch when a PO is given); the form shows it read-only. Direct receipts still pick a branch.
- **Create GRN:** two buttons - *Save as draft* and *Confirm now* (creates, then confirms; if confirming fails the GRN stays a draft and the message says so). Both land on the GRN table.
- **GRN table:** same layout as Purchase Orders - vendor-first row (contact/address per Procurement configuration), GRN no. with PO + warehouse, created date, status, total, invoice, branch (admin + branching), date-range filter, server-side sort, search on GRN no. / PO / vendor. Actions: View, **Receipt** (A4 / 80mm / 58mm print preview, `GrnReceiptPrint.tsx`), **Approve** for Drafts (confirms the receipt, with a confirmation prompt and an idempotency key).
- **PO rejection:** a rejected PO now has its own status `Rejected` (was sent back to `Draft`, which looked like a new draft / pending). It is editable, deletable and can be resubmitted like a draft. No migration (status is stored as a string).
- **Tests:** +6 (`Po_Reject...`, GL payload/ProblemDetails). Suite 202/202. Not covered by tests: the GRN list query and GRN-from-PO branch (needs the full GRN service fixture).
### 2026-10-18 (3) — Inventory counted twice in the General Ledger (fixed)
- **Bug:** `FifoCostingService.PostLedgerAsync` always posted a generic "STOCK" journal (Dr Inventory / Cr Clearing, reversed for issues) per ledger row, and GRN, sales invoice, purchase return, stock adjustment and sales return ALSO post their own complete document journal. Each movement therefore reached the GL twice (a 100,000 GRN showed 200,000 of Inventory). Seen on GRN 2.
- **Fix:** new `postGlJournal` parameter (default true). Passed `false` by GRN, sales posting, purchase return and `StockMutator` (adjustments, sales returns, purchase-return deltas). Production runs and transfers have no journal of their own and keep the STOCK journal. Local `JournalEntryStub` rows are unchanged.
- **Existing data is NOT corrected:** journals already in the GL stay. For each affected document the "STOCK" journals (Reference like `GRN#2`) need a reversal in the GL (GRN 2: three, 20,000 + 30,000 + 50,000). Not done automatically.
- **Test:** `StockLedgerPost_AddsTheGenericStockJournal_OnlyWhenTheDocumentHasNoJournalOfItsOwn`. Suite 203/203.
### 2026-10-18 (4) — Configurable customer fields (Sales Configuration)
- **What:** Settings -> Sales Configuration -> *Customer fields* has seven switches: Phone, Email, Address (lines 1 and 2), City, Country, Credit limit, Credit days (the customer's "age"). All default **on**, so nothing changes until one is turned off. The New customer dialog, Edit customer dialog, customer profile page, and the customers table / cards (Phone, Credit limit columns) follow the switches.
- **Server rules (`CustomerService`):** a new value for a switched-off field is refused (`422 CONFIG_DISABLED`); on update a switched-off field **keeps its stored value** (a blank/zero from a form that no longer has the field can't wipe it). Tax registration and display name are not configurable.
- **Where stored:** seven `bool` columns on `sales_config` (migration `AddCustomerFieldSwitches`, additive, default true, **not applied**). `useCustomerConfig` reads them from `GET /sales-config`; until it loads, the UI assumes all on.
- **Tests:** `CustomerFieldSwitchTests` (12 cases). Suite 214/214.