Revert "Refactor API modules to remove mock implementations and integrate real endpoints"
This reverts commit
This commit is contained in:
+13
-34
@@ -13,37 +13,25 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
|
||||
> **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built.
|
||||
|
||||
> **2026-07-14 — mock data removed, `lib/api/mock-data.ts` deleted.** Every `lib/api/*.ts` file's commented-out real `fetch` block was restored and the in-memory mock block deleted, per user request. Master Data + Procurement modules (`categories`, `uoms`, `warehouses`, `items`, `vendors`, `requisitions`, `rfqs`, `purchase-orders`) now call the live backend (`Backend/ERPCore` §1/§2, both implemented + smoke-tested). GRN/Stock/Purchase-Return/Reason-Code modules (`grns`, `stock`, `stock-transfers`, `stock-adjustments`, `stock-counts`, `purchase-returns`, `reason-codes`, `wastage`) also now call real (documented-or-assumed) endpoint paths, but **no backend controller exists for any of them yet** (`Backend/PROGRESS.md` §3/§4/§5 are unstarted, and Purchase Return is explicitly deferred) — those calls will 404 against a running backend until that work happens. This was a deliberate tradeoff the user confirmed explicitly (see the two `AskUserQuestion` exchanges this session) rather than silently faking data.
|
||||
|
||||
## 1. Auth
|
||||
- [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage
|
||||
- [~] Forgot password — add email screen — UI built (`app/login/forgot`); not yet wired to API
|
||||
- [~] Forgot password — verify OTP screen — UI built (`app/login/forgot/otp`); not yet wired to API
|
||||
- [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API
|
||||
|
||||
> **2026-07-14:** left untouched during the mock-data removal pass — there was never a mock `lib/api/auth.ts` to begin with (these screens simply don't call anything yet), and `Backend/PROGRESS.md` §6 confirms no `POST /auth/login` controller exists (`ICurrentUser`/JWT validation are wired, but there's no token issuer). Nothing to wire until that lands.
|
||||
|
||||
## 2. Master Data screens
|
||||
- [x] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. **2026-07-14: wired to the live backend** (`lib/api/items.ts`), no more mock data.
|
||||
- [x] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03. **2026-07-14: wired to the live backend.** Note: the real `ItemDetailDto` does not include a `conversions` field (only `Reorder`) — the Item detail page's conversion editor now round-trips purely through `PUT /items/{itemId}/uom-conversions`'s own request/response, not the GET response.
|
||||
- [x] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04. **2026-07-14: wired to the live backend.**
|
||||
- [x] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult<T>` was built earlier but unused until now). **2026-07-14: wired to the live backend.**
|
||||
- [x] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. **2026-07-14: wired to the live backend**; added `warehousesApi.get()` (the page needed it but the original stub never had it) and wrapped the real `GET /warehouses/{id}/bins` (`IReadOnlyList<BinDto>`, not paged) into a synthetic single-page `PagedResponse<Bin>` so existing `.items`-based call sites didn't need touching.
|
||||
- [x] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05. **2026-07-14: wired to the live backend.**
|
||||
- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item.
|
||||
- [~] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03
|
||||
- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04
|
||||
- [~] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult<T>` was built earlier but unused until now).
|
||||
- [~] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. Frontend-only (see note below); no ETag handling since there's no edit/delete yet, only create.
|
||||
- [~] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05
|
||||
|
||||
## 3. Procurement screens
|
||||
- [x] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01. **2026-07-14: wired to the live backend**; dropped `RequisitionSummary.lineCount` (the real `RequisitionSummaryDto` doesn't return it) from the list screen.
|
||||
- [x] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02. **2026-07-14: wired to the live backend**, with real contract mismatches found and fixed (see the dedicated deviation note below — this one needed real rework, not just an API-client swap).
|
||||
- [x] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07. **2026-07-14: wired to the live backend.**
|
||||
- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page. **Still `[~]`:** the API client now calls real endpoint paths, but `Backend/PROGRESS.md` §2 explicitly defers Purchase Return until GRN + Stock Core exist — these calls 404 against a live backend today.
|
||||
|
||||
> **2026-07-14 — RFQ real-backend contract mismatches found while removing mock data (`Backend/ERPCore/Dtos/Procurement/RfqDtos.cs` / `RfqService.cs` vs. the frontend's speculative types):**
|
||||
> - The backend does **not** persist which vendors were invited to an RFQ (`RfqService.MapRfq` never sets a vendor list) — `Rfq`/`RfqSummary` no longer carry `vendorIds`. `types/procurement.ts` and the `[id]`/list pages were updated to stop relying on it; the RFQ detail page now shows "Quoted: ..." (derived from `RfqComparison.vendorIds`, i.e. vendors who have actually submitted a quotation) instead of "Invited: ...".
|
||||
> - `CreateRfqRequest.RequisitionId` is `[Required]` server-side, not optional as the frontend assumed — `app/dashboard/procurement/rfqs/new/page.tsx` now requires picking a Submitted requisition (a picker was added for the case where one wasn't passed in via `?requisitionId=`) before an RFQ can be created.
|
||||
> - `RfqComparisonDto`'s real shape is `{ rfqId, vendorIds, rows: [{ itemId, qty, quotes: [{ vendorId, quotationId, unitPrice, leadDays }] }] }` — the frontend's assumed `{ lines: [{ cells }] }` naming was wrong; `types/procurement.ts` (`RfqComparisonRow`/`RfqComparisonCell`) and both consuming pages (RFQ detail, PO-from-RFQ prefill) were corrected to match.
|
||||
> - The "Record a quotation" vendor picker on the RFQ detail page now offers any active vendor who hasn't already quoted (matching what `AddQuotationAsync` actually validates — vendor exists + hasn't already quoted, not "was invited") rather than a now-nonexistent "pending invited vendors" list.
|
||||
> - `GET /rfqs` (list) still does not exist on `RfqsController` (only `GET /rfqs/{id}`) — `rfqsApi.list()` calls it anyway per the user's "remove all mock data" instruction, so the RFQ list screen 404s until that endpoint is added. Flagged here for whoever picks up `Backend/PROGRESS.md` §2.
|
||||
> - Also fixed while cross-checking DTOs: `ReqLine.requiredBy` is nullable (`DateOnly?` server-side, not a mandatory string), and `RequisitionSummary` never had a `lineCount` field (removed from the requisitions list column and the RFQ picker label).
|
||||
- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01
|
||||
- [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02
|
||||
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07
|
||||
- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page
|
||||
|
||||
## 4. Receiving screens
|
||||
- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail
|
||||
@@ -54,7 +42,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
|
||||
> **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below).
|
||||
>
|
||||
> **2026-07-14 — mock data removed:** `lib/api/grns.ts` now calls real endpoint paths (`apiRequest`/`apiRequestWithETag` against `/grns`) instead of an in-memory store. There is still no `GrnsController` on the backend (`Backend/PROGRESS.md` §3 unstarted), so every call here 404s against a running backend — this was a deliberate, user-confirmed tradeoff (see `AskUserQuestion` exchange this session), not an oversight. Also added `grnsApi.getWithETag()` (the assumed `GET /grns/{id}` didn't have an ETag-returning variant, but `app/dashboard/receiving/grn/[id]/edit/page.tsx`'s `update()` call needs an `If-Match` token to send — matching the same pattern `purchase-orders.ts` already uses for `get`/`getWithETag`).
|
||||
> **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`).
|
||||
>
|
||||
> **Deviation — `GET /grns` and `GET /grns/{id}`:** the API doc only specifies `POST /grns`, `POST /grns/{id}/confirm`, `POST /grns/{id}/lines/{id}/release` (no list/detail read). A list screen and a confirm/release screen both need to reload a GRN, so `lib/api/grns.ts` (`grnsApi.list`/`grnsApi.get`) and `types/grn.ts` assume these two GET endpoints will exist once the backend is built — flag this to whoever implements `Backend/PROGRESS.md` §3 so `docs/11-BACKEND-PHASE1.md` gets the corresponding doc update.
|
||||
|
||||
@@ -70,11 +58,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type).
|
||||
- Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`)
|
||||
|
||||
> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**.
|
||||
> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4).
|
||||
>
|
||||
> **2026-07-14 — mock data removed:** the in-memory Stock Core (`mockStockLayers`/`mockStockLedger`/`receiveLayer`/`consumeFifo`/etc., previously in `lib/api/mock-data.ts`) is gone. `stock.ts`/`stock-transfers.ts`/`stock-adjustments.ts`/`stock-counts.ts`/`reason-codes.ts` now call real endpoint paths; `stockApi.onHandList()` was rewritten to compose real (`itemsApi.list` × `warehousesApi.list` × `stockApi.onHand` per pair) calls instead of iterating a mock-derived key set. `wastage.ts` was rewritten the same way — it now calls `reasonCodesApi.list()`/`stockAdjustmentsApi.list()+get()`/`stockApi.ledger()` instead of reading mock arrays directly, so `wastageReasonCodeIds()` and `wastageApi.list()` are now `async` (both call sites in `app/dashboard/stock/wastage/{page,new/page}.tsx` were updated accordingly). **None of §5's backend exists yet** (`Backend/PROGRESS.md` §4/§5 unstarted), so every one of these calls 404s against a running backend — a deliberate, user-confirmed tradeoff (see `AskUserQuestion` exchange this session), not an oversight. `tsc --noEmit` and `eslint` are clean (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` pattern remain, unchanged from before this pass).
|
||||
>
|
||||
> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions).
|
||||
> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions).
|
||||
>
|
||||
> **Simplifications (mock-data limitations, not spec decisions):** `StockLayer` has no per-bin field (matches the real ER model, docs/10 Part C.5 — only `StockLedger` carries `bin_id`), so Count lines don't attempt bin-level snapshotting. Transfers don't expose batch selection in the create UI (FIFO picks layers regardless of batch). Adjustment increases always cost at "last known cost" for that item/warehouse (FR-STK-07); there's no landed-cost/manual-cost override. In-transit quantity is shown for visibility at the destination warehouse only and is not subtracted a second time from the source's `available` (dispatch already reduced the source layer's `qtyRemaining`) — the docs' `available = onHand − onHold − reserved − inTransit(out)` formula is ambiguous on this point given dispatch semantics; this was a judgment call, noted here for whoever builds the real backend to confirm or correct.
|
||||
|
||||
@@ -142,10 +128,3 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- **Housekeeping:** removed `app/dashboard/vendors/view vendors/` — confirmed byte-for-byte identical to `vendors/[id]/page.tsx` and untracked in git, same IDE-artifact pattern (malformed file-open path) as the garbled GRN duplicate folders removed in the Warehouse Management pass; noted here rather than silently dropped. Left `app/dashboard/receiving/grn/[id]/edit/` alone — it's untracked too but is a distinct, non-duplicate GRN-edit screen, not an artifact.
|
||||
- Same `[~]` posture as every other module this session: built against the documented+assumed Master Data contract (`docs/11-BACKEND-PHASE1.md` §2), no Master Data backend exists (`Backend/PROGRESS.md` §1 unchanged).
|
||||
- Verified: `tsc --noEmit` clean after clearing a stale `.next` type cache that still referenced the just-deleted `view vendors` route (same pre-existing `login/page.tsx` error only remains); `eslint` clean aside from the same established `set-state-in-effect` pattern; `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure, unrelated). All 5 new/changed routes confirmed rendering 200 with no error boundary against the dev server (one false-alarm 500 during testing traced to an unrelated stale process already bound to port 3000, not this code — retested clean on the actual dev server port).
|
||||
|
||||
### 2026-07-14 — Mock data removed everywhere; Master Data + Procurement wired to the live backend
|
||||
- User asked to integrate the (now-built) backend into the frontend, frontend-only, no mock data. Confirmed scope first: the backend only has controllers for Master Data (`Backend/PROGRESS.md` §1) and Procurement minus returns (§2) — GRN (§3), Stock Core (§4), Stock Transactions (§5), and Auth (§6) have no controllers at all yet. User's explicit instruction after that was still "remove mock data in all" — so every `lib/api/*.ts` module was switched to real `fetch` calls, accepting that GRN/Stock/Purchase-Return/Reason-Code calls will 404 against a live backend until that work exists (not silently left mocked).
|
||||
- **Deleted `lib/api/mock-data.ts` entirely** and restored the real-`fetch` implementation in every one of: `categories`, `uoms`, `warehouses` (added missing `get()`; wrapped the real non-paged `GET /warehouses/{id}/bins` array response into a synthetic `PagedResponse<Bin>` so existing `.items` call sites kept working), `items`, `vendors`, `requisitions`, `purchase-orders`, `purchase-returns`, `reason-codes`, `grns` (added `getWithETag()` for the GRN edit page's `If-Match`), `stock`, `stock-transfers`, `stock-adjustments`, `stock-counts`. `wastage.ts` had no prior real-mode block (it's a frontend-only lens with no documented endpoint of its own) — rewrote it to compose the now-real `reasonCodesApi`/`stockAdjustmentsApi`/`stockApi` calls instead of reading mock arrays directly; its two exports became `async` as a result, and both call sites (`app/dashboard/stock/wastage/{page,new/page}.tsx`) were updated.
|
||||
- **Did not blindly trust the frontend's pre-written "real implementation" comments** — cross-checked every Master Data/Procurement DTO against the actual `Backend/ERPCore/Dtos/**/*.cs` and controllers before wiring, since those blocks were written speculatively before/alongside the real backend and had drifted in the RFQ case (see the dedicated §3 deviation note above): the backend doesn't persist RFQ-invited vendors, `requisitionId` is required (not optional) to create an RFQ, `RfqComparisonDto` uses `rows`/`quotes`/`quotationId` (not `lines`/`cells`), and `RequisitionSummaryDto` has no `lineCount`. Fixed `types/procurement.ts` and the RFQ list/detail/new pages plus the PO-from-RFQ prefill accordingly, rather than shipping types that would silently be `undefined` at runtime.
|
||||
- Left `app/login/*` untouched — no mock auth existed to remove, and there's still no `POST /auth/login` controller to wire to.
|
||||
- Verified: `tsc --noEmit` clean (only the pre-existing, unrelated `login/page.tsx` resolver-typing error remains — confirmed pre-existing via `git` history, not introduced here). `eslint` shows the same established `react-hooks/set-state-in-effect`/`static-components` pattern as before, confirmed unchanged by spot-checking it also fires on files untouched this session (`app/login/forgot/reset/page.tsx`). Did not start the backend/Postgres or click through the UI live in this pass — verification was type-check + lint only; the Master Data/Procurement screens should be smoke-tested against a running `dotnet run` + Postgres before considering this "done" in practice.
|
||||
|
||||
@@ -115,7 +115,7 @@ function NewPurchaseOrderContent() {
|
||||
setVendorId(rfqVendorId)
|
||||
setLines(
|
||||
rfq.lines.map((l): DraftLine => {
|
||||
const cell = comparison.rows.find((row) => row.itemId === l.itemId)?.quotes.find((c) => c.vendorId === rfqVendorId)
|
||||
const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId)
|
||||
return {
|
||||
key: newKey(),
|
||||
itemId: l.itemId,
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function RequisitionsListPage() {
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Requested by</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
@@ -112,6 +113,7 @@ export default function RequisitionsListPage() {
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<RequisitionStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">#{r.requestedBy}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -67,10 +67,13 @@ export default function RfqDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rfqId])
|
||||
|
||||
// The backend derives `comparison.vendorIds` from vendors who have actually quoted
|
||||
// (RfqService.GetComparisonAsync) — invited-but-not-yet-quoted vendors aren't
|
||||
// persisted anywhere, so "pending" vendors can no longer be listed here.
|
||||
const quotedVendorIds = useMemo(() => new Set(comparison?.vendorIds ?? []), [comparison])
|
||||
const quotedVendorIds = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId)
|
||||
return set
|
||||
}, [comparison])
|
||||
|
||||
const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds])
|
||||
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
@@ -79,14 +82,6 @@ export default function RfqDetailPage() {
|
||||
return vendors.find((v) => v.vendorId === vendorId)
|
||||
}
|
||||
|
||||
// The backend doesn't persist which vendors were "invited" — any active vendor that
|
||||
// hasn't already quoted can be offered a quotation (AddQuotationAsync only checks the
|
||||
// vendor exists and hasn't already quoted this RFQ, not that it was invited).
|
||||
const quotableVendors = useMemo(
|
||||
() => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)),
|
||||
[vendors, quotedVendorIds]
|
||||
)
|
||||
|
||||
function selectQuoteVendor(vendorId: number | null) {
|
||||
setQuoteVendorId(vendorId)
|
||||
setQuoteFormError(null)
|
||||
@@ -161,9 +156,8 @@ export default function RfqDetailPage() {
|
||||
<RfqStatusBadge status={rfq.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
From Requisition #{rfq.requisitionId}
|
||||
{quotedVendorIds.size > 0 &&
|
||||
` — Quoted: ${[...quotedVendorIds].map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}`}
|
||||
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""}
|
||||
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +191,7 @@ export default function RfqDetailPage() {
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Vendor comparison</h2>
|
||||
{comparison.rows.every((r) => r.quotes.length === 0) ? (
|
||||
{comparison.lines.every((l) => l.cells.length === 0) ? (
|
||||
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -205,19 +199,19 @@ export default function RfqDetailPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
{comparison.vendorIds.map((vid) => (
|
||||
{rfq.vendorIds.map((vid) => (
|
||||
<TableHead key={vid} className="h-12 px-3 text-sm">{vendorFor(vid)?.code ?? `#${vid}`}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{comparison.rows.map((row) => {
|
||||
const item = itemFor(row.itemId)
|
||||
{comparison.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={row.itemId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${row.itemId}`}</TableCell>
|
||||
{comparison.vendorIds.map((vid) => {
|
||||
const cell = row.quotes.find((c) => c.vendorId === vid)
|
||||
<TableRow key={line.itemId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
{rfq.vendorIds.map((vid) => {
|
||||
const cell = line.cells.find((c) => c.vendorId === vid)
|
||||
return (
|
||||
<TableCell key={vid} className="px-3 py-3.5">
|
||||
{cell ? (
|
||||
@@ -254,7 +248,7 @@ export default function RfqDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quotableVendors.length > 0 && (
|
||||
{pendingVendors.length > 0 && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<h2 className="text-base font-semibold text-foreground">Record a quotation</h2>
|
||||
|
||||
@@ -262,12 +256,12 @@ export default function RfqDetailPage() {
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Select a vendor" />
|
||||
<SelectValue placeholder="Select an invited vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{quotableVendors.map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
{pendingVendors.map((vid) => (
|
||||
<SelectItem key={vid} value={vid} className="text-base">
|
||||
{vendorFor(vid)?.code ?? `#${vid}`} — {vendorFor(vid)?.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateRfqLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateRfqLineInput, RequisitionSummary } from "@/types/procurement"
|
||||
import { CreateRfqLineInput } from "@/types/procurement"
|
||||
import { ItemListItem, Vendor } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -44,17 +44,13 @@ function emptyLine(): DraftLine {
|
||||
function NewRfqContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const requisitionIdParam = Number(searchParams.get("requisitionId")) || null
|
||||
const requisitionId = Number(searchParams.get("requisitionId")) || null
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionIdParam)
|
||||
const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionId)
|
||||
|
||||
// Backend requires a requisitionId to create an RFQ (CreateRfqRequest.RequisitionId
|
||||
// is [Required]) — if one wasn't passed in via query param, the user must pick one.
|
||||
const [requisitionId, setRequisitionId] = useState<number | null>(requisitionIdParam)
|
||||
const [vendorIds, setVendorIds] = useState<Set<number>>(new Set())
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
@@ -63,46 +59,24 @@ function NewRfqContent() {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
requisitionsApi.list({ status: "Submitted", pageSize: 200 }),
|
||||
])
|
||||
.then(([it, ve, req]) => {
|
||||
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), vendorsApi.list({ pageSize: 200, status: "Active" })])
|
||||
.then(([it, ve]) => {
|
||||
setItems(it.items)
|
||||
setVendors(ve.items)
|
||||
setRequisitions(req.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!requisitionIdParam) return
|
||||
if (!requisitionId) return
|
||||
requisitionsApi
|
||||
.get(requisitionIdParam)
|
||||
.get(requisitionId)
|
||||
.then((req) => {
|
||||
setLines(req.lines.map((l): DraftLine => ({ key: newKey(), itemId: l.itemId, qty: String(l.qty) })))
|
||||
})
|
||||
.catch((err) => setHeaderError(errorMessage(err)))
|
||||
.finally(() => setRequisitionLoading(false))
|
||||
}, [requisitionIdParam])
|
||||
|
||||
function selectRequisition(id: number | null) {
|
||||
setRequisitionId(id)
|
||||
setHeaderError(null)
|
||||
if (!id) {
|
||||
setLines([emptyLine()])
|
||||
return
|
||||
}
|
||||
setRequisitionLoading(true)
|
||||
requisitionsApi
|
||||
.get(id)
|
||||
.then((req) => {
|
||||
setLines(req.lines.map((l): DraftLine => ({ key: newKey(), itemId: l.itemId, qty: String(l.qty) })))
|
||||
})
|
||||
.catch((err) => setHeaderError(errorMessage(err)))
|
||||
.finally(() => setRequisitionLoading(false))
|
||||
}
|
||||
}, [requisitionId])
|
||||
|
||||
function toggleVendor(vendorId: number) {
|
||||
setVendorIds((prev) => {
|
||||
@@ -125,10 +99,6 @@ function NewRfqContent() {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
|
||||
if (!requisitionId) {
|
||||
setHeaderError("Select a requisition to raise this RFQ against.")
|
||||
return
|
||||
}
|
||||
if (vendorIds.size === 0) {
|
||||
setHeaderError("Select at least one vendor to invite.")
|
||||
return
|
||||
@@ -192,24 +162,6 @@ function NewRfqContent() {
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{!requisitionIdParam && (
|
||||
<div className="flex flex-col gap-2 sm:w-96">
|
||||
<Label className="text-base">Requisition</Label>
|
||||
<Select<number | null> value={requisitionId} onValueChange={selectRequisition}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Select a submitted requisition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(requisitions ?? []).map((r) => (
|
||||
<SelectItem key={r.requisitionId} value={r.requisitionId} className="text-base">
|
||||
{r.docNo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Invite vendors</Label>
|
||||
<div className="grid grid-cols-1 gap-1 rounded-xl border p-3 sm:grid-cols-2">
|
||||
|
||||
@@ -5,8 +5,10 @@ import Link from "next/link"
|
||||
import { FileText, Plus } from "lucide-react"
|
||||
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { RfqSummary } from "@/types/procurement"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -15,15 +17,22 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
export default function RfqsListPage() {
|
||||
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
rfqsApi
|
||||
.list()
|
||||
.then((r) => setRfqs(r.items))
|
||||
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
|
||||
.then(([r, v]) => {
|
||||
setRfqs(r.items)
|
||||
setVendors(v.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function vendorNames(vendorIds: number[]) {
|
||||
return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -66,6 +75,7 @@ export default function RfqsListPage() {
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Requisition</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Vendors invited</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
@@ -78,7 +88,8 @@ export default function RfqsListPage() {
|
||||
{r.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">#{r.requisitionId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.requisitionId ? `#${r.requisitionId}` : <span className="text-muted-foreground">—</span>}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorNames(r.vendorIds)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<RfqStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
|
||||
@@ -66,7 +66,6 @@ export default function EditGrnPage() {
|
||||
const grnId = Number(params.id)
|
||||
|
||||
const [grn, setGrn] = useState<Grn | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
@@ -84,19 +83,18 @@ export default function EditGrnPage() {
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(grnId)) return
|
||||
Promise.all([
|
||||
grnsApi.getWithETag(grnId),
|
||||
grnsApi.get(grnId),
|
||||
warehousesApi.list(),
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
])
|
||||
.then(([{ data: g, etag: tag }, wh, it, uo]) => {
|
||||
.then(([g, wh, it, uo]) => {
|
||||
if (g.status !== "Draft") {
|
||||
setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`)
|
||||
setGrn(g)
|
||||
return
|
||||
}
|
||||
setGrn(g)
|
||||
setEtag(tag)
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
@@ -190,23 +188,14 @@ export default function EditGrnPage() {
|
||||
}
|
||||
})
|
||||
|
||||
if (!etag) {
|
||||
setSubmitError("Missing concurrency token — reload the page and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await grnsApi.update(
|
||||
grn.grnId,
|
||||
{
|
||||
poId: grn.poId,
|
||||
vendorId: grn.vendorId,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
},
|
||||
etag
|
||||
)
|
||||
const updated = await grnsApi.update(grn.grnId, {
|
||||
poId: grn.poId,
|
||||
vendorId: grn.vendorId,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("GRN updated", `${updated.docNo} saved.`)
|
||||
router.push(`/dashboard/receiving/grn/${updated.grnId}`)
|
||||
} catch (err) {
|
||||
|
||||
@@ -40,14 +40,9 @@ export default function NewWastagePage() {
|
||||
const [result, setResult] = useState<StockAdjustment | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
warehousesApi.list(),
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
reasonCodesApi.list("Adjustment"),
|
||||
wastageReasonCodeIds(),
|
||||
])
|
||||
.then(([wh, it, rc, wastageIdList]) => {
|
||||
const wastageIds = new Set(wastageIdList)
|
||||
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
|
||||
.then(([wh, it, rc]) => {
|
||||
const wastageIds = new Set(wastageReasonCodeIds())
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
|
||||
|
||||
@@ -31,9 +31,9 @@ export default function WastagePage() {
|
||||
const [reasonCodeId, setReasonCodeId] = useState<number | "All">("All")
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list(), wastageReasonCodeIds()])
|
||||
.then(([rc, it, wh, wastageIdList]) => {
|
||||
const wastageIds = new Set(wastageIdList)
|
||||
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([rc, it, wh]) => {
|
||||
const wastageIds = new Set(wastageReasonCodeIds())
|
||||
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
|
||||
setItems(it.items)
|
||||
setWarehouses(wh.items)
|
||||
|
||||
@@ -1,16 +1,64 @@
|
||||
// One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04).
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
//
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens
|
||||
// can be reviewed without a running backend. Restore the commented block and
|
||||
// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { Category, CategoryTreeNode, CreateCategoryRequest } from "@/types/master-data"
|
||||
import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest } from "@/lib/api-client"
|
||||
//
|
||||
// export const categoriesApi = {
|
||||
// list() {
|
||||
// return apiRequest<PagedResponse<Category>>("/categories")
|
||||
// },
|
||||
// tree() {
|
||||
// return apiRequest<CategoryTreeNode[]>("/categories?tree=true")
|
||||
// },
|
||||
// create(request: CreateCategoryRequest) {
|
||||
// return apiRequest<Category>("/categories", { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
function buildTree(categories: Category[]): CategoryTreeNode[] {
|
||||
const nodes = new Map<number, CategoryTreeNode>(categories.map((c) => [c.categoryId, { ...c, children: [] }]))
|
||||
const roots: CategoryTreeNode[] = []
|
||||
for (const node of nodes.values()) {
|
||||
if (node.parentId !== null && nodes.has(node.parentId)) {
|
||||
nodes.get(node.parentId)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
export const categoriesApi = {
|
||||
list() {
|
||||
return apiRequest<PagedResponse<Category>>("/categories")
|
||||
list(): Promise<PagedResponse<Category>> {
|
||||
const items = [...mockCategories].sort((a, b) => a.name.localeCompare(b.name))
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 200, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
tree() {
|
||||
return apiRequest<CategoryTreeNode[]>("/categories?tree=true")
|
||||
|
||||
tree(): Promise<CategoryTreeNode[]> {
|
||||
return mockDelay(buildTree(mockCategories))
|
||||
},
|
||||
create(request: CreateCategoryRequest) {
|
||||
return apiRequest<Category>("/categories", { method: "POST", body: request })
|
||||
|
||||
create(request: CreateCategoryRequest): Promise<Category> {
|
||||
const name = request.name.trim()
|
||||
if (!name) return Promise.reject(new Error("Category name is required."))
|
||||
const parentId = request.parentId ?? null
|
||||
if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) {
|
||||
return Promise.reject(new Error("Selected parent category does not exist."))
|
||||
}
|
||||
const category: Category = { categoryId: allocateCategoryId(), name, parentId }
|
||||
mockCategories.push(category)
|
||||
return mockDelay(category)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,71 +1,282 @@
|
||||
// One typed client method per GRN endpoint (docs/11-BACKEND-PHASE1.md §4).
|
||||
//
|
||||
// Note: no GrnsController exists yet (Backend/PROGRESS.md §3 is unstarted).
|
||||
// These calls will 404 until that backend is built. GET /grns and GET
|
||||
// /grns/{id} are also not in docs/11-BACKEND-PHASE1.md §4 — see
|
||||
// Frontend/PROGRESS.md §4 for that assumed-endpoint deviation.
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with an in-memory mock store (lib/api/mock-data.ts) so the GRN
|
||||
// screens (list/create/confirm/release) can be reviewed end-to-end without a
|
||||
// running backend. Restore the commented block and delete the mock block once
|
||||
// Backend/PROGRESS.md §3/§4 (GRN + Stock Core) exist. Note GET /grns and
|
||||
// GET /grns/{id} are not yet in docs/11-BACKEND-PHASE1.md §4 — see the note in
|
||||
// Frontend/PROGRESS.md §4.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import {
|
||||
ConfirmGrnResponse,
|
||||
CreateGrnRequest,
|
||||
CreatedLayer,
|
||||
Grn,
|
||||
GrnStatus,
|
||||
GrnSummary,
|
||||
ReleaseAction,
|
||||
ReleaseGrnLineResponse,
|
||||
} from "@/types/grn"
|
||||
import { allocateGrnId, allocateGrnLineId, mockDelay, mockGrns, mockPurchaseOrders, receiveLayer } from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface ListGrnsParams {
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// q?: string
|
||||
// status?: GrnStatus
|
||||
// poId?: number
|
||||
// warehouseId?: number
|
||||
// }
|
||||
//
|
||||
// export const grnsApi = {
|
||||
// list(params: ListGrnsParams = {}) {
|
||||
// return apiRequest<PagedResponse<GrnSummary>>(`/grns${buildQuery(params)}`)
|
||||
// },
|
||||
//
|
||||
// get(grnId: number) {
|
||||
// return apiRequest<Grn>(`/grns/${grnId}`)
|
||||
// },
|
||||
//
|
||||
// async create(request: CreateGrnRequest) {
|
||||
// const { data } = await apiRequestWithETag<Grn>("/grns", { method: "POST", body: request })
|
||||
// return data
|
||||
// },
|
||||
//
|
||||
// // Draft-only — a GRN with createdLayers/ledger postings (Confirmed/Closed) is
|
||||
// // immutable per docs/11 §4.
|
||||
// async update(grnId: number, request: CreateGrnRequest, ifMatch: string) {
|
||||
// const { data } = await apiRequestWithETag<Grn>(`/grns/${grnId}`, { method: "PUT", body: request, ifMatch })
|
||||
// return data
|
||||
// },
|
||||
//
|
||||
// remove(grnId: number) {
|
||||
// return apiRequest<void>(`/grns/${grnId}`, { method: "DELETE" })
|
||||
// },
|
||||
//
|
||||
// confirm(grnId: number, idempotencyKey?: string) {
|
||||
// return apiRequest<ConfirmGrnResponse>(`/grns/${grnId}/confirm`, {
|
||||
// method: "POST",
|
||||
// body: {},
|
||||
// idempotencyKey,
|
||||
// })
|
||||
// },
|
||||
//
|
||||
// releaseLine(grnId: number, grnLineId: number, action: ReleaseAction) {
|
||||
// return apiRequest<ReleaseGrnLineResponse>(`/grns/${grnId}/lines/${grnLineId}/release`, {
|
||||
// method: "POST",
|
||||
// body: { action },
|
||||
// })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListGrnsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
/** Free-text search over doc no. and vendor/PO/warehouse id (docs/11 §1.5). */
|
||||
q?: string
|
||||
status?: GrnStatus
|
||||
poId?: number
|
||||
warehouseId?: number
|
||||
}
|
||||
|
||||
function toSummary(grn: Grn): GrnSummary {
|
||||
return {
|
||||
grnId: grn.grnId,
|
||||
docNo: grn.docNo,
|
||||
poId: grn.poId,
|
||||
vendorId: grn.vendorId,
|
||||
warehouseId: grn.warehouseId,
|
||||
status: grn.status,
|
||||
createdAt: grn.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const grnsApi = {
|
||||
list(params: ListGrnsParams = {}) {
|
||||
return apiRequest<PagedResponse<GrnSummary>>(`/grns${buildQuery(params)}`)
|
||||
},
|
||||
list(params: ListGrnsParams = {}): Promise<PagedResponse<GrnSummary>> {
|
||||
const term = params.q?.trim().toLowerCase()
|
||||
|
||||
get(grnId: number) {
|
||||
return apiRequest<Grn>(`/grns/${grnId}`)
|
||||
},
|
||||
const filtered = mockGrns
|
||||
.filter((g) => !params.status || g.status === params.status)
|
||||
.filter((g) => !params.poId || g.poId === params.poId)
|
||||
.filter((g) => !params.warehouseId || g.warehouseId === params.warehouseId)
|
||||
.filter((g) => {
|
||||
if (!term) return true
|
||||
const haystack = [g.docNo, String(g.poId ?? ""), String(g.vendorId), String(g.warehouseId)]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
return haystack.includes(term)
|
||||
})
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.grnId - a.grnId)
|
||||
|
||||
getWithETag(grnId: number) {
|
||||
return apiRequestWithETag<Grn>(`/grns/${grnId}`)
|
||||
},
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
const totalItems = filtered.length
|
||||
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
|
||||
|
||||
async create(request: CreateGrnRequest) {
|
||||
const { data } = await apiRequestWithETag<Grn>("/grns", { method: "POST", body: request })
|
||||
return data
|
||||
},
|
||||
|
||||
// Draft-only — a GRN with createdLayers/ledger postings (Confirmed/Closed) is
|
||||
// immutable per docs/11 §4.
|
||||
async update(grnId: number, request: CreateGrnRequest, ifMatch: string) {
|
||||
const { data } = await apiRequestWithETag<Grn>(`/grns/${grnId}`, { method: "PUT", body: request, ifMatch })
|
||||
return data
|
||||
},
|
||||
|
||||
remove(grnId: number) {
|
||||
return apiRequest<void>(`/grns/${grnId}`, { method: "DELETE" })
|
||||
},
|
||||
|
||||
confirm(grnId: number, idempotencyKey?: string) {
|
||||
return apiRequest<ConfirmGrnResponse>(`/grns/${grnId}/confirm`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
idempotencyKey,
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems, totalPages },
|
||||
})
|
||||
},
|
||||
|
||||
releaseLine(grnId: number, grnLineId: number, action: ReleaseAction) {
|
||||
return apiRequest<ReleaseGrnLineResponse>(`/grns/${grnId}/lines/${grnLineId}/release`, {
|
||||
method: "POST",
|
||||
body: { action },
|
||||
})
|
||||
get(grnId: number): Promise<Grn> {
|
||||
const grn = mockGrns.find((g) => g.grnId === grnId)
|
||||
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
|
||||
return mockDelay(grn)
|
||||
},
|
||||
|
||||
create(request: CreateGrnRequest): Promise<Grn> {
|
||||
const grnId = allocateGrnId()
|
||||
const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined
|
||||
const grn: Grn = {
|
||||
grnId,
|
||||
docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`,
|
||||
poId: request.poId ?? null,
|
||||
// Vendor is derived from the PO when receiving against one (as the real
|
||||
// backend does) — request.vendorId is only meaningful for a direct receipt.
|
||||
vendorId: referencedPo?.vendorId ?? request.vendorId ?? 0,
|
||||
warehouseId: request.warehouseId,
|
||||
status: "Draft",
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.lines.map((line) => ({
|
||||
grnLineId: allocateGrnLineId(),
|
||||
poLineId: line.poLineId ?? null,
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
binId: line.binId ?? null,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
receivedValue: Math.round(line.qty * line.unitCost * 100) / 100,
|
||||
holdStatus: line.holdStatus,
|
||||
batchId: line.batch ? allocateGrnLineId() : null,
|
||||
})),
|
||||
}
|
||||
mockGrns.push(grn)
|
||||
return mockDelay(grn)
|
||||
},
|
||||
|
||||
confirm(grnId: number, idempotencyKey?: string): Promise<ConfirmGrnResponse> {
|
||||
void idempotencyKey // real backend dedupes on this; the mock always reprocesses
|
||||
const grn = mockGrns.find((g) => g.grnId === grnId)
|
||||
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
|
||||
if (grn.status === "Confirmed" || grn.status === "Closed") {
|
||||
return Promise.reject(new Error(`${grn.docNo} has already been confirmed.`))
|
||||
}
|
||||
|
||||
grn.status = "Confirmed"
|
||||
|
||||
const createdLayers: CreatedLayer[] = []
|
||||
const ledgerRefs: number[] = []
|
||||
|
||||
for (const line of grn.lines) {
|
||||
// FR-GRN-06: each line creates a FIFO layer + posts an inbound ledger entry.
|
||||
const { layer, ledger } = receiveLayer({
|
||||
itemId: line.itemId,
|
||||
warehouseId: grn.warehouseId,
|
||||
binId: line.binId,
|
||||
batchId: line.batchId,
|
||||
grnLineId: line.grnLineId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
userId: grn.createdBy,
|
||||
sourceDocType: "GRN",
|
||||
sourceDocId: grn.grnId,
|
||||
})
|
||||
createdLayers.push({
|
||||
layerId: layer.layerId,
|
||||
itemId: layer.itemId,
|
||||
warehouseId: layer.warehouseId,
|
||||
batchId: layer.batchId,
|
||||
qtyReceived: layer.qtyReceived,
|
||||
qtyRemaining: layer.qtyRemaining,
|
||||
unitCost: layer.unitCost,
|
||||
receiptDate: layer.receiptDate,
|
||||
})
|
||||
ledgerRefs.push(ledger.ledgerId)
|
||||
|
||||
// FR-PROC-07: accrue the PO line's received quantity as GRNs confirm.
|
||||
if (line.poLineId && grn.poId) {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === grn.poId)
|
||||
const poLine = po?.lines.find((l) => l.poLineId === line.poLineId)
|
||||
if (poLine) poLine.qtyReceived = Math.min(poLine.qty, poLine.qtyReceived + line.qty)
|
||||
}
|
||||
}
|
||||
|
||||
let poStatus: string | null = null
|
||||
if (grn.poId) {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === grn.poId)
|
||||
if (po) {
|
||||
const fullyReceived = po.lines.every((l) => l.qtyReceived >= l.qty)
|
||||
const anyReceived = po.lines.some((l) => l.qtyReceived > 0)
|
||||
po.status = fullyReceived ? "FullyReceived" : anyReceived ? "PartiallyReceived" : po.status
|
||||
poStatus = po.status
|
||||
}
|
||||
}
|
||||
|
||||
const response: ConfirmGrnResponse = {
|
||||
grnId: grn.grnId,
|
||||
status: grn.status,
|
||||
postedAt: new Date().toISOString(),
|
||||
createdLayers,
|
||||
ledgerRefs,
|
||||
poStatus,
|
||||
}
|
||||
return mockDelay(response)
|
||||
},
|
||||
|
||||
releaseLine(grnId: number, grnLineId: number, action: ReleaseAction): Promise<ReleaseGrnLineResponse> {
|
||||
const grn = mockGrns.find((g) => g.grnId === grnId)
|
||||
const line = grn?.lines.find((l) => l.grnLineId === grnLineId)
|
||||
if (!grn || !line) return Promise.reject(new Error(`Mock GRN line ${grnLineId} not found`))
|
||||
|
||||
line.holdStatus = action === "Release" ? "Available" : "Rejected"
|
||||
return mockDelay({ grnLineId: line.grnLineId, holdStatus: line.holdStatus })
|
||||
},
|
||||
|
||||
// Draft-only — once confirmed, a GRN has created stock layers/ledger entries
|
||||
// and is no longer safe to rewrite in place.
|
||||
update(grnId: number, request: CreateGrnRequest): Promise<Grn> {
|
||||
const grn = mockGrns.find((g) => g.grnId === grnId)
|
||||
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
|
||||
if (grn.status !== "Draft") {
|
||||
return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be edited.`))
|
||||
}
|
||||
|
||||
const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined
|
||||
grn.poId = request.poId ?? null
|
||||
grn.vendorId = referencedPo?.vendorId ?? request.vendorId ?? grn.vendorId
|
||||
grn.warehouseId = request.warehouseId
|
||||
grn.lines = request.lines.map((line) => ({
|
||||
grnLineId: allocateGrnLineId(),
|
||||
poLineId: line.poLineId ?? null,
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
binId: line.binId ?? null,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
receivedValue: Math.round(line.qty * line.unitCost * 100) / 100,
|
||||
holdStatus: line.holdStatus,
|
||||
batchId: line.batch ? allocateGrnLineId() : null,
|
||||
}))
|
||||
return mockDelay(grn)
|
||||
},
|
||||
|
||||
remove(grnId: number): Promise<void> {
|
||||
const grn = mockGrns.find((g) => g.grnId === grnId)
|
||||
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
|
||||
if (grn.status !== "Draft") {
|
||||
return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be deleted.`))
|
||||
}
|
||||
mockGrns.splice(mockGrns.indexOf(grn), 1)
|
||||
return mockDelay(undefined)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// One typed client method per Item endpoint (docs/11-BACKEND-PHASE1.md §2.1, FR-MD-01/05/08).
|
||||
// `list` also backs the GRN/PO/Requisition/RFQ item pickers.
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
// `list` also backs the GRN/PO/Requisition/RFQ item pickers built in earlier sessions.
|
||||
//
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens
|
||||
// can be reviewed without a running backend. Restore the commented block and
|
||||
// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
|
||||
import { ApiResult } from "@/lib/api-client"
|
||||
import { EntityStatus, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateItemRequest,
|
||||
@@ -12,7 +17,52 @@ import {
|
||||
UpdateUomConversionsRequest,
|
||||
UpdateUomConversionsResponse,
|
||||
} from "@/types/master-data"
|
||||
import {
|
||||
allocateItemId,
|
||||
bumpItemVersion,
|
||||
getItemVersion,
|
||||
initItemVersion,
|
||||
mockDelay,
|
||||
mockItems,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface ListItemsParams {
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// q?: string
|
||||
// status?: EntityStatus
|
||||
// categoryId?: number
|
||||
// trackingMode?: TrackingMode
|
||||
// }
|
||||
//
|
||||
// export const itemsApi = {
|
||||
// list(params: ListItemsParams = {}) {
|
||||
// return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
|
||||
// },
|
||||
// get(itemId: number) {
|
||||
// return apiRequestWithETag<Item>(`/items/${itemId}`)
|
||||
// },
|
||||
// create(request: CreateItemRequest) {
|
||||
// return apiRequestWithETag<Item>("/items", { method: "POST", body: request })
|
||||
// },
|
||||
// update(itemId: number, request: UpdateItemRequest, ifMatch: string) {
|
||||
// return apiRequestWithETag<Item>(`/items/${itemId}`, { method: "PUT", body: request, ifMatch })
|
||||
// },
|
||||
// updateStatus(itemId: number, status: EntityStatus) {
|
||||
// return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
|
||||
// },
|
||||
// updateReorder(itemId: number, request: UpdateItemReorderRequest) {
|
||||
// return apiRequest<{ settings: UpdateItemReorderRequest["settings"] }>(`/items/${itemId}/reorder`, { method: "PUT", body: request })
|
||||
// },
|
||||
// updateUomConversions(itemId: number, request: UpdateUomConversionsRequest) {
|
||||
// return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListItemsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -22,26 +72,132 @@ export interface ListItemsParams {
|
||||
trackingMode?: TrackingMode
|
||||
}
|
||||
|
||||
function toListItem(item: Item): ItemListItem {
|
||||
return {
|
||||
itemId: item.itemId,
|
||||
sku: item.sku,
|
||||
name: item.name,
|
||||
categoryId: item.categoryId,
|
||||
baseUomId: item.baseUomId,
|
||||
defaultVendorId: item.defaultVendorId,
|
||||
itemType: item.itemType,
|
||||
trackingMode: item.trackingMode,
|
||||
taxClass: item.taxClass,
|
||||
status: item.status,
|
||||
}
|
||||
}
|
||||
|
||||
function skuTaken(sku: string, excludeItemId?: number) {
|
||||
return mockItems.some((i) => i.itemId !== excludeItemId && i.sku.toLowerCase() === sku.toLowerCase())
|
||||
}
|
||||
|
||||
export const itemsApi = {
|
||||
list(params: ListItemsParams = {}) {
|
||||
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
|
||||
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
|
||||
const term = params.q?.trim().toLowerCase()
|
||||
const filtered = mockItems
|
||||
.filter((i) => !params.status || i.status === params.status)
|
||||
.filter((i) => !params.categoryId || i.categoryId === params.categoryId)
|
||||
.filter((i) => !params.trackingMode || i.trackingMode === params.trackingMode)
|
||||
.filter((i) => !term || `${i.sku} ${i.name}`.toLowerCase().includes(term))
|
||||
.sort((a, b) => a.sku.localeCompare(b.sku))
|
||||
.map(toListItem)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
get(itemId: number) {
|
||||
return apiRequestWithETag<Item>(`/items/${itemId}`)
|
||||
|
||||
get(itemId: number): Promise<ApiResult<Item>> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
return mockDelay({ data: item, etag: String(getItemVersion(itemId)) })
|
||||
},
|
||||
create(request: CreateItemRequest) {
|
||||
return apiRequestWithETag<Item>("/items", { method: "POST", body: request })
|
||||
|
||||
create(request: CreateItemRequest): Promise<ApiResult<Item>> {
|
||||
const sku = request.sku.trim()
|
||||
if (!sku) return Promise.reject(new Error("SKU is required."))
|
||||
if (skuTaken(sku)) {
|
||||
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
const item: Item = {
|
||||
itemId: allocateItemId(),
|
||||
sku,
|
||||
name: request.name.trim(),
|
||||
description: request.description?.trim() || null,
|
||||
categoryId: request.categoryId,
|
||||
baseUomId: request.baseUomId,
|
||||
defaultVendorId: request.defaultVendorId ?? null,
|
||||
itemType: request.itemType,
|
||||
trackingMode: request.trackingMode,
|
||||
taxClass: request.taxClass?.trim() || null,
|
||||
status: "Active",
|
||||
reorder: [],
|
||||
conversions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
}
|
||||
mockItems.push(item)
|
||||
initItemVersion(item.itemId)
|
||||
return mockDelay({ data: item, etag: "1" })
|
||||
},
|
||||
update(itemId: number, request: UpdateItemRequest, ifMatch: string) {
|
||||
return apiRequestWithETag<Item>(`/items/${itemId}`, { method: "PUT", body: request, ifMatch })
|
||||
|
||||
update(itemId: number, request: UpdateItemRequest, ifMatch: string): Promise<ApiResult<Item>> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
if (String(getItemVersion(itemId)) !== ifMatch) {
|
||||
return Promise.reject(Object.assign(new Error("The item was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
|
||||
}
|
||||
const sku = request.sku.trim()
|
||||
if (!sku) return Promise.reject(new Error("SKU is required."))
|
||||
if (skuTaken(sku, itemId)) {
|
||||
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
|
||||
item.sku = sku
|
||||
item.name = request.name.trim()
|
||||
item.description = request.description?.trim() || null
|
||||
item.categoryId = request.categoryId
|
||||
item.baseUomId = request.baseUomId
|
||||
item.defaultVendorId = request.defaultVendorId ?? null
|
||||
item.itemType = request.itemType
|
||||
item.trackingMode = request.trackingMode
|
||||
item.taxClass = request.taxClass?.trim() || null
|
||||
item.updatedAt = new Date().toISOString()
|
||||
|
||||
const next = bumpItemVersion(itemId)
|
||||
return mockDelay({ data: item, etag: String(next) })
|
||||
},
|
||||
updateStatus(itemId: number, status: EntityStatus) {
|
||||
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
|
||||
|
||||
updateStatus(itemId: number, status: EntityStatus): Promise<void> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
item.status = status
|
||||
item.updatedAt = new Date().toISOString()
|
||||
bumpItemVersion(itemId)
|
||||
return mockDelay(undefined)
|
||||
},
|
||||
updateReorder(itemId: number, request: UpdateItemReorderRequest) {
|
||||
return apiRequest<{ settings: UpdateItemReorderRequest["settings"] }>(`/items/${itemId}/reorder`, { method: "PUT", body: request })
|
||||
|
||||
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: UpdateItemReorderRequest["settings"] }> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
item.reorder = request.settings
|
||||
item.updatedAt = new Date().toISOString()
|
||||
bumpItemVersion(itemId)
|
||||
return mockDelay({ settings: item.reorder })
|
||||
},
|
||||
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest) {
|
||||
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request })
|
||||
|
||||
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
item.conversions = request.conversions.map((c, i) => ({ conversionId: 1000 + itemId * 10 + i, fromUom: c.fromUom, toUom: c.toUom, factor: c.factor }))
|
||||
item.updatedAt = new Date().toISOString()
|
||||
bumpItemVersion(itemId)
|
||||
return mockDelay({ itemId: item.itemId, baseUomId: item.baseUomId, conversions: item.conversions })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
// Temporary in-memory sample data so the GRN screens can be reviewed as pure UI
|
||||
// without a running backend. Shapes mirror docs/11-BACKEND-PHASE1.md exactly.
|
||||
//
|
||||
// This file (and the "MOCK" blocks in the sibling lib/api/*.ts files) is meant
|
||||
// to be deleted once the real GRN backend exists — the original fetch-based
|
||||
// implementations are left commented out in each file for that switch-back.
|
||||
import { Bin, Category, Item, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement"
|
||||
import { Grn } from "@/types/grn"
|
||||
import {
|
||||
AdjustmentStatus,
|
||||
CountStatus,
|
||||
CountType,
|
||||
LedgerDirection,
|
||||
LedgerEntry,
|
||||
ReasonCode,
|
||||
TransferStatus,
|
||||
} from "@/types/stock"
|
||||
|
||||
export const mockWarehouses: Warehouse[] = [
|
||||
{ warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" },
|
||||
{ warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" },
|
||||
]
|
||||
|
||||
export const mockBins: Bin[] = [
|
||||
{ binId: 1, warehouseId: 1, code: "A-01-01", binType: "Shelf" },
|
||||
{ binId: 2, warehouseId: 1, code: "A-01-02", binType: "Shelf" },
|
||||
{ binId: 3, warehouseId: 1, code: "B-02-01", binType: "Pallet" },
|
||||
{ binId: 4, warehouseId: 2, code: "C-01-01", binType: "Shelf" },
|
||||
{ binId: 5, warehouseId: 2, code: "C-01-02", binType: "Shelf" },
|
||||
]
|
||||
|
||||
let nextWarehouseId = 3
|
||||
let nextBinId = 6
|
||||
|
||||
export function allocateWarehouseId() {
|
||||
return nextWarehouseId++
|
||||
}
|
||||
|
||||
export function allocateBinId() {
|
||||
return nextBinId++
|
||||
}
|
||||
|
||||
export const mockUoms: Uom[] = [
|
||||
{ uomId: 1, name: "EA" },
|
||||
{ uomId: 2, name: "Box-12" },
|
||||
{ uomId: 3, name: "KG" },
|
||||
]
|
||||
|
||||
let nextUomId = 4
|
||||
|
||||
export function allocateUomId() {
|
||||
return nextUomId++
|
||||
}
|
||||
|
||||
export const mockCategories: Category[] = [
|
||||
{ categoryId: 3, name: "Hardware", parentId: null },
|
||||
{ categoryId: 12, name: "Fasteners", parentId: 3 },
|
||||
{ categoryId: 20, name: "Power Tools", parentId: null },
|
||||
]
|
||||
|
||||
let nextCategoryId = 21
|
||||
|
||||
export function allocateCategoryId() {
|
||||
return nextCategoryId++
|
||||
}
|
||||
|
||||
export const mockVendors: Vendor[] = [
|
||||
{
|
||||
vendorId: 5,
|
||||
code: "VN-005",
|
||||
name: "Lanka Steel Traders (Pvt) Ltd",
|
||||
terms: "NET30",
|
||||
taxReg: "134567890-7000",
|
||||
currency: "LKR",
|
||||
status: "Active",
|
||||
createdAt: "2026-06-01T08:00:00Z",
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
vendorId: 8,
|
||||
code: "VN-008",
|
||||
name: "Ceylon Hardware Supplies",
|
||||
terms: "NET45",
|
||||
taxReg: "198765432-1000",
|
||||
currency: "LKR",
|
||||
status: "Active",
|
||||
createdAt: "2026-06-05T08:00:00Z",
|
||||
updatedAt: null,
|
||||
},
|
||||
]
|
||||
|
||||
// A handful more so the vendors list has something real to paginate/search through.
|
||||
const extraVendorNames = [
|
||||
"Colombo Timber & Plywood Co.",
|
||||
"Kandy Electrical Distributors",
|
||||
"Galle Packaging Solutions",
|
||||
"Jaffna Agro Supplies",
|
||||
"Negombo Fasteners (Pvt) Ltd",
|
||||
"Kurunegala Paints & Coatings",
|
||||
"Trinco Marine Hardware",
|
||||
"Ratnapura Gems & Tools",
|
||||
]
|
||||
for (let i = 0; i < extraVendorNames.length; i++) {
|
||||
const vendorId = 9 + i
|
||||
mockVendors.push({
|
||||
vendorId,
|
||||
code: `VN-${String(vendorId).padStart(3, "0")}`,
|
||||
name: extraVendorNames[i],
|
||||
terms: i % 2 === 0 ? "NET30" : "NET60",
|
||||
taxReg: `1${String(10000000 + vendorId * 137)}-${7000 + i}`,
|
||||
currency: "LKR",
|
||||
status: i % 5 === 0 ? "Inactive" : "Active",
|
||||
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
|
||||
updatedAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
let nextVendorId = 9 + extraVendorNames.length
|
||||
|
||||
export function allocateVendorId() {
|
||||
return nextVendorId++
|
||||
}
|
||||
|
||||
// Concurrency token per vendor (stands in for the real backend's xmin/RowVersion
|
||||
// ETag, docs/11 §1.6) — kept out-of-band since the public Vendor type has no
|
||||
// version field of its own (it travels as an HTTP ETag header, not a body field).
|
||||
const mockVendorVersions = new Map<number, number>(mockVendors.map((v) => [v.vendorId, 1]))
|
||||
|
||||
export function getVendorVersion(vendorId: number): number {
|
||||
return mockVendorVersions.get(vendorId) ?? 1
|
||||
}
|
||||
|
||||
export function bumpVendorVersion(vendorId: number): number {
|
||||
const next = getVendorVersion(vendorId) + 1
|
||||
mockVendorVersions.set(vendorId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function initVendorVersion(vendorId: number) {
|
||||
mockVendorVersions.set(vendorId, 1)
|
||||
}
|
||||
|
||||
// Full Item records (docs/11 §2.1). ItemListItem (the list/GRN-picker view) is
|
||||
// derived from these in lib/api/items.ts, same "full record → mapped summary"
|
||||
// pattern as mockPurchaseOrders → PurchaseOrderSummary.
|
||||
export const mockItems: Item[] = [
|
||||
{
|
||||
itemId: 1001,
|
||||
sku: "ITM-1001",
|
||||
name: "Steel Bolt M8x40",
|
||||
description: "Grade 8.8 zinc-plated hex bolt",
|
||||
categoryId: 12,
|
||||
baseUomId: 1,
|
||||
defaultVendorId: 5,
|
||||
itemType: "Stocked",
|
||||
trackingMode: "Batch",
|
||||
taxClass: "STD",
|
||||
status: "Active",
|
||||
reorder: [{ warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 }],
|
||||
conversions: [{ conversionId: 33, fromUom: 2, toUom: 1, factor: 12 }],
|
||||
createdAt: "2026-06-01T08:00:00Z",
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
itemId: 1002,
|
||||
sku: "ITM-1002",
|
||||
name: "Steel Nut M8",
|
||||
description: "Grade 8 zinc-plated hex nut",
|
||||
categoryId: 12,
|
||||
baseUomId: 1,
|
||||
defaultVendorId: 5,
|
||||
itemType: "Stocked",
|
||||
trackingMode: "None",
|
||||
taxClass: "STD",
|
||||
status: "Active",
|
||||
reorder: [{ warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 }],
|
||||
conversions: [],
|
||||
createdAt: "2026-06-01T08:05:00Z",
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
itemId: 1003,
|
||||
sku: "ITM-1003",
|
||||
name: "Cordless Drill 18V",
|
||||
description: "18V lithium-ion cordless drill/driver, includes charger",
|
||||
categoryId: 20,
|
||||
baseUomId: 1,
|
||||
defaultVendorId: 8,
|
||||
itemType: "Stocked",
|
||||
trackingMode: "Serial",
|
||||
taxClass: "STD",
|
||||
status: "Active",
|
||||
reorder: [{ warehouseId: 2, reorderPoint: 15, reorderQty: 20 }],
|
||||
conversions: [],
|
||||
createdAt: "2026-06-05T08:10:00Z",
|
||||
updatedAt: null,
|
||||
},
|
||||
]
|
||||
|
||||
let nextItemId = 1004
|
||||
|
||||
export function allocateItemId() {
|
||||
return nextItemId++
|
||||
}
|
||||
|
||||
// Concurrency token per item (same out-of-band ETag pattern as mockVendorVersions).
|
||||
const mockItemVersions = new Map<number, number>(mockItems.map((i) => [i.itemId, 1]))
|
||||
|
||||
export function getItemVersion(itemId: number): number {
|
||||
return mockItemVersions.get(itemId) ?? 1
|
||||
}
|
||||
|
||||
export function bumpItemVersion(itemId: number): number {
|
||||
const next = getItemVersion(itemId) + 1
|
||||
mockItemVersions.set(itemId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function initItemVersion(itemId: number) {
|
||||
mockItemVersions.set(itemId, 1)
|
||||
}
|
||||
|
||||
export const mockPurchaseOrders: PurchaseOrder[] = [
|
||||
{
|
||||
poId: 342,
|
||||
docNo: "PO-2026-00342",
|
||||
vendorId: 5,
|
||||
requisitionId: 210,
|
||||
status: "Approved",
|
||||
approvalRequired: false,
|
||||
createdBy: 17,
|
||||
createdAt: "2026-07-07T09:40:00Z",
|
||||
updatedAt: null,
|
||||
totals: { subTotal: 112100.0, tax: 20178.0, grandTotal: 132278.0, currency: "LKR" },
|
||||
lines: [
|
||||
{ poLineId: 900, itemId: 1001, uomId: 1, warehouseId: 1, qty: 5000, unitPrice: 12.5, tax: 0.18, qtyReceived: 0 },
|
||||
{ poLineId: 901, itemId: 1002, uomId: 1, warehouseId: 1, qty: 8000, unitPrice: 6.2, tax: 0.18, qtyReceived: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
poId: 350,
|
||||
docNo: "PO-2026-00350",
|
||||
vendorId: 8,
|
||||
requisitionId: null,
|
||||
status: "PartiallyReceived",
|
||||
approvalRequired: false,
|
||||
createdBy: 17,
|
||||
createdAt: "2026-07-09T09:00:00Z",
|
||||
updatedAt: "2026-07-10T11:00:00Z",
|
||||
totals: { subTotal: 22500.0, tax: 4050.0, grandTotal: 26550.0, currency: "LKR" },
|
||||
lines: [
|
||||
{ poLineId: 910, itemId: 1003, uomId: 1, warehouseId: 2, qty: 50, unitPrice: 450.0, tax: 0.18, qtyReceived: 20 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
let nextPoId = 351
|
||||
|
||||
export function allocatePoId() {
|
||||
return nextPoId++
|
||||
}
|
||||
|
||||
// Concurrency token per PO (same out-of-band ETag pattern as mockVendorVersions,
|
||||
// docs/11 §1.6) — backs PUT /purchase-orders/{poId}'s If-Match (FR-PROC-05, Option B).
|
||||
const mockPoVersions = new Map<number, number>(mockPurchaseOrders.map((p) => [p.poId, 1]))
|
||||
|
||||
export function getPoVersion(poId: number): number {
|
||||
return mockPoVersions.get(poId) ?? 1
|
||||
}
|
||||
|
||||
export function bumpPoVersion(poId: number): number {
|
||||
const next = getPoVersion(poId) + 1
|
||||
mockPoVersions.set(poId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function initPoVersion(poId: number) {
|
||||
mockPoVersions.set(poId, 1)
|
||||
}
|
||||
|
||||
export const mockGrns: Grn[] = [
|
||||
{
|
||||
grnId: 780,
|
||||
docNo: "GRN-2026-00780",
|
||||
poId: 342,
|
||||
vendorId: 5,
|
||||
warehouseId: 1,
|
||||
status: "Draft",
|
||||
createdBy: 17,
|
||||
createdAt: "2026-07-11T10:00:00Z",
|
||||
lines: [
|
||||
{
|
||||
grnLineId: 1300,
|
||||
poLineId: 900,
|
||||
itemId: 1001,
|
||||
uomId: 1,
|
||||
binId: 1,
|
||||
qty: 5000,
|
||||
unitCost: 12.5,
|
||||
receivedValue: 62500.0,
|
||||
holdStatus: "OnHold",
|
||||
batchId: 410,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
grnId: 781,
|
||||
docNo: "GRN-2026-00781",
|
||||
poId: null,
|
||||
vendorId: 8,
|
||||
warehouseId: 2,
|
||||
status: "Confirmed",
|
||||
createdBy: 17,
|
||||
createdAt: "2026-07-10T14:30:00Z",
|
||||
lines: [
|
||||
{
|
||||
grnLineId: 1310,
|
||||
poLineId: null,
|
||||
itemId: 1003,
|
||||
uomId: 1,
|
||||
binId: 4,
|
||||
qty: 5,
|
||||
unitCost: 450.0,
|
||||
receivedValue: 2250.0,
|
||||
holdStatus: "Available",
|
||||
batchId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// A handful more so the list screen's pagination/search/filter controls have
|
||||
// something real to page through (10 items total across statuses/warehouses).
|
||||
const extraStatuses: Grn["status"][] = ["Draft", "Confirmed", "Closed", "Confirmed", "Draft", "Confirmed", "Closed", "Draft"]
|
||||
for (let i = 0; i < extraStatuses.length; i++) {
|
||||
const grnId = 782 + i
|
||||
const warehouseId = i % 2 === 0 ? 1 : 2
|
||||
const vendorId = i % 2 === 0 ? 5 : 8
|
||||
const itemId = i % 2 === 0 ? 1001 : 1003
|
||||
const status = extraStatuses[i]
|
||||
mockGrns.push({
|
||||
grnId,
|
||||
docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`,
|
||||
poId: i % 3 === 0 ? null : 342,
|
||||
vendorId,
|
||||
warehouseId,
|
||||
status,
|
||||
createdBy: 17,
|
||||
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
|
||||
lines: [
|
||||
{
|
||||
grnLineId: 2000 + i,
|
||||
poLineId: i % 3 === 0 ? null : 900,
|
||||
itemId,
|
||||
uomId: 1,
|
||||
binId: warehouseId === 1 ? 1 : 4,
|
||||
qty: 100 * (i + 1),
|
||||
unitCost: 10 + i,
|
||||
receivedValue: 100 * (i + 1) * (10 + i),
|
||||
holdStatus: status === "Draft" ? "OnHold" : "Available",
|
||||
batchId: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
let nextGrnId = 782 + extraStatuses.length
|
||||
let nextGrnLineId = 2000 + extraStatuses.length
|
||||
|
||||
export function allocateGrnId() {
|
||||
return nextGrnId++
|
||||
}
|
||||
|
||||
export function allocateGrnLineId() {
|
||||
return nextGrnLineId++
|
||||
}
|
||||
|
||||
/** Small delay so loading states are visible when reviewing the UI. */
|
||||
export function mockDelay<T>(value: T, ms = 300): Promise<T> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(value), ms))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stock Core (FIFO layers + immutable ledger) — docs/10 Part C.5, FR-STK-01..04.
|
||||
// GRN confirm and every stock transaction below post through these helpers so
|
||||
// Stock Enquiry / Ledger / Valuation reflect what actually happened this session.
|
||||
// ============================================================================
|
||||
|
||||
export interface MockStockLayer {
|
||||
layerId: number
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
batchId: number | null
|
||||
serialId: number | null
|
||||
grnLineId: number | null
|
||||
qtyReceived: number
|
||||
qtyRemaining: number
|
||||
unitCost: number
|
||||
receiptDate: string
|
||||
}
|
||||
|
||||
export const mockStockLayers: MockStockLayer[] = []
|
||||
export const mockStockLedger: LedgerEntry[] = []
|
||||
|
||||
let nextLayerId = 9001
|
||||
let nextLedgerId = 55010
|
||||
|
||||
export function allocateLayerId() {
|
||||
return nextLayerId++
|
||||
}
|
||||
|
||||
export function allocateLedgerId() {
|
||||
return nextLedgerId++
|
||||
}
|
||||
|
||||
function round2(n: number) {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function latestRunningBalance(itemId: number, warehouseId: number): number {
|
||||
for (let i = mockStockLedger.length - 1; i >= 0; i--) {
|
||||
const entry = mockStockLedger[i]
|
||||
if (entry.itemId === itemId && entry.warehouseId === warehouseId) return entry.runningBalance
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function postLedgerEntry(input: {
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
binId?: number | null
|
||||
batchId?: number | null
|
||||
serialId?: number | null
|
||||
userId: number
|
||||
direction: LedgerDirection
|
||||
qtyBase: number
|
||||
unitCost: number
|
||||
sourceDocType: string
|
||||
sourceDocId: number
|
||||
}): LedgerEntry {
|
||||
const prior = latestRunningBalance(input.itemId, input.warehouseId)
|
||||
const delta = input.direction === "In" ? input.qtyBase : -input.qtyBase
|
||||
const entry: LedgerEntry = {
|
||||
ledgerId: allocateLedgerId(),
|
||||
itemId: input.itemId,
|
||||
warehouseId: input.warehouseId,
|
||||
binId: input.binId ?? null,
|
||||
batchId: input.batchId ?? null,
|
||||
serialId: input.serialId ?? null,
|
||||
direction: input.direction,
|
||||
qtyBase: input.qtyBase,
|
||||
unitCost: input.unitCost,
|
||||
value: round2(input.qtyBase * input.unitCost),
|
||||
runningBalance: round2(prior + delta),
|
||||
sourceDocType: input.sourceDocType,
|
||||
sourceDocId: input.sourceDocId,
|
||||
userId: input.userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
mockStockLedger.push(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** Creates a FIFO layer + posts the matching inbound ledger entry (FR-GRN-06 / FR-STK-02). */
|
||||
export function receiveLayer(input: {
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
binId?: number | null
|
||||
batchId?: number | null
|
||||
serialId?: number | null
|
||||
grnLineId?: number | null
|
||||
qty: number
|
||||
unitCost: number
|
||||
userId: number
|
||||
sourceDocType: string
|
||||
sourceDocId: number
|
||||
}): { layer: MockStockLayer; ledger: LedgerEntry } {
|
||||
const layer: MockStockLayer = {
|
||||
layerId: allocateLayerId(),
|
||||
itemId: input.itemId,
|
||||
warehouseId: input.warehouseId,
|
||||
batchId: input.batchId ?? null,
|
||||
serialId: input.serialId ?? null,
|
||||
grnLineId: input.grnLineId ?? null,
|
||||
qtyReceived: input.qty,
|
||||
qtyRemaining: input.qty,
|
||||
unitCost: input.unitCost,
|
||||
receiptDate: new Date().toISOString(),
|
||||
}
|
||||
mockStockLayers.push(layer)
|
||||
const ledger = postLedgerEntry({
|
||||
itemId: input.itemId,
|
||||
warehouseId: input.warehouseId,
|
||||
binId: input.binId,
|
||||
batchId: input.batchId,
|
||||
serialId: input.serialId,
|
||||
userId: input.userId,
|
||||
direction: "In",
|
||||
qtyBase: input.qty,
|
||||
unitCost: input.unitCost,
|
||||
sourceDocType: input.sourceDocType,
|
||||
sourceDocId: input.sourceDocId,
|
||||
})
|
||||
return { layer, ledger }
|
||||
}
|
||||
|
||||
/** 409 STOCK_NEGATIVE_BLOCKED (docs/11 §7) — thrown by consumeFifo when available < requested. */
|
||||
export class StockNegativeError extends Error {
|
||||
code = "STOCK_NEGATIVE_BLOCKED"
|
||||
constructor(itemId: number, warehouseId: number) {
|
||||
super(`Not enough available stock for item #${itemId} at warehouse #${warehouseId}.`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A layer is unavailable while its originating GRN line is still on hold/rejected (docs/10 C.9). */
|
||||
function isLayerOnHold(layer: MockStockLayer): boolean {
|
||||
if (!layer.grnLineId) return false
|
||||
for (const grn of mockGrns) {
|
||||
const line = grn.lines.find((l) => l.grnLineId === layer.grnLineId)
|
||||
if (line) return line.holdStatus === "OnHold" || line.holdStatus === "Rejected"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Consumes the oldest open (non-held) layers first (FR-STK-03); throws StockNegativeError if insufficient. */
|
||||
export function consumeFifo(
|
||||
itemId: number,
|
||||
warehouseId: number,
|
||||
qty: number
|
||||
): { layerId: number; qtyConsumed: number; unitCost: number }[] {
|
||||
let remaining = qty
|
||||
const consumed: { layerId: number; qtyConsumed: number; unitCost: number }[] = []
|
||||
const candidates = mockStockLayers
|
||||
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0 && !isLayerOnHold(l))
|
||||
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
|
||||
|
||||
for (const layer of candidates) {
|
||||
if (remaining <= 0) break
|
||||
const take = Math.min(layer.qtyRemaining, remaining)
|
||||
layer.qtyRemaining = round2(layer.qtyRemaining - take)
|
||||
remaining = round2(remaining - take)
|
||||
consumed.push({ layerId: layer.layerId, qtyConsumed: take, unitCost: layer.unitCost })
|
||||
}
|
||||
if (remaining > 0.0001) throw new StockNegativeError(itemId, warehouseId)
|
||||
return consumed
|
||||
}
|
||||
|
||||
/** "Last cost" for an adjustment increase (FR-STK-07) when no more specific cost is supplied. */
|
||||
export function lastKnownCost(itemId: number, warehouseId: number): number {
|
||||
const layers = mockStockLayers
|
||||
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId)
|
||||
.sort((a, b) => new Date(b.receiptDate).getTime() - new Date(a.receiptDate).getTime())
|
||||
return layers[0]?.unitCost ?? 10
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes stock for a Purchase Return against the specific layer its GRN line created
|
||||
* (FR-PROC-08) — deliberately not routed through consumeFifo: a return disposes of the
|
||||
* exact received batch (often On-hold/Rejected, which consumeFifo's isLayerOnHold filter
|
||||
* would otherwise skip), not just "the oldest open layer for this item/warehouse".
|
||||
* Throws StockNegativeError (409 STOCK_NEGATIVE_BLOCKED, docs/11 §3.4) if the return
|
||||
* qty exceeds what remains on that layer.
|
||||
*/
|
||||
export function consumeLayerByGrnLine(
|
||||
grnLineId: number,
|
||||
qty: number
|
||||
): { layerId: number; qtyConsumed: number; unitCost: number; itemId: number; warehouseId: number } {
|
||||
const layer = mockStockLayers.find((l) => l.grnLineId === grnLineId)
|
||||
if (!layer || layer.qtyRemaining < qty) {
|
||||
const itemId = layer?.itemId ?? 0
|
||||
const warehouseId = layer?.warehouseId ?? 0
|
||||
throw new StockNegativeError(itemId, warehouseId)
|
||||
}
|
||||
layer.qtyRemaining = round2(layer.qtyRemaining - qty)
|
||||
return { layerId: layer.layerId, qtyConsumed: qty, unitCost: layer.unitCost, itemId: layer.itemId, warehouseId: layer.warehouseId }
|
||||
}
|
||||
|
||||
export function computeOnHand(itemId: number, warehouseId: number) {
|
||||
const layers = mockStockLayers.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId)
|
||||
const onHand = round2(layers.reduce((sum, l) => sum + l.qtyRemaining, 0))
|
||||
const onHold = round2(layers.filter(isLayerOnHold).reduce((sum, l) => sum + l.qtyRemaining, 0))
|
||||
const inTransit = round2(
|
||||
mockStockTransfers
|
||||
.filter((t) => t.status === "InTransit" && t.destWarehouseId === warehouseId)
|
||||
.flatMap((t) => t.lines)
|
||||
.filter((l) => l.itemId === itemId)
|
||||
.reduce((sum, l) => sum + l.qty, 0)
|
||||
)
|
||||
const reserved = 0
|
||||
const available = Math.max(0, round2(onHand - onHold - reserved))
|
||||
return { onHand, onHold, inTransit, reserved, available }
|
||||
}
|
||||
|
||||
/** Every item/warehouse combination that currently has (or ever had) a layer — drives the Enquiry screen. */
|
||||
export function knownStockKeys(): { itemId: number; warehouseId: number }[] {
|
||||
const seen = new Map<string, { itemId: number; warehouseId: number }>()
|
||||
for (const layer of mockStockLayers) {
|
||||
seen.set(`${layer.itemId}:${layer.warehouseId}`, { itemId: layer.itemId, warehouseId: layer.warehouseId })
|
||||
}
|
||||
return [...seen.values()]
|
||||
}
|
||||
|
||||
// --- Reference data (docs/11 §6) --------------------------------------------------
|
||||
|
||||
export interface MockItemReorder {
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
reorderPoint: number
|
||||
reorderQty: number
|
||||
}
|
||||
|
||||
export const mockItemReorders: MockItemReorder[] = [
|
||||
{ itemId: 1001, warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 },
|
||||
{ itemId: 1002, warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 },
|
||||
{ itemId: 1003, warehouseId: 2, reorderPoint: 15, reorderQty: 20 },
|
||||
]
|
||||
|
||||
export const mockReasonCodes: ReasonCode[] = [
|
||||
{ reasonCodeId: 1, code: "DMG", description: "Damage", context: "Adjustment" },
|
||||
{ reasonCodeId: 2, code: "LOSS", description: "Theft/Loss", context: "Adjustment" },
|
||||
{ reasonCodeId: 3, code: "CNTVAR", description: "Count Variance", context: "Adjustment" },
|
||||
{ reasonCodeId: 4, code: "EXPWO", description: "Expiry Write-off", context: "Adjustment" },
|
||||
{ reasonCodeId: 5, code: "SYSCORR", description: "System Correction", context: "Adjustment" },
|
||||
{ reasonCodeId: 22, code: "QREJ", description: "Quality Reject", context: "Return" },
|
||||
]
|
||||
|
||||
// --- Seed some prior receipts so Enquiry/Ledger/Valuation aren't empty on first load ---
|
||||
|
||||
receiveLayer({
|
||||
itemId: 1001, warehouseId: 1, binId: 1, batchId: 411, qty: 3000, unitCost: 12.5,
|
||||
userId: 17, sourceDocType: "GRN", sourceDocId: 779,
|
||||
})
|
||||
receiveLayer({
|
||||
itemId: 1002, warehouseId: 1, binId: 2, qty: 6000, unitCost: 6.2,
|
||||
userId: 17, sourceDocType: "GRN", sourceDocId: 779,
|
||||
})
|
||||
receiveLayer({
|
||||
itemId: 1003, warehouseId: 2, binId: 4, qty: 20, unitCost: 450,
|
||||
userId: 17, sourceDocType: "GRN", sourceDocId: 781,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Transfers (FR-STK-05/06) — create → dispatch (consume src) → receive (create dest).
|
||||
// ============================================================================
|
||||
|
||||
export interface MockTransferLine {
|
||||
transferLineId: number
|
||||
itemId: number
|
||||
srcBinId: number | null
|
||||
destBinId: number | null
|
||||
batchId: number | null
|
||||
qty: number
|
||||
/** Recorded on dispatch so receive() can create cost-preserving destination layers (FR-STK-06). */
|
||||
dispatchedChunks: { layerId: number; qtyConsumed: number; unitCost: number }[]
|
||||
}
|
||||
|
||||
export interface MockStockTransfer {
|
||||
transferId: number
|
||||
docNo: string
|
||||
srcWarehouseId: number
|
||||
destWarehouseId: number
|
||||
status: TransferStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lines: MockTransferLine[]
|
||||
}
|
||||
|
||||
export const mockStockTransfers: MockStockTransfer[] = []
|
||||
let nextTransferId = 55
|
||||
let nextTransferLineId = 300
|
||||
|
||||
export function allocateTransferId() {
|
||||
return nextTransferId++
|
||||
}
|
||||
|
||||
export function allocateTransferLineId() {
|
||||
return nextTransferLineId++
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Adjustments (FR-STK-07) — auto-post on creation.
|
||||
// ============================================================================
|
||||
|
||||
export interface MockAdjustmentLine {
|
||||
adjLineId: number
|
||||
itemId: number
|
||||
binId: number | null
|
||||
batchId: number | null
|
||||
qtyDelta: number
|
||||
}
|
||||
|
||||
export interface MockStockAdjustment {
|
||||
adjustmentId: number
|
||||
docNo: string
|
||||
warehouseId: number
|
||||
reasonCodeId: number
|
||||
status: AdjustmentStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lines: MockAdjustmentLine[]
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
|
||||
export const mockStockAdjustments: MockStockAdjustment[] = []
|
||||
let nextAdjustmentId = 77
|
||||
let nextAdjLineId = 210
|
||||
|
||||
export function allocateAdjustmentId() {
|
||||
return nextAdjustmentId++
|
||||
}
|
||||
|
||||
export function allocateAdjLineId() {
|
||||
return nextAdjLineId++
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Counts (FR-STK-08) — snapshot system qty → enter counted qty → post variance.
|
||||
// ============================================================================
|
||||
|
||||
export interface MockCountLine {
|
||||
countLineId: number
|
||||
itemId: number
|
||||
binId: number | null
|
||||
systemQty: number
|
||||
countedQty: number | null
|
||||
variance: number | null
|
||||
}
|
||||
|
||||
export interface MockStockCount {
|
||||
countId: number
|
||||
docNo: string
|
||||
warehouseId: number
|
||||
countType: CountType
|
||||
status: CountStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lines: MockCountLine[]
|
||||
}
|
||||
|
||||
export const mockStockCounts: MockStockCount[] = []
|
||||
let nextCountId = 30
|
||||
let nextCountLineId = 400
|
||||
|
||||
export function allocateCountId() {
|
||||
return nextCountId++
|
||||
}
|
||||
|
||||
export function allocateCountLineId() {
|
||||
return nextCountLineId++
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Procurement (FR-PROC-01..09) — Requisition → RFQ → Quotations → PO → Return.
|
||||
// No Procurement backend exists yet; same frontend-only posture as GRN/Stock.
|
||||
// ============================================================================
|
||||
|
||||
export const mockRequisitions: Requisition[] = [
|
||||
// Seeded to match mockPurchaseOrders[0].requisitionId (PO-2026-00342 was raised
|
||||
// against this requisition) so the two screens cross-reference consistently.
|
||||
{
|
||||
requisitionId: 210,
|
||||
docNo: "PR-2026-00210",
|
||||
status: "Submitted",
|
||||
requestedBy: 17,
|
||||
createdAt: "2026-07-06T08:30:00Z",
|
||||
lines: [
|
||||
{ reqLineId: 501, itemId: 1001, qty: 5000, requiredBy: "2026-07-20" },
|
||||
{ reqLineId: 502, itemId: 1002, qty: 8000, requiredBy: "2026-07-20" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
let nextRequisitionId = 211
|
||||
let nextReqLineId = 503
|
||||
|
||||
export function allocateRequisitionId() {
|
||||
return nextRequisitionId++
|
||||
}
|
||||
|
||||
export function allocateReqLineId() {
|
||||
return nextReqLineId++
|
||||
}
|
||||
|
||||
export const mockRfqs: Rfq[] = []
|
||||
let nextRfqId = 89
|
||||
let nextRfqLineId = 703
|
||||
|
||||
export function allocateRfqId() {
|
||||
return nextRfqId++
|
||||
}
|
||||
|
||||
export function allocateRfqLineId() {
|
||||
return nextRfqLineId++
|
||||
}
|
||||
|
||||
export const mockQuotations: Quotation[] = []
|
||||
let nextQuotationId = 141
|
||||
|
||||
export function allocateQuotationId() {
|
||||
return nextQuotationId++
|
||||
}
|
||||
|
||||
export const mockPurchaseReturns: PurchaseReturn[] = []
|
||||
let nextPurchaseReturnId = 61
|
||||
let nextPurchaseReturnLineId = 121
|
||||
|
||||
export function allocatePurchaseReturnId() {
|
||||
return nextPurchaseReturnId++
|
||||
}
|
||||
|
||||
export function allocatePurchaseReturnLineId() {
|
||||
return nextPurchaseReturnLineId++
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
// One typed client method per Purchase Order endpoint (docs/11-BACKEND-PHASE1.md §3.3,
|
||||
// FR-PROC-03..07). `get`/`list` also back the GRN "against a PO" picker.
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so the GRN and
|
||||
// Procurement screens can be reviewed without a running backend. Restore the
|
||||
// commented block and delete the mock block once Backend/PROGRESS.md §2/§3/§4
|
||||
// (Procurement + GRN + Stock Core) exist.
|
||||
import { ApiResult } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CancelPurchaseOrderRequest,
|
||||
@@ -10,7 +16,48 @@ import {
|
||||
PurchaseOrderSummary,
|
||||
UpdatePurchaseOrderRequest,
|
||||
} from "@/types/procurement"
|
||||
import {
|
||||
allocatePoId,
|
||||
bumpPoVersion,
|
||||
getPoVersion,
|
||||
initPoVersion,
|
||||
mockDelay,
|
||||
mockPurchaseOrders,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface ListPurchaseOrdersParams {
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// q?: string
|
||||
// status?: PurchaseOrderStatus
|
||||
// vendorId?: number
|
||||
// }
|
||||
//
|
||||
// export const purchaseOrdersApi = {
|
||||
// list(params: ListPurchaseOrdersParams = {}) {
|
||||
// return apiRequest<PagedResponse<PurchaseOrderSummary>>(`/purchase-orders${buildQuery(params)}`)
|
||||
// },
|
||||
// get(poId: number) {
|
||||
// return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||
// },
|
||||
// getWithETag(poId: number) {
|
||||
// return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||
// },
|
||||
// create(request: CreatePurchaseOrderRequest) {
|
||||
// return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
|
||||
// },
|
||||
// update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string) {
|
||||
// return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
|
||||
// },
|
||||
// cancel(poId: number, request: CancelPurchaseOrderRequest) {
|
||||
// return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListPurchaseOrdersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -24,23 +71,131 @@ export function isPoEditable(status: PurchaseOrderStatus): boolean {
|
||||
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled"
|
||||
}
|
||||
|
||||
function computeTotals(lines: CreatePurchaseOrderRequest["lines"], currency = "LKR") {
|
||||
const subTotal = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice, 0) * 100) / 100
|
||||
const tax = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice * l.tax, 0) * 100) / 100
|
||||
return { subTotal, tax, grandTotal: Math.round((subTotal + tax) * 100) / 100, currency }
|
||||
}
|
||||
|
||||
export const purchaseOrdersApi = {
|
||||
list(params: ListPurchaseOrdersParams = {}) {
|
||||
return apiRequest<PagedResponse<PurchaseOrderSummary>>(`/purchase-orders${buildQuery(params)}`)
|
||||
list(params: ListPurchaseOrdersParams = {}): Promise<PagedResponse<PurchaseOrderSummary>> {
|
||||
const term = params.q?.trim().toLowerCase()
|
||||
const filtered = mockPurchaseOrders
|
||||
.filter((po) => !params.status || po.status === params.status)
|
||||
.filter((po) => !params.vendorId || po.vendorId === params.vendorId)
|
||||
.filter((po) => !term || `${po.docNo} ${po.vendorId}`.toLowerCase().includes(term))
|
||||
.sort((a, b) => b.poId - a.poId)
|
||||
.map(
|
||||
(po): PurchaseOrderSummary => ({
|
||||
poId: po.poId,
|
||||
docNo: po.docNo,
|
||||
vendorId: po.vendorId,
|
||||
status: po.status,
|
||||
approvalRequired: po.approvalRequired,
|
||||
createdAt: po.createdAt,
|
||||
totals: po.totals,
|
||||
})
|
||||
)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
get(poId: number) {
|
||||
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||
|
||||
get(poId: number): Promise<PurchaseOrder> {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
||||
return mockDelay(po)
|
||||
},
|
||||
getWithETag(poId: number) {
|
||||
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||
|
||||
getWithETag(poId: number): Promise<ApiResult<PurchaseOrder>> {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
||||
return mockDelay({ data: po, etag: String(getPoVersion(poId)) })
|
||||
},
|
||||
create(request: CreatePurchaseOrderRequest) {
|
||||
return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
|
||||
|
||||
create(request: CreatePurchaseOrderRequest): Promise<ApiResult<PurchaseOrder>> {
|
||||
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
|
||||
const poId = allocatePoId()
|
||||
// FR-PROC-04: approvalRequired defaults false → auto-approved on creation.
|
||||
const po: PurchaseOrder = {
|
||||
poId,
|
||||
docNo: `PO-2026-${String(poId).padStart(5, "0")}`,
|
||||
vendorId: request.vendorId,
|
||||
requisitionId: request.requisitionId ?? null,
|
||||
status: "Approved",
|
||||
approvalRequired: false,
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
totals: computeTotals(request.lines),
|
||||
lines: request.lines.map((l, i) => ({
|
||||
poLineId: 900 + poId * 10 + i,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
warehouseId: l.warehouseId,
|
||||
qty: l.qty,
|
||||
unitPrice: l.unitPrice,
|
||||
tax: l.tax,
|
||||
qtyReceived: 0,
|
||||
})),
|
||||
}
|
||||
mockPurchaseOrders.push(po)
|
||||
initPoVersion(poId)
|
||||
return mockDelay({ data: po, etag: "1" })
|
||||
},
|
||||
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string) {
|
||||
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
|
||||
|
||||
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string): Promise<ApiResult<PurchaseOrder>> {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
||||
if (!isPoEditable(po.status)) {
|
||||
return Promise.reject(Object.assign(new Error(`${po.docNo} is ${po.status} and can no longer be edited.`), { code: "PO_NOT_EDITABLE" }))
|
||||
}
|
||||
if (String(getPoVersion(poId)) !== ifMatch) {
|
||||
return Promise.reject(Object.assign(new Error("The purchase order was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
|
||||
}
|
||||
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
|
||||
|
||||
const priorQtyReceived = new Map(po.lines.map((l) => [l.poLineId, l.qtyReceived]))
|
||||
po.vendorId = request.vendorId
|
||||
po.requisitionId = request.requisitionId ?? null
|
||||
po.totals = computeTotals(request.lines, po.totals.currency)
|
||||
po.lines = request.lines.map((l, i) => {
|
||||
// Preserve qtyReceived for lines that already existed (edit-while-open must not erase receipt progress).
|
||||
const existingLineId = po.lines[i]?.poLineId
|
||||
return {
|
||||
poLineId: existingLineId ?? 900 + poId * 10 + i,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
warehouseId: l.warehouseId,
|
||||
qty: l.qty,
|
||||
unitPrice: l.unitPrice,
|
||||
tax: l.tax,
|
||||
qtyReceived: existingLineId ? (priorQtyReceived.get(existingLineId) ?? 0) : 0,
|
||||
}
|
||||
})
|
||||
po.updatedAt = new Date().toISOString()
|
||||
|
||||
const next = bumpPoVersion(poId)
|
||||
return mockDelay({ data: po, etag: String(next) })
|
||||
},
|
||||
cancel(poId: number, request: CancelPurchaseOrderRequest) {
|
||||
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
|
||||
|
||||
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
||||
if (po.lines.some((l) => l.qtyReceived > 0)) {
|
||||
return Promise.reject(new Error(`${po.docNo} has receipts against it and can no longer be cancelled.`))
|
||||
}
|
||||
if (!request.reason.trim()) return Promise.reject(new Error("A cancellation reason is required."))
|
||||
po.status = "Cancelled"
|
||||
po.updatedAt = new Date().toISOString()
|
||||
bumpPoVersion(poId)
|
||||
return mockDelay(po)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,19 +1,113 @@
|
||||
// One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4, FR-PROC-08).
|
||||
//
|
||||
// Note: no PurchaseReturnsController exists yet (Backend/PROGRESS.md §2: "deferred —
|
||||
// needs GRN lines + stock ledger/FIFO"). These calls will 404 until that's built.
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement
|
||||
// screens can be reviewed without a running backend. Restore the commented block
|
||||
// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreatePurchaseReturnRequest, PurchaseReturn, PurchaseReturnSummary } from "@/types/procurement"
|
||||
import {
|
||||
allocatePurchaseReturnId,
|
||||
allocatePurchaseReturnLineId,
|
||||
consumeLayerByGrnLine,
|
||||
mockDelay,
|
||||
mockPurchaseReturns,
|
||||
postLedgerEntry,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest } from "@/lib/api-client"
|
||||
//
|
||||
// export const purchaseReturnsApi = {
|
||||
// list() {
|
||||
// return apiRequest<PagedResponse<PurchaseReturnSummary>>("/purchase-returns")
|
||||
// },
|
||||
// get(returnId: number) {
|
||||
// return apiRequest<PurchaseReturn>(`/purchase-returns/${returnId}`)
|
||||
// },
|
||||
// create(request: CreatePurchaseReturnRequest) {
|
||||
// return apiRequest<PurchaseReturn>("/purchase-returns", { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
function toSummary(r: PurchaseReturn): PurchaseReturnSummary {
|
||||
return {
|
||||
returnId: r.returnId,
|
||||
docNo: r.docNo,
|
||||
vendorId: r.vendorId,
|
||||
warehouseId: r.warehouseId,
|
||||
reasonCodeId: r.reasonCodeId,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const purchaseReturnsApi = {
|
||||
list() {
|
||||
return apiRequest<PagedResponse<PurchaseReturnSummary>>("/purchase-returns")
|
||||
list(): Promise<PagedResponse<PurchaseReturnSummary>> {
|
||||
const items = [...mockPurchaseReturns].sort((a, b) => b.returnId - a.returnId).map(toSummary)
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
get(returnId: number) {
|
||||
return apiRequest<PurchaseReturn>(`/purchase-returns/${returnId}`)
|
||||
|
||||
get(returnId: number): Promise<PurchaseReturn> {
|
||||
const r = mockPurchaseReturns.find((x) => x.returnId === returnId)
|
||||
if (!r) return Promise.reject(new Error(`Mock purchase return ${returnId} not found`))
|
||||
return mockDelay(r)
|
||||
},
|
||||
create(request: CreatePurchaseReturnRequest) {
|
||||
return apiRequest<PurchaseReturn>("/purchase-returns", { method: "POST", body: request })
|
||||
|
||||
create(request: CreatePurchaseReturnRequest): Promise<PurchaseReturn> {
|
||||
if (!request.reasonCodeId) {
|
||||
return Promise.reject(Object.assign(new Error("A reason code is required for returns."), { code: "REASON_CODE_REQUIRED" }))
|
||||
}
|
||||
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
|
||||
|
||||
const returnId = allocatePurchaseReturnId()
|
||||
const ledgerRefs: number[] = []
|
||||
const lines: PurchaseReturn["lines"] = []
|
||||
|
||||
try {
|
||||
for (const line of request.lines) {
|
||||
// FR-PROC-08: consumes the exact layer the GRN line created; throws
|
||||
// StockNegativeError (409 STOCK_NEGATIVE_BLOCKED) if qty exceeds it.
|
||||
const chunk = consumeLayerByGrnLine(line.grnLineId, line.qty)
|
||||
const ledger = postLedgerEntry({
|
||||
itemId: line.itemId,
|
||||
warehouseId: chunk.warehouseId,
|
||||
userId: 17,
|
||||
direction: "Out",
|
||||
qtyBase: chunk.qtyConsumed,
|
||||
unitCost: chunk.unitCost,
|
||||
sourceDocType: "PurchaseReturn",
|
||||
sourceDocId: returnId,
|
||||
})
|
||||
ledgerRefs.push(ledger.ledgerId)
|
||||
lines.push({
|
||||
returnLineId: allocatePurchaseReturnLineId(),
|
||||
grnLineId: line.grnLineId,
|
||||
itemId: line.itemId,
|
||||
qty: line.qty,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
const purchaseReturn: PurchaseReturn = {
|
||||
returnId,
|
||||
docNo: `PRET-2026-${String(returnId).padStart(5, "0")}`,
|
||||
vendorId: request.vendorId,
|
||||
warehouseId: request.warehouseId,
|
||||
reasonCodeId: request.reasonCodeId,
|
||||
status: "Posted",
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines,
|
||||
ledgerRefs,
|
||||
}
|
||||
mockPurchaseReturns.push(purchaseReturn)
|
||||
return mockDelay(purchaseReturn)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
// One typed client method for reference data (docs/11-BACKEND-PHASE1.md §6).
|
||||
//
|
||||
// Note: no ReasonCodesController exists yet — this will 404 until Stock
|
||||
// Transactions (Backend/PROGRESS.md §5) is built.
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts).
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { ReasonCode, ReasonCodeContext } from "@/types/stock"
|
||||
import { mockDelay, mockReasonCodes } from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export const reasonCodesApi = {
|
||||
// list(context?: ReasonCodeContext) {
|
||||
// return apiRequest<PagedResponse<ReasonCode>>(`/reason-codes${buildQuery({ context })}`)
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export const reasonCodesApi = {
|
||||
list(context?: ReasonCodeContext) {
|
||||
return apiRequest<PagedResponse<ReasonCode>>(`/reason-codes${buildQuery({ context })}`)
|
||||
list(context?: ReasonCodeContext): Promise<PagedResponse<ReasonCode>> {
|
||||
const items = mockReasonCodes.filter((r) => !context || r.context === context)
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,25 +1,103 @@
|
||||
// One typed client method per Requisition endpoint (docs/11-BACKEND-PHASE1.md §3.1, FR-PROC-01).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement
|
||||
// screens can be reviewed without a running backend. Restore the commented block
|
||||
// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateRequisitionRequest, Requisition, RequisitionStatus, RequisitionSummary } from "@/types/procurement"
|
||||
import { allocateReqLineId, allocateRequisitionId, mockDelay, mockRequisitions } from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface ListRequisitionsParams {
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// status?: RequisitionStatus
|
||||
// }
|
||||
//
|
||||
// export const requisitionsApi = {
|
||||
// list(params: ListRequisitionsParams = {}) {
|
||||
// return apiRequest<PagedResponse<RequisitionSummary>>(`/requisitions${buildQuery(params)}`)
|
||||
// },
|
||||
// get(requisitionId: number) {
|
||||
// return apiRequest<Requisition>(`/requisitions/${requisitionId}`)
|
||||
// },
|
||||
// create(request: CreateRequisitionRequest) {
|
||||
// return apiRequest<Requisition>("/requisitions", { method: "POST", body: request })
|
||||
// },
|
||||
// submit(requisitionId: number) {
|
||||
// return apiRequest<Requisition>(`/requisitions/${requisitionId}/submit`, { method: "POST", body: {} })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListRequisitionsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: RequisitionStatus
|
||||
}
|
||||
|
||||
function toSummary(r: Requisition): RequisitionSummary {
|
||||
return {
|
||||
requisitionId: r.requisitionId,
|
||||
docNo: r.docNo,
|
||||
status: r.status,
|
||||
requestedBy: r.requestedBy,
|
||||
createdAt: r.createdAt,
|
||||
lineCount: r.lines.length,
|
||||
}
|
||||
}
|
||||
|
||||
export const requisitionsApi = {
|
||||
list(params: ListRequisitionsParams = {}) {
|
||||
return apiRequest<PagedResponse<RequisitionSummary>>(`/requisitions${buildQuery(params)}`)
|
||||
list(params: ListRequisitionsParams = {}): Promise<PagedResponse<RequisitionSummary>> {
|
||||
const filtered = mockRequisitions
|
||||
.filter((r) => !params.status || r.status === params.status)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.requisitionId - a.requisitionId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
get(requisitionId: number) {
|
||||
return apiRequest<Requisition>(`/requisitions/${requisitionId}`)
|
||||
|
||||
get(requisitionId: number): Promise<Requisition> {
|
||||
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
|
||||
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
|
||||
return mockDelay(r)
|
||||
},
|
||||
create(request: CreateRequisitionRequest) {
|
||||
return apiRequest<Requisition>("/requisitions", { method: "POST", body: request })
|
||||
|
||||
create(request: CreateRequisitionRequest): Promise<Requisition> {
|
||||
if (request.lines.length === 0) {
|
||||
return Promise.reject(new Error("A requisition needs at least one line."))
|
||||
}
|
||||
const requisition: Requisition = {
|
||||
requisitionId: allocateRequisitionId(),
|
||||
docNo: "",
|
||||
status: "Draft",
|
||||
requestedBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.lines.map((l) => ({ reqLineId: allocateReqLineId(), itemId: l.itemId, qty: l.qty, requiredBy: l.requiredBy })),
|
||||
}
|
||||
requisition.docNo = `PR-2026-${String(requisition.requisitionId).padStart(5, "0")}`
|
||||
mockRequisitions.push(requisition)
|
||||
return mockDelay(requisition)
|
||||
},
|
||||
submit(requisitionId: number) {
|
||||
return apiRequest<Requisition>(`/requisitions/${requisitionId}/submit`, { method: "POST", body: {} })
|
||||
|
||||
submit(requisitionId: number): Promise<Requisition> {
|
||||
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
|
||||
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
|
||||
if (r.status !== "Draft") {
|
||||
return Promise.reject(new Error(`${r.docNo} has already been submitted.`))
|
||||
}
|
||||
r.status = "Submitted"
|
||||
return mockDelay(r)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,26 +1,126 @@
|
||||
// One typed client method per RFQ/Quotation endpoint (docs/11-BACKEND-PHASE1.md §3.2, FR-PROC-02).
|
||||
//
|
||||
// Note: `list()` calls GET /rfqs, which is not implemented by RfqsController
|
||||
// (only GET /rfqs/{id} exists) — see Backend/PROGRESS.md §2. This will 404
|
||||
// until that endpoint is added; flagged here rather than silently faked.
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement
|
||||
// screens can be reviewed without a running backend. Restore the commented block
|
||||
// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateQuotationRequest, CreateRfqRequest, Quotation, Rfq, RfqComparison, RfqSummary } from "@/types/procurement"
|
||||
import {
|
||||
CreateQuotationRequest,
|
||||
CreateRfqRequest,
|
||||
Quotation,
|
||||
Rfq,
|
||||
RfqComparison,
|
||||
RfqComparisonLine,
|
||||
RfqSummary,
|
||||
} from "@/types/procurement"
|
||||
import {
|
||||
allocateQuotationId,
|
||||
allocateRfqId,
|
||||
allocateRfqLineId,
|
||||
mockDelay,
|
||||
mockQuotations,
|
||||
mockRfqs,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest } from "@/lib/api-client"
|
||||
//
|
||||
// export const rfqsApi = {
|
||||
// list() {
|
||||
// return apiRequest<PagedResponse<RfqSummary>>("/rfqs")
|
||||
// },
|
||||
// get(rfqId: number) {
|
||||
// return apiRequest<Rfq>(`/rfqs/${rfqId}`)
|
||||
// },
|
||||
// create(request: CreateRfqRequest) {
|
||||
// return apiRequest<Rfq>("/rfqs", { method: "POST", body: request })
|
||||
// },
|
||||
// addQuotation(rfqId: number, request: CreateQuotationRequest) {
|
||||
// return apiRequest<Quotation>(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request })
|
||||
// },
|
||||
// comparison(rfqId: number) {
|
||||
// return apiRequest<RfqComparison>(`/rfqs/${rfqId}/comparison`)
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
function toSummary(r: Rfq): RfqSummary {
|
||||
return {
|
||||
rfqId: r.rfqId,
|
||||
docNo: r.docNo,
|
||||
requisitionId: r.requisitionId,
|
||||
status: r.status,
|
||||
vendorIds: r.vendorIds,
|
||||
createdAt: r.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const rfqsApi = {
|
||||
list() {
|
||||
return apiRequest<PagedResponse<RfqSummary>>("/rfqs")
|
||||
list(): Promise<PagedResponse<RfqSummary>> {
|
||||
const items = [...mockRfqs].sort((a, b) => b.rfqId - a.rfqId).map(toSummary)
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
get(rfqId: number) {
|
||||
return apiRequest<Rfq>(`/rfqs/${rfqId}`)
|
||||
|
||||
get(rfqId: number): Promise<Rfq> {
|
||||
const r = mockRfqs.find((x) => x.rfqId === rfqId)
|
||||
if (!r) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
|
||||
return mockDelay(r)
|
||||
},
|
||||
create(request: CreateRfqRequest) {
|
||||
return apiRequest<Rfq>("/rfqs", { method: "POST", body: request })
|
||||
|
||||
create(request: CreateRfqRequest): Promise<Rfq> {
|
||||
if (request.vendorIds.length === 0) return Promise.reject(new Error("Select at least one vendor."))
|
||||
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
|
||||
const rfq: Rfq = {
|
||||
rfqId: allocateRfqId(),
|
||||
docNo: "",
|
||||
requisitionId: request.requisitionId ?? null,
|
||||
status: "Open",
|
||||
vendorIds: request.vendorIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.lines.map((l) => ({ rfqLineId: allocateRfqLineId(), itemId: l.itemId, qty: l.qty })),
|
||||
}
|
||||
rfq.docNo = `RFQ-2026-${String(rfq.rfqId).padStart(5, "0")}`
|
||||
mockRfqs.push(rfq)
|
||||
return mockDelay(rfq)
|
||||
},
|
||||
addQuotation(rfqId: number, request: CreateQuotationRequest) {
|
||||
return apiRequest<Quotation>(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request })
|
||||
|
||||
addQuotation(rfqId: number, request: CreateQuotationRequest): Promise<Quotation> {
|
||||
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
|
||||
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
|
||||
if (!rfq.vendorIds.includes(request.vendorId)) {
|
||||
return Promise.reject(new Error("This vendor was not invited to the RFQ."))
|
||||
}
|
||||
const quotation: Quotation = {
|
||||
quotationId: allocateQuotationId(),
|
||||
rfqId,
|
||||
vendorId: request.vendorId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.lines,
|
||||
}
|
||||
mockQuotations.push(quotation)
|
||||
return mockDelay(quotation)
|
||||
},
|
||||
comparison(rfqId: number) {
|
||||
return apiRequest<RfqComparison>(`/rfqs/${rfqId}/comparison`)
|
||||
|
||||
comparison(rfqId: number): Promise<RfqComparison> {
|
||||
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
|
||||
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
|
||||
const quotations = mockQuotations.filter((q) => q.rfqId === rfqId)
|
||||
|
||||
const lines: RfqComparisonLine[] = rfq.lines.map((rfqLine) => ({
|
||||
itemId: rfqLine.itemId,
|
||||
qty: rfqLine.qty,
|
||||
cells: quotations
|
||||
.map((q) => {
|
||||
const line = q.lines.find((l) => l.itemId === rfqLine.itemId)
|
||||
return line ? { vendorId: q.vendorId, unitPrice: line.unitPrice, leadDays: line.leadDays } : null
|
||||
})
|
||||
.filter((c): c is { vendorId: number; unitPrice: number; leadDays: number } => c !== null),
|
||||
}))
|
||||
|
||||
return mockDelay({ rfqId, vendorIds: rfq.vendorIds, lines })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,27 +1,152 @@
|
||||
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
|
||||
//
|
||||
// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These
|
||||
// calls will 404 until that's built. GET /stock-adjustments and GET
|
||||
// /stock-adjustments/{id} are also not documented in docs/11 §5.5 — same
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the
|
||||
// commented block and delete the mock block once Backend/PROGRESS.md §5
|
||||
// (stock transactions) exists. GET /stock-adjustments and GET
|
||||
// /stock-adjustments/{id} are not documented in docs/11 §5.5 — same
|
||||
// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
|
||||
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
|
||||
import {
|
||||
allocateAdjLineId,
|
||||
allocateAdjustmentId,
|
||||
consumeFifo,
|
||||
lastKnownCost,
|
||||
mockDelay,
|
||||
mockStockAdjustments,
|
||||
postLedgerEntry,
|
||||
receiveLayer,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface ListAdjustmentsParams {
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// warehouseId?: number
|
||||
// }
|
||||
//
|
||||
// export const stockAdjustmentsApi = {
|
||||
// list(params: ListAdjustmentsParams = {}) {
|
||||
// return apiRequest<PagedResponse<StockAdjustmentSummary>>(`/stock-adjustments${buildQuery(params)}`)
|
||||
// },
|
||||
// get(adjustmentId: number) {
|
||||
// return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}`)
|
||||
// },
|
||||
// create(request: CreateAdjustmentRequest) {
|
||||
// return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListAdjustmentsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
warehouseId?: number
|
||||
}
|
||||
|
||||
function toSummary(a: (typeof mockStockAdjustments)[number]): StockAdjustmentSummary {
|
||||
return {
|
||||
adjustmentId: a.adjustmentId,
|
||||
docNo: a.docNo,
|
||||
warehouseId: a.warehouseId,
|
||||
reasonCodeId: a.reasonCodeId,
|
||||
status: a.status,
|
||||
createdAt: a.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const stockAdjustmentsApi = {
|
||||
list(params: ListAdjustmentsParams = {}) {
|
||||
return apiRequest<PagedResponse<StockAdjustmentSummary>>(`/stock-adjustments${buildQuery(params)}`)
|
||||
list(params: ListAdjustmentsParams = {}): Promise<PagedResponse<StockAdjustmentSummary>> {
|
||||
const filtered = mockStockAdjustments
|
||||
.filter((a) => !params.warehouseId || a.warehouseId === params.warehouseId)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.adjustmentId - a.adjustmentId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
get(adjustmentId: number) {
|
||||
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}`)
|
||||
|
||||
get(adjustmentId: number): Promise<StockAdjustment> {
|
||||
const a = mockStockAdjustments.find((x) => x.adjustmentId === adjustmentId)
|
||||
if (!a) return Promise.reject(new Error(`Mock adjustment ${adjustmentId} not found`))
|
||||
return mockDelay(a)
|
||||
},
|
||||
create(request: CreateAdjustmentRequest) {
|
||||
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
|
||||
|
||||
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
|
||||
if (!request.reasonCodeId) {
|
||||
return Promise.reject(Object.assign(new Error("A reason code is required for adjustments."), { code: "REASON_CODE_REQUIRED" }))
|
||||
}
|
||||
|
||||
const adjustmentId = allocateAdjustmentId()
|
||||
const ledgerRefs: number[] = []
|
||||
const lines: StockAdjustment["lines"] = []
|
||||
|
||||
try {
|
||||
for (const line of request.lines) {
|
||||
const adjLineId = allocateAdjLineId()
|
||||
lines.push({ adjLineId, itemId: line.itemId, binId: line.binId ?? null, batchId: line.batchId ?? null, qtyDelta: line.qtyDelta })
|
||||
|
||||
if (line.qtyDelta > 0) {
|
||||
// FR-STK-07: increase creates a layer at the last known cost.
|
||||
const unitCost = lastKnownCost(line.itemId, request.warehouseId)
|
||||
const { ledger } = receiveLayer({
|
||||
itemId: line.itemId,
|
||||
warehouseId: request.warehouseId,
|
||||
binId: line.binId,
|
||||
batchId: line.batchId,
|
||||
qty: line.qtyDelta,
|
||||
unitCost,
|
||||
userId: 17,
|
||||
sourceDocType: "Adjustment",
|
||||
sourceDocId: adjustmentId,
|
||||
})
|
||||
ledgerRefs.push(ledger.ledgerId)
|
||||
} else if (line.qtyDelta < 0) {
|
||||
// Decrease consumes FIFO layers (409 STOCK_NEGATIVE_BLOCKED if insufficient).
|
||||
const chunks = consumeFifo(line.itemId, request.warehouseId, Math.abs(line.qtyDelta))
|
||||
for (const chunk of chunks) {
|
||||
const ledger = postLedgerEntry({
|
||||
itemId: line.itemId,
|
||||
warehouseId: request.warehouseId,
|
||||
binId: line.binId,
|
||||
batchId: line.batchId,
|
||||
userId: 17,
|
||||
direction: "Out",
|
||||
qtyBase: chunk.qtyConsumed,
|
||||
unitCost: chunk.unitCost,
|
||||
sourceDocType: "Adjustment",
|
||||
sourceDocId: adjustmentId,
|
||||
})
|
||||
ledgerRefs.push(ledger.ledgerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
const adjustment = {
|
||||
adjustmentId,
|
||||
docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`,
|
||||
warehouseId: request.warehouseId,
|
||||
reasonCodeId: request.reasonCodeId,
|
||||
status: "Posted" as AdjustmentStatus,
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines,
|
||||
ledgerRefs,
|
||||
}
|
||||
mockStockAdjustments.push(adjustment)
|
||||
return mockDelay(adjustment)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// One typed client method per count endpoint (docs/11-BACKEND-PHASE1.md §5.6).
|
||||
//
|
||||
// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These
|
||||
// calls will 404 until that's built. GET /stock-counts and GET
|
||||
// /stock-counts/{id} are also not documented in docs/11 §5.6 — same
|
||||
// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the
|
||||
// commented block and delete the mock block once Backend/PROGRESS.md §5
|
||||
// (stock transactions) exists. GET /stock-counts and GET /stock-counts/{id}
|
||||
// are not documented in docs/11 §5.6 — same assumed-extension deviation as
|
||||
// GRN (see Frontend/PROGRESS.md §5).
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CountStatus,
|
||||
CreateCountRequest,
|
||||
EnterCountsRequest,
|
||||
EnterCountsResponse,
|
||||
@@ -14,27 +16,183 @@ import {
|
||||
StockCount,
|
||||
StockCountSummary,
|
||||
} from "@/types/stock"
|
||||
import {
|
||||
allocateAdjLineId,
|
||||
allocateAdjustmentId,
|
||||
allocateCountId,
|
||||
allocateCountLineId,
|
||||
computeOnHand,
|
||||
consumeFifo,
|
||||
lastKnownCost,
|
||||
mockDelay,
|
||||
mockStockAdjustments,
|
||||
mockStockCounts,
|
||||
postLedgerEntry,
|
||||
receiveLayer,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface ListCountsParams {
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// warehouseId?: number
|
||||
// }
|
||||
//
|
||||
// export const stockCountsApi = {
|
||||
// list(params: ListCountsParams = {}) {
|
||||
// return apiRequest<PagedResponse<StockCountSummary>>(`/stock-counts${buildQuery(params)}`)
|
||||
// },
|
||||
// get(countId: number) {
|
||||
// return apiRequest<StockCount>(`/stock-counts/${countId}`)
|
||||
// },
|
||||
// create(request: CreateCountRequest) {
|
||||
// return apiRequest<StockCount>("/stock-counts", { method: "POST", body: request })
|
||||
// },
|
||||
// enterCounts(countId: number, request: EnterCountsRequest) {
|
||||
// return apiRequest<EnterCountsResponse>(`/stock-counts/${countId}/counts`, { method: "PUT", body: request })
|
||||
// },
|
||||
// post(countId: number) {
|
||||
// return apiRequest<PostCountResponse>(`/stock-counts/${countId}/post`, { method: "POST", body: {} })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListCountsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
warehouseId?: number
|
||||
}
|
||||
|
||||
function toSummary(c: (typeof mockStockCounts)[number]): StockCountSummary {
|
||||
return {
|
||||
countId: c.countId,
|
||||
docNo: c.docNo,
|
||||
warehouseId: c.warehouseId,
|
||||
countType: c.countType,
|
||||
status: c.status,
|
||||
createdAt: c.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const stockCountsApi = {
|
||||
list(params: ListCountsParams = {}) {
|
||||
return apiRequest<PagedResponse<StockCountSummary>>(`/stock-counts${buildQuery(params)}`)
|
||||
list(params: ListCountsParams = {}): Promise<PagedResponse<StockCountSummary>> {
|
||||
const filtered = mockStockCounts
|
||||
.filter((c) => !params.warehouseId || c.warehouseId === params.warehouseId)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.countId - a.countId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
get(countId: number) {
|
||||
return apiRequest<StockCount>(`/stock-counts/${countId}`)
|
||||
|
||||
get(countId: number): Promise<StockCount> {
|
||||
const c = mockStockCounts.find((x) => x.countId === countId)
|
||||
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
|
||||
return mockDelay(c)
|
||||
},
|
||||
create(request: CreateCountRequest) {
|
||||
return apiRequest<StockCount>("/stock-counts", { method: "POST", body: request })
|
||||
|
||||
create(request: CreateCountRequest): Promise<StockCount> {
|
||||
const countId = allocateCountId()
|
||||
const count = {
|
||||
countId,
|
||||
docNo: `CNT-2026-${String(countId).padStart(5, "0")}`,
|
||||
warehouseId: request.warehouseId,
|
||||
countType: request.countType,
|
||||
status: "Draft" as CountStatus,
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.itemIds.map((itemId) => ({
|
||||
countLineId: allocateCountLineId(),
|
||||
itemId,
|
||||
binId: null,
|
||||
systemQty: computeOnHand(itemId, request.warehouseId).onHand,
|
||||
countedQty: null,
|
||||
variance: null,
|
||||
})),
|
||||
}
|
||||
mockStockCounts.push(count)
|
||||
return mockDelay(count)
|
||||
},
|
||||
enterCounts(countId: number, request: EnterCountsRequest) {
|
||||
return apiRequest<EnterCountsResponse>(`/stock-counts/${countId}/counts`, { method: "PUT", body: request })
|
||||
|
||||
enterCounts(countId: number, request: EnterCountsRequest): Promise<EnterCountsResponse> {
|
||||
const c = mockStockCounts.find((x) => x.countId === countId)
|
||||
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
|
||||
|
||||
for (const input of request.lines) {
|
||||
const line = c.lines.find((l) => l.countLineId === input.countLineId)
|
||||
if (!line) continue
|
||||
line.countedQty = input.countedQty
|
||||
line.variance = Math.round((input.countedQty - line.systemQty) * 100) / 100
|
||||
}
|
||||
return mockDelay({ lines: c.lines })
|
||||
},
|
||||
post(countId: number) {
|
||||
return apiRequest<PostCountResponse>(`/stock-counts/${countId}/post`, { method: "POST", body: {} })
|
||||
|
||||
post(countId: number): Promise<PostCountResponse> {
|
||||
const c = mockStockCounts.find((x) => x.countId === countId)
|
||||
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
|
||||
if (c.status === "Posted") return Promise.reject(new Error(`${c.docNo} has already been posted.`))
|
||||
|
||||
const adjustmentId = allocateAdjustmentId()
|
||||
const ledgerRefs: number[] = []
|
||||
const adjLines: { adjLineId: number; itemId: number; binId: number | null; batchId: number | null; qtyDelta: number }[] = []
|
||||
|
||||
for (const line of c.lines) {
|
||||
if (!line.variance) continue
|
||||
adjLines.push({ adjLineId: allocateAdjLineId(), itemId: line.itemId, binId: line.binId, batchId: null, qtyDelta: line.variance })
|
||||
|
||||
if (line.variance > 0) {
|
||||
const unitCost = lastKnownCost(line.itemId, c.warehouseId)
|
||||
const { ledger } = receiveLayer({
|
||||
itemId: line.itemId,
|
||||
warehouseId: c.warehouseId,
|
||||
qty: line.variance,
|
||||
unitCost,
|
||||
userId: 17,
|
||||
sourceDocType: "Count",
|
||||
sourceDocId: c.countId,
|
||||
})
|
||||
ledgerRefs.push(ledger.ledgerId)
|
||||
} else {
|
||||
const chunks = consumeFifo(line.itemId, c.warehouseId, Math.abs(line.variance))
|
||||
for (const chunk of chunks) {
|
||||
const ledger = postLedgerEntry({
|
||||
itemId: line.itemId,
|
||||
warehouseId: c.warehouseId,
|
||||
userId: 17,
|
||||
direction: "Out",
|
||||
qtyBase: chunk.qtyConsumed,
|
||||
unitCost: chunk.unitCost,
|
||||
sourceDocType: "Count",
|
||||
sourceDocId: c.countId,
|
||||
})
|
||||
ledgerRefs.push(ledger.ledgerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count Variance reason code (docs/8.3 seed list) — posted as its own adjustment record.
|
||||
mockStockAdjustments.push({
|
||||
adjustmentId,
|
||||
docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`,
|
||||
warehouseId: c.warehouseId,
|
||||
reasonCodeId: 3,
|
||||
status: "Posted",
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: adjLines,
|
||||
ledgerRefs,
|
||||
})
|
||||
|
||||
c.status = "Posted"
|
||||
return mockDelay({ countId: c.countId, status: c.status, adjustmentId, ledgerRefs })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// One typed client method per transfer endpoint (docs/11-BACKEND-PHASE1.md §5.4).
|
||||
//
|
||||
// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These
|
||||
// calls will 404 until that's built. GET /stock-transfers and GET
|
||||
// /stock-transfers/{id} are also not documented in docs/11 §5.4 — same
|
||||
// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the
|
||||
// commented block and delete the mock block once Backend/PROGRESS.md §5
|
||||
// (stock transactions) exists. GET /stock-transfers and GET /stock-transfers/{id}
|
||||
// are not documented in docs/11 §5.4 — same assumed-extension deviation as GRN
|
||||
// (see Frontend/PROGRESS.md §5).
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateTransferRequest,
|
||||
@@ -15,7 +16,50 @@ import {
|
||||
StockTransferSummary,
|
||||
TransferStatus,
|
||||
} from "@/types/stock"
|
||||
import {
|
||||
MockTransferLine,
|
||||
allocateTransferId,
|
||||
allocateTransferLineId,
|
||||
consumeFifo,
|
||||
mockDelay,
|
||||
mockStockTransfers,
|
||||
postLedgerEntry,
|
||||
receiveLayer,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface ListTransfersParams {
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// status?: TransferStatus
|
||||
// srcWarehouseId?: number
|
||||
// destWarehouseId?: number
|
||||
// }
|
||||
//
|
||||
// export const stockTransfersApi = {
|
||||
// list(params: ListTransfersParams = {}) {
|
||||
// return apiRequest<PagedResponse<StockTransferSummary>>(`/stock-transfers${buildQuery(params)}`)
|
||||
// },
|
||||
// get(transferId: number) {
|
||||
// return apiRequest<StockTransfer>(`/stock-transfers/${transferId}`)
|
||||
// },
|
||||
// create(request: CreateTransferRequest) {
|
||||
// return apiRequest<StockTransfer>("/stock-transfers", { method: "POST", body: request })
|
||||
// },
|
||||
// dispatch(transferId: number) {
|
||||
// return apiRequest<DispatchTransferResponse>(`/stock-transfers/${transferId}/dispatch`, { method: "POST", body: {} })
|
||||
// },
|
||||
// receive(transferId: number, lines: ReceiveTransferLineInput[]) {
|
||||
// return apiRequest<ReceiveTransferResponse>(`/stock-transfers/${transferId}/receive`, {
|
||||
// method: "POST",
|
||||
// body: { lines },
|
||||
// })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListTransfersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -24,23 +68,154 @@ export interface ListTransfersParams {
|
||||
destWarehouseId?: number
|
||||
}
|
||||
|
||||
function toPublicLine(line: MockTransferLine) {
|
||||
return {
|
||||
transferLineId: line.transferLineId,
|
||||
itemId: line.itemId,
|
||||
srcBinId: line.srcBinId,
|
||||
destBinId: line.destBinId,
|
||||
batchId: line.batchId,
|
||||
qty: line.qty,
|
||||
}
|
||||
}
|
||||
|
||||
function toSummary(t: (typeof mockStockTransfers)[number]): StockTransferSummary {
|
||||
return {
|
||||
transferId: t.transferId,
|
||||
docNo: t.docNo,
|
||||
srcWarehouseId: t.srcWarehouseId,
|
||||
destWarehouseId: t.destWarehouseId,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
function toPublic(t: (typeof mockStockTransfers)[number]): StockTransfer {
|
||||
return { ...toSummary(t), createdBy: t.createdBy, lines: t.lines.map(toPublicLine) }
|
||||
}
|
||||
|
||||
export const stockTransfersApi = {
|
||||
list(params: ListTransfersParams = {}) {
|
||||
return apiRequest<PagedResponse<StockTransferSummary>>(`/stock-transfers${buildQuery(params)}`)
|
||||
},
|
||||
get(transferId: number) {
|
||||
return apiRequest<StockTransfer>(`/stock-transfers/${transferId}`)
|
||||
},
|
||||
create(request: CreateTransferRequest) {
|
||||
return apiRequest<StockTransfer>("/stock-transfers", { method: "POST", body: request })
|
||||
},
|
||||
dispatch(transferId: number) {
|
||||
return apiRequest<DispatchTransferResponse>(`/stock-transfers/${transferId}/dispatch`, { method: "POST", body: {} })
|
||||
},
|
||||
receive(transferId: number, lines: ReceiveTransferLineInput[]) {
|
||||
return apiRequest<ReceiveTransferResponse>(`/stock-transfers/${transferId}/receive`, {
|
||||
method: "POST",
|
||||
body: { lines },
|
||||
list(params: ListTransfersParams = {}): Promise<PagedResponse<StockTransferSummary>> {
|
||||
const filtered = mockStockTransfers
|
||||
.filter((t) => !params.status || t.status === params.status)
|
||||
.filter((t) => !params.srcWarehouseId || t.srcWarehouseId === params.srcWarehouseId)
|
||||
.filter((t) => !params.destWarehouseId || t.destWarehouseId === params.destWarehouseId)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.transferId - a.transferId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
|
||||
get(transferId: number): Promise<StockTransfer> {
|
||||
const t = mockStockTransfers.find((x) => x.transferId === transferId)
|
||||
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
|
||||
return mockDelay(toPublic(t))
|
||||
},
|
||||
|
||||
create(request: CreateTransferRequest): Promise<StockTransfer> {
|
||||
const transferId = allocateTransferId()
|
||||
const t = {
|
||||
transferId,
|
||||
docNo: `TRF-2026-${String(transferId).padStart(5, "0")}`,
|
||||
srcWarehouseId: request.srcWarehouseId,
|
||||
destWarehouseId: request.destWarehouseId,
|
||||
status: "Draft" as TransferStatus,
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.lines.map((l) => ({
|
||||
transferLineId: allocateTransferLineId(),
|
||||
itemId: l.itemId,
|
||||
srcBinId: l.srcBinId ?? null,
|
||||
destBinId: l.destBinId ?? null,
|
||||
batchId: l.batchId ?? null,
|
||||
qty: l.qty,
|
||||
dispatchedChunks: [],
|
||||
})),
|
||||
}
|
||||
mockStockTransfers.push(t)
|
||||
return mockDelay(toPublic(t))
|
||||
},
|
||||
|
||||
dispatch(transferId: number): Promise<DispatchTransferResponse> {
|
||||
const t = mockStockTransfers.find((x) => x.transferId === transferId)
|
||||
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
|
||||
if (t.status !== "Draft") return Promise.reject(new Error(`${t.docNo} has already been dispatched.`))
|
||||
|
||||
const consumedLayers: { layerId: number; qtyConsumed: number; unitCost: number }[] = []
|
||||
const ledgerRefs: number[] = []
|
||||
|
||||
try {
|
||||
for (const line of t.lines) {
|
||||
const chunks = consumeFifo(line.itemId, t.srcWarehouseId, line.qty)
|
||||
line.dispatchedChunks = chunks
|
||||
for (const chunk of chunks) {
|
||||
const ledger = postLedgerEntry({
|
||||
itemId: line.itemId,
|
||||
warehouseId: t.srcWarehouseId,
|
||||
binId: line.srcBinId,
|
||||
batchId: line.batchId,
|
||||
userId: t.createdBy,
|
||||
direction: "Out",
|
||||
qtyBase: chunk.qtyConsumed,
|
||||
unitCost: chunk.unitCost,
|
||||
sourceDocType: "Transfer",
|
||||
sourceDocId: t.transferId,
|
||||
})
|
||||
consumedLayers.push(chunk)
|
||||
ledgerRefs.push(ledger.ledgerId)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
t.status = "InTransit"
|
||||
return mockDelay({ transferId: t.transferId, status: t.status, consumedLayers, ledgerRefs })
|
||||
},
|
||||
|
||||
receive(transferId: number, lines: ReceiveTransferLineInput[]): Promise<ReceiveTransferResponse> {
|
||||
const t = mockStockTransfers.find((x) => x.transferId === transferId)
|
||||
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
|
||||
if (t.status !== "InTransit") return Promise.reject(new Error(`${t.docNo} is not in transit.`))
|
||||
|
||||
const createdLayers: { layerId: number; warehouseId: number; qtyReceived: number; unitCost: number }[] = []
|
||||
const ledgerRefs: number[] = []
|
||||
|
||||
for (const input of lines) {
|
||||
const line = t.lines.find((l) => l.transferLineId === input.transferLineId)
|
||||
if (!line) continue
|
||||
// Cost-preserving (FR-STK-06): one destination layer per dispatched chunk, at its exact source cost.
|
||||
for (const chunk of line.dispatchedChunks) {
|
||||
const { layer, ledger } = receiveLayer({
|
||||
itemId: line.itemId,
|
||||
warehouseId: t.destWarehouseId,
|
||||
binId: line.destBinId,
|
||||
batchId: line.batchId,
|
||||
qty: chunk.qtyConsumed,
|
||||
unitCost: chunk.unitCost,
|
||||
userId: t.createdBy,
|
||||
sourceDocType: "Transfer",
|
||||
sourceDocId: t.transferId,
|
||||
})
|
||||
createdLayers.push({
|
||||
layerId: layer.layerId,
|
||||
warehouseId: layer.warehouseId,
|
||||
qtyReceived: layer.qtyReceived,
|
||||
unitCost: layer.unitCost,
|
||||
})
|
||||
ledgerRefs.push(ledger.ledgerId)
|
||||
}
|
||||
}
|
||||
|
||||
t.status = "Received"
|
||||
return mockDelay({ transferId: t.transferId, status: t.status, createdLayers, ledgerRefs })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,15 +1,62 @@
|
||||
// One typed client method per stock-enquiry endpoint (docs/11-BACKEND-PHASE1.md §5.1-5.3, §5.7).
|
||||
//
|
||||
// Note: no Stock Core backend exists yet (Backend/PROGRESS.md §4 is unstarted).
|
||||
// These calls will 404 until that's built. `onHandList`/`createReorderRequisition`
|
||||
// are frontend-only conveniences, not documented endpoints (same posture as the
|
||||
// GRN list/detail assumed-extension deviation, see Frontend/PROGRESS.md §5).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with the in-memory Stock Core (lib/api/mock-data.ts) so the Stock
|
||||
// Management screens can be reviewed without a running backend. Restore the
|
||||
// commented block and delete the mock block once Backend/PROGRESS.md §4
|
||||
// (Stock Core) exists.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { LedgerEntry, OnHand, ReorderAlert, ReorderRequisitionResponse, Valuation } from "@/types/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import {
|
||||
allocateReqLineId,
|
||||
allocateRequisitionId,
|
||||
computeOnHand,
|
||||
knownStockKeys,
|
||||
mockDelay,
|
||||
mockItemReorders,
|
||||
mockRequisitions,
|
||||
mockStockLayers,
|
||||
mockStockLedger,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface LedgerQuery {
|
||||
// itemId?: number
|
||||
// warehouseId?: number
|
||||
// from?: string
|
||||
// to?: string
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// }
|
||||
//
|
||||
// export const stockApi = {
|
||||
// onHand(itemId: number, warehouseId: number) {
|
||||
// return apiRequest<OnHand>(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`)
|
||||
// },
|
||||
//
|
||||
// ledger(params: LedgerQuery) {
|
||||
// return apiRequest<PagedResponse<LedgerEntry>>(`/stock/ledger${buildQuery(params)}`)
|
||||
// },
|
||||
//
|
||||
// valuation(itemId: number, warehouseId: number) {
|
||||
// return apiRequest<Valuation>(`/stock/valuation${buildQuery({ itemId, warehouseId })}`)
|
||||
// },
|
||||
//
|
||||
// reorderAlerts(warehouseId?: number) {
|
||||
// return apiRequest<PagedResponse<ReorderAlert>>(`/stock/reorder-alerts${buildQuery({ warehouseId })}`)
|
||||
// },
|
||||
//
|
||||
// createReorderRequisition(itemId: number, warehouseId: number) {
|
||||
// return apiRequest<ReorderRequisitionResponse>(
|
||||
// `/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`,
|
||||
// { method: "POST", body: {} }
|
||||
// )
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface LedgerQuery {
|
||||
itemId?: number
|
||||
warehouseId?: number
|
||||
@@ -20,37 +67,124 @@ export interface LedgerQuery {
|
||||
}
|
||||
|
||||
export const stockApi = {
|
||||
onHand(itemId: number, warehouseId: number) {
|
||||
return apiRequest<OnHand>(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`)
|
||||
onHand(itemId: number, warehouseId: number): Promise<OnHand> {
|
||||
const computed = computeOnHand(itemId, warehouseId)
|
||||
return mockDelay({
|
||||
itemId,
|
||||
warehouseId,
|
||||
...computed,
|
||||
asOf: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
|
||||
/** Every item x warehouse combination — composed client-side, not a documented endpoint. */
|
||||
async onHandList(): Promise<OnHand[]> {
|
||||
const [items, warehouses] = await Promise.all([
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list(),
|
||||
])
|
||||
const pairs = items.items.flatMap((item) => warehouses.items.map((wh) => ({ item, wh })))
|
||||
const rows = await Promise.all(pairs.map(({ item, wh }) => stockApi.onHand(item.itemId, wh.warehouseId)))
|
||||
return rows.filter((r) => r.onHand > 0 || r.onHold > 0 || r.inTransit > 0)
|
||||
/** Every item/warehouse pair currently on record — the Enquiry screen's row source. */
|
||||
onHandList(): Promise<OnHand[]> {
|
||||
const rows = knownStockKeys().map(({ itemId, warehouseId }) => ({
|
||||
itemId,
|
||||
warehouseId,
|
||||
...computeOnHand(itemId, warehouseId),
|
||||
asOf: new Date().toISOString(),
|
||||
}))
|
||||
return mockDelay(rows)
|
||||
},
|
||||
|
||||
ledger(params: LedgerQuery) {
|
||||
return apiRequest<PagedResponse<LedgerEntry>>(`/stock/ledger${buildQuery(params)}`)
|
||||
ledger(params: LedgerQuery): Promise<PagedResponse<LedgerEntry>> {
|
||||
const from = params.from ? new Date(params.from).getTime() : null
|
||||
const to = params.to ? new Date(params.to).getTime() : null
|
||||
|
||||
const filtered = mockStockLedger
|
||||
.filter((e) => !params.itemId || e.itemId === params.itemId)
|
||||
.filter((e) => !params.warehouseId || e.warehouseId === params.warehouseId)
|
||||
.filter((e) => from === null || new Date(e.createdAt).getTime() >= from)
|
||||
.filter((e) => to === null || new Date(e.createdAt).getTime() <= to)
|
||||
.sort((a, b) => b.ledgerId - a.ledgerId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems: filtered.length,
|
||||
totalPages: pageSize <= 0 ? 0 : Math.ceil(filtered.length / pageSize),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
valuation(itemId: number, warehouseId: number) {
|
||||
return apiRequest<Valuation>(`/stock/valuation${buildQuery({ itemId, warehouseId })}`)
|
||||
valuation(itemId: number, warehouseId: number): Promise<Valuation> {
|
||||
const layers = mockStockLayers
|
||||
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0)
|
||||
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
|
||||
.map((l) => ({
|
||||
layerId: l.layerId,
|
||||
qtyRemaining: l.qtyRemaining,
|
||||
unitCost: l.unitCost,
|
||||
value: Math.round(l.qtyRemaining * l.unitCost * 100) / 100,
|
||||
receiptDate: l.receiptDate,
|
||||
}))
|
||||
|
||||
const totalQty = layers.reduce((sum, l) => sum + l.qtyRemaining, 0)
|
||||
const totalValue = Math.round(layers.reduce((sum, l) => sum + l.value, 0) * 100) / 100
|
||||
|
||||
return mockDelay({
|
||||
itemId,
|
||||
warehouseId,
|
||||
layers,
|
||||
totalQty,
|
||||
totalValue,
|
||||
currency: "LKR",
|
||||
costingMethod: "FIFO",
|
||||
})
|
||||
},
|
||||
|
||||
reorderAlerts(warehouseId?: number) {
|
||||
return apiRequest<PagedResponse<ReorderAlert>>(`/stock/reorder-alerts${buildQuery({ warehouseId })}`)
|
||||
reorderAlerts(warehouseId?: number): Promise<PagedResponse<ReorderAlert>> {
|
||||
const items = mockItemReorders
|
||||
.filter((r) => !warehouseId || r.warehouseId === warehouseId)
|
||||
.map((r) => ({ ...r, available: computeOnHand(r.itemId, r.warehouseId).available }))
|
||||
.filter((r) => r.available <= r.reorderPoint)
|
||||
.map((r) => ({
|
||||
itemId: r.itemId,
|
||||
warehouseId: r.warehouseId,
|
||||
available: r.available,
|
||||
reorderPoint: r.reorderPoint,
|
||||
reorderQty: r.reorderQty,
|
||||
suggestedRequisitionQty: r.reorderQty,
|
||||
}))
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
createReorderRequisition(itemId: number, warehouseId: number) {
|
||||
return apiRequest<ReorderRequisitionResponse>(
|
||||
`/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`,
|
||||
{ method: "POST", body: {} }
|
||||
)
|
||||
createReorderRequisition(itemId: number, warehouseId: number): Promise<ReorderRequisitionResponse> {
|
||||
const setting = mockItemReorders.find((r) => r.itemId === itemId && r.warehouseId === warehouseId)
|
||||
const qty = setting?.reorderQty ?? 0
|
||||
const requisitionId = allocateRequisitionId()
|
||||
const requiredBy = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||
|
||||
// Genuinely lands in the Requisitions list (§3), not a fabricated response —
|
||||
// same "wire mock modules together" posture as GRN confirm → Stock Core.
|
||||
mockRequisitions.push({
|
||||
requisitionId,
|
||||
docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`,
|
||||
status: "Draft",
|
||||
requestedBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: [{ reqLineId: allocateReqLineId(), itemId, qty, requiredBy }],
|
||||
})
|
||||
|
||||
return mockDelay({
|
||||
requisitionId,
|
||||
docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`,
|
||||
itemId,
|
||||
warehouseId,
|
||||
qty,
|
||||
status: "Draft",
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
|
||||
// `list` also backs the GRN/PO line UOM picker.
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
// `list` also backs the GRN/PO line UOM picker built in earlier sessions.
|
||||
//
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens
|
||||
// can be reviewed without a running backend. Restore the commented block and
|
||||
// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateUomRequest, Uom } from "@/types/master-data"
|
||||
import { allocateUomId, mockDelay, mockUoms } from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest } from "@/lib/api-client"
|
||||
//
|
||||
// export const uomsApi = {
|
||||
// list() {
|
||||
// return apiRequest<PagedResponse<Uom>>("/uoms")
|
||||
// },
|
||||
// create(request: CreateUomRequest) {
|
||||
// return apiRequest<Uom>("/uoms", { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export const uomsApi = {
|
||||
list() {
|
||||
return apiRequest<PagedResponse<Uom>>("/uoms")
|
||||
list(): Promise<PagedResponse<Uom>> {
|
||||
return mockDelay({
|
||||
items: mockUoms,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: mockUoms.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
create(request: CreateUomRequest) {
|
||||
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
|
||||
|
||||
create(request: CreateUomRequest): Promise<Uom> {
|
||||
const name = request.name.trim()
|
||||
if (!name) return Promise.reject(new Error("UOM name is required."))
|
||||
if (mockUoms.some((u) => u.name.toLowerCase() === name.toLowerCase())) {
|
||||
return Promise.reject(Object.assign(new Error(`UOM "${name}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
const uom: Uom = { uomId: allocateUomId(), name }
|
||||
mockUoms.push(uom)
|
||||
return mockDelay(uom)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,63 @@
|
||||
// One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md
|
||||
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters
|
||||
// are deactivated, not hard-deleted, FR-MD-08).
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
// §2.4, FR-MD-06). "GET/PUT/PATCH follow the Item pattern" per the doc — ETag/If-Match
|
||||
// on update, PATCH status for deactivate (masters are deactivated, not hard-deleted,
|
||||
// FR-MD-08).
|
||||
//
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens can
|
||||
// be reviewed without a running backend. Restore the commented block and delete
|
||||
// the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
|
||||
import { ApiResult } from "@/lib/api-client"
|
||||
import { EntityStatus, PagedResponse } from "@/types/common"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import {
|
||||
allocateVendorId,
|
||||
bumpVendorVersion,
|
||||
getVendorVersion,
|
||||
initVendorVersion,
|
||||
mockDelay,
|
||||
mockVendors,
|
||||
} from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
//
|
||||
// export interface ListVendorsParams {
|
||||
// page?: number
|
||||
// pageSize?: number
|
||||
// q?: string
|
||||
// status?: EntityStatus
|
||||
// }
|
||||
//
|
||||
// export interface CreateVendorRequest {
|
||||
// code: string
|
||||
// name: string
|
||||
// terms?: string | null
|
||||
// taxReg?: string | null
|
||||
// currency: string
|
||||
// }
|
||||
//
|
||||
// export type UpdateVendorRequest = CreateVendorRequest
|
||||
//
|
||||
// export const vendorsApi = {
|
||||
// list(params: ListVendorsParams = {}) {
|
||||
// return apiRequest<PagedResponse<Vendor>>(`/vendors${buildQuery(params)}`)
|
||||
// },
|
||||
// get(vendorId: number) {
|
||||
// return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`)
|
||||
// },
|
||||
// create(request: CreateVendorRequest) {
|
||||
// return apiRequestWithETag<Vendor>("/vendors", { method: "POST", body: request })
|
||||
// },
|
||||
// update(vendorId: number, request: UpdateVendorRequest, ifMatch: string) {
|
||||
// return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch })
|
||||
// },
|
||||
// updateStatus(vendorId: number, status: EntityStatus) {
|
||||
// return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListVendorsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -22,20 +75,91 @@ export interface CreateVendorRequest {
|
||||
|
||||
export type UpdateVendorRequest = CreateVendorRequest
|
||||
|
||||
function codeTaken(code: string, excludeVendorId?: number) {
|
||||
return mockVendors.some((v) => v.vendorId !== excludeVendorId && v.code.toLowerCase() === code.toLowerCase())
|
||||
}
|
||||
|
||||
export const vendorsApi = {
|
||||
list(params: ListVendorsParams = {}) {
|
||||
return apiRequest<PagedResponse<Vendor>>(`/vendors${buildQuery(params)}`)
|
||||
list(params: ListVendorsParams = {}): Promise<PagedResponse<Vendor>> {
|
||||
const term = params.q?.trim().toLowerCase()
|
||||
const filtered = mockVendors
|
||||
.filter((v) => !params.status || v.status === params.status)
|
||||
.filter((v) => !term || `${v.code} ${v.name}`.toLowerCase().includes(term))
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
const totalItems = filtered.length
|
||||
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems, totalPages },
|
||||
})
|
||||
},
|
||||
get(vendorId: number) {
|
||||
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`)
|
||||
|
||||
get(vendorId: number): Promise<ApiResult<Vendor>> {
|
||||
const v = mockVendors.find((x) => x.vendorId === vendorId)
|
||||
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
|
||||
return mockDelay({ data: v, etag: String(getVendorVersion(vendorId)) })
|
||||
},
|
||||
create(request: CreateVendorRequest) {
|
||||
return apiRequestWithETag<Vendor>("/vendors", { method: "POST", body: request })
|
||||
|
||||
create(request: CreateVendorRequest): Promise<ApiResult<Vendor>> {
|
||||
const code = request.code.trim()
|
||||
if (!code) return Promise.reject(new Error("Vendor code is required."))
|
||||
if (codeTaken(code)) {
|
||||
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
const vendor: Vendor = {
|
||||
vendorId: allocateVendorId(),
|
||||
code,
|
||||
name: request.name.trim(),
|
||||
terms: request.terms?.trim() || null,
|
||||
taxReg: request.taxReg?.trim() || null,
|
||||
currency: request.currency.trim().toUpperCase(),
|
||||
status: "Active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
}
|
||||
mockVendors.push(vendor)
|
||||
initVendorVersion(vendor.vendorId)
|
||||
return mockDelay({ data: vendor, etag: "1" })
|
||||
},
|
||||
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string) {
|
||||
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch })
|
||||
|
||||
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string): Promise<ApiResult<Vendor>> {
|
||||
const v = mockVendors.find((x) => x.vendorId === vendorId)
|
||||
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
|
||||
|
||||
if (String(getVendorVersion(vendorId)) !== ifMatch) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error("The vendor was modified by another request."), { code: "CONCURRENCY_CONFLICT" })
|
||||
)
|
||||
}
|
||||
|
||||
const code = request.code.trim()
|
||||
if (!code) return Promise.reject(new Error("Vendor code is required."))
|
||||
if (codeTaken(code, vendorId)) {
|
||||
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
|
||||
v.code = code
|
||||
v.name = request.name.trim()
|
||||
v.terms = request.terms?.trim() || null
|
||||
v.taxReg = request.taxReg?.trim() || null
|
||||
v.currency = request.currency.trim().toUpperCase()
|
||||
v.updatedAt = new Date().toISOString()
|
||||
|
||||
const next = bumpVendorVersion(vendorId)
|
||||
return mockDelay({ data: v, etag: String(next) })
|
||||
},
|
||||
updateStatus(vendorId: number, status: EntityStatus) {
|
||||
return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
|
||||
|
||||
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
|
||||
const v = mockVendors.find((x) => x.vendorId === vendorId)
|
||||
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
|
||||
v.status = status
|
||||
v.updatedAt = new Date().toISOString()
|
||||
bumpVendorVersion(vendorId)
|
||||
return mockDelay(undefined)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,9 +1,47 @@
|
||||
// One typed client method per warehouse/bin endpoint (docs/11-BACKEND-PHASE1.md §2.5,
|
||||
// FR-MD-07/FR-WH-01).
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
//
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so the GRN/Stock/
|
||||
// Warehouse screens can be reviewed without a running backend. Restore the
|
||||
// commented block and delete the mock block once Backend/PROGRESS.md §1
|
||||
// (Master Data) exists.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
import { allocateBinId, allocateWarehouseId, mockBins, mockDelay, mockWarehouses } from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest } from "@/lib/api-client"
|
||||
//
|
||||
// export interface CreateWarehouseRequest {
|
||||
// code: string
|
||||
// name: string
|
||||
// }
|
||||
//
|
||||
// export interface CreateBinRequest {
|
||||
// code: string
|
||||
// binType?: string | null
|
||||
// }
|
||||
//
|
||||
// export const warehousesApi = {
|
||||
// list() {
|
||||
// return apiRequest<PagedResponse<Warehouse>>("/warehouses")
|
||||
// },
|
||||
//
|
||||
// create(request: CreateWarehouseRequest) {
|
||||
// return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
|
||||
// },
|
||||
//
|
||||
// listBins(warehouseId: number) {
|
||||
// return apiRequest<PagedResponse<Bin>>(`/warehouses/${warehouseId}/bins`)
|
||||
// },
|
||||
//
|
||||
// createBin(warehouseId: number, request: CreateBinRequest) {
|
||||
// return apiRequest<Bin>(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface CreateWarehouseRequest {
|
||||
code: string
|
||||
name: string
|
||||
@@ -15,24 +53,46 @@ export interface CreateBinRequest {
|
||||
}
|
||||
|
||||
export const warehousesApi = {
|
||||
list() {
|
||||
return apiRequest<PagedResponse<Warehouse>>("/warehouses")
|
||||
list(): Promise<PagedResponse<Warehouse>> {
|
||||
return mockDelay({
|
||||
items: mockWarehouses,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: mockWarehouses.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
get(warehouseId: number) {
|
||||
return apiRequest<Warehouse>(`/warehouses/${warehouseId}`)
|
||||
get(warehouseId: number): Promise<Warehouse> {
|
||||
const wh = mockWarehouses.find((w) => w.warehouseId === warehouseId)
|
||||
if (!wh) return Promise.reject(new Error(`Mock warehouse ${warehouseId} not found`))
|
||||
return mockDelay(wh)
|
||||
},
|
||||
|
||||
create(request: CreateWarehouseRequest) {
|
||||
return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
|
||||
create(request: CreateWarehouseRequest): Promise<Warehouse> {
|
||||
const code = request.code.trim()
|
||||
if (!code) return Promise.reject(new Error("Warehouse code is required."))
|
||||
if (mockWarehouses.some((w) => w.code.toLowerCase() === code.toLowerCase())) {
|
||||
return Promise.reject(Object.assign(new Error(`Warehouse code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
const warehouse: Warehouse = { warehouseId: allocateWarehouseId(), code, name: request.name.trim() }
|
||||
mockWarehouses.push(warehouse)
|
||||
return mockDelay(warehouse)
|
||||
},
|
||||
|
||||
async listBins(warehouseId: number): Promise<PagedResponse<Bin>> {
|
||||
const items = await apiRequest<Bin[]>(`/warehouses/${warehouseId}/bins`)
|
||||
return { items, pagination: { page: 1, pageSize: items.length, totalItems: items.length, totalPages: 1 } }
|
||||
listBins(warehouseId: number): Promise<PagedResponse<Bin>> {
|
||||
const items = mockBins.filter((b) => b.warehouseId === warehouseId)
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
createBin(warehouseId: number, request: CreateBinRequest) {
|
||||
return apiRequest<Bin>(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request })
|
||||
createBin(warehouseId: number, request: CreateBinRequest): Promise<Bin> {
|
||||
const code = request.code.trim()
|
||||
if (!code) return Promise.reject(new Error("Bin code is required."))
|
||||
if (mockBins.some((b) => b.warehouseId === warehouseId && b.code.toLowerCase() === code.toLowerCase())) {
|
||||
return Promise.reject(Object.assign(new Error(`Bin code "${code}" already exists in this warehouse.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
const bin: Bin = { binId: allocateBinId(), warehouseId, code, binType: request.binType?.trim() || null }
|
||||
mockBins.push(bin)
|
||||
return mockDelay(bin)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
// "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the
|
||||
// SRS — stock write-offs (damage, theft/loss, expiry) are modeled as Stock
|
||||
// Adjustments with a mandatory reason code (FR-STK-07). This module is a
|
||||
// frontend-only lens: it composes stockAdjustmentsApi/reasonCodesApi/stockApi
|
||||
// (real endpoints, though none of §5's backend exists yet — see
|
||||
// Backend/PROGRESS.md §5), filtered to loss-type reason codes and flattened to
|
||||
// per-line records for a focused "record wastage" flow and report. No new
|
||||
// backend concept.
|
||||
// Adjustments with a mandatory reason code (FR-STK-07), and the reason-code seed
|
||||
// list (docs/8.3 / docs/11 §6) already includes Damage / Theft-Loss / Expiry
|
||||
// Write-off. This module is a frontend-only lens: it reuses stockAdjustmentsApi
|
||||
// and the shared mock Stock Core, filtered to loss-type reason codes and
|
||||
// flattened to per-line records for a focused "record wastage" flow and report.
|
||||
// No new backend concept, no new mock store.
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { mockDelay, mockReasonCodes, mockStockAdjustments, mockStockLedger } from "@/lib/api/mock-data"
|
||||
import { StockAdjustment } from "@/types/stock"
|
||||
|
||||
/** Reason-code strings treated as "wastage" (loss-type) causes, per docs/8.3. */
|
||||
const WASTAGE_CODES = new Set(["DMG", "LOSS", "EXPWO"])
|
||||
|
||||
export async function wastageReasonCodeIds(): Promise<number[]> {
|
||||
const { items } = await reasonCodesApi.list("Adjustment")
|
||||
return items.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId)
|
||||
export function wastageReasonCodeIds(): number[] {
|
||||
return mockReasonCodes.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId)
|
||||
}
|
||||
|
||||
export interface WastageRecord {
|
||||
@@ -48,23 +46,26 @@ export interface RecordWastageInput {
|
||||
}
|
||||
|
||||
export const wastageApi = {
|
||||
async list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
|
||||
const wastageIds = new Set(await wastageReasonCodeIds())
|
||||
const { items: summaries } = await stockAdjustmentsApi.list({ warehouseId: params.warehouseId, pageSize: 200 })
|
||||
const candidates = summaries.filter(
|
||||
(a) => wastageIds.has(a.reasonCodeId) && (!params.reasonCodeId || a.reasonCodeId === params.reasonCodeId)
|
||||
)
|
||||
|
||||
const adjustments = await Promise.all(candidates.map((a) => stockAdjustmentsApi.get(a.adjustmentId)))
|
||||
list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
|
||||
const wastageIds = new Set(wastageReasonCodeIds())
|
||||
const records: WastageRecord[] = []
|
||||
|
||||
for (const adj of adjustments) {
|
||||
for (const adj of mockStockAdjustments) {
|
||||
if (!wastageIds.has(adj.reasonCodeId)) continue
|
||||
if (params.warehouseId && adj.warehouseId !== params.warehouseId) continue
|
||||
if (params.reasonCodeId && adj.reasonCodeId !== params.reasonCodeId) continue
|
||||
|
||||
for (const line of adj.lines) {
|
||||
if (line.qtyDelta >= 0) continue // wastage is always a decrease
|
||||
|
||||
const { items: ledger } = await stockApi.ledger({ itemId: line.itemId, warehouseId: adj.warehouseId, pageSize: 200 })
|
||||
const value = ledger
|
||||
.filter((l) => l.sourceDocType === "Adjustment" && l.sourceDocId === adj.adjustmentId && l.direction === "Out")
|
||||
const value = mockStockLedger
|
||||
.filter(
|
||||
(l) =>
|
||||
l.sourceDocType === "Adjustment" &&
|
||||
l.sourceDocId === adj.adjustmentId &&
|
||||
l.itemId === line.itemId &&
|
||||
l.direction === "Out"
|
||||
)
|
||||
.reduce((sum, l) => sum + l.value, 0)
|
||||
|
||||
records.push({
|
||||
@@ -83,7 +84,7 @@ export const wastageApi = {
|
||||
}
|
||||
|
||||
records.sort((a, b) => b.adjustmentId - a.adjustmentId)
|
||||
return records
|
||||
return mockDelay(records)
|
||||
},
|
||||
|
||||
/** Records wastage as a single-line, negative-qtyDelta stock adjustment (FR-STK-07). */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09). Mirrors the
|
||||
// real Backend/ERPCore/Dtos/Procurement/*.cs shapes.
|
||||
// planned Dtos/Procurement/*.cs — no Procurement backend exists yet, see
|
||||
// Frontend/PROGRESS.md §3 for the same frontend-only posture as GRN/Stock.
|
||||
|
||||
// --- 3.1 Requisitions --------------------------------------------------------------
|
||||
|
||||
@@ -9,7 +10,7 @@ export interface ReqLine {
|
||||
reqLineId: number
|
||||
itemId: number
|
||||
qty: number
|
||||
requiredBy: string | null
|
||||
requiredBy: string
|
||||
}
|
||||
|
||||
export interface Requisition {
|
||||
@@ -21,19 +22,19 @@ export interface Requisition {
|
||||
lines: ReqLine[]
|
||||
}
|
||||
|
||||
/** Matches RequisitionSummaryDto — the backend does not include a line count on the summary row. */
|
||||
export interface RequisitionSummary {
|
||||
requisitionId: number
|
||||
docNo: string
|
||||
status: RequisitionStatus
|
||||
requestedBy: number
|
||||
createdAt: string
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
export interface CreateReqLineInput {
|
||||
itemId: number
|
||||
qty: number
|
||||
requiredBy?: string | null
|
||||
requiredBy: string
|
||||
}
|
||||
|
||||
export interface CreateRequisitionRequest {
|
||||
@@ -42,7 +43,8 @@ export interface CreateRequisitionRequest {
|
||||
|
||||
// --- 3.2 RFQs & Quotations ----------------------------------------------------------
|
||||
|
||||
/** Phase 1 backend only ever sets "Open" (RfqDtos.cs); no server-side transition to Closed. */
|
||||
/** Phase 1 only documents "Open" on creation; "Closed" is a frontend-only convenience
|
||||
* applied once a PO is created from the RFQ (see Frontend/PROGRESS.md §3 deviation note). */
|
||||
export type RfqStatus = "Open" | "Closed"
|
||||
|
||||
export interface RfqLine {
|
||||
@@ -51,14 +53,12 @@ export interface RfqLine {
|
||||
qty: number
|
||||
}
|
||||
|
||||
/** Note: the backend does not persist invited vendors (RfqService.MapRfq) — there is
|
||||
* no `vendorIds` field on the stored RFQ. Vendors who have quoted are only derivable
|
||||
* from `RfqComparison.vendorIds`. */
|
||||
export interface Rfq {
|
||||
rfqId: number
|
||||
docNo: string
|
||||
requisitionId: number
|
||||
requisitionId: number | null
|
||||
status: RfqStatus
|
||||
vendorIds: number[]
|
||||
createdAt: string
|
||||
lines: RfqLine[]
|
||||
}
|
||||
@@ -66,8 +66,9 @@ export interface Rfq {
|
||||
export interface RfqSummary {
|
||||
rfqId: number
|
||||
docNo: string
|
||||
requisitionId: number
|
||||
requisitionId: number | null
|
||||
status: RfqStatus
|
||||
vendorIds: number[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -77,8 +78,7 @@ export interface CreateRfqLineInput {
|
||||
}
|
||||
|
||||
export interface CreateRfqRequest {
|
||||
/** Required server-side (CreateRfqRequest.RequisitionId has [Required]). */
|
||||
requisitionId: number
|
||||
requisitionId?: number | null
|
||||
vendorIds: number[]
|
||||
lines: CreateRfqLineInput[]
|
||||
}
|
||||
@@ -102,25 +102,22 @@ export interface CreateQuotationRequest {
|
||||
lines: QuotationLine[]
|
||||
}
|
||||
|
||||
/** One vendor's price/lead-time for a given RFQ line (docs/11 §3.2, RfqComparisonCellDto). */
|
||||
export interface RfqComparisonCell {
|
||||
vendorId: number
|
||||
quotationId: number
|
||||
unitPrice: number
|
||||
leadDays: number
|
||||
}
|
||||
|
||||
export interface RfqComparisonRow {
|
||||
export interface RfqComparisonLine {
|
||||
itemId: number
|
||||
qty: number
|
||||
quotes: RfqComparisonCell[]
|
||||
cells: RfqComparisonCell[]
|
||||
}
|
||||
|
||||
/** `vendorIds` here are the vendors who have quoted, not the vendors originally invited. */
|
||||
export interface RfqComparison {
|
||||
rfqId: number
|
||||
vendorIds: number[]
|
||||
rows: RfqComparisonRow[]
|
||||
lines: RfqComparisonLine[]
|
||||
}
|
||||
|
||||
// --- 3.3 Purchase Orders -------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user