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
18 KiB
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
§9 and backend/plans/next-enhancements.md
§12 — read it before picking up any task from either section, same relationship
docs/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
(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'sjumlah) — 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/:idsucceeds 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 asmanual_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-datasurface, all-stores view (admin bypasseskode_tokoscoping). - Backend work tracked in
backend/plans/next-enhancements.md(new section); Flutter work tracked in rootplans/next-enhancements.md(new section) — both as ad-hoc-originated backlog perAGENTS.md§7, not a freshe/enhancepass.
Backend design
1. Schema — new backend/pfm-web-app/src/db/init-stock.ts
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, mirroringdocument-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 adecrementmovement, returns{ok:true}or{ok:false, reason}.src/utils/stock-lookup.ts—getInStockSkuSet(kodeToko)(SKUs withSUM(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_expiredflag; non-admin forced to ownkode_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_qtythenrecordStockMovementwith typeintakeifdocument_idpresent elsemanual_seed).stock/[noSku]/route.ts—GETbatch list for one SKU (store-scoped), sorted byexpiry_date ASC.stock/batches/[id]/route.ts—PUTedit a batch ({batch_code?, expiry_date?, outer_qty?, inner_qty?, note?}),FOR UPDATE, computes deltas, updates, logsadjustment; 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
checkResSELECT to also fetchscan_mode. - Accept new top-level PUT body field
stock_batch_id: number | null. - Inside the existing
withTransactionblock (reuse it — don't open a second transaction), after theocr_itemsrewrite: ifdoc.scan_mode === 'Product' && stock_batch_id, calldecrementBatchForProductScan; on{ok:false},throwa 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 andapi/v1/scan-product/route.ts: after callingclassifyAndMatchProduct(), filterpossibleMatchesthroughfilterMatchesByStock(matches, await getInStockSkuSet(kodeToko)). Do not changeclassifyAndMatchProduct'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) intopage.tsx(shell + login + tabs) + extractedStoreManager.tsx+SkuManager.tsx(pure extraction, no behavior change) + newStockManager.tsx(read-only: fetchesGET /api/v1/stockwith nokode_tokoparam as admin → all-store table; row click expands a batch sub-table viaGET /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/isDepletedgetters),StockItem(summary row: noSku, namaItem, kodeToko?, totals, batchCount, nearestExpiryDate, hasExpired).stock_response_parser.dart— pure envelope unwrap ({status,data}→ typed lists), mirroringproduct_scan_response_parser.dart.stock_batch_sort.dart— puresortBatchesForDisplay()(expired-first, then soonest-expiry) andpickClosestBatch(batches, extractedExpiry).quantity_parse.dart— pureparseLeadingQty(String)for"10 KRG"→10style extraction, used to seed the stock-entry screen's default pre-filled quantities fromDocumentItem.banyak/.jumlah.stock_provider.dart—StockNotifier extends StateNotifier<List<StockItem>>+stockProvider, matchingpending_documents_provider.dart's conventions (network viaref.read(apiClientProvider), no premature caching abstractions).app_config.dart— addstockEndpoint = '/stock'.local_storage.dart— newstockEntryBoxNameHive box (draft persistence for "Isi Nanti" resume), registered ininit(), wiped inclearAll().
8. Stock-entry screen (new lib/features/stock/)
part of + mixin split (matching both existing editor screens' convention):
stock_entry_screen.dart— shell, receivesDocumentModelviastate.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 asubmittedflag 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 viasortBatchesForDisplay, expired-red flag, tap-to-edit (bottom sheet, reusesstock_entry_batch_form.dart), FAB "+ Tambah Batch Manual" (documentId: null→manual_seed).camera_drawer.dart— newbuildDrawerItem(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—_skuBatchesbecomesMap<String, List<StockBatch>>; classifier-matched-SKU branch additionally fetches real batch data per candidate (stockProvider.fetchBatchDetail); fallback branch switches frommasterSkusEndpointtostockEndpoint(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— addselectedStockBatchIdtofinalDoc/toPutPayload()asstock_batch_id.product_expiry_card.dart— rewritten props (List<StockBatch> batches,StockBatch? selectedBatch), depleted-batch warning row. Updatetest/product_expiry_card_test.dartfor the new shape (breaking change, not additive).document_model.dart— addselectedStockBatchIdfield.
Sequencing (7 chunks, backend → Flutter → admin)
- Backend schema + core CRUD (
init-stock.ts,stock-mapper.ts,stock-movement.ts'srecordStockMovement, all 3 stock routes). Verify live: merge-add, 409 on collision, correct movement rows. - Backend decrement hook + in-stock filter (
decrementBatchForProductScan,documents/[id]/route.tswiring,stock-lookup.ts,parse/route.ts+scan-product/route.tsfiltering). Verify live: decrement to negative, 400 on bad batch id, filtered candidates. - Flutter data layer (models, parser, sort/qty pure functions, provider, Hive box, config). Unit tests only.
- 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. - Flutter Stocks menu (list/detail screens, drawer, manual add/edit). Live-verify search, expiry sort/flag, manual seed, edit.
- 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). - Admin web (
page.tsxsplit +StoreManager.tsx/SkuManager.tsxextraction + newStockManager.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_idshould 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.