feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification
Fixes reported from APK field testing: DO/Product scan mode was inconsistent between the camera drawer and documents screen (now one shared provider, with an orange/green color cue); unconfirmed scans leaked into history with placeholder data before the user tapped confirm (backend now gates GET /documents on a new `confirmed` column, flipped only by PUT); and Product Scan ran the GPU classifier twice, once at upload and again on review (now a single pass at upload, persisted and read directly by the editor). Also removes the unused "Hubungkan ke PO" field and fabricated PO/SO/DO placeholder values from the Product Scan flow, closes out the per-document-polling and save-recovery tasks (6.1/6.3), and splits several touched files to stay under the repo's 256-line guideline. Full detail in docs/iteration-log.md and backend/docs/iteration-log.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2febe0c886
commit
ada6488592
67 files changed
+5660
-1097
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Flutter ↔ Backend API Contract Map
|
||||||
|
|
||||||
|
Written 2026-07-10 from a full read of both sides of the wire (every Flutter call
|
||||||
|
site in `lib/` and every backend route it touches). This is the context document
|
||||||
|
for `plans/next-enhancements.md` sections 6-7 (Flutter) and
|
||||||
|
`backend/plans/next-enhancements.md` section 9 (backend counterparts) — read it
|
||||||
|
before picking up any of those tasks. Gap IDs (**G1**-**G10**) below are referenced
|
||||||
|
from the task entries so a future session can trace a task back to the evidence.
|
||||||
|
|
||||||
|
## Endpoint inventory (what the app actually calls)
|
||||||
|
|
||||||
|
| # | Flutter call site | Method + path | Auth | Backend route |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `auth_provider.dart` `login()` | `POST /api/v1/auth/login` | none (issues token) | `api/v1/auth/login/route.ts` |
|
||||||
|
| 2 | `auth_provider.dart` `checkLoginState()` | `GET /api/v1/auth/me` | Bearer | `api/v1/auth/me/route.ts` |
|
||||||
|
| 3 | `pending_documents_provider.dart` `_uploadAndProcess` | `POST /api/v1/documents/upload` (multipart: `image`, `latitude?`, `longitude?`, `scan_mode`) | Bearer, 401 enforced | `api/v1/documents/upload/route.ts` |
|
||||||
|
| 4 | `pending_documents_provider.dart` `_pollUntilParsed` (every 2s, ≤130×) and `documents_screen.dart` `_loadDocuments` | `GET /api/v1/documents` | Bearer, 401 enforced | `api/v1/documents/route.ts` |
|
||||||
|
| 5 | `editor_screen.dart` save, `product_editor_logic.dart` `_submit`, `pending_documents_provider.dart` `retrySync` | `PUT /api/v1/documents/:id` (`toPutPayload()`) | Bearer, 401 + per-store 403 | `api/v1/documents/[id]/route.ts` |
|
||||||
|
| 6 | `product_editor_logic.dart` `_fetchClassificationAndSkus` | `GET /api/skus` — via `apiBaseUrl.replaceAll('/api/v1', '/api/skus')` | **none** (classic dev route) | `api/skus/route.ts` |
|
||||||
|
| 7 | `product_editor_logic.dart` `_fetchClassificationAndSkus` | `POST /api/scan-pfm` (JSON `{image_base64}`) — same base-URL string hack | **none** (classic dev route) | `api/scan-pfm/route.ts` |
|
||||||
|
| 8 | `app_config.dart` `_isBackendReachable` (startup probe) | `POST /api/v1/auth/login` with `{}` | none | same as #1 |
|
||||||
|
|
||||||
|
Base URL: `AppConfig.apiBaseUrl` resolved once at startup (LAN first, ngrok
|
||||||
|
fallback), frozen into the singleton Dio client (`api_client.dart:13`). Timeouts:
|
||||||
|
`connectTimeout` 10s, `receiveTimeout` 240s (sized for upload's synchronous
|
||||||
|
210s-worst-case parse; every other call inherits it).
|
||||||
|
|
||||||
|
## Response envelopes
|
||||||
|
|
||||||
|
- **v1 success**: `{ status: "success", message?, data: ... }` — Flutter reads
|
||||||
|
`response.data['data']`.
|
||||||
|
- **v1 error**: `{ status: "error", error: { statusCode, code, message } }`
|
||||||
|
(`utils/api-error.ts` → `lib/http-status.ts`), mirrored by
|
||||||
|
`lib/core/network/api_exception.dart` (`ApiException.fromDioException`). These
|
||||||
|
two are deliberately kept in sync — keep it that way.
|
||||||
|
- **Classic routes**: ad-hoc shapes — `GET /api/skus` returns `{ skus: [...] }`,
|
||||||
|
`POST /api/scan-pfm` returns a flat
|
||||||
|
`{ classification, ocr, possibleMatches, layoutParsingResult }`. No envelope,
|
||||||
|
no auth, CORS-open.
|
||||||
|
|
||||||
|
## The DO document lifecycle (happy path, as implemented)
|
||||||
|
|
||||||
|
1. Capture → blur check → `addDocument()` persists a `PendingDocument` to Hive
|
||||||
|
(`uploading`) → multipart POST to `/documents/upload` with `scan_mode` (`'DO'`
|
||||||
|
or `'Product'` from `scanModeProvider`).
|
||||||
|
2. Upload route: dedups by `file_hash`+`kode_toko`; inserts `documents` row
|
||||||
|
(`parsed=false`); **synchronously** calls internal `/api/parse` (210s abort);
|
||||||
|
parse writes `metadata` JSONB + `ocr_items` rows and sets `parsed=true`.
|
||||||
|
Response `data` is a **stub** DocumentModel (real `id`, empty header/items).
|
||||||
|
3. Client flips item to `processing` and polls `GET /documents` every 2s, looking
|
||||||
|
for its `id` in the full list with `parsed==true` (the list endpoint filters
|
||||||
|
`WHERE parsed = true`, so "appears in list" *is* the parse-done signal).
|
||||||
|
4. On success the pending card routes to `/editor` (or `/product-editor` when
|
||||||
|
`scanMode == 'Product'`) with `pendingId` passed as `state.extra`.
|
||||||
|
5. Editor save: builds `DocumentModel` (`id = _document?.id ?? pendingId ?? now-ms`),
|
||||||
|
saves to Hive `documentBox`, `PUT /documents/:id`; on success removes the
|
||||||
|
pending item, on failure marks it `syncFailed` (retry = re-PUT via `retrySync`).
|
||||||
|
6. `documents_screen._loadDocuments`: shows Hive cache, then fetches the server
|
||||||
|
list, **clears the whole Hive box**, and re-saves only server rows.
|
||||||
|
|
||||||
|
The PUT route writes `metadata` in **both** shapes (legacy web keys `noPO`/
|
||||||
|
`customerInfo`/… *and* mobile `header`/`shipment` sub-objects); the GET list maps
|
||||||
|
whichever exists. `DocumentModel.fromJson`/`toPutPayload` match this contract.
|
||||||
|
|
||||||
|
## Gaps found (G1-G10)
|
||||||
|
|
||||||
|
**G1 — No `GET /api/v1/documents/:id`, and no parse status in the contract.**
|
||||||
|
`api/v1/documents/[id]/route.ts` only has PUT. The poller must fetch the *entire*
|
||||||
|
list every 2s (server side: one `documents` query + one `ocr_items` query **per
|
||||||
|
document per poll** — N+1 that grows with history size). Because the list filters
|
||||||
|
`parsed=true`, the client cannot distinguish "still parsing" / "parse failed" /
|
||||||
|
"not mine": a server-side parse failure (upload route swallows it,
|
||||||
|
`upload/route.ts:134`) surfaces only as the client's generic 260s timeout
|
||||||
|
("Gagal mengekstrak data (Timeout)"). Match detection also relies on a Dart object
|
||||||
|
*identity* trick (`found != doc`, `pending_documents_provider.dart:133`).
|
||||||
|
|
||||||
|
**G2 — Product flow calls unauthenticated dev routes via URL string-hacking.**
|
||||||
|
`product_editor_logic.dart:57,69` rewrites the base URL with
|
||||||
|
`.replaceAll('/api/v1', '/api/skus' | '/api/scan-pfm')`. Those classic routes are
|
||||||
|
documented (backend/CLAUDE.md) as dev-only, never-authed, and not part of the
|
||||||
|
production surface; backend task 4.5 (shipped) restricts the public ngrok tunnel
|
||||||
|
to `/api/v1/*`, so both calls are expected to fail off-LAN. The existing v1
|
||||||
|
alternative `GET /api/v1/master/skus` is **admin-only** (403 for store accounts)
|
||||||
|
and uses a different envelope (`{status,data}` vs `{skus}`), so the client can't
|
||||||
|
just switch paths.
|
||||||
|
|
||||||
|
**G3 — RESOLVED 2026-07-10 (root task 7.2 / backend task 11.1).** Double
|
||||||
|
classification per product scan. Upload with `scan_mode=Product` already ran
|
||||||
|
the classifier inside `/api/parse` and stored only the top-1 SKU as the
|
||||||
|
document's single item. The product editor then re-read the image file,
|
||||||
|
base64-encoded it (~MBs through Dio JSON), and re-ran the whole classify+OCR
|
||||||
|
pipeline via `/api/scan-pfm` — ignoring the stored parse result except as a
|
||||||
|
lat/lng fallback. Two GPU passes per photo; the reviewed result could
|
||||||
|
disagree with the stored one. Fixed by having `/api/parse`'s Product branch
|
||||||
|
call the same shared `classifyAndMatchProduct()` used by
|
||||||
|
`POST /api/v1/scan-product` (task 9.3) and persist the full result (top-5
|
||||||
|
`possibleMatches` + OCR `extractedExpiryDate`) in
|
||||||
|
`documents.metadata.productScan`, surfaced by `document-mapper.ts`. The
|
||||||
|
editor now reads this directly from the document instead of re-classifying —
|
||||||
|
one GPU pass per photo, editor opens instantly like DO Scan's does.
|
||||||
|
|
||||||
|
**G4 — Product documents are typed by magic strings, `scan_mode` is never
|
||||||
|
persisted.** The backend fabricates placeholder metadata for product scans
|
||||||
|
(`PO-PRODUCT-001`, `noSO 1002003004`, `DO-PRODUCT-999`, `plat B 1234 PFM`,
|
||||||
|
`order_untuk: "PRODUCT SCAN"` — `parse/route.ts:107-135`) and the Flutter side
|
||||||
|
detects "is a product doc" by `orderUntuk == 'PRODUCT SCAN'`
|
||||||
|
(`documents_screen.dart:110`, `product_editor_logic.dart:36`). `scan_mode` is
|
||||||
|
sent at upload and forwarded to parse but never stored in `documents` nor
|
||||||
|
returned by GET, and `DocumentModel` has no doc-type field. Editing `orderUntuk`
|
||||||
|
silently moves a doc between tabs.
|
||||||
|
|
||||||
|
**G5 — PUT to a client-generated ID can never succeed.** Both editors fall back
|
||||||
|
to `finalDoc.id = widget.pendingId ?? now-ms` when `_document` is null (e.g.
|
||||||
|
`pendingId` no longer found in the provider — the `orElse` stub at
|
||||||
|
`product_editor_logic.dart:26` makes this reachable). The PUT then targets
|
||||||
|
`/documents/<13-digit ms timestamp>`; the backend `parseInt`s it into a value
|
||||||
|
that can't match (or even fit) the int4 `documents.id` → 404/500 → the item is
|
||||||
|
stuck in `syncFailed` and every retry re-fails identically.
|
||||||
|
|
||||||
|
**G6 — Server refresh wipes locally-saved-but-unsynced documents.** After a
|
||||||
|
failed PUT the editors keep the corrected doc in Hive (`saveDocument(finalDoc)`)
|
||||||
|
and mark the pending item `syncFailed` — but `documents_screen._loadDocuments`
|
||||||
|
(`documents_screen.dart:55`) does `documentBox.clear()` and refills from the
|
||||||
|
server list, deleting the local-only copy from history. Recovery survives only
|
||||||
|
via the pending item's embedded `document`; the history list lies in between.
|
||||||
|
|
||||||
|
**G7 — Mock data presented as real data in the product editor.** Offline/error
|
||||||
|
fallback fabricates three hardcoded SKU "matches" with fake confidences
|
||||||
|
(`product_editor_logic.dart:116-126`) and fake batch/expiry dates
|
||||||
|
(`'15/12/2026','20/04/2027'` — also used whenever OCR extracted no expiry date,
|
||||||
|
line 103). A reviewer cannot tell mock from model output. AGENTS.md §5 wants mock
|
||||||
|
data behind an explicit Demo/Live switch, not silently inlined in the live path.
|
||||||
|
|
||||||
|
**G8 — Route params ride on `state.extra`.** `/editor` and `/product-editor`
|
||||||
|
receive `pendingId` via `GoRoute state.extra` (`app_router.dart:34,41`), which
|
||||||
|
does not survive process death/state restoration and can't be deep-linked; a
|
||||||
|
restored editor gets `pendingId == null` and renders empty (feeding G5).
|
||||||
|
|
||||||
|
**G9 — `checkLoginState` error handling is string-typed and fail-open.**
|
||||||
|
`auth_provider.dart:30` detects 401 by `e.toString().contains('401')` instead of
|
||||||
|
`ApiException.from(e).statusCode`, and any *other* failure (timeout, 500, dead
|
||||||
|
tunnel) silently keeps the user "logged in" with a possibly-stale profile in
|
||||||
|
SharedPreferences. Plan task 1.1's original claim ("never pings the server") is
|
||||||
|
stale — `/auth/me` *is* called now; the residual gap is this fragile detection.
|
||||||
|
|
||||||
|
**G10 — Dedup response is a second, emptier stub.** A duplicate upload returns
|
||||||
|
the *original* document's id with empty header/items and no `parsed` flag
|
||||||
|
(`upload/route.ts:75-96`); the client treats it as fresh and re-polls. Works if
|
||||||
|
the original parsed; if the original's parse failed (G1), the second client also
|
||||||
|
burns the full 260s timeout. Low severity on its own — folds into G1's fix.
|
||||||
|
|
||||||
|
**G11 — No draft/confirmed distinction — `parsed` triggers list visibility, not
|
||||||
|
user confirmation.** `GET /api/v1/documents` filters `WHERE parsed = true`. `parsed`
|
||||||
|
is set the instant the backend's OCR pass finishes (`api/parse/route.ts`), which
|
||||||
|
happens **synchronously right after upload** — before the mobile user ever taps
|
||||||
|
"Simpan & Konfirmasi" in the editor (that action only happens on
|
||||||
|
`PUT /api/v1/documents/:id`). A scan the user captured, previewed, and then backed
|
||||||
|
out of (without confirming) is already sitting in the server's document list with
|
||||||
|
blank/placeholder fields — visible both to the store account and to admin. Fix:
|
||||||
|
separate "OCR finished" (`parsed`) from "user confirmed" (`confirmed`) as two
|
||||||
|
distinct booleans, gate list visibility on the latter. Root cause of the
|
||||||
|
`document_card.dart:25` fallback to the placeholder string "Staff Toko" (empty
|
||||||
|
`namaPenerima` on an unconfirmed DO document). Task: backend §10.1 (adds column +
|
||||||
|
gates list) → Flutter §8.2 (adds `confirmed` field to `DocumentModel`). Added
|
||||||
|
2026-07-10 from user APK testing feedback.
|
||||||
|
|
||||||
|
**G12 — Product Scan documents carry fabricated PO/SO/DO placeholder values with
|
||||||
|
no real-world referent.** `api/parse/route.ts` Product-scan branch (lines ~107-118)
|
||||||
|
stamps every Product Scan upload with `noPO: "PO-PRODUCT-001"`, `noSO:
|
||||||
|
"1002003004"`, `noDO: "DO-PRODUCT-999"` — concepts that don't apply to a product
|
||||||
|
verification scan. Not user-facing: `pdf_service.dart`'s Product receipt branch
|
||||||
|
never prints them (shows "Nomor Batch"/expiry instead), and
|
||||||
|
`product_editor_submit_logic.dart`'s `_submit()` never reads them (it builds
|
||||||
|
`noPo`/`noSo`/`noDo` itself from the user's PO-link dropdown/batch selection). Only
|
||||||
|
visible in raw debug logs. Same class of issue as the already-fixed G7 (fabricated
|
||||||
|
SKU matches/dates) — dishonest raw data, low risk to remove since nothing meaningful
|
||||||
|
depends on the values. Task: backend §10.2 (bundled with §10.1, same file). Added
|
||||||
|
2026-07-10 from user APK testing feedback.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Flutter-side fixes: root `plans/next-enhancements.md` §6 (G1 client half, G5,
|
||||||
|
G6) and §7 (G2 client half, G3, G4 client half, G7) and §8 (G11 client half).
|
||||||
|
G8/G9 are folded into existing §1/§4-adjacent tasks as noted there.
|
||||||
|
- Backend counterparts: `backend/plans/next-enhancements.md` §9 (G1/G10 server
|
||||||
|
half: GET-by-id + parse status; G2: non-admin v1 SKU read + v1 scan endpoint;
|
||||||
|
G4 server half: persist and return `scan_mode`) and §10 (G11 server half:
|
||||||
|
`confirmed` column + list filter + PUT flip; G12: remove fabricated PO/SO/DO).
|
||||||
|
- Cross-cutting sequencing: backend §9 ships first; Flutter §6/§7 consume it.
|
||||||
|
Backend §10 ships first; Flutter §8.2 consumes it. Flutter §8.1 is independent.
|
||||||
|
|
||||||
@@ -52,6 +52,97 @@ workflow and have no task numbers; see `git log` for real dates/history.
|
|||||||
|
|
||||||
*(New features shipped via `n`/`next` go below, organized the same way, with task numbers.)*
|
*(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.
|
||||||
@@ -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.
|
||||||
@@ -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 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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).*
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user