Files
pfm-ocr/docs/api-contract-map.md
T
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 ada6488592 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>
2026-07-10 15:19:32 +07:00

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