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

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

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

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

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

No files matched your search

+170 -1
View File
@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@gradio/client": "^2.2.1",
"bcryptjs": "^3.0.3",
"jsonwebtoken": "^9.0.3",
"next": "16.2.6",
"pg": "^8.21.0",
"puppeteer-core": "^25.1.0",
@@ -17,6 +19,8 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -1584,6 +1588,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1605,6 +1616,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -2568,6 +2597,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -2626,6 +2664,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -2988,6 +3032,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.364",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
@@ -4667,6 +4720,40 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4683,6 +4770,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5004,6 +5112,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5011,6 +5155,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5120,7 +5270,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5941,6 +6090,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+4
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@gradio/client": "^2.2.1",
"bcryptjs": "^3.0.3",
"jsonwebtoken": "^9.0.3",
"next": "16.2.6",
"pg": "^8.21.0",
"puppeteer-core": "^25.1.0",
@@ -18,6 +20,8 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
import os
import re
import time
import pickle
import numpy as np
import torch
from PIL import Image
from torchvision import transforms
from pathlib import Path
# Setup directories
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DATASET_DIR = SCRIPT_DIR / "foto-kemasan-v2"
DEFAULT_MODELS_DIR = SCRIPT_DIR / "models"
DEFAULT_INDEX_PATH = DEFAULT_MODELS_DIR / "dinov2_index.pkl"
# Allowed image extensions
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".bmp")
# DINOv2 Image preprocessing
DINOV2_TRANSFORMS = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
def get_embedding(dinov2_model, image: Image.Image, device):
if image.mode != "RGB":
image = image.convert("RGB")
tensor = DINOV2_TRANSFORMS(image).unsqueeze(0).to(device)
with torch.no_grad():
embedding = dinov2_model(tensor)
# L2 normalization for dot product similarity
embedding = embedding / embedding.norm(dim=-1, keepdim=True)
return embedding.squeeze(0).cpu().numpy()
def run_indexing(src_dir=DEFAULT_DATASET_DIR, out_path=DEFAULT_INDEX_PATH):
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
src_path = Path(src_dir).resolve()
out_file_path = Path(out_path).resolve()
if not src_path.is_dir():
print(f"Error: Source dataset directory not found: {src_path}")
return False
out_file_path.parent.mkdir(parents=True, exist_ok=True)
# Load DINOv2 Model from Torch Hub
print("Loading DINOv2 model (dinov2_vits14)...")
t0 = time.perf_counter()
dinov2_model = torch.hub.load("facebookresearch/dinov2", "dinov2_vits14").to(device)
dinov2_model.eval()
print(f"DINOv2 loaded in {time.perf_counter() - t0:.2f}s")
# Scan dataset directory
class_dirs = [d for d in src_path.iterdir() if d.is_dir()]
class_dirs.sort()
embeddings_list = []
metadata_list = []
total_images = 0
indexed_images = 0
for c_dir in class_dirs:
class_name = c_dir.name
images = sorted(
[f for f in c_dir.iterdir() if f.suffix.lower() in IMAGE_EXTS],
key=lambda p: p.name
)
if not images:
continue
print(f"Processing class: {class_name} ({len(images)} images)")
total_images += len(images)
for img_file in images:
try:
# Load image
image = Image.open(img_file).convert("RGB")
# Extract DINOv2 embedding (using whole image as reference photo)
embedding = get_embedding(dinov2_model, image, device)
embeddings_list.append(embedding)
metadata_list.append({
"class_name": class_name,
"image_path": str(img_file.relative_to(src_path.parent)),
"file_name": img_file.name
})
indexed_images += 1
except Exception as e:
print(f" [Error] Failed to process {img_file.name}: {e}")
# Save the index
if embeddings_list:
embeddings_arr = np.vstack(embeddings_list)
index_data = {
"embeddings": embeddings_arr,
"metadata": metadata_list
}
with open(out_file_path, "wb") as f:
pickle.dump(index_data, f)
print(f"\nSuccess! Indexed {indexed_images}/{total_images} images.")
print(f"DINOv2 Vector Index saved to: {out_file_path}")
return True
else:
print("\n[Warning] No images were successfully indexed.")
return False
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Build DINOv2 image vector index for PFM products")
parser.add_argument("--src-dir", default=str(DEFAULT_DATASET_DIR), help="Source directory of classes")
parser.add_argument("--output", default=str(DEFAULT_INDEX_PATH), help="Output pickle index path")
args = parser.parse_args()
run_indexing(src_dir=args.src_dir, out_path=args.output)
+27 -3
View File
@@ -221,13 +221,35 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe
}
}
async function fetchParse(baseUrl: string, filename: string): Promise<any> {
// Store name/address are assigned per-account now, not OCR-detected (see
// v1/documents/upload/route.ts + /api/parse). To score store/alamat fields
// the same way production behaves, simulate "the account assigned to this
// image's ground-truth store uploaded it" by resolving gt.store -> kode_toko
// and passing that through, same as a real account's JWT would.
async function fetchStoreNameToKodeMap(): Promise<Map<string, string>> {
const { Pool } = await import("pg");
const pool = new Pool({
host: process.env.PGHOST || "localhost",
port: parseInt(process.env.PGPORT || "5432", 10),
user: process.env.PGUSER || "postgres",
password: process.env.PGPASSWORD || "postgres",
database: process.env.PGDATABASE || "dopfm",
});
try {
const res = await pool.query("SELECT nama_toko, kode_toko FROM store_master");
return new Map(res.rows.map((r: any) => [r.nama_toko, r.kode_toko]));
} finally {
await pool.end();
}
}
async function fetchParse(baseUrl: string, filename: string, kodeToko?: string): Promise<any> {
const res = await fetchWithTimeout(
`${baseUrl}/api/parse`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
body: JSON.stringify({ filename, kodeToko }),
},
FETCH_TIMEOUT_MS
);
@@ -540,6 +562,7 @@ async function main() {
const gtMap = loadGroundTruth();
const testImages = listTestImages();
const storeNameToKode = await fetchStoreNameToKodeMap();
if (testImages.length === 0) {
console.error(`No test images found in ${TEST_IMAGES_DIR}`);
@@ -566,7 +589,8 @@ async function main() {
if (!gt) continue;
process.stdout.write(`Parsing ${filename}... `);
try {
const parsed = await fetchParse(args.baseUrl, filename);
const kodeToko = storeNameToKode.get(gt.store);
const parsed = await fetchParse(args.baseUrl, filename, kodeToko);
const { checks, detail } = compareImage(gt, parsed);
allChecks.push(...checks);
detailsByFile[filename] = detail;
+8 -10
View File
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { Client } from "@gradio/client";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const maxDuration = 120; // Allow up to 120 seconds for slow model inference
@@ -21,7 +22,7 @@ export async function GET(req: NextRequest) {
`, [parseInt(runId)]);
if (runRes.rowCount === 0) {
return NextResponse.json({ success: false, error: "Run not found" }, { status: 404 });
return errorResponse(404, "Run not found");
}
return NextResponse.json({ success: true, run: runRes.rows[0] });
}
@@ -66,7 +67,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: true, runs: runsRes.rows });
} catch (error: any) {
console.error("Failed to fetch arena runs/stats:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -81,7 +82,7 @@ export async function POST(req: NextRequest) {
image = body.image;
if (!engine || !image) {
return NextResponse.json({ error: "Missing engine or image" }, { status: 400 });
return errorResponse(400, "Missing engine or image");
}
imageType = body.imageType || "do";
@@ -103,7 +104,7 @@ export async function POST(req: NextRequest) {
const filePath = path.join(process.cwd(), "public", cleanPath);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: `File not found on server: ${image}` }, { status: 404 });
return errorResponse(404, `File not found on server: ${image}`);
}
imageBuffer = fs.readFileSync(filePath);
base64Image = `data:image/jpeg;base64,${imageBuffer.toString("base64")}`;
@@ -117,7 +118,7 @@ export async function POST(req: NextRequest) {
base64Image = `data:image/jpeg;base64,${image}`;
imageBuffer = Buffer.from(image, "base64");
} else {
return NextResponse.json({ error: "Invalid image format" }, { status: 400 });
return errorResponse(400, "Invalid image format");
}
let outputText = "";
@@ -212,7 +213,7 @@ export async function POST(req: NextRequest) {
outputText = data[0] || "";
} else {
return NextResponse.json({ error: `Unknown engine: ${engine}` }, { status: 400 });
return errorResponse(400, `Unknown engine: ${engine}`);
}
const elapsedMs = Date.now() - startTime;
@@ -259,9 +260,6 @@ export async function POST(req: NextRequest) {
console.error("Failed to log failure to arena_runs:", dbErr);
}
return NextResponse.json({
success: false,
error: error.message || "Failed to process OCR request"
}, { status: 500 });
return errorResponse(500, error.message || "Failed to process OCR request");
}
}
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { query } from "../../../db";
import crypto from "crypto";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
const PUBLIC_DIR = path.join(process.cwd(), "public/do-pfm");
@@ -12,7 +13,7 @@ export async function POST(req: NextRequest) {
const { filename, image } = await req.json();
if (!filename || !image) {
return NextResponse.json({ error: "Filename and image base64 data are required" }, { status: 400 });
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
@@ -50,6 +51,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error cropping file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -14,7 +15,7 @@ export async function GET(
const docId = parseInt(id, 10);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400 });
return errorResponse(400, "Invalid document ID");
}
const res = await query(
@@ -23,7 +24,7 @@ export async function GET(
);
if (res.rowCount === 0 || !res.rows[0]) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
}
return NextResponse.json({
@@ -32,6 +33,6 @@ export async function GET(
});
} catch (error: any) {
console.error("Error fetching document logs:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -8,14 +9,14 @@ export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("file");
if (!filename) {
return NextResponse.json({ error: "File name is required" }, { status: 400 });
return errorResponse(400, "File name is required");
}
const safeFile = path.basename(filename);
const filePath = path.join(UPLOADS_DIR, safeFile);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
return errorResponse(404, "File not found");
}
// Determine content type based on extension
@@ -41,6 +42,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error serving file from uploads:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
+10 -12
View File
@@ -9,6 +9,7 @@ import {
getProcessName,
unloadOtherEngines
} from "../../../utils/docker";
import { errorResponse } from "@/utils/api-error";
export async function GET(req: NextRequest) {
try {
@@ -32,7 +33,7 @@ export async function GET(req: NextRequest) {
});
} catch (error: any) {
console.error("Failed to fetch GPU/container status:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -44,7 +45,7 @@ export async function POST(req: NextRequest) {
if (action === "kill") {
const pid = parseInt(body.pid);
if (!pid || isNaN(pid)) {
return NextResponse.json({ success: false, error: "Invalid PID" }, { status: 400 });
return errorResponse(400, "Invalid PID");
}
// Check if process is protected (same rules as admin_panel.py)
@@ -52,17 +53,14 @@ export async function POST(req: NextRequest) {
const procNameLower = procName.toLowerCase();
const protectedKeywords = ["rustdesk", "xorg", "nginx", "systemd", "dockerd", "python3", "node"];
if (anyKeywordMatch(procNameLower, protectedKeywords)) {
return NextResponse.json({
success: false,
error: `Operation Denied: Process ${pid} (${procName || "system"}) is protected and cannot be killed.`
}, { status: 403 });
return errorResponse(403, `Operation Denied: Process ${pid} (${procName || "system"}) is protected and cannot be killed.`);
}
try {
process.kill(pid, 9);
return NextResponse.json({ success: true, message: `Successfully killed process ${pid}` });
} catch (err: any) {
return NextResponse.json({ success: false, error: `Failed to kill process: ${err.message}` }, { status: 500 });
return errorResponse(500, `Failed to kill process: ${err.message}`);
}
}
@@ -79,12 +77,12 @@ export async function POST(req: NextRequest) {
];
if (!validActions.includes(containerAction) || !validContainers.includes(containerName)) {
return NextResponse.json({ success: false, error: "Invalid container name or action" }, { status: 400 });
return errorResponse(400, "Invalid container name or action");
}
// Prevent self-stopping nextjs app accidentally through UI
if (containerName === "paddleocr-pfm-web-app" && containerAction === "stop") {
return NextResponse.json({ success: false, error: "Cannot stop the active web application container itself." }, { status: 400 });
return errorResponse(400, "Cannot stop the active web application container itself.");
}
await manageContainer(containerName, containerAction);
@@ -97,7 +95,7 @@ export async function POST(req: NextRequest) {
if (action === "saveSettings") {
const { cudaDevices } = body;
if (typeof cudaDevices !== "string" || cudaDevices.trim() === "") {
return NextResponse.json({ success: false, error: "Invalid GPU allocation settings" }, { status: 400 });
return errorResponse(400, "Invalid GPU allocation settings");
}
const cleanCuda = cudaDevices.trim();
@@ -146,10 +144,10 @@ export async function POST(req: NextRequest) {
});
}
return NextResponse.json({ success: false, error: "Invalid API action" }, { status: 400 });
return errorResponse(400, "Invalid API action");
} catch (error: any) {
console.error("GPU API POST error:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query, cleanupAndReindexItems } from "../../../db";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -79,7 +80,7 @@ export async function GET(req: NextRequest) {
});
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
}
// List view: return history list from DB
@@ -121,7 +122,7 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -129,7 +130,7 @@ export async function DELETE(req: NextRequest) {
try {
const { filename } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
@@ -163,11 +164,11 @@ export async function DELETE(req: NextRequest) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
} catch (error: unknown) {
console.error("Error in DELETE history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -175,7 +176,7 @@ export async function POST(req: NextRequest) {
try {
const { filename, remark } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
@@ -192,10 +193,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
} catch (error: unknown) {
console.error("Error in POST history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function GET() {
try {
@@ -17,6 +18,6 @@ export async function GET() {
return NextResponse.json({ files });
} catch (error: any) {
console.error("Error reading test-images directory:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
// Separate from DO manual_labels.json - product scan ground truth only
const LABELS_PATH = path.join(process.cwd(), "..", "sources", "product_manual_labels.json");
interface ProductScanLabel {
filename: string;
no_sku: string;
nama_item: string;
expiry_date: string;
top1_confidence: number | null;
notes: string;
saved_at: string;
}
function sanitizeFilename(filename: string): string {
let cleaned = filename.replace(/\\/g, "/");
while (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1);
}
return cleaned.replace(/\.\.\//g, "");
}
function normalizeDateString(dateStr: string): string {
if (!dateStr) return "";
const trimmed = dateStr.trim();
// Pattern 1: d Month YYYY (e.g. 7 June 2026)
const textPattern = /^(\d{1,2})\s+([a-zA-Z]+)\s+(\d{4})$/;
const tm = trimmed.match(textPattern);
if (tm) {
const day = tm[1].padStart(2, "0");
const month = tm[2].charAt(0).toUpperCase() + tm[2].slice(1).toLowerCase();
const year = tm[3];
return `${day} ${month} ${year}`;
}
// Pattern 2: d/m/YYYY or d-m-YYYY or d.m.YYYY (e.g. 7/6/2026)
const digitPattern = /^(\d{1,2})([-./])(\d{1,2})\2(\d{2,4})$/;
const dm = trimmed.match(digitPattern);
if (dm) {
const day = dm[1].padStart(2, "0");
const month = dm[3].padStart(2, "0");
let year = dm[4];
if (year.length === 2) {
year = "20" + year;
}
return `${day}/${month}/${year}`;
}
return trimmed;
}
function readLabels(): ProductScanLabel[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
return raw.trim() ? JSON.parse(raw) : [];
}
function writeLabels(labels: ProductScanLabel[]) {
const dir = path.dirname(LABELS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(LABELS_PATH, JSON.stringify(labels, null, 2), "utf8");
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return errorResponse(400, "Filename parameter is required");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const existing = labels.find((l) => l.filename === safeFilename);
if (existing) {
return NextResponse.json(existing);
}
// Return empty default state if not found
return NextResponse.json({
filename: safeFilename,
no_sku: "",
nama_item: "",
expiry_date: "",
top1_confidence: null,
notes: "",
saved_at: ""
});
} catch (err: unknown) {
console.error("Error in GET manual-label-scan:", err);
const message = err instanceof Error ? err.message : "Failed to load product label";
return errorResponse(500, message);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { filename } = body;
if (!filename) {
return errorResponse(400, "Filename is required in request body");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const index = labels.findIndex((l) => l.filename === safeFilename);
const entry: ProductScanLabel = {
filename: safeFilename,
no_sku: body.no_sku || "",
nama_item: body.nama_item || "",
expiry_date: normalizeDateString(body.expiry_date || ""),
top1_confidence: typeof body.top1_confidence === "number" ? body.top1_confidence : null,
notes: body.notes || "",
saved_at: new Date().toISOString()
};
if (index >= 0) {
labels[index] = entry;
} else {
labels.push(entry);
}
writeLabels(labels);
return NextResponse.json({ success: true, filePath: LABELS_PATH, entry });
} catch (err: unknown) {
console.error("Error in POST manual-label-scan:", err);
const message = err instanceof Error ? err.message : "Failed to save product label";
return errorResponse(500, message);
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const LABELS_PATH = path.join(process.cwd(), "..", "sources", "manual_labels.json");
@@ -17,63 +18,83 @@ function writeLabels(labels: any[]) {
fs.writeFileSync(LABELS_PATH, JSON.stringify(labels, null, 2), "utf8");
}
const SCALAR_FIELDS = ["noPO", "noSO", "noDO", "tanggal", "customer", "store", "alamat", "plat"] as const;
// Fetches the latest automated parser result for a filename, in the same
// shape as a manual_labels.json entry, so it can be used as fill-in data.
async function fetchLatestParsed(safeFilename: string): Promise<Record<string, any> | null> {
try {
const docRes = await query(
"SELECT id, metadata FROM documents WHERE filename = $1",
[safeFilename]
);
if (!docRes.rowCount || docRes.rowCount === 0) return null;
const doc = docRes.rows[0];
const meta = doc.metadata || {};
const itemsRes = await query(
"SELECT kode_barang, nama_barang, banyak, jumlah FROM ocr_items WHERE document_id = $1 ORDER BY row_index",
[doc.id]
);
return {
noPO: meta.noPO || "",
noSO: meta.noSO || "",
noDO: meta.noDO || "",
tanggal: meta.tanggal || "",
customer: meta.customerInfo || "",
store: meta.orderUntuk || "",
alamat: meta.alamat || "",
plat: meta.platTruk || "",
items: itemsRes.rows.map(row => ({
kodeBarang: row.kode_barang || "",
namaBarang: row.nama_barang || "",
banyak: row.banyak || "",
jumlah: row.jumlah || ""
}))
};
} catch (dbErr) {
console.error("DB fallback failed inside manual-label GET:", dbErr);
return null;
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return NextResponse.json({ error: "Filename parameter is required" }, { status: 400 });
return errorResponse(400, "Filename parameter is required");
}
const safeFilename = path.basename(filename);
const labels = readLabels();
const existing = labels.find(l => l.filename === safeFilename);
const latest = await fetchLatestParsed(safeFilename);
if (existing) {
return NextResponse.json(existing);
// Never overwrite a field the user already corrected manually - only
// fill in whatever is still blank, using the latest AI/DB parse.
const merged = { ...existing, filename: safeFilename };
if (latest) {
for (const field of SCALAR_FIELDS) {
if (!merged[field]) merged[field] = latest[field];
}
if (!merged.items || merged.items.length === 0) {
merged.items = latest.items;
}
}
// aiPredicted is the raw AI value for every field, always included
// (even when a manual value already exists) so the UI can show what
// the AI actually predicted next to the current/manual value.
return NextResponse.json({ ...merged, aiPredicted: latest });
}
// Try fallback to automated parser results in database
try {
const docRes = await query(
"SELECT id, metadata FROM documents WHERE filename = $1",
[safeFilename]
);
if (docRes.rowCount && docRes.rowCount > 0) {
const doc = docRes.rows[0];
const docId = doc.id;
const meta = doc.metadata || {};
// Fetch ocr items
const itemsRes = await query(
"SELECT kode_barang, nama_barang, banyak, jumlah FROM ocr_items WHERE document_id = $1 ORDER BY row_index",
[docId]
);
const items = itemsRes.rows.map(row => ({
kodeBarang: row.kode_barang || "",
namaBarang: row.nama_barang || "",
banyak: row.banyak || "",
jumlah: row.jumlah || ""
}));
return NextResponse.json({
filename,
noPO: meta.noPO || "",
noSO: meta.noSO || "",
noDO: meta.noDO || "",
tanggal: meta.tanggal || "",
customer: meta.customerInfo || "",
store: meta.orderUntuk || "",
alamat: meta.alamat || "",
plat: meta.platTruk || "",
items
});
}
} catch (dbErr) {
console.error("DB fallback failed inside manual-label GET:", dbErr);
if (latest) {
return NextResponse.json({ filename, ...latest, aiPredicted: latest });
}
// Return empty default state if not found anywhere
@@ -87,11 +108,12 @@ export async function GET(req: NextRequest) {
store: "",
alamat: "",
plat: "",
items: []
items: [],
aiPredicted: null
});
} catch (err: any) {
console.error("Error in GET manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to load manual label" }, { status: 500 });
return errorResponse(500, err.message || "Failed to load manual label");
}
}
@@ -101,7 +123,7 @@ export async function POST(req: NextRequest) {
const { filename } = body;
if (!filename) {
return NextResponse.json({ error: "Filename is required in request body" }, { status: 400 });
return errorResponse(400, "Filename is required in request body");
}
const safeFilename = path.basename(filename);
@@ -120,6 +142,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, filePath: LABELS_PATH });
} catch (err: any) {
console.error("Error in POST manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to save manual label" }, { status: 500 });
return errorResponse(500, err.message || "Failed to save manual label");
}
}
+53 -32
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { query, withTransaction, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { errorResponse } from "@/utils/api-error";
// Bounds each pipeline call so a wedged GPU container fails fast into the existing
// graceful fallback path instead of hanging the request indefinitely.
@@ -42,9 +43,9 @@ function getStringSimilarity(s1: string, s2: string): number {
export async function POST(req: NextRequest) {
let safeFile = "";
try {
const { filename } = await req.json();
const { filename, kodeToko } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
safeFile = path.basename(filename);
@@ -59,7 +60,7 @@ export async function POST(req: NextRequest) {
if (!fs.existsSync(filePath)) {
filePath = path.join("/uploads", safeFile);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
return errorResponse(404, "File not found");
}
isUpload = true;
isSample = false;
@@ -137,7 +138,7 @@ export async function POST(req: NextRequest) {
} catch (dbErr) {
console.error("Failed to mark document as parsed on pipeline error:", dbErr);
}
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
data = await response.json();
@@ -388,8 +389,23 @@ export async function POST(req: NextRequest) {
}
docMetadata.items = checkedItems;
// Resolve store information using master database
const resolvedStore = await resolveStoreFromText(markdownText);
// Store name/address are assigned per-account, not OCR-detected: if the
// uploading account's store (kodeToko) was passed through, use it
// directly. Only fall back to OCR-based text matching when no account
// context is available (e.g. legacy/internal callers).
let resolvedStore: { orderUntuk: string; alamat: string } | null = null;
if (kodeToko) {
const storeRes = await query(
"SELECT nama_toko, alamat FROM store_master WHERE kode_toko = $1",
[kodeToko]
);
if (storeRes.rowCount && storeRes.rowCount > 0) {
resolvedStore = { orderUntuk: storeRes.rows[0].nama_toko, alamat: storeRes.rows[0].alamat };
}
}
if (!resolvedStore) {
resolvedStore = await resolveStoreFromText(markdownText);
}
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
(docMetadata as any).alamat = resolvedStore.alamat;
@@ -443,31 +459,36 @@ export async function POST(req: NextRequest) {
const docId = insertDocRes.rows[0].id;
// Delete existing items for this document to avoid unique constraints / stale data
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Delete-then-reinsert must be atomic: without a transaction, a failure partway
// through the insert loop leaves the document row already upserted above but
// only some (or none) of its items, since the delete has already committed
// independently.
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
for (let i = 0; i < docMetadata.items.length; i++) {
const item = docMetadata.items[i];
await query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
)
VALUES ($1, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
`, [
docId,
i,
(item as any).kodeBarangOriginal || item.kodeBarang,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
for (let i = 0; i < docMetadata.items.length; i++) {
const item = docMetadata.items[i];
await client.query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
)
VALUES ($1, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
`, [
docId,
i,
(item as any).kodeBarangOriginal || item.kodeBarang,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
});
// Return wrapped success response with items, flagged, remarks, and intermediate post-processing details
return NextResponse.json({
@@ -508,7 +529,7 @@ export async function POST(req: NextRequest) {
console.error("Failed to mark document as parsed on route catch:", dbErr);
}
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -44,6 +45,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error fetching produk PFM:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { query } from "../../../db";
import crypto from "crypto";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
const PUBLIC_DIR = path.join(process.cwd(), "public/do-pfm");
@@ -12,7 +13,7 @@ export async function POST(req: NextRequest) {
const { filename, image } = await req.json();
if (!filename || !image) {
return NextResponse.json({ error: "Filename and image base64 data are required" }, { status: 400 });
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
@@ -50,6 +51,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error rotating file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -37,7 +38,7 @@ export async function POST(req: NextRequest) {
try {
const { image_base64 } = await req.json();
if (!image_base64) {
return NextResponse.json({ error: "Image is required" }, { status: 400 });
return errorResponse(400, "Image is required");
}
// Call Python FastAPI server inside the container
@@ -54,7 +55,7 @@ export async function POST(req: NextRequest) {
if (!response.ok) {
const errText = await response.text();
return NextResponse.json({ error: `Classifier service error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
@@ -131,6 +132,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in scan-pfm API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -18,6 +19,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in SKUs API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const res = await query(
"SELECT kode_toko, nama_toko, alamat FROM store_master ORDER BY nama_toko"
);
const stores = res.rows.map(row => ({
kodeToko: row.kode_toko,
namaToko: row.nama_toko,
alamat: row.alamat
}));
return NextResponse.json({ stores });
} catch (error: unknown) {
console.error("Error in stores API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function POST(req: NextRequest) {
try {
@@ -8,7 +9,7 @@ export async function POST(req: NextRequest) {
const { page, rowIndex, action } = body;
if (!page || rowIndex === undefined || !action) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
return errorResponse(400, "Missing required fields");
}
const safeFile = path.basename(page);
@@ -16,14 +17,14 @@ export async function POST(req: NextRequest) {
// Get document ID
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [safeFile]);
if (!docRes.rowCount || docRes.rowCount === 0) {
return NextResponse.json({ error: "Document not found in database" }, { status: 404 });
return errorResponse(404, "Document not found in database");
}
const docId = docRes.rows[0].id;
if (action === "edit") {
const { field, value } = body;
if (!field || value === undefined) {
return NextResponse.json({ error: "Missing edit parameters" }, { status: 400 });
return errorResponse(400, "Missing edit parameters");
}
// Map UI field names to database columns
@@ -35,7 +36,7 @@ export async function POST(req: NextRequest) {
} else if (field === "jumlah") {
colName = "jumlah";
} else {
return NextResponse.json({ error: "Invalid field name" }, { status: 400 });
return errorResponse(400, "Invalid field name");
}
await query(
@@ -49,7 +50,7 @@ export async function POST(req: NextRequest) {
} else if (action === "flag") {
const { isFlagged, remark } = body;
if (isFlagged === undefined || remark === undefined) {
return NextResponse.json({ error: "Missing flag parameters" }, { status: 400 });
return errorResponse(400, "Missing flag parameters");
}
await query(
@@ -61,11 +62,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
return errorResponse(400, "Invalid action");
}
} catch (error: unknown) {
console.error("Error in update-row API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { startActiveLog, getActiveLog, clearActiveLog } from "../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -19,7 +20,7 @@ export async function POST(req: NextRequest) {
const file = formData.get("file") as Blob | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
return errorResponse(400, "No file uploaded");
}
const originalName = file instanceof File ? file.name : "document.jpg";
@@ -67,7 +68,7 @@ export async function POST(req: NextRequest) {
if (!response.ok) {
clearActiveLog(filename);
const errText = await response.text();
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
let data = await response.json();
@@ -208,7 +209,7 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in upload API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,4 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { errorResponse } from "@/utils/api-error";
import { signAccountToken } from "@/utils/auth";
import { query } from "../../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -15,24 +19,37 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const { username, password } = body;
// Simple authentication logic for demo
if (username === "admin" && password === "password") {
if (!username || !password) {
return errorResponse(401, "Invalid username or password", { headers: corsHeaders });
}
// Each account is assigned exactly one store (kode_toko) - the token
// carries that assignment so store name/address never need OCR
// detection later; whichever account uploads, its own store is used.
const accountRes = await query(
"SELECT id, username, kode_toko, password FROM accounts WHERE username = $1",
[username]
);
if (accountRes.rowCount && accountRes.rowCount > 0 && bcrypt.compareSync(password, accountRes.rows[0].password)) {
const account = accountRes.rows[0];
const token = signAccountToken({
accountId: account.id,
username: account.username,
kodeToko: account.kode_toko
});
return NextResponse.json({
status: "success",
message: "Login successful",
data: {
token: "demo-auth-token-12345"
}
data: { token }
}, { headers: corsHeaders });
}
return NextResponse.json({
status: "error",
message: "Invalid username or password"
}, { status: 401, headers: corsHeaders });
return errorResponse(401, "Invalid username or password", { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in login API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -16,18 +18,23 @@ export async function PUT(
context: { params: Promise<{ id: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const params = await context.params;
const { id } = params;
const docId = parseInt(id);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400, headers: corsHeaders });
return errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Check if document exists
const checkRes = await query("SELECT id, filename, upload_time FROM documents WHERE id = $1", [docId]);
if (!checkRes.rowCount || checkRes.rowCount === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404, headers: corsHeaders });
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = checkRes.rows[0];
@@ -89,28 +96,32 @@ export async function PUT(
WHERE id = $1
`, [docId, latFloat, lngFloat, JSON.stringify(metadata)]);
// Delete existing ocr_items to recreate them
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Delete-then-reinsert must be atomic: without a transaction, a failure partway
// through the insert loop leaves the document with its header already updated
// above but only some (or none) of its items, since the delete has already
// committed independently.
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Insert new items
for (let i = 0; i < items.length; i++) {
const item = items[i];
const nomorSku = item.nomor_sku || item.nomorSku || "";
const namaBarang = item.nama_barang || item.namaBarang || "";
const banyak = item.banyak || "";
const jumlah = item.jumlah || "";
for (let i = 0; i < items.length; i++) {
const item = items[i];
const nomorSku = item.nomor_sku || item.nomorSku || "";
const namaBarang = item.nama_barang || item.namaBarang || "";
const banyak = item.banyak || "";
const jumlah = item.jumlah || "";
await query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
) VALUES ($1, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
`, [docId, i, nomorSku, namaBarang, banyak, jumlah]);
}
await client.query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
) VALUES ($1, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
`, [docId, i, nomorSku, namaBarang, banyak, jumlah]);
}
});
// Return the updated document mapping
const mappedData = {
@@ -150,6 +161,6 @@ export async function PUT(
} catch (error: unknown) {
console.error("Error in update document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -13,6 +15,11 @@ export async function OPTIONS() {
export async function GET(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
// Retrieve all custom-uploaded documents
const docRes = await query(`
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude
@@ -114,6 +121,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in list documents API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -3,6 +3,8 @@ import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const UPLOADS_DIR = "/uploads";
@@ -23,11 +25,19 @@ export async function POST(req: NextRequest) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
// The account uploading is assigned exactly one store (kode_toko) - pass
// it through to /api/parse so store name/address are set directly from
// that assignment instead of being OCR-detected from the document photo.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400, headers: corsHeaders });
return errorResponse(400, "No file uploaded", { headers: corsHeaders });
}
const originalName = file instanceof File ? file.name : "document.jpg";
@@ -112,7 +122,7 @@ export async function POST(req: NextRequest) {
await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: finalFilename }),
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko }),
signal: AbortSignal.timeout(210_000)
});
} catch (err) {
@@ -151,6 +161,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in upload API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { logVllmCallToAll } from "../../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -104,7 +105,7 @@ async function handleProxy(req: NextRequest) {
} catch (error) {
console.error("[vllm-proxy] Error forwarding request:", error);
return NextResponse.json({ error: "Failed to proxy request to vLLM server" }, { status: 500 });
return errorResponse(500, "Failed to proxy request to vLLM server");
}
}
+130 -13
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { getErrorMessage } from "@/utils/client-error";
interface ItemRow {
kodeBarang: string;
@@ -22,9 +23,52 @@ interface FormData {
items: ItemRow[];
}
interface Store {
kodeToko: string;
namaToko: string;
alamat: string;
}
interface Sku {
no_sku: string;
nama_item: string;
}
// Raw AI/DB parse for the current file, shown alongside each field as a
// reference note so the user can see what the AI predicted vs the current
// (possibly manually corrected) value. Same shape as FormData minus filename.
interface AiPredicted {
noPO: string;
noSO: string;
noDO: string;
tanggal: string;
customer: string;
store: string;
alamat: string;
plat: string;
items: ItemRow[];
}
// Small caption under a field showing what the AI predicted for it, so the
// user reviewing/correcting ground truth can see both values at a glance.
// Renders nothing if the file was never parsed (aiValue is undefined).
function AiNote({ current, aiValue }: { current: string; aiValue: string | undefined }) {
if (aiValue === undefined) return null;
const differs = (current || "").trim() !== (aiValue || "").trim();
return (
<span className={`text-[11px] leading-tight ${differs ? "text-amber-500" : "text-slate-600"}`}>
AI: {aiValue || "(tidak terdeteksi)"}
{differs && current ? " · beda dari manual" : ""}
</span>
);
}
export default function ManualLabelPage() {
const [files, setFiles] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState<number>(-1);
const [stores, setStores] = useState<Store[]>([]);
const [skus, setSkus] = useState<Sku[]>([]);
const [aiPredicted, setAiPredicted] = useState<AiPredicted | null>(null);
const [formData, setFormData] = useState<FormData>({
filename: "",
noPO: "",
@@ -66,6 +110,38 @@ export default function ManualLabelPage() {
fetchFiles();
}, []);
// Fetch store master list on mount, to power the store dropdown + address autofill
useEffect(() => {
const fetchStores = async () => {
try {
const res = await fetch("/api/stores");
if (res.ok) {
const data = await res.json();
setStores(data.stores || []);
}
} catch (err) {
console.error("Error loading store list:", err);
}
};
fetchStores();
}, []);
// Fetch SKU master list on mount, to power the item-name autofill by SKU
useEffect(() => {
const fetchSkus = async () => {
try {
const res = await fetch("/api/skus");
if (res.ok) {
const data = await res.json();
setSkus(data.skus || []);
}
} catch (err) {
console.error("Error loading SKU list:", err);
}
};
fetchSkus();
}, []);
// Fetch existing manual label when currentIndex changes
useEffect(() => {
if (currentIndex < 0 || currentIndex >= files.length) return;
@@ -97,6 +173,7 @@ export default function ManualLabelPage() {
plat: data.plat || "",
items: itemsList
});
setAiPredicted(data.aiPredicted || null);
}
} catch (err) {
console.error("Error fetching label data:", err);
@@ -132,13 +209,32 @@ export default function ManualLabelPage() {
}));
};
// When the store name matches a known store_master entry, default the
// address to that store's address. The user can still edit it afterward -
// this only sets a starting point, it doesn't lock the field.
const handleStoreChange = (value: string) => {
const matched = stores.find(s => s.namaToko === value);
setFormData(prev => ({
...prev,
store: value,
alamat: matched ? matched.alamat : prev.alamat
}));
};
const handleItemChange = (index: number, field: keyof ItemRow, value: string) => {
setFormData(prev => {
const updatedItems = [...prev.items];
updatedItems[index] = {
...updatedItems[index],
[field]: value
};
const updatedRow = { ...updatedItems[index], [field]: value };
// When the SKU code matches a known sku_master entry, default the item
// name to that SKU's name. Same convention as the store->alamat
// autofill: it's a starting point, not locked - still editable after.
if (field === "kodeBarang") {
const matched = skus.find(s => s.no_sku === value);
if (matched) updatedRow.namaBarang = matched.nama_item;
}
updatedItems[index] = updatedRow;
return {
...prev,
items: updatedItems
@@ -189,12 +285,12 @@ export default function ManualLabelPage() {
showToast("Ground truth data saved successfully!");
return true;
} else {
const errText = await res.text();
showToast(`Error: ${errText}`, true);
const data = await res.json().catch(() => undefined);
showToast(`Error: ${getErrorMessage(null, data, `HTTP ${res.status}`)}`, true);
return false;
}
} catch (err: any) {
showToast(`Save error: ${err.message}`, true);
showToast(`Save error: ${getErrorMessage(err)}`, true);
return false;
}
};
@@ -308,6 +404,7 @@ export default function ManualLabelPage() {
placeholder="e.g. PO/26/0000241938"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noPO} aiValue={aiPredicted?.noPO} />
</div>
<div className="flex flex-col gap-1.5">
@@ -319,6 +416,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 1691972227"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noSO} aiValue={aiPredicted?.noSO} />
</div>
</div>
@@ -332,6 +430,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 1659990561"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noDO} aiValue={aiPredicted?.noDO} />
</div>
<div className="flex flex-col gap-1.5">
@@ -343,6 +442,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 30 June 2026"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.tanggal} aiValue={aiPredicted?.tanggal} />
</div>
</div>
@@ -356,17 +456,25 @@ export default function ManualLabelPage() {
placeholder="e.g. PT.PRIMAFOOD INTERNATIONAL"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.customer} aiValue={aiPredicted?.customer} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Store (Tujuan Kirim)</label>
<input
type="text"
list="storeOptions"
value={formData.store}
onChange={(e) => handleInputChange("store", e.target.value)}
placeholder="e.g. PX HEAD OFFICE ANCOL"
onChange={(e) => handleStoreChange(e.target.value)}
placeholder="Ketik untuk cari, atau pilih dari daftar..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<datalist id="storeOptions">
{stores.map(s => (
<option key={s.kodeToko} value={s.namaToko} />
))}
</datalist>
<AiNote current={formData.store} aiValue={aiPredicted?.store} />
</div>
</div>
@@ -379,6 +487,7 @@ export default function ManualLabelPage() {
placeholder="e.g. B 9256 JXR"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.plat} aiValue={aiPredicted?.plat} />
</div>
<div className="flex flex-col gap-1.5">
@@ -387,9 +496,10 @@ export default function ManualLabelPage() {
rows={2}
value={formData.alamat}
onChange={(e) => handleInputChange("alamat", e.target.value)}
placeholder="Complete address text..."
placeholder="Terisi otomatis saat memilih store, atau isi manual..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 resize-none"
/>
<AiNote current={formData.alamat} aiValue={aiPredicted?.alamat} />
</div>
</div>
</div>
@@ -412,8 +522,10 @@ export default function ManualLabelPage() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{formData.items.map((item, idx) => (
<tr key={idx} className="group hover:bg-slate-900/30">
{formData.items.map((item, idx) => {
const aiItem = aiPredicted?.items?.[idx];
return (
<tr key={idx} className="group hover:bg-slate-900/30 align-top">
<td className="py-2 px-1">
<input
type="text"
@@ -422,6 +534,7 @@ export default function ManualLabelPage() {
placeholder="SKU Code"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.kodeBarang} aiValue={aiItem?.kodeBarang} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -431,6 +544,7 @@ export default function ManualLabelPage() {
placeholder="Description"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.namaBarang} aiValue={aiItem?.namaBarang} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -440,6 +554,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 2 KRG"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.banyak} aiValue={aiItem?.banyak} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -449,6 +564,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 40 PC"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.jumlah} aiValue={aiItem?.jumlah} /></div>
</td>
<td className="py-2 px-1 text-center">
<button
@@ -459,7 +575,8 @@ export default function ManualLabelPage() {
</button>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
+13 -4
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { getErrorMessage } from "@/utils/client-error";
interface HistoryItem {
id: number;
@@ -32,6 +33,9 @@ export default function Home() {
if (res.ok) {
const data = await res.json();
setHistory(data.history || []);
} else {
const data = await res.json().catch(() => undefined);
console.error("Failed to fetch history:", getErrorMessage(null, data, `HTTP ${res.status}`));
}
} catch (err) {
console.error("Failed to fetch history:", err);
@@ -57,7 +61,8 @@ export default function Home() {
const detailsData = await detailsRes.json();
setSelectedDetails(detailsData);
} else {
throw new Error("Failed to load document details");
const data = await detailsRes.json().catch(() => undefined);
throw new Error(getErrorMessage(null, data, "Failed to load document details"));
}
// 2. Fetch processing logs
@@ -68,7 +73,7 @@ export default function Home() {
}
} catch (err: any) {
console.error("Error loading document:", err);
setErrorMsg(err.message || "Error loading document details");
setErrorMsg(getErrorMessage(err));
}
};
@@ -114,7 +119,7 @@ export default function Home() {
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to process image");
throw new Error(getErrorMessage(null, data, "Failed to process image"));
}
const data = await res.json();
@@ -137,7 +142,7 @@ export default function Home() {
}
} catch (err: any) {
console.error("Upload failed:", err);
setErrorMsg(err.message || "Failed to upload and process file.");
setErrorMsg(getErrorMessage(err, undefined, "Failed to upload and process file."));
} finally {
setUploading(false);
}
@@ -165,9 +170,13 @@ export default function Home() {
setSelectedLogs(null);
}
fetchHistory();
} else {
const data = await res.json().catch(() => undefined);
setErrorMsg(getErrorMessage(null, data, "Failed to delete document"));
}
} catch (err) {
console.error("Failed to delete document:", err);
setErrorMsg(getErrorMessage(err, undefined, "Failed to delete document"));
}
};
File diff suppressed because it is too large. Load diff
+20 -1
View File
@@ -1,4 +1,4 @@
import { Pool } from "pg";
import { Pool, PoolClient } from "pg";
import { initDb } from "./init";
const pool = new Pool({
@@ -35,6 +35,25 @@ export async function query(text: string, params?: unknown[]) {
return p.query(text, params);
}
/** Runs `fn` inside a BEGIN/COMMIT transaction on a single held connection, rolling back and rethrowing on any failure. */
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const p = await getPool();
const client = await p.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
export async function cleanupAndReindexItems(docId: number) {
// 1. Delete rows where kode_barang is blank/null or doesn't match an 8-digit number
await query(
+36
View File
@@ -1,6 +1,7 @@
import { Pool } from "pg";
import fs from "fs";
import path from "path";
import bcrypt from "bcryptjs";
import { parseDOMetadata } from "../utils/parser";
const UPLOADS_DIR = "/uploads";
@@ -103,6 +104,41 @@ export async function initDb(pool: Pool) {
);
`);
// Create accounts table - each account is assigned one store (kode_toko),
// so store name/address never need to be OCR-detected: whichever account
// uploads a document, that account's assigned store is used directly.
await pool.query(`
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
kode_toko VARCHAR(255) REFERENCES store_master(kode_toko),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
`);
// Seed the demo account, assigned to the head office store. Hashed here
// (not as a SQL literal) since bcrypt can't run inside plain SQL.
await pool.query(
`INSERT INTO accounts (username, password, kode_toko)
VALUES ('admin', $1, 'WH_JOFFICE')
ON CONFLICT (username) DO NOTHING;`,
[bcrypt.hashSync("password", 10)]
);
// Migrate any plaintext passwords (pre-existing accounts from before hashing
// was added) to bcrypt hashes. Idempotent: bcrypt hashes always start with
// "$2", so already-hashed rows are skipped on every re-run.
const plaintextAccounts = await pool.query(
"SELECT id, password FROM accounts WHERE password NOT LIKE '$2%'"
);
for (const account of plaintextAccounts.rows) {
await pool.query("UPDATE accounts SET password = $1 WHERE id = $2", [
bcrypt.hashSync(account.password, 10),
account.id
]);
}
// Create arena_runs table
await pool.query(`
CREATE TABLE IF NOT EXISTS arena_runs (
@@ -0,0 +1,47 @@
// Framework-agnostic HTTP status/error helpers shared by server routes and client components.
export const HTTP_STATUS_TEXT: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
413: "Payload Too Large",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
export function reasonPhraseForStatus(status: number): string {
return HTTP_STATUS_TEXT[status] ?? "Error";
}
export function codeForStatus(status: number): string {
const phrase = HTTP_STATUS_TEXT[status];
if (!phrase) return `HTTP_${status}`;
return phrase.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
}
export interface ApiErrorBody {
status: "error";
error: {
statusCode: number;
code: string;
message: string;
};
}
export function buildApiErrorBody(status: number, message: string, code?: string): ApiErrorBody {
return {
status: "error",
error: {
statusCode: status,
code: code ?? codeForStatus(status),
message,
},
};
}
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { buildApiErrorBody } from "@/lib/http-status";
export function errorResponse(
status: number,
message: string,
opts?: { code?: string; headers?: HeadersInit }
): NextResponse {
return NextResponse.json(buildApiErrorBody(status, message, opts?.code), {
status,
headers: opts?.headers,
});
}
+30
View File
@@ -0,0 +1,30 @@
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "dev-only-insecure-secret-change-me";
export interface AccountTokenPayload {
accountId: number;
username: string;
kodeToko: string | null;
}
export function signAccountToken(payload: AccountTokenPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: "30d" });
}
/** Returns the decoded payload, or null if the token is missing/invalid/expired. */
export function verifyAccountToken(token: string | null | undefined): AccountTokenPayload | null {
if (!token) return null;
try {
return jwt.verify(token, JWT_SECRET) as AccountTokenPayload;
} catch {
return null;
}
}
/** Extracts and verifies the Bearer token from a request's Authorization header. */
export function getAccountFromAuthHeader(authHeader: string | null): AccountTokenPayload | null {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice("Bearer ".length).trim();
return verifyAccountToken(token);
}
@@ -0,0 +1,20 @@
import { HTTP_STATUS_TEXT } from "@/lib/http-status";
/**
* Formats an error the same way across every client component: given the
* parsed JSON body of a failed fetch (if any) and/or the caught exception,
* produce a single "404 Not Found: message" style string mirroring the
* backend's { status: "error", error: { statusCode, code, message } } envelope.
*/
export function getErrorMessage(err: unknown, data?: unknown, fallback = "Unexpected error"): string {
const errorBody = data && typeof data === "object" ? (data as Record<string, unknown>).error : undefined;
if (errorBody && typeof errorBody === "object" && typeof (errorBody as Record<string, unknown>).message === "string") {
const body = errorBody as Record<string, unknown>;
const statusCode = body.statusCode;
const label = (typeof statusCode === "number" && HTTP_STATUS_TEXT[statusCode]) || body.code;
return typeof statusCode === "number" ? `${statusCode} ${label}: ${body.message}` : (body.message as string);
}
if (err instanceof Error) return err.message;
if (err != null) return String(err);
return fallback;
}
+20 -41
View File
@@ -161,7 +161,8 @@ function cleanDateValue(raw: string): string {
export function parseDOMetadata(markdown: string) {
const metadata = {
vendorInfo: "Not Found",
customerInfo: "Not Found",
// Always PT. PRIMAFOOD INTERNATIONAL for this customer - never parsed from OCR.
customerInfo: "PT. PRIMAFOOD INTERNATIONAL",
tanggal: "Not Found",
noSO: "Not Found",
noDO: "Not Found",
@@ -198,33 +199,6 @@ export function parseDOMetadata(markdown: string) {
if (vendorMatch) metadata.vendorInfo = vendorMatch[1].trim();
}
// Extract Customer Info (e.g., Kepada Yth : PT.PRIMAFOOD INTERNATIONAL)
const customerStop = /(?:no\.?\s*(?:so|do|po)|tanggal|date|#|\d{2}:\d{2}:\d{2})/i;
let customerStartIndex = lines.findIndex(line =>
/(?:Kepada Yth|Yth|Customer|Deliver To)\s*[:\-]/i.test(line) || /PT\.\s*PRIMAFOOD/i.test(line)
);
if (customerStartIndex === -1) {
// Fallback: search for a secondary PT. line
const ptIndices = lines.map((l, idx) => l.toUpperCase().includes("PT.") ? idx : -1).filter(idx => idx !== -1);
// Ensure the secondary PT line is not the vendor line
const secondaryIndices = ptIndices.filter(idx => idx !== vendorStartIndex);
if (secondaryIndices.length > 0) {
customerStartIndex = secondaryIndices[0];
}
}
if (customerStartIndex !== -1) {
const customerLines: string[] = [lines[customerStartIndex]];
for (let i = customerStartIndex + 1; i < Math.min(lines.length, customerStartIndex + 4); i++) {
if (customerStop.test(lines[i])) break;
customerLines.push(lines[i]);
}
metadata.customerInfo = customerLines.join("\n");
} else {
const customerMatch = cleanMarkdown.match(/(?:Kepada Yth|Yth|Customer|Deliver To)[ \t]*[:\-][ \t]*([^\n]+)/i) || cleanMarkdown.match(/(PT\.[ \t]*PRIMAFOOD[^\n]*)/i);
if (customerMatch) metadata.customerInfo = customerMatch[1].trim();
}
// Extract Tanggal (Date)
const tanggalMatch = cleanMarkdown.match(/Tanggal\s*[:\-.]?\s*([^\n]{6,100})/i) ||
cleanMarkdown.match(/(?:Date|D\.O\.\s*Date)\s*[:\-.]?\s*([^\n]{6,100})/i);
@@ -404,11 +378,20 @@ export function parseDOMetadata(markdown: string) {
}
}
if (globalTenDigits.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = globalTenDigits[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = globalTenDigits[1];
} else if (globalTenDigits.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = globalTenDigits[0];
// Exclude numbers already assigned to another field, so a still-missing SO
// can't silently be backfilled with the DO/PO value that was just extracted
// correctly (e.g. SO garbled/missing but DO is a clean 16xxxxxxxx number —
// without this filter, DO's value gets duplicated into SO).
const alreadyAssigned = [metadata.noSO, metadata.noDO, metadata.noPO].filter(
(v) => v && v !== "Not Found"
);
const freeTenDigits = globalTenDigits.filter((v) => !alreadyAssigned.includes(v));
if (freeTenDigits.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = freeTenDigits[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = freeTenDigits[1];
} else if (freeTenDigits.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = freeTenDigits[0];
}
// 3. Scan for PO number globally
@@ -437,7 +420,6 @@ export function parseDOMetadata(markdown: string) {
// Clean final values
metadata.vendorInfo = cleanFinalValue(metadata.vendorInfo, true);
metadata.customerInfo = cleanFinalValue(metadata.customerInfo, true);
metadata.tanggal = cleanDateValue(cleanFinalValue(metadata.tanggal));
metadata.noSO = cleanFinalValue(metadata.noSO);
metadata.noDO = cleanFinalValue(metadata.noDO);
@@ -729,9 +711,6 @@ export function parseDOMetadata(markdown: string) {
}
}
// Force customerInfo to always be PT.PRIMAFOOD INTERNATIONAL as requested
metadata.customerInfo = "PT.PRIMAFOOD INTERNATIONAL";
// Extract truck license plate
(metadata as any).platTruk = extractPlatTruk(cleanMarkdown);
@@ -847,12 +826,12 @@ export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata>
const currentFullYear = new Date().getFullYear();
const result = { ...meta };
// --- customerInfo / vendorInfo ---
// OCR typically drops the space after Indonesian business-entity prefixes ("PT.PRIMAFOOD"
// instead of "PT. PRIMAFOOD"). Normalize the standard prefixes to always have one space.
// --- vendorInfo ---
// OCR typically drops the space after Indonesian business-entity prefixes ("PT.CHAROEN"
// instead of "PT. CHAROEN"). Normalize the standard prefixes to always have one space.
// (customerInfo needs no such cleanup - it's a fixed constant, never parsed from OCR.)
const normalizeEntityPrefix = (v: string) =>
v && v !== "Not Found" ? v.replace(/\b(PT|CV|UD|PD|TB)\.(?=\S)/gi, (_, p) => `${p.toUpperCase()}. `) : v;
result.customerInfo = normalizeEntityPrefix(result.customerInfo);
result.vendorInfo = normalizeEntityPrefix(result.vendorInfo);
// --- tanggal ---