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

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

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

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

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

No files matched your search

+8 -10
View File
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { Client } from "@gradio/client";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const maxDuration = 120; // Allow up to 120 seconds for slow model inference
@@ -21,7 +22,7 @@ export async function GET(req: NextRequest) {
`, [parseInt(runId)]);
if (runRes.rowCount === 0) {
return NextResponse.json({ success: false, error: "Run not found" }, { status: 404 });
return errorResponse(404, "Run not found");
}
return NextResponse.json({ success: true, run: runRes.rows[0] });
}
@@ -66,7 +67,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: true, runs: runsRes.rows });
} catch (error: any) {
console.error("Failed to fetch arena runs/stats:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -81,7 +82,7 @@ export async function POST(req: NextRequest) {
image = body.image;
if (!engine || !image) {
return NextResponse.json({ error: "Missing engine or image" }, { status: 400 });
return errorResponse(400, "Missing engine or image");
}
imageType = body.imageType || "do";
@@ -103,7 +104,7 @@ export async function POST(req: NextRequest) {
const filePath = path.join(process.cwd(), "public", cleanPath);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: `File not found on server: ${image}` }, { status: 404 });
return errorResponse(404, `File not found on server: ${image}`);
}
imageBuffer = fs.readFileSync(filePath);
base64Image = `data:image/jpeg;base64,${imageBuffer.toString("base64")}`;
@@ -117,7 +118,7 @@ export async function POST(req: NextRequest) {
base64Image = `data:image/jpeg;base64,${image}`;
imageBuffer = Buffer.from(image, "base64");
} else {
return NextResponse.json({ error: "Invalid image format" }, { status: 400 });
return errorResponse(400, "Invalid image format");
}
let outputText = "";
@@ -212,7 +213,7 @@ export async function POST(req: NextRequest) {
outputText = data[0] || "";
} else {
return NextResponse.json({ error: `Unknown engine: ${engine}` }, { status: 400 });
return errorResponse(400, `Unknown engine: ${engine}`);
}
const elapsedMs = Date.now() - startTime;
@@ -259,9 +260,6 @@ export async function POST(req: NextRequest) {
console.error("Failed to log failure to arena_runs:", dbErr);
}
return NextResponse.json({
success: false,
error: error.message || "Failed to process OCR request"
}, { status: 500 });
return errorResponse(500, error.message || "Failed to process OCR request");
}
}
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { query } from "../../../db";
import crypto from "crypto";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
const PUBLIC_DIR = path.join(process.cwd(), "public/do-pfm");
@@ -12,7 +13,7 @@ export async function POST(req: NextRequest) {
const { filename, image } = await req.json();
if (!filename || !image) {
return NextResponse.json({ error: "Filename and image base64 data are required" }, { status: 400 });
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
@@ -50,6 +51,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error cropping file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -14,7 +15,7 @@ export async function GET(
const docId = parseInt(id, 10);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400 });
return errorResponse(400, "Invalid document ID");
}
const res = await query(
@@ -23,7 +24,7 @@ export async function GET(
);
if (res.rowCount === 0 || !res.rows[0]) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
}
return NextResponse.json({
@@ -32,6 +33,6 @@ export async function GET(
});
} catch (error: any) {
console.error("Error fetching document logs:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -8,14 +9,14 @@ export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("file");
if (!filename) {
return NextResponse.json({ error: "File name is required" }, { status: 400 });
return errorResponse(400, "File name is required");
}
const safeFile = path.basename(filename);
const filePath = path.join(UPLOADS_DIR, safeFile);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
return errorResponse(404, "File not found");
}
// Determine content type based on extension
@@ -41,6 +42,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error serving file from uploads:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
+10 -12
View File
@@ -9,6 +9,7 @@ import {
getProcessName,
unloadOtherEngines
} from "../../../utils/docker";
import { errorResponse } from "@/utils/api-error";
export async function GET(req: NextRequest) {
try {
@@ -32,7 +33,7 @@ export async function GET(req: NextRequest) {
});
} catch (error: any) {
console.error("Failed to fetch GPU/container status:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -44,7 +45,7 @@ export async function POST(req: NextRequest) {
if (action === "kill") {
const pid = parseInt(body.pid);
if (!pid || isNaN(pid)) {
return NextResponse.json({ success: false, error: "Invalid PID" }, { status: 400 });
return errorResponse(400, "Invalid PID");
}
// Check if process is protected (same rules as admin_panel.py)
@@ -52,17 +53,14 @@ export async function POST(req: NextRequest) {
const procNameLower = procName.toLowerCase();
const protectedKeywords = ["rustdesk", "xorg", "nginx", "systemd", "dockerd", "python3", "node"];
if (anyKeywordMatch(procNameLower, protectedKeywords)) {
return NextResponse.json({
success: false,
error: `Operation Denied: Process ${pid} (${procName || "system"}) is protected and cannot be killed.`
}, { status: 403 });
return errorResponse(403, `Operation Denied: Process ${pid} (${procName || "system"}) is protected and cannot be killed.`);
}
try {
process.kill(pid, 9);
return NextResponse.json({ success: true, message: `Successfully killed process ${pid}` });
} catch (err: any) {
return NextResponse.json({ success: false, error: `Failed to kill process: ${err.message}` }, { status: 500 });
return errorResponse(500, `Failed to kill process: ${err.message}`);
}
}
@@ -79,12 +77,12 @@ export async function POST(req: NextRequest) {
];
if (!validActions.includes(containerAction) || !validContainers.includes(containerName)) {
return NextResponse.json({ success: false, error: "Invalid container name or action" }, { status: 400 });
return errorResponse(400, "Invalid container name or action");
}
// Prevent self-stopping nextjs app accidentally through UI
if (containerName === "paddleocr-pfm-web-app" && containerAction === "stop") {
return NextResponse.json({ success: false, error: "Cannot stop the active web application container itself." }, { status: 400 });
return errorResponse(400, "Cannot stop the active web application container itself.");
}
await manageContainer(containerName, containerAction);
@@ -97,7 +95,7 @@ export async function POST(req: NextRequest) {
if (action === "saveSettings") {
const { cudaDevices } = body;
if (typeof cudaDevices !== "string" || cudaDevices.trim() === "") {
return NextResponse.json({ success: false, error: "Invalid GPU allocation settings" }, { status: 400 });
return errorResponse(400, "Invalid GPU allocation settings");
}
const cleanCuda = cudaDevices.trim();
@@ -146,10 +144,10 @@ export async function POST(req: NextRequest) {
});
}
return NextResponse.json({ success: false, error: "Invalid API action" }, { status: 400 });
return errorResponse(400, "Invalid API action");
} catch (error: any) {
console.error("GPU API POST error:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query, cleanupAndReindexItems } from "../../../db";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -79,7 +80,7 @@ export async function GET(req: NextRequest) {
});
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
}
// List view: return history list from DB
@@ -121,7 +122,7 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -129,7 +130,7 @@ export async function DELETE(req: NextRequest) {
try {
const { filename } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
@@ -163,11 +164,11 @@ export async function DELETE(req: NextRequest) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
} catch (error: unknown) {
console.error("Error in DELETE history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -175,7 +176,7 @@ export async function POST(req: NextRequest) {
try {
const { filename, remark } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
@@ -192,10 +193,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
} catch (error: unknown) {
console.error("Error in POST history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function GET() {
try {
@@ -17,6 +18,6 @@ export async function GET() {
return NextResponse.json({ files });
} catch (error: any) {
console.error("Error reading test-images directory:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
// Separate from DO manual_labels.json - product scan ground truth only
const LABELS_PATH = path.join(process.cwd(), "..", "sources", "product_manual_labels.json");
interface ProductScanLabel {
filename: string;
no_sku: string;
nama_item: string;
expiry_date: string;
top1_confidence: number | null;
notes: string;
saved_at: string;
}
function sanitizeFilename(filename: string): string {
let cleaned = filename.replace(/\\/g, "/");
while (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1);
}
return cleaned.replace(/\.\.\//g, "");
}
function normalizeDateString(dateStr: string): string {
if (!dateStr) return "";
const trimmed = dateStr.trim();
// Pattern 1: d Month YYYY (e.g. 7 June 2026)
const textPattern = /^(\d{1,2})\s+([a-zA-Z]+)\s+(\d{4})$/;
const tm = trimmed.match(textPattern);
if (tm) {
const day = tm[1].padStart(2, "0");
const month = tm[2].charAt(0).toUpperCase() + tm[2].slice(1).toLowerCase();
const year = tm[3];
return `${day} ${month} ${year}`;
}
// Pattern 2: d/m/YYYY or d-m-YYYY or d.m.YYYY (e.g. 7/6/2026)
const digitPattern = /^(\d{1,2})([-./])(\d{1,2})\2(\d{2,4})$/;
const dm = trimmed.match(digitPattern);
if (dm) {
const day = dm[1].padStart(2, "0");
const month = dm[3].padStart(2, "0");
let year = dm[4];
if (year.length === 2) {
year = "20" + year;
}
return `${day}/${month}/${year}`;
}
return trimmed;
}
function readLabels(): ProductScanLabel[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
return raw.trim() ? JSON.parse(raw) : [];
}
function writeLabels(labels: ProductScanLabel[]) {
const dir = path.dirname(LABELS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(LABELS_PATH, JSON.stringify(labels, null, 2), "utf8");
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return errorResponse(400, "Filename parameter is required");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const existing = labels.find((l) => l.filename === safeFilename);
if (existing) {
return NextResponse.json(existing);
}
// Return empty default state if not found
return NextResponse.json({
filename: safeFilename,
no_sku: "",
nama_item: "",
expiry_date: "",
top1_confidence: null,
notes: "",
saved_at: ""
});
} catch (err: unknown) {
console.error("Error in GET manual-label-scan:", err);
const message = err instanceof Error ? err.message : "Failed to load product label";
return errorResponse(500, message);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { filename } = body;
if (!filename) {
return errorResponse(400, "Filename is required in request body");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const index = labels.findIndex((l) => l.filename === safeFilename);
const entry: ProductScanLabel = {
filename: safeFilename,
no_sku: body.no_sku || "",
nama_item: body.nama_item || "",
expiry_date: normalizeDateString(body.expiry_date || ""),
top1_confidence: typeof body.top1_confidence === "number" ? body.top1_confidence : null,
notes: body.notes || "",
saved_at: new Date().toISOString()
};
if (index >= 0) {
labels[index] = entry;
} else {
labels.push(entry);
}
writeLabels(labels);
return NextResponse.json({ success: true, filePath: LABELS_PATH, entry });
} catch (err: unknown) {
console.error("Error in POST manual-label-scan:", err);
const message = err instanceof Error ? err.message : "Failed to save product label";
return errorResponse(500, message);
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const LABELS_PATH = path.join(process.cwd(), "..", "sources", "manual_labels.json");
@@ -17,63 +18,83 @@ function writeLabels(labels: any[]) {
fs.writeFileSync(LABELS_PATH, JSON.stringify(labels, null, 2), "utf8");
}
const SCALAR_FIELDS = ["noPO", "noSO", "noDO", "tanggal", "customer", "store", "alamat", "plat"] as const;
// Fetches the latest automated parser result for a filename, in the same
// shape as a manual_labels.json entry, so it can be used as fill-in data.
async function fetchLatestParsed(safeFilename: string): Promise<Record<string, any> | null> {
try {
const docRes = await query(
"SELECT id, metadata FROM documents WHERE filename = $1",
[safeFilename]
);
if (!docRes.rowCount || docRes.rowCount === 0) return null;
const doc = docRes.rows[0];
const meta = doc.metadata || {};
const itemsRes = await query(
"SELECT kode_barang, nama_barang, banyak, jumlah FROM ocr_items WHERE document_id = $1 ORDER BY row_index",
[doc.id]
);
return {
noPO: meta.noPO || "",
noSO: meta.noSO || "",
noDO: meta.noDO || "",
tanggal: meta.tanggal || "",
customer: meta.customerInfo || "",
store: meta.orderUntuk || "",
alamat: meta.alamat || "",
plat: meta.platTruk || "",
items: itemsRes.rows.map(row => ({
kodeBarang: row.kode_barang || "",
namaBarang: row.nama_barang || "",
banyak: row.banyak || "",
jumlah: row.jumlah || ""
}))
};
} catch (dbErr) {
console.error("DB fallback failed inside manual-label GET:", dbErr);
return null;
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return NextResponse.json({ error: "Filename parameter is required" }, { status: 400 });
return errorResponse(400, "Filename parameter is required");
}
const safeFilename = path.basename(filename);
const labels = readLabels();
const existing = labels.find(l => l.filename === safeFilename);
const latest = await fetchLatestParsed(safeFilename);
if (existing) {
return NextResponse.json(existing);
// Never overwrite a field the user already corrected manually - only
// fill in whatever is still blank, using the latest AI/DB parse.
const merged = { ...existing, filename: safeFilename };
if (latest) {
for (const field of SCALAR_FIELDS) {
if (!merged[field]) merged[field] = latest[field];
}
if (!merged.items || merged.items.length === 0) {
merged.items = latest.items;
}
}
// aiPredicted is the raw AI value for every field, always included
// (even when a manual value already exists) so the UI can show what
// the AI actually predicted next to the current/manual value.
return NextResponse.json({ ...merged, aiPredicted: latest });
}
// Try fallback to automated parser results in database
try {
const docRes = await query(
"SELECT id, metadata FROM documents WHERE filename = $1",
[safeFilename]
);
if (docRes.rowCount && docRes.rowCount > 0) {
const doc = docRes.rows[0];
const docId = doc.id;
const meta = doc.metadata || {};
// Fetch ocr items
const itemsRes = await query(
"SELECT kode_barang, nama_barang, banyak, jumlah FROM ocr_items WHERE document_id = $1 ORDER BY row_index",
[docId]
);
const items = itemsRes.rows.map(row => ({
kodeBarang: row.kode_barang || "",
namaBarang: row.nama_barang || "",
banyak: row.banyak || "",
jumlah: row.jumlah || ""
}));
return NextResponse.json({
filename,
noPO: meta.noPO || "",
noSO: meta.noSO || "",
noDO: meta.noDO || "",
tanggal: meta.tanggal || "",
customer: meta.customerInfo || "",
store: meta.orderUntuk || "",
alamat: meta.alamat || "",
plat: meta.platTruk || "",
items
});
}
} catch (dbErr) {
console.error("DB fallback failed inside manual-label GET:", dbErr);
if (latest) {
return NextResponse.json({ filename, ...latest, aiPredicted: latest });
}
// Return empty default state if not found anywhere
@@ -87,11 +108,12 @@ export async function GET(req: NextRequest) {
store: "",
alamat: "",
plat: "",
items: []
items: [],
aiPredicted: null
});
} catch (err: any) {
console.error("Error in GET manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to load manual label" }, { status: 500 });
return errorResponse(500, err.message || "Failed to load manual label");
}
}
@@ -101,7 +123,7 @@ export async function POST(req: NextRequest) {
const { filename } = body;
if (!filename) {
return NextResponse.json({ error: "Filename is required in request body" }, { status: 400 });
return errorResponse(400, "Filename is required in request body");
}
const safeFilename = path.basename(filename);
@@ -120,6 +142,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, filePath: LABELS_PATH });
} catch (err: any) {
console.error("Error in POST manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to save manual label" }, { status: 500 });
return errorResponse(500, err.message || "Failed to save manual label");
}
}
+53 -32
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { query, withTransaction, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { errorResponse } from "@/utils/api-error";
// Bounds each pipeline call so a wedged GPU container fails fast into the existing
// graceful fallback path instead of hanging the request indefinitely.
@@ -42,9 +43,9 @@ function getStringSimilarity(s1: string, s2: string): number {
export async function POST(req: NextRequest) {
let safeFile = "";
try {
const { filename } = await req.json();
const { filename, kodeToko } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
safeFile = path.basename(filename);
@@ -59,7 +60,7 @@ export async function POST(req: NextRequest) {
if (!fs.existsSync(filePath)) {
filePath = path.join("/uploads", safeFile);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
return errorResponse(404, "File not found");
}
isUpload = true;
isSample = false;
@@ -137,7 +138,7 @@ export async function POST(req: NextRequest) {
} catch (dbErr) {
console.error("Failed to mark document as parsed on pipeline error:", dbErr);
}
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
data = await response.json();
@@ -388,8 +389,23 @@ export async function POST(req: NextRequest) {
}
docMetadata.items = checkedItems;
// Resolve store information using master database
const resolvedStore = await resolveStoreFromText(markdownText);
// Store name/address are assigned per-account, not OCR-detected: if the
// uploading account's store (kodeToko) was passed through, use it
// directly. Only fall back to OCR-based text matching when no account
// context is available (e.g. legacy/internal callers).
let resolvedStore: { orderUntuk: string; alamat: string } | null = null;
if (kodeToko) {
const storeRes = await query(
"SELECT nama_toko, alamat FROM store_master WHERE kode_toko = $1",
[kodeToko]
);
if (storeRes.rowCount && storeRes.rowCount > 0) {
resolvedStore = { orderUntuk: storeRes.rows[0].nama_toko, alamat: storeRes.rows[0].alamat };
}
}
if (!resolvedStore) {
resolvedStore = await resolveStoreFromText(markdownText);
}
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
(docMetadata as any).alamat = resolvedStore.alamat;
@@ -443,31 +459,36 @@ export async function POST(req: NextRequest) {
const docId = insertDocRes.rows[0].id;
// Delete existing items for this document to avoid unique constraints / stale data
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Delete-then-reinsert must be atomic: without a transaction, a failure partway
// through the insert loop leaves the document row already upserted above but
// only some (or none) of its items, since the delete has already committed
// independently.
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
for (let i = 0; i < docMetadata.items.length; i++) {
const item = docMetadata.items[i];
await query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
)
VALUES ($1, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
`, [
docId,
i,
(item as any).kodeBarangOriginal || item.kodeBarang,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
for (let i = 0; i < docMetadata.items.length; i++) {
const item = docMetadata.items[i];
await client.query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
)
VALUES ($1, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
`, [
docId,
i,
(item as any).kodeBarangOriginal || item.kodeBarang,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
});
// Return wrapped success response with items, flagged, remarks, and intermediate post-processing details
return NextResponse.json({
@@ -508,7 +529,7 @@ export async function POST(req: NextRequest) {
console.error("Failed to mark document as parsed on route catch:", dbErr);
}
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -44,6 +45,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error fetching produk PFM:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { query } from "../../../db";
import crypto from "crypto";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
const PUBLIC_DIR = path.join(process.cwd(), "public/do-pfm");
@@ -12,7 +13,7 @@ export async function POST(req: NextRequest) {
const { filename, image } = await req.json();
if (!filename || !image) {
return NextResponse.json({ error: "Filename and image base64 data are required" }, { status: 400 });
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
@@ -50,6 +51,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error rotating file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -37,7 +38,7 @@ export async function POST(req: NextRequest) {
try {
const { image_base64 } = await req.json();
if (!image_base64) {
return NextResponse.json({ error: "Image is required" }, { status: 400 });
return errorResponse(400, "Image is required");
}
// Call Python FastAPI server inside the container
@@ -54,7 +55,7 @@ export async function POST(req: NextRequest) {
if (!response.ok) {
const errText = await response.text();
return NextResponse.json({ error: `Classifier service error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
@@ -131,6 +132,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in scan-pfm API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -18,6 +19,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in SKUs API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const res = await query(
"SELECT kode_toko, nama_toko, alamat FROM store_master ORDER BY nama_toko"
);
const stores = res.rows.map(row => ({
kodeToko: row.kode_toko,
namaToko: row.nama_toko,
alamat: row.alamat
}));
return NextResponse.json({ stores });
} catch (error: unknown) {
console.error("Error in stores API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function POST(req: NextRequest) {
try {
@@ -8,7 +9,7 @@ export async function POST(req: NextRequest) {
const { page, rowIndex, action } = body;
if (!page || rowIndex === undefined || !action) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
return errorResponse(400, "Missing required fields");
}
const safeFile = path.basename(page);
@@ -16,14 +17,14 @@ export async function POST(req: NextRequest) {
// Get document ID
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [safeFile]);
if (!docRes.rowCount || docRes.rowCount === 0) {
return NextResponse.json({ error: "Document not found in database" }, { status: 404 });
return errorResponse(404, "Document not found in database");
}
const docId = docRes.rows[0].id;
if (action === "edit") {
const { field, value } = body;
if (!field || value === undefined) {
return NextResponse.json({ error: "Missing edit parameters" }, { status: 400 });
return errorResponse(400, "Missing edit parameters");
}
// Map UI field names to database columns
@@ -35,7 +36,7 @@ export async function POST(req: NextRequest) {
} else if (field === "jumlah") {
colName = "jumlah";
} else {
return NextResponse.json({ error: "Invalid field name" }, { status: 400 });
return errorResponse(400, "Invalid field name");
}
await query(
@@ -49,7 +50,7 @@ export async function POST(req: NextRequest) {
} else if (action === "flag") {
const { isFlagged, remark } = body;
if (isFlagged === undefined || remark === undefined) {
return NextResponse.json({ error: "Missing flag parameters" }, { status: 400 });
return errorResponse(400, "Missing flag parameters");
}
await query(
@@ -61,11 +62,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
return errorResponse(400, "Invalid action");
}
} catch (error: unknown) {
console.error("Error in update-row API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { startActiveLog, getActiveLog, clearActiveLog } from "../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -19,7 +20,7 @@ export async function POST(req: NextRequest) {
const file = formData.get("file") as Blob | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
return errorResponse(400, "No file uploaded");
}
const originalName = file instanceof File ? file.name : "document.jpg";
@@ -67,7 +68,7 @@ export async function POST(req: NextRequest) {
if (!response.ok) {
clearActiveLog(filename);
const errText = await response.text();
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
let data = await response.json();
@@ -208,7 +209,7 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in upload API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,4 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { errorResponse } from "@/utils/api-error";
import { signAccountToken } from "@/utils/auth";
import { query } from "../../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -15,24 +19,37 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const { username, password } = body;
// Simple authentication logic for demo
if (username === "admin" && password === "password") {
if (!username || !password) {
return errorResponse(401, "Invalid username or password", { headers: corsHeaders });
}
// Each account is assigned exactly one store (kode_toko) - the token
// carries that assignment so store name/address never need OCR
// detection later; whichever account uploads, its own store is used.
const accountRes = await query(
"SELECT id, username, kode_toko, password FROM accounts WHERE username = $1",
[username]
);
if (accountRes.rowCount && accountRes.rowCount > 0 && bcrypt.compareSync(password, accountRes.rows[0].password)) {
const account = accountRes.rows[0];
const token = signAccountToken({
accountId: account.id,
username: account.username,
kodeToko: account.kode_toko
});
return NextResponse.json({
status: "success",
message: "Login successful",
data: {
token: "demo-auth-token-12345"
}
data: { token }
}, { headers: corsHeaders });
}
return NextResponse.json({
status: "error",
message: "Invalid username or password"
}, { status: 401, headers: corsHeaders });
return errorResponse(401, "Invalid username or password", { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in login API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -16,18 +18,23 @@ export async function PUT(
context: { params: Promise<{ id: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const params = await context.params;
const { id } = params;
const docId = parseInt(id);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400, headers: corsHeaders });
return errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Check if document exists
const checkRes = await query("SELECT id, filename, upload_time FROM documents WHERE id = $1", [docId]);
if (!checkRes.rowCount || checkRes.rowCount === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404, headers: corsHeaders });
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = checkRes.rows[0];
@@ -89,28 +96,32 @@ export async function PUT(
WHERE id = $1
`, [docId, latFloat, lngFloat, JSON.stringify(metadata)]);
// Delete existing ocr_items to recreate them
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Delete-then-reinsert must be atomic: without a transaction, a failure partway
// through the insert loop leaves the document with its header already updated
// above but only some (or none) of its items, since the delete has already
// committed independently.
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Insert new items
for (let i = 0; i < items.length; i++) {
const item = items[i];
const nomorSku = item.nomor_sku || item.nomorSku || "";
const namaBarang = item.nama_barang || item.namaBarang || "";
const banyak = item.banyak || "";
const jumlah = item.jumlah || "";
for (let i = 0; i < items.length; i++) {
const item = items[i];
const nomorSku = item.nomor_sku || item.nomorSku || "";
const namaBarang = item.nama_barang || item.namaBarang || "";
const banyak = item.banyak || "";
const jumlah = item.jumlah || "";
await query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
) VALUES ($1, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
`, [docId, i, nomorSku, namaBarang, banyak, jumlah]);
}
await client.query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
) VALUES ($1, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
`, [docId, i, nomorSku, namaBarang, banyak, jumlah]);
}
});
// Return the updated document mapping
const mappedData = {
@@ -150,6 +161,6 @@ export async function PUT(
} catch (error: unknown) {
console.error("Error in update document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -13,6 +15,11 @@ export async function OPTIONS() {
export async function GET(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
// Retrieve all custom-uploaded documents
const docRes = await query(`
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude
@@ -114,6 +121,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in list documents API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -3,6 +3,8 @@ import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const UPLOADS_DIR = "/uploads";
@@ -23,11 +25,19 @@ export async function POST(req: NextRequest) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
// The account uploading is assigned exactly one store (kode_toko) - pass
// it through to /api/parse so store name/address are set directly from
// that assignment instead of being OCR-detected from the document photo.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400, headers: corsHeaders });
return errorResponse(400, "No file uploaded", { headers: corsHeaders });
}
const originalName = file instanceof File ? file.name : "document.jpg";
@@ -112,7 +122,7 @@ export async function POST(req: NextRequest) {
await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: finalFilename }),
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko }),
signal: AbortSignal.timeout(210_000)
});
} catch (err) {
@@ -151,6 +161,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in upload API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { logVllmCallToAll } from "../../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -104,7 +105,7 @@ async function handleProxy(req: NextRequest) {
} catch (error) {
console.error("[vllm-proxy] Error forwarding request:", error);
return NextResponse.json({ error: "Failed to proxy request to vLLM server" }, { status: 500 });
return errorResponse(500, "Failed to proxy request to vLLM server");
}
}
+130 -13
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { getErrorMessage } from "@/utils/client-error";
interface ItemRow {
kodeBarang: string;
@@ -22,9 +23,52 @@ interface FormData {
items: ItemRow[];
}
interface Store {
kodeToko: string;
namaToko: string;
alamat: string;
}
interface Sku {
no_sku: string;
nama_item: string;
}
// Raw AI/DB parse for the current file, shown alongside each field as a
// reference note so the user can see what the AI predicted vs the current
// (possibly manually corrected) value. Same shape as FormData minus filename.
interface AiPredicted {
noPO: string;
noSO: string;
noDO: string;
tanggal: string;
customer: string;
store: string;
alamat: string;
plat: string;
items: ItemRow[];
}
// Small caption under a field showing what the AI predicted for it, so the
// user reviewing/correcting ground truth can see both values at a glance.
// Renders nothing if the file was never parsed (aiValue is undefined).
function AiNote({ current, aiValue }: { current: string; aiValue: string | undefined }) {
if (aiValue === undefined) return null;
const differs = (current || "").trim() !== (aiValue || "").trim();
return (
<span className={`text-[11px] leading-tight ${differs ? "text-amber-500" : "text-slate-600"}`}>
AI: {aiValue || "(tidak terdeteksi)"}
{differs && current ? " · beda dari manual" : ""}
</span>
);
}
export default function ManualLabelPage() {
const [files, setFiles] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState<number>(-1);
const [stores, setStores] = useState<Store[]>([]);
const [skus, setSkus] = useState<Sku[]>([]);
const [aiPredicted, setAiPredicted] = useState<AiPredicted | null>(null);
const [formData, setFormData] = useState<FormData>({
filename: "",
noPO: "",
@@ -66,6 +110,38 @@ export default function ManualLabelPage() {
fetchFiles();
}, []);
// Fetch store master list on mount, to power the store dropdown + address autofill
useEffect(() => {
const fetchStores = async () => {
try {
const res = await fetch("/api/stores");
if (res.ok) {
const data = await res.json();
setStores(data.stores || []);
}
} catch (err) {
console.error("Error loading store list:", err);
}
};
fetchStores();
}, []);
// Fetch SKU master list on mount, to power the item-name autofill by SKU
useEffect(() => {
const fetchSkus = async () => {
try {
const res = await fetch("/api/skus");
if (res.ok) {
const data = await res.json();
setSkus(data.skus || []);
}
} catch (err) {
console.error("Error loading SKU list:", err);
}
};
fetchSkus();
}, []);
// Fetch existing manual label when currentIndex changes
useEffect(() => {
if (currentIndex < 0 || currentIndex >= files.length) return;
@@ -97,6 +173,7 @@ export default function ManualLabelPage() {
plat: data.plat || "",
items: itemsList
});
setAiPredicted(data.aiPredicted || null);
}
} catch (err) {
console.error("Error fetching label data:", err);
@@ -132,13 +209,32 @@ export default function ManualLabelPage() {
}));
};
// When the store name matches a known store_master entry, default the
// address to that store's address. The user can still edit it afterward -
// this only sets a starting point, it doesn't lock the field.
const handleStoreChange = (value: string) => {
const matched = stores.find(s => s.namaToko === value);
setFormData(prev => ({
...prev,
store: value,
alamat: matched ? matched.alamat : prev.alamat
}));
};
const handleItemChange = (index: number, field: keyof ItemRow, value: string) => {
setFormData(prev => {
const updatedItems = [...prev.items];
updatedItems[index] = {
...updatedItems[index],
[field]: value
};
const updatedRow = { ...updatedItems[index], [field]: value };
// When the SKU code matches a known sku_master entry, default the item
// name to that SKU's name. Same convention as the store->alamat
// autofill: it's a starting point, not locked - still editable after.
if (field === "kodeBarang") {
const matched = skus.find(s => s.no_sku === value);
if (matched) updatedRow.namaBarang = matched.nama_item;
}
updatedItems[index] = updatedRow;
return {
...prev,
items: updatedItems
@@ -189,12 +285,12 @@ export default function ManualLabelPage() {
showToast("Ground truth data saved successfully!");
return true;
} else {
const errText = await res.text();
showToast(`Error: ${errText}`, true);
const data = await res.json().catch(() => undefined);
showToast(`Error: ${getErrorMessage(null, data, `HTTP ${res.status}`)}`, true);
return false;
}
} catch (err: any) {
showToast(`Save error: ${err.message}`, true);
showToast(`Save error: ${getErrorMessage(err)}`, true);
return false;
}
};
@@ -308,6 +404,7 @@ export default function ManualLabelPage() {
placeholder="e.g. PO/26/0000241938"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noPO} aiValue={aiPredicted?.noPO} />
</div>
<div className="flex flex-col gap-1.5">
@@ -319,6 +416,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 1691972227"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noSO} aiValue={aiPredicted?.noSO} />
</div>
</div>
@@ -332,6 +430,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 1659990561"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noDO} aiValue={aiPredicted?.noDO} />
</div>
<div className="flex flex-col gap-1.5">
@@ -343,6 +442,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 30 June 2026"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.tanggal} aiValue={aiPredicted?.tanggal} />
</div>
</div>
@@ -356,17 +456,25 @@ export default function ManualLabelPage() {
placeholder="e.g. PT.PRIMAFOOD INTERNATIONAL"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.customer} aiValue={aiPredicted?.customer} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Store (Tujuan Kirim)</label>
<input
type="text"
list="storeOptions"
value={formData.store}
onChange={(e) => handleInputChange("store", e.target.value)}
placeholder="e.g. PX HEAD OFFICE ANCOL"
onChange={(e) => handleStoreChange(e.target.value)}
placeholder="Ketik untuk cari, atau pilih dari daftar..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<datalist id="storeOptions">
{stores.map(s => (
<option key={s.kodeToko} value={s.namaToko} />
))}
</datalist>
<AiNote current={formData.store} aiValue={aiPredicted?.store} />
</div>
</div>
@@ -379,6 +487,7 @@ export default function ManualLabelPage() {
placeholder="e.g. B 9256 JXR"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.plat} aiValue={aiPredicted?.plat} />
</div>
<div className="flex flex-col gap-1.5">
@@ -387,9 +496,10 @@ export default function ManualLabelPage() {
rows={2}
value={formData.alamat}
onChange={(e) => handleInputChange("alamat", e.target.value)}
placeholder="Complete address text..."
placeholder="Terisi otomatis saat memilih store, atau isi manual..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 resize-none"
/>
<AiNote current={formData.alamat} aiValue={aiPredicted?.alamat} />
</div>
</div>
</div>
@@ -412,8 +522,10 @@ export default function ManualLabelPage() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{formData.items.map((item, idx) => (
<tr key={idx} className="group hover:bg-slate-900/30">
{formData.items.map((item, idx) => {
const aiItem = aiPredicted?.items?.[idx];
return (
<tr key={idx} className="group hover:bg-slate-900/30 align-top">
<td className="py-2 px-1">
<input
type="text"
@@ -422,6 +534,7 @@ export default function ManualLabelPage() {
placeholder="SKU Code"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.kodeBarang} aiValue={aiItem?.kodeBarang} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -431,6 +544,7 @@ export default function ManualLabelPage() {
placeholder="Description"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.namaBarang} aiValue={aiItem?.namaBarang} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -440,6 +554,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 2 KRG"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.banyak} aiValue={aiItem?.banyak} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -449,6 +564,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 40 PC"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.jumlah} aiValue={aiItem?.jumlah} /></div>
</td>
<td className="py-2 px-1 text-center">
<button
@@ -459,7 +575,8 @@ export default function ManualLabelPage() {
</button>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
+13 -4
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { getErrorMessage } from "@/utils/client-error";
interface HistoryItem {
id: number;
@@ -32,6 +33,9 @@ export default function Home() {
if (res.ok) {
const data = await res.json();
setHistory(data.history || []);
} else {
const data = await res.json().catch(() => undefined);
console.error("Failed to fetch history:", getErrorMessage(null, data, `HTTP ${res.status}`));
}
} catch (err) {
console.error("Failed to fetch history:", err);
@@ -57,7 +61,8 @@ export default function Home() {
const detailsData = await detailsRes.json();
setSelectedDetails(detailsData);
} else {
throw new Error("Failed to load document details");
const data = await detailsRes.json().catch(() => undefined);
throw new Error(getErrorMessage(null, data, "Failed to load document details"));
}
// 2. Fetch processing logs
@@ -68,7 +73,7 @@ export default function Home() {
}
} catch (err: any) {
console.error("Error loading document:", err);
setErrorMsg(err.message || "Error loading document details");
setErrorMsg(getErrorMessage(err));
}
};
@@ -114,7 +119,7 @@ export default function Home() {
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to process image");
throw new Error(getErrorMessage(null, data, "Failed to process image"));
}
const data = await res.json();
@@ -137,7 +142,7 @@ export default function Home() {
}
} catch (err: any) {
console.error("Upload failed:", err);
setErrorMsg(err.message || "Failed to upload and process file.");
setErrorMsg(getErrorMessage(err, undefined, "Failed to upload and process file."));
} finally {
setUploading(false);
}
@@ -165,9 +170,13 @@ export default function Home() {
setSelectedLogs(null);
}
fetchHistory();
} else {
const data = await res.json().catch(() => undefined);
setErrorMsg(getErrorMessage(null, data, "Failed to delete document"));
}
} catch (err) {
console.error("Failed to delete document:", err);
setErrorMsg(getErrorMessage(err, undefined, "Failed to delete document"));
}
};
File diff suppressed because it is too large. Load diff
+20 -1
View File
@@ -1,4 +1,4 @@
import { Pool } from "pg";
import { Pool, PoolClient } from "pg";
import { initDb } from "./init";
const pool = new Pool({
@@ -35,6 +35,25 @@ export async function query(text: string, params?: unknown[]) {
return p.query(text, params);
}
/** Runs `fn` inside a BEGIN/COMMIT transaction on a single held connection, rolling back and rethrowing on any failure. */
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const p = await getPool();
const client = await p.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
export async function cleanupAndReindexItems(docId: number) {
// 1. Delete rows where kode_barang is blank/null or doesn't match an 8-digit number
await query(
+36
View File
@@ -1,6 +1,7 @@
import { Pool } from "pg";
import fs from "fs";
import path from "path";
import bcrypt from "bcryptjs";
import { parseDOMetadata } from "../utils/parser";
const UPLOADS_DIR = "/uploads";
@@ -103,6 +104,41 @@ export async function initDb(pool: Pool) {
);
`);
// Create accounts table - each account is assigned one store (kode_toko),
// so store name/address never need to be OCR-detected: whichever account
// uploads a document, that account's assigned store is used directly.
await pool.query(`
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
kode_toko VARCHAR(255) REFERENCES store_master(kode_toko),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
`);
// Seed the demo account, assigned to the head office store. Hashed here
// (not as a SQL literal) since bcrypt can't run inside plain SQL.
await pool.query(
`INSERT INTO accounts (username, password, kode_toko)
VALUES ('admin', $1, 'WH_JOFFICE')
ON CONFLICT (username) DO NOTHING;`,
[bcrypt.hashSync("password", 10)]
);
// Migrate any plaintext passwords (pre-existing accounts from before hashing
// was added) to bcrypt hashes. Idempotent: bcrypt hashes always start with
// "$2", so already-hashed rows are skipped on every re-run.
const plaintextAccounts = await pool.query(
"SELECT id, password FROM accounts WHERE password NOT LIKE '$2%'"
);
for (const account of plaintextAccounts.rows) {
await pool.query("UPDATE accounts SET password = $1 WHERE id = $2", [
bcrypt.hashSync(account.password, 10),
account.id
]);
}
// Create arena_runs table
await pool.query(`
CREATE TABLE IF NOT EXISTS arena_runs (
@@ -0,0 +1,47 @@
// Framework-agnostic HTTP status/error helpers shared by server routes and client components.
export const HTTP_STATUS_TEXT: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
413: "Payload Too Large",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
export function reasonPhraseForStatus(status: number): string {
return HTTP_STATUS_TEXT[status] ?? "Error";
}
export function codeForStatus(status: number): string {
const phrase = HTTP_STATUS_TEXT[status];
if (!phrase) return `HTTP_${status}`;
return phrase.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
}
export interface ApiErrorBody {
status: "error";
error: {
statusCode: number;
code: string;
message: string;
};
}
export function buildApiErrorBody(status: number, message: string, code?: string): ApiErrorBody {
return {
status: "error",
error: {
statusCode: status,
code: code ?? codeForStatus(status),
message,
},
};
}
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { buildApiErrorBody } from "@/lib/http-status";
export function errorResponse(
status: number,
message: string,
opts?: { code?: string; headers?: HeadersInit }
): NextResponse {
return NextResponse.json(buildApiErrorBody(status, message, opts?.code), {
status,
headers: opts?.headers,
});
}
+30
View File
@@ -0,0 +1,30 @@
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "dev-only-insecure-secret-change-me";
export interface AccountTokenPayload {
accountId: number;
username: string;
kodeToko: string | null;
}
export function signAccountToken(payload: AccountTokenPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: "30d" });
}
/** Returns the decoded payload, or null if the token is missing/invalid/expired. */
export function verifyAccountToken(token: string | null | undefined): AccountTokenPayload | null {
if (!token) return null;
try {
return jwt.verify(token, JWT_SECRET) as AccountTokenPayload;
} catch {
return null;
}
}
/** Extracts and verifies the Bearer token from a request's Authorization header. */
export function getAccountFromAuthHeader(authHeader: string | null): AccountTokenPayload | null {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice("Bearer ".length).trim();
return verifyAccountToken(token);
}
@@ -0,0 +1,20 @@
import { HTTP_STATUS_TEXT } from "@/lib/http-status";
/**
* Formats an error the same way across every client component: given the
* parsed JSON body of a failed fetch (if any) and/or the caught exception,
* produce a single "404 Not Found: message" style string mirroring the
* backend's { status: "error", error: { statusCode, code, message } } envelope.
*/
export function getErrorMessage(err: unknown, data?: unknown, fallback = "Unexpected error"): string {
const errorBody = data && typeof data === "object" ? (data as Record<string, unknown>).error : undefined;
if (errorBody && typeof errorBody === "object" && typeof (errorBody as Record<string, unknown>).message === "string") {
const body = errorBody as Record<string, unknown>;
const statusCode = body.statusCode;
const label = (typeof statusCode === "number" && HTTP_STATUS_TEXT[statusCode]) || body.code;
return typeof statusCode === "number" ? `${statusCode} ${label}: ${body.message}` : (body.message as string);
}
if (err instanceof Error) return err.message;
if (err != null) return String(err);
return fallback;
}
+20 -41
View File
@@ -161,7 +161,8 @@ function cleanDateValue(raw: string): string {
export function parseDOMetadata(markdown: string) {
const metadata = {
vendorInfo: "Not Found",
customerInfo: "Not Found",
// Always PT. PRIMAFOOD INTERNATIONAL for this customer - never parsed from OCR.
customerInfo: "PT. PRIMAFOOD INTERNATIONAL",
tanggal: "Not Found",
noSO: "Not Found",
noDO: "Not Found",
@@ -198,33 +199,6 @@ export function parseDOMetadata(markdown: string) {
if (vendorMatch) metadata.vendorInfo = vendorMatch[1].trim();
}
// Extract Customer Info (e.g., Kepada Yth : PT.PRIMAFOOD INTERNATIONAL)
const customerStop = /(?:no\.?\s*(?:so|do|po)|tanggal|date|#|\d{2}:\d{2}:\d{2})/i;
let customerStartIndex = lines.findIndex(line =>
/(?:Kepada Yth|Yth|Customer|Deliver To)\s*[:\-]/i.test(line) || /PT\.\s*PRIMAFOOD/i.test(line)
);
if (customerStartIndex === -1) {
// Fallback: search for a secondary PT. line
const ptIndices = lines.map((l, idx) => l.toUpperCase().includes("PT.") ? idx : -1).filter(idx => idx !== -1);
// Ensure the secondary PT line is not the vendor line
const secondaryIndices = ptIndices.filter(idx => idx !== vendorStartIndex);
if (secondaryIndices.length > 0) {
customerStartIndex = secondaryIndices[0];
}
}
if (customerStartIndex !== -1) {
const customerLines: string[] = [lines[customerStartIndex]];
for (let i = customerStartIndex + 1; i < Math.min(lines.length, customerStartIndex + 4); i++) {
if (customerStop.test(lines[i])) break;
customerLines.push(lines[i]);
}
metadata.customerInfo = customerLines.join("\n");
} else {
const customerMatch = cleanMarkdown.match(/(?:Kepada Yth|Yth|Customer|Deliver To)[ \t]*[:\-][ \t]*([^\n]+)/i) || cleanMarkdown.match(/(PT\.[ \t]*PRIMAFOOD[^\n]*)/i);
if (customerMatch) metadata.customerInfo = customerMatch[1].trim();
}
// Extract Tanggal (Date)
const tanggalMatch = cleanMarkdown.match(/Tanggal\s*[:\-.]?\s*([^\n]{6,100})/i) ||
cleanMarkdown.match(/(?:Date|D\.O\.\s*Date)\s*[:\-.]?\s*([^\n]{6,100})/i);
@@ -404,11 +378,20 @@ export function parseDOMetadata(markdown: string) {
}
}
if (globalTenDigits.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = globalTenDigits[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = globalTenDigits[1];
} else if (globalTenDigits.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = globalTenDigits[0];
// Exclude numbers already assigned to another field, so a still-missing SO
// can't silently be backfilled with the DO/PO value that was just extracted
// correctly (e.g. SO garbled/missing but DO is a clean 16xxxxxxxx number —
// without this filter, DO's value gets duplicated into SO).
const alreadyAssigned = [metadata.noSO, metadata.noDO, metadata.noPO].filter(
(v) => v && v !== "Not Found"
);
const freeTenDigits = globalTenDigits.filter((v) => !alreadyAssigned.includes(v));
if (freeTenDigits.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = freeTenDigits[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = freeTenDigits[1];
} else if (freeTenDigits.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = freeTenDigits[0];
}
// 3. Scan for PO number globally
@@ -437,7 +420,6 @@ export function parseDOMetadata(markdown: string) {
// Clean final values
metadata.vendorInfo = cleanFinalValue(metadata.vendorInfo, true);
metadata.customerInfo = cleanFinalValue(metadata.customerInfo, true);
metadata.tanggal = cleanDateValue(cleanFinalValue(metadata.tanggal));
metadata.noSO = cleanFinalValue(metadata.noSO);
metadata.noDO = cleanFinalValue(metadata.noDO);
@@ -729,9 +711,6 @@ export function parseDOMetadata(markdown: string) {
}
}
// Force customerInfo to always be PT.PRIMAFOOD INTERNATIONAL as requested
metadata.customerInfo = "PT.PRIMAFOOD INTERNATIONAL";
// Extract truck license plate
(metadata as any).platTruk = extractPlatTruk(cleanMarkdown);
@@ -847,12 +826,12 @@ export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata>
const currentFullYear = new Date().getFullYear();
const result = { ...meta };
// --- customerInfo / vendorInfo ---
// OCR typically drops the space after Indonesian business-entity prefixes ("PT.PRIMAFOOD"
// instead of "PT. PRIMAFOOD"). Normalize the standard prefixes to always have one space.
// --- vendorInfo ---
// OCR typically drops the space after Indonesian business-entity prefixes ("PT.CHAROEN"
// instead of "PT. CHAROEN"). Normalize the standard prefixes to always have one space.
// (customerInfo needs no such cleanup - it's a fixed constant, never parsed from OCR.)
const normalizeEntityPrefix = (v: string) =>
v && v !== "Not Found" ? v.replace(/\b(PT|CV|UD|PD|TB)\.(?=\S)/gi, (_, p) => `${p.toUpperCase()}. `) : v;
result.customerInfo = normalizeEntityPrefix(result.customerInfo);
result.vendorInfo = normalizeEntityPrefix(result.vendorInfo);
// --- tanggal ---