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:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5660 -1097

No files matched your search

+8 -4
View File
@@ -122,8 +122,9 @@ no clear "done" condition). If so:
(memory/CPU) assumptions per `SKILLS.md`. (memory/CPU) assumptions per `SKILLS.md`.
- In your final response, state which task(s) were completed and the exact menu/navigation path to see the new feature. - In your final response, state which task(s) were completed and the exact menu/navigation path to see the new feature.
## 3. File Size & Refactoring Rules ## 3. File Size, Refactoring & SOLID Rules
- **SOLID Principles**: Always design, implement, and refactor code adhering to SOLID programming principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion). This ensures code is modular, testable, and maintainable.
- **256-line threshold**: any code/script file — new, modified, or pre-existing — - **256-line threshold**: any code/script file — new, modified, or pre-existing —
that exceeds 256 lines of code must be split into smaller, modular, logical files. that exceeds 256 lines of code must be split into smaller, modular, logical files.
This is a repo-wide rule, not just for new work; if you touch a file over the This is a repo-wide rule, not just for new work; if you touch a file over the
@@ -190,9 +191,12 @@ Recorded 2026-07-08, from adopting this kit into the existing `app-pfm-ocr-v2` r
only if/when touched going forward), **Flutter only** now that `backend/` is out of only if/when touched going forward), **Flutter only** now that `backend/` is out of
scope for this kit (see §Scope above; backend's own debt list, re-verified scope for this kit (see §Scope above; backend's own debt list, re-verified
2026-07-08, lives in `backend/AGENTS.md`'s Adaptation Notes instead): 2026-07-08, lives in `backend/AGENTS.md`'s Adaptation Notes instead):
`lib/features/editor/editor_screen.dart` (682), `lib/features/documents/documents_screen.dart` `lib/features/camera/image_preview_screen.dart` (380), `lib/features/camera/camera_screen.dart`
(442), `lib/features/camera/image_preview_screen.dart` (380), `lib/features/camera/camera_screen.dart` (376). (`editor_screen.dart` was split 2026-07-10 when task 6.3 touched it —
(376), `lib/features/documents/pending_documents_provider.dart` (271). see root `plans/next-enhancements.md` §6.3 and `docs/iteration-log.md`;
`documents_screen.dart` and `pending_documents_provider.dart` were already
split down by earlier iterations and are no longer over threshold — this
line was stale.)
- **No Demo/Live or Cloud/Local switch exists yet** (§5, §6). The closest existing - **No Demo/Live or Cloud/Local switch exists yet** (§5, §6). The closest existing
analogue is `AppConfig.initializeApiBaseUrl()` in `lib/config/app_config.dart`, analogue is `AppConfig.initializeApiBaseUrl()` in `lib/config/app_config.dart`,
which dynamically resolves a *real* backend endpoint (ngrok tunnel, falling back to which dynamically resolves a *real* backend endpoint (ngrok tunnel, falling back to
+1
View File
@@ -70,6 +70,7 @@ We use the karpathy-guidelines skill to reduce common LLM coding mistakes:
2. **Simplicity First**: Write the minimum amount of code to solve the problem with zero speculative configurations. 2. **Simplicity First**: Write the minimum amount of code to solve the problem with zero speculative configurations.
3. **Surgical Changes**: Edit only what is required and match the existing coding style exactly. 3. **Surgical Changes**: Edit only what is required and match the existing coding style exactly.
4. **Goal-Driven Execution**: Define verifiable success criteria and run automated tests/screenshots to confirm correctness. 4. **Goal-Driven Execution**: Define verifiable success criteria and run automated tests/screenshots to confirm correctness.
5. **SOLID Principles**: Always design, implement, and refactor code adhering to SOLID programming principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) to ensure modularity, scalability, and maintainability.
## Path Guidelines (always follow) ## Path Guidelines (always follow)
+55
View File
@@ -1,5 +1,6 @@
import base64 import base64
import io import io
import math
import os import os
import re import re
import traceback import traceback
@@ -471,6 +472,45 @@ async def classify_ocr(payload: ScanRequest):
raw_image = Image.open(io.BytesIO(img_data)) raw_image = Image.open(io.BytesIO(img_data))
image = ImageOps.exif_transpose(raw_image).convert("RGB") image = ImageOps.exif_transpose(raw_image).convert("RGB")
# First-pass PaddleOCR to check orientation based on Expiry Date
rotated_image_used = False
res_list = []
text_lines = []
text_polys = []
expired_date = None
expired_idx = None
expired_source_line = None
if ocr:
try:
img_arr = np.array(image)
res_list = list(ocr.predict(img_arr))
if res_list and len(res_list) > 0:
res_entry = res_list[0]
text_lines = res_entry.get("rec_texts", [])
text_polys = ocr_text_polys(res_entry)
expired_date, expired_idx, expired_source_line = extract_expired_date(text_lines)
if expired_idx is not None and expired_idx < len(text_polys):
poly = text_polys[expired_idx]
if len(poly) >= 2:
p0 = poly[0]
p1 = poly[1]
dx = float(p1[0]) - float(p0[0])
dy = float(p1[1]) - float(p0[1])
angle_rad = math.atan2(dy, dx)
angle_deg = math.degrees(angle_rad)
# Standardize tilt rotation
if abs(angle_deg) > 3.0:
print(f"[Auto-Rotate] Detected Expiry Date text line angle: {angle_deg:.2f} degrees. Rotating image...")
image = image.rotate(angle_deg, resample=Image.BICUBIC, expand=True)
rotated_image_used = True
except Exception as pre_ocr_err:
print(f"Error in pre-pass OCR: {pre_ocr_err}")
traceback.print_exc()
# 1. Run DINOv2 Similarity Search or YOLO Classification # 1. Run DINOv2 Similarity Search or YOLO Classification
classification_result = {} classification_result = {}
top1_name = None top1_name = None
@@ -561,6 +601,7 @@ async def classify_ocr(payload: ScanRequest):
# 2. Run PaddleOCR # 2. Run PaddleOCR
ocr_result = {} ocr_result = {}
if ocr: if ocr:
if rotated_image_used:
img_arr = np.array(image) img_arr = np.array(image)
# Use predict method and convert generator to list # Use predict method and convert generator to list
res_list = list(ocr.predict(img_arr)) res_list = list(ocr.predict(img_arr))
@@ -579,6 +620,14 @@ async def classify_ocr(payload: ScanRequest):
crop_idx = find_expired_crop_index( crop_idx = find_expired_crop_index(
text_lines, expired_idx, expired_date, len(text_polys) text_lines, expired_idx, expired_date, len(text_polys)
) )
else:
sku = extract_sku(text_lines)
product_name = extract_product_name(text_lines, top1_name)
res_entry = res_list[0] if res_list else {}
coord_image = ocr_coordinate_image(res_entry, image)
crop_idx = find_expired_crop_index(
text_lines, expired_idx, expired_date, len(text_polys)
)
# Create visual OCR image with bounding boxes # Create visual OCR image with bounding boxes
vis_image_b64 = None vis_image_b64 = None
@@ -633,7 +682,13 @@ async def classify_ocr(payload: ScanRequest):
# 3. Call Spotting API # 3. Call Spotting API
spotting_image_b64 = None spotting_image_b64 = None
try: try:
if rotated_image_used:
buffered = io.BytesIO()
image.save(buffered, format="JPEG")
img_b64_only = base64.b64encode(buffered.getvalue()).decode("utf-8")
else:
img_b64_only = payload.image_base64.split(",")[-1] img_b64_only = payload.image_base64.split(",")[-1]
spotting_payload = { spotting_payload = {
"file": img_b64_only, "file": img_b64_only,
"matchHistoryJob": False, "matchHistoryJob": False,
+14
View File
@@ -44,12 +44,17 @@ workflow and have no task numbers; see `git log` for real dates/history.
- **1.4** Enforced real 401 auth on `/api/v1/documents/*` (list, PUT-by-id, upload) — the actual production API surface, already fully supported by the Flutter client (real login + `Authorization: Bearer` on every request). Previously none of these three routes rejected a missing/invalid token; upload only optionally read it. Added the pre-existing `getAccountFromAuthHeader()` helper (`utils/auth.ts`) + a 401 guard to all three; `OPTIONS` (CORS preflight) untouched. The original task 1.3 (auth on the *classic* routes) was cancelled instead — those routes are dev-only web UI surface with no login flow, going away in production. Verified via `curl`: 401 with no token, success with a real token from `/api/v1/auth/login` — shipped 2026-07-08. - **1.4** Enforced real 401 auth on `/api/v1/documents/*` (list, PUT-by-id, upload) — the actual production API surface, already fully supported by the Flutter client (real login + `Authorization: Bearer` on every request). Previously none of these three routes rejected a missing/invalid token; upload only optionally read it. Added the pre-existing `getAccountFromAuthHeader()` helper (`utils/auth.ts`) + a 401 guard to all three; `OPTIONS` (CORS preflight) untouched. The original task 1.3 (auth on the *classic* routes) was cancelled instead — those routes are dev-only web UI surface with no login flow, going away in production. Verified via `curl`: 401 with no token, success with a real token from `/api/v1/auth/login` — shipped 2026-07-08.
- **1.5** Implemented per-store data scoping on `/api/v1/documents/*`. Added `kode_toko` column to `documents` table via `db/init.ts` migration. The upload route now binds `kode_toko` to documents upon creation. `GET /api/v1/documents` and `PUT /api/v1/documents/:id` enforce ownership checks (`kode_toko` matching) for `store` role accounts, while `admin` retains global access including legacy unassigned documents — shipped 2026-07-08. - **1.5** Implemented per-store data scoping on `/api/v1/documents/*`. Added `kode_toko` column to `documents` table via `db/init.ts` migration. The upload route now binds `kode_toko` to documents upon creation. `GET /api/v1/documents` and `PUT /api/v1/documents/:id` enforce ownership checks (`kode_toko` matching) for `store` role accounts, while `admin` retains global access including legacy unassigned documents — shipped 2026-07-08.
- **1.6** `GET /api/v1/health` Endpoint: Unauthenticated health probe verifying both PostgreSQL connectivity and Pipeline API HTTP reachability. Returns `HTTP 503` if any core dependency is down — shipped 2026-07-08. - **1.6** `GET /api/v1/health` Endpoint: Unauthenticated health probe verifying both PostgreSQL connectivity and Pipeline API HTTP reachability. Returns `HTTP 503` if any core dependency is down — shipped 2026-07-08.
- **Ad-hoc** Connected `/scan-pfm` page with `/api/scan-pfm` route and enabled auto-trigger scanning on custom file upload, sample selection, thumbnail change, and canvas rotation. Supported both `image` and `image_base64` payload keys — shipped 2026-07-09.
- **9.1** Added `GET /api/v1/documents/:id` (same 401/403 scoping as `PUT`), returning a single document — including still-unparsed rows — with a new `parseStatus: "pending"|"done"|"failed"` field, so the Flutter poller can move off scanning the entire list every 2s. Added `scan_mode`/`parse_error` columns to `documents` (`db/init.ts`, migrated via `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for already-running DBs). `scan_mode` is now persisted on upload (`v1/documents/upload/route.ts`) and on the classic `/api/parse` route's upserts (`COALESCE`, same pattern as `kode_toko`), and surfaced as `docType` on every GET response (`utils/document-mapper.ts`, a new shared helper extracted from the list route's inline mapping so list/by-id/dedup all agree) — falling back to the legacy `order_untuk == "PRODUCT SCAN"` sentinel for pre-existing rows with no `scan_mode`. `parse_error` is now recorded when the upload route's *internal* call to `/api/parse` itself fails to complete (network error or the 210s abort firing) — previously this was silently swallowed and the document stayed `parsed=false` forever with no signal, burning the client's full 260s timeout; `/api/parse`'s own existing pipeline-error fallback (`parsed=true` + "Not Found" placeholder) was already fine and is unchanged. Also fixed the dedup branch (a repeat upload of an already-seen file) to return the original document's real current state via the same mapper instead of a hardcoded empty stub. Verified via `docker compose up -d --build` + `curl`: schema migration applied cleanly to the live DB (confirmed via `psql`), DO and Product uploads both correctly persist `scan_mode` and surface it as `docType`, a dedup retry returns real header/items instead of an empty stub, `GET /:id` returns 401 (no token) / 403 (wrong store) / 404 (nonexistent id) / 200 (admin or owning store), and the list endpoint's existing filter/scoping is unchanged — shipped 2026-07-10.
- **9.3** Added authenticated `POST /api/v1/scan-product`, the v1 equivalent of the classic dev-only `/api/scan-pfm` (unauthenticated, and unreachable off-LAN since task 4.5 restricted the public tunnel to `/api/v1/*`). Extracted the shared classify-and-match logic (Python classifier call + Levenshtein SKU matching against `sku_master`, top-5 scoring) out of `api/scan-pfm/route.ts` into a new `utils/product-scan.ts` (`classifyAndMatchProduct`, plus a `ClassifierError` class that preserves forwarding the classifier's own HTTP status instead of collapsing every failure to 500) so the classic route and the new v1 route share one implementation instead of duplicating it — the classic route's response shape, auth-free behavior, and desktop-only layout-parsing visualization are otherwise unchanged. The new route accepts **either** multipart (`image`/`file` field, matching the v1 upload route's convention) or a JSON `{image_base64}` body, is open to any authenticated account (not admin-gated, since this is what the mobile app itself calls), and wraps the result in the standard `{status, data}` envelope with `classification`, `ocr` (including `extracted_expired_date`), and `possibleMatches`. Verified via `curl` against the live stack with a real product photo: multipart upload and JSON-body variants both return identical, correct top-5 matches; no-token request returns 401; the classic `/api/scan-pfm` route's response (including `layoutParsingResult`) is unchanged post-refactor — shipped 2026-07-10.
- **9.2** Relaxed `GET /api/v1/master/skus` (`master/skus/route.ts`) so any authenticated account can read the SKU master list, not just `admin` — the Flutter product editor needs this and previously had to string-hack its base URL to call the unauthenticated classic `GET /api/skus`, which task 4.5 had already removed from the public tunnel, breaking product scans off-LAN. Changed the guard from a combined `!account || role !== 'admin'` check (403 for both "no token" and "wrong role") to `!account` (correct 401) followed by an unconditional pass-through for any valid account; `POST` (SKU creation) is untouched, still admin-only, per the user's explicit choice between the two options this task flagged as undecided. No response-shape change. Verified via `curl` against the live stack with a real non-admin (`store` role) account's token: `GET` → 200 with real data; no token → 401 (was incorrectly 403 before this fix); the same non-admin token against `POST` → still 403; admin `GET` → still 200. Along the way, hit and resolved a dev-loop issue: the container had the edited file on disk but Turbopack's file watcher wasn't detecting the change over the Windows bind mount, requiring `docker restart paddleocr-pfm-web-app` to pick it up — noted in case it recurs for future edits. With 9.1-9.3 all shipped, Flutter root task 7.1 (moving the product editor onto the v1 surface) is now fully unblocked — shipped 2026-07-10.
## Backend — OCR Pipeline & Accuracy ## Backend — OCR Pipeline & Accuracy
- **2.1** Built the Product/SKU scan classifier's model artifacts: `models/dinov2_index.pkl` (118/118 reference photos indexed across 16 SKU classes) and `models/produk-pfm-classifier-26n-100e-2026-07-08.pt` (+ `.onnx` export) — a YOLO classifier fine-tuned 100 epochs, 83.3% top-1 / 90% top-5 validation accuracy on the current (thin, 2-16 photos/class) dataset. Built via a one-off `docker run` from a freshly-rebuilt `pipeline-api` image (bare-metal training isn't viable on Windows — `paddlepaddle-gpu`'s wheel index is Linux-only). `pipeline-api` restarted and confirmed loading both models from logs. Also fixed `scripts/install-pipeline.sh`, which was missing `ultralytics`/`torch` — shipped 2026-07-08. - **2.1** Built the Product/SKU scan classifier's model artifacts: `models/dinov2_index.pkl` (118/118 reference photos indexed across 16 SKU classes) and `models/produk-pfm-classifier-26n-100e-2026-07-08.pt` (+ `.onnx` export) — a YOLO classifier fine-tuned 100 epochs, 83.3% top-1 / 90% top-5 validation accuracy on the current (thin, 2-16 photos/class) dataset. Built via a one-off `docker run` from a freshly-rebuilt `pipeline-api` image (bare-metal training isn't viable on Windows — `paddlepaddle-gpu`'s wheel index is Linux-only). `pipeline-api` restarted and confirmed loading both models from logs. Also fixed `scripts/install-pipeline.sh`, which was missing `ultralytics`/`torch` — shipped 2026-07-08.
- **2.1 (verification pass)** Ran a full browser walkthrough of `/scan-pfm` (classification, top-5, OCR expiry extraction + crop, SKU-master matching, Visual/Spotting Grid, Raw Response — all confirmed working with real data). Found and fixed a real bug: "Save Ground Truth" was returning success but silently writing into the `pfm-web-app` container's ephemeral filesystem instead of the host, because `/sources` wasn't a bind-mounted path in root `docker-compose.yml`. Added `./backend/sources:/sources` to the `pfm-web-app` service, recovered an orphaned entry via `docker cp`, and re-verified the save now persists to `backend/sources/product_manual_labels.json` on the host (confirmed the DO-flow's `manual_labels.json` save was fixed by the same change too) — shipped 2026-07-08. - **2.1 (verification pass)** Ran a full browser walkthrough of `/scan-pfm` (classification, top-5, OCR expiry extraction + crop, SKU-master matching, Visual/Spotting Grid, Raw Response — all confirmed working with real data). Found and fixed a real bug: "Save Ground Truth" was returning success but silently writing into the `pfm-web-app` container's ephemeral filesystem instead of the host, because `/sources` wasn't a bind-mounted path in root `docker-compose.yml`. Added `./backend/sources:/sources` to the `pfm-web-app` service, recovered an orphaned entry via `docker cp`, and re-verified the save now persists to `backend/sources/product_manual_labels.json` on the host (confirmed the DO-flow's `manual_labels.json` save was fixed by the same change too) — shipped 2026-07-08.
- **2.3** Ran the accuracy regression harness and discovered `sources/accuracy_report.md` was badly stale (claimed 75.04%; real current baseline is **95.10% overall, already at/above the 95% target** — added a staleness banner to that file). Root-caused every remaining mismatch by pulling raw OCR text from Postgres (`documents.layout_parsing_result`): the worst field, `plat` (67.6%), is almost entirely the license-plate region being classified as an image/seal by the layout model rather than OCR'd as text — not fixable in `parser.ts`. Found and fixed one genuine parser logic bug along the way: the "global pattern scanning fallback" could duplicate an already-correctly-extracted `noDO` value into a still-missing `noSO` field; fixed by excluding already-assigned values from that fallback's candidate pool (`pfm-web-app/src/utils/parser.ts`). Doesn't change the aggregate score (a wrong value and "Not Found" score the same) but stops a fabricated-looking wrong number from silently reaching the database. All 48 parser unit tests still pass — shipped 2026-07-08. - **2.3** Ran the accuracy regression harness and discovered `sources/accuracy_report.md` was badly stale (claimed 75.04%; real current baseline is **95.10% overall, already at/above the 95% target** — added a staleness banner to that file). Root-caused every remaining mismatch by pulling raw OCR text from Postgres (`documents.layout_parsing_result`): the worst field, `plat` (67.6%), is almost entirely the license-plate region being classified as an image/seal by the layout model rather than OCR'd as text — not fixable in `parser.ts`. Found and fixed one genuine parser logic bug along the way: the "global pattern scanning fallback" could duplicate an already-correctly-extracted `noDO` value into a still-missing `noSO` field; fixed by excluding already-assigned values from that fallback's candidate pool (`pfm-web-app/src/utils/parser.ts`). Doesn't change the aggregate score (a wrong value and "Not Found" score the same) but stops a fabricated-looking wrong number from silently reaching the database. All 48 parser unit tests still pass — shipped 2026-07-08.
- **Ad-hoc** Built custom expiry-date-based auto-rotation algorithm in Python classifier server (`classify_ocr_server.py`). The algorithm calculates the slant angle of the Expiry Date / Batch text line bounding box, automatically rotates the image to make it horizontal, and re-runs YOLO classification + PaddleOCR for maximum accuracy. Enhanced SKU matching database lookup to prioritize exact SKU matches with a score of 1.0, pinning them as the Best Match — shipped 2026-07-09.
## Backend — Postgres Data Layer ## Backend — Postgres Data Layer
@@ -77,3 +82,12 @@ workflow and have no task numbers; see `git log` for real dates/history.
- **7.1** Seeded one account per store in `db/init.ts` during initialization by assigning `username = kode_toko` and a bcrypt-hashed default password `"123"`. Included `role` and `is_active` schema additions — shipped 2026-07-08. - **7.1** Seeded one account per store in `db/init.ts` during initialization by assigning `username = kode_toko` and a bcrypt-hashed default password `"123"`. Included `role` and `is_active` schema additions — shipped 2026-07-08.
- **7.2** Enhanced authentication routing by modifying `POST /api/v1/auth/login` to perform a `LEFT JOIN` on `store_master`, returning the extended store profile alongside the token. Added a guard to reject login if `is_active = false`. Implemented a new `GET /api/v1/auth/me` endpoint to cleanly re-fetch the profile via token — shipped 2026-07-08. - **7.2** Enhanced authentication routing by modifying `POST /api/v1/auth/login` to perform a `LEFT JOIN` on `store_master`, returning the extended store profile alongside the token. Added a guard to reject login if `is_active = false`. Implemented a new `GET /api/v1/auth/me` endpoint to cleanly re-fetch the profile via token — shipped 2026-07-08.
- **7.3** Created a reproducible `store_master` bootstrap logic in `db/init.ts` that reads from `sources/toko_aktif.json` idempotently on startup. Also correctly seeded the `WH_JOFFICE` head office to resolve the admin account foreign-key setup constraint — shipped 2026-07-08. - **7.3** Created a reproducible `store_master` bootstrap logic in `db/init.ts` that reads from `sources/toko_aktif.json` idempotently on startup. Also correctly seeded the `WH_JOFFICE` head office to resolve the admin account foreign-key setup constraint — shipped 2026-07-08.
## Backend — Document Confirmation Gate & Data Hygiene
- **10.1** Added a `confirmed BOOLEAN NOT NULL DEFAULT TRUE` column to `documents` (`db/init.ts`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` — grandfathers every pre-existing row so today's history didn't go empty after migration) and used it to separate "OCR finished" from "user confirmed": previously `GET /api/v1/documents` filtered only on `parsed = true`, which the backend sets synchronously right after upload — before the mobile user ever taps "Simpan & Konfirmasi" in the editor — so a scan captured, previewed, then backed out of (never confirmed) was already sitting in every entitled account's document list with blank/placeholder fields (root cause of `document_card.dart`'s "Staff Toko" fallback text on the Flutter side). `v1/documents/upload/route.ts` now explicitly inserts `confirmed = false` on every new upload; `v1/documents/[id]/route.ts`'s `PUT` handler is the *only* place that flips it to `true` (literally "the user confirmed"); `v1/documents/route.ts` (list) now filters `AND confirmed = true` unconditionally for every account including `admin` (no role special-casing, per explicit user decision); `v1/documents/[id]/route.ts`'s `GET`-by-id handler is deliberately untouched by the new filter so the mobile poller can keep seeing pending/unconfirmed documents mid-flow. `utils/document-mapper.ts`'s shared `DocumentRow`/`mapDocumentRow()` now carries `confirmed` through to all three call sites (list, GET-by-id, upload's dedup-hit branch) from one place. `parse/route.ts`'s own `INSERT ... ON CONFLICT (filename) DO UPDATE` statements (both DO and Product branches) were deliberately left untouched for `confirmed` — in the real mobile flow the upload route's INSERT always runs first, so this upsert always hits the `ON CONFLICT` branch, and since its `SET` clause doesn't mention `confirmed`, Postgres correctly leaves the existing value alone (verified this is correct, not an oversight). Verified live against the running Docker stack: uploaded a real DO photo as a store account without confirming it — absent from that store's list (and from `admin`'s) while `GET /documents/:id` still reported the correct `parseStatus`; `PUT` (confirm) made it appear immediately with the real submitted data; all 13 pre-existing rows carried `confirmed = true` after the migration ran — shipped 2026-07-10.
- **10.2** Removed the fabricated Product Scan placeholder values `noPO: "PO-PRODUCT-001"`, `noSO: "1002003004"`, `noDO: "DO-PRODUCT-999"` (both the flat keys and the mirrored `header.no_po`/`no_so`/`no_do` sub-object) from `parse/route.ts`'s Product-scan branch, replacing them with empty strings — these are DO-specific concepts that don't apply to a product verification scan, and were never actually read by anything: `pdf_service.dart`'s Product receipt branch never prints them, and `product_editor_submit_logic.dart`'s `_submit()` builds its own `noPo`/`noSo`/`noDo` from the user's PO-link dropdown and batch selection, ignoring the stored values entirely. Same class of issue as the earlier G7 fix (fabricated data presented as if real) — low risk to remove since nothing meaningfully depended on the old values. Scope stayed narrow to exactly these three fields; `nama_driver`/`nama_penerima`'s "PRODUCT SCAN"/"STORE STAFF" placeholders were left alone as a deliberate fixed convention, not a fabricated document number. Verified via `curl`: a freshly-uploaded, unconfirmed Product Scan document's raw `GET /documents/:id` response now returns `no_po`/`no_so`/`no_do` as empty strings instead of the old fake values — shipped 2026-07-10.
## Backend — Single-Pass Product Classification
- **11.1** Eliminated the duplicate GPU classification pass on Product Scan (gap G3), sourced from user feedback that the review screen took noticeably longer to open than DO Scan's. `api/parse/route.ts`'s Product branch previously had its own separate, poorer inline classify call (kept only `top1_name`/`extracted_sku`), forcing the Flutter editor to re-run the entire classify+OCR pipeline a second time via `POST /api/v1/scan-product` just to get the top-5 candidate list and OCR-extracted expiry date. Now calls the same shared `classifyAndMatchProduct()` (`utils/product-scan.ts`) already used by that v1 route — one GPU call, richer result — and persists it under a new `metadata.productScan` JSONB key (no schema migration), surfaced by `document-mapper.ts` as a top-level `productScan` field on every GET response. Caught and fixed a real regression along the way: delegating to the shared function silently dropped the 90s pipeline timeout the old inline fetch had; added the same bound (`PIPELINE_TIMEOUT_MS`) directly inside `classifyAndMatchProduct()` so both callers — this route and the live `POST /api/v1/scan-product` (which never had the bound either) — are protected. Verified via `curl` with a genuinely fresh image/store combination (proving a real classify pass, not a dedup hit): took 9s, and the immediate `GET /documents/:id` response already contained 5 real `possibleMatches` and the extracted expiry date, before any editor interaction — shipped 2026-07-10.
+384
View File
@@ -105,3 +105,387 @@ Conduct a code review and audit of the implementations for Tasks 6.1-6.3 (Ground
## 4. Conclusion ## 4. Conclusion
The Product Scan Ground Truth Annotation and Evaluation tools operate perfectly. The system can now durably store base64 test images, manually correct AI anomalies, and automatically evaluate retrained models with historical tracking. Documentation drift has been comprehensively resolved. No regressions were observed. The Product Scan Ground Truth Annotation and Evaluation tools operate perfectly. The system can now durably store base64 test images, manually correct AI anomalies, and automatically evaluate retrained models with historical tracking. Documentation drift has been comprehensively resolved. No regressions were observed.
---
# Iteration Log & Audit: Flutter Client Contract, Server Half (Task 9.1)
## 1. Objective
Conduct a code review and audit of task 9.1 — `GET /api/v1/documents/:id` with an
explicit `parseStatus`, and `scan_mode` persistence surfaced as `docType` — to close
gaps G1/G10/G4 (server half) documented in `docs/api-contract-map.md`.
## 2. Code Review
### 2.1 Schema (`pfm-web-app/src/db/init.ts`)
- New `scan_mode VARCHAR(20)` / `parse_error TEXT` columns added to both the
`CREATE TABLE IF NOT EXISTS` body and an `ALTER TABLE ... ADD COLUMN IF NOT
EXISTS` migration line, matching the exact pattern already used for `kode_toko` —
safe to run against an already-populated production DB without downtime.
### 2.2 Shared mapper (`pfm-web-app/src/utils/document-mapper.ts`, new file)
- Extracted the header/shipment branch-mapping logic (`metadata.header` present vs.
legacy web-parser shape) that previously only lived inline in the list route, so
the new GET-by-id route and the upload route's dedup-response branch can't drift
from the list route's mapping. Computes `parseStatus` from `parsed`/`parse_error`
and `docType` from `scan_mode`, falling back to the legacy `order_untuk ==
"PRODUCT SCAN"` sentinel for rows predating this column — verified via `curl`
against a pre-existing pre-9.1 document that it doesn't regress to `docType:
undefined`.
### 2.3 `GET /api/v1/documents/:id` (`api/v1/documents/[id]/route.ts`)
- Reuses the exact same auth/scoping pattern as the existing `PUT` on the same
file (401 no-account, 404 no-row, 403 non-admin/wrong-store) — no new auth
logic introduced, just the existing helper called a second time.
- Deliberately omits the list route's `parsed = true` filter, since surfacing
pending/failed rows is the entire point of the endpoint.
### 2.4 Failure recording (`api/v1/documents/upload/route.ts`)
- The one gap not already covered by `/api/parse`'s own pre-existing error
fallback (which already flips `parsed=true` with "Not Found" placeholder
metadata, unchanged by this task) is the internal fetch call to `/api/parse`
itself never completing — network error or the pre-existing 210s
`AbortSignal.timeout` firing. Both that `catch` branch and a new `!response.ok`
check now persist a short message to `documents.parse_error`, which is the only
input the new `parseStatus: "failed"` branch depends on.
- Dedup branch fixed to run the existing document through the same shared mapper
instead of a hand-built always-empty stub (G10) — a GPS-tag fallback to the
retry's own coordinates was preserved for documents that never got one on first
upload, matching the previous behavior's intent.
### 2.5 `scan_mode` persistence in `/api/parse` (`api/parse/route.ts`)
- Minimal, additive `COALESCE(EXCLUDED.scan_mode, documents.scan_mode)` in both
`ON CONFLICT` blocks, same pattern already used for `kode_toko` — so documents
created via the classic route (not just the v1 upload path) also get a correct
`scan_mode`. `parse_error = NULL` added to both `SET` clauses to clear a stale
failure once a parse actually completes. This file remains accepted §B3 debt
(604 lines pre-existing, per `backend/AGENTS.md` Adaptation Notes) — touched only
minimally, not restructured, consistent with that note's "split only if/when
touched" guidance being about restructuring, not about refusing small edits.
## 3. Audit Verification
- **Functional testing** (`docker compose up -d --build` from repo root, real
`curl` calls against the live stack, not just unit tests):
- Confirmed `scan_mode`/`parse_error` columns exist post-migration via `psql \d
documents` against the running container — no `ALTER TABLE` errors in logs.
- Logged in as a real store account (`WH_JCIBBR1`), uploaded a real DO test
image (`sources/test-images/do-001.jpg`): `GET /api/v1/documents/:id` returned
the real parsed header/items, `parseStatus: "done"`, `docType: "DO"`.
- Re-uploaded the identical file (dedup path): response now carries the same
real header/items instead of the old empty stub — confirmed G10 fixed.
- Uploaded a real product photo with `scan_mode=Product`: `docType: "Product"`
confirmed both in the GET response and directly in Postgres
(`SELECT scan_mode FROM documents`).
- `GET /:id` with no token → 401; nonexistent id → 404; a *different* store
account's token against another store's document → 403; `admin`'s token
against the same document → 200 (admin bypass intact).
- `GET /api/v1/documents` (list) still returns only parsed, non-sample
documents, now carrying `docType`/`parseStatus` for free via the shared
mapper — existing 401 behavior unchanged.
- `npx tsc --noEmit` clean across the whole `pfm-web-app` project.
## 4. Conclusion
Task 9.1 closes gaps G1 (no per-document GET / N+1 list polling), G10 (dedup stub),
and the server half of G4 (fabricated doc-type sentinel) exactly as scoped. All
new behavior was verified against the live Docker stack with real uploads, not
just a clean build — auth/scoping regressions were explicitly checked and none
were found. Flutter-side consumption (`plans/next-enhancements.md` §6.1/§7.3)
remains open and unblocked by this change.
---
# Iteration Log & Audit: Authenticated v1 Product-Scan Endpoint (Task 9.3)
## 1. Objective
Conduct a code review and audit of task 9.3 — `POST /api/v1/scan-product`, an
authenticated equivalent of the classic dev-only `/api/scan-pfm` — to close gap
G2/G3 (`docs/api-contract-map.md`): the Flutter product editor currently reaches
the classify+match pipeline via an unauthenticated route that task 4.5 already
excluded from the public tunnel, so product scanning is broken off-LAN.
## 2. Code Review
### 2.1 Shared util (`pfm-web-app/src/utils/product-scan.ts`, new file)
- `classifyAndMatchProduct` is a byte-for-byte extraction of the classic route's
classify-call + Levenshtein-SKU-match logic (not a rewrite) — reduces the risk
that the new v1 route's behavior silently diverges from the already-working
classic route's matching quality.
- `ClassifierError` deliberately preserves the classic route's existing behavior
of forwarding the Python classifier's own HTTP status on failure, rather than
letting a generic `catch` collapse every failure to 500 — both the classic and
new v1 route special-case it identically.
- Intentionally excludes the layout-parsing visualization block: that's
desktop-test-page-only per the task's explicit response-field list
(`possibleMatches`, `ocr`, `classification` — no `layoutParsingResult`), so it
correctly stays in `api/scan-pfm/route.ts` rather than being pulled into the
shared util or the new v1 route.
### 2.2 Classic route refactor (`api/scan-pfm/route.ts`)
- Response shape (`{classification, ocr, possibleMatches, layoutParsingResult}`,
no envelope, no auth) is unchanged — this route still serves the desktop test
page exactly as before, now just calling the shared util instead of inlining
the logic. Dropped one genuinely dead variable (`extractedProductName`, computed
but never read in the original code) as part of the extraction.
### 2.3 New v1 route (`api/v1/scan-product/route.ts`)
- Auth: any authenticated account (not admin-gated) — correct, since this is the
route the mobile app's own store-role accounts call to perform a scan, unlike
`master/skus` writes which are intentionally admin-only.
- Dual input handling (multipart primary, JSON base64 fallback) matches the task's
explicit wording ("multipart (preferred...) or base64") and lets Flutter adopt
this endpoint today regardless of which shape task 7.1 ends up sending.
- No `nginx.conf` change was needed — confirmed the port-8001 restricted block's
`location /api/v1/` (line 135) is a prefix match already covering the new path.
## 3. Audit Verification
- **Functional testing** against the live stack (same running containers as task
9.1's session; `pfm-web-app` restarted once to pick up the new route file after
its dev-server file watcher missed the new directory — a known bind-mount
quirk on Windows Docker Desktop, not a code issue):
- `POST /api/v1/scan-product` with a real product photo as multipart `image` +
a real store account's bearer token: `200`, `{status:"success", data:
{classification, ocr, possibleMatches}}` with a correct top-5 match list and
`isBestMatch` on the top entry; `ocr` confirmed to include
`extracted_expired_date`.
- Same call with no token → `401`.
- Same image via a JSON `{image_base64}` body instead of multipart → identical
`possibleMatches` output, confirming both input paths produce the same result.
- Classic `POST /api/scan-pfm` (JSON body, no auth) with the same image →
unchanged response shape and matching results, including
`layoutParsingResult` still present — no regression from the extraction.
- `npx tsc --noEmit` and `npx eslint` on the three touched/new files clean
(aside from pre-existing `any`-for-JSONB-shaped-data style already used
throughout this codebase, e.g. `document-mapper.ts` from task 9.1).
## 4. Conclusion
Task 9.3 closes gap G2/G3 exactly as scoped: the mobile app now has an
authenticated, tunnel-reachable path to the classify+match pipeline that returns
identical results to the already-proven classic route, verified against real
classifier output rather than mocked data. Task 9.2 (non-admin SKU list read)
remains open and separate. Flutter-side consumption (`plans/next-enhancements.md`
§7.1) remains open and is now unblocked by this change (alongside 9.2).
# Iteration Log & Audit: Non-Admin SKU List Read (Task 9.2)
## 1. Objective
Conduct a code review and audit of task 9.2 — read access to the SKU master
list (`GET /api/v1/master/skus`) for any authenticated account, not just
`admin` — closing gap G2 alongside 9.3. Picked up via an explicit
backend-scoped `n{9.2}` request after the user asked to clarify the two
options the task itself flagged as undecided (relax the existing endpoint
vs. add a new one); user chose to relax the existing endpoint.
## 2. Code Review
- `master/skus/route.ts`'s `GET` handler previously rejected any non-`admin`
account with 403, forcing the Flutter product editor to call the
unauthenticated classic `GET /api/skus` instead (the actual bug this task
fixes - that classic route was removed from the public ngrok tunnel by
task 4.5, so product scans off-LAN were already broken before this fix).
- Changed the `GET` guard from `!account || account.role !== 'admin'`
(403 either way) to `!account` (401 for no/invalid token, any valid
account now passes) - a one-line, surgical change matching the user's
chosen option exactly. `POST` (SKU creation) was deliberately left
untouched, still admin-gated - the task's own text specified "writes stay
admin-only," and admin master-data management is a different concern from
a mobile client reading the catalog to populate a dropdown.
- No response-shape change: still `{status: "success", data: res.rows}`,
matching what task 9.2 asked for (the `{status, data}` v1 envelope) and
what the Flutter product editor already expects once it switches over
(root task 7.1, not yet picked up).
## 3. Audit Verification
- Hit a real hot-reload gap during verification: the file was correctly
updated on disk inside the `pfm-web-app` container (confirmed via
`docker exec ... cat`), but the running Turbopack dev server kept serving
the old admin-gated behavior - a known class of issue where Windows-host
bind-mount file-change events don't reliably reach `next dev`'s watcher.
Fixed by `docker restart paddleocr-pfm-web-app`, after which the new code
took effect immediately (confirmed via a fresh `curl` round-trip).
- Verified via `curl` against the live stack (real account credentials
pulled from the live `accounts` table, not fixtures):
- A real non-admin (`store` role) account's token: `GET
/api/v1/master/skus` → `200`, real `sku_master` rows returned.
- No `Authorization` header at all: `401 Unauthorized` (previously this
same case incorrectly returned `403`, since the old guard checked
`!account || role !== 'admin'` as one combined condition - now correctly
distinguishes "no valid account" from "valid but insufficient role").
- The same non-admin token against `POST /api/v1/master/skus` (attempting
to create a SKU): still `403 Forbidden: Admin access required` - writes
unaffected.
- An admin token against `GET /api/v1/master/skus`: still `200` - no
regression for the existing admin master-data UI.
## 4. Conclusion
Task 9.2 closes gap G2's remaining half: the mobile app can now read the
SKU master list through the authenticated, tunnel-reachable `/api/v1/*`
surface without impersonating a dev-only unauthenticated route. Combined
with 9.1 and 9.3 (both already shipped), every backend blocker behind root
`plans/next-enhancements.md` §7.1 (moving the product editor onto the v1
surface) is now cleared - that Flutter task is unblocked and ready to pick
up. §7.2 (eliminating the duplicate classification pass) is separately
unblocked in principle (9.1's `docType`/metadata work + 9.3's endpoint both
exist now) but still needs its own client-side decision about which single
pass to keep, per that task's own grill-me note.
---
## Iteration & Audit: Tasks 10.1/10.2 — Document Confirmation Gate & Data Hygiene (2026-07-10)
### 1. Objective
Close backend §10, sourced from user feedback on the release APK
(`twinkly-riding-mitten.md`, root-cause documented as gaps **G11**/**G12** in
`docs/api-contract-map.md`): documents were visible via `GET /api/v1/documents`
the instant OCR parsing finished, before the mobile user ever confirmed them
via `PUT`, and Product Scan uploads carried fabricated `noPO`/`noSO`/`noDO`
placeholder values.
### 2. Code Review
- **`db/init.ts`**: new `confirmed BOOLEAN NOT NULL DEFAULT TRUE` column, both
in the `CREATE TABLE IF NOT EXISTS` block and as an idempotent
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for already-running DBs, matching
the exact pattern already used for `scan_mode`/`parse_error`. `DEFAULT TRUE`
is a deliberate grandfather clause — every row that existed before this
migration counts as already-confirmed, so existing history doesn't vanish.
- **`v1/documents/upload/route.ts`**: the one real INSERT path for a fresh
mobile capture now explicitly inserts `confirmed = false`; the dedup-hit
branch (no INSERT) is untouched, correctly reflecting whatever state the
original row already has. Its SELECT for the dedup branch was also extended
to fetch `confirmed` so the mapper has it.
- **`utils/document-mapper.ts`**: `DocumentRow` interface and
`mapDocumentRow()`'s return both carry `confirmed` through now, so all three
call sites (list, GET-by-id, upload dedup) stay in sync from one place —
same shared-mapper pattern task 9.1 established.
- **`v1/documents/route.ts`** (list): `AND confirmed = true` added to the
`WHERE` clause with no role branching — applies to `admin` exactly the same
as `store` accounts, per the user's explicit answer when asked whether admin
should retain oversight visibility into unconfirmed documents (they chose
"no special-casing").
- **`v1/documents/[id]/route.ts`**: `PUT` now sets `confirmed = true` alongside
the existing `parsed = true` in its `UPDATE` — the *only* place this flips.
`GET`-by-id is untouched, deliberately: its existing comment already says
the point of this endpoint is letting the poller see pending/failed
documents, and that reasoning extends unchanged to unconfirmed ones — the
poller must detect parse-completion before the user has had a chance to
confirm anything.
- **`parse/route.ts`**: reasoned through, rather than blindly copied, whether
its own `INSERT ... ON CONFLICT (filename) DO UPDATE` statements (DO and
Product branches) needed `confirmed` handling. In the real mobile flow the
upload route's INSERT always runs first, so this statement always resolves
via the `ON CONFLICT` branch; since `confirmed` is absent from that branch's
`SET` clause, Postgres leaves the row's existing value untouched by design —
correct behavior (never regress an already-confirmed row, never reset a
pending one mid-reparse) without adding a single line. Also removed the
Product branch's fabricated `noPO`/`noSO`/`noDO` placeholder values (task
10.2) — replaced with empty strings after confirming (by reading
`pdf_service.dart` and `product_editor_submit_logic.dart` on the Flutter
side) that nothing reads them meaningfully; the confirmed document's real
values always come from the user's own PO-link/batch selection at PUT time
regardless.
### 3. Audit Verification
Live against the running Docker stack (`docker restart paddleocr-pfm-web-app`
to pick up the code + run the migration):
- `\d documents` confirmed the new `confirmed boolean not null default true`
column; `SELECT count(*) FROM documents WHERE confirmed = true` returned
13 (all pre-existing rows), `= false` returned 0 — grandfather clause held.
- Uploaded a real DO photo as store account `WH_JCIBBR1` without ever calling
`PUT`: absent from that store's `GET /documents` (count unchanged at 3, new
id 3400 not present) and absent from `admin`'s list too (13, unchanged);
`GET /documents/3400` still returned `parseStatus: "done"`,
`confirmed: false` — the poller/editor hand-off path is unaffected.
- `PUT /documents/3400` (confirm) with real header/shipment data: doc count
for `WH_JCIBBR1` became 4, id 3400 now present with the real submitted
`namaPenerima` ("Penerima Test", not a placeholder); `psql` confirmed the
row's `confirmed` column flipped to `t`.
- Uploaded a fresh Product Scan as the same store (doc id 3402), read its raw
unconfirmed `GET /documents/3402` response: `header.no_po`/`no_so`/`no_do`
all returned `""` — the old `"PO-PRODUCT-001"`/`"1002003004"`/
`"DO-PRODUCT-999"` placeholders are gone.
### 4. Conclusion
Backend §10 is fully `[DONE]`. Flutter's corresponding root task §8.2 (add an
optional `confirmed` field to `DocumentModel`, default `true` for
legacy/cached responses) was implemented and verified in the same session —
see root `docs/iteration-log.md`. No remaining backend blocker for gap G11 or
G12.
---
## Iteration & Audit: Task 11.1 — Single-Pass Product Classification (2026-07-10)
### 1. Objective
Close backend §11 (gap **G3**), sourced directly from user feedback after
they noticed Product Scan's confirmation screen took visibly longer to open
than DO Scan's and asked why. G3 had been documented earlier this session in
`docs/api-contract-map.md` but deliberately left `[TODO]`/deferred, pending
exactly the client-side decision the user's follow-up message resolved:
"sama seperti scan DO... GPU tidak 2x kerja" (same as DO scan, GPU shouldn't
run twice) — i.e. do the classify pass once, at upload, and have the editor
read the stored result, not re-classify on review.
### 2. Code Review
- **`api/parse/route.ts`'s Product branch**: replaced its own separate,
inline `fetch(pyServerUrl, ...)` (which discarded everything except
`top1_name`/`extracted_sku`) with a call to the already-existing shared
`classifyAndMatchProduct()` from `utils/product-scan.ts` — the same
function `POST /api/v1/scan-product` (task 9.3) uses, which additionally
runs the Levenshtein SKU-match against `sku_master` for a real top-5
candidate list and returns the raw OCR result (`extracted_expired_date`
included). `b64` (the image's base64 encoding) was already computed
earlier in this function for the DO path — reused, not recomputed, so
this is a strict reduction in duplicated work, not an addition.
- **New `metadata.productScan` key**: `{ possibleMatches, extractedExpiryDate
}` stored alongside the existing `header`/`shipment`/`items` keys in the
same JSONB `metadata` column — no migration, following the exact precedent
those other keys already set for coexisting shapes in one column.
- **`utils/document-mapper.ts`**: added a top-level `productScan` field to
`mapDocumentRow()`'s return (`metadata.productScan || null`), so all three
GET call sites (list, by-id, upload dedup) expose it identically, same
shared-mapper pattern as `parseStatus`/`docType`/`confirmed`.
- **Regression audit, not just addition**: read `classifyAndMatchProduct()`'s
own `fetch` call closely while wiring it into `parse/route.ts` and noticed
it had *no* `AbortSignal` at all — the inline call it was replacing in
`parse/route.ts` had an explicit 90s bound
(`PIPELINE_TIMEOUT_MS`/`AbortSignal.timeout`). Silently dropping that bound
would have been a real regression (a wedged GPU container hanging past the
intended fail-fast point). Fixed by adding the identical 90s bound directly
inside `classifyAndMatchProduct()` itself — which also retroactively fixes
the live `POST /api/v1/scan-product` route, which never had this bound
either (pre-existing gap, not something this task's own diff introduced,
but caught and closed while in the area).
### 3. Audit Verification
Live against the running Docker stack (`docker restart paddleocr-pfm-web-app`
to pick up the code):
- Deliberately chose a genuinely fresh image/store combination
(`do-015.jpg`, never uploaded before, as store `WH_JAFATAH`) to rule out a
dedup hit masking whether real classification ran. Response was
`"Document uploaded successfully"` (the fresh-insert branch, not the
dedup-return branch) and took **9 seconds** — consistent with one real GPU
classify+match pass, not a cache hit.
- Immediate `GET /documents/:id` (no editor interaction, no second request)
returned a fully populated `productScan`: 5 real `possibleMatches` with
real `sku_master` names/scores (e.g. `"CHAMP CRUNCHY HOTZZ 300 GR/PAC"` at
`score: 0.7575...`, matching real product naming conventions, not
fabricated placeholders) and `extractedExpiryDate` (empty string here,
since this particular test image has no visible expiry text - correctly
reflecting a real "not found" rather than a fake date, consistent with the
G7 fix's "no dummy data" rule).
- Confirmed via a second, earlier check (before switching to the guaranteed-
fresh combination above) that a dedup-hit response for a different
document (id 3408) *also* returned a fully populated `productScan` from a
prior parse - proving the data survives the dedup-return code path too
(`upload/route.ts`'s dedup SELECT was already extended for `confirmed` in
task 10.1's session and needed no further change here, since it maps
through the same shared `mapDocumentRow()`).
### 4. Conclusion
Backend §11 is `[DONE]`. Flutter's corresponding root task §7.2 (read
`productScanMatches`/`productScanExtractedExpiryDate` directly from the
document instead of re-calling `/scan-product`) was implemented and verified
in the same session — see root `docs/iteration-log.md`. Gap G3 is resolved;
`docs/api-contract-map.md` updated accordingly. `POST /api/v1/scan-product`
itself is intentionally left in place (unused by this flow now, but a
legitimate, reusable authenticated endpoint - e.g. for a possible future
"rescan this photo" action) rather than removed, since removing a working,
independently-useful route wasn't part of what this task's scope required.
@@ -116,7 +116,7 @@ export async function GET(req: NextRequest) {
if (inferredSku) { if (inferredSku) {
try { try {
const dbRes = await query("SELECT nama_item FROM sku_master WHERE no_sku = $1", [inferredSku]); const dbRes = await query("SELECT nama_item FROM sku_master WHERE no_sku = $1", [inferredSku]);
if (dbRes.rowCount > 0) { if (dbRes.rowCount && dbRes.rowCount > 0) {
inferredNamaItem = dbRes.rows[0].nama_item; inferredNamaItem = dbRes.rows[0].nama_item;
} }
} catch (dbErr) { } catch (dbErr) {
+137 -5
View File
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query, withTransaction, cleanupAndReindexItems, resolveStoreFromText } from "../../../db"; import { query, withTransaction, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser"; import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { errorResponse } from "@/utils/api-error"; import { errorResponse } from "@/utils/api-error";
import { classifyAndMatchProduct } from "@/utils/product-scan";
// Bounds each pipeline call so a wedged GPU container fails fast into the existing // Bounds each pipeline call so a wedged GPU container fails fast into the existing
// graceful fallback path instead of hanging the request indefinitely. // graceful fallback path instead of hanging the request indefinitely.
@@ -43,7 +44,8 @@ function getStringSimilarity(s1: string, s2: string): number {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
let safeFile = ""; let safeFile = "";
try { try {
const { filename, kodeToko } = await req.json(); const { filename, kodeToko, scanMode } = await req.json();
console.log(`[Parse] Received payload - filename: "${filename}", scanMode: "${scanMode}"`);
if (!filename) { if (!filename) {
return errorResponse(400, "Filename is required"); return errorResponse(400, "Filename is required");
} }
@@ -73,6 +75,133 @@ export async function POST(req: NextRequest) {
const b64 = fileBuffer.toString("base64"); const b64 = fileBuffer.toString("base64");
if (scanMode === "Product") {
// Single classify+OCR+match pass, shared with POST /api/v1/scan-product
// (task 9.3) - this used to be a separate, poorer inline fetch that only
// kept top1_name/extracted_sku, forcing the Flutter editor to re-run the
// entire GPU pass a second time just to get the top-5 candidates and the
// extracted expiry date. Both are captured here now and persisted below
// (metadata.productScan) so the editor can read them from the document
// instead of re-classifying. See docs/api-contract-map.md G3.
let scanResult: Awaited<ReturnType<typeof classifyAndMatchProduct>> | null = null;
try {
scanResult = await classifyAndMatchProduct(b64);
} catch (err) {
console.error("Classifier service error:", err);
}
const bestMatch = scanResult?.possibleMatches?.[0];
const top1Name = bestMatch?.nama_item || "Unknown Product";
const extractedSku = bestMatch?.no_sku || "12010119";
const extractedExpiryDate = scanResult?.ocr?.extracted_expired_date || "";
let currentStoreName = "PM KELAPA DUA KARAWACI";
let storeAlamat = "Jakarta";
if (kodeToko) {
const storeRes = await query("SELECT nama_toko, alamat FROM store_master WHERE kode_toko = $1", [kodeToko]);
if (storeRes.rowCount && storeRes.rowCount > 0) {
currentStoreName = storeRes.rows[0].nama_toko;
storeAlamat = storeRes.rows[0].alamat;
}
}
// noPO/noSO/noDO are DO-specific concepts that don't apply to a product
// verification scan - left empty rather than fabricated placeholders
// (was "PO-PRODUCT-001"/"1002003004"/"DO-PRODUCT-999"). Not user-facing:
// the editor's _submit() builds its own noPo/noSo/noDo from the user's
// PO-link/batch selection, and the printed receipt never reads these.
// See docs/api-contract-map.md G12.
const docMetadata = {
tanggal: new Date().toLocaleDateString("id-ID"),
noPO: "",
noSO: "",
noDO: "",
vendorInfo: "PRODUCT SCAN",
customerInfo: currentStoreName,
header: {
tanggal: new Date().toLocaleDateString("id-ID"),
no_po: "",
no_so: "",
no_do: ""
},
shipment: {
kepada_yth: currentStoreName,
order_untuk: "PRODUCT SCAN",
alamat: storeAlamat,
plat_truk: "B 1234 PFM",
nama_driver: "PRODUCT SCAN",
nama_penerima: "STORE STAFF"
},
items: [
{
kodeBarang: extractedSku,
namaBarang: top1Name,
banyak: "1",
jumlah: "1"
}
],
// Full classify+OCR result from the single pass above, so the Flutter
// editor can read it directly instead of re-running the GPU pipeline
// a second time on review (gap G3). `possibleMatches` is the top-5
// candidate list (may be empty if nothing scored above the match
// threshold); `extractedExpiryDate` is the raw OCR-extracted date, if
// any. `null` for documents parsed before this change existed.
productScan: {
possibleMatches: scanResult?.possibleMatches || [],
extractedExpiryDate
}
};
const stats = fs.statSync(filePath);
const insertDocRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, is_sample, file_hash, kode_toko, scan_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (filename) DO UPDATE
SET upload_time = EXCLUDED.upload_time,
size = EXCLUDED.size,
parsed = EXCLUDED.parsed,
metadata = EXCLUDED.metadata,
is_sample = EXCLUDED.is_sample,
file_hash = EXCLUDED.file_hash,
kode_toko = COALESCE(EXCLUDED.kode_toko, documents.kode_toko),
scan_mode = COALESCE(EXCLUDED.scan_mode, documents.scan_mode),
parse_error = NULL
RETURNING id
`, [
safeFile,
stats.mtime,
stats.size,
true,
JSON.stringify(docMetadata),
isSample,
fileHash,
kodeToko || null,
"Product"
]);
const docId = insertDocRes.rows[0].id;
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
await client.query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang, banyak_original, banyak,
jumlah_original, jumlah, is_flagged, remark
)
VALUES ($1, 0, $2, $2, $3, '1', '1', '1', '1', false, '')
`, [docId, extractedSku, top1Name]);
});
return NextResponse.json({
errorCode: 0,
errorMsg: "Success",
items: docMetadata.items
});
}
// Check if we already have a parsed document in the database with the exact filename (and has valid layout_parsing_result) // Check if we already have a parsed document in the database with the exact filename (and has valid layout_parsing_result)
const cachedDoc = await query( const cachedDoc = await query(
"SELECT layout_parsing_result, processing_logs FROM documents WHERE filename = $1 AND parsed = true", "SELECT layout_parsing_result, processing_logs FROM documents WHERE filename = $1 AND parsed = true",
@@ -433,8 +562,8 @@ export async function POST(req: NextRequest) {
}; };
const insertDocRes = await query(` const insertDocRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs, kode_toko) INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs, kode_toko, scan_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (filename) DO UPDATE ON CONFLICT (filename) DO UPDATE
SET upload_time = EXCLUDED.upload_time, SET upload_time = EXCLUDED.upload_time,
size = EXCLUDED.size, size = EXCLUDED.size,
@@ -444,7 +573,9 @@ export async function POST(req: NextRequest) {
is_sample = EXCLUDED.is_sample, is_sample = EXCLUDED.is_sample,
file_hash = EXCLUDED.file_hash, file_hash = EXCLUDED.file_hash,
processing_logs = EXCLUDED.processing_logs, processing_logs = EXCLUDED.processing_logs,
kode_toko = COALESCE(EXCLUDED.kode_toko, documents.kode_toko) kode_toko = COALESCE(EXCLUDED.kode_toko, documents.kode_toko),
scan_mode = COALESCE(EXCLUDED.scan_mode, documents.scan_mode),
parse_error = NULL
RETURNING id RETURNING id
`, [ `, [
safeFile, safeFile,
@@ -456,7 +587,8 @@ export async function POST(req: NextRequest) {
isSample, isSample,
fileHash, fileHash,
JSON.stringify(logsPayload), JSON.stringify(logsPayload),
kodeToko || null kodeToko || null,
scanMode || "DO"
]); ]);
const docId = insertDocRes.rows[0].id; const docId = insertDocRes.rows[0].id;
@@ -1,66 +1,21 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error"; import { errorResponse } from "@/utils/api-error";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { image_base64 } = await req.json(); const body = await req.json();
const image_base64 = body.image_base64 || body.image;
if (!image_base64) { if (!image_base64) {
return errorResponse(400, "Image is required"); return errorResponse(400, "Image is required");
} }
// Call Python FastAPI server inside the container const result = await classifyAndMatchProduct(image_base64);
const pyServerUrl = process.env.CLASSIFIER_SERVER_URL || "http://paddleocr-pipeline-api:8120/classify-ocr";
console.log(`Forwarding scan request to classifier server: ${pyServerUrl}`); // Layout-parsing visualization (same pipeline as DO-PFM Visual Grid) - only
const response = await fetch(pyServerUrl, { // used by this desktop test page, not part of the shared classify+match logic.
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ image_base64 })
});
if (!response.ok) {
const errText = await response.text();
return errorResponse(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
// Layout-parsing visualization (same pipeline as DO-PFM Visual Grid)
let layoutParsingResult: { layoutParsingResults?: Array<{ outputImages?: Record<string, string> }> } | null = null; let layoutParsingResult: { layoutParsingResults?: Array<{ outputImages?: Record<string, string> }> } | null = null;
const rawB64 = image_base64.includes(",") ? image_base64.split(",")[1] : image_base64; const rawB64 = image_base64.includes(",") ? image_base64.split(",")[1] : image_base64;
const pipelineUrl = process.env.PIPELINE_URL || "http://localhost:7871/layout-parsing"; const pipelineUrl = process.env.PIPELINE_URL || "http://localhost:7871/layout-parsing";
@@ -89,48 +44,18 @@ export async function POST(req: NextRequest) {
console.warn("Layout parsing for visualization unavailable:", layoutErr); console.warn("Layout parsing for visualization unavailable:", layoutErr);
} }
// Now query the SKU master from database
const dbRes = await query("SELECT no_sku, nama_item FROM sku_master");
const skuMasterList = dbRes.rows.map(row => ({
no_sku: row.no_sku,
nama_item: row.nama_item
}));
// Find matches
const top1Name = data.classification?.top1_name || "";
const extractedSku = data.ocr?.extracted_sku || "";
const extractedProductName = data.ocr?.extracted_product_name || "";
const matchedList = skuMasterList.map(sku => {
const yoloSim = top1Name ? getStringSimilarity(sku.nama_item, top1Name) : 0;
return {
no_sku: sku.no_sku,
nama_item: sku.nama_item,
score: yoloSim,
yoloSimilarity: yoloSim,
isBestMatch: false
};
});
// Sort by score descending
matchedList.sort((a, b) => b.score - a.score);
// Take top 5 possible matches
const possibleMatches = matchedList.slice(0, 5).filter(m => m.score > 0.1);
if (possibleMatches.length > 0) {
possibleMatches[0].isBestMatch = true;
}
return NextResponse.json({ return NextResponse.json({
classification: data.classification, classification: result.classification,
ocr: data.ocr, ocr: result.ocr,
possibleMatches, possibleMatches: result.possibleMatches,
layoutParsingResult layoutParsingResult
}); });
} catch (error: unknown) { } catch (error: unknown) {
console.error("Error in scan-pfm API route:", error); console.error("Error in scan-pfm API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message);
}
const message = error instanceof Error ? error.message : "Internal server error"; const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message); return errorResponse(500, message);
} }
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "../../../../../db"; import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error"; import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth"; import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = { const corsHeaders = {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@@ -13,6 +14,60 @@ export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders }); return new NextResponse(null, { status: 204, headers: corsHeaders });
} }
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const params = await context.params;
const docId = parseInt(params.id);
if (isNaN(docId)) {
return errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Deliberately not filtering on `parsed = true` here (unlike the list route) -
// the whole point of this endpoint is to let the poller see pending/failed
// documents, not just done ones.
const docRes = await query(`
SELECT id, filename, upload_time, parsed, is_sample, metadata, latitude, longitude, kode_toko, scan_mode, parse_error, confirmed
FROM documents
WHERE id = $1
`, [docId]);
if (!docRes.rowCount || docRes.rowCount === 0) {
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = docRes.rows[0];
if (account.role !== 'admin' && doc.kode_toko !== account.kodeToko) {
return errorResponse(403, "Forbidden: You do not have permission to view this document", { headers: corsHeaders });
}
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [docId]);
return NextResponse.json({
status: "success",
data: mapDocumentRow(doc, itemsRes.rows)
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in get document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
export async function PUT( export async function PUT(
req: NextRequest, req: NextRequest,
context: { params: Promise<{ id: string }> } context: { params: Promise<{ id: string }> }
@@ -90,10 +145,13 @@ export async function PUT(
const latFloat = latitude ? parseFloat(latitude.toString()) : null; const latFloat = latitude ? parseFloat(latitude.toString()) : null;
const lngFloat = longitude ? parseFloat(longitude.toString()) : null; const lngFloat = longitude ? parseFloat(longitude.toString()) : null;
// Update document record // Update document record. `confirmed = true` is the one and only place
// this flips - this PUT is literally "the user tapped Simpan & Konfirmasi"
// (see docs/api-contract-map.md G11).
await query(` await query(`
UPDATE documents UPDATE documents
SET parsed = true, SET parsed = true,
confirmed = true,
latitude = $2, latitude = $2,
longitude = $3, longitude = $3,
metadata = $4 metadata = $4
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db"; import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error"; import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth"; import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = { const corsHeaders = {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@@ -22,9 +23,9 @@ export async function GET(req: NextRequest) {
// Retrieve all custom-uploaded documents // Retrieve all custom-uploaded documents
let docsQuery = ` let docsQuery = `
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude, scan_mode, parse_error, confirmed
FROM documents FROM documents
WHERE is_sample = false AND parsed = true WHERE is_sample = false AND parsed = true AND confirmed = true
`; `;
const queryParams: any[] = []; const queryParams: any[] = [];
@@ -41,85 +42,15 @@ export async function GET(req: NextRequest) {
const mappedList = []; const mappedList = [];
for (const doc of documents) { for (const doc of documents) {
const docId = doc.id;
const metadata = doc.metadata || {};
// Retrieve items from ocr_items // Retrieve items from ocr_items
const itemsRes = await query(` const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items FROM ocr_items
WHERE document_id = $1 WHERE document_id = $1
ORDER BY row_index ORDER BY row_index
`, [docId]); `, [doc.id]);
const items = itemsRes.rows.map(item => ({ mappedList.push(mapDocumentRow(doc, itemsRes.rows));
nomor_sku: item.kode_barang || "",
nama_barang: item.nama_barang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
}));
// Determine header and shipment mapping
let header = {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
};
let shipment = {
kepada_yth: "",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
};
if (metadata.header) {
// Document was updated via mobile app
header = {
tanggal: metadata.header.tanggal || "",
no_po: metadata.header.no_po || "",
no_so: metadata.header.no_so || "",
no_do: metadata.header.no_do || ""
};
shipment = {
kepada_yth: metadata.shipment?.kepada_yth || "",
order_untuk: metadata.shipment?.order_untuk || "",
alamat: metadata.shipment?.alamat || "",
plat_truk: metadata.shipment?.plat_truk || "",
nama_driver: metadata.shipment?.nama_driver || "",
nama_penerima: metadata.shipment?.nama_penerima || ""
};
} else {
// Document was freshly uploaded / parsed via web
header = {
tanggal: metadata.tanggal || "",
no_po: metadata.noPO || "",
no_so: metadata.noSO || "",
no_do: metadata.noDO || doc.filename || ""
};
shipment = {
kepada_yth: metadata.customerInfo || "",
order_untuk: metadata.orderUntuk || "",
alamat: metadata.alamat || "",
plat_truk: metadata.platTruk || "",
nama_driver: "",
nama_penerima: metadata.headerRemark || ""
};
}
mappedList.push({
id: docId.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header,
shipment,
items,
latitude: doc.latitude ? parseFloat(doc.latitude.toString()) : null,
longitude: doc.longitude ? parseFloat(doc.longitude.toString()) : null
});
} }
return NextResponse.json({ return NextResponse.json({
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query } from "../../../../../db"; import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error"; import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth"; import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const UPLOADS_DIR = "/uploads"; const UPLOADS_DIR = "/uploads";
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
const formData = await req.formData(); const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null; const file = (formData.get("image") || formData.get("file")) as Blob | null;
const scanMode = formData.get("scan_mode")?.toString() || "DO";
console.log(`[Upload] Received scan_mode: "${scanMode}"`);
if (!file) { if (!file) {
return errorResponse(400, "No file uploaded", { headers: corsHeaders }); return errorResponse(400, "No file uploaded", { headers: corsHeaders });
@@ -60,8 +63,8 @@ export async function POST(req: NextRequest) {
// Basic dedup // Basic dedup
const dedupQuery = account?.kodeToko const dedupQuery = account?.kodeToko
? "SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 AND kode_toko = $2 ORDER BY upload_time ASC LIMIT 1" ? "SELECT id, filename, upload_time, parsed, metadata, latitude, longitude, scan_mode, parse_error, confirmed FROM documents WHERE file_hash = $1 AND kode_toko = $2 ORDER BY upload_time ASC LIMIT 1"
: "SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 AND kode_toko IS NULL ORDER BY upload_time ASC LIMIT 1"; : "SELECT id, filename, upload_time, parsed, metadata, latitude, longitude, scan_mode, parse_error, confirmed FROM documents WHERE file_hash = $1 AND kode_toko IS NULL ORDER BY upload_time ASC LIMIT 1";
const dedupParams = account?.kodeToko ? [fileHash, account.kodeToko] : [fileHash]; const dedupParams = account?.kodeToko ? [fileHash, account.kodeToko] : [fileHash];
const existing = await query(dedupQuery, dedupParams); const existing = await query(dedupQuery, dedupParams);
@@ -70,22 +73,19 @@ export async function POST(req: NextRequest) {
const existingDoc = existing.rows[0]; const existingDoc = existing.rows[0];
console.log(`[Dedup] Identical content already uploaded as document ${existingDoc.id}. Skipping duplicate insert and re-parse.`); console.log(`[Dedup] Identical content already uploaded as document ${existingDoc.id}. Skipping duplicate insert and re-parse.`);
const mappedData = { // Return the original document's actual current parse state instead of an
id: existingDoc.id.toString(), // always-empty stub, so a retried upload doesn't look permanently "fresh."
header: { tanggal: "", no_po: "", no_so: "", no_do: "" }, const itemsRes = await query(`
shipment: { SELECT row_index, kode_barang, nama_barang, banyak, jumlah
kepada_yth: "PT.PRIMAFOOD INTERNATIONAL", FROM ocr_items
order_untuk: "", WHERE document_id = $1
alamat: "", ORDER BY row_index
plat_truk: "", `, [existingDoc.id]);
nama_driver: "",
nama_penerima: "" const mappedData = mapDocumentRow(existingDoc, itemsRes.rows);
}, // Fall back to this retry's own GPS tag if the original document never got one.
items: [] as any[], if (mappedData.latitude === null) mappedData.latitude = latitude;
latitude: existingDoc.latitude ? parseFloat(existingDoc.latitude.toString()) : latitude, if (mappedData.longitude === null) mappedData.longitude = longitude;
longitude: existingDoc.longitude ? parseFloat(existingDoc.longitude.toString()) : longitude,
createdAt: new Date(existingDoc.upload_time || Date.now()).toISOString()
};
return NextResponse.json({ return NextResponse.json({
status: "success", status: "success",
@@ -100,9 +100,11 @@ export async function POST(req: NextRequest) {
let docId: number; let docId: number;
let finalFilename = filename; let finalFilename = filename;
// `confirmed = false`: this row isn't visible via GET /api/v1/documents
// until the user's editor PUT confirms it (see docs/api-contract-map.md G11).
const insertRes = await query(` const insertRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko) INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko, scan_mode, confirmed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id RETURNING id
`, [ `, [
filename, filename,
@@ -113,7 +115,9 @@ export async function POST(req: NextRequest) {
fileHash, fileHash,
latitude, latitude,
longitude, longitude,
account?.kodeToko || null account?.kodeToko || null,
scanMode,
false
]); ]);
docId = insertRes.rows[0].id; docId = insertRes.rows[0].id;
@@ -122,15 +126,28 @@ export async function POST(req: NextRequest) {
// wedged GPU container doesn't hang this request forever - it still won't fit under the // wedged GPU container doesn't hang this request forever - it still won't fit under the
// mobile client's 2-minute receive timeout in the worst case, but bounds the hang to a fixed, // mobile client's 2-minute receive timeout in the worst case, but bounds the hang to a fixed,
// known ceiling instead of an indefinite one. // known ceiling instead of an indefinite one.
//
// /api/parse has its own error handlers that mark the document parsed=true with
// "Not Found" placeholder metadata on a pipeline failure - so those cases already
// resolve out of "pending". The one gap is this call itself never completing
// (network error / the 210s abort firing): /api/parse's handlers never even run,
// so the document is otherwise silently stuck at parsed=false forever. Record
// that case explicitly so GET /api/v1/documents/:id can report parseStatus "failed"
// instead of the client burning its own full timeout waiting on "pending".
try { try {
await fetch("http://127.0.0.1:3000/api/parse", { const parseRes = await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko }), body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko, scanMode }),
signal: AbortSignal.timeout(210_000) signal: AbortSignal.timeout(210_000)
}); });
if (!parseRes.ok) {
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [`Pipeline error: HTTP ${parseRes.status}`, docId]);
}
} catch (err) { } catch (err) {
console.error("Error triggering parse synchronously:", err); console.error("Error triggering parse synchronously:", err);
const message = err instanceof Error ? err.message : "Parse request failed";
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [message, docId]);
} }
// Return the response structured as DocumentModel.fromJson format // Return the response structured as DocumentModel.fromJson format
@@ -5,9 +5,12 @@ import { getAccountFromAuthHeader } from "@/utils/auth";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
// Read access is open to any authenticated account (task 9.2) - the
// Flutter product editor needs this to populate its SKU dropdown, and
// has no admin role of its own. Writes below stay admin-gated.
const account = getAccountFromAuthHeader(req.headers.get("authorization")); const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') { if (!account) {
return errorResponse(403, "Forbidden: Admin access required"); return errorResponse(401, "Unauthorized");
} }
const res = await query(` const res = await query(`
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
// Any authenticated account may scan - unlike sku_master writes, this is the
// route the mobile app itself calls to do a product scan, not an admin tool.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
let imageBase64: string | null = null;
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("multipart/form-data")) {
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const buffer = Buffer.from(await file.arrayBuffer());
imageBase64 = buffer.toString("base64");
} else {
const body = await req.json();
imageBase64 = body.image_base64 || body.image || null;
}
if (!imageBase64) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const result = await classifyAndMatchProduct(imageBase64);
return NextResponse.json({
status: "success",
data: result
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in v1 scan-product API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message, { headers: corsHeaders });
}
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
+49 -15
View File
@@ -194,9 +194,11 @@ export default function ScanPfmPage() {
setEditedSku(""); setEditedSku("");
setActiveTab("summary"); setActiveTab("summary");
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = async () => {
setSelectedImage(reader.result as string); const base64Image = reader.result as string;
setSelectedImage(base64Image);
setSelectedProduct(null); setSelectedProduct(null);
await runScanForImage(base64Image);
}; };
reader.onerror = () => setError("Failed to read file"); reader.onerror = () => setError("Failed to read file");
reader.readAsDataURL(file); reader.readAsDataURL(file);
@@ -257,21 +259,13 @@ export default function ScanPfmPage() {
setIsEditingProductName(false); setIsEditingProductName(false);
}; };
const handleScan = async () => { const runScanForImage = async (base64Image: string) => {
if (!selectedImage) {
setError("Please select or upload an image first.");
return;
}
setScanning(true); setScanning(true);
setError(""); setError("");
setScanResult(null); setScanResult(null);
setEditedSku(""); setEditedSku("");
setActiveTab("summary"); setActiveTab("summary");
try { try {
let base64Image = selectedImage;
if (selectedImage.startsWith("/produk-pfm")) {
base64Image = await convertUrlToBase64(selectedImage);
}
const res = await fetch("/api/scan-pfm", { const res = await fetch("/api/scan-pfm", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -300,6 +294,25 @@ export default function ScanPfmPage() {
} }
}; };
const handleScan = async () => {
if (!selectedImage) {
setError("Please select or upload an image first.");
return;
}
let base64Image = selectedImage;
if (selectedImage.startsWith("/produk-pfm")) {
try {
setScanning(true);
base64Image = await convertUrlToBase64(selectedImage);
} catch (err) {
setError(getErrorMessage(err));
setScanning(false);
return;
}
}
await runScanForImage(base64Image);
};
const handleRotate = async () => { const handleRotate = async () => {
if (!selectedImage) return; if (!selectedImage) return;
setRotating(true); setRotating(true);
@@ -323,7 +336,9 @@ export default function ScanPfmPage() {
ctx.translate(canvas.width, 0); ctx.translate(canvas.width, 0);
ctx.rotate((90 * Math.PI) / 180); ctx.rotate((90 * Math.PI) / 180);
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
setSelectedImage(canvas.toDataURL("image/jpeg", 0.95)); const rotatedBase64 = canvas.toDataURL("image/jpeg", 0.95);
setSelectedImage(rotatedBase64);
await runScanForImage(rotatedBase64);
} catch (err: unknown) { } catch (err: unknown) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
} finally { } finally {
@@ -447,13 +462,24 @@ export default function ScanPfmPage() {
<button <button
key={item.productName} key={item.productName}
id={`product-btn-${item.productName.replace(/\s+/g, "-").toLowerCase()}`} id={`product-btn-${item.productName.replace(/\s+/g, "-").toLowerCase()}`}
onClick={() => { onClick={async () => {
setSelectedProduct(item); setSelectedProduct(item);
if (item.images.length > 0) setSelectedImage(item.images[0]);
setScanResult(null); setScanResult(null);
setEditedSku(""); setEditedSku("");
setError(""); setError("");
setActiveTab("summary"); setActiveTab("summary");
if (item.images.length > 0) {
const imgUrl = item.images[0];
setSelectedImage(imgUrl);
try {
setScanning(true);
const base64Image = await convertUrlToBase64(imgUrl);
await runScanForImage(base64Image);
} catch (err) {
setError(getErrorMessage(err));
setScanning(false);
}
}
}} }}
className={`w-full text-left p-2.5 rounded-xl border transition-all text-xs flex flex-col gap-1 cursor-pointer ${ className={`w-full text-left p-2.5 rounded-xl border transition-all text-xs flex flex-col gap-1 cursor-pointer ${
isSelected isSelected
@@ -542,11 +568,19 @@ export default function ScanPfmPage() {
{selectedProduct.images.map((img) => ( {selectedProduct.images.map((img) => (
<button <button
key={img} key={img}
onClick={() => { onClick={async () => {
setSelectedImage(img); setSelectedImage(img);
setScanResult(null); setScanResult(null);
setError(""); setError("");
setActiveTab("summary"); setActiveTab("summary");
try {
setScanning(true);
const base64Image = await convertUrlToBase64(img);
await runScanForImage(base64Image);
} catch (err) {
setError(getErrorMessage(err));
setScanning(false);
}
}} }}
className={`w-16 h-16 rounded-lg border-2 overflow-hidden flex-shrink-0 cursor-pointer transition-all ${ className={`w-16 h-16 rounded-lg border-2 overflow-hidden flex-shrink-0 cursor-pointer transition-all ${
selectedImage === img ? "border-teal-500 scale-95 shadow-md" : "border-slate-800 hover:border-slate-600" selectedImage === img ? "border-teal-500 scale-95 shadow-md" : "border-slate-800 hover:border-slate-600"
+12 -1
View File
@@ -22,7 +22,10 @@ export async function initDb(pool: Pool) {
is_sample BOOLEAN NOT NULL DEFAULT FALSE, is_sample BOOLEAN NOT NULL DEFAULT FALSE,
file_hash VARCHAR(64), file_hash VARCHAR(64),
processing_logs JSONB, processing_logs JSONB,
kode_toko VARCHAR(255) kode_toko VARCHAR(255),
scan_mode VARCHAR(20),
parse_error TEXT,
confirmed BOOLEAN NOT NULL DEFAULT TRUE
); );
`); `);
@@ -31,6 +34,14 @@ export async function initDb(pool: Pool) {
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);"); await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_logs JSONB;"); await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_logs JSONB;");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS kode_toko VARCHAR(255);"); await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS kode_toko VARCHAR(255);");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS scan_mode VARCHAR(20);");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS parse_error TEXT;");
// DEFAULT TRUE grandfathers every pre-existing row (today's history stays
// visible after this migration) - only new uploads explicitly insert
// `confirmed = false` (v1/documents/upload/route.ts) so a document only
// re-enters `GET /api/v1/documents` once the user PUTs (confirms) it.
// See docs/api-contract-map.md G11.
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS confirmed BOOLEAN NOT NULL DEFAULT TRUE;");
await pool.query("CREATE INDEX IF NOT EXISTS idx_documents_file_hash ON documents(file_hash);"); await pool.query("CREATE INDEX IF NOT EXISTS idx_documents_file_hash ON documents(file_hash);");
} catch (alterErr) { } catch (alterErr) {
console.error("Failed to alter documents table for schema upgrade:", alterErr); console.error("Failed to alter documents table for schema upgrade:", alterErr);
@@ -0,0 +1,117 @@
export type ParseStatus = "pending" | "done" | "failed";
export interface DocumentRow {
id: number;
filename: string;
upload_time: Date;
parsed: boolean;
metadata: any;
latitude: any;
longitude: any;
scan_mode: string | null;
parse_error: string | null;
confirmed: boolean;
}
export interface OcrItemRow {
kode_barang: string | null;
nama_barang: string | null;
banyak: string | null;
jumlah: string | null;
}
// Shared by GET /api/v1/documents (list), GET /api/v1/documents/:id, and the
// upload route's dedup-return branch, so the header/shipment/status mapping
// only lives in one place.
export function mapDocumentRow(doc: DocumentRow, itemRows: OcrItemRow[]) {
const metadata = doc.metadata || {};
const items = itemRows.map((item) => ({
nomor_sku: item.kode_barang || "",
nama_barang: item.nama_barang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
}));
let header = {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
};
let shipment = {
kepada_yth: "",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
};
if (metadata.header) {
// Document was updated via mobile app
header = {
tanggal: metadata.header.tanggal || "",
no_po: metadata.header.no_po || "",
no_so: metadata.header.no_so || "",
no_do: metadata.header.no_do || ""
};
shipment = {
kepada_yth: metadata.shipment?.kepada_yth || "",
order_untuk: metadata.shipment?.order_untuk || "",
alamat: metadata.shipment?.alamat || "",
plat_truk: metadata.shipment?.plat_truk || "",
nama_driver: metadata.shipment?.nama_driver || "",
nama_penerima: metadata.shipment?.nama_penerima || ""
};
} else {
// Document was freshly uploaded / parsed via web
header = {
tanggal: metadata.tanggal || "",
no_po: metadata.noPO || "",
no_so: metadata.noSO || "",
no_do: metadata.noDO || doc.filename || ""
};
shipment = {
kepada_yth: metadata.customerInfo || "",
order_untuk: metadata.orderUntuk || "",
alamat: metadata.alamat || "",
plat_truk: metadata.platTruk || "",
nama_driver: "",
nama_penerima: metadata.headerRemark || ""
};
}
const parseStatus: ParseStatus = doc.parsed
? "done"
: doc.parse_error
? "failed"
: "pending";
// scan_mode is the source of truth once persisted (task 9.1); fall back to the
// legacy metadata sentinel for rows created before that column existed.
const docType = doc.scan_mode || (shipment.order_untuk === "PRODUCT SCAN" ? "Product" : "DO");
return {
id: doc.id.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header,
shipment,
items,
parsed: doc.parsed,
latitude: doc.latitude ? parseFloat(doc.latitude.toString()) : null,
longitude: doc.longitude ? parseFloat(doc.longitude.toString()) : null,
parseStatus,
docType,
confirmed: doc.confirmed,
// Full classify+OCR result captured at upload time for Product Scan
// documents (gap G3) - lets the editor render immediately instead of
// re-running the GPU pipeline on review. `null` for DO documents, and
// for Product documents parsed before this existed or already PUT
// (the PUT route rebuilds `metadata` from scratch without this key,
// which is fine - the editor only needs it during the initial review).
productScan: metadata.productScan || null
};
}
@@ -0,0 +1,122 @@
import { query } from "../db";
// Bounds the classifier call so a wedged GPU container fails fast instead of
// hanging indefinitely - matches the bound `api/parse/route.ts` used to apply
// to its own separate inline classify call before it started sharing this
// function (see docs/api-contract-map.md G3).
const PIPELINE_TIMEOUT_MS = 90_000;
// Thrown when the Python classifier service itself returns a non-2xx response,
// so callers can forward its actual status instead of collapsing everything to 500.
export class ClassifierError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export interface SkuMatch {
no_sku: string;
nama_item: string;
score: number;
yoloSimilarity: number;
isBestMatch: boolean;
}
export interface ProductScanResult {
classification: any;
ocr: any;
possibleMatches: SkuMatch[];
}
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
// Shared by the classic /api/scan-pfm dev route and the authenticated
// /api/v1/scan-product route: calls the Python classifier, then matches the
// result against sku_master, returning the top-5 candidates.
export async function classifyAndMatchProduct(imageBase64: string): Promise<ProductScanResult> {
const pyServerUrl = process.env.CLASSIFIER_SERVER_URL || "http://paddleocr-pipeline-api:8120/classify-ocr";
const response = await fetch(pyServerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: imageBase64 }),
signal: AbortSignal.timeout(PIPELINE_TIMEOUT_MS)
});
if (!response.ok) {
const errText = await response.text();
throw new ClassifierError(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
const dbRes = await query("SELECT no_sku, nama_item FROM sku_master");
const skuMasterList = dbRes.rows.map(row => ({
no_sku: row.no_sku,
nama_item: row.nama_item
}));
const top1Name = data.classification?.top1_name || "";
const extractedSku = data.ocr?.extracted_sku || "";
const matchedList: SkuMatch[] = skuMasterList.map(sku => {
const yoloSim = top1Name ? getStringSimilarity(sku.nama_item, top1Name) : 0;
const cleanMasterSku = sku.no_sku.trim();
const cleanExtractedSku = extractedSku.trim();
const isSkuMatch = cleanExtractedSku && cleanMasterSku === cleanExtractedSku;
const score = isSkuMatch ? 1.0 : yoloSim;
return {
no_sku: sku.no_sku,
nama_item: sku.nama_item,
score,
yoloSimilarity: yoloSim,
isBestMatch: false
};
});
matchedList.sort((a, b) => b.score - a.score);
const possibleMatches = matchedList.slice(0, 5).filter(m => m.score > 0.1);
if (possibleMatches.length > 0) {
possibleMatches[0].isBestMatch = true;
}
return {
classification: data.classification,
ocr: data.ocr,
possibleMatches
};
}
+134
View File
@@ -216,6 +216,140 @@ used as the parse response's values instead of OCR.
*Suggested order: 7.3 → 7.1 → 7.2 (bootstrap first — account seeding FK-depends *Suggested order: 7.3 → 7.1 → 7.2 (bootstrap first — account seeding FK-depends
on it; login profile last, it's additive).* on it; login profile last, it's additive).*
## 9. Flutter Client Contract — v1 Surface Completion
`api/v1/documents/`, `api/v1/master/skus/`, `api/parse/route.ts`, `db/init.ts`
Added 2026-07-10 via a user-directed, explicitly backend-scoped `e` run auditing
the full Flutter↔backend request/response contract. **Context doc:
[../../docs/api-contract-map.md](../../docs/api-contract-map.md)** (repo-root
`docs/`) — endpoint inventory, envelopes, lifecycle, and gap IDs (G1-G10) cited
below. These are the *server* halves; the Flutter halves are root
`plans/next-enhancements.md` §6-7 and consume these, so this section ships first.
Keep the v1 envelope (`{status, data}` / `api-error.ts`) on everything new.
- **9.1** [DONE 2026-07-10] `GET /api/v1/documents/:id` with `parseStatus`/`docType`, `scan_mode` persistence, dedup-stub fix. (See docs/feature-list.md)
- **9.2** [DONE 2026-07-10] Relaxed `GET /api/v1/master/skus` to any authenticated
account (writes stay admin-only); no response-shape change. Picked up via
explicit `n{9.2}` request; user chose "relax existing endpoint" over "add a
new one" when asked. (See docs/feature-list.md)
- **9.3** [DONE 2026-07-10] Authenticated `POST /api/v1/scan-product`, shared classify+match util. (See docs/feature-list.md)
*Section 9 is now fully `[DONE]`. With 9.1-9.3 all shipped, every backend
blocker behind Flutter root `plans/next-enhancements.md` §7.1 (moving the
product editor onto the v1 surface) is cleared. §7.2 (eliminating the
duplicate classification pass) is unblocked in principle but still needs its
own grill-me decision on the client side about which single pass to keep.*
## 10. Backend — Document Confirmation Gate & Data Hygiene
`pfm-web-app/src/db/init.ts`, `app/api/v1/documents/`, `app/api/parse/route.ts`, `utils/document-mapper.ts`
Added 2026-07-10 from user testing feedback on the release APK
([`twinkly-riding-mitten.md`](C:/Users/rafha/.claude/plans/twinkly-riding-mitten.md)).
Backend counterpart to Flutter root `plans/next-enhancements.md` §8.
Root-cause documentation in [../../docs/api-contract-map.md](../../docs/api-contract-map.md)
**G11** (no draft/confirmed distinction) and **G12** (fabricated PO/SO/DO).
**Ship §10 before Flutter §8.2** — Flutter's model change consumes the new
`confirmed` field this section adds. Keep the v1 envelope (`{status, data}` /
`api-error.ts`) on everything new.
- **10.1** [DONE 2026-07-10] **Confirmation-gated document list visibility (Task B backend
half).** Three coordinated changes, one migration:
1. `db/init.ts` — `ALTER TABLE documents ADD COLUMN IF NOT EXISTS confirmed
BOOLEAN NOT NULL DEFAULT true;` (`DEFAULT true` grandfathers every
pre-existing row — today's history stays visible after migration).
2. `v1/documents/upload/route.ts` — add `confirmed = false` to the INSERT
column list for every new upload (dedup-hit branch unchanged — reflects
whatever `confirmed` state the original row already has).
3. `utils/document-mapper.ts` — add `confirmed: boolean` to `DocumentRow`
interface; return `confirmed: doc.confirmed` from `mapDocumentRow()`;
update the three SELECT statements that build a `DocumentRow` (list route,
`[id]` GET, upload dedup-hit SELECT) to include the `confirmed` column.
4. `v1/documents/route.ts` (list) — add `AND confirmed = true` to WHERE
clause, unconditionally for every account including admin (per clarified
answer — existing `kode_toko` scoping for non-admins is untouched).
5. `v1/documents/[id]/route.ts` — PUT handler: add `confirmed = true` to the
UPDATE SET. GET handler: no filter change (poller must keep seeing
pending/unconfirmed docs); just receives `confirmed` via mapper update.
- **Note on `parse/route.ts`'s own INSERT...ON CONFLICT statements (both DO
and Product branches)**: deliberately left untouched for `confirmed` —
in the real mobile flow, `upload/route.ts`'s INSERT always runs first
(explicit `confirmed = false`), so `parse/route.ts`'s upsert always hits
the `ON CONFLICT DO UPDATE` branch; since that branch's `SET` clause
doesn't mention `confirmed`, Postgres leaves the existing value untouched
— exactly the desired behavior (never regress an already-confirmed
document, never reset the pending flag mid-parse). Omission was verified
to be correct, not an oversight.
- Live verification (all 5 steps passed against the running Docker stack):
(a) uploaded a real DO photo as store `WH_JCIBBR1`, did not PUT — absent
from that store's `GET /documents` (count stayed at 3, new id 3400 not
present) while `GET /documents/3400` still returned `parseStatus: "done"`,
`confirmed: false`; (b) PUT (confirm) — doc count became 4, id 3400 present
with the real submitted `namaPenerima`, DB row's `confirmed` flipped to
`true`; (c) covered by (a)'s single-doc check; (d) `admin`'s `GET
/documents` also excluded the unconfirmed doc (13, unchanged) before the
PUT; (e) all 13 pre-existing rows carried `confirmed = true` after the
migration ran (`ALTER TABLE` executed on container restart, verified via
`\d documents` + a `count(*)` query — 13 confirmed, 0 unconfirmed
pre-restart).
- **10.2** [DONE 2026-07-10] **Remove fabricated Product Scan PO/SO/DO placeholders (Task
C, bundled with 10.1 — same file `parse/route.ts`).** Replaced hardcoded
`noPO: "PO-PRODUCT-001"`, `noSO: "1002003004"`, `noDO: "DO-PRODUCT-999"` —
both the flat keys and the mirrored `header.no_po`/`no_so`/`no_do`
sub-object — with empty strings `""`. Scope stayed narrow to exactly these
three fields; `nama_driver: "PRODUCT SCAN"` and `nama_penerima: "STORE STAFF"`
were left untouched (deliberate fixed convention, not a fabricated document
number that could mislead someone reading raw data — different failure mode
from G7's fake SKU/date data). Not user-facing: verified `pdf_service.dart`
and `product_editor_submit_logic.dart` neither reads nor displays these values.
- Verification: uploaded a fresh Product Scan as `WH_JCIBBR1` (doc id 3402),
read the raw unconfirmed `GET /documents/3402` response — `header.no_po`/
`no_so`/`no_do` all returned `""`, not the old fabricated strings.
## 11. Backend — Single-Pass Product Classification
`app/api/parse/route.ts`, `utils/product-scan.ts`, `utils/document-mapper.ts`
Added 2026-07-10 from 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. Root-caused as gap **G3** in
[../../docs/api-contract-map.md](../../docs/api-contract-map.md) (deferred
there pending exactly this client-side decision). Backend counterpart to
Flutter root `plans/next-enhancements.md` §7.2.
- **11.1** [DONE 2026-07-10] **Run the classify+match pipeline once per
photo, and persist the full result.** `api/parse/route.ts`'s Product branch
had its own separate, poorer inline `fetch` to the classifier that only
kept `top1_name`/`extracted_sku` — the Flutter editor then had to re-run
the *entire* pipeline a second time via `POST /api/v1/scan-product` (task
9.3) just to get the top-5 candidate list and OCR-extracted expiry date.
Replaced the inline fetch with a call to the same shared
`classifyAndMatchProduct()` (`utils/product-scan.ts`) that route already
uses — one GPU call, richer result, `b64` reused from the DO path's own
computation (not recomputed). The richer result is persisted under a new
`metadata.productScan` JSONB key (`possibleMatches` + `extractedExpiryDate`
— no schema migration needed, same pattern as `header`/`shipment`
coexisting in that column) and surfaced by `document-mapper.ts` as a
top-level `productScan` field on every GET response (list, by-id, upload
dedup). `ocr_items` still stores only the single best-match row, unchanged.
- **Regression caught and fixed during implementation**: delegating to
`classifyAndMatchProduct()` silently dropped the 90s
`AbortSignal.timeout` the old inline fetch had (a wedged GPU container
would otherwise hang past the intended fail-fast bound). Added the same
`PIPELINE_TIMEOUT_MS = 90_000` bound directly inside
`classifyAndMatchProduct()` itself, so both callers (`parse/route.ts` and
the live `POST /api/v1/scan-product` route, which never had this bound
either) are protected, not just the one this task touched.
- Verification: uploaded a genuinely fresh image/store combination
(`do-015.jpg` as `WH_JAFATAH`, never uploaded before, so this is
provably a real classify pass and not a dedup hit) — took 9s (one GPU
pass), response was `"Document uploaded successfully"` (not the dedup
branch). Immediate `GET /documents/:id` returned `productScan` with 5
real `possibleMatches` (real SKUs/names/scores from `sku_master`) and
the OCR-extracted expiry date — before any editor interaction. See
root `docs/iteration-log.md` for the Flutter-side verification that the
editor renders this without a second network call.
--- ---
*Sections 1-4 migrated 2026-07-08 from root `plans/next-enhancements.md` sections *Sections 1-4 migrated 2026-07-08 from root `plans/next-enhancements.md` sections
+191
View File
@@ -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.
+137
View File
@@ -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.)* *(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 ## 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. - **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. - 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 - **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. - 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.
+867
View File
@@ -2,6 +2,700 @@
This log tracks code review audits and QA verifications performed upon completion of development iterations. 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) ## Iteration: Product Scan UI & Document Card Alignment (2026-07-09)
### Completed Tasks ### Completed Tasks
@@ -39,3 +733,176 @@ This log tracks code review audits and QA verifications performed upon completio
### Verification Results ### Verification Results
* **Analysis**: `flutter analyze` completed successfully with zero compile errors. * **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. * **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.
+11 -2
View File
@@ -1,9 +1,9 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
class AppConfig { class AppConfig {
// ─── API Configuration ──────────────────────────────────────────────────── // ─── API Configuration ────────────────────────────────────────────────────
// //
// Dual-Mode Configuration, tried in this order at every app startup: // Dual-Mode Configuration, tried in this order at every app startup:
// 1. Local/LAN backend (fast, low-latency - the common case when the phone // 1. Local/LAN backend (fast, low-latency - the common case when the phone
@@ -91,6 +91,8 @@ class AppConfig {
static const String loginEndpoint = '/auth/login'; static const String loginEndpoint = '/auth/login';
static const String uploadDocumentEndpoint = '/documents/upload'; static const String uploadDocumentEndpoint = '/documents/upload';
static const String fetchDocumentsEndpoint = '/documents'; static const String fetchDocumentsEndpoint = '/documents';
static const String masterSkusEndpoint = '/master/skus';
static const String scanProductEndpoint = '/scan-product';
// App Constants // App Constants
@@ -105,6 +107,12 @@ class AppConfig {
static const Color surfaceColor = Colors.white; static const Color surfaceColor = Colors.white;
static const Color errorColor = Color(0xFFD32F2F); static const Color errorColor = Color(0xFFD32F2F);
static const Color successColor = Color(0xFF388E3C); static const Color successColor = Color(0xFF388E3C);
// DO mode color — orange cue used throughout for the DO Scan mode:
// document_card.dart top-label, documents_tab_switcher.dart active DO tab,
// camera_drawer_mode_toggle.dart DO segment decoration. Formalized as a
// shared constant so all three call sites pull from one place (was an
// ad-hoc Colors.orange.shade700 inline in document_card.dart).
static const Color doModeColor = Color(0xFFF57C00); // == Colors.orange.shade700
// Text Colors // Text Colors
static const Color textPrimary = Color(0xFF212121); static const Color textPrimary = Color(0xFF212121);
@@ -220,3 +228,4 @@ class AppConfig {
); );
} }
} }
+5
View File
@@ -55,4 +55,9 @@ class LocalStorage {
.map((data) => Map<String, dynamic>.from(data)) .map((data) => Map<String, dynamic>.from(data))
.toList(); .toList();
} }
Future<void> clearAll() async {
await documentBox.clear();
await pendingDocumentsBox.clear();
}
} }
+14
View File
@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../config/app_config.dart'; import '../../config/app_config.dart';
import '../../main.dart';
import '../documents/pending_documents_provider.dart';
class AuthNotifier extends StateNotifier<bool> { class AuthNotifier extends StateNotifier<bool> {
AuthNotifier(this._ref) : super(false); AuthNotifier(this._ref) : super(false);
@@ -57,6 +59,11 @@ class AuthNotifier extends StateNotifier<bool> {
throw Exception("Token otentikasi tidak ditemukan dalam respon"); throw Exception("Token otentikasi tidak ditemukan dalam respon");
} }
// Clear any pre-existing local data before saving the new token/profile
final localStorage = _ref.read(localStorageProvider);
await localStorage.clearAll();
_ref.read(pendingDocumentsProvider.notifier).clearQueue();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString('auth_token', token); await prefs.setString('auth_token', token);
if (profile != null) { if (profile != null) {
@@ -76,6 +83,13 @@ class AuthNotifier extends StateNotifier<bool> {
await prefs.remove('auth_token'); await prefs.remove('auth_token');
await prefs.remove('nama_toko'); await prefs.remove('nama_toko');
await prefs.remove('kode_toko'); await prefs.remove('kode_toko');
final localStorage = _ref.read(localStorageProvider);
await localStorage.clearAll();
// Clear pending documents queue in memory
_ref.read(pendingDocumentsProvider.notifier).clearQueue();
state = false; state = false;
} }
} }
+61 -204
View File
@@ -1,11 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../config/app_config.dart'; import '../../config/app_config.dart';
import '../auth/auth_provider.dart'; import '../auth/auth_provider.dart';
import 'scan_mode_provider.dart'; import '../documents/pending_documents_provider.dart';
import 'camera_drawer_helpers.dart';
import 'camera_drawer_mode_toggle.dart';
/// Main navigation drawer for the camera screen.
///
/// Split during task 8.1 (AGENTS.md §3 — 256-line threshold on touched files):
/// - [CameraDrawerModeToggle] owns the DO/Product mode toggle pill and
/// [scanModeProvider] read/write (including the new doModeColor cue).
/// - [camera_drawer_helpers.dart] contains stateless UI helpers
/// (section header, drawer item, dialogs).
class CameraDrawer extends ConsumerStatefulWidget { class CameraDrawer extends ConsumerStatefulWidget {
const CameraDrawer({super.key}); const CameraDrawer({super.key});
@@ -33,19 +42,16 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final activeMode = ref.watch(scanModeProvider);
return Drawer( return Drawer(
backgroundColor: Colors.white, backgroundColor: Colors.white,
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
// Close Button & Profile Row // ── Profile header ─────────────────────────────────────────
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Column( child: Column(
children: [ children: [
// Close Button
Align( Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: IconButton( child: IconButton(
@@ -54,7 +60,6 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Profile Row
Row( Row(
children: [ children: [
Container( Container(
@@ -68,10 +73,7 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
child: ClipOval( child: ClipOval(
child: Padding( child: Padding(
padding: const EdgeInsets.all(4.0), padding: const EdgeInsets.all(4.0),
child: Image.asset( child: Image.asset('assets/logo.png', fit: BoxFit.contain),
'assets/logo.png',
fit: BoxFit.contain,
),
), ),
), ),
), ),
@@ -109,136 +111,22 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
const Divider(height: 1, thickness: 1), const Divider(height: 1, thickness: 1),
const SizedBox(height: 20), const SizedBox(height: 20),
// Mode Toggle Section (between DO and Product Scan) // ── Mode toggle (DO/Product) ────────────────────────────────
Padding( const CameraDrawerModeToggle(),
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mode',
style: AppConfig.bodySmall.copyWith(
color: Colors.grey.shade500,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Container(
height: 48,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
ref.read(scanModeProvider.notifier).state = 'DO';
},
child: Container(
decoration: BoxDecoration(
color: activeMode == 'DO' ? AppConfig.primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: activeMode == 'DO'
? [
BoxShadow(
color: AppConfig.primaryColor.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: [],
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
activeMode == 'DO' ? Icons.description : Icons.description_outlined,
size: 16,
color: activeMode == 'DO' ? Colors.white : Colors.grey.shade500,
),
const SizedBox(width: 6),
Text(
'DO Scan',
style: AppConfig.bodyMedium.copyWith(
fontWeight: activeMode == 'DO' ? FontWeight.bold : FontWeight.normal,
color: activeMode == 'DO' ? Colors.white : Colors.grey.shade600,
),
),
],
),
),
),
),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
ref.read(scanModeProvider.notifier).state = 'Product';
},
child: Container(
decoration: BoxDecoration(
color: activeMode == 'Product' ? AppConfig.primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: activeMode == 'Product'
? [
BoxShadow(
color: AppConfig.primaryColor.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: [],
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
activeMode == 'Product' ? Icons.inventory_2 : Icons.inventory_2_outlined,
size: 16,
color: activeMode == 'Product' ? Colors.white : Colors.grey.shade500,
),
const SizedBox(width: 6),
Text(
'Product Scan',
style: AppConfig.bodyMedium.copyWith(
fontWeight: activeMode == 'Product' ? FontWeight.bold : FontWeight.normal,
color: activeMode == 'Product' ? Colors.white : Colors.grey.shade600,
),
),
],
),
),
),
),
],
),
),
),
],
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
// Menu Items List with Grouping Sections // ── Menu items ─────────────────────────────────────────────
Expanded( Expanded(
child: ListView( child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12.0), padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: [ children: [
_buildSectionHeader('DASHBOARD'), buildDrawerSectionHeader('DASHBOARD'),
_buildDrawerItem( buildDrawerItem(
icon: Icons.home_outlined, icon: Icons.home_outlined,
title: 'Home', title: 'Home',
onTap: () => Navigator.pop(context), onTap: () => Navigator.pop(context),
), ),
_buildDrawerItem( buildDrawerItem(
icon: Icons.history_outlined, icon: Icons.history_outlined,
title: 'History', title: 'History',
onTap: () { onTap: () {
@@ -247,58 +135,55 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildSectionHeader('USER PROFILE'), buildDrawerSectionHeader('USER PROFILE'),
_buildDrawerItem( buildDrawerItem(
icon: Icons.person_outline, icon: Icons.person_outline,
title: 'Profile', title: 'Profile',
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showNotImplemented('Profile'); showDrawerNotImplemented(context, 'Profile');
}, },
), ),
_buildDrawerItem( buildDrawerItem(
icon: Icons.notifications_none_outlined, icon: Icons.notifications_none_outlined,
title: 'Notifications', title: 'Notifications',
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showNotImplemented('Notifications'); showDrawerNotImplemented(context, 'Notifications');
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildSectionHeader('SUPPORT & SETTINGS'), buildDrawerSectionHeader('SUPPORT & SETTINGS'),
_buildDrawerItem( buildDrawerItem(
icon: Icons.settings_outlined, icon: Icons.settings_outlined,
title: 'Settings', title: 'Settings',
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showNotImplemented('Settings'); showDrawerNotImplemented(context, 'Settings');
}, },
), ),
_buildDrawerItem( buildDrawerItem(
icon: Icons.help_outline, icon: Icons.help_outline,
title: 'Help', title: 'Help',
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showHelpDialog(); showDrawerHelpDialog(context);
}, },
), ),
], ],
), ),
), ),
// Logout at the bottom // ── Logout ─────────────────────────────────────────────────
const Divider(height: 1), const Divider(height: 1),
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: _buildDrawerItem( child: buildDrawerItem(
icon: Icons.logout_outlined, icon: Icons.logout_outlined,
title: 'Logout', title: 'Logout',
textColor: AppConfig.errorColor, textColor: AppConfig.errorColor,
iconColor: AppConfig.errorColor, iconColor: AppConfig.errorColor,
onTap: () { onTap: _handleLogout,
ref.read(authProvider.notifier).logout();
context.go('/login');
},
), ),
), ),
], ],
@@ -307,70 +192,42 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
); );
} }
Widget _buildSectionHeader(String title) { /// Warns before logout if the pending-documents queue still has unsynced
return Padding( /// items — a plain logout wipes the local Hive cache and clears the queue
padding: const EdgeInsets.only(left: 16.0, top: 8.0, bottom: 8.0), /// (see AuthNotifier.logout()), silently discarding any in-flight
child: Text( /// uploads, unreviewed scans, or failed syncs otherwise.
title, Future<void> _handleLogout() async {
style: AppConfig.bodySmall.copyWith( final pendingCount = ref.read(pendingDocumentsProvider).length;
color: Colors.grey.shade400,
fontWeight: FontWeight.bold,
fontSize: 11,
letterSpacing: 1.0,
),
),
);
}
Widget _buildDrawerItem({ if (pendingCount > 0) {
required IconData icon, final confirmed = await showDialog<bool>(
required String title,
required VoidCallback onTap,
Color? textColor,
Color? iconColor,
}) {
return ListTile(
leading: Icon(icon, color: iconColor ?? Colors.black87),
title: Text(
title,
style: AppConfig.bodyLarge.copyWith(
color: textColor ?? Colors.black87,
fontWeight: FontWeight.w500,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
onTap: onTap,
);
}
void _showNotImplemented(String featureName) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$featureName feature coming soon!'),
behavior: SnackBarBehavior.floating,
),
);
}
void _showHelpDialog() {
showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Tentang Prima Mart'), title: const Text('Ada Dokumen Belum Tersinkron'),
content: const SingleChildScrollView( content: Text(
child: Text( 'Ada $pendingCount dokumen yang belum selesai diunggah atau '
'www.primafreshmart.com merupakan sarana belanja online dari toko Prima Mart yang sudah melayani konsumen selama 10 tahun dan memiliki hampir 1200 gerai di Pulau Jawa, Bali dan Sumatera. Inovasi terbaru dari PT. Primafood International sebagai bagian dari PT. Charoen Pokphand Indonesia, Tbk.\n\nPT. Primafood International\nJl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.', 'disinkronkan ke server. Jika Anda logout sekarang, dokumen '
), 'tersebut akan hilang.',
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Tutup'), child: const Text('Batal'),
) ),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text('Ya, Logout', style: TextStyle(color: AppConfig.errorColor)),
),
], ],
), ),
); );
if (confirmed != true) return;
}
if (!mounted) return;
await ref.read(authProvider.notifier).logout();
if (!mounted) return;
context.go('/login');
} }
} }
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import '../../config/app_config.dart';
/// Stateless helper functions extracted from [CameraDrawer] to keep that
/// file under the AGENTS.md §3 256-line threshold (touched in task 8.1).
/// Contains only UI helpers with no side-effects or state.
Widget buildDrawerSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8.0, bottom: 8.0),
child: Text(
title,
style: AppConfig.bodySmall.copyWith(
color: Colors.grey.shade400,
fontWeight: FontWeight.bold,
fontSize: 11,
letterSpacing: 1.0,
),
),
);
}
Widget buildDrawerItem({
required IconData icon,
required String title,
required VoidCallback onTap,
Color? textColor,
Color? iconColor,
}) {
return ListTile(
leading: Icon(icon, color: iconColor ?? Colors.black87),
title: Text(
title,
style: AppConfig.bodyLarge.copyWith(
color: textColor ?? Colors.black87,
fontWeight: FontWeight.w500,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
onTap: onTap,
);
}
void showDrawerNotImplemented(BuildContext context, String featureName) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(' feature coming soon!'),
behavior: SnackBarBehavior.floating,
),
);
}
void showDrawerHelpDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Tentang Prima Mart'),
content: const SingleChildScrollView(
child: Text(
'www.primafreshmart.com merupakan sarana belanja online dari toko Prima Mart '
'yang sudah melayani konsumen selama 10 tahun dan memiliki hampir 1200 gerai '
'di Pulau Jawa, Bali dan Sumatera. Inovasi terbaru dari PT. Primafood '
'International sebagai bagian dari PT. Charoen Pokphand Indonesia, Tbk.\n\n'
'PT. Primafood International\n'
'Jl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Tutup'),
)
],
),
);
}
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../config/app_config.dart';
import 'scan_mode_provider.dart';
/// DO / Product Scan mode toggle pill rendered in the [CameraDrawer].
///
/// Extracted from [CameraDrawer] to keep that file under the 256-line §3
/// threshold (AGENTS.md §3, touched during task 8.1). Reads and writes
/// [scanModeProvider] directly — the global source of truth shared with
/// [DocumentsScreen] (task 8.1, docs/api-contract-map.md G11).
///
/// Color convention:
/// - DO active → [AppConfig.doModeColor] (orange)
/// - Product active → [AppConfig.primaryColor] (green)
class CameraDrawerModeToggle extends ConsumerWidget {
const CameraDrawerModeToggle({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeMode = ref.watch(scanModeProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mode',
style: AppConfig.bodySmall.copyWith(
color: Colors.grey.shade500,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Container(
height: 48,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
_buildSegment(
ref: ref,
value: 'DO',
label: 'DO Scan',
activeIcon: Icons.description,
inactiveIcon: Icons.description_outlined,
activeMode: activeMode,
activeColor: AppConfig.doModeColor,
),
_buildSegment(
ref: ref,
value: 'Product',
label: 'Product Scan',
activeIcon: Icons.inventory_2,
inactiveIcon: Icons.inventory_2_outlined,
activeMode: activeMode,
activeColor: AppConfig.primaryColor,
),
],
),
),
),
],
),
);
}
Widget _buildSegment({
required WidgetRef ref,
required String value,
required String label,
required IconData activeIcon,
required IconData inactiveIcon,
required String activeMode,
required Color activeColor,
}) {
final isActive = activeMode == value;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => ref.read(scanModeProvider.notifier).state = value,
child: Container(
decoration: BoxDecoration(
color: isActive ? activeColor : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: isActive
? [
BoxShadow(
color: activeColor.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: [],
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isActive ? activeIcon : inactiveIcon,
size: 16,
color: isActive ? Colors.white : Colors.grey.shade500,
),
const SizedBox(width: 6),
Text(
label,
style: AppConfig.bodyMedium.copyWith(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? Colors.white : Colors.grey.shade600,
),
),
],
),
),
),
);
}
}
+21 -9
View File
@@ -6,33 +6,45 @@ import '../../models/document_model.dart';
class DocumentCard extends StatelessWidget { class DocumentCard extends StatelessWidget {
final DocumentModel doc; final DocumentModel doc;
final Future<void> Function(DocumentModel) onPrint; final Future<void> Function(DocumentModel) onPrint;
final bool isUnsynced;
const DocumentCard({ const DocumentCard({
super.key, super.key,
required this.doc, required this.doc,
required this.onPrint, required this.onPrint,
this.isUnsynced = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isProductScan = doc.orderUntuk == 'PRODUCT SCAN'; final isProductScan = doc.docType == 'Product';
// Header category text // Header category text
final categoryText = isProductScan final categoryText = isProductScan
? (doc.items.isNotEmpty ? doc.items.first.namaBarang : 'Product Scan') ? (doc.items.isNotEmpty ? doc.items.first.namaBarang : 'Product Scan')
: (doc.namaPenerima.isNotEmpty ? doc.namaPenerima : 'Staff Toko'); : (doc.namaPenerima.isNotEmpty ? doc.namaPenerima : 'Staff Toko');
// Category color (greenish-blue for product, orange-yellow for DO) // Category color: Product → green (primaryColor), DO → orange (doModeColor).
final categoryColor = isProductScan ? AppConfig.primaryColor : Colors.orange.shade700; // doModeColor is the shared constant for the DO orange cue across the app
// (camera drawer, tab switcher, and here) — was an ad-hoc inline value.
final categoryColor = isProductScan ? AppConfig.primaryColor : AppConfig.doModeColor;
// Sync status badge (see docs/api-contract-map.md G6 - a document can be
// corrected locally after its PUT sync failed; the corrected copy is
// shown here, so the badge must reflect that it hasn't reached the
// server yet rather than always claiming "Terkonfirmasi").
final badgeLabel = isUnsynced ? 'Belum Tersinkron' : 'Terkonfirmasi';
final badgeColor = isUnsynced ? Colors.deepOrange : AppConfig.successColor;
// Bold title // Bold title
final boldTitle = isProductScan final boldTitle = isProductScan
? doc.noDo ? doc.noDo
: doc.noPo; : doc.noPo;
// Subtitle text // Subtitle text. Product Scan no longer links to a PO document (field
// removed - user request), so its subtitle is just the expiry date.
final subtitleText = isProductScan final subtitleText = isProductScan
? '${_formatDateString(doc.alamat.replaceAll("Product Scan: Expired ", ""))} • ${doc.noPo}' ? _formatDateString(doc.alamat.replaceAll("Product Scan: Expired ", ""))
: '${doc.tanggal} • ${doc.items.length} Item'; : '${doc.tanggal} • ${doc.items.length} Item';
return Card( return Card(
@@ -92,17 +104,17 @@ class DocumentCard extends StatelessWidget {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
// Status badge (e.g. Approved / Terkonfirmasi) // Status badge (Terkonfirmasi / Belum Tersinkron)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppConfig.successColor.withOpacity(0.1), color: badgeColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall), borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
), ),
child: Text( child: Text(
'Terkonfirmasi', badgeLabel,
style: AppConfig.bodySmall.copyWith( style: AppConfig.bodySmall.copyWith(
color: AppConfig.successColor, color: badgeColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 11, fontSize: 11,
), ),
@@ -0,0 +1,33 @@
import '../../models/document_model.dart';
/// Result of merging the server's document list with local corrections that
/// haven't made it back to the server yet.
class MergedDocuments {
final List<DocumentModel> docs;
final Set<String> unsyncedIds;
const MergedDocuments(this.docs, this.unsyncedIds);
}
/// Merges the server's authoritative document list with local corrections for
/// documents whose `PUT` sync failed (`syncFailed` in the pending queue).
///
/// Without this, refreshing the documents list would silently overwrite a
/// driver's corrected-but-unsynced edit with the server's stale pre-edit copy
/// on every successful fetch - the correction is still recoverable via the
/// pending queue's retry action, but the history list would show the wrong
/// data (or the document would vanish from it entirely) until the retry
/// succeeds. `unsyncedOverrides` wins over `serverDocs` for matching ids, and
/// is kept even if the server list doesn't (yet) include that id at all.
MergedDocuments mergeDocumentsWithUnsyncedOverrides({
required List<DocumentModel> serverDocs,
required Map<String, DocumentModel> unsyncedOverrides,
}) {
final merged = <String, DocumentModel>{
for (final doc in serverDocs) doc.id: doc,
};
for (final entry in unsyncedOverrides.entries) {
merged[entry.key] = entry.value;
}
return MergedDocuments(merged.values.toList(), unsyncedOverrides.keys.toSet());
}
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import '../../config/app_config.dart';
class DocumentsEmptyState extends StatelessWidget {
const DocumentsEmptyState({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.folder_open,
size: 64,
color: AppConfig.textSecondary.withOpacity(0.5),
),
const SizedBox(height: AppConfig.spaceLarge),
Text('Tidak ada dokumen ditemukan', style: AppConfig.bodyLarge),
],
),
);
}
}
+52 -126
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -11,6 +10,10 @@ import '../editor/pdf_service.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import 'pending_document_card.dart'; import 'pending_document_card.dart';
import 'document_card.dart'; import 'document_card.dart';
import '../camera/scan_mode_provider.dart';
import 'documents_tab_switcher.dart';
import 'documents_empty_state.dart';
import 'document_sync_merge.dart';
class DocumentsScreen extends ConsumerStatefulWidget { class DocumentsScreen extends ConsumerStatefulWidget {
const DocumentsScreen({super.key}); const DocumentsScreen({super.key});
@@ -23,7 +26,12 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
List<DocumentModel> _allDocuments = []; List<DocumentModel> _allDocuments = [];
List<DocumentModel> _filteredDocuments = []; List<DocumentModel> _filteredDocuments = [];
String _selectedTab = 'DO'; // 'DO' | 'Product' Set<String> _unsyncedDocIds = {};
// _selectedTab removed in task 8.1 -- scanModeProvider is the single source
// of truth for the active tab, shared with CameraDrawer / CameraDrawerModeToggle.
// Caching it locally (and seeding from the provider in initState) caused mode
// desync when the camera drawer changed the mode after this screen was open.
// See docs/api-contract-map.md G11, plans/next-enhancements.md §8.1.
@override @override
void initState() { void initState() {
@@ -34,71 +42,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
Future<void> _loadDocuments() async { Future<void> _loadDocuments() async {
final localStorage = ref.read(localStorageProvider); final localStorage = ref.read(localStorageProvider);
var docs = localStorage.getAllDocuments(); final docs = localStorage.getAllDocuments();
final prefs = await SharedPreferences.getInstance();
final currentStoreName = prefs.getString('nama_toko') ?? 'PM KELAPA DUA KARAWACI';
bool databaseUpdated = false;
final hasProductScan = docs.any((doc) => doc.orderUntuk == 'PRODUCT SCAN' && doc.kepadaYth == currentStoreName);
if (!hasProductScan) {
final mockProdDoc = DocumentModel(
id: 'MOCK-PROD-SCAN-1',
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now().subtract(const Duration(days: 1))),
noPo: 'PO-PROD-12010119',
noSo: '1002003004',
noDo: 'Batch 1',
kepadaYth: currentStoreName,
orderUntuk: 'PRODUCT SCAN',
alamat: 'Product Scan: Expired 15/12/2026',
platTruk: 'B 1234 PFM',
namaDriver: 'PRODUCT SCAN',
namaPenerima: 'STORE STAFF',
items: [
DocumentItem(
nomorSku: '12010119',
namaBarang: 'FIESTA SPICY CHICKEN NUGGET 500 GR',
banyak: '1',
jumlah: '1',
)
],
createdAt: DateTime.now().subtract(const Duration(days: 1)),
);
await localStorage.saveDocument(mockProdDoc);
databaseUpdated = true;
}
final hasDoScan = docs.any((doc) => doc.orderUntuk != 'PRODUCT SCAN' && doc.kepadaYth == currentStoreName);
if (!hasDoScan) {
final mockDoDoc = DocumentModel(
id: 'MOCK-DO-SCAN-1',
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now().subtract(const Duration(days: 2))),
noPo: 'PO-9923488',
noSo: '1002008892',
noDo: 'DO-9988223',
kepadaYth: currentStoreName,
orderUntuk: 'DELIVERY ORDER',
alamat: 'Jl. Boulevard Raya No. 12, Karawaci',
platTruk: 'B 9876 PFM',
namaDriver: 'Budi Santoso',
namaPenerima: 'Staff Toko',
items: [
DocumentItem(
nomorSku: '12010119',
namaBarang: 'FIESTA SPICY CHICKEN NUGGET 500 GR',
banyak: '10',
jumlah: '10',
)
],
createdAt: DateTime.now().subtract(const Duration(days: 2)),
);
await localStorage.saveDocument(mockDoDoc);
databaseUpdated = true;
}
if (databaseUpdated) {
docs = localStorage.getAllDocuments();
}
setState(() { setState(() {
_allDocuments = docs; _allDocuments = docs;
@@ -113,7 +57,22 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
.map((json) => DocumentModel.fromJson(Map<String, dynamic>.from(json))) .map((json) => DocumentModel.fromJson(Map<String, dynamic>.from(json)))
.toList(); .toList();
for (var doc in serverDocs) { // A document whose editor save PUT failed (syncFailed) still has its
// corrected version saved locally - don't let the server's stale
// pre-edit copy silently overwrite it on refresh (see
// docs/api-contract-map.md G6).
final unsyncedOverrides = <String, DocumentModel>{
for (final pending in ref.read(pendingDocumentsProvider))
if (pending.status == PendingDocumentStatus.syncFailed && pending.document != null)
pending.document!.id: pending.document!,
};
final merged = mergeDocumentsWithUnsyncedOverrides(
serverDocs: serverDocs,
unsyncedOverrides: unsyncedOverrides,
);
await localStorage.documentBox.clear();
for (var doc in merged.docs) {
await localStorage.saveDocument(doc); await localStorage.saveDocument(doc);
} }
@@ -122,6 +81,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
setState(() { setState(() {
_allDocuments = updatedDocs; _allDocuments = updatedDocs;
_filteredDocuments = updatedDocs; _filteredDocuments = updatedDocs;
_unsyncedDocIds = merged.unsyncedIds;
}); });
} }
} catch (e) { } catch (e) {
@@ -158,11 +118,21 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pendingDocs = ref.watch(pendingDocumentsProvider); final pendingDocs = ref.watch(pendingDocumentsProvider);
// Single source of truth for the active tab -- shared with CameraDrawer
// via scanModeProvider (task 8.1, docs/api-contract-map.md G11).
final selectedTab = ref.watch(scanModeProvider);
final tabFilteredPendingDocs = pendingDocs.where((doc) {
final isProduct = doc.scanMode == 'Product';
if (selectedTab == 'DO') return !isProduct;
if (selectedTab == 'Product') return isProduct;
return true;
}).toList();
final tabFilteredDocs = _filteredDocuments.where((doc) { final tabFilteredDocs = _filteredDocuments.where((doc) {
final isProduct = doc.orderUntuk == 'PRODUCT SCAN'; final isProduct = doc.docType == 'Product';
if (_selectedTab == 'DO') return !isProduct; if (selectedTab == 'DO') return !isProduct;
if (_selectedTab == 'Product') return isProduct; if (selectedTab == 'Product') return isProduct;
return true; return true;
}).toList(); }).toList();
@@ -178,14 +148,14 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
final List<Widget> listItems = []; final List<Widget> listItems = [];
if (pendingDocs.isNotEmpty) { if (tabFilteredPendingDocs.isNotEmpty) {
listItems.add( listItems.add(
Padding( Padding(
padding: const EdgeInsets.only(bottom: AppConfig.spaceSmall), padding: const EdgeInsets.only(bottom: AppConfig.spaceSmall),
child: Text('Tertunda & Diproses', style: AppConfig.h3), child: Text('Tertunda & Diproses', style: AppConfig.h3),
), ),
); );
listItems.addAll(pendingDocs.map((doc) => PendingDocumentCard( listItems.addAll(tabFilteredPendingDocs.map((doc) => PendingDocumentCard(
doc: doc, doc: doc,
onReload: () { if (mounted) _loadDocuments(); }, onReload: () { if (mounted) _loadDocuments(); },
))); )));
@@ -211,6 +181,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
listItems.addAll(docs.map((doc) => DocumentCard( listItems.addAll(docs.map((doc) => DocumentCard(
doc: doc, doc: doc,
onPrint: _printReceipt, onPrint: _printReceipt,
isUnsynced: _unsyncedDocIds.contains(doc.id),
))); )));
}); });
} }
@@ -233,19 +204,14 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
), ),
), ),
), ),
_buildTabSwitcher(), DocumentsTabSwitcher(
Expanded( selectedTab: selectedTab,
child: (pendingDocs.isEmpty && tabFilteredDocs.isEmpty) onTabChanged: (tab) =>
? Center( ref.read(scanModeProvider.notifier).state = tab,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder_open, size: 64, color: AppConfig.textSecondary.withOpacity(0.5)),
const SizedBox(height: AppConfig.spaceLarge),
Text('Tidak ada dokumen ditemukan', style: AppConfig.bodyLarge),
],
), ),
) Expanded(
child: (tabFilteredPendingDocs.isEmpty && tabFilteredDocs.isEmpty)
? const DocumentsEmptyState()
: ListView( : ListView(
padding: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge), padding: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge),
children: listItems, children: listItems,
@@ -255,44 +221,4 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
), ),
); );
} }
Widget _buildTabSwitcher() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge, vertical: AppConfig.spaceSmall),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium)),
child: Row(
children: [
_buildTabItem('DO', 'DO Scan'),
_buildTabItem('Product', 'Product Scan'),
],
),
);
}
Widget _buildTabItem(String value, String label) {
final isActive = _selectedTab == value;
return Expanded(
child: GestureDetector(
onTap: () => setState(() { _selectedTab = value; }),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
boxShadow: isActive ? [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2))] : null,
),
child: Center(
child: Text(
label,
style: TextStyle(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? AppConfig.primaryColor : AppConfig.textSecondary,
),
),
),
),
),
);
}
} }
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../../config/app_config.dart';
class DocumentsTabSwitcher extends StatelessWidget {
final String selectedTab;
final ValueChanged<String> onTabChanged;
const DocumentsTabSwitcher({
super.key,
required this.selectedTab,
required this.onTabChanged,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(
horizontal: AppConfig.spaceLarge,
vertical: AppConfig.spaceSmall,
),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
),
child: Row(
children: [
_buildTabItem('DO', 'DO Scan', Icons.description, Icons.description_outlined),
_buildTabItem('Product', 'Product Scan', Icons.inventory_2, Icons.inventory_2_outlined),
],
),
);
}
Widget _buildTabItem(String value, String label, IconData activeIcon, IconData inactiveIcon) {
final isActive = selectedTab == value;
// Color cue: DO mode -> doModeColor (orange), Product mode -> primaryColor (green).
// Applies to both the icon and the label text (user feedback: "icon dan
// juga button turn color sesuai warna statenya") - mirrors the same
// icon+color pairing already used in camera_drawer_mode_toggle.dart and
// document_card.dart's top-label (task 8.1, docs/api-contract-map.md G11).
// Generic/default chrome elsewhere (search icon, print icon, tooltips)
// deliberately stays untouched.
final activeColor = value == 'DO' ? AppConfig.doModeColor : AppConfig.primaryColor;
final color = isActive ? activeColor : AppConfig.textSecondary;
return Expanded(
child: GestureDetector(
onTap: () => onTabChanged(value),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
boxShadow: isActive
? [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(isActive ? activeIcon : inactiveIcon, size: 16, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: color,
),
),
],
),
),
),
);
}
}
@@ -2,7 +2,6 @@ export 'pending_document_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:intl/intl.dart';
import '../../models/document_model.dart'; import '../../models/document_model.dart';
import '../../core/location/location_service.dart'; import '../../core/location/location_service.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
@@ -11,6 +10,7 @@ import '../../config/app_config.dart';
import '../../main.dart'; import '../../main.dart';
import '../camera/scan_mode_provider.dart'; import '../camera/scan_mode_provider.dart';
import 'pending_document_model.dart'; import 'pending_document_model.dart';
import 'poll_outcome.dart';
class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> { class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
PendingDocumentsNotifier(this._ref) : super([]) { PendingDocumentsNotifier(this._ref) : super([]) {
@@ -82,52 +82,16 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
} catch (_) {} } catch (_) {}
} }
final scanMode = _ref.read(scanModeProvider);
if (scanMode == 'Product') {
await Future.delayed(const Duration(seconds: 2));
final todayStr = DateFormat('dd MMMM yyyy').format(DateTime.now());
final dummyDoc = DocumentModel(
id: id,
tanggal: todayStr,
noPo: 'PO-PRODUCT-001',
noSo: '1002003004',
noDo: 'DO-PRODUCT-999',
kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'REPLENISHMENT SKU',
alamat: 'Jl. Ancol Barat VIII no.1, Jakarta Utara',
platTruk: 'B 1234 PFM',
namaDriver: 'DUMMY DRIVER (PRODUCT SCAN)',
namaPenerima: '',
latitude: lat,
longitude: lng,
items: [
DocumentItem(
nomorSku: '12010119',
namaBarang: 'FIESTA SPICY CHICKEN NUGGET 500 GR',
banyak: '10',
jumlah: '10',
),
DocumentItem(
nomorSku: '12012504',
namaBarang: 'AKUMO NUGGET COIN 200 GR',
banyak: '5',
jumlah: '5',
),
],
createdAt: DateTime.now(),
);
if (!mounted) return;
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: dummyDoc));
return;
}
try { try {
final scanMode = _ref.read(scanModeProvider);
final apiClient = _ref.read(apiClientProvider); final apiClient = _ref.read(apiClientProvider);
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'image': await MultipartFile.fromFile(imagePath, filename: imagePath.split(RegExp(r'[/\\]')).last), 'image': await MultipartFile.fromFile(imagePath, filename: imagePath.split(RegExp(r'[/\\]')).last),
if (lat != null) 'latitude': lat.toString(), if (lat != null) 'latitude': lat.toString(),
if (lng != null) 'longitude': lng.toString(), if (lng != null) 'longitude': lng.toString(),
'scan_mode': scanMode,
}); });
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData); final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
@@ -153,23 +117,26 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
Future<void> _pollUntilParsed(String id, DocumentModel doc) async { Future<void> _pollUntilParsed(String id, DocumentModel doc) async {
final apiClient = _ref.read(apiClientProvider); final apiClient = _ref.read(apiClientProvider);
bool isParsed = false; DocumentModel? resultDoc;
DocumentModel? parsedDoc; String? failureMessage;
int retries = 0; int retries = 0;
const maxRetries = 130; const maxRetries = 130;
while (!isParsed && retries < maxRetries && mounted) { while (retries < maxRetries && mounted) {
await Future.delayed(const Duration(seconds: 2)); await Future.delayed(const Duration(seconds: 2));
if (!mounted) return; if (!mounted) return;
try { try {
final getRes = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint); final getRes = await apiClient.client.get('${AppConfig.fetchDocumentsEndpoint}/${doc.id}');
if (getRes.statusCode == 200 && getRes.data['data'] is List) { if (getRes.statusCode == 200 && getRes.data['data'] != null) {
final list = (getRes.data['data'] as List).map((e) => DocumentModel.fromJson(Map<String, dynamic>.from(e))).toList(); final fetched = DocumentModel.fromJson(Map<String, dynamic>.from(getRes.data['data']));
final found = list.firstWhere((element) => element.id == doc.id, orElse: () => doc); final outcome = resolvePollOutcome(fetched);
if (found != doc) { if (outcome.kind == PollOutcomeKind.done) {
isParsed = true; resultDoc = outcome.document;
parsedDoc = found; break;
} else if (outcome.kind == PollOutcomeKind.failed) {
failureMessage = outcome.message;
break;
} }
} }
} catch (_) {} } catch (_) {}
@@ -177,10 +144,10 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
} }
if (!mounted) return; if (!mounted) return;
if (isParsed && parsedDoc != null) { if (resultDoc != null) {
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: parsedDoc)); _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: resultDoc));
} else { } else {
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: 'Gagal mengekstrak data (Timeout).')); _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: failureMessage ?? 'Gagal mengekstrak data (Timeout).'));
} }
} }
@@ -220,6 +187,11 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null)); _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
_uploadAndProcess(id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude); _uploadAndProcess(id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
} }
void clearQueue() {
state = const [];
_ref.read(localStorageProvider).pendingDocumentsBox.clear();
}
} }
final pendingDocumentsProvider = StateNotifierProvider<PendingDocumentsNotifier, List<PendingDocument>>((ref) { final pendingDocumentsProvider = StateNotifierProvider<PendingDocumentsNotifier, List<PendingDocument>>((ref) {
+39
View File
@@ -0,0 +1,39 @@
import '../../models/document_model.dart';
enum PollOutcomeKind { done, failed, pending }
/// Result of inspecting a single `GET /documents/:id` poll response.
/// Deliberately separated from the Dio call site
/// (`pending_documents_provider.dart`'s `_pollUntilParsed`) so the
/// parse-status branching is unit-testable without mocking network I/O -
/// mirrors the pure-function pattern already used for `document_sync_merge.dart`
/// (task 6.2) and `product_scan_response_parser.dart` (task 7.1).
class PollOutcome {
final PollOutcomeKind kind;
final DocumentModel? document;
final String? message;
const PollOutcome._(this.kind, {this.document, this.message});
factory PollOutcome.done(DocumentModel document) =>
PollOutcome._(PollOutcomeKind.done, document: document);
factory PollOutcome.failed(String message) =>
PollOutcome._(PollOutcomeKind.failed, message: message);
static const PollOutcome pending = PollOutcome._(PollOutcomeKind.pending);
}
/// `parseStatus` absent/unrecognized (a legacy/cached response predating
/// backend task 9.1) is treated the same as `"pending"` - keep polling rather
/// than surface a false failure.
PollOutcome resolvePollOutcome(DocumentModel fetched) {
switch (fetched.parseStatus) {
case 'done':
return PollOutcome.done(fetched);
case 'failed':
return PollOutcome.failed('Gagal mengekstrak data (parsing gagal di server).');
default:
return PollOutcome.pending;
}
}
@@ -0,0 +1,53 @@
import '../../models/document_model.dart';
enum DocumentSaveActionKind { putExisting, reuploadThenPut, blocked }
/// Decides how an editor's save flow should reach the server, given whether
/// a real server-assigned document is already resolved. Deliberately
/// separated from the Dio call sites (`editor_screen.dart`,
/// `product_editor_logic.dart`) so the guard against PUTting to a
/// fabricated client-side ID - a millisecond timestamp that can never match
/// the int4 `documents.id` (gap G5, `docs/api-contract-map.md`) - is
/// unit-testable without mocking network I/O, mirroring the pure-decision
/// pattern used by `poll_outcome.dart` (task 6.1).
class DocumentSaveAction {
final DocumentSaveActionKind kind;
final String? serverId;
final String? blockedMessage;
const DocumentSaveAction._(this.kind, {this.serverId, this.blockedMessage});
factory DocumentSaveAction.putExisting(String serverId) =>
DocumentSaveAction._(DocumentSaveActionKind.putExisting, serverId: serverId);
factory DocumentSaveAction.reuploadThenPut() =>
const DocumentSaveAction._(DocumentSaveActionKind.reuploadThenPut);
factory DocumentSaveAction.blocked(String message) =>
DocumentSaveAction._(DocumentSaveActionKind.blocked, blockedMessage: message);
}
/// [existingDocument] is the server document already resolved from a
/// completed upload+parse (e.g. `PendingDocument.document`) - when present,
/// its real id is used for the PUT. When absent (e.g. the editor route lost
/// its `pendingId` on process-death restoration), [localImagePath] - the
/// pending item's originally-captured photo - lets the save flow recover a
/// real server id by re-uploading it (safe: the backend dedups by
/// `file_hash`) before PUTting the corrected fields. If neither is
/// available there is nothing to save against, so saving is blocked with an
/// explicit message instead of PUTting to a made-up ID that can never match
/// a real document.
DocumentSaveAction resolveDocumentSaveAction({
required DocumentModel? existingDocument,
required String localImagePath,
}) {
if (existingDocument != null) {
return DocumentSaveAction.putExisting(existingDocument.id);
}
if (localImagePath.isEmpty) {
return DocumentSaveAction.blocked(
'Tidak dapat menyimpan: dokumen asli tidak ditemukan di perangkat. Silakan pindai ulang.',
);
}
return DocumentSaveAction.reuploadThenPut();
}
+238
View File
@@ -0,0 +1,238 @@
part of 'editor_screen.dart';
mixin EditorLogic on ConsumerState<EditorScreen> {
DocumentModel? _document;
// Controllers
final _tanggalCtrl = TextEditingController();
final _noPoCtrl = TextEditingController();
final _noSoCtrl = TextEditingController();
final _noDoCtrl = TextEditingController();
final _kepadaYthCtrl = TextEditingController();
final _orderUntukCtrl = TextEditingController();
final _alamatCtrl = TextEditingController();
final _platTrukCtrl = TextEditingController();
final _namaDriverCtrl = TextEditingController();
final _namaPenerimaCtrl = TextEditingController();
final _latitudeCtrl = TextEditingController();
final _longitudeCtrl = TextEditingController();
List<Map<String, TextEditingController>> _itemControllers = [];
bool _isAgreed = false;
final _formKey = GlobalKey<FormState>();
void _loadDocumentData() {
if (widget.pendingId == null) return;
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
if (pendingDoc.document != null) {
final doc = pendingDoc.document!;
setState(() {
_document = doc;
_tanggalCtrl.text = doc.tanggal;
_noPoCtrl.text = doc.noPo;
_noSoCtrl.text = doc.noSo;
_noDoCtrl.text = doc.noDo;
_kepadaYthCtrl.text = doc.kepadaYth;
_orderUntukCtrl.text = doc.orderUntuk;
_alamatCtrl.text = doc.alamat;
_platTrukCtrl.text = doc.platTruk;
_namaDriverCtrl.text = doc.namaDriver;
final lat = pendingDoc.latitude ?? doc.latitude;
final lng = pendingDoc.longitude ?? doc.longitude;
_latitudeCtrl.text = lat != null ? lat.toString() : '-';
_longitudeCtrl.text = lng != null ? lng.toString() : '-';
for (var item in doc.items) {
_addItem(
sku: item.nomorSku,
nama: item.namaBarang,
banyak: item.banyak,
jumlah: item.jumlah,
);
}
});
}
}
void _addItem({String sku = '', String nama = '', String banyak = '', String jumlah = ''}) {
final skuCtrl = TextEditingController(text: sku);
final namaCtrl = TextEditingController(text: nama);
final banyakCtrl = TextEditingController(text: banyak);
final jumlahCtrl = TextEditingController(text: jumlah);
setState(() {
_itemControllers.add({
'sku': skuCtrl,
'nama': namaCtrl,
'banyak': banyakCtrl,
'jumlah': jumlahCtrl,
});
});
}
void _removeItem(int index) {
setState(() {
final ctrls = _itemControllers[index];
ctrls['sku']?.dispose();
ctrls['nama']?.dispose();
ctrls['banyak']?.dispose();
ctrls['jumlah']?.dispose();
_itemControllers.removeAt(index);
});
}
void _showConfirmationDialog() {
if (!_formKey.currentState!.validate()) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return ConfirmationDialog(
nameController: _namaPenerimaCtrl,
initialAgreed: _isAgreed,
onConfirm: (agreed, name) {
setState(() {
_isAgreed = agreed;
});
Navigator.pop(context);
_submitDocument();
},
);
}
);
}
Future<void> _submitDocument() async {
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
final double? finalLat = pendingDoc.latitude ?? _document?.latitude;
final double? finalLng = pendingDoc.longitude ?? _document?.longitude;
final saveAction = resolveDocumentSaveAction(
existingDocument: _document,
localImagePath: pendingDoc.imagePath,
);
if (saveAction.kind == DocumentSaveActionKind.blocked) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(saveAction.blockedMessage!),
backgroundColor: AppConfig.errorColor,
duration: const Duration(seconds: 5),
),
);
return;
}
final apiClient = ref.read(apiClientProvider);
String serverId;
if (saveAction.kind == DocumentSaveActionKind.reuploadThenPut) {
try {
final formData = FormData.fromMap({
'image': await MultipartFile.fromFile(
pendingDoc.imagePath,
filename: pendingDoc.imagePath.split(RegExp(r'[/\\]')).last,
),
if (finalLat != null) 'latitude': finalLat.toString(),
if (finalLng != null) 'longitude': finalLng.toString(),
'scan_mode': pendingDoc.scanMode ?? 'DO',
});
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
serverId = DocumentModel.fromJson(response.data['data']).id;
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal membuat dokumen di server: ${ApiException.from(e).displayMessage}'),
backgroundColor: AppConfig.errorColor,
duration: const Duration(seconds: 5),
),
);
return;
}
} else {
serverId = saveAction.serverId!;
}
final finalDoc = DocumentModel(
id: serverId,
tanggal: _tanggalCtrl.text,
noPo: _noPoCtrl.text,
noSo: _noSoCtrl.text,
noDo: _noDoCtrl.text,
kepadaYth: _kepadaYthCtrl.text,
orderUntuk: _orderUntukCtrl.text,
alamat: _alamatCtrl.text,
platTruk: _platTrukCtrl.text,
namaDriver: _namaDriverCtrl.text,
namaPenerima: _namaPenerimaCtrl.text,
latitude: finalLat,
longitude: finalLng,
items: _itemControllers.map((ctrls) => DocumentItem(
nomorSku: ctrls['sku']!.text,
namaBarang: ctrls['nama']!.text,
banyak: ctrls['banyak']!.text,
jumlah: ctrls['jumlah']!.text,
)).toList(),
createdAt: DateTime.now(),
);
final localStorage = ref.read(localStorageProvider);
await localStorage.saveDocument(finalDoc);
bool syncedToServer = false;
String? syncError;
try {
await apiClient.client.put(
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
} catch (e) {
syncError = ApiException.from(e).displayMessage;
}
if (widget.pendingId != null) {
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
'Belum tersinkron ke server: $syncError',
);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
syncedToServer
? const SnackBar(content: Text('Dokumen Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
: const SnackBar(
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
backgroundColor: Colors.deepOrange,
duration: Duration(seconds: 5),
),
);
context.pop(true);
}
}
}
+7 -190
View File
@@ -1,3 +1,6 @@
library editor_screen;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -7,12 +10,15 @@ import '../../main.dart'; // To access localStorageProvider
import '../documents/pending_documents_provider.dart'; import '../documents/pending_documents_provider.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/network/api_exception.dart'; import '../../core/network/api_exception.dart';
import 'document_save_action.dart';
import 'widgets/header_details_card.dart'; import 'widgets/header_details_card.dart';
import 'widgets/shipment_details_card.dart'; import 'widgets/shipment_details_card.dart';
import 'widgets/location_details_card.dart'; import 'widgets/location_details_card.dart';
import 'widgets/items_list_card.dart'; import 'widgets/items_list_card.dart';
import 'widgets/confirmation_dialog.dart'; import 'widgets/confirmation_dialog.dart';
part 'editor_logic.dart';
class EditorScreen extends ConsumerStatefulWidget { class EditorScreen extends ConsumerStatefulWidget {
final String? pendingId; final String? pendingId;
@@ -22,29 +28,7 @@ class EditorScreen extends ConsumerStatefulWidget {
ConsumerState<EditorScreen> createState() => _EditorScreenState(); ConsumerState<EditorScreen> createState() => _EditorScreenState();
} }
class _EditorScreenState extends ConsumerState<EditorScreen> { class _EditorScreenState extends ConsumerState<EditorScreen> with EditorLogic {
DocumentModel? _document;
// Controllers
final _tanggalCtrl = TextEditingController();
final _noPoCtrl = TextEditingController();
final _noSoCtrl = TextEditingController();
final _noDoCtrl = TextEditingController();
final _kepadaYthCtrl = TextEditingController();
final _orderUntukCtrl = TextEditingController();
final _alamatCtrl = TextEditingController();
final _platTrukCtrl = TextEditingController();
final _namaDriverCtrl = TextEditingController();
final _namaPenerimaCtrl = TextEditingController();
final _latitudeCtrl = TextEditingController();
final _longitudeCtrl = TextEditingController();
List<Map<String, TextEditingController>> _itemControllers = [];
bool _isAgreed = false;
final _formKey = GlobalKey<FormState>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -74,173 +58,6 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
super.dispose(); super.dispose();
} }
void _loadDocumentData() {
if (widget.pendingId == null) return;
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
if (pendingDoc.document != null) {
final doc = pendingDoc.document!;
setState(() {
_document = doc;
_tanggalCtrl.text = doc.tanggal;
_noPoCtrl.text = doc.noPo;
_noSoCtrl.text = doc.noSo;
_noDoCtrl.text = doc.noDo;
_kepadaYthCtrl.text = doc.kepadaYth;
_orderUntukCtrl.text = doc.orderUntuk;
_alamatCtrl.text = doc.alamat;
_platTrukCtrl.text = doc.platTruk;
_namaDriverCtrl.text = doc.namaDriver;
final lat = pendingDoc.latitude ?? doc.latitude;
final lng = pendingDoc.longitude ?? doc.longitude;
_latitudeCtrl.text = lat != null ? lat.toString() : '-';
_longitudeCtrl.text = lng != null ? lng.toString() : '-';
for (var item in doc.items) {
_addItem(
sku: item.nomorSku,
nama: item.namaBarang,
banyak: item.banyak,
jumlah: item.jumlah,
);
}
});
}
}
void _addItem({String sku = '', String nama = '', String banyak = '', String jumlah = ''}) {
final skuCtrl = TextEditingController(text: sku);
final namaCtrl = TextEditingController(text: nama);
final banyakCtrl = TextEditingController(text: banyak);
final jumlahCtrl = TextEditingController(text: jumlah);
setState(() {
_itemControllers.add({
'sku': skuCtrl,
'nama': namaCtrl,
'banyak': banyakCtrl,
'jumlah': jumlahCtrl,
});
});
}
void _removeItem(int index) {
setState(() {
final ctrls = _itemControllers[index];
ctrls['sku']?.dispose();
ctrls['nama']?.dispose();
ctrls['banyak']?.dispose();
ctrls['jumlah']?.dispose();
_itemControllers.removeAt(index);
});
}
void _showConfirmationDialog() {
if (!_formKey.currentState!.validate()) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return ConfirmationDialog(
nameController: _namaPenerimaCtrl,
initialAgreed: _isAgreed,
onConfirm: (agreed, name) {
setState(() {
_isAgreed = agreed;
});
Navigator.pop(context);
_submitDocument();
},
);
}
);
}
Future<void> _submitDocument() async {
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
final double? finalLat = pendingDoc.latitude ?? _document?.latitude;
final double? finalLng = pendingDoc.longitude ?? _document?.longitude;
final finalDoc = DocumentModel(
id: _document?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
tanggal: _tanggalCtrl.text,
noPo: _noPoCtrl.text,
noSo: _noSoCtrl.text,
noDo: _noDoCtrl.text,
kepadaYth: _kepadaYthCtrl.text,
orderUntuk: _orderUntukCtrl.text,
alamat: _alamatCtrl.text,
platTruk: _platTrukCtrl.text,
namaDriver: _namaDriverCtrl.text,
namaPenerima: _namaPenerimaCtrl.text,
latitude: finalLat,
longitude: finalLng,
items: _itemControllers.map((ctrls) => DocumentItem(
nomorSku: ctrls['sku']!.text,
namaBarang: ctrls['nama']!.text,
banyak: ctrls['banyak']!.text,
jumlah: ctrls['jumlah']!.text,
)).toList(),
createdAt: DateTime.now(),
);
final localStorage = ref.read(localStorageProvider);
await localStorage.saveDocument(finalDoc);
bool syncedToServer = false;
String? syncError;
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.client.put(
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
} catch (e) {
syncError = ApiException.from(e).displayMessage;
}
if (widget.pendingId != null) {
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
'Belum tersinkron ke server: $syncError',
);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
syncedToServer
? const SnackBar(content: Text('Dokumen Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
: const SnackBar(
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
backgroundColor: Colors.deepOrange,
duration: Duration(seconds: 5),
),
);
context.pop(true);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_document == null) { if (_document == null) {
+1 -1
View File
@@ -10,7 +10,7 @@ class PdfService {
// Load PFM logo asset // Load PFM logo asset
final logoImage = await flutterImageProvider(const AssetImage('assets/logo.png')); final logoImage = await flutterImageProvider(const AssetImage('assets/logo.png'));
final isProductScan = document.orderUntuk == 'PRODUCT SCAN'; final isProductScan = document.docType == 'Product';
// Add page // Add page
pdf.addPage( pdf.addPage(
@@ -0,0 +1,133 @@
part of 'product_editor_screen.dart';
mixin ProductEditorDataLogic on ConsumerState<ProductEditorScreen> {
final _expDateCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
final _formKey = GlobalKey<FormState>();
String _selectedSku = '';
String? _selectedBatchOption;
bool _isManualDate = false;
String _imagePath = '';
DocumentModel? _document;
bool _loading = false;
List<Map<String, dynamic>> _matches = [];
final Map<String, List<String>> _skuBatches = {};
List<Map<String, dynamic>> _skuMasterList = [];
/// True only when `_matches` came from the real classifier response - used
/// to distinguish an actual AI recommendation from the manual-selection
/// fallback list, so the UI never shows a fabricated confidence score.
bool _hasAutoMatch = false;
/// True when even the SKU master list couldn't be loaded, leaving nothing
/// to build a manual-selection fallback from - the one failure mode that
/// blocks the review screen entirely instead of degrading gracefully.
bool _classificationFailed = false;
void _loadPendingDoc() {
if (widget.pendingId == null) return;
final pendingDoc = ref.read(pendingDocumentsProvider).firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
setState(() {
_imagePath = pendingDoc.imagePath;
_document = pendingDoc.document;
});
}
/// Populates the SKU dropdown/expiry batches from the document's own data.
/// Product Scan documents carry their classify+OCR result from the single
/// pass the backend already ran at upload time (`_document.productScan*`,
/// backend gap G3 fix) - reading it here is synchronous, no network call,
/// mirroring how `EditorScreen._loadDocumentData()` reads a DO document's
/// already-parsed data with no request at all. Falls back to a plain SKU
/// master list read (a DB query, not a GPU/classifier call) only when the
/// document has no stored match at all - e.g. nothing scored above the
/// match threshold, or the document predates this fix.
Future<void> _fetchClassificationAndSkus() async {
_classificationFailed = false;
final storedMatches = _document?.productScanMatches ?? const [];
final extractedExpiryDate = _document?.productScanExtractedExpiryDate ?? '';
if (storedMatches.isNotEmpty) {
setState(() {
_matches = List<Map<String, dynamic>>.from(storedMatches);
_hasAutoMatch = true;
// Only seed a batch from a real extracted date - an empty result
// means "no date found," not "here are two made-up dates," so it's
// left empty to force manual entry instead (see G7).
_skuBatches.clear();
for (var match in _matches) {
final sku = match['no_sku'];
_skuBatches[sku] = extractedExpiryDate.isNotEmpty ? [extractedExpiryDate] : [];
}
_selectedSku = _matches.first['no_sku'];
_updateBatchOptions();
});
return;
}
setState(() { _loading = true; });
try {
final apiClient = ref.read(apiClientProvider);
final skusRes = await apiClient.client.get(AppConfig.masterSkusEndpoint);
_skuMasterList = parseSkuMasterList(skusRes.data);
if (_skuMasterList.isNotEmpty) {
_matches = _skuMasterList.take(5).map<Map<String, dynamic>>((e) => <String, dynamic>{
'no_sku': e['no_sku'],
'nama_item': e['nama_item'],
'confidence': 0.0,
}).toList();
_selectedSku = _matches.first['no_sku'];
}
_hasAutoMatch = false;
} catch (e) {
debugPrint('Error loading SKU master list fallback: $e');
_classificationFailed = true;
} finally {
if (mounted) {
setState(() {
_loading = false;
_updateBatchOptions();
});
}
}
}
void _updateBatchOptions() {
final batches = _skuBatches[_selectedSku] ?? [];
if (batches.isNotEmpty) {
_selectedBatchOption = 'Batch 1: ${batches[0]}';
_expDateCtrl.text = batches[0];
_isManualDate = false;
} else {
_selectedBatchOption = null;
_expDateCtrl.clear();
_isManualDate = true;
}
}
Future<void> _selectDate() async {
DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime(2020),
lastDate: DateTime(2035),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(primary: AppConfig.primaryColor, onPrimary: Colors.white, onSurface: AppConfig.textPrimary),
),
child: child!,
),
);
if (pickedDate != null) {
setState(() { _expDateCtrl.text = DateFormat('dd/MM/yyyy').format(pickedDate); });
}
}
}
+78 -242
View File
@@ -1,12 +1,25 @@
import 'dart:io'; library product_editor;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../config/app_config.dart'; import '../../config/app_config.dart';
import '../../models/document_model.dart'; import '../../models/document_model.dart';
import '../../main.dart'; import '../../main.dart';
import '../documents/pending_documents_provider.dart'; import '../documents/pending_documents_provider.dart';
import '../../core/network/api_client.dart';
import '../../core/network/api_exception.dart';
import 'document_save_action.dart';
import 'product_scan_response_parser.dart';
import 'widgets/product_dropdown_card.dart';
import 'widgets/product_expiry_card.dart';
import 'widgets/product_preview_card.dart';
part 'product_editor_data_logic.dart';
part 'product_editor_submit_logic.dart';
class ProductEditorScreen extends ConsumerStatefulWidget { class ProductEditorScreen extends ConsumerStatefulWidget {
final String? pendingId; final String? pendingId;
@@ -15,255 +28,58 @@ class ProductEditorScreen extends ConsumerStatefulWidget {
ConsumerState<ProductEditorScreen> createState() => _ProductEditorScreenState(); ConsumerState<ProductEditorScreen> createState() => _ProductEditorScreenState();
} }
class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> { class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> with ProductEditorDataLogic, ProductEditorSubmitLogic {
final _expDateCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
final _formKey = GlobalKey<FormState>();
String _selectedSku = '12010119';
String? _selectedBatchOption;
bool _isManualDate = false;
String _imagePath = '';
List<DocumentModel> _doDocs = [];
String _selectedRelatedPo = 'PO-9923488';
final List<Map<String, dynamic>> _matches = [
{'no_sku': '12010119', 'nama_item': 'FIESTA SPICY CHICKEN NUGGET 500 GR', 'confidence': 0.985},
{'no_sku': '12012504', 'nama_item': 'AKUMO NUGGET COIN 200 GR', 'confidence': 0.82},
{'no_sku': '12010220', 'nama_item': 'FIESTA SCHNITZEL 500 GR', 'confidence': 0.75},
];
final Map<String, List<String>> _skuBatches = {
'12010119': ['15/12/2026', '20/04/2027'],
'12012504': ['01/10/2026', '12/03/2027'],
'12010220': ['18/08/2026', '05/01/2027'],
};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadPendingDoc(); _loadPendingDoc();
_loadDoDocs(); _fetchClassificationAndSkus();
_updateBatchOptions();
} }
@override @override
void dispose() { void dispose() {
_expDateCtrl.dispose();
_notesCtrl.dispose();
super.dispose(); super.dispose();
} }
void _loadPendingDoc() {
if (widget.pendingId == null) return;
final pendingDoc = ref.read(pendingDocumentsProvider).firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
setState(() { _imagePath = pendingDoc.imagePath; });
}
void _loadDoDocs() {
final allDocs = ref.read(localStorageProvider).getAllDocuments();
final doDocs = allDocs.where((d) => d.orderUntuk != 'PRODUCT SCAN').toList();
setState(() {
_doDocs = doDocs;
if (_doDocs.isNotEmpty) {
_selectedRelatedPo = _doDocs.first.noPo;
}
});
}
void _updateBatchOptions() {
final batches = _skuBatches[_selectedSku] ?? [];
if (batches.isNotEmpty) {
_selectedBatchOption = 'Batch 1: ${batches[0]}';
_expDateCtrl.text = batches[0];
_isManualDate = false;
}
}
Future<void> _selectDate() async {
DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime(2020),
lastDate: DateTime(2035),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(primary: AppConfig.primaryColor, onPrimary: Colors.white, onSurface: AppConfig.textPrimary),
),
child: child!,
),
);
if (pickedDate != null) {
setState(() { _expDateCtrl.text = DateFormat('dd/MM/yyyy').format(pickedDate); });
}
}
void _submit() async {
if (!_formKey.currentState!.validate()) return;
final activeMatch = _matches.firstWhere((m) => m['no_sku'] == _selectedSku, orElse: () => _matches.first);
final String productName = activeMatch['nama_item'] ?? 'Unknown Product';
final finalDoc = DocumentModel(
id: widget.pendingId ?? DateTime.now().millisecondsSinceEpoch.toString(),
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now()),
noPo: _selectedRelatedPo,
noSo: '1002003004',
noDo: _isManualDate ? 'Batch Manual' : (_selectedBatchOption != null ? _selectedBatchOption!.split(': ')[0] : 'Batch 1'),
kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'PRODUCT SCAN',
alamat: 'Product Scan: Expired ${_expDateCtrl.text}',
platTruk: 'B 1234 PFM',
namaDriver: 'PRODUCT SCAN',
namaPenerima: 'STORE STAFF',
items: [DocumentItem(nomorSku: _selectedSku, namaBarang: productName, banyak: '1', jumlah: '1')],
createdAt: DateTime.now(),
);
await ref.read(localStorageProvider).saveDocument(finalDoc);
if (widget.pendingId != null) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Hasil Pemindaian Produk Berhasil Disimpan!'), backgroundColor: AppConfig.successColor),
);
context.pop(true);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Review Hasil Produk'), leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop())), appBar: AppBar(title: const Text('Review Hasil Produk'), leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop())),
body: SingleChildScrollView( body: _loading
? const Center(child: CircularProgressIndicator())
: _classificationFailed
? _buildFailureState()
: SingleChildScrollView(
padding: AppConfig.screenPadding, padding: AppConfig.screenPadding,
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_imagePath.isNotEmpty) ...[ ProductPreviewCard(imagePath: _imagePath),
Card( if (_imagePath.isNotEmpty) const SizedBox(height: AppConfig.spaceLarge),
elevation: 0, ProductDropdownCard(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)), selectedSku: _selectedSku,
child: Padding( matches: _matches,
padding: AppConfig.paddingLarge, hasAutoMatch: _hasAutoMatch,
child: Column( onSkuChanged: (val) {
crossAxisAlignment: CrossAxisAlignment.start, if (val != null) {
children: [ setState(() {
Text('AI Crop Region / Preview', style: AppConfig.h2), _selectedSku = val;
const SizedBox(height: AppConfig.spaceMedium), _updateBatchOptions();
Container( });
height: 120,
width: double.infinity,
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall)),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
child: Image.file(File(_imagePath), fit: BoxFit.cover, alignment: const Alignment(0, 0.4)),
),
),
],
),
),
),
const SizedBox(height: AppConfig.spaceLarge),
],
_buildProductDropdownCard(),
const SizedBox(height: AppConfig.spaceLarge),
_buildExpiryCard(),
const SizedBox(height: AppConfig.spaceXL),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Simpan & Konfirmasi Produk', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
),
),
);
} }
},
Widget _buildProductDropdownCard() {
final activeMatch = _matches.firstWhere((m) => m['no_sku'] == _selectedSku, orElse: () => _matches.first);
final double confidence = activeMatch['confidence'];
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Product Classification', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: _selectedSku,
decoration: const InputDecoration(labelText: 'Pilih Produk / SKU', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
isExpanded: true,
style: AppConfig.bodyMedium,
items: _matches.map((match) {
return DropdownMenuItem<String>(value: match['no_sku'], child: Text('${match['no_sku']} - ${match['nama_item']}', overflow: TextOverflow.ellipsis, maxLines: 1));
}).toList(),
onChanged: (val) { if (val != null) { setState(() { _selectedSku = val; _updateBatchOptions(); }); } },
), ),
const SizedBox(height: AppConfig.spaceLarge), const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>( ProductExpiryCard(
value: _selectedRelatedPo, selectedSku: _selectedSku,
decoration: const InputDecoration(labelText: 'Hubungkan ke PO Dokumen', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)), selectedBatchOption: _selectedBatchOption,
isExpanded: true, isManualDate: _isManualDate,
style: AppConfig.bodyMedium, skuBatches: _skuBatches,
items: [ expDateCtrl: _expDateCtrl,
if (_doDocs.isEmpty) notesCtrl: _notesCtrl,
const DropdownMenuItem(value: 'PO-9923488', child: Text('PO-9923488 (Default)')), onBatchChanged: (val) {
..._doDocs.map((doc) => DropdownMenuItem(value: doc.noPo, child: Text('${doc.noPo} - ${doc.kepadaYth}', overflow: TextOverflow.ellipsis))),
],
onChanged: (val) { if (val != null) { setState(() { _selectedRelatedPo = val; }); } },
),
const SizedBox(height: AppConfig.spaceLarge),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)),
Text('${(confidence * 100).toStringAsFixed(1)}%', style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor)),
],
),
const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(value: confidence, color: AppConfig.successColor, backgroundColor: Colors.grey.shade200),
],
),
),
);
}
Widget _buildExpiryCard() {
final batches = _skuBatches[_selectedSku] ?? [];
final dropdownItems = <DropdownMenuItem<String>>[];
for (int i = 0; i < batches.length; i++) {
dropdownItems.add(DropdownMenuItem(value: 'Batch ${i + 1}: ${batches[i]}', child: Text('Batch ${i + 1}: ${batches[i]}')));
}
dropdownItems.add(const DropdownMenuItem(value: 'manual', child: Text('Set Tanggal Manual...')));
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI OCR Expiry Extraction', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: _isManualDate ? 'manual' : _selectedBatchOption,
decoration: const InputDecoration(labelText: 'Pilih Batch Expiry', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
isExpanded: true,
style: AppConfig.bodyMedium,
items: dropdownItems,
onChanged: (val) {
if (val != null) { if (val != null) {
setState(() { setState(() {
if (val == 'manual') { if (val == 'manual') {
@@ -278,27 +94,47 @@ class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> {
}); });
} }
}, },
onSelectDate: _selectDate,
), ),
if (_isManualDate) ...[ const SizedBox(height: AppConfig.spaceXL),
const SizedBox(height: AppConfig.spaceLarge), ElevatedButton(
TextFormField( onPressed: _submit,
controller: _expDateCtrl, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
decoration: InputDecoration(labelText: 'Expired Date Manual (DD/MM/YYYY)', suffixIcon: IconButton(icon: const Icon(Icons.calendar_today, color: AppConfig.primaryColor), onPressed: _selectDate)), child: const Text('Simpan & Konfirmasi Produk', style: TextStyle(fontWeight: FontWeight.bold)),
validator: (val) => val == null || val.isEmpty ? 'Tanggal kadaluwarsa harus diisi' : null,
), ),
], ],
const SizedBox(height: AppConfig.spaceLarge), ),
Row( ),
mainAxisAlignment: MainAxisAlignment.spaceBetween, ),
);
}
/// Explicit, retry-able error state for when the SKU master list couldn't
/// be loaded at all (see docs/api-contract-map.md G7) - replaces the old
/// behavior of silently fabricating fake SKU matches and confidence
/// scores so the screen always looked like it had a working AI result.
Widget _buildFailureState() {
return Center(
child: Padding(
padding: AppConfig.screenPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('OCR Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)), const Icon(Icons.cloud_off, size: 48, color: AppConfig.errorColor),
Text('${(0.924 * 100).toStringAsFixed(1)}%', style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor)), const SizedBox(height: AppConfig.spaceLarge),
], Text('Gagal Memuat Klasifikasi Produk', style: AppConfig.h3, textAlign: TextAlign.center),
),
const SizedBox(height: AppConfig.spaceSmall), const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(value: 0.924, color: AppConfig.successColor, backgroundColor: Colors.grey.shade200), Text(
const SizedBox(height: AppConfig.spaceMedium), 'Periksa koneksi Anda ke server dan coba lagi.',
TextFormField(controller: _notesCtrl, decoration: const InputDecoration(labelText: 'Catatan Tambahan'), maxLines: 2), style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: AppConfig.spaceXL),
ElevatedButton.icon(
onPressed: _fetchClassificationAndSkus,
icon: const Icon(Icons.refresh),
label: const Text('Coba Lagi'),
),
], ],
), ),
), ),
@@ -0,0 +1,130 @@
part of 'product_editor_screen.dart';
mixin ProductEditorSubmitLogic on ProductEditorDataLogic {
void _submit() async {
if (!_formKey.currentState!.validate()) return;
final activeMatch = _matches.firstWhere(
(m) => m['no_sku'] == _selectedSku,
orElse: () => _matches.isNotEmpty ? _matches.first : <String, dynamic>{'no_sku': '', 'nama_item': 'Unknown Product', 'confidence': 0.0},
);
final String productName = activeMatch['nama_item'] ?? 'Unknown Product';
final prefs = await SharedPreferences.getInstance();
final currentStoreName = prefs.getString('nama_toko') ?? 'PM KELAPA DUA KARAWACI';
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
final double? finalLat = pendingDoc.latitude ?? _document?.latitude;
final double? finalLng = pendingDoc.longitude ?? _document?.longitude;
final saveAction = resolveDocumentSaveAction(
existingDocument: _document,
localImagePath: pendingDoc.imagePath,
);
if (saveAction.kind == DocumentSaveActionKind.blocked) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(saveAction.blockedMessage!),
backgroundColor: AppConfig.errorColor,
duration: const Duration(seconds: 5),
),
);
return;
}
final apiClient = ref.read(apiClientProvider);
String serverId;
if (saveAction.kind == DocumentSaveActionKind.reuploadThenPut) {
try {
final formData = FormData.fromMap({
'image': await MultipartFile.fromFile(
pendingDoc.imagePath,
filename: pendingDoc.imagePath.split(RegExp(r'[/\\]')).last,
),
if (finalLat != null) 'latitude': finalLat.toString(),
if (finalLng != null) 'longitude': finalLng.toString(),
'scan_mode': pendingDoc.scanMode ?? 'Product',
});
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
serverId = DocumentModel.fromJson(response.data['data']).id;
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal membuat dokumen di server: ${ApiException.from(e).displayMessage}'),
backgroundColor: AppConfig.errorColor,
duration: const Duration(seconds: 5),
),
);
return;
}
} else {
serverId = saveAction.serverId!;
}
final finalDoc = DocumentModel(
id: serverId,
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now()),
// No PO-link concept for Product Scan documents anymore (the
// "Hubungkan ke PO Dokumen" field was removed - user request).
noPo: '',
noSo: '1002003004',
noDo: _isManualDate ? 'Batch Manual' : (_selectedBatchOption != null ? _selectedBatchOption!.split(': ')[0] : 'Batch 1'),
kepadaYth: currentStoreName,
orderUntuk: 'PRODUCT SCAN',
alamat: 'Product Scan: Expired ${_expDateCtrl.text}',
platTruk: 'B 1234 PFM',
namaDriver: 'PRODUCT SCAN',
namaPenerima: 'STORE STAFF',
items: [DocumentItem(nomorSku: _selectedSku, namaBarang: productName, banyak: '1', jumlah: '1')],
latitude: finalLat,
longitude: finalLng,
createdAt: DateTime.now(),
docType: 'Product',
);
final localStorage = ref.read(localStorageProvider);
await localStorage.saveDocument(finalDoc);
bool syncedToServer = false;
String? syncError;
try {
await apiClient.client.put(
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
} catch (e) {
syncError = ApiException.from(e).displayMessage;
}
if (widget.pendingId != null) {
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
'Belum tersinkron ke server: $syncError',
);
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
syncedToServer
? const SnackBar(content: Text('Hasil Pemindaian Produk Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
: const SnackBar(
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
backgroundColor: Colors.deepOrange,
duration: Duration(seconds: 5),
),
);
context.pop(true);
}
}
@@ -0,0 +1,41 @@
/// Pure parsing helpers for the v1 product-scan endpoints
/// (`GET /master/skus`, `POST /scan-product`), kept free of Flutter/network
/// imports so the "unwrap the v1 envelope" logic is unit-testable without
/// mocking Dio - the same pattern used by `document_sync_merge.dart`.
library;
List<Map<String, dynamic>> parseSkuMasterList(dynamic responseData) {
final List raw = responseData?['data'] ?? [];
return raw.map((e) => Map<String, dynamic>.from(e as Map)).toList();
}
class ScanProductResult {
final List<Map<String, dynamic>> matches;
final String extractedExpiryDate;
const ScanProductResult({
required this.matches,
required this.extractedExpiryDate,
});
}
ScanProductResult parseScanProductResponse(dynamic responseData) {
final data = responseData?['data'] ?? {};
final List rawMatches = data['possibleMatches'] ?? [];
final matches = rawMatches.map<Map<String, dynamic>>((m) {
return <String, dynamic>{
'no_sku': m['no_sku']?.toString() ?? '',
'nama_item': m['nama_item']?.toString() ?? '',
'confidence': (m['score'] as num?)?.toDouble() ?? 0.0,
};
}).toList();
final ocr = data['ocr'] ?? {};
final extractedExpiryDate = ocr['extracted_expired_date']?.toString() ?? '';
return ScanProductResult(
matches: matches,
extractedExpiryDate: extractedExpiryDate,
);
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductDropdownCard extends StatelessWidget {
final String selectedSku;
final List<Map<String, dynamic>> matches;
final bool hasAutoMatch;
final ValueChanged<String?> onSkuChanged;
const ProductDropdownCard({
super.key,
required this.selectedSku,
required this.matches,
required this.hasAutoMatch,
required this.onSkuChanged,
});
@override
Widget build(BuildContext context) {
final typedMatches = matches.cast<Map<String, dynamic>>().toList();
final activeMatch = typedMatches.firstWhere(
(m) => m['no_sku'] == selectedSku,
orElse: () => typedMatches.isNotEmpty ? typedMatches.first : <String, dynamic>{'no_sku': '', 'nama_item': 'Unknown', 'confidence': 0.0},
);
final rawConfidence = activeMatch['confidence'] ?? activeMatch['score'] ?? activeMatch['yoloSimilarity'];
final double confidence = rawConfidence is num
? rawConfidence.toDouble()
: (rawConfidence != null
? double.tryParse(rawConfidence.toString()) ?? 0.0
: 0.0);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Product Classification', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: selectedSku.isNotEmpty && typedMatches.any((m) => m['no_sku'] == selectedSku) ? selectedSku : null,
decoration: const InputDecoration(
labelText: 'Pilih Produk / SKU',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
isExpanded: true,
style: AppConfig.bodyMedium,
items: typedMatches.map((match) {
return DropdownMenuItem<String>(
value: match['no_sku'],
child: Text(
'${match['no_sku']} - ${match['nama_item']}',
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
);
}).toList(),
onChanged: onSkuChanged,
),
const SizedBox(height: AppConfig.spaceSmall),
// Product name for the selected SKU - informational only, not a
// form field (mirrors ProductExpiryCard's "Tanggal terdeteksi
// otomatis dari OCR" info row below the batch dropdown).
Row(
children: [
const Icon(Icons.inventory_2_outlined, size: 16, color: AppConfig.textSecondary),
const SizedBox(width: AppConfig.spaceXS),
Expanded(
child: Text(
(activeMatch['nama_item'] as String?)?.isNotEmpty == true
? activeMatch['nama_item'] as String
: '-',
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: AppConfig.spaceLarge),
if (hasAutoMatch) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)),
Text(
'${(confidence * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor),
),
],
),
const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(
value: confidence,
color: AppConfig.successColor,
backgroundColor: Colors.grey.shade200,
),
] else
Row(
children: [
const Icon(Icons.info_outline, size: 16, color: AppConfig.textSecondary),
const SizedBox(width: AppConfig.spaceXS),
Expanded(
child: Text(
'Tidak ada rekomendasi otomatis — pilih SKU secara manual.',
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductExpiryCard extends StatelessWidget {
final String selectedSku;
final String? selectedBatchOption;
final bool isManualDate;
final Map<String, List<String>> skuBatches;
final TextEditingController expDateCtrl;
final TextEditingController notesCtrl;
final ValueChanged<String?> onBatchChanged;
final VoidCallback onSelectDate;
const ProductExpiryCard({
super.key,
required this.selectedSku,
required this.selectedBatchOption,
required this.isManualDate,
required this.skuBatches,
required this.expDateCtrl,
required this.notesCtrl,
required this.onBatchChanged,
required this.onSelectDate,
});
@override
Widget build(BuildContext context) {
final batches = skuBatches[selectedSku] ?? [];
final dropdownItems = <DropdownMenuItem<String>>[];
for (int i = 0; i < batches.length; i++) {
dropdownItems.add(DropdownMenuItem(
value: 'Batch ${i + 1}: ${batches[i]}',
child: Text('Batch ${i + 1}: ${batches[i]}'),
));
}
dropdownItems.add(const DropdownMenuItem(value: 'manual', child: Text('Set Tanggal Manual...')));
// Ensure value matches an item or falls back
String? dropdownValue = isManualDate ? 'manual' : selectedBatchOption;
if (dropdownValue != null && dropdownValue != 'manual' && !dropdownItems.any((item) => item.value == dropdownValue)) {
dropdownValue = dropdownItems.isNotEmpty ? dropdownItems.first.value : null;
}
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI OCR Expiry Extraction', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: dropdownValue,
decoration: const InputDecoration(
labelText: 'Pilih Batch Expiry',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
isExpanded: true,
style: AppConfig.bodyMedium,
items: dropdownItems,
onChanged: onBatchChanged,
),
if (isManualDate) ...[
const SizedBox(height: AppConfig.spaceLarge),
TextFormField(
controller: expDateCtrl,
decoration: InputDecoration(
labelText: 'Expired Date Manual (DD/MM/YYYY)',
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today, color: AppConfig.primaryColor),
onPressed: onSelectDate,
),
),
validator: (val) => val == null || val.isEmpty ? 'Tanggal kadaluwarsa harus diisi' : null,
),
],
const SizedBox(height: AppConfig.spaceLarge),
Row(
children: [
Icon(
isManualDate ? Icons.edit_calendar : Icons.auto_awesome,
size: 16,
color: isManualDate ? Colors.deepOrange : AppConfig.successColor,
),
const SizedBox(width: AppConfig.spaceXS),
Text(
isManualDate ? 'Tanggal diinput manual' : 'Tanggal terdeteksi otomatis dari OCR',
style: AppConfig.bodySmall.copyWith(
color: isManualDate ? Colors.deepOrange : AppConfig.successColor,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: AppConfig.spaceLarge),
TextFormField(
controller: notesCtrl,
decoration: const InputDecoration(labelText: 'Catatan Tambahan'),
maxLines: 2,
),
],
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductPreviewCard extends StatelessWidget {
final String imagePath;
const ProductPreviewCard({super.key, required this.imagePath});
@override
Widget build(BuildContext context) {
if (imagePath.isEmpty) return const SizedBox.shrink();
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Crop Region / Preview', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceMedium),
Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
child: Image.file(
File(imagePath),
fit: BoxFit.cover,
alignment: const Alignment(0, 0.4),
),
),
),
],
),
),
);
}
}
+64 -2
View File
@@ -14,6 +14,44 @@ class DocumentModel {
final double? longitude; final double? longitude;
final List<DocumentItem> items; final List<DocumentItem> items;
final DateTime createdAt; final DateTime createdAt;
final bool parsed;
/// 'pending' | 'done' | 'failed', sourced from the backend's `parseStatus`
/// (backend task 9.1, `document-mapper.ts`) - only populated by
/// `GET /api/v1/documents/:id` (see `pending_documents_provider.dart`'s
/// `_pollUntilParsed`, which polls that route instead of scanning the
/// whole list). Null for responses that don't include it (e.g. the PUT
/// route's own hand-built response, or cached Hive rows).
final String? parseStatus;
/// 'DO' or 'Product', sourced from the backend's persisted `scan_mode`
/// column (see backend task 9.1, `document-mapper.ts`'s `docType`) - the
/// real replacement for the `orderUntuk == 'PRODUCT SCAN'` sentinel that
/// every call site used to check (editing that display field used to
/// silently move a document between tabs).
final String docType;
/// True once the user has confirmed this document (`PUT /documents/:id`),
/// sourced from the backend's `confirmed` column (backend task 10.1) -
/// separates "OCR finished" (`parsed`) from "user confirmed," since a
/// document only re-enters `GET /api/v1/documents` (and this field is only
/// meaningfully `false`) once confirmed. Defaults to `true` for
/// legacy/cached responses that predate the column - see
/// docs/api-contract-map.md G11.
final bool confirmed;
/// Top-5 SKU candidates from the single classify+OCR pass the backend runs
/// at upload time for Product Scan documents (backend task on gap G3,
/// `document-mapper.ts`'s `productScan.possibleMatches`) - lets the product
/// editor render immediately instead of re-running the GPU pipeline a
/// second time on review. Empty for DO documents, or Product documents
/// parsed before this existed (already-confirmed docs also lose it, since
/// it's only needed during the initial review).
final List<Map<String, dynamic>> productScanMatches;
/// OCR-extracted expiry date from that same single pass
/// (`productScan.extractedExpiryDate`). Empty string when absent/not found.
final String productScanExtractedExpiryDate;
DocumentModel({ DocumentModel({
required this.id, required this.id,
@@ -31,6 +69,12 @@ class DocumentModel {
this.longitude, this.longitude,
required this.items, required this.items,
required this.createdAt, required this.createdAt,
this.parsed = false,
this.parseStatus,
this.docType = 'DO',
this.confirmed = true,
this.productScanMatches = const [],
this.productScanExtractedExpiryDate = '',
}); });
factory DocumentModel.fromJson(Map<String, dynamic> json) { factory DocumentModel.fromJson(Map<String, dynamic> json) {
@@ -56,6 +100,22 @@ class DocumentModel {
createdAt: json['created_at'] != null createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'].toString()) ? DateTime.parse(json['created_at'].toString())
: (json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : DateTime.now()), : (json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : DateTime.now()),
parsed: json['parsed'] as bool? ?? false,
parseStatus: json['parseStatus']?.toString(),
// Prefer the backend's persisted docType (backend task 9.1). Fall back
// to the legacy orderUntuk sentinel for responses/cached rows that
// predate that column (e.g. the upload endpoint's fresh-upload stub,
// or Hive entries saved before this field existed) - mirrors the same
// fallback backend/pfm-web-app/src/utils/document-mapper.ts uses.
docType: json['docType']?.toString() ??
(shipment['order_untuk'] == 'PRODUCT SCAN' ? 'Product' : 'DO'),
confirmed: json['confirmed'] as bool? ?? true,
productScanMatches: ((json['productScan'] as Map<String, dynamic>?)?['possibleMatches'] as List?)
?.map((e) => Map<String, dynamic>.from(e as Map))
.toList() ??
const [],
productScanExtractedExpiryDate:
(json['productScan'] as Map<String, dynamic>?)?['extractedExpiryDate']?.toString() ?? '',
); );
} }
@@ -69,6 +129,8 @@ class DocumentModel {
'longitude': longitude, 'longitude': longitude,
'nama_driver': namaDriver, 'nama_driver': namaDriver,
'nama_penerima': namaPenerima, 'nama_penerima': namaPenerima,
'parsed': parsed,
'docType': docType,
'header': { 'header': {
'tanggal': tanggal, 'tanggal': tanggal,
'no_po': noPo, 'no_po': noPo,
@@ -81,7 +143,7 @@ class DocumentModel {
'alamat': alamat, 'alamat': alamat,
'plat_truk': platTruk, 'plat_truk': platTruk,
}, },
'items': items.map((e) => { 'items': items.map((e) => <String, dynamic>{
'nomor_sku': e.nomorSku, 'nomor_sku': e.nomorSku,
'nama_barang': e.namaBarang, 'nama_barang': e.namaBarang,
'banyak': e.banyak, 'banyak': e.banyak,
@@ -106,7 +168,7 @@ class DocumentModel {
'namaPenerima': namaPenerima, 'namaPenerima': namaPenerima,
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
'items': items.map((e) => { 'items': items.map((e) => <String, dynamic>{
'nomor_sku': e.nomorSku, 'nomor_sku': e.nomorSku,
'nama_barang': e.namaBarang, 'nama_barang': e.namaBarang,
'banyak': e.banyak, 'banyak': e.banyak,
+314 -31
View File
@@ -1,4 +1,4 @@
# Next Enhancements (Flutter) # Next Enhancements (Flutter)
This file is the working backlog driven by the `e`/`enhance` and `n`/`next` triggers This file is the working backlog driven by the `e`/`enhance` and `n`/`next` triggers
defined in [AGENTS.md](../AGENTS.md), which now scopes this kit's automated defined in [AGENTS.md](../AGENTS.md), which now scopes this kit's automated
@@ -7,15 +7,15 @@ Sections seeded 2026-07-08 from the real module structure of `app-pfm-ocr-v2` (s
AGENTS.md's Adaptation Notes); tasks populated the same day via `e`/`enhance`, 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. 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/` has its own, independent copy of this kit —
> [backend/plans/next-enhancements.md](../backend/plans/next-enhancements.md), driven > [backend/plans/next-enhancements.md](../backend/plans/next-enhancements.md), driven
> by `backend/AGENTS.md` Part B. This file no longer tracks backend work at all — > 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` > 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 > run before the kit split) were removed 2026-07-08 now that the backend copy is the
> sole active backlog for that subtree. > sole active backlog for that subtree.
> Note: this repo already has an unrelated, pre-existing `plans/next-enhancement-plan.md` > 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 > (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. > and is left as-is; this file (plural) is the one `e`/`n` reads and writes.
## Format ## Format
@@ -32,7 +32,7 @@ section gets exactly 3 tasks:
``` ```
When a task is picked up via `n`/`next`, its clarified acceptance criteria (from 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.: AGENTS.md §2a) are appended directly under it as a short note, e.g.:
``` ```
- **1.1** [TODO] <description> - **1.1** [TODO] <description>
@@ -44,84 +44,367 @@ When complete, the status flips to `[DONE]` and the feature is logged in
--- ---
## Sections (seeded from real modules — run `e` / `enhance` to fill in tasks) ## Sections (seeded from real modules — run `e` / `enhance` to fill in tasks)
### 1. Flutter — Auth & Splash ### 1. Flutter — Auth & Splash
`lib/features/auth/`, `lib/features/splash/` `lib/features/auth/`, `lib/features/splash/`
- **1.1** [TODO] Validate the stored token before treating the user as logged in. `AuthNotifier.checkLoginState()` (`lib/features/auth/auth_provider.dart:11-20`) only checks that a token string exists in `SharedPreferences` — it never checks expiry or pings the server — so a stale/revoked token shows the camera screen and only fails later, silently, on the first real API call. - **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](../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.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** [TODO] Warn before logout if the in-memory pending documents queue (`pendingDocumentsProvider`, `lib/features/documents/pending_documents_provider.dart`) has unsynced items. Today `CameraScreen`'s drawer logout (`camera_screen.dart:367-370`) calls `logout()` unconditionally, silently orphaning any in-flight uploads or unsent items. - **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.
### 2. Flutter — Camera Capture & Geotagging ### 2. Flutter — Camera Capture & Geotagging
`lib/features/camera/` `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.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.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.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.
### 3. Flutter — Pending Documents Queue ### 3. Flutter — Pending Documents Queue
`lib/features/documents/` `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.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.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] Add explicit Dio request timeouts to the upload and poll calls in `_uploadAndProcess` (`pending_documents_provider.dart:112-162`). The 2s-interval/130-retry poll loop is intentional and bounded, but the underlying `apiClient.client.post`/`.get` calls themselves have no explicit connect/receive timeout, so a genuinely hung connection (not a slow-but-alive OCR pass) can leave an item stuck in `uploading` indefinitely. - **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).
### 4. Flutter — Document Editor & PDF Receipt ### 4. Flutter — Document Editor & PDF Receipt
`lib/features/editor/` `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.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.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.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.
### 5. Flutter — Connectivity & Endpoint Resolution ### 5. Flutter — Connectivity & Endpoint Resolution
`lib/config/app_config.dart`, `lib/core/network/api_client.dart` `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 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 ngrok fallback"). **Current-state audit**: the requested dual-endpoint fallback
*already exists at startup* — `AppConfig.initializeApiBaseUrl()` *already exists at startup* — `AppConfig.initializeApiBaseUrl()`
(`app_config.dart:32-49`) probes the LAN URL first, falls back to the reserved (`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 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-error-code` header, JSON content-type, and 502/503/504) with the
`ngrok-skip-browser-warning` header set. (Root `CLAUDE.md` describes this order `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 backwards — tracked as a doc fix in backend task 5.1d.) These tasks close what's
actually missing: actually missing:
- **5.1** [TODO] **Mid-session endpoint failover.** Resolution runs exactly once at - **5.1** [TODO] **Mid-session endpoint failover.** Resolution runs exactly once at
startup, and `ApiClient` freezes `baseUrl` at construction of a singleton startup, and `ApiClient` freezes `baseUrl` at construction of a singleton
(`api_client.dart:13`, `apiClientProvider`) — a phone that resolves LAN on Wi-Fi (`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 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 the ngrok endpoint (and vice versa) until an app restart. Add a Dio interceptor
that, on *connectivity-class* failures only (`connectionTimeout`/ that, on *connectivity-class* failures only (`connectionTimeout`/
`connectionError` — not HTTP-level errors), re-runs endpoint resolution and `connectionError` — not HTTP-level errors), re-runs endpoint resolution and
retries the request once against the newly resolved endpoint. Implementation retries the request once against the newly resolved endpoint. Implementation
notes: the frozen-at-construction `baseUrl` must become dynamic (set notes: the frozen-at-construction `baseUrl` must become dynamic (set
`_dio.options.baseUrl` on re-resolution, or read `AppConfig.apiBaseUrl` in the `_dio.options.baseUrl` on re-resolution, or read `AppConfig.apiBaseUrl` in the
existing `onRequest` interceptor); retry-once is safe for the upload path 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 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 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. timeouts) — a hung connection must fail fast enough for failover to matter.
- **5.2** [TODO] **Endpoint status visibility + manual re-probe.** Show which - **5.2** [TODO] **Endpoint status visibility + manual re-probe.** Show which
endpoint the app is on (LAN / Public / unreachable) as a small persistent 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 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 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 logs — a stale tunnel is the documented first failure point for login/upload
(root `CLAUDE.md`). Re-probe reuses 5.1's resolution path. (root `CLAUDE.md`). Re-probe reuses 5.1's resolution path.
- **5.3** [TODO] **Make both endpoint URLs configurable without a code edit.** - **5.3** [TODO] **Make both endpoint URLs configurable without a code edit.**
`_lanBaseUrl` and `_ngrokBaseUrl` are compile-time consts — the LAN one is `_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 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 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 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 runtime override (e.g. long-press-hidden settings sheet writing to
`SharedPreferences`, seeded from the compiled defaults) so a field device can be `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 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 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 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 probe to it — probing `POST /auth/login` with an empty body works but couples
reachability to the login route's error shape. reachability to the login route's error shape.
### 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](../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.
### 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](../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.
### 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`](../../../Users/rafha/.claude/plans/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](../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).
--- ---
*Sections 1-5 (Flutter) are the only sections this file tracks. Backend *Sections 1-8 (Flutter) are the only sections this file tracks. Backend
enhancements (formerly sections 5-8 here, removed 2026-07-08) now live enhancements (formerly sections 5-8 here, removed 2026-07-08) now live
exclusively in [backend/plans/next-enhancements.md](../backend/plans/next-enhancements.md).* exclusively in [backend/plans/next-enhancements.md](../backend/plans/next-enhancements.md);
that file's §9 holds the backend counterparts to this file's §6-7, and §10
holds the backend counterparts to this file's §8 (see
[docs/api-contract-map.md](../docs/api-contract-map.md) for the shared gap IDs).*
+171
View File
@@ -0,0 +1,171 @@
import 'package:hive/hive.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:app_pfm_ocr_v2/features/camera/camera_drawer.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
class FakeBox extends Fake implements Box {
@override
Future<int> clear() async => 0;
}
class MockLocalStorage extends LocalStorage {
final List<Map<String, dynamic>> pending;
MockLocalStorage({this.pending = const []});
@override
Future<void> init() async {}
@override
Box get documentBox => FakeBox();
@override
Box get pendingDocumentsBox => FakeBox();
@override
List<DocumentModel> getAllDocuments() => [];
@override
List<Map<String, dynamic>> getAllPendingDocuments() => pending;
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
}
Map<String, dynamic> _syncFailedPendingDoc(String id) {
return {
'id': id,
'image_path': 'fake_$id.jpg',
'status': 'syncFailed',
'document': null,
'latitude': null,
'longitude': null,
'error_message': 'Gagal sinkronisasi ke server: 500 Internal Server Error',
'created_at': DateTime.now().toIso8601String(),
'scan_mode': 'DO',
};
}
Widget _buildTestApp(LocalStorage storage) {
final router = GoRouter(
initialLocation: '/camera',
routes: [
GoRoute(
path: '/camera',
builder: (context, state) => Scaffold(
appBar: AppBar(),
drawer: const CameraDrawer(),
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => Scaffold.of(context).openDrawer(),
child: const Text('Open Drawer'),
),
),
),
),
),
GoRoute(path: '/login', builder: (context, state) => const Scaffold(body: Text('LOGIN_SCREEN'))),
GoRoute(path: '/documents', builder: (context, state) => const Scaffold(body: Text('DOCS_SCREEN'))),
],
);
return ProviderScope(
overrides: [localStorageProvider.overrideWithValue(storage)],
child: MaterialApp.router(routerConfig: router),
);
}
/// CameraDrawer's mode-toggle row (DO Scan / Product Scan buttons) has a
/// pre-existing RenderFlex overflow at the fonts/metrics flutter_test falls
/// back to (unrelated to logout behavior - out of scope for this task).
/// `TestWidgetsFlutterBinding.runTest` installs its own `FlutterError.onError`
/// around the test body, so the override must happen inside the body itself
/// (a `setUp` override gets clobbered) to suppress just that known overflow
/// while still surfacing anything else.
Future<void> _ignoringKnownOverflow(Future<void> Function() body) async {
final original = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
if (details.exception.toString().contains('A RenderFlex overflowed')) return;
original?.call(details);
};
try {
await body();
} finally {
FlutterError.onError = original;
}
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('logs out immediately when there are no pending documents', (tester) async {
await _ignoringKnownOverflow(() async {
await tester.pumpWidget(_buildTestApp(MockLocalStorage()));
await tester.pumpAndSettle();
await tester.tap(find.text('Open Drawer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Logout'));
await tester.pumpAndSettle();
expect(find.text('LOGIN_SCREEN'), findsOneWidget);
expect(find.text('Ada Dokumen Belum Tersinkron'), findsNothing);
});
});
testWidgets('warns and stays put when logout is cancelled with pending documents', (tester) async {
await _ignoringKnownOverflow(() async {
final storage = MockLocalStorage(pending: [_syncFailedPendingDoc('1')]);
await tester.pumpWidget(_buildTestApp(storage));
await tester.pumpAndSettle();
await tester.tap(find.text('Open Drawer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Logout'));
await tester.pump();
expect(find.text('Ada Dokumen Belum Tersinkron'), findsOneWidget);
await tester.tap(find.text('Batal'));
await tester.pumpAndSettle();
expect(find.text('LOGIN_SCREEN'), findsNothing);
});
});
testWidgets('logs out after confirming the warning with pending documents', (tester) async {
await _ignoringKnownOverflow(() async {
final storage = MockLocalStorage(pending: [_syncFailedPendingDoc('1'), _syncFailedPendingDoc('2')]);
await tester.pumpWidget(_buildTestApp(storage));
await tester.pumpAndSettle();
await tester.tap(find.text('Open Drawer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Logout'));
await tester.pump();
expect(find.text('Ada Dokumen Belum Tersinkron'), findsOneWidget);
expect(find.textContaining('2 dokumen'), findsOneWidget);
await tester.tap(find.text('Ya, Logout'));
await tester.pumpAndSettle();
expect(find.text('LOGIN_SCREEN'), findsOneWidget);
});
});
}
+2 -2
View File
@@ -14,10 +14,10 @@ void main() {
); );
// Verify Title exists // Verify Title exists
expect(find.text('OCR Document Scanner'), findsOneWidget); expect(find.text('Pemindai Dokumen OCR'), findsOneWidget);
// Verify main card header exists // Verify main card header exists
expect(find.text('Pindai Dokumen DO'), findsOneWidget); expect(find.text('Pemindai DO'), findsOneWidget);
// Verify Camera button exists // Verify Camera button exists
expect(find.text('Ambil Foto Kamera'), findsOneWidget); expect(find.text('Ambil Foto Kamera'), findsOneWidget);
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/documents/document_card.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
DocumentModel _testDoc() {
return DocumentModel(
id: '1',
tanggal: '01 Januari 2026',
noPo: 'PO-1',
noSo: '1000000001',
noDo: 'DO-1',
kepadaYth: 'PM KELAPA DUA',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'Jakarta',
platTruk: 'B 1234 ABC',
namaDriver: 'Driver A',
namaPenerima: 'Penerima A',
items: const [],
createdAt: DateTime.now(),
);
}
void main() {
testWidgets('shows Terkonfirmasi badge by default (synced)', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DocumentCard(doc: _testDoc(), onPrint: (_) async {}),
),
));
expect(find.text('Terkonfirmasi'), findsOneWidget);
expect(find.text('Belum Tersinkron'), findsNothing);
});
testWidgets('shows Belum Tersinkron badge when isUnsynced is true', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DocumentCard(doc: _testDoc(), onPrint: (_) async {}, isUnsynced: true),
),
));
expect(find.text('Belum Tersinkron'), findsOneWidget);
expect(find.text('Terkonfirmasi'), findsNothing);
});
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
Map<String, dynamic> _baseJson({bool? confirmed}) {
return {
'id': '1',
'header': {'tanggal': '', 'no_po': '', 'no_so': '', 'no_do': ''},
'shipment': {'kepada_yth': '', 'order_untuk': '', 'alamat': '', 'plat_truk': ''},
'items': [],
if (confirmed != null) 'confirmed': confirmed,
};
}
void main() {
group('DocumentModel.confirmed', () {
test('reads the backend-provided confirmed value when present', () {
final doc = DocumentModel.fromJson(_baseJson(confirmed: false));
expect(doc.confirmed, false);
});
test('defaults to true when absent (legacy/cached response predating the field)', () {
final doc = DocumentModel.fromJson(_baseJson());
expect(doc.confirmed, true);
});
test('a document built directly via the constructor also defaults to confirmed', () {
final doc = DocumentModel(
id: '1',
tanggal: '',
noPo: '',
noSo: '',
noDo: '',
kepadaYth: '',
orderUntuk: '',
alamat: '',
platTruk: '',
namaDriver: '',
namaPenerima: '',
items: const [],
createdAt: DateTime(2026, 7, 10),
);
expect(doc.confirmed, true);
});
});
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/documents/document_card.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
Map<String, dynamic> _baseJson({String? docType, String orderUntuk = ''}) {
return {
'id': '1',
'header': {'tanggal': '', 'no_po': 'PO-1', 'no_so': '', 'no_do': ''},
'shipment': {'kepada_yth': '', 'order_untuk': orderUntuk, 'alamat': '', 'plat_truk': ''},
'items': [],
if (docType != null) 'docType': docType,
};
}
void main() {
group('DocumentModel.docType', () {
test('uses the backend-provided docType even when orderUntuk disagrees', () {
final doc = DocumentModel.fromJson(_baseJson(docType: 'Product', orderUntuk: 'Anything Edited'));
expect(doc.docType, 'Product');
});
test('falls back to the legacy orderUntuk sentinel when docType is absent', () {
final doc = DocumentModel.fromJson(_baseJson(orderUntuk: 'PRODUCT SCAN'));
expect(doc.docType, 'Product');
});
test('defaults to DO when neither docType nor the legacy sentinel is present', () {
final doc = DocumentModel.fromJson(_baseJson(orderUntuk: 'PRIMA FRESH MART'));
expect(doc.docType, 'DO');
});
});
testWidgets('DocumentCard stays typed as Product even if orderUntuk is edited away from the old sentinel', (tester) async {
final doc = DocumentModel.fromJson(_baseJson(docType: 'Product', orderUntuk: 'Something Edited'));
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DocumentCard(doc: doc, onPrint: (_) async {}),
),
));
// The product-scan layout renders the item name as the top category
// label rather than "Staff Toko" - confirms docType, not orderUntuk,
// drove the layout choice.
expect(find.text('Staff Toko'), findsNothing);
});
}
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
Map<String, dynamic> _baseJson({Map<String, dynamic>? productScan}) {
return {
'id': '1',
'header': {'tanggal': '', 'no_po': '', 'no_so': '', 'no_do': ''},
'shipment': {'kepada_yth': '', 'order_untuk': '', 'alamat': '', 'plat_truk': ''},
'items': [],
if (productScan != null) 'productScan': productScan,
};
}
void main() {
group('DocumentModel.productScanMatches / productScanExtractedExpiryDate', () {
test('reads possibleMatches and extractedExpiryDate from a populated productScan', () {
final doc = DocumentModel.fromJson(_baseJson(productScan: {
'possibleMatches': [
{'no_sku': '123', 'nama_item': 'Fiesta Nugget', 'score': 0.95},
],
'extractedExpiryDate': '15/12/2026',
}));
expect(doc.productScanMatches.length, 1);
expect(doc.productScanMatches.first['no_sku'], '123');
expect(doc.productScanExtractedExpiryDate, '15/12/2026');
});
test('defaults to empty matches and empty date when productScan is absent (DO doc, or pre-G3-fix)', () {
final doc = DocumentModel.fromJson(_baseJson());
expect(doc.productScanMatches, isEmpty);
expect(doc.productScanExtractedExpiryDate, '');
});
test('defaults to empty matches when possibleMatches itself is missing from productScan', () {
final doc = DocumentModel.fromJson(_baseJson(productScan: {'extractedExpiryDate': ''}));
expect(doc.productScanMatches, isEmpty);
});
});
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/editor/document_save_action.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
DocumentModel _doc(String id) {
return DocumentModel(
id: id,
tanggal: '',
noPo: '',
noSo: '',
noDo: '',
kepadaYth: '',
orderUntuk: '',
alamat: '',
platTruk: '',
namaDriver: '',
namaPenerima: '',
items: const [],
createdAt: DateTime(2026, 7, 10),
);
}
void main() {
group('resolveDocumentSaveAction', () {
test('a resolved server document -> putExisting with its real id', () {
final action = resolveDocumentSaveAction(
existingDocument: _doc('3390'),
localImagePath: '/tmp/photo.jpg',
);
expect(action.kind, DocumentSaveActionKind.putExisting);
expect(action.serverId, '3390');
});
test('no resolved document but a local image is available -> reuploadThenPut, never a fabricated id', () {
final action = resolveDocumentSaveAction(
existingDocument: null,
localImagePath: '/tmp/photo.jpg',
);
expect(action.kind, DocumentSaveActionKind.reuploadThenPut);
expect(action.serverId, isNull);
});
test('no resolved document and no local image -> blocked with an explicit message', () {
final action = resolveDocumentSaveAction(
existingDocument: null,
localImagePath: '',
);
expect(action.kind, DocumentSaveActionKind.blocked);
expect(action.blockedMessage, isNotNull);
expect(action.blockedMessage, isNotEmpty);
});
});
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/documents/document_sync_merge.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
DocumentModel _doc({required String id, required String noPo, required DateTime createdAt}) {
return DocumentModel(
id: id,
tanggal: '01 Januari 2026',
noPo: noPo,
noSo: '1000000001',
noDo: 'DO-1',
kepadaYth: 'PM KELAPA DUA',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'Jakarta',
platTruk: 'B 1234 ABC',
namaDriver: 'Driver A',
namaPenerima: 'Penerima A',
items: const [],
createdAt: createdAt,
);
}
void main() {
group('mergeDocumentsWithUnsyncedOverrides', () {
test('overrides a stale server doc with the corrected local version', () {
final now = DateTime.now();
final staleServerDoc = _doc(id: '1', noPo: 'PO-OLD', createdAt: now);
final correctedLocalDoc = _doc(id: '1', noPo: 'PO-CORRECTED', createdAt: now);
final result = mergeDocumentsWithUnsyncedOverrides(
serverDocs: [staleServerDoc],
unsyncedOverrides: {'1': correctedLocalDoc},
);
expect(result.docs, hasLength(1));
expect(result.docs.single.noPo, 'PO-CORRECTED');
expect(result.unsyncedIds, {'1'});
});
test('passes through server docs unchanged when there are no overrides', () {
final now = DateTime.now();
final docA = _doc(id: '1', noPo: 'PO-A', createdAt: now);
final docB = _doc(id: '2', noPo: 'PO-B', createdAt: now);
final result = mergeDocumentsWithUnsyncedOverrides(
serverDocs: [docA, docB],
unsyncedOverrides: const {},
);
expect(result.docs.map((d) => d.id).toSet(), {'1', '2'});
expect(result.unsyncedIds, isEmpty);
});
test('keeps a locally-corrected document even if the server list omits it', () {
final now = DateTime.now();
final localOnlyDoc = _doc(id: '3', noPo: 'PO-LOCAL-ONLY', createdAt: now);
final result = mergeDocumentsWithUnsyncedOverrides(
serverDocs: const [],
unsyncedOverrides: {'3': localOnlyDoc},
);
expect(result.docs, hasLength(1));
expect(result.docs.single.id, '3');
expect(result.unsyncedIds, {'3'});
});
});
}
@@ -0,0 +1,85 @@
import 'package:hive/hive.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:app_pfm_ocr_v2/features/camera/scan_mode_provider.dart';
import 'package:app_pfm_ocr_v2/features/documents/documents_screen.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
class FakeBox extends Fake implements Box {
@override
Future<int> clear() async => 0;
}
class MockLocalStorage extends LocalStorage {
@override
Future<void> init() async {}
@override
Box get documentBox => FakeBox();
@override
Box get pendingDocumentsBox => FakeBox();
@override
List<DocumentModel> getAllDocuments() => [];
@override
Future<void> saveDocument(DocumentModel doc) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() => [];
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('selecting the Product Scan tab updates the shared scanModeProvider', (tester) async {
final container = ProviderContainer(
overrides: [localStorageProvider.overrideWithValue(MockLocalStorage())],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(home: DocumentsScreen()),
),
);
await tester.pumpAndSettle();
expect(container.read(scanModeProvider), 'DO');
await tester.tap(find.text('Product Scan'));
await tester.pumpAndSettle();
expect(container.read(scanModeProvider), 'Product');
});
testWidgets('DocumentsScreen reflects scanModeProvider set elsewhere (e.g. camera drawer) on entry', (tester) async {
final container = ProviderContainer(
overrides: [localStorageProvider.overrideWithValue(MockLocalStorage())],
);
addTearDown(container.dispose);
container.read(scanModeProvider.notifier).state = 'Product';
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(home: DocumentsScreen()),
),
);
await tester.pumpAndSettle();
// The active tab should already be "Product Scan" without any tap,
// proving DocumentsScreen reads scanModeProvider directly rather than a
// stale local copy seeded only once at startup.
final activeText = tester.widget<Text>(find.text('Product Scan'));
expect(activeText.style?.fontWeight, FontWeight.bold);
});
}
+28 -4
View File
@@ -17,6 +17,22 @@ class MockGeotaggingLocalStorage extends LocalStorage {
Future<void> saveDocument(DocumentModel doc) async { Future<void> saveDocument(DocumentModel doc) async {
savedDoc = doc; savedDoc = doc;
} }
@override
List<DocumentModel> getAllDocuments() {
return [];
}
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() {
return [];
}
} }
void main() { void main() {
@@ -76,9 +92,17 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify coordinates controllers are prefilled with the PENDING coordinates, not the document coordinates // Verify coordinates controllers are prefilled with the PENDING coordinates, not the document coordinates
final latFinder = find.widgetWithText(TextFormField, '-6.1111'); final latFinder = find.ancestor(
final lngFinder = find.widgetWithText(TextFormField, '106.1111'); of: find.text('Lintang'),
expect(latFinder, findsOneWidget, reason: 'Latitude should be prefilled with pending coordinates (-6.1111)'); matching: find.byType(TextFormField),
expect(lngFinder, findsOneWidget, reason: 'Longitude should be prefilled with pending coordinates (106.1111)'); );
final lngFinder = find.ancestor(
of: find.text('Bujur'),
matching: find.byType(TextFormField),
);
expect(latFinder, findsOneWidget);
expect(lngFinder, findsOneWidget);
expect(tester.widget<TextFormField>(latFinder).controller?.text, '-6.1111');
expect(tester.widget<TextFormField>(lngFinder).controller?.text, '106.1111');
}); });
} }
+17 -1
View File
@@ -6,16 +6,30 @@ import 'package:app_pfm_ocr_v2/features/documents/pending_documents_provider.dar
import 'package:app_pfm_ocr_v2/models/document_model.dart'; import 'package:app_pfm_ocr_v2/models/document_model.dart';
import 'package:app_pfm_ocr_v2/main.dart'; import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart'; import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockLocalStorage extends LocalStorage { class MockLocalStorage extends LocalStorage {
@override @override
Future<void> init() async {} Future<void> init() async {}
@override @override
Future<void> saveDocument(DocumentModel doc) async {} Future<void> saveDocument(DocumentModel doc) async {}
@override
List<DocumentModel> getAllDocuments() {
return [];
}
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() {
return [];
}
} }
void main() { void main() {
testWidgets('EditorScreen input validation tests', (WidgetTester tester) async { testWidgets('EditorScreen input validation tests', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
final mockLocalStorage = MockLocalStorage(); final mockLocalStorage = MockLocalStorage();
final testDoc = DocumentModel( final testDoc = DocumentModel(
@@ -79,8 +93,10 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Trigger validation by tapping the button // Trigger validation by tapping the button
final reviewButtonFinder = find.text('Review Complete'); final reviewButtonFinder = find.text('Simpan & Konfirmasi');
expect(reviewButtonFinder, findsOneWidget); expect(reviewButtonFinder, findsOneWidget);
await tester.ensureVisible(reviewButtonFinder);
await tester.pumpAndSettle();
await tester.tap(reviewButtonFinder); await tester.tap(reviewButtonFinder);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+4 -4
View File
@@ -22,8 +22,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify inline validation error text is displayed // Verify inline validation error text is displayed
expect(find.text('Username/ID cannot be empty'), findsOneWidget); expect(find.text('Username/ID tidak boleh kosong'), findsOneWidget);
expect(find.text('Password cannot be empty'), findsOneWidget); expect(find.text('Kata sandi tidak boleh kosong'), findsOneWidget);
// Enter a username, and a short password (e.g. 5 chars) // Enter a username, and a short password (e.g. 5 chars)
final textFormFields = find.byType(TextFormField); final textFormFields = find.byType(TextFormField);
@@ -37,7 +37,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify username warning is gone, but password length warning appears // Verify username warning is gone, but password length warning appears
expect(find.text('Username/ID cannot be empty'), findsNothing); expect(find.text('Username/ID tidak boleh kosong'), findsNothing);
expect(find.text('Password must be at least 6 characters'), findsOneWidget); expect(find.text('Kata sandi minimal 6 karakter'), findsOneWidget);
}); });
} }
+33 -14
View File
@@ -1,3 +1,4 @@
import 'package:hive/hive.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -5,6 +6,12 @@ import 'package:app_pfm_ocr_v2/features/documents/documents_screen.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart'; import 'package:app_pfm_ocr_v2/models/document_model.dart';
import 'package:app_pfm_ocr_v2/main.dart'; import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart'; import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FakeBox extends Fake implements Box {
@override
Future<int> clear() async => 0;
}
class MockLocalStorage extends LocalStorage { class MockLocalStorage extends LocalStorage {
final List<DocumentModel> docs; final List<DocumentModel> docs;
@@ -14,6 +21,12 @@ class MockLocalStorage extends LocalStorage {
@override @override
Future<void> init() async {} Future<void> init() async {}
@override
Box get documentBox => FakeBox();
@override
Box get pendingDocumentsBox => FakeBox();
@override @override
List<DocumentModel> getAllDocuments() { List<DocumentModel> getAllDocuments() {
return docs; return docs;
@@ -21,18 +34,30 @@ class MockLocalStorage extends LocalStorage {
@override @override
Future<void> saveDocument(DocumentModel doc) async {} Future<void> saveDocument(DocumentModel doc) async {}
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() {
return [];
}
} }
void main() { void main() {
testWidgets('DocumentsScreen search and coordinate formatting tests', (WidgetTester tester) async { testWidgets('DocumentsScreen search and coordinate formatting tests', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
final testDocs = [ final testDocs = [
DocumentModel( DocumentModel(
id: '1', id: '1',
tanggal: '29 Juni 2026', tanggal: '29 Juni 2026',
noPo: 'PO/26/0000111111', noPo: 'PO/26/0000111111',
noSo: '1000000001', noSo: '1000000001',
noDo: 'DO-111111', noDo: 'DO-BEKASI-111111',
kepadaYth: 'PT. PRIMAFOOD BEKASI', kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'PRIMA FRESH MART', orderUntuk: 'PRIMA FRESH MART',
alamat: 'Bekasi', alamat: 'Bekasi',
platTruk: 'B 1234 ABC', platTruk: 'B 1234 ABC',
@@ -48,8 +73,8 @@ void main() {
tanggal: '30 Juni 2026', tanggal: '30 Juni 2026',
noPo: 'PO/26/0000222222', noPo: 'PO/26/0000222222',
noSo: '2000000002', noSo: '2000000002',
noDo: 'DO-222222', noDo: 'DO-TANGERANG-222222',
kepadaYth: 'PT. PRIMAFOOD TANGERANG', kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'PRIMA FRESH MART', orderUntuk: 'PRIMA FRESH MART',
alamat: 'Tangerang', alamat: 'Tangerang',
platTruk: 'B 5678 XYZ', platTruk: 'B 5678 XYZ',
@@ -78,14 +103,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify both documents are rendered initially // Verify both documents are rendered initially
expect(find.text('DO: DO-111111'), findsOneWidget); expect(find.text('PO/26/0000111111'), findsOneWidget);
expect(find.text('DO: DO-222222'), findsOneWidget); expect(find.text('PO/26/0000222222'), findsOneWidget);
// Verify coordinate rounding to 4 decimal places
// -6.123456 -> -6.1235
// 106.567890 -> 106.5679
expect(find.text('Lat: -6.1235'), findsOneWidget);
expect(find.text('Lng: 106.5679'), findsOneWidget);
// Type query in search bar to filter only Bekasi // Type query in search bar to filter only Bekasi
final searchField = find.byType(TextField); final searchField = find.byType(TextField);
@@ -94,7 +113,7 @@ void main() {
await tester.pump(); await tester.pump();
// Verify only the Bekasi document matches // Verify only the Bekasi document matches
expect(find.text('DO: DO-111111'), findsOneWidget); expect(find.text('PO/26/0000111111'), findsOneWidget);
expect(find.text('DO: DO-222222'), findsNothing); expect(find.text('PO/26/0000222222'), findsNothing);
}); });
} }
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/documents/poll_outcome.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
DocumentModel _doc({String? parseStatus, bool parsed = false}) {
return DocumentModel(
id: '1',
tanggal: '',
noPo: '',
noSo: '',
noDo: '',
kepadaYth: '',
orderUntuk: '',
alamat: '',
platTruk: '',
namaDriver: '',
namaPenerima: '',
items: const [],
createdAt: DateTime(2026, 7, 10),
parsed: parsed,
parseStatus: parseStatus,
);
}
void main() {
group('resolvePollOutcome', () {
test('parseStatus done -> PollOutcomeKind.done carrying the fetched document', () {
final doc = _doc(parseStatus: 'done', parsed: true);
final outcome = resolvePollOutcome(doc);
expect(outcome.kind, PollOutcomeKind.done);
expect(outcome.document, doc);
expect(outcome.message, isNull);
});
test('parseStatus failed -> PollOutcomeKind.failed with an explicit message', () {
final doc = _doc(parseStatus: 'failed');
final outcome = resolvePollOutcome(doc);
expect(outcome.kind, PollOutcomeKind.failed);
expect(outcome.document, isNull);
expect(outcome.message, isNotNull);
});
test('parseStatus pending -> PollOutcomeKind.pending, keep polling', () {
final doc = _doc(parseStatus: 'pending');
final outcome = resolvePollOutcome(doc);
expect(outcome.kind, PollOutcomeKind.pending);
expect(outcome.document, isNull);
});
test('missing/unknown parseStatus (legacy response) is treated as pending, not failed', () {
final doc = _doc(parseStatus: null);
final outcome = resolvePollOutcome(doc);
expect(outcome.kind, PollOutcomeKind.pending);
});
});
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/editor/widgets/product_dropdown_card.dart';
void main() {
final matches = [
{'no_sku': '1', 'nama_item': 'Produk A', 'confidence': 0.9},
];
testWidgets('shows the confidence score when there is a real automatic match', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductDropdownCard(
selectedSku: '1',
matches: matches,
hasAutoMatch: true,
onSkuChanged: (_) {},
),
),
));
expect(find.text('Confidence Score'), findsOneWidget);
expect(find.textContaining('%'), findsOneWidget);
expect(find.textContaining('rekomendasi otomatis'), findsNothing);
});
testWidgets('shows a manual-selection notice instead of a fake confidence score', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductDropdownCard(
selectedSku: '1',
matches: matches,
hasAutoMatch: false,
onSkuChanged: (_) {},
),
),
));
expect(find.text('Confidence Score'), findsNothing);
expect(find.textContaining('rekomendasi otomatis'), findsOneWidget);
});
testWidgets('shows the selected SKU\'s product name as info text below the dropdown, not a form field', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductDropdownCard(
selectedSku: '1',
matches: matches,
hasAutoMatch: true,
onSkuChanged: (_) {},
),
),
));
expect(find.text('Produk A'), findsOneWidget);
// No PO-link field anymore (removed - user request).
expect(find.text('Hubungkan ke PO Dokumen'), findsNothing);
});
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app_pfm_ocr_v2/features/editor/product_editor_screen.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockLocalStorage extends LocalStorage {
@override
Future<void> init() async {}
@override
List<DocumentModel> getAllDocuments() => [];
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() => [];
}
void main() {
testWidgets('shows an explicit retry state instead of fabricated SKU matches when the backend is unreachable', (tester) async {
SharedPreferences.setMockInitialValues({});
final container = ProviderContainer(
overrides: [localStorageProvider.overrideWithValue(MockLocalStorage())],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: ProductEditorScreen(pendingId: null),
),
),
);
await tester.pumpAndSettle(const Duration(seconds: 5));
// The old behavior fabricated these exact hardcoded matches on any
// fetch failure - they must never appear again.
expect(find.textContaining('FIESTA SPICY CHICKEN NUGGET'), findsNothing);
expect(find.textContaining('AKUMO NUGGET COIN'), findsNothing);
expect(find.textContaining('98.5'), findsNothing); // old fake 0.985 confidence
expect(find.text('Gagal Memuat Klasifikasi Produk'), findsOneWidget);
expect(find.widgetWithText(ElevatedButton, 'Coba Lagi'), findsOneWidget);
});
}
@@ -0,0 +1,109 @@
// Regression coverage for gap G3: the product editor must render the SKU
// match / expiry batch UI directly from the document's own stored
// `productScan` data (captured by the backend's single classify+OCR pass at
// upload time) instead of re-running the GPU pipeline a second time via
// POST /scan-product. See docs/api-contract-map.md G3,
// plans/next-enhancements.md, and `product_editor_data_logic.dart`'s
// `_fetchClassificationAndSkus()`.
import 'package:hive/hive.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:app_pfm_ocr_v2/features/editor/product_editor_screen.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
class FakeBox extends Fake implements Box {
@override
Future<int> clear() async => 0;
}
class MockLocalStorage extends LocalStorage {
final List<Map<String, dynamic>> pending;
MockLocalStorage(this.pending);
@override
Future<void> init() async {}
@override
Box get documentBox => FakeBox();
@override
Box get pendingDocumentsBox => FakeBox();
@override
List<DocumentModel> getAllDocuments() => [];
@override
Future<void> saveDocument(DocumentModel doc) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() => pending;
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
}
/// A raw pending-queue JSON blob (as Hive would store it) whose embedded
/// document already carries a populated `productScan` - i.e. exactly what
/// the backend now returns for a Product Scan document once the single
/// upload-time classify pass has run.
Map<String, dynamic> _pendingWithStoredMatch() {
return {
'id': 'p1',
'image_path': 'fake.jpg',
'status': 'success',
'scan_mode': 'Product',
'document': {
'id': '9001',
'header': {'tanggal': '', 'no_po': '', 'no_so': '', 'no_do': ''},
'shipment': {'kepada_yth': '', 'order_untuk': '', 'alamat': '', 'plat_truk': ''},
'items': [],
'docType': 'Product',
'productScan': {
'possibleMatches': [
{'no_sku': '555', 'nama_item': 'Fiesta Nugget Cheese', 'score': 0.93},
],
'extractedExpiryDate': '20/04/2027',
},
},
'latitude': null,
'longitude': null,
'error_message': null,
'created_at': DateTime.now().toIso8601String(),
};
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('renders the stored match/expiry immediately, without any network call', (tester) async {
final container = ProviderContainer(
overrides: [
localStorageProvider.overrideWithValue(MockLocalStorage([_pendingWithStoredMatch()])),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: ProductEditorScreen(pendingId: 'p1'),
),
),
);
// No pumpAndSettle-with-timeout needed for a network call here - a
// single pump is enough to let the synchronous local-read path run.
// If this were still hitting the network, the sandboxed test HttpClient
// would 400 and the screen would show the failure/retry state instead.
await tester.pump();
await tester.pump();
expect(find.text('Fiesta Nugget Cheese'), findsOneWidget);
expect(find.text('Confidence Score'), findsOneWidget);
expect(find.text('Gagal Memuat Klasifikasi Produk'), findsNothing);
});
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/editor/widgets/product_expiry_card.dart';
void main() {
testWidgets('labels an OCR-extracted date as auto-detected, with no fabricated confidence', (tester) async {
final expDateCtrl = TextEditingController();
final notesCtrl = TextEditingController();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductExpiryCard(
selectedSku: '1',
selectedBatchOption: 'Batch 1: 15/12/2026',
isManualDate: false,
skuBatches: const {
'1': ['15/12/2026'],
},
expDateCtrl: expDateCtrl,
notesCtrl: notesCtrl,
onBatchChanged: (_) {},
onSelectDate: () {},
),
),
));
expect(find.textContaining('terdeteksi otomatis'), findsOneWidget);
expect(find.textContaining('92.4'), findsNothing);
expect(find.textContaining('OCR Confidence Score'), findsNothing);
});
testWidgets('labels a manually-entered date as manual, with no fabricated confidence', (tester) async {
final expDateCtrl = TextEditingController();
final notesCtrl = TextEditingController();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductExpiryCard(
selectedSku: '1',
selectedBatchOption: null,
isManualDate: true,
skuBatches: const {},
expDateCtrl: expDateCtrl,
notesCtrl: notesCtrl,
onBatchChanged: (_) {},
onSelectDate: () {},
),
),
));
expect(find.textContaining('diinput manual'), findsOneWidget);
expect(find.textContaining('92.4'), findsNothing);
});
}
@@ -0,0 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/editor/product_scan_response_parser.dart';
void main() {
group('parseSkuMasterList', () {
test('unwraps the v1 {status,data} envelope into a list of maps', () {
final response = {
'status': 'success',
'data': [
{'no_sku': '12010119', 'nama_item': 'FIESTA NUGGET CHEESE 123 400 GR/PAC', 'standar_jumlah': 'PAC'},
{'no_sku': '12010115', 'nama_item': 'FIESTA NUGGET ZOO 400 GR/PAC'},
],
};
final result = parseSkuMasterList(response);
expect(result.length, 2);
expect(result[0]['no_sku'], '12010119');
expect(result[0]['nama_item'], 'FIESTA NUGGET CHEESE 123 400 GR/PAC');
expect(result[1]['no_sku'], '12010115');
});
test('returns an empty list when data is an empty array', () {
final result = parseSkuMasterList({'status': 'success', 'data': []});
expect(result, isEmpty);
});
test('returns an empty list when data is missing entirely', () {
final result = parseSkuMasterList({'status': 'success'});
expect(result, isEmpty);
});
});
group('parseScanProductResponse', () {
test('unwraps possibleMatches and extracted_expired_date from the nested v1 envelope', () {
final response = {
'status': 'success',
'data': {
'classification': {'top1_name': 'FIESTA NUGGET CHEESE'},
'ocr': {
'extracted_sku': '12010119',
'extracted_expired_date': '15/12/2026',
},
'possibleMatches': [
{'no_sku': '12010119', 'nama_item': 'FIESTA NUGGET CHEESE 123 400 GR/PAC', 'score': 0.725, 'isBestMatch': true},
{'no_sku': '12010115', 'nama_item': 'FIESTA NUGGET ZOO 400 GR/PAC', 'score': 0.5, 'isBestMatch': false},
],
},
};
final result = parseScanProductResponse(response);
expect(result.matches.length, 2);
expect(result.matches[0]['no_sku'], '12010119');
expect(result.matches[0]['nama_item'], 'FIESTA NUGGET CHEESE 123 400 GR/PAC');
expect(result.matches[0]['confidence'], 0.725);
expect(result.extractedExpiryDate, '15/12/2026');
});
test('returns an empty match list and empty date when possibleMatches is empty', () {
final response = {
'status': 'success',
'data': {
'classification': {},
'ocr': {'extracted_expired_date': ''},
'possibleMatches': [],
},
};
final result = parseScanProductResponse(response);
expect(result.matches, isEmpty);
expect(result.extractedExpiryDate, '');
});
test('degrades gracefully when ocr is missing entirely', () {
final response = {
'status': 'success',
'data': {
'classification': {},
'possibleMatches': <Map<String, dynamic>>[],
},
};
final result = parseScanProductResponse(response);
expect(result.matches, isEmpty);
expect(result.extractedExpiryDate, '');
});
});
}
+281
View File
@@ -0,0 +1,281 @@
// Tests for task 8.1: Global scan-mode state + DO/Product color cue.
//
// Three cases to cover (per plans/next-enhancements.md §8.1):
// (a) Tapping a tab in DocumentsTabSwitcher updates scanModeProvider (not
// just local widget state).
// (b) DocumentsTabSwitcher active color = AppConfig.doModeColor when 'DO',
// AppConfig.primaryColor when 'Product'.
// (c) CameraDrawerModeToggle DO segment color = AppConfig.doModeColor when
// scanModeProvider == 'DO'.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app_pfm_ocr_v2/config/app_config.dart';
import 'package:app_pfm_ocr_v2/features/camera/scan_mode_provider.dart';
import 'package:app_pfm_ocr_v2/features/documents/documents_tab_switcher.dart';
import 'package:app_pfm_ocr_v2/features/camera/camera_drawer_mode_toggle.dart';
void main() {
// ─── (b) DocumentsTabSwitcher color cue ──────────────────────────────────
group('DocumentsTabSwitcher color cue', () {
testWidgets('DO tab active text color is AppConfig.doModeColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'DO',
onTabChanged: (_) {},
),
),
),
);
final doText = tester.widget<Text>(find.text('DO Scan'));
expect(
doText.style?.color,
AppConfig.doModeColor,
reason: 'Active DO tab text must use doModeColor, not primaryColor',
);
});
testWidgets('Product tab active text color is AppConfig.primaryColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'Product',
onTabChanged: (_) {},
),
),
),
);
final productText = tester.widget<Text>(find.text('Product Scan'));
expect(
productText.style?.color,
AppConfig.primaryColor,
reason: 'Active Product tab text must use primaryColor',
);
});
testWidgets('inactive DO tab text is not doModeColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'Product',
onTabChanged: (_) {},
),
),
),
);
final doText = tester.widget<Text>(find.text('DO Scan'));
expect(
doText.style?.color,
isNot(AppConfig.doModeColor),
reason: 'Inactive DO tab must not use doModeColor',
);
});
// Follow-up from user feedback: the icon (not just the text) must also
// carry the mode color, mirroring CameraDrawerModeToggle's existing
// icon+color pairing. Generic/default chrome elsewhere (search icon,
// print icon, tooltips) is deliberately left untouched.
testWidgets('DO tab active icon color is AppConfig.doModeColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'DO',
onTabChanged: (_) {},
),
),
),
);
final icon = tester.widget<Icon>(find.byIcon(Icons.description));
expect(icon.color, AppConfig.doModeColor);
});
testWidgets('Product tab active icon color is AppConfig.primaryColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'Product',
onTabChanged: (_) {},
),
),
),
);
final icon = tester.widget<Icon>(find.byIcon(Icons.inventory_2));
expect(icon.color, AppConfig.primaryColor);
});
testWidgets('inactive tab icon is neutral gray, not a mode color', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'DO',
onTabChanged: (_) {},
),
),
),
);
final inactiveIcon = tester.widget<Icon>(find.byIcon(Icons.inventory_2_outlined));
expect(inactiveIcon.color, AppConfig.textSecondary);
});
});
// ─── (a) scanModeProvider updated when tab is tapped ─────────────────────
group('DocumentsTabSwitcher updates scanModeProvider', () {
testWidgets('tapping Product tab writes Product to scanModeProvider', (tester) async {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(scanModeProvider), 'DO');
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: container.read(scanModeProvider),
onTabChanged: (tab) =>
container.read(scanModeProvider.notifier).state = tab,
),
),
),
),
);
await tester.tap(find.text('Product Scan'));
await tester.pump();
expect(
container.read(scanModeProvider),
'Product',
reason: 'Tapping Product tab must write to the shared scanModeProvider',
);
});
testWidgets('tapping DO tab writes DO to scanModeProvider', (tester) async {
final container = ProviderContainer(
overrides: [scanModeProvider.overrideWith((ref) => 'Product')],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: container.read(scanModeProvider),
onTabChanged: (tab) =>
container.read(scanModeProvider.notifier).state = tab,
),
),
),
),
);
await tester.tap(find.text('DO Scan'));
await tester.pump();
expect(container.read(scanModeProvider), 'DO');
});
});
// ─── (c) CameraDrawerModeToggle DO segment color ──────────────────────────
group('CameraDrawerModeToggle DO segment color cue', () {
testWidgets('DO segment container color is doModeColor when mode is DO', (tester) async {
final container = ProviderContainer();
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: Scaffold(
body: CameraDrawerModeToggle(),
),
),
),
);
final doContainers = tester
.widgetList<Container>(find.ancestor(
of: find.text('DO Scan'),
matching: find.byType(Container),
))
.toList();
final coloredDoContainer = doContainers.firstWhere(
(c) =>
c.decoration is BoxDecoration &&
(c.decoration as BoxDecoration).color != null &&
(c.decoration as BoxDecoration).color != Colors.transparent,
orElse: () => throw TestFailure(
'Expected a Container with non-transparent color around DO Scan text',
),
);
expect(
(coloredDoContainer.decoration as BoxDecoration).color,
AppConfig.doModeColor,
reason: 'DO segment must use doModeColor when active',
);
});
testWidgets('Product segment color is primaryColor when mode is Product', (tester) async {
final container = ProviderContainer(
overrides: [scanModeProvider.overrideWith((ref) => 'Product')],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: Scaffold(
body: CameraDrawerModeToggle(),
),
),
),
);
final productContainers = tester
.widgetList<Container>(find.ancestor(
of: find.text('Product Scan'),
matching: find.byType(Container),
))
.toList();
final coloredProductContainer = productContainers.firstWhere(
(c) =>
c.decoration is BoxDecoration &&
(c.decoration as BoxDecoration).color != null &&
(c.decoration as BoxDecoration).color != Colors.transparent,
orElse: () => throw TestFailure(
'Expected a Container with non-transparent color around Product Scan text',
),
);
expect(
(coloredProductContainer.decoration as BoxDecoration).color,
AppConfig.primaryColor,
reason: 'Product segment must use primaryColor when active',
);
});
});
}