feat(app): implement Product Scan review flow, dynamic batch expiry picker, custom PDFs, PO relationships, and aligned card layouts

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-09 12:36:06 +07:00
1 parent 9ff4a4a922
commit 577be04308
69 files changed
+5645 -1657

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const APP_ROOT = path.join(__dirname, "..", "pfm-web-app");
const SOURCES_DIR = path.join(__dirname, "..", "sources");
const LABELS_PATH = path.join(SOURCES_DIR, "product_manual_labels.json");
const HISTORY_PATH = path.join(SOURCES_DIR, "product_accuracy_history.jsonl");
const FETCH_TIMEOUT_MS = 120_000;
interface GroundTruth {
filename: string;
no_sku: string;
nama_item: string;
expiry_date: string;
}
interface ScanResponse {
classification?: {
top1_name: string;
top1_confidence: number;
};
ocr?: {
extracted_expired_date: string;
};
possibleMatches?: Array<{
no_sku: string;
nama_item: string;
isBestMatch: boolean;
}>;
}
interface Check {
field: "no_sku" | "nama_item" | "expiry_date";
match: boolean;
}
interface HistoryEntry {
timestamp: string;
commit: string;
imageCount: { training: number; validation: number };
failedImages: string[];
fields: {
training: Record<string, { correct: number; total: number }>;
validation: Record<string, { correct: number; total: number }>;
};
}
function parseArgs(argv: string[]) {
return {
baseUrl: process.env.ACCURACY_BASE_URL || "http://localhost:3000",
};
}
function norm(v: unknown): string {
if (!v) return "";
return String(v).replace(/\s+/g, " ").trim().toUpperCase();
}
function isMatch(a: unknown, b: unknown): boolean {
return norm(a) === norm(b);
}
function getImagePath(filename: string): string {
if (filename.includes("/")) {
return path.join(APP_ROOT, "public", "produk-pfm", "foto-kemasan-v2", filename);
}
return path.join(SOURCES_DIR, "product-test-images", filename);
}
async function checkServerReachable(baseUrl: string) {
try {
const res = await fetch(`${baseUrl}/api/v1/health`, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
throw new Error(`Next dev server not reachable at ${baseUrl}. Ensure it's running.`);
}
}
async function fetchScan(baseUrl: string, base64: string): Promise<ScanResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(`${baseUrl}/api/scan-pfm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: base64 }),
signal: controller.signal
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
function getGitCommit(): string {
try { return execSync("git rev-parse --short HEAD", { cwd: __dirname }).toString().trim(); }
catch { return "unknown"; }
}
function pct(c: number, t: number) { return t === 0 ? 0 : (c / t) * 100; }
function fmtPct(n: number) { return `${n.toFixed(1)}%`; }
async function main() {
const args = parseArgs(process.argv.slice(2));
await checkServerReachable(args.baseUrl);
if (!fs.existsSync(LABELS_PATH)) {
console.error(`No labels found at ${LABELS_PATH}`);
process.exit(1);
}
const labels: GroundTruth[] = JSON.parse(fs.readFileSync(LABELS_PATH, "utf8"));
const results = {
training: [] as { gt: GroundTruth; checks: Check[] }[],
validation: [] as { gt: GroundTruth; checks: Check[] }[],
failed: [] as string[]
};
for (const gt of labels) {
if (gt.filename.startsWith("uploaded-")) continue; // Skip phantom
const imgPath = getImagePath(gt.filename);
if (!fs.existsSync(imgPath)) {
console.warn(`Warning: Image missing from disk: ${imgPath}`);
continue;
}
process.stdout.write(`Scanning ${gt.filename}... `);
try {
const b64 = "data:image/jpeg;base64," + fs.readFileSync(imgPath, "base64");
const parsed = await fetchScan(args.baseUrl, b64);
const bestMatchSku = parsed.possibleMatches?.find(m => m.isBestMatch)?.no_sku || "";
const predictedItemName = parsed.classification?.top1_name || "";
const predictedExpiry = parsed.ocr?.extracted_expired_date || "";
const checks: Check[] = [
{ field: "no_sku", match: isMatch(gt.no_sku, bestMatchSku) },
{ field: "nama_item", match: isMatch(gt.nama_item, predictedItemName) },
{ field: "expiry_date", match: isMatch(gt.expiry_date, predictedExpiry) }
];
if (gt.filename.includes("/")) {
results.training.push({ gt, checks });
} else {
results.validation.push({ gt, checks });
}
const score = checks.filter(c => c.match).length;
console.log(`done (${score}/3)`);
} catch (err) {
console.log(`FAILED (${(err as Error).message})`);
results.failed.push(gt.filename);
}
}
const aggregate = (list: { checks: Check[] }[]) => {
const agg: Record<string, { correct: number; total: number }> = {
no_sku: { correct: 0, total: 0 },
nama_item: { correct: 0, total: 0 },
expiry_date: { correct: 0, total: 0 }
};
for (const item of list) {
for (const check of item.checks) {
agg[check.field].total++;
if (check.match) agg[check.field].correct++;
}
}
return agg;
};
const trainAgg = aggregate(results.training);
const valAgg = aggregate(results.validation);
console.log("\n=== Product Scan Accuracy Summary ===");
console.log(`Training Images: ${results.training.length} | Validation Images: ${results.validation.length} | Failed: ${results.failed.length}\n`);
console.log("Field | Training Set | Validation Set");
console.log("---------------|--------------|---------------");
["no_sku", "nama_item", "expiry_date"].forEach(f => {
const t = trainAgg[f].total ? fmtPct(pct(trainAgg[f].correct, trainAgg[f].total)) : "n/a";
const v = valAgg[f].total ? fmtPct(pct(valAgg[f].correct, valAgg[f].total)) : "n/a";
console.log(`${f.padEnd(14)} | ${t.padEnd(12)} | ${v.padEnd(14)}`);
});
console.log("");
const entry: HistoryEntry = {
timestamp: new Date().toISOString(),
commit: getGitCommit(),
imageCount: { training: results.training.length, validation: results.validation.length },
failedImages: results.failed,
fields: { training: trainAgg, validation: valAgg }
};
fs.appendFileSync(HISTORY_PATH, JSON.stringify(entry) + "\n");
}
main().catch(err => {
console.error("Fatal error:", err);
process.exit(1);
});