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
This commit is contained in:
1 parent
e23cf7c73d
commit
dc0dd81318
2 files changed
+400
-4
No files matched your search
@@ -0,0 +1,305 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user