Complete all for Items

This commit is contained in:
2026-07-17 14:27:51 +05:30
parent f72b24fcaa
commit 62a5d857de
103 changed files with 2540 additions and 3259 deletions
+59 -3
View File
@@ -81,7 +81,7 @@ Domain errors add a stable `code` (catalog §7):
| `POST /api/v1/auth/lock` | LockUserAccount | Required + CSRF |
| `POST /api/v1/auth/change-password` | ChangeUserPassword | Required + CSRF |
| `POST /api/v1/auth/verify-password` | VerifyPassword | Required |
| `POST /api/v1/auth/logout` | LogoutUser | Anonymous* |
| `POST /api/v1/auth/logout` | LogoutUser | Anonymous*`userId` **optional** |
| `PUT /api/v1/auth/me` | UpdateUser | Required + CSRF |
| `POST /api/v1/auth/2fa/initiate` | initiateTwoFASetup | Required + CSRF |
| `POST /api/v1/auth/2fa/complete` | completeTwoFASetup | Required + CSRF |
@@ -100,6 +100,13 @@ Domain errors add a stable `code` (catalog §7):
than the bearer token — carried over from AuthHex's own design, not introduced by this proxy. Tracked as an accepted risk
in docs/02-SECURITY.md Part A.
> **`POST /auth/logout` — `userId` is optional (2026-07-17).** AuthHex returns `user.userId: null` in its own
> login/register response, so a browser never learns the id it would need to send. When omitted, ERPCore resolves it from
> the session token's `UserId` claim. The `erp_at`/`erp_rt`/`XSRF-TOKEN` cookies are cleared **regardless** of whether the
> upstream revoke succeeds — a logout that leaves the caller holding a live session cookie is worse than one that leaves a
> stale session server-side (which lapses on its own). Previously the required `userId` made a browser logout impossible:
> the cookies survived and "logging out" was cosmetic.
Request/response field shapes match AuthHex's own payloads one-for-one (project-root `API_REFERENCE.md` §3–§5), except
session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the
`erp_rt` cookie rather than the request body.
@@ -124,8 +131,10 @@ Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandI
"brandId": 2, "baseUomId": 1,
"defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD",
"status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
"conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ],
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" }
```
`conversions` is inlined (added 2026-07-17) because they are otherwise unreadable: `PUT /items/{id}/uom-conversions` returns them but nothing reads them back, so a detail screen could never show current state before editing.
#### `POST /items`
The `sku` is **generated by the client** (it encodes the chosen item-type values, e.g. `BL-100-0003`); the server only enforces uniqueness. `subCategoryId`/`brandId` are optional.
@@ -359,7 +368,18 @@ Requires `If-Match`. All three flags are **required** — a partial body is a `4
```
`POST /requisitions/{id}/submit`**200 OK** `status: "Submitted"`.
`GET /requisitions?status=Submitted` → list envelope of `RequisitionSummaryDto` (`{requisitionId, docNo, status, requestedBy, createdAt, lineCount}`).
### 3.2 RFQs & Quotations
#### `GET /rfqs`
Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of
`{ rfqId, docNo, requisitionId, status, lineCount, quotationCount }`.
> **`vendorIds` is not persisted.** `POST /rfqs` validates the invited vendors but stores no
> RFQ↔vendor link, so neither `GET /rfqs` nor `GET /rfqs/{id}` returns them. Quotations
> reference vendors directly — `quotationCount` (and the comparison's `vendorIds`) are the
> facts that survive. A UI cannot show "invited but not yet quoted".
#### `POST /rfqs`
```json
{ "requisitionId": 210, "vendorIds": [5, 8, 11],
@@ -417,6 +437,12 @@ Edit while open (not FullyReceived/Closed/Cancelled); requires `If-Match`. → *
**200 OK** `status: "Cancelled"`; `409` if any receipt exists.
### 3.4 Purchase Returns
#### `GET /purchase-returns`
Query: `q`, `vendorId`, `warehouseId`, + paging. → list envelope of
`{ returnId, docNo, vendorId, warehouseId, reasonCodeId, status, createdBy, createdAt, lineCount }`.
#### `GET /purchase-returns/{returnId}` → the full return incl. `lines` and `ledgerRefs`; `404` if absent.
#### `POST /purchase-returns`
```json
{ "vendorId": 5, "warehouseId": 1, "reasonCodeId": 22,
@@ -435,6 +461,12 @@ Edit while open (not FullyReceived/Closed/Cancelled); requires `If-Match`. → *
## 4. Goods Receipt (GRN)
> On **confirm**, each line creates a **FIFO cost layer** and posts an **inbound ledger** entry (FR-GRN-06). Goods may land `holdStatus: "OnHold"` (not issuable) until released.
> **No `PUT` or `DELETE` exists for a GRN** — a receipt is corrected with a reversing document, never edited or erased (FR-X-05).
> **Gap vs FR-GRN-04:** `CreateGrnLineInput` carries `batch` but has **no serial-number field**, so serials cannot be captured on receipt as the requirement mandates. Tracked in `Backend/PROGRESS.md`.
### 4.0 `GET /grns`
Query: `q`, `status` (`Draft|Confirmed|Closed`), `poId`, `vendorId`, `warehouseId`, + paging. → list envelope of
`{ grnId, docNo, poId, vendorId, warehouseId, status, createdBy, createdAt, postedAt, lineCount }`.
### 4.1 `POST /grns`
Against a PO (lines default from open PO lines) or direct (`poId: null`, by permission).
@@ -479,9 +511,18 @@ Received `OnHold` → the layer is **not** available until released.
{ "itemId": 1001, "warehouseId": 1, "onHand": 5000, "available": 0, "onHold": 5000,
"inTransit": 0, "reserved": 0, "asOf": "2026-07-07T10:06:00Z" }
```
`available = onHand onHold reserved inTransit(out)`. `reserved` always `0` in Phase 1 (stub, FR-STK-11).
`available = onHand onHold reserved`. `inTransit` is **reported, not subtracted again**: dispatch has already consumed the source layers, so removing it a second time would double-count (docs/10 C.9). `reserved` always `0` in Phase 1 (stub, FR-STK-11).
#### `GET /stock/on-hand/list?warehouseId=1&itemId=1001`
Both filters optional; + paging. On-hand for every (item, warehouse) pair that holds stock — backs the Stock Enquiry list. Pairs come from `STOCK_LAYER`, so an item that never had a receipt in a warehouse does not appear.
```json
{ "items": [ { "itemId": 1001, "warehouseId": 1, "onHand": 5000, "available": 0, "onHold": 5000,
"inTransit": 0, "reserved": 0, "asOf": "2026-07-17T10:06:00Z" } ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }
```
### 5.2 `GET /stock/ledger?itemId=1001&warehouseId=1&from=2026-07-01&to=2026-07-07`
Also accepts **`sourceDocType`** + **`sourceDocId`** — the only way to ask "what movements did this document post?", since the ledger's document reference is polymorphic with no FK to follow (docs/10 C.9). `sourceDocType` is the document-type prefix as stored: `GRN`, `ADJ`, `TRF`, `PRET`, `CNT` (`Domain/DocumentTypes.cs`), not the friendly name.
```json
{ "items": [ { "ledgerId": 55010, "itemId": 1001, "warehouseId": 1, "binId": 45, "batchId": 410,
"serialId": null, "direction": "In", "qtyBase": 5000, "unitCost": 12.50, "value": 62500.00,
@@ -501,6 +542,10 @@ Received `OnHold` → the layer is **not** available until released.
### 5.4 Transfers (in-transit)
> create → dispatch → receive. Dispatch consumes source FIFO layers into in-transit; receive creates the destination layer at inherited cost (cost-preserving, FR-STK-06).
#### `GET /stock-transfers`
Query: `q`, `status` (`Draft|InTransit|Received|Closed`), `srcWarehouseId`, `destWarehouseId`, + paging. → list envelope of
`{ transferId, docNo, srcWarehouseId, destWarehouseId, status, createdBy, createdAt, lineCount }`.
#### `POST /stock-transfers`
```json
{ "srcWarehouseId": 1, "destWarehouseId": 2,
@@ -535,6 +580,12 @@ Received `OnHold` → the layer is **not** available until released.
### 5.5 Adjustments (auto-post)
> Reason code mandatory. Decrease consumes FIFO layers; increase creates a layer at supplied/last cost (FR-STK-07).
#### `GET /stock-adjustments`
Query: `q`, `warehouseId`, `reasonCodeId`, + paging. Newest first. → list envelope of
`{ adjustmentId, docNo, warehouseId, reasonCodeId, status, createdBy, createdAt, lineCount }`.
#### `GET /stock-adjustments/{adjustmentId}` → the full adjustment incl. `lines` and `ledgerRefs`; `404` if absent.
#### `POST /stock-adjustments`
```json
{ "warehouseId": 1, "reasonCodeId": 4,
@@ -550,6 +601,10 @@ Received `OnHold` → the layer is **not** available until released.
`400 REASON_CODE_REQUIRED` if `reasonCodeId` omitted.
### 5.6 Counts
#### `GET /stock-counts`
Query: `q`, `status` (`Draft|Counted|Posted`), `warehouseId`, + paging. → list envelope of
`{ countId, docNo, warehouseId, countType, status, createdBy, createdAt, lineCount }`.
#### `POST /stock-counts`
```json
{ "warehouseId": 1, "countType": "Cycle", "itemIds": [1001, 1002] }
@@ -564,12 +619,13 @@ Received `OnHold` → the layer is **not** available until released.
```json
{ "lines": [ { "countLineId": 400, "countedQty": 4980 } ] }
```
**200 OK**`{ "lines": [ { "countLineId": 400, "systemQty": 4985, "countedQty": 4980, "variance": -5 } ] }`
**200 OK**the whole `CountDto` (with server-computed `variance`), not just the lines.
#### `POST /stock-counts/{id}/post` → **200 OK** (posts variance adjustment, closes count)
```json
{ "countId": 30, "status": "Posted", "adjustmentId": 78, "ledgerRefs": [ 55060 ] }
```
`adjustmentId` is **null** when the count had no variance to post.
### 5.7 Reorder alerts
#### `GET /stock/reorder-alerts?warehouseId=1`
+19 -15
View File
@@ -14,9 +14,10 @@
| Styling / UI | **Tailwind CSS** for all styling — no CSS modules, styled-components, or inline style objects. UI primitives come from **shadcn/ui** (`components/ui/`, built on `@base-ui/react` + `class-variance-authority`); use/extend existing components there before adding a new one, and generate new primitives via the shadcn CLI to keep the pattern consistent. |
| State / forms | **Plain React hooks** (`useState`, `useReducer`, custom hooks). No form/state library. |
| Validation | **Dependency-free** (hand-rolled helpers). See §3. |
| API access | A single **typed fetch client** (`lib/`) against `NEXT_PUBLIC_API_BASE_URL`; all calls go through it. No scattered `fetch()` in components. |
| API access | A single **typed fetch client** (`lib/api-client.ts`) against a **same-origin `/api/v1`**; all calls go through it. No scattered `fetch()` in components. |
| Transport | The API is reached through a **Next `rewrites()` proxy** (`next.config.ts`: `/api/*``BACKEND_ORIGIN`, default `http://localhost:5224`). Same-origin by construction, so there is no CORS on the backend and none is needed. `BACKEND_ORIGIN` is **server-side only** — deliberately not `NEXT_PUBLIC_*`, since the browser only ever talks to the Next server. |
| Types | TS types in `types/` **mirror the API DTOs** in `11-BACKEND-PHASE1.md`. When the contract changes, update these first. |
| Auth | Store the bearer token from `/auth/login`; attach `Authorization: Bearer <token>` in the API client. |
| Auth | **httpOnly cookie session — there is no bearer token to store.** `POST /auth/login` sets `erp_at`/`erp_rt`/`XSRF-TOKEN`; the client just sends `credentials: "include"`. JS cannot read `erp_at` by design. `proxy.ts` guards `/dashboard/*` on the cookie's *presence* only (it cannot validate an RS256 JWT at the edge) — **the API remains the authority**. `lib/auth-session.ts` caches the user *profile* in localStorage for display only, because there is no `GET /auth/me`; it is not a credential. |
Principles:
- **The API contract is the source of truth.** The UI adapts to `11-BACKEND-PHASE1.md`, never the reverse.
@@ -115,7 +116,7 @@ Each screen calls the endpoints in `11-BACKEND-PHASE1.md`. System steps (blue) a
| Flow | Screens | Key endpoints |
|---|---|---|
| Login | Login | `POST /auth/login` |
| Login | Login | `POST /auth/login` (sets the session cookies), `POST /auth/logout` |
| Replenishment | Reorder alerts | `GET /stock/reorder-alerts`, `POST /stock/reorder-alerts/{itemId}/requisition` |
| Procurement | Requisition, RFQ, PO | `POST /requisitions`, `/rfqs`, `/rfqs/{id}/quotations`, `GET /rfqs/{id}/comparison`, `POST /purchase-orders`, `PUT /purchase-orders/{id}` |
| Receiving | GRN | `POST /grns`, `POST /grns/{id}/confirm`, `POST /grns/{id}/lines/{lineId}/release` |
@@ -128,20 +129,23 @@ Each screen calls the endpoints in `11-BACKEND-PHASE1.md`. System steps (blue) a
### 2.2 Master data screens (supporting, outside the core flow)
Vendors, Items, Categories, Subcategories, UOM, Warehouses, Brands, and Item Types are supporting master-data CRUD screens the flow above depends on but doesn't itself route through, so they're intentionally absent from the diagram/table. List screens follow one pagination convention: `page`/`pageSize`/`q`/`sortOrder` params, page size 5, debounced search, Previous/Next controls.
> **2026-07-16 — these are real backend entities now; the UI has NOT caught up.** Brand, Subcategory, Item Type (the frontend's "Variant Categories") and a Product Configuration gate were built on the backend (`docs/11 §2.3/2.6/2.7/2.8`). The screens below still run on `lib/api/mock-data.ts` and do not call any of it. Reconciling them is outstanding frontend work — the contract drift is listed in §2.2.1.
> **2026-07-17 — the frontend is connected to the real API.** `lib/api/mock-data.ts` is **deleted**; every `lib/api/*.ts` module calls the backend. The drift listed here previously has been reconciled — what follows records the decisions so they are not re-litigated.
**Brand** (`app/dashboard/products/brands`) and **Variant Category** (`app/dashboard/products/variants`) began as UI-only additions with no backend. The Item variant builder on `/dashboard/products/new` reads the Variant Category list live: checking a category (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is auto-created per combination across however many categories are checked, with an auto-generated SKU. See `Frontend/PROGRESS.md` (2026-07-15 entries) for the full rationale and discarded design iterations.
**Brand** (`app/dashboard/products/brands`), **Item Type** (`app/dashboard/products/item-types`, formerly "Variant Category") and **Subcategory** (`app/dashboard/products/categories/[id]`) are real backend entities (`docs/11 §2.3/2.6/2.7`). The item builder on `/dashboard/products/new` reads the Item Type list live from `GET /item-types`: checking a type (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is created per combination across however many types are checked, with a client-generated SKU. See `Frontend/PROGRESS.md` for the history.
#### 2.2.1 Contract drift to reconcile (backend is authoritative — §1)
- **`variantCategoriesApi``GET /item-types`.** Same shape (name-only list), new name. `variantCategoryId``itemTypeId`.
- **`Item.itemType``stockNature`.** The `Stocked|NonStocked|Service` field was renamed. `itemType` now means something else entirely (Color/Size), so this rename is not cosmetic — read `docs/11 §2.7` before touching it.
- **Send both category FKs.** `effectiveCategoryId = subCategoryId ?? categoryId` must become `categoryId` **and** `subCategoryId`; the server rejects a subcategory that doesn't belong to the category (422). Subcategories are their own resource now, not `Category.parentId`, and **`GET /categories?tree=true` no longer exists**.
- **Colour hex-packing stays frontend-only.** There is no value table, so `"Red|#EF4444"`, `encodeColorValue`/`decodeColorValue`/`isColorCategory` have nothing to reconcile against — keep them.
- **SKU generation stays client-side** (`buildVariantSku`) and is now the *only* record of which colour/size an item is; the server only uniqueness-checks it. Nothing can query items by colour.
- **`remove()` must become `PATCH /{id}/status`.** There are no `DELETE` endpoints on any master (FR-MD-08) the mock's unconditional delete has no backend equivalent.
- **`initialQty` remains unbacked** — no Stock Core wiring; still informational-only.
- **New: Product Configuration** (`GET`/`PUT /product-config`) gates subcategories/brands/item-types. This is the backend for the toggle screen; note **only 3 of that design's ~13 toggles exist**, and `itemTypesEnabled` is advisory — the frontend is what honours it (`docs/11 §2.8`). No `Switch` primitive exists in `components/ui/` yet.
- **Non-transactional create loop:** the builder's per-row `itemsApi.create()` has no transaction — a `SKU_DUPLICATE` on row 7 of 12 leaves 6 items created. Real HTTP calls will make this failure mode visible in a way mock data never did.
#### 2.2.1 Resolved contract decisions (backend is authoritative — §1)
- **`variantCategoriesApi``itemTypesApi`** (`GET /item-types`); `variantCategoryId``itemTypeId`.
- **`Item.itemType``stockNature`.** `itemType` now means a Color/Size dimension master — a different concept (`docs/11 §2.7`). The item-detail label reads "Stock nature".
- **Both category FKs travel.** The builder sends `categoryId` **and** `subCategoryId`; the old `subCategoryId ?? categoryId` collapse lost the parent. The server rejects a mismatched pair with 422. Subcategories are their own resource `Category.parentId` and `?tree=true` are gone.
- **Colour hex-packing stays frontend-only.** There is no value table server-side, so `"Red|#EF4444"` + `encodeColorValue`/`decodeColorValue`/`isColorCategory` have nothing to reconcile against. Kept as-is.
- **SKU generation stays client-side** (`buildVariantSku`) and is the *only* record of which colour/size an item is; the server only uniqueness-checks it. **Nothing can query items by colour** — accepted (`docs/10 Part C.9`).
- **`remove()` `updateStatus(id, "Inactive")`** everywhere. There are no `DELETE` endpoints on any master (FR-MD-08); the lists show a Status column and Deactivate/Activate.
- **`initialQty` is gone** from the builder — the Item contract has no such field and there is no initial-receipt flow. Stock arrives via a GRN.
- **Product Configuration** (`app/dashboard/products/settings`, `GET`/`PUT /product-config`) — only **3** of the original design's ~13 toggles exist. `subcategoriesEnabled`/`brandsEnabled` are server-enforced (`CONFIG_DISABLED`); **`itemTypesEnabled` is advisory** and this app is what honours it (it hides the builder's type section). The UI states that distinction on the screen rather than implying a guarantee.
- **Non-transactional create loop:** the builder's per-row `itemsApi.create()` has no transaction — a `SKU_DUPLICATE` on row 7 of 12 leaves 6 items created. The error message now says how many landed rather than implying nothing happened. A transactional bulk-create endpoint would be the real fix.
- **GRN edit/delete removed** — the API has no `PUT`/`DELETE` for a GRN; receipts are corrected by reversing documents (FR-X-05).
- **RFQ invited-vendors is not persisted** — `POST /rfqs` validates `vendorIds` then discards them, so the list/detail screens show quotations received instead of vendors invited.
- **Known gap — serial numbers:** FR-GRN-04 requires capturing serials on receipt, but `CreateGrnLineInput` has no such field (only `batch`). The UI does not collect them rather than silently discarding them. Needs a backend change to honour the requirement.
---