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>
192 lines
13 KiB
Markdown
192 lines
13 KiB
Markdown
# 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.
|
||
|