Files
pfm-ocr/docs/stock-feature-plan.md
T
Rafhan Mazaya FathurrahmanandClaude Fable 5 e6daa9b053 docs(plan): per-sale expiry tracking design — batch registry + candidate matching
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
2026-07-16 17:00:36 +07:00

18 KiB
Raw Blame History

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'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

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.