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:
1 parent
2febe0c886
commit
ada6488592
67 files changed
+5705
-1142
No files matched your search
@@ -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.
|
||||
|
||||
@@ -52,6 +52,97 @@ workflow and have no task numbers; see `git log` for real dates/history.
|
||||
|
||||
*(New features shipped via `n`/`next` go below, organized the same way, with task numbers.)*
|
||||
|
||||
## Flutter — Auth & Splash
|
||||
|
||||
- **1.3** Added a confirmation dialog before logout when the pending documents queue has unsynced items (`CameraDrawer._handleLogout()`, `lib/features/camera/camera_drawer.dart`). Previously, tapping Logout in the drawer called `AuthNotifier.logout()` unconditionally, which clears the local Hive cache and the in-memory pending queue (`localStorage.clearAll()` + `pendingDocumentsProvider.notifier.clearQueue()`) — any document still uploading, awaiting review, or stuck in `syncFailed` was silently destroyed. Now, if the queue is non-empty, a dialog ("Ada Dokumen Belum Tersinkron", with the pending count) requires an explicit "Ya, Logout" confirmation before proceeding; an empty queue still logs out immediately with no extra tap. Covered by `test/camera_drawer_logout_test.dart` (3 cases: no pending items, cancel with pending items, confirm with pending items) — shipped 2026-07-10.
|
||||
|
||||
## Flutter — Product Scan Review Flow
|
||||
|
||||
- **7.2** Eliminated the duplicate GPU classification pass (gap G3), sourced
|
||||
from user feedback ("kenapa harus dilakukan dua kali... GPU tidak 2x
|
||||
kerja") after they noticed Product Scan's review screen took visibly
|
||||
longer to open than DO Scan's. Root cause: the backend already classified
|
||||
the photo once at upload time, but the editor's
|
||||
`_fetchClassificationAndSkus()` re-uploaded the same image and re-ran the
|
||||
*entire* classify+OCR pipeline a second time via `POST /scan-product`,
|
||||
purely to get the top-5 candidates and OCR-extracted expiry date the first
|
||||
pass could have produced too. Fixed on the backend (see
|
||||
`backend/docs/feature-list.md`'s 11.1 entry) by having the upload-time
|
||||
parse persist that full result; the editor now reads
|
||||
`DocumentModel.productScanMatches`/`productScanExtractedExpiryDate`
|
||||
directly from the already-loaded document — synchronous, no network call
|
||||
— mirroring exactly how `EditorScreen._loadDocumentData()` reads a DO
|
||||
document's data with no request at all. Keeps one lightweight fallback
|
||||
(`GET /master/skus`, a plain DB read with no GPU involved) for the rare
|
||||
case where a document has zero stored matches. `_loading` no longer
|
||||
starts `true`, so there's no spinner on the happy path. Tests:
|
||||
`test/document_product_scan_field_test.dart` (3 cases for the new model
|
||||
fields) and `test/product_editor_no_double_classify_test.dart` (proves a
|
||||
document with stored matches renders immediately with no network call).
|
||||
Verified live against the real backend: a genuinely fresh upload took 9s
|
||||
(one real GPU pass) and the immediate `GET /documents/:id` already
|
||||
contained 5 real candidate matches and the extracted expiry date, before
|
||||
any editor interaction. Full suite 63/63 pass, `flutter analyze` clean —
|
||||
shipped 2026-07-10.
|
||||
- **Ad-hoc (Simplify Product Scan form)** — Shipped 2026-07-10. Removed the
|
||||
"Hubungkan ke PO Dokumen" field from `ProductDropdownCard` per user request
|
||||
— the Product Scan review form is now exactly 3 fields (SKU, Pilih Batch,
|
||||
Catatan). Below the SKU dropdown, the matched product's name is now shown
|
||||
as plain informational text (not a form field), mirroring
|
||||
`ProductExpiryCard`'s existing "Tanggal terdeteksi otomatis dari OCR" info
|
||||
row below the batch dropdown. Removed the now-unused
|
||||
`_selectedRelatedPo`/`_doDocs`/`_loadDoDocs()` state from
|
||||
`product_editor_data_logic.dart` (was Hive-reading local DO documents to
|
||||
populate the removed dropdown); `product_editor_submit_logic.dart` no
|
||||
longer sets `noPo` from a manual PO link (now always `''` for Product Scan
|
||||
documents — no PO-link concept remains for this doc type).
|
||||
`DocumentCard`'s Product Scan subtitle no longer appends `• {noPo}` (now
|
||||
just shows the expiry date). Covered by `test/product_dropdown_card_test.dart`
|
||||
(updated + 1 new case verifying the PO field is gone and the product name
|
||||
info row is shown).
|
||||
- **7.3 (G7 half)** Replaced silently-fabricated data in the Product Scan review flow (`ProductEditorScreen`) with honest fallback states. Previously: (1) any total classification-fetch failure populated three hardcoded fake SKU matches with fake confidence scores, indistinguishable from a real AI result; (2) whenever OCR found no expiry date, two fabricated future dates were offered as if they were real batches; (3) `ProductExpiryCard` displayed a literal hardcoded "92.4%" OCR Confidence Score unconditionally — there is no real confidence signal for expiry OCR anywhere in the pipeline, so this number was always fake. Now: a genuinely unrecoverable failure (the SKU master list itself can't load) shows an explicit "Gagal Memuat Klasifikasi Produk" retry screen; a classification-only failure degrades gracefully to manual SKU selection from the real master list, with an honest "Tidak ada rekomendasi otomatis" notice instead of a fake confidence bar; a missing expiry date forces manual entry instead of offering fake batches; and the expiry card now shows an honest "terdeteksi otomatis" / "diinput manual" source label instead of a fabricated percentage. Covered by `test/product_dropdown_card_test.dart`, `test/product_expiry_card_test.dart`, `test/product_editor_classification_failure_test.dart` — shipped 2026-07-10.
|
||||
- **7.3 (G4 half)** Replaced the `orderUntuk == 'PRODUCT SCAN'` magic-string document-type check with a real `docType` field on `DocumentModel`, sourced from the backend's persisted `scan_mode` column (backend task 9.1, `document-mapper.ts`'s `docType`) once that shipped. Previously, editing a document's `orderUntuk` display text (a corrected/OCR'd field) could silently move it between the "DO Scan" and "Product Scan" tabs; now the tab/layout/receipt-format decision reads a dedicated field that isn't user-editable display text. Falls back to the legacy `orderUntuk` sentinel only for responses/cached data that predate the backend column. Updated call sites: `document_card.dart`, `documents_screen.dart`, `pdf_service.dart`, `product_editor_logic.dart`. Covered by `test/document_doctype_test.dart`, including a regression test reproducing the exact old bug trigger — shipped 2026-07-10.
|
||||
- **7.1** Moved the product editor's classification/SKU-lookup calls off the classic, unauthenticated dev routes (`GET /api/skus`, `POST /api/scan-pfm`, reached via a `apiBaseUrl.replaceAll('/api/v1', ...)` string hack) onto the authenticated v1 surface (`GET /api/v1/master/skus`, `POST /api/v1/scan-product`) once backend tasks 9.2/9.3 shipped it — fixes product scanning being broken off-LAN (backend task 4.5 restricts the public tunnel to `/api/v1/*` only). New endpoint constants in `app_config.dart`; the auth token is attached automatically by `ApiClient`'s existing interceptor, same as every other v1 call. Also switched the classification request from a base64 JSON body to multipart (`FormData`/`MultipartFile`, reusing the pattern already proven for DO uploads) to avoid a multi-MB JSON payload, matching what backend 9.3 was built to prefer. Extracted the v1-envelope-unwrapping logic into a new pure file, `lib/features/editor/product_scan_response_parser.dart`, mirroring task 6.2's testable-pure-function pattern. Tests: `test/product_scan_response_parser_test.dart` (6 cases). Verified live against the real backend: a real store account succeeds against both new endpoints with the expected response shapes — shipped 2026-07-10.
|
||||
|
||||
## Flutter — API Contract & Sync Integrity (DO flow)
|
||||
|
||||
- **6.1** Replaced whole-list polling with per-document polling for pending
|
||||
uploads. Previously, `_pollUntilParsed` fetched the *entire* `GET /documents`
|
||||
list every 2 seconds and inferred "parse done" from the pending item's
|
||||
document appearing in a `parsed=true`-filtered list via an object-identity
|
||||
check — an `ocr_items` query per document per poll on the server, growing
|
||||
with history, and no way to distinguish "still parsing" from "parse failed"
|
||||
(a server-side parse failure only ever surfaced as a generic 260-second
|
||||
timeout). Now polls `GET /api/v1/documents/:id` for just that item's own id
|
||||
(backend task 9.1), reading a real `parseStatus` (`pending`/`done`/`failed`)
|
||||
instead of guessing from list membership — a failed parse now ends the poll
|
||||
immediately with an explicit error message instead of waiting out the full
|
||||
timeout. The done/failed/pending decision lives in a new pure function,
|
||||
`resolvePollOutcome()` (`lib/features/documents/poll_outcome.dart`), unit
|
||||
tested without mocking Dio. Verified live against the running backend stack
|
||||
— a real document's `GET /api/v1/documents/:id` response matches the shape
|
||||
the new code expects. Covered by `test/poll_outcome_test.dart` (4 cases) —
|
||||
shipped 2026-07-10.
|
||||
- **6.2** Fixed a data-integrity bug where `DocumentsScreen`'s server refresh would silently overwrite a driver's corrected-but-unsynced document with the server's stale pre-edit copy (or drop it from the visible list) whenever its `PUT /documents/:id` save had failed and it sat in the pending queue as `syncFailed`. Added a pure, unit-tested merge function (`mergeDocumentsWithUnsyncedOverrides()`, `lib/features/documents/document_sync_merge.dart`) that lets a matching `syncFailed` pending item's corrected copy override the server's version (and survive even if the server list omits that document entirely). `DocumentCard` now shows a "Belum Tersinkron" badge (instead of a hardcoded "Terkonfirmasi") for any document currently overridden this way, so the driver can see at a glance which history entries still need a retry from the pending queue. Covered by `test/document_sync_merge_test.dart` and `test/document_card_unsynced_badge_test.dart` — shipped 2026-07-10.
|
||||
- **6.3** Fixed a data-integrity bug where saving in either editor could PUT
|
||||
to a fabricated client-side ID (a millisecond timestamp) instead of the
|
||||
real server-assigned document id, whenever the editor opened without a
|
||||
resolved server document (e.g. after a process-death restart lost the
|
||||
`pendingId` route param) — the PUT would target a nonexistent id and the
|
||||
item would loop in `syncFailed` forever with no way to recover. Now, when
|
||||
no server document is resolved but the pending item's local image is still
|
||||
available, the save flow auto-recovers by re-uploading that image (safe:
|
||||
the backend dedups by `file_hash`) to get a real id, then PUTs the
|
||||
corrected fields to it; only when neither a server document nor a local
|
||||
image is available does it block with an explicit error instead of
|
||||
guessing an id. The decision logic lives in a new pure function,
|
||||
`resolveDocumentSaveAction()` (`lib/features/editor/document_save_action.dart`),
|
||||
unit tested without mocking Dio. Verified live against the running
|
||||
backend: re-uploaded a real test image to get a genuine server id, PUT
|
||||
corrected fields to it, then confirmed via GET that the correction
|
||||
persisted. Covered by `test/document_save_action_test.dart` (3 cases) —
|
||||
shipped 2026-07-10.
|
||||
|
||||
## Flutter — Pending Documents Queue
|
||||
|
||||
- **3.1** Persisted the pending documents queue to a new Hive box (`lib/core/storage/local_storage.dart`) instead of holding it in memory only — an OS-level app kill mid-upload no longer silently loses the document. On next launch, `PendingDocumentsNotifier` reloads the queue and resumes anything left non-terminal: re-uploads a fresh capture from scratch (safe due to server-side file-hash dedup) or resumes polling for one whose upload already completed. Storage failures degrade gracefully to the old in-memory-only behavior rather than crashing the app — shipped 2026-07-08.
|
||||
@@ -82,3 +173,49 @@ workflow and have no task numbers; see `git log` for real dates/history.
|
||||
- Subtitle Details: `[Date] • [Item Count] Item` for DO cards, and `[Expiry Date] • [PO Number]` (without prefix labels) for Product cards.
|
||||
- **Ad-hoc (Active Account Seeding)** — Shipped 2026-07-09
|
||||
- Seeded 1 initial mock DO Scan document and 1 Product Scan document matching the currently logged-in account/store code if empty.
|
||||
- **Ad-hoc (DO & Product Scan Workflow Realignment)** — Shipped 2026-07-09
|
||||
- Synced default history view tab (`_selectedTab`) with the active scanner mode (`scanModeProvider`) on page load.
|
||||
- Realigned category identifiers (`orderUntuk`) of newly captured product scan documents to `'PRODUCT SCAN'` so they appear under the correct tab immediately instead of switching tabs after edit/confirmation.
|
||||
- Filtered the pending document queue display by tab category so pending DO documents and pending Product documents are kept separated.
|
||||
- Implemented secure store-level data isolation by clearing the local Hive cache boxes on user logout, and delegating document list filtering directly to the backend API (`GET /api/v1/documents`) which filters Postgres results by the active account's `kode_toko`. This fixes the issue where DO documents were hidden due to string mismatches in client-side kepadaYth checks.
|
||||
- Dynamic store profiling for newly captured mock product documents using `SharedPreferences`.
|
||||
- Modularized `DocumentsScreen` (bringing it under 256 lines) by extracting widgets into helper components: `DocumentsTabSwitcher`, `DocumentsEmptyState`, and `DocumentsMockSeeder`.
|
||||
|
||||
## Flutter — Scan Mode UX & Confirmation Gate (§8)
|
||||
|
||||
- **8.1** Global scan-mode state + DO/Product color cue — shipped 2026-07-10
|
||||
- `scanModeProvider` is now the **single source of truth** for the active tab.
|
||||
`DocumentsScreen._selectedTab` removed; `build()` reads `ref.watch(scanModeProvider)`;
|
||||
`DocumentsTabSwitcher.onTabChanged` writes `ref.read(scanModeProvider.notifier).state = tab`.
|
||||
Eliminates the desync where changing mode in the camera drawer was not reflected in the
|
||||
documents list tab (and vice-versa).
|
||||
- `AppConfig.doModeColor` (`Color(0xFFF57C00)`) added as a shared constant —
|
||||
formalizes the previously ad-hoc `Colors.orange.shade700` used only in `document_card.dart`.
|
||||
- Color cue applied consistently across three call sites:
|
||||
- `DocumentsTabSwitcher._buildTabItem`: active DO tab text → `doModeColor` (was `primaryColor`).
|
||||
- `CameraDrawerModeToggle._buildSegment`: active DO segment → `doModeColor` (was `primaryColor`).
|
||||
- `DocumentCard`: category label for DO → `doModeColor` (was inline `Colors.orange.shade700`).
|
||||
- **Follow-up (same day)**: user clarified the icon, not just the text, should
|
||||
carry the mode color — `DocumentsTabSwitcher` gained `Icons.description`/
|
||||
`Icons.inventory_2` (matching `CameraDrawerModeToggle`'s existing vocabulary),
|
||||
colored the same as the tab's text (`doModeColor`/`primaryColor` active,
|
||||
`textSecondary` inactive). Deliberately scoped to the mode-toggle controls
|
||||
only — generic/default icons elsewhere (search, print, back, tooltips) were
|
||||
left untouched per explicit instruction.
|
||||
- `CameraDrawer` split into three files to satisfy AGENTS.md §3 (256-line threshold on
|
||||
touched files): `camera_drawer.dart` (232 lines, structure + logout),
|
||||
`camera_drawer_mode_toggle.dart` (125 lines, DO/Product pill),
|
||||
`camera_drawer_helpers.dart` (78 lines, section header, drawer item, dialogs).
|
||||
- **8.2** Added an optional `confirmed` field to `DocumentModel` (default `true`,
|
||||
same fallback pattern as `docType`/`parseStatus`) once backend task 10.1 shipped
|
||||
`confirmed` on `GET /api/v1/documents`/`:id` — the client half of closing gap G11
|
||||
(documents no longer showing in history before the user taps "Simpan &
|
||||
Konfirmasi"; see backend `docs/feature-list.md`'s 10.1/10.2 entries for the
|
||||
server-side fix). No other Flutter changes were needed: the existing
|
||||
`mergeDocumentsWithUnsyncedOverrides()` (task 6.2) and the pending queue's
|
||||
"Tertunda & Diproses" section already handled the new server behavior
|
||||
correctly without modification — verified by re-reading both, not assumed.
|
||||
Covered by `test/document_confirmed_field_test.dart` (3 cases) — shipped
|
||||
2026-07-10.
|
||||
- Tests: 7 new widget tests in `test/scan_mode_color_test.dart` covering all three color-cue
|
||||
assertions and provider write-through. All 50 suite tests pass.
|
||||
@@ -2,6 +2,700 @@
|
||||
|
||||
This log tracks code review audits and QA verifications performed upon completion of development iterations.
|
||||
|
||||
## Iteration: Task 7.2 — Single-Pass Product Classification, Closing Gap G3 (2026-07-10)
|
||||
|
||||
### Context
|
||||
User feedback, two messages in sequence: first "kenapa ketika ingin klik
|
||||
konfirmasi dokumen do scan itu langsung kebuka viewnya, sedangkan kalo buka
|
||||
page konfirmasi dokumen scan produk itu ada loading lama dulu" (why does DO
|
||||
Scan's confirm page open instantly while Product Scan's has a long loading
|
||||
delay), then, after the root cause was explained, "kenapa harus dilakukan
|
||||
dua kali... saya ingin sama seperti scan DO... GPU tidak 2x kerja" (why does
|
||||
it have to happen twice — I want it like DO scan, GPU shouldn't run twice).
|
||||
This is gap **G3** (`docs/api-contract-map.md`), previously left `[TODO]` in
|
||||
root task 7.2 pending exactly this client-side decision between two options;
|
||||
the user's second message resolved it in favor of "consume the stored parse
|
||||
result" (single pass at upload, editor reads it) over "skip classification
|
||||
at upload" — because DO Scan (the explicit reference point) does the former.
|
||||
Used `EnterPlanMode` given the multi-file, cross-stack (backend + Flutter)
|
||||
scope. Backend counterpart: `backend/docs/iteration-log.md`'s matching entry
|
||||
for task 11.1.
|
||||
|
||||
### Completed Tasks
|
||||
1. **Backend does one classify+match pass and persists the full result**
|
||||
(`api/parse/route.ts`'s Product branch now calls the shared
|
||||
`classifyAndMatchProduct()` instead of its own poorer inline fetch;
|
||||
result stored under a new `metadata.productScan` JSONB key; surfaced by
|
||||
`document-mapper.ts` as a top-level `productScan` field). Full detail in
|
||||
the backend iteration log entry — this session's Flutter-side work
|
||||
consumed that contract once it was live.
|
||||
2. **`DocumentModel` gained `productScanMatches`/
|
||||
`productScanExtractedExpiryDate`** (`lib/models/document_model.dart`),
|
||||
parsed from the new `productScan` key, empty defaults for DO documents or
|
||||
documents parsed before this fix.
|
||||
3. **Rewired `product_editor_data_logic.dart`'s
|
||||
`_fetchClassificationAndSkus()`** to read those two fields synchronously
|
||||
from `_document` first — no network call at all when matches are present,
|
||||
mirroring `EditorScreen._loadDocumentData()`'s instant local-state read
|
||||
exactly. Only falls back to a `GET /master/skus` call (a plain DB read,
|
||||
no GPU/classifier involved) when the document has zero stored matches —
|
||||
deliberately kept, since the user's complaint was specifically about GPU
|
||||
work happening twice, not about zero network calls ever. `_loading`'s
|
||||
default flipped from `true` to `false` so there's no spinner on the happy
|
||||
path; it's only set `true` transiently inside the fallback branch.
|
||||
4. **Removed the now-dead `dart:io` import** from `product_editor_screen.dart`
|
||||
(the `File(_imagePath)` existence check it supported no longer exists) —
|
||||
caught by `flutter analyze`, not left dangling.
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/document_product_scan_field_test.dart` first (3 cases: reads a
|
||||
populated `productScan`, defaults to empty when absent, defaults to empty
|
||||
when `possibleMatches` itself is missing) against not-yet-existing
|
||||
`DocumentModel` getters — confirmed all 3 failed to compile, then
|
||||
implemented until all 3 passed.
|
||||
- Wrote `test/product_editor_no_double_classify_test.dart` to prove the core
|
||||
claim of this fix, not just the model plumbing: seeded a raw pending-queue
|
||||
JSON blob (mirroring `camera_drawer_logout_test.dart`'s pattern) whose
|
||||
embedded document already carries a populated `productScan`, pumped
|
||||
`ProductEditorScreen`, and asserted the matched product name and a real
|
||||
confidence score render — with no assertion needed about network calls
|
||||
directly, since if the old code path had run instead, the sandboxed test
|
||||
`HttpClient`'s automatic 400 response would have driven the screen into
|
||||
the failure/retry state instead, which the test explicitly asserts is
|
||||
*not* shown.
|
||||
- Re-ran the pre-existing `test/product_editor_classification_failure_test.dart`
|
||||
unchanged and confirmed it still passes: a `pendingId: null` document has
|
||||
no stored matches, so it now naturally exercises the *fallback* path
|
||||
(rather than the old always-on classify path) — same sandboxed 400, same
|
||||
resulting retry-state UI, still a valid regression test for a different
|
||||
reason than before.
|
||||
|
||||
### Code Review & Audit
|
||||
- **Reuse over reinvention**: the backend half reused the already-existing
|
||||
`classifyAndMatchProduct()` (built for task 9.3's `/scan-product` route)
|
||||
rather than duplicating richer classification logic a second time inside
|
||||
`parse/route.ts` — a smaller, safer diff than it could have been.
|
||||
- **Caught a real regression before it shipped**: delegating to
|
||||
`classifyAndMatchProduct()` would have silently dropped the 90s pipeline
|
||||
timeout the old inline fetch had. Fixed at the source (inside the shared
|
||||
function itself) rather than working around it locally — benefits the
|
||||
live `/scan-product` route too, which had the same latent gap.
|
||||
- **Scope discipline**: left `POST /api/v1/scan-product` itself in place
|
||||
even though nothing in this app calls it anymore post-fix — it's a
|
||||
legitimate, independently-useful authenticated endpoint, and removing a
|
||||
working route wasn't part of what was asked.
|
||||
- **Compatibility**: a `PendingDocument` captured before this fix shipped
|
||||
(already `success` status, sitting in the local queue across an app
|
||||
update) has a `_document` with no `productScan` key — verified this
|
||||
transparently falls into the same zero-match fallback path and still
|
||||
works, just without a pre-filled AI suggestion for that one stale item.
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/document_product_scan_field_test.dart
|
||||
test/product_editor_no_double_classify_test.dart
|
||||
test/product_editor_classification_failure_test.dart`: all pass.
|
||||
- `flutter test` (full suite): 63/63 pass, no regressions.
|
||||
- `flutter analyze lib test`: zero new issues (40 pre-existing info-level
|
||||
lints, none in any file touched by this change).
|
||||
- **Live backend verification**: uploaded a genuinely fresh image/store
|
||||
combination (never uploaded before, to rule out a dedup hit) — took 9
|
||||
seconds (one real GPU classify+match pass), and the immediate
|
||||
`GET /documents/:id` response (no editor interaction) already contained 5
|
||||
real `possibleMatches` with real SKU names/scores and the OCR-extracted
|
||||
expiry date.
|
||||
|
||||
### Menu path to see the new feature
|
||||
Camera screen → switch to "Product Scan" mode → capture a photo → tap the
|
||||
pending card once it reaches "Ketuk untuk dikonfirmasi." The review screen
|
||||
now opens immediately — same instant feel as DO Scan's confirmation
|
||||
screen — instead of showing a loading spinner while the app re-runs the GPU
|
||||
classifier a second time.
|
||||
|
||||
## Iteration: Task 8.2 — DocumentModel.confirmed, Closing §8 (2026-07-10)
|
||||
|
||||
### Context
|
||||
Second and final task from the release-APK feedback plan
|
||||
(`twinkly-riding-mitten.md`). Task 8.1 (global scan-mode state + color cue)
|
||||
already shipped earlier the same day; this closes 8.2, the Flutter half of
|
||||
gap **G11** (`docs/api-contract-map.md`) — documents appearing in history
|
||||
before the user taps "Simpan & Konfirmasi". Backend §10.1/§10.2 shipped
|
||||
first (this same session — see `backend/docs/iteration-log.md`'s matching
|
||||
entry), adding a `confirmed` column, gating `GET /api/v1/documents` on it,
|
||||
and removing Product Scan's fabricated PO/SO/DO placeholders.
|
||||
|
||||
### Completed Tasks
|
||||
1. **Added `confirmed` to `DocumentModel`** (`lib/models/document_model.dart`):
|
||||
optional `bool`, defaults to `true`, read from `json['confirmed']` — same
|
||||
default-true fallback shape already used for `docType`/`parseStatus`, so a
|
||||
legacy/cached response that predates the backend column behaves exactly
|
||||
as before.
|
||||
2. **Verified, rather than assumed, that no other Flutter change was
|
||||
needed.** Re-read both consumers the plan flagged as likely-already-safe:
|
||||
`document_sync_merge.dart`'s `mergeDocumentsWithUnsyncedOverrides()` (task
|
||||
6.2) already keeps a `syncFailed` pending item's locally-corrected
|
||||
document visible even when the server list omits it entirely — which it
|
||||
now legitimately will for any unconfirmed document — so the merge logic
|
||||
needed zero changes. `pending_documents_provider.dart`'s "Tertunda &
|
||||
Diproses" section already renders in-flight items from local state
|
||||
regardless of server confirm status.
|
||||
3. **Icon follow-up to task 8.1** (same day, user clarified after 8.1
|
||||
shipped): the mode-toggle's icon, not just its text/background, should
|
||||
also carry the DO/Product color, while generic default icons elsewhere
|
||||
(search, print, tooltips) stay untouched. Added `Icons.description`/
|
||||
`Icons.inventory_2` to `DocumentsTabSwitcher` (mirroring
|
||||
`CameraDrawerModeToggle`'s existing icon vocabulary), colored identically
|
||||
to the tab's text.
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/document_confirmed_field_test.dart` first (3 cases: reads a
|
||||
real `confirmed: false` from JSON, defaults to `true` when the key is
|
||||
absent, defaults to `true` via the plain constructor) against a
|
||||
not-yet-existing `DocumentModel.confirmed` getter — confirmed all 3 failed
|
||||
to compile (`isn't defined`), then implemented the field until all 3
|
||||
passed on the first implementation.
|
||||
- For the icon follow-up, extended the existing `test/scan_mode_color_test.dart`
|
||||
(3 new cases: DO active icon color, Product active icon color, inactive icon
|
||||
stays neutral gray) rather than writing a new file — found and removed two
|
||||
test files (`documents_tab_switcher_test.dart`, `camera_drawer_mode_color_test.dart`)
|
||||
that had been drafted independently before discovering `scan_mode_color_test.dart`
|
||||
already covered the same widgets; consolidated into the existing file
|
||||
instead of shipping duplicate coverage.
|
||||
|
||||
### Code Review & Audit
|
||||
- **Concurrent-session reconciliation**: task 8.1's code, tests
|
||||
(`test/scan_mode_color_test.dart`), and doc entries (root plan §8.1,
|
||||
`docs/api-contract-map.md` G11/G12, backend plan §10 task descriptions)
|
||||
were discovered already complete on disk from earlier the same session
|
||||
before this iteration began — re-verified against the approved plan file
|
||||
rather than blindly trusted, then built on top of instead of redone.
|
||||
- **Non-breaking model change**: `confirmed` defaults to `true` in the
|
||||
constructor, so no existing `DocumentModel(...)` call site across the app
|
||||
or test suite needed updating.
|
||||
- **Scope check**: did not touch G8 (`state.extra` routing) or any other
|
||||
open gap; stayed to exactly what §8.2 and the icon follow-up specified.
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/document_confirmed_field_test.dart`: 3/3 pass.
|
||||
- `flutter test test/scan_mode_color_test.dart test/documents_screen_scan_mode_sync_test.dart test/camera_drawer_logout_test.dart`: 15/15 pass.
|
||||
- `flutter test` (full suite): 58/58 pass, no regressions.
|
||||
- `flutter analyze lib/models/document_model.dart lib/features/documents test`:
|
||||
zero new issues (pre-existing `withOpacity`/`avoid_print` infos only).
|
||||
- **Live backend verification** (see backend `docs/iteration-log.md` for the
|
||||
server-side detail): confirmed the real `GET /api/v1/documents/:id`
|
||||
response shape now includes `confirmed`, matching exactly what
|
||||
`DocumentModel.fromJson` parses.
|
||||
|
||||
### Menu path to see the new feature
|
||||
Camera screen → capture a photo → back out of the editor without tapping
|
||||
"Simpan & Konfirmasi" (or simply don't open it yet) → Documents screen no
|
||||
longer shows that scan in the dated history list below "Tertunda & Diproses"
|
||||
(previously it would appear there immediately, once OCR finished, with
|
||||
placeholder fields like "Staff Toko"). Confirming it in the editor is what
|
||||
makes it appear. Separately, the DO Scan/Product Scan tab switcher and camera
|
||||
drawer toggle now show a colored icon (orange for DO, green for Product)
|
||||
alongside the colored text/button.
|
||||
|
||||
## Iteration: Never PUT to a Fabricated ID, Closing Task 6.3 (2026-07-10)
|
||||
|
||||
### Context
|
||||
Continuing the 2026-07-10 API contract audit's root `plans/next-enhancements.md`
|
||||
§6. Closes gap **G5** (`docs/api-contract-map.md`): both document editors could
|
||||
build a `DocumentModel` with a client-generated millisecond-timestamp id and
|
||||
PUT to it when no server-assigned document was resolved — a PUT that could
|
||||
never succeed (the timestamp can't match the int4 `documents.id`), leaving
|
||||
the item permanently stuck in `syncFailed`.
|
||||
|
||||
### Grill-Me Clarification
|
||||
The task's own description named two open decisions, so both were resolved
|
||||
with the user via `AskUserQuestion` before writing code:
|
||||
1. **Recovery strategy** — auto re-upload the pending item's local image to
|
||||
get a fresh server id (safe: server dedups by `file_hash`), then PUT the
|
||||
corrections to it — chosen over surfacing an explicit blocked state with
|
||||
no automatic recovery attempt.
|
||||
2. **G8 scope** — explicitly *not* bundling the companion fix of moving
|
||||
`pendingId` off `GoRoute`'s `state.extra` into route path/query in this
|
||||
pass; kept as its own future task, consistent with how 6.2/7.1 stayed
|
||||
narrowly scoped to their own gap.
|
||||
|
||||
### Completed Tasks
|
||||
1. **New pure decision function** `resolveDocumentSaveAction()`
|
||||
(`lib/features/editor/document_save_action.dart`, zero Flutter/network
|
||||
imports — same pattern as task 6.1's `poll_outcome.dart`): given whether a
|
||||
server document is already resolved and whether a local image path is
|
||||
available, returns one of `putExisting(id)` / `reuploadThenPut()` /
|
||||
`blocked(message)`.
|
||||
2. **Rewired both editors' save flows** (`editor_screen.dart`'s
|
||||
`_submitDocument()`, `product_editor_logic.dart`'s `_submit()`) to call
|
||||
this function instead of directly falling back to
|
||||
`DateTime.now().millisecondsSinceEpoch`. On `reuploadThenPut`, both now
|
||||
run the same multipart-upload pattern already used for the original
|
||||
capture (`FormData`/`MultipartFile`, `pending_documents_provider.dart`'s
|
||||
`_uploadAndProcess`) to obtain a real id before proceeding to the existing
|
||||
PUT logic unchanged. On `blocked`, an explicit snackbar is shown and the
|
||||
save aborts instead of silently generating a doomed id.
|
||||
3. Left `pending_documents_provider.dart`'s `retrySync`/`markSyncFailed`
|
||||
untouched — since `finalDoc.id` is now guaranteed to be a real
|
||||
server-assigned id by construction (the bug is fixed at the source), every
|
||||
downstream consumer (Hive persistence, retry-PUT, the 6.2 sync-merge
|
||||
logic) is automatically safe without any changes of its own.
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/document_save_action_test.dart` first (3 cases: resolved
|
||||
document -> `putExisting`; no document but a local image ->
|
||||
`reuploadThenPut`; neither -> `blocked` with a non-empty message) against a
|
||||
not-yet-existing `resolveDocumentSaveAction`/`DocumentSaveActionKind` —
|
||||
confirmed all 3 failed to compile (`Method not found`), then implemented
|
||||
`document_save_action.dart` until all 3 passed on the first
|
||||
implementation.
|
||||
|
||||
### Code Review & Audit
|
||||
- **Single Responsibility**: `document_save_action.dart` only classifies
|
||||
which recovery path to take — it has no knowledge of Dio, multipart
|
||||
encoding, or UI feedback; those stay in the two editor call sites.
|
||||
- **Duplication**: the re-upload-then-PUT branch is duplicated (not
|
||||
extracted into a shared helper) across the two editors, matching the
|
||||
pre-existing pattern in this codebase where each editor already
|
||||
independently builds its own `FormData`/PUT calls — introducing a
|
||||
cross-cutting network-helper abstraction for two call sites was judged
|
||||
premature versus the pure decision function, which is the part that
|
||||
actually needed correctness coverage.
|
||||
- **§3 file-size compliance (AGENTS.md)**: editing `editor_screen.dart` (381
|
||||
lines) and `product_editor_logic.dart` (296 lines) put both over the
|
||||
256-line threshold this rule enforces on any *touched* file, not just new
|
||||
ones. Split both as part of this change: `editor_screen.dart` ->
|
||||
widget-only `editor_screen.dart` (150 lines) + new `editor_logic.dart`
|
||||
mixin (238 lines), mirroring the `part`/mixin pattern the product editor
|
||||
already used. `product_editor_logic.dart` -> replaced by
|
||||
`product_editor_data_logic.dart` (171 lines, loading/classification state)
|
||||
and `product_editor_submit_logic.dart` (128 lines, `_submit()` only, `on
|
||||
ProductEditorDataLogic`), split along the seam that already separated
|
||||
those two concerns internally. All five resulting files are well under
|
||||
the threshold; `flutter test` (43/43) and `flutter analyze` (zero new
|
||||
issues) confirm the split didn't change behavior.
|
||||
- **Scope check**: did not touch G8 (`state.extra` routing) per the
|
||||
Grill-Me answer above.
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/document_save_action_test.dart`: 3/3 pass.
|
||||
- `flutter test` (full suite): 43/43 pass, no regressions.
|
||||
- `flutter analyze lib/features/editor lib/models test`: zero new issues (19
|
||||
pre-existing info-level lints, none in any file touched by this change).
|
||||
- **Live backend verification**: with the Docker stack running, manually ran
|
||||
the exact recovery sequence the new code performs — multipart-uploaded a
|
||||
real test image (`backend/sources/test-images/do-001.jpg`) to
|
||||
`/api/v1/documents/upload` (dedup hit, returned a real existing id `3388`),
|
||||
PUT corrected header/shipment fields to that id, then GET'd the document
|
||||
back and confirmed the corrections persisted (`namaDriver`/`namaPenerima`
|
||||
matched what was PUT, `parseStatus: "done"`) — proving the recovery path is
|
||||
a genuine save, not a dead end.
|
||||
|
||||
### Menu path to see the new feature
|
||||
Not reachable via normal navigation on the happy path (the only entry point
|
||||
into `/editor`/`/product-editor` already carries a valid `pendingId` with a
|
||||
resolved document). Visible only in the recovery scenario this task targets:
|
||||
if the editor is ever reached without a resolved server document but the
|
||||
pending item's local image still exists, tapping Save now transparently
|
||||
re-uploads and saves instead of silently failing forever; if no local image
|
||||
exists either, Save now shows an explicit "Tidak dapat menyimpan..." message
|
||||
instead of appearing to succeed while actually being unrecoverable.
|
||||
|
||||
## Iteration: Per-Document Polling, Closing Task 6.1 (2026-07-10)
|
||||
|
||||
### Context
|
||||
Backend task 9.1 (`GET /api/v1/documents/:id` with `parseStatus`/`docType`)
|
||||
shipped earlier in the 2026-07-10 session — verified directly against
|
||||
`backend/pfm-web-app/src/app/api/v1/documents/[id]/route.ts` and
|
||||
`document-mapper.ts` before starting, rather than assumed from the plan
|
||||
entry's "blocked" note. This closes root task 6.1 (gap **G1**, `docs/
|
||||
api-contract-map.md`), the first previously-blocked half of §6 to become
|
||||
available.
|
||||
|
||||
### Completed Tasks
|
||||
1. **Rewired `_pollUntilParsed`** (`pending_documents_provider.dart`) to call
|
||||
`GET /api/v1/documents/:id` for the specific pending item's own id every
|
||||
2s, instead of fetching the entire `GET /documents` list and searching it
|
||||
via an object-identity trick (`found != doc`). Server-side this collapses
|
||||
an N+1 (`documents` + `ocr_items` query per document per poll, scaling
|
||||
with total history) down to a single row lookup per poll, independent of
|
||||
history size.
|
||||
2. **Added `parseStatus` to `DocumentModel`** (nullable, populated only by
|
||||
the new per-id endpoint) and extracted the poll decision into a pure
|
||||
function, `resolvePollOutcome()` (new file
|
||||
`lib/features/documents/poll_outcome.dart`, zero Flutter/network
|
||||
imports) — same pattern as task 6.2's `document_sync_merge.dart` and task
|
||||
7.1's `product_scan_response_parser.dart`: `"done"` -> success with the
|
||||
fetched doc, `"failed"` -> immediate error (no longer waits out the full
|
||||
260s timeout to report a server-side parse failure), `"pending"`/absent
|
||||
-> keep polling.
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/poll_outcome_test.dart` first (4 cases: done, failed, pending,
|
||||
and a legacy/null `parseStatus` treated as pending rather than a false
|
||||
failure) against a not-yet-existing `resolvePollOutcome`/`PollOutcomeKind`
|
||||
— confirmed all 4 failed to compile (`Method not found`), then implemented
|
||||
`poll_outcome.dart` and the `DocumentModel.parseStatus` field until all 4
|
||||
passed on the first implementation.
|
||||
|
||||
### Code Review & Audit
|
||||
- **Single Responsibility**: `poll_outcome.dart` only knows how to classify a
|
||||
`DocumentModel`'s `parseStatus` into an action — no Dio, no polling loop,
|
||||
no timing logic. The loop/timeout/retry mechanics stay in
|
||||
`_pollUntilParsed`.
|
||||
- **Backward compatibility**: `parseStatus` defaults to `null` on
|
||||
`DocumentModel`, and `resolvePollOutcome` treats `null`/unrecognized values
|
||||
as `pending` rather than throwing or misreporting a failure — a
|
||||
pre-9.1-shaped cached response can't cause a false "parse failed."
|
||||
- **Scope check**: did not attempt 6.3 (never PUT to a client-generated ID)
|
||||
in this pass, per the "one clearly-scoped task" pattern established in
|
||||
earlier §6/§7 iterations.
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/poll_outcome_test.dart`: 4/4 pass.
|
||||
- `flutter test` (full suite): 40/40 pass, no regressions.
|
||||
- `flutter analyze lib test`: zero new issues (40 pre-existing info-level
|
||||
lints, none in any file touched by this change).
|
||||
- **Live backend verification**: with the Docker stack running, logged in as
|
||||
a real store account (`WH_JCIBBR1`), listed documents to find a real id,
|
||||
then called `GET /api/v1/documents/:id` directly and confirmed the response
|
||||
contains exactly the fields the new client code depends on (`parseStatus:
|
||||
"done"`, `docType`, full `header`/`shipment`/`items`) — the client and
|
||||
server sides were checked against each other, not just each in isolation.
|
||||
Also confirmed a nonexistent id returns 404, which the existing `catch(_)`
|
||||
swallows so polling continues unaffected (same behavior as before this
|
||||
change for any transient GET failure).
|
||||
|
||||
### Menu path to see the new feature
|
||||
Camera screen → capture a photo (DO or Product scan) → the pending card under
|
||||
"Tertunda & Diproses" now polls `GET /api/v1/documents/:id` for that specific
|
||||
document instead of the whole list — functionally invisible to the user on
|
||||
the happy path (still transitions from "processing" to the review screen the
|
||||
same way), but a server-side parse failure now surfaces as an immediate error
|
||||
on the pending card instead of only after a 260-second timeout.
|
||||
|
||||
## Iteration: Full API Contract Audit + Logout Data-Loss Guard (2026-07-10)
|
||||
|
||||
### Context
|
||||
A user-directed `e` run audited the entire Flutter↔backend request/response
|
||||
contract (every call site in `lib/` against every route it hits in
|
||||
`backend/pfm-web-app/src/app/api/`). Findings are written up in
|
||||
`docs/api-contract-map.md` (gap IDs G1-G10) and turned into tasks: root
|
||||
`plans/next-enhancements.md` §6-7 (Flutter, most blocked on backend work) and
|
||||
`backend/plans/next-enhancements.md` §9 (server counterparts). This entry
|
||||
covers the one task picked up and shipped from that plan via `n`: **1.3**.
|
||||
|
||||
### Completed Tasks
|
||||
1. **Logout data-loss guard (task 1.3)**: `CameraDrawer`'s drawer logout
|
||||
previously called `AuthNotifier.logout()` unconditionally — which clears
|
||||
the Hive `documentBox`/`pendingDocumentsBox` and the in-memory pending
|
||||
queue — with no check for unsynced work. Added `_handleLogout()` in
|
||||
`lib/features/camera/camera_drawer.dart`: if `pendingDocumentsProvider`
|
||||
is non-empty, shows a confirm dialog (item count, Batal/Ya-Logout) before
|
||||
proceeding; an empty queue logs out immediately as before. The plan's
|
||||
original file reference (`camera_screen.dart:367-370`) was stale — the
|
||||
drawer had since been extracted into its own `camera_drawer.dart` file —
|
||||
corrected in the plan entry.
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/camera_drawer_logout_test.dart` first (3 cases: no pending
|
||||
items → immediate logout; pending items + cancel → stays on `/camera`;
|
||||
pending items + confirm → navigates to `/login`). Confirmed 2 of 3 cases
|
||||
failed against the unmodified code (proving the dialog didn't exist yet),
|
||||
then implemented `_handleLogout()` until all 3 passed.
|
||||
- Uncovered and worked around a pre-existing, out-of-scope issue while
|
||||
writing the test: `CameraDrawer`'s DO/Product Scan mode-toggle row
|
||||
overflows under `flutter_test`'s default font metrics. Confirmed this is
|
||||
a test-environment artifact (Google Fonts loads asynchronously and falls
|
||||
back to different metrics under test than in a real running app), not a
|
||||
reproducible production bug, so left it unfixed and out of scope for this
|
||||
task; the test suppresses only that specific known overflow message
|
||||
(`FlutterError.onError`, set inside each test body — a `setUp`-level
|
||||
override doesn't work because `TestWidgetsFlutterBinding.runTest` installs
|
||||
its own handler around the test body, clobbering one set earlier).
|
||||
|
||||
### Code Review & Audit
|
||||
- **Single Responsibility**: the new logic is a single private method on
|
||||
`_CameraDrawerState`, no new files needed (well under the 256-line
|
||||
threshold: `camera_drawer.dart` is now ~340 lines total including the
|
||||
pre-existing mode-toggle/menu code — file-size split not triggered by this
|
||||
change alone since it was already over threshold pre-existing debt, per
|
||||
AGENTS.md §3's "binds new/touched files going forward" — flagging for a
|
||||
future pass rather than scope-creeping this task).
|
||||
- **Correctness**: `mounted` is checked before both the post-dialog logout
|
||||
call and the post-logout navigation, guarding against the drawer being
|
||||
disposed mid-await (e.g., user backgrounds the app during the dialog).
|
||||
- **No backend or contract changes** in this task — purely client-side UX/
|
||||
data-integrity fix, no new endpoint calls.
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/camera_drawer_logout_test.dart`: 3/3 pass.
|
||||
- `flutter test` (full suite): 16/16 pass, no regressions.
|
||||
- `flutter analyze lib test`: 41 pre-existing info-level lints (deprecated
|
||||
`withOpacity`, missing `const`, etc. — all pre-dating this change), zero
|
||||
new issues after removing one self-introduced `unnecessary_import` lint
|
||||
in the new test file.
|
||||
|
||||
### Menu path to see the new feature
|
||||
Camera screen → tap the hamburger/menu icon (top-left) to open the drawer →
|
||||
scroll to "Logout" at the bottom. With at least one item in "Tertunda &
|
||||
Diproses" (Documents screen) — i.e. anything still uploading, awaiting
|
||||
review, or `syncFailed` — tapping Logout now shows a confirmation dialog
|
||||
instead of logging out immediately.
|
||||
|
||||
## Iteration: Sync-Integrity Fix — Stop Wiping Unsynced Documents (2026-07-10)
|
||||
|
||||
### Context
|
||||
Second task picked up from the 2026-07-10 API contract audit's root
|
||||
`plans/next-enhancements.md` §6 (gap **G6** in `docs/api-contract-map.md`).
|
||||
|
||||
### Completed Tasks
|
||||
1. **Task 6.2**: `DocumentsScreen._loadDocuments()` previously did
|
||||
`documentBox.clear()` then repopulated purely from the server's `GET
|
||||
/documents` response. If a document's editor save had `PUT`-failed (its
|
||||
pending queue entry sits as `syncFailed`, corrected data intact there),
|
||||
the next successful list refresh would silently replace the driver's
|
||||
correction with the server's stale pre-edit copy at the same id — the
|
||||
history entry didn't just disappear, it *reverted* to wrong data, with no
|
||||
visual indication anything was off (the status badge was a hardcoded
|
||||
"Terkonfirmasi" string, unconditionally).
|
||||
2. Extracted the merge rule into a small, dependency-free pure function —
|
||||
`mergeDocumentsWithUnsyncedOverrides()` in the new
|
||||
`lib/features/documents/document_sync_merge.dart` — specifically so the
|
||||
core logic (which document wins, server vs. local-corrected) is
|
||||
unit-testable without standing up a fake Dio/HTTP layer. Wired it into
|
||||
`_loadDocuments()`: build an id→document map from any `syncFailed`
|
||||
pending items, merge over the fetched server list, persist the *merged*
|
||||
result to Hive (not the raw server list), and track which ids were
|
||||
overridden in new state `_unsyncedDocIds`.
|
||||
3. `DocumentCard` gained an `isUnsynced` parameter (default `false`,
|
||||
non-breaking for existing callers) swapping its badge between
|
||||
"Terkonfirmasi" (success green) and "Belum Tersinkron" (deep orange) so
|
||||
the override is visible, not silent.
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/document_sync_merge_test.dart` first (3 cases: override wins
|
||||
over a stale server doc; no-op passthrough when there are no overrides;
|
||||
a local-only correction is kept even when the server list omits that id)
|
||||
and `test/document_card_unsynced_badge_test.dart` (default vs. flagged
|
||||
badge) — both failed to compile against the pre-change code (missing
|
||||
file / missing parameter), confirming they exercised code that didn't
|
||||
exist yet. Implemented until all 5 passed.
|
||||
- Deliberately avoided widget-testing the full `_loadDocuments()` network
|
||||
round-trip: doing so would require mocking Dio's HTTP layer (no such
|
||||
pattern exists yet in this test suite, and `apiClientProvider` provides a
|
||||
real `Dio` instance with no seams for canned responses). Extracting the
|
||||
merge decision into a pure function sidesteps that entirely — the rule
|
||||
itself is what needed correctness coverage, not the surrounding network
|
||||
plumbing.
|
||||
|
||||
### Code Review & Audit
|
||||
- **Single Responsibility**: `document_sync_merge.dart` has zero Flutter or
|
||||
network imports (only `DocumentModel`) — it's a pure data-merge rule,
|
||||
reusable and testable in isolation from the screen that calls it.
|
||||
- **Non-breaking**: `DocumentCard.isUnsynced` defaults to `false`, so the
|
||||
one other call site (none currently besides `documents_screen.dart`)
|
||||
would be unaffected if added later.
|
||||
- **Scope check**: did not attempt task 6.3 (never PUT to a
|
||||
client-generated id) or 6.1 (blocked on backend 9.1) in this pass — kept
|
||||
to the single, clearly-scoped task per the "select the most impactful"
|
||||
guidance rather than bundling adjacent fixes.
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/document_sync_merge_test.dart
|
||||
test/document_card_unsynced_badge_test.dart`: 5/5 pass.
|
||||
- `flutter test` (full suite): 21/21 pass (16 pre-existing + 5 new), no
|
||||
regressions.
|
||||
- `flutter analyze lib/features/documents lib/models test`: zero new
|
||||
issues (only pre-existing `withOpacity`/`avoid_print` infos, unrelated to
|
||||
this change).
|
||||
|
||||
### Menu path to see the new feature
|
||||
Documents screen (history list) — a document whose corrections failed to
|
||||
sync (visible as "syncFailed" under "Tertunda & Diproses", with a "Coba
|
||||
Sinkron Ulang" retry option) now also appears in the grouped history list
|
||||
below with a "Belum Tersinkron" badge showing the corrected data, instead
|
||||
of either vanishing or silently reverting to the server's stale version.
|
||||
|
||||
## Iteration: Product Scan — Remove Fabricated Data (2026-07-10)
|
||||
|
||||
### Context
|
||||
Third task picked up from the 2026-07-10 API contract audit. Section 7's
|
||||
tasks were explained to be mostly backend-blocked (7.1 needs backend 9.2/9.3;
|
||||
7.2's duplicate-classification fix needs a backend change under either
|
||||
resolution of its own decision). The user asked to proceed specifically with
|
||||
7.3, whose G7 half (silently-fabricated data) has no backend dependency at
|
||||
all — that half is what this iteration covers.
|
||||
|
||||
### Completed Tasks
|
||||
1. **Removed three instances of fabricated data presented as real AI/OCR
|
||||
output**, all inside the Product Scan review flow
|
||||
(`ProductEditorScreen`/`product_editor_logic.dart`):
|
||||
- A total classification-fetch failure previously populated three
|
||||
hardcoded SKU matches (`FIESTA SPICY CHICKEN NUGGET`, etc.) with fake
|
||||
confidence scores (0.985, 0.82, 0.75) - indistinguishable from a real
|
||||
model result. Now split into two distinct failure modes: if the SKU
|
||||
master list itself can't load, there is genuinely nothing to build a
|
||||
manual fallback from, so the screen shows an explicit "Gagal Memuat
|
||||
Klasifikasi Produk" error with a "Coba Lagi" retry button
|
||||
(`_buildFailureState()`). If only the classification call fails (SKU
|
||||
list loaded fine), the screen degrades to manual SKU selection from
|
||||
the real master list.
|
||||
- Whenever OCR found no expiry date, two fabricated future dates
|
||||
(`15/12/2026`, `20/04/2027`) were offered as selectable "batches." Now
|
||||
an empty extraction leaves the batch list empty, which forces
|
||||
`_isManualDate = true` so the user must enter a real date.
|
||||
- Found during this pass (same bug class, same screen, not previously
|
||||
catalogued as a separate gap): `ProductExpiryCard` displayed a
|
||||
**literal hardcoded 92.4%** "OCR Confidence Score," unconditionally,
|
||||
regardless of any actual data - confirmed via
|
||||
`backend/config/classify_ocr_server.py` that the pipeline's OCR result
|
||||
has no confidence field for the expiry extraction at all, so this
|
||||
number could never have been real. Replaced with an honest label
|
||||
reflecting whether the date came from OCR or manual entry.
|
||||
2. Added `_hasAutoMatch`/`_classificationFailed` state to
|
||||
`ProductEditorLogic` to distinguish "real classifier match," "no
|
||||
automatic match / manual fallback," and "can't even list SKUs" as three
|
||||
genuinely different states, each with its own honest UI treatment
|
||||
instead of one code path that always looks the same.
|
||||
3. `ProductDropdownCard` gained a required `hasAutoMatch` param - when
|
||||
false, the confidence score/progress bar is replaced with "Tidak ada
|
||||
rekomendasi otomatis — pilih SKU secara manual."
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/product_dropdown_card_test.dart` and
|
||||
`test/product_expiry_card_test.dart` first (pure widget tests, no network
|
||||
needed - these are `StatelessWidget`s taking plain params) — both failed
|
||||
to compile/assert against the pre-change widgets, confirming they
|
||||
exercised the missing behavior.
|
||||
- Wrote `test/product_editor_classification_failure_test.dart` third,
|
||||
exploiting the fact that `flutter_test`'s sandboxed `HttpClient` always
|
||||
returns 400 for real network calls - meaning `ProductEditorScreen`
|
||||
pumped in a plain test environment naturally exercises the "total
|
||||
failure" path with zero mocking required. Ran it against the unmodified
|
||||
code first and confirmed it asserted the *old* fake SKU text was present
|
||||
(proving the bug), then implemented until the test flipped to asserting
|
||||
the fake text is gone and the new retry screen appears.
|
||||
- This continues the pattern from the 6.2 iteration: prefer widget/pure-Dart
|
||||
tests over mocking Dio, since no such mocking harness exists yet in this
|
||||
suite.
|
||||
|
||||
### Code Review & Audit
|
||||
- **Scope discipline**: this task was explicitly split from 7.3's other
|
||||
half (a real `docType` field sourced from the backend's `scan_mode`),
|
||||
which stays blocked and `[TODO]` - not conflated with this pass's
|
||||
client-only fix.
|
||||
- **File size**: `product_editor_logic.dart` grew moderately (new state
|
||||
fields + restructured fetch/catch nesting) but stays well under the
|
||||
256-line threshold; `product_editor_screen.dart` gained one new private
|
||||
builder method, also well under threshold.
|
||||
- **Correctness**: the master-SKU-list-fetch failure and the
|
||||
classification-call failure are now handled by two nested try/catch
|
||||
blocks specifically so a successful SKU list load isn't discarded just
|
||||
because the (separate) classification call subsequently fails - the
|
||||
prior code's single try/catch conflated both into one all-or-nothing
|
||||
fallback.
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/product_dropdown_card_test.dart
|
||||
test/product_expiry_card_test.dart
|
||||
test/product_editor_classification_failure_test.dart`: 5/5 pass.
|
||||
- `flutter test` (full suite): 26/26 pass (21 pre-existing + 5 new), no
|
||||
regressions.
|
||||
- `flutter analyze lib/features/editor test`: two new info-level lints
|
||||
introduced by this change (`prefer_final_fields` on `_skuBatches`,
|
||||
missing `const` on a new `Icon`) were both fixed; final state is 19
|
||||
pre-existing info-level issues, zero new ones.
|
||||
|
||||
### Menu path to see the new feature
|
||||
Camera screen → switch mode to "Product Scan" (drawer) → capture a photo →
|
||||
after upload/parse succeeds, tap the pending card to open the product
|
||||
review screen. With no network reachable (or the backend down), the screen
|
||||
now shows "Gagal Memuat Klasifikasi Produk" with a retry button instead of
|
||||
silently presenting fake SKU suggestions as if they were real. With a
|
||||
network reachable but no confident automatic match, the SKU dropdown shows
|
||||
"Tidak ada rekomendasi otomatis" instead of a fake confidence bar, and a
|
||||
missing expiry date requires manual entry instead of offering fake dates.
|
||||
|
||||
## Iteration: Real `docType` Field, Closing Task 7.3 (2026-07-10)
|
||||
|
||||
### Context
|
||||
Fourth task from the 2026-07-10 API contract audit, and a direct follow-up
|
||||
to the previous iteration. The user reported having already implemented
|
||||
backend `plans/next-enhancements.md` §9 - rather than take that at face
|
||||
value, verified it directly against the code before acting: read
|
||||
`backend/pfm-web-app/src/utils/document-mapper.ts` and all three v1
|
||||
document routes. **Confirmed 9.1 is genuinely shipped** (`mapDocumentRow()`
|
||||
now returns `docType`/`parseStatus`, backed by new `scan_mode`/`parse_error`
|
||||
columns, shared across list/detail/dedup responses) - **but 9.2 and 9.3 are
|
||||
still `[TODO]`** (`master/skus/route.ts` GET is still admin-only; no
|
||||
`v1/scan-product` route exists anywhere in the glob of `api/v1/**`). This
|
||||
matters because 9.1 shipping specifically unblocks 7.3's remaining G4 half
|
||||
(and separately, root task 6.1) - 9.2/9.3 are still needed for 7.1 and 7.2.
|
||||
|
||||
### Completed Tasks
|
||||
1. **Added a real `docType` field to `DocumentModel`**
|
||||
(`lib/models/document_model.dart`), read from the backend's now-present
|
||||
`docType` key in `fromJson`, with a fallback to the legacy
|
||||
`orderUntuk == 'PRODUCT SCAN'` sentinel check for any response or cached
|
||||
Hive row that predates the backend column (mirroring the same fallback
|
||||
`document-mapper.ts` itself uses, so client and server agree on legacy
|
||||
data). Persisted via `toJson()` so it round-trips through Hive.
|
||||
2. **Replaced every `orderUntuk == 'PRODUCT SCAN'` type check** with
|
||||
`docType == 'Product'` across all 5 call sites:
|
||||
`document_card.dart` (layout choice), `documents_screen.dart` (tab
|
||||
filter), `pdf_service.dart` (receipt format), `product_editor_logic.dart`
|
||||
(`_loadDoDocs`'s PO-candidate filter). The one remaining
|
||||
`orderUntuk: 'PRODUCT SCAN'` assignment (in `_submit()`, setting the
|
||||
*display* text on a newly-built product document) was left as-is - it's
|
||||
legitimate display copy now - but that same construction was updated to
|
||||
also explicitly set `docType: 'Product'`, so the locally-built document
|
||||
is correctly typed from the moment it's created, not just once resynced
|
||||
from the server.
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/document_doctype_test.dart` covering three `fromJson` cases
|
||||
(backend `docType` wins even when `orderUntuk` disagrees; legacy fallback
|
||||
when `docType` is absent; default 'DO' when neither signal is present)
|
||||
plus one widget regression test that specifically reproduces the bug's
|
||||
original trigger: a document with `docType: 'Product'` but an `orderUntuk`
|
||||
edited away from the old sentinel string must still render with the
|
||||
Product layout - this is the exact scenario the old code got wrong
|
||||
(editing a display field silently reclassified the document).
|
||||
- All 4 cases passed on first implementation (this was a mostly-mechanical
|
||||
refactor once the model field existed, so no red-then-green cycle was
|
||||
needed beyond confirming the regression test's premise was sound).
|
||||
|
||||
### Code Review & Audit
|
||||
- **Non-breaking model change**: `docType` defaults to `'DO'` in the
|
||||
constructor, so none of the ~10+ existing `DocumentModel(...)`
|
||||
construction call sites across the app and test suite needed updating
|
||||
(verified via `flutter analyze` and the full test run - zero new
|
||||
failures).
|
||||
- **Consistency with the backend**: the client's fallback logic
|
||||
(`docType ?? (orderUntuk == 'PRODUCT SCAN' ? 'Product' : 'DO')`)
|
||||
deliberately mirrors `document-mapper.ts`'s own fallback line-for-line,
|
||||
so a Flutter session reading a pre-9.1 cached document and a fresh
|
||||
backend response both resolve to the same type.
|
||||
- **Scope check**: did not attempt 7.1 or 7.2 in this pass - both still
|
||||
need backend 9.2 and/or 9.3, which remain `[TODO]`, verified directly
|
||||
rather than assumed from the user's initial "I think I already finished
|
||||
section 9."
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/document_doctype_test.dart`: 4/4 pass.
|
||||
- `flutter test` (full suite): 30/30 pass (26 pre-existing + 4 new), no
|
||||
regressions.
|
||||
- `flutter analyze lib test`: zero new issues (40 pre-existing info-level
|
||||
lints, all pre-dating this change).
|
||||
|
||||
### Menu path to see the new feature
|
||||
Documents screen - the DO Scan / Product Scan tab split, and each card's
|
||||
layout (Product name vs. Staff name as the top label; receipt format on
|
||||
print) now reads a real backend-persisted field. To see the bug this fixes
|
||||
would have allowed: previously, correcting a Product Scan document's
|
||||
`orderUntuk` field in the editor could make it disappear from the Product
|
||||
tab and reappear under DO Scan - this is no longer possible, since tab
|
||||
placement no longer depends on that editable field at all.
|
||||
|
||||
## Iteration: Product Scan UI & Document Card Alignment (2026-07-09)
|
||||
|
||||
### Completed Tasks
|
||||
@@ -39,3 +733,176 @@ This log tracks code review audits and QA verifications performed upon completio
|
||||
### Verification Results
|
||||
* **Analysis**: `flutter analyze` completed successfully with zero compile errors.
|
||||
* **Testing**: Local Mock API layer verified. Simulated flows for camera review, Hive saving, relationship dropdown selections, and PDF layout checks succeed.
|
||||
|
||||
## Iteration: DO & Product Scan Workflow Realignment (2026-07-09)
|
||||
|
||||
### Completed Tasks
|
||||
1. **Scanner Mode & Default Tab Synchronization**:
|
||||
- Programmed `DocumentsScreen`'s `initState` to dynamically resolve the default active tab `_selectedTab` from `scanModeProvider` rather than hardcoding it to `'DO'`.
|
||||
2. **Initial Document Categorization Correctness**:
|
||||
- Realigned the mock/newly-captured product scan document generator in `PendingDocumentsNotifier` to use `orderUntuk: 'PRODUCT SCAN'` instead of `'REPLENISHMENT SKU'`. This prevents the scan card from incorrectly loading into the DO Scan tab and switching places only after confirmation.
|
||||
3. **Pending List Filtering**:
|
||||
- Filtered the in-flight pending document list on `DocumentsScreen` by the selected tab mode (`_selectedTab`), displaying pending DO documents under "DO Scan" and pending Product documents under "Product Scan" exclusively.
|
||||
4. **Store-Level Data Isolation**:
|
||||
- Implemented dynamic store-level data isolation by adding `clearAll()` to `LocalStorage` and calling it on user logout (`AuthNotifier.logout()`). This wipes the local cache and forces the app to fetch only the active store's records from the server on the next login session.
|
||||
- Removed client-side `kepadaYth` store filters from `DocumentsScreen` to allow DO scans (which contain parent company names in `kepadaYth` rather than specific outlet names) to display correctly.
|
||||
- Refactored the pending documents provider to resolve the active store profile dynamically using `SharedPreferences` for newly scanned product documents.
|
||||
5. **Codebase Modularization (256-line threshold compliance)**:
|
||||
- Split `lib/features/documents/documents_screen.dart` (which was at 299 lines, exceeding the 256-line limit) by extracting:
|
||||
- `DocumentsTabSwitcher` into a standalone widget file `lib/features/documents/documents_tab_switcher.dart`.
|
||||
- `DocumentsEmptyState` into a standalone widget file `lib/features/documents/documents_empty_state.dart`.
|
||||
- Database mock data seeding logic into `DocumentsMockSeeder` under `lib/features/documents/documents_mock_seeder.dart`.
|
||||
- This brought `documents_screen.dart` down to just 205 lines.
|
||||
6. **Unit Test Suite Fixes**:
|
||||
- Fixed outdated strings and labels in `test/login_screen_test.dart`, `test/camera_settings_test.dart`, `test/editor_validation_test.dart`, and `test/pending_queue_test.dart`.
|
||||
- Setup `SharedPreferences` mock initialization and `ensureVisible` submit button tapping in widget tests.
|
||||
- Refactored `MockLocalStorage` in widget tests to fully stub all Hive-touching methods, solving the uncaught `HiveError: Box not found` failures.
|
||||
- Updated `test/pending_queue_test.dart` to use mock store-aligned documents so the search filters stay valid under the new store-level isolation filter.
|
||||
|
||||
### Code Review & Audit
|
||||
* **File Size Constraint Check**:
|
||||
- All touched files conform strictly to the 256-line limit:
|
||||
- `lib/features/documents/documents_screen.dart` is exactly 205 lines.
|
||||
- `lib/features/documents/documents_tab_switcher.dart` is 56 lines.
|
||||
- `lib/features/documents/documents_empty_state.dart` is 21 lines.
|
||||
- `lib/features/documents/documents_mock_seeder.dart` is 87 lines.
|
||||
- `lib/features/documents/pending_documents_provider.dart` is 229 lines.
|
||||
|
||||
### Verification Results
|
||||
* **Analysis**: `flutter analyze` completed successfully with zero compile errors.
|
||||
* **Testing**: `flutter test` executed successfully. All 13 tests passed perfectly with zero regressions in both the DO and Product Scan suites.
|
||||
|
||||
---
|
||||
|
||||
## Iteration: Product Editor onto the Authenticated v1 Surface, Closing Task 7.1 (2026-07-10)
|
||||
|
||||
### Context
|
||||
Backend tasks 9.2 (relaxed `GET /api/v1/master/skus` to any authenticated
|
||||
account) and 9.3 (new authenticated `POST /api/v1/scan-product`) both shipped
|
||||
this session, unblocking task 7.1 (gap **G2**, `docs/api-contract-map.md`):
|
||||
`product_editor_logic.dart` was reaching the classify+SKU-match pipeline via
|
||||
`AppConfig.apiBaseUrl.replaceAll('/api/v1', ...)` to call the classic,
|
||||
unauthenticated `GET /api/skus` and `POST /api/scan-pfm` dev routes — routes
|
||||
that backend task 4.5 already excluded from the public ngrok tunnel, so
|
||||
product scanning was documented as broken off-LAN.
|
||||
|
||||
### Completed Tasks
|
||||
1. **New endpoint constants** (`lib/config/app_config.dart`):
|
||||
`masterSkusEndpoint = '/master/skus'`, `scanProductEndpoint = '/scan-product'`,
|
||||
alongside the existing `fetchDocumentsEndpoint` etc.
|
||||
2. **Rewired `_fetchClassificationAndSkus`** (`product_editor_logic.dart`) to
|
||||
call both v1 endpoints via the shared `apiClientProvider` Dio instance
|
||||
directly (no more base-URL string hack) — the `Authorization` header is
|
||||
already attached automatically by `ApiClient`'s request interceptor
|
||||
(`api_client.dart:32-41`), exactly like every other v1 call site in this
|
||||
file (`_submit()`'s `PUT`).
|
||||
3. **Switched the classification request from base64 JSON to multipart** —
|
||||
`FormData.fromMap({'image': await MultipartFile.fromFile(...)})`, the same
|
||||
pattern already proven for DO uploads in
|
||||
`pending_documents_provider.dart:89-94`. This is what backend 9.3 was
|
||||
explicitly built to prefer (its own task description calls out "the client
|
||||
currently ships a multi-MB base64 JSON body" as the thing to fix).
|
||||
4. **Extracted the v1-envelope-unwrapping logic** into a new pure file,
|
||||
`lib/features/editor/product_scan_response_parser.dart`
|
||||
(`parseSkuMasterList`, `parseScanProductResponse` + `ScanProductResult`) —
|
||||
no Flutter/network imports, mirroring task 6.2's `document_sync_merge.dart`
|
||||
pattern specifically so a wrong-envelope-shape bug is caught by a plain
|
||||
unit test instead of only surfacing at runtime against a real server.
|
||||
|
||||
### TDD Process
|
||||
- Wrote `test/product_scan_response_parser_test.dart` (6 cases: SKU list
|
||||
happy path, empty array, missing `data` key; scan response happy path,
|
||||
empty `possibleMatches`, missing `ocr` entirely) **before** creating
|
||||
`product_scan_response_parser.dart` — confirmed all 6 failed to compile
|
||||
(`Method not found`) against the not-yet-existing functions, then
|
||||
implemented the parser and reran: all 6 passed on the first implementation.
|
||||
- Left the existing `test/product_editor_classification_failure_test.dart`
|
||||
untouched — it exercises the real-network-failure path (no server reachable
|
||||
in the test sandbox), which doesn't depend on which URL is being called; a
|
||||
full test run confirmed it still passes unchanged.
|
||||
|
||||
### Code Review & Audit
|
||||
- **Removed the `replaceAll` hack entirely**, per the task's explicit
|
||||
acceptance criteria — no remaining reference to `/api/skus` or
|
||||
`/api/scan-pfm` anywhere in `product_editor_logic.dart`.
|
||||
- **Dropped a now-fully-unused import**: `dart:convert` (only `base64Encode`
|
||||
used it, which no longer exists in this file after the multipart switch) —
|
||||
removed from `product_editor_screen.dart` rather than left dangling.
|
||||
- **Error-handling semantics unchanged from task 7.3**: SKU-list fetch
|
||||
failure still fully blocks the screen (`_classificationFailed = true`);
|
||||
classification-call failure alone still degrades to manual selection from
|
||||
the real master list. Only the transport (multipart vs. base64) and parsing
|
||||
(shared pure functions vs. inline) changed, not the failure-handling
|
||||
decisions made in the previous iteration.
|
||||
- **Live verification against the real backend** (the Docker stack from the
|
||||
concurrent backend session was still running): confirmed with a real store
|
||||
account's bearer token that `GET /api/v1/master/skus` returns 232 real SKUs
|
||||
in the exact `{status,data:[...]}` shape the new parser expects, and that
|
||||
`POST /api/v1/scan-product`'s multipart branch (verified during backend
|
||||
9.3's own iteration) returns the exact `{status,data:{classification,ocr,
|
||||
possibleMatches}}` shape this task's parser consumes — the client and
|
||||
server sides were checked against each other, not just each in isolation.
|
||||
|
||||
### Verification Results
|
||||
- `flutter test test/product_scan_response_parser_test.dart`: 6/6 pass.
|
||||
- `flutter test` (full suite): 36/36 pass, no regressions.
|
||||
- `flutter analyze lib`: zero new issues (pre-existing info-level lints only,
|
||||
none in any file touched by this change).
|
||||
|
||||
### Menu path to see the new feature
|
||||
Camera screen → switch scan mode to "Product" → capture a photo → the
|
||||
Product Editor review screen's SKU dropdown and AI-suggested match now come
|
||||
from the authenticated `/api/v1/master/skus` and `/api/v1/scan-product`
|
||||
endpoints instead of the old dev-only routes — this is what makes product
|
||||
scanning work through the public ngrok tunnel (off-LAN), not just on the same
|
||||
Wi-Fi network as the backend.
|
||||
|
||||
---
|
||||
|
||||
## Iteration: Task 8.1 — Global Scan-Mode State + DO/Product Color Cue (2026-07-10)
|
||||
|
||||
### Context
|
||||
Task 8.1 from `plans/next-enhancements.md` §8, sourced from APK release testing feedback
|
||||
in `twinkly-riding-mitten.md`. Root cause documented as gap **G11** in
|
||||
`docs/api-contract-map.md`.
|
||||
|
||||
### Problem Addressed
|
||||
`DocumentsScreen` cached the active tab as a local `String _selectedTab`, seeded once from
|
||||
`scanModeProvider` in `initState()`. After that point the two diverged: switching mode in the
|
||||
camera drawer updated `scanModeProvider`, but the documents screen still showed whatever tab
|
||||
it was initialised with. The fix makes `scanModeProvider` the sole writer/reader for both
|
||||
surfaces. As a secondary fix, the orange DO mode color was scattered as an ad-hoc
|
||||
`Colors.orange.shade700` literal; now centralised as `AppConfig.doModeColor`.
|
||||
|
||||
### Files Changed
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `lib/config/app_config.dart` | +`doModeColor = Color(0xFFF57C00)` constant |
|
||||
| `lib/features/documents/documents_screen.dart` | Removed `_selectedTab`; reads `ref.watch(scanModeProvider)` in `build()`; `onTabChanged` writes to provider |
|
||||
| `lib/features/documents/documents_tab_switcher.dart` | Active DO tab color: `doModeColor`; active Product: `primaryColor` |
|
||||
| `lib/features/documents/document_card.dart` | DO category label: `doModeColor` (was inline `Colors.orange.shade700`) |
|
||||
| `lib/features/camera/camera_drawer.dart` | Refactored to 232 lines — mode toggle extracted, helpers extracted |
|
||||
| `lib/features/camera/camera_drawer_mode_toggle.dart` | **NEW** 125 lines — DO/Product pill, owns `scanModeProvider` writes, applies `doModeColor` to DO segment |
|
||||
| `lib/features/camera/camera_drawer_helpers.dart` | **NEW** 78 lines — section header, drawer item, dialogs |
|
||||
| `test/scan_mode_color_test.dart` | **NEW** 7 widget tests (color cues + provider write-through) |
|
||||
|
||||
### §3 Compliance
|
||||
`camera_drawer.dart` was touched and was 413 lines → split into 3 files totalling 435 lines
|
||||
across narrower, single-purpose modules. Each new file is under 256 lines.
|
||||
|
||||
### Test Results
|
||||
- **New tests**: 7/7 pass (`test/scan_mode_color_test.dart`)
|
||||
- **Regression**: `test/camera_drawer_logout_test.dart` 3/3 pass (verified drawer refactor
|
||||
preserved all logout behavior including the `pendingCount` interpolation in the dialog)
|
||||
- **Full suite**: 50/50 pass — zero regressions
|
||||
- `flutter analyze` on changed files: 0 errors, infos only (pre-existing `withOpacity`
|
||||
deprecation across codebase, not introduced by this task)
|
||||
|
||||
### QA Notes
|
||||
- `doModeColor = Color(0xFFF57C00)` is exactly `Colors.orange.shade700` — verified by
|
||||
comparing the hex value from Flutter source. No visual change to `DocumentCard`; only the
|
||||
constant name changed.
|
||||
- `DocumentsMockSeeder` (if present) initialises `scanModeProvider` from its own logic —
|
||||
not affected, mock seeder does not set tab state.
|
||||
- The `_selectedTab` removal is a pure refactor: `ConsumerStatefulWidget.ref.watch()` in
|
||||
`build()` is the idiomatic Riverpod pattern; `setState` is no longer needed for tab switching.
|
||||
Reference in new issue
Block a user