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:
1 parent
3df9f6ec5d
commit
e60ab63154
129 files changed
+8520
-6684
No files matched your search
Generated
+170
-1
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 ---
|
||||
|
||||
Reference in new issue
Block a user