Files
pfm-ocr/plans/next-enhancements.md
T
Rafhan Mazaya FathurrahmanandClaude Fable 5 e6daa9b053 docs(plan): per-sale expiry tracking design — batch registry + candidate matching
Grilled 2026-07-16 with the user; full decision record in
docs/expiry-tracking-plan.md. Core reframe: expiry is captured once per
batch at DO intake (staff-typed on the stock-entry confirmation page, from
the physical packs), so the cashier scan only MATCHES OCR fragments against
the 1-3 known in-stock batch dates instead of free-reading damaged
dot-matrix prints (proven model-capability ceiling, 2026-07-15). Fallback:
auto-FEFO + 'inferred' flag, zero cashier interaction. No cloud, ever.

- docs/expiry-tracking-plan.md: architecture, matching algorithm spec
  (resolveExpiryFromEvidence), schema/API deltas, phases 1-3, testing plan
- backend plans §13 (13.1-13.4): matcher util + offline tuning, route
  wiring + expiry_source provenance, multi-frame union, dot-matrix
  recognizer fine-tune
- root plans §10 (10.1-10.3): cashier fast path, inferred badge +
  end-of-day review, burst capture for mounted camera
- stock-feature-plan.md: extension note (batch dropdown becomes the
  manual-override path)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8TumxFDnyVnfsR3mxPXfX
2026-07-16 17:00:36 +07:00

48 KiB
Raw Blame History

Next Enhancements (Flutter)

This file is the working backlog driven by the e/enhance and n/next triggers defined in AGENTS.md, which now scopes this kit's automated behaviors to the Flutter app only (see AGENTS.md's "Scope: excludes backend/"). Sections seeded 2026-07-08 from the real module structure of app-pfm-ocr-v2 (see AGENTS.md's Adaptation Notes); tasks populated the same day via e/enhance, grounded in a direct read of each module's current code rather than invented work.

backend/ has its own, independent copy of this kit — backend/plans/next-enhancements.md, driven by backend/AGENTS.md Part B. This file no longer tracks backend work at all — the backend sections that briefly lived here (5-8, from the one backend-scoped e run before the kit split) were removed 2026-07-08 now that the backend copy is the sole active backlog for that subtree.

Note: this repo already has an unrelated, pre-existing plans/next-enhancement-plan.md (singular) — a [DONE] QA verification checklist. It is not part of this workflow and is left as-is; this file (plural) is the one e/n reads and writes.

Format

Tasks are grouped under a numbered section per module of the application. Each section gets exactly 3 tasks:

## 1. <Section / Module Name>

- **1.1** [TODO] <clear, specific description of the functional change>
- **1.2** [TODO] <...>
- **1.3** [TODO] <...>

When a task is picked up via n/next, its clarified acceptance criteria (from AGENTS.md §2a) are appended directly under it as a short note, e.g.:

- **1.1** [TODO] <description>
  - Acceptance: <1-3 line resolved scope, from the clarification step>

When complete, the status flips to [DONE] and the feature is logged in docs/feature-list.md.


Sections (seeded from real modules — run e / enhance to fill in tasks)

1. Flutter — Auth & Splash

lib/features/auth/, lib/features/splash/

  • 1.1 [TODO] Harden the token validation that now exists. (Description refreshed 2026-07-10 — the original claim "never pings the server" is stale: checkLoginState() now calls GET /auth/me and logs out on 401.) Remaining gap (see docs/api-contract-map.md G9): 401 detection is e.toString().contains('401') (auth_provider.dart:30) instead of reading ApiException.from(e).statusCode, and any non-401 failure (timeout, 500, dead tunnel) silently treats the user as logged in with a possibly-stale cached profile. Use the structured exception, and decide/handle the offline-start case explicitly.

  • 1.2 [TODO] Add a global 401/403 response interceptor to the Dio client that force-logs-out and redirects to /login, so an expired/revoked token surfaces as a clear re-login prompt instead of failing whatever screen happens to make the next API call.

  • 1.3 [DONE 2026-07-10] Warn before logout if the pending documents queue (pendingDocumentsProvider) has unsynced items. (File reference corrected: the drawer now lives in lib/features/camera/camera_drawer.dart, not camera_screen.dart — extracted in a later commit.) Acceptance: if the queue is non-empty when Logout is tapped, show a confirm dialog ("Ada Dokumen Belum Tersinkron" / item count / Batal-or-Ya-Logout) before calling logout(); if empty, logout proceeds immediately as before. Implemented in CameraDrawer._handleLogout() (camera_drawer.dart). See docs/feature-list.md.

  • 1.4 [TODO] Warn before login wipes local state, mirroring 1.3's logout guard. AuthNotifier.login() (auth_provider.dart:62-65) unconditionally calls localStorage.clearAll() and pendingDocumentsProvider.clearQueue() before saving the new session — if there's a non-empty pending/unsynced queue at the moment of login (re-login after a forced logout, or a shared device), it's silently destroyed with zero recovery path, unlike the logout path which now confirms first.

  • 1.5 [TODO] Stop sequencing the splash screen's fixed delays ahead of the network-dependent auth check. _checkAuthAndNavigate() (splash_screen.dart:32-43) always waits the full 2s fade + a hardcoded extra 1s "for effect" before even starting checkLoginState()'s /auth/me round trip — every cold launch pays ~3s of artificial delay on top of real network latency. Run the animation and the auth check concurrently (Future.wait) and navigate as soon as both finish.

  • 1.6 [TODO] Add an error boundary around the splash screen's auth check. _checkAuthAndNavigate() calls await ref.read(authProvider.notifier).checkLoginState() with no try/catch — an exception from SharedPreferences.getInstance() or elsewhere in that call chain propagates uncaught, and since neither context.go('/camera') nor context.go('/login') ever executes, the user is stuck on the splash screen indefinitely with no visible error and no way forward except force-closing the app.

2. Flutter — Camera Capture & Geotagging

lib/features/camera/

  • 2.1 [TODO] Surface actionable, user-visible feedback when location can't be determined, instead of only logging it. Every failure path in LocationService.determinePosition() (lib/core/location/location_service.dart) — services disabled, permission denied, deniedForever, or all four GPS-fix strategies failing — only calls debugPrint and returns null; the driver gets no on-screen prompt (e.g. "enable location" / "open app settings") and the document just uploads without a GPS tag.

  • 2.2 [TODO] Show a location-fix quality/staleness indicator on the capture screen before the shutter is pressed. _currentPosition in CameraScreen (camera_screen.dart:22,37-62) is used whatever its age or accuracy, with no on-screen warning when no fix has landed yet or the fix is old — a document can silently upload with a poor or missing GPS tag.

  • 2.3 [TODO] Replace the static "posisikan seluruh halaman dokumen di dalam foto" instructional text with a live document-alignment overlay during capture. CameraScreen only launches the OS's native camera app via ImagePicker(source: ImageSource.camera) (camera_screen.dart:69-74) — there's no in-app camera preview, so the framing guideline is shown once beforehand and then unavailable during the actual shot.

  • 2.4 [TODO] Enforce the blur check instead of only displaying it. ImagePreviewScreen's "Unggah Dokumen" button (image_preview_screen.dart:286-294) is gated only on !_analyzing — not on !_blurResult!.isBlur — so a photo flagged "Foto Terdeteksi Blur! Diharuskan untuk mengambil ulang gambar" can still be uploaded as-is. The blur badge is currently cosmetic.

  • 2.5 [TODO] Flag or restrict gallery-sourced captures. _pickFromGallery() (camera_screen.dart:96-144) lets staff select any existing photo from the device gallery, not just a fresh capture — if it has no EXIF GPS and no live position is cached, it uploads with no geotag and nothing on the document distinguishes it later from a live camera capture, undermining the GPS tag's purpose as proof the staff was physically present.

  • 2.6 [TODO] Surface camera/gallery picker failures to the user. _takePicture()/_pickFromGallery() (camera_screen.dart:91-93,141-143) catch all image_picker errors (permission denied, picker cancelled abnormally, IO failure) with only debugPrint — tapping "Ambil Foto Kamera"/"Pilih Dari Galeri" can silently do nothing with zero on-screen explanation of why.

3. Flutter — Pending Documents Queue

lib/features/documents/

  • 3.1 [DONE] Persisted the pending documents queue to disk via a new Hive box (LocalStorage.pendingDocumentsBox/savePendingDocument/removePendingDocument/getAllPendingDocuments, lib/core/storage/local_storage.dart). PendingDocumentsNotifier now hydrates from disk on construction and resumes anything not yet terminal: a persisted uploading item (fresh capture, or a retryUpload interrupted mid-flight) re-runs _uploadAndProcess from scratch — safe because the upload endpoint dedupes by file hash server-side — and a persisted processing item resumes polling via the newly extracted _pollUntilParsed/_resumePolling instead of re-uploading. retrySync's own transient flip to uploading is deliberately kept in-memory-only (_updateItemInMemory) so an interrupted sync-retry resumes as "resend the PUT," not "redo the whole upload." Storage failures are caught and swallowed everywhere (hydrate, _persistPendingDocument, _removePersistedPendingDocument) so a Hive error degrades to the old in-memory-only behavior rather than crashing the queue. Verified via flutter analyze (clean) and flutter test (no new failures vs. the pre-existing 3-test baseline). Completed 2026-07-08.

  • 3.2 [TODO] Add a persistent "pending/syncing count" badge visible from the camera screen (not just the /documents list), reflecting pendingDocumentsProvider state — today a driver who navigates away from /documents gets no visibility into background uploads still in progress or stuck in error/syncFailed.

  • 3.3 [TODO] Right-size per-request timeouts. (Description refreshed 2026-07-10 — the original claim "no explicit connect/receive timeout" is stale: ApiClient now sets connectTimeout 10s / receiveTimeout 240s globally, api_client.dart:14-19.) Remaining gap: the 240s receive timeout is sized for the upload's synchronous OCR pass but is inherited by every call — a hung 2s-interval poll GET /documents can stall one iteration for up to 4 minutes, and login/list calls hang far longer than useful. Pass tighter per-request Options(receiveTimeout: ...) on the poll/list/login paths, keeping the long timeout only where the slow parse justifies it. Coordinate with 5.1 (failover needs fast failure).

  • 3.4 [TODO] Clean up orphaned captured-image files. Neither removeDocument() nor the success path in _uploadAndProcess() (pending_documents_provider.dart) ever deletes the underlying photo file at imagePath — only the Hive record is removed. Every synced or manually-dismissed document leaves its captured image on device storage indefinitely, growing unbounded over the app's lifetime.

  • 3.5 [TODO] Stop reporting network failures as "OCR timeout." _pollUntilParsed()'s per-iteration catch (pending_documents_provider.dart:142) silently swallows every exception (including plain connectivity errors) and just retries; if all 130 retries are consumed this way, the final message is always the generic "Gagal mengekstrak data (Timeout)." — masking a bad Wi-Fi connection as a backend/OCR problem and making real issues harder to diagnose in the field.

  • 3.6 [TODO] Throttle pending-queue processing on load instead of firing everything at once. _loadPendingDocuments() (pending_documents_provider.dart:22-36) starts an independent upload-or-poll loop for every non-terminal item simultaneously on provider construction — if a store queues many documents while offline and then reconnects, the app fires that many concurrent multipart uploads and 2s-interval polling loops at once against a single-GPU backend already flagged as a throughput bottleneck.

4. Flutter — Document Editor & PDF Receipt

lib/features/editor/

  • 4.1 [TODO] Add an unsaved-changes guard when navigating away from EditorScreen with edited-but-unsaved field values. There is currently no PopScope/back-navigation interception, so a back-swipe or system back button silently discards manual corrections to OCR'd header/item fields.

  • 4.2 [TODO] Block save when a line item's SKU isn't in the master registry, instead of only relabeling it for display. The SKU listener in _addItem (editor_screen.dart:108-115) sets the item name to "SKU Tidak Terdaftar" for an unrecognized SKU but doesn't stop form submission, so a document with an unregistered/mistyped SKU can still be saved and its receipt printed.

  • 4.3 [TODO] Include the captured GPS coordinates on the printed PDF receipt. PdfService.generateAndPrintReceipt (pdf_service.dart:36-52) prints header/shipment/item fields but never includes document.latitude/longitude, even though the editor captures and displays them (_latitudeCtrl/_longitudeCtrl) — the geotag exists in the data model but isn't part of the audit-trail document a store keeps.

  • 4.4 [TODO] Block save on a document with zero line items. _showConfirmationDialog() (editor_logic.dart:94-117) only runs _formKey.currentState!.validate() (per-field validators) — nothing checks _itemControllers.isNotEmpty. If every item is removed via _removeItem, the form still validates and the document saves/prints with an empty items list.

  • 4.5 [TODO] Stop validating DO items against a static bundled SKU list. The item SKU field's validator (items_list_card.dart:85) checks membership in MasterSku.data — a hardcoded Map compiled into the app (lib/data/master_sku.dart) — instead of the live /api/v1/master/skus endpoint the Product Scan flow already uses (task 7.1). Any SKU added/changed centrally won't validate correctly in the DO editor until the app is rebuilt and redeployed to every device, and the bundled copy can silently drift from the real master data.

  • 4.6 [TODO] Strengthen delivery confirmation beyond a typed name. ConfirmationDialog (confirmation_dialog.dart) accepts any freely-typed string as namaPenerima plus a checkbox — nothing verifies the person confirming is who they claim, yet that typed name is printed on the PDF receipt's signature line (pdf_service.dart) as if it were an actual signature. Consider a lightweight identity check (PIN re-entry, or a captured signature/initial) before treating it as a confirmed receipt.

5. Flutter — Connectivity & Endpoint Resolution

lib/config/app_config.dart, lib/core/network/api_client.dart

Added 2026-07-08 via a user-directed e run ("dual endpoint: local first, public ngrok fallback"). Current-state audit: the requested dual-endpoint fallback already exists at startup — AppConfig.initializeApiBaseUrl() (app_config.dart:32-49) probes the LAN URL first, falls back to the reserved ngrok domain, and validates each probe is a real backend (checks the ngrok-error-code header, JSON content-type, and 502/503/504) with the ngrok-skip-browser-warning header set. (Root CLAUDE.md describes this order backwards — tracked as a doc fix in backend task 5.1d.) These tasks close what's actually missing:

  • 5.1 [TODO] Mid-session endpoint failover. Resolution runs exactly once at startup, and ApiClient freezes baseUrl at construction of a singleton (api_client.dart:13, apiClientProvider) — a phone that resolves LAN on Wi-Fi and then leaves the building fails every subsequent call with no path back to the ngrok endpoint (and vice versa) until an app restart. Add a Dio interceptor that, on connectivity-class failures only (connectionTimeout/ connectionError — not HTTP-level errors), re-runs endpoint resolution and retries the request once against the newly resolved endpoint. Implementation notes: the frozen-at-construction baseUrl must become dynamic (set _dio.options.baseUrl on re-resolution, or read AppConfig.apiBaseUrl in the existing onRequest interceptor); retry-once is safe for the upload path because the backend dedups by file_hash (backend task 1.1), but audit other POST/PUT call sites before blanket-retrying. Coordinate with 3.3 (explicit Dio timeouts) — a hung connection must fail fast enough for failover to matter.

  • 5.2 [TODO] Endpoint status visibility + manual re-probe. Show which endpoint the app is on (LAN / Public / unreachable) as a small persistent indicator (camera screen or drawer) with a tap-to-re-probe action, so a driver or tester can see and fix "wrong/stale endpoint" in the field without reading logs — a stale tunnel is the documented first failure point for login/upload (root CLAUDE.md). Re-probe reuses 5.1's resolution path.

  • 5.3 [TODO] Make both endpoint URLs configurable without a code edit. _lanBaseUrl and _ngrokBaseUrl are compile-time consts — the LAN one is regex-patched by start-dev-tunnel.ps1 (which breaks silently if the const is renamed/moved; the script warns but the app still ships the stale IP), the ngrok one requires a manual source edit if the reserved domain ever changes. Add a runtime override (e.g. long-press-hidden settings sheet writing to SharedPreferences, seeded from the compiled defaults) so a field device can be repointed without rebuilding the APK. Keep the script's patch working (or teach it to fail loudly — backend task 4.3 covers verifying the tunnel end). Once backend task 1.6 ships GET /api/v1/health, switch _isBackendReachable's probe to it — probing POST /auth/login with an empty body works but couples reachability to the login route's error shape.

  • 5.4 [TODO] Stop unconditionally logging sensitive data. LogInterceptor(requestBody: true, responseBody: true) (api_client.dart:44-47) logs every full request/response body — including the Authorization: Bearer token and personal data (driver/receiver names, GPS coordinates, DO contents) — via Dio's default print, which is not gated behind kDebugMode and will also run in release builds, exposing tokens and PII to on-device logs (adb logcat) in the field.

  • 5.5 [TODO] Add proactive connectivity-change detection to complement 5.1's reactive failover. There's no OS-level network-change listener (e.g. connectivity_plus) — the app only discovers a stale endpoint after a request already times out. Listening for WiFi↔cellular transitions would let it re-resolve the base URL immediately instead of waiting for the next failed call.

  • 5.6 [TODO] Encrypt the LAN leg of traffic. _lanBaseUrl (app_config.dart:22) is plain http://, meaning the bearer token and all document contents travel unencrypted over the store's local Wi-Fi whenever the LAN endpoint is active — anyone else on that network can passively sniff credentials or DO data. The ngrok leg is already HTTPS; the LAN leg has no equivalent protection.

6. Flutter — API Contract & Sync Integrity (DO flow)

lib/features/documents/, lib/models/document_model.dart, lib/core/network/

Added 2026-07-10 via a user-directed e run auditing the full frontend↔backend request/response contract. Read docs/api-contract-map.md first — it maps every Flutter call site to its backend route, documents both envelopes and the DO document lifecycle, and defines the gap IDs (G1-G10) cited below. Backend counterparts live in backend/plans/next-enhancements.md §9 and generally must ship first.

  • 6.1 [DONE 2026-07-10] Poll a single document with a real parse status instead of scanning the whole list (G1, G10 client half; unblocked once backend 9.1 shipped GET /api/v1/documents/:id). _pollUntilParsed (pending_documents_provider.dart) now calls GET /documents/:id every 2s for the specific pending item's own id instead of fetching and scanning the entire GET /documents list — one row + one ocr_items query per poll, flat regardless of history size, instead of the old N+1 across the whole list. Added a parseStatus field to DocumentModel (nullable, populated only by this endpoint) and extracted the branching decision into a new pure function, resolvePollOutcome() in lib/features/documents/poll_outcome.dart (no Flutter/network imports, mirroring task 6.2's document_sync_merge.dart / task 7.1's product_scan_response_parser.dart pattern): parseStatus: "done" -> success with the fetched document; "failed" -> immediate error with an explicit message instead of waiting out the full 260s timeout; "pending" or absent (legacy/cached response) -> keep polling. Deleted the old object-identity match trick (found != doc). Tests: test/poll_outcome_test.dart (4 cases: done/failed/pending/legacy-null). Verified live against the running backend: logged in as a real store account, confirmed GET /api/v1/documents/:id for a real document returns exactly the shape DocumentModel.fromJson/resolvePollOutcome expect (parseStatus, docType, full header/shipment/items), and that a nonexistent id 404s (handled by the existing catch, polling continues, same as before). See docs/feature-list.md.

  • 6.2 [DONE 2026-07-10] Stop wiping locally-saved-but-unsynced documents on refresh (G6). Acceptance (resolved without a Grill-Me pass — the task's own description already specified the approach): a document with a syncFailed pending entry keeps its corrected local version in the history list — visibly flagged, not silently reverted to the server's stale pre-edit copy or dropped. Implemented via a pure, unit-tested merge function, mergeDocumentsWithUnsyncedOverrides() in the new lib/features/documents/document_sync_merge.dart (deliberately a plain-Dart file with no Flutter/network imports, so the merge rule itself is testable without mocking Dio): for each server-fetched document, a matching pendingDocumentsProvider entry with status == syncFailed overrides it with the locally-corrected copy (and is kept even if the server list omits that id entirely). documents_screen._loadDocuments now persists the merged list instead of the raw server list, and tracks which ids were overridden in _unsyncedDocIds. DocumentCard gained an isUnsynced param that swaps its previously-hardcoded "Terkonfirmasi" badge for "Belum Tersinkron" (deepOrange) when set. Tests: test/document_sync_merge_test.dart (3 cases: override wins, no-op passthrough, local-only doc kept though absent server-side), test/document_card_unsynced_badge_test.dart (2 cases: default vs. flagged badge). See docs/feature-list.md.

  • 6.3 [DONE 2026-07-10] Never PUT to a client-generated ID (G5).

    • Acceptance (resolved via Grill-Me): when no resolved server document is available, auto-recover by re-uploading the pending item's local image to get a real server-assigned id (safe: server dedups by file_hash), then PUT the corrected fields to that real id — rather than blocking outright. G8 (moving pendingId off GoRoute's state.extra) is explicitly kept out of scope for this pass; it stays open as its own future task.
    • Both editors previously built finalDoc.id = _document?.id ?? widget.pendingId ?? now-ms (editor_screen.dart save, product_editor_logic.dart); when _document was null the PUT targeted /documents/<13-digit-timestamp>, which can never match the int4 documents.id — the item looped in syncFailed forever with no path to recovery.
    • Extracted the guard into a new pure decision function, resolveDocumentSaveAction() in lib/features/editor/document_save_action.dart (no Flutter/network imports, same pattern as task 6.1's poll_outcome.dart): a resolved existingDocument -> PUT to its real id (unchanged happy path); no document but a local image path -> reuploadThenPut (re-upload via the same multipart pattern used elsewhere, then PUT); no document and no local image -> blocked with an explicit user-facing message instead of silently fabricating an id. Both editor_screen.dart's _submitDocument() and product_editor_logic.dart's _submit() now call this function and branch on its result identically.
    • §3 file-size compliance: touching editor_screen.dart (381 lines) and product_editor_logic.dart (296 lines) put both over the 256-line threshold, so both were split as part of this change (AGENTS.md §3 binds touched files, not just new ones). editor_screen.dart was split into a widget-only file (150 lines) plus a new editor_logic.dart mixin (238 lines, EditorLogic on ConsumerState<EditorScreen>), mirroring the part-file pattern the product editor already used. product_editor_logic.dart itself was replaced by two smaller part files along the same seam it already had internally (data-loading vs. submit): product_editor_data_logic.dart (171 lines, ProductEditorDataLogic) and product_editor_submit_logic.dart (128 lines, ProductEditorSubmitLogic on ProductEditorDataLogic).
    • Tests: test/document_save_action_test.dart (3 cases: putExisting, reuploadThenPut, blocked). Verified live against the running backend: ran the full recovery sequence by hand (multipart re-upload of a real test image -> real server id returned -> PUT corrected fields to that id -> GET confirms the correction persisted), proving the recovery path is a genuine save, not a dead end. See docs/feature-list.md.
  • 6.4 [TODO] Fix DocumentModel.toJson() silently dropping fields on every local persist. toJson() (document_model.dart:124-153), used for all Hive writes (pending queue and the confirmed documentBox, including documents_screen.dart's clear-and-resave on every refresh), omits confirmed, parseStatus, productScanMatches, and productScanExtractedExpiryDate. Any document reloaded from local cache silently reverts confirmed to its default true and loses task 7.2's stored classification data, defeating both the confirmation gate and the "don't reclassify" optimization specifically in the cached/offline path.

  • 6.5 [TODO] Add an offline/stale-data indicator to the documents list. _loadDocuments()'s network fetch failure (documents_screen.dart:87-89) is swallowed with only a debugPrint — a store on a dead connection sees the same "Dokumen" list with no on-screen indication it might be showing stale cached data rather than the current server state.

  • 6.6 [TODO] Extend document search to item contents. _onSearchChanged() (documents_screen.dart:92-103) filters only on header fields (noDo/noPo/noSo/tanggal/kepadaYth) — a staff member searching for a document by product/SKU name gets no results, even though that's a natural way to look up "that frozen chicken delivery from last week."

7. Flutter — Product Scan Review Flow

lib/features/editor/product_editor_screen.dart + product_editor_logic.dart + widgets/product_*, lib/features/camera/scan_mode_provider.dart

Added 2026-07-10, same contract audit (gap IDs from docs/api-contract-map.md). The product review flow shipped 2026-07-09 works on the happy path but is wired to non-production endpoints and placeholder data.

  • 7.1 [DONE 2026-07-10] Move the product editor onto the authenticated /api/v1/* surface (G2). _fetchClassificationAndSkus now calls GET AppConfig.masterSkusEndpoint (/master/skus) and POST AppConfig.scanProductEndpoint (/scan-product) — both new endpoint constants in app_config.dart — instead of string-hacking the base URL to reach the classic unauthenticated /api/skus//api/scan-pfm dev routes. The Authorization header is attached automatically by ApiClient's existing request interceptor (api_client.dart:32-41), same as every other v1 call site. Also switched the classification call from a base64 JSON body to multipart (FormData/MultipartFile, the same pattern already used for DO uploads in pending_documents_provider.dart) — backend 9.3 was built to prefer this specifically to avoid shipping a multi-MB base64 payload. Extracted the "unwrap the v1 {status,data} envelope" logic into a new pure file, lib/features/editor/product_scan_response_parser.dart (parseSkuMasterList/parseScanProductResponse), mirroring task 6.2's document_sync_merge.dart pattern so the parsing logic is unit-testable without mocking Dio. Tests: test/product_scan_response_parser_test.dart (6 cases covering both envelope shapes, empty lists, and missing ocr). Verified live against the running backend stack: a real store account's token succeeds against GET /api/v1/master/skus (232 real SKUs, matching envelope shape) and POST /api/v1/scan-product (multipart, real classification + top-5 matches) — see backend docs/iteration-log.md's task 9.3 entry for the matching server-side verification. Full flutter test suite (36 tests) and flutter analyze lib clean, no regressions. See docs/feature-list.md.

  • 7.2 [DONE 2026-07-10] Classify each product photo once, not twice (G3). Resolved via user feedback ("kenapa harus dilakukan dua kali... GPU tidak 2x kerja") after noticing Product Scan's editor took visibly longer to open than DO Scan's -- decision matched this task's own "consume the stored parse result" option (the other, "skip classification at upload," was rejected: the user explicitly wants Product Scan's editor to open the same way DO's does -- instantly, from already-complete data).

    • Backend counterpart (see backend/plans/next-enhancements.md §11): api/parse/route.ts's Product branch now calls the same shared classifyAndMatchProduct() util POST /api/v1/scan-product already used (task 9.3), instead of its own poorer inline classify call that only kept top1_name/extracted_sku. The full result -- top-5 possibleMatches and OCR extractedExpiryDate -- is now persisted in documents.metadata.productScan (JSONB, no migration) and surfaced by document-mapper.ts on every GET response.
    • lib/models/document_model.dart gained productScanMatches/ productScanExtractedExpiryDate, parsed from the new productScan key (empty defaults for DO documents or pre-fix Product documents).
    • product_editor_data_logic.dart's _fetchClassificationAndSkus() no longer re-uploads the image to /scan-product at all -- it reads _document.productScanMatches/productScanExtractedExpiryDate synchronously (mirrors EditorScreen._loadDocumentData()'s instant local-state read exactly), and only falls back to a cheap GET /master/skus (a plain DB read, no GPU) when the document has zero stored matches (e.g. a pre-fix document, or nothing scored above the match threshold). _loading no longer starts true -- no spinner on the happy path.
    • Tests: test/document_product_scan_field_test.dart (3 cases, the new DocumentModel fields), test/product_editor_no_double_classify_test.dart (proves a document with stored matches renders immediately with no network call -- seeds a raw pending-queue JSON blob with productScan already populated and asserts the SKU/confidence UI appears without hitting the sandboxed-test-network 400 path that would fire if a second classify call were attempted). test/product_editor_classification_failure_test.dart (pre-existing) continues to pass unchanged -- a pendingId: null document has no stored matches, so it now exercises the fallback path instead of the old always-on classify path, hitting the same sandboxed 400 and showing the same retry state. Full suite 63/63 pass, flutter analyze clean.
    • Verified live against the running backend: uploaded a genuinely fresh image/store combination as Product Scan -- took 9s (one real GPU classify+match pass, confirmed not a dedup hit via the response's "Document uploaded successfully" message) -- then GET /documents/:id immediately returned 5 real possibleMatches and the extracted expiry date, before any editor interaction. See docs/iteration-log.md.
  • 7.3 [DONE 2026-07-10] Replace magic-string typing and silent mock data (G4 client half + G7). Split into two independent halves at pickup (per the Grill-Me step — the two halves have different blockers):

    • G7 half — DONE 2026-07-10, no backend dependency. The editor silently fabricated data in three places, all presented as if it were real AI/OCR output: three hardcoded SKU "matches" with fake confidences on any total fetch failure (old product_editor_logic.dart:116-126); fake batch dates '15/12/2026'/'20/04/2027' whenever OCR extracted no expiry (old line 103); and — found during this pass, same bug class, same screen — ProductExpiryCard's "OCR Confidence Score" was a literal hardcoded 92.4%, unconditional, not derived from any real signal at all (there is none - classify_ocr_server.py's OCR result has no confidence field for the expiry extraction). Fixed by: distinguishing "SKU master list itself failed to load" (the one truly-blocking failure, now surfaced as an explicit "Gagal Memuat Klasifikasi Produk" + Coba Lagi retry state, ProductEditorScreen._buildFailureState()) from "classification call failed but the master list loaded fine" (now degrades to manual SKU selection from the master list, flagged via new _hasAutoMatch, with the fake confidence UI replaced by an honest "Tidak ada rekomendasi otomatis" notice in ProductDropdownCard); no expiry match no longer seeds fake dates, instead forcing _isManualDate = true (_updateBatchOptions()); and ProductExpiryCard's fake 92.4% was replaced with a real, honest label ("Tanggal terdeteksi otomatis dari OCR" vs "Tanggal diinput manual") since there's no real confidence value to show. Tests: test/product_dropdown_card_test.dart, test/product_expiry_card_test.dart, test/product_editor_classification_failure_test.dart. See docs/feature-list.md.
    • G4 half — DONE 2026-07-10, unblocked by backend 9.1 shipping (verified against the actual code, not assumed - backend/pfm-web-app/src/utils/ document-mapper.ts's mapDocumentRow() now returns docType/ parseStatus, and all three v1 document routes use it). Added a real docType field to DocumentModel (lib/models/document_model.dart), read from the backend's docType in fromJson, falling back to the legacy orderUntuk == 'PRODUCT SCAN' sentinel only for responses/cached Hive rows that predate the column (mirrors the backend's own fallback). Replaced every orderUntuk == 'PRODUCT SCAN' type check with docType == 'Product': document_card.dart, documents_screen.dart (tab filter), pdf_service.dart (receipt layout), product_editor_logic.dart (_loadDoDocs PO-candidate filter, and the product-scan _submit() now explicitly sets docType: 'Product' instead of relying on the orderUntuk display string alone). Editing the orderUntuk display field can no longer move a document between tabs. Test: test/document_doctype_test.dart (3 fromJson cases + 1 widget regression test specifically reproducing the old bug's trigger - a doc with docType: 'Product' but an edited, non-matching orderUntuk still renders as Product). See docs/feature-list.md.
  • 7.4 [TODO] Stop submitting hardcoded placeholder fields as real data. _submit() (product_editor_submit_logic.dart:76,81) sends noSo: '1002003004' and platTruk: 'B 1234 PFM' as literal constants on every Product Scan document — these make sense for a real DO but are meaningless here, yet they're persisted server-side as if real. Either omit/null them for docType: 'Product' or replace with fields that actually apply to a product scan.

  • 7.5 [TODO] Fix the store-name fallback that can misattribute data to the wrong store. currentStoreName (product_editor_submit_logic.dart:13) falls back to the hardcoded literal 'PM KELAPA DUA KARAWACI' (a real store name) if the cached nama_toko preference is ever missing — silently attributing a scan to a different physical store instead of surfacing an error or blocking submission.

  • 7.6 [TODO] Guard _submit() against double-tap. product_editor_screen.dart:101 wires onPressed: _submit directly with no in-flight/loading guard — unlike other async actions in the app, nothing disables the button while a submission (including the re-upload path) is still running, so a fast double-tap can fire two concurrent submissions.

8. Flutter — Scan Mode UX & Confirmation Gate

lib/features/camera/scan_mode_provider.dart, lib/features/documents/, lib/config/app_config.dart, lib/models/document_model.dart

Added 2026-07-10 from user testing feedback on the release APK (twinkly-riding-mitten.md). Three issues found: (1) scanModeProvider and DocumentsScreen._selectedTab are independent, causing mode desync across screens; (2) documents appear in history before the user taps "Simpan & Konfirmasi" because parsed=true is set at OCR completion not at user confirmation; (3) Product Scan stores fabricated PO/SO/DO placeholder values. Backend §10.1/§10.2 shipped 2026-07-10, unblocking 8.2. See docs/api-contract-map.md G11, G12 for root-cause documentation. §8 is now fully [DONE].

  • 8.1 [DONE] Global scan-mode state + DO/Product color cue. Make scanModeProvider the single source of truth bidirectionally: remove _selectedTab from DocumentsScreen entirely (replace reads with ref.watch(scanModeProvider), replace writes with ref.read(scanModeProvider.notifier).state = tab). Formalize the existing ad-hoc Colors.orange.shade700 in document_card.dart:28 as AppConfig.doModeColor, then apply it as the active-state color in DocumentsTabSwitcher._buildTabItem (DO tab) and in the camera drawer's DO segment decoration (was primaryColor for both segments). Product stays AppConfig.primaryColor. Scope: Flutter-only, no backend dependency.

    • Tests: (a) widget test that tapping "Product Scan" tab updates scanModeProvider (read via ProviderContainer, not local widget state); (b) widget test that tab switcher active color = doModeColor when 'DO', primaryColor when 'Product'; (c) widget test camera drawer DO segment decoration = doModeColor when provider = 'DO'.
    • Follow-up (same day): user clarified the icon should carry the mode color too, not just the button/text, while explicitly leaving default chrome (tooltips, etc.) untouched. Added Icons.description/ Icons.inventory_2 to DocumentsTabSwitcher (matching CameraDrawerModeToggle's existing vocabulary), colored identically to the tab's text. Tests added to test/scan_mode_color_test.dart (3 new cases: DO active icon color, Product active icon color, inactive icon stays neutral gray).
  • 8.2 [DONE 2026-07-10] Flutter half of confirmation-gated document visibility. Backend §10.1 shipped first (adds confirmed column + filters list to confirmed = true). Added optional bool confirmed (default true) to DocumentModel, read from json['confirmed'] with the same default-true fallback pattern as docType/parseStatus — so a legacy/cached response without the field behaves as before. No other Flutter changes needed, verified rather than assumed: mergeDocumentsWithUnsyncedOverrides() (task 6.2) already keeps syncFailed local copies visible even when the server list omits them (which it now legitimately will for unconfirmed docs), and pending_documents_provider.dart's "Tertunda & Diproses" section already shows in-flight items from local state regardless of server confirm status.

    • Tests: test/document_confirmed_field_test.dart (3 cases: reads a real confirmed: false from JSON, defaults to true when the key is absent, defaults to true via the plain constructor too) — same shape as test/document_doctype_test.dart. Full suite 58/58 pass, flutter analyze clean. Live-verified against the real backend response shape (see backend docs/iteration-log.md's task 10.1/10.2 entry).
  • 8.3 [TODO] Persist the active scan mode across app restarts. scanModeProvider (scan_mode_provider.dart:3) is a plain in-memory StateProvider<String>((ref) => 'DO') — every cold launch resets to 'DO' regardless of which mode the staff was last using, even for a store role that primarily does Product Scans.

  • 8.4 [TODO] Add a confirmation step before deleting a pending document. Both the error and syncFailed bottom sheets in PendingDocumentCard (pending_document_card.dart:76-83,102-109) wire "Hapus Dokumen" straight to removeDocument() with no confirm dialog — unlike the 1.3 logout guard, a single mistap permanently discards a captured photo and any OCR/edit progress with no undo.

  • 8.5 [TODO] Surface how long a document has been awaiting confirmation. A success-status pending item (OCR done, waiting for the user to tap through and confirm) has no staleness/aging indicator — it can sit unconfirmed indefinitely with no on-screen reminder, risking goods being shelved before the DO is ever formally confirmed.

9. Flutter — Stocks Menu & DO-to-Stock Flow

lib/features/stock/ (new), lib/features/editor/editor_logic.dart, lib/features/editor/product_editor_*, lib/core/router/app_router.dart, lib/features/camera/camera_drawer.dart

Added 2026-07-10 from a user-directed, extensively grilled ad-hoc feature request (not an e/enhance section — see AGENTS.md §7). Read docs/stock-feature-plan.md first — it is the full design doc (schema, API contracts, screen/file layout, sequencing, testing plan) for this section and for backend counterpart backend/plans/next-enhancements.md §12. Status: planned, not yet implemented — no code for this feature exists in the codebase yet.

  • 9.1 [TODO] Stock data layer. New lib/models/stock_model.dart (StockBatch/StockItem), lib/features/stock/stock_response_parser.dart, stock_batch_sort.dart, quantity_parse.dart (pure functions, unit tested), stock_provider.dart (StateNotifier per pending_documents_provider.dart convention), new stockEndpoint in app_config.dart, new stockEntryBoxName Hive box in local_storage.dart. Blocked on backend §12.1 shipping the /api/v1/stock/* routes this layer calls.
  • 9.2 [TODO] Stock-entry screen + DO-confirm trigger. New lib/features/stock/stock_entry_screen.dart (+ stock_entry_logic.dart/ stock_entry_submit_logic.dart part files, widgets) — the mandatory-by-default screen auto-navigated to right after a DO's PUT /api/v1/documents/:id succeeds (editor_logic.dart's _submitDocument()), pre-filling each item with 1 batch at its confirmed quantity, "+ Tambah Batch" to split, "Isi Nanti" to defer (Hive-persisted, resumable). New pure lib/features/editor/document_submit_navigation.dart (mirrors resolveDocumentSaveAction/resolvePollOutcome) so editor_logic.dart stays under the 256-line threshold. New /stock-entry route in app_router.dart. Blocked on 9.1.
  • 9.3 [TODO] Stocks menu (list/detail) + drawer entry. New lib/features/stock/stocks_screen.dart (searchable SKU list + "Perlu diisi" resumable-draft banner) and stock_detail_screen.dart (batch list, soonest-expiry-first, red "Kadaluwarsa" flag, manual add/edit batch actions). New "Stok" item in camera_drawer.dart, /stocks//stock-detail routes in app_router.dart. Blocked on 9.1.
  • 9.4 [TODO] Product Scan rewrite onto real stock batches. Replace product_editor_data_logic.dart's fake _skuBatches (a single OCR-echoed date string) with real per-store batch data from 9.1's provider; SKU candidates restricted to in-stock SKUs (backend §12.2 filter); closest-to- OCR-expiry batch auto-selected; depleted batches shown with a warning, not hidden. product_editor_submit_logic.dart sends the selected batch id (stock_batch_id) on PUT so backend §12.2's decrement hook fires. product_expiry_card.dart prop-shape rewrite (breaking change — update test/product_expiry_card_test.dart, don't leave it stale). New selectedStockBatchId field on DocumentModel. Blocked on 9.1 and backend §12.2.
  • 9.5 [TODO] Define draft-recovery behavior for a killed app mid-stock-entry. The plan covers explicit "Isi Nanti" (finish-later) resumability, but not what happens if the app is killed while the stock-entry form is open, before that button is tapped — the same class of loss already fixed for the pending-upload queue (task 3.1). Specify whether the form autosaves per-field to Hive or only on an explicit exit action, so typed batch/quantity data isn't silently lost.
  • 9.6 [TODO] Resolve the SKU master staleness risk before it hits stock intake. Since the DO editor currently validates SKUs against a static bundled lib/data/master_sku.dart map instead of the live /api/v1/master/skus endpoint (task 4.5), a SKU that exists in the backend's sku_master table but not yet in the bundled copy would fail client-side validation during DO entry — blocking that item from ever reaching stock intake even though the backend would accept it. Fix 4.5 before or alongside 9.1.
  • 9.7 [TODO] Require a reason for manual, DO-unlinked stock adjustments. stock_movements.note (init-stock.ts's schema) is an optional, nullable VARCHAR(500) — DO-triggered movements are always tied to a real document_id, but a manual "Tambah Batch" (manual_seed/adjustment, no DO link) can be submitted with no justification at all, leaving no mandatory audit trail for a fabricated or corrected stock quantity.

Suggested order: 9.1 → 9.2/9.3 (independent of each other once 9.1 lands) → 9.4 (needs backend §12.2's decrement hook, not just §12.1's CRUD). 9.5-9.7 are design-completeness fixes to fold into 9.1-9.4's implementation, not a separate pass.

10. Cashier Fast Path — Automated Expiry Resolution

lib/features/editor/ (product editor), lib/features/documents/, lib/features/camera/

Added 2026-07-16 from a user-directed grilling session (ad-hoc feature per AGENTS.md §7). Read docs/expiry-tracking-plan.md first — full design and confirmed decisions (checkout must be fully automated: zero typing, zero blocking prompts; unresolvable scans auto-record the FEFO batch with an inferred flag). Consumes backend §13's resolution (resolvedBatch/source/score inside productScan). Blocked on 9.1–9.4 and backend §13.2.

  • 10.1 [TODO] One-tap (or zero-tap) confirm at the cashier. The Product Scan editor pre-selects backend §13's resolved batch; when source is single_batch/matched_exact/matched_fragment, the flow collapses to a single confirm tap with the resolved expiry shown prominently — grill at pickup whether to go full auto-confirm (no tap) for high-confidence resolutions. The §9.4 batch dropdown remains as the manual-override path only (override ⇒ backend records expiry_source = 'manual').
  • 10.2 [TODO] inferred badge + end-of-day review list. Sales resolved as inferred_fefo show a small amber "perkiraan" badge in the editor and in the history/documents list — informational only, never a blocking prompt (confirmed decision). New review entry point (drawer or History filter) listing flagged sales via backend §13.2's ?expiry_source=inferred_fefo filter, so staff can optionally correct them after hours with the packs in hand — zero checkout impact by design.
  • 10.3 [TODO] Phase 2 — burst capture mode for a mounted camera. Camera layer gains a burst mode (N frames over ~1s) intended for a fixed-mounted phone at the checkout counter; frames upload together and backend §13.3 unions OCR evidence across them. Includes a settings toggle (hand-held single-shot vs mounted burst). Blocked on backend §13.3.

Suggested order: 10.1 → 10.2 (10.1's source plumbing feeds 10.2's badge); 10.3 only after Phase-1 field measurement says fragment quality is the bottleneck.


Sections 1-10 (Flutter) are the only sections this file tracks. Backend enhancements (formerly sections 5-8 here, removed 2026-07-08) now live exclusively in backend/plans/next-enhancements.md; that file's §9 holds the backend counterparts to this file's §6-7, §10 holds the backend counterparts to this file's §8, §12 holds the backend counterparts to this file's §9, and §13 holds the backend counterparts to this file's §10 (see docs/api-contract-map.md, docs/stock-feature-plan.md, and docs/expiry-tracking-plan.md for the shared design docs).