feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification

Fixes reported from APK field testing: DO/Product scan mode was inconsistent
between the camera drawer and documents screen (now one shared provider,
with an orange/green color cue); unconfirmed scans leaked into history with
placeholder data before the user tapped confirm (backend now gates
GET /documents on a new `confirmed` column, flipped only by PUT); and
Product Scan ran the GPU classifier twice, once at upload and again on
review (now a single pass at upload, persisted and read directly by the
editor). Also removes the unused "Hubungkan ke PO" field and fabricated
PO/SO/DO placeholder values from the Product Scan flow, closes out the
per-document-polling and save-recovery tasks (6.1/6.3), and splits several
touched files to stay under the repo's 256-line guideline.

Full detail in docs/iteration-log.md and backend/docs/iteration-log.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5705 -1142

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
# Flutter ↔ Backend API Contract Map
Written 2026-07-10 from a full read of both sides of the wire (every Flutter call
site in `lib/` and every backend route it touches). This is the context document
for `plans/next-enhancements.md` sections 6-7 (Flutter) and
`backend/plans/next-enhancements.md` section 9 (backend counterparts) — read it
before picking up any of those tasks. Gap IDs (**G1**-**G10**) below are referenced
from the task entries so a future session can trace a task back to the evidence.
## Endpoint inventory (what the app actually calls)
| # | Flutter call site | Method + path | Auth | Backend route |
|---|---|---|---|---|
| 1 | `auth_provider.dart` `login()` | `POST /api/v1/auth/login` | none (issues token) | `api/v1/auth/login/route.ts` |
| 2 | `auth_provider.dart` `checkLoginState()` | `GET /api/v1/auth/me` | Bearer | `api/v1/auth/me/route.ts` |
| 3 | `pending_documents_provider.dart` `_uploadAndProcess` | `POST /api/v1/documents/upload` (multipart: `image`, `latitude?`, `longitude?`, `scan_mode`) | Bearer, 401 enforced | `api/v1/documents/upload/route.ts` |
| 4 | `pending_documents_provider.dart` `_pollUntilParsed` (every 2s, ≤130×) and `documents_screen.dart` `_loadDocuments` | `GET /api/v1/documents` | Bearer, 401 enforced | `api/v1/documents/route.ts` |
| 5 | `editor_screen.dart` save, `product_editor_logic.dart` `_submit`, `pending_documents_provider.dart` `retrySync` | `PUT /api/v1/documents/:id` (`toPutPayload()`) | Bearer, 401 + per-store 403 | `api/v1/documents/[id]/route.ts` |
| 6 | `product_editor_logic.dart` `_fetchClassificationAndSkus` | `GET /api/skus` — via `apiBaseUrl.replaceAll('/api/v1', '/api/skus')` | **none** (classic dev route) | `api/skus/route.ts` |
| 7 | `product_editor_logic.dart` `_fetchClassificationAndSkus` | `POST /api/scan-pfm` (JSON `{image_base64}`) — same base-URL string hack | **none** (classic dev route) | `api/scan-pfm/route.ts` |
| 8 | `app_config.dart` `_isBackendReachable` (startup probe) | `POST /api/v1/auth/login` with `{}` | none | same as #1 |
Base URL: `AppConfig.apiBaseUrl` resolved once at startup (LAN first, ngrok
fallback), frozen into the singleton Dio client (`api_client.dart:13`). Timeouts:
`connectTimeout` 10s, `receiveTimeout` 240s (sized for upload's synchronous
210s-worst-case parse; every other call inherits it).
## Response envelopes
- **v1 success**: `{ status: "success", message?, data: ... }` — Flutter reads
`response.data['data']`.
- **v1 error**: `{ status: "error", error: { statusCode, code, message } }`
(`utils/api-error.ts` → `lib/http-status.ts`), mirrored by
`lib/core/network/api_exception.dart` (`ApiException.fromDioException`). These
two are deliberately kept in sync — keep it that way.
- **Classic routes**: ad-hoc shapes — `GET /api/skus` returns `{ skus: [...] }`,
`POST /api/scan-pfm` returns a flat
`{ classification, ocr, possibleMatches, layoutParsingResult }`. No envelope,
no auth, CORS-open.
## The DO document lifecycle (happy path, as implemented)
1. Capture → blur check → `addDocument()` persists a `PendingDocument` to Hive
(`uploading`) → multipart POST to `/documents/upload` with `scan_mode` (`'DO'`
or `'Product'` from `scanModeProvider`).
2. Upload route: dedups by `file_hash`+`kode_toko`; inserts `documents` row
(`parsed=false`); **synchronously** calls internal `/api/parse` (210s abort);
parse writes `metadata` JSONB + `ocr_items` rows and sets `parsed=true`.
Response `data` is a **stub** DocumentModel (real `id`, empty header/items).
3. Client flips item to `processing` and polls `GET /documents` every 2s, looking
for its `id` in the full list with `parsed==true` (the list endpoint filters
`WHERE parsed = true`, so "appears in list" *is* the parse-done signal).
4. On success the pending card routes to `/editor` (or `/product-editor` when
`scanMode == 'Product'`) with `pendingId` passed as `state.extra`.
5. Editor save: builds `DocumentModel` (`id = _document?.id ?? pendingId ?? now-ms`),
saves to Hive `documentBox`, `PUT /documents/:id`; on success removes the
pending item, on failure marks it `syncFailed` (retry = re-PUT via `retrySync`).
6. `documents_screen._loadDocuments`: shows Hive cache, then fetches the server
list, **clears the whole Hive box**, and re-saves only server rows.
The PUT route writes `metadata` in **both** shapes (legacy web keys `noPO`/
`customerInfo`/… *and* mobile `header`/`shipment` sub-objects); the GET list maps
whichever exists. `DocumentModel.fromJson`/`toPutPayload` match this contract.
## Gaps found (G1-G10)
**G1 — No `GET /api/v1/documents/:id`, and no parse status in the contract.**
`api/v1/documents/[id]/route.ts` only has PUT. The poller must fetch the *entire*
list every 2s (server side: one `documents` query + one `ocr_items` query **per
document per poll** — N+1 that grows with history size). Because the list filters
`parsed=true`, the client cannot distinguish "still parsing" / "parse failed" /
"not mine": a server-side parse failure (upload route swallows it,
`upload/route.ts:134`) surfaces only as the client's generic 260s timeout
("Gagal mengekstrak data (Timeout)"). Match detection also relies on a Dart object
*identity* trick (`found != doc`, `pending_documents_provider.dart:133`).
**G2 — Product flow calls unauthenticated dev routes via URL string-hacking.**
`product_editor_logic.dart:57,69` rewrites the base URL with
`.replaceAll('/api/v1', '/api/skus' | '/api/scan-pfm')`. Those classic routes are
documented (backend/CLAUDE.md) as dev-only, never-authed, and not part of the
production surface; backend task 4.5 (shipped) restricts the public ngrok tunnel
to `/api/v1/*`, so both calls are expected to fail off-LAN. The existing v1
alternative `GET /api/v1/master/skus` is **admin-only** (403 for store accounts)
and uses a different envelope (`{status,data}` vs `{skus}`), so the client can't
just switch paths.
**G3 — RESOLVED 2026-07-10 (root task 7.2 / backend task 11.1).** Double
classification per product scan. Upload with `scan_mode=Product` already ran
the classifier inside `/api/parse` and stored only the top-1 SKU as the
document's single item. The product editor then re-read the image file,
base64-encoded it (~MBs through Dio JSON), and re-ran the whole classify+OCR
pipeline via `/api/scan-pfm` — ignoring the stored parse result except as a
lat/lng fallback. Two GPU passes per photo; the reviewed result could
disagree with the stored one. Fixed by having `/api/parse`'s Product branch
call the same shared `classifyAndMatchProduct()` used by
`POST /api/v1/scan-product` (task 9.3) and persist the full result (top-5
`possibleMatches` + OCR `extractedExpiryDate`) in
`documents.metadata.productScan`, surfaced by `document-mapper.ts`. The
editor now reads this directly from the document instead of re-classifying —
one GPU pass per photo, editor opens instantly like DO Scan's does.
**G4 — Product documents are typed by magic strings, `scan_mode` is never
persisted.** The backend fabricates placeholder metadata for product scans
(`PO-PRODUCT-001`, `noSO 1002003004`, `DO-PRODUCT-999`, `plat B 1234 PFM`,
`order_untuk: "PRODUCT SCAN"` — `parse/route.ts:107-135`) and the Flutter side
detects "is a product doc" by `orderUntuk == 'PRODUCT SCAN'`
(`documents_screen.dart:110`, `product_editor_logic.dart:36`). `scan_mode` is
sent at upload and forwarded to parse but never stored in `documents` nor
returned by GET, and `DocumentModel` has no doc-type field. Editing `orderUntuk`
silently moves a doc between tabs.
**G5 — PUT to a client-generated ID can never succeed.** Both editors fall back
to `finalDoc.id = widget.pendingId ?? now-ms` when `_document` is null (e.g.
`pendingId` no longer found in the provider — the `orElse` stub at
`product_editor_logic.dart:26` makes this reachable). The PUT then targets
`/documents/<13-digit ms timestamp>`; the backend `parseInt`s it into a value
that can't match (or even fit) the int4 `documents.id` → 404/500 → the item is
stuck in `syncFailed` and every retry re-fails identically.
**G6 — Server refresh wipes locally-saved-but-unsynced documents.** After a
failed PUT the editors keep the corrected doc in Hive (`saveDocument(finalDoc)`)
and mark the pending item `syncFailed` — but `documents_screen._loadDocuments`
(`documents_screen.dart:55`) does `documentBox.clear()` and refills from the
server list, deleting the local-only copy from history. Recovery survives only
via the pending item's embedded `document`; the history list lies in between.
**G7 — Mock data presented as real data in the product editor.** Offline/error
fallback fabricates three hardcoded SKU "matches" with fake confidences
(`product_editor_logic.dart:116-126`) and fake batch/expiry dates
(`'15/12/2026','20/04/2027'` — also used whenever OCR extracted no expiry date,
line 103). A reviewer cannot tell mock from model output. AGENTS.md §5 wants mock
data behind an explicit Demo/Live switch, not silently inlined in the live path.
**G8 — Route params ride on `state.extra`.** `/editor` and `/product-editor`
receive `pendingId` via `GoRoute state.extra` (`app_router.dart:34,41`), which
does not survive process death/state restoration and can't be deep-linked; a
restored editor gets `pendingId == null` and renders empty (feeding G5).
**G9 — `checkLoginState` error handling is string-typed and fail-open.**
`auth_provider.dart:30` detects 401 by `e.toString().contains('401')` instead of
`ApiException.from(e).statusCode`, and any *other* failure (timeout, 500, dead
tunnel) silently keeps the user "logged in" with a possibly-stale profile in
SharedPreferences. Plan task 1.1's original claim ("never pings the server") is
stale — `/auth/me` *is* called now; the residual gap is this fragile detection.
**G10 — Dedup response is a second, emptier stub.** A duplicate upload returns
the *original* document's id with empty header/items and no `parsed` flag
(`upload/route.ts:75-96`); the client treats it as fresh and re-polls. Works if
the original parsed; if the original's parse failed (G1), the second client also
burns the full 260s timeout. Low severity on its own — folds into G1's fix.
**G11 — No draft/confirmed distinction — `parsed` triggers list visibility, not
user confirmation.** `GET /api/v1/documents` filters `WHERE parsed = true`. `parsed`
is set the instant the backend's OCR pass finishes (`api/parse/route.ts`), which
happens **synchronously right after upload** — before the mobile user ever taps
"Simpan & Konfirmasi" in the editor (that action only happens on
`PUT /api/v1/documents/:id`). A scan the user captured, previewed, and then backed
out of (without confirming) is already sitting in the server's document list with
blank/placeholder fields — visible both to the store account and to admin. Fix:
separate "OCR finished" (`parsed`) from "user confirmed" (`confirmed`) as two
distinct booleans, gate list visibility on the latter. Root cause of the
`document_card.dart:25` fallback to the placeholder string "Staff Toko" (empty
`namaPenerima` on an unconfirmed DO document). Task: backend §10.1 (adds column +
gates list) → Flutter §8.2 (adds `confirmed` field to `DocumentModel`). Added
2026-07-10 from user APK testing feedback.
**G12 — Product Scan documents carry fabricated PO/SO/DO placeholder values with
no real-world referent.** `api/parse/route.ts` Product-scan branch (lines ~107-118)
stamps every Product Scan upload with `noPO: "PO-PRODUCT-001"`, `noSO:
"1002003004"`, `noDO: "DO-PRODUCT-999"` — concepts that don't apply to a product
verification scan. Not user-facing: `pdf_service.dart`'s Product receipt branch
never prints them (shows "Nomor Batch"/expiry instead), and
`product_editor_submit_logic.dart`'s `_submit()` never reads them (it builds
`noPo`/`noSo`/`noDo` itself from the user's PO-link dropdown/batch selection). Only
visible in raw debug logs. Same class of issue as the already-fixed G7 (fabricated
SKU matches/dates) — dishonest raw data, low risk to remove since nothing meaningful
depends on the values. Task: backend §10.2 (bundled with §10.1, same file). Added
2026-07-10 from user APK testing feedback.
## Ownership
- Flutter-side fixes: root `plans/next-enhancements.md` §6 (G1 client half, G5,
G6) and §7 (G2 client half, G3, G4 client half, G7) and §8 (G11 client half).
G8/G9 are folded into existing §1/§4-adjacent tasks as noted there.
- Backend counterparts: `backend/plans/next-enhancements.md` §9 (G1/G10 server
half: GET-by-id + parse status; G2: non-admin v1 SKU read + v1 scan endpoint;
G4 server half: persist and return `scan_mode`) and §10 (G11 server half:
`confirmed` column + list filter + PUT flip; G12: remove fabricated PO/SO/DO).
- Cross-cutting sequencing: backend §9 ships first; Flutter §6/§7 consume it.
Backend §10 ships first; Flutter §8.2 consumes it. Flutter §8.1 is independent.