feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification

Fixes reported from APK field testing: DO/Product scan mode was inconsistent
between the camera drawer and documents screen (now one shared provider,
with an orange/green color cue); unconfirmed scans leaked into history with
placeholder data before the user tapped confirm (backend now gates
GET /documents on a new `confirmed` column, flipped only by PUT); and
Product Scan ran the GPU classifier twice, once at upload and again on
review (now a single pass at upload, persisted and read directly by the
editor). Also removes the unused "Hubungkan ke PO" field and fabricated
PO/SO/DO placeholder values from the Product Scan flow, closes out the
per-document-polling and save-recovery tasks (6.1/6.3), and splits several
touched files to stay under the repo's 256-line guideline.

Full detail in docs/iteration-log.md and backend/docs/iteration-log.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5705 -1142

No files matched your search

+1
View File
@@ -70,6 +70,7 @@ We use the karpathy-guidelines skill to reduce common LLM coding mistakes:
2. **Simplicity First**: Write the minimum amount of code to solve the problem with zero speculative configurations.
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.
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)
+74 -19
View File
@@ -1,5 +1,6 @@
import base64
import io
import math
import os
import re
import traceback
@@ -471,6 +472,45 @@ async def classify_ocr(payload: ScanRequest):
raw_image = Image.open(io.BytesIO(img_data))
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
classification_result = {}
top1_name = None
@@ -561,24 +601,33 @@ async def classify_ocr(payload: ScanRequest):
# 2. Run PaddleOCR
ocr_result = {}
if ocr:
img_arr = np.array(image)
# Use predict method and convert generator to list
res_list = list(ocr.predict(img_arr))
text_lines = []
if res_list and len(res_list) > 0:
text_lines = res_list[0].get("rec_texts", [])
sku = extract_sku(text_lines)
expired_date, expired_idx, expired_source_line = extract_expired_date(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)
text_polys = ocr_text_polys(res_entry)
crop_idx = find_expired_crop_index(
text_lines, expired_idx, expired_date, len(text_polys)
)
if rotated_image_used:
img_arr = np.array(image)
# Use predict method and convert generator to list
res_list = list(ocr.predict(img_arr))
text_lines = []
if res_list and len(res_list) > 0:
text_lines = res_list[0].get("rec_texts", [])
sku = extract_sku(text_lines)
expired_date, expired_idx, expired_source_line = extract_expired_date(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)
text_polys = ocr_text_polys(res_entry)
crop_idx = find_expired_crop_index(
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
vis_image_b64 = None
@@ -633,7 +682,13 @@ async def classify_ocr(payload: ScanRequest):
# 3. Call Spotting API
spotting_image_b64 = None
try:
img_b64_only = payload.image_base64.split(",")[-1]
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]
spotting_payload = {
"file": img_b64_only,
"matchHistoryJob": False,
+14
View File
@@ -44,12 +44,17 @@ workflow and have no task numbers; see `git log` for real dates/history.
- **1.4** Enforced real 401 auth on `/api/v1/documents/*` (list, PUT-by-id, upload) — the actual production API surface, already fully supported by the Flutter client (real login + `Authorization: Bearer` on every request). Previously none of these three routes rejected a missing/invalid token; upload only optionally read it. Added the pre-existing `getAccountFromAuthHeader()` helper (`utils/auth.ts`) + a 401 guard to all three; `OPTIONS` (CORS preflight) untouched. The original task 1.3 (auth on the *classic* routes) was cancelled instead — those routes are dev-only web UI surface with no login flow, going away in production. Verified via `curl`: 401 with no token, success with a real token from `/api/v1/auth/login` — shipped 2026-07-08.
- **1.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.
- **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
- **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.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
@@ -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.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.
## Backend — Document Confirmation Gate & Data Hygiene
- **10.1** Added a `confirmed BOOLEAN NOT NULL DEFAULT TRUE` column to `documents` (`db/init.ts`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` — grandfathers every pre-existing row so today's history didn't go empty after migration) and used it to separate "OCR finished" from "user confirmed": previously `GET /api/v1/documents` filtered only on `parsed = true`, which the backend sets synchronously right after upload — before the mobile user ever taps "Simpan & Konfirmasi" in the editor — so a scan captured, previewed, then backed out of (never confirmed) was already sitting in every entitled account's document list with blank/placeholder fields (root cause of `document_card.dart`'s "Staff Toko" fallback text on the Flutter side). `v1/documents/upload/route.ts` now explicitly inserts `confirmed = false` on every new upload; `v1/documents/[id]/route.ts`'s `PUT` handler is the *only* place that flips it to `true` (literally "the user confirmed"); `v1/documents/route.ts` (list) now filters `AND confirmed = true` unconditionally for every account including `admin` (no role special-casing, per explicit user decision); `v1/documents/[id]/route.ts`'s `GET`-by-id handler is deliberately untouched by the new filter so the mobile poller can keep seeing pending/unconfirmed documents mid-flow. `utils/document-mapper.ts`'s shared `DocumentRow`/`mapDocumentRow()` now carries `confirmed` through to all three call sites (list, GET-by-id, upload's dedup-hit branch) from one place. `parse/route.ts`'s own `INSERT ... ON CONFLICT (filename) DO UPDATE` statements (both DO and Product branches) were deliberately left untouched for `confirmed` — in the real mobile flow the upload route's INSERT always runs first, so this upsert always hits the `ON CONFLICT` branch, and since its `SET` clause doesn't mention `confirmed`, Postgres correctly leaves the existing value alone (verified this is correct, not an oversight). Verified live against the running Docker stack: uploaded a real DO photo as a store account without confirming it — absent from that store's list (and from `admin`'s) while `GET /documents/:id` still reported the correct `parseStatus`; `PUT` (confirm) made it appear immediately with the real submitted data; all 13 pre-existing rows carried `confirmed = true` after the migration ran — shipped 2026-07-10.
- **10.2** Removed the fabricated Product Scan placeholder values `noPO: "PO-PRODUCT-001"`, `noSO: "1002003004"`, `noDO: "DO-PRODUCT-999"` (both the flat keys and the mirrored `header.no_po`/`no_so`/`no_do` sub-object) from `parse/route.ts`'s Product-scan branch, replacing them with empty strings — these are DO-specific concepts that don't apply to a product verification scan, and were never actually read by anything: `pdf_service.dart`'s Product receipt branch never prints them, and `product_editor_submit_logic.dart`'s `_submit()` builds its own `noPo`/`noSo`/`noDo` from the user's PO-link dropdown and batch selection, ignoring the stored values entirely. Same class of issue as the earlier G7 fix (fabricated data presented as if real) — low risk to remove since nothing meaningfully depended on the old values. Scope stayed narrow to exactly these three fields; `nama_driver`/`nama_penerima`'s "PRODUCT SCAN"/"STORE STAFF" placeholders were left alone as a deliberate fixed convention, not a fabricated document number. Verified via `curl`: a freshly-uploaded, unconfirmed Product Scan document's raw `GET /documents/:id` response now returns `no_po`/`no_so`/`no_do` as empty strings instead of the old fake values — shipped 2026-07-10.
## Backend — Single-Pass Product Classification
- **11.1** Eliminated the duplicate GPU classification pass on Product Scan (gap G3), sourced from user feedback that the review screen took noticeably longer to open than DO Scan's. `api/parse/route.ts`'s Product branch previously had its own separate, poorer inline classify call (kept only `top1_name`/`extracted_sku`), forcing the Flutter editor to re-run the entire classify+OCR pipeline a second time via `POST /api/v1/scan-product` just to get the top-5 candidate list and OCR-extracted expiry date. Now calls the same shared `classifyAndMatchProduct()` (`utils/product-scan.ts`) already used by that v1 route — one GPU call, richer result — and persists it under a new `metadata.productScan` JSONB key (no schema migration), surfaced by `document-mapper.ts` as a top-level `productScan` field on every GET response. Caught and fixed a real regression along the way: delegating to the shared function silently dropped the 90s pipeline timeout the old inline fetch had; added the same bound (`PIPELINE_TIMEOUT_MS`) directly inside `classifyAndMatchProduct()` so both callers — this route and the live `POST /api/v1/scan-product` (which never had the bound either) — are protected. Verified via `curl` with a genuinely fresh image/store combination (proving a real classify pass, not a dedup hit): took 9s, and the immediate `GET /documents/:id` response already contained 5 real `possibleMatches` and the extracted expiry date, before any editor interaction — shipped 2026-07-10.
+384
View File
@@ -105,3 +105,387 @@ Conduct a code review and audit of the implementations for Tasks 6.1-6.3 (Ground
## 4. Conclusion
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) {
try {
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;
}
} catch (dbErr) {
+137 -5
View File
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query, withTransaction, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
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
// 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) {
let safeFile = "";
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) {
return errorResponse(400, "Filename is required");
}
@@ -73,6 +75,133 @@ export async function POST(req: NextRequest) {
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)
const cachedDoc = await query(
"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(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs, kode_toko)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
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, $11)
ON CONFLICT (filename) DO UPDATE
SET upload_time = EXCLUDED.upload_time,
size = EXCLUDED.size,
@@ -444,7 +573,9 @@ export async function POST(req: NextRequest) {
is_sample = EXCLUDED.is_sample,
file_hash = EXCLUDED.file_hash,
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
`, [
safeFile,
@@ -456,7 +587,8 @@ export async function POST(req: NextRequest) {
isSample,
fileHash,
JSON.stringify(logsPayload),
kodeToko || null
kodeToko || null,
scanMode || "DO"
]);
const docId = insertDocRes.rows[0].id;
@@ -1,66 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
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) {
try {
const { image_base64 } = await req.json();
const body = await req.json();
const image_base64 = body.image_base64 || body.image;
if (!image_base64) {
return errorResponse(400, "Image is required");
}
// Call Python FastAPI server inside the container
const pyServerUrl = process.env.CLASSIFIER_SERVER_URL || "http://paddleocr-pipeline-api:8120/classify-ocr";
console.log(`Forwarding scan request to classifier server: ${pyServerUrl}`);
const response = await fetch(pyServerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ image_base64 })
});
const result = await classifyAndMatchProduct(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)
// Layout-parsing visualization (same pipeline as DO-PFM Visual Grid) - only
// used by this desktop test page, not part of the shared classify+match logic.
let layoutParsingResult: { layoutParsingResults?: Array<{ outputImages?: Record<string, string> }> } | null = null;
const rawB64 = image_base64.includes(",") ? image_base64.split(",")[1] : image_base64;
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);
}
// 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({
classification: data.classification,
ocr: data.ocr,
possibleMatches,
classification: result.classification,
ocr: result.ocr,
possibleMatches: result.possibleMatches,
layoutParsingResult
});
} catch (error: unknown) {
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";
return errorResponse(500, message);
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -13,6 +14,60 @@ export async function OPTIONS() {
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(
req: NextRequest,
context: { params: Promise<{ id: string }> }
@@ -90,10 +145,13 @@ export async function PUT(
const latFloat = latitude ? parseFloat(latitude.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(`
UPDATE documents
SET parsed = true,
confirmed = true,
latitude = $2,
longitude = $3,
metadata = $4
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -22,9 +23,9 @@ export async function GET(req: NextRequest) {
// Retrieve all custom-uploaded documents
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
WHERE is_sample = false AND parsed = true
WHERE is_sample = false AND parsed = true AND confirmed = true
`;
const queryParams: any[] = [];
@@ -41,85 +42,15 @@ export async function GET(req: NextRequest) {
const mappedList = [];
for (const doc of documents) {
const docId = doc.id;
const metadata = doc.metadata || {};
// Retrieve items from ocr_items
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]);
`, [doc.id]);
const items = itemsRes.rows.map(item => ({
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
});
mappedList.push(mapDocumentRow(doc, itemsRes.rows));
}
return NextResponse.json({
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const UPLOADS_DIR = "/uploads";
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
const formData = await req.formData();
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) {
return errorResponse(400, "No file uploaded", { headers: corsHeaders });
@@ -60,8 +63,8 @@ export async function POST(req: NextRequest) {
// Basic dedup
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, 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 = $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 IS NULL ORDER BY upload_time ASC LIMIT 1";
const dedupParams = account?.kodeToko ? [fileHash, account.kodeToko] : [fileHash];
const existing = await query(dedupQuery, dedupParams);
@@ -70,22 +73,19 @@ export async function POST(req: NextRequest) {
const existingDoc = existing.rows[0];
console.log(`[Dedup] Identical content already uploaded as document ${existingDoc.id}. Skipping duplicate insert and re-parse.`);
const mappedData = {
id: existingDoc.id.toString(),
header: { tanggal: "", no_po: "", no_so: "", no_do: "" },
shipment: {
kepada_yth: "PT.PRIMAFOOD INTERNATIONAL",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
},
items: [] as any[],
latitude: existingDoc.latitude ? parseFloat(existingDoc.latitude.toString()) : latitude,
longitude: existingDoc.longitude ? parseFloat(existingDoc.longitude.toString()) : longitude,
createdAt: new Date(existingDoc.upload_time || Date.now()).toISOString()
};
// Return the original document's actual current parse state instead of an
// always-empty stub, so a retried upload doesn't look permanently "fresh."
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [existingDoc.id]);
const mappedData = mapDocumentRow(existingDoc, itemsRes.rows);
// Fall back to this retry's own GPS tag if the original document never got one.
if (mappedData.latitude === null) mappedData.latitude = latitude;
if (mappedData.longitude === null) mappedData.longitude = longitude;
return NextResponse.json({
status: "success",
@@ -100,9 +100,11 @@ export async function POST(req: NextRequest) {
let docId: number;
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(`
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
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, $10, $11)
RETURNING id
`, [
filename,
@@ -113,7 +115,9 @@ export async function POST(req: NextRequest) {
fileHash,
latitude,
longitude,
account?.kodeToko || null
account?.kodeToko || null,
scanMode,
false
]);
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
// 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.
//
// /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 {
await fetch("http://127.0.0.1:3000/api/parse", {
const parseRes = await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST",
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)
});
if (!parseRes.ok) {
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [`Pipeline error: HTTP ${parseRes.status}`, docId]);
}
} catch (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
@@ -5,9 +5,12 @@ import { getAccountFromAuthHeader } from "@/utils/auth";
export async function GET(req: NextRequest) {
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"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
if (!account) {
return errorResponse(401, "Unauthorized");
}
const res = await query(`
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
// Any authenticated account may scan - unlike sku_master writes, this is the
// route the mobile app itself calls to do a product scan, not an admin tool.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
let imageBase64: string | null = null;
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("multipart/form-data")) {
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const buffer = Buffer.from(await file.arrayBuffer());
imageBase64 = buffer.toString("base64");
} else {
const body = await req.json();
imageBase64 = body.image_base64 || body.image || null;
}
if (!imageBase64) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const result = await classifyAndMatchProduct(imageBase64);
return NextResponse.json({
status: "success",
data: result
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in v1 scan-product API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message, { headers: corsHeaders });
}
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
+49 -15
View File
@@ -194,9 +194,11 @@ export default function ScanPfmPage() {
setEditedSku("");
setActiveTab("summary");
const reader = new FileReader();
reader.onload = () => {
setSelectedImage(reader.result as string);
reader.onload = async () => {
const base64Image = reader.result as string;
setSelectedImage(base64Image);
setSelectedProduct(null);
await runScanForImage(base64Image);
};
reader.onerror = () => setError("Failed to read file");
reader.readAsDataURL(file);
@@ -257,21 +259,13 @@ export default function ScanPfmPage() {
setIsEditingProductName(false);
};
const handleScan = async () => {
if (!selectedImage) {
setError("Please select or upload an image first.");
return;
}
const runScanForImage = async (base64Image: string) => {
setScanning(true);
setError("");
setScanResult(null);
setEditedSku("");
setActiveTab("summary");
try {
let base64Image = selectedImage;
if (selectedImage.startsWith("/produk-pfm")) {
base64Image = await convertUrlToBase64(selectedImage);
}
const res = await fetch("/api/scan-pfm", {
method: "POST",
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 () => {
if (!selectedImage) return;
setRotating(true);
@@ -323,7 +336,9 @@ export default function ScanPfmPage() {
ctx.translate(canvas.width, 0);
ctx.rotate((90 * Math.PI) / 180);
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) {
setError(getErrorMessage(err));
} finally {
@@ -447,13 +462,24 @@ export default function ScanPfmPage() {
<button
key={item.productName}
id={`product-btn-${item.productName.replace(/\s+/g, "-").toLowerCase()}`}
onClick={() => {
onClick={async () => {
setSelectedProduct(item);
if (item.images.length > 0) setSelectedImage(item.images[0]);
setScanResult(null);
setEditedSku("");
setError("");
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 ${
isSelected
@@ -542,11 +568,19 @@ export default function ScanPfmPage() {
{selectedProduct.images.map((img) => (
<button
key={img}
onClick={() => {
onClick={async () => {
setSelectedImage(img);
setScanResult(null);
setError("");
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 ${
selectedImage === img ? "border-teal-500 scale-95 shadow-md" : "border-slate-800 hover:border-slate-600"
+12 -1
View File
@@ -22,7 +22,10 @@ export async function initDb(pool: Pool) {
is_sample BOOLEAN NOT NULL DEFAULT FALSE,
file_hash VARCHAR(64),
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 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 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);");
} catch (alterErr) {
console.error("Failed to alter documents table for schema upgrade:", alterErr);
@@ -0,0 +1,117 @@
export type ParseStatus = "pending" | "done" | "failed";
export interface DocumentRow {
id: number;
filename: string;
upload_time: Date;
parsed: boolean;
metadata: any;
latitude: any;
longitude: any;
scan_mode: string | null;
parse_error: string | null;
confirmed: boolean;
}
export interface OcrItemRow {
kode_barang: string | null;
nama_barang: string | null;
banyak: string | null;
jumlah: string | null;
}
// Shared by GET /api/v1/documents (list), GET /api/v1/documents/:id, and the
// upload route's dedup-return branch, so the header/shipment/status mapping
// only lives in one place.
export function mapDocumentRow(doc: DocumentRow, itemRows: OcrItemRow[]) {
const metadata = doc.metadata || {};
const items = itemRows.map((item) => ({
nomor_sku: item.kode_barang || "",
nama_barang: item.nama_barang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
}));
let header = {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
};
let shipment = {
kepada_yth: "",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
};
if (metadata.header) {
// Document was updated via mobile app
header = {
tanggal: metadata.header.tanggal || "",
no_po: metadata.header.no_po || "",
no_so: metadata.header.no_so || "",
no_do: metadata.header.no_do || ""
};
shipment = {
kepada_yth: metadata.shipment?.kepada_yth || "",
order_untuk: metadata.shipment?.order_untuk || "",
alamat: metadata.shipment?.alamat || "",
plat_truk: metadata.shipment?.plat_truk || "",
nama_driver: metadata.shipment?.nama_driver || "",
nama_penerima: metadata.shipment?.nama_penerima || ""
};
} else {
// Document was freshly uploaded / parsed via web
header = {
tanggal: metadata.tanggal || "",
no_po: metadata.noPO || "",
no_so: metadata.noSO || "",
no_do: metadata.noDO || doc.filename || ""
};
shipment = {
kepada_yth: metadata.customerInfo || "",
order_untuk: metadata.orderUntuk || "",
alamat: metadata.alamat || "",
plat_truk: metadata.platTruk || "",
nama_driver: "",
nama_penerima: metadata.headerRemark || ""
};
}
const parseStatus: ParseStatus = doc.parsed
? "done"
: doc.parse_error
? "failed"
: "pending";
// scan_mode is the source of truth once persisted (task 9.1); fall back to the
// legacy metadata sentinel for rows created before that column existed.
const docType = doc.scan_mode || (shipment.order_untuk === "PRODUCT SCAN" ? "Product" : "DO");
return {
id: doc.id.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header,
shipment,
items,
parsed: doc.parsed,
latitude: doc.latitude ? parseFloat(doc.latitude.toString()) : null,
longitude: doc.longitude ? parseFloat(doc.longitude.toString()) : null,
parseStatus,
docType,
confirmed: doc.confirmed,
// Full classify+OCR result captured at upload time for Product Scan
// documents (gap G3) - lets the editor render immediately instead of
// re-running the GPU pipeline on review. `null` for DO documents, and
// for Product documents parsed before this existed or already PUT
// (the PUT route rebuilds `metadata` from scratch without this key,
// which is fine - the editor only needs it during the initial review).
productScan: metadata.productScan || null
};
}
@@ -0,0 +1,122 @@
import { query } from "../db";
// Bounds the classifier call so a wedged GPU container fails fast instead of
// hanging indefinitely - matches the bound `api/parse/route.ts` used to apply
// to its own separate inline classify call before it started sharing this
// function (see docs/api-contract-map.md G3).
const PIPELINE_TIMEOUT_MS = 90_000;
// Thrown when the Python classifier service itself returns a non-2xx response,
// so callers can forward its actual status instead of collapsing everything to 500.
export class ClassifierError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export interface SkuMatch {
no_sku: string;
nama_item: string;
score: number;
yoloSimilarity: number;
isBestMatch: boolean;
}
export interface ProductScanResult {
classification: any;
ocr: any;
possibleMatches: SkuMatch[];
}
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
// Shared by the classic /api/scan-pfm dev route and the authenticated
// /api/v1/scan-product route: calls the Python classifier, then matches the
// result against sku_master, returning the top-5 candidates.
export async function classifyAndMatchProduct(imageBase64: string): Promise<ProductScanResult> {
const pyServerUrl = process.env.CLASSIFIER_SERVER_URL || "http://paddleocr-pipeline-api:8120/classify-ocr";
const response = await fetch(pyServerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: imageBase64 }),
signal: AbortSignal.timeout(PIPELINE_TIMEOUT_MS)
});
if (!response.ok) {
const errText = await response.text();
throw new ClassifierError(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
const dbRes = await query("SELECT no_sku, nama_item FROM sku_master");
const skuMasterList = dbRes.rows.map(row => ({
no_sku: row.no_sku,
nama_item: row.nama_item
}));
const top1Name = data.classification?.top1_name || "";
const extractedSku = data.ocr?.extracted_sku || "";
const matchedList: SkuMatch[] = skuMasterList.map(sku => {
const yoloSim = top1Name ? getStringSimilarity(sku.nama_item, top1Name) : 0;
const cleanMasterSku = sku.no_sku.trim();
const cleanExtractedSku = extractedSku.trim();
const isSkuMatch = cleanExtractedSku && cleanMasterSku === cleanExtractedSku;
const score = isSkuMatch ? 1.0 : yoloSim;
return {
no_sku: sku.no_sku,
nama_item: sku.nama_item,
score,
yoloSimilarity: yoloSim,
isBestMatch: false
};
});
matchedList.sort((a, b) => b.score - a.score);
const possibleMatches = matchedList.slice(0, 5).filter(m => m.score > 0.1);
if (possibleMatches.length > 0) {
possibleMatches[0].isBestMatch = true;
}
return {
classification: data.classification,
ocr: data.ocr,
possibleMatches
};
}
+134
View File
@@ -216,6 +216,140 @@ used as the parse response's values instead of OCR.
*Suggested order: 7.3 → 7.1 → 7.2 (bootstrap first — account seeding FK-depends
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