Files
pfm-ocr/docs/api-contract-map.md
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

13 KiB
Raw Permalink Blame History

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 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 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.