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>
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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.*
|
||||
@@ -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"
|
||||
@@ -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}}
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
[
|
||||
conti[
|
||||
{
|
||||
"noSku": "11110059",
|
||||
"namaItem": "CEKER BERKUKU FROZEN PACK 1 KG(*)",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
@@ -1,3 +0,0 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
JWT_SECRET="pfm_ocr_super_secret_key_12345"
|
||||
PORT=3000
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 359 KiB |
|
Before Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 364 KiB |
@@ -1 +0,0 @@
|
||||
dummy_png_content_data_here
|
||||
|
Before Width: | Height: | Size: 370 KiB |
@@ -1 +0,0 @@
|
||||
dummy_png_content_data_here
|
||||
@@ -1 +0,0 @@
|
||||
dummy_png_content_data_here
|
||||
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 854 KiB |
|
Before Width: | Height: | Size: 508 KiB |