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>
13 KiB
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 readsresponse.data['data']. - v1 error:
{ status: "error", error: { statusCode, code, message } }(utils/api-error.ts→lib/http-status.ts), mirrored bylib/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/skusreturns{ skus: [...] },POST /api/scan-pfmreturns a flat{ classification, ocr, possibleMatches, layoutParsingResult }. No envelope, no auth, CORS-open.
The DO document lifecycle (happy path, as implemented)
- Capture → blur check →
addDocument()persists aPendingDocumentto Hive (uploading) → multipart POST to/documents/uploadwithscan_mode('DO'or'Product'fromscanModeProvider). - Upload route: dedups by
file_hash+kode_toko; insertsdocumentsrow (parsed=false); synchronously calls internal/api/parse(210s abort); parse writesmetadataJSONB +ocr_itemsrows and setsparsed=true. Responsedatais a stub DocumentModel (realid, empty header/items). - Client flips item to
processingand pollsGET /documentsevery 2s, looking for itsidin the full list withparsed==true(the list endpoint filtersWHERE parsed = true, so "appears in list" is the parse-done signal). - On success the pending card routes to
/editor(or/product-editorwhenscanMode == 'Product') withpendingIdpassed asstate.extra. - Editor save: builds
DocumentModel(id = _document?.id ?? pendingId ?? now-ms), saves to HivedocumentBox,PUT /documents/:id; on success removes the pending item, on failure marks itsyncFailed(retry = re-PUT viaretrySync). 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 parseInts 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 returnscan_mode) and §10 (G11 server half:confirmedcolumn + 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.