Grilled 2026-07-16 with the user; full decision record in docs/expiry-tracking-plan.md. Core reframe: expiry is captured once per batch at DO intake (staff-typed on the stock-entry confirmation page, from the physical packs), so the cashier scan only MATCHES OCR fragments against the 1-3 known in-stock batch dates instead of free-reading damaged dot-matrix prints (proven model-capability ceiling, 2026-07-15). Fallback: auto-FEFO + 'inferred' flag, zero cashier interaction. No cloud, ever. - docs/expiry-tracking-plan.md: architecture, matching algorithm spec (resolveExpiryFromEvidence), schema/API deltas, phases 1-3, testing plan - backend plans §13 (13.1-13.4): matcher util + offline tuning, route wiring + expiry_source provenance, multi-frame union, dot-matrix recognizer fine-tune - root plans §10 (10.1-10.3): cashier fast path, inferred badge + end-of-day review, burst capture for mounted camera - stock-feature-plan.md: extension note (batch dropdown becomes the manual-override path) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8TumxFDnyVnfsR3mxPXfX
314 lines
18 KiB
Markdown
314 lines
18 KiB
Markdown
# 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<List<StockItem>>` +
|
||
`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<String,
|
||
List<StockBatch>>`; 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<StockBatch> 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.
|