# Stocks Feature — Full-Stack Implementation Plan Written 2026-07-10 after an extensive grilling/clarification session with the user (see chat history — not reproduced here). This is the context doc for the Stocks backlog entries in root [`plans/next-enhancements.md`](../plans/next-enhancements.md) §9 and [`backend/plans/next-enhancements.md`](../backend/plans/next-enhancements.md) §12 — read it before picking up any task from either section, same relationship [`docs/api-contract-map.md`](api-contract-map.md) has to its own gap-fix tasks. **Status: planned, not yet implemented.** Nothing described below exists in the codebase yet — this doc is the design record to build from when the tasks below are picked up via `n`/`next`. **Extended 2026-07-16 by [`expiry-tracking-plan.md`](expiry-tracking-plan.md)** (backend §13 / root §10): the Product Scan batch *selection* described in §5/§10 below becomes an automated candidate-matching resolution at the cashier (fragment-match OCR evidence against the in-stock batches this plan registers; auto-FEFO + `inferred` flag as fallback). The dropdown UX below survives as the manual-override path. Schema, decrement semantics, and everything else in this doc are unchanged. ## Context The app currently tracks Delivery Order (DO) documents and a "Product Scan" shelf-verification flow, but has **no concept of shelf stock/inventory at all** — no batch, no expiry-per-batch, no quantity-on-hand. The Product Scan editor already has a placeholder "batch/expiry" dropdown (`ProductExpiryCard`), but it's fake: it just echoes the single OCR-extracted expiry-date string from the photo, with no real batch code, no quantity, no link to what was actually delivered. The user wants to close this gap: every confirmed DO should feed real, per-store, per-SKU batch records (batch code + expiry + quantity, split across boxes/packs), track where each batch came from, and let Product Scan consume from that real batch pool (matching against it, and decrementing it) instead of operating on fabricated data. **Outcome**: a new Postgres schema + `/api/v1/stock/*` endpoints (backend), a new mandatory-by-default "stock entry" step triggered right after DO confirmation, a new "Stok" menu (Flutter), a rewired Product Scan batch-matching flow, and a basic read-only admin web view. ## Confirmed decisions (resolved via one-at-a-time grilling, do not re-litigate) - **Full-stack, per-store** (`kode_toko`) stock. Batch = unique `(kode_toko, no_sku, batch_code, expiry_date)`; repeat deliveries of the same combo **merge** (quantity adds), never duplicate. - Batches track **both** outer qty (boxes/karung, matches DO's `banyak`) and inner qty (packs/pieces, matches DO's `jumlah`) — both **manually typed**, seeded from the DO item's already-user-corrected quantities (physical verification already happens in the existing DO editor before confirmation; no re-verification here). - **Every quantity change is logged** in an append-only movement table (`intake`/`decrement`/`adjustment`/`manual_seed`), referencing the causing document where applicable. - **DO confirm trigger**: right after `PUT /api/v1/documents/:id` succeeds in the DO editor (`editor_logic.dart`), auto-navigate to a stock-entry screen. Each item **starts with 1 pre-filled batch** (full confirmed quantity); user can split into more via "+ Tambah Batch". An explicit **"Isi Nanti"** (finish later) exits without hard-blocking, leaving that DO resumable/visible from the Stocks menu. - Batch code is **always manually typed** (no OCR/camera in stock-entry — plain form only). - **Stocks menu** ("Stok" in the drawer): two-level SKU list → batch detail, sorted soonest-expiry-first, expired batches flagged red (visual only, no write-off workflow this pass). Batches are manually editable after creation (logged as `adjustment`). Manual "add batch" with no DO link is supported (seeds pre-existing inventory, logged as `manual_seed`). - **Product Scan integration**: SKU candidates restricted to in-stock (qty > 0) SKUs only. Batch dropdown shows all of that SKU's in-stock batches (no cap); closest-to-OCR-extracted-expiry batch is auto-selected. Depleted/negative batches still selectable with a warning. Confirming decrements the selected batch's **inner qty by exactly 1**, allowed to go **negative**, logged. **If the selected batch id is invalid/cross-store, the whole document confirm fails (400)** — never a silent skip. - **Admin web**: read-only "Stock" tab in the existing `admin/master-data` surface, all-stores view (admin bypasses `kode_toko` scoping). - Backend work tracked in `backend/plans/next-enhancements.md` (new section); Flutter work tracked in root `plans/next-enhancements.md` (new section) — both as ad-hoc-originated backlog per `AGENTS.md` §7, not a fresh `e`/`enhance` pass. ## Backend design ### 1. Schema — new `backend/pfm-web-app/src/db/init-stock.ts` ```sql CREATE TABLE IF NOT EXISTS stock_batches ( id SERIAL PRIMARY KEY, kode_toko VARCHAR(255) NOT NULL REFERENCES store_master(kode_toko), no_sku VARCHAR(255) NOT NULL REFERENCES sku_master(no_sku), batch_code VARCHAR(255) NOT NULL, expiry_date DATE NOT NULL, outer_qty INTEGER NOT NULL DEFAULT 0, inner_qty INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE (kode_toko, no_sku, batch_code, expiry_date) ); CREATE INDEX IF NOT EXISTS idx_stock_batches_store_sku ON stock_batches(kode_toko, no_sku); CREATE TABLE IF NOT EXISTS stock_movements ( id SERIAL PRIMARY KEY, batch_id INTEGER NOT NULL REFERENCES stock_batches(id) ON DELETE RESTRICT, document_id INTEGER REFERENCES documents(id) ON DELETE SET NULL, movement_type VARCHAR(20) NOT NULL CHECK (movement_type IN ('intake','decrement','adjustment','manual_seed')), outer_delta INTEGER NOT NULL DEFAULT 0, inner_delta INTEGER NOT NULL DEFAULT 0, note VARCHAR(500), created_by_account_id INTEGER REFERENCES accounts(id), created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_stock_movements_batch ON stock_movements(batch_id); CREATE INDEX IF NOT EXISTS idx_stock_movements_document ON stock_movements(document_id); ``` No soft-delete/DELETE route — batches are edit-only, matching "manually editable, never removed." No `CHECK (qty >= 0)` — decrements must go negative. Wrap in `try/catch` + `console.error`, matching `init.ts`'s existing idiom for every other table. Wire into `init.ts`: `import { initStockSchema } from "./init-stock"; await initStockSchema(pool);` right after the accounts table's plaintext-password migration block (~line 209, before the `arena_runs` table), since `stock_batches` FKs both `store_master` and `sku_master`, both of which must already exist. This is itself the AGENTS.md §3-compliant "split" — new logic lives in a fresh sub-256-line file rather than growing the already-over-budget `init.ts` (562 lines) further. ### 2. New utils - **`src/utils/stock-mapper.ts`** — `mapStockSummaryRow`, `mapStockBatchRow`, mirroring `document-mapper.ts`'s typed-row → response-shape pattern. - **`src/utils/stock-movement.ts`** — `recordStockMovement(client, {batchId, documentId, movementType, outerDelta, innerDelta, note})` (single INSERT); `decrementBatchForProductScan(client, {batchId, kodeToko, documentId})` — `SELECT ... FOR UPDATE`, store-isolation check, `inner_qty = inner_qty - 1` (no floor), logs a `decrement` movement, returns `{ok:true}` or `{ok:false, reason}`. - **`src/utils/stock-lookup.ts`** — `getInStockSkuSet(kodeToko)` (SKUs with `SUM(inner_qty) > 0`); `filterMatchesByStock(matches, inStock)` for the classifier candidate filter. ### 3. New routes under `src/app/api/v1/stock/` - **`stock/route.ts`** — `GET` (summary per SKU: total outer/inner qty, batch count, nearest expiry, `has_expired` flag; non-admin forced to own `kode_toko`, admin sees all stores via optional `?kode_toko=`); `POST` (create/merge a batch — `{kode_toko?, no_sku, batch_code, expiry_date, outer_qty, inner_qty, document_id?}`, `withTransaction`: `INSERT ... ON CONFLICT (kode_toko,no_sku,batch_code,expiry_date) DO UPDATE SET outer_qty = outer_qty + EXCLUDED.outer_qty, inner_qty = inner_qty + EXCLUDED.inner_qty` then `recordStockMovement` with type `intake` if `document_id` present else `manual_seed`). - **`stock/[noSku]/route.ts`** — `GET` batch list for one SKU (store-scoped), sorted by `expiry_date ASC`. - **`stock/batches/[id]/route.ts`** — `PUT` edit a batch (`{batch_code?, expiry_date?, outer_qty?, inner_qty?, note?}`), `FOR UPDATE`, computes deltas, updates, logs `adjustment`; 403 on cross-store, 409 on unique collision. All follow existing conventions: `getAccountFromAuthHeader`, inline `NextResponse.json({status:"success", data:...})` (no `successResponse` helper exists in this codebase — don't invent one), `errorResponse()` from `utils/api-error.ts`. ### 4. Decrement integration — `documents/[id]/route.ts` - Extend the existing `checkRes` SELECT to also fetch `scan_mode`. - Accept new top-level PUT body field `stock_batch_id: number | null`. - Inside the **existing** `withTransaction` block (reuse it — don't open a second transaction), after the `ocr_items` rewrite: if `doc.scan_mode === 'Product' && stock_batch_id`, call `decrementBatchForProductScan`; on `{ok:false}`, `throw` a tagged error so the transaction rolls back, and the route's catch returns **400** (not the current 500) for this specific case. - Net effect: document confirm and stock decrement are atomic — per the confirmed "fail the whole confirm on bad batch id" decision. ### 5. Product Scan candidate filtering - `api/parse/route.ts`'s Product branch and `api/v1/scan-product/route.ts`: after calling `classifyAndMatchProduct()`, filter `possibleMatches` through `filterMatchesByStock(matches, await getInStockSkuSet(kodeToko))`. **Do not change `classifyAndMatchProduct`'s signature** — it's shared with the anonymous, store-agnostic desktop dev route; filter at the two authenticated call sites instead. ### 6. Admin web view - Split `admin/master-data/page.tsx` (419 lines, already over threshold) into `page.tsx` (shell + login + tabs) + extracted `StoreManager.tsx` + `SkuManager.tsx` (pure extraction, no behavior change) + new `StockManager.tsx` (read-only: fetches `GET /api/v1/stock` with no `kode_toko` param as admin → all-store table; row click expands a batch sub-table via `GET /api/v1/stock/:noSku?kode_toko=...`). ## Flutter design ### 7. Data layer (new `lib/features/stock/`) - `lib/models/stock_model.dart` — `StockBatch` (id, noSku, batchCode, expiryDate, outerQty, innerQty, `isExpired`/`isDepleted` getters), `StockItem` (summary row: noSku, namaItem, kodeToko?, totals, batchCount, nearestExpiryDate, hasExpired). - `stock_response_parser.dart` — pure envelope unwrap (`{status,data}` → typed lists), mirroring `product_scan_response_parser.dart`. - `stock_batch_sort.dart` — pure `sortBatchesForDisplay()` (expired-first, then soonest-expiry) and `pickClosestBatch(batches, extractedExpiry)`. - `quantity_parse.dart` — pure `parseLeadingQty(String)` for `"10 KRG"` → `10` style extraction, used to seed the stock-entry screen's default pre-filled quantities from `DocumentItem.banyak`/`.jumlah`. - `stock_provider.dart` — `StockNotifier extends StateNotifier>` + `stockProvider`, matching `pending_documents_provider.dart`'s conventions (network via `ref.read(apiClientProvider)`, no premature caching abstractions). - `app_config.dart` — add `stockEndpoint = '/stock'`. - `local_storage.dart` — new `stockEntryBoxName` Hive box (draft persistence for "Isi Nanti" resume), registered in `init()`, wiped in `clearAll()`. ### 8. Stock-entry screen (new `lib/features/stock/`) `part of` + mixin split (matching both existing editor screens' convention): - `stock_entry_screen.dart` — shell, receives `DocumentModel` via `state.extra`. - `stock_entry_logic.dart` (`part of`) — per-item batch-draft state, Hive draft load/resume, `batchesSumMatches()` validation (pure function, own test file). - `stock_entry_submit_logic.dart` (`part of`) — per-item submit (`stockProvider.createOrMergeBatch(..., documentId: document.id)`, idempotency via a `submitted` flag per batch draft so resume never double-POSTs), "Isi Nanti" (persist + pop), "Selesai" (clear draft + pop). - `widgets/stock_entry_item_card.dart`, `widgets/stock_entry_batch_form.dart` (also reused by the Stocks menu's manual-add/edit). **`editor_logic.dart`** — on `syncedToServer == true`, replace `context.pop(true)` with `context.pushReplacement('/stock-entry', extra: finalDoc)`; extract the branch into a new pure `lib/features/editor/document_submit_navigation.dart` (`resolveDocumentSubmitNavigation({required bool syncedToServer})`), matching the `resolveDocumentSaveAction`/`resolvePollOutcome` pattern already used twice in this codebase. Keeps `editor_logic.dart` under 256 lines and testable without widget mocking. **`app_router.dart`** — add `/stock-entry` (`extra: DocumentModel`), `/stocks`, `/stock-detail` (`extra: StockItem`). ### 9. Stocks menu (new `lib/features/stock/`) - `stocks_screen.dart` — "Perlu diisi" banner (from Hive drafts, tap-to-resume), searchable SKU list. - `stock_detail_screen.dart` — batch list via `sortBatchesForDisplay`, expired-red flag, tap-to-edit (bottom sheet, reuses `stock_entry_batch_form.dart`), FAB "+ Tambah Batch Manual" (`documentId: null` → `manual_seed`). - `camera_drawer.dart` — new `buildDrawerItem(icon: Icons.inventory_2_outlined, title: 'Stok', onTap: () { Navigator.pop(context); context.push('/stocks'); })` right after "History". ### 10. Product Scan rewrite - `product_editor_data_logic.dart` — `_skuBatches` becomes `Map>`; classifier-matched-SKU branch additionally fetches real batch data per candidate (`stockProvider.fetchBatchDetail`); fallback branch switches from `masterSkusEndpoint` to `stockEndpoint` (filtered to qty > 0) — this **removes** a code path rather than adding one, and removes the manual-date-entry fallback entirely (no longer a valid state once candidates are stock-guaranteed to have ≥1 batch). - `product_editor_submit_logic.dart` — add `selectedStockBatchId` to `finalDoc`/`toPutPayload()` as `stock_batch_id`. - `product_expiry_card.dart` — rewritten props (`List batches`, `StockBatch? selectedBatch`), depleted-batch warning row. Update `test/product_expiry_card_test.dart` for the new shape (breaking change, not additive). - `document_model.dart` — add `selectedStockBatchId` field. ## Sequencing (7 chunks, backend → Flutter → admin) 1. Backend schema + core CRUD (`init-stock.ts`, `stock-mapper.ts`, `stock-movement.ts`'s `recordStockMovement`, all 3 stock routes). Verify live: merge-add, 409 on collision, correct movement rows. 2. Backend decrement hook + in-stock filter (`decrementBatchForProductScan`, `documents/[id]/route.ts` wiring, `stock-lookup.ts`, `parse/route.ts` + `scan-product/route.ts` filtering). Verify live: decrement to negative, 400 on bad batch id, filtered candidates. 3. Flutter data layer (models, parser, sort/qty pure functions, provider, Hive box, config). Unit tests only. 4. Flutter stock-entry screen + DO-confirm integration (`editor_logic.dart`, `document_submit_navigation.dart`, router). Live-verify: DO confirm → stock-entry → submit → Postgres rows; "Isi Nanti" resumability. 5. Flutter Stocks menu (list/detail screens, drawer, manual add/edit). Live-verify search, expiry sort/flag, manual seed, edit. 6. Flutter Product Scan rewrite (`product_editor_data_logic.dart`, `product_editor_submit_logic.dart`, `product_expiry_card.dart`, updated test). Live-verify: depleted SKU disappears from candidates, closest-expiry auto-pick, decrement (including going negative). 7. Admin web (`page.tsx` split + `StoreManager.tsx`/`SkuManager.tsx` extraction + new `StockManager.tsx`). Live-verify cross-store view. Backend chunks (1-2, 7's backend half) are tracked as backend/plans `§12`; Flutter chunks (3-6) are tracked as root plans `§9`. Each task flips `[TODO]` → `[DONE]` and gets logged in the relevant `docs/feature-list.md` as it ships, per the normal `n`/`next` workflow. ## Testing plan **Dart unit tests** (pure functions, no Dio mocking — this repo's established convention): `stock_response_parser_test.dart`, `stock_batch_sort_test.dart` (ordering + `pickClosestBatch`), `quantity_parse_test.dart`, `document_submit_navigation_test.dart`, `stock_entry_sum_validation_test.dart`. Update `product_expiry_card_test.dart` for the new prop shape. **Backend**: no existing route-test harness — this repo relies on live verification against the running Docker stack (per established practice). For each chunk: `docker compose up -d --build`, curl every new endpoint as both a store account and admin, confirm merge/409/movement-row correctness, then a real end-to-end pass through the actual Flutter app (DO scan → confirm → stock-entry → submit; Product Scan → depleted-SKU exclusion → decrement) with Postgres row checks, matching how prior tasks (6.1–8.2, 9.x, 10.x, 11.1) were verified in this repo's iteration logs. ## Open implementation-time decisions (flagged during planning, not yet re-confirmed) - Whether an invalid/cross-store `stock_batch_id` should ever be *allowed* to skip silently instead of failing the whole confirm — resolved: **fail the whole confirm (400)**, per user answer during grilling. - Stock-entry screen default state (ask batch count upfront vs. start with 1 pre-filled batch) — resolved: **start with 1 pre-filled, "+ Tambah Batch" to split**, per user answer during grilling.