Files
pfm-ocr/docs/stock-feature-plan.md
T
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 dc0dd81318 docs(plan): enhance-refresh Flutter backlog (27 grounded tasks) + Stocks feature plan
Adds tasks 1.4-8.5 across existing sections 1-8 (each traced to a specific
file/line, not invented busywork) plus a new section 9 (Stocks Menu &
DO-to-Stock flow) seeded from a user-directed, grilled ad-hoc feature
request - full design doc in docs/stock-feature-plan.md, status: planned,
no code yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xsxk4ZkDQVVaLUcixDcqb5
2026-07-14 08:32:34 +07:00

306 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
## 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.