Adopt agents-settings kit, ship Product/SKU scan models, harden auth, verify OCR accuracy

Backend (app-pfm-ocr-v2/backend):
- Product/SKU scan feature complete: trained DINOv2 index (118 reference
  photos, 16 SKU classes) and YOLO classifier (83.3% top-1 val accuracy),
  fixed scripts/install-pipeline.sh (was missing ultralytics/torch), fully
  browser-verified end-to-end on /scan-pfm. Mobile m-scan-pfm page cancelled
  (Flutter app handles mobile; web UI is desktop-only for pipeline testing).
- Fixed a real data-loss bug: Save Ground Truth (scan-pfm and the DO-flow's
  manual-label) was silently writing into the pfm-web-app container's
  ephemeral filesystem instead of the host, because /sources wasn't
  bind-mounted in docker-compose.yml. Added the mount, recovered an
  orphaned entry.
- accounts.password is now bcrypt-hashed (bcryptjs, idempotent migration
  in db/init.ts) instead of plaintext; login route compares hashes.
- /api/v1/documents/* (list, PUT, upload) now enforces real 401 auth,
  matching what the Flutter client already sends. The "classic" routes
  deliberately stay open — they're dev-only web UI with no login flow and
  won't exist in production.
- OCR accuracy investigated end-to-end: real baseline is 95.10% overall
  (target met; accuracy_report.md was stale at 75.04%, now flagged). Fixed
  one genuine parser.ts bug (SO/DO field duplication in the global fallback
  regex); remaining gaps are OCR/layout-model limitations, not parser bugs.
- Adopted a standalone copy of the fhanyuh/agents-settings e/n workflow
  scoped to backend/ (AGENTS.md Part A/B split, SKILLS.md, plans/, docs/),
  independent of the root copy which now covers Flutter only.
- next-implementation.md deleted; content folded into
  backend/plans/next-enhancements.md for traceability.

Root:
- Adopted fhanyuh/agents-settings kit (AGENTS.md, SKILLS.md, plans/,
  docs/feature-list.md), scoped to the Flutter app only.
- Pending documents queue now persists to Hive (lib/core/storage) instead
  of memory-only, surviving an app kill mid-upload.

Removed backend_backup/ (stale Express/Prisma prototype, superseded by
pfm-web-app) and the completed plans/next-enhancement-plan.md checklist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-08 11:56:32 +07:00
1 parent 3df9f6ec5d
commit e60ab63154
129 files changed
+8520 -6684

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
# Agent Instructions
This is the authoritative rules file for any AI coding agent (Claude Code, Cursor,
GitHub Copilot, Aider, etc.) working in a project that uses this starter kit. Agents
that don't read AGENTS.md natively should be pointed at it via their own config file
— see `docs/vibe-coding/` for per-tool instructions. `CLAUDE.md` imports this file.
## Scope: excludes `backend/`
This kit's automated behaviors — the `e`/`enhance` and `n`/`next` triggers, the §3
file-size threshold as an *enforcement action* (splitting a file because this kit
says so), and the §5 Demo/Live and §6 Cloud/Local switches — govern the **Flutter
app at the repo root** (`lib/`, `test/`, root-level config/docs) only.
**`backend/` is out of scope for this kit.** It is a separately governed subtree
with its own pre-existing rules files — `backend/CLAUDE.md` (project-specific
architecture/history/confidentiality notes) and `backend/AGENTS.md` (carried over
near-unchanged from `ai-ocr-pfm-2026`: uv conventions, issue-recording workflow,
karpathy coding guidelines) — that predate this kit's adoption and are tuned to
that codebase's own conventions. Do not let this kit's generic rules override or
be conflated with them:
- Don't run `e`/`enhance` against `backend/` modules, and don't let `n`/`next`
auto-seed or pick up backend-section tasks, unless the user explicitly asks for
a backend-scoped run for that specific invocation.
- Don't apply §3's 256-line split as a repo-wide mandate inside `backend/` — that
subtree already documents its own pre-existing large files as accepted debt (see
`backend/CLAUDE.md`) and has its own change conventions.
- §5 (Demo/Live) and §6 (Cloud/Local) describe app-level UX switches for the
Flutter client; they are not a mandate to add mock-data layers or deployment
switches inside the Next.js backend.
- If asked to work inside `backend/`, defer entirely to `backend/CLAUDE.md` and
`backend/AGENTS.md` (and `backend/plans/next-enhancements.md` for feature-level
status — `backend/next-implementation.md` was deleted 2026-07-08, its content
folded into that plan) instead of this file.
See **Adaptation Notes** at the end of this file for how `plans/next-enhancements.md`
reconciles this after an earlier adoption pass mistakenly seeded backend sections.
## 0. Adopting Into an Existing Project
This kit auto-detects whether it's landing in a new or existing codebase — no
separate trigger is required. You can also type "i" / "init" at any time to force a
re-audit (e.g. after a large refactor or reorg).
**Auto-detection**: the first time "e"/"enhance" or "n"/"next" runs, if
`plans/next-enhancements.md` is still the empty seed template (or missing) AND the
repo already contains files/history beyond this kit's own files, treat it as an
existing project and run the audit below first. Skip it on a genuinely empty/new
project — there's nothing to audit.
**The audit** (owned by the Software Architect role, see `SKILLS.md`):
- **Don't overwrite silently.** If the repo already has its own README.md/AGENTS.md/
CLAUDE.md, merge additively — append a clearly-marked section pointing at this
kit's files rather than deleting existing project-specific instructions.
- **Discover reality before writing anything**:
- Detect the tech stack and real build/test/lint commands (package.json, Makefile,
pyproject.toml, etc.).
- Map the actual existing folder/module structure — this becomes the section list
`e`/`enhance` uses, replacing invented sections.
- Scan for files already over the 256-line threshold (§3) and list them as
**pre-existing debt**, not a blocker — the rule binds new/touched files going
forward, it does not retroactively force-refactor untouched legacy code.
- Check for existing equivalents of Demo/Live (§5) or Cloud/Local (§6) patterns;
adapt to what's already there instead of introducing a redundant second switch.
- Seed `plans/next-enhancements.md` sections from the real discovered modules (not
blank placeholders); optionally backfill `docs/feature-list.md` with a short
"Existing Features (pre-kit)" summary so the log doesn't start misleadingly empty.
- Record any deviation from this kit's defaults (because the project already does it
differently) as a short **Adaptation Notes** subsection appended to the end of this
file — so future `e`/`n` runs see local reality, not just the template's
assumptions.
- Once the audit (and any merge/adaptation) is done, `e`/`enhance` and `n`/`next`
behave exactly as described below.
## 1. Trigger "e" or "enhance"
If the user types "e", "enhance", or requests an enhancement plan:
- Read `/plans/next-enhancements.md` to understand the current platform structure, history, and active tasks.
- Overwrite or update the active tasks list inside `/plans/next-enhancements.md`.
- The plan must cover each main section/module of the application.
- Inside the tasks list, define **exactly 3 new enhancements per section** with:
1. A unique number (e.g., `1.1`, `1.2`, `1.3`).
2. A clear, specific description of the functional change.
3. A status (initially set to `[TODO]`).
- Present this plan to the user in your final summary response.
## 2. Trigger "n", "next", or "n{x}"
If the user types "n", "next", "n{x}" (where `{x}` is a positive integer), or requests execution of the next enhancement task(s):
- Read `/plans/next-enhancements.md` to check the status of tasks.
- If all tasks are `[DONE]` (or none are `[TODO]`), automatically run the **"e" / "enhance"** workflow first.
- Otherwise, select the most impactful `[TODO]` task(s) by strategic value, functional impact, or UX contribution — not just the first in order. If `{x}` is given, select the top `{x}` tasks and execute them sequentially.
### 2a. Clarify before building ("Grill Me" step)
Before implementing a task, check whether its scope or acceptance criteria are
genuinely ambiguous (multiple valid interpretations, unspecified UI/data behavior,
no clear "done" condition). If so:
- Ask clarifying questions **one at a time** until the task is unambiguous — use
your tool's native mechanism (e.g. Claude Code's `AskUserQuestion`) where available,
otherwise ask inline and wait for the answer before continuing.
- Record the resolved acceptance criteria as a short 1-3 line note next to the task
entry in `/plans/next-enhancements.md` before writing any code.
- Skip this step entirely when the task is already unambiguous — don't grill the
user on obvious work.
- Implement the selected task(s) fully in the codebase, applying the relevant role(s)
from `SKILLS.md` (Architect, Backend, Frontend, QA, Hardware/Compatibility).
- Once complete:
1. Update the task's status in `/plans/next-enhancements.md` to `[DONE]`.
2. Document the new/updated feature in `/docs/feature-list.md` under the right section heading.
- **Verify build integrity** — this is not just "it compiles and runs":
- QA pass: exercise the golden path and edge cases; run/extend automated tests.
- Hardware/Compatibility pass: check cross-platform, cross-browser, and resource
(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.
## 3. File Size & Refactoring Rules
- **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.
This is a repo-wide rule, not just for new work; if you touch a file over the
threshold, split it as part of that change.
- **Why**: an agent reading a file spends its context budget parsing it before it can
reason about the task. Small, single-purpose files keep that cost low and keep the
agent's understanding accurate (see the "smart zone / dumb zone" problem — models
reason worse as context fills up).
- This applies to AGENTS.md, CLAUDE.md, and SKILLS.md too: keep each under ~250 lines.
Push detail into linked docs (`docs/`) rather than growing the root files.
## 4. Roles
Every implementation task should be viewed through the lens of the relevant role(s)
defined in `SKILLS.md`: Software Architect, Backend Engineer, Frontend Engineer,
QA/Test Engineer, and Hardware & Performance Compatibility Reviewer. A single agent
plays all roles in sequence unless the harness supports spawning role-specific
subagents (see `CLAUDE.md`).
## 5. Mockup Data & Demo/Live Mode
- Store all mock/sample data in `/data/mockup/` — keep it separate from UI components and styling.
- Provide a mock API layer that reads from `/data/mockup/` and mirrors the real backend contract.
- Add a switcher control (icon) in the UI to toggle **Demo** (mock API + mock data) and **Live** (real API + real data).
## 6. Cloud vs Local (On-Premise)
- Provide a setting to choose **Cloud** or **Local (on-premise)** deployment.
- **Cloud**: use remote/cloud-hosted API endpoints and services.
- **Local**: use on-premise/self-hosted API endpoints and services.
- Persist the selection and route all backend/service calls to the chosen environment.
## 7. Ad-hoc Feature Requests
For direct feature requests not using "e"/"n", implement the feature and document it in `/docs/feature-list.md`.
## Adaptation Notes (this project)
Recorded 2026-07-08, from adopting this kit into the existing `app-pfm-ocr-v2` repo.
- **Tech stack**: Flutter mobile app at the repo root (`lib/`) is the field client;
`backend/` runs a Next.js API gateway + Python PaddleOCR/vLLM OCR pipeline +
Postgres. See root `CLAUDE.md` and `backend/CLAUDE.md` for full architecture.
- **Real commands**: `flutter test`, `flutter analyze lib`, `flutter build apk --release`
(Flutter, repo root); `npm run dev`/`build`/`lint` inside `backend/pfm-web-app`
(Next.js) — see `backend/CLAUDE.md` for the OCR accuracy regression harness and the
uv/vLLM Python service commands. There is no single unified test runner across both
halves; Flutter and backend are tested independently.
- **Real module boundaries** (seeded into `plans/next-enhancements.md`): Flutter —
Auth & Splash, Camera Capture & Geotagging, Pending Documents Queue, Document
Editor & PDF Receipt; Backend — Next.js API Gateway, OCR Pipeline & Accuracy,
Postgres Data Layer, DevOps/Docker & Dev Tunnel.
- **Scope correction (2026-07-08, later same day)**: an early `e`/`enhance` run
seeded and executed against the four Backend sections above (5-8) before the
**Scope: excludes `backend/`** rule at the top of this file was written. Those
sections were first frozen as archival, then removed outright from
`plans/next-enhancements.md` (2026-07-08) once `backend/` got its own independent
kit copy — the already-shipped 7.1 DB-transaction fix stays recorded in
`docs/feature-list.md`, but the task itself no longer has an entry in this file's
plan. `plans/next-enhancements.md` now tracks only the Flutter sections (1-4).
Backend enhancement tracking lives entirely in `backend/`'s own copy of this kit
(`backend/plans/next-enhancements.md`, driven by `backend/AGENTS.md` Part B).
- **Pre-existing files over the 256-line threshold** (§3 debt, not a blocker — split
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
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`
(442), `lib/features/camera/image_preview_screen.dart` (380), `lib/features/camera/camera_screen.dart`
(376), `lib/features/documents/pending_documents_provider.dart` (271).
- **No Demo/Live or Cloud/Local switch exists yet** (§5, §6). The closest existing
analogue is `AppConfig.initializeApiBaseUrl()` in `lib/config/app_config.dart`,
which dynamically resolves a *real* backend endpoint (ngrok tunnel, falling back to
a LAN IP) — it always talks to the live backend with real data, never mock data, so
it is not a Demo/Live switch. Building an actual mock-data Demo mode and a
Cloud/Local deployment switch remain open `n`/`next` work, not something this
adoption pass implemented.
- **Naming collision to watch for**: `docker-compose.demo.yml` at the repo root
already uses "demo" for an unrelated concept — a **production-mode** Compose
override (`npm start` instead of `npm run dev`), not a mock-data mode. Don't
conflate it with §5's Demo/Live data switch when that feature is eventually built.
- **Pre-existing, unrelated governance files left as-is** (not part of this kit, not
touched by this adoption):
- `.agents/AGENTS.md` — a short set of OCR post-processing/parsing rules (table
column-shift correction, date normalization). Different purpose and path from
this kit's root `AGENTS.md`; both coexist.
- `plans/next-enhancement-plan.md` (singular) — a pre-existing, fully `[DONE]` QA
verification checklist auto-managed by "QE guidelines," unrelated to the `e`/`n`
workflow's `plans/next-enhancements.md` (plural, added by this kit). Kept side by
side, not merged or migrated.
- **CLAUDE.md**: the repo already had a substantial project-specific root
`CLAUDE.md`. Per this section's "merge additively" rule, this kit's import and
Claude-specific notes were appended as a new, clearly-marked section rather than
replacing the file.
+93
View File
@@ -0,0 +1,93 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
An on-premise, GPU-accelerated OCR system that turns a driver's phone photo of a Delivery Order (DO) into structured, database-backed data. A **Flutter mobile app** (this root) is the field-facing client; a **Dockerized backend** (`backend/`) runs the Next.js API gateway, the PaddleOCR/vLLM pipeline, and Postgres.
Read `README.md` first for the full architecture (mermaid diagrams, tech stack, accuracy numbers). Read `backend/CLAUDE.md` before touching anything under `backend/` — it documents backend-specific history, gotchas, and confidentiality notes not repeated here.
## Repository structure
```
lib/ # Flutter app (this is the root Dart package)
config/ app_config.dart — theme + dynamic API base URL resolution
core/ Dio client, Hive storage, geolocation, go_router
features/ auth, camera, documents, editor, splash — one folder per feature
models/
test/ # Flutter widget/unit tests (flat, not mirrored to lib/)
backend/ # Next.js gateway + Python OCR pipeline + Postgres (see backend/CLAUDE.md)
docs/ # Deep-dive workflow & extraction-rule docs
docker-compose.yml # Canonical backend stack — always run from repo root, not backend/
docker-compose.demo.yml # Production-mode override (npm start instead of npm run dev)
start-dev-tunnel.ps1 # Syncs LAN IP into app_config.dart + starts ngrok
```
**Two `docker-compose.yml` files exist** (`./docker-compose.yml` and `backend/docker-compose.yml`, a legacy standalone duplicate with a different Compose project name). Always run `docker compose` from the repo root — running it from inside `backend/` causes container-name conflicts with anything already started from root.
## Commands
### Flutter app (repo root)
```bash
flutter pub get # install deps
flutter run # run on connected device/emulator
flutter test # run all tests in test/
flutter test test/blur_detector_test.dart # run a single test file
flutter analyze lib # static analysis (flutter_lints)
flutter build apk --release # release APK -> build/app/outputs/flutter-apk/app-release.apk
./start-dev-tunnel.ps1 # detect LAN IP, patch app_config.dart, start ngrok
```
The release APK is currently debug-signed (`android/app/build.gradle.kts` has a TODO for a real signing config) — fine for internal installs, not Play Store distribution.
### Backend (from repo root)
```bash
cp backend/.env.example backend/.env # first-time setup; set CUDA_VISIBLE_DEVICES
docker compose up --build # full stack (dev mode, hot-reloads pfm-web-app)
docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d --build # production mode, rebuild before every demo
```
See `backend/CLAUDE.md` for the Next.js app commands (`npm run dev/build/lint`), the accuracy regression harness, and the uv/vLLM Python service commands.
## Architecture notes for the Flutter client
- **API base URL is resolved dynamically at startup**, not hardcoded to one value: `AppConfig.initializeApiBaseUrl()` in `lib/config/app_config.dart` tries a fixed ngrok domain first, falling back to a hardcoded LAN IP. If login/upload fails on a device, this is the first thing to check — re-run `start-dev-tunnel.ps1` if the host machine's LAN IP or ngrok tunnel has gone stale.
- **Upload flow**: capture → Laplacian-variance blur check → GPS tag → multipart upload → the app polls `GET /documents` every 2s (up to 2 min) until the backend's OCR pass sets `parsed=true` → operator reviews/corrects on-device → `PUT /documents/:id` saves final data → PDF receipt generated/printed locally.
- **Pending-upload queue is persisted to a Hive box** (`lib/features/documents/pending_documents_provider.dart`, `lib/core/storage/local_storage.dart`) — an OS-level app kill mid-upload no longer loses the document; on next launch the queue reloads and resumes anything left non-terminal (fixed 2026-07-08, see `plans/next-enhancements.md` §3.1).
- Auth (`lib/features/auth/`) talks to a demo-stub backend (single hardcoded admin/password, no real server-side token verification) — don't build on top of it assuming real security.
## Confidentiality
`backend/` contains real client business data committed to source (SKU/vendor/customer master data, scanned DO photos, hand-labeled ground truth) — see `backend/CLAUDE.md`'s Confidentiality section before exporting, logging, or sharing anything from `backend/sources/` or `backend/uploads/`.
## Agents Settings Kit
@AGENTS.md
The rules above are the shared, cross-tool source of truth for the `e`/`enhance` and
`n`/`next` enhancement workflow — kept in `AGENTS.md`, not duplicated here, so
Cursor/Copilot/other agents stay in sync (see `docs/vibe-coding/` if that directory
is added later). Roles referenced above are defined in `SKILLS.md`. The workflow's
backlog and shipped-feature log live in `plans/next-enhancements.md` and
`docs/feature-list.md` respectively — see `AGENTS.md`'s Adaptation Notes for how
those coexist with this repo's pre-existing `.agents/AGENTS.md` (OCR parsing rules)
and `plans/next-enhancement-plan.md` (a separate, already-`[DONE]` QA checklist).
**Scope: this kit excludes `backend/`.** `AGENTS.md`'s "Scope" section (top of the
file) is authoritative — the `e`/`n` workflow, its §3 file-size enforcement, and its
§5/§6 Demo-Live/Cloud-Local switches apply to the Flutter app only. `backend/` keeps
its own pre-existing `backend/CLAUDE.md` + `backend/AGENTS.md`; don't run `e`/`n`
against backend modules or apply this kit's generic rules there unless the user
explicitly asks for a backend-scoped run.
Claude-specific notes:
- **Role subagents**: when a task benefits from a fresh, unbiased pass — code review,
QA verification, architecture check — spawn the relevant `SKILLS.md` role via the
`Agent` tool instead of continuing in the current context. This mirrors the
"review in a fresh context window" practice: a context that has been implementing
a feature is a worse reviewer of that same feature.
- **Clarifying questions** (AGENTS.md §2a): use `AskUserQuestion` for the one-at-a-time
grilling step, not free-text questions buried in a longer response.
- **Plan mode**: for any `n`/`next` task that touches multiple files or has more than
one reasonable implementation approach, use `EnterPlanMode` before writing code.
+89
View File
@@ -0,0 +1,89 @@
# Skills & Roles
Five roles an agent applies during `n`/`next` execution (see `AGENTS.md`). One agent
can play all of them in sequence; a multi-agent harness may spawn each as a separate
subagent for a fresh-context pass. Order matters: Architect → Backend/Frontend → QA →
Hardware/Compatibility.
## 1. Software Architect
**Responsibilities**
- Decide where new code lives; keep module boundaries clean.
- Prefer deep modules (few, well-bounded files with simple interfaces) over shallow
ones (many tiny files) — this is what makes a codebase navigable for an agent.
- Own the 256-LOC split rule (`AGENTS.md` §3): when a file crosses the threshold,
decide the split boundary before anyone patches around it.
- Keep the overall plan (`plans/next-enhancements.md`) structured by real
module/section boundaries, not arbitrary groupings.
- Owns the existing-project audit (`AGENTS.md` §0): discovers the real stack, build/
test commands, folder structure, and existing conventions before any section or
task is defined, so the plan reflects reality instead of invented sections.
**When invoked**: start of every `e`/`enhance` run (defining sections); start of every
`n`/`next` task, before implementation begins; and at the start of an auto-detected
or explicit `i`/`init` audit in an existing codebase.
**Handoff**: hands the Backend/Frontend roles a target file layout and interface
contract, not just a task description.
## 2. Backend Engineer
**Responsibilities**
- Implement API/data-layer logic.
- Wire the mock-vs-live routing required by the Demo/Live switch (`AGENTS.md` §5) and
the Cloud/Local endpoint switch (`AGENTS.md` §6) — both must resolve through the
same contract so swapping either setting never changes calling code.
- Keep business logic out of route handlers; route handlers stay thin.
**When invoked**: any task touching data, APIs, or service integration.
**Handoff**: gives Frontend a stable contract (types/schema) to build against; gives
QA the list of new/changed endpoints and their expected error modes.
## 3. Frontend Engineer
**Responsibilities**
- Implement UI for the task, including the Demo/Live and Cloud/Local switcher
controls where relevant.
- Consume the Backend's contract rather than reaching around it.
- Keep components small and composable, respecting the 256-LOC rule.
**When invoked**: any task with a user-facing surface.
**Handoff**: gives QA the golden-path user flow and the edge cases it's aware of.
## 4. QA / Test Engineer
**Responsibilities**
- During the clarification step (`AGENTS.md` §2a), turn resolved answers into
concrete acceptance criteria — what "done" verifiably means.
- Write/extend automated tests for the change.
- Run the **verify build integrity** pass: golden path + edge cases + regression
check on adjacent features, not just "it compiles."
- Reject work back to the relevant role if acceptance criteria aren't met — don't
patch around a failing check.
**When invoked**: acceptance-criteria drafting during §2a; final verification pass
before a task is marked `[DONE]`.
**Handoff**: reports pass/fail with specifics (what broke, under what input) back to
whichever role owns that surface.
## 5. Hardware & Performance Compatibility Reviewer
**Responsibilities**
- Check the change against realistic hardware/runtime constraints: memory and CPU
footprint, cross-platform behavior (Windows/Mac/Linux), cross-browser/device
behavior for UI work, and target-deployment limits (e.g. constrained edge/on-prem
hardware under the Local mode from `AGENTS.md` §6).
- Flag newly introduced heavy dependencies, OS-specific APIs, or assumptions that
break under Local/on-premise deployment.
- Flag anything that would degrade badly on lower-spec hardware or slower networks,
and suggest a lighter-weight alternative when one exists.
**When invoked**: final verification pass, alongside QA, before a task is marked
`[DONE]`; also whenever a task adds a new dependency or changes the deployment/runtime
surface.
**Handoff**: blocks `[DONE]` status until concerns are resolved or explicitly accepted
as a documented trade-off in `docs/feature-list.md`.
+3
View File
@@ -3,3 +3,6 @@ APP_PORT=8000
# GPU index to allocate (e.g. 0, 1, or 0,1)
CUDA_VISIBLE_DEVICES=1
# Secret used to sign/verify account login JWTs
JWT_SECRET=change-me
+115 -175
View File
@@ -1,6 +1,19 @@
# AGENTS: PaddleOCR-VL-1.6 vLLM Service
# AGENTS: PaddleOCR-VL-1.6 vLLM Service + Agents Settings Kit
This repository serves **PaddleOCR-VL-1.6** as a dedicated VLM inference backend using **vLLM**. All Python workflows use **uv** (never bare `pip` or system Python).
This is the authoritative rules file for any AI coding agent (Claude Code, Cursor,
GitHub Copilot, Aider, etc.) working inside `backend/`. Two unrelated concerns live
here side by side: **Part A** is this repo's original vLLM/PaddleOCR service doc.
**Part B** (appended 2026-07-08) is a **backend-scoped copy** of the
[fhanyuh/agents-settings](https://github.com/fhanyuh/agents-settings) `e`/`enhance`
and `n`/`next` workflow — see root `../AGENTS.md` for the same kit covering the
Flutter side of this repo. The two copies are independent: this one's
`plans/next-enhancements.md` and `docs/feature-list.md` only track backend work.
---
# Part A — vLLM Service (PaddleOCR-VL-1.6)
This repository serves **PaddleOCR-VL-1.6** as a dedicated VLM inference backend using **vLLM**. All Python workflows use **uv** (never bare `pip` or system Python). Full detail (client usage examples, tuning, troubleshooting, issue-file template) moved to [docs/vllm-service.md](docs/vllm-service.md) 2026-07-08 to keep this file under the Part B kit's 256-line threshold (§3) — this section keeps only the essentials.
## Architecture
@@ -10,90 +23,18 @@ Client (PaddleOCR pipeline) --> HTTP /v1 --> paddleocr genai_server (vLLM ba
This service exposes only the VLM stage. Clients connect with `vl_rec_backend="vllm-server"` and `vl_rec_server_url="http://<host>:8118/v1"`.
## Prerequisites
- Linux with NVIDIA GPU (CC >= 8.0 recommended; CUDA 12.6+ driver support)
- [uv](https://docs.astral.sh/uv/) installed (`uv --version`)
- ~16 GB GPU VRAM for default settings (tune via `config/vllm_config.yaml`)
## Quick start
```bash
cd <YOUR-WORKING-DIR>/ai-ocr-pfm-2026
# 1) Create Python 3.12 venv and install dependencies
./scripts/install.sh
# 2) Start the vLLM-backed genai server
./scripts/serve.sh
./scripts/install.sh # 1) Create Python 3.12 venv and install dependencies
./scripts/serve.sh # 2) Start the vLLM-backed genai server
```
Default endpoint: `http://0.0.0.0:8118/v1`
## uv conventions (always follow)
| Task | Command |
|------|---------|
| Create/sync env | `uv sync` |
| Run any Python | `uv run <command>` |
| Add a package | `uv add <package>` |
| Run server | `./scripts/serve.sh` or `uv run paddleocr genai_server ...` |
Never use `python -m pip`, `pip install`, or `python -m venv` directly in this repo.
Default endpoint: `http://0.0.0.0:8118/v1`. Never use `python -m pip`, `pip install`, or `python -m venv` directly in this repo — always `uv sync` / `uv run` / `uv add`.
## Issue recording (always follow)
**Every problem encountered** during install, serve, debug, or client integration must be written to `issues/` before moving on — even if it was resolved in the same session.
### Naming
```
issues/{NN}-{slug}.md
```
| Part | Rule | Example |
|------|------|---------|
| `{NN}` | Two-digit running number (`01`, `02`, …). Increment from the highest existing file. | `03` |
| `{slug}` | Lowercase kebab-case summary of the problem | `gpu-memory-startup-failure` |
Full example: `issues/04-gpu-memory-startup-failure.md`
### When to create a file
- Install or dependency errors (flash-attn, vLLM, uv conflicts)
- Server startup or runtime failures (OOM, port bind, model load)
- Client integration bugs or misconfiguration
- Workarounds that took non-obvious steps to discover
Do **not** rely on chat history or inline comments alone — if it blocked progress, it belongs in `issues/`.
### File template
```markdown
# Issue {NN}: {Short title}
## Problem
What failed, with exact error message or symptom.
## Context
Environment, command run, relevant config (`.env`, `config/vllm_config.yaml`).
## Solution
What fixed it, or current workaround / open status.
## References
Links, related issue files, or AGENTS.md sections.
```
### Index
Check `issues/` for the next number:
```bash
ls issues/*.md 2>/dev/null | sort
```
See [issues/](issues/) for recorded problems and fixes from this project.
**Every problem encountered** during install, serve, debug, or client integration must be written to `issues/{NN}-{slug}.md` before moving on — even if resolved in the same session. Naming/template details: [docs/vllm-service.md](docs/vllm-service.md#issue-recording--naming-and-template).
## Environment variables
@@ -108,97 +49,7 @@ Copy `.env.example` to `.env` and adjust as needed:
| `VLLM_CONFIG` | `config/vllm_config.yaml` | vLLM backend YAML config |
| `CUDA_VISIBLE_DEVICES` | `1` (see `.env.example`) | GPU index(es) to use |
On dual-GPU hosts, pick the GPU with more free VRAM. If startup fails with a memory error, lower `gpu-memory-utilization` in `config/vllm_config.yaml`.
## Client usage
After the server is running:
```bash
# CLI
uv run paddleocr doc_parser \
--input https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/paddleocr_vl_demo.png \
--vl_rec_backend vllm-server \
--vl_rec_server_url http://localhost:8118/v1
```
```python
from paddleocr import PaddleOCRVL
pipeline = PaddleOCRVL(
vl_rec_backend="vllm-server",
vl_rec_server_url="http://127.0.0.1:8118/v1",
)
output = pipeline.predict("path/to/image.png")
```
Note: The full PaddleOCR-VL client should run in a **separate** environment if it needs PaddlePaddle GPU + Transformers. This repo is the isolated vLLM server only.
## Tuning vLLM
Edit `config/vllm_config.yaml`:
```yaml
gpu-memory-utilization: 0.8
max-num-seqs: 128
```
Reference: [PaddleOCR-VL vLLM parameter tuning](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html#331-server-side-parameter-adjustment)
## Troubleshooting
See `issues/` for full write-ups. Quick pointers:
| Symptom | Issue file |
|---------|------------|
| `paddleocr install_genai_server_deps` / `No module named pip` | [01-genai-server-deps-pip-in-uv-venv.md](issues/01-genai-server-deps-pip-in-uv-venv.md) |
| flash-attn wheel incompatible with Python version | [02-flash-attn-wheel-python-version-mismatch.md](issues/02-flash-attn-wheel-python-version-mismatch.md) |
| `uv pip` targets wrong venv from another project | [03-active-virtual-env-from-other-project.md](issues/03-active-virtual-env-from-other-project.md) |
| Free memory below `gpu-memory-utilization` on startup | [04-gpu-memory-startup-failure.md](issues/04-gpu-memory-startup-failure.md) |
| `TokenizersBackend has no attribute all_special_tokens_extended` | [05-transformers-tokenizers-incompatibility.md](issues/05-transformers-tokenizers-incompatibility.md) |
| Extracted images not shown in Gradio demo (raw base64 in markdown) | [06-extracted-images-raw-base64-not-displayed.md](issues/06-extracted-images-raw-base64-not-displayed.md) |
### flash-attn build failures
Install the prebuilt wheel after `uv sync` (see `scripts/install.sh`):
```bash
FLASH_ATTN_WHEEL="https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.3.14/flash_attn-2.8.2+cu128torch2.8-cp312-cp312-linux_x86_64.whl" \
./scripts/install.sh
```
Pick the wheel matching your Python and CUDA versions from [flash-attention prebuild wheels](https://mjunya.com/flash-attention-prebuild-wheels/). Details: [02-flash-attn-wheel-python-version-mismatch.md](issues/02-flash-attn-wheel-python-version-mismatch.md).
Note: `paddleocr install_genai_server_deps` uses `pip` internally and is incompatible with uv-managed venvs. See [01-genai-server-deps-pip-in-uv-venv.md](issues/01-genai-server-deps-pip-in-uv-venv.md). This repo installs the vLLM stack via `uv sync` + `uv pip`.
### `TokenizersBackend has no attribute all_special_tokens_extended`
Pin transformers (already in `pyproject.toml`):
```bash
uv pip install "transformers==4.57.6"
```
See [05-transformers-tokenizers-incompatibility.md](issues/05-transformers-tokenizers-incompatibility.md).
### Do not install `paddlepaddle-gpu` in this venv
vLLM and PaddlePaddle GPU conflict. This server env uses `paddleocr[doc-parser]` without Paddle GPU.
### GPU memory on startup
If vLLM reports free memory below `gpu-memory-utilization`, either:
- Set `CUDA_VISIBLE_DEVICES` to a less-busy GPU
- Lower `gpu-memory-utilization` in `config/vllm_config.yaml` (e.g. `0.75` or `0.7`)
See [04-gpu-memory-startup-failure.md](issues/04-gpu-memory-startup-failure.md).
### Health check
```bash
curl -s http://localhost:8118/v1/models | jq .
```
On dual-GPU hosts, pick the GPU with more free VRAM. If startup fails with a memory error, lower `gpu-memory-utilization` in `config/vllm_config.yaml` — see [docs/vllm-service.md](docs/vllm-service.md#gpu-memory-on-startup).
## File map
@@ -210,15 +61,11 @@ curl -s http://localhost:8118/v1/models | jq .
| `scripts/serve.sh` | Start `paddleocr genai_server` |
| `config/vllm_config.yaml` | vLLM backend tuning |
| `.env.example` | Environment variable template |
## References
- [PaddleOCR-VL usage tutorial](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html)
- [PaddleOCR genai_server FAQ](https://github.com/PaddlePaddle/PaddleOCR/discussions/16822)
| `docs/vllm-service.md` | Full vLLM reference (client usage, tuning, troubleshooting) |
## Coding Guidelines (always follow)
We use the [karpathy-guidelines](file:///home/user/LABS/OCR/paddle-ocr-vl-1-6-using-vllm-2026/andrej-karpathy-skills/skills/karpathy-guidelines/SKILL.md) skill to reduce common LLM coding mistakes. Refer to [SKILL.md](file:///home/user/LABS/OCR/paddle-ocr-vl-1-6-using-vllm-2026/andrej-karpathy-skills/skills/karpathy-guidelines/SKILL.md) for details:
We use the karpathy-guidelines skill to reduce common LLM coding mistakes:
1. **Think Before Coding**: Explicitly state assumptions and surface tradeoffs instead of making silent choices.
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.
@@ -235,3 +82,96 @@ When the user intentionally asks to test the app:
- Take a screenshot for each sample image, each step, and each variant/option (if any), until the OCR result appears.
- Save the screenshots in the `/screenshots/` folder.
- Follow the file naming convention: `{2-digit-number}-{step#}-{variant_or_options_if_any}-{slug}.jpg` (e.g., `01-step1-default-upload.jpg`).
---
# Part B — Agents Settings Kit (backend-scoped `e`/`n` workflow)
Backend-scoped copy of the [fhanyuh/agents-settings](https://github.com/fhanyuh/agents-settings) kit, adopted 2026-07-08. Covers only `backend/` modules (Next.js API Gateway, OCR Pipeline & Accuracy, Postgres Data Layer, DevOps/Docker) — Flutter modules are tracked by the separate copy at root `../AGENTS.md`. `../CLAUDE.md` (root) and `CLAUDE.md` (this dir) each import their own copy.
## B0. Adopting Into an Existing Project
Already done for this repo (this split *is* that adoption, mirroring root's own §0 audit). Re-run "i"/"init" here to force a re-audit of `backend/` specifically (e.g. after a large refactor).
## B1. Trigger "e" or "enhance"
- Read `plans/next-enhancements.md` (this dir) to understand current backend structure, history, and active tasks.
- Overwrite or update the active tasks list inside it.
- The plan must cover each backend section/module.
- Define **exactly 3 new enhancements per section**, each with a unique number (e.g. `1.1`), a clear functional description, and status `[TODO]`.
- Present the plan to the user in your final summary.
## B2. Trigger "n", "next", or "n{x}"
- Read `plans/next-enhancements.md` to check task status.
- If all tasks are `[DONE]` (or none `[TODO]`), run **"e"/"enhance"** first.
- Otherwise select the most impactful `[TODO]` task(s) by strategic value/impact — not just first-in-order. If `{x}` given, take the top `{x}` sequentially.
### B2a. Clarify before building ("Grill Me" step)
Same rule as root AGENTS.md §2a: if scope/acceptance criteria are genuinely ambiguous, ask one question at a time (`AskUserQuestion` in Claude Code) until unambiguous, and record the resolved criteria as a 1-3 line note next to the task entry before writing code. Skip when the task is already unambiguous.
- Implement the task(s) fully, applying the relevant role(s) from `SKILLS.md` (this dir).
- On completion: flip status to `[DONE]` in `plans/next-enhancements.md`, document the feature in `docs/feature-list.md` (this dir) under the right section.
- **Verify build integrity**: QA pass (golden path + edge cases + regression check on adjacent features — see `backend/CLAUDE.md`'s accuracy regression harness for OCR/parser changes specifically) and Hardware/Compatibility pass (cross-platform, GPU/VRAM footprint under Local/on-prem deployment — see Part A above).
- State which task(s) were completed and the exact route/endpoint/menu path to see the new feature.
## B3. File Size & Refactoring Rules
Same 256-line threshold as root AGENTS.md §3, backend-wide. Applies to this file, `SKILLS.md`, and `CLAUDE.md` too — which is why Part A above was trimmed and linked out to `docs/vllm-service.md` rather than left inline.
## B4. Roles
See `SKILLS.md` (this dir) — same 5 roles as root (Architect, Backend, Frontend, QA, Hardware/Compatibility), applied to backend surfaces only (API routes, OCR pipeline, DB layer, Docker/deploy).
## B5. Mockup Data & Demo/Live Mode
Same as root AGENTS.md §5: mock data under `/data/mockup/`, a mock API layer mirroring the real backend contract, and a Demo/Live switcher. Not yet built for backend — see Adaptation Notes.
## B6. Cloud vs Local (On-Premise)
Same as root AGENTS.md §6, applied to backend service endpoints (Next.js gateway, pipeline API, vLLM server, Postgres) rather than the Flutter client's API base URL.
## B7. Ad-hoc Feature Requests
Direct feature requests not using "e"/"n": implement and document in `docs/feature-list.md` (this dir).
## Adaptation Notes (backend, split from root 2026-07-08)
- **Origin**: sections 5-8 of root `plans/next-enhancements.md` (Backend — Next.js API
Gateway, Backend — OCR Pipeline & Accuracy, Backend — Postgres Data Layer, DevOps —
Docker & Dev Tunnel) copied here as sections 1-4, statuses re-verified against the
live code before the copy (not copied blind) — see task 7.1's `withTransaction`
claim, task 5.1/5.2's dedup + timeout claims, and task 6.1's empty `models/` claim,
all confirmed still accurate as of 2026-07-08. The root copy is frozen/archival
(see root `AGENTS.md`'s "Scope: excludes `backend/`") rather than deleted, so this
file — not the root one — is the single active source of truth going forward.
- **Real commands**: `npm run dev`/`build`/`lint` in `pfm-web-app/`; accuracy
regression harness `node pfm-web-app/scripts/accuracy-check.mts`; Python services
via `./scripts/install.sh` + `./scripts/serve.sh` (this vLLM repo) and
`./scripts/install-pipeline.sh` + `./scripts/serve-pipeline.sh` (pipeline API +
classifier). Full stack: `docker compose up -d --build` **from the repo root**, not
from inside `backend/` (see root `CLAUDE.md` — two `docker-compose.yml` files
exist and running from here risks container-name conflicts).
- **Pre-existing files over the 256-line threshold** (§B3 debt, not a blocker — split
only if/when touched): `pfm-web-app/src/app/scan-pfm/page.tsx` (1169),
`pfm-web-app/src/utils/parser.ts` (908), `pfm-web-app/src/app/page.tsx` (737),
`config/classify_ocr_server.py` (691), `pfm-web-app/src/app/manual-label/page.tsx`
(612), `pfm-web-app/src/app/api/parse/route.ts` (604), `pfm-web-app/src/db/init.ts`
(477), `compare_sources_accuracy.py` (451), `pfm-web-app/src/utils/docker.ts` (362),
`pfm-web-app/public/produk-pfm/train_classifier.py` (351), `compare_accuracy.py`
(308), `pfm-web-app/src/app/api/arena/route.ts` (265). This file itself (`AGENTS.md`)
was at 237 lines pre-kit and would have exceeded 256 once Part B was appended —
hence the split into `docs/vllm-service.md`.
- **No Demo/Live or Cloud/Local switch exists yet** (§B5, §B6) for the backend
either. `docker-compose.override.yml` exposing `db`/`pipeline-api` directly to the
host is a local-dev convenience, not a Cloud/Local deployment switch.
- **Naming collision resolved by this split**: `AGENTS.md` already existed in this
directory (vLLM service doc, committed 2026-06-30, unrelated to this kit) before
Part B was appended — unlike root, where `AGENTS.md` didn't previously exist. Don't
assume backend's `AGENTS.md` is kit-only when reading it from another tool; Part A
is unrelated, pre-existing content kept for a reason.
- **Pre-existing, unrelated governance files left as-is**: `.agents/AGENTS.md` at the
*repo root* (different path, OCR post-processing rules) and root
`plans/next-enhancement-plan.md` (singular, `[DONE]` QA checklist) — neither is
part of this kit; see root `AGENTS.md`'s own Adaptation Notes.
+87
View File
@@ -0,0 +1,87 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
`app-pfm-ocr-v2/backend` is the **next-generation rewrite of `ai-ocr-pfm-2026`** — same underlying OCR infra (PaddleOCR-VL-1.6 on vLLM + a PaddlePaddle layout-parsing pipeline), same client (Charoen Pokphand/Primafood-branded frozen food products), but a reworked Next.js app (`pfm-web-app/`) and DB schema. If you need background on the shared OCR/vLLM infra (uv conventions, issue-recording workflow, GPU tuning), see `AGENTS.md` — it's carried over near-unchanged from the previous project.
**The active plan for porting the Product/SKU-scanning feature lives in [`plans/next-enhancements.md`](plans/next-enhancements.md) §2** — read it before touching anything related to `scan-pfm`, `produk-pfm`, or the product classifier, since it records exactly what's done vs. still missing and the decisions already made about how to build it. (This used to be a separate `next-implementation.md`; that file was deleted 2026-07-08 once its content was folded into the plan for traceability with the rest of the `e`/`n` backlog.)
## How this project differs from `ai-ocr-pfm-2026`
- **DO-PFM UI is consolidated into a single page.** Unlike the old project's per-route pages (`do-pfm/page.tsx`, `m-do-pfm/page.tsx`), v2's entire upload/history/item-review flow lives in one `pfm-web-app/src/app/page.tsx` (client component, local state, no separate routes). `nginx.conf` still has `/do-pfm`/`/m-do-pfm` location blocks left over from the old routing — these are currently dead (no matching Next.js route, would 404).
- **Standalone-purpose pages still get their own route folder**, e.g. `pfm-web-app/src/app/manual-label/page.tsx` — a self-contained ground-truth annotation tool (own header, own theme, no shared chrome with the root page) backed by `api/manual-label/route.ts` and `sources/manual_labels.json`. This is the pattern to follow for any new single-purpose page (see `plans/next-enhancements.md` §2 for the Product-scan pages, which follow it).
- **Real JWT auth, enforced on the production surface**: `src/utils/auth.ts` signs/verifies tokens (`signAccountToken`/`verifyAccountToken`/`getAccountFromAuthHeader`) against an `accounts` table, each account bound to exactly one `kode_toko` (store) — the intent being that an account's own store is used on upload instead of relying on OCR-based store-text matching. **Passwords are bcrypt-hashed** (`accounts.password`, via `bcryptjs` — chosen over native `bcrypt` since the `pfm-web-app` Docker stage is `node:20-slim` with no build toolchain for native addons; `pfm-web-app/src/db/init.ts` hashes the seed and idempotently migrates any pre-existing plaintext rows on every startup). As of 2026-07-08, `api/v1/documents/*` (list, PUT-by-id, upload) **reject requests with a missing/invalid token (401)** — this is the real production surface, and the Flutter client already does a real login and attaches `Authorization: Bearer <token>` to every request (`lib/features/auth/auth_provider.dart` + `lib/core/network/api_client.dart`). The **classic routes** (`/api/upload`, `/api/scan-pfm`, `/api/parse`, `/api/history`, etc.) and the root/`scan-pfm`/`manual-label` pages deliberately do **not** check auth at all and never will unless that decision changes — they're dev-only web UI with no login screen, not part of the production surface (see `plans/next-enhancements.md` task 1.3, cancelled, and 1.4, shipped instead).
- **Richer SKU master data**: `pfm-web-app/import_sku.js` imports from a TSV with extended packaging columns (`standar_jumlah`, `berat_kemasan`, `isi_outer_kg`, `isi_outer_pac`, `jenis_outer`) added via `ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS`, superseding the old project's bare `no_sku`/`nama_item` seed list.
- **Accuracy regression harness** (new, doesn't exist in the old project): `pfm-web-app/scripts/accuracy-check.mts` hits the live `/api/parse` endpoint for every image in `sources/test-images/`, diffs against hand-labeled ground truth in `sources/manual_labels.json` at three post-processing stages (`layer1RawRegex` → `layer2Sanitized` → `layer3Final` — trace these stage names into `utils/parser.ts` to see where each is produced), and appends run-over-run results to `sources/accuracy_history.jsonl`. Run this after touching `parser.ts` to check for regressions:
```bash
node pfm-web-app/scripts/accuracy-check.mts # reuse cached OCR (fast)
node pfm-web-app/scripts/accuracy-check.mts --refresh-ocr # force fresh pipeline run
node pfm-web-app/scripts/accuracy-check.mts --detail <filename> # full per-stage breakdown for one image
```
`compare_accuracy.py` / `compare_sources_accuracy.py` / `generate_excel.py` at the repo root build human-readable Excel/HTML comparison reports from the same data (`sources/comparison_report.xlsx`, `sources/comparison_side_by_side.html`) — these are analysis tooling, not part of the running app.
- **`api/vllm-proxy/[[...path]]/route.ts`**: a passthrough proxy to the vLLM server (`paddleocr-vllm-server:8118`) that logs every call via `logVllmCallToAll` (`utils/active-log.ts`) — used for debugging/observability, not part of the OCR pipeline itself.
- **`docker-compose.override.yml`** exposes `db` (`5432`) and `pipeline-api` (`8090`) directly to the host for local dev — not present in the old project's compose setup.
## Product/SKU scanning flow — status
See [`plans/next-enhancements.md`](plans/next-enhancements.md) §2 (task 2.1) for full detail — kept there instead of a separate doc so status stays traceable against the rest of the `e`/`n` backlog. **Feature-complete as of 2026-07-08**: the backend (`config/classify_ocr_server.py` with DINOv2 similarity search + YOLO classifier fallback, `api/scan-pfm/route.ts`, `api/produk-pfm/route.ts`, DB schema), the reference photo dataset (`pfm-web-app/public/produk-pfm/foto-kemasan-v2/`, 16 SKU subfolders), the desktop frontend page (`scan-pfm/page.tsx`, full feature parity), and the trained model artifacts (`models/dinov2_index.pkl` — 118/118 photos indexed; `models/produk-pfm-classifier-26n-100e-2026-07-08.pt` — 83.3% top-1 val accuracy on the current thin dataset) all now exist and load cleanly on `pipeline-api` startup. **No mobile web page is planned**: `scan-pfm/page.tsx` is desktop-only, used to test the pipeline; real mobile product scanning goes through the Flutter app instead, so `m-scan-pfm/page.tsx` and its `nginx.conf` route are intentionally left unbuilt/dead (see plan task 2.2, cancelled 2026-07-08). Not yet done: an actual browser pass uploading a photo through `/scan-pfm` end-to-end (verified via container logs/model-loading so far, not a UI test).
## Confidentiality
Same concerns as `ai-ocr-pfm-2026` apply here, plus more surface area:
- `pfm-web-app/src/db/init.ts` and `db/migrations/005_create_sku_master.sql` contain the client's real product catalog and real vendor/customer identities, committed directly in source.
- `sources/` holds live business data: `Rekap SKU Aktif CPI Cikande per April 2026 v2.xlsx`, `Tabel Toko Aktif Juni 2026.xlsx`, `toko_aktif.json`, `manual_labels.json`, `ai_results.json` — real SKU/store master data and hand-labeled ground truth from real scanned documents, not fixtures.
- `uploads/` contains real scanned delivery-order photos and their OCR JSON output.
- The `accounts` table stores bcrypt-hashed passwords as of 2026-07-08 (see above) — still don't log or export its contents, and it's not wired into most routes yet (task 1.3), so don't treat it as a secure boundary for anything beyond the `api/v1/*` REST layer.
## Commands
Web app (`pfm-web-app/`):
```bash
npm run dev # next dev -H 0.0.0.0 (binds all interfaces — for LAN/tunnel access during mobile testing)
npm run build
npm run start
npm run lint
```
Accuracy regression check (see above) — run after any `parser.ts` change:
```bash
node pfm-web-app/scripts/accuracy-check.mts
```
`pfm-web-app/src/utils/parser.test.ts` — same standalone `node:assert` script as the old project, covering `parseDOMetadata`/`sanitizeParsedMetadata`. Run with a TS-capable runner, e.g. `npx tsx pfm-web-app/src/utils/parser.test.ts`.
Python services (uv-managed, same as `ai-ocr-pfm-2026` — see `AGENTS.md`):
```bash
./scripts/install.sh # bootstrap .venv for vLLM server
./scripts/install-pipeline.sh # bootstrap .venv-api — currently missing ultralytics/torch, see plans/next-enhancements.md task 2.1
./scripts/serve.sh # vLLM genai server on :8118
./scripts/serve-pipeline.sh # pipeline API on :8090 + classify_ocr_server.py on :8120
```
Full stack:
```bash
docker compose up -d --build
```
## Agents Settings Kit (backend-scoped)
@AGENTS.md
`AGENTS.md` in this directory now has two parts: Part A is the pre-existing vLLM
service doc referenced above; Part B (appended 2026-07-08) is a **backend-scoped
copy** of the [fhanyuh/agents-settings](https://github.com/fhanyuh/agents-settings)
`e`/`enhance`/`n`/`next` workflow, independent of the root-level copy that covers
the Flutter side (see root `CLAUDE.md`/`AGENTS.md`). Roles are in `SKILLS.md` (this
dir). The backlog and shipped-feature log live in `plans/next-enhancements.md` and
`docs/feature-list.md` (this dir) — these are backend-only and separate from the
root project's equivalents, which now only track Flutter work.
Claude-specific notes (same as root):
- Spawn the relevant `SKILLS.md` role via the `Agent` tool for a fresh-context
review/QA/architecture pass instead of continuing in the implementing context.
- Use `AskUserQuestion` for the one-at-a-time clarification step (§B2a).
- Use `EnterPlanMode` before writing code for any `n`/`next` task that touches
multiple files or has more than one reasonable implementation approach.
+103
View File
@@ -0,0 +1,103 @@
# Skills & Roles (backend)
Backend-scoped copy of the root `SKILLS.md` — same five roles, applied to
`backend/` surfaces (Next.js API gateway, OCR pipeline, Postgres, Docker/deploy)
during `n`/`next` execution (see `AGENTS.md` Part B, this dir). One agent can play
all of them in sequence; a multi-agent harness may spawn each as a separate
subagent for a fresh-context pass. Order matters: Architect → Backend/Frontend →
QA → Hardware/Compatibility.
## 1. Software Architect
**Responsibilities**
- Decide where new backend code lives; keep module boundaries clean (API routes vs.
`utils/` business logic vs. `db/` layer vs. the Python pipeline in `config/`).
- Prefer deep modules (few, well-bounded files with simple interfaces) over shallow
ones — this is what keeps the codebase navigable for an agent.
- Own the 256-LOC split rule (`AGENTS.md` Part B §B3): when a file crosses the
threshold, decide the split boundary before anyone patches around it.
- Keep `plans/next-enhancements.md` (this dir) structured by real backend module
boundaries, not arbitrary groupings.
- Owns the existing-project audit (`AGENTS.md` Part B §B0) for backend specifically.
**When invoked**: start of every `e`/`enhance` run; start of every `n`/`next` task,
before implementation begins.
**Handoff**: hands the Backend/Frontend roles a target file layout and interface
contract, not just a task description.
## 2. Backend Engineer
**Responsibilities**
- Implement Next.js API route logic, DB access (`src/db/`), and the Python OCR
pipeline (`config/classify_ocr_server.py`, pipeline API) as the task requires.
- Wire the mock-vs-live routing required by the Demo/Live switch (`AGENTS.md` Part B
§B5) and the Cloud/Local endpoint switch (§B6) if/when built — both must resolve
through the same contract so swapping either setting never changes calling code.
- Keep business logic out of route handlers (`src/app/api/**/route.ts`); route
handlers stay thin, matching the existing `utils/parser.ts`-style separation.
- Use `withTransaction` (`src/db/index.ts`) for any multi-statement write that must
be atomic — see task 7.1 in the pre-kit history for why this matters here.
**When invoked**: any task touching API routes, the DB layer, or the OCR pipeline.
**Handoff**: gives Frontend a stable contract (types/response shape) to build
against; gives QA the list of new/changed endpoints and their expected error modes.
## 3. Frontend Engineer
**Responsibilities**
- Implement UI for the task inside `pfm-web-app/src/app/`, including Demo/Live and
Cloud/Local switcher controls where relevant.
- Follow this repo's existing page pattern: consolidated single-page flows (root
`page.tsx`) vs. standalone-purpose route folders (`manual-label/page.tsx`,
`scan-pfm/page.tsx`) — see backend `CLAUDE.md` for which pattern a given feature
should follow.
- Consume the Backend Engineer's contract rather than reaching around it.
- Keep components small and composable, respecting the 256-LOC rule.
**When invoked**: any task with a user-facing surface inside `pfm-web-app/`.
**Handoff**: gives QA the golden-path user flow and the edge cases it's aware of.
## 4. QA / Test Engineer
**Responsibilities**
- During clarification (`AGENTS.md` Part B §B2a), turn resolved answers into
concrete acceptance criteria — what "done" verifiably means.
- Write/extend automated tests (`parser.test.ts` pattern) for the change.
- For anything touching `parser.ts` or the OCR pipeline, run the accuracy
regression harness (`node pfm-web-app/scripts/accuracy-check.mts`) and check for
regressions against the current baseline (~89.4% overall, target 95% — see
backend `CLAUDE.md`), not just "it compiles."
- Run the **verify build integrity** pass: golden path + edge cases + regression
check on adjacent features.
- Reject work back to the relevant role if acceptance criteria aren't met — don't
patch around a failing check.
**When invoked**: acceptance-criteria drafting during §B2a; final verification pass
before a task is marked `[DONE]`.
**Handoff**: reports pass/fail with specifics (what broke, under what input) back to
whichever role owns that surface.
## 5. Hardware & Performance Compatibility Reviewer
**Responsibilities**
- Check the change against this stack's real constraints: single vs. dual-GPU dev
mode (`docker-compose.yml` runs `npm run dev`, a known throughput ceiling), VRAM
budget for vLLM (`gpu-memory-utilization` in `config/vllm_config.yaml`), and
behavior under the Local/on-prem deployment mode from `AGENTS.md` Part B §B6.
- Flag newly introduced heavy Python/Node dependencies, GPU-specific assumptions, or
anything that would break the isolated vLLM-server-only environment (no
`paddlepaddle-gpu` in this venv — see `AGENTS.md` Part A / `docs/vllm-service.md`).
- Flag anything that would degrade badly on lower-spec hardware or slower networks
(e.g. the mobile app's 2s polling loop against a slow backend response), and
suggest a lighter-weight alternative when one exists.
**When invoked**: final verification pass, alongside QA, before a task is marked
`[DONE]`; also whenever a task adds a new dependency or changes the deployment/
runtime surface.
**Handoff**: blocks `[DONE]` status until concerns are resolved or explicitly
accepted as a documented trade-off in `docs/feature-list.md` (this dir).
+124 -25
View File
@@ -3,9 +3,12 @@ import io
import os
import re
import traceback
import pickle
from datetime import date
from pathlib import Path
import torch
from torchvision import transforms
import requests
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
@@ -94,6 +97,43 @@ app.add_middleware(
allow_headers=["*"],
)
# DINOv2 Image preprocessing
DINOV2_TRANSFORMS = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
# Load DINOv2 Vector Similarity Search Model on startup
print("Loading DINOv2 for similarity search...")
dinov2_model = None
dinov2_index = None
models_dir = resolve_classifier_models_dir()
dinov2_index_path = models_dir / "dinov2_index.pkl" if models_dir else None
if dinov2_index_path and dinov2_index_path.is_file():
try:
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using DINOv2 on device: {device}")
# Load the index
print(f"Loading DINOv2 index from {dinov2_index_path}...")
with open(dinov2_index_path, "rb") as f:
dinov2_index = pickle.load(f)
print(f"DINOv2 index loaded with {len(dinov2_index['metadata'])} reference images.")
# Load DINOv2 model
dinov2_model = torch.hub.load("facebookresearch/dinov2", "dinov2_vits14").to(device)
dinov2_model.eval()
print("DINOv2 model loaded successfully.")
except Exception as e:
print(f"Error loading DINOv2 model or index: {e}")
dinov2_model = None
dinov2_index = None
else:
print(f"DINOv2 index not found at {dinov2_index_path}. DINOv2 search disabled.")
# Load models on startup
print("Loading YOLO model...")
yolo_model = None
@@ -431,33 +471,92 @@ async def classify_ocr(payload: ScanRequest):
raw_image = Image.open(io.BytesIO(img_data))
image = ImageOps.exif_transpose(raw_image).convert("RGB")
# 1. Run YOLO Classification
# 1. Run DINOv2 Similarity Search or YOLO Classification
classification_result = {}
top1_name = None
if yolo_model:
results = yolo_model(image)
probs = results[0].probs
top1_idx = probs.top1
top1_conf = float(probs.top1conf)
top1_name = results[0].names[top1_idx]
all_probs = []
for idx, val in enumerate(probs.data):
all_probs.append({
"name": results[0].names[idx],
"confidence": float(val)
})
all_probs.sort(key=lambda x: x["confidence"], reverse=True)
classification_result = {
"top1_name": top1_name,
"top1_confidence": top1_conf,
"all_probabilities": all_probs
}
else:
classification_result = {
"error": "YOLO model not loaded"
}
# Try DINOv2 first if index exists
if dinov2_model and dinov2_index:
try:
device = "cuda" if torch.cuda.is_available() else "cpu"
# Preprocess image
preprocessed = DINOV2_TRANSFORMS(image).unsqueeze(0).to(device)
# Extract query embedding
with torch.no_grad():
query_emb = dinov2_model(preprocessed)
query_emb = query_emb / query_emb.norm(dim=-1, keepdim=True)
query_emb = query_emb.squeeze(0).cpu().numpy()
ref_embeddings = dinov2_index["embeddings"] # (N, 384)
ref_metadata = dinov2_index["metadata"] # List of dicts
# Compute Cosine Similarity
similarities = np.dot(ref_embeddings, query_emb)
# Aggregate similarities per class (max similarity of any reference image in the class)
class_sims = {}
for idx, meta in enumerate(ref_metadata):
c_name = meta["class_name"]
sim = float(similarities[idx])
if c_name not in class_sims or sim > class_sims[c_name]:
class_sims[c_name] = sim
# Sort classes by similarity
sorted_classes = sorted(class_sims.items(), key=lambda x: x[1], reverse=True)
all_probs = []
for c_name, sim in sorted_classes:
all_probs.append({
"name": c_name,
"confidence": sim
})
if all_probs:
top1_name = all_probs[0]["name"]
top1_conf = all_probs[0]["confidence"]
classification_result = {
"top1_name": top1_name,
"top1_confidence": top1_conf,
"all_probabilities": all_probs,
"method": "dinov2_similarity"
}
print(f"[DINOv2] Best match: {top1_name} ({top1_conf:.4f})")
except Exception as dinov2_err:
print(f"[DINOv2 Error] Similarity search failed, falling back to YOLO: {dinov2_err}")
traceback.print_exc()
top1_name = None
# Fallback to YOLO if DINOv2 was not run or failed
if not top1_name:
if yolo_model:
results = yolo_model(image)
probs = results[0].probs
top1_idx = probs.top1
top1_conf = float(probs.top1conf)
top1_name = results[0].names[top1_idx]
all_probs = []
for idx, val in enumerate(probs.data):
all_probs.append({
"name": results[0].names[idx],
"confidence": float(val)
})
all_probs.sort(key=lambda x: x["confidence"], reverse=True)
classification_result = {
"top1_name": top1_name,
"top1_confidence": top1_conf,
"all_probabilities": all_probs,
"method": "yolo_classifier"
}
print(f"[YOLO Fallback] Best match: {top1_name} ({top1_conf:.4f})")
else:
classification_result = {
"error": "Both DINOv2 index and YOLO model are unavailable"
}
# 2. Run PaddleOCR
ocr_result = {}
+1
View File
@@ -107,6 +107,7 @@ services:
- PGUSER=postgres
- PGPASSWORD=postgres
- PGDATABASE=dopfm
- JWT_SECRET=${JWT_SECRET:-dev-only-insecure-secret-change-me}
pid: "host"
volumes:
- ./pfm-web-app:/app
+51
View File
@@ -0,0 +1,51 @@
# Feature List (backend)
Structured log of shipped backend features, updated by the `n`/`next` workflow (see
[AGENTS.md](../AGENTS.md) Part B) whenever a task in
[plans/next-enhancements.md](../plans/next-enhancements.md) is marked `[DONE]`.
Split out 2026-07-08 from root `docs/feature-list.md`'s backend sections — this file
is the sole home for backend feature history going forward.
## Format
```
## <Section / Module Name>
- **<task number>** <feature description> — shipped <date>
```
---
## Existing Features (pre-kit)
Backfilled 2026-07-08 during adoption of this kit — these predate the `e`/`n`
workflow and have no task numbers; see `git log` for real dates/history.
### Backend — Next.js API Gateway
- Upload/parse/documents CRUD routes, GPU status endpoint, vLLM proxy, manual-label review tool.
### Backend — OCR Pipeline & Accuracy
- PaddleOCR + vLLM classification pipeline with DB layout caching, table column-shift correction, date normalization, and an accuracy regression harness (`pfm-web-app/scripts/accuracy-check.mts`) — **95.10% overall as of 2026-07-08** (target 95% met; see task 2.3 below for the investigation and `CLAUDE.md`).
### Backend — Postgres Data Layer
- Schema/init in `pfm-web-app/src/db/init.ts`, served via the canonical root `docker-compose.yml` stack.
### DevOps — Docker & Dev Tunnel
- Root `docker-compose.yml` (dev, hot-reload) and `docker-compose.demo.yml` (production-mode override); `start-dev-tunnel.ps1` syncs the host LAN IP into the Flutter app config and starts an ngrok tunnel.
*(New features shipped via `n`/`next` go below, organized the same way, with task numbers.)*
## Backend — Next.js API Gateway
- **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.
## 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.
## Backend — Postgres Data Layer
- **3.1** Wrapped the `ocr_items` delete-then-reinsert in `/api/parse` and `/api/v1/documents/[id]` PUT inside a DB transaction (`withTransaction` helper, `pfm-web-app/src/db/index.ts`) — a mid-loop insert failure now rolls back to the previous item set instead of leaving a document with a correct header but partial/missing items — shipped 2026-07-08. (Renumbered from root's `7.1` when this file split from root `docs/feature-list.md`.)
- **3.2** Hashed `accounts.password` with `bcryptjs` (pure-JS, no native compile step — the `pfm-web-app` Docker image has no build toolchain). `db/init.ts` hashes the seed and idempotently migrates any pre-existing plaintext rows on every startup; `api/v1/auth/login/route.ts` now compares with `bcrypt.compareSync` and cleanly rejects missing credentials with a 401 instead of risking a raw-query edge case. Verified via `psql` (hash format) and `curl` (correct login succeeds, wrong/missing password returns 401) — shipped 2026-07-08.
+138
View File
@@ -0,0 +1,138 @@
# vLLM Service — Full Reference
Detail split out of `../AGENTS.md` (2026-07-08, to keep that file under the
Agents Settings Kit's 256-line threshold once the `e`/`n` workflow was appended
to it). `AGENTS.md` keeps the short version — architecture, quick start, the
env var table, file map — and links here for everything else.
## Issue recording — naming and template
```
issues/{NN}-{slug}.md
```
| Part | Rule | Example |
|------|------|---------|
| `{NN}` | Two-digit running number (`01`, `02`, …). Increment from the highest existing file. | `03` |
| `{slug}` | Lowercase kebab-case summary of the problem | `gpu-memory-startup-failure` |
Full example: `issues/04-gpu-memory-startup-failure.md`
### File template
```markdown
# Issue {NN}: {Short title}
## Problem
What failed, with exact error message or symptom.
## Context
Environment, command run, relevant config (`.env`, `config/vllm_config.yaml`).
## Solution
What fixed it, or current workaround / open status.
## References
Links, related issue files, or AGENTS.md sections.
```
Check `issues/` for the next number:
```bash
ls issues/*.md 2>/dev/null | sort
```
## Client usage
After the server is running:
```bash
# CLI
uv run paddleocr doc_parser \
--input https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/paddleocr_vl_demo.png \
--vl_rec_backend vllm-server \
--vl_rec_server_url http://localhost:8118/v1
```
```python
from paddleocr import PaddleOCRVL
pipeline = PaddleOCRVL(
vl_rec_backend="vllm-server",
vl_rec_server_url="http://127.0.0.1:8118/v1",
)
output = pipeline.predict("path/to/image.png")
```
Note: The full PaddleOCR-VL client should run in a **separate** environment if it needs PaddlePaddle GPU + Transformers. This repo is the isolated vLLM server only.
## Tuning vLLM
Edit `config/vllm_config.yaml`:
```yaml
gpu-memory-utilization: 0.8
max-num-seqs: 128
```
Reference: [PaddleOCR-VL vLLM parameter tuning](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html#331-server-side-parameter-adjustment)
## Troubleshooting
See `issues/` for full write-ups. Quick pointers:
| Symptom | Issue file |
|---------|------------|
| `paddleocr install_genai_server_deps` / `No module named pip` | [01-genai-server-deps-pip-in-uv-venv.md](../issues/01-genai-server-deps-pip-in-uv-venv.md) |
| flash-attn wheel incompatible with Python version | [02-flash-attn-wheel-python-version-mismatch.md](../issues/02-flash-attn-wheel-python-version-mismatch.md) |
| `uv pip` targets wrong venv from another project | [03-active-virtual-env-from-other-project.md](../issues/03-active-virtual-env-from-other-project.md) |
| Free memory below `gpu-memory-utilization` on startup | [04-gpu-memory-startup-failure.md](../issues/04-gpu-memory-startup-failure.md) |
| `TokenizersBackend has no attribute all_special_tokens_extended` | [05-transformers-tokenizers-incompatibility.md](../issues/05-transformers-tokenizers-incompatibility.md) |
| Extracted images not shown in Gradio demo (raw base64 in markdown) | [06-extracted-images-raw-base64-not-displayed.md](../issues/06-extracted-images-raw-base64-not-displayed.md) |
### flash-attn build failures
Install the prebuilt wheel after `uv sync` (see `scripts/install.sh`):
```bash
FLASH_ATTN_WHEEL="https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.3.14/flash_attn-2.8.2+cu128torch2.8-cp312-cp312-linux_x86_64.whl" \
./scripts/install.sh
```
Pick the wheel matching your Python and CUDA versions from [flash-attention prebuild wheels](https://mjunya.com/flash-attention-prebuild-wheels/). Details: [02-flash-attn-wheel-python-version-mismatch.md](../issues/02-flash-attn-wheel-python-version-mismatch.md).
Note: `paddleocr install_genai_server_deps` uses `pip` internally and is incompatible with uv-managed venvs. See [01-genai-server-deps-pip-in-uv-venv.md](../issues/01-genai-server-deps-pip-in-uv-venv.md). This repo installs the vLLM stack via `uv sync` + `uv pip`.
### `TokenizersBackend has no attribute all_special_tokens_extended`
Pin transformers (already in `pyproject.toml`):
```bash
uv pip install "transformers==4.57.6"
```
See [05-transformers-tokenizers-incompatibility.md](../issues/05-transformers-tokenizers-incompatibility.md).
### Do not install `paddlepaddle-gpu` in this venv
vLLM and PaddlePaddle GPU conflict. This server env uses `paddleocr[doc-parser]` without Paddle GPU.
### GPU memory on startup
If vLLM reports free memory below `gpu-memory-utilization`, either:
- Set `CUDA_VISIBLE_DEVICES` to a less-busy GPU
- Lower `gpu-memory-utilization` in `config/vllm_config.yaml` (e.g. `0.75` or `0.7`)
See [04-gpu-memory-startup-failure.md](../issues/04-gpu-memory-startup-failure.md).
### Health check
```bash
curl -s http://localhost:8118/v1/models | jq .
```
## References
- [PaddleOCR-VL usage tutorial](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html)
- [PaddleOCR genai_server FAQ](https://github.com/PaddlePaddle/PaddleOCR/discussions/16822)
+2 -2
View File
@@ -148,7 +148,7 @@ http {
# Route to Pipeline API
location /layout-parsing {
proxy_pass http://paddleocr-pipeline-api-v10:8090/layout-parsing;
proxy_pass http://paddleocr-pipeline-api:8090/layout-parsing;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
@@ -159,7 +159,7 @@ http {
}
location /health {
proxy_pass http://paddleocr-pipeline-api-v10:8090/health;
proxy_pass http://paddleocr-pipeline-api:8090/health;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
+170 -1
View File
@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@gradio/client": "^2.2.1",
"bcryptjs": "^3.0.3",
"jsonwebtoken": "^9.0.3",
"next": "16.2.6",
"pg": "^8.21.0",
"puppeteer-core": "^25.1.0",
@@ -17,6 +19,8 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -1584,6 +1588,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1605,6 +1616,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -2568,6 +2597,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -2626,6 +2664,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -2988,6 +3032,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.364",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
@@ -4667,6 +4720,40 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4683,6 +4770,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5004,6 +5112,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5011,6 +5155,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5120,7 +5270,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5941,6 +6090,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+4
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@gradio/client": "^2.2.1",
"bcryptjs": "^3.0.3",
"jsonwebtoken": "^9.0.3",
"next": "16.2.6",
"pg": "^8.21.0",
"puppeteer-core": "^25.1.0",
@@ -18,6 +20,8 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
import os
import re
import time
import pickle
import numpy as np
import torch
from PIL import Image
from torchvision import transforms
from pathlib import Path
# Setup directories
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DATASET_DIR = SCRIPT_DIR / "foto-kemasan-v2"
DEFAULT_MODELS_DIR = SCRIPT_DIR / "models"
DEFAULT_INDEX_PATH = DEFAULT_MODELS_DIR / "dinov2_index.pkl"
# Allowed image extensions
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".bmp")
# DINOv2 Image preprocessing
DINOV2_TRANSFORMS = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
def get_embedding(dinov2_model, image: Image.Image, device):
if image.mode != "RGB":
image = image.convert("RGB")
tensor = DINOV2_TRANSFORMS(image).unsqueeze(0).to(device)
with torch.no_grad():
embedding = dinov2_model(tensor)
# L2 normalization for dot product similarity
embedding = embedding / embedding.norm(dim=-1, keepdim=True)
return embedding.squeeze(0).cpu().numpy()
def run_indexing(src_dir=DEFAULT_DATASET_DIR, out_path=DEFAULT_INDEX_PATH):
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
src_path = Path(src_dir).resolve()
out_file_path = Path(out_path).resolve()
if not src_path.is_dir():
print(f"Error: Source dataset directory not found: {src_path}")
return False
out_file_path.parent.mkdir(parents=True, exist_ok=True)
# Load DINOv2 Model from Torch Hub
print("Loading DINOv2 model (dinov2_vits14)...")
t0 = time.perf_counter()
dinov2_model = torch.hub.load("facebookresearch/dinov2", "dinov2_vits14").to(device)
dinov2_model.eval()
print(f"DINOv2 loaded in {time.perf_counter() - t0:.2f}s")
# Scan dataset directory
class_dirs = [d for d in src_path.iterdir() if d.is_dir()]
class_dirs.sort()
embeddings_list = []
metadata_list = []
total_images = 0
indexed_images = 0
for c_dir in class_dirs:
class_name = c_dir.name
images = sorted(
[f for f in c_dir.iterdir() if f.suffix.lower() in IMAGE_EXTS],
key=lambda p: p.name
)
if not images:
continue
print(f"Processing class: {class_name} ({len(images)} images)")
total_images += len(images)
for img_file in images:
try:
# Load image
image = Image.open(img_file).convert("RGB")
# Extract DINOv2 embedding (using whole image as reference photo)
embedding = get_embedding(dinov2_model, image, device)
embeddings_list.append(embedding)
metadata_list.append({
"class_name": class_name,
"image_path": str(img_file.relative_to(src_path.parent)),
"file_name": img_file.name
})
indexed_images += 1
except Exception as e:
print(f" [Error] Failed to process {img_file.name}: {e}")
# Save the index
if embeddings_list:
embeddings_arr = np.vstack(embeddings_list)
index_data = {
"embeddings": embeddings_arr,
"metadata": metadata_list
}
with open(out_file_path, "wb") as f:
pickle.dump(index_data, f)
print(f"\nSuccess! Indexed {indexed_images}/{total_images} images.")
print(f"DINOv2 Vector Index saved to: {out_file_path}")
return True
else:
print("\n[Warning] No images were successfully indexed.")
return False
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Build DINOv2 image vector index for PFM products")
parser.add_argument("--src-dir", default=str(DEFAULT_DATASET_DIR), help="Source directory of classes")
parser.add_argument("--output", default=str(DEFAULT_INDEX_PATH), help="Output pickle index path")
args = parser.parse_args()
run_indexing(src_dir=args.src_dir, out_path=args.output)
+27 -3
View File
@@ -221,13 +221,35 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe
}
}
async function fetchParse(baseUrl: string, filename: string): Promise<any> {
// Store name/address are assigned per-account now, not OCR-detected (see
// v1/documents/upload/route.ts + /api/parse). To score store/alamat fields
// the same way production behaves, simulate "the account assigned to this
// image's ground-truth store uploaded it" by resolving gt.store -> kode_toko
// and passing that through, same as a real account's JWT would.
async function fetchStoreNameToKodeMap(): Promise<Map<string, string>> {
const { Pool } = await import("pg");
const pool = new Pool({
host: process.env.PGHOST || "localhost",
port: parseInt(process.env.PGPORT || "5432", 10),
user: process.env.PGUSER || "postgres",
password: process.env.PGPASSWORD || "postgres",
database: process.env.PGDATABASE || "dopfm",
});
try {
const res = await pool.query("SELECT nama_toko, kode_toko FROM store_master");
return new Map(res.rows.map((r: any) => [r.nama_toko, r.kode_toko]));
} finally {
await pool.end();
}
}
async function fetchParse(baseUrl: string, filename: string, kodeToko?: string): Promise<any> {
const res = await fetchWithTimeout(
`${baseUrl}/api/parse`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
body: JSON.stringify({ filename, kodeToko }),
},
FETCH_TIMEOUT_MS
);
@@ -540,6 +562,7 @@ async function main() {
const gtMap = loadGroundTruth();
const testImages = listTestImages();
const storeNameToKode = await fetchStoreNameToKodeMap();
if (testImages.length === 0) {
console.error(`No test images found in ${TEST_IMAGES_DIR}`);
@@ -566,7 +589,8 @@ async function main() {
if (!gt) continue;
process.stdout.write(`Parsing ${filename}... `);
try {
const parsed = await fetchParse(args.baseUrl, filename);
const kodeToko = storeNameToKode.get(gt.store);
const parsed = await fetchParse(args.baseUrl, filename, kodeToko);
const { checks, detail } = compareImage(gt, parsed);
allChecks.push(...checks);
detailsByFile[filename] = detail;
+8 -10
View File
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { Client } from "@gradio/client";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const maxDuration = 120; // Allow up to 120 seconds for slow model inference
@@ -21,7 +22,7 @@ export async function GET(req: NextRequest) {
`, [parseInt(runId)]);
if (runRes.rowCount === 0) {
return NextResponse.json({ success: false, error: "Run not found" }, { status: 404 });
return errorResponse(404, "Run not found");
}
return NextResponse.json({ success: true, run: runRes.rows[0] });
}
@@ -66,7 +67,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: true, runs: runsRes.rows });
} catch (error: any) {
console.error("Failed to fetch arena runs/stats:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -81,7 +82,7 @@ export async function POST(req: NextRequest) {
image = body.image;
if (!engine || !image) {
return NextResponse.json({ error: "Missing engine or image" }, { status: 400 });
return errorResponse(400, "Missing engine or image");
}
imageType = body.imageType || "do";
@@ -103,7 +104,7 @@ export async function POST(req: NextRequest) {
const filePath = path.join(process.cwd(), "public", cleanPath);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: `File not found on server: ${image}` }, { status: 404 });
return errorResponse(404, `File not found on server: ${image}`);
}
imageBuffer = fs.readFileSync(filePath);
base64Image = `data:image/jpeg;base64,${imageBuffer.toString("base64")}`;
@@ -117,7 +118,7 @@ export async function POST(req: NextRequest) {
base64Image = `data:image/jpeg;base64,${image}`;
imageBuffer = Buffer.from(image, "base64");
} else {
return NextResponse.json({ error: "Invalid image format" }, { status: 400 });
return errorResponse(400, "Invalid image format");
}
let outputText = "";
@@ -212,7 +213,7 @@ export async function POST(req: NextRequest) {
outputText = data[0] || "";
} else {
return NextResponse.json({ error: `Unknown engine: ${engine}` }, { status: 400 });
return errorResponse(400, `Unknown engine: ${engine}`);
}
const elapsedMs = Date.now() - startTime;
@@ -259,9 +260,6 @@ export async function POST(req: NextRequest) {
console.error("Failed to log failure to arena_runs:", dbErr);
}
return NextResponse.json({
success: false,
error: error.message || "Failed to process OCR request"
}, { status: 500 });
return errorResponse(500, error.message || "Failed to process OCR request");
}
}
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { query } from "../../../db";
import crypto from "crypto";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
const PUBLIC_DIR = path.join(process.cwd(), "public/do-pfm");
@@ -12,7 +13,7 @@ export async function POST(req: NextRequest) {
const { filename, image } = await req.json();
if (!filename || !image) {
return NextResponse.json({ error: "Filename and image base64 data are required" }, { status: 400 });
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
@@ -50,6 +51,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error cropping file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -14,7 +15,7 @@ export async function GET(
const docId = parseInt(id, 10);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400 });
return errorResponse(400, "Invalid document ID");
}
const res = await query(
@@ -23,7 +24,7 @@ export async function GET(
);
if (res.rowCount === 0 || !res.rows[0]) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
}
return NextResponse.json({
@@ -32,6 +33,6 @@ export async function GET(
});
} catch (error: any) {
console.error("Error fetching document logs:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -8,14 +9,14 @@ export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("file");
if (!filename) {
return NextResponse.json({ error: "File name is required" }, { status: 400 });
return errorResponse(400, "File name is required");
}
const safeFile = path.basename(filename);
const filePath = path.join(UPLOADS_DIR, safeFile);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
return errorResponse(404, "File not found");
}
// Determine content type based on extension
@@ -41,6 +42,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error serving file from uploads:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
+10 -12
View File
@@ -9,6 +9,7 @@ import {
getProcessName,
unloadOtherEngines
} from "../../../utils/docker";
import { errorResponse } from "@/utils/api-error";
export async function GET(req: NextRequest) {
try {
@@ -32,7 +33,7 @@ export async function GET(req: NextRequest) {
});
} catch (error: any) {
console.error("Failed to fetch GPU/container status:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -44,7 +45,7 @@ export async function POST(req: NextRequest) {
if (action === "kill") {
const pid = parseInt(body.pid);
if (!pid || isNaN(pid)) {
return NextResponse.json({ success: false, error: "Invalid PID" }, { status: 400 });
return errorResponse(400, "Invalid PID");
}
// Check if process is protected (same rules as admin_panel.py)
@@ -52,17 +53,14 @@ export async function POST(req: NextRequest) {
const procNameLower = procName.toLowerCase();
const protectedKeywords = ["rustdesk", "xorg", "nginx", "systemd", "dockerd", "python3", "node"];
if (anyKeywordMatch(procNameLower, protectedKeywords)) {
return NextResponse.json({
success: false,
error: `Operation Denied: Process ${pid} (${procName || "system"}) is protected and cannot be killed.`
}, { status: 403 });
return errorResponse(403, `Operation Denied: Process ${pid} (${procName || "system"}) is protected and cannot be killed.`);
}
try {
process.kill(pid, 9);
return NextResponse.json({ success: true, message: `Successfully killed process ${pid}` });
} catch (err: any) {
return NextResponse.json({ success: false, error: `Failed to kill process: ${err.message}` }, { status: 500 });
return errorResponse(500, `Failed to kill process: ${err.message}`);
}
}
@@ -79,12 +77,12 @@ export async function POST(req: NextRequest) {
];
if (!validActions.includes(containerAction) || !validContainers.includes(containerName)) {
return NextResponse.json({ success: false, error: "Invalid container name or action" }, { status: 400 });
return errorResponse(400, "Invalid container name or action");
}
// Prevent self-stopping nextjs app accidentally through UI
if (containerName === "paddleocr-pfm-web-app" && containerAction === "stop") {
return NextResponse.json({ success: false, error: "Cannot stop the active web application container itself." }, { status: 400 });
return errorResponse(400, "Cannot stop the active web application container itself.");
}
await manageContainer(containerName, containerAction);
@@ -97,7 +95,7 @@ export async function POST(req: NextRequest) {
if (action === "saveSettings") {
const { cudaDevices } = body;
if (typeof cudaDevices !== "string" || cudaDevices.trim() === "") {
return NextResponse.json({ success: false, error: "Invalid GPU allocation settings" }, { status: 400 });
return errorResponse(400, "Invalid GPU allocation settings");
}
const cleanCuda = cudaDevices.trim();
@@ -146,10 +144,10 @@ export async function POST(req: NextRequest) {
});
}
return NextResponse.json({ success: false, error: "Invalid API action" }, { status: 400 });
return errorResponse(400, "Invalid API action");
} catch (error: any) {
console.error("GPU API POST error:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query, cleanupAndReindexItems } from "../../../db";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -79,7 +80,7 @@ export async function GET(req: NextRequest) {
});
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
}
// List view: return history list from DB
@@ -121,7 +122,7 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -129,7 +130,7 @@ export async function DELETE(req: NextRequest) {
try {
const { filename } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
@@ -163,11 +164,11 @@ export async function DELETE(req: NextRequest) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
} catch (error: unknown) {
console.error("Error in DELETE history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -175,7 +176,7 @@ export async function POST(req: NextRequest) {
try {
const { filename, remark } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
@@ -192,10 +193,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
} catch (error: unknown) {
console.error("Error in POST history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function GET() {
try {
@@ -17,6 +18,6 @@ export async function GET() {
return NextResponse.json({ files });
} catch (error: any) {
console.error("Error reading test-images directory:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
// Separate from DO manual_labels.json - product scan ground truth only
const LABELS_PATH = path.join(process.cwd(), "..", "sources", "product_manual_labels.json");
interface ProductScanLabel {
filename: string;
no_sku: string;
nama_item: string;
expiry_date: string;
top1_confidence: number | null;
notes: string;
saved_at: string;
}
function sanitizeFilename(filename: string): string {
let cleaned = filename.replace(/\\/g, "/");
while (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1);
}
return cleaned.replace(/\.\.\//g, "");
}
function normalizeDateString(dateStr: string): string {
if (!dateStr) return "";
const trimmed = dateStr.trim();
// Pattern 1: d Month YYYY (e.g. 7 June 2026)
const textPattern = /^(\d{1,2})\s+([a-zA-Z]+)\s+(\d{4})$/;
const tm = trimmed.match(textPattern);
if (tm) {
const day = tm[1].padStart(2, "0");
const month = tm[2].charAt(0).toUpperCase() + tm[2].slice(1).toLowerCase();
const year = tm[3];
return `${day} ${month} ${year}`;
}
// Pattern 2: d/m/YYYY or d-m-YYYY or d.m.YYYY (e.g. 7/6/2026)
const digitPattern = /^(\d{1,2})([-./])(\d{1,2})\2(\d{2,4})$/;
const dm = trimmed.match(digitPattern);
if (dm) {
const day = dm[1].padStart(2, "0");
const month = dm[3].padStart(2, "0");
let year = dm[4];
if (year.length === 2) {
year = "20" + year;
}
return `${day}/${month}/${year}`;
}
return trimmed;
}
function readLabels(): ProductScanLabel[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
return raw.trim() ? JSON.parse(raw) : [];
}
function writeLabels(labels: ProductScanLabel[]) {
const dir = path.dirname(LABELS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(LABELS_PATH, JSON.stringify(labels, null, 2), "utf8");
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return errorResponse(400, "Filename parameter is required");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const existing = labels.find((l) => l.filename === safeFilename);
if (existing) {
return NextResponse.json(existing);
}
// Return empty default state if not found
return NextResponse.json({
filename: safeFilename,
no_sku: "",
nama_item: "",
expiry_date: "",
top1_confidence: null,
notes: "",
saved_at: ""
});
} catch (err: unknown) {
console.error("Error in GET manual-label-scan:", err);
const message = err instanceof Error ? err.message : "Failed to load product label";
return errorResponse(500, message);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { filename } = body;
if (!filename) {
return errorResponse(400, "Filename is required in request body");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const index = labels.findIndex((l) => l.filename === safeFilename);
const entry: ProductScanLabel = {
filename: safeFilename,
no_sku: body.no_sku || "",
nama_item: body.nama_item || "",
expiry_date: normalizeDateString(body.expiry_date || ""),
top1_confidence: typeof body.top1_confidence === "number" ? body.top1_confidence : null,
notes: body.notes || "",
saved_at: new Date().toISOString()
};
if (index >= 0) {
labels[index] = entry;
} else {
labels.push(entry);
}
writeLabels(labels);
return NextResponse.json({ success: true, filePath: LABELS_PATH, entry });
} catch (err: unknown) {
console.error("Error in POST manual-label-scan:", err);
const message = err instanceof Error ? err.message : "Failed to save product label";
return errorResponse(500, message);
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const LABELS_PATH = path.join(process.cwd(), "..", "sources", "manual_labels.json");
@@ -17,63 +18,83 @@ function writeLabels(labels: any[]) {
fs.writeFileSync(LABELS_PATH, JSON.stringify(labels, null, 2), "utf8");
}
const SCALAR_FIELDS = ["noPO", "noSO", "noDO", "tanggal", "customer", "store", "alamat", "plat"] as const;
// Fetches the latest automated parser result for a filename, in the same
// shape as a manual_labels.json entry, so it can be used as fill-in data.
async function fetchLatestParsed(safeFilename: string): Promise<Record<string, any> | null> {
try {
const docRes = await query(
"SELECT id, metadata FROM documents WHERE filename = $1",
[safeFilename]
);
if (!docRes.rowCount || docRes.rowCount === 0) return null;
const doc = docRes.rows[0];
const meta = doc.metadata || {};
const itemsRes = await query(
"SELECT kode_barang, nama_barang, banyak, jumlah FROM ocr_items WHERE document_id = $1 ORDER BY row_index",
[doc.id]
);
return {
noPO: meta.noPO || "",
noSO: meta.noSO || "",
noDO: meta.noDO || "",
tanggal: meta.tanggal || "",
customer: meta.customerInfo || "",
store: meta.orderUntuk || "",
alamat: meta.alamat || "",
plat: meta.platTruk || "",
items: itemsRes.rows.map(row => ({
kodeBarang: row.kode_barang || "",
namaBarang: row.nama_barang || "",
banyak: row.banyak || "",
jumlah: row.jumlah || ""
}))
};
} catch (dbErr) {
console.error("DB fallback failed inside manual-label GET:", dbErr);
return null;
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return NextResponse.json({ error: "Filename parameter is required" }, { status: 400 });
return errorResponse(400, "Filename parameter is required");
}
const safeFilename = path.basename(filename);
const labels = readLabels();
const existing = labels.find(l => l.filename === safeFilename);
const latest = await fetchLatestParsed(safeFilename);
if (existing) {
return NextResponse.json(existing);
// Never overwrite a field the user already corrected manually - only
// fill in whatever is still blank, using the latest AI/DB parse.
const merged = { ...existing, filename: safeFilename };
if (latest) {
for (const field of SCALAR_FIELDS) {
if (!merged[field]) merged[field] = latest[field];
}
if (!merged.items || merged.items.length === 0) {
merged.items = latest.items;
}
}
// aiPredicted is the raw AI value for every field, always included
// (even when a manual value already exists) so the UI can show what
// the AI actually predicted next to the current/manual value.
return NextResponse.json({ ...merged, aiPredicted: latest });
}
// Try fallback to automated parser results in database
try {
const docRes = await query(
"SELECT id, metadata FROM documents WHERE filename = $1",
[safeFilename]
);
if (docRes.rowCount && docRes.rowCount > 0) {
const doc = docRes.rows[0];
const docId = doc.id;
const meta = doc.metadata || {};
// Fetch ocr items
const itemsRes = await query(
"SELECT kode_barang, nama_barang, banyak, jumlah FROM ocr_items WHERE document_id = $1 ORDER BY row_index",
[docId]
);
const items = itemsRes.rows.map(row => ({
kodeBarang: row.kode_barang || "",
namaBarang: row.nama_barang || "",
banyak: row.banyak || "",
jumlah: row.jumlah || ""
}));
return NextResponse.json({
filename,
noPO: meta.noPO || "",
noSO: meta.noSO || "",
noDO: meta.noDO || "",
tanggal: meta.tanggal || "",
customer: meta.customerInfo || "",
store: meta.orderUntuk || "",
alamat: meta.alamat || "",
plat: meta.platTruk || "",
items
});
}
} catch (dbErr) {
console.error("DB fallback failed inside manual-label GET:", dbErr);
if (latest) {
return NextResponse.json({ filename, ...latest, aiPredicted: latest });
}
// Return empty default state if not found anywhere
@@ -87,11 +108,12 @@ export async function GET(req: NextRequest) {
store: "",
alamat: "",
plat: "",
items: []
items: [],
aiPredicted: null
});
} catch (err: any) {
console.error("Error in GET manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to load manual label" }, { status: 500 });
return errorResponse(500, err.message || "Failed to load manual label");
}
}
@@ -101,7 +123,7 @@ export async function POST(req: NextRequest) {
const { filename } = body;
if (!filename) {
return NextResponse.json({ error: "Filename is required in request body" }, { status: 400 });
return errorResponse(400, "Filename is required in request body");
}
const safeFilename = path.basename(filename);
@@ -120,6 +142,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, filePath: LABELS_PATH });
} catch (err: any) {
console.error("Error in POST manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to save manual label" }, { status: 500 });
return errorResponse(500, err.message || "Failed to save manual label");
}
}
+53 -32
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { query, withTransaction, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { errorResponse } from "@/utils/api-error";
// Bounds each pipeline call so a wedged GPU container fails fast into the existing
// graceful fallback path instead of hanging the request indefinitely.
@@ -42,9 +43,9 @@ function getStringSimilarity(s1: string, s2: string): number {
export async function POST(req: NextRequest) {
let safeFile = "";
try {
const { filename } = await req.json();
const { filename, kodeToko } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
safeFile = path.basename(filename);
@@ -59,7 +60,7 @@ export async function POST(req: NextRequest) {
if (!fs.existsSync(filePath)) {
filePath = path.join("/uploads", safeFile);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
return errorResponse(404, "File not found");
}
isUpload = true;
isSample = false;
@@ -137,7 +138,7 @@ export async function POST(req: NextRequest) {
} catch (dbErr) {
console.error("Failed to mark document as parsed on pipeline error:", dbErr);
}
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
data = await response.json();
@@ -388,8 +389,23 @@ export async function POST(req: NextRequest) {
}
docMetadata.items = checkedItems;
// Resolve store information using master database
const resolvedStore = await resolveStoreFromText(markdownText);
// Store name/address are assigned per-account, not OCR-detected: if the
// uploading account's store (kodeToko) was passed through, use it
// directly. Only fall back to OCR-based text matching when no account
// context is available (e.g. legacy/internal callers).
let resolvedStore: { orderUntuk: string; alamat: string } | null = null;
if (kodeToko) {
const storeRes = await query(
"SELECT nama_toko, alamat FROM store_master WHERE kode_toko = $1",
[kodeToko]
);
if (storeRes.rowCount && storeRes.rowCount > 0) {
resolvedStore = { orderUntuk: storeRes.rows[0].nama_toko, alamat: storeRes.rows[0].alamat };
}
}
if (!resolvedStore) {
resolvedStore = await resolveStoreFromText(markdownText);
}
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
(docMetadata as any).alamat = resolvedStore.alamat;
@@ -443,31 +459,36 @@ export async function POST(req: NextRequest) {
const docId = insertDocRes.rows[0].id;
// Delete existing items for this document to avoid unique constraints / stale data
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Delete-then-reinsert must be atomic: without a transaction, a failure partway
// through the insert loop leaves the document row already upserted above but
// only some (or none) of its items, since the delete has already committed
// independently.
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
for (let i = 0; i < docMetadata.items.length; i++) {
const item = docMetadata.items[i];
await 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, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
`, [
docId,
i,
(item as any).kodeBarangOriginal || item.kodeBarang,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
for (let i = 0; i < docMetadata.items.length; i++) {
const item = docMetadata.items[i];
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, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
`, [
docId,
i,
(item as any).kodeBarangOriginal || item.kodeBarang,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
});
// Return wrapped success response with items, flagged, remarks, and intermediate post-processing details
return NextResponse.json({
@@ -508,7 +529,7 @@ export async function POST(req: NextRequest) {
console.error("Failed to mark document as parsed on route catch:", dbErr);
}
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -44,6 +45,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error fetching produk PFM:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { query } from "../../../db";
import crypto from "crypto";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
const PUBLIC_DIR = path.join(process.cwd(), "public/do-pfm");
@@ -12,7 +13,7 @@ export async function POST(req: NextRequest) {
const { filename, image } = await req.json();
if (!filename || !image) {
return NextResponse.json({ error: "Filename and image base64 data are required" }, { status: 400 });
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
@@ -50,6 +51,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error rotating file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -37,7 +38,7 @@ export async function POST(req: NextRequest) {
try {
const { image_base64 } = await req.json();
if (!image_base64) {
return NextResponse.json({ error: "Image is required" }, { status: 400 });
return errorResponse(400, "Image is required");
}
// Call Python FastAPI server inside the container
@@ -54,7 +55,7 @@ export async function POST(req: NextRequest) {
if (!response.ok) {
const errText = await response.text();
return NextResponse.json({ error: `Classifier service error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
@@ -131,6 +132,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in scan-pfm API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -18,6 +19,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in SKUs API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const res = await query(
"SELECT kode_toko, nama_toko, alamat FROM store_master ORDER BY nama_toko"
);
const stores = res.rows.map(row => ({
kodeToko: row.kode_toko,
namaToko: row.nama_toko,
alamat: row.alamat
}));
return NextResponse.json({ stores });
} catch (error: unknown) {
console.error("Error in stores API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function POST(req: NextRequest) {
try {
@@ -8,7 +9,7 @@ export async function POST(req: NextRequest) {
const { page, rowIndex, action } = body;
if (!page || rowIndex === undefined || !action) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
return errorResponse(400, "Missing required fields");
}
const safeFile = path.basename(page);
@@ -16,14 +17,14 @@ export async function POST(req: NextRequest) {
// Get document ID
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [safeFile]);
if (!docRes.rowCount || docRes.rowCount === 0) {
return NextResponse.json({ error: "Document not found in database" }, { status: 404 });
return errorResponse(404, "Document not found in database");
}
const docId = docRes.rows[0].id;
if (action === "edit") {
const { field, value } = body;
if (!field || value === undefined) {
return NextResponse.json({ error: "Missing edit parameters" }, { status: 400 });
return errorResponse(400, "Missing edit parameters");
}
// Map UI field names to database columns
@@ -35,7 +36,7 @@ export async function POST(req: NextRequest) {
} else if (field === "jumlah") {
colName = "jumlah";
} else {
return NextResponse.json({ error: "Invalid field name" }, { status: 400 });
return errorResponse(400, "Invalid field name");
}
await query(
@@ -49,7 +50,7 @@ export async function POST(req: NextRequest) {
} else if (action === "flag") {
const { isFlagged, remark } = body;
if (isFlagged === undefined || remark === undefined) {
return NextResponse.json({ error: "Missing flag parameters" }, { status: 400 });
return errorResponse(400, "Missing flag parameters");
}
await query(
@@ -61,11 +62,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
return errorResponse(400, "Invalid action");
}
} catch (error: unknown) {
console.error("Error in update-row API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { startActiveLog, getActiveLog, clearActiveLog } from "../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -19,7 +20,7 @@ export async function POST(req: NextRequest) {
const file = formData.get("file") as Blob | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
return errorResponse(400, "No file uploaded");
}
const originalName = file instanceof File ? file.name : "document.jpg";
@@ -67,7 +68,7 @@ export async function POST(req: NextRequest) {
if (!response.ok) {
clearActiveLog(filename);
const errText = await response.text();
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
let data = await response.json();
@@ -208,7 +209,7 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in upload API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,4 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { errorResponse } from "@/utils/api-error";
import { signAccountToken } from "@/utils/auth";
import { query } from "../../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -15,24 +19,37 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const { username, password } = body;
// Simple authentication logic for demo
if (username === "admin" && password === "password") {
if (!username || !password) {
return errorResponse(401, "Invalid username or password", { headers: corsHeaders });
}
// Each account is assigned exactly one store (kode_toko) - the token
// carries that assignment so store name/address never need OCR
// detection later; whichever account uploads, its own store is used.
const accountRes = await query(
"SELECT id, username, kode_toko, password FROM accounts WHERE username = $1",
[username]
);
if (accountRes.rowCount && accountRes.rowCount > 0 && bcrypt.compareSync(password, accountRes.rows[0].password)) {
const account = accountRes.rows[0];
const token = signAccountToken({
accountId: account.id,
username: account.username,
kodeToko: account.kode_toko
});
return NextResponse.json({
status: "success",
message: "Login successful",
data: {
token: "demo-auth-token-12345"
}
data: { token }
}, { headers: corsHeaders });
}
return NextResponse.json({
status: "error",
message: "Invalid username or password"
}, { status: 401, headers: corsHeaders });
return errorResponse(401, "Invalid username or password", { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in login API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -16,18 +18,23 @@ export async function PUT(
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 { id } = params;
const docId = parseInt(id);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400, headers: corsHeaders });
return errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Check if document exists
const checkRes = await query("SELECT id, filename, upload_time FROM documents WHERE id = $1", [docId]);
if (!checkRes.rowCount || checkRes.rowCount === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404, headers: corsHeaders });
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = checkRes.rows[0];
@@ -89,28 +96,32 @@ export async function PUT(
WHERE id = $1
`, [docId, latFloat, lngFloat, JSON.stringify(metadata)]);
// Delete existing ocr_items to recreate them
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Delete-then-reinsert must be atomic: without a transaction, a failure partway
// through the insert loop leaves the document with its header already updated
// above but only some (or none) of its items, since the delete has already
// committed independently.
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Insert new items
for (let i = 0; i < items.length; i++) {
const item = items[i];
const nomorSku = item.nomor_sku || item.nomorSku || "";
const namaBarang = item.nama_barang || item.namaBarang || "";
const banyak = item.banyak || "";
const jumlah = item.jumlah || "";
for (let i = 0; i < items.length; i++) {
const item = items[i];
const nomorSku = item.nomor_sku || item.nomorSku || "";
const namaBarang = item.nama_barang || item.namaBarang || "";
const banyak = item.banyak || "";
const jumlah = item.jumlah || "";
await 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, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
`, [docId, i, nomorSku, namaBarang, banyak, jumlah]);
}
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, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
`, [docId, i, nomorSku, namaBarang, banyak, jumlah]);
}
});
// Return the updated document mapping
const mappedData = {
@@ -150,6 +161,6 @@ export async function PUT(
} catch (error: unknown) {
console.error("Error in update document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -13,6 +15,11 @@ export async function OPTIONS() {
export async function GET(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
// Retrieve all custom-uploaded documents
const docRes = await query(`
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude
@@ -114,6 +121,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in list documents API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -3,6 +3,8 @@ import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const UPLOADS_DIR = "/uploads";
@@ -23,11 +25,19 @@ export async function POST(req: NextRequest) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
// The account uploading is assigned exactly one store (kode_toko) - pass
// it through to /api/parse so store name/address are set directly from
// that assignment instead of being OCR-detected from the document photo.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400, headers: corsHeaders });
return errorResponse(400, "No file uploaded", { headers: corsHeaders });
}
const originalName = file instanceof File ? file.name : "document.jpg";
@@ -112,7 +122,7 @@ export async function POST(req: NextRequest) {
await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: finalFilename }),
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko }),
signal: AbortSignal.timeout(210_000)
});
} catch (err) {
@@ -151,6 +161,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in upload API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { logVllmCallToAll } from "../../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -104,7 +105,7 @@ async function handleProxy(req: NextRequest) {
} catch (error) {
console.error("[vllm-proxy] Error forwarding request:", error);
return NextResponse.json({ error: "Failed to proxy request to vLLM server" }, { status: 500 });
return errorResponse(500, "Failed to proxy request to vLLM server");
}
}
+130 -13
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { getErrorMessage } from "@/utils/client-error";
interface ItemRow {
kodeBarang: string;
@@ -22,9 +23,52 @@ interface FormData {
items: ItemRow[];
}
interface Store {
kodeToko: string;
namaToko: string;
alamat: string;
}
interface Sku {
no_sku: string;
nama_item: string;
}
// Raw AI/DB parse for the current file, shown alongside each field as a
// reference note so the user can see what the AI predicted vs the current
// (possibly manually corrected) value. Same shape as FormData minus filename.
interface AiPredicted {
noPO: string;
noSO: string;
noDO: string;
tanggal: string;
customer: string;
store: string;
alamat: string;
plat: string;
items: ItemRow[];
}
// Small caption under a field showing what the AI predicted for it, so the
// user reviewing/correcting ground truth can see both values at a glance.
// Renders nothing if the file was never parsed (aiValue is undefined).
function AiNote({ current, aiValue }: { current: string; aiValue: string | undefined }) {
if (aiValue === undefined) return null;
const differs = (current || "").trim() !== (aiValue || "").trim();
return (
<span className={`text-[11px] leading-tight ${differs ? "text-amber-500" : "text-slate-600"}`}>
AI: {aiValue || "(tidak terdeteksi)"}
{differs && current ? " · beda dari manual" : ""}
</span>
);
}
export default function ManualLabelPage() {
const [files, setFiles] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState<number>(-1);
const [stores, setStores] = useState<Store[]>([]);
const [skus, setSkus] = useState<Sku[]>([]);
const [aiPredicted, setAiPredicted] = useState<AiPredicted | null>(null);
const [formData, setFormData] = useState<FormData>({
filename: "",
noPO: "",
@@ -66,6 +110,38 @@ export default function ManualLabelPage() {
fetchFiles();
}, []);
// Fetch store master list on mount, to power the store dropdown + address autofill
useEffect(() => {
const fetchStores = async () => {
try {
const res = await fetch("/api/stores");
if (res.ok) {
const data = await res.json();
setStores(data.stores || []);
}
} catch (err) {
console.error("Error loading store list:", err);
}
};
fetchStores();
}, []);
// Fetch SKU master list on mount, to power the item-name autofill by SKU
useEffect(() => {
const fetchSkus = async () => {
try {
const res = await fetch("/api/skus");
if (res.ok) {
const data = await res.json();
setSkus(data.skus || []);
}
} catch (err) {
console.error("Error loading SKU list:", err);
}
};
fetchSkus();
}, []);
// Fetch existing manual label when currentIndex changes
useEffect(() => {
if (currentIndex < 0 || currentIndex >= files.length) return;
@@ -97,6 +173,7 @@ export default function ManualLabelPage() {
plat: data.plat || "",
items: itemsList
});
setAiPredicted(data.aiPredicted || null);
}
} catch (err) {
console.error("Error fetching label data:", err);
@@ -132,13 +209,32 @@ export default function ManualLabelPage() {
}));
};
// When the store name matches a known store_master entry, default the
// address to that store's address. The user can still edit it afterward -
// this only sets a starting point, it doesn't lock the field.
const handleStoreChange = (value: string) => {
const matched = stores.find(s => s.namaToko === value);
setFormData(prev => ({
...prev,
store: value,
alamat: matched ? matched.alamat : prev.alamat
}));
};
const handleItemChange = (index: number, field: keyof ItemRow, value: string) => {
setFormData(prev => {
const updatedItems = [...prev.items];
updatedItems[index] = {
...updatedItems[index],
[field]: value
};
const updatedRow = { ...updatedItems[index], [field]: value };
// When the SKU code matches a known sku_master entry, default the item
// name to that SKU's name. Same convention as the store->alamat
// autofill: it's a starting point, not locked - still editable after.
if (field === "kodeBarang") {
const matched = skus.find(s => s.no_sku === value);
if (matched) updatedRow.namaBarang = matched.nama_item;
}
updatedItems[index] = updatedRow;
return {
...prev,
items: updatedItems
@@ -189,12 +285,12 @@ export default function ManualLabelPage() {
showToast("Ground truth data saved successfully!");
return true;
} else {
const errText = await res.text();
showToast(`Error: ${errText}`, true);
const data = await res.json().catch(() => undefined);
showToast(`Error: ${getErrorMessage(null, data, `HTTP ${res.status}`)}`, true);
return false;
}
} catch (err: any) {
showToast(`Save error: ${err.message}`, true);
showToast(`Save error: ${getErrorMessage(err)}`, true);
return false;
}
};
@@ -308,6 +404,7 @@ export default function ManualLabelPage() {
placeholder="e.g. PO/26/0000241938"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noPO} aiValue={aiPredicted?.noPO} />
</div>
<div className="flex flex-col gap-1.5">
@@ -319,6 +416,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 1691972227"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noSO} aiValue={aiPredicted?.noSO} />
</div>
</div>
@@ -332,6 +430,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 1659990561"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noDO} aiValue={aiPredicted?.noDO} />
</div>
<div className="flex flex-col gap-1.5">
@@ -343,6 +442,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 30 June 2026"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.tanggal} aiValue={aiPredicted?.tanggal} />
</div>
</div>
@@ -356,17 +456,25 @@ export default function ManualLabelPage() {
placeholder="e.g. PT.PRIMAFOOD INTERNATIONAL"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.customer} aiValue={aiPredicted?.customer} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Store (Tujuan Kirim)</label>
<input
type="text"
list="storeOptions"
value={formData.store}
onChange={(e) => handleInputChange("store", e.target.value)}
placeholder="e.g. PX HEAD OFFICE ANCOL"
onChange={(e) => handleStoreChange(e.target.value)}
placeholder="Ketik untuk cari, atau pilih dari daftar..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<datalist id="storeOptions">
{stores.map(s => (
<option key={s.kodeToko} value={s.namaToko} />
))}
</datalist>
<AiNote current={formData.store} aiValue={aiPredicted?.store} />
</div>
</div>
@@ -379,6 +487,7 @@ export default function ManualLabelPage() {
placeholder="e.g. B 9256 JXR"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.plat} aiValue={aiPredicted?.plat} />
</div>
<div className="flex flex-col gap-1.5">
@@ -387,9 +496,10 @@ export default function ManualLabelPage() {
rows={2}
value={formData.alamat}
onChange={(e) => handleInputChange("alamat", e.target.value)}
placeholder="Complete address text..."
placeholder="Terisi otomatis saat memilih store, atau isi manual..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 resize-none"
/>
<AiNote current={formData.alamat} aiValue={aiPredicted?.alamat} />
</div>
</div>
</div>
@@ -412,8 +522,10 @@ export default function ManualLabelPage() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{formData.items.map((item, idx) => (
<tr key={idx} className="group hover:bg-slate-900/30">
{formData.items.map((item, idx) => {
const aiItem = aiPredicted?.items?.[idx];
return (
<tr key={idx} className="group hover:bg-slate-900/30 align-top">
<td className="py-2 px-1">
<input
type="text"
@@ -422,6 +534,7 @@ export default function ManualLabelPage() {
placeholder="SKU Code"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.kodeBarang} aiValue={aiItem?.kodeBarang} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -431,6 +544,7 @@ export default function ManualLabelPage() {
placeholder="Description"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.namaBarang} aiValue={aiItem?.namaBarang} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -440,6 +554,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 2 KRG"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.banyak} aiValue={aiItem?.banyak} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -449,6 +564,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 40 PC"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.jumlah} aiValue={aiItem?.jumlah} /></div>
</td>
<td className="py-2 px-1 text-center">
<button
@@ -459,7 +575,8 @@ export default function ManualLabelPage() {
</button>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
+13 -4
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { getErrorMessage } from "@/utils/client-error";
interface HistoryItem {
id: number;
@@ -32,6 +33,9 @@ export default function Home() {
if (res.ok) {
const data = await res.json();
setHistory(data.history || []);
} else {
const data = await res.json().catch(() => undefined);
console.error("Failed to fetch history:", getErrorMessage(null, data, `HTTP ${res.status}`));
}
} catch (err) {
console.error("Failed to fetch history:", err);
@@ -57,7 +61,8 @@ export default function Home() {
const detailsData = await detailsRes.json();
setSelectedDetails(detailsData);
} else {
throw new Error("Failed to load document details");
const data = await detailsRes.json().catch(() => undefined);
throw new Error(getErrorMessage(null, data, "Failed to load document details"));
}
// 2. Fetch processing logs
@@ -68,7 +73,7 @@ export default function Home() {
}
} catch (err: any) {
console.error("Error loading document:", err);
setErrorMsg(err.message || "Error loading document details");
setErrorMsg(getErrorMessage(err));
}
};
@@ -114,7 +119,7 @@ export default function Home() {
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to process image");
throw new Error(getErrorMessage(null, data, "Failed to process image"));
}
const data = await res.json();
@@ -137,7 +142,7 @@ export default function Home() {
}
} catch (err: any) {
console.error("Upload failed:", err);
setErrorMsg(err.message || "Failed to upload and process file.");
setErrorMsg(getErrorMessage(err, undefined, "Failed to upload and process file."));
} finally {
setUploading(false);
}
@@ -165,9 +170,13 @@ export default function Home() {
setSelectedLogs(null);
}
fetchHistory();
} else {
const data = await res.json().catch(() => undefined);
setErrorMsg(getErrorMessage(null, data, "Failed to delete document"));
}
} catch (err) {
console.error("Failed to delete document:", err);
setErrorMsg(getErrorMessage(err, undefined, "Failed to delete document"));
}
};
File diff suppressed because it is too large. Load diff
+20 -1
View File
@@ -1,4 +1,4 @@
import { Pool } from "pg";
import { Pool, PoolClient } from "pg";
import { initDb } from "./init";
const pool = new Pool({
@@ -35,6 +35,25 @@ export async function query(text: string, params?: unknown[]) {
return p.query(text, params);
}
/** Runs `fn` inside a BEGIN/COMMIT transaction on a single held connection, rolling back and rethrowing on any failure. */
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const p = await getPool();
const client = await p.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
export async function cleanupAndReindexItems(docId: number) {
// 1. Delete rows where kode_barang is blank/null or doesn't match an 8-digit number
await query(
+36
View File
@@ -1,6 +1,7 @@
import { Pool } from "pg";
import fs from "fs";
import path from "path";
import bcrypt from "bcryptjs";
import { parseDOMetadata } from "../utils/parser";
const UPLOADS_DIR = "/uploads";
@@ -103,6 +104,41 @@ export async function initDb(pool: Pool) {
);
`);
// Create accounts table - each account is assigned one store (kode_toko),
// so store name/address never need to be OCR-detected: whichever account
// uploads a document, that account's assigned store is used directly.
await pool.query(`
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
kode_toko VARCHAR(255) REFERENCES store_master(kode_toko),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
`);
// Seed the demo account, assigned to the head office store. Hashed here
// (not as a SQL literal) since bcrypt can't run inside plain SQL.
await pool.query(
`INSERT INTO accounts (username, password, kode_toko)
VALUES ('admin', $1, 'WH_JOFFICE')
ON CONFLICT (username) DO NOTHING;`,
[bcrypt.hashSync("password", 10)]
);
// Migrate any plaintext passwords (pre-existing accounts from before hashing
// was added) to bcrypt hashes. Idempotent: bcrypt hashes always start with
// "$2", so already-hashed rows are skipped on every re-run.
const plaintextAccounts = await pool.query(
"SELECT id, password FROM accounts WHERE password NOT LIKE '$2%'"
);
for (const account of plaintextAccounts.rows) {
await pool.query("UPDATE accounts SET password = $1 WHERE id = $2", [
bcrypt.hashSync(account.password, 10),
account.id
]);
}
// Create arena_runs table
await pool.query(`
CREATE TABLE IF NOT EXISTS arena_runs (
@@ -0,0 +1,47 @@
// Framework-agnostic HTTP status/error helpers shared by server routes and client components.
export const HTTP_STATUS_TEXT: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
413: "Payload Too Large",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
export function reasonPhraseForStatus(status: number): string {
return HTTP_STATUS_TEXT[status] ?? "Error";
}
export function codeForStatus(status: number): string {
const phrase = HTTP_STATUS_TEXT[status];
if (!phrase) return `HTTP_${status}`;
return phrase.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
}
export interface ApiErrorBody {
status: "error";
error: {
statusCode: number;
code: string;
message: string;
};
}
export function buildApiErrorBody(status: number, message: string, code?: string): ApiErrorBody {
return {
status: "error",
error: {
statusCode: status,
code: code ?? codeForStatus(status),
message,
},
};
}
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { buildApiErrorBody } from "@/lib/http-status";
export function errorResponse(
status: number,
message: string,
opts?: { code?: string; headers?: HeadersInit }
): NextResponse {
return NextResponse.json(buildApiErrorBody(status, message, opts?.code), {
status,
headers: opts?.headers,
});
}
+30
View File
@@ -0,0 +1,30 @@
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "dev-only-insecure-secret-change-me";
export interface AccountTokenPayload {
accountId: number;
username: string;
kodeToko: string | null;
}
export function signAccountToken(payload: AccountTokenPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: "30d" });
}
/** Returns the decoded payload, or null if the token is missing/invalid/expired. */
export function verifyAccountToken(token: string | null | undefined): AccountTokenPayload | null {
if (!token) return null;
try {
return jwt.verify(token, JWT_SECRET) as AccountTokenPayload;
} catch {
return null;
}
}
/** Extracts and verifies the Bearer token from a request's Authorization header. */
export function getAccountFromAuthHeader(authHeader: string | null): AccountTokenPayload | null {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice("Bearer ".length).trim();
return verifyAccountToken(token);
}
@@ -0,0 +1,20 @@
import { HTTP_STATUS_TEXT } from "@/lib/http-status";
/**
* Formats an error the same way across every client component: given the
* parsed JSON body of a failed fetch (if any) and/or the caught exception,
* produce a single "404 Not Found: message" style string mirroring the
* backend's { status: "error", error: { statusCode, code, message } } envelope.
*/
export function getErrorMessage(err: unknown, data?: unknown, fallback = "Unexpected error"): string {
const errorBody = data && typeof data === "object" ? (data as Record<string, unknown>).error : undefined;
if (errorBody && typeof errorBody === "object" && typeof (errorBody as Record<string, unknown>).message === "string") {
const body = errorBody as Record<string, unknown>;
const statusCode = body.statusCode;
const label = (typeof statusCode === "number" && HTTP_STATUS_TEXT[statusCode]) || body.code;
return typeof statusCode === "number" ? `${statusCode} ${label}: ${body.message}` : (body.message as string);
}
if (err instanceof Error) return err.message;
if (err != null) return String(err);
return fallback;
}
+20 -41
View File
@@ -161,7 +161,8 @@ function cleanDateValue(raw: string): string {
export function parseDOMetadata(markdown: string) {
const metadata = {
vendorInfo: "Not Found",
customerInfo: "Not Found",
// Always PT. PRIMAFOOD INTERNATIONAL for this customer - never parsed from OCR.
customerInfo: "PT. PRIMAFOOD INTERNATIONAL",
tanggal: "Not Found",
noSO: "Not Found",
noDO: "Not Found",
@@ -198,33 +199,6 @@ export function parseDOMetadata(markdown: string) {
if (vendorMatch) metadata.vendorInfo = vendorMatch[1].trim();
}
// Extract Customer Info (e.g., Kepada Yth : PT.PRIMAFOOD INTERNATIONAL)
const customerStop = /(?:no\.?\s*(?:so|do|po)|tanggal|date|#|\d{2}:\d{2}:\d{2})/i;
let customerStartIndex = lines.findIndex(line =>
/(?:Kepada Yth|Yth|Customer|Deliver To)\s*[:\-]/i.test(line) || /PT\.\s*PRIMAFOOD/i.test(line)
);
if (customerStartIndex === -1) {
// Fallback: search for a secondary PT. line
const ptIndices = lines.map((l, idx) => l.toUpperCase().includes("PT.") ? idx : -1).filter(idx => idx !== -1);
// Ensure the secondary PT line is not the vendor line
const secondaryIndices = ptIndices.filter(idx => idx !== vendorStartIndex);
if (secondaryIndices.length > 0) {
customerStartIndex = secondaryIndices[0];
}
}
if (customerStartIndex !== -1) {
const customerLines: string[] = [lines[customerStartIndex]];
for (let i = customerStartIndex + 1; i < Math.min(lines.length, customerStartIndex + 4); i++) {
if (customerStop.test(lines[i])) break;
customerLines.push(lines[i]);
}
metadata.customerInfo = customerLines.join("\n");
} else {
const customerMatch = cleanMarkdown.match(/(?:Kepada Yth|Yth|Customer|Deliver To)[ \t]*[:\-][ \t]*([^\n]+)/i) || cleanMarkdown.match(/(PT\.[ \t]*PRIMAFOOD[^\n]*)/i);
if (customerMatch) metadata.customerInfo = customerMatch[1].trim();
}
// Extract Tanggal (Date)
const tanggalMatch = cleanMarkdown.match(/Tanggal\s*[:\-.]?\s*([^\n]{6,100})/i) ||
cleanMarkdown.match(/(?:Date|D\.O\.\s*Date)\s*[:\-.]?\s*([^\n]{6,100})/i);
@@ -404,11 +378,20 @@ export function parseDOMetadata(markdown: string) {
}
}
if (globalTenDigits.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = globalTenDigits[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = globalTenDigits[1];
} else if (globalTenDigits.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = globalTenDigits[0];
// Exclude numbers already assigned to another field, so a still-missing SO
// can't silently be backfilled with the DO/PO value that was just extracted
// correctly (e.g. SO garbled/missing but DO is a clean 16xxxxxxxx number —
// without this filter, DO's value gets duplicated into SO).
const alreadyAssigned = [metadata.noSO, metadata.noDO, metadata.noPO].filter(
(v) => v && v !== "Not Found"
);
const freeTenDigits = globalTenDigits.filter((v) => !alreadyAssigned.includes(v));
if (freeTenDigits.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = freeTenDigits[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = freeTenDigits[1];
} else if (freeTenDigits.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = freeTenDigits[0];
}
// 3. Scan for PO number globally
@@ -437,7 +420,6 @@ export function parseDOMetadata(markdown: string) {
// Clean final values
metadata.vendorInfo = cleanFinalValue(metadata.vendorInfo, true);
metadata.customerInfo = cleanFinalValue(metadata.customerInfo, true);
metadata.tanggal = cleanDateValue(cleanFinalValue(metadata.tanggal));
metadata.noSO = cleanFinalValue(metadata.noSO);
metadata.noDO = cleanFinalValue(metadata.noDO);
@@ -729,9 +711,6 @@ export function parseDOMetadata(markdown: string) {
}
}
// Force customerInfo to always be PT.PRIMAFOOD INTERNATIONAL as requested
metadata.customerInfo = "PT.PRIMAFOOD INTERNATIONAL";
// Extract truck license plate
(metadata as any).platTruk = extractPlatTruk(cleanMarkdown);
@@ -847,12 +826,12 @@ export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata>
const currentFullYear = new Date().getFullYear();
const result = { ...meta };
// --- customerInfo / vendorInfo ---
// OCR typically drops the space after Indonesian business-entity prefixes ("PT.PRIMAFOOD"
// instead of "PT. PRIMAFOOD"). Normalize the standard prefixes to always have one space.
// --- vendorInfo ---
// OCR typically drops the space after Indonesian business-entity prefixes ("PT.CHAROEN"
// instead of "PT. CHAROEN"). Normalize the standard prefixes to always have one space.
// (customerInfo needs no such cleanup - it's a fixed constant, never parsed from OCR.)
const normalizeEntityPrefix = (v: string) =>
v && v !== "Not Found" ? v.replace(/\b(PT|CV|UD|PD|TB)\.(?=\S)/gi, (_, p) => `${p.toUpperCase()}. `) : v;
result.customerInfo = normalizeEntityPrefix(result.customerInfo);
result.vendorInfo = normalizeEntityPrefix(result.vendorInfo);
// --- tanggal ---
+255
View File
@@ -0,0 +1,255 @@
# Next Enhancements (backend)
Working backlog driven by the `e`/`enhance` and `n`/`next` triggers defined in
[AGENTS.md](../AGENTS.md) Part B. Split out 2026-07-08 from root
`plans/next-enhancements.md` sections 5-8, renumbered 1-4 — this file now owns all
backend enhancement tracking going forward; the root file only tracks Flutter
sections from this point on. Statuses below were re-verified against the live code
at split time (not copied blind):
- **1.1** `file_hash` dedup — confirmed present, `v1/documents/upload/route.ts:59,98`.
- **1.2** `AbortSignal.timeout` on both hops — confirmed present in `parse/route.ts`
and `v1/documents/upload/route.ts`.
- **1.3** classic routes skipping auth — confirmed still true at split time; later cancelled as out-of-scope (dev-only web UI, no production benefit) — see §1 below, 1.4 shipped instead.
- **3.1** `withTransaction` — confirmed present and used in `db/index.ts`,
`api/parse/route.ts`, `api/v1/documents/[id]/route.ts`.
- **2.1** `pfm-web-app/public/produk-pfm/models/` — corrected 2026-07-08 (was
checked against the wrong path, `backend/models`, in an earlier pass this same
day): the directory does exist. Later the same day, task 2.1 itself was completed
— see §2 below — so the artifacts now exist too.
- **2.2** `m-scan-pfm/page.tsx` — confirmed still missing; desktop `scan-pfm/page.tsx`
confirmed present, **and now confirmed full feature parity** (confidence bar,
top-5 candidate list, expiry-date crop preview all present, 1169 lines) — see §2
below, migrated from `next-implementation.md` (deleted 2026-07-08, content now
lives here).
- **3.2** password hashing — confirmed plaintext at split time (2026-07-08 morning); fixed later the same day, see §3 below.
- **3.3** unique index on `documents.file_hash` — confirmed still absent (only
`filename` has a UNIQUE constraint in `db/init.ts`).
- **4.2** healthcheck for `pipeline-api`/vLLM — confirmed no `healthcheck` block in
root `docker-compose.yml`.
## Format
Tasks are grouped under a numbered section per backend module. Each section gets
exactly 3 tasks:
```
## 1. <Section / Module Name>
- **1.1** [TODO] <clear, specific description of the functional change>
- **1.2** [TODO] <...>
- **1.3** [TODO] <...>
```
When a task is picked up via `n`/`next`, its clarified acceptance criteria (from
`AGENTS.md` Part B §B2a) are appended directly under it. When complete, the status
flips to `[DONE]` and the feature is logged in
[docs/feature-list.md](../docs/feature-list.md) (this dir).
---
## 1. Backend — Next.js API Gateway
`pfm-web-app/src/app/api/`
- **1.1** [DONE] ~~Make `documents/upload/route.ts` return `201` immediately...~~ Investigated 2026-07-08: the `file_hash` dedup check is already shipped pre-existing in `v1/documents/upload/route.ts` (lines 58-89) — a duplicate upload returns the existing document instead of re-inserting/re-parsing. The "return 201 immediately, don't await OCR" half turned out to be a deliberate, already-documented tradeoff (see the route's own comment): the pipeline call stays synchronous but is now bounded by `AbortSignal.timeout(210_000)`. Not changed further this round since it's an intentional decision, not an oversight.
- **1.2** [DONE] Investigated 2026-07-08: already shipped pre-existing. Both hops of the upload → `/api/parse` → pipeline-API chain already have `AbortSignal.timeout` (210s and `PIPELINE_TIMEOUT_MS`=90s respectively), with comments cross-referencing each other.
- **1.3** [CANCELLED 2026-07-08] ~~Wire the existing JWT auth onto the classic API routes (`/api/upload`, `/api/scan-pfm`, `/api/parse`, `/api/history`, etc.)~~ — investigated after 3.2 unblocked it, found the premise was stale: those "classic" routes are used exclusively by the dev-only web UI (root DO-PFM page, `scan-pfm`, `manual-label`), which has no login screen and never sends a token — the user confirmed this web UI won't exist in production, so enforcing auth there would break it today for zero production benefit, and optional identification there is a no-op (nobody ever sends a token). Same reasoning as 2.2's cancellation. See **1.4** below for what was actually shipped instead.
- **1.4** [DONE 2026-07-08] Enforced real 401 auth on `/api/v1/documents/*` instead — this is the actual production API surface, already fully supported by the Flutter client (`lib/features/auth/auth_provider.dart` does a real login and stores the JWT; `lib/core/network/api_client.dart`'s interceptor already attaches `Authorization: Bearer <token>` to every request — root `CLAUDE.md`'s "demo-stub, no real server-side auth" claim for Flutter was itself stale). Before this, `v1/documents/route.ts` (list) and `v1/documents/[id]/route.ts` (PUT) didn't check auth at all, and `v1/documents/upload/route.ts` only optionally read the token (never rejected a missing one). Added `getAccountFromAuthHeader()` (`utils/auth.ts`, pre-existing helper) + a `401` guard to all three; `OPTIONS` (CORS preflight) on all three left untouched. Did **not** add per-account data filtering to the document list (still returns all non-sample parsed documents regardless of uploader — that's a feature change, not an auth fix; flagged as a possible future task). Did **not** add Flutter-side 401→auto-logout handling (`api_client.dart` has no interceptor for it; a 401 surfaces as a normal `ApiException` today) — a Flutter-side follow-up, not a backend task.
- Verified end-to-end via `curl`: all three endpoints return 401 with no token; after `POST /api/v1/auth/login` with `admin`/`password` to get a real token, the same three endpoints succeed with `Authorization: Bearer <token>` (`GET` 200 with real data, `PUT` reaches its normal 404-for-bad-id logic, `POST upload` reaches its normal downstream logic) — confirming the auth check itself works without touching any other behavior. `OPTIONS` on all three still returns 204 unauthenticated.
## 2. Backend — OCR Pipeline & Accuracy
`config/`, `pfm-web-app/src/utils/parser.ts`, accuracy regression harness (see `CLAUDE.md`)
**Product/SKU scan sub-feature — decisions & state** (migrated 2026-07-08 from
`next-implementation.md`, which is now deleted; this section is the sole source of
truth for it going forward):
- **Decisions made**: new pages are **standalone routes** (`scan-pfm` — done), following the same self-contained pattern as `manual-label/page.tsx` — own
header/theme, no shared chrome with the root DO-PFM page. Build to **full feature
parity** with the old `ai-ocr-pfm-2026` pages (confidence bars, top-5 SKU
candidates, visual OCR overlay, expiry-date crop preview), not a lean MVP.
- **Scope correction 2026-07-08 (later)**: `m-scan-pfm/page.tsx` (mobile web page)
is **not needed** — user clarified the web `scan-pfm` page is desktop-only,
used for testing the pipeline, not a production mobile surface. Real mobile
scanning is already handled by the Flutter app instead. Focus for this
sub-feature going forward is **backend services** (model artifacts, pipeline
correctness), not any additional frontend. See 2.2 below — cancelled, not
reassigned.
- **Already shipped, re-verified 2026-07-08** (all confirmed present, not assumed):
`config/classify_ocr_server.py` (DINOv2 + YOLO classify, SKU/expiry/name OCR),
`pfm-web-app/public/produk-pfm/index_dinov2.py` + `train_classifier.py`,
`api/scan-pfm/route.ts` (Levenshtein match against `sku_master`),
`api/produk-pfm/route.ts`, DB schema (`sku_master`/`store_master`/`arena_runs` in
`db/init.ts`), `scripts/serve-pipeline.sh` wiring, `Dockerfile`'s `ultralytics`
install in the pipeline-api stage, `nginx.conf` routes for `/scan-pfm`,
`/m-scan-pfm`, `/produk-pfm`.
- **Stale claims corrected 2026-07-08** (next-implementation.md said these didn't
exist; they now do): `pfm-web-app/public/produk-pfm/foto-kemasan-v2/` dataset —
16 SKU subfolders now present (was "❌ does not exist"); `scan-pfm/page.tsx` — now
exists at full feature parity, 1169 lines (was "❌ does not exist").
- **Newly observed, not in original doc's scope**: `/produk-pfm` has an nginx proxy
block and an API route (`api/produk-pfm/route.ts`) but no matching frontend page
(`src/app/produk-pfm/page.tsx` doesn't exist) — dead route, same class of issue as
scan-pfm/m-scan-pfm were. Not turned into a task below since it wasn't part of the
original decision record; flagging for a future `e`/`enhance` pass to pick up.
- **2.1** [DONE 2026-07-08] Built the initial model artifacts. Bare-metal
(`./scripts/install-pipeline.sh`) doesn't work in this Windows dev environment —
`paddlepaddle-gpu`'s wheel index is Linux-only — so this ran entirely through
Docker instead:
1. Added `ultralytics>=8.0` to `scripts/install-pipeline.sh` (was missing there
even though the `Dockerfile` pipeline-api stage already installed it — fixed
for bare-metal parity, though bare-metal training itself still isn't viable on
Windows).
2. `docker compose build pipeline-api` from repo root — cached layers reused, only
the final `COPY . /app` re-ran, picking up the (until-then-unbaked) 16-folder
dataset.
3. Ran a one-off `docker run --gpus all` from the fresh image with `models/`
mounted **writable** (not the live service's `:ro` mount) — the live
`pipeline-api` container's copy was stale (image predated the dataset) and
read-only either way, so training couldn't happen through it directly.
`index_dinov2.py` → indexed 118/118 images across all 16 classes →
`models/dinov2_index.pkl` (196KB). `train_classifier.py train --imgsz 224` → 100
epochs (~1.5 min), **83.3% top-1 / 90% top-5 validation accuracy** (thin dataset,
2-16 images/class — accuracy will improve as more reference photos are added) →
`models/produk-pfm-classifier-26n-100e-2026-07-08.pt` (3.2MB) + `.onnx` export
(5.9MB).
4. `docker compose restart pipeline-api` (bind-mounted `models/` is live, no
rebuild needed for the running service) — confirmed via `docker logs`: "DINOv2
model loaded successfully" and "YOLO model loaded successfully... Using
classifier weights: produk-pfm-classifier-26n-100e-2026-07-08.pt".
- Note on Git Bash + Docker on Windows: `docker run ... bash -c "/app/..."` fails
with exit 127 ("No such file or directory") because MSYS path-conversion mangles
the leading `/app/...` argument into a Windows path — fix is
`MSYS_NO_PATHCONV=1` before the `docker run`/`docker exec` invocation.
**Full browser verification, 2026-07-08 (later same day)** — ran `/scan-pfm` live
(`http://localhost:3000/scan-pfm`, plus confirmed `http://localhost:8000/scan-pfm`
through nginx) via Claude-in-Chrome, selecting the 16-image "FIESTA STIKIE"
sample and clicking through the full flow, not just checking container logs:
- AI Classification: 100.00% confidence, correct class, "Model Active" —
confirms the freshly-trained YOLO weights are actually being used, not a
fallback.
- "View Top 5 Predicted Probabilities" — expands correctly with 5 ranked
candidates + "Use" override buttons.
- AI OCR Text Extraction — expiry date `26/02/2027` extracted correctly with a
label-crop preview image.
- "Possible Match from SKU Master" — Levenshtein-ranked list, correct best match
at 71% similarity.
- Visual Grid tab, Spotting Grid tab, Raw Response (JSON) tab — all render
correctly with real data.
- **Bug found and fixed**: "Save Ground Truth" returned HTTP 200 and looked
successful in the UI, but `api/manual-label-scan/route.ts` writes to
`path.join(process.cwd(), "..", "sources", "product_manual_labels.json")` —
inside the `pfm-web-app` container this resolves to `/sources/...`, which
was **not a bind-mounted path** in root `docker-compose.yml` (only `/app`,
`/uploads`, and the Docker socket were mounted for that service). Every save
was landing in the container's ephemeral writable layer, invisible to the host,
to git, and to any other tooling — confirmed by finding an orphaned entry from
2026-07-07 (an earlier, unrelated test) sitting in the container with no trace
on disk. **Fix**: added `./backend/sources:/sources` to the `pfm-web-app`
service's volumes in root `docker-compose.yml`, recovered the orphaned entry
via `docker cp` before recreating the container, then `docker compose up -d
pfm-web-app` (recreated both `pfm-web-app` and, as its dependency,
`pipeline-api` — re-verified both models reloaded correctly after). Re-ran the
full scan + save and confirmed the entry now lands directly in
`backend/sources/product_manual_labels.json` on the host.
- **Confirmed (2026-07-08, follow-up check) the same bug pattern in
`api/manual-label/route.ts`** (DO-flow ground-truth save,
`sources/manual_labels.json`, identical `process.cwd()`-relative path) is now
also fixed by the same volume mount — verified `md5sum` matches exactly between
the container's `/sources/manual_labels.json` and the host's
`backend/sources/manual_labels.json`, with a fresh host mtime. No separate fix
needed; the one `docker-compose.yml` change covers both routes.
- Acceptance met: `models/dinov2_index.pkl` exists, "Indexed 118/118 images" log
confirmed; both models load cleanly on `pipeline-api` restart per container logs.
End-to-end `/scan-pfm` UI test (upload a real photo, confirm live top-5 result)
— done, see the full browser verification note above (2026-07-08, later still).
- **2.2** [CANCELLED 2026-07-08] ~~Port `m-scan-pfm/page.tsx` (mobile) from `ai-ocr-pfm-2026` into v2~~ — user decided this isn't needed: the web `scan-pfm` page is desktop-only (used to test the pipeline), and real mobile product scanning is handled by the Flutter app, not a web mobile page. The `/m-scan-pfm` nginx block stays dead intentionally — do not resurrect this task without an explicit ask. `docs/feature-list.md` will not get an entry for it.
- **2.3** [DONE 2026-07-08] Ran the accuracy regression harness. **Key finding:
`sources/accuracy_report.md` was badly stale** (said 75.04% overall) — the real
current baseline (confirmed via a fresh harness run at commit `3df9f6e`, matching
`sources/accuracy_history.jsonl`'s latest entry exactly) is **95.10% overall,
already at/above the 95% target**. Added a staleness banner to
`accuracy_report.md` pointing here instead of leaving it to mislead future work.
- **Investigated the worst field, `plat` (67.6%, unchanged between L1/L3 — no
sanitization ever touches it)** by pulling raw OCR text for every mismatching
image from `documents.layout_parsing_result` in Postgres. Of 13 mismatches: 11
are the plate region getting classified by the layout model as an `image_box`/
`seal_box` (never becomes text at all — e.g. `Truck No.` cell literally contains
an `<img>` tag, not digits) or the suffix OCR'd as digits instead of letters
(`"B 9536 000"` vs ground truth `"B 9536 UCO"` — `0` for `O`/`C`). **Not
fixable in `parser.ts`** — there's no text to parse in most cases; the
remaining 1-2 are single-character OCR misreads (`"VOC"` vs `"VCC"`) too
fragile to correct generically without risking false positives elsewhere.
- Cross-checked all other mismatching fields (`noSO`/`noPO`/`noDO`/`tanggal`,
12 mismatches total via `--dump-json`) the same way. Same story: mostly
garbled/missing OCR text (image regions, blank captures) or single-digit OCR
noise (`191848878` vs `1691848878` — one digit dropped;
`PO/26/0000145236` vs `...143236` — one digit substituted) — not reliably
parser-fixable without risking corrupting currently-correct extractions.
- **`do-008.jpg` looks like a ground-truth labeling error, not a parser or OCR
bug** — its `noPO`/`noSO`/`noDO` are all cleanly, unambiguously OCR'd (no
visual noise) but are completely different digit sequences from
`manual_labels.json`'s values, not a plausible misread of them. Flagged here
for human review rather than "fixed" by bending the parser to match a
ground-truth value that's plausibly just wrong for this image.
- **Found and fixed one real parser logic bug** (not an OCR-quality issue): in
`do-026.jpg`, the SO field OCR'd as garbage (`"No. SO : 06 No. 2026"`, date text
bleeding into the SO cell) so extraction correctly fell through to
`"Not Found"` — but the *global* fallback (`parser.ts`, "Global pattern
scanning fallback", `\b16\d{8}\b` scan) then backfilled it with the only
`16xxxxxxxx`-shaped number in the whole document, which was actually the
*already-correctly-extracted* `noDO` value — silently duplicating a DO number
into the SO field. Fixed by excluding values already assigned to
`noSO`/`noDO`/`noPO` from that global backfill pool. Confirmed via a direct
`parseDOMetadata()` unit call and re-running the harness:
`noSO` now correctly shows `"Not Found"` instead of the fabricated duplicate.
**This doesn't move the overall percentage** (a wrong value and "Not Found"
are both mismatches against ground truth) — it's a correctness fix, not an
accuracy-score fix: previously a plausible-looking *wrong* number could reach
the database unreviewed; now it's honestly flagged as missing instead.
- All 48 `parser.test.ts` unit tests still pass; full harness re-run shows no
regressions (`plat`/`noSO`/`noPO`/`noDO`/`tanggal`/etc. all unchanged except
the corrected `do-026.jpg` `noSO` value described above).
- **Conclusion**: the 95% target is already met at the aggregate level, and the
remaining gap is now predominantly an OCR/layout-model accuracy ceiling (garbled
text, misclassified stamp/seal regions), not a `parser.ts` logic gap — further
`parser.ts` tuning on the current 37-image set has low remaining ROI. Future
accuracy work should target the OCR/layout pipeline itself (`config/`,
PaddleOCR-VL prompt/model tuning) or expanding the test set to catch different
failure modes, not more regex tweaks.
*Not in scope for either 2.1/2.2 (per next-implementation.md's own note, still true
2026-07-08): batch/lot number extraction doesn't exist in `classify_ocr_server.py`
in either project (only SKU, product name, expiry date are extracted) — if
requested later, follow the same OCR-regex-cascade pattern already used for
expiry-date extraction.*
## 3. Backend — Postgres Data Layer
`pfm-web-app/src/db/`
- **3.1** [DONE] Wrapped the `ocr_items` delete-then-reinsert logic in both `/api/parse` and the `/api/v1/documents/[id]` PUT route inside a single DB transaction (`withTransaction` helper in `db/index.ts`), so a mid-loop failure can no longer leave a document with a correct header but silently missing items — it now rolls back to the previous item set instead. Completed 2026-07-08.
- **3.2** [DONE 2026-07-08] Hashed `accounts.password` with `bcryptjs` (not native `bcrypt` — the `pfm-web-app` Docker stage is `node:20-slim` with no build toolchain, so a native addon would break the image build). `db/init.ts` now hashes the seed password and idempotently migrates any existing plaintext rows (`WHERE password NOT LIKE '$2%'`) on every startup — safe to re-run, no double-hashing. `api/v1/auth/login/route.ts` now fetches by username only and compares with `bcrypt.compareSync`, plus a small added guard for missing username/password (previously would have fallen through to a DB query with `undefined`; now a clean 401). Installed via `docker exec paddleocr-pfm-web-app npm install bcryptjs` (container has its own anonymous `node_modules` volume, not host-synced) + `docker compose restart pfm-web-app`. Verified: `SELECT password FROM accounts` shows a `$2b$10$...` hash, `curl` login with the correct password succeeds (200 + token), wrong password and missing password both return a clean 401 (not a 500). This unblocks task 1.3.
- **3.3** [TODO] Add a unique index on `documents.file_hash` so the dedup check from task 1.1 can do an efficient existing-document lookup before insert instead of a full scan.
## 4. DevOps — Docker & Dev Tunnel
`../docker-compose.yml`, `../docker-compose.demo.yml`, `../start-dev-tunnel.ps1`
- **4.1** [TODO] Decide and document (in root `README.md`/`CLAUDE.md`) a deliberate policy for when the PoC/demo runs `docker-compose.demo.yml` (production build) vs. the dev-mode default — `docker-compose.yml` alone runs `npm run dev`, a known throughput ceiling already flagged in the reliability audit.
- **4.2** [TODO] Add a startup healthcheck/readiness gate for `pipeline-api`/vLLM in `docker-compose.yml` so the Next.js gateway doesn't accept uploads before the GPU pipeline is actually ready to serve them.
- **4.3** [TODO] Extend `start-dev-tunnel.ps1` to verify the ngrok tunnel/LAN IP is actually reachable (not just started) before reporting success, since a stale tunnel is the documented first failure point for login/upload from the Flutter app.
---
*Sections 1-4 migrated 2026-07-08 from root `plans/next-enhancements.md` sections
5-8 (originally populated the same day via a backend-scoped `e`/`enhance` run,
grounded in a prior end-to-end reliability audit and, for §2's Product/SKU scan
sub-feature, `next-implementation.md`'s status table). This file is the sole home
for backend enhancement tasks — the root copy's sections 5-8 were removed outright
from `plans/next-enhancements.md` (not just frozen) per root `AGENTS.md`'s "Scope:
excludes `backend/`" note; the already-shipped 3.1/7.1 DB-transaction fix stays
recorded in root `docs/feature-list.md` regardless, since that's a shipped-feature
log, not a backlog.*
*`next-implementation.md` itself was deleted 2026-07-08 once its full content
(decisions, verified state, action steps, verification checklist) was folded into
§2 above with corrected statuses — it's no longer a separate source of truth.*
+3
View File
@@ -24,4 +24,7 @@ echo "==> Installing PaddleX serving dependencies"
uv pip install --python "${VENV}" \
"aiohttp>=3.9" "filetype>=1.2" "fastapi>=0.110" "starlette>=0.36" "uvicorn>=0.16"
echo "==> Installing ultralytics (YOLO classifier fallback, product/SKU scan)"
uv pip install --python "${VENV}" "ultralytics>=8.0"
echo "==> Done. Start pipeline API with: ./scripts/serve-pipeline.sh"
+38
View File
@@ -5,3 +5,41 @@
{"timestamp":"2026-07-04T07:29:39.005Z","commit":"095dd4c","refreshOcr":true,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.19367588932806,"layer2Sanitized":67.19367588932806,"layer3Final":88.83553421368548},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":10.81081081081081,"layer2Sanitized":10.81081081081081,"layer3Final":94.5945945945946},"kodeBarang":{"layer1RawRegex":97.6,"layer2Sanitized":97.6,"layer3Final":97.6},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":93.60000000000001},"banyak":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999},"jumlah":{"layer1RawRegex":76,"layer2Sanitized":76,"layer3Final":93.60000000000001}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":46.15384615384615,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-04T08:29:06.624Z","commit":"095dd4c","refreshOcr":true,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.72068511198947,"layer2Sanitized":67.72068511198947,"layer3Final":89.4357743097239},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":97.2972972972973},"kodeBarang":{"layer1RawRegex":98.4,"layer2Sanitized":98.4,"layer3Final":98.4},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":94.39999999999999},"banyak":{"layer1RawRegex":77.60000000000001,"layer2Sanitized":77.60000000000001,"layer3Final":95.19999999999999},"jumlah":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":84.61538461538461,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-04T09:58:40.843Z","commit":"095dd4c","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.72068511198947,"layer2Sanitized":67.72068511198947,"layer3Final":89.4357743097239},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":97.2972972972973},"kodeBarang":{"layer1RawRegex":98.4,"layer2Sanitized":98.4,"layer3Final":98.4},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":94.39999999999999},"banyak":{"layer1RawRegex":77.60000000000001,"layer2Sanitized":77.60000000000001,"layer3Final":95.19999999999999},"jumlah":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":84.61538461538461,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-06T02:03:00.078Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.72068511198947,"layer2Sanitized":67.72068511198947,"layer3Final":89.4357743097239},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":97.2972972972973},"kodeBarang":{"layer1RawRegex":98.4,"layer2Sanitized":98.4,"layer3Final":98.4},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":94.39999999999999},"banyak":{"layer1RawRegex":77.60000000000001,"layer2Sanitized":77.60000000000001,"layer3Final":95.19999999999999},"jumlah":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":84.61538461538461,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-06T03:27:28.122Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":["do-001.jpg","do-002.jpg","do-003.jpg","do-004.jpg","do-005.jpg","do-006.jpg","do-007.jpg","do-008.jpg","do-009.jpg","do-010.jpg","do-011.jpg","do-012.jpg","do-013.jpg","do-014.jpg","do-015.jpg","do-016.jpg","do-017.jpg","do-018.jpg","do-019.jpg","do-020.jpg","do-021.jpg","do-022.jpg","do-023.jpg","do-024.jpg","do-025.jpg","do-026.jpg","do-027.jpg","do-028.jpg","do-029.jpg","do-030.jpg","do-031.jpg","do-032.jpg","do-033.jpg","do-034.jpg","do-035.jpg","do-036.jpg","do-037.jpg"],"overall":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":0},"fields":{},"perImage":{}}
{"timestamp":"2026-07-06T03:29:14.056Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.72068511198947,"layer2Sanitized":67.72068511198947,"layer3Final":89.4357743097239},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":97.2972972972973},"kodeBarang":{"layer1RawRegex":98.4,"layer2Sanitized":98.4,"layer3Final":98.4},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":94.39999999999999},"banyak":{"layer1RawRegex":77.60000000000001,"layer2Sanitized":77.60000000000001,"layer3Final":95.19999999999999},"jumlah":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":84.61538461538461,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-06T09:26:53.022Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-06T09:31:28.799Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:56:42.124Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:57:32.808Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:57:52.670Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:03.790Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:14.535Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:25.264Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:36.372Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:47.317Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:57.981Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:09.065Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:19.615Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:30.262Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:40.752Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:51.392Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:01.741Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:13.427Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:24.424Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:35.516Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:47.181Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:58.931Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:09.373Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:21.125Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:33.405Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:44.798Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:56.339Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:02:07.986Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:02:19.368Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:02:31.985Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:08:52.590Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:16:13.504Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:17:00.228Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:20:07.251Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:20:23.707Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
+90
View File
@@ -1,5 +1,18 @@
# AI OCR Accuracy & Performance Report
> **⚠️ Stale as of 2026-07-08.** The numbers below (75.04% overall) predate several
> parser fixes (table column-shift correction, unit normalization, date
> standardization — see git log) and are significantly out of date. The real
> current baseline, confirmed 2026-07-08 by re-running
> `node pfm-web-app/scripts/accuracy-check.mts` at commit `3df9f6e` (matches
> `sources/accuracy_history.jsonl`'s latest entry exactly): **95.10% overall
> layer3Final** — already at/above the 95% target. Worst fields now: `plat`
> (67.6%, almost entirely OCR/layout-model misses — see
> `backend/plans/next-enhancements.md` §2 task 2.3 for the full investigation),
> `noSO` (86.5%), `tanggal` (89.2%), `noPO`/`noDO` (91.9% each). Don't trust the
> prose/table below without re-running the harness first — this file isn't
> auto-regenerated on every run.
This report summarizes the comparison of **AI OCR Extraction (Layer 3 Final)** against the **Manual Ground Truth Labels** across all **37 test images**.
---
@@ -44,3 +57,80 @@ This report summarizes the comparison of **AI OCR Extraction (Layer 3 Final)** a
- *Recommendation*: Enhance `sanitizeParsedMetadata` in [parser.ts](file:///d:/Client/Data%20Bisnis%20Solusi/app-pfm-ocr-v2/backend/pfm-web-app/src/utils/parser.ts) to parse multiple date format variations into a unified target format.
3. **Plat Nomor (56.76%)**:
- *Why it's low*: License plates on delivery orders are often stamped, handwritten, or placed in odd margins, which makes clean extraction difficult.
---
## 🧪 Parser Unit Test Results (`parser.test.ts`)
**Last run:** 2026-07-06 · **Result: 48 / 48 passed (100%)** ✅
Unit-level regression tests for `parseDOMetadata()` and `sanitizeParsedMetadata()` in [parser.ts](file:///d:/Client/Data%20Bisnis%20Solusi/app-pfm-ocr-v2/backend/pfm-web-app/src/utils/parser.ts). Run anytime with:
```
npx tsx src/utils/parser.test.ts
```
(from `backend/pfm-web-app`). Triggered this run by the **Customer Name post-processing cleanup** — removed the now-dead OCR extraction/regex logic for `customerInfo` (it was always overwritten by a hardcoded `"PT. PRIMAFOOD INTERNATIONAL"` constant anyway) and confirmed nothing else regressed.
| Status | Count |
| :--- | :---: |
| ✅ Passed | **48** |
| ❌ Failed | **0** |
| **Total** | **48** |
### `parseDOMetadata` — 26 tests
| # | Test Case | Result |
| :---: | :--- | :---: |
| 1 | PO standard PO/26/ | ✅ |
| 2 | PO misread P0/26/ on label | ✅ |
| 3 | PO label raw 10-digit, real PO in body | ✅ |
| 4 | PO misread F0/20/ — use current year NOT 20 | ✅ |
| 5 | PO body P0/26/ | ✅ |
| 6 | PO real doc: label raw, body has P0/26/ | ✅ |
| 7 | PO fused F012070000170727 | ✅ |
| 8 | PO fused PO12070000190729 | ✅ |
| 9 | PO noise digits PO120/0000170727 | ✅ |
| 10 | Date trailing noise cut | ✅ |
| 11 | Date no space 25May2020 | ✅ |
| 12 | Date standard 23 June 2026 | ✅ |
| 13 | Date Tanggal blank shifted to No.SO | ✅ |
| 14 | Date prefix timestamp noise | ✅ |
| 15 | Date (Asli/Copy) prefix noise | ✅ |
| 16 | Date junk suffix cut | ✅ |
| 17 | Date bad OCR month Hv → Not Found | ✅ |
| 18 | Date single digit 7 May 2026 → 07 May 2026 | ✅ |
| 19 | Date single digit 4 Apr 2026 → 04 April 2026 | ✅ |
| 20 | 00117709 before Tanggal must not pollute date | ✅ |
| 21 | Plate B 9427 UXT | ✅ |
| 22 | Plate B-9999-XYZ dash | ✅ |
| 23 | Plate ignore PO/SO prefix | ✅ |
| 24 | Plate B9427UXT adjacent | ✅ |
| 25 | Plate real doc B 9723 CXS | ✅ |
| 26 | Table column shift alignment correction | ✅ |
### `sanitizeParsedMetadata` — 22 tests
| # | Test Case | Result |
| :---: | :--- | :---: |
| 27 | valid tanggal 30 June 2026 passes | ✅ |
| 28 | valid tanggal 25 May 2020 passes | ✅ |
| 29 | single digit tanggal 4 April 2026 → 04 April 2026 | ✅ |
| 30 | tanggal bad month Hv → Not Found | ✅ |
| 31 | tanggal as number 0011770 → Not Found | ✅ |
| 32 | tanggal day 0 → Not Found | ✅ |
| 33 | tanggal day 32 → Not Found | ✅ |
| 34 | tanggal year 2009 (too old) → Not Found | ✅ |
| 35 | tanggal Not Found stays Not Found | ✅ |
| 36 | tanggal with noise suffix → Not Found | ✅ |
| 37 | valid noPO PO/26/0000178435 passes | ✅ |
| 38 | noPO wrong year auto-corrected to current | ✅ |
| 39 | noPO raw number → Not Found | ✅ |
| 40 | noPO Not Found stays Not Found | ✅ |
| 41 | valid noSO 1691908676 passes | ✅ |
| 42 | noSO 'abc' → Not Found | ✅ |
| 43 | noSO too short '123' → Not Found | ✅ |
| 44 | valid noDO 1659932080 passes | ✅ |
| 45 | noDO 'XYZXYZ' → Not Found | ✅ |
| 46 | valid platTruk B 9427 UXT passes | ✅ |
| 47 | platTruk XY 1234 ABC invalid prefix → empty | ✅ |
| 48 | platTruk empty stays empty | ✅ |
> Note: no test case directly exercises `customerInfo` (it wasn't covered before this change either) - passing confirms the surrounding logic (PO/date/plate/table parsing) is unaffected by the cleanup.
+1 -1
View File
@@ -46,7 +46,7 @@
| 34 | `Sample DO PFM-page-00020.jpg` | ✅ success | 0° | No | `1659889989` | `PO/26/0000135032` | 1 |
| 35 | `Sample DO PFM-page-00021.jpg` | ✅ success | 0° | No | `1659877015` | `PO/26/0000121086` | 3 |
| 36 | `Sample DO PFM-page-00022.jpg` | ✅ success | 0° | No | `1659896184` | `PO/26/0000140731` | 3 |
| 37 | `Sample DO PFM-page-00023.jpg` | ✅ success | 0.05° | No | `1659922449` | `PO/26/0000168198` | 1 |
| 37 | `Sample DO PFM-page-00023.jpg` | ✅ success | 0.05° | No | `165992/2449` | `PO/26/0000168198` | 1 |
---
File diff suppressed because it is too large. Load diff
+85 -79
View File
@@ -49,8 +49,8 @@
"noDO": "1659932080",
"tanggal": "15 May 2026",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "AYAM HITZ HO ANCOL",
"alamat": "JL. ANCOL BARAT VIII/1 KEL. ANCOL, KEC. PADEMANGAN JAKARTA UTARA, DKI JAKARTA",
"store": "PM TAKTAKAN SERANG",
"alamat": "JL. RAYA TAKTAKAN NO. 1 G, KEL. LONTAR BARU, KEC. SERANG, SERANG",
"plat": "B 9723 CXS",
"items": [
{
@@ -94,7 +94,7 @@
{
"kodeBarang": "21002601",
"namaBarang": null,
"banyak": "1",
"banyak": "1 BOX",
"jumlah": "6 PAC"
}
]
@@ -235,8 +235,8 @@
"noDO": "1659943004",
"tanggal": "25 May 2026",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1 KEL. ANCOL, KEC. PADEMANGAN JAKARTA UTARA, DKI JAKARTA",
"store": "PM MUSTIKA MEDIA",
"alamat": "RUKO GRAHA MUSTIKA MEDIA, JL. KP LUBANG BUAYA, RT.001, RW.003, KEL. LUBANG BUAYA, KEC. SETU, BEKASI 17320",
"plat": "B 9536 UCO",
"items": [
{
@@ -421,9 +421,9 @@
"noSO": "1691879196",
"noDO": "1659905109",
"tanggal": "23 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM PANDU RAYA",
"alamat": "JL. PANDU RAYA/ H. ACHMAD ADNAWIJAYA NO. 68, RT. 001, RW. 015, KEL. TEGALGUNDIL, KEC. BOGOR UTARA, BOGOR",
"plat": "B 9948 VCC",
"items": [
{
@@ -440,9 +440,9 @@
"noSO": "1691871022",
"noDO": "1659897809",
"tanggal": "17 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM NEW BINTARO SEKTOR 5",
"alamat": "JL. BINTARO UTAMA RAYA 5 BLOK EA NO 17, RT.001, RW.011, KEL. JURANGMANGGU TIMUR, KEC. PONDOK AREN, KOTA TANGERANG SELATAN 15222",
"plat": "B 9599 CXS",
"items": [
{
@@ -465,9 +465,9 @@
"noSO": "1691870689",
"noDO": "1659897396",
"tanggal": "17 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM TAMAN DUTA CIBODAS",
"alamat": "PERUM TAMAN CIBODAS, JL. DUTA RAYA BLOK K1 NO. 20, RT. 007, RW. 006, KEL. SANGIANG JAYA, KEC. PERIUK, TANGERANG",
"plat": "B 9254 SCG",
"items": [
{
@@ -490,9 +490,9 @@
"noSO": "1691865548",
"noDO": "1659892421",
"tanggal": "14 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM SASMITA",
"alamat": "JL. KENAIBAN RT. 002, RW. 002, KEL. PABUARAN, KEC. KARAWACI, TANGERANG",
"plat": "B 9053 VCF",
"items": [
{
@@ -521,10 +521,10 @@
"noSO": "1691859106",
"noDO": "1659886692",
"tanggal": "09 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"plat": "B 9053 VCF",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM SALAM RAYA",
"alamat": "JL.SALAM RAYA, RT.005, RW.003, KEL. SUKABUMI UTARA, KEC. KEBON JERUK, JAKARTA BARAT 11540",
"plat": "B 9260 BXT",
"items": [
{
"kodeBarang": "12010112",
@@ -582,9 +582,9 @@
"noSO": "1691868563",
"noDO": "1659895333",
"tanggal": "16 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM REMPOA",
"alamat": "JL. REMPOA RAYA NO. 08, RT. 004, RW. 013, KEL. BINTARO, KEC. PESANGGRAHAN, JAKARTA SELATAN",
"plat": "B 9365 AI",
"items": [
{
@@ -619,9 +619,9 @@
"noSO": "1691869400",
"noDO": "1659896184",
"tanggal": "16 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "MOTHER STORE - PM WARUNG JATI TIMUR RAYA",
"alamat": "JL. WARUNG JATI TIMUR NO.26, RT.007, RW.004, KEL. KALIBATA, KEC. PANCORAN, JAKARTA SELATAN 12740",
"plat": "B 9310 BXT",
"items": [
{
@@ -680,9 +680,9 @@
"noSO": "1691903313",
"noDO": "1659927197",
"tanggal": "11 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM RAWA BUNTU",
"alamat": "JL. RAWA BUNTU UTARA BLOK UA NO. 16, RT. 002, RW. 006, KEL. RAWA BUNTU, KEC. SERPONG, TANGERANG SELATAN",
"plat": "B 9683 VCF",
"items": [
{
@@ -711,9 +711,9 @@
"noSO": "1691895167",
"noDO": "1659919748",
"tanggal": "05 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "MOTHER STORE - PM WARUNG JATI TIMUR RAYA",
"alamat": "JL. WARUNG JATI TIMUR NO.26, RT.007, RW.004, KEL. KALIBATA, KEC. PANCORAN, JAKARTA SELATAN 12740",
"plat": "B 9042 VCC",
"items": [
{
@@ -754,9 +754,9 @@
"noSO": "1691895765",
"noDO": "1659920402",
"tanggal": "06 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM PASAR PARUNG BOGOR",
"alamat": "JL. TULANG KUNING - PASAR PARUNG DESA WANO, RT.001, RW.005, KEL. WARU, KEC. PARUNG, BOGOR 16330",
"plat": "B 9260 VCF",
"items": [
{
@@ -791,9 +791,9 @@
"noSO": "1691896671",
"noDO": "1659921089",
"tanggal": "06 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KELAPA DUA KARAWACI",
"alamat": "RUKO SENTRA NIAGA KELAPA II / R 9, KEL. KELAPA DUA, KEC. KELAPA DUA, TANGERANG",
"plat": "B 9899 CXR",
"items": [
{
@@ -846,16 +846,16 @@
"noSO": "1691895813",
"noDO": "1659920441",
"tanggal": "06 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM PANORAMA BALI RESIDENCE BGR",
"alamat": "PANORAMA BALI RESIDENCE B NO 16, KEL. PUTAT NUTUG, KEC. CISEENG, KAB. BOGOR 16120",
"plat": "B 9417 VCC",
"items": [
{
"kodeBarang": "11140062",
"namaBarang": "AMPELA 1 KG FROZEN (20 PAC/KARUNG)(*)",
"banyak": "1 KRG",
"jumlah": "2 PAC"
"jumlah": "20 PAC"
},
{
"kodeBarang": "11310014",
@@ -883,9 +883,9 @@
"noSO": "1691898090",
"noDO": "1659922449",
"tanggal": "07 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KARANG BOLONG SERANG",
"alamat": "JL. RAYA KARANG BOLONG BANDULU (KP SEMPUR) NO.22, RT.004, RW.001, KEL. BANDULU, KEC. ANYAR, KAB. SERANG 42166",
"plat": "B 9259 BCU",
"items": [
{
@@ -944,9 +944,9 @@
"noSO": "1691853079",
"noDO": "1659881372",
"tanggal": "06 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM HAJI MUHI",
"alamat": "JL. H. MUHI NO. 14, RT. 001, RW. 004, KEL. PONDOK PINANG, KEC. KEBAYORAN LAMA, JAKARTA SELATAN",
"plat": "B 9260 BXT",
"items": [
{
@@ -981,10 +981,10 @@
"noSO": "1691848409",
"noDO": "1659876922",
"tanggal": "02 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"plat": "B 9260 VCF",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM TANAH KUSIR",
"alamat": "JL. RC VETERAN NO. 17, RT. 04, RW. 012, KEL. BINTARO, KEC. PESANGGRAHAN, JAKARTA SELATAN",
"plat": "B 9260 BXT",
"items": [
{
"kodeBarang": "11110074",
@@ -1012,9 +1012,9 @@
"noSO": "1691850584",
"noDO": "1659878594",
"tanggal": "03 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI ARE",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KAMPUNG TENGAH",
"alamat": "JL. RAYA TENGAH RT. 004, RW. 009, KEL. TENGAH, KEC. KRAMAT JATI, JAKARTA TIMUR 13540",
"plat": "B 9308 BXT",
"items": [
{
@@ -1037,9 +1037,9 @@
"noSO": "1691848878",
"noDO": "1659877509",
"tanggal": "02 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM CIPETE RAYA",
"alamat": "JL. CIPETE RAYA NO. 15 F, RT. 006, RW. 003, KEL. CIPETE SELATAN, KEC. CILANDAK, JAKARTA SELATAN",
"plat": "B 9767 NCD",
"items": [
{
@@ -1062,9 +1062,9 @@
"noSO": "1691872559",
"noDO": "1659898844",
"tanggal": "18 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "KU - DANAU SINGKARAK DEPOK",
"alamat": "JL. DANAU SINGKARAK, RT.001, RW.008, KEL. SUKAMAJU, KEC. CILODONG, DEPOK 16415",
"plat": "B 9255 JXR",
"items": [
{
@@ -1105,9 +1105,9 @@
"noSO": "1691852095",
"noDO": "1659880471",
"tanggal": "04 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KAMPUNG SAWAH 2",
"alamat": "JL. KI HAJAR DEWANTARA KAV. 12 A, RT. 001, RW. 004, KEL. SAWAH, KEC. CIPUTAT, TANGERANG SELATAN 15413",
"plat": "B 9402 SCH",
"items": [
{
@@ -1122,6 +1122,12 @@
"banyak": "1 KRG",
"jumlah": "20 PAC"
},
{
"kodeBarang": "11310024",
"namaBarang": "AYAM SIZE A FROZEN (0.9-1)KG/PC(*)",
"banyak": "2 KRG",
"jumlah": "40 PC"
},
{
"kodeBarang": "11660050",
"namaBarang": "PAHA BAWAH (1 KG)(*)",
@@ -1136,16 +1142,16 @@
"noSO": "1691863154",
"noDO": "1659889989",
"tanggal": "11 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM GRAND CIKARANG CITY 2",
"alamat": "JL. BOULEVARD CINITY BLOK D12R -26, KEL. KARANG RAHARJA, KEC. CIKARANG UTARA, KAB. BEKASI 17534",
"plat": "B 9372 BXT",
"items": [
{
"kodeBarang": "11310013",
"namaBarang": "AYAM SIZE1 FROZEN (0.75-0.8) KG(*)",
"banyak": "1 KRG",
"jumlah": "30 PC"
"banyak": "2 KRG",
"jumlah": "60 PC"
}
]
},
@@ -1155,9 +1161,9 @@
"noSO": "1691848523",
"noDO": "1659877015",
"tanggal": "02 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM TIMBUL",
"alamat": "JL. TIMBUL NO. 01, RT. 007, RW. 004, KEL. CIPEDAK, KEC. JAGAKARSA, JAKARTA SELATAN",
"plat": "B 9726 UCT",
"items": [
{
@@ -1186,9 +1192,9 @@
"noSO": "1691869400",
"noDO": "1659896184",
"tanggal": "16 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "MOTHER STORE - PM WARUNG JATI TIMUR RAYA",
"alamat": "JL. WARUNG JATI TIMUR NO.26, RT.007, RW.004, KEL. KALIBATA, KEC. PANCORAN, JAKARTA SELATAN 12740",
"plat": "B 9310 BXT",
"items": [
{
@@ -1217,9 +1223,9 @@
"noSO": "1691898090",
"noDO": "1659922449",
"tanggal": "07 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KARANG BOLONG SERANG",
"alamat": "JL. RAYA KARANG BOLONG BANDULU (KP SEMPUR) NO.22, RT.004, RW.001, KEL. BANDULU, KEC. ANYAR, KAB. SERANG 42166",
"plat": "B 9259 BCU",
"items": [
{
@@ -0,0 +1,20 @@
[
{
"filename": "12010119 FIESTA NUGGET CHEESE 123 400 GR PER PAC/WhatsApp Image 2026-05-28 at 11.50.03.jpeg",
"no_sku": "",
"nama_item": "",
"expiry_date": "23/09/2026",
"top1_confidence": null,
"notes": "",
"saved_at": "2026-07-07T13:01:57.538Z"
},
{
"filename": "12030102 FIESTA STIKIE 200 GR-PAC/WhatsApp Image 2026-06-29 at 09.10.01 (1).jpeg",
"no_sku": "12030102",
"nama_item": "12030102 FIESTA STIKIE 200 GR-PAC",
"expiry_date": "26/02/2027",
"top1_confidence": 1,
"notes": "",
"saved_at": "2026-07-08T03:47:59.797Z"
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
[
conti[
{
"noSku": "11110059",
"namaItem": "CEKER BERKUKU FROZEN PACK 1 KG(*)",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

-3
View File
@@ -1,3 +0,0 @@
DATABASE_URL="file:./dev.db"
JWT_SECRET="pfm_ocr_super_secret_key_12345"
PORT=3000
-18
View File
@@ -1,18 +0,0 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: pfm-ocr-postgres
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgrespassword
POSTGRES_DB: pfm_ocr_db
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:
-4709
View File
File diff suppressed because it is too large. Load diff
-39
View File
@@ -1,39 +0,0 @@
{
"name": "app-pfm-ocr-backend",
"version": "1.0.0",
"description": "On-premise OCR & Auth Backend for Prima Mart Scanner",
"main": "dist/app.js",
"scripts": {
"build": "tsc",
"start": "node dist/app.js",
"dev": "ts-node-dev --respawn --transpile-only src/app.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@prisma/client": "^5.12.1",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.11",
"@types/node": "^20.12.7",
"@types/supertest": "^6.0.2",
"prisma": "^5.12.1",
"supertest": "^6.3.4",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5",
"vitest": "^1.5.0"
}
}
Binary file not shown.
@@ -1,40 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT,
"filePath" TEXT NOT NULL,
"status" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tanggal" TEXT NOT NULL,
"noPo" TEXT NOT NULL,
"noSo" TEXT NOT NULL,
"noDo" TEXT NOT NULL,
"kepadaYth" TEXT NOT NULL,
"orderUntuk" TEXT NOT NULL,
"alamat" TEXT NOT NULL,
"platTruk" TEXT NOT NULL,
CONSTRAINT "Document_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "DocumentItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentId" TEXT NOT NULL,
"nomorSku" TEXT NOT NULL,
"namaBarang" TEXT NOT NULL,
"banyak" TEXT NOT NULL,
"jumlah" TEXT NOT NULL,
CONSTRAINT "DocumentItem_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
@@ -1,26 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Document" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT,
"filePath" TEXT NOT NULL,
"status" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tanggal" TEXT NOT NULL,
"noPo" TEXT NOT NULL,
"noSo" TEXT NOT NULL,
"noDo" TEXT NOT NULL,
"kepadaYth" TEXT NOT NULL,
"orderUntuk" TEXT NOT NULL,
"alamat" TEXT NOT NULL,
"platTruk" TEXT NOT NULL,
"namaDriver" TEXT NOT NULL DEFAULT '',
"namaPenerima" TEXT NOT NULL DEFAULT '',
CONSTRAINT "Document_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Document" ("alamat", "createdAt", "filePath", "id", "kepadaYth", "noDo", "noPo", "noSo", "orderUntuk", "platTruk", "status", "tanggal", "userId") SELECT "alamat", "createdAt", "filePath", "id", "kepadaYth", "noDo", "noPo", "noSo", "orderUntuk", "platTruk", "status", "tanggal", "userId" FROM "Document";
DROP TABLE "Document";
ALTER TABLE "new_Document" RENAME TO "Document";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"
-54
View File
@@ -1,54 +0,0 @@
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
username String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
documents Document[]
}
model Document {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id])
filePath String
status String // "uploading" | "processing" | "success" | "error"
createdAt DateTime @default(now())
// Header Info
tanggal String
noPo String
noSo String
noDo String
// Shipment Info
kepadaYth String
orderUntuk String
alamat String
platTruk String
namaDriver String @default("")
namaPenerima String @default("")
latitude Float?
longitude Float?
items DocumentItem[]
}
model DocumentItem {
id String @id @default(uuid())
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
nomorSku String
namaBarang String
banyak String
jumlah String
}
-103
View File
@@ -1,103 +0,0 @@
import express from 'express'
import cors from 'cors'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import bcrypt from 'bcrypt'
import { PrismaClient } from '@prisma/client'
import { config } from './config'
import { AuthController } from './controllers/auth.controller'
import { DocumentController } from './controllers/document.controller'
import { authenticateToken } from './middleware/auth.middleware'
const prisma = new PrismaClient()
// Ensure uploads folder exists
const uploadsDir = path.join(__dirname, '../uploads')
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true })
}
// Multer storage engine configuration
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadsDir)
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9)
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname))
}
})
const upload = multer({ storage })
const app = express()
// Global Middleware
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use('/uploads', express.static(uploadsDir))
// Auth Routes
app.post('/api/v1/auth/register', AuthController.register)
app.post('/api/v1/auth/login', AuthController.login)
// Document Routes
app.post(
'/api/v1/documents/upload',
upload.single('image'),
authenticateToken as any,
DocumentController.upload as any
)
app.get(
'/api/v1/documents',
authenticateToken as any,
DocumentController.list as any
)
app.put(
'/api/v1/documents/:id',
authenticateToken as any,
DocumentController.update as any
)
// Handle 404 Route
app.use((req, res) => {
res.status(404).json({
status: 'error',
message: 'Endpoint not found'
})
})
// Global Error Handler
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Unhandled Server Error:', err)
res.status(500).json({
status: 'error',
message: 'Internal server error occurred'
})
})
// Start server if not running in test context
if (process.env.NODE_ENV !== 'test') {
prisma.user.findFirst().then(async (user) => {
if (!user) {
const passwordHash = await bcrypt.hash('password123', 10)
await prisma.user.create({
data: {
username: 'admin',
passwordHash
}
})
console.log('Default user "admin" with password "password123" seeded successfully.')
}
}).catch(err => {
console.error('Error during automatic database seeding:', err)
})
app.listen(config.PORT, () => {
console.log(`PFM OCR Server is listening on port ${config.PORT}`)
})
}
export { app }
-10
View File
@@ -1,10 +0,0 @@
import dotenv from 'dotenv'
import path from 'path'
dotenv.config({ path: path.join(__dirname, '../.env') })
export const config = {
PORT: process.env.PORT ? parseInt(process.env.PORT) : 3000,
DATABASE_URL: process.env.DATABASE_URL || '',
JWT_SECRET: process.env.JWT_SECRET || 'pfm_ocr_fallback_secret',
}
@@ -1,96 +0,0 @@
import { Request, Response } from 'express'
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import { config } from '../config'
const prisma = new PrismaClient()
export class AuthController {
public static async register(req: Request, res: Response) {
const { username, password } = req.body
if (!username || !password) {
return res.status(400).json({
status: 'error',
message: 'Username and password are required'
})
}
try {
const existingUser = await prisma.user.findUnique({ where: { username } })
if (existingUser) {
return res.status(400).json({
status: 'error',
message: 'Username already exists'
})
}
const passwordHash = await bcrypt.hash(password, 10)
const user = await prisma.user.create({
data: {
username,
passwordHash
}
})
return res.status(201).json({
status: 'success',
message: 'User registered successfully',
data: {
id: user.id,
username: user.username,
createdAt: user.createdAt
}
})
} catch (error) {
return res.status(500).json({
status: 'error',
message: 'Failed to register user'
})
}
}
public static async login(req: Request, res: Response) {
const { username, password } = req.body
if (!username || !password) {
return res.status(400).json({
status: 'error',
message: 'Username and password are required'
})
}
try {
const user = await prisma.user.findUnique({ where: { username } })
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({
status: 'error',
message: 'Invalid username or password'
})
}
const token = jwt.sign(
{ id: user.id, username: user.username },
config.JWT_SECRET,
{ expiresIn: '30d' }
)
return res.status(200).json({
status: 'success',
data: {
token,
user: {
id: user.id,
username: user.username
}
}
})
} catch (error) {
return res.status(500).json({
status: 'error',
message: 'Login failed'
})
}
}
}
@@ -1,285 +0,0 @@
import { Response } from 'express'
import { AuthenticatedRequest } from '../middleware/auth.middleware'
import { PrismaClient } from '@prisma/client'
import { OcrService } from '../services/ocr.service'
const prisma = new PrismaClient()
export interface DocumentItem {
nomorSku: string
namaBarang: string
banyak: string
jumlah: string
}
export interface DocumentModel {
id: string
header: {
tanggal: string
no_po: string
no_so: string
no_do: string
}
shipment: {
kepada_yth: string;
order_untuk: string;
alamat: string;
plat_truk: string;
nama_driver: string;
nama_penerima: string;
}
items: DocumentItem[]
latitude: number | null
longitude: number | null
createdAt: Date
}
export class DocumentController {
public static async upload(req: AuthenticatedRequest, res: Response) {
if (!req.file) {
return res.status(400).json({
status: 'error',
message: 'No image file uploaded'
})
}
try {
const ocrData = OcrService.generateMockOcr()
const userId = req.user?.id
const latitude = req.body.latitude ? parseFloat(req.body.latitude.toString()) : null
const longitude = req.body.longitude ? parseFloat(req.body.longitude.toString()) : null
// Save to database using transaction
const savedDoc = await prisma.$transaction(async (tx) => {
const doc = await tx.document.create({
data: {
userId: userId || null,
filePath: req.file!.path,
status: 'success',
tanggal: ocrData.header.tanggal,
noPo: ocrData.header.no_po,
noSo: ocrData.header.no_so,
noDo: ocrData.header.no_do,
kepadaYth: ocrData.shipment.kepada_yth,
orderUntuk: ocrData.shipment.order_untuk,
alamat: ocrData.shipment.alamat,
platTruk: ocrData.shipment.plat_truk,
namaDriver: ocrData.shipment.nama_driver,
latitude: latitude,
longitude: longitude,
items: {
create: ocrData.items.map(item => ({
nomorSku: item.nomorSku,
namaBarang: item.namaBarang,
banyak: item.banyak,
jumlah: item.jumlah
}))
}
},
include: {
items: true
}
})
return doc
})
// Map DB structure to exact response format needed by Flutter
const mappedData = {
id: savedDoc.id,
header: {
tanggal: savedDoc.tanggal,
no_po: savedDoc.noPo,
no_so: savedDoc.noSo,
no_do: savedDoc.noDo
},
shipment: {
kepada_yth: savedDoc.kepadaYth,
order_untuk: savedDoc.orderUntuk,
alamat: savedDoc.alamat,
plat_truk: savedDoc.platTruk,
nama_driver: savedDoc.namaDriver,
nama_penerima: savedDoc.namaPenerima
},
items: savedDoc.items.map(item => ({
nomor_sku: item.nomorSku,
nama_barang: item.namaBarang,
banyak: item.banyak,
jumlah: item.jumlah
})),
latitude: savedDoc.latitude,
longitude: savedDoc.longitude
}
return res.status(201).json({
status: 'success',
message: 'Document processed successfully',
data: mappedData
})
} catch (error) {
console.error('Upload processing error:', error)
return res.status(500).json({
status: 'error',
message: 'Failed to process document image'
})
}
}
public static async list(req: AuthenticatedRequest, res: Response) {
const userId = req.user?.id
try {
const documents = await prisma.document.findMany({
where: userId ? { userId } : {},
include: { items: true },
orderBy: { createdAt: 'desc' }
})
const mappedList = documents.map(doc => ({
id: doc.id,
filePath: doc.filePath,
createdAt: doc.createdAt,
header: {
tanggal: doc.tanggal,
no_po: doc.noPo,
no_so: doc.noSo,
no_do: doc.noDo
},
shipment: {
kepada_yth: doc.kepadaYth,
order_untuk: doc.orderUntuk,
alamat: doc.alamat,
plat_truk: doc.platTruk,
nama_driver: doc.namaDriver,
nama_penerima: doc.namaPenerima
},
items: doc.items.map(item => ({
nomor_sku: item.nomorSku,
nama_barang: item.namaBarang,
banyak: item.banyak,
jumlah: item.jumlah
})),
latitude: doc.latitude,
longitude: doc.longitude
}))
return res.status(200).json({
status: 'success',
data: mappedList
})
} catch (error) {
return res.status(500).json({
status: 'error',
message: 'Failed to retrieve documents'
})
}
}
public static async update(req: AuthenticatedRequest, res: Response) {
const { id } = req.params
const {
tanggal,
noPo,
noSo,
noDo,
kepadaYth,
orderUntuk,
alamat,
platTruk,
namaDriver,
namaPenerima,
latitude,
longitude,
items
} = req.body
const userId = req.user?.id
try {
const existingDoc = await prisma.document.findUnique({
where: { id }
})
if (!existingDoc || (existingDoc.userId && existingDoc.userId !== userId)) {
return res.status(404).json({
status: 'error',
message: 'Document not found'
})
}
const updatedDoc = await prisma.$transaction(async (tx) => {
await tx.documentItem.deleteMany({
where: { documentId: id }
})
const doc = await tx.document.update({
where: { id },
data: {
tanggal,
noPo,
noSo,
noDo,
kepadaYth,
orderUntuk,
alamat,
platTruk,
namaDriver,
namaPenerima,
latitude: latitude !== undefined ? (latitude ? parseFloat(latitude.toString()) : null) : undefined,
longitude: longitude !== undefined ? (longitude ? parseFloat(longitude.toString()) : null) : undefined,
items: {
create: items.map((item: any) => ({
nomorSku: item.nomor_sku || item.nomorSku,
namaBarang: item.nama_barang || item.namaBarang,
banyak: item.banyak,
jumlah: item.jumlah
}))
}
},
include: {
items: true
}
})
return doc
})
const mappedData = {
id: updatedDoc.id,
header: {
tanggal: updatedDoc.tanggal,
no_po: updatedDoc.noPo,
no_so: updatedDoc.noSo,
no_do: updatedDoc.noDo
},
shipment: {
kepada_yth: updatedDoc.kepadaYth,
order_untuk: updatedDoc.orderUntuk,
alamat: updatedDoc.alamat,
plat_truk: updatedDoc.platTruk,
nama_driver: updatedDoc.namaDriver,
nama_penerima: updatedDoc.namaPenerima
},
items: updatedDoc.items.map(item => ({
nomor_sku: item.nomorSku,
nama_barang: item.namaBarang,
banyak: item.banyak,
jumlah: item.jumlah
})),
latitude: updatedDoc.latitude,
longitude: updatedDoc.longitude
}
return res.status(200).json({
status: 'success',
message: 'Document updated successfully',
data: mappedData
})
} catch (error) {
console.error('Update document error:', error)
return res.status(500).json({
status: 'error',
message: 'Failed to update document'
})
}
}
}
@@ -1,53 +0,0 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { config } from '../config'
import fs from 'fs'
export interface AuthenticatedRequest extends Request {
user?: {
id: string
username: string
}
}
export const authenticateToken = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1]
const cleanUpFile = () => {
if (req.file && fs.existsSync(req.file.path)) {
try {
fs.unlinkSync(req.file.path)
} catch (err) {
console.error('Failed to clean up file:', err)
}
}
}
if (!token) {
cleanUpFile()
return res.status(401).json({
status: 'error',
message: 'Access token is required'
})
}
try {
const decoded = jwt.verify(token, config.JWT_SECRET) as {
id: string
username: string
}
req.user = decoded
next()
} catch (error) {
cleanUpFile()
return res.status(403).json({
status: 'error',
message: 'Invalid or expired token'
})
}
}
@@ -1,87 +0,0 @@
import { DocumentModel } from '../controllers/document.controller'
interface MockSku {
sku: string
name: string
}
const COMMON_SKUS: MockSku[] = [
{ sku: '12010112', name: 'FIESTA CHICKEN NUGGET 400 GR/PAC' },
{ sku: '13010513', name: 'CHAMP CHICK SSG 375 GR' },
{ sku: '13030502', name: 'CHAMP CHICK MEATBALL 500 GR' },
{ sku: '12080101', name: 'FIESTA SPICY CHICK 400 GR/PAC' },
{ sku: '11640053', name: 'PAHA UTUH (1 KG)(*)' },
{ sku: '11690053', name: 'SBB (FILLET DADA )1 KG(*)' },
{ sku: '15010102', name: 'FIESTA SHOESTRING 1000 GR' },
{ sku: '12010511', name: 'CHAMP NUGGET AYAM 450 GR/PAC' },
{ sku: '13010102', name: 'FIESTA CHICK SSG 500 GR' },
{ sku: '11310024', name: 'AYAM SIZE A FROZEN (0.9-1)KG/PC(*)' },
{ sku: '11110059', name: 'CEKER BERKUKU FROZEN PACK 1 KG(*)' },
{ sku: '11720055', name: 'FULL WING FROZ PACK 1 KG(*)' },
{ sku: '12020102', name: 'FIESTA SPICY WING 400 GR/PAC' },
{ sku: '13010112', name: 'FIESTA CHEESE SSG 300 GR' }
]
const CUSTOMERS = [
{
name: 'PT.PRIMAFOOD INTERNATIONAL DKI AREA',
address: 'JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430'
},
{
name: 'PT.PRIMAFOOD INTERNATIONAL WEST JAVA',
address: 'JL. RAYA MELEGONG NO. 45, BOGOR SELATAN, BOGOR, 16130'
},
{
name: 'PT.PRIMAFOOD INTERNATIONAL TANGERANG',
address: 'JL. INDUSTRI JAYA NO. 8, CIKUPA, TANGERANG, 15710'
}
]
const TRUCK_PLATES = ['B 9310 BXT', 'F 1244 AA', 'D 8203 YZ', 'B 4402 SDU', 'F 9128 CC']
const DRIVER_NAMES = ['Supriatna', 'Budi Santoso', 'Asep Saepudin', 'Dadang', 'Iwan']
export class OcrService {
public static generateMockOcr(): Omit<DocumentModel, 'id' | 'createdAt'> {
const customer = CUSTOMERS[Math.floor(Math.random() * CUSTOMERS.length)]
const platTruk = TRUCK_PLATES[Math.floor(Math.random() * TRUCK_PLATES.length)]
// Generate dates
const dateOptions: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' }
const dateStr = new Date().toLocaleDateString('id-ID', dateOptions)
// Select random number of items (1 to 4)
const itemsCount = Math.floor(Math.random() * 4) + 1
const selectedSkus = [...COMMON_SKUS].sort(() => 0.5 - Math.random()).slice(0, itemsCount)
const items = selectedSkus.map(skuInfo => {
const boxQty = Math.floor(Math.random() * 5) + 1
const packMultiplier = skuInfo.name.includes('375') || skuInfo.name.includes('Sausage') ? 15 : 12
return {
nomorSku: skuInfo.sku,
namaBarang: skuInfo.name,
banyak: `${boxQty} BOX`,
jumlah: `${boxQty * packMultiplier} PAC`
}
})
const namaDriver = DRIVER_NAMES[Math.floor(Math.random() * DRIVER_NAMES.length)]
return {
header: {
tanggal: dateStr,
no_po: `PO/26/0000${Math.floor(100000 + Math.random() * 900000)}`,
no_so: `${Math.floor(1000000000 + Math.random() * 9000000000)}`,
no_do: `${Math.floor(1000000000 + Math.random() * 9000000000)}`
},
shipment: {
kepada_yth: customer.name,
order_untuk: 'PRIMA FRESH MART',
alamat: customer.address,
plat_truk: platTruk,
nama_driver: namaDriver,
nama_penerima: ''
},
items
}
}
}
@@ -1,107 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import request from 'supertest'
import { app } from '../../src/app'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
describe('Auth Integration Tests', () => {
const testUsername = `user_${Date.now()}`
const testPassword = 'SecurePassword123'
beforeAll(async () => {
// Clear any potential conflicts
await prisma.user.deleteMany({
where: { username: { startsWith: 'user_' } }
})
})
afterAll(async () => {
await prisma.user.deleteMany({
where: { username: { startsWith: 'user_' } }
})
await prisma.$disconnect()
})
describe('POST /api/v1/auth/register', () => {
it('should register a new user successfully', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({
username: testUsername,
password: testPassword
})
expect(res.status).toBe(201)
expect(res.body).toHaveProperty('status', 'success')
expect(res.body).toHaveProperty('message')
expect(res.body.data).toHaveProperty('id')
expect(res.body.data).toHaveProperty('username', testUsername)
expect(res.body.data).not.toHaveProperty('passwordHash')
})
it('should fail registration if username already exists', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({
username: testUsername,
password: testPassword
})
expect(res.status).toBe(400)
expect(res.body.message).toMatch(/exists/i)
})
it('should fail registration with empty fields', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({
username: '',
password: ''
})
expect(res.status).toBe(400)
expect(res.body.message).toMatch(/required/i)
})
})
describe('POST /api/v1/auth/login', () => {
it('should login successfully with correct credentials', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({
username: testUsername,
password: testPassword
})
expect(res.status).toBe(200)
expect(res.body).toHaveProperty('status', 'success')
expect(res.body.data).toHaveProperty('token')
expect(res.body.data.user).toHaveProperty('username', testUsername)
})
it('should fail login with incorrect password', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({
username: testUsername,
password: 'WrongPassword'
})
expect(res.status).toBe(401)
expect(res.body.message).toMatch(/invalid/i)
})
it('should fail login for non-existent user', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({
username: 'doesnotexist',
password: 'SomePassword'
})
expect(res.status).toBe(401)
expect(res.body.message).toMatch(/invalid/i)
})
})
})
@@ -1,176 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import request from 'supertest'
import { app } from '../../src/app'
import { PrismaClient } from '@prisma/client'
import path from 'path'
import fs from 'fs'
const prisma = new PrismaClient()
describe('Document Integration Tests', () => {
let authToken: string
let userId: string
const testUsername = `doc_user_${Date.now()}`
const dummyFilePath = path.join(__dirname, 'dummy_test_image.png')
beforeAll(async () => {
// Clear old data
await prisma.documentItem.deleteMany({})
await prisma.document.deleteMany({})
await prisma.user.deleteMany({
where: { username: { startsWith: 'doc_user_' } }
})
// Create a dummy image file for testing
fs.writeFileSync(dummyFilePath, 'dummy_png_content_data_here')
// Create a user and get auth token
const regRes = await request(app)
.post('/api/v1/auth/register')
.send({
username: testUsername,
password: 'Password123'
})
userId = regRes.body.data.id
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({
username: testUsername,
password: 'Password123'
})
authToken = loginRes.body.data.token
})
afterAll(async () => {
// Clean up dummy file
if (fs.existsSync(dummyFilePath)) {
fs.unlinkSync(dummyFilePath)
}
// Clean up uploaded files in test environment if any
const uploadsDir = path.join(__dirname, '../../uploads')
if (fs.existsSync(uploadsDir)) {
const files = fs.readdirSync(uploadsDir)
for (const file of files) {
if (file.startsWith('test-')) {
fs.unlinkSync(path.join(uploadsDir, file))
}
}
}
await prisma.documentItem.deleteMany({})
await prisma.document.deleteMany({})
await prisma.user.deleteMany({
where: { username: { startsWith: 'doc_user_' } }
})
await prisma.$disconnect()
})
describe('POST /api/v1/documents/upload', () => {
it('should fail upload without authentication', async () => {
const res = await request(app)
.post('/api/v1/documents/upload')
.attach('image', dummyFilePath)
expect(res.status).toBe(401)
expect(res.body.message).toMatch(/token/i)
})
it('should upload document and return structured JSON with coordinates', async () => {
const res = await request(app)
.post('/api/v1/documents/upload')
.set('Authorization', `Bearer ${authToken}`)
.field('latitude', '-6.2088')
.field('longitude', '106.8456')
.attach('image', dummyFilePath)
expect(res.status).toBe(201)
expect(res.body).toHaveProperty('status', 'success')
expect(res.body.data).toHaveProperty('header')
expect(res.body.data.header).toHaveProperty('tanggal')
expect(res.body.data.header).toHaveProperty('no_po')
expect(res.body.data).toHaveProperty('shipment')
expect(res.body.data.shipment).toHaveProperty('kepada_yth')
expect(res.body.data).toHaveProperty('latitude', -6.2088)
expect(res.body.data).toHaveProperty('longitude', 106.8456)
expect(res.body.data).toHaveProperty('items')
expect(res.body.data.items.length).toBeGreaterThan(0)
const firstItem = res.body.data.items[0]
expect(firstItem).toHaveProperty('nomor_sku')
expect(firstItem).toHaveProperty('nama_barang')
expect(firstItem).toHaveProperty('banyak')
expect(firstItem).toHaveProperty('jumlah')
})
})
describe('GET /api/v1/documents', () => {
it('should fail listing without authentication', async () => {
const res = await request(app)
.get('/api/v1/documents')
expect(res.status).toBe(401)
})
it('should retrieve list of uploaded documents', async () => {
const res = await request(app)
.get('/api/v1/documents')
.set('Authorization', `Bearer ${authToken}`)
expect(res.status).toBe(200)
expect(res.body).toHaveProperty('status', 'success')
expect(Array.isArray(res.body.data)).toBe(true)
expect(res.body.data.length).toBeGreaterThan(0)
expect(res.body.data[0]).toHaveProperty('filePath')
expect(res.body.data[0]).toHaveProperty('items')
})
})
describe('PUT /api/v1/documents/:id', () => {
it('should update document successfully', async () => {
const uploadRes = await request(app)
.post('/api/v1/documents/upload')
.set('Authorization', `Bearer ${authToken}`)
.attach('image', dummyFilePath)
const docId = uploadRes.body.data.id
const updateRes = await request(app)
.put(`/api/v1/documents/${docId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
tanggal: '29 Juni 2026',
noPo: 'PO/26/0000999999',
noSo: '1122334455',
noDo: '5544332211',
kepadaYth: 'PT. PRIMAFOOD BEKASI',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'JL. BEKASI RAYA NO. 1',
platTruk: 'B 1234 XYZ',
namaDriver: 'Sopir Baru',
namaPenerima: 'Penerima Toko Baru',
latitude: -6.1234,
longitude: 106.5678,
items: [
{
nomor_sku: '12010112',
nama_barang: 'FIESTA CHICKEN NUGGET 400 GR/PAC',
banyak: '5 BOX',
jumlah: '60 PAC'
}
]
})
expect(updateRes.status).toBe(200)
expect(updateRes.body).toHaveProperty('status', 'success')
expect(updateRes.body.data).toHaveProperty('header')
expect(updateRes.body.data.header).toHaveProperty('no_po', 'PO/26/0000999999')
expect(updateRes.body.data.shipment).toHaveProperty('nama_driver', 'Sopir Baru')
expect(updateRes.body.data.shipment).toHaveProperty('nama_penerima', 'Penerima Toko Baru')
expect(updateRes.body.data).toHaveProperty('latitude', -6.1234)
expect(updateRes.body.data).toHaveProperty('longitude', 106.5678)
expect(updateRes.body.data.items[0]).toHaveProperty('nomor_sku', '12010112')
})
})
})
-17
View File
@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "es2022",
"module": "commonjs",
"lib": ["es2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules"]
}
@@ -1 +0,0 @@
dummy_png_content_data_here
@@ -1 +0,0 @@
dummy_png_content_data_here
@@ -1 +0,0 @@
dummy_png_content_data_here
Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

@@ -1 +0,0 @@
dummy_png_content_data_here
Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

@@ -1 +0,0 @@
dummy_png_content_data_here
@@ -1 +0,0 @@
dummy_png_content_data_here
Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 KiB

Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more