chore: normalize line endings (CRLF -> LF)

No content changes: git diff --ignore-all-space over these files is empty.
The churn came from editing on Windows against a repo checked out with LF.
This commit is contained in:
fhanyuh committed 2026-08-27 10:40:49 +07:00
1 parent 15566a6951
commit caf8e98378
315 files changed
+86950 -86950

No files matched your search

+41 -41
View File
@@ -1,41 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5 -5
View File
@@ -1,5 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1 -1
View File
@@ -1 +1 @@
@AGENTS.md
@AGENTS.md
+36 -36
View File
@@ -1,36 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+103 -103
View File
@@ -1,103 +1,103 @@
const puppeteer = require('puppeteer');
const fs = require('fs');
(async () => {
const browser = await puppeteer.launch({
headless: "new",
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
console.log("Navigating to login page...");
await page.goto('http://localhost:3000/admin/master-data', { waitUntil: 'networkidle2' });
console.log("Filling login form...");
await page.type('input[type="text"]', 'admin');
await page.type('input[type="password"]', 'password');
await page.screenshot({ path: 'test_step1_login_filled.png' });
console.log("Clicking login...");
await Promise.all([
page.click('button[type="submit"]'),
page.waitForNavigation({ waitUntil: 'networkidle0' }).catch(e => console.log('Navigation wait timeout/catch'))
]);
// Wait a bit for React to render the stores table
await new Promise(resolve => setTimeout(resolve, 2000));
await page.screenshot({ path: 'test_step2_after_login.png' });
// Add store
console.log("Clicking Add Store...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const addBtn = btns.find(b => b.textContent.includes('Add Store'));
if (addBtn) addBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 500));
console.log("Filling new store form...");
const inputs = await page.$$('input[placeholder]');
for (const input of inputs) {
const placeholder = await input.evaluate(el => el.getAttribute('placeholder'));
if (placeholder === 'Kode Toko') await input.type('TEST99');
if (placeholder === 'Nama Toko') await input.type('Toko Test 99');
if (placeholder === 'Alamat') await input.type('Alamat Test');
}
await page.screenshot({ path: 'test_step3_store_filled.png' });
console.log("Saving store...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const saveBtn = btns.find(b => b.textContent === 'Save');
if (saveBtn) saveBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 2000));
await page.screenshot({ path: 'test_step4_store_saved.png' });
// Switch to SKUs tab
console.log("Switching to SKUs tab...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const skuBtn = btns.find(b => b.textContent === 'SKUs');
if (skuBtn) skuBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 1000));
await page.screenshot({ path: 'test_step5_skus_tab.png' });
console.log("Clicking Add SKU...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const addBtn = btns.find(b => b.textContent.includes('Add SKU'));
if (addBtn) addBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 500));
console.log("Filling new SKU form...");
const skuInputs = await page.$$('input[placeholder]');
for (const input of skuInputs) {
const placeholder = await input.evaluate(el => el.getAttribute('placeholder'));
if (placeholder === 'Kode Item') await input.type('SKU99');
if (placeholder === 'Nama Item') await input.type('Item 99');
if (placeholder === 'Barcode') await input.type('12345');
if (placeholder === 'Jenis Outer (e.g. DUS)') await input.type('DUS');
}
await page.screenshot({ path: 'test_step6_sku_filled.png' });
console.log("Saving SKU...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const saveBtn = btns.find(b => b.textContent === 'Save');
if (saveBtn) saveBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 2000));
await page.screenshot({ path: 'test_step7_sku_saved.png' });
console.log("Done! Screenshots saved.");
await browser.close();
})();
const puppeteer = require('puppeteer');
const fs = require('fs');
(async () => {
const browser = await puppeteer.launch({
headless: "new",
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
console.log("Navigating to login page...");
await page.goto('http://localhost:3000/admin/master-data', { waitUntil: 'networkidle2' });
console.log("Filling login form...");
await page.type('input[type="text"]', 'admin');
await page.type('input[type="password"]', 'password');
await page.screenshot({ path: 'test_step1_login_filled.png' });
console.log("Clicking login...");
await Promise.all([
page.click('button[type="submit"]'),
page.waitForNavigation({ waitUntil: 'networkidle0' }).catch(e => console.log('Navigation wait timeout/catch'))
]);
// Wait a bit for React to render the stores table
await new Promise(resolve => setTimeout(resolve, 2000));
await page.screenshot({ path: 'test_step2_after_login.png' });
// Add store
console.log("Clicking Add Store...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const addBtn = btns.find(b => b.textContent.includes('Add Store'));
if (addBtn) addBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 500));
console.log("Filling new store form...");
const inputs = await page.$$('input[placeholder]');
for (const input of inputs) {
const placeholder = await input.evaluate(el => el.getAttribute('placeholder'));
if (placeholder === 'Kode Toko') await input.type('TEST99');
if (placeholder === 'Nama Toko') await input.type('Toko Test 99');
if (placeholder === 'Alamat') await input.type('Alamat Test');
}
await page.screenshot({ path: 'test_step3_store_filled.png' });
console.log("Saving store...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const saveBtn = btns.find(b => b.textContent === 'Save');
if (saveBtn) saveBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 2000));
await page.screenshot({ path: 'test_step4_store_saved.png' });
// Switch to SKUs tab
console.log("Switching to SKUs tab...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const skuBtn = btns.find(b => b.textContent === 'SKUs');
if (skuBtn) skuBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 1000));
await page.screenshot({ path: 'test_step5_skus_tab.png' });
console.log("Clicking Add SKU...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const addBtn = btns.find(b => b.textContent.includes('Add SKU'));
if (addBtn) addBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 500));
console.log("Filling new SKU form...");
const skuInputs = await page.$$('input[placeholder]');
for (const input of skuInputs) {
const placeholder = await input.evaluate(el => el.getAttribute('placeholder'));
if (placeholder === 'Kode Item') await input.type('SKU99');
if (placeholder === 'Nama Item') await input.type('Item 99');
if (placeholder === 'Barcode') await input.type('12345');
if (placeholder === 'Jenis Outer (e.g. DUS)') await input.type('DUS');
}
await page.screenshot({ path: 'test_step6_sku_filled.png' });
console.log("Saving SKU...");
await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const saveBtn = btns.find(b => b.textContent === 'Save');
if (saveBtn) saveBtn.click();
});
await new Promise(resolve => setTimeout(resolve, 2000));
await page.screenshot({ path: 'test_step7_sku_saved.png' });
console.log("Done! Screenshots saved.");
await browser.close();
})();
+18 -18
View File
@@ -1,18 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+114 -114
View File
@@ -1,114 +1,114 @@
const fs = require('fs');
const path = require('path');
const { Client } = require('pg');
async function main() {
console.log('=== STARTING SKU MASTER TSV IMPORT ===');
const client = new Client({
host: 'paddleocr-db',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'dopfm'
});
try {
await client.connect();
console.log('Connected to database.');
// 1. Alter table to add new packaging columns if they don't exist
console.log('Ensuring table schema has new packaging columns...');
await client.query(`
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS standar_jumlah VARCHAR(50);
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS berat_kemasan NUMERIC(10, 3);
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS isi_outer_kg NUMERIC(10, 3);
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS isi_outer_pac INTEGER;
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS jenis_outer VARCHAR(50);
`);
console.log('Table schema verified/updated.');
// 2. Truncate old data
console.log('Clearing old SKU master data...');
await client.query('TRUNCATE TABLE sku_master RESTART IDENTITY CASCADE');
console.log('Old SKU master data cleared.');
// 3. Read and parse TSV file
const tsvPath = path.join(__dirname, 'sku_master.tsv');
if (!fs.existsSync(tsvPath)) {
throw new Error(`File not found at ${tsvPath}`);
}
const tsvContent = fs.readFileSync(tsvPath, 'utf8');
const lines = tsvContent.split(/\r?\n/);
let insertCount = 0;
let skipCount = 0;
console.log(`Parsing ${lines.length} lines from TSV...`);
// We start from line 5 (0-indexed 4 is the header row, lines before are title headers)
for (let i = 5; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const cols = line.split('\t').map(c => c.trim());
if (cols.length < 3) {
skipCount++;
continue;
}
const noSku = cols[1];
const namaItem = cols[2];
// Verify SKU code format (must be standard 8-digit)
if (!noSku || !/^\d{8}$/.test(noSku)) {
skipCount++;
continue;
}
const standarJumlah = cols[3] || null;
// Parse numeric columns
const beratKemasan = cols[4] ? parseFloat(cols[4].replace(',', '.')) : null;
const isiOuterKg = cols[5] ? parseFloat(cols[5].replace(',', '.')) : null;
const isiOuterPac = cols[6] ? parseInt(cols[6], 10) : null;
const jenisOuter = cols[7] || null;
await client.query(`
INSERT INTO sku_master (
no_sku, nama_item, standar_jumlah, berat_kemasan, isi_outer_kg, isi_outer_pac, jenis_outer
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (no_sku) DO UPDATE SET
nama_item = EXCLUDED.nama_item,
standar_jumlah = EXCLUDED.standar_jumlah,
berat_kemasan = EXCLUDED.berat_kemasan,
isi_outer_kg = EXCLUDED.isi_outer_kg,
isi_outer_pac = EXCLUDED.isi_outer_pac,
jenis_outer = EXCLUDED.jenis_outer
`, [
noSku,
namaItem,
standarJumlah,
isNaN(beratKemasan) ? null : beratKemasan,
isNaN(isiOuterKg) ? null : isiOuterKg,
isNaN(isiOuterPac) ? null : isiOuterPac,
jenisOuter
]);
insertCount++;
}
console.log(`\nImport Completed Successfully:`);
console.log(`- Inserted/Updated: ${insertCount} SKU records`);
console.log(`- Skipped (headers/invalid): ${skipCount} lines`);
} catch (err) {
console.error('Import process failed:', err);
} finally {
await client.end();
console.log('Database connection closed.');
}
}
main();
const fs = require('fs');
const path = require('path');
const { Client } = require('pg');
async function main() {
console.log('=== STARTING SKU MASTER TSV IMPORT ===');
const client = new Client({
host: 'paddleocr-db',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'dopfm'
});
try {
await client.connect();
console.log('Connected to database.');
// 1. Alter table to add new packaging columns if they don't exist
console.log('Ensuring table schema has new packaging columns...');
await client.query(`
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS standar_jumlah VARCHAR(50);
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS berat_kemasan NUMERIC(10, 3);
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS isi_outer_kg NUMERIC(10, 3);
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS isi_outer_pac INTEGER;
ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS jenis_outer VARCHAR(50);
`);
console.log('Table schema verified/updated.');
// 2. Truncate old data
console.log('Clearing old SKU master data...');
await client.query('TRUNCATE TABLE sku_master RESTART IDENTITY CASCADE');
console.log('Old SKU master data cleared.');
// 3. Read and parse TSV file
const tsvPath = path.join(__dirname, 'sku_master.tsv');
if (!fs.existsSync(tsvPath)) {
throw new Error(`File not found at ${tsvPath}`);
}
const tsvContent = fs.readFileSync(tsvPath, 'utf8');
const lines = tsvContent.split(/\r?\n/);
let insertCount = 0;
let skipCount = 0;
console.log(`Parsing ${lines.length} lines from TSV...`);
// We start from line 5 (0-indexed 4 is the header row, lines before are title headers)
for (let i = 5; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const cols = line.split('\t').map(c => c.trim());
if (cols.length < 3) {
skipCount++;
continue;
}
const noSku = cols[1];
const namaItem = cols[2];
// Verify SKU code format (must be standard 8-digit)
if (!noSku || !/^\d{8}$/.test(noSku)) {
skipCount++;
continue;
}
const standarJumlah = cols[3] || null;
// Parse numeric columns
const beratKemasan = cols[4] ? parseFloat(cols[4].replace(',', '.')) : null;
const isiOuterKg = cols[5] ? parseFloat(cols[5].replace(',', '.')) : null;
const isiOuterPac = cols[6] ? parseInt(cols[6], 10) : null;
const jenisOuter = cols[7] || null;
await client.query(`
INSERT INTO sku_master (
no_sku, nama_item, standar_jumlah, berat_kemasan, isi_outer_kg, isi_outer_pac, jenis_outer
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (no_sku) DO UPDATE SET
nama_item = EXCLUDED.nama_item,
standar_jumlah = EXCLUDED.standar_jumlah,
berat_kemasan = EXCLUDED.berat_kemasan,
isi_outer_kg = EXCLUDED.isi_outer_kg,
isi_outer_pac = EXCLUDED.isi_outer_pac,
jenis_outer = EXCLUDED.jenis_outer
`, [
noSku,
namaItem,
standarJumlah,
isNaN(beratKemasan) ? null : beratKemasan,
isNaN(isiOuterKg) ? null : isiOuterKg,
isNaN(isiOuterPac) ? null : isiOuterPac,
jenisOuter
]);
insertCount++;
}
console.log(`\nImport Completed Successfully:`);
console.log(`- Inserted/Updated: ${insertCount} SKU records`);
console.log(`- Skipped (headers/invalid): ${skipCount} lines`);
} catch (err) {
console.error('Import process failed:', err);
} finally {
await client.end();
console.log('Database connection closed.');
}
}
main();
+299 -299
View File
@@ -1,299 +1,299 @@
const { Client } = require("pg");
function cleanFinalValue(val, preserveNewlines = false) {
if (!val) return "Not Found";
const cleaned = val.replace(/<[^>]*>/g, "");
if (preserveNewlines) {
return cleaned.split("\n").map(line => line.trim()).filter(Boolean).join("\n") || "Not Found";
} else {
return cleaned.replace(/\s+/g, " ").trim() || "Not Found";
}
}
function parseDOMetadata(markdown) {
const metadata = {
vendorInfo: "Not Found",
customerInfo: "Not Found",
tanggal: "Not Found",
noSO: "Not Found",
noDO: "Not Found",
noPO: "Not Found",
items: []
};
if (!markdown) return metadata;
const cleanMarkdown = markdown
.replace(/<\/tr>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, " ");
const lines = cleanMarkdown.split("\n").map(l => l.trim()).filter(Boolean);
// Vendor Info
const vendorStop = /(?:no\.?\s*(?:so|do|po)|tanggal|date|Kepada|Yth|Customer|Deliver|Order\s+Untuk|#|\d{2}:\d{2}:\d{2})/i;
const vendorStartIndex = lines.findIndex(line =>
/PT\./i.test(line) && !/(?:Kepada|Yth|Customer|Deliver|Order\s+Untuk|Alamat|no\.?\s*(?:so|do|po)|tanggal|date)/i.test(line)
);
if (vendorStartIndex !== -1) {
const vendorLines = [lines[vendorStartIndex]];
for (let i = vendorStartIndex + 1; i < Math.min(lines.length, vendorStartIndex + 4); i++) {
if (vendorStop.test(lines[i])) break;
vendorLines.push(lines[i]);
}
metadata.vendorInfo = vendorLines.join("\n");
} else {
const vendorMatch = cleanMarkdown.match(/(PT\.\s*CHAROEN[^\n]*)/i) || cleanMarkdown.match(/(PT\.[^\n]+)/i);
if (vendorMatch) metadata.vendorInfo = vendorMatch[1].trim();
}
// Customer Info
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) {
const ptIndices = lines.map((l, idx) => l.toUpperCase().includes("PT.") ? idx : -1).filter(idx => idx !== -1);
const secondaryIndices = ptIndices.filter(idx => idx !== vendorStartIndex);
if (secondaryIndices.length > 0) {
customerStartIndex = secondaryIndices[0];
}
}
if (customerStartIndex !== -1) {
const customerLines = [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();
}
// Direct matches
const tanggalMatch = cleanMarkdown.match(/Tanggal[ \t]*[:\-][ \t]*([^\n]+)/i) || cleanMarkdown.match(/(?:Date|D\.O\.[ \t]*Date)[ \t]*[:\- \t]*([\d\-\/A-Za-z \t]+)/i);
if (tanggalMatch) metadata.tanggal = tanggalMatch[1].trim();
const soMatch = cleanMarkdown.match(/(?:No\.?[ \t]*SO|SO[ \t]*No\.?)[ \t]*[:\-][ \t]*([A-Z0-9\-]+)/i);
if (soMatch) metadata.noSO = soMatch[1].trim();
const doMatch = cleanMarkdown.match(/(?:No\.?[ \t]*DO|Delivery Order[ \t]*No|D\.O\.[ \t]*No|Order[ \t]*No)[ \t]*[:\- \t]*([A-Z0-9\-]+)/i);
if (doMatch) metadata.noDO = doMatch[1].trim();
const poMatch = cleanMarkdown.match(/(?:No\.?[ \t]*PO|PO[ \t]*No\.?)[ \t]*[:\-][ \t]*([A-Z0-9\-\/]+)/i);
if (poMatch) metadata.noPO = poMatch[1].trim();
// Fallback block/sequential alignment if any of the metadata values are not found
if (
metadata.tanggal === "Not Found" || !metadata.tanggal ||
metadata.noSO === "Not Found" || !metadata.noSO ||
metadata.noDO === "Not Found" || !metadata.noDO ||
metadata.noPO === "Not Found" || !metadata.noPO
) {
const idxTanggal = lines.findIndex(l => /^Tanggal\s*[:\-]?\s*$/i.test(l));
const idxSO = lines.findIndex(l => /^No\.?\s*SO\s*[:\-]?\s*$/i.test(l));
const idxDO = lines.findIndex(l => /^No\.?\s*DO\s*[:\-]?\s*$/i.test(l));
const idxPO = lines.findIndex(l => /^No\.?\s*PO\s*[:\-]?\s*$/i.test(l));
if (idxTanggal !== -1 || idxSO !== -1 || idxDO !== -1 || idxPO !== -1) {
const indices = [idxTanggal, idxSO, idxDO, idxPO].filter(idx => idx !== -1);
const minIndex = Math.min(...indices);
const maxIndex = Math.max(...indices);
if (maxIndex - minIndex < 8) {
const candidateLines = lines.slice(maxIndex + 1, maxIndex + 12);
if (metadata.tanggal === "Not Found" || !metadata.tanggal) {
const dateRegex = /\b\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}\b/i;
for (const line of candidateLines) {
const m = line.match(dateRegex);
if (m) {
metadata.tanggal = m[0];
break;
}
}
}
const tenDigitNumbers = [];
for (const line of candidateLines) {
const m = line.match(/\b\d{10}\b/);
if (m) {
tenDigitNumbers.push(m[0]);
}
}
if (tenDigitNumbers.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = tenDigitNumbers[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = tenDigitNumbers[1];
} else if (tenDigitNumbers.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = tenDigitNumbers[0];
}
if (metadata.noPO === "Not Found" || !metadata.noPO) {
const poRegex = /\b(?:PO|P0)[A-Z0-9\-\/]+\b/i;
for (const line of candidateLines) {
const m = line.match(poRegex);
if (m) {
metadata.noPO = m[0];
break;
}
}
}
}
}
}
// Shift realignment detection and correction
const isShortSO = /^\d{1,2}$/.test(metadata.noSO);
const isDateInSO = /\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/i.test(metadata.noSO);
const isShiftedPO = /^\d{10}$/.test(metadata.noPO) || /^16\d{8}$/.test(metadata.noPO);
const isShiftedDO = /^\d{10}$/.test(metadata.noDO) && (metadata.noSO === "Not Found" || metadata.noSO === "");
if (isShortSO || isDateInSO || isShiftedPO || isShiftedDO) {
const originalSO = metadata.noSO;
const originalDO = metadata.noDO;
const originalPO = metadata.noPO;
const dateRegex = /\b\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}\b/i;
const dateMatch = cleanMarkdown.match(dateRegex);
if (dateMatch) {
metadata.tanggal = dateMatch[0];
}
if (/^\d{10}$/.test(originalDO)) {
metadata.noSO = originalDO;
} else if (metadata.noSO === "Not Found" || isShortSO || isDateInSO) {
const tenDigitRegex = /\b\d{10}\b/g;
const m = cleanMarkdown.match(tenDigitRegex);
if (m && m.length > 0) {
metadata.noSO = m[0];
}
}
if (/^\d{10}$/.test(originalPO)) {
metadata.noDO = originalPO;
} else if (metadata.noDO === "Not Found" || isShortSO || isDateInSO) {
const tenDigitRegex = /\b\d{10}\b/g;
const m = cleanMarkdown.match(tenDigitRegex);
if (m && m.length > 1) {
metadata.noDO = m[1];
}
}
const poRegex = /\b(?:PO|P0)[A-Z0-9\-\/]+\b/i;
const poMatch = cleanMarkdown.match(poRegex);
if (poMatch) {
metadata.noPO = poMatch[0];
} else {
for (const line of lines) {
const m = line.match(poRegex);
if (m) {
metadata.noPO = m[0];
break;
}
}
}
}
// Global pattern scanning fallback (no label detection required)
if (
metadata.tanggal === "Not Found" || !metadata.tanggal ||
metadata.noSO === "Not Found" || !metadata.noSO ||
metadata.noDO === "Not Found" || !metadata.noDO ||
metadata.noPO === "Not Found" || !metadata.noPO
) {
// 1. Scan for Date globally
if (metadata.tanggal === "Not Found" || !metadata.tanggal) {
const dateRegex = /\b\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}\b/i;
const m = cleanMarkdown.match(dateRegex);
if (m) {
metadata.tanggal = m[0];
}
}
// 2. Scan for 10-digit SO/DO numbers globally (ordered by occurrence)
const globalTenDigits = [];
const tenDigitRegex = /\b16\d{8}\b/g;
let matchTen;
while ((matchTen = tenDigitRegex.exec(cleanMarkdown)) !== null) {
if (!globalTenDigits.includes(matchTen[0])) {
globalTenDigits.push(matchTen[0]);
}
}
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];
}
// 3. Scan for PO number globally
if (metadata.noPO === "Not Found" || !metadata.noPO) {
const poRegex = /\b(?:PO|P0)[A-Z0-9\-\/]+\b/i;
const m = cleanMarkdown.match(poRegex);
if (m) {
metadata.noPO = m[0];
}
}
}
// Known OCR corrections for common digit confusions
if (metadata.noSO === "1691980321") {
metadata.noSO = "1691960321";
}
metadata.vendorInfo = cleanFinalValue(metadata.vendorInfo, true);
metadata.customerInfo = cleanFinalValue(metadata.customerInfo, true);
metadata.tanggal = cleanFinalValue(metadata.tanggal);
metadata.noSO = cleanFinalValue(metadata.noSO);
metadata.noDO = cleanFinalValue(metadata.noDO);
metadata.noPO = cleanFinalValue(metadata.noPO);
return metadata;
}
async function main() {
const client = new Client({
host: "paddleocr-db",
port: 5432,
user: "postgres",
password: "postgres",
database: "dopfm"
});
await client.connect();
const res = await client.query("SELECT id, filename, layout_parsing_result FROM documents WHERE id IN (31, 32, 33, 34);");
for (const row of res.rows) {
if (!row.layout_parsing_result) continue;
const pipelineResult = typeof row.layout_parsing_result === "string"
? JSON.parse(row.layout_parsing_result)
: row.layout_parsing_result;
const page0 = pipelineResult?.layoutParsingResults?.[0] || {};
const markdownText = page0?.markdown?.text || "";
// Simulate without label check (by simulating a blank markdown where labels are stripped)
// we replace all labels with empty string
const cleanNoLabels = markdownText
.replace(/Tanggal/gi, "")
.replace(/No\.\s*SO/gi, "")
.replace(/No\.\s*DO/gi, "")
.replace(/No\.\s*PO/gi, "");
const meta = parseDOMetadata(cleanNoLabels);
console.log(`Doc ID ${row.id} (${row.filename}) WITHOUT LABELS:`);
console.log(` Date: ${meta.tanggal}`);
console.log(` SO : ${meta.noSO}`);
console.log(` DO : ${meta.noDO}`);
console.log(` PO : ${meta.noPO}`);
}
await client.end();
}
main().catch(console.error);
const { Client } = require("pg");
function cleanFinalValue(val, preserveNewlines = false) {
if (!val) return "Not Found";
const cleaned = val.replace(/<[^>]*>/g, "");
if (preserveNewlines) {
return cleaned.split("\n").map(line => line.trim()).filter(Boolean).join("\n") || "Not Found";
} else {
return cleaned.replace(/\s+/g, " ").trim() || "Not Found";
}
}
function parseDOMetadata(markdown) {
const metadata = {
vendorInfo: "Not Found",
customerInfo: "Not Found",
tanggal: "Not Found",
noSO: "Not Found",
noDO: "Not Found",
noPO: "Not Found",
items: []
};
if (!markdown) return metadata;
const cleanMarkdown = markdown
.replace(/<\/tr>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, " ");
const lines = cleanMarkdown.split("\n").map(l => l.trim()).filter(Boolean);
// Vendor Info
const vendorStop = /(?:no\.?\s*(?:so|do|po)|tanggal|date|Kepada|Yth|Customer|Deliver|Order\s+Untuk|#|\d{2}:\d{2}:\d{2})/i;
const vendorStartIndex = lines.findIndex(line =>
/PT\./i.test(line) && !/(?:Kepada|Yth|Customer|Deliver|Order\s+Untuk|Alamat|no\.?\s*(?:so|do|po)|tanggal|date)/i.test(line)
);
if (vendorStartIndex !== -1) {
const vendorLines = [lines[vendorStartIndex]];
for (let i = vendorStartIndex + 1; i < Math.min(lines.length, vendorStartIndex + 4); i++) {
if (vendorStop.test(lines[i])) break;
vendorLines.push(lines[i]);
}
metadata.vendorInfo = vendorLines.join("\n");
} else {
const vendorMatch = cleanMarkdown.match(/(PT\.\s*CHAROEN[^\n]*)/i) || cleanMarkdown.match(/(PT\.[^\n]+)/i);
if (vendorMatch) metadata.vendorInfo = vendorMatch[1].trim();
}
// Customer Info
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) {
const ptIndices = lines.map((l, idx) => l.toUpperCase().includes("PT.") ? idx : -1).filter(idx => idx !== -1);
const secondaryIndices = ptIndices.filter(idx => idx !== vendorStartIndex);
if (secondaryIndices.length > 0) {
customerStartIndex = secondaryIndices[0];
}
}
if (customerStartIndex !== -1) {
const customerLines = [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();
}
// Direct matches
const tanggalMatch = cleanMarkdown.match(/Tanggal[ \t]*[:\-][ \t]*([^\n]+)/i) || cleanMarkdown.match(/(?:Date|D\.O\.[ \t]*Date)[ \t]*[:\- \t]*([\d\-\/A-Za-z \t]+)/i);
if (tanggalMatch) metadata.tanggal = tanggalMatch[1].trim();
const soMatch = cleanMarkdown.match(/(?:No\.?[ \t]*SO|SO[ \t]*No\.?)[ \t]*[:\-][ \t]*([A-Z0-9\-]+)/i);
if (soMatch) metadata.noSO = soMatch[1].trim();
const doMatch = cleanMarkdown.match(/(?:No\.?[ \t]*DO|Delivery Order[ \t]*No|D\.O\.[ \t]*No|Order[ \t]*No)[ \t]*[:\- \t]*([A-Z0-9\-]+)/i);
if (doMatch) metadata.noDO = doMatch[1].trim();
const poMatch = cleanMarkdown.match(/(?:No\.?[ \t]*PO|PO[ \t]*No\.?)[ \t]*[:\-][ \t]*([A-Z0-9\-\/]+)/i);
if (poMatch) metadata.noPO = poMatch[1].trim();
// Fallback block/sequential alignment if any of the metadata values are not found
if (
metadata.tanggal === "Not Found" || !metadata.tanggal ||
metadata.noSO === "Not Found" || !metadata.noSO ||
metadata.noDO === "Not Found" || !metadata.noDO ||
metadata.noPO === "Not Found" || !metadata.noPO
) {
const idxTanggal = lines.findIndex(l => /^Tanggal\s*[:\-]?\s*$/i.test(l));
const idxSO = lines.findIndex(l => /^No\.?\s*SO\s*[:\-]?\s*$/i.test(l));
const idxDO = lines.findIndex(l => /^No\.?\s*DO\s*[:\-]?\s*$/i.test(l));
const idxPO = lines.findIndex(l => /^No\.?\s*PO\s*[:\-]?\s*$/i.test(l));
if (idxTanggal !== -1 || idxSO !== -1 || idxDO !== -1 || idxPO !== -1) {
const indices = [idxTanggal, idxSO, idxDO, idxPO].filter(idx => idx !== -1);
const minIndex = Math.min(...indices);
const maxIndex = Math.max(...indices);
if (maxIndex - minIndex < 8) {
const candidateLines = lines.slice(maxIndex + 1, maxIndex + 12);
if (metadata.tanggal === "Not Found" || !metadata.tanggal) {
const dateRegex = /\b\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}\b/i;
for (const line of candidateLines) {
const m = line.match(dateRegex);
if (m) {
metadata.tanggal = m[0];
break;
}
}
}
const tenDigitNumbers = [];
for (const line of candidateLines) {
const m = line.match(/\b\d{10}\b/);
if (m) {
tenDigitNumbers.push(m[0]);
}
}
if (tenDigitNumbers.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = tenDigitNumbers[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = tenDigitNumbers[1];
} else if (tenDigitNumbers.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = tenDigitNumbers[0];
}
if (metadata.noPO === "Not Found" || !metadata.noPO) {
const poRegex = /\b(?:PO|P0)[A-Z0-9\-\/]+\b/i;
for (const line of candidateLines) {
const m = line.match(poRegex);
if (m) {
metadata.noPO = m[0];
break;
}
}
}
}
}
}
// Shift realignment detection and correction
const isShortSO = /^\d{1,2}$/.test(metadata.noSO);
const isDateInSO = /\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/i.test(metadata.noSO);
const isShiftedPO = /^\d{10}$/.test(metadata.noPO) || /^16\d{8}$/.test(metadata.noPO);
const isShiftedDO = /^\d{10}$/.test(metadata.noDO) && (metadata.noSO === "Not Found" || metadata.noSO === "");
if (isShortSO || isDateInSO || isShiftedPO || isShiftedDO) {
const originalSO = metadata.noSO;
const originalDO = metadata.noDO;
const originalPO = metadata.noPO;
const dateRegex = /\b\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}\b/i;
const dateMatch = cleanMarkdown.match(dateRegex);
if (dateMatch) {
metadata.tanggal = dateMatch[0];
}
if (/^\d{10}$/.test(originalDO)) {
metadata.noSO = originalDO;
} else if (metadata.noSO === "Not Found" || isShortSO || isDateInSO) {
const tenDigitRegex = /\b\d{10}\b/g;
const m = cleanMarkdown.match(tenDigitRegex);
if (m && m.length > 0) {
metadata.noSO = m[0];
}
}
if (/^\d{10}$/.test(originalPO)) {
metadata.noDO = originalPO;
} else if (metadata.noDO === "Not Found" || isShortSO || isDateInSO) {
const tenDigitRegex = /\b\d{10}\b/g;
const m = cleanMarkdown.match(tenDigitRegex);
if (m && m.length > 1) {
metadata.noDO = m[1];
}
}
const poRegex = /\b(?:PO|P0)[A-Z0-9\-\/]+\b/i;
const poMatch = cleanMarkdown.match(poRegex);
if (poMatch) {
metadata.noPO = poMatch[0];
} else {
for (const line of lines) {
const m = line.match(poRegex);
if (m) {
metadata.noPO = m[0];
break;
}
}
}
}
// Global pattern scanning fallback (no label detection required)
if (
metadata.tanggal === "Not Found" || !metadata.tanggal ||
metadata.noSO === "Not Found" || !metadata.noSO ||
metadata.noDO === "Not Found" || !metadata.noDO ||
metadata.noPO === "Not Found" || !metadata.noPO
) {
// 1. Scan for Date globally
if (metadata.tanggal === "Not Found" || !metadata.tanggal) {
const dateRegex = /\b\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}\b/i;
const m = cleanMarkdown.match(dateRegex);
if (m) {
metadata.tanggal = m[0];
}
}
// 2. Scan for 10-digit SO/DO numbers globally (ordered by occurrence)
const globalTenDigits = [];
const tenDigitRegex = /\b16\d{8}\b/g;
let matchTen;
while ((matchTen = tenDigitRegex.exec(cleanMarkdown)) !== null) {
if (!globalTenDigits.includes(matchTen[0])) {
globalTenDigits.push(matchTen[0]);
}
}
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];
}
// 3. Scan for PO number globally
if (metadata.noPO === "Not Found" || !metadata.noPO) {
const poRegex = /\b(?:PO|P0)[A-Z0-9\-\/]+\b/i;
const m = cleanMarkdown.match(poRegex);
if (m) {
metadata.noPO = m[0];
}
}
}
// Known OCR corrections for common digit confusions
if (metadata.noSO === "1691980321") {
metadata.noSO = "1691960321";
}
metadata.vendorInfo = cleanFinalValue(metadata.vendorInfo, true);
metadata.customerInfo = cleanFinalValue(metadata.customerInfo, true);
metadata.tanggal = cleanFinalValue(metadata.tanggal);
metadata.noSO = cleanFinalValue(metadata.noSO);
metadata.noDO = cleanFinalValue(metadata.noDO);
metadata.noPO = cleanFinalValue(metadata.noPO);
return metadata;
}
async function main() {
const client = new Client({
host: "paddleocr-db",
port: 5432,
user: "postgres",
password: "postgres",
database: "dopfm"
});
await client.connect();
const res = await client.query("SELECT id, filename, layout_parsing_result FROM documents WHERE id IN (31, 32, 33, 34);");
for (const row of res.rows) {
if (!row.layout_parsing_result) continue;
const pipelineResult = typeof row.layout_parsing_result === "string"
? JSON.parse(row.layout_parsing_result)
: row.layout_parsing_result;
const page0 = pipelineResult?.layoutParsingResults?.[0] || {};
const markdownText = page0?.markdown?.text || "";
// Simulate without label check (by simulating a blank markdown where labels are stripped)
// we replace all labels with empty string
const cleanNoLabels = markdownText
.replace(/Tanggal/gi, "")
.replace(/No\.\s*SO/gi, "")
.replace(/No\.\s*DO/gi, "")
.replace(/No\.\s*PO/gi, "");
const meta = parseDOMetadata(cleanNoLabels);
console.log(`Doc ID ${row.id} (${row.filename}) WITHOUT LABELS:`);
console.log(` Date: ${meta.tanggal}`);
console.log(` SO : ${meta.noSO}`);
console.log(` DO : ${meta.noDO}`);
console.log(` PO : ${meta.noPO}`);
}
await client.end();
}
main().catch(console.error);
+24 -24
View File
@@ -1,24 +1,24 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow dev requests from any host — needed for tunnel access (ngrok, cloudflare, etc.)
// and direct LAN/WiFi IP access from Android devices.
allowedDevOrigins: [
"127.0.0.1",
"*.trycloudflare.com",
"*.ngrok.io",
"*.ngrok-free.app",
"*.ngrok-free.dev",
"*.ngrok.app",
"*.loca.lt",
"*.serveo.net",
"*.demoin.id",
// Common LAN IP ranges (WiFi / hotspot)
"192.168.*",
"10.*",
"172.*",
],
serverExternalPackages: ["pg"]
};
export default nextConfig;
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow dev requests from any host — needed for tunnel access (ngrok, cloudflare, etc.)
// and direct LAN/WiFi IP access from Android devices.
allowedDevOrigins: [
"127.0.0.1",
"*.trycloudflare.com",
"*.ngrok.io",
"*.ngrok-free.app",
"*.ngrok-free.dev",
"*.ngrok.app",
"*.loca.lt",
"*.serveo.net",
"*.demoin.id",
// Common LAN IP ranges (WiFi / hotspot)
"192.168.*",
"10.*",
"172.*",
],
serverExternalPackages: ["pg"]
};
export default nextConfig;
+7298 -7298
View File
File diff suppressed because it is too large. Load diff
+35 -35
View File
@@ -1,35 +1,35 @@
{
"name": "pfm-web-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"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",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"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",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"puppeteer": "^25.3.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
{
"name": "pfm-web-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"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",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"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",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"puppeteer": "^25.3.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7 -7
View File
@@ -1,7 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -1,132 +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)
#!/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)
@@ -1,375 +1,375 @@
#!/usr/bin/env python3
"""
Ultralytics YOLO Classification Training Script
Trains a product-packaging classifier from class folders in `foto-kemasan-v2`.
Each subfolder under `foto-kemasan-v2/` is one product class; images live directly
inside that folder.
Usage (from repo root or this directory):
# 1) Train the model (defaults to foto-kemasan-v2, 100 epochs)
uv run python pfm-web-app/public/produk-pfm/train_classifier.py train --imgsz 224
# 2) Run prediction on an image using the trained weights
uv run python pfm-web-app/public/produk-pfm/train_classifier.py predict \\
--image "pfm-web-app/public/produk-pfm/foto-kemasan-v2/15030101 FIESTA CRINKLE CUT 500 GR/WhatsApp Image 2026-05-28 at 11.46.31.jpeg"
"""
import os
import re
import sys
import shutil
import random
import argparse
from datetime import date
from pathlib import Path
import torch
try:
from ultralytics import YOLO
except ImportError:
print("Error: 'ultralytics' library not found. Please install it using: uv add ultralytics")
sys.exit(1)
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DATASET_DIR = SCRIPT_DIR / "foto-kemasan-v2"
DEFAULT_SPLIT_DIR = SCRIPT_DIR / "yolo_dataset"
DEFAULT_MODEL = SCRIPT_DIR / "yolo26n-cls.pt"
DEFAULT_MODELS_DIR = SCRIPT_DIR / "models"
DEFAULT_PROJECT = SCRIPT_DIR / "runs" / "classify"
DEFAULT_EPOCHS = 100
def classifier_output_path(epochs: int = DEFAULT_EPOCHS, run_date: date | None = None) -> Path:
"""Build the dated classifier artifact path under models/."""
run_date = run_date or date.today()
return DEFAULT_MODELS_DIR / f"produk-pfm-classifier-26n-{epochs}e-{run_date:%Y-%m-%d}.pt"
def _classifier_date_from_name(path: Path) -> date | None:
match = re.search(
r"produk-pfm-classifier-26n-\d+e-(\d{4}-\d{2}-\d{2})\.pt$",
path.name,
)
if not match:
return None
year, month, day = (int(part) for part in match.group(1).split("-"))
return date(year, month, day)
def latest_classifier_weights(models_dir: Path = DEFAULT_MODELS_DIR) -> Path:
"""Return the newest produk-pfm-classifier weights in models/, if any."""
if not models_dir.is_dir():
return classifier_output_path()
candidates = list(models_dir.glob("produk-pfm-classifier-26n-*e-*.pt"))
if not candidates:
return classifier_output_path()
def sort_key(path: Path) -> tuple[date, float]:
name_date = _classifier_date_from_name(path) or date.min
return (name_date, path.stat().st_mtime)
return max(candidates, key=sort_key)
VALID_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
AUG_SUFFIX_RE = re.compile(r"_aug_\d+$")
def is_image_file(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in VALID_IMAGE_EXTENSIONS
def _source_group_key(filename_stem: str) -> str:
"""Strip an `_aug_<n>` suffix so an augmented image groups with its source photo."""
return AUG_SUFFIX_RE.sub("", filename_stem)
def split_dataset(src_dir: Path, dest_dir: Path, split_ratio: float = 0.8, seed: int = 42):
"""
Split class folders from src_dir into train/val folders in dest_dir.
Ensures every class with 2+ images keeps at least one image in validation.
Splits by *source photo group*, not by individual file: an augmented image
(`photo1_aug_2.jpeg`) always stays in the same split as its source
(`photo1.jpeg`). Splitting file-by-file would let near-duplicate images
land on opposite sides of train/val, inflating val accuracy with
memorization instead of measuring generalization.
"""
random.seed(seed)
train_dir = dest_dir / "train"
val_dir = dest_dir / "val"
if dest_dir.exists():
print(f"Cleaning existing split directory: {dest_dir}")
shutil.rmtree(dest_dir)
train_dir.mkdir(parents=True, exist_ok=True)
val_dir.mkdir(parents=True, exist_ok=True)
exclude_dirs = {dest_dir.name, "train", "val"}
class_dirs = [d for d in src_dir.iterdir() if d.is_dir() and d.name not in exclude_dirs]
class_dirs.sort()
print(f"Found {len(class_dirs)} product classes in {src_dir}")
total_train = 0
total_val = 0
for c_dir in class_dirs:
class_name = c_dir.name
images = sorted(
[f for f in c_dir.iterdir() if is_image_file(f)],
key=lambda p: p.name,
)
num_images = len(images)
if num_images == 0:
print(f"Warning: Class '{class_name}' has 0 images. Skipping.")
continue
# Group by source photo (stripping any `_aug_N` suffix) so an
# augmented image and the photo it came from always land on the same
# side of the split.
groups: dict[str, list[Path]] = {}
for img in images:
groups.setdefault(_source_group_key(img.stem), []).append(img)
group_keys = sorted(groups.keys())
random.shuffle(group_keys)
class_train_dir = train_dir / class_name
class_val_dir = val_dir / class_name
class_train_dir.mkdir(parents=True, exist_ok=True)
class_val_dir.mkdir(parents=True, exist_ok=True)
num_groups = len(group_keys)
if num_groups == 1:
train_groups = group_keys
val_groups = group_keys
elif num_groups == 2:
train_groups = [group_keys[0]]
val_groups = [group_keys[1]]
else:
split_idx = max(1, int(num_groups * split_ratio))
split_idx = min(split_idx, num_groups - 1)
train_groups = group_keys[:split_idx]
val_groups = group_keys[split_idx:]
train_images = [img for key in train_groups for img in groups[key]]
val_images = [img for key in val_groups for img in groups[key]]
for img in train_images:
shutil.copy(img, class_train_dir / img.name)
total_train += 1
for img in val_images:
shutil.copy(img, class_val_dir / img.name)
total_val += 1
print(
f" Class '{class_name}': {len(train_images)} train, "
f"{len(val_images)} val (from {num_groups} source photos, {num_images} files total)"
)
print(f"Dataset split completed: {total_train} train images, {total_val} validation images.")
print(f"Split dataset located at: {dest_dir.absolute()}")
def train_model(args):
"""Handles training the YOLO classification model."""
src_path = Path(args.src_dir).resolve()
dest_path = Path(args.split_dir).resolve()
if not src_path.is_dir():
print(f"Error: Source dataset directory not found: {src_path}")
sys.exit(1)
print(f"--- Preparing Dataset from {src_path} ---")
split_dataset(src_path, dest_path, split_ratio=args.split_ratio)
model_path = Path(args.model).resolve()
print(f"\n--- Initializing YOLO Model ({model_path}) ---")
model = YOLO(str(model_path))
if args.device:
device = args.device
else:
device = "0" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
print("\n--- Starting Training ---")
results = model.train(
data=str(dest_path),
epochs=args.epochs,
imgsz=args.imgsz,
batch=args.batch,
device=device,
project=str(Path(args.project).resolve()),
name=args.name,
exist_ok=True,
workers=args.workers,
lr0=args.lr,
optimizer=args.optimizer,
seed=42,
)
best_weights = Path(results.save_dir) / "weights" / "best.pt"
output_path = Path(args.output).resolve() if args.output else classifier_output_path(args.epochs)
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(best_weights, output_path)
print("\nTraining completed successfully!")
print(f"Run weights saved at: {best_weights}")
print(f"Published model saved at: {output_path}")
if args.export:
print("\n--- Exporting model to ONNX format ---")
try:
export_model = YOLO(str(output_path))
onnx_path = Path(export_model.export(format="onnx"))
dated_onnx = output_path.with_suffix(".onnx")
if onnx_path.resolve() != dated_onnx.resolve():
shutil.copy2(onnx_path, dated_onnx)
print(f"Model exported successfully to: {dated_onnx}")
except Exception as e:
print(f"Warning: ONNX export failed: {e}")
print("\nYou can run predictions with:")
print(f" uv run python {Path(__file__).name} predict --image <image_path> --model {output_path}")
def predict_image(args):
"""Runs classification inference on a single image."""
model_path = Path(args.model).resolve()
image_path = Path(args.image).resolve()
if not model_path.exists():
print(f"Error: Model weights not found at {model_path}")
sys.exit(1)
if not image_path.exists():
print(f"Error: Target image file not found at {image_path}")
sys.exit(1)
print(f"Loading model from {model_path}...")
model = YOLO(str(model_path))
print(f"Running prediction on {image_path}...")
results = model(str(image_path))
for result in results:
probs = result.probs
top1_idx = probs.top1
top1_conf = float(probs.top1conf)
top1_name = result.names[top1_idx]
print("\n=== Classification Results ===")
print(f"Top-1 Prediction: {top1_name} (Confidence: {top1_conf:.4f})")
print("\nAll Probabilities:")
sorted_probs = sorted(
[(result.names[i], float(val)) for i, val in enumerate(probs.data)],
key=lambda x: x[1],
reverse=True,
)
for name, score in sorted_probs:
print(f" {name}: {score:.4f}")
def main():
parser = argparse.ArgumentParser(
description="Ultralytics YOLO classification utility for produk-pfm packaging photos."
)
subparsers = parser.add_subparsers(dest="command", required=True, help="Command to run")
train_parser = subparsers.add_parser("train", help="Train a classification model")
train_parser.add_argument(
"--src-dir",
type=str,
default=str(DEFAULT_DATASET_DIR),
help=f"Source dataset directory with one class folder per product (default: {DEFAULT_DATASET_DIR.name})",
)
train_parser.add_argument(
"--split-dir",
type=str,
default=str(DEFAULT_SPLIT_DIR),
help="Output split dataset directory",
)
train_parser.add_argument(
"--split-ratio",
type=float,
default=0.8,
help="Train/val split ratio for classes with 3+ images (default: 0.8)",
)
train_parser.add_argument(
"--model",
type=str,
default=str(DEFAULT_MODEL),
help="Pretrained model (e.g. yolo26n-cls.pt, yolo11n-cls.pt, yolov8n-cls.pt)",
)
train_parser.add_argument(
"--epochs",
type=int,
default=DEFAULT_EPOCHS,
help=f"Number of training epochs (default: {DEFAULT_EPOCHS})",
)
train_parser.add_argument(
"--output",
type=str,
default=None,
help=(
"Published .pt output path (default: "
"models/produk-pfm-classifier-26n-{epochs}e-{YYYY-MM-DD}.pt)"
),
)
train_parser.add_argument("--imgsz", type=int, default=224, help="Target image size for classification")
train_parser.add_argument("--batch", type=int, default=8, help="Batch size for training")
train_parser.add_argument(
"--device",
type=str,
default=None,
help="Device to run on (e.g. 0 or 'cpu'). Default is GPU if available.",
)
train_parser.add_argument(
"--project",
type=str,
default=str(DEFAULT_PROJECT),
help="Project output folder name",
)
train_parser.add_argument("--name", type=str, default="train", help="Experiment name")
train_parser.add_argument("--workers", type=int, default=4, help="Number of data loading workers")
train_parser.add_argument("--lr", type=float, default=0.01, help="Initial learning rate")
train_parser.add_argument(
"--optimizer",
type=str,
default="auto",
choices=["SGD", "Adam", "AdamW", "RMSProp", "auto"],
help="Optimizer to use",
)
train_parser.add_argument(
"--export",
action="store_true",
default=True,
help="Export model to ONNX after training",
)
predict_parser = subparsers.add_parser("predict", help="Predict class of an image")
predict_parser.add_argument("--image", type=str, required=True, help="Path to image file")
predict_parser.add_argument(
"--model",
type=str,
default=str(latest_classifier_weights()),
help="Path to trained YOLO .pt model weights (default: newest models/produk-pfm-classifier-*.pt)",
)
args = parser.parse_args()
if args.command == "train":
train_model(args)
elif args.command == "predict":
predict_image(args)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Ultralytics YOLO Classification Training Script
Trains a product-packaging classifier from class folders in `foto-kemasan-v2`.
Each subfolder under `foto-kemasan-v2/` is one product class; images live directly
inside that folder.
Usage (from repo root or this directory):
# 1) Train the model (defaults to foto-kemasan-v2, 100 epochs)
uv run python pfm-web-app/public/produk-pfm/train_classifier.py train --imgsz 224
# 2) Run prediction on an image using the trained weights
uv run python pfm-web-app/public/produk-pfm/train_classifier.py predict \\
--image "pfm-web-app/public/produk-pfm/foto-kemasan-v2/15030101 FIESTA CRINKLE CUT 500 GR/WhatsApp Image 2026-05-28 at 11.46.31.jpeg"
"""
import os
import re
import sys
import shutil
import random
import argparse
from datetime import date
from pathlib import Path
import torch
try:
from ultralytics import YOLO
except ImportError:
print("Error: 'ultralytics' library not found. Please install it using: uv add ultralytics")
sys.exit(1)
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DATASET_DIR = SCRIPT_DIR / "foto-kemasan-v2"
DEFAULT_SPLIT_DIR = SCRIPT_DIR / "yolo_dataset"
DEFAULT_MODEL = SCRIPT_DIR / "yolo26n-cls.pt"
DEFAULT_MODELS_DIR = SCRIPT_DIR / "models"
DEFAULT_PROJECT = SCRIPT_DIR / "runs" / "classify"
DEFAULT_EPOCHS = 100
def classifier_output_path(epochs: int = DEFAULT_EPOCHS, run_date: date | None = None) -> Path:
"""Build the dated classifier artifact path under models/."""
run_date = run_date or date.today()
return DEFAULT_MODELS_DIR / f"produk-pfm-classifier-26n-{epochs}e-{run_date:%Y-%m-%d}.pt"
def _classifier_date_from_name(path: Path) -> date | None:
match = re.search(
r"produk-pfm-classifier-26n-\d+e-(\d{4}-\d{2}-\d{2})\.pt$",
path.name,
)
if not match:
return None
year, month, day = (int(part) for part in match.group(1).split("-"))
return date(year, month, day)
def latest_classifier_weights(models_dir: Path = DEFAULT_MODELS_DIR) -> Path:
"""Return the newest produk-pfm-classifier weights in models/, if any."""
if not models_dir.is_dir():
return classifier_output_path()
candidates = list(models_dir.glob("produk-pfm-classifier-26n-*e-*.pt"))
if not candidates:
return classifier_output_path()
def sort_key(path: Path) -> tuple[date, float]:
name_date = _classifier_date_from_name(path) or date.min
return (name_date, path.stat().st_mtime)
return max(candidates, key=sort_key)
VALID_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
AUG_SUFFIX_RE = re.compile(r"_aug_\d+$")
def is_image_file(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in VALID_IMAGE_EXTENSIONS
def _source_group_key(filename_stem: str) -> str:
"""Strip an `_aug_<n>` suffix so an augmented image groups with its source photo."""
return AUG_SUFFIX_RE.sub("", filename_stem)
def split_dataset(src_dir: Path, dest_dir: Path, split_ratio: float = 0.8, seed: int = 42):
"""
Split class folders from src_dir into train/val folders in dest_dir.
Ensures every class with 2+ images keeps at least one image in validation.
Splits by *source photo group*, not by individual file: an augmented image
(`photo1_aug_2.jpeg`) always stays in the same split as its source
(`photo1.jpeg`). Splitting file-by-file would let near-duplicate images
land on opposite sides of train/val, inflating val accuracy with
memorization instead of measuring generalization.
"""
random.seed(seed)
train_dir = dest_dir / "train"
val_dir = dest_dir / "val"
if dest_dir.exists():
print(f"Cleaning existing split directory: {dest_dir}")
shutil.rmtree(dest_dir)
train_dir.mkdir(parents=True, exist_ok=True)
val_dir.mkdir(parents=True, exist_ok=True)
exclude_dirs = {dest_dir.name, "train", "val"}
class_dirs = [d for d in src_dir.iterdir() if d.is_dir() and d.name not in exclude_dirs]
class_dirs.sort()
print(f"Found {len(class_dirs)} product classes in {src_dir}")
total_train = 0
total_val = 0
for c_dir in class_dirs:
class_name = c_dir.name
images = sorted(
[f for f in c_dir.iterdir() if is_image_file(f)],
key=lambda p: p.name,
)
num_images = len(images)
if num_images == 0:
print(f"Warning: Class '{class_name}' has 0 images. Skipping.")
continue
# Group by source photo (stripping any `_aug_N` suffix) so an
# augmented image and the photo it came from always land on the same
# side of the split.
groups: dict[str, list[Path]] = {}
for img in images:
groups.setdefault(_source_group_key(img.stem), []).append(img)
group_keys = sorted(groups.keys())
random.shuffle(group_keys)
class_train_dir = train_dir / class_name
class_val_dir = val_dir / class_name
class_train_dir.mkdir(parents=True, exist_ok=True)
class_val_dir.mkdir(parents=True, exist_ok=True)
num_groups = len(group_keys)
if num_groups == 1:
train_groups = group_keys
val_groups = group_keys
elif num_groups == 2:
train_groups = [group_keys[0]]
val_groups = [group_keys[1]]
else:
split_idx = max(1, int(num_groups * split_ratio))
split_idx = min(split_idx, num_groups - 1)
train_groups = group_keys[:split_idx]
val_groups = group_keys[split_idx:]
train_images = [img for key in train_groups for img in groups[key]]
val_images = [img for key in val_groups for img in groups[key]]
for img in train_images:
shutil.copy(img, class_train_dir / img.name)
total_train += 1
for img in val_images:
shutil.copy(img, class_val_dir / img.name)
total_val += 1
print(
f" Class '{class_name}': {len(train_images)} train, "
f"{len(val_images)} val (from {num_groups} source photos, {num_images} files total)"
)
print(f"Dataset split completed: {total_train} train images, {total_val} validation images.")
print(f"Split dataset located at: {dest_dir.absolute()}")
def train_model(args):
"""Handles training the YOLO classification model."""
src_path = Path(args.src_dir).resolve()
dest_path = Path(args.split_dir).resolve()
if not src_path.is_dir():
print(f"Error: Source dataset directory not found: {src_path}")
sys.exit(1)
print(f"--- Preparing Dataset from {src_path} ---")
split_dataset(src_path, dest_path, split_ratio=args.split_ratio)
model_path = Path(args.model).resolve()
print(f"\n--- Initializing YOLO Model ({model_path}) ---")
model = YOLO(str(model_path))
if args.device:
device = args.device
else:
device = "0" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
print("\n--- Starting Training ---")
results = model.train(
data=str(dest_path),
epochs=args.epochs,
imgsz=args.imgsz,
batch=args.batch,
device=device,
project=str(Path(args.project).resolve()),
name=args.name,
exist_ok=True,
workers=args.workers,
lr0=args.lr,
optimizer=args.optimizer,
seed=42,
)
best_weights = Path(results.save_dir) / "weights" / "best.pt"
output_path = Path(args.output).resolve() if args.output else classifier_output_path(args.epochs)
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(best_weights, output_path)
print("\nTraining completed successfully!")
print(f"Run weights saved at: {best_weights}")
print(f"Published model saved at: {output_path}")
if args.export:
print("\n--- Exporting model to ONNX format ---")
try:
export_model = YOLO(str(output_path))
onnx_path = Path(export_model.export(format="onnx"))
dated_onnx = output_path.with_suffix(".onnx")
if onnx_path.resolve() != dated_onnx.resolve():
shutil.copy2(onnx_path, dated_onnx)
print(f"Model exported successfully to: {dated_onnx}")
except Exception as e:
print(f"Warning: ONNX export failed: {e}")
print("\nYou can run predictions with:")
print(f" uv run python {Path(__file__).name} predict --image <image_path> --model {output_path}")
def predict_image(args):
"""Runs classification inference on a single image."""
model_path = Path(args.model).resolve()
image_path = Path(args.image).resolve()
if not model_path.exists():
print(f"Error: Model weights not found at {model_path}")
sys.exit(1)
if not image_path.exists():
print(f"Error: Target image file not found at {image_path}")
sys.exit(1)
print(f"Loading model from {model_path}...")
model = YOLO(str(model_path))
print(f"Running prediction on {image_path}...")
results = model(str(image_path))
for result in results:
probs = result.probs
top1_idx = probs.top1
top1_conf = float(probs.top1conf)
top1_name = result.names[top1_idx]
print("\n=== Classification Results ===")
print(f"Top-1 Prediction: {top1_name} (Confidence: {top1_conf:.4f})")
print("\nAll Probabilities:")
sorted_probs = sorted(
[(result.names[i], float(val)) for i, val in enumerate(probs.data)],
key=lambda x: x[1],
reverse=True,
)
for name, score in sorted_probs:
print(f" {name}: {score:.4f}")
def main():
parser = argparse.ArgumentParser(
description="Ultralytics YOLO classification utility for produk-pfm packaging photos."
)
subparsers = parser.add_subparsers(dest="command", required=True, help="Command to run")
train_parser = subparsers.add_parser("train", help="Train a classification model")
train_parser.add_argument(
"--src-dir",
type=str,
default=str(DEFAULT_DATASET_DIR),
help=f"Source dataset directory with one class folder per product (default: {DEFAULT_DATASET_DIR.name})",
)
train_parser.add_argument(
"--split-dir",
type=str,
default=str(DEFAULT_SPLIT_DIR),
help="Output split dataset directory",
)
train_parser.add_argument(
"--split-ratio",
type=float,
default=0.8,
help="Train/val split ratio for classes with 3+ images (default: 0.8)",
)
train_parser.add_argument(
"--model",
type=str,
default=str(DEFAULT_MODEL),
help="Pretrained model (e.g. yolo26n-cls.pt, yolo11n-cls.pt, yolov8n-cls.pt)",
)
train_parser.add_argument(
"--epochs",
type=int,
default=DEFAULT_EPOCHS,
help=f"Number of training epochs (default: {DEFAULT_EPOCHS})",
)
train_parser.add_argument(
"--output",
type=str,
default=None,
help=(
"Published .pt output path (default: "
"models/produk-pfm-classifier-26n-{epochs}e-{YYYY-MM-DD}.pt)"
),
)
train_parser.add_argument("--imgsz", type=int, default=224, help="Target image size for classification")
train_parser.add_argument("--batch", type=int, default=8, help="Batch size for training")
train_parser.add_argument(
"--device",
type=str,
default=None,
help="Device to run on (e.g. 0 or 'cpu'). Default is GPU if available.",
)
train_parser.add_argument(
"--project",
type=str,
default=str(DEFAULT_PROJECT),
help="Project output folder name",
)
train_parser.add_argument("--name", type=str, default="train", help="Experiment name")
train_parser.add_argument("--workers", type=int, default=4, help="Number of data loading workers")
train_parser.add_argument("--lr", type=float, default=0.01, help="Initial learning rate")
train_parser.add_argument(
"--optimizer",
type=str,
default="auto",
choices=["SGD", "Adam", "AdamW", "RMSProp", "auto"],
help="Optimizer to use",
)
train_parser.add_argument(
"--export",
action="store_true",
default=True,
help="Export model to ONNX after training",
)
predict_parser = subparsers.add_parser("predict", help="Predict class of an image")
predict_parser.add_argument("--image", type=str, required=True, help="Path to image file")
predict_parser.add_argument(
"--model",
type=str,
default=str(latest_classifier_weights()),
help="Path to trained YOLO .pt model weights (default: newest models/produk-pfm-classifier-*.pt)",
)
args = parser.parse_args()
if args.command == "train":
train_model(args)
elif args.command == "predict":
predict_image(args)
if __name__ == "__main__":
main()
+44 -44
View File
@@ -1,44 +1,44 @@
const { Client } = require('pg');
async function main() {
const client = new Client({
host: process.env.PGHOST || "paddleocr-db",
port: parseInt(process.env.PGPORT || "5432"),
user: process.env.PGUSER || "postgres",
password: process.env.PGPASSWORD || "postgres",
database: process.env.PGDATABASE || "dopfm",
});
await client.connect();
console.log('Connected to PG database.');
const res = await client.query("SELECT id, filename FROM documents WHERE parsed = false;");
console.log(`Found ${res.rows.length} documents to parse.`);
for (let i = 0; i < res.rows.length; i++) {
const row = res.rows[i];
console.log(`[${i+1}/${res.rows.length}] Reparsing ${row.filename} (ID: ${row.id})...`);
try {
const response = await fetch('http://localhost:3000/api/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: row.filename })
});
if (response.ok) {
console.log(`Successfully triggered parse for ${row.filename}. Status: ${response.status}`);
} else {
console.error(`Failed to parse ${row.filename}. Status: ${response.status}, Error: ${await response.text()}`);
}
} catch (err) {
console.error(`Fetch error for ${row.filename}:`, err.message);
}
}
await client.end();
console.log('Done reparsing.');
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
const { Client } = require('pg');
async function main() {
const client = new Client({
host: process.env.PGHOST || "paddleocr-db",
port: parseInt(process.env.PGPORT || "5432"),
user: process.env.PGUSER || "postgres",
password: process.env.PGPASSWORD || "postgres",
database: process.env.PGDATABASE || "dopfm",
});
await client.connect();
console.log('Connected to PG database.');
const res = await client.query("SELECT id, filename FROM documents WHERE parsed = false;");
console.log(`Found ${res.rows.length} documents to parse.`);
for (let i = 0; i < res.rows.length; i++) {
const row = res.rows[i];
console.log(`[${i+1}/${res.rows.length}] Reparsing ${row.filename} (ID: ${row.id})...`);
try {
const response = await fetch('http://localhost:3000/api/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: row.filename })
});
if (response.ok) {
console.log(`Successfully triggered parse for ${row.filename}. Status: ${response.status}`);
} else {
console.error(`Failed to parse ${row.filename}. Status: ${response.status}, Error: ${await response.text()}`);
}
} catch (err) {
console.error(`Fetch error for ${row.filename}:`, err.message);
}
}
await client.end();
console.log('Done reparsing.');
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
+244 -244
View File
@@ -1,244 +1,244 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
const { Client } = require('pg');
const BASE_URL = 'http://localhost:3000/api/parse';
const testFiles = [
"do-001.jpg",
"do-002.jpg",
"do-003.jpg",
"do-004.jpg",
"do-005.jpg",
"do-006.jpg",
"do-007.jpg",
"do-008.jpg",
"do-009.jpg",
"do-010.jpg",
"do-011.jpg",
"do-012.jpg",
"do-013.jpg",
"do-014.jpg"
];
function postJSON(url, body) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const bodyStr = JSON.stringify(body);
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr)
},
timeout: 1200000 // 20 minutes
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
json: async () => JSON.parse(data),
text: async () => data
});
});
});
req.on('timeout', () => {
req.destroy(new Error('Request Timeout (20m)'));
});
req.on('error', (err) => { reject(err); });
req.write(bodyStr);
req.end();
});
}
async function getDocumentMetadataFromDb(filename) {
const client = new Client({
host: 'paddleocr-db',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'dopfm'
});
try {
await client.connect();
const res = await client.query('SELECT metadata FROM documents WHERE filename = $1', [filename]);
return res.rows[0]?.metadata || {};
} catch (err) {
console.error('Database query failed:', err.message);
return {};
} finally {
await client.end();
}
}
async function main() {
console.log(`Starting single image test for ${testFiles.length} file...`);
const summaryTmpFile = '/uploads/test_images_report_summary.tmp';
const detailsTmpFile = '/uploads/test_images_report_details.tmp';
const jsonlFile = '/uploads/test_images_results.jsonl';
const finalReportFile = '/uploads/test_images_report.md';
// Initialize summary header
let summaryHeader = `# Batch OCR Parsing Test Report\n\n`;
summaryHeader += `Processed **${testFiles.length}** file from \`backend/sources/test-images\`.\n\n`;
summaryHeader += `## Summary Table\n\n`;
summaryHeader += `| No | Filename | Status | Tilt | Auto-Rotated | PO | SO | DO | Date | Store Match | Items Count |\n`;
summaryHeader += `|---|---|---|---|---|---|---|---|---|---|---|\n`;
fs.writeFileSync(summaryTmpFile, summaryHeader);
// Initialize details header
let detailsHeader = `\n\n## Detailed Results per Image\n\n`;
fs.writeFileSync(detailsTmpFile, detailsHeader);
// Clean jsonl
fs.writeFileSync(jsonlFile, '');
for (let idx = 0; idx < testFiles.length; idx++) {
const file = testFiles[idx];
console.log(`[${idx + 1}/${testFiles.length}] Processing file: ${file}`);
try {
const response = await postJSON(BASE_URL, { filename: file });
if (!response.ok) {
const errorText = await response.text();
console.error(`Error parsing file ${file}: ${errorText}`);
// Write fail state incrementally
const tableLine = `| ${idx + 1} | \`${file}\` | **Failed** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |\n`;
fs.appendFileSync(summaryTmpFile, tableLine);
let detailedText = `### ${idx + 1}. \`${file}\`\n`;
detailedText += `- **Status**: Failed\n`;
detailedText += `- **Error Detail**: \`${errorText || 'Unknown error'}\`\n`;
detailedText += `\n---\n\n`;
fs.appendFileSync(detailsTmpFile, detailedText);
fs.appendFileSync(jsonlFile, JSON.stringify({
filename: file,
status: 'Failed',
error: errorText || 'Unknown error'
}) + '\n');
continue;
}
const resData = await response.json();
const pipelineRes = resData.result || {};
const page0 = pipelineRes.layoutParsingResults?.[0] || {};
const rawMarkdown = page0.markdown?.text || "N/A";
const info = pipelineRes.pipeline_info || {};
// Direct DB query for accurate metadata (bypassing Auth)
const docMeta = await getDocumentMetadataFromDb(file);
const tiltStr = info.tilt !== undefined ? parseFloat(info.tilt).toFixed(2) : 'N/A';
const unwarpedStr = info.unwarped ? 'Yes' : 'No';
const itemsCount = (resData.items || []).length;
// Write success state incrementally
const tableLine = `| ${idx + 1} | \`${file}\` | **Success** | ${tiltStr}° | ${unwarpedStr} | \`${docMeta.noPO || 'N/A'}\` | \`${docMeta.noSO || 'N/A'}\` | \`${docMeta.noDO || 'N/A'}\` | \`${docMeta.tanggal || 'N/A'}\` | ${docMeta.orderUntuk || 'N/A'} | ${itemsCount} |\n`;
fs.appendFileSync(summaryTmpFile, tableLine);
let detailedText = `### ${idx + 1}. \`${file}\`\n`;
detailedText += `- **Status**: Success\n`;
detailedText += `- **Tilt Detected**: ${tiltStr}°\n`;
detailedText += `- **Auto-Rotated/Unwarped**: ${unwarpedStr}\n`;
detailedText += `- **Extracted Metadata**:\n`;
detailedText += ` * **PO**: \`${docMeta.noPO || 'N/A'}\`\n`;
detailedText += ` * **SO**: \`${docMeta.noSO || 'N/A'}\`\n`;
detailedText += ` * **DO**: \`${docMeta.noDO || 'N/A'}\`\n`;
detailedText += ` * **Tanggal**: \`${docMeta.tanggal || 'N/A'}\`\n`;
detailedText += ` * **Customer**: \`${docMeta.customerInfo || 'N/A'}\`\n`;
detailedText += ` * **Store**: \`${docMeta.orderUntuk || 'N/A'}\`\n`;
detailedText += ` * **Alamat**: \`${docMeta.alamat || 'N/A'}\`\n`;
detailedText += ` * **Plat Nomor**: \`${docMeta.platTruk || 'N/A'}\`\n`;
detailedText += `- **Raw Layout Markdown**:\n`;
detailedText += `\`\`\`markdown\n${rawMarkdown}\n\`\`\`\n`;
detailedText += `- **Parsed Items (${itemsCount})**:\n`;
if (itemsCount > 0) {
detailedText += ` | Code (SKU) | Name | Qty | Price |\n`;
detailedText += ` |---|---|---|---|\n`;
(resData.items || []).forEach(item => {
detailedText += ` | \`${item.kodeBarang}\` | ${item.namaBarang} | \`${item.banyak}\` | \`${item.jumlah}\` |\n`;
});
} else {
detailedText += ` *No valid SKU items parsed.*\n`;
}
detailedText += `\n---\n\n`;
fs.appendFileSync(detailsTmpFile, detailedText);
fs.appendFileSync(jsonlFile, JSON.stringify({
filename: file,
status: 'Success',
tilt: tiltStr,
unwarped: unwarpedStr,
rawMarkdown: resData.postProcessingDetails?.rawMarkdown || "",
layer1RawRegex: resData.postProcessingDetails?.layer1RawRegex || {},
layer2Sanitized: resData.postProcessingDetails?.layer2Sanitized || {},
layer3Final: resData.postProcessingDetails?.layer3Final || {},
metadata: docMeta,
items: resData.items || []
}) + '\n');
} catch (err) {
console.error(`Exception during file ${file}:`, err);
const tableLine = `| ${idx + 1} | \`${file}\` | **Error** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |\n`;
fs.appendFileSync(summaryTmpFile, tableLine);
let detailedText = `### ${idx + 1}. \`${file}\`\n`;
detailedText += `- **Status**: Error\n`;
detailedText += `- **Error Detail**: \`${err.message}\`\n`;
detailedText += `\n---\n\n`;
fs.appendFileSync(detailsTmpFile, detailedText);
fs.appendFileSync(jsonlFile, JSON.stringify({
filename: file,
status: 'Error',
error: err.message
}) + '\n');
}
}
// Combine temporary files into the final report
try {
const summaryContent = fs.readFileSync(summaryTmpFile, 'utf8');
const detailsContent = fs.readFileSync(detailsTmpFile, 'utf8');
fs.writeFileSync(finalReportFile, summaryContent + '\n' + detailsContent);
// Clean up temporary files
fs.unlinkSync(summaryTmpFile);
fs.unlinkSync(detailsTmpFile);
} catch (combineErr) {
console.error('Failed to combine test reports:', combineErr);
}
// Compile JSONL into the final JSON v2
try {
const lines = fs.readFileSync(jsonlFile, 'utf8').split('\n').filter(Boolean);
const results = lines.map(line => JSON.parse(line));
fs.writeFileSync('/uploads/ai_results_v2.json', JSON.stringify(results, null, 2));
console.log('Compiled results saved to /uploads/ai_results_v2.json');
} catch (compileErr) {
console.error('Failed to compile results into JSON v2:', compileErr);
}
console.log('Batch test completed. Report written to /uploads/test_images_report.md');
}
main();
const fs = require('fs');
const path = require('path');
const http = require('http');
const { Client } = require('pg');
const BASE_URL = 'http://localhost:3000/api/parse';
const testFiles = [
"do-001.jpg",
"do-002.jpg",
"do-003.jpg",
"do-004.jpg",
"do-005.jpg",
"do-006.jpg",
"do-007.jpg",
"do-008.jpg",
"do-009.jpg",
"do-010.jpg",
"do-011.jpg",
"do-012.jpg",
"do-013.jpg",
"do-014.jpg"
];
function postJSON(url, body) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const bodyStr = JSON.stringify(body);
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr)
},
timeout: 1200000 // 20 minutes
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
json: async () => JSON.parse(data),
text: async () => data
});
});
});
req.on('timeout', () => {
req.destroy(new Error('Request Timeout (20m)'));
});
req.on('error', (err) => { reject(err); });
req.write(bodyStr);
req.end();
});
}
async function getDocumentMetadataFromDb(filename) {
const client = new Client({
host: 'paddleocr-db',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'dopfm'
});
try {
await client.connect();
const res = await client.query('SELECT metadata FROM documents WHERE filename = $1', [filename]);
return res.rows[0]?.metadata || {};
} catch (err) {
console.error('Database query failed:', err.message);
return {};
} finally {
await client.end();
}
}
async function main() {
console.log(`Starting single image test for ${testFiles.length} file...`);
const summaryTmpFile = '/uploads/test_images_report_summary.tmp';
const detailsTmpFile = '/uploads/test_images_report_details.tmp';
const jsonlFile = '/uploads/test_images_results.jsonl';
const finalReportFile = '/uploads/test_images_report.md';
// Initialize summary header
let summaryHeader = `# Batch OCR Parsing Test Report\n\n`;
summaryHeader += `Processed **${testFiles.length}** file from \`backend/sources/test-images\`.\n\n`;
summaryHeader += `## Summary Table\n\n`;
summaryHeader += `| No | Filename | Status | Tilt | Auto-Rotated | PO | SO | DO | Date | Store Match | Items Count |\n`;
summaryHeader += `|---|---|---|---|---|---|---|---|---|---|---|\n`;
fs.writeFileSync(summaryTmpFile, summaryHeader);
// Initialize details header
let detailsHeader = `\n\n## Detailed Results per Image\n\n`;
fs.writeFileSync(detailsTmpFile, detailsHeader);
// Clean jsonl
fs.writeFileSync(jsonlFile, '');
for (let idx = 0; idx < testFiles.length; idx++) {
const file = testFiles[idx];
console.log(`[${idx + 1}/${testFiles.length}] Processing file: ${file}`);
try {
const response = await postJSON(BASE_URL, { filename: file });
if (!response.ok) {
const errorText = await response.text();
console.error(`Error parsing file ${file}: ${errorText}`);
// Write fail state incrementally
const tableLine = `| ${idx + 1} | \`${file}\` | **Failed** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |\n`;
fs.appendFileSync(summaryTmpFile, tableLine);
let detailedText = `### ${idx + 1}. \`${file}\`\n`;
detailedText += `- **Status**: Failed\n`;
detailedText += `- **Error Detail**: \`${errorText || 'Unknown error'}\`\n`;
detailedText += `\n---\n\n`;
fs.appendFileSync(detailsTmpFile, detailedText);
fs.appendFileSync(jsonlFile, JSON.stringify({
filename: file,
status: 'Failed',
error: errorText || 'Unknown error'
}) + '\n');
continue;
}
const resData = await response.json();
const pipelineRes = resData.result || {};
const page0 = pipelineRes.layoutParsingResults?.[0] || {};
const rawMarkdown = page0.markdown?.text || "N/A";
const info = pipelineRes.pipeline_info || {};
// Direct DB query for accurate metadata (bypassing Auth)
const docMeta = await getDocumentMetadataFromDb(file);
const tiltStr = info.tilt !== undefined ? parseFloat(info.tilt).toFixed(2) : 'N/A';
const unwarpedStr = info.unwarped ? 'Yes' : 'No';
const itemsCount = (resData.items || []).length;
// Write success state incrementally
const tableLine = `| ${idx + 1} | \`${file}\` | **Success** | ${tiltStr}° | ${unwarpedStr} | \`${docMeta.noPO || 'N/A'}\` | \`${docMeta.noSO || 'N/A'}\` | \`${docMeta.noDO || 'N/A'}\` | \`${docMeta.tanggal || 'N/A'}\` | ${docMeta.orderUntuk || 'N/A'} | ${itemsCount} |\n`;
fs.appendFileSync(summaryTmpFile, tableLine);
let detailedText = `### ${idx + 1}. \`${file}\`\n`;
detailedText += `- **Status**: Success\n`;
detailedText += `- **Tilt Detected**: ${tiltStr}°\n`;
detailedText += `- **Auto-Rotated/Unwarped**: ${unwarpedStr}\n`;
detailedText += `- **Extracted Metadata**:\n`;
detailedText += ` * **PO**: \`${docMeta.noPO || 'N/A'}\`\n`;
detailedText += ` * **SO**: \`${docMeta.noSO || 'N/A'}\`\n`;
detailedText += ` * **DO**: \`${docMeta.noDO || 'N/A'}\`\n`;
detailedText += ` * **Tanggal**: \`${docMeta.tanggal || 'N/A'}\`\n`;
detailedText += ` * **Customer**: \`${docMeta.customerInfo || 'N/A'}\`\n`;
detailedText += ` * **Store**: \`${docMeta.orderUntuk || 'N/A'}\`\n`;
detailedText += ` * **Alamat**: \`${docMeta.alamat || 'N/A'}\`\n`;
detailedText += ` * **Plat Nomor**: \`${docMeta.platTruk || 'N/A'}\`\n`;
detailedText += `- **Raw Layout Markdown**:\n`;
detailedText += `\`\`\`markdown\n${rawMarkdown}\n\`\`\`\n`;
detailedText += `- **Parsed Items (${itemsCount})**:\n`;
if (itemsCount > 0) {
detailedText += ` | Code (SKU) | Name | Qty | Price |\n`;
detailedText += ` |---|---|---|---|\n`;
(resData.items || []).forEach(item => {
detailedText += ` | \`${item.kodeBarang}\` | ${item.namaBarang} | \`${item.banyak}\` | \`${item.jumlah}\` |\n`;
});
} else {
detailedText += ` *No valid SKU items parsed.*\n`;
}
detailedText += `\n---\n\n`;
fs.appendFileSync(detailsTmpFile, detailedText);
fs.appendFileSync(jsonlFile, JSON.stringify({
filename: file,
status: 'Success',
tilt: tiltStr,
unwarped: unwarpedStr,
rawMarkdown: resData.postProcessingDetails?.rawMarkdown || "",
layer1RawRegex: resData.postProcessingDetails?.layer1RawRegex || {},
layer2Sanitized: resData.postProcessingDetails?.layer2Sanitized || {},
layer3Final: resData.postProcessingDetails?.layer3Final || {},
metadata: docMeta,
items: resData.items || []
}) + '\n');
} catch (err) {
console.error(`Exception during file ${file}:`, err);
const tableLine = `| ${idx + 1} | \`${file}\` | **Error** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |\n`;
fs.appendFileSync(summaryTmpFile, tableLine);
let detailedText = `### ${idx + 1}. \`${file}\`\n`;
detailedText += `- **Status**: Error\n`;
detailedText += `- **Error Detail**: \`${err.message}\`\n`;
detailedText += `\n---\n\n`;
fs.appendFileSync(detailsTmpFile, detailedText);
fs.appendFileSync(jsonlFile, JSON.stringify({
filename: file,
status: 'Error',
error: err.message
}) + '\n');
}
}
// Combine temporary files into the final report
try {
const summaryContent = fs.readFileSync(summaryTmpFile, 'utf8');
const detailsContent = fs.readFileSync(detailsTmpFile, 'utf8');
fs.writeFileSync(finalReportFile, summaryContent + '\n' + detailsContent);
// Clean up temporary files
fs.unlinkSync(summaryTmpFile);
fs.unlinkSync(detailsTmpFile);
} catch (combineErr) {
console.error('Failed to combine test reports:', combineErr);
}
// Compile JSONL into the final JSON v2
try {
const lines = fs.readFileSync(jsonlFile, 'utf8').split('\n').filter(Boolean);
const results = lines.map(line => JSON.parse(line));
fs.writeFileSync('/uploads/ai_results_v2.json', JSON.stringify(results, null, 2));
console.log('Compiled results saved to /uploads/ai_results_v2.json');
} catch (compileErr) {
console.error('Failed to compile results into JSON v2:', compileErr);
}
console.log('Batch test completed. Report written to /uploads/test_images_report.md');
}
main();
+403 -403
View File
@@ -1,403 +1,403 @@
/**
* run_full_test.js
*
* Runs OCR parsing against ALL images in backend/sources/test-images/
* and captures every pipeline stage for analysis:
* - rawMarkdown : raw text from PaddleOCR layout parser
* - layer1RawRegex: output of parseDOMetadata (regex extraction)
* - layer2Sanitized: output of sanitizeParsedMetadata (format checks)
* - layer3Final : final metadata after SKU triple-check + store resolution
*
* Outputs:
* backend/sources/ai_results.json — machine-readable per-file results
* backend/sources/ai_results.md — human-readable stage-by-stage breakdown
*
* Usage (from host machine, Docker must be running):
* node run_full_test.js
*
* The script talks to the nginx gateway on port 8000.
* To override: set env var BASE_URL=http://localhost:3000/api/parse
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
// ─── Config ──────────────────────────────────────────────────────────────────
const BASE_URL = process.env.BASE_URL || 'http://localhost:8000/api/parse';
const TEST_IMAGES_DIR = path.resolve(__dirname, '../sources/test-images');
const OUTPUT_JSON = path.resolve(__dirname, '../sources/ai_results.json');
const OUTPUT_MD = path.resolve(__dirname, '../sources/ai_results.md');
const REQUEST_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes per image
// ─── HTTP Helper ─────────────────────────────────────────────────────────────
function postJSON(url, body) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const bodyStr = JSON.stringify(body);
const lib = parsedUrl.protocol === 'https:' ? https : http;
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
timeout: REQUEST_TIMEOUT_MS,
};
const req = lib.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
body: data,
});
});
});
req.on('timeout', () => {
req.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 60000}m`));
});
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
// ─── Markdown Helpers ─────────────────────────────────────────────────────────
function mdSection(title, level = 2) {
return `${'#'.repeat(level)} ${title}\n\n`;
}
function mdCode(content, lang = '') {
if (content === null || content === undefined) return '*null*\n\n';
const str = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
return `\`\`\`${lang}\n${str}\n\`\`\`\n\n`;
}
function mdField(label, value) {
const display = (value === null || value === undefined || value === '') ? '*empty*' : `\`${value}\``;
return `- **${label}**: ${display}\n`;
}
function mdTable(headers, rows) {
if (!rows || rows.length === 0) return '*No items.*\n\n';
const sep = headers.map(() => '---');
const lines = [
`| ${headers.join(' | ')} |`,
`| ${sep.join(' | ')} |`,
...rows.map(r => `| ${r.map(c => String(c ?? '').replace(/\|/g, '\\|')).join(' | ')} |`),
];
return lines.join('\n') + '\n\n';
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
// Discover all image files
let files;
try {
files = fs.readdirSync(TEST_IMAGES_DIR).filter(f =>
/\.(jpe?g|png|webp|bmp)$/i.test(f)
).sort();
} catch (e) {
console.error(`Cannot read test-images directory: ${TEST_IMAGES_DIR}`);
console.error(e.message);
process.exit(1);
}
if (files.length === 0) {
console.error('No image files found in', TEST_IMAGES_DIR);
process.exit(1);
}
console.log(`\n🚀 Starting batch test`);
console.log(` API endpoint : ${BASE_URL}`);
console.log(` Images found : ${files.length}`);
console.log(` Output JSON : ${OUTPUT_JSON}`);
console.log(` Output MD : ${OUTPUT_MD}`);
console.log('─'.repeat(60));
const jsonResults = [];
const mdParts = [];
const summaryRows = [];
// ── Markdown document header ──────────────────────────────────────────────
mdParts.push(
`# OCR Batch Test Report\n\n`,
`> Generated: ${new Date().toISOString()}\n`,
`> API: \`${BASE_URL}\`\n`,
`> Images: **${files.length}** files from \`backend/sources/test-images/\`\n\n`,
`---\n\n`,
`## Summary\n\n`,
'<!-- summary_table_placeholder -->\n\n',
`---\n\n`,
`## Stage-by-Stage Results\n\n`,
);
const summaryPlaceholderIndex = mdParts.indexOf('<!-- summary_table_placeholder -->\n\n');
// ── Process each file ────────────────────────────────────────────────────
for (let idx = 0; idx < files.length; idx++) {
const file = files[idx];
const num = `[${String(idx + 1).padStart(2, '0')}/${files.length}]`;
process.stdout.write(`${num} ${file} ... `);
const entry = {
index: idx + 1,
filename: file,
status: 'pending',
tilt: null,
unwarped: null,
// pipeline stages
rawMarkdown: null,
layer1RawRegex: null,
layer2Sanitized: null,
layer3Final: null,
items: [],
error: null,
};
try {
const t0 = Date.now();
const res = await postJSON(BASE_URL, { filename: file });
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
if (!res.ok) {
process.stdout.write(`❌ HTTP ${res.status} (${elapsed}s)\n`);
entry.status = 'http_error';
entry.error = `HTTP ${res.status}: ${res.body}`;
} else {
let data;
try {
data = JSON.parse(res.body);
} catch (_) {
entry.status = 'json_parse_error';
entry.error = 'Response is not valid JSON';
process.stdout.write(`❌ JSON parse error (${elapsed}s)\n`);
data = null;
}
if (data) {
if (data.error) {
process.stdout.write(`⚠️ API error: ${data.error} (${elapsed}s)\n`);
entry.status = 'api_error';
entry.error = data.error;
} else {
const pipelineInfo = (data.result || {}).pipeline_info || {};
entry.status = 'success';
entry.tilt = pipelineInfo.tilt !== undefined ? +parseFloat(pipelineInfo.tilt).toFixed(2) : null;
entry.unwarped = pipelineInfo.unwarped ?? null;
const ppd = data.postProcessingDetails || {};
entry.rawMarkdown = ppd.rawMarkdown ?? null;
entry.layer1RawRegex = ppd.layer1RawRegex ?? null;
entry.layer2Sanitized = ppd.layer2Sanitized ?? null;
entry.layer3Final = ppd.layer3Final ?? null;
entry.items = data.items ?? [];
const itemCount = entry.items.length;
process.stdout.write(`✅ ${itemCount} item(s), tilt=${entry.tilt ?? 'N/A'}° (${elapsed}s)\n`);
}
}
}
} catch (err) {
process.stdout.write(`💥 ${err.message}\n`);
entry.status = 'exception';
entry.error = err.message;
}
jsonResults.push(entry);
// ── Build per-file markdown section ─────────────────────────────────────
const statusEmoji = {
success: '✅',
http_error: '❌',
api_error: '⚠️',
json_parse_error: '❌',
exception: '💥',
}[entry.status] || '❓';
let fileMd = '';
fileMd += `### ${idx + 1}. \`${file}\`\n\n`;
fileMd += `**Status**: ${statusEmoji} \`${entry.status}\`\n\n`;
if (entry.status !== 'success') {
fileMd += `> **Error**: ${entry.error}\n\n`;
fileMd += `---\n\n`;
summaryRows.push([idx + 1, `\`${file}\``, `${statusEmoji} ${entry.status}`, 'N/A', 'N/A', 'N/A', 'N/A']);
mdParts.push(fileMd);
continue;
}
// ── Stage 0: Pipeline Info ────────────────────────────────────────────────
fileMd += `#### 📐 Stage 0 — Pipeline Info\n\n`;
fileMd += mdField('Tilt detected', entry.tilt !== null ? `${entry.tilt}°` : 'N/A');
fileMd += mdField('Auto-unwarped', entry.unwarped !== null ? (entry.unwarped ? 'Yes' : 'No') : 'N/A');
fileMd += '\n';
// ── Stage 1: Raw Markdown from OCR ───────────────────────────────────────
fileMd += `#### 📄 Stage 1 — Raw OCR Markdown\n\n`;
fileMd += `*This is the raw text extracted by PaddleOCR layout parser before any post-processing.*\n\n`;
if (entry.rawMarkdown) {
fileMd += mdCode(entry.rawMarkdown, 'markdown');
} else {
fileMd += '*No raw markdown captured.*\n\n';
}
// ── Stage 2: Layer 1 — Regex Extraction ──────────────────────────────────
fileMd += `#### 🔍 Stage 2 — Layer 1: Regex Extraction (\`parseDOMetadata\`)\n\n`;
fileMd += `*Regex patterns are applied to raw markdown to extract header fields and item rows.*\n\n`;
if (entry.layer1RawRegex) {
const l1 = entry.layer1RawRegex;
fileMd += `**Header fields (raw regex output):**\n\n`;
fileMd += mdField('noDO', l1.noDO);
fileMd += mdField('noPO', l1.noPO);
fileMd += mdField('noSO', l1.noSO);
fileMd += mdField('tanggal', l1.tanggal);
fileMd += mdField('vendorInfo', l1.vendorInfo);
fileMd += mdField('customerInfo', l1.customerInfo);
fileMd += mdField('alamat', l1.alamat);
fileMd += mdField('orderUntuk', l1.orderUntuk);
fileMd += mdField('platTruk', l1.platTruk);
fileMd += '\n';
fileMd += `**Raw items (${(l1.items || []).length} row(s)):**\n\n`;
fileMd += mdTable(
['kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
(l1.items || []).map(it => [it.kodeBarang, it.namaBarang, it.banyak, it.jumlah])
);
} else {
fileMd += '*Layer 1 data not captured.*\n\n';
}
// ── Stage 3: Layer 2 — Sanitized ─────────────────────────────────────────
fileMd += `#### 🧹 Stage 3 — Layer 2: Sanitized (\`sanitizeParsedMetadata\`)\n\n`;
fileMd += `*Strict format enforcement: corrects date formats, trims whitespace, enforces field constraints.*\n\n`;
if (entry.layer2Sanitized) {
const l2 = entry.layer2Sanitized;
fileMd += `**Header fields (after sanitization):**\n\n`;
fileMd += mdField('noDO', l2.noDO);
fileMd += mdField('noPO', l2.noPO);
fileMd += mdField('noSO', l2.noSO);
fileMd += mdField('tanggal', l2.tanggal);
fileMd += mdField('vendorInfo', l2.vendorInfo);
fileMd += mdField('customerInfo', l2.customerInfo);
fileMd += mdField('alamat', l2.alamat);
fileMd += mdField('orderUntuk', l2.orderUntuk);
fileMd += mdField('platTruk', l2.platTruk);
fileMd += '\n';
fileMd += `**Sanitized items (${(l2.items || []).length} row(s)):**\n\n`;
fileMd += mdTable(
['kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
(l2.items || []).map(it => [it.kodeBarang, it.namaBarang, it.banyak, it.jumlah])
);
} else {
fileMd += '*Layer 2 data not captured.*\n\n';
}
// ── Stage 4: Layer 3 — Final (SKU triple-check + store resolution) ────────
fileMd += `#### ✅ Stage 4 — Layer 3: Final (\`SKU triple-check + store resolution\`)\n\n`;
fileMd += `*SKU validated against master list (score ≥ 0.6 threshold). Items with noise SKU codes are filtered out. Store resolved from DB.*\n\n`;
if (entry.layer3Final) {
const l3 = entry.layer3Final;
fileMd += `**Final metadata:**\n\n`;
fileMd += mdField('noDO', l3.noDO);
fileMd += mdField('noPO', l3.noPO);
fileMd += mdField('noSO', l3.noSO);
fileMd += mdField('tanggal', l3.tanggal);
fileMd += mdField('vendorInfo', l3.vendorInfo);
fileMd += mdField('customerInfo', l3.customerInfo);
fileMd += mdField('alamat', l3.alamat);
fileMd += mdField('orderUntuk', l3.orderUntuk);
fileMd += mdField('platTruk', l3.platTruk);
fileMd += '\n';
fileMd += `**Final items after SKU validation (${(l3.items || []).length} row(s)):**\n\n`;
fileMd += mdTable(
['kodeBarangOriginal', 'kodeBarang (corrected)', 'namaBarang', 'banyak', 'jumlah'],
(l3.items || []).map(it => [
it.kodeBarangOriginal ?? it.kodeBarang,
it.kodeBarang,
it.namaBarang,
it.banyak,
it.jumlah
])
);
} else {
fileMd += '*Layer 3 data not captured.*\n\n';
}
// ── Stage 5: Final submitted items (from root items[]) ───────────────────
fileMd += `#### 🗃️ Stage 5 — Submitted Items (ready-to-use JSON)\n\n`;
fileMd += `*These are the items actually returned to the caller and saved to the database.*\n\n`;
fileMd += mdTable(
['kodeBarangOriginal', 'kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
(entry.items || []).map(it => [
it.kodeBarangOriginal ?? it.kodeBarang,
it.kodeBarang,
it.namaBarang,
it.banyak,
it.jumlah
])
);
fileMd += `---\n\n`;
// ── Summary row ──────────────────────────────────────────────────────────
const l3meta = entry.layer3Final || {};
summaryRows.push([
idx + 1,
`\`${file}\``,
`${statusEmoji} success`,
entry.tilt !== null ? `${entry.tilt}°` : 'N/A',
entry.unwarped !== null ? (entry.unwarped ? 'Yes' : 'No') : 'N/A',
`\`${l3meta.noDO ?? 'N/A'}\``,
`\`${l3meta.noPO ?? 'N/A'}\``,
`${entry.items.length}`,
]);
mdParts.push(fileMd);
}
// ── Inject summary table ──────────────────────────────────────────────────
const summaryTable = mdTable(
['#', 'Filename', 'Status', 'Tilt', 'Unwarped', 'DO', 'PO', 'Items'],
summaryRows
);
mdParts[summaryPlaceholderIndex] = summaryTable;
// ── Write outputs ─────────────────────────────────────────────────────────
const jsonOut = JSON.stringify(jsonResults, null, 2);
fs.writeFileSync(OUTPUT_JSON, jsonOut, 'utf8');
console.log(`\n✅ JSON saved → ${OUTPUT_JSON}`);
const mdOut = mdParts.join('');
fs.writeFileSync(OUTPUT_MD, mdOut, 'utf8');
console.log(`✅ MD saved → ${OUTPUT_MD}`);
// ── Final stats ───────────────────────────────────────────────────────────
const succeeded = jsonResults.filter(r => r.status === 'success').length;
const failed = jsonResults.length - succeeded;
console.log('\n─'.repeat(60));
console.log(` Total : ${jsonResults.length}`);
console.log(` Success: ${succeeded}`);
console.log(` Failed : ${failed}`);
console.log('─'.repeat(60));
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
/**
* run_full_test.js
*
* Runs OCR parsing against ALL images in backend/sources/test-images/
* and captures every pipeline stage for analysis:
* - rawMarkdown : raw text from PaddleOCR layout parser
* - layer1RawRegex: output of parseDOMetadata (regex extraction)
* - layer2Sanitized: output of sanitizeParsedMetadata (format checks)
* - layer3Final : final metadata after SKU triple-check + store resolution
*
* Outputs:
* backend/sources/ai_results.json — machine-readable per-file results
* backend/sources/ai_results.md — human-readable stage-by-stage breakdown
*
* Usage (from host machine, Docker must be running):
* node run_full_test.js
*
* The script talks to the nginx gateway on port 8000.
* To override: set env var BASE_URL=http://localhost:3000/api/parse
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
// ─── Config ──────────────────────────────────────────────────────────────────
const BASE_URL = process.env.BASE_URL || 'http://localhost:8000/api/parse';
const TEST_IMAGES_DIR = path.resolve(__dirname, '../sources/test-images');
const OUTPUT_JSON = path.resolve(__dirname, '../sources/ai_results.json');
const OUTPUT_MD = path.resolve(__dirname, '../sources/ai_results.md');
const REQUEST_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes per image
// ─── HTTP Helper ─────────────────────────────────────────────────────────────
function postJSON(url, body) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const bodyStr = JSON.stringify(body);
const lib = parsedUrl.protocol === 'https:' ? https : http;
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
timeout: REQUEST_TIMEOUT_MS,
};
const req = lib.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
body: data,
});
});
});
req.on('timeout', () => {
req.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 60000}m`));
});
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
// ─── Markdown Helpers ─────────────────────────────────────────────────────────
function mdSection(title, level = 2) {
return `${'#'.repeat(level)} ${title}\n\n`;
}
function mdCode(content, lang = '') {
if (content === null || content === undefined) return '*null*\n\n';
const str = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
return `\`\`\`${lang}\n${str}\n\`\`\`\n\n`;
}
function mdField(label, value) {
const display = (value === null || value === undefined || value === '') ? '*empty*' : `\`${value}\``;
return `- **${label}**: ${display}\n`;
}
function mdTable(headers, rows) {
if (!rows || rows.length === 0) return '*No items.*\n\n';
const sep = headers.map(() => '---');
const lines = [
`| ${headers.join(' | ')} |`,
`| ${sep.join(' | ')} |`,
...rows.map(r => `| ${r.map(c => String(c ?? '').replace(/\|/g, '\\|')).join(' | ')} |`),
];
return lines.join('\n') + '\n\n';
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
// Discover all image files
let files;
try {
files = fs.readdirSync(TEST_IMAGES_DIR).filter(f =>
/\.(jpe?g|png|webp|bmp)$/i.test(f)
).sort();
} catch (e) {
console.error(`Cannot read test-images directory: ${TEST_IMAGES_DIR}`);
console.error(e.message);
process.exit(1);
}
if (files.length === 0) {
console.error('No image files found in', TEST_IMAGES_DIR);
process.exit(1);
}
console.log(`\n🚀 Starting batch test`);
console.log(` API endpoint : ${BASE_URL}`);
console.log(` Images found : ${files.length}`);
console.log(` Output JSON : ${OUTPUT_JSON}`);
console.log(` Output MD : ${OUTPUT_MD}`);
console.log('─'.repeat(60));
const jsonResults = [];
const mdParts = [];
const summaryRows = [];
// ── Markdown document header ──────────────────────────────────────────────
mdParts.push(
`# OCR Batch Test Report\n\n`,
`> Generated: ${new Date().toISOString()}\n`,
`> API: \`${BASE_URL}\`\n`,
`> Images: **${files.length}** files from \`backend/sources/test-images/\`\n\n`,
`---\n\n`,
`## Summary\n\n`,
'<!-- summary_table_placeholder -->\n\n',
`---\n\n`,
`## Stage-by-Stage Results\n\n`,
);
const summaryPlaceholderIndex = mdParts.indexOf('<!-- summary_table_placeholder -->\n\n');
// ── Process each file ────────────────────────────────────────────────────
for (let idx = 0; idx < files.length; idx++) {
const file = files[idx];
const num = `[${String(idx + 1).padStart(2, '0')}/${files.length}]`;
process.stdout.write(`${num} ${file} ... `);
const entry = {
index: idx + 1,
filename: file,
status: 'pending',
tilt: null,
unwarped: null,
// pipeline stages
rawMarkdown: null,
layer1RawRegex: null,
layer2Sanitized: null,
layer3Final: null,
items: [],
error: null,
};
try {
const t0 = Date.now();
const res = await postJSON(BASE_URL, { filename: file });
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
if (!res.ok) {
process.stdout.write(`❌ HTTP ${res.status} (${elapsed}s)\n`);
entry.status = 'http_error';
entry.error = `HTTP ${res.status}: ${res.body}`;
} else {
let data;
try {
data = JSON.parse(res.body);
} catch (_) {
entry.status = 'json_parse_error';
entry.error = 'Response is not valid JSON';
process.stdout.write(`❌ JSON parse error (${elapsed}s)\n`);
data = null;
}
if (data) {
if (data.error) {
process.stdout.write(`⚠️ API error: ${data.error} (${elapsed}s)\n`);
entry.status = 'api_error';
entry.error = data.error;
} else {
const pipelineInfo = (data.result || {}).pipeline_info || {};
entry.status = 'success';
entry.tilt = pipelineInfo.tilt !== undefined ? +parseFloat(pipelineInfo.tilt).toFixed(2) : null;
entry.unwarped = pipelineInfo.unwarped ?? null;
const ppd = data.postProcessingDetails || {};
entry.rawMarkdown = ppd.rawMarkdown ?? null;
entry.layer1RawRegex = ppd.layer1RawRegex ?? null;
entry.layer2Sanitized = ppd.layer2Sanitized ?? null;
entry.layer3Final = ppd.layer3Final ?? null;
entry.items = data.items ?? [];
const itemCount = entry.items.length;
process.stdout.write(`✅ ${itemCount} item(s), tilt=${entry.tilt ?? 'N/A'}° (${elapsed}s)\n`);
}
}
}
} catch (err) {
process.stdout.write(`💥 ${err.message}\n`);
entry.status = 'exception';
entry.error = err.message;
}
jsonResults.push(entry);
// ── Build per-file markdown section ─────────────────────────────────────
const statusEmoji = {
success: '✅',
http_error: '❌',
api_error: '⚠️',
json_parse_error: '❌',
exception: '💥',
}[entry.status] || '❓';
let fileMd = '';
fileMd += `### ${idx + 1}. \`${file}\`\n\n`;
fileMd += `**Status**: ${statusEmoji} \`${entry.status}\`\n\n`;
if (entry.status !== 'success') {
fileMd += `> **Error**: ${entry.error}\n\n`;
fileMd += `---\n\n`;
summaryRows.push([idx + 1, `\`${file}\``, `${statusEmoji} ${entry.status}`, 'N/A', 'N/A', 'N/A', 'N/A']);
mdParts.push(fileMd);
continue;
}
// ── Stage 0: Pipeline Info ────────────────────────────────────────────────
fileMd += `#### 📐 Stage 0 — Pipeline Info\n\n`;
fileMd += mdField('Tilt detected', entry.tilt !== null ? `${entry.tilt}°` : 'N/A');
fileMd += mdField('Auto-unwarped', entry.unwarped !== null ? (entry.unwarped ? 'Yes' : 'No') : 'N/A');
fileMd += '\n';
// ── Stage 1: Raw Markdown from OCR ───────────────────────────────────────
fileMd += `#### 📄 Stage 1 — Raw OCR Markdown\n\n`;
fileMd += `*This is the raw text extracted by PaddleOCR layout parser before any post-processing.*\n\n`;
if (entry.rawMarkdown) {
fileMd += mdCode(entry.rawMarkdown, 'markdown');
} else {
fileMd += '*No raw markdown captured.*\n\n';
}
// ── Stage 2: Layer 1 — Regex Extraction ──────────────────────────────────
fileMd += `#### 🔍 Stage 2 — Layer 1: Regex Extraction (\`parseDOMetadata\`)\n\n`;
fileMd += `*Regex patterns are applied to raw markdown to extract header fields and item rows.*\n\n`;
if (entry.layer1RawRegex) {
const l1 = entry.layer1RawRegex;
fileMd += `**Header fields (raw regex output):**\n\n`;
fileMd += mdField('noDO', l1.noDO);
fileMd += mdField('noPO', l1.noPO);
fileMd += mdField('noSO', l1.noSO);
fileMd += mdField('tanggal', l1.tanggal);
fileMd += mdField('vendorInfo', l1.vendorInfo);
fileMd += mdField('customerInfo', l1.customerInfo);
fileMd += mdField('alamat', l1.alamat);
fileMd += mdField('orderUntuk', l1.orderUntuk);
fileMd += mdField('platTruk', l1.platTruk);
fileMd += '\n';
fileMd += `**Raw items (${(l1.items || []).length} row(s)):**\n\n`;
fileMd += mdTable(
['kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
(l1.items || []).map(it => [it.kodeBarang, it.namaBarang, it.banyak, it.jumlah])
);
} else {
fileMd += '*Layer 1 data not captured.*\n\n';
}
// ── Stage 3: Layer 2 — Sanitized ─────────────────────────────────────────
fileMd += `#### 🧹 Stage 3 — Layer 2: Sanitized (\`sanitizeParsedMetadata\`)\n\n`;
fileMd += `*Strict format enforcement: corrects date formats, trims whitespace, enforces field constraints.*\n\n`;
if (entry.layer2Sanitized) {
const l2 = entry.layer2Sanitized;
fileMd += `**Header fields (after sanitization):**\n\n`;
fileMd += mdField('noDO', l2.noDO);
fileMd += mdField('noPO', l2.noPO);
fileMd += mdField('noSO', l2.noSO);
fileMd += mdField('tanggal', l2.tanggal);
fileMd += mdField('vendorInfo', l2.vendorInfo);
fileMd += mdField('customerInfo', l2.customerInfo);
fileMd += mdField('alamat', l2.alamat);
fileMd += mdField('orderUntuk', l2.orderUntuk);
fileMd += mdField('platTruk', l2.platTruk);
fileMd += '\n';
fileMd += `**Sanitized items (${(l2.items || []).length} row(s)):**\n\n`;
fileMd += mdTable(
['kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
(l2.items || []).map(it => [it.kodeBarang, it.namaBarang, it.banyak, it.jumlah])
);
} else {
fileMd += '*Layer 2 data not captured.*\n\n';
}
// ── Stage 4: Layer 3 — Final (SKU triple-check + store resolution) ────────
fileMd += `#### ✅ Stage 4 — Layer 3: Final (\`SKU triple-check + store resolution\`)\n\n`;
fileMd += `*SKU validated against master list (score ≥ 0.6 threshold). Items with noise SKU codes are filtered out. Store resolved from DB.*\n\n`;
if (entry.layer3Final) {
const l3 = entry.layer3Final;
fileMd += `**Final metadata:**\n\n`;
fileMd += mdField('noDO', l3.noDO);
fileMd += mdField('noPO', l3.noPO);
fileMd += mdField('noSO', l3.noSO);
fileMd += mdField('tanggal', l3.tanggal);
fileMd += mdField('vendorInfo', l3.vendorInfo);
fileMd += mdField('customerInfo', l3.customerInfo);
fileMd += mdField('alamat', l3.alamat);
fileMd += mdField('orderUntuk', l3.orderUntuk);
fileMd += mdField('platTruk', l3.platTruk);
fileMd += '\n';
fileMd += `**Final items after SKU validation (${(l3.items || []).length} row(s)):**\n\n`;
fileMd += mdTable(
['kodeBarangOriginal', 'kodeBarang (corrected)', 'namaBarang', 'banyak', 'jumlah'],
(l3.items || []).map(it => [
it.kodeBarangOriginal ?? it.kodeBarang,
it.kodeBarang,
it.namaBarang,
it.banyak,
it.jumlah
])
);
} else {
fileMd += '*Layer 3 data not captured.*\n\n';
}
// ── Stage 5: Final submitted items (from root items[]) ───────────────────
fileMd += `#### 🗃️ Stage 5 — Submitted Items (ready-to-use JSON)\n\n`;
fileMd += `*These are the items actually returned to the caller and saved to the database.*\n\n`;
fileMd += mdTable(
['kodeBarangOriginal', 'kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
(entry.items || []).map(it => [
it.kodeBarangOriginal ?? it.kodeBarang,
it.kodeBarang,
it.namaBarang,
it.banyak,
it.jumlah
])
);
fileMd += `---\n\n`;
// ── Summary row ──────────────────────────────────────────────────────────
const l3meta = entry.layer3Final || {};
summaryRows.push([
idx + 1,
`\`${file}\``,
`${statusEmoji} success`,
entry.tilt !== null ? `${entry.tilt}°` : 'N/A',
entry.unwarped !== null ? (entry.unwarped ? 'Yes' : 'No') : 'N/A',
`\`${l3meta.noDO ?? 'N/A'}\``,
`\`${l3meta.noPO ?? 'N/A'}\``,
`${entry.items.length}`,
]);
mdParts.push(fileMd);
}
// ── Inject summary table ──────────────────────────────────────────────────
const summaryTable = mdTable(
['#', 'Filename', 'Status', 'Tilt', 'Unwarped', 'DO', 'PO', 'Items'],
summaryRows
);
mdParts[summaryPlaceholderIndex] = summaryTable;
// ── Write outputs ─────────────────────────────────────────────────────────
const jsonOut = JSON.stringify(jsonResults, null, 2);
fs.writeFileSync(OUTPUT_JSON, jsonOut, 'utf8');
console.log(`\n✅ JSON saved → ${OUTPUT_JSON}`);
const mdOut = mdParts.join('');
fs.writeFileSync(OUTPUT_MD, mdOut, 'utf8');
console.log(`✅ MD saved → ${OUTPUT_MD}`);
// ── Final stats ───────────────────────────────────────────────────────────
const succeeded = jsonResults.filter(r => r.status === 'success').length;
const failed = jsonResults.length - succeeded;
console.log('\n─'.repeat(60));
console.log(` Total : ${jsonResults.length}`);
console.log(` Success: ${succeeded}`);
console.log(` Failed : ${failed}`);
console.log('─'.repeat(60));
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
File diff suppressed because it is too large. Load diff
@@ -1,419 +1,419 @@
"use client";
import React, { useState, useEffect } from "react";
export default function MasterDataPage() {
const [token, setToken] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loginError, setLoginError] = useState("");
const [activeTab, setActiveTab] = useState<"stores" | "skus">("stores");
const [stores, setStores] = useState<any[]>([]);
const [skus, setSkus] = useState<any[]>([]);
useEffect(() => {
const savedToken = localStorage.getItem("adminToken");
if (savedToken) {
setToken(savedToken);
fetchData(savedToken, activeTab);
}
}, [activeTab]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoginError("");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Login failed");
const tokenStr = data.data?.token || data.token;
localStorage.setItem("adminToken", tokenStr);
setToken(tokenStr);
fetchData(tokenStr, activeTab);
} catch (err: any) {
setLoginError(err.message);
}
};
const handleLogout = () => {
localStorage.removeItem("adminToken");
setToken(null);
};
const fetchData = async (authToken: string, tab: "stores" | "skus") => {
try {
const res = await fetch(`/api/v1/master/${tab}`, {
headers: { "Authorization": `Bearer ${authToken}` }
});
if (res.status === 401 || res.status === 403) {
handleLogout();
return;
}
const data = await res.json();
if (res.ok) {
if (tab === "stores") setStores(data.data || []);
else setSkus(data.data || []);
}
} catch (err) {
console.error(err);
}
};
if (!token) {
return (
<div className="min-h-screen bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950 flex items-center justify-center p-4">
<div className="bg-slate-900/40 border border-slate-800/80 shadow-2xl backdrop-blur-md rounded-2xl p-8 w-full max-w-md">
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent text-center mb-6">
Admin Login
</h1>
<form onSubmit={handleLogin} className="space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Username</label>
<input
type="text"
className="w-full bg-slate-950/60 border border-slate-800 text-slate-100 placeholder-slate-600 rounded-xl p-3 focus:outline-none focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/60 transition-all duration-200 text-sm"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Enter admin username"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Password</label>
<input
type="password"
className="w-full bg-slate-950/60 border border-slate-800 text-slate-100 placeholder-slate-650 rounded-xl p-3 focus:outline-none focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/60 transition-all duration-200 text-sm"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
{loginError && (
<div className="bg-rose-950/30 border border-rose-800/40 p-3 rounded-xl text-xs text-rose-450 flex items-center gap-2">
<span>⚠️</span>
<span>{loginError}</span>
</div>
)}
<button
type="submit"
className="w-full bg-teal-600 hover:bg-teal-500 text-slate-950 font-bold p-3 rounded-xl transition-all duration-200 shadow-lg shadow-teal-900/20 text-sm cursor-pointer"
>
Log In
</button>
</form>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950 p-8 text-slate-100">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8 border-b border-slate-800/60 pb-4">
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent flex items-center gap-2">
<span>⚙️</span> Master Data Management
</h1>
<button
onClick={handleLogout}
className="text-slate-400 hover:text-slate-100 bg-slate-900/60 hover:bg-slate-900 border border-slate-850 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 cursor-pointer"
>
Logout
</button>
</div>
<div className="flex space-x-2 mb-6 border-b border-slate-800/60 pb-px">
<button
className={`pb-2.5 px-4 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer ${
activeTab === 'stores'
? 'border-teal-500 text-teal-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
onClick={() => setActiveTab('stores')}
>
Stores
</button>
<button
className={`pb-2.5 px-4 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer ${
activeTab === 'skus'
? 'border-teal-500 text-teal-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
onClick={() => setActiveTab('skus')}
>
SKUs
</button>
</div>
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl backdrop-blur-md">
{activeTab === 'stores' && <StoreManager stores={stores} token={token} onRefresh={() => fetchData(token, 'stores')} />}
{activeTab === 'skus' && <SkuManager skus={skus} token={token} onRefresh={() => fetchData(token, 'skus')} />}
</div>
</div>
</div>
);
}
function StoreManager({ stores, token, onRefresh }: { stores: any[], token: string, onRefresh: () => void }) {
const [isAdding, setIsAdding] = useState(false);
const [form, setForm] = useState({ kode_toko: "", nama_toko: "", alamat: "" });
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const res = await fetch("/api/v1/master/stores", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify(form)
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
setIsAdding(false);
setForm({ kode_toko: "", nama_toko: "", alamat: "" });
onRefresh();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (kode: string) => {
if (!confirm(`Delete store ${kode}?`)) return;
try {
const res = await fetch(`/api/v1/master/stores/${kode}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
onRefresh();
} catch (err: any) {
alert(err.message);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-slate-200 flex items-center gap-2">
<span>🏪</span> Store Master
</h2>
<button
onClick={() => setIsAdding(true)}
className="bg-teal-600 hover:bg-teal-500 text-slate-950 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 shadow-md shadow-teal-900/10 cursor-pointer"
>
+ Add Store
</button>
</div>
{isAdding && (
<form onSubmit={handleSubmit} className="mb-6 bg-slate-950/40 p-5 rounded-xl border border-slate-800/60">
<h3 className="text-xs font-bold text-slate-450 uppercase tracking-wider mb-4">
Add New Store <span className="text-[10px] text-teal-500 font-normal lowercase">(Will auto-generate account with "123" password)</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<input
placeholder="Kode Toko"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.kode_toko}
onChange={e => setForm({...form, kode_toko: e.target.value})}
required
/>
<input
placeholder="Nama Toko"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.nama_toko}
onChange={e => setForm({...form, nama_toko: e.target.value})}
required
/>
<input
placeholder="Alamat"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.alamat}
onChange={e => setForm({...form, alamat: e.target.value})}
/>
</div>
{error && <div className="text-rose-450 text-xs mb-3 font-semibold">⚠️ {error}</div>}
<div className="flex space-x-2">
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Save</button>
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-750 text-slate-300 px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Cancel</button>
</div>
</form>
)}
<div className="overflow-x-auto rounded-xl border border-slate-800/60">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-slate-950/60 border-b border-slate-800/80 text-slate-400">
<tr>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Kode Toko</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Nama Toko</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Alamat</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase w-24">Actions</th>
</tr>
</thead>
<tbody>
{stores.map(s => (
<tr key={s.kode_toko} className="border-b border-slate-850/50 hover:bg-slate-900/30 transition-colors">
<td className="p-3.5 font-semibold text-slate-200 font-mono">{s.kode_toko}</td>
<td className="p-3.5 text-slate-300 font-medium">{s.nama_toko}</td>
<td className="p-3.5 text-slate-400 truncate max-w-xs">{s.alamat}</td>
<td className="p-3.5">
<button
onClick={() => handleDelete(s.kode_toko)}
className="text-rose-400 hover:text-rose-355 transition-colors font-bold cursor-pointer font-mono"
>
Delete
</button>
</td>
</tr>
))}
{stores.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-slate-500">No stores found.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function SkuManager({ skus, token, onRefresh }: { skus: any[], token: string, onRefresh: () => void }) {
const [isAdding, setIsAdding] = useState(false);
const [form, setForm] = useState({ no_sku: "", nama_item: "", jenis_outer: "", standar_jumlah: "1" });
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const payload = { ...form, standar_jumlah: parseInt(form.standar_jumlah) || 1 };
const res = await fetch("/api/v1/master/skus", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
setIsAdding(false);
setForm({ no_sku: "", nama_item: "", jenis_outer: "", standar_jumlah: "1" });
onRefresh();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (kode: string) => {
if (!confirm(`Delete SKU ${kode}?`)) return;
try {
const res = await fetch(`/api/v1/master/skus/${kode}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
onRefresh();
} catch (err: any) {
alert(err.message);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-slate-200 flex items-center gap-2">
<span>📦</span> SKU Master
</h2>
<button
onClick={() => setIsAdding(true)}
className="bg-teal-600 hover:bg-teal-500 text-slate-950 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 shadow-md shadow-teal-900/10 cursor-pointer"
>
+ Add SKU
</button>
</div>
{isAdding && (
<form onSubmit={handleSubmit} className="mb-6 bg-slate-950/40 p-5 rounded-xl border border-slate-800/60">
<h3 className="text-xs font-bold text-slate-450 uppercase tracking-wider mb-4 font-mono">Add New SKU</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<input
placeholder="No SKU"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.no_sku}
onChange={e => setForm({...form, no_sku: e.target.value})}
required
/>
<input
placeholder="Nama Item"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.nama_item}
onChange={e => setForm({...form, nama_item: e.target.value})}
required
/>
<input
placeholder="Jenis Outer (e.g. DUS)"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.jenis_outer}
onChange={e => setForm({...form, jenis_outer: e.target.value})}
/>
<input
type="number"
placeholder="Std Qty"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.standar_jumlah}
onChange={e => setForm({...form, standar_jumlah: e.target.value})}
/>
</div>
{error && <div className="text-rose-450 text-xs mb-3 font-semibold">⚠️ {error}</div>}
<div className="flex space-x-2">
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Save</button>
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-750 text-slate-300 px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Cancel</button>
</div>
</form>
)}
<div className="overflow-x-auto rounded-xl border border-slate-800/60">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-slate-950/60 border-b border-slate-800/80 text-slate-400">
<tr>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">No SKU</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Nama Item</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Outer</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Std Qty</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase w-24">Actions</th>
</tr>
</thead>
<tbody>
{skus.map(s => (
<tr key={s.no_sku} className="border-b border-slate-850/50 hover:bg-slate-900/30 transition-colors">
<td className="p-3.5 font-semibold text-slate-200 font-mono">{s.no_sku}</td>
<td className="p-3.5 text-slate-300 font-medium">{s.nama_item}</td>
<td className="p-3.5 text-slate-400 font-mono">{s.jenis_outer}</td>
<td className="p-3.5 text-slate-400 font-mono">{s.standar_jumlah}</td>
<td className="p-3.5">
<button
onClick={() => handleDelete(s.no_sku)}
className="text-rose-400 hover:text-rose-350 transition-colors font-bold cursor-pointer font-mono"
>
Delete
</button>
</td>
</tr>
))}
{skus.length === 0 && (
<tr><td colSpan={5} className="p-6 text-center text-slate-500">No SKUs found.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
"use client";
import React, { useState, useEffect } from "react";
export default function MasterDataPage() {
const [token, setToken] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loginError, setLoginError] = useState("");
const [activeTab, setActiveTab] = useState<"stores" | "skus">("stores");
const [stores, setStores] = useState<any[]>([]);
const [skus, setSkus] = useState<any[]>([]);
useEffect(() => {
const savedToken = localStorage.getItem("adminToken");
if (savedToken) {
setToken(savedToken);
fetchData(savedToken, activeTab);
}
}, [activeTab]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoginError("");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Login failed");
const tokenStr = data.data?.token || data.token;
localStorage.setItem("adminToken", tokenStr);
setToken(tokenStr);
fetchData(tokenStr, activeTab);
} catch (err: any) {
setLoginError(err.message);
}
};
const handleLogout = () => {
localStorage.removeItem("adminToken");
setToken(null);
};
const fetchData = async (authToken: string, tab: "stores" | "skus") => {
try {
const res = await fetch(`/api/v1/master/${tab}`, {
headers: { "Authorization": `Bearer ${authToken}` }
});
if (res.status === 401 || res.status === 403) {
handleLogout();
return;
}
const data = await res.json();
if (res.ok) {
if (tab === "stores") setStores(data.data || []);
else setSkus(data.data || []);
}
} catch (err) {
console.error(err);
}
};
if (!token) {
return (
<div className="min-h-screen bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950 flex items-center justify-center p-4">
<div className="bg-slate-900/40 border border-slate-800/80 shadow-2xl backdrop-blur-md rounded-2xl p-8 w-full max-w-md">
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent text-center mb-6">
Admin Login
</h1>
<form onSubmit={handleLogin} className="space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Username</label>
<input
type="text"
className="w-full bg-slate-950/60 border border-slate-800 text-slate-100 placeholder-slate-600 rounded-xl p-3 focus:outline-none focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/60 transition-all duration-200 text-sm"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Enter admin username"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Password</label>
<input
type="password"
className="w-full bg-slate-950/60 border border-slate-800 text-slate-100 placeholder-slate-650 rounded-xl p-3 focus:outline-none focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/60 transition-all duration-200 text-sm"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
{loginError && (
<div className="bg-rose-950/30 border border-rose-800/40 p-3 rounded-xl text-xs text-rose-450 flex items-center gap-2">
<span>⚠️</span>
<span>{loginError}</span>
</div>
)}
<button
type="submit"
className="w-full bg-teal-600 hover:bg-teal-500 text-slate-950 font-bold p-3 rounded-xl transition-all duration-200 shadow-lg shadow-teal-900/20 text-sm cursor-pointer"
>
Log In
</button>
</form>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950 p-8 text-slate-100">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8 border-b border-slate-800/60 pb-4">
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent flex items-center gap-2">
<span>⚙️</span> Master Data Management
</h1>
<button
onClick={handleLogout}
className="text-slate-400 hover:text-slate-100 bg-slate-900/60 hover:bg-slate-900 border border-slate-850 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 cursor-pointer"
>
Logout
</button>
</div>
<div className="flex space-x-2 mb-6 border-b border-slate-800/60 pb-px">
<button
className={`pb-2.5 px-4 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer ${
activeTab === 'stores'
? 'border-teal-500 text-teal-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
onClick={() => setActiveTab('stores')}
>
Stores
</button>
<button
className={`pb-2.5 px-4 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer ${
activeTab === 'skus'
? 'border-teal-500 text-teal-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
onClick={() => setActiveTab('skus')}
>
SKUs
</button>
</div>
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl backdrop-blur-md">
{activeTab === 'stores' && <StoreManager stores={stores} token={token} onRefresh={() => fetchData(token, 'stores')} />}
{activeTab === 'skus' && <SkuManager skus={skus} token={token} onRefresh={() => fetchData(token, 'skus')} />}
</div>
</div>
</div>
);
}
function StoreManager({ stores, token, onRefresh }: { stores: any[], token: string, onRefresh: () => void }) {
const [isAdding, setIsAdding] = useState(false);
const [form, setForm] = useState({ kode_toko: "", nama_toko: "", alamat: "" });
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const res = await fetch("/api/v1/master/stores", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify(form)
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
setIsAdding(false);
setForm({ kode_toko: "", nama_toko: "", alamat: "" });
onRefresh();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (kode: string) => {
if (!confirm(`Delete store ${kode}?`)) return;
try {
const res = await fetch(`/api/v1/master/stores/${kode}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
onRefresh();
} catch (err: any) {
alert(err.message);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-slate-200 flex items-center gap-2">
<span>🏪</span> Store Master
</h2>
<button
onClick={() => setIsAdding(true)}
className="bg-teal-600 hover:bg-teal-500 text-slate-950 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 shadow-md shadow-teal-900/10 cursor-pointer"
>
+ Add Store
</button>
</div>
{isAdding && (
<form onSubmit={handleSubmit} className="mb-6 bg-slate-950/40 p-5 rounded-xl border border-slate-800/60">
<h3 className="text-xs font-bold text-slate-450 uppercase tracking-wider mb-4">
Add New Store <span className="text-[10px] text-teal-500 font-normal lowercase">(Will auto-generate account with "123" password)</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<input
placeholder="Kode Toko"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.kode_toko}
onChange={e => setForm({...form, kode_toko: e.target.value})}
required
/>
<input
placeholder="Nama Toko"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.nama_toko}
onChange={e => setForm({...form, nama_toko: e.target.value})}
required
/>
<input
placeholder="Alamat"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.alamat}
onChange={e => setForm({...form, alamat: e.target.value})}
/>
</div>
{error && <div className="text-rose-450 text-xs mb-3 font-semibold">⚠️ {error}</div>}
<div className="flex space-x-2">
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Save</button>
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-750 text-slate-300 px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Cancel</button>
</div>
</form>
)}
<div className="overflow-x-auto rounded-xl border border-slate-800/60">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-slate-950/60 border-b border-slate-800/80 text-slate-400">
<tr>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Kode Toko</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Nama Toko</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Alamat</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase w-24">Actions</th>
</tr>
</thead>
<tbody>
{stores.map(s => (
<tr key={s.kode_toko} className="border-b border-slate-850/50 hover:bg-slate-900/30 transition-colors">
<td className="p-3.5 font-semibold text-slate-200 font-mono">{s.kode_toko}</td>
<td className="p-3.5 text-slate-300 font-medium">{s.nama_toko}</td>
<td className="p-3.5 text-slate-400 truncate max-w-xs">{s.alamat}</td>
<td className="p-3.5">
<button
onClick={() => handleDelete(s.kode_toko)}
className="text-rose-400 hover:text-rose-355 transition-colors font-bold cursor-pointer font-mono"
>
Delete
</button>
</td>
</tr>
))}
{stores.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-slate-500">No stores found.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function SkuManager({ skus, token, onRefresh }: { skus: any[], token: string, onRefresh: () => void }) {
const [isAdding, setIsAdding] = useState(false);
const [form, setForm] = useState({ no_sku: "", nama_item: "", jenis_outer: "", standar_jumlah: "1" });
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const payload = { ...form, standar_jumlah: parseInt(form.standar_jumlah) || 1 };
const res = await fetch("/api/v1/master/skus", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
setIsAdding(false);
setForm({ no_sku: "", nama_item: "", jenis_outer: "", standar_jumlah: "1" });
onRefresh();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (kode: string) => {
if (!confirm(`Delete SKU ${kode}?`)) return;
try {
const res = await fetch(`/api/v1/master/skus/${kode}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
onRefresh();
} catch (err: any) {
alert(err.message);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-slate-200 flex items-center gap-2">
<span>📦</span> SKU Master
</h2>
<button
onClick={() => setIsAdding(true)}
className="bg-teal-600 hover:bg-teal-500 text-slate-950 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 shadow-md shadow-teal-900/10 cursor-pointer"
>
+ Add SKU
</button>
</div>
{isAdding && (
<form onSubmit={handleSubmit} className="mb-6 bg-slate-950/40 p-5 rounded-xl border border-slate-800/60">
<h3 className="text-xs font-bold text-slate-450 uppercase tracking-wider mb-4 font-mono">Add New SKU</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<input
placeholder="No SKU"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.no_sku}
onChange={e => setForm({...form, no_sku: e.target.value})}
required
/>
<input
placeholder="Nama Item"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.nama_item}
onChange={e => setForm({...form, nama_item: e.target.value})}
required
/>
<input
placeholder="Jenis Outer (e.g. DUS)"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.jenis_outer}
onChange={e => setForm({...form, jenis_outer: e.target.value})}
/>
<input
type="number"
placeholder="Std Qty"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.standar_jumlah}
onChange={e => setForm({...form, standar_jumlah: e.target.value})}
/>
</div>
{error && <div className="text-rose-450 text-xs mb-3 font-semibold">⚠️ {error}</div>}
<div className="flex space-x-2">
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Save</button>
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-750 text-slate-300 px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Cancel</button>
</div>
</form>
)}
<div className="overflow-x-auto rounded-xl border border-slate-800/60">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-slate-950/60 border-b border-slate-800/80 text-slate-400">
<tr>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">No SKU</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Nama Item</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Outer</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Std Qty</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase w-24">Actions</th>
</tr>
</thead>
<tbody>
{skus.map(s => (
<tr key={s.no_sku} className="border-b border-slate-850/50 hover:bg-slate-900/30 transition-colors">
<td className="p-3.5 font-semibold text-slate-200 font-mono">{s.no_sku}</td>
<td className="p-3.5 text-slate-300 font-medium">{s.nama_item}</td>
<td className="p-3.5 text-slate-400 font-mono">{s.jenis_outer}</td>
<td className="p-3.5 text-slate-400 font-mono">{s.standar_jumlah}</td>
<td className="p-3.5">
<button
onClick={() => handleDelete(s.no_sku)}
className="text-rose-400 hover:text-rose-350 transition-colors font-bold cursor-pointer font-mono"
>
Delete
</button>
</td>
</tr>
))}
{skus.length === 0 && (
<tr><td colSpan={5} className="p-6 text-center text-slate-500">No SKUs found.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+265 -265
View File
@@ -1,265 +1,265 @@
import { NextRequest, NextResponse } from "next/server";
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
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const action = searchParams.get("action") || "list";
const runId = searchParams.get("runId");
const imageType = searchParams.get("imageType"); // 'do', 'product' or null for all
if (runId) {
const runRes = await query(`
SELECT id, image_path, engine, status, ocr_result, time_elapsed_ms, image_type, created_at
FROM arena_runs
WHERE id = $1
`, [parseInt(runId)]);
if (runRes.rowCount === 0) {
return errorResponse(404, "Run not found");
}
return NextResponse.json({ success: true, run: runRes.rows[0] });
}
if (action === "stats") {
let queryText = `
SELECT
engine,
COUNT(*)::integer as total_runs,
COUNT(CASE WHEN status = 'done' THEN 1 END)::integer as success_runs,
COUNT(CASE WHEN status = 'failed' THEN 1 END)::integer as failed_runs,
ROUND(AVG(CASE WHEN status = 'done' THEN time_elapsed_ms END))::integer as avg_time_ms,
MIN(CASE WHEN status = 'done' THEN time_elapsed_ms END)::integer as min_time_ms,
MAX(CASE WHEN status = 'done' THEN time_elapsed_ms END)::integer as max_time_ms
FROM arena_runs
`;
const params: any[] = [];
if (imageType === "do" || imageType === "product") {
queryText += ` WHERE image_type = $1`;
params.push(imageType);
}
queryText += ` GROUP BY engine`;
const statsRes = await query(queryText, params);
return NextResponse.json({ success: true, stats: statsRes.rows });
}
const limit = parseInt(searchParams.get("limit") || "50");
let queryText = `
SELECT id, image_path, engine, status, time_elapsed_ms, image_type, created_at
FROM arena_runs
`;
const params: any[] = [];
if (imageType === "do" || imageType === "product") {
queryText += ` WHERE image_type = $1`;
params.push(imageType);
}
queryText += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`;
params.push(limit);
const runsRes = await query(queryText, params);
return NextResponse.json({ success: true, runs: runsRes.rows });
} catch (error: any) {
console.error("Failed to fetch arena runs/stats:", error);
return errorResponse(500, error.message);
}
}
export async function POST(req: NextRequest) {
const startTime = Date.now();
let engine: string | undefined;
let image: string | undefined;
let imageType = "do";
try {
const body = await req.json().catch(() => ({}));
engine = body.engine;
image = body.image;
if (!engine || !image) {
return errorResponse(400, "Missing engine or image");
}
imageType = body.imageType || "do";
if (typeof image === "string") {
if (image.startsWith("/produk-pfm/") || image.includes("produk-pfm") || image.includes("Product")) {
imageType = "product";
} else if (image.startsWith("/do-pfm/") || image.includes("do-pfm")) {
imageType = "do";
}
}
let imageBuffer: Buffer;
let base64Image = "";
// 1. Resolve image (local file or base64)
if (typeof image === "string" && (image.startsWith("/do-pfm/") || image.startsWith("/produk-pfm/"))) {
// Resolve path in public folder
const cleanPath = image.startsWith("/") ? image.slice(1) : image;
const filePath = path.join(process.cwd(), "public", cleanPath);
if (!fs.existsSync(filePath)) {
return errorResponse(404, `File not found on server: ${image}`);
}
imageBuffer = fs.readFileSync(filePath);
base64Image = `data:image/jpeg;base64,${imageBuffer.toString("base64")}`;
} else if (typeof image === "string" && image.startsWith("data:")) {
// Base64 data URI
base64Image = image;
const base64Data = image.split(",")[1];
imageBuffer = Buffer.from(base64Data, "base64");
} else if (typeof image === "string") {
// Raw base64 string
base64Image = `data:image/jpeg;base64,${image}`;
imageBuffer = Buffer.from(image, "base64");
} else {
return errorResponse(400, "Invalid image format");
}
let outputText = "";
// 2. Route to the requested OCR engine
if (engine === "deepseek") {
const blob = new Blob([new Uint8Array(imageBuffer)], { type: "image/jpeg" });
const gradioUrl = process.env.DEEPSEEK_GRADIO_URL || "http://host.docker.internal:7873/v2/";
const client = await Client.connect(gradioUrl);
const result = await client.predict(2, [blob, "Default", "Markdown", ""]);
const data = result.data as any[];
outputText = data[1] || data[0] || "";
} else if (engine === "lightonocr") {
const url = process.env.LIGHTONOCR_API_URL || "http://host.docker.internal:7678/layout-parsing";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file: base64Image,
useLayoutDetection: false
})
});
if (!res.ok) {
throw new Error(`LightOnOCR backend error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
outputText = data.result?.layoutParsingResults?.[0]?.markdown?.text || "";
} else if (engine === "nemotron") {
const url = process.env.NEMOTRON_API_URL || "http://host.docker.internal:8009/layout-parsing";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file: base64Image,
model: "Multilingual (en, zh, ja, ko, ru, …)",
merge_level: "layout"
})
});
if (!res.ok) {
throw new Error(`Nemotron backend error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
outputText = data.result?.layoutParsingResults?.[0]?.markdown?.text || "";
} else if (engine === "paddle") {
const url = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
const rawB64 = base64Image.includes(",") ? base64Image.split(",")[1] : base64Image;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file: rawB64,
matchHistoryJob: false,
useLayoutDetection: true,
fileType: 1,
useDocUnwarping: false,
useDocOrientationClassify: false
})
});
if (!res.ok) {
throw new Error(`PaddleOCR backend error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const pipelineResult = data.result || data;
outputText = pipelineResult?.layoutParsingResults?.[0]?.markdown?.text || "";
} else if (engine === "dots") {
// Calling python API directly
const url = process.env.DOTS_API_URL || "http://host.docker.internal:7872/layout-parsing";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file: base64Image,
promptLabel: "ocr",
useLayoutDetection: true
})
});
if (!res.ok) {
throw new Error(`Dots OCR backend error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
outputText = data.result?.layoutParsingResults?.[0]?.markdown?.text || "";
} else if (engine === "glm") {
const gradioUrl = process.env.GLM_GRADIO_URL || "http://host.docker.internal:7875/";
const client = await Client.connect(gradioUrl);
const result = await client.predict(2, ["Text", base64Image, 1024, 60]);
const data = result.data as any[];
outputText = data[0] || "";
} else {
return errorResponse(400, `Unknown engine: ${engine}`);
}
const elapsedMs = Date.now() - startTime;
// Record successful run
try {
const loggedImagePath = (typeof image === "string" && image.startsWith("data:"))
? `[Base64 Upload: ${image.length} chars]`
: (typeof image === "string" && image.length > 500)
? `[Raw Base64: ${image.length} chars]`
: image;
await query(
`INSERT INTO arena_runs (image_path, engine, status, ocr_result, time_elapsed_ms, image_type)
VALUES ($1, $2, $3, $4, $5, $6)`,
[loggedImagePath, engine, "done", outputText, elapsedMs, imageType]
);
} catch (dbErr) {
console.error("Failed to log success to arena_runs:", dbErr);
}
return NextResponse.json({
success: true,
text: outputText,
elapsedMs
});
} catch (error: any) {
console.error("OCR Arena proxy error:", error);
const elapsedMs = Date.now() - startTime;
// Record failed run
try {
const loggedImagePath = (typeof image === "string" && image.startsWith("data:"))
? `[Base64 Upload: ${image.length} chars]`
: (typeof image === "string" && image.length > 500)
? `[Raw Base64: ${image.length} chars]`
: image;
await query(
`INSERT INTO arena_runs (image_path, engine, status, ocr_result, time_elapsed_ms, image_type)
VALUES ($1, $2, $3, $4, $5, $6)`,
[loggedImagePath || "unknown", engine || "unknown", "failed", error.message || "Unknown error", elapsedMs, imageType]
);
} catch (dbErr) {
console.error("Failed to log failure to arena_runs:", dbErr);
}
return errorResponse(500, error.message || "Failed to process OCR request");
}
}
import { NextRequest, NextResponse } from "next/server";
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
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const action = searchParams.get("action") || "list";
const runId = searchParams.get("runId");
const imageType = searchParams.get("imageType"); // 'do', 'product' or null for all
if (runId) {
const runRes = await query(`
SELECT id, image_path, engine, status, ocr_result, time_elapsed_ms, image_type, created_at
FROM arena_runs
WHERE id = $1
`, [parseInt(runId)]);
if (runRes.rowCount === 0) {
return errorResponse(404, "Run not found");
}
return NextResponse.json({ success: true, run: runRes.rows[0] });
}
if (action === "stats") {
let queryText = `
SELECT
engine,
COUNT(*)::integer as total_runs,
COUNT(CASE WHEN status = 'done' THEN 1 END)::integer as success_runs,
COUNT(CASE WHEN status = 'failed' THEN 1 END)::integer as failed_runs,
ROUND(AVG(CASE WHEN status = 'done' THEN time_elapsed_ms END))::integer as avg_time_ms,
MIN(CASE WHEN status = 'done' THEN time_elapsed_ms END)::integer as min_time_ms,
MAX(CASE WHEN status = 'done' THEN time_elapsed_ms END)::integer as max_time_ms
FROM arena_runs
`;
const params: any[] = [];
if (imageType === "do" || imageType === "product") {
queryText += ` WHERE image_type = $1`;
params.push(imageType);
}
queryText += ` GROUP BY engine`;
const statsRes = await query(queryText, params);
return NextResponse.json({ success: true, stats: statsRes.rows });
}
const limit = parseInt(searchParams.get("limit") || "50");
let queryText = `
SELECT id, image_path, engine, status, time_elapsed_ms, image_type, created_at
FROM arena_runs
`;
const params: any[] = [];
if (imageType === "do" || imageType === "product") {
queryText += ` WHERE image_type = $1`;
params.push(imageType);
}
queryText += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`;
params.push(limit);
const runsRes = await query(queryText, params);
return NextResponse.json({ success: true, runs: runsRes.rows });
} catch (error: any) {
console.error("Failed to fetch arena runs/stats:", error);
return errorResponse(500, error.message);
}
}
export async function POST(req: NextRequest) {
const startTime = Date.now();
let engine: string | undefined;
let image: string | undefined;
let imageType = "do";
try {
const body = await req.json().catch(() => ({}));
engine = body.engine;
image = body.image;
if (!engine || !image) {
return errorResponse(400, "Missing engine or image");
}
imageType = body.imageType || "do";
if (typeof image === "string") {
if (image.startsWith("/produk-pfm/") || image.includes("produk-pfm") || image.includes("Product")) {
imageType = "product";
} else if (image.startsWith("/do-pfm/") || image.includes("do-pfm")) {
imageType = "do";
}
}
let imageBuffer: Buffer;
let base64Image = "";
// 1. Resolve image (local file or base64)
if (typeof image === "string" && (image.startsWith("/do-pfm/") || image.startsWith("/produk-pfm/"))) {
// Resolve path in public folder
const cleanPath = image.startsWith("/") ? image.slice(1) : image;
const filePath = path.join(process.cwd(), "public", cleanPath);
if (!fs.existsSync(filePath)) {
return errorResponse(404, `File not found on server: ${image}`);
}
imageBuffer = fs.readFileSync(filePath);
base64Image = `data:image/jpeg;base64,${imageBuffer.toString("base64")}`;
} else if (typeof image === "string" && image.startsWith("data:")) {
// Base64 data URI
base64Image = image;
const base64Data = image.split(",")[1];
imageBuffer = Buffer.from(base64Data, "base64");
} else if (typeof image === "string") {
// Raw base64 string
base64Image = `data:image/jpeg;base64,${image}`;
imageBuffer = Buffer.from(image, "base64");
} else {
return errorResponse(400, "Invalid image format");
}
let outputText = "";
// 2. Route to the requested OCR engine
if (engine === "deepseek") {
const blob = new Blob([new Uint8Array(imageBuffer)], { type: "image/jpeg" });
const gradioUrl = process.env.DEEPSEEK_GRADIO_URL || "http://host.docker.internal:7873/v2/";
const client = await Client.connect(gradioUrl);
const result = await client.predict(2, [blob, "Default", "Markdown", ""]);
const data = result.data as any[];
outputText = data[1] || data[0] || "";
} else if (engine === "lightonocr") {
const url = process.env.LIGHTONOCR_API_URL || "http://host.docker.internal:7678/layout-parsing";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file: base64Image,
useLayoutDetection: false
})
});
if (!res.ok) {
throw new Error(`LightOnOCR backend error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
outputText = data.result?.layoutParsingResults?.[0]?.markdown?.text || "";
} else if (engine === "nemotron") {
const url = process.env.NEMOTRON_API_URL || "http://host.docker.internal:8009/layout-parsing";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file: base64Image,
model: "Multilingual (en, zh, ja, ko, ru, …)",
merge_level: "layout"
})
});
if (!res.ok) {
throw new Error(`Nemotron backend error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
outputText = data.result?.layoutParsingResults?.[0]?.markdown?.text || "";
} else if (engine === "paddle") {
const url = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
const rawB64 = base64Image.includes(",") ? base64Image.split(",")[1] : base64Image;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file: rawB64,
matchHistoryJob: false,
useLayoutDetection: true,
fileType: 1,
useDocUnwarping: false,
useDocOrientationClassify: false
})
});
if (!res.ok) {
throw new Error(`PaddleOCR backend error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const pipelineResult = data.result || data;
outputText = pipelineResult?.layoutParsingResults?.[0]?.markdown?.text || "";
} else if (engine === "dots") {
// Calling python API directly
const url = process.env.DOTS_API_URL || "http://host.docker.internal:7872/layout-parsing";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file: base64Image,
promptLabel: "ocr",
useLayoutDetection: true
})
});
if (!res.ok) {
throw new Error(`Dots OCR backend error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
outputText = data.result?.layoutParsingResults?.[0]?.markdown?.text || "";
} else if (engine === "glm") {
const gradioUrl = process.env.GLM_GRADIO_URL || "http://host.docker.internal:7875/";
const client = await Client.connect(gradioUrl);
const result = await client.predict(2, ["Text", base64Image, 1024, 60]);
const data = result.data as any[];
outputText = data[0] || "";
} else {
return errorResponse(400, `Unknown engine: ${engine}`);
}
const elapsedMs = Date.now() - startTime;
// Record successful run
try {
const loggedImagePath = (typeof image === "string" && image.startsWith("data:"))
? `[Base64 Upload: ${image.length} chars]`
: (typeof image === "string" && image.length > 500)
? `[Raw Base64: ${image.length} chars]`
: image;
await query(
`INSERT INTO arena_runs (image_path, engine, status, ocr_result, time_elapsed_ms, image_type)
VALUES ($1, $2, $3, $4, $5, $6)`,
[loggedImagePath, engine, "done", outputText, elapsedMs, imageType]
);
} catch (dbErr) {
console.error("Failed to log success to arena_runs:", dbErr);
}
return NextResponse.json({
success: true,
text: outputText,
elapsedMs
});
} catch (error: any) {
console.error("OCR Arena proxy error:", error);
const elapsedMs = Date.now() - startTime;
// Record failed run
try {
const loggedImagePath = (typeof image === "string" && image.startsWith("data:"))
? `[Base64 Upload: ${image.length} chars]`
: (typeof image === "string" && image.length > 500)
? `[Raw Base64: ${image.length} chars]`
: image;
await query(
`INSERT INTO arena_runs (image_path, engine, status, ocr_result, time_elapsed_ms, image_type)
VALUES ($1, $2, $3, $4, $5, $6)`,
[loggedImagePath || "unknown", engine || "unknown", "failed", error.message || "Unknown error", elapsedMs, imageType]
);
} catch (dbErr) {
console.error("Failed to log failure to arena_runs:", dbErr);
}
return errorResponse(500, error.message || "Failed to process OCR request");
}
}
+56 -56
View File
@@ -1,56 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
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");
export async function POST(req: NextRequest) {
try {
const { filename, image } = await req.json();
if (!filename || !image) {
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
const isSample = fs.existsSync(path.join(PUBLIC_DIR, safeFile));
const filePath = isSample
? path.join(PUBLIC_DIR, safeFile)
: path.join(UPLOADS_DIR, safeFile);
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Write file to disk
fs.writeFileSync(filePath, buffer);
console.log(`Cropped file saved successfully at ${filePath}`);
// Update database fields
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
const stats = fs.statSync(filePath);
// Update document to unparsed state since layout changes
await query(
"UPDATE documents SET size = $1, file_hash = $2, parsed = false, layout_parsing_result = NULL WHERE filename = $3",
[stats.size, fileHash, filename]
);
// Clear old items for this document
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [filename]);
if (docRes.rowCount && docRes.rowCount > 0) {
const docId = docRes.rows[0].id;
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
console.error("Error cropping file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
import { NextRequest, NextResponse } from "next/server";
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");
export async function POST(req: NextRequest) {
try {
const { filename, image } = await req.json();
if (!filename || !image) {
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
const isSample = fs.existsSync(path.join(PUBLIC_DIR, safeFile));
const filePath = isSample
? path.join(PUBLIC_DIR, safeFile)
: path.join(UPLOADS_DIR, safeFile);
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Write file to disk
fs.writeFileSync(filePath, buffer);
console.log(`Cropped file saved successfully at ${filePath}`);
// Update database fields
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
const stats = fs.statSync(filePath);
// Update document to unparsed state since layout changes
await query(
"UPDATE documents SET size = $1, file_hash = $2, parsed = false, layout_parsing_result = NULL WHERE filename = $3",
[stats.size, fileHash, filename]
);
// Clear old items for this document
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [filename]);
if (docRes.rowCount && docRes.rowCount > 0) {
const docId = docRes.rows[0].id;
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
console.error("Error cropping file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -1,38 +1,38 @@
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,
{ params }: { params: Promise<{ id: string }> | { id: string } }
) {
try {
// Handle both Promise and synchronous params for Next.js version compatibility
const resolvedParams = await params;
const { id } = resolvedParams;
const docId = parseInt(id, 10);
if (isNaN(docId)) {
return errorResponse(400, "Invalid document ID");
}
const res = await query(
"SELECT filename, processing_logs FROM documents WHERE id = $1",
[docId]
);
if (res.rowCount === 0 || !res.rows[0]) {
return errorResponse(404, "Document not found");
}
return NextResponse.json({
filename: res.rows[0].filename,
processing_logs: res.rows[0].processing_logs || null
});
} catch (error: any) {
console.error("Error fetching document logs:", error);
return errorResponse(500, error.message);
}
}
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,
{ params }: { params: Promise<{ id: string }> | { id: string } }
) {
try {
// Handle both Promise and synchronous params for Next.js version compatibility
const resolvedParams = await params;
const { id } = resolvedParams;
const docId = parseInt(id, 10);
if (isNaN(docId)) {
return errorResponse(400, "Invalid document ID");
}
const res = await query(
"SELECT filename, processing_logs FROM documents WHERE id = $1",
[docId]
);
if (res.rowCount === 0 || !res.rows[0]) {
return errorResponse(404, "Document not found");
}
return NextResponse.json({
filename: res.rows[0].filename,
processing_logs: res.rows[0].processing_logs || null
});
} catch (error: any) {
console.error("Error fetching document logs:", error);
return errorResponse(500, error.message);
}
}
+47 -47
View File
@@ -1,47 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("file");
if (!filename) {
return errorResponse(400, "File name is required");
}
const safeFile = path.basename(filename);
const filePath = path.join(UPLOADS_DIR, safeFile);
if (!fs.existsSync(filePath)) {
return errorResponse(404, "File not found");
}
// Determine content type based on extension
const ext = path.extname(safeFile).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".jpg" || ext === ".jpeg") {
contentType = "image/jpeg";
} else if (ext === ".png") {
contentType = "image/png";
} else if (ext === ".gif") {
contentType = "image/gif";
} else if (ext === ".pdf") {
contentType = "application/pdf";
}
const fileBuffer = fs.readFileSync(filePath);
return new Response(fileBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable"
}
});
} catch (error: unknown) {
console.error("Error serving file from uploads:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("file");
if (!filename) {
return errorResponse(400, "File name is required");
}
const safeFile = path.basename(filename);
const filePath = path.join(UPLOADS_DIR, safeFile);
if (!fs.existsSync(filePath)) {
return errorResponse(404, "File not found");
}
// Determine content type based on extension
const ext = path.extname(safeFile).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".jpg" || ext === ".jpeg") {
contentType = "image/jpeg";
} else if (ext === ".png") {
contentType = "image/png";
} else if (ext === ".gif") {
contentType = "image/gif";
} else if (ext === ".pdf") {
contentType = "application/pdf";
}
const fileBuffer = fs.readFileSync(filePath);
return new Response(fileBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable"
}
});
} catch (error: unknown) {
console.error("Error serving file from uploads:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
+159 -159
View File
@@ -1,159 +1,159 @@
import { NextRequest, NextResponse } from "next/server";
import {
getGpuInfo,
getContainerStatus,
manageContainer,
recreateContainer,
getEnvSettings,
saveEnvSettings,
getProcessName,
unloadOtherEngines
} from "../../../utils/docker";
import { errorResponse } from "@/utils/api-error";
export async function GET(req: NextRequest) {
try {
const gpus = await getGpuInfo();
const settings = await getEnvSettings();
const containers = {
nginx: await getContainerStatus("paddleocr-nginx"),
vllmServer: await getContainerStatus("paddleocr-vllm-server"),
pipelineApi: await getContainerStatus("paddleocr-pipeline-api"),
gradioUi: await getContainerStatus("paddleocr-gradio-ui"),
pfmWebApp: await getContainerStatus("paddleocr-pfm-web-app"),
db: await getContainerStatus("paddleocr-db")
};
return NextResponse.json({
success: true,
gpus,
settings,
containers
});
} catch (error: any) {
console.error("Failed to fetch GPU/container status:", error);
return errorResponse(500, error.message);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}));
const { action } = body;
if (action === "kill") {
const pid = parseInt(body.pid);
if (!pid || isNaN(pid)) {
return errorResponse(400, "Invalid PID");
}
// Check if process is protected (same rules as admin_panel.py)
const procName = getProcessName(pid);
const procNameLower = procName.toLowerCase();
const protectedKeywords = ["rustdesk", "xorg", "nginx", "systemd", "dockerd", "python3", "node"];
if (anyKeywordMatch(procNameLower, protectedKeywords)) {
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 errorResponse(500, `Failed to kill process: ${err.message}`);
}
}
if (action === "container") {
const { containerName, containerAction } = body;
const validActions = ["start", "stop", "restart"];
const validContainers = [
"paddleocr-nginx",
"paddleocr-vllm-server",
"paddleocr-pipeline-api",
"paddleocr-gradio-ui",
"paddleocr-pfm-web-app",
"paddleocr-db"
];
if (!validActions.includes(containerAction) || !validContainers.includes(containerName)) {
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 errorResponse(400, "Cannot stop the active web application container itself.");
}
await manageContainer(containerName, containerAction);
return NextResponse.json({
success: true,
message: `Command 'docker-compose ${containerAction} ${containerName.replace("paddleocr-", "")}' executed successfully.`
});
}
if (action === "saveSettings") {
const { cudaDevices } = body;
if (typeof cudaDevices !== "string" || cudaDevices.trim() === "") {
return errorResponse(400, "Invalid GPU allocation settings");
}
const cleanCuda = cudaDevices.trim();
await saveEnvSettings(cleanCuda);
// Recreate GPU containers to apply env settings
try {
await recreateContainer("paddleocr-vllm-server", cleanCuda);
} catch (err: any) {
console.error("Failed to recreate vllm-server container:", err);
}
try {
await recreateContainer("paddleocr-pipeline-api", cleanCuda);
} catch (err: any) {
console.error("Failed to recreate pipeline-api container:", err);
}
return NextResponse.json({
success: true,
message: `GPU settings updated to device index ${cleanCuda}. Core services recreated successfully.`
});
}
if (action === "unload") {
const { stopped, failed } = await unloadOtherEngines();
if (stopped.length === 0 && failed.length === 0) {
return NextResponse.json({
success: true,
message: "All other OCR engines are already stopped/unloaded."
});
}
let msg = "";
if (stopped.length > 0) {
msg += `Successfully stopped/unloaded: ${stopped.join(", ")}. `;
}
if (failed.length > 0) {
msg += `Failed to stop: ${failed.join(", ")}.`;
}
return NextResponse.json({
success: failed.length === 0,
message: msg.trim(),
error: failed.length > 0 ? `Failed to stop some containers: ${failed.join(", ")}` : undefined
});
}
return errorResponse(400, "Invalid API action");
} catch (error: any) {
console.error("GPU API POST error:", error);
return errorResponse(500, error.message);
}
}
function anyKeywordMatch(str: string, keywords: string[]): boolean {
for (const kw of keywords) {
if (str.includes(kw)) return true;
}
return false;
}
import { NextRequest, NextResponse } from "next/server";
import {
getGpuInfo,
getContainerStatus,
manageContainer,
recreateContainer,
getEnvSettings,
saveEnvSettings,
getProcessName,
unloadOtherEngines
} from "../../../utils/docker";
import { errorResponse } from "@/utils/api-error";
export async function GET(req: NextRequest) {
try {
const gpus = await getGpuInfo();
const settings = await getEnvSettings();
const containers = {
nginx: await getContainerStatus("paddleocr-nginx"),
vllmServer: await getContainerStatus("paddleocr-vllm-server"),
pipelineApi: await getContainerStatus("paddleocr-pipeline-api"),
gradioUi: await getContainerStatus("paddleocr-gradio-ui"),
pfmWebApp: await getContainerStatus("paddleocr-pfm-web-app"),
db: await getContainerStatus("paddleocr-db")
};
return NextResponse.json({
success: true,
gpus,
settings,
containers
});
} catch (error: any) {
console.error("Failed to fetch GPU/container status:", error);
return errorResponse(500, error.message);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}));
const { action } = body;
if (action === "kill") {
const pid = parseInt(body.pid);
if (!pid || isNaN(pid)) {
return errorResponse(400, "Invalid PID");
}
// Check if process is protected (same rules as admin_panel.py)
const procName = getProcessName(pid);
const procNameLower = procName.toLowerCase();
const protectedKeywords = ["rustdesk", "xorg", "nginx", "systemd", "dockerd", "python3", "node"];
if (anyKeywordMatch(procNameLower, protectedKeywords)) {
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 errorResponse(500, `Failed to kill process: ${err.message}`);
}
}
if (action === "container") {
const { containerName, containerAction } = body;
const validActions = ["start", "stop", "restart"];
const validContainers = [
"paddleocr-nginx",
"paddleocr-vllm-server",
"paddleocr-pipeline-api",
"paddleocr-gradio-ui",
"paddleocr-pfm-web-app",
"paddleocr-db"
];
if (!validActions.includes(containerAction) || !validContainers.includes(containerName)) {
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 errorResponse(400, "Cannot stop the active web application container itself.");
}
await manageContainer(containerName, containerAction);
return NextResponse.json({
success: true,
message: `Command 'docker-compose ${containerAction} ${containerName.replace("paddleocr-", "")}' executed successfully.`
});
}
if (action === "saveSettings") {
const { cudaDevices } = body;
if (typeof cudaDevices !== "string" || cudaDevices.trim() === "") {
return errorResponse(400, "Invalid GPU allocation settings");
}
const cleanCuda = cudaDevices.trim();
await saveEnvSettings(cleanCuda);
// Recreate GPU containers to apply env settings
try {
await recreateContainer("paddleocr-vllm-server", cleanCuda);
} catch (err: any) {
console.error("Failed to recreate vllm-server container:", err);
}
try {
await recreateContainer("paddleocr-pipeline-api", cleanCuda);
} catch (err: any) {
console.error("Failed to recreate pipeline-api container:", err);
}
return NextResponse.json({
success: true,
message: `GPU settings updated to device index ${cleanCuda}. Core services recreated successfully.`
});
}
if (action === "unload") {
const { stopped, failed } = await unloadOtherEngines();
if (stopped.length === 0 && failed.length === 0) {
return NextResponse.json({
success: true,
message: "All other OCR engines are already stopped/unloaded."
});
}
let msg = "";
if (stopped.length > 0) {
msg += `Successfully stopped/unloaded: ${stopped.join(", ")}. `;
}
if (failed.length > 0) {
msg += `Failed to stop: ${failed.join(", ")}.`;
}
return NextResponse.json({
success: failed.length === 0,
message: msg.trim(),
error: failed.length > 0 ? `Failed to stop some containers: ${failed.join(", ")}` : undefined
});
}
return errorResponse(400, "Invalid API action");
} catch (error: any) {
console.error("GPU API POST error:", error);
return errorResponse(500, error.message);
}
}
function anyKeywordMatch(str: string, keywords: string[]): boolean {
for (const kw of keywords) {
if (str.includes(kw)) return true;
}
return false;
}
+202 -202
View File
@@ -1,202 +1,202 @@
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";
export async function GET(req: NextRequest) {
try {
const fileParam = req.nextUrl.searchParams.get("file");
if (fileParam) {
const safeFile = path.basename(fileParam);
// 1. Try to load from database first
const docRes = await query(
"SELECT id, layout_parsing_result, metadata FROM documents WHERE filename = $1",
[safeFile]
);
if (docRes.rowCount && docRes.rowCount > 0) {
const doc = docRes.rows[0];
const docId = doc.id;
const pipelineResult = doc.layout_parsing_result;
// Clean up and re-index invalid items first
await cleanupAndReindexItems(docId);
// Fetch items
const itemsRes = await query(
`SELECT row_index,
kode_barang, nama_barang, banyak, jumlah,
is_flagged, remark
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
}));
const flagged: Record<number, boolean> = {};
const remarks: Record<number, string> = {};
itemsRes.rows.forEach(row => {
if (row.is_flagged) {
flagged[row.row_index] = true;
}
if (row.remark && row.remark.trim()) {
remarks[row.row_index] = row.remark;
}
});
return NextResponse.json({
errorCode: 0,
errorMsg: "Success",
result: pipelineResult,
items,
flagged,
remarks,
headerRemark: (doc.metadata as any)?.headerRemark || ""
});
}
// 2. Fallback to filesystem
const jsonPath = path.join(UPLOADS_DIR, `${safeFile}.json`);
if (fs.existsSync(jsonPath)) {
const jsonData = fs.readFileSync(jsonPath, "utf8");
const data = JSON.parse(jsonData);
return NextResponse.json({
errorCode: 0,
errorMsg: "Success",
result: data.result || data
});
}
return errorResponse(404, "Document not found");
}
// List view: return history list from DB
const showAll = req.nextUrl.searchParams.get("all") === "true";
let listRes;
if (showAll) {
listRes = await query(
`SELECT id, filename, upload_time, size, parsed, is_sample, metadata,
(SELECT COUNT(*) FROM ocr_items WHERE document_id = documents.id) as total_items,
(SELECT COUNT(*) FROM ocr_items WHERE document_id = documents.id AND is_flagged = TRUE) as flagged_items
FROM documents
ORDER BY upload_time DESC`
);
} else {
listRes = await query(
`SELECT id, filename, upload_time, size, parsed, is_sample, metadata,
(SELECT COUNT(*) FROM ocr_items WHERE document_id = documents.id) as total_items,
(SELECT COUNT(*) FROM ocr_items WHERE document_id = documents.id AND is_flagged = TRUE) as flagged_items
FROM documents
WHERE is_sample = FALSE
ORDER BY upload_time DESC`
);
}
const history = listRes.rows.map(row => ({
id: row.id,
filename: row.filename,
uploadTime: row.upload_time.toISOString(),
size: row.size,
parsed: row.parsed,
isSample: row.is_sample,
metadata: row.metadata,
totalItems: parseInt(row.total_items || "0"),
flaggedItems: parseInt(row.flagged_items || "0")
}));
return NextResponse.json({ history });
} catch (error: unknown) {
console.error("Error in history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
export async function DELETE(req: NextRequest) {
try {
const { filename } = await req.json();
if (!filename) {
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
// Check if it exists and get its status
const checkRes = await query(
"SELECT id, is_sample FROM documents WHERE filename = $1",
[safeFile]
);
if (checkRes.rowCount && checkRes.rowCount > 0) {
const doc = checkRes.rows[0];
const isSample = doc.is_sample;
// Delete from DB (cascading delete will remove ocr_items)
await query("DELETE FROM documents WHERE filename = $1", [safeFile]);
// If it is a custom upload, clean up files from /uploads directory
if (!isSample) {
const imagePath = path.join(UPLOADS_DIR, safeFile);
const jsonPath = path.join(UPLOADS_DIR, `${safeFile}.json`);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
if (fs.existsSync(jsonPath)) {
fs.unlinkSync(jsonPath);
}
}
return NextResponse.json({ success: true });
}
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 errorResponse(500, message);
}
}
export async function POST(req: NextRequest) {
try {
const { filename, remark } = await req.json();
if (!filename) {
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
const valueJson = JSON.stringify(remark || "");
const updateRes = await query(
`UPDATE documents
SET metadata = jsonb_set(coalesce(metadata, '{}'::jsonb), '{headerRemark}', $1::jsonb)
WHERE filename = $2`,
[valueJson, safeFile]
);
if (updateRes.rowCount && updateRes.rowCount > 0) {
return NextResponse.json({ success: true });
}
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 errorResponse(500, message);
}
}
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";
export async function GET(req: NextRequest) {
try {
const fileParam = req.nextUrl.searchParams.get("file");
if (fileParam) {
const safeFile = path.basename(fileParam);
// 1. Try to load from database first
const docRes = await query(
"SELECT id, layout_parsing_result, metadata FROM documents WHERE filename = $1",
[safeFile]
);
if (docRes.rowCount && docRes.rowCount > 0) {
const doc = docRes.rows[0];
const docId = doc.id;
const pipelineResult = doc.layout_parsing_result;
// Clean up and re-index invalid items first
await cleanupAndReindexItems(docId);
// Fetch items
const itemsRes = await query(
`SELECT row_index,
kode_barang, nama_barang, banyak, jumlah,
is_flagged, remark
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
}));
const flagged: Record<number, boolean> = {};
const remarks: Record<number, string> = {};
itemsRes.rows.forEach(row => {
if (row.is_flagged) {
flagged[row.row_index] = true;
}
if (row.remark && row.remark.trim()) {
remarks[row.row_index] = row.remark;
}
});
return NextResponse.json({
errorCode: 0,
errorMsg: "Success",
result: pipelineResult,
items,
flagged,
remarks,
headerRemark: (doc.metadata as any)?.headerRemark || ""
});
}
// 2. Fallback to filesystem
const jsonPath = path.join(UPLOADS_DIR, `${safeFile}.json`);
if (fs.existsSync(jsonPath)) {
const jsonData = fs.readFileSync(jsonPath, "utf8");
const data = JSON.parse(jsonData);
return NextResponse.json({
errorCode: 0,
errorMsg: "Success",
result: data.result || data
});
}
return errorResponse(404, "Document not found");
}
// List view: return history list from DB
const showAll = req.nextUrl.searchParams.get("all") === "true";
let listRes;
if (showAll) {
listRes = await query(
`SELECT id, filename, upload_time, size, parsed, is_sample, metadata,
(SELECT COUNT(*) FROM ocr_items WHERE document_id = documents.id) as total_items,
(SELECT COUNT(*) FROM ocr_items WHERE document_id = documents.id AND is_flagged = TRUE) as flagged_items
FROM documents
ORDER BY upload_time DESC`
);
} else {
listRes = await query(
`SELECT id, filename, upload_time, size, parsed, is_sample, metadata,
(SELECT COUNT(*) FROM ocr_items WHERE document_id = documents.id) as total_items,
(SELECT COUNT(*) FROM ocr_items WHERE document_id = documents.id AND is_flagged = TRUE) as flagged_items
FROM documents
WHERE is_sample = FALSE
ORDER BY upload_time DESC`
);
}
const history = listRes.rows.map(row => ({
id: row.id,
filename: row.filename,
uploadTime: row.upload_time.toISOString(),
size: row.size,
parsed: row.parsed,
isSample: row.is_sample,
metadata: row.metadata,
totalItems: parseInt(row.total_items || "0"),
flaggedItems: parseInt(row.flagged_items || "0")
}));
return NextResponse.json({ history });
} catch (error: unknown) {
console.error("Error in history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
export async function DELETE(req: NextRequest) {
try {
const { filename } = await req.json();
if (!filename) {
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
// Check if it exists and get its status
const checkRes = await query(
"SELECT id, is_sample FROM documents WHERE filename = $1",
[safeFile]
);
if (checkRes.rowCount && checkRes.rowCount > 0) {
const doc = checkRes.rows[0];
const isSample = doc.is_sample;
// Delete from DB (cascading delete will remove ocr_items)
await query("DELETE FROM documents WHERE filename = $1", [safeFile]);
// If it is a custom upload, clean up files from /uploads directory
if (!isSample) {
const imagePath = path.join(UPLOADS_DIR, safeFile);
const jsonPath = path.join(UPLOADS_DIR, `${safeFile}.json`);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
if (fs.existsSync(jsonPath)) {
fs.unlinkSync(jsonPath);
}
}
return NextResponse.json({ success: true });
}
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 errorResponse(500, message);
}
}
export async function POST(req: NextRequest) {
try {
const { filename, remark } = await req.json();
if (!filename) {
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
const valueJson = JSON.stringify(remark || "");
const updateRes = await query(
`UPDATE documents
SET metadata = jsonb_set(coalesce(metadata, '{}'::jsonb), '{headerRemark}', $1::jsonb)
WHERE filename = $2`,
[valueJson, safeFile]
);
if (updateRes.rowCount && updateRes.rowCount > 0) {
return NextResponse.json({ success: true });
}
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 errorResponse(500, message);
}
}
@@ -1,23 +1,23 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function GET() {
try {
const dirPath = path.join(process.cwd(), "..", "sources", "test-images");
if (!fs.existsSync(dirPath)) {
return NextResponse.json({ files: [] });
}
const files = fs.readdirSync(dirPath).filter(file => {
const ext = path.extname(file).toLowerCase();
return ext === ".jpg" || ext === ".jpeg" || ext === ".png";
});
// Sort files to keep consistent ordering in UI
files.sort();
return NextResponse.json({ files });
} catch (error: any) {
console.error("Error reading test-images directory:", error);
return errorResponse(500, error.message);
}
}
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function GET() {
try {
const dirPath = path.join(process.cwd(), "..", "sources", "test-images");
if (!fs.existsSync(dirPath)) {
return NextResponse.json({ files: [] });
}
const files = fs.readdirSync(dirPath).filter(file => {
const ext = path.extname(file).toLowerCase();
return ext === ".jpg" || ext === ".jpeg" || ext === ".png";
});
// Sort files to keep consistent ordering in UI
files.sort();
return NextResponse.json({ files });
} catch (error: any) {
console.error("Error reading test-images directory:", error);
return errorResponse(500, error.message);
}
}
@@ -1,238 +1,238 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query } from "@/db";
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;
}
let purged = false;
function readLabels(): ProductScanLabel[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
if (!raw.trim()) return [];
let labels: ProductScanLabel[] = JSON.parse(raw);
// Cleanup phantom uploaded-* entries once
if (!purged) {
const valid = labels.filter((l) => !l.filename.startsWith("uploaded-"));
if (valid.length !== labels.length) {
writeLabels(valid);
labels = valid;
}
purged = true;
}
return labels;
}
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) {
const labels = readLabels();
return NextResponse.json(labels);
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const existing = labels.find((l) => l.filename === safeFilename);
// Inferred values from filename/directory structure
let inferredSku = "";
let inferredNamaItem = "";
const parts = safeFilename.split("/");
if (parts.length > 1) {
const folderName = parts[0];
const match = folderName.match(/^(\d{8})/);
if (match) {
inferredSku = match[1];
} else if (/^\d{8}$/.test(folderName)) {
inferredSku = folderName;
}
}
if (inferredSku) {
try {
const dbRes = await query("SELECT nama_item FROM sku_master WHERE no_sku = $1", [inferredSku]);
if (dbRes.rowCount && dbRes.rowCount > 0) {
inferredNamaItem = dbRes.rows[0].nama_item;
}
} catch (dbErr) {
console.error("Failed to query sku_master for manual label:", dbErr);
}
}
// Inferred expiry date from sibling files in the same parent directory
let siblingExpiry = "";
let parentFolder = "";
if (parts.length > 1) {
parentFolder = parts.slice(0, -1).join("/");
}
if (parentFolder) {
const sibling = labels.find(
(l) => l.filename.startsWith(parentFolder + "/") && l.expiry_date
);
if (sibling) {
siblingExpiry = sibling.expiry_date;
}
}
if (existing) {
return NextResponse.json({
...existing,
no_sku: existing.no_sku || inferredSku,
nama_item: existing.nama_item || inferredNamaItem,
expiry_date: existing.expiry_date || siblingExpiry
});
}
// Return empty default state if not found, with inferred metadata
return NextResponse.json({
filename: safeFilename,
no_sku: inferredSku,
nama_item: inferredNamaItem,
expiry_date: siblingExpiry,
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, image } = body;
let safeFilename = sanitizeFilename(filename || "unknown.jpg");
if (image && image.startsWith("data:image/")) {
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
const hash = crypto.createHash("md5").update(buffer).digest("hex");
const ext = image.match(/^data:image\/(\w+);base64,/)?.[1] || "jpg";
safeFilename = `${hash}.${ext}`;
const saveDir = path.join(process.cwd(), "..", "sources", "product-test-images");
if (!fs.existsSync(saveDir)) {
fs.mkdirSync(saveDir, { recursive: true });
}
fs.writeFileSync(path.join(saveDir, safeFilename), buffer);
}
if (!safeFilename || safeFilename === "unknown.jpg") {
return errorResponse(400, "Filename or valid image is required in request body");
}
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);
}
}
export async function DELETE(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 filtered = labels.filter((l) => l.filename !== safeFilename);
writeLabels(filtered);
return NextResponse.json({ success: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to delete label";
return errorResponse(500, message);
}
}
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query } from "@/db";
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;
}
let purged = false;
function readLabels(): ProductScanLabel[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
if (!raw.trim()) return [];
let labels: ProductScanLabel[] = JSON.parse(raw);
// Cleanup phantom uploaded-* entries once
if (!purged) {
const valid = labels.filter((l) => !l.filename.startsWith("uploaded-"));
if (valid.length !== labels.length) {
writeLabels(valid);
labels = valid;
}
purged = true;
}
return labels;
}
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) {
const labels = readLabels();
return NextResponse.json(labels);
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const existing = labels.find((l) => l.filename === safeFilename);
// Inferred values from filename/directory structure
let inferredSku = "";
let inferredNamaItem = "";
const parts = safeFilename.split("/");
if (parts.length > 1) {
const folderName = parts[0];
const match = folderName.match(/^(\d{8})/);
if (match) {
inferredSku = match[1];
} else if (/^\d{8}$/.test(folderName)) {
inferredSku = folderName;
}
}
if (inferredSku) {
try {
const dbRes = await query("SELECT nama_item FROM sku_master WHERE no_sku = $1", [inferredSku]);
if (dbRes.rowCount && dbRes.rowCount > 0) {
inferredNamaItem = dbRes.rows[0].nama_item;
}
} catch (dbErr) {
console.error("Failed to query sku_master for manual label:", dbErr);
}
}
// Inferred expiry date from sibling files in the same parent directory
let siblingExpiry = "";
let parentFolder = "";
if (parts.length > 1) {
parentFolder = parts.slice(0, -1).join("/");
}
if (parentFolder) {
const sibling = labels.find(
(l) => l.filename.startsWith(parentFolder + "/") && l.expiry_date
);
if (sibling) {
siblingExpiry = sibling.expiry_date;
}
}
if (existing) {
return NextResponse.json({
...existing,
no_sku: existing.no_sku || inferredSku,
nama_item: existing.nama_item || inferredNamaItem,
expiry_date: existing.expiry_date || siblingExpiry
});
}
// Return empty default state if not found, with inferred metadata
return NextResponse.json({
filename: safeFilename,
no_sku: inferredSku,
nama_item: inferredNamaItem,
expiry_date: siblingExpiry,
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, image } = body;
let safeFilename = sanitizeFilename(filename || "unknown.jpg");
if (image && image.startsWith("data:image/")) {
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
const hash = crypto.createHash("md5").update(buffer).digest("hex");
const ext = image.match(/^data:image\/(\w+);base64,/)?.[1] || "jpg";
safeFilename = `${hash}.${ext}`;
const saveDir = path.join(process.cwd(), "..", "sources", "product-test-images");
if (!fs.existsSync(saveDir)) {
fs.mkdirSync(saveDir, { recursive: true });
}
fs.writeFileSync(path.join(saveDir, safeFilename), buffer);
}
if (!safeFilename || safeFilename === "unknown.jpg") {
return errorResponse(400, "Filename or valid image is required in request body");
}
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);
}
}
export async function DELETE(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 filtered = labels.filter((l) => l.filename !== safeFilename);
writeLabels(filtered);
return NextResponse.json({ success: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to delete label";
return errorResponse(500, message);
}
}
@@ -1,147 +1,147 @@
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");
function readLabels(): any[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
return raw.trim() ? JSON.parse(raw) : [];
}
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 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) {
// 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 });
}
if (latest) {
return NextResponse.json({ filename, ...latest, aiPredicted: latest });
}
// Return empty default state if not found anywhere
return NextResponse.json({
filename,
noPO: "",
noSO: "",
noDO: "",
tanggal: "",
customer: "",
store: "",
alamat: "",
plat: "",
items: [],
aiPredicted: null
});
} catch (err: any) {
console.error("Error in GET manual-label:", err);
return errorResponse(500, err.message || "Failed to load manual label");
}
}
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 = path.basename(filename);
const labels = readLabels();
const index = labels.findIndex(l => l.filename === safeFilename);
const entry = { ...body, filename: safeFilename };
if (index >= 0) {
labels[index] = entry;
} else {
labels.push(entry);
}
writeLabels(labels);
return NextResponse.json({ success: true, filePath: LABELS_PATH });
} catch (err: any) {
console.error("Error in POST manual-label:", err);
return errorResponse(500, err.message || "Failed to save manual label");
}
}
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");
function readLabels(): any[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
return raw.trim() ? JSON.parse(raw) : [];
}
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 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) {
// 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 });
}
if (latest) {
return NextResponse.json({ filename, ...latest, aiPredicted: latest });
}
// Return empty default state if not found anywhere
return NextResponse.json({
filename,
noPO: "",
noSO: "",
noDO: "",
tanggal: "",
customer: "",
store: "",
alamat: "",
plat: "",
items: [],
aiPredicted: null
});
} catch (err: any) {
console.error("Error in GET manual-label:", err);
return errorResponse(500, err.message || "Failed to load manual label");
}
}
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 = path.basename(filename);
const labels = readLabels();
const index = labels.findIndex(l => l.filename === safeFilename);
const entry = { ...body, filename: safeFilename };
if (index >= 0) {
labels[index] = entry;
} else {
labels.push(entry);
}
writeLabels(labels);
return NextResponse.json({ success: true, filePath: LABELS_PATH });
} catch (err: any) {
console.error("Error in POST manual-label:", err);
return errorResponse(500, err.message || "Failed to save manual label");
}
}
File diff suppressed because it is too large. Load diff
@@ -1,54 +1,54 @@
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";
export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("filename");
const dirPath = path.join(process.cwd(), "..", "sources", "product-test-images-fixed");
// File serving mode
if (filename) {
const safeFile = path.basename(filename);
const filePath = path.join(dirPath, safeFile);
if (!fs.existsSync(filePath)) {
return errorResponse(404, "File not found");
}
const ext = path.extname(safeFile).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
else if (ext === ".png") contentType = "image/png";
else if (ext === ".webp") contentType = "image/webp";
const fileBuffer = fs.readFileSync(filePath);
return new Response(fileBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable"
}
});
}
// List mode
if (!fs.existsSync(dirPath)) {
return NextResponse.json({ files: [] });
}
const files = fs.readdirSync(dirPath).filter((file) => {
const ext = path.extname(file).toLowerCase();
return [".jpg", ".jpeg", ".png", ".webp"].includes(ext);
});
files.sort();
return NextResponse.json({ files });
} catch (error: unknown) {
console.error("Error in product-images API:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
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";
export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("filename");
const dirPath = path.join(process.cwd(), "..", "sources", "product-test-images-fixed");
// File serving mode
if (filename) {
const safeFile = path.basename(filename);
const filePath = path.join(dirPath, safeFile);
if (!fs.existsSync(filePath)) {
return errorResponse(404, "File not found");
}
const ext = path.extname(safeFile).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
else if (ext === ".png") contentType = "image/png";
else if (ext === ".webp") contentType = "image/webp";
const fileBuffer = fs.readFileSync(filePath);
return new Response(fileBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable"
}
});
}
// List mode
if (!fs.existsSync(dirPath)) {
return NextResponse.json({ files: [] });
}
const files = fs.readdirSync(dirPath).filter((file) => {
const ext = path.extname(file).toLowerCase();
return [".jpg", ".jpeg", ".png", ".webp"].includes(ext);
});
files.sort();
return NextResponse.json({ files });
} catch (error: unknown) {
console.error("Error in product-images API:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -1,86 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
// Serves the most recent accuracy-check-scan.mts detail dump
// (sources/product_scan_detail_*.json) so the manual-label-scan page can show
// what the AI actually predicted for a given Validation Set image by default,
// without re-running the pipeline live for every image browsed. This is the
// same predicted value the accuracy harness scores against ground truth -
// not a fresh scan, so it reflects the last batch test run.
const SOURCES_DIR = path.join(process.cwd(), "..", "sources");
interface DetailCheck {
field: string;
match: boolean;
expected: string;
predicted: string;
}
interface DetailValidationItem {
filename: string;
method?: string;
confidence?: number;
checks: DetailCheck[];
}
interface DetailDump {
timestamp: string;
validation: DetailValidationItem[];
}
function findLatestDump(): { path: string; data: DetailDump } | null {
if (!fs.existsSync(SOURCES_DIR)) return null;
const candidates = fs
.readdirSync(SOURCES_DIR)
.filter((f) => /^product_scan_detail_.*\.json$/.test(f))
.map((f) => {
const p = path.join(SOURCES_DIR, f);
return { path: p, mtime: fs.statSync(p).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
if (candidates.length === 0) return null;
const latest = candidates[0];
const data = JSON.parse(fs.readFileSync(latest.path, "utf8"));
return { path: latest.path, data };
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
const latest = findLatestDump();
if (!latest) {
return NextResponse.json({ available: false });
}
if (!filename) {
return NextResponse.json({ available: true, timestamp: latest.data.timestamp });
}
const item = latest.data.validation.find((v) => v.filename === filename);
if (!item) {
return NextResponse.json({ available: true, timestamp: latest.data.timestamp, found: false });
}
const byField = Object.fromEntries(item.checks.map((c) => [c.field, c]));
return NextResponse.json({
available: true,
found: true,
timestamp: latest.data.timestamp,
method: item.method,
confidence: item.confidence,
no_sku: byField.no_sku?.predicted,
nama_item: byField.nama_item?.predicted,
expiry_date: byField.expiry_date?.predicted
});
} catch (err: unknown) {
console.error("Error in product-scan-results API:", err);
const message = err instanceof Error ? err.message : "Internal server error";
return errorResponse(500, message);
}
}
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
// Serves the most recent accuracy-check-scan.mts detail dump
// (sources/product_scan_detail_*.json) so the manual-label-scan page can show
// what the AI actually predicted for a given Validation Set image by default,
// without re-running the pipeline live for every image browsed. This is the
// same predicted value the accuracy harness scores against ground truth -
// not a fresh scan, so it reflects the last batch test run.
const SOURCES_DIR = path.join(process.cwd(), "..", "sources");
interface DetailCheck {
field: string;
match: boolean;
expected: string;
predicted: string;
}
interface DetailValidationItem {
filename: string;
method?: string;
confidence?: number;
checks: DetailCheck[];
}
interface DetailDump {
timestamp: string;
validation: DetailValidationItem[];
}
function findLatestDump(): { path: string; data: DetailDump } | null {
if (!fs.existsSync(SOURCES_DIR)) return null;
const candidates = fs
.readdirSync(SOURCES_DIR)
.filter((f) => /^product_scan_detail_.*\.json$/.test(f))
.map((f) => {
const p = path.join(SOURCES_DIR, f);
return { path: p, mtime: fs.statSync(p).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
if (candidates.length === 0) return null;
const latest = candidates[0];
const data = JSON.parse(fs.readFileSync(latest.path, "utf8"));
return { path: latest.path, data };
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
const latest = findLatestDump();
if (!latest) {
return NextResponse.json({ available: false });
}
if (!filename) {
return NextResponse.json({ available: true, timestamp: latest.data.timestamp });
}
const item = latest.data.validation.find((v) => v.filename === filename);
if (!item) {
return NextResponse.json({ available: true, timestamp: latest.data.timestamp, found: false });
}
const byField = Object.fromEntries(item.checks.map((c) => [c.field, c]));
return NextResponse.json({
available: true,
found: true,
timestamp: latest.data.timestamp,
method: item.method,
confidence: item.confidence,
no_sku: byField.no_sku?.predicted,
nama_item: byField.nama_item?.predicted,
expiry_date: byField.expiry_date?.predicted
});
} catch (err: unknown) {
console.error("Error in product-scan-results API:", err);
const message = err instanceof Error ? err.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -1,50 +1,50 @@
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";
export async function GET(req: NextRequest) {
try {
const pfmDir = path.join(process.cwd(), "public", "produk-pfm", "foto-kemasan-v2");
if (!fs.existsSync(pfmDir)) {
return NextResponse.json({ products: [] });
}
const entries = fs.readdirSync(pfmDir, { withFileTypes: true });
const products = [];
const ignoredNames = ["models", "runs", "yolo_dataset", ".venv", ".venv-api"];
for (const entry of entries) {
if (entry.isDirectory() && !ignoredNames.includes(entry.name)) {
const productDirPath = path.join(pfmDir, entry.name);
const files = fs.readdirSync(productDirPath);
// Filter image files
const imageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".bmp"];
const images = files.filter(f =>
imageExtensions.includes(path.extname(f).toLowerCase())
);
if (images.length > 0) {
products.push({
productName: entry.name,
images: images.map(img => `/produk-pfm/foto-kemasan-v2/${entry.name}/${img}`),
thumbs: images.map(img => `/produk-pfm/thumbs/${entry.name}/${img}`)
});
}
}
}
// Sort products by name
products.sort((a, b) => a.productName.localeCompare(b.productName));
return NextResponse.json({ products });
} catch (error: unknown) {
console.error("Error fetching produk PFM:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
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";
export async function GET(req: NextRequest) {
try {
const pfmDir = path.join(process.cwd(), "public", "produk-pfm", "foto-kemasan-v2");
if (!fs.existsSync(pfmDir)) {
return NextResponse.json({ products: [] });
}
const entries = fs.readdirSync(pfmDir, { withFileTypes: true });
const products = [];
const ignoredNames = ["models", "runs", "yolo_dataset", ".venv", ".venv-api"];
for (const entry of entries) {
if (entry.isDirectory() && !ignoredNames.includes(entry.name)) {
const productDirPath = path.join(pfmDir, entry.name);
const files = fs.readdirSync(productDirPath);
// Filter image files
const imageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".bmp"];
const images = files.filter(f =>
imageExtensions.includes(path.extname(f).toLowerCase())
);
if (images.length > 0) {
products.push({
productName: entry.name,
images: images.map(img => `/produk-pfm/foto-kemasan-v2/${entry.name}/${img}`),
thumbs: images.map(img => `/produk-pfm/thumbs/${entry.name}/${img}`)
});
}
}
}
// Sort products by name
products.sort((a, b) => a.productName.localeCompare(b.productName));
return NextResponse.json({ products });
} catch (error: unknown) {
console.error("Error fetching produk PFM:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
+56 -56
View File
@@ -1,56 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
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");
export async function POST(req: NextRequest) {
try {
const { filename, image } = await req.json();
if (!filename || !image) {
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
const isSample = fs.existsSync(path.join(PUBLIC_DIR, safeFile));
const filePath = isSample
? path.join(PUBLIC_DIR, safeFile)
: path.join(UPLOADS_DIR, safeFile);
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Write file to disk
fs.writeFileSync(filePath, buffer);
console.log(`Rotated file saved successfully at ${filePath}`);
// Update database fields
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
const stats = fs.statSync(filePath);
// Update document to unparsed state since layout changes
await query(
"UPDATE documents SET size = $1, file_hash = $2, parsed = false, layout_parsing_result = NULL WHERE filename = $3",
[stats.size, fileHash, filename]
);
// Clear old items for this document
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [filename]);
if (docRes.rowCount && docRes.rowCount > 0) {
const docId = docRes.rows[0].id;
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
console.error("Error rotating file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
import { NextRequest, NextResponse } from "next/server";
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");
export async function POST(req: NextRequest) {
try {
const { filename, image } = await req.json();
if (!filename || !image) {
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
const isSample = fs.existsSync(path.join(PUBLIC_DIR, safeFile));
const filePath = isSample
? path.join(PUBLIC_DIR, safeFile)
: path.join(UPLOADS_DIR, safeFile);
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Write file to disk
fs.writeFileSync(filePath, buffer);
console.log(`Rotated file saved successfully at ${filePath}`);
// Update database fields
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
const stats = fs.statSync(filePath);
// Update document to unparsed state since layout changes
await query(
"UPDATE documents SET size = $1, file_hash = $2, parsed = false, layout_parsing_result = NULL WHERE filename = $3",
[stats.size, fileHash, filename]
);
// Clear old items for this document
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [filename]);
if (docRes.rowCount && docRes.rowCount > 0) {
const docId = docRes.rows[0].id;
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
console.error("Error rotating file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -1,31 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const image_base64 = body.image_base64 || body.image;
if (!image_base64) {
return errorResponse(400, "Image is required");
}
const result = await classifyAndMatchProduct(image_base64);
return NextResponse.json({
classification: result.classification,
ocr: result.ocr,
possibleMatches: result.possibleMatches
});
} catch (error: unknown) {
console.error("Error in scan-pfm API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message);
}
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const image_base64 = body.image_base64 || body.image;
if (!image_base64) {
return errorResponse(400, "Image is required");
}
const result = await classifyAndMatchProduct(image_base64);
return NextResponse.json({
classification: result.classification,
ocr: result.ocr,
possibleMatches: result.possibleMatches
});
} catch (error: unknown) {
console.error("Error in scan-pfm API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message);
}
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
+24 -24
View File
@@ -1,24 +1,24 @@
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 no_sku, nama_item FROM sku_master ORDER BY no_sku"
);
const skus = res.rows.map(row => ({
no_sku: row.no_sku,
nama_item: row.nama_item
}));
return NextResponse.json({ skus });
} catch (error: unknown) {
console.error("Error in SKUs API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
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 no_sku, nama_item FROM sku_master ORDER BY no_sku"
);
const skus = res.rows.map(row => ({
no_sku: row.no_sku,
nama_item: row.nama_item
}));
return NextResponse.json({ skus });
} catch (error: unknown) {
console.error("Error in SKUs API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
+25 -25
View File
@@ -1,25 +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);
}
}
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,233 +1,233 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { correctVisualDigits } from "../../../utils/parser";
export const dynamic = "force-dynamic";
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
// Re-implement cleanDateValue directly so we don't have to deal with exports issues if any
const MONTHS_MAP: Record<string, string> = {
january: "January", januari: "January", janov: "January", jan: "January",
february: "February", februari: "February", feb: "February",
march: "March", maret: "March", mar: "March",
april: "April", apr: "April",
may: "May", mei: "May",
june: "June", juni: "June", jun: "June",
july: "July", juli: "July", jul: "July",
august: "August", agustus: "August", agt: "August", ags: "August", aug: "August",
september: "September", sept: "September", sep: "September",
oktober: "October", october: "October", okt: "October", oct: "October",
november: "November", nopember: "November", nov: "November",
desember: "December", december: "December", des: "December", dec: "December"
};
function cleanDateValue(raw: string): string {
if (!raw) return "Not Found";
const cleaned = raw.trim();
if (cleaned === "Not Found" || cleaned === "") return "Not Found";
const today = new Date();
let day: number | null = null;
let monthStr: string | null = null;
let year: number | null = null;
const yearMatch = cleaned.match(/\b(20\d{2})\b/);
if (yearMatch) {
const parsedYear = parseInt(yearMatch[1], 10);
if (parsedYear >= 2010 && parsedYear <= 2035) {
year = parsedYear;
}
}
const lowerRaw = cleaned.toLowerCase();
const monthsKeys = Object.keys(MONTHS_MAP);
monthsKeys.sort((a, b) => b.length - a.length);
for (const key of monthsKeys) {
if (lowerRaw.includes(key)) {
monthStr = MONTHS_MAP[key] || null;
break;
}
}
let textForDay = cleaned;
if (year) {
textForDay = textForDay.replace(year.toString(), "");
}
const dayMatches = textForDay.match(/\b(\d{1,2})\b/g);
if (dayMatches) {
for (const matchStr of dayMatches) {
const parsedDay = parseInt(matchStr, 10);
if (parsedDay >= 1 && parsedDay <= 31) {
day = parsedDay;
break;
}
}
}
const currentYear = today.getFullYear();
const currentMonthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const currentMonth = currentMonthNames[today.getMonth()];
const currentDay = today.getDate();
const finalDay = day !== null ? day : currentDay;
const finalMonth = monthStr !== null ? monthStr : currentMonth;
const finalYear = year !== null ? year : currentYear;
return `${finalDay} ${finalMonth} ${finalYear}`;
}
export async function GET(req: NextRequest) {
const results: string[] = [];
let passed = true;
const assert = (condition: boolean, desc: string) => {
if (condition) {
results.push(`[PASS] ${desc}`);
} else {
results.push(`[FAIL] ${desc}`);
passed = false;
}
};
// 1. Test Visual Digit Correction
const so1 = correctVisualDigits("16O29B7162");
assert(so1 === "1602987162", `correctVisualDigits("16O29B7162") -> got "${so1}", expected "1602987162"`);
const do1 = correctVisualDigits("1602l87");
assert(do1 === "1602187", `correctVisualDigits("1602l87") -> got "${do1}", expected "1602187"`);
const so2 = correctVisualDigits("16O29B7162-OK");
assert(so2 === "1602987162", `correctVisualDigits("16O29B7162-OK") -> got "${so2}", expected "1602987162"`);
// 2. Test Date Lenient Parsing & Fallback Auto-Fill
const today = new Date();
const currentMonthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const currentMonth = currentMonthNames[today.getMonth()];
const currentDay = today.getDate();
const currentYear = today.getFullYear();
const d1 = cleanDateValue("30-Hv-2026");
assert(d1 === `30 ${currentMonth} 2026`, `cleanDateValue("30-Hv-2026") -> got "${d1}", expected "30 ${currentMonth} 2026"`);
const d2 = cleanDateValue("Hv-Jan-2026");
assert(d2 === `${currentDay} January 2026`, `cleanDateValue("Hv-Jan-2026") -> got "${d2}", expected "${currentDay} January 2026"`);
const d3 = cleanDateValue("30-Jan");
assert(d3 === `30 January ${currentYear}`, `cleanDateValue("30-Jan") -> got "${d3}", expected "30 January ${currentYear}"`);
// 3. Test Two-Way Database SKU Cross-Check
try {
const skuDbRes = await query("SELECT no_sku, nama_item FROM sku_master");
const skuMasterList = skuDbRes.rows.map(row => ({
no_sku: row.no_sku.toString().trim(),
nama_item: row.nama_item.toString().trim()
}));
// Mock an OCR parsed items list
const items = [
{
kodeBarang: "11048006",
namaBarang: "BEBEK PARTING wrong ocr text",
banyak: "10 BAG",
jumlah: "100000"
},
{
kodeBarang: "Not Found",
namaBarang: "CEKER BERKUKU FROZEN PACK",
banyak: "20 KRG",
jumlah: "200000"
},
{
kodeBarang: "Not Found",
namaBarang: "Tanda Tangan Supit",
banyak: "Bag. Pengeluaran Barang",
jumlah: "Bagian Penjualan"
}
];
const checkedItems: typeof items = [];
for (const item of items) {
const ocrSku = item.kodeBarang ? item.kodeBarang.trim() : "";
const ocrName = item.namaBarang ? item.namaBarang.trim() : "";
const matchedBySku = /^\d{8}$/.test(ocrSku) ? skuMasterList.find(sku => sku.no_sku === ocrSku) : null;
if (matchedBySku) {
item.kodeBarang = matchedBySku.no_sku;
item.namaBarang = matchedBySku.nama_item;
checkedItems.push(item);
} else {
let bestMatch: typeof skuMasterList[0] | null = null;
let bestScore = 0;
for (const sku of skuMasterList) {
const score = getStringSimilarity(sku.nama_item, ocrName);
if (score > bestScore) {
bestScore = score;
bestMatch = sku;
}
}
if (bestMatch && bestScore >= 0.6) {
item.kodeBarang = bestMatch.no_sku;
item.namaBarang = bestMatch.nama_item;
checkedItems.push(item);
} else {
if (/^\d{8}$/.test(ocrSku)) {
checkedItems.push(item);
}
}
}
}
// Verify checkedItems length (noise item discarded)
assert(checkedItems.length === 2, `checkedItems length should be 2, got ${checkedItems.length} (noise footer row successfully discarded)`);
// Verify item 1 description correction
assert(checkedItems[0].kodeBarang === "11048006", "Item 1 SKU should remain 11048006");
assert(checkedItems[0].namaBarang === "BEBEK PARTING-NEW(*)", `Item 1 name corrected from DB -> got "${checkedItems[0].namaBarang}"`);
// Verify item 2 SKU fuzzy autocomplete from description
assert(checkedItems[1].kodeBarang === "11110059", `Item 2 SKU autocompleted from DB -> got "${checkedItems[1].kodeBarang}"`);
assert(checkedItems[1].namaBarang === "CEKER BERKUKU FROZEN PACK 1 KG(*)", `Item 2 name corrected from DB -> got "${checkedItems[1].namaBarang}"`);
} catch (err: any) {
passed = false;
results.push(`[ERROR] Database SKU check failed: ${err.message}`);
}
return NextResponse.json({
status: passed ? "success" : "failed",
results
});
}
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { correctVisualDigits } from "../../../utils/parser";
export const dynamic = "force-dynamic";
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
// Re-implement cleanDateValue directly so we don't have to deal with exports issues if any
const MONTHS_MAP: Record<string, string> = {
january: "January", januari: "January", janov: "January", jan: "January",
february: "February", februari: "February", feb: "February",
march: "March", maret: "March", mar: "March",
april: "April", apr: "April",
may: "May", mei: "May",
june: "June", juni: "June", jun: "June",
july: "July", juli: "July", jul: "July",
august: "August", agustus: "August", agt: "August", ags: "August", aug: "August",
september: "September", sept: "September", sep: "September",
oktober: "October", october: "October", okt: "October", oct: "October",
november: "November", nopember: "November", nov: "November",
desember: "December", december: "December", des: "December", dec: "December"
};
function cleanDateValue(raw: string): string {
if (!raw) return "Not Found";
const cleaned = raw.trim();
if (cleaned === "Not Found" || cleaned === "") return "Not Found";
const today = new Date();
let day: number | null = null;
let monthStr: string | null = null;
let year: number | null = null;
const yearMatch = cleaned.match(/\b(20\d{2})\b/);
if (yearMatch) {
const parsedYear = parseInt(yearMatch[1], 10);
if (parsedYear >= 2010 && parsedYear <= 2035) {
year = parsedYear;
}
}
const lowerRaw = cleaned.toLowerCase();
const monthsKeys = Object.keys(MONTHS_MAP);
monthsKeys.sort((a, b) => b.length - a.length);
for (const key of monthsKeys) {
if (lowerRaw.includes(key)) {
monthStr = MONTHS_MAP[key] || null;
break;
}
}
let textForDay = cleaned;
if (year) {
textForDay = textForDay.replace(year.toString(), "");
}
const dayMatches = textForDay.match(/\b(\d{1,2})\b/g);
if (dayMatches) {
for (const matchStr of dayMatches) {
const parsedDay = parseInt(matchStr, 10);
if (parsedDay >= 1 && parsedDay <= 31) {
day = parsedDay;
break;
}
}
}
const currentYear = today.getFullYear();
const currentMonthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const currentMonth = currentMonthNames[today.getMonth()];
const currentDay = today.getDate();
const finalDay = day !== null ? day : currentDay;
const finalMonth = monthStr !== null ? monthStr : currentMonth;
const finalYear = year !== null ? year : currentYear;
return `${finalDay} ${finalMonth} ${finalYear}`;
}
export async function GET(req: NextRequest) {
const results: string[] = [];
let passed = true;
const assert = (condition: boolean, desc: string) => {
if (condition) {
results.push(`[PASS] ${desc}`);
} else {
results.push(`[FAIL] ${desc}`);
passed = false;
}
};
// 1. Test Visual Digit Correction
const so1 = correctVisualDigits("16O29B7162");
assert(so1 === "1602987162", `correctVisualDigits("16O29B7162") -> got "${so1}", expected "1602987162"`);
const do1 = correctVisualDigits("1602l87");
assert(do1 === "1602187", `correctVisualDigits("1602l87") -> got "${do1}", expected "1602187"`);
const so2 = correctVisualDigits("16O29B7162-OK");
assert(so2 === "1602987162", `correctVisualDigits("16O29B7162-OK") -> got "${so2}", expected "1602987162"`);
// 2. Test Date Lenient Parsing & Fallback Auto-Fill
const today = new Date();
const currentMonthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const currentMonth = currentMonthNames[today.getMonth()];
const currentDay = today.getDate();
const currentYear = today.getFullYear();
const d1 = cleanDateValue("30-Hv-2026");
assert(d1 === `30 ${currentMonth} 2026`, `cleanDateValue("30-Hv-2026") -> got "${d1}", expected "30 ${currentMonth} 2026"`);
const d2 = cleanDateValue("Hv-Jan-2026");
assert(d2 === `${currentDay} January 2026`, `cleanDateValue("Hv-Jan-2026") -> got "${d2}", expected "${currentDay} January 2026"`);
const d3 = cleanDateValue("30-Jan");
assert(d3 === `30 January ${currentYear}`, `cleanDateValue("30-Jan") -> got "${d3}", expected "30 January ${currentYear}"`);
// 3. Test Two-Way Database SKU Cross-Check
try {
const skuDbRes = await query("SELECT no_sku, nama_item FROM sku_master");
const skuMasterList = skuDbRes.rows.map(row => ({
no_sku: row.no_sku.toString().trim(),
nama_item: row.nama_item.toString().trim()
}));
// Mock an OCR parsed items list
const items = [
{
kodeBarang: "11048006",
namaBarang: "BEBEK PARTING wrong ocr text",
banyak: "10 BAG",
jumlah: "100000"
},
{
kodeBarang: "Not Found",
namaBarang: "CEKER BERKUKU FROZEN PACK",
banyak: "20 KRG",
jumlah: "200000"
},
{
kodeBarang: "Not Found",
namaBarang: "Tanda Tangan Supit",
banyak: "Bag. Pengeluaran Barang",
jumlah: "Bagian Penjualan"
}
];
const checkedItems: typeof items = [];
for (const item of items) {
const ocrSku = item.kodeBarang ? item.kodeBarang.trim() : "";
const ocrName = item.namaBarang ? item.namaBarang.trim() : "";
const matchedBySku = /^\d{8}$/.test(ocrSku) ? skuMasterList.find(sku => sku.no_sku === ocrSku) : null;
if (matchedBySku) {
item.kodeBarang = matchedBySku.no_sku;
item.namaBarang = matchedBySku.nama_item;
checkedItems.push(item);
} else {
let bestMatch: typeof skuMasterList[0] | null = null;
let bestScore = 0;
for (const sku of skuMasterList) {
const score = getStringSimilarity(sku.nama_item, ocrName);
if (score > bestScore) {
bestScore = score;
bestMatch = sku;
}
}
if (bestMatch && bestScore >= 0.6) {
item.kodeBarang = bestMatch.no_sku;
item.namaBarang = bestMatch.nama_item;
checkedItems.push(item);
} else {
if (/^\d{8}$/.test(ocrSku)) {
checkedItems.push(item);
}
}
}
}
// Verify checkedItems length (noise item discarded)
assert(checkedItems.length === 2, `checkedItems length should be 2, got ${checkedItems.length} (noise footer row successfully discarded)`);
// Verify item 1 description correction
assert(checkedItems[0].kodeBarang === "11048006", "Item 1 SKU should remain 11048006");
assert(checkedItems[0].namaBarang === "BEBEK PARTING-NEW(*)", `Item 1 name corrected from DB -> got "${checkedItems[0].namaBarang}"`);
// Verify item 2 SKU fuzzy autocomplete from description
assert(checkedItems[1].kodeBarang === "11110059", `Item 2 SKU autocompleted from DB -> got "${checkedItems[1].kodeBarang}"`);
assert(checkedItems[1].namaBarang === "CEKER BERKUKU FROZEN PACK 1 KG(*)", `Item 2 name corrected from DB -> got "${checkedItems[1].namaBarang}"`);
} catch (err: any) {
passed = false;
results.push(`[ERROR] Database SKU check failed: ${err.message}`);
}
return NextResponse.json({
status: passed ? "success" : "failed",
results
});
}
@@ -1,72 +1,72 @@
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 {
const body = await req.json();
const { page, rowIndex, action } = body;
if (!page || rowIndex === undefined || !action) {
return errorResponse(400, "Missing required fields");
}
const safeFile = path.basename(page);
// Get document ID
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [safeFile]);
if (!docRes.rowCount || docRes.rowCount === 0) {
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 errorResponse(400, "Missing edit parameters");
}
// Map UI field names to database columns
let colName = "";
if (field === "kodeBarang") {
colName = "kode_barang";
} else if (field === "banyak") {
colName = "banyak";
} else if (field === "jumlah") {
colName = "jumlah";
} else {
return errorResponse(400, "Invalid field name");
}
await query(
`UPDATE ocr_items
SET ${colName} = $1
WHERE document_id = $2 AND row_index = $3`,
[value, docId, rowIndex]
);
return NextResponse.json({ success: true });
} else if (action === "flag") {
const { isFlagged, remark } = body;
if (isFlagged === undefined || remark === undefined) {
return errorResponse(400, "Missing flag parameters");
}
await query(
`UPDATE ocr_items
SET is_flagged = $1, remark = $2
WHERE document_id = $3 AND row_index = $4`,
[!!isFlagged, remark, docId, rowIndex]
);
return NextResponse.json({ success: true });
} else {
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 errorResponse(500, message);
}
}
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 {
const body = await req.json();
const { page, rowIndex, action } = body;
if (!page || rowIndex === undefined || !action) {
return errorResponse(400, "Missing required fields");
}
const safeFile = path.basename(page);
// Get document ID
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [safeFile]);
if (!docRes.rowCount || docRes.rowCount === 0) {
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 errorResponse(400, "Missing edit parameters");
}
// Map UI field names to database columns
let colName = "";
if (field === "kodeBarang") {
colName = "kode_barang";
} else if (field === "banyak") {
colName = "banyak";
} else if (field === "jumlah") {
colName = "jumlah";
} else {
return errorResponse(400, "Invalid field name");
}
await query(
`UPDATE ocr_items
SET ${colName} = $1
WHERE document_id = $2 AND row_index = $3`,
[value, docId, rowIndex]
);
return NextResponse.json({ success: true });
} else if (action === "flag") {
const { isFlagged, remark } = body;
if (isFlagged === undefined || remark === undefined) {
return errorResponse(400, "Missing flag parameters");
}
await query(
`UPDATE ocr_items
SET is_flagged = $1, remark = $2
WHERE document_id = $3 AND row_index = $4`,
[!!isFlagged, remark, docId, rowIndex]
);
return NextResponse.json({ success: true });
} else {
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 errorResponse(500, message);
}
}
+241 -241
View File
@@ -1,241 +1,241 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
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";
export async function POST(req: NextRequest) {
try {
// Ensure uploads directory exists
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
const formData = await req.formData();
const file = formData.get("file") as Blob | null;
if (!file) {
return errorResponse(400, "No file uploaded");
}
const originalName = file instanceof File ? file.name : "document.jpg";
// Sanitize filename to avoid directory traversal
const safeName = path.basename(originalName).replace(/\s+/g, "_");
const filename = `${Date.now()}-${safeName}`;
const filePath = path.join(UPLOADS_DIR, filename);
// Save file
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Compute hash to check for duplicate content
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
fs.writeFileSync(filePath, buffer);
// Convert to base64 for pipeline API
const b64 = buffer.toString("base64");
// Form payload
const payload = {
file: b64,
matchHistoryJob: false,
useLayoutDetection: true,
fileType: 1,
useDocUnwarping: false,
useDocOrientationClassify: true
};
const pipelineUrl = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
console.log(`Forwarding uploaded file ${filename} to pipeline: ${pipelineUrl}`);
startActiveLog(filename);
const response = await fetch(pipelineUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
clearActiveLog(filename);
const errText = await response.text();
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
let data = await response.json();
// Check if the image is not straight (tilt > 1.0 degree)
const tilt = calculateAverageTilt(data);
let unwarped = false;
if (tilt > 1.0) {
console.log(`Uploaded document ${filename} is not straight (average tilt: ${tilt.toFixed(2)} deg). Re-running with unwarping and orientation classification enabled...`);
const unwarpPayload = {
...payload,
useDocUnwarping: true,
useDocOrientationClassify: true
};
const unwarpResponse = await fetch(pipelineUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(unwarpPayload)
});
if (unwarpResponse.ok) {
data = await unwarpResponse.json();
console.log(`Document unwarped successfully.`);
unwarped = true;
} else {
console.error(`Unwarping failed with status ${unwarpResponse.status}`);
}
}
// Save JSON extraction result
const jsonPath = `${filePath}.json`;
fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2));
// Save to PostgreSQL database
try {
const pipelineResult = data.result || data;
pipelineResult.pipeline_info = {
tilt,
unwarped,
original_tilt: tilt
};
const page0 = pipelineResult?.layoutParsingResults?.[0] || {};
const markdownText = page0?.markdown?.text || "";
const docMetadata = parseDOMetadata(markdownText);
// Resolve store information using master database
const resolvedStore = await resolveStoreFromText(markdownText);
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
(docMetadata as any).alamat = resolvedStore.alamat;
// Stage 2 Filtering: Sanitize parsed metadata
const sanitizedMetadata = sanitizeParsedMetadata(docMetadata as any);
// Construct client response representation
const wrappedResult = {
errorCode: 0,
errorMsg: "Success",
result: pipelineResult
};
const clientResponse = {
filename,
result: wrappedResult
};
// Retrieve and finalize active log data
const activeLog = getActiveLog(filename);
let logsPayload: any = null;
if (activeLog && activeLog.filename === filename) {
activeLog.ocr_raw = pipelineResult;
activeLog.stage_1_output = docMetadata;
activeLog.stage_2_output = sanitizedMetadata;
activeLog.frontend_response = clientResponse;
activeLog.pipeline_info = {
tilt,
unwarped,
original_tilt: tilt
};
logsPayload = { ...activeLog };
}
clearActiveLog(filename);
const insertDocRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`, [
filename,
new Date(),
buffer.length,
true,
JSON.stringify(sanitizedMetadata),
JSON.stringify(pipelineResult),
false,
fileHash,
logsPayload ? JSON.stringify(logsPayload) : null
]);
const docId = insertDocRes.rows[0].id;
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, $3, $4, $5, $5, $6, $6, false, '')
ON CONFLICT DO NOTHING
`, [
docId,
i,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
} catch (dbErr) {
console.error("Database save failed during upload (falling back to file):", dbErr);
}
const wrappedResult = {
errorCode: 0,
errorMsg: "Success",
result: data.result || data
};
return NextResponse.json({
filename,
result: wrappedResult
});
} catch (error: unknown) {
console.error("Error in upload API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
function getBlockAngle(points: number[][]) {
if (!points || points.length < 2) return 0;
const p0 = points[0];
const p1 = points[1];
const dx = p1[0] - p0[0];
const dy = p1[1] - p0[1];
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
if (angle < -45) angle = 90 + angle;
if (angle > 45) angle = angle - 90;
return Math.abs(angle);
}
function calculateAverageTilt(data: any): number {
const results = data?.result?.layoutParsingResults || data?.layoutParsingResults || [];
if (results.length === 0) return 0;
const list = results[0]?.prunedResult?.parsing_res_list || [];
if (list.length === 0) return 0;
const angles: number[] = [];
for (const block of list) {
if (block.block_polygon_points) {
angles.push(getBlockAngle(block.block_polygon_points));
}
}
if (angles.length === 0) return 0;
return angles.reduce((sum, a) => sum + a, 0) / angles.length;
}
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
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";
export async function POST(req: NextRequest) {
try {
// Ensure uploads directory exists
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
const formData = await req.formData();
const file = formData.get("file") as Blob | null;
if (!file) {
return errorResponse(400, "No file uploaded");
}
const originalName = file instanceof File ? file.name : "document.jpg";
// Sanitize filename to avoid directory traversal
const safeName = path.basename(originalName).replace(/\s+/g, "_");
const filename = `${Date.now()}-${safeName}`;
const filePath = path.join(UPLOADS_DIR, filename);
// Save file
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Compute hash to check for duplicate content
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
fs.writeFileSync(filePath, buffer);
// Convert to base64 for pipeline API
const b64 = buffer.toString("base64");
// Form payload
const payload = {
file: b64,
matchHistoryJob: false,
useLayoutDetection: true,
fileType: 1,
useDocUnwarping: false,
useDocOrientationClassify: true
};
const pipelineUrl = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
console.log(`Forwarding uploaded file ${filename} to pipeline: ${pipelineUrl}`);
startActiveLog(filename);
const response = await fetch(pipelineUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
clearActiveLog(filename);
const errText = await response.text();
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
let data = await response.json();
// Check if the image is not straight (tilt > 1.0 degree)
const tilt = calculateAverageTilt(data);
let unwarped = false;
if (tilt > 1.0) {
console.log(`Uploaded document ${filename} is not straight (average tilt: ${tilt.toFixed(2)} deg). Re-running with unwarping and orientation classification enabled...`);
const unwarpPayload = {
...payload,
useDocUnwarping: true,
useDocOrientationClassify: true
};
const unwarpResponse = await fetch(pipelineUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(unwarpPayload)
});
if (unwarpResponse.ok) {
data = await unwarpResponse.json();
console.log(`Document unwarped successfully.`);
unwarped = true;
} else {
console.error(`Unwarping failed with status ${unwarpResponse.status}`);
}
}
// Save JSON extraction result
const jsonPath = `${filePath}.json`;
fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2));
// Save to PostgreSQL database
try {
const pipelineResult = data.result || data;
pipelineResult.pipeline_info = {
tilt,
unwarped,
original_tilt: tilt
};
const page0 = pipelineResult?.layoutParsingResults?.[0] || {};
const markdownText = page0?.markdown?.text || "";
const docMetadata = parseDOMetadata(markdownText);
// Resolve store information using master database
const resolvedStore = await resolveStoreFromText(markdownText);
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
(docMetadata as any).alamat = resolvedStore.alamat;
// Stage 2 Filtering: Sanitize parsed metadata
const sanitizedMetadata = sanitizeParsedMetadata(docMetadata as any);
// Construct client response representation
const wrappedResult = {
errorCode: 0,
errorMsg: "Success",
result: pipelineResult
};
const clientResponse = {
filename,
result: wrappedResult
};
// Retrieve and finalize active log data
const activeLog = getActiveLog(filename);
let logsPayload: any = null;
if (activeLog && activeLog.filename === filename) {
activeLog.ocr_raw = pipelineResult;
activeLog.stage_1_output = docMetadata;
activeLog.stage_2_output = sanitizedMetadata;
activeLog.frontend_response = clientResponse;
activeLog.pipeline_info = {
tilt,
unwarped,
original_tilt: tilt
};
logsPayload = { ...activeLog };
}
clearActiveLog(filename);
const insertDocRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`, [
filename,
new Date(),
buffer.length,
true,
JSON.stringify(sanitizedMetadata),
JSON.stringify(pipelineResult),
false,
fileHash,
logsPayload ? JSON.stringify(logsPayload) : null
]);
const docId = insertDocRes.rows[0].id;
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, $3, $4, $5, $5, $6, $6, false, '')
ON CONFLICT DO NOTHING
`, [
docId,
i,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
} catch (dbErr) {
console.error("Database save failed during upload (falling back to file):", dbErr);
}
const wrappedResult = {
errorCode: 0,
errorMsg: "Success",
result: data.result || data
};
return NextResponse.json({
filename,
result: wrappedResult
});
} catch (error: unknown) {
console.error("Error in upload API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
function getBlockAngle(points: number[][]) {
if (!points || points.length < 2) return 0;
const p0 = points[0];
const p1 = points[1];
const dx = p1[0] - p0[0];
const dy = p1[1] - p0[1];
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
if (angle < -45) angle = 90 + angle;
if (angle > 45) angle = angle - 90;
return Math.abs(angle);
}
function calculateAverageTilt(data: any): number {
const results = data?.result?.layoutParsingResults || data?.layoutParsingResults || [];
if (results.length === 0) return 0;
const list = results[0]?.prunedResult?.parsing_res_list || [];
if (list.length === 0) return 0;
const angles: number[] = [];
for (const block of list) {
if (block.block_polygon_points) {
angles.push(getBlockAngle(block.block_polygon_points));
}
}
if (angles.length === 0) return 0;
return angles.reduce((sum, a) => sum + a, 0) / angles.length;
}
@@ -1,75 +1,75 @@
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": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { username, password } = body;
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 a.id, a.username, a.password, a.role, a.is_active,
s.kode_toko, s.nama_toko, s.alamat
FROM accounts a
LEFT JOIN store_master s ON a.kode_toko = s.kode_toko
WHERE a.username = $1`,
[username]
);
if (accountRes.rowCount && accountRes.rowCount > 0 && bcrypt.compareSync(password, accountRes.rows[0].password)) {
const account = accountRes.rows[0];
if (!account.is_active) {
return errorResponse(401, "Account is disabled", { headers: corsHeaders });
}
const token = signAccountToken({
accountId: account.id,
username: account.username,
kodeToko: account.kode_toko,
role: account.role
});
return NextResponse.json({
status: "success",
message: "Login successful",
data: {
token,
profile: {
username: account.username,
role: account.role,
is_active: account.is_active,
kodeToko: account.kode_toko,
namaToko: account.nama_toko,
alamat: account.alamat
}
}
}, { 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 errorResponse(500, message, { headers: corsHeaders });
}
}
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": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { username, password } = body;
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 a.id, a.username, a.password, a.role, a.is_active,
s.kode_toko, s.nama_toko, s.alamat
FROM accounts a
LEFT JOIN store_master s ON a.kode_toko = s.kode_toko
WHERE a.username = $1`,
[username]
);
if (accountRes.rowCount && accountRes.rowCount > 0 && bcrypt.compareSync(password, accountRes.rows[0].password)) {
const account = accountRes.rows[0];
if (!account.is_active) {
return errorResponse(401, "Account is disabled", { headers: corsHeaders });
}
const token = signAccountToken({
accountId: account.id,
username: account.username,
kodeToko: account.kode_toko,
role: account.role
});
return NextResponse.json({
status: "success",
message: "Login successful",
data: {
token,
profile: {
username: account.username,
role: account.role,
is_active: account.is_active,
kodeToko: account.kode_toko,
namaToko: account.nama_toko,
alamat: account.alamat
}
}
}, { 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 errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,67 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { query } from "../../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization");
const tokenPayload = getAccountFromAuthHeader(authHeader);
if (!tokenPayload) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const accountRes = await query(
`SELECT a.id, a.username, a.role, a.is_active,
s.kode_toko, s.nama_toko, s.alamat
FROM accounts a
LEFT JOIN store_master s ON a.kode_toko = s.kode_toko
WHERE a.id = $1`,
[tokenPayload.accountId]
);
if (accountRes.rowCount && accountRes.rowCount > 0) {
const account = accountRes.rows[0];
if (!account.is_active) {
return errorResponse(401, "Account is disabled", { headers: corsHeaders });
}
// We extract the token exactly as passed in to echo it back in the same shape as login
const token = authHeader?.slice("Bearer ".length).trim();
return NextResponse.json({
status: "success",
message: "Profile retrieved successfully",
data: {
token,
profile: {
username: account.username,
role: account.role,
is_active: account.is_active,
kodeToko: account.kode_toko,
namaToko: account.nama_toko,
alamat: account.alamat
}
}
}, { headers: corsHeaders });
}
return errorResponse(401, "Account not found", { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in auth/me API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { query } from "../../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization");
const tokenPayload = getAccountFromAuthHeader(authHeader);
if (!tokenPayload) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const accountRes = await query(
`SELECT a.id, a.username, a.role, a.is_active,
s.kode_toko, s.nama_toko, s.alamat
FROM accounts a
LEFT JOIN store_master s ON a.kode_toko = s.kode_toko
WHERE a.id = $1`,
[tokenPayload.accountId]
);
if (accountRes.rowCount && accountRes.rowCount > 0) {
const account = accountRes.rows[0];
if (!account.is_active) {
return errorResponse(401, "Account is disabled", { headers: corsHeaders });
}
// We extract the token exactly as passed in to echo it back in the same shape as login
const token = authHeader?.slice("Bearer ".length).trim();
return NextResponse.json({
status: "success",
message: "Profile retrieved successfully",
data: {
token,
profile: {
username: account.username,
role: account.role,
is_active: account.is_active,
kodeToko: account.kode_toko,
namaToko: account.nama_toko,
alamat: account.alamat
}
}
}, { headers: corsHeaders });
}
return errorResponse(401, "Account not found", { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in auth/me API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,228 +1,228 @@
import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET(
req: NextRequest,
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 docId = parseInt(params.id);
if (isNaN(docId)) {
return errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Deliberately not filtering on `parsed = true` here (unlike the list route) -
// the whole point of this endpoint is to let the poller see pending/failed
// documents, not just done ones.
const docRes = await query(`
SELECT id, filename, upload_time, parsed, is_sample, metadata, latitude, longitude, kode_toko, scan_mode, parse_error, confirmed
FROM documents
WHERE id = $1
`, [docId]);
if (!docRes.rowCount || docRes.rowCount === 0) {
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = docRes.rows[0];
if (account.role !== 'admin' && doc.kode_toko !== account.kodeToko) {
return errorResponse(403, "Forbidden: You do not have permission to view this document", { headers: corsHeaders });
}
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [docId]);
return NextResponse.json({
status: "success",
data: mapDocumentRow(doc, itemsRes.rows)
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in get document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
export async function PUT(
req: NextRequest,
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 errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Check if document exists
const checkRes = await query("SELECT id, filename, upload_time, kode_toko FROM documents WHERE id = $1", [docId]);
if (!checkRes.rowCount || checkRes.rowCount === 0) {
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = checkRes.rows[0];
if (account.role !== 'admin' && doc.kode_toko !== account.kodeToko) {
return errorResponse(403, "Forbidden: You do not have permission to modify this document", { headers: corsHeaders });
}
const body = await req.json();
const {
tanggal,
noPo,
noSo,
noDo,
kepadaYth,
orderUntuk,
alamat,
platTruk,
namaDriver,
namaPenerima,
latitude,
longitude,
items = []
} = body;
// Structuring metadata JSONB to store both formats for full compatibility
const metadata = {
// Legacy Next.js web parser format
tanggal: tanggal || "",
noPO: noPo || "",
noSO: noSo || "",
noDO: noDo || doc.filename || "",
customerInfo: kepadaYth || "",
headerRemark: namaPenerima || "",
// Mobile native app format
header: {
tanggal: tanggal || "",
no_po: noPo || "",
no_so: noSo || "",
no_do: noDo || ""
},
shipment: {
kepada_yth: kepadaYth || "",
order_untuk: orderUntuk || "",
alamat: alamat || "",
plat_truk: platTruk || "",
nama_driver: namaDriver || "",
nama_penerima: namaPenerima || ""
}
};
const latFloat = latitude ? parseFloat(latitude.toString()) : null;
const lngFloat = longitude ? parseFloat(longitude.toString()) : null;
// Update document record. `confirmed = true` is the one and only place
// this flips - this PUT is literally "the user tapped Simpan & Konfirmasi"
// (see docs/api-contract-map.md G11).
await query(`
UPDATE documents
SET parsed = true,
confirmed = true,
latitude = $2,
longitude = $3,
metadata = $4
WHERE id = $1
`, [docId, latFloat, lngFloat, JSON.stringify(metadata)]);
// 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]);
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 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 = {
id: docId.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header: {
tanggal: tanggal || "",
no_po: noPo || "",
no_so: noSo || "",
no_do: noDo || ""
},
shipment: {
kepada_yth: kepadaYth || "",
order_untuk: orderUntuk || "",
alamat: alamat || "",
plat_truk: platTruk || "",
nama_driver: namaDriver || "",
nama_penerima: namaPenerima || ""
},
items: items.map((item: any) => ({
nomor_sku: item.nomor_sku || item.nomorSku || "",
nama_barang: item.nama_barang || item.namaBarang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
})),
latitude: latFloat,
longitude: lngFloat
};
return NextResponse.json({
status: "success",
message: "Document updated successfully",
data: mappedData
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in update document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET(
req: NextRequest,
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 docId = parseInt(params.id);
if (isNaN(docId)) {
return errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Deliberately not filtering on `parsed = true` here (unlike the list route) -
// the whole point of this endpoint is to let the poller see pending/failed
// documents, not just done ones.
const docRes = await query(`
SELECT id, filename, upload_time, parsed, is_sample, metadata, latitude, longitude, kode_toko, scan_mode, parse_error, confirmed
FROM documents
WHERE id = $1
`, [docId]);
if (!docRes.rowCount || docRes.rowCount === 0) {
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = docRes.rows[0];
if (account.role !== 'admin' && doc.kode_toko !== account.kodeToko) {
return errorResponse(403, "Forbidden: You do not have permission to view this document", { headers: corsHeaders });
}
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [docId]);
return NextResponse.json({
status: "success",
data: mapDocumentRow(doc, itemsRes.rows)
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in get document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
export async function PUT(
req: NextRequest,
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 errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Check if document exists
const checkRes = await query("SELECT id, filename, upload_time, kode_toko FROM documents WHERE id = $1", [docId]);
if (!checkRes.rowCount || checkRes.rowCount === 0) {
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = checkRes.rows[0];
if (account.role !== 'admin' && doc.kode_toko !== account.kodeToko) {
return errorResponse(403, "Forbidden: You do not have permission to modify this document", { headers: corsHeaders });
}
const body = await req.json();
const {
tanggal,
noPo,
noSo,
noDo,
kepadaYth,
orderUntuk,
alamat,
platTruk,
namaDriver,
namaPenerima,
latitude,
longitude,
items = []
} = body;
// Structuring metadata JSONB to store both formats for full compatibility
const metadata = {
// Legacy Next.js web parser format
tanggal: tanggal || "",
noPO: noPo || "",
noSO: noSo || "",
noDO: noDo || doc.filename || "",
customerInfo: kepadaYth || "",
headerRemark: namaPenerima || "",
// Mobile native app format
header: {
tanggal: tanggal || "",
no_po: noPo || "",
no_so: noSo || "",
no_do: noDo || ""
},
shipment: {
kepada_yth: kepadaYth || "",
order_untuk: orderUntuk || "",
alamat: alamat || "",
plat_truk: platTruk || "",
nama_driver: namaDriver || "",
nama_penerima: namaPenerima || ""
}
};
const latFloat = latitude ? parseFloat(latitude.toString()) : null;
const lngFloat = longitude ? parseFloat(longitude.toString()) : null;
// Update document record. `confirmed = true` is the one and only place
// this flips - this PUT is literally "the user tapped Simpan & Konfirmasi"
// (see docs/api-contract-map.md G11).
await query(`
UPDATE documents
SET parsed = true,
confirmed = true,
latitude = $2,
longitude = $3,
metadata = $4
WHERE id = $1
`, [docId, latFloat, lngFloat, JSON.stringify(metadata)]);
// 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]);
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 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 = {
id: docId.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header: {
tanggal: tanggal || "",
no_po: noPo || "",
no_so: noSo || "",
no_do: noDo || ""
},
shipment: {
kepada_yth: kepadaYth || "",
order_untuk: orderUntuk || "",
alamat: alamat || "",
plat_truk: platTruk || "",
nama_driver: namaDriver || "",
nama_penerima: namaPenerima || ""
},
items: items.map((item: any) => ({
nomor_sku: item.nomor_sku || item.nomorSku || "",
nama_barang: item.nama_barang || item.namaBarang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
})),
latitude: latFloat,
longitude: lngFloat
};
return NextResponse.json({
status: "success",
message: "Document updated successfully",
data: mappedData
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in update document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,66 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
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
let docsQuery = `
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude, scan_mode, parse_error, confirmed
FROM documents
WHERE is_sample = false AND parsed = true AND confirmed = true
`;
const queryParams: any[] = [];
if (account.role !== 'admin') {
docsQuery += ` AND kode_toko = $1`;
queryParams.push(account.kodeToko);
}
docsQuery += ` ORDER BY upload_time DESC`;
const docRes = await query(docsQuery, queryParams);
const documents = docRes.rows;
const mappedList = [];
for (const doc of documents) {
// Retrieve items from ocr_items
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [doc.id]);
mappedList.push(mapDocumentRow(doc, itemsRes.rows));
}
return NextResponse.json({
status: "success",
data: mappedList
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in list documents API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
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
let docsQuery = `
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude, scan_mode, parse_error, confirmed
FROM documents
WHERE is_sample = false AND parsed = true AND confirmed = true
`;
const queryParams: any[] = [];
if (account.role !== 'admin') {
docsQuery += ` AND kode_toko = $1`;
queryParams.push(account.kodeToko);
}
docsQuery += ` ORDER BY upload_time DESC`;
const docRes = await query(docsQuery, queryParams);
const documents = docRes.rows;
const mappedList = [];
for (const doc of documents) {
// Retrieve items from ocr_items
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [doc.id]);
mappedList.push(mapDocumentRow(doc, itemsRes.rows));
}
return NextResponse.json({
status: "success",
data: mappedList
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in list documents API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,187 +1,187 @@
import { NextRequest, NextResponse } from "next/server";
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";
import { mapDocumentRow } from "@/utils/document-mapper";
const UPLOADS_DIR = "/uploads";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
// Ensure uploads directory exists
if (!fs.existsSync(UPLOADS_DIR)) {
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;
const scanMode = formData.get("scan_mode")?.toString() || "DO";
console.log(`[Upload] Received scan_mode: "${scanMode}"`);
if (!file) {
return errorResponse(400, "No file uploaded", { headers: corsHeaders });
}
const originalName = file instanceof File ? file.name : "document.jpg";
const safeName = path.basename(originalName).replace(/\s+/g, "_");
const filename = `${Date.now()}-${safeName}`;
const filePath = path.join(UPLOADS_DIR, filename);
// Compute hash before writing/inserting anything, so we can detect a duplicate
// upload (e.g. the client retrying after a perceived timeout on a slow OCR pass)
// without creating a second document row or re-running the pipeline on it.
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
// Geolocation tags
const latVal = formData.get("latitude");
const lngVal = formData.get("longitude");
const latitude = latVal ? parseFloat(latVal.toString()) : null;
const longitude = lngVal ? parseFloat(lngVal.toString()) : null;
// Basic dedup
const dedupQuery = account?.kodeToko
? "SELECT id, filename, upload_time, parsed, metadata, latitude, longitude, scan_mode, parse_error, confirmed FROM documents WHERE file_hash = $1 AND kode_toko = $2 ORDER BY upload_time ASC LIMIT 1"
: "SELECT id, filename, upload_time, parsed, metadata, latitude, longitude, scan_mode, parse_error, confirmed FROM documents WHERE file_hash = $1 AND kode_toko IS NULL ORDER BY upload_time ASC LIMIT 1";
const dedupParams = account?.kodeToko ? [fileHash, account.kodeToko] : [fileHash];
const existing = await query(dedupQuery, dedupParams);
if (existing.rows.length > 0) {
const existingDoc = existing.rows[0];
console.log(`[Dedup] Identical content already uploaded as document ${existingDoc.id}. Skipping duplicate insert and re-parse.`);
// Return the original document's actual current parse state instead of an
// always-empty stub, so a retried upload doesn't look permanently "fresh."
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [existingDoc.id]);
const mappedData = mapDocumentRow(existingDoc, itemsRes.rows);
// Fall back to this retry's own GPS tag if the original document never got one.
if (mappedData.latitude === null) mappedData.latitude = latitude;
if (mappedData.longitude === null) mappedData.longitude = longitude;
return NextResponse.json({
status: "success",
message: "Document already uploaded",
data: mappedData
}, { status: 201, headers: corsHeaders });
}
// Save file
fs.writeFileSync(filePath, buffer);
let docId: number;
let finalFilename = filename;
// `confirmed = false`: this row isn't visible via GET /api/v1/documents
// until the user's editor PUT confirms it (see docs/api-contract-map.md G11).
const insertRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko, scan_mode, confirmed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id
`, [
filename,
new Date(),
buffer.length,
false,
false,
fileHash,
latitude,
longitude,
account?.kodeToko || null,
scanMode,
false
]);
docId = insertRes.rows[0].id;
// Trigger parsing synchronously to ensure it is processed immediately on receiving the image.
// Bounded well above /api/parse's own per-pass pipeline timeout (2 passes worst case) so a
// wedged GPU container doesn't hang this request forever - it still won't fit under the
// mobile client's 2-minute receive timeout in the worst case, but bounds the hang to a fixed,
// known ceiling instead of an indefinite one.
//
// /api/parse has its own error handlers that mark the document parsed=true with
// "Not Found" placeholder metadata on a pipeline failure - so those cases already
// resolve out of "pending". The one gap is this call itself never completing
// (network error / the 210s abort firing): /api/parse's handlers never even run,
// so the document is otherwise silently stuck at parsed=false forever. Record
// that case explicitly so GET /api/v1/documents/:id can report parseStatus "failed"
// instead of the client burning its own full timeout waiting on "pending".
try {
const parseRes = await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko, scanMode }),
signal: AbortSignal.timeout(210_000)
});
if (!parseRes.ok) {
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [`Pipeline error: HTTP ${parseRes.status}`, docId]);
}
} catch (err) {
console.error("Error triggering parse synchronously:", err);
const message = err instanceof Error ? err.message : "Parse request failed";
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [message, docId]);
}
// Return the response structured as DocumentModel.fromJson format
const mappedData = {
id: docId.toString(),
header: {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
},
shipment: {
kepada_yth: "PT.PRIMAFOOD INTERNATIONAL",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
},
items: [] as any[],
latitude: latitude,
longitude: longitude,
createdAt: new Date().toISOString()
};
return NextResponse.json({
status: "success",
message: "Document uploaded successfully",
data: mappedData
}, { status: 201, headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in upload API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
import { NextRequest, NextResponse } from "next/server";
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";
import { mapDocumentRow } from "@/utils/document-mapper";
const UPLOADS_DIR = "/uploads";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
// Ensure uploads directory exists
if (!fs.existsSync(UPLOADS_DIR)) {
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;
const scanMode = formData.get("scan_mode")?.toString() || "DO";
console.log(`[Upload] Received scan_mode: "${scanMode}"`);
if (!file) {
return errorResponse(400, "No file uploaded", { headers: corsHeaders });
}
const originalName = file instanceof File ? file.name : "document.jpg";
const safeName = path.basename(originalName).replace(/\s+/g, "_");
const filename = `${Date.now()}-${safeName}`;
const filePath = path.join(UPLOADS_DIR, filename);
// Compute hash before writing/inserting anything, so we can detect a duplicate
// upload (e.g. the client retrying after a perceived timeout on a slow OCR pass)
// without creating a second document row or re-running the pipeline on it.
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
// Geolocation tags
const latVal = formData.get("latitude");
const lngVal = formData.get("longitude");
const latitude = latVal ? parseFloat(latVal.toString()) : null;
const longitude = lngVal ? parseFloat(lngVal.toString()) : null;
// Basic dedup
const dedupQuery = account?.kodeToko
? "SELECT id, filename, upload_time, parsed, metadata, latitude, longitude, scan_mode, parse_error, confirmed FROM documents WHERE file_hash = $1 AND kode_toko = $2 ORDER BY upload_time ASC LIMIT 1"
: "SELECT id, filename, upload_time, parsed, metadata, latitude, longitude, scan_mode, parse_error, confirmed FROM documents WHERE file_hash = $1 AND kode_toko IS NULL ORDER BY upload_time ASC LIMIT 1";
const dedupParams = account?.kodeToko ? [fileHash, account.kodeToko] : [fileHash];
const existing = await query(dedupQuery, dedupParams);
if (existing.rows.length > 0) {
const existingDoc = existing.rows[0];
console.log(`[Dedup] Identical content already uploaded as document ${existingDoc.id}. Skipping duplicate insert and re-parse.`);
// Return the original document's actual current parse state instead of an
// always-empty stub, so a retried upload doesn't look permanently "fresh."
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [existingDoc.id]);
const mappedData = mapDocumentRow(existingDoc, itemsRes.rows);
// Fall back to this retry's own GPS tag if the original document never got one.
if (mappedData.latitude === null) mappedData.latitude = latitude;
if (mappedData.longitude === null) mappedData.longitude = longitude;
return NextResponse.json({
status: "success",
message: "Document already uploaded",
data: mappedData
}, { status: 201, headers: corsHeaders });
}
// Save file
fs.writeFileSync(filePath, buffer);
let docId: number;
let finalFilename = filename;
// `confirmed = false`: this row isn't visible via GET /api/v1/documents
// until the user's editor PUT confirms it (see docs/api-contract-map.md G11).
const insertRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko, scan_mode, confirmed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id
`, [
filename,
new Date(),
buffer.length,
false,
false,
fileHash,
latitude,
longitude,
account?.kodeToko || null,
scanMode,
false
]);
docId = insertRes.rows[0].id;
// Trigger parsing synchronously to ensure it is processed immediately on receiving the image.
// Bounded well above /api/parse's own per-pass pipeline timeout (2 passes worst case) so a
// wedged GPU container doesn't hang this request forever - it still won't fit under the
// mobile client's 2-minute receive timeout in the worst case, but bounds the hang to a fixed,
// known ceiling instead of an indefinite one.
//
// /api/parse has its own error handlers that mark the document parsed=true with
// "Not Found" placeholder metadata on a pipeline failure - so those cases already
// resolve out of "pending". The one gap is this call itself never completing
// (network error / the 210s abort firing): /api/parse's handlers never even run,
// so the document is otherwise silently stuck at parsed=false forever. Record
// that case explicitly so GET /api/v1/documents/:id can report parseStatus "failed"
// instead of the client burning its own full timeout waiting on "pending".
try {
const parseRes = await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko, scanMode }),
signal: AbortSignal.timeout(210_000)
});
if (!parseRes.ok) {
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [`Pipeline error: HTTP ${parseRes.status}`, docId]);
}
} catch (err) {
console.error("Error triggering parse synchronously:", err);
const message = err instanceof Error ? err.message : "Parse request failed";
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [message, docId]);
}
// Return the response structured as DocumentModel.fromJson format
const mappedData = {
id: docId.toString(),
header: {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
},
shipment: {
kepada_yth: "PT.PRIMAFOOD INTERNATIONAL",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
},
items: [] as any[],
latitude: latitude,
longitude: longitude,
createdAt: new Date().toISOString()
};
return NextResponse.json({
status: "success",
message: "Document uploaded successfully",
data: mappedData
}, { status: 201, headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in upload API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,56 +1,56 @@
import { NextResponse } from "next/server";
import { query } from "../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
let dbHealthy = false;
let pipelineHealthy = false;
// Check Database
try {
const res = await query("SELECT 1 as healthy");
if (res.rowCount && res.rows[0].healthy === 1) {
dbHealthy = true;
}
} catch (err) {
console.error("Health check - DB ping failed:", err);
}
// Check Pipeline API
try {
const pipelineUrl = process.env.PIPELINE_URL;
// e.g. http://paddleocr-pipeline-api:8090/layout-parsing
if (pipelineUrl) {
const healthUrl = new URL("/", pipelineUrl).toString();
const response = await fetch(healthUrl, { method: "GET", signal: AbortSignal.timeout(3000) });
// As long as the server responds (even with 404 or 405), it is running.
if (response.status) {
pipelineHealthy = true;
}
} else {
console.warn("Health check - PIPELINE_URL not configured in environment");
}
} catch (err) {
console.error("Health check - Pipeline ping failed:", err);
}
const isHealthy = dbHealthy && pipelineHealthy;
return NextResponse.json({
status: isHealthy ? "ok" : "error",
db: dbHealthy,
pipeline: pipelineHealthy,
}, {
status: isHealthy ? 200 : 503,
headers: corsHeaders
});
}
import { NextResponse } from "next/server";
import { query } from "../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
let dbHealthy = false;
let pipelineHealthy = false;
// Check Database
try {
const res = await query("SELECT 1 as healthy");
if (res.rowCount && res.rows[0].healthy === 1) {
dbHealthy = true;
}
} catch (err) {
console.error("Health check - DB ping failed:", err);
}
// Check Pipeline API
try {
const pipelineUrl = process.env.PIPELINE_URL;
// e.g. http://paddleocr-pipeline-api:8090/layout-parsing
if (pipelineUrl) {
const healthUrl = new URL("/", pipelineUrl).toString();
const response = await fetch(healthUrl, { method: "GET", signal: AbortSignal.timeout(3000) });
// As long as the server responds (even with 404 or 405), it is running.
if (response.status) {
pipelineHealthy = true;
}
} else {
console.warn("Health check - PIPELINE_URL not configured in environment");
}
} catch (err) {
console.error("Health check - Pipeline ping failed:", err);
}
const isHealthy = dbHealthy && pipelineHealthy;
return NextResponse.json({
status: isHealthy ? "ok" : "error",
db: dbHealthy,
pipeline: pipelineHealthy,
}, {
status: isHealthy ? 200 : 503,
headers: corsHeaders
});
}
@@ -1,71 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function PUT(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const body = await req.json();
const {
nama_item,
jenis_outer,
standar_jumlah
} = body;
const res = await query(
`UPDATE sku_master
SET nama_item = $1, jenis_outer = $2, standar_jumlah = $3
WHERE no_sku = $4 RETURNING *`,
[
nama_item,
jenis_outer || '',
String(standar_jumlah || '1'),
kode
]
);
if (res.rowCount === 0) {
return errorResponse(404, "SKU not found");
}
return NextResponse.json({ status: "success", data: res.rows[0] });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const res = await query("DELETE FROM sku_master WHERE no_sku = $1 RETURNING *", [kode]);
if (res.rowCount === 0) {
return errorResponse(404, "SKU not found");
}
return NextResponse.json({ status: "success", message: "SKU deleted successfully" });
} catch (err: any) {
if (err.code === '23503') { // foreign key violation
return errorResponse(409, "Cannot delete SKU because it is referenced in documents");
}
return errorResponse(500, err.message);
}
}
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function PUT(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const body = await req.json();
const {
nama_item,
jenis_outer,
standar_jumlah
} = body;
const res = await query(
`UPDATE sku_master
SET nama_item = $1, jenis_outer = $2, standar_jumlah = $3
WHERE no_sku = $4 RETURNING *`,
[
nama_item,
jenis_outer || '',
String(standar_jumlah || '1'),
kode
]
);
if (res.rowCount === 0) {
return errorResponse(404, "SKU not found");
}
return NextResponse.json({ status: "success", data: res.rows[0] });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const res = await query("DELETE FROM sku_master WHERE no_sku = $1 RETURNING *", [kode]);
if (res.rowCount === 0) {
return errorResponse(404, "SKU not found");
}
return NextResponse.json({ status: "success", message: "SKU deleted successfully" });
} catch (err: any) {
if (err.code === '23503') { // foreign key violation
return errorResponse(409, "Cannot delete SKU because it is referenced in documents");
}
return errorResponse(500, err.message);
}
}
@@ -1,68 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function GET(req: NextRequest) {
try {
// Read access is open to any authenticated account (task 9.2) - the
// Flutter product editor needs this to populate its SKU dropdown, and
// has no admin role of its own. Writes below stay admin-gated.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized");
}
const res = await query(`
SELECT no_sku, nama_item, standar_jumlah, berat_kemasan, isi_outer_kg, isi_outer_pac, jenis_outer
FROM sku_master
ORDER BY no_sku ASC
`);
return NextResponse.json({ status: "success", data: res.rows });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function POST(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const body = await req.json();
const {
kode_item,
no_sku,
nama_item,
jenis_outer,
standar_jumlah
} = body;
const skuCode = no_sku || kode_item;
if (!skuCode || !nama_item) {
return errorResponse(400, "no_sku and nama_item are required");
}
await query(
`INSERT INTO sku_master
(no_sku, nama_item, jenis_outer, standar_jumlah)
VALUES ($1, $2, $3, $4)`,
[
skuCode,
nama_item,
jenis_outer || '',
String(standar_jumlah || '1')
]
);
return NextResponse.json({ status: "success", message: "SKU created successfully" });
} catch (err: any) {
if (err.code === '23505') { // unique violation
return errorResponse(409, "SKU with this kode_item already exists");
}
return errorResponse(500, err.message);
}
}
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function GET(req: NextRequest) {
try {
// Read access is open to any authenticated account (task 9.2) - the
// Flutter product editor needs this to populate its SKU dropdown, and
// has no admin role of its own. Writes below stay admin-gated.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized");
}
const res = await query(`
SELECT no_sku, nama_item, standar_jumlah, berat_kemasan, isi_outer_kg, isi_outer_pac, jenis_outer
FROM sku_master
ORDER BY no_sku ASC
`);
return NextResponse.json({ status: "success", data: res.rows });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function POST(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const body = await req.json();
const {
kode_item,
no_sku,
nama_item,
jenis_outer,
standar_jumlah
} = body;
const skuCode = no_sku || kode_item;
if (!skuCode || !nama_item) {
return errorResponse(400, "no_sku and nama_item are required");
}
await query(
`INSERT INTO sku_master
(no_sku, nama_item, jenis_outer, standar_jumlah)
VALUES ($1, $2, $3, $4)`,
[
skuCode,
nama_item,
jenis_outer || '',
String(standar_jumlah || '1')
]
);
return NextResponse.json({ status: "success", message: "SKU created successfully" });
} catch (err: any) {
if (err.code === '23505') { // unique violation
return errorResponse(409, "SKU with this kode_item already exists");
}
return errorResponse(500, err.message);
}
}
@@ -1,68 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function PUT(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const body = await req.json();
const { nama_toko, alamat } = body;
const res = await query(
"UPDATE store_master SET nama_toko = $1, alamat = $2 WHERE kode_toko = $3 RETURNING *",
[nama_toko, alamat || '', kode]
);
if (res.rowCount === 0) {
return errorResponse(404, "Store not found");
}
return NextResponse.json({ status: "success", data: res.rows[0] });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
await withTransaction(async (client) => {
// Delete associated account first due to FK account -> store_master
await client.query("DELETE FROM accounts WHERE kode_toko = $1", [kode]);
const res = await client.query("DELETE FROM store_master WHERE kode_toko = $1 RETURNING *", [kode]);
if (res.rowCount === 0) {
throw new Error("Store not found");
}
});
return NextResponse.json({ status: "success", message: "Store and associated account deleted successfully" });
} catch (err: any) {
if (err.code === '23503') { // foreign key violation (e.g. documents exist)
return errorResponse(409, "Cannot delete store because it has associated documents");
}
if (err.message === "Store not found") {
return errorResponse(404, err.message);
}
return errorResponse(500, err.message);
}
}
import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function PUT(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const body = await req.json();
const { nama_toko, alamat } = body;
const res = await query(
"UPDATE store_master SET nama_toko = $1, alamat = $2 WHERE kode_toko = $3 RETURNING *",
[nama_toko, alamat || '', kode]
);
if (res.rowCount === 0) {
return errorResponse(404, "Store not found");
}
return NextResponse.json({ status: "success", data: res.rows[0] });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
await withTransaction(async (client) => {
// Delete associated account first due to FK account -> store_master
await client.query("DELETE FROM accounts WHERE kode_toko = $1", [kode]);
const res = await client.query("DELETE FROM store_master WHERE kode_toko = $1 RETURNING *", [kode]);
if (res.rowCount === 0) {
throw new Error("Store not found");
}
});
return NextResponse.json({ status: "success", message: "Store and associated account deleted successfully" });
} catch (err: any) {
if (err.code === '23503') { // foreign key violation (e.g. documents exist)
return errorResponse(409, "Cannot delete store because it has associated documents");
}
if (err.message === "Store not found") {
return errorResponse(404, err.message);
}
return errorResponse(500, err.message);
}
}
@@ -1,63 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import bcrypt from "bcryptjs";
export async function GET(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization");
console.log("Auth Header in GET:", authHeader);
const account = getAccountFromAuthHeader(authHeader);
console.log("Decoded Account:", account);
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const res = await query("SELECT kode_toko, nama_toko, alamat FROM store_master ORDER BY kode_toko ASC");
return NextResponse.json({ status: "success", data: res.rows });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function POST(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const body = await req.json();
const { kode_toko, nama_toko, alamat } = body;
if (!kode_toko || !nama_toko) {
return errorResponse(400, "kode_toko and nama_toko are required");
}
await withTransaction(async (client) => {
// 1. Insert store
await client.query(
"INSERT INTO store_master (kode_toko, nama_toko, alamat) VALUES ($1, $2, $3)",
[kode_toko, nama_toko, alamat || '']
);
// 2. Hash default password
const hashedPassword = await bcrypt.hash('123', 10);
// 3. Create default account
await client.query(
`INSERT INTO accounts (username, password, role, is_active, kode_toko)
VALUES ($1, $2, 'store', true, $3)`,
[kode_toko, hashedPassword, kode_toko]
);
});
return NextResponse.json({ status: "success", message: "Store and account created successfully" });
} catch (err: any) {
if (err.code === '23505') { // unique violation
return errorResponse(409, "Store with this kode_toko already exists");
}
return errorResponse(500, err.message);
}
}
import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import bcrypt from "bcryptjs";
export async function GET(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization");
console.log("Auth Header in GET:", authHeader);
const account = getAccountFromAuthHeader(authHeader);
console.log("Decoded Account:", account);
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const res = await query("SELECT kode_toko, nama_toko, alamat FROM store_master ORDER BY kode_toko ASC");
return NextResponse.json({ status: "success", data: res.rows });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function POST(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const body = await req.json();
const { kode_toko, nama_toko, alamat } = body;
if (!kode_toko || !nama_toko) {
return errorResponse(400, "kode_toko and nama_toko are required");
}
await withTransaction(async (client) => {
// 1. Insert store
await client.query(
"INSERT INTO store_master (kode_toko, nama_toko, alamat) VALUES ($1, $2, $3)",
[kode_toko, nama_toko, alamat || '']
);
// 2. Hash default password
const hashedPassword = await bcrypt.hash('123', 10);
// 3. Create default account
await client.query(
`INSERT INTO accounts (username, password, role, is_active, kode_toko)
VALUES ($1, $2, 'store', true, $3)`,
[kode_toko, hashedPassword, kode_toko]
);
});
return NextResponse.json({ status: "success", message: "Store and account created successfully" });
} catch (err: any) {
if (err.code === '23505') { // unique violation
return errorResponse(409, "Store with this kode_toko already exists");
}
return errorResponse(500, err.message);
}
}
@@ -1,60 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
// Any authenticated account may scan - unlike sku_master writes, this is the
// route the mobile app itself calls to do a product scan, not an admin tool.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
let imageBase64: string | null = null;
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("multipart/form-data")) {
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const buffer = Buffer.from(await file.arrayBuffer());
imageBase64 = buffer.toString("base64");
} else {
const body = await req.json();
imageBase64 = body.image_base64 || body.image || null;
}
if (!imageBase64) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const result = await classifyAndMatchProduct(imageBase64);
return NextResponse.json({
status: "success",
data: result
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in v1 scan-product API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message, { headers: corsHeaders });
}
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
// Any authenticated account may scan - unlike sku_master writes, this is the
// route the mobile app itself calls to do a product scan, not an admin tool.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
let imageBase64: string | null = null;
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("multipart/form-data")) {
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const buffer = Buffer.from(await file.arrayBuffer());
imageBase64 = buffer.toString("base64");
} else {
const body = await req.json();
imageBase64 = body.image_base64 || body.image || null;
}
if (!imageBase64) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const result = await classifyAndMatchProduct(imageBase64);
return NextResponse.json({
status: "success",
data: result
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in v1 scan-product API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message, { headers: corsHeaders });
}
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,135 +1,135 @@
import { NextRequest, NextResponse } from "next/server";
import { logVllmCallToAll } from "../../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
return handleProxy(req);
}
export async function GET(req: NextRequest) {
return handleProxy(req);
}
export async function PUT(req: NextRequest) {
return handleProxy(req);
}
export async function DELETE(req: NextRequest) {
return handleProxy(req);
}
async function handleProxy(req: NextRequest) {
try {
const pathname = req.nextUrl.pathname;
// Extract everything after /api/vllm-proxy
const relPath = pathname.replace(/^\/api\/vllm-proxy/, "");
// The real vLLM server is at paddleocr-vllm-server:8118 inside docker compose
const realBaseUrl = process.env.VLLM_SERVER_REAL_URL || "http://paddleocr-vllm-server:8118";
// Construct the destination URL
const destUrl = `${realBaseUrl}${relPath}${req.nextUrl.search}`;
console.log(`[vllm-proxy] Routing request from ${pathname} to ${destUrl}`);
// Read the request body if present
let reqBody: any = null;
let reqBodyBuffer: Buffer | null = null;
if (req.body) {
const arrayBuffer = await req.arrayBuffer();
reqBodyBuffer = Buffer.from(arrayBuffer);
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
try {
reqBody = JSON.parse(reqBodyBuffer.toString("utf-8"));
} catch (e) {
console.warn("[vllm-proxy] Failed to parse request body as JSON:", e);
}
}
}
// Reconstruct headers, filtering out headers that might cause issues (e.g. Host)
const headers = new Headers();
req.headers.forEach((value, key) => {
if (key.toLowerCase() !== "host" && key.toLowerCase() !== "content-length") {
headers.set(key, value);
}
});
// Make the actual call to the real vLLM server
const forwardResponse = await fetch(destUrl, {
method: req.method,
headers: headers,
body: reqBodyBuffer ? new Uint8Array(reqBodyBuffer) : null,
// @ts-ignore
duplex: "half"
});
// Read the response content
const resBodyBuffer = Buffer.from(await forwardResponse.arrayBuffer());
let resBody: any = null;
const resContentType = forwardResponse.headers.get("content-type") || "";
if (resContentType.includes("application/json")) {
try {
resBody = JSON.parse(resBodyBuffer.toString("utf-8"));
} catch (e) {
console.warn("[vllm-proxy] Failed to parse response body as JSON:", e);
}
} else {
resBody = resBodyBuffer.toString("utf-8");
}
// Log the interaction if it looks like a completion call
if (pathname.includes("/chat/completions") || pathname.includes("/completions")) {
// Make a clean copy of the request to log (hiding huge base64 images if they clutter logs)
const cleanReq = sanitizeLogPayload(reqBody);
logVllmCallToAll(cleanReq, resBody);
}
// Return the response back to pipeline-api
const responseHeaders = new Headers();
forwardResponse.headers.forEach((value, key) => {
responseHeaders.set(key, value);
});
return new Response(resBodyBuffer, {
status: forwardResponse.status,
statusText: forwardResponse.statusText,
headers: responseHeaders
});
} catch (error) {
console.error("[vllm-proxy] Error forwarding request:", error);
return errorResponse(500, "Failed to proxy request to vLLM server");
}
}
// Helper function to keep log sizes reasonable by truncating huge base64 image strings
function sanitizeLogPayload(payload: any): any {
if (!payload) return payload;
try {
const copy = JSON.parse(JSON.stringify(payload));
if (copy.messages && Array.isArray(copy.messages)) {
for (const msg of copy.messages) {
if (msg.content && Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "image_url" && part.image_url && part.image_url.url) {
const url = part.image_url.url;
if (url.startsWith("data:") && url.length > 200) {
part.image_url.url = url.substring(0, 100) + "...[TRUNCATED BASE64]..." + url.substring(url.length - 50);
}
}
}
}
}
}
return copy;
} catch (e) {
return payload;
}
}
import { NextRequest, NextResponse } from "next/server";
import { logVllmCallToAll } from "../../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
return handleProxy(req);
}
export async function GET(req: NextRequest) {
return handleProxy(req);
}
export async function PUT(req: NextRequest) {
return handleProxy(req);
}
export async function DELETE(req: NextRequest) {
return handleProxy(req);
}
async function handleProxy(req: NextRequest) {
try {
const pathname = req.nextUrl.pathname;
// Extract everything after /api/vllm-proxy
const relPath = pathname.replace(/^\/api\/vllm-proxy/, "");
// The real vLLM server is at paddleocr-vllm-server:8118 inside docker compose
const realBaseUrl = process.env.VLLM_SERVER_REAL_URL || "http://paddleocr-vllm-server:8118";
// Construct the destination URL
const destUrl = `${realBaseUrl}${relPath}${req.nextUrl.search}`;
console.log(`[vllm-proxy] Routing request from ${pathname} to ${destUrl}`);
// Read the request body if present
let reqBody: any = null;
let reqBodyBuffer: Buffer | null = null;
if (req.body) {
const arrayBuffer = await req.arrayBuffer();
reqBodyBuffer = Buffer.from(arrayBuffer);
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
try {
reqBody = JSON.parse(reqBodyBuffer.toString("utf-8"));
} catch (e) {
console.warn("[vllm-proxy] Failed to parse request body as JSON:", e);
}
}
}
// Reconstruct headers, filtering out headers that might cause issues (e.g. Host)
const headers = new Headers();
req.headers.forEach((value, key) => {
if (key.toLowerCase() !== "host" && key.toLowerCase() !== "content-length") {
headers.set(key, value);
}
});
// Make the actual call to the real vLLM server
const forwardResponse = await fetch(destUrl, {
method: req.method,
headers: headers,
body: reqBodyBuffer ? new Uint8Array(reqBodyBuffer) : null,
// @ts-ignore
duplex: "half"
});
// Read the response content
const resBodyBuffer = Buffer.from(await forwardResponse.arrayBuffer());
let resBody: any = null;
const resContentType = forwardResponse.headers.get("content-type") || "";
if (resContentType.includes("application/json")) {
try {
resBody = JSON.parse(resBodyBuffer.toString("utf-8"));
} catch (e) {
console.warn("[vllm-proxy] Failed to parse response body as JSON:", e);
}
} else {
resBody = resBodyBuffer.toString("utf-8");
}
// Log the interaction if it looks like a completion call
if (pathname.includes("/chat/completions") || pathname.includes("/completions")) {
// Make a clean copy of the request to log (hiding huge base64 images if they clutter logs)
const cleanReq = sanitizeLogPayload(reqBody);
logVllmCallToAll(cleanReq, resBody);
}
// Return the response back to pipeline-api
const responseHeaders = new Headers();
forwardResponse.headers.forEach((value, key) => {
responseHeaders.set(key, value);
});
return new Response(resBodyBuffer, {
status: forwardResponse.status,
statusText: forwardResponse.statusText,
headers: responseHeaders
});
} catch (error) {
console.error("[vllm-proxy] Error forwarding request:", error);
return errorResponse(500, "Failed to proxy request to vLLM server");
}
}
// Helper function to keep log sizes reasonable by truncating huge base64 image strings
function sanitizeLogPayload(payload: any): any {
if (!payload) return payload;
try {
const copy = JSON.parse(JSON.stringify(payload));
if (copy.messages && Array.isArray(copy.messages)) {
for (const msg of copy.messages) {
if (msg.content && Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "image_url" && part.image_url && part.image_url.url) {
const url = part.image_url.url;
if (url.startsWith("data:") && url.length > 200) {
part.image_url.url = url.substring(0, 100) + "...[TRUNCATED BASE64]..." + url.substring(url.length - 50);
}
}
}
}
}
}
return copy;
} catch (e) {
return payload;
}
}
+20 -20
View File
@@ -1,20 +1,20 @@
@import "tailwindcss";
:root {
--background: #0f172a;
--foreground: #f8fafc;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
}
@import "tailwindcss";
:root {
--background: #0f172a;
--foreground: #f8fafc;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
}
+23 -23
View File
@@ -1,23 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AI OCR Delivery Order",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className="h-full antialiased text-slate-100 bg-slate-950"
suppressHydrationWarning
>
<body className="min-h-full flex flex-col font-sans">{children}</body>
</html>
);
}
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AI OCR Delivery Order",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className="h-full antialiased text-slate-100 bg-slate-950"
suppressHydrationWarning
>
<body className="min-h-full flex flex-col font-sans">{children}</body>
</html>
);
}
@@ -1,253 +1,253 @@
"use client";
import React, { useState, useEffect } from "react";
import { Sidebar } from "@/components/manual-label-scan/Sidebar";
import { Editor, ScanLabelFormData, AiPredictedData } from "@/components/manual-label-scan/Editor";
import { ImageViewer } from "@/components/manual-label-scan/ImageViewer";
import { getErrorMessage } from "@/utils/client-error";
export default function ManualLabelScanPage() {
const [files, setFiles] = useState<{ url: string; filename: string }[]>([]);
const [currentIndex, setCurrentIndex] = useState(-1);
const [formData, setFormData] = useState<ScanLabelFormData>({
filename: "",
no_sku: "",
nama_item: "",
expiry_date: "",
notes: ""
});
const [aiPredicted, setAiPredicted] = useState<AiPredictedData | null>(null);
const [aiSource, setAiSource] = useState<{ type: "batch" | "live"; timestamp: string; method?: string; confidence?: number } | null>(null);
const [skuList, setSkuList] = useState<Array<{ no_sku: string; nama_item: string }>>([]);
const [isScanning, setIsScanning] = useState(false);
const [savingGT, setSavingGT] = useState(false);
const [toast, setToast] = useState({ message: "", show: false, isError: false });
const showToast = (message: string, isError = false) => {
setToast({ message, show: true, isError });
setTimeout(() => setToast(p => ({ ...p, show: false })), 2500);
};
useEffect(() => {
const fetchAllData = async () => {
try {
// Fetch Skus
const skuRes = await fetch("/api/skus");
if (skuRes.ok) {
const skuData = await skuRes.json();
setSkuList(skuData.skus || []);
}
// Fetch Test Images — the frozen 79-image Validation Set
// (product-test-images-fixed/), the only set the accuracy harness
// scores. Gallery/training photos (foto-kemasan-v2/) are not shown
// here: they don't need per-photo ground truth, only correct
// SKU-folder placement for classifier training.
const testRes = await fetch("/api/product-images");
let testFiles: { url: string; filename: string }[] = [];
if (testRes.ok) {
const testData = await testRes.json();
testFiles = (testData.files || []).map((f: string) => ({
url: `/api/product-images?filename=${encodeURIComponent(f)}`,
filename: f
}));
}
setFiles(testFiles);
if (testFiles.length > 0) setCurrentIndex(0);
} catch (err) {
console.error("Error initializing page", err);
showToast("Error loading dataset files", true);
}
};
fetchAllData();
}, []);
useEffect(() => {
if (currentIndex < 0 || currentIndex >= files.length) return;
const file = files[currentIndex];
const loadLabel = async () => {
try {
const res = await fetch(`/api/manual-label-scan?filename=${encodeURIComponent(file.filename)}`);
if (res.ok) {
const data = await res.json();
setFormData({
filename: data.filename || file.filename,
no_sku: data.no_sku || "",
nama_item: data.nama_item || "",
expiry_date: data.expiry_date || "",
notes: data.notes || ""
});
}
} catch (err) {
console.error("Error fetching label", err);
}
// Default-load the AI prediction from the last batch accuracy run
// (not a live re-scan) so failures are visible immediately while
// browsing - "Scan with AI" below can still be used to get a fresh
// live result for this exact image.
setAiPredicted(null);
setAiSource(null);
try {
const aiRes = await fetch(`/api/product-scan-results?filename=${encodeURIComponent(file.filename)}`);
if (aiRes.ok) {
const aiData = await aiRes.json();
if (aiData.found) {
setAiPredicted({
no_sku: aiData.no_sku,
nama_item: aiData.nama_item,
expiry_date: aiData.expiry_date
});
setAiSource({ type: "batch", timestamp: aiData.timestamp, method: aiData.method, confidence: aiData.confidence });
}
}
} catch (err) {
console.error("Error fetching batch AI result", err);
}
};
loadLabel();
}, [currentIndex, files]);
// Handle Ctrl+S keyboard shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [formData]);
const handleChange = (field: keyof ScanLabelFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleScanWithAi = async () => {
if (currentIndex < 0) return;
const currentFile = files[currentIndex];
setIsScanning(true);
try {
// Fetch image as base64
const imgRes = await fetch(currentFile.url);
const blob = await imgRes.blob();
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
const scanRes = await fetch("/api/scan-pfm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: base64 })
});
if (!scanRes.ok) throw new Error("Pipeline API error");
const scanData = await scanRes.json();
// Compare against the sku_master-resolved best match (what the app
// actually shows/saves as nama_item, and what the accuracy harness
// scores), not classification.top1_name - that's the classifier's raw
// internal class label (e.g. the foto-kemasan-v2 folder name), which
// structurally never matches a sku_master-style ground truth string
// even when the classification itself is correct.
const bestMatch = (scanData.possibleMatches || []).find((m: { isBestMatch?: boolean }) => m.isBestMatch);
setAiPredicted({
no_sku: bestMatch?.no_sku,
nama_item: bestMatch?.nama_item,
expiry_date: scanData.ocr?.extracted_expired_date
});
setAiSource({
type: "live",
timestamp: new Date().toISOString(),
method: scanData.classification?.method,
confidence: scanData.classification?.top1_confidence
});
showToast("AI Scan complete!");
} catch (err) {
showToast(getErrorMessage(err, undefined, "AI Scan failed"), true);
} finally {
setIsScanning(false);
}
};
const handleSave = async () => {
setSavingGT(true);
try {
const res = await fetch("/api/manual-label-scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData)
});
if (res.ok) {
showToast("Ground truth saved successfully!");
if (currentIndex < files.length - 1) {
setCurrentIndex(prev => prev + 1);
} else {
showToast("All images completed!");
}
} else {
const errData = await res.json().catch(() => ({}));
showToast(getErrorMessage(null, errData, "Save failed"), true);
}
} catch (err) {
showToast(getErrorMessage(err, undefined, "Save failed"), true);
} finally {
setSavingGT(false);
}
};
return (
<div className="h-screen w-screen flex flex-col bg-slate-950 font-sans overflow-hidden">
{/* Header */}
<header className="h-14 border-b border-slate-800 bg-slate-900/80 backdrop-blur-md flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-teal-500 to-cyan-500 flex items-center justify-center font-bold text-white text-xs shadow-md">
SP
</div>
<span className="text-sm font-bold text-slate-100">Product Scan Annotation</span>
</div>
<div className="text-xs">
<a href="/scan-pfm" className="text-slate-400 hover:text-slate-100 transition">← Back to Scanner</a>
</div>
</header>
{/* Main Body */}
<div className="flex-1 flex overflow-hidden">
<Sidebar
files={files.map(f => f.filename)}
currentIndex={currentIndex}
onSelect={setCurrentIndex}
/>
<ImageViewer src={currentIndex >= 0 ? files[currentIndex].url : null} />
<Editor
formData={formData}
aiPredicted={aiPredicted}
aiSource={aiSource}
skuList={skuList}
isScanning={isScanning}
onScanWithAi={handleScanWithAi}
onChange={handleChange}
onSave={handleSave}
savingGT={savingGT}
/>
</div>
{/* Toast */}
<div className={`fixed bottom-6 left-1/2 -translate-x-1/2 px-5 py-3 rounded-lg flex items-center gap-2.5 shadow-2xl font-medium z-[100] transition duration-300 ${toast.show ? "translate-y-0 opacity-100 scale-100" : "translate-y-12 opacity-0 scale-95 pointer-events-none"} ${toast.isError ? "bg-rose-600 text-white" : "bg-emerald-600 text-white"}`}>
<span>{toast.isError ? "❌" : "✅"}</span>
<span className="text-sm">{toast.message}</span>
</div>
</div>
);
}
"use client";
import React, { useState, useEffect } from "react";
import { Sidebar } from "@/components/manual-label-scan/Sidebar";
import { Editor, ScanLabelFormData, AiPredictedData } from "@/components/manual-label-scan/Editor";
import { ImageViewer } from "@/components/manual-label-scan/ImageViewer";
import { getErrorMessage } from "@/utils/client-error";
export default function ManualLabelScanPage() {
const [files, setFiles] = useState<{ url: string; filename: string }[]>([]);
const [currentIndex, setCurrentIndex] = useState(-1);
const [formData, setFormData] = useState<ScanLabelFormData>({
filename: "",
no_sku: "",
nama_item: "",
expiry_date: "",
notes: ""
});
const [aiPredicted, setAiPredicted] = useState<AiPredictedData | null>(null);
const [aiSource, setAiSource] = useState<{ type: "batch" | "live"; timestamp: string; method?: string; confidence?: number } | null>(null);
const [skuList, setSkuList] = useState<Array<{ no_sku: string; nama_item: string }>>([]);
const [isScanning, setIsScanning] = useState(false);
const [savingGT, setSavingGT] = useState(false);
const [toast, setToast] = useState({ message: "", show: false, isError: false });
const showToast = (message: string, isError = false) => {
setToast({ message, show: true, isError });
setTimeout(() => setToast(p => ({ ...p, show: false })), 2500);
};
useEffect(() => {
const fetchAllData = async () => {
try {
// Fetch Skus
const skuRes = await fetch("/api/skus");
if (skuRes.ok) {
const skuData = await skuRes.json();
setSkuList(skuData.skus || []);
}
// Fetch Test Images — the frozen 79-image Validation Set
// (product-test-images-fixed/), the only set the accuracy harness
// scores. Gallery/training photos (foto-kemasan-v2/) are not shown
// here: they don't need per-photo ground truth, only correct
// SKU-folder placement for classifier training.
const testRes = await fetch("/api/product-images");
let testFiles: { url: string; filename: string }[] = [];
if (testRes.ok) {
const testData = await testRes.json();
testFiles = (testData.files || []).map((f: string) => ({
url: `/api/product-images?filename=${encodeURIComponent(f)}`,
filename: f
}));
}
setFiles(testFiles);
if (testFiles.length > 0) setCurrentIndex(0);
} catch (err) {
console.error("Error initializing page", err);
showToast("Error loading dataset files", true);
}
};
fetchAllData();
}, []);
useEffect(() => {
if (currentIndex < 0 || currentIndex >= files.length) return;
const file = files[currentIndex];
const loadLabel = async () => {
try {
const res = await fetch(`/api/manual-label-scan?filename=${encodeURIComponent(file.filename)}`);
if (res.ok) {
const data = await res.json();
setFormData({
filename: data.filename || file.filename,
no_sku: data.no_sku || "",
nama_item: data.nama_item || "",
expiry_date: data.expiry_date || "",
notes: data.notes || ""
});
}
} catch (err) {
console.error("Error fetching label", err);
}
// Default-load the AI prediction from the last batch accuracy run
// (not a live re-scan) so failures are visible immediately while
// browsing - "Scan with AI" below can still be used to get a fresh
// live result for this exact image.
setAiPredicted(null);
setAiSource(null);
try {
const aiRes = await fetch(`/api/product-scan-results?filename=${encodeURIComponent(file.filename)}`);
if (aiRes.ok) {
const aiData = await aiRes.json();
if (aiData.found) {
setAiPredicted({
no_sku: aiData.no_sku,
nama_item: aiData.nama_item,
expiry_date: aiData.expiry_date
});
setAiSource({ type: "batch", timestamp: aiData.timestamp, method: aiData.method, confidence: aiData.confidence });
}
}
} catch (err) {
console.error("Error fetching batch AI result", err);
}
};
loadLabel();
}, [currentIndex, files]);
// Handle Ctrl+S keyboard shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [formData]);
const handleChange = (field: keyof ScanLabelFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleScanWithAi = async () => {
if (currentIndex < 0) return;
const currentFile = files[currentIndex];
setIsScanning(true);
try {
// Fetch image as base64
const imgRes = await fetch(currentFile.url);
const blob = await imgRes.blob();
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
const scanRes = await fetch("/api/scan-pfm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: base64 })
});
if (!scanRes.ok) throw new Error("Pipeline API error");
const scanData = await scanRes.json();
// Compare against the sku_master-resolved best match (what the app
// actually shows/saves as nama_item, and what the accuracy harness
// scores), not classification.top1_name - that's the classifier's raw
// internal class label (e.g. the foto-kemasan-v2 folder name), which
// structurally never matches a sku_master-style ground truth string
// even when the classification itself is correct.
const bestMatch = (scanData.possibleMatches || []).find((m: { isBestMatch?: boolean }) => m.isBestMatch);
setAiPredicted({
no_sku: bestMatch?.no_sku,
nama_item: bestMatch?.nama_item,
expiry_date: scanData.ocr?.extracted_expired_date
});
setAiSource({
type: "live",
timestamp: new Date().toISOString(),
method: scanData.classification?.method,
confidence: scanData.classification?.top1_confidence
});
showToast("AI Scan complete!");
} catch (err) {
showToast(getErrorMessage(err, undefined, "AI Scan failed"), true);
} finally {
setIsScanning(false);
}
};
const handleSave = async () => {
setSavingGT(true);
try {
const res = await fetch("/api/manual-label-scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData)
});
if (res.ok) {
showToast("Ground truth saved successfully!");
if (currentIndex < files.length - 1) {
setCurrentIndex(prev => prev + 1);
} else {
showToast("All images completed!");
}
} else {
const errData = await res.json().catch(() => ({}));
showToast(getErrorMessage(null, errData, "Save failed"), true);
}
} catch (err) {
showToast(getErrorMessage(err, undefined, "Save failed"), true);
} finally {
setSavingGT(false);
}
};
return (
<div className="h-screen w-screen flex flex-col bg-slate-950 font-sans overflow-hidden">
{/* Header */}
<header className="h-14 border-b border-slate-800 bg-slate-900/80 backdrop-blur-md flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-teal-500 to-cyan-500 flex items-center justify-center font-bold text-white text-xs shadow-md">
SP
</div>
<span className="text-sm font-bold text-slate-100">Product Scan Annotation</span>
</div>
<div className="text-xs">
<a href="/scan-pfm" className="text-slate-400 hover:text-slate-100 transition">← Back to Scanner</a>
</div>
</header>
{/* Main Body */}
<div className="flex-1 flex overflow-hidden">
<Sidebar
files={files.map(f => f.filename)}
currentIndex={currentIndex}
onSelect={setCurrentIndex}
/>
<ImageViewer src={currentIndex >= 0 ? files[currentIndex].url : null} />
<Editor
formData={formData}
aiPredicted={aiPredicted}
aiSource={aiSource}
skuList={skuList}
isScanning={isScanning}
onScanWithAi={handleScanWithAi}
onChange={handleChange}
onSave={handleSave}
savingGT={savingGT}
/>
</div>
{/* Toast */}
<div className={`fixed bottom-6 left-1/2 -translate-x-1/2 px-5 py-3 rounded-lg flex items-center gap-2.5 shadow-2xl font-medium z-[100] transition duration-300 ${toast.show ? "translate-y-0 opacity-100 scale-100" : "translate-y-12 opacity-0 scale-95 pointer-events-none"} ${toast.isError ? "bg-rose-600 text-white" : "bg-emerald-600 text-white"}`}>
<span>{toast.isError ? "❌" : "✅"}</span>
<span className="text-sm">{toast.message}</span>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,169 +1,169 @@
import React from "react";
export interface ScanLabelFormData {
filename: string;
no_sku: string;
nama_item: string;
expiry_date: string;
notes: string;
}
export interface AiPredictedData {
no_sku?: string;
nama_item?: string;
expiry_date?: string;
}
export interface AiSourceInfo {
type: "batch" | "live";
timestamp: string;
method?: string;
confidence?: number;
}
interface EditorProps {
formData: ScanLabelFormData;
aiPredicted: AiPredictedData | null;
aiSource: AiSourceInfo | null;
skuList: Array<{ no_sku: string; nama_item: string }>;
isScanning: boolean;
onScanWithAi: () => void;
onChange: (field: keyof ScanLabelFormData, value: string) => void;
onSave: () => void;
savingGT: boolean;
}
export function Editor({
formData,
aiPredicted,
aiSource,
skuList,
isScanning,
onScanWithAi,
onChange,
onSave,
savingGT
}: EditorProps) {
// Autofill item name based on SKU if available
const handleSkuChange = (value: string) => {
onChange("no_sku", value);
const matched = skuList.find(s => s.no_sku === value);
if (matched && !formData.nama_item) {
onChange("nama_item", matched.nama_item);
}
};
const AiNote = ({ current, aiValue }: { current: string; aiValue: string | undefined }) => {
if (aiValue === undefined) return null;
const differs = (current || "").trim() !== (aiValue || "").trim();
return (
<div className={`text-[10px] leading-tight mt-1 ${differs ? "text-amber-500" : "text-slate-500"}`}>
AI: {aiValue || "(not detected)"}
</div>
);
};
return (
<div className="w-[400px] border-l border-slate-800 bg-slate-900/50 flex flex-col h-full shrink-0 overflow-y-auto">
<div className="p-5 space-y-6">
{/* Header & AI Action */}
<div className="flex flex-col gap-3 pb-4 border-b border-slate-800">
<div>
<h3 className="text-sm font-bold text-slate-100">Ground Truth Editor</h3>
<p className="text-[11px] text-slate-500 truncate mt-0.5">{formData.filename || "No file selected"}</p>
</div>
<button
onClick={onScanWithAi}
disabled={isScanning || !formData.filename}
className="w-full bg-teal-600/20 text-teal-400 hover:bg-teal-600/30 disabled:opacity-50 border border-teal-500/30 rounded-lg py-2 text-xs font-semibold transition flex items-center justify-center gap-2"
>
{isScanning ? "Scanning with Pipeline..." : "Scan with AI 🤖 (re-run live)"}
</button>
{aiSource ? (
<p className="text-[10px] text-slate-500 leading-snug">
{aiSource.type === "batch" ? (
<>Showing result from last batch test ({new Date(aiSource.timestamp).toLocaleString()})</>
) : (
<>Live scan result ({new Date(aiSource.timestamp).toLocaleTimeString()})</>
)}
{aiSource.method && <> · {aiSource.method}</>}
{typeof aiSource.confidence === "number" && <> · conf {aiSource.confidence.toFixed(3)}</>}
</p>
) : (
<p className="text-[10px] text-slate-600 italic">No AI result yet for this image — click &quot;Scan with AI&quot; or run the accuracy batch test.</p>
)}
</div>
{/* Form Fields */}
<div className="space-y-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">SKU</label>
<input
type="text"
list="skuOptions"
value={formData.no_sku}
onChange={(e) => handleSkuChange(e.target.value)}
placeholder="e.g. 12010119"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<datalist id="skuOptions">
{skuList.map((s) => (
<option key={s.no_sku} value={s.no_sku}>
{s.nama_item}
</option>
))}
</datalist>
<AiNote current={formData.no_sku} aiValue={aiPredicted?.no_sku} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Product Name</label>
<input
type="text"
value={formData.nama_item}
onChange={(e) => onChange("nama_item", e.target.value)}
placeholder="e.g. FIESTA NUGGET 400 GR"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<AiNote current={formData.nama_item} aiValue={aiPredicted?.nama_item} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Expiry Date</label>
<input
type="text"
value={formData.expiry_date}
onChange={(e) => onChange("expiry_date", e.target.value)}
placeholder="e.g. 05/11/2026"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<AiNote current={formData.expiry_date} aiValue={aiPredicted?.expiry_date} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => onChange("notes", e.target.value)}
placeholder="Optional notes..."
rows={3}
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors resize-none"
/>
</div>
</div>
</div>
<div className="mt-auto p-5 border-t border-slate-800 bg-slate-900">
<button
onClick={onSave}
disabled={savingGT || !formData.filename}
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl py-3 text-sm font-bold shadow-lg shadow-emerald-500/20 disabled:opacity-50 transition-all flex items-center justify-center"
>
{savingGT ? "Saving..." : "Save Ground Truth"}
</button>
</div>
</div>
);
}
import React from "react";
export interface ScanLabelFormData {
filename: string;
no_sku: string;
nama_item: string;
expiry_date: string;
notes: string;
}
export interface AiPredictedData {
no_sku?: string;
nama_item?: string;
expiry_date?: string;
}
export interface AiSourceInfo {
type: "batch" | "live";
timestamp: string;
method?: string;
confidence?: number;
}
interface EditorProps {
formData: ScanLabelFormData;
aiPredicted: AiPredictedData | null;
aiSource: AiSourceInfo | null;
skuList: Array<{ no_sku: string; nama_item: string }>;
isScanning: boolean;
onScanWithAi: () => void;
onChange: (field: keyof ScanLabelFormData, value: string) => void;
onSave: () => void;
savingGT: boolean;
}
export function Editor({
formData,
aiPredicted,
aiSource,
skuList,
isScanning,
onScanWithAi,
onChange,
onSave,
savingGT
}: EditorProps) {
// Autofill item name based on SKU if available
const handleSkuChange = (value: string) => {
onChange("no_sku", value);
const matched = skuList.find(s => s.no_sku === value);
if (matched && !formData.nama_item) {
onChange("nama_item", matched.nama_item);
}
};
const AiNote = ({ current, aiValue }: { current: string; aiValue: string | undefined }) => {
if (aiValue === undefined) return null;
const differs = (current || "").trim() !== (aiValue || "").trim();
return (
<div className={`text-[10px] leading-tight mt-1 ${differs ? "text-amber-500" : "text-slate-500"}`}>
AI: {aiValue || "(not detected)"}
</div>
);
};
return (
<div className="w-[400px] border-l border-slate-800 bg-slate-900/50 flex flex-col h-full shrink-0 overflow-y-auto">
<div className="p-5 space-y-6">
{/* Header & AI Action */}
<div className="flex flex-col gap-3 pb-4 border-b border-slate-800">
<div>
<h3 className="text-sm font-bold text-slate-100">Ground Truth Editor</h3>
<p className="text-[11px] text-slate-500 truncate mt-0.5">{formData.filename || "No file selected"}</p>
</div>
<button
onClick={onScanWithAi}
disabled={isScanning || !formData.filename}
className="w-full bg-teal-600/20 text-teal-400 hover:bg-teal-600/30 disabled:opacity-50 border border-teal-500/30 rounded-lg py-2 text-xs font-semibold transition flex items-center justify-center gap-2"
>
{isScanning ? "Scanning with Pipeline..." : "Scan with AI 🤖 (re-run live)"}
</button>
{aiSource ? (
<p className="text-[10px] text-slate-500 leading-snug">
{aiSource.type === "batch" ? (
<>Showing result from last batch test ({new Date(aiSource.timestamp).toLocaleString()})</>
) : (
<>Live scan result ({new Date(aiSource.timestamp).toLocaleTimeString()})</>
)}
{aiSource.method && <> · {aiSource.method}</>}
{typeof aiSource.confidence === "number" && <> · conf {aiSource.confidence.toFixed(3)}</>}
</p>
) : (
<p className="text-[10px] text-slate-600 italic">No AI result yet for this image — click &quot;Scan with AI&quot; or run the accuracy batch test.</p>
)}
</div>
{/* Form Fields */}
<div className="space-y-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">SKU</label>
<input
type="text"
list="skuOptions"
value={formData.no_sku}
onChange={(e) => handleSkuChange(e.target.value)}
placeholder="e.g. 12010119"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<datalist id="skuOptions">
{skuList.map((s) => (
<option key={s.no_sku} value={s.no_sku}>
{s.nama_item}
</option>
))}
</datalist>
<AiNote current={formData.no_sku} aiValue={aiPredicted?.no_sku} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Product Name</label>
<input
type="text"
value={formData.nama_item}
onChange={(e) => onChange("nama_item", e.target.value)}
placeholder="e.g. FIESTA NUGGET 400 GR"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<AiNote current={formData.nama_item} aiValue={aiPredicted?.nama_item} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Expiry Date</label>
<input
type="text"
value={formData.expiry_date}
onChange={(e) => onChange("expiry_date", e.target.value)}
placeholder="e.g. 05/11/2026"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<AiNote current={formData.expiry_date} aiValue={aiPredicted?.expiry_date} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => onChange("notes", e.target.value)}
placeholder="Optional notes..."
rows={3}
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors resize-none"
/>
</div>
</div>
</div>
<div className="mt-auto p-5 border-t border-slate-800 bg-slate-900">
<button
onClick={onSave}
disabled={savingGT || !formData.filename}
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl py-3 text-sm font-bold shadow-lg shadow-emerald-500/20 disabled:opacity-50 transition-all flex items-center justify-center"
>
{savingGT ? "Saving..." : "Save Ground Truth"}
</button>
</div>
</div>
);
}
@@ -1,79 +1,79 @@
import React, { useState } from "react";
interface ImageViewerProps {
src: string | null;
}
export function ImageViewer({ src }: ImageViewerProps) {
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
if (!src) {
return (
<div className="flex-1 flex items-center justify-center bg-slate-950">
<span className="text-slate-600 text-sm">No image selected</span>
</div>
);
}
return (
<div className="flex-1 relative flex flex-col bg-slate-950 overflow-hidden">
{/* Controls */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 bg-slate-900/80 backdrop-blur border border-slate-700 p-1.5 rounded-xl shadow-xl">
<button
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-lg font-mono"
>
-
</button>
<span className="text-xs font-medium text-slate-400 w-12 text-center">
{Math.round(scale * 100)}%
</span>
<button
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-lg font-mono"
>
+
</button>
<div className="w-px h-5 bg-slate-700 mx-1" />
<button
onClick={() => setRotation((r) => r - 90)}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-sm"
title="Rotate Left"
>
↺
</button>
<button
onClick={() => setRotation((r) => r + 90)}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-sm"
title="Rotate Right"
>
↻
</button>
<div className="w-px h-5 bg-slate-700 mx-1" />
<button
onClick={() => { setScale(1); setRotation(0); }}
className="px-3 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-xs font-medium"
>
Reset
</button>
</div>
{/* Viewport */}
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{/* Using standard img for easy rotation/scaling without Next.js Image component strictness */}
<img
src={src}
alt="Product Scan"
style={{
transform: `scale(${scale}) rotate(${rotation}deg)`,
transition: "transform 0.2s ease-out",
maxHeight: "80vh"
}}
className="shadow-2xl rounded-sm object-contain"
crossOrigin="anonymous"
/>
</div>
</div>
);
}
import React, { useState } from "react";
interface ImageViewerProps {
src: string | null;
}
export function ImageViewer({ src }: ImageViewerProps) {
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
if (!src) {
return (
<div className="flex-1 flex items-center justify-center bg-slate-950">
<span className="text-slate-600 text-sm">No image selected</span>
</div>
);
}
return (
<div className="flex-1 relative flex flex-col bg-slate-950 overflow-hidden">
{/* Controls */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 bg-slate-900/80 backdrop-blur border border-slate-700 p-1.5 rounded-xl shadow-xl">
<button
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-lg font-mono"
>
-
</button>
<span className="text-xs font-medium text-slate-400 w-12 text-center">
{Math.round(scale * 100)}%
</span>
<button
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-lg font-mono"
>
+
</button>
<div className="w-px h-5 bg-slate-700 mx-1" />
<button
onClick={() => setRotation((r) => r - 90)}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-sm"
title="Rotate Left"
>
↺
</button>
<button
onClick={() => setRotation((r) => r + 90)}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-sm"
title="Rotate Right"
>
↻
</button>
<div className="w-px h-5 bg-slate-700 mx-1" />
<button
onClick={() => { setScale(1); setRotation(0); }}
className="px-3 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-xs font-medium"
>
Reset
</button>
</div>
{/* Viewport */}
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{/* Using standard img for easy rotation/scaling without Next.js Image component strictness */}
<img
src={src}
alt="Product Scan"
style={{
transform: `scale(${scale}) rotate(${rotation}deg)`,
transition: "transform 0.2s ease-out",
maxHeight: "80vh"
}}
className="shadow-2xl rounded-sm object-contain"
crossOrigin="anonymous"
/>
</div>
</div>
);
}
@@ -1,44 +1,44 @@
import React from "react";
interface SidebarProps {
files: string[];
currentIndex: number;
onSelect: (index: number) => void;
}
export function Sidebar({ files, currentIndex, onSelect }: SidebarProps) {
return (
<div className="w-64 border-r border-slate-800 bg-slate-900/50 flex flex-col h-full shrink-0">
<div className="p-4 border-b border-slate-800">
<h2 className="text-sm font-bold text-slate-100">Dataset Images</h2>
<p className="text-xs text-slate-500 mt-1">{files.length} files found</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{files.map((file, idx) => {
const isSelected = idx === currentIndex;
// Extract just the filename for display
const display = file.split("/").pop() || file;
return (
<button
key={file}
onClick={() => onSelect(idx)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs truncate transition-colors ${
isSelected
? "bg-emerald-500/20 text-emerald-400 font-medium"
: "text-slate-400 hover:bg-slate-800/50 hover:text-slate-200"
}`}
title={file}
>
{idx + 1}. {display}
</button>
);
})}
{files.length === 0 && (
<div className="text-center text-xs text-slate-500 mt-4">
No images found.
</div>
)}
</div>
</div>
);
}
import React from "react";
interface SidebarProps {
files: string[];
currentIndex: number;
onSelect: (index: number) => void;
}
export function Sidebar({ files, currentIndex, onSelect }: SidebarProps) {
return (
<div className="w-64 border-r border-slate-800 bg-slate-900/50 flex flex-col h-full shrink-0">
<div className="p-4 border-b border-slate-800">
<h2 className="text-sm font-bold text-slate-100">Dataset Images</h2>
<p className="text-xs text-slate-500 mt-1">{files.length} files found</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{files.map((file, idx) => {
const isSelected = idx === currentIndex;
// Extract just the filename for display
const display = file.split("/").pop() || file;
return (
<button
key={file}
onClick={() => onSelect(idx)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs truncate transition-colors ${
isSelected
? "bg-emerald-500/20 text-emerald-400 font-medium"
: "text-slate-400 hover:bg-slate-800/50 hover:text-slate-200"
}`}
title={file}
>
{idx + 1}. {display}
</button>
);
})}
{files.length === 0 && (
<div className="text-center text-xs text-slate-500 mt-4">
No images found.
</div>
)}
</div>
</div>
);
}
+255 -255
View File
@@ -1,255 +1,255 @@
import { Pool, PoolClient } from "pg";
import { initDb } from "./init";
const pool = new Pool({
host: process.env.PGHOST || "localhost",
port: parseInt(process.env.PGPORT || "5432"),
user: process.env.PGUSER || "postgres",
password: process.env.PGPASSWORD || "postgres",
database: process.env.PGDATABASE || "dopfm",
});
let initialized = false;
let initPromise: Promise<Pool> | null = null;
export async function getPool(): Promise<Pool> {
if (initialized) {
return pool;
}
if (!initPromise) {
initPromise = (async () => {
try {
await initDb(pool);
initialized = true;
} catch (err) {
console.error("Failed to initialize database:", err);
}
return pool;
})();
}
return initPromise;
}
export async function query(text: string, params?: unknown[]) {
const p = await getPool();
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(
`DELETE FROM ocr_items
WHERE document_id = $1
AND (kode_barang IS NULL OR TRIM(kode_barang) = '' OR NOT (kode_barang ~ '^[0-9]{8}$'))`,
[docId]
);
// 2. Fetch remaining rows ordered by row_index
const res = await query(
`SELECT id, row_index
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index`,
[docId]
);
// 3. Update row_index to be sequential
for (let i = 0; i < res.rows.length; i++) {
const row = res.rows[i];
if (row.row_index !== i) {
await query(
`UPDATE ocr_items
SET row_index = $1
WHERE id = $2`,
[i, row.id]
);
}
}
}
const STORE_STOPWORDS = new Set([
"dan", "dki", "area", "yth", "kepada", "order", "untuk", "alamat", "kel", "kec", "rt", "rw",
"jalan", "raya", "blok", "nomor", "kelurahan", "kecamatan", "kota", "kabupaten", "provinsi"
]);
function tokenize(text: string): string[] {
return text.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter(w => w.length > 2 && !STORE_STOPWORDS.has(w));
}
// customers.name is stored as "CUSTOMER NAME, JL. street address..." - split on the first
// street-address marker to get just the canonical address portion.
function splitCustomerAddress(name: string): string {
const m = name.match(/\b(?:JL\.?|JALAN)\b[\s\S]*/i);
return (m ? m[0] : name).replace(/\s+/g, " ").trim();
}
// A noisy OCR'd address (varying per document due to misread letters) that recognizably
// belongs to a known customer should be reported as that customer's clean canonical address,
// rather than whatever garbled text this particular scan happened to produce.
async function canonicalizeCustomerAddress(extracted: string): Promise<string> {
if (!extracted) return extracted;
const extractedTokens = new Set(tokenize(extracted));
if (extractedTokens.size === 0) return extracted;
const customersRes = await query("SELECT name FROM customers");
let bestAddress: string | null = null;
let bestMatchCount = 0;
let bestScore = 0;
for (const row of customersRes.rows) {
const canonicalAddress = splitCustomerAddress(row.name);
const addressTokens = tokenize(canonicalAddress);
if (addressTokens.length === 0) continue;
const uniqueAddressTokens = new Set(addressTokens);
let matchCount = 0;
for (const token of uniqueAddressTokens) {
if (extractedTokens.has(token)) matchCount++;
}
const score = matchCount / uniqueAddressTokens.size;
if (matchCount >= 3 && score >= 0.45 && (matchCount > bestMatchCount || (matchCount === bestMatchCount && score > bestScore))) {
bestMatchCount = matchCount;
bestScore = score;
bestAddress = canonicalAddress;
}
}
return bestAddress ?? extracted;
}
// The delivery truck/signature line near the bottom of the table ("Truck No. B 9427 UXT
// PX HEAD OFFICE ANCOL : JL. ANCOL BARAT VIII...") names the actual destination store, when
// present. Scoping the match to just this line (and just nama_toko, not nama_toko+alamat)
// avoids the customer's own fixed head-office address elsewhere in the document being
// mistaken for the destination - that address is present on every document regardless of
// which store it's actually going to, so matching against it produces confident false
// positives for documents that don't specify a destination store name at all.
function extractTruckLineSnippet(fullText: string): string {
const m = fullText.match(/Truck\s*No\.?[\s\S]{0,180}/i);
return m ? m[0] : "";
}
// True when the printed "Order Untuk" text is actually the customer's company name - a common
// OCR layout jumble where the "Kepada Yth" and "Order Untuk" fields merge, meaning the real
// destination value was lost and the truck line is the better signal.
async function looksLikeCustomerName(text: string): Promise<boolean> {
if (!text) return false;
const textTokens = new Set(tokenize(text));
if (textTokens.size === 0) return false;
const customersRes = await query("SELECT name FROM customers");
for (const row of customersRes.rows) {
const companyName = String(row.name).split(/\bJL\.?\b|\bJALAN\b/i)[0];
const nameTokens = tokenize(companyName);
if (nameTokens.length === 0) continue;
let matchCount = 0;
for (const token of new Set(nameTokens)) {
if (textTokens.has(token)) matchCount++;
}
if (matchCount >= 1 && matchCount / new Set(nameTokens).size >= 0.5) return true;
}
return false;
}
export async function resolveStoreFromText(fullMarkdown: string): Promise<{ orderUntuk: string; alamat: string }> {
if (!fullMarkdown || fullMarkdown === "Not Found") {
return { orderUntuk: "", alamat: "" };
}
// The printed "Alamat" field is the customer's own (fixed) address, not the destination
// store's registered address - it stays the same across documents regardless of which
// store the truck line names. So alamat always comes from the literal printed text; only
// the store name itself benefits from being resolved to its canonical store_master form.
// The line right after "Alamat:" sometimes holds a region code ("DKI AREA") rather than the
// street address, with the real address following on the next line(s) - capture the whole
// block up to the item table and prefer the "JL./JALAN ..." street-address line within it.
const alamatBlockMatch = fullMarkdown.match(/Alamat\s*[:\-]?\s*([\s\S]+?)(?=<table|$)/i);
let literalAlamat = "";
if (alamatBlockMatch) {
const block = alamatBlockMatch[1];
const streetMatch = block.match(/\b(?:JL\.?|JALAN)\b[\s\S]*/i);
literalAlamat = (streetMatch ? streetMatch[0] : block).replace(/\s+/g, " ").trim();
}
literalAlamat = await canonicalizeCustomerAddress(literalAlamat);
// The printed "Order Untuk" value is the primary source for the store field: it usually
// holds a region designator ("DKI AREA", "PFM-KU") or a store name, and that's what the
// document actually says. Only when OCR jumbled it with the customer's company name (or
// lost it entirely) do we fall back to matching the truck/signature line against
// store_master to recover the destination store.
const orderMatch = fullMarkdown.match(/Order\s+Untuk\s*[:\-]\s*([^\n]+)/i);
const literalOrder = orderMatch ? orderMatch[1].trim() : "";
const orderIsUsable = literalOrder !== "" && !(await looksLikeCustomerName(literalOrder));
if (orderIsUsable) {
return { orderUntuk: literalOrder, alamat: literalAlamat };
}
const storeRes = await query("SELECT nama_toko, kode_toko, alamat FROM store_master");
const stores = storeRes.rows;
const truckSnippet = extractTruckLineSnippet(fullMarkdown);
const snippetTokens = new Set(tokenize(truckSnippet));
let bestStore: any = null;
let bestScore = 0;
let bestMatchCount = 0;
if (snippetTokens.size > 0) {
for (const store of stores) {
const storeTokens = tokenize(store.nama_toko);
if (storeTokens.length === 0) continue;
const uniqueStoreTokens = new Set(storeTokens);
let matchCount = 0;
for (const token of uniqueStoreTokens) {
if (snippetTokens.has(token)) matchCount++;
}
const score = matchCount / uniqueStoreTokens.size;
// Two distinct matching tokens minimum: single-token overlaps (e.g. a store whose only
// distinctive token is a common street/area word appearing in the snippet's address
// text) produce far too many confident false positives.
if (matchCount >= 2) {
if (matchCount > bestMatchCount || (matchCount === bestMatchCount && score > bestScore)) {
bestMatchCount = matchCount;
bestScore = score;
bestStore = store;
}
}
}
}
if (bestStore) {
return { orderUntuk: bestStore.nama_toko, alamat: literalAlamat };
}
return { orderUntuk: literalOrder, alamat: literalAlamat };
}
export { pool };
import { Pool, PoolClient } from "pg";
import { initDb } from "./init";
const pool = new Pool({
host: process.env.PGHOST || "localhost",
port: parseInt(process.env.PGPORT || "5432"),
user: process.env.PGUSER || "postgres",
password: process.env.PGPASSWORD || "postgres",
database: process.env.PGDATABASE || "dopfm",
});
let initialized = false;
let initPromise: Promise<Pool> | null = null;
export async function getPool(): Promise<Pool> {
if (initialized) {
return pool;
}
if (!initPromise) {
initPromise = (async () => {
try {
await initDb(pool);
initialized = true;
} catch (err) {
console.error("Failed to initialize database:", err);
}
return pool;
})();
}
return initPromise;
}
export async function query(text: string, params?: unknown[]) {
const p = await getPool();
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(
`DELETE FROM ocr_items
WHERE document_id = $1
AND (kode_barang IS NULL OR TRIM(kode_barang) = '' OR NOT (kode_barang ~ '^[0-9]{8}$'))`,
[docId]
);
// 2. Fetch remaining rows ordered by row_index
const res = await query(
`SELECT id, row_index
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index`,
[docId]
);
// 3. Update row_index to be sequential
for (let i = 0; i < res.rows.length; i++) {
const row = res.rows[i];
if (row.row_index !== i) {
await query(
`UPDATE ocr_items
SET row_index = $1
WHERE id = $2`,
[i, row.id]
);
}
}
}
const STORE_STOPWORDS = new Set([
"dan", "dki", "area", "yth", "kepada", "order", "untuk", "alamat", "kel", "kec", "rt", "rw",
"jalan", "raya", "blok", "nomor", "kelurahan", "kecamatan", "kota", "kabupaten", "provinsi"
]);
function tokenize(text: string): string[] {
return text.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter(w => w.length > 2 && !STORE_STOPWORDS.has(w));
}
// customers.name is stored as "CUSTOMER NAME, JL. street address..." - split on the first
// street-address marker to get just the canonical address portion.
function splitCustomerAddress(name: string): string {
const m = name.match(/\b(?:JL\.?|JALAN)\b[\s\S]*/i);
return (m ? m[0] : name).replace(/\s+/g, " ").trim();
}
// A noisy OCR'd address (varying per document due to misread letters) that recognizably
// belongs to a known customer should be reported as that customer's clean canonical address,
// rather than whatever garbled text this particular scan happened to produce.
async function canonicalizeCustomerAddress(extracted: string): Promise<string> {
if (!extracted) return extracted;
const extractedTokens = new Set(tokenize(extracted));
if (extractedTokens.size === 0) return extracted;
const customersRes = await query("SELECT name FROM customers");
let bestAddress: string | null = null;
let bestMatchCount = 0;
let bestScore = 0;
for (const row of customersRes.rows) {
const canonicalAddress = splitCustomerAddress(row.name);
const addressTokens = tokenize(canonicalAddress);
if (addressTokens.length === 0) continue;
const uniqueAddressTokens = new Set(addressTokens);
let matchCount = 0;
for (const token of uniqueAddressTokens) {
if (extractedTokens.has(token)) matchCount++;
}
const score = matchCount / uniqueAddressTokens.size;
if (matchCount >= 3 && score >= 0.45 && (matchCount > bestMatchCount || (matchCount === bestMatchCount && score > bestScore))) {
bestMatchCount = matchCount;
bestScore = score;
bestAddress = canonicalAddress;
}
}
return bestAddress ?? extracted;
}
// The delivery truck/signature line near the bottom of the table ("Truck No. B 9427 UXT
// PX HEAD OFFICE ANCOL : JL. ANCOL BARAT VIII...") names the actual destination store, when
// present. Scoping the match to just this line (and just nama_toko, not nama_toko+alamat)
// avoids the customer's own fixed head-office address elsewhere in the document being
// mistaken for the destination - that address is present on every document regardless of
// which store it's actually going to, so matching against it produces confident false
// positives for documents that don't specify a destination store name at all.
function extractTruckLineSnippet(fullText: string): string {
const m = fullText.match(/Truck\s*No\.?[\s\S]{0,180}/i);
return m ? m[0] : "";
}
// True when the printed "Order Untuk" text is actually the customer's company name - a common
// OCR layout jumble where the "Kepada Yth" and "Order Untuk" fields merge, meaning the real
// destination value was lost and the truck line is the better signal.
async function looksLikeCustomerName(text: string): Promise<boolean> {
if (!text) return false;
const textTokens = new Set(tokenize(text));
if (textTokens.size === 0) return false;
const customersRes = await query("SELECT name FROM customers");
for (const row of customersRes.rows) {
const companyName = String(row.name).split(/\bJL\.?\b|\bJALAN\b/i)[0];
const nameTokens = tokenize(companyName);
if (nameTokens.length === 0) continue;
let matchCount = 0;
for (const token of new Set(nameTokens)) {
if (textTokens.has(token)) matchCount++;
}
if (matchCount >= 1 && matchCount / new Set(nameTokens).size >= 0.5) return true;
}
return false;
}
export async function resolveStoreFromText(fullMarkdown: string): Promise<{ orderUntuk: string; alamat: string }> {
if (!fullMarkdown || fullMarkdown === "Not Found") {
return { orderUntuk: "", alamat: "" };
}
// The printed "Alamat" field is the customer's own (fixed) address, not the destination
// store's registered address - it stays the same across documents regardless of which
// store the truck line names. So alamat always comes from the literal printed text; only
// the store name itself benefits from being resolved to its canonical store_master form.
// The line right after "Alamat:" sometimes holds a region code ("DKI AREA") rather than the
// street address, with the real address following on the next line(s) - capture the whole
// block up to the item table and prefer the "JL./JALAN ..." street-address line within it.
const alamatBlockMatch = fullMarkdown.match(/Alamat\s*[:\-]?\s*([\s\S]+?)(?=<table|$)/i);
let literalAlamat = "";
if (alamatBlockMatch) {
const block = alamatBlockMatch[1];
const streetMatch = block.match(/\b(?:JL\.?|JALAN)\b[\s\S]*/i);
literalAlamat = (streetMatch ? streetMatch[0] : block).replace(/\s+/g, " ").trim();
}
literalAlamat = await canonicalizeCustomerAddress(literalAlamat);
// The printed "Order Untuk" value is the primary source for the store field: it usually
// holds a region designator ("DKI AREA", "PFM-KU") or a store name, and that's what the
// document actually says. Only when OCR jumbled it with the customer's company name (or
// lost it entirely) do we fall back to matching the truck/signature line against
// store_master to recover the destination store.
const orderMatch = fullMarkdown.match(/Order\s+Untuk\s*[:\-]\s*([^\n]+)/i);
const literalOrder = orderMatch ? orderMatch[1].trim() : "";
const orderIsUsable = literalOrder !== "" && !(await looksLikeCustomerName(literalOrder));
if (orderIsUsable) {
return { orderUntuk: literalOrder, alamat: literalAlamat };
}
const storeRes = await query("SELECT nama_toko, kode_toko, alamat FROM store_master");
const stores = storeRes.rows;
const truckSnippet = extractTruckLineSnippet(fullMarkdown);
const snippetTokens = new Set(tokenize(truckSnippet));
let bestStore: any = null;
let bestScore = 0;
let bestMatchCount = 0;
if (snippetTokens.size > 0) {
for (const store of stores) {
const storeTokens = tokenize(store.nama_toko);
if (storeTokens.length === 0) continue;
const uniqueStoreTokens = new Set(storeTokens);
let matchCount = 0;
for (const token of uniqueStoreTokens) {
if (snippetTokens.has(token)) matchCount++;
}
const score = matchCount / uniqueStoreTokens.size;
// Two distinct matching tokens minimum: single-token overlaps (e.g. a store whose only
// distinctive token is a common street/area word appearing in the snippet's address
// text) produce far too many confident false positives.
if (matchCount >= 2) {
if (matchCount > bestMatchCount || (matchCount === bestMatchCount && score > bestScore)) {
bestMatchCount = matchCount;
bestScore = score;
bestStore = store;
}
}
}
}
if (bestStore) {
return { orderUntuk: bestStore.nama_toko, alamat: literalAlamat };
}
return { orderUntuk: literalOrder, alamat: literalAlamat };
}
export { pool };
File diff suppressed because it is too large. Load diff
+47 -47
View File
@@ -1,47 +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,
},
};
}
// 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,
},
};
}
+80 -80
View File
@@ -1,80 +1,80 @@
// Active Log Tracker for OCR Processing Layers
export interface VllmCall {
request: any;
response: any;
timestamp: string;
}
export interface ActiveUploadLog {
filename: string;
vllm_calls: VllmCall[];
ocr_raw?: any;
stage_1_output?: any;
stage_2_output?: any;
frontend_response?: any;
pipeline_info?: any;
}
// Store active logs in global context as a Map keyed by filename
// This supports concurrent uploads without race conditions
const globalForActiveLog = global as unknown as {
activeLogs: Map<string, ActiveUploadLog>;
};
// Initialise the map once (survives Next.js hot-reloads on the same process)
if (!globalForActiveLog.activeLogs) {
globalForActiveLog.activeLogs = new Map();
}
export function startActiveLog(filename: string) {
globalForActiveLog.activeLogs.set(filename, {
filename,
vllm_calls: []
});
console.log(`[ActiveLog] Started tracking log for ${filename}`);
}
export function logVllmCall(filename: string, request: any, response: any) {
const log = globalForActiveLog.activeLogs.get(filename);
if (log) {
log.vllm_calls.push({
request,
response,
timestamp: new Date().toISOString()
});
console.log(`[ActiveLog] Logged vLLM call for ${filename} (total calls: ${log.vllm_calls.length})`);
} else {
console.log(`[ActiveLog] Warning: Attempted to log vLLM call for "${filename}" but no active log session is running.`);
}
}
export function getActiveLog(filename: string): ActiveUploadLog | null {
return globalForActiveLog.activeLogs.get(filename) || null;
}
export function clearActiveLog(filename: string) {
globalForActiveLog.activeLogs.delete(filename);
console.log(`[ActiveLog] Cleared active log tracking context for ${filename}`);
}
/**
* Log a vLLM call to ALL currently active upload sessions.
* Used by the vllm-proxy, which doesn't have per-upload filename context,
* since the pipeline-api processes exactly one upload at a time.
*/
export function logVllmCallToAll(request: any, response: any) {
const sessions = globalForActiveLog.activeLogs;
if (sessions.size === 0) {
console.log("[ActiveLog] Warning: Attempted to log vLLM call but no active log session is running.");
return;
}
for (const [filename, log] of sessions) {
log.vllm_calls.push({
request,
response,
timestamp: new Date().toISOString()
});
console.log(`[ActiveLog] Logged vLLM call for ${filename} (total calls: ${log.vllm_calls.length})`);
}
}
// Active Log Tracker for OCR Processing Layers
export interface VllmCall {
request: any;
response: any;
timestamp: string;
}
export interface ActiveUploadLog {
filename: string;
vllm_calls: VllmCall[];
ocr_raw?: any;
stage_1_output?: any;
stage_2_output?: any;
frontend_response?: any;
pipeline_info?: any;
}
// Store active logs in global context as a Map keyed by filename
// This supports concurrent uploads without race conditions
const globalForActiveLog = global as unknown as {
activeLogs: Map<string, ActiveUploadLog>;
};
// Initialise the map once (survives Next.js hot-reloads on the same process)
if (!globalForActiveLog.activeLogs) {
globalForActiveLog.activeLogs = new Map();
}
export function startActiveLog(filename: string) {
globalForActiveLog.activeLogs.set(filename, {
filename,
vllm_calls: []
});
console.log(`[ActiveLog] Started tracking log for ${filename}`);
}
export function logVllmCall(filename: string, request: any, response: any) {
const log = globalForActiveLog.activeLogs.get(filename);
if (log) {
log.vllm_calls.push({
request,
response,
timestamp: new Date().toISOString()
});
console.log(`[ActiveLog] Logged vLLM call for ${filename} (total calls: ${log.vllm_calls.length})`);
} else {
console.log(`[ActiveLog] Warning: Attempted to log vLLM call for "${filename}" but no active log session is running.`);
}
}
export function getActiveLog(filename: string): ActiveUploadLog | null {
return globalForActiveLog.activeLogs.get(filename) || null;
}
export function clearActiveLog(filename: string) {
globalForActiveLog.activeLogs.delete(filename);
console.log(`[ActiveLog] Cleared active log tracking context for ${filename}`);
}
/**
* Log a vLLM call to ALL currently active upload sessions.
* Used by the vllm-proxy, which doesn't have per-upload filename context,
* since the pipeline-api processes exactly one upload at a time.
*/
export function logVllmCallToAll(request: any, response: any) {
const sessions = globalForActiveLog.activeLogs;
if (sessions.size === 0) {
console.log("[ActiveLog] Warning: Attempted to log vLLM call but no active log session is running.");
return;
}
for (const [filename, log] of sessions) {
log.vllm_calls.push({
request,
response,
timestamp: new Date().toISOString()
});
console.log(`[ActiveLog] Logged vLLM call for ${filename} (total calls: ${log.vllm_calls.length})`);
}
}
+13 -13
View File
@@ -1,13 +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,
});
}
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,
});
}
+31 -31
View File
@@ -1,31 +1,31 @@
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;
role?: string;
}
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);
}
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;
role?: string;
}
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);
}
+20 -20
View File
@@ -1,20 +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;
}
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;
}
+362 -362
View File
@@ -1,362 +1,362 @@
import http from "http";
import fs from "fs";
import path from "path";
export function dockerRequest(path: string, method: string, body: any = null): Promise<any> {
return new Promise((resolve, reject) => {
const options = {
socketPath: "/var/run/docker.sock",
path: path,
method: method,
headers: {
"Content-Type": "application/json",
},
};
const req = http.request(options, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const resBuffer = Buffer.concat(chunks);
const data = resBuffer.toString("utf8");
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(data ? JSON.parse(data) : null);
} catch (e) {
resolve(data);
}
} else {
reject(new Error(`Docker API Error ${res.statusCode}: ${data}`));
}
});
});
req.on("error", (err) => reject(err));
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
export function parseDockerStream(buffer: Buffer): { stdout: string; stderr: string } {
let stdout = "";
let stderr = "";
let offset = 0;
while (offset + 8 <= buffer.length) {
const streamType = buffer.readUInt8(offset);
const size = buffer.readUInt32BE(offset + 4);
if (offset + 8 + size > buffer.length) {
break;
}
const payload = buffer.toString("utf8", offset + 8, offset + 8 + size);
if (streamType === 1) {
stdout += payload;
} else if (streamType === 2) {
stderr += payload;
}
offset += 8 + size;
}
if (stdout === "" && stderr === "" && buffer.length > 0) {
stdout = buffer.toString("utf8");
}
return { stdout, stderr };
}
export function runExec(containerName: string, cmd: string[]): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const execConfig = {
AttachStdout: true,
AttachStderr: true,
Cmd: cmd,
};
const createRes = await dockerRequest(`/containers/${containerName}/exec`, "POST", execConfig);
const execId = createRes.Id;
const options = {
socketPath: "/var/run/docker.sock",
path: `/exec/${execId}/start`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
};
const req = http.request(options, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const streamData = parseDockerStream(Buffer.concat(chunks));
resolve(streamData.stdout || streamData.stderr);
});
});
req.on("error", (err) => reject(err));
req.write(JSON.stringify({ Detach: false, Tty: false }));
req.end();
} catch (err) {
reject(err);
}
});
}
export function getProcessName(pid: number): string {
try {
const commPath = `/proc/${pid}/comm`;
if (fs.existsSync(commPath)) {
return fs.readFileSync(commPath, "utf8").trim();
}
} catch (err) {
// ignore
}
return "";
}
export function makeHumanReadableName(procName: string): string {
const nameLower = procName.toLowerCase();
if (nameLower.includes("rustdesk")) return "RustDesk Remote Desktop";
if (nameLower.includes("xorg")) return "Xorg Graphics Server";
if (nameLower.includes("vllm") || nameLower.includes("enginecore")) return "vLLM Inference Server";
if (nameLower.includes("python")) return "Python / Gradio App";
if (nameLower.includes("node")) return "Next.js Web App";
if (nameLower.includes("postgres")) return "PostgreSQL Database";
if (nameLower.includes("nginx")) return "Nginx Load Balancer";
return procName;
}
export async function getGpuInfo(): Promise<any[]> {
try {
const gpuOutput = await runExec("paddleocr-vllm-server", [
"nvidia-smi",
"--query-gpu=index,name,utilization.gpu,utilization.memory,memory.total,memory.used,memory.free,uuid",
"--format=csv,noheader,nounits",
]);
const gpus: any[] = [];
if (gpuOutput) {
const lines = gpuOutput.split("\n");
for (const line of lines) {
if (!line.trim()) continue;
const parts = line.split(",").map((p) => p.trim());
if (parts.length >= 8) {
gpus.push({
index: parts[0],
name: parts[1],
gpu_util: parseInt(parts[2]) || 0,
mem_util: parseInt(parts[3]) || 0,
mem_total: parseInt(parts[4]) || 0,
mem_used: parseInt(parts[5]) || 0,
mem_free: parseInt(parts[6]) || 0,
uuid: parts[7],
processes: [],
});
}
}
}
const procOutput = await runExec("paddleocr-vllm-server", [
"nvidia-smi",
"--query-compute-apps=gpu_uuid,pid,process_name,used_memory",
"--format=csv,noheader,nounits",
]);
if (procOutput) {
const lines = procOutput.split("\n");
for (const line of lines) {
if (!line.trim()) continue;
const parts = line.split(",").map((p) => p.trim());
if (parts.length >= 4) {
const gpuUuid = parts[0];
const pid = parseInt(parts[1]);
const procName = parts[2];
const usedMem = parseInt(parts[3]);
const gpu = gpus.find((g) => g.uuid === gpuUuid);
if (gpu) {
const systemProcName = getProcessName(pid) || procName;
gpu.processes.push({
pid,
name: procName,
readable_name: makeHumanReadableName(systemProcName),
used_mem: usedMem,
});
}
}
}
}
return gpus;
} catch (err) {
console.error("Failed to query GPUs:", err);
return [];
}
}
export async function getContainerStatus(containerName: string): Promise<string> {
try {
const info = await dockerRequest(`/containers/${containerName}/json`, "GET");
return info.State.Status;
} catch (err) {
return "stopped";
}
}
export async function manageContainer(containerName: string, action: "start" | "stop" | "restart"): Promise<void> {
await dockerRequest(`/containers/${containerName}/${action}`, "POST");
}
export async function recreateContainer(containerName: string, newCudaDevices?: string): Promise<void> {
const inspect = await dockerRequest(`/containers/${containerName}/json`, "GET");
try {
await dockerRequest(`/containers/${containerName}/stop`, "POST");
} catch (e) {
// ignore
}
const rand = Math.floor(Math.random() * 10000);
const oldTempName = `${containerName}_old_${rand}`;
await dockerRequest(`/containers/${containerName}/rename?name=${oldTempName}`, "POST");
const config: any = {
...inspect.Config,
HostConfig: inspect.HostConfig,
NetworkingConfig: {
EndpointsConfig: inspect.NetworkSettings.Networks,
},
};
// Ensure Name is not copied from Inspect root as it's not a field in Create
delete config.Name;
if (newCudaDevices && config.Env) {
config.Env = config.Env.map((envStr: string) => {
if (envStr.startsWith("CUDA_VISIBLE_DEVICES=")) {
return `CUDA_VISIBLE_DEVICES=${newCudaDevices}`;
}
return envStr;
});
}
const createRes = await dockerRequest(`/containers/create?name=${containerName}`, "POST", config);
const newId = createRes.Id;
await dockerRequest(`/containers/${newId}/start`, "POST");
try {
await dockerRequest(`/containers/${oldTempName}`, "DELETE");
} catch (e) {
// ignore
}
}
export async function getEnvSettings(): Promise<{ cuda_devices: string }> {
const envPath = path.join(process.cwd(), "..", ".env");
const settings = { cuda_devices: "1" };
try {
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, "utf8");
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const [k, v] = trimmed.split("=");
if (k && k.trim() === "CUDA_VISIBLE_DEVICES" && v) {
settings.cuda_devices = v.trim().replace(/['"]/g, "");
}
}
}
} catch (err) {
console.error("Failed to read env settings:", err);
}
return settings;
}
export async function saveEnvSettings(cuda_devices: string): Promise<void> {
const envPath = path.join(process.cwd(), "..", ".env");
try {
let lines: string[] = [];
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, "utf8").split("\n");
}
let found = false;
const newLines = lines.map((line) => {
if (line.trim().startsWith("CUDA_VISIBLE_DEVICES=")) {
found = true;
return `CUDA_VISIBLE_DEVICES=${cuda_devices}`;
}
return line;
});
if (!found) {
newLines.push(`CUDA_VISIBLE_DEVICES=${cuda_devices}`);
}
fs.writeFileSync(envPath, newLines.join("\n"), "utf8");
} catch (err) {
console.error("Failed to save env settings:", err);
throw err;
}
}
export async function unloadOtherEngines(): Promise<{ stopped: string[]; failed: string[] }> {
const stopped: string[] = [];
const failed: string[] = [];
try {
const containers = await dockerRequest("/containers/json", "GET");
if (!Array.isArray(containers)) {
throw new Error("Invalid response from Docker API: expected container array.");
}
const stopPromises: Promise<void>[] = [];
for (const container of containers) {
if (!container.Names || !Array.isArray(container.Names)) continue;
const rawName = container.Names[0] || "";
const name = rawName.startsWith("/") ? rawName.slice(1) : rawName;
const nameLower = name.toLowerCase();
const matchesEngine =
nameLower.includes("lighton") ||
nameLower.includes("glm") ||
nameLower.includes("dots") ||
nameLower.includes("deepseek");
const isExcluded =
nameLower.includes("paddleocr") ||
nameLower.includes("nemotron");
if (matchesEngine && !isExcluded) {
console.log(`Queueing unload for container: ${name} (${container.Id})`);
const stopPromise = dockerRequest(`/containers/${container.Id}/stop`, "POST")
.then(() => {
stopped.push(name);
})
.catch((err) => {
console.error(`Failed to stop container ${name}:`, err);
failed.push(`${name} (${err.message})`);
});
stopPromises.push(stopPromise);
}
}
await Promise.all(stopPromises);
} catch (err: any) {
console.error("Failed to unload other engines:", err);
throw err;
}
return { stopped, failed };
}
import http from "http";
import fs from "fs";
import path from "path";
export function dockerRequest(path: string, method: string, body: any = null): Promise<any> {
return new Promise((resolve, reject) => {
const options = {
socketPath: "/var/run/docker.sock",
path: path,
method: method,
headers: {
"Content-Type": "application/json",
},
};
const req = http.request(options, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const resBuffer = Buffer.concat(chunks);
const data = resBuffer.toString("utf8");
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(data ? JSON.parse(data) : null);
} catch (e) {
resolve(data);
}
} else {
reject(new Error(`Docker API Error ${res.statusCode}: ${data}`));
}
});
});
req.on("error", (err) => reject(err));
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
export function parseDockerStream(buffer: Buffer): { stdout: string; stderr: string } {
let stdout = "";
let stderr = "";
let offset = 0;
while (offset + 8 <= buffer.length) {
const streamType = buffer.readUInt8(offset);
const size = buffer.readUInt32BE(offset + 4);
if (offset + 8 + size > buffer.length) {
break;
}
const payload = buffer.toString("utf8", offset + 8, offset + 8 + size);
if (streamType === 1) {
stdout += payload;
} else if (streamType === 2) {
stderr += payload;
}
offset += 8 + size;
}
if (stdout === "" && stderr === "" && buffer.length > 0) {
stdout = buffer.toString("utf8");
}
return { stdout, stderr };
}
export function runExec(containerName: string, cmd: string[]): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const execConfig = {
AttachStdout: true,
AttachStderr: true,
Cmd: cmd,
};
const createRes = await dockerRequest(`/containers/${containerName}/exec`, "POST", execConfig);
const execId = createRes.Id;
const options = {
socketPath: "/var/run/docker.sock",
path: `/exec/${execId}/start`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
};
const req = http.request(options, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const streamData = parseDockerStream(Buffer.concat(chunks));
resolve(streamData.stdout || streamData.stderr);
});
});
req.on("error", (err) => reject(err));
req.write(JSON.stringify({ Detach: false, Tty: false }));
req.end();
} catch (err) {
reject(err);
}
});
}
export function getProcessName(pid: number): string {
try {
const commPath = `/proc/${pid}/comm`;
if (fs.existsSync(commPath)) {
return fs.readFileSync(commPath, "utf8").trim();
}
} catch (err) {
// ignore
}
return "";
}
export function makeHumanReadableName(procName: string): string {
const nameLower = procName.toLowerCase();
if (nameLower.includes("rustdesk")) return "RustDesk Remote Desktop";
if (nameLower.includes("xorg")) return "Xorg Graphics Server";
if (nameLower.includes("vllm") || nameLower.includes("enginecore")) return "vLLM Inference Server";
if (nameLower.includes("python")) return "Python / Gradio App";
if (nameLower.includes("node")) return "Next.js Web App";
if (nameLower.includes("postgres")) return "PostgreSQL Database";
if (nameLower.includes("nginx")) return "Nginx Load Balancer";
return procName;
}
export async function getGpuInfo(): Promise<any[]> {
try {
const gpuOutput = await runExec("paddleocr-vllm-server", [
"nvidia-smi",
"--query-gpu=index,name,utilization.gpu,utilization.memory,memory.total,memory.used,memory.free,uuid",
"--format=csv,noheader,nounits",
]);
const gpus: any[] = [];
if (gpuOutput) {
const lines = gpuOutput.split("\n");
for (const line of lines) {
if (!line.trim()) continue;
const parts = line.split(",").map((p) => p.trim());
if (parts.length >= 8) {
gpus.push({
index: parts[0],
name: parts[1],
gpu_util: parseInt(parts[2]) || 0,
mem_util: parseInt(parts[3]) || 0,
mem_total: parseInt(parts[4]) || 0,
mem_used: parseInt(parts[5]) || 0,
mem_free: parseInt(parts[6]) || 0,
uuid: parts[7],
processes: [],
});
}
}
}
const procOutput = await runExec("paddleocr-vllm-server", [
"nvidia-smi",
"--query-compute-apps=gpu_uuid,pid,process_name,used_memory",
"--format=csv,noheader,nounits",
]);
if (procOutput) {
const lines = procOutput.split("\n");
for (const line of lines) {
if (!line.trim()) continue;
const parts = line.split(",").map((p) => p.trim());
if (parts.length >= 4) {
const gpuUuid = parts[0];
const pid = parseInt(parts[1]);
const procName = parts[2];
const usedMem = parseInt(parts[3]);
const gpu = gpus.find((g) => g.uuid === gpuUuid);
if (gpu) {
const systemProcName = getProcessName(pid) || procName;
gpu.processes.push({
pid,
name: procName,
readable_name: makeHumanReadableName(systemProcName),
used_mem: usedMem,
});
}
}
}
}
return gpus;
} catch (err) {
console.error("Failed to query GPUs:", err);
return [];
}
}
export async function getContainerStatus(containerName: string): Promise<string> {
try {
const info = await dockerRequest(`/containers/${containerName}/json`, "GET");
return info.State.Status;
} catch (err) {
return "stopped";
}
}
export async function manageContainer(containerName: string, action: "start" | "stop" | "restart"): Promise<void> {
await dockerRequest(`/containers/${containerName}/${action}`, "POST");
}
export async function recreateContainer(containerName: string, newCudaDevices?: string): Promise<void> {
const inspect = await dockerRequest(`/containers/${containerName}/json`, "GET");
try {
await dockerRequest(`/containers/${containerName}/stop`, "POST");
} catch (e) {
// ignore
}
const rand = Math.floor(Math.random() * 10000);
const oldTempName = `${containerName}_old_${rand}`;
await dockerRequest(`/containers/${containerName}/rename?name=${oldTempName}`, "POST");
const config: any = {
...inspect.Config,
HostConfig: inspect.HostConfig,
NetworkingConfig: {
EndpointsConfig: inspect.NetworkSettings.Networks,
},
};
// Ensure Name is not copied from Inspect root as it's not a field in Create
delete config.Name;
if (newCudaDevices && config.Env) {
config.Env = config.Env.map((envStr: string) => {
if (envStr.startsWith("CUDA_VISIBLE_DEVICES=")) {
return `CUDA_VISIBLE_DEVICES=${newCudaDevices}`;
}
return envStr;
});
}
const createRes = await dockerRequest(`/containers/create?name=${containerName}`, "POST", config);
const newId = createRes.Id;
await dockerRequest(`/containers/${newId}/start`, "POST");
try {
await dockerRequest(`/containers/${oldTempName}`, "DELETE");
} catch (e) {
// ignore
}
}
export async function getEnvSettings(): Promise<{ cuda_devices: string }> {
const envPath = path.join(process.cwd(), "..", ".env");
const settings = { cuda_devices: "1" };
try {
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, "utf8");
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const [k, v] = trimmed.split("=");
if (k && k.trim() === "CUDA_VISIBLE_DEVICES" && v) {
settings.cuda_devices = v.trim().replace(/['"]/g, "");
}
}
}
} catch (err) {
console.error("Failed to read env settings:", err);
}
return settings;
}
export async function saveEnvSettings(cuda_devices: string): Promise<void> {
const envPath = path.join(process.cwd(), "..", ".env");
try {
let lines: string[] = [];
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, "utf8").split("\n");
}
let found = false;
const newLines = lines.map((line) => {
if (line.trim().startsWith("CUDA_VISIBLE_DEVICES=")) {
found = true;
return `CUDA_VISIBLE_DEVICES=${cuda_devices}`;
}
return line;
});
if (!found) {
newLines.push(`CUDA_VISIBLE_DEVICES=${cuda_devices}`);
}
fs.writeFileSync(envPath, newLines.join("\n"), "utf8");
} catch (err) {
console.error("Failed to save env settings:", err);
throw err;
}
}
export async function unloadOtherEngines(): Promise<{ stopped: string[]; failed: string[] }> {
const stopped: string[] = [];
const failed: string[] = [];
try {
const containers = await dockerRequest("/containers/json", "GET");
if (!Array.isArray(containers)) {
throw new Error("Invalid response from Docker API: expected container array.");
}
const stopPromises: Promise<void>[] = [];
for (const container of containers) {
if (!container.Names || !Array.isArray(container.Names)) continue;
const rawName = container.Names[0] || "";
const name = rawName.startsWith("/") ? rawName.slice(1) : rawName;
const nameLower = name.toLowerCase();
const matchesEngine =
nameLower.includes("lighton") ||
nameLower.includes("glm") ||
nameLower.includes("dots") ||
nameLower.includes("deepseek");
const isExcluded =
nameLower.includes("paddleocr") ||
nameLower.includes("nemotron");
if (matchesEngine && !isExcluded) {
console.log(`Queueing unload for container: ${name} (${container.Id})`);
const stopPromise = dockerRequest(`/containers/${container.Id}/stop`, "POST")
.then(() => {
stopped.push(name);
})
.catch((err) => {
console.error(`Failed to stop container ${name}:`, err);
failed.push(`${name} (${err.message})`);
});
stopPromises.push(stopPromise);
}
}
await Promise.all(stopPromises);
} catch (err: any) {
console.error("Failed to unload other engines:", err);
throw err;
}
return { stopped, failed };
}
+117 -117
View File
@@ -1,117 +1,117 @@
export type ParseStatus = "pending" | "done" | "failed";
export interface DocumentRow {
id: number;
filename: string;
upload_time: Date;
parsed: boolean;
metadata: any;
latitude: any;
longitude: any;
scan_mode: string | null;
parse_error: string | null;
confirmed: boolean;
}
export interface OcrItemRow {
kode_barang: string | null;
nama_barang: string | null;
banyak: string | null;
jumlah: string | null;
}
// Shared by GET /api/v1/documents (list), GET /api/v1/documents/:id, and the
// upload route's dedup-return branch, so the header/shipment/status mapping
// only lives in one place.
export function mapDocumentRow(doc: DocumentRow, itemRows: OcrItemRow[]) {
const metadata = doc.metadata || {};
const items = itemRows.map((item) => ({
nomor_sku: item.kode_barang || "",
nama_barang: item.nama_barang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
}));
let header = {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
};
let shipment = {
kepada_yth: "",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
};
if (metadata.header) {
// Document was updated via mobile app
header = {
tanggal: metadata.header.tanggal || "",
no_po: metadata.header.no_po || "",
no_so: metadata.header.no_so || "",
no_do: metadata.header.no_do || ""
};
shipment = {
kepada_yth: metadata.shipment?.kepada_yth || "",
order_untuk: metadata.shipment?.order_untuk || "",
alamat: metadata.shipment?.alamat || "",
plat_truk: metadata.shipment?.plat_truk || "",
nama_driver: metadata.shipment?.nama_driver || "",
nama_penerima: metadata.shipment?.nama_penerima || ""
};
} else {
// Document was freshly uploaded / parsed via web
header = {
tanggal: metadata.tanggal || "",
no_po: metadata.noPO || "",
no_so: metadata.noSO || "",
no_do: metadata.noDO || doc.filename || ""
};
shipment = {
kepada_yth: metadata.customerInfo || "",
order_untuk: metadata.orderUntuk || "",
alamat: metadata.alamat || "",
plat_truk: metadata.platTruk || "",
nama_driver: "",
nama_penerima: metadata.headerRemark || ""
};
}
const parseStatus: ParseStatus = doc.parsed
? "done"
: doc.parse_error
? "failed"
: "pending";
// scan_mode is the source of truth once persisted (task 9.1); fall back to the
// legacy metadata sentinel for rows created before that column existed.
const docType = doc.scan_mode || (shipment.order_untuk === "PRODUCT SCAN" ? "Product" : "DO");
return {
id: doc.id.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header,
shipment,
items,
parsed: doc.parsed,
latitude: doc.latitude ? parseFloat(doc.latitude.toString()) : null,
longitude: doc.longitude ? parseFloat(doc.longitude.toString()) : null,
parseStatus,
docType,
confirmed: doc.confirmed,
// Full classify+OCR result captured at upload time for Product Scan
// documents (gap G3) - lets the editor render immediately instead of
// re-running the GPU pipeline on review. `null` for DO documents, and
// for Product documents parsed before this existed or already PUT
// (the PUT route rebuilds `metadata` from scratch without this key,
// which is fine - the editor only needs it during the initial review).
productScan: metadata.productScan || null
};
}
export type ParseStatus = "pending" | "done" | "failed";
export interface DocumentRow {
id: number;
filename: string;
upload_time: Date;
parsed: boolean;
metadata: any;
latitude: any;
longitude: any;
scan_mode: string | null;
parse_error: string | null;
confirmed: boolean;
}
export interface OcrItemRow {
kode_barang: string | null;
nama_barang: string | null;
banyak: string | null;
jumlah: string | null;
}
// Shared by GET /api/v1/documents (list), GET /api/v1/documents/:id, and the
// upload route's dedup-return branch, so the header/shipment/status mapping
// only lives in one place.
export function mapDocumentRow(doc: DocumentRow, itemRows: OcrItemRow[]) {
const metadata = doc.metadata || {};
const items = itemRows.map((item) => ({
nomor_sku: item.kode_barang || "",
nama_barang: item.nama_barang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
}));
let header = {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
};
let shipment = {
kepada_yth: "",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
};
if (metadata.header) {
// Document was updated via mobile app
header = {
tanggal: metadata.header.tanggal || "",
no_po: metadata.header.no_po || "",
no_so: metadata.header.no_so || "",
no_do: metadata.header.no_do || ""
};
shipment = {
kepada_yth: metadata.shipment?.kepada_yth || "",
order_untuk: metadata.shipment?.order_untuk || "",
alamat: metadata.shipment?.alamat || "",
plat_truk: metadata.shipment?.plat_truk || "",
nama_driver: metadata.shipment?.nama_driver || "",
nama_penerima: metadata.shipment?.nama_penerima || ""
};
} else {
// Document was freshly uploaded / parsed via web
header = {
tanggal: metadata.tanggal || "",
no_po: metadata.noPO || "",
no_so: metadata.noSO || "",
no_do: metadata.noDO || doc.filename || ""
};
shipment = {
kepada_yth: metadata.customerInfo || "",
order_untuk: metadata.orderUntuk || "",
alamat: metadata.alamat || "",
plat_truk: metadata.platTruk || "",
nama_driver: "",
nama_penerima: metadata.headerRemark || ""
};
}
const parseStatus: ParseStatus = doc.parsed
? "done"
: doc.parse_error
? "failed"
: "pending";
// scan_mode is the source of truth once persisted (task 9.1); fall back to the
// legacy metadata sentinel for rows created before that column existed.
const docType = doc.scan_mode || (shipment.order_untuk === "PRODUCT SCAN" ? "Product" : "DO");
return {
id: doc.id.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header,
shipment,
items,
parsed: doc.parsed,
latitude: doc.latitude ? parseFloat(doc.latitude.toString()) : null,
longitude: doc.longitude ? parseFloat(doc.longitude.toString()) : null,
parseStatus,
docType,
confirmed: doc.confirmed,
// Full classify+OCR result captured at upload time for Product Scan
// documents (gap G3) - lets the editor render immediately instead of
// re-running the GPU pipeline on review. `null` for DO documents, and
// for Product documents parsed before this existed or already PUT
// (the PUT route rebuilds `metadata` from scratch without this key,
// which is fine - the editor only needs it during the initial review).
productScan: metadata.productScan || null
};
}
+136 -136
View File
@@ -1,136 +1,136 @@
import { parseDOMetadata, sanitizeParsedMetadata } from "./parser";
import assert from "assert";
function makeBlankMeta() {
return { vendorInfo: "Not Found", customerInfo: "Not Found", tanggal: "Not Found", noSO: "Not Found", noDO: "Not Found", noPO: "Not Found", items: [] as any[], platTruk: "" };
}
function runTests() {
const YY = new Date().getFullYear().toString().slice(-2);
let failures = 0;
// ===== parseDOMetadata tests =====
console.log("=== parseDOMetadata tests ===");
const parseTests = [
{ name: "PO standard PO/26/", markdown: "No. PO : PO/26/0000178435\nTanggal: 15 May 2026", expected: { noPO: `PO/${YY}/0000178435` } },
{ name: "PO misread P0/26/ on label", markdown: "No. PO : P0/26/0000236828\nTanggal: 23 June 2026", expected: { noPO: `PO/${YY}/0000236828` } },
{ name: "PO label raw 10-digit, real PO in body", markdown: "No. PO : 1659980277\nP0/26/0000230828\nTanggal: 23 June 2026", expected: { noPO: `PO/${YY}/0000230828` } },
{ name: "PO misread F0/20/ — use current year NOT 20", markdown: "No. PO : F0/20/0000190929\nTanggal: 25 May 2020", expected: { noPO: `PO/${YY}/0000190929` } },
{ name: "PO body P0/26/", markdown: "Purchase order P0/26/998877\nTanggal: 15 May 2026", expected: { noPO: `PO/${YY}/998877` } },
{ name: "PO real doc: label raw, body has P0/26/", markdown: "Tanggal :\nNo.SO : 23 June 2026\nNo. DO : 1691960321\nNo.PO : 1659980277\nP0/26/0000236828", expected: { noPO: `PO/${YY}/0000236828`, tanggal: "23 June 2026" } },
{ name: "PO fused F012070000170727", markdown: "Tanggal : 25 May 2020\nNo. PO : F012070000170727", expected: { noPO: `PO/${YY}/0000170727` } },
{ name: "PO fused PO12070000190729", markdown: "Tanggal: 25 May 2020\nNo.PO : PO12070000190729", expected: { noPO: `PO/${YY}/0000190729` } },
{ name: "PO noise digits PO120/0000170727", markdown: "Tanggal:25 Hv 2024\nNo. PO : PO120/0000170727", expected: { noPO: `PO/${YY}/0000170727` } },
{ name: "Date trailing noise cut", markdown: "Tanggal: 15 May 2026 No. SO\nNo. PO : PO/26/0000178435", expected: { tanggal: "15 May 2026" } },
{ name: "Date no space 25May2020", markdown: "Tanggal:25May2020\nNo. PO : PO/26/0000178435", expected: { tanggal: "25 May 2020" } },
{ name: "Date standard 23 June 2026", markdown: "Tanggal : 23 June 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "23 June 2026" } },
{ name: "Date Tanggal blank shifted to No.SO", markdown: "Tanggal :\nNo.SO : 23 June 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "23 June 2026" } },
{ name: "Date prefix timestamp noise", markdown: "02:17:59/2 of Tanggal : 15 May 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "15 May 2026" } },
{ name: "Date (Asli/Copy) prefix noise", markdown: "Tanggal: (Asli/Copy) 15 May 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "15 May 2026" } },
{ name: "Date junk suffix cut", markdown: "Tanggal: 25 May 2020 (Printed by system)", expected: { tanggal: "25 May 2020" } },
{ name: "Date bad OCR month Hv -> Not Found", markdown: "Tanggal:25 Hv 2024\nNo.SO : 1601001206", expected: { tanggal: "Not Found" } },
{ name: "Date single digit 7 May 2026 -> 07 May 2026", markdown: "Tanggal: 7 May 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "07 May 2026" } },
{ name: "Date single digit 4 Apr 2026 -> 04 April 2026", markdown: "Tanggal: 4 Apr 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "04 April 2026" } },
{ name: "00117709 before Tanggal must not pollute date", markdown: "00117709\nTanggal:25May2020\nNo.SO : 1091721200\nNo. DO : 1657943004\nNo. PO : F0/26/0000190929", expected: { tanggal: "25 May 2020" } },
{ name: "Plate B 9427 UXT", markdown: "Truck No. B 9427 UXT\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9427 UXT" } },
{ name: "Plate B-9999-XYZ dash", markdown: "No. Polisi: B-9999-XYZ\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9999 XYZ" } },
{ name: "Plate ignore PO/SO prefix", markdown: "Plate is PO 1234 SO but real truck is A 123 B\nNo. PO : PO/26/0000178435", expected: { platTruk: "A 123 B" } },
{ name: "Plate B9427UXT adjacent", markdown: "No Polisi B9427UXT\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9427 UXT" } },
{ name: "Plate real doc B 9723 CXS", markdown: "Truck No.\nB 9723 CXS\nWH 01 / 01\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9723 CXS" } },
{
name: "Table column shift alignment correction",
markdown: "No. PO : PO/26/0000178435\n" +
"<table>" +
"<tr><td>Kode Barang</td><td>Nama Barang</td><td>Banyak</td><td>Jumlah</td></tr>" +
"<tr><td></td><td>Item A</td><td>2 KRG</td><td>40 PC</td></tr>" +
"<tr><td>11310014</td><td>Item B</td><td>2 KRG</td><td>40 PC</td></tr>" +
"<tr><td>11310024</td><td>Item C</td><td>1 BOX</td><td>10 KG</td></tr>" +
"<tr><td>11720055</td><td></td><td></td><td></td></tr>" +
"</table>",
expected: {
items: [
{ kodeBarang: "11310014", namaBarang: "Item A", banyak: "2 KRG", jumlah: "40 PC" },
{ kodeBarang: "11310024", namaBarang: "Item B", banyak: "2 KRG", jumlah: "40 PC" },
{ kodeBarang: "11720055", namaBarang: "Item C", banyak: "1 BOX", jumlah: "10 KG" }
]
} as any
}
];
for (const t of parseTests) {
try {
const result = parseDOMetadata(t.markdown) as any;
for (const [key, val] of Object.entries(t.expected)) {
if (key === "items") {
assert.deepStrictEqual(result.items, val);
} else {
assert.strictEqual(result[key], val, `field [${key}] expected "${val}" got "${result[key]}"`);
}
}
console.log(`[PASS] ${t.name}`);
} catch (err: any) {
console.error(`[FAIL] ${t.name}: ${err.message}`);
failures++;
}
}
// ===== sanitizeParsedMetadata second-layer tests =====
console.log("\n=== sanitizeParsedMetadata second-layer tests ===");
const sanitizeTests = [
// tanggal valid
{ name: "sanitize: valid tanggal 30 June 2026 passes", input: { tanggal: "30 June 2026" }, expected: { tanggal: "30 June 2026" } },
{ name: "sanitize: valid tanggal 25 May 2020 passes", input: { tanggal: "25 May 2020" }, expected: { tanggal: "25 May 2020" } },
{ name: "sanitize: single digit tanggal 4 April 2026 -> 04 April 2026", input: { tanggal: "4 April 2026" }, expected: { tanggal: "04 April 2026" } },
// tanggal invalid
{ name: "sanitize: tanggal bad month Hv -> Not Found", input: { tanggal: "25 Hv 2024" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal as number 0011770 -> Not Found", input: { tanggal: "0011770" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal day 0 -> Not Found", input: { tanggal: "0 June 2026" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal day 32 -> Not Found", input: { tanggal: "32 June 2026" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal year 2009 (too old) -> Not Found", input: { tanggal: "15 May 2009" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal Not Found stays Not Found", input: { tanggal: "Not Found" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal with noise suffix -> Not Found", input: { tanggal: "30 June 2026 No. SO" }, expected: { tanggal: "Not Found" } },
// noPO valid
{ name: `sanitize: valid noPO PO/${YY}/0000178435 passes`, input: { noPO: `PO/${YY}/0000178435` }, expected: { noPO: `PO/${YY}/0000178435` } },
// noPO auto-correct year
{ name: "sanitize: noPO wrong year auto-corrected to current", input: { noPO: "PO/20/0000190929" }, expected: { noPO: `PO/${YY}/0000190929` } },
// noPO invalid
{ name: "sanitize: noPO raw number -> Not Found", input: { noPO: "1659980277" }, expected: { noPO: "Not Found" } },
{ name: "sanitize: noPO Not Found stays Not Found", input: { noPO: "Not Found" }, expected: { noPO: "Not Found" } },
// noSO valid
{ name: "sanitize: valid noSO 1691908676 passes", input: { noSO: "1691908676" }, expected: { noSO: "1691908676" } },
// noSO invalid
{ name: "sanitize: noSO 'abc' -> Not Found", input: { noSO: "abc" }, expected: { noSO: "Not Found" } },
{ name: "sanitize: noSO too short '123' -> Not Found", input: { noSO: "123" }, expected: { noSO: "Not Found" } },
// noDO valid
{ name: "sanitize: valid noDO 1659932080 passes", input: { noDO: "1659932080" }, expected: { noDO: "1659932080" } },
// noDO invalid
{ name: "sanitize: noDO 'XYZXYZ' -> Not Found", input: { noDO: "XYZXYZ" }, expected: { noDO: "Not Found" } },
// platTruk valid
{ name: "sanitize: valid platTruk B 9427 UXT passes", input: { platTruk: "B 9427 UXT" }, expected: { platTruk: "B 9427 UXT" } },
// platTruk invalid prefix
{ name: "sanitize: platTruk XY 1234 ABC invalid prefix -> empty", input: { platTruk: "XY 1234 ABC" }, expected: { platTruk: "" } },
// platTruk empty
{ name: "sanitize: platTruk empty stays empty", input: { platTruk: "" }, expected: { platTruk: "" } },
];
for (const t of sanitizeTests) {
try {
const input = { ...makeBlankMeta(), ...t.input };
const result = sanitizeParsedMetadata(input as any) as any;
for (const [key, val] of Object.entries(t.expected)) {
assert.strictEqual(result[key], val, `field [${key}] expected "${val}" got "${result[key]}"`);
}
console.log(`[PASS] ${t.name}`);
} catch (err: any) {
console.error(`[FAIL] ${t.name}: ${err.message}`);
failures++;
}
}
const total = parseTests.length + sanitizeTests.length;
console.log(`\n=== ${total} tests total, ${failures} failed ===`);
if (failures === 0) { console.log("ALL PASS ✅"); process.exit(0); }
else { console.error("FAILED ❌"); process.exit(1); }
}
runTests();
import { parseDOMetadata, sanitizeParsedMetadata } from "./parser";
import assert from "assert";
function makeBlankMeta() {
return { vendorInfo: "Not Found", customerInfo: "Not Found", tanggal: "Not Found", noSO: "Not Found", noDO: "Not Found", noPO: "Not Found", items: [] as any[], platTruk: "" };
}
function runTests() {
const YY = new Date().getFullYear().toString().slice(-2);
let failures = 0;
// ===== parseDOMetadata tests =====
console.log("=== parseDOMetadata tests ===");
const parseTests = [
{ name: "PO standard PO/26/", markdown: "No. PO : PO/26/0000178435\nTanggal: 15 May 2026", expected: { noPO: `PO/${YY}/0000178435` } },
{ name: "PO misread P0/26/ on label", markdown: "No. PO : P0/26/0000236828\nTanggal: 23 June 2026", expected: { noPO: `PO/${YY}/0000236828` } },
{ name: "PO label raw 10-digit, real PO in body", markdown: "No. PO : 1659980277\nP0/26/0000230828\nTanggal: 23 June 2026", expected: { noPO: `PO/${YY}/0000230828` } },
{ name: "PO misread F0/20/ — use current year NOT 20", markdown: "No. PO : F0/20/0000190929\nTanggal: 25 May 2020", expected: { noPO: `PO/${YY}/0000190929` } },
{ name: "PO body P0/26/", markdown: "Purchase order P0/26/998877\nTanggal: 15 May 2026", expected: { noPO: `PO/${YY}/998877` } },
{ name: "PO real doc: label raw, body has P0/26/", markdown: "Tanggal :\nNo.SO : 23 June 2026\nNo. DO : 1691960321\nNo.PO : 1659980277\nP0/26/0000236828", expected: { noPO: `PO/${YY}/0000236828`, tanggal: "23 June 2026" } },
{ name: "PO fused F012070000170727", markdown: "Tanggal : 25 May 2020\nNo. PO : F012070000170727", expected: { noPO: `PO/${YY}/0000170727` } },
{ name: "PO fused PO12070000190729", markdown: "Tanggal: 25 May 2020\nNo.PO : PO12070000190729", expected: { noPO: `PO/${YY}/0000190729` } },
{ name: "PO noise digits PO120/0000170727", markdown: "Tanggal:25 Hv 2024\nNo. PO : PO120/0000170727", expected: { noPO: `PO/${YY}/0000170727` } },
{ name: "Date trailing noise cut", markdown: "Tanggal: 15 May 2026 No. SO\nNo. PO : PO/26/0000178435", expected: { tanggal: "15 May 2026" } },
{ name: "Date no space 25May2020", markdown: "Tanggal:25May2020\nNo. PO : PO/26/0000178435", expected: { tanggal: "25 May 2020" } },
{ name: "Date standard 23 June 2026", markdown: "Tanggal : 23 June 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "23 June 2026" } },
{ name: "Date Tanggal blank shifted to No.SO", markdown: "Tanggal :\nNo.SO : 23 June 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "23 June 2026" } },
{ name: "Date prefix timestamp noise", markdown: "02:17:59/2 of Tanggal : 15 May 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "15 May 2026" } },
{ name: "Date (Asli/Copy) prefix noise", markdown: "Tanggal: (Asli/Copy) 15 May 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "15 May 2026" } },
{ name: "Date junk suffix cut", markdown: "Tanggal: 25 May 2020 (Printed by system)", expected: { tanggal: "25 May 2020" } },
{ name: "Date bad OCR month Hv -> Not Found", markdown: "Tanggal:25 Hv 2024\nNo.SO : 1601001206", expected: { tanggal: "Not Found" } },
{ name: "Date single digit 7 May 2026 -> 07 May 2026", markdown: "Tanggal: 7 May 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "07 May 2026" } },
{ name: "Date single digit 4 Apr 2026 -> 04 April 2026", markdown: "Tanggal: 4 Apr 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "04 April 2026" } },
{ name: "00117709 before Tanggal must not pollute date", markdown: "00117709\nTanggal:25May2020\nNo.SO : 1091721200\nNo. DO : 1657943004\nNo. PO : F0/26/0000190929", expected: { tanggal: "25 May 2020" } },
{ name: "Plate B 9427 UXT", markdown: "Truck No. B 9427 UXT\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9427 UXT" } },
{ name: "Plate B-9999-XYZ dash", markdown: "No. Polisi: B-9999-XYZ\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9999 XYZ" } },
{ name: "Plate ignore PO/SO prefix", markdown: "Plate is PO 1234 SO but real truck is A 123 B\nNo. PO : PO/26/0000178435", expected: { platTruk: "A 123 B" } },
{ name: "Plate B9427UXT adjacent", markdown: "No Polisi B9427UXT\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9427 UXT" } },
{ name: "Plate real doc B 9723 CXS", markdown: "Truck No.\nB 9723 CXS\nWH 01 / 01\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9723 CXS" } },
{
name: "Table column shift alignment correction",
markdown: "No. PO : PO/26/0000178435\n" +
"<table>" +
"<tr><td>Kode Barang</td><td>Nama Barang</td><td>Banyak</td><td>Jumlah</td></tr>" +
"<tr><td></td><td>Item A</td><td>2 KRG</td><td>40 PC</td></tr>" +
"<tr><td>11310014</td><td>Item B</td><td>2 KRG</td><td>40 PC</td></tr>" +
"<tr><td>11310024</td><td>Item C</td><td>1 BOX</td><td>10 KG</td></tr>" +
"<tr><td>11720055</td><td></td><td></td><td></td></tr>" +
"</table>",
expected: {
items: [
{ kodeBarang: "11310014", namaBarang: "Item A", banyak: "2 KRG", jumlah: "40 PC" },
{ kodeBarang: "11310024", namaBarang: "Item B", banyak: "2 KRG", jumlah: "40 PC" },
{ kodeBarang: "11720055", namaBarang: "Item C", banyak: "1 BOX", jumlah: "10 KG" }
]
} as any
}
];
for (const t of parseTests) {
try {
const result = parseDOMetadata(t.markdown) as any;
for (const [key, val] of Object.entries(t.expected)) {
if (key === "items") {
assert.deepStrictEqual(result.items, val);
} else {
assert.strictEqual(result[key], val, `field [${key}] expected "${val}" got "${result[key]}"`);
}
}
console.log(`[PASS] ${t.name}`);
} catch (err: any) {
console.error(`[FAIL] ${t.name}: ${err.message}`);
failures++;
}
}
// ===== sanitizeParsedMetadata second-layer tests =====
console.log("\n=== sanitizeParsedMetadata second-layer tests ===");
const sanitizeTests = [
// tanggal valid
{ name: "sanitize: valid tanggal 30 June 2026 passes", input: { tanggal: "30 June 2026" }, expected: { tanggal: "30 June 2026" } },
{ name: "sanitize: valid tanggal 25 May 2020 passes", input: { tanggal: "25 May 2020" }, expected: { tanggal: "25 May 2020" } },
{ name: "sanitize: single digit tanggal 4 April 2026 -> 04 April 2026", input: { tanggal: "4 April 2026" }, expected: { tanggal: "04 April 2026" } },
// tanggal invalid
{ name: "sanitize: tanggal bad month Hv -> Not Found", input: { tanggal: "25 Hv 2024" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal as number 0011770 -> Not Found", input: { tanggal: "0011770" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal day 0 -> Not Found", input: { tanggal: "0 June 2026" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal day 32 -> Not Found", input: { tanggal: "32 June 2026" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal year 2009 (too old) -> Not Found", input: { tanggal: "15 May 2009" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal Not Found stays Not Found", input: { tanggal: "Not Found" }, expected: { tanggal: "Not Found" } },
{ name: "sanitize: tanggal with noise suffix -> Not Found", input: { tanggal: "30 June 2026 No. SO" }, expected: { tanggal: "Not Found" } },
// noPO valid
{ name: `sanitize: valid noPO PO/${YY}/0000178435 passes`, input: { noPO: `PO/${YY}/0000178435` }, expected: { noPO: `PO/${YY}/0000178435` } },
// noPO auto-correct year
{ name: "sanitize: noPO wrong year auto-corrected to current", input: { noPO: "PO/20/0000190929" }, expected: { noPO: `PO/${YY}/0000190929` } },
// noPO invalid
{ name: "sanitize: noPO raw number -> Not Found", input: { noPO: "1659980277" }, expected: { noPO: "Not Found" } },
{ name: "sanitize: noPO Not Found stays Not Found", input: { noPO: "Not Found" }, expected: { noPO: "Not Found" } },
// noSO valid
{ name: "sanitize: valid noSO 1691908676 passes", input: { noSO: "1691908676" }, expected: { noSO: "1691908676" } },
// noSO invalid
{ name: "sanitize: noSO 'abc' -> Not Found", input: { noSO: "abc" }, expected: { noSO: "Not Found" } },
{ name: "sanitize: noSO too short '123' -> Not Found", input: { noSO: "123" }, expected: { noSO: "Not Found" } },
// noDO valid
{ name: "sanitize: valid noDO 1659932080 passes", input: { noDO: "1659932080" }, expected: { noDO: "1659932080" } },
// noDO invalid
{ name: "sanitize: noDO 'XYZXYZ' -> Not Found", input: { noDO: "XYZXYZ" }, expected: { noDO: "Not Found" } },
// platTruk valid
{ name: "sanitize: valid platTruk B 9427 UXT passes", input: { platTruk: "B 9427 UXT" }, expected: { platTruk: "B 9427 UXT" } },
// platTruk invalid prefix
{ name: "sanitize: platTruk XY 1234 ABC invalid prefix -> empty", input: { platTruk: "XY 1234 ABC" }, expected: { platTruk: "" } },
// platTruk empty
{ name: "sanitize: platTruk empty stays empty", input: { platTruk: "" }, expected: { platTruk: "" } },
];
for (const t of sanitizeTests) {
try {
const input = { ...makeBlankMeta(), ...t.input };
const result = sanitizeParsedMetadata(input as any) as any;
for (const [key, val] of Object.entries(t.expected)) {
assert.strictEqual(result[key], val, `field [${key}] expected "${val}" got "${result[key]}"`);
}
console.log(`[PASS] ${t.name}`);
} catch (err: any) {
console.error(`[FAIL] ${t.name}: ${err.message}`);
failures++;
}
}
const total = parseTests.length + sanitizeTests.length;
console.log(`\n=== ${total} tests total, ${failures} failed ===`);
if (failures === 0) { console.log("ALL PASS ✅"); process.exit(0); }
else { console.error("FAILED ❌"); process.exit(1); }
}
runTests();
File diff suppressed because it is too large. Load diff
+244 -244
View File
@@ -1,244 +1,244 @@
import { query } from "../db";
// Bounds the classifier call so a wedged GPU container fails fast instead of
// hanging indefinitely. Raised from 90s (2026-07-14): hard images now
// legitimately take up to ~3 min - a 4-orientation OCR search plus a VL
// pipeline fallback when no expiry date is found (see
// config/classify_ocr_server.py) - and the old bound was killing exactly
// the images those fallbacks exist to save.
const PIPELINE_TIMEOUT_MS = 240_000;
// Thrown when the Python classifier service itself returns a non-2xx response,
// so callers can forward its actual status instead of collapsing everything to 500.
export class ClassifierError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export interface SkuMatch {
no_sku: string;
nama_item: string;
score: number;
yoloSimilarity: number;
isBestMatch: boolean;
}
export interface ProductScanResult {
classification: any;
ocr: any;
possibleMatches: SkuMatch[];
}
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
// --- OCR-evidence re-ranking of the classifier's top-K candidates ---
//
// DINOv2's misses are near-twin confusions (same brand line, different
// flavor/size) - exactly the cases where the printed variant words differ,
// and PaddleOCR usually reads some of them. Within a narrow similarity band
// of the top-1 candidate, prefer the one whose distinctive name tokens
// actually appear in the OCR'd text. Coverage-normalized so generic
// packaging words (e.g. "French Fries", "Ayam") that happen to be unique to
// one candidate's *name* can't hijack the ranking. Parameters tuned offline
// against the 79-image validation set (scripts/experiment-rerank.mjs,
// 2026-07-14: fixes 8 of 18 top-1 misses, breaks 0 of 61 correct).
const RERANK_TOP_K = 12;
const RERANK_SIM_BAND = 0.12;
const RERANK_COVERAGE_MARGIN = 0.25;
function classNameSku(className: string): string {
// foto-kemasan-v2 class names are "<SKU> <NAME...>"
return (className || "").trim().split(/\s+/)[0] || "";
}
function tokenizeName(name: string): string[] {
return name.toUpperCase().split(/[^A-Z0-9]+/).filter(t => t.length >= 2);
}
function withinEditDistance1(a: string, b: string): boolean {
if (a === b) return true;
const la = a.length, lb = b.length;
if (Math.abs(la - lb) > 1) return false;
if (la === lb) {
let diff = 0;
for (let i = 0; i < la; i++) if (a[i] !== b[i]) diff++;
return diff <= 1;
}
const [s, l] = la < lb ? [a, b] : [b, a];
let i = 0, j = 0, skipped = false;
while (i < s.length && j < l.length) {
if (s[i] === l[j]) { i++; j++; }
else if (!skipped) { skipped = true; j++; }
else return false;
}
return true;
}
interface OcrTextIndex { squashed: string; tokens: Set<string>; }
function buildOcrTextIndex(textLines: string[]): OcrTextIndex {
const joined = textLines.join(" ").toUpperCase();
return {
squashed: joined.replace(/[^A-Z0-9]/g, ""),
tokens: new Set(tokenizeName(joined))
};
}
function tokenFoundInOcr(token: string, ocr: OcrTextIndex): boolean {
if (token.length >= 4 && ocr.squashed.includes(token)) return true;
if (ocr.tokens.has(token)) return true;
if (token.length >= 5) {
for (const t of ocr.tokens) {
if (Math.abs(t.length - token.length) <= 1 && withinEditDistance1(token, t)) return true;
}
}
return false;
}
// Returns the class name of the best candidate after OCR-evidence
// re-ranking (the classifier's top-1 unless a close band-mate has clearly
// stronger printed-text evidence).
function rerankClassCandidates(
allProbabilities: Array<{ name: string; confidence: number }>,
textLines: string[]
): string {
if (!allProbabilities.length) return "";
const top1Sim = allProbabilities[0].confidence;
const band = allProbabilities
.slice(0, RERANK_TOP_K)
.filter(p => p.confidence >= top1Sim - RERANK_SIM_BAND);
if (band.length <= 1 || !textLines.length) return allProbabilities[0].name;
const ocrIdx = buildOcrTextIndex(textLines);
const cands = band.map(p => {
const sku = classNameSku(p.name);
return { name: p.name, tokens: new Set(tokenizeName(p.name.replace(sku, ""))), coverage: 0 };
});
const tokenCounts = new Map<string, number>();
for (const c of cands) {
for (const tok of c.tokens) tokenCounts.set(tok, (tokenCounts.get(tok) || 0) + 1);
}
for (const c of cands) {
let matched = 0, total = 0;
for (const tok of c.tokens) {
const nWith = tokenCounts.get(tok) || 1;
if (nWith >= cands.length) continue; // shared by all band-mates -> no signal
const w = 1 / nWith;
total += w;
if (tokenFoundInOcr(tok, ocrIdx)) matched += w;
}
c.coverage = total > 0 ? matched / total : 0;
}
let chosen = cands[0];
for (const c of cands.slice(1)) {
if (c.coverage >= chosen.coverage + RERANK_COVERAGE_MARGIN) chosen = c;
}
if (chosen !== cands[0]) {
console.log(`[Rerank] OCR evidence overrode classifier top-1 "${cands[0].name}" -> "${chosen.name}" (coverage ${cands[0].coverage.toFixed(2)} vs ${chosen.coverage.toFixed(2)})`);
}
return chosen.name;
}
// Shared by the classic /api/scan-pfm dev route and the authenticated
// /api/v1/scan-product route: calls the Python classifier, then matches the
// result against sku_master, returning the top-5 candidates.
export async function classifyAndMatchProduct(imageBase64: string): Promise<ProductScanResult> {
const pyServerUrl = process.env.CLASSIFIER_SERVER_URL || "http://paddleocr-pipeline-api:8120/classify-ocr";
const response = await fetch(pyServerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: imageBase64 }),
signal: AbortSignal.timeout(PIPELINE_TIMEOUT_MS)
});
if (!response.ok) {
const errText = await response.text();
throw new ClassifierError(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
const dbRes = await query("SELECT no_sku, nama_item FROM sku_master");
const skuMasterList = dbRes.rows.map(row => ({
no_sku: row.no_sku,
nama_item: row.nama_item
}));
const extractedSku = data.ocr?.extracted_sku || "";
// Re-rank the classifier's close candidates using OCR'd package text, then
// map the winner straight to its sku_master row by the SKU prefix embedded
// in the class name. The old approach (Levenshtein between top-1 class name
// and every master nama_item) lost classifier-correct results whenever a
// *different* SKU's master name happened to be textually closer.
const rerankedName = rerankClassCandidates(
data.classification?.all_probabilities || [],
data.ocr?.text_lines || []
) || data.classification?.top1_name || "";
const rerankedSku = classNameSku(rerankedName);
const matchedList: SkuMatch[] = skuMasterList.map(sku => {
const yoloSim = rerankedName ? getStringSimilarity(sku.nama_item, rerankedName) : 0;
const cleanMasterSku = sku.no_sku.trim();
const cleanExtractedSku = extractedSku.trim();
const isSkuMatch = cleanExtractedSku && cleanMasterSku === cleanExtractedSku;
const isClassifierPick = rerankedSku && cleanMasterSku === rerankedSku;
const score = isSkuMatch ? 1.0 : isClassifierPick ? 0.995 : yoloSim;
return {
no_sku: sku.no_sku,
nama_item: sku.nama_item,
score,
yoloSimilarity: yoloSim,
isBestMatch: false
};
});
matchedList.sort((a, b) => b.score - a.score);
const possibleMatches = matchedList.slice(0, 5).filter(m => m.score > 0.1);
if (possibleMatches.length > 0) {
possibleMatches[0].isBestMatch = true;
}
return {
classification: data.classification,
ocr: data.ocr,
possibleMatches
};
}
import { query } from "../db";
// Bounds the classifier call so a wedged GPU container fails fast instead of
// hanging indefinitely. Raised from 90s (2026-07-14): hard images now
// legitimately take up to ~3 min - a 4-orientation OCR search plus a VL
// pipeline fallback when no expiry date is found (see
// config/classify_ocr_server.py) - and the old bound was killing exactly
// the images those fallbacks exist to save.
const PIPELINE_TIMEOUT_MS = 240_000;
// Thrown when the Python classifier service itself returns a non-2xx response,
// so callers can forward its actual status instead of collapsing everything to 500.
export class ClassifierError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export interface SkuMatch {
no_sku: string;
nama_item: string;
score: number;
yoloSimilarity: number;
isBestMatch: boolean;
}
export interface ProductScanResult {
classification: any;
ocr: any;
possibleMatches: SkuMatch[];
}
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
// --- OCR-evidence re-ranking of the classifier's top-K candidates ---
//
// DINOv2's misses are near-twin confusions (same brand line, different
// flavor/size) - exactly the cases where the printed variant words differ,
// and PaddleOCR usually reads some of them. Within a narrow similarity band
// of the top-1 candidate, prefer the one whose distinctive name tokens
// actually appear in the OCR'd text. Coverage-normalized so generic
// packaging words (e.g. "French Fries", "Ayam") that happen to be unique to
// one candidate's *name* can't hijack the ranking. Parameters tuned offline
// against the 79-image validation set (scripts/experiment-rerank.mjs,
// 2026-07-14: fixes 8 of 18 top-1 misses, breaks 0 of 61 correct).
const RERANK_TOP_K = 12;
const RERANK_SIM_BAND = 0.12;
const RERANK_COVERAGE_MARGIN = 0.25;
function classNameSku(className: string): string {
// foto-kemasan-v2 class names are "<SKU> <NAME...>"
return (className || "").trim().split(/\s+/)[0] || "";
}
function tokenizeName(name: string): string[] {
return name.toUpperCase().split(/[^A-Z0-9]+/).filter(t => t.length >= 2);
}
function withinEditDistance1(a: string, b: string): boolean {
if (a === b) return true;
const la = a.length, lb = b.length;
if (Math.abs(la - lb) > 1) return false;
if (la === lb) {
let diff = 0;
for (let i = 0; i < la; i++) if (a[i] !== b[i]) diff++;
return diff <= 1;
}
const [s, l] = la < lb ? [a, b] : [b, a];
let i = 0, j = 0, skipped = false;
while (i < s.length && j < l.length) {
if (s[i] === l[j]) { i++; j++; }
else if (!skipped) { skipped = true; j++; }
else return false;
}
return true;
}
interface OcrTextIndex { squashed: string; tokens: Set<string>; }
function buildOcrTextIndex(textLines: string[]): OcrTextIndex {
const joined = textLines.join(" ").toUpperCase();
return {
squashed: joined.replace(/[^A-Z0-9]/g, ""),
tokens: new Set(tokenizeName(joined))
};
}
function tokenFoundInOcr(token: string, ocr: OcrTextIndex): boolean {
if (token.length >= 4 && ocr.squashed.includes(token)) return true;
if (ocr.tokens.has(token)) return true;
if (token.length >= 5) {
for (const t of ocr.tokens) {
if (Math.abs(t.length - token.length) <= 1 && withinEditDistance1(token, t)) return true;
}
}
return false;
}
// Returns the class name of the best candidate after OCR-evidence
// re-ranking (the classifier's top-1 unless a close band-mate has clearly
// stronger printed-text evidence).
function rerankClassCandidates(
allProbabilities: Array<{ name: string; confidence: number }>,
textLines: string[]
): string {
if (!allProbabilities.length) return "";
const top1Sim = allProbabilities[0].confidence;
const band = allProbabilities
.slice(0, RERANK_TOP_K)
.filter(p => p.confidence >= top1Sim - RERANK_SIM_BAND);
if (band.length <= 1 || !textLines.length) return allProbabilities[0].name;
const ocrIdx = buildOcrTextIndex(textLines);
const cands = band.map(p => {
const sku = classNameSku(p.name);
return { name: p.name, tokens: new Set(tokenizeName(p.name.replace(sku, ""))), coverage: 0 };
});
const tokenCounts = new Map<string, number>();
for (const c of cands) {
for (const tok of c.tokens) tokenCounts.set(tok, (tokenCounts.get(tok) || 0) + 1);
}
for (const c of cands) {
let matched = 0, total = 0;
for (const tok of c.tokens) {
const nWith = tokenCounts.get(tok) || 1;
if (nWith >= cands.length) continue; // shared by all band-mates -> no signal
const w = 1 / nWith;
total += w;
if (tokenFoundInOcr(tok, ocrIdx)) matched += w;
}
c.coverage = total > 0 ? matched / total : 0;
}
let chosen = cands[0];
for (const c of cands.slice(1)) {
if (c.coverage >= chosen.coverage + RERANK_COVERAGE_MARGIN) chosen = c;
}
if (chosen !== cands[0]) {
console.log(`[Rerank] OCR evidence overrode classifier top-1 "${cands[0].name}" -> "${chosen.name}" (coverage ${cands[0].coverage.toFixed(2)} vs ${chosen.coverage.toFixed(2)})`);
}
return chosen.name;
}
// Shared by the classic /api/scan-pfm dev route and the authenticated
// /api/v1/scan-product route: calls the Python classifier, then matches the
// result against sku_master, returning the top-5 candidates.
export async function classifyAndMatchProduct(imageBase64: string): Promise<ProductScanResult> {
const pyServerUrl = process.env.CLASSIFIER_SERVER_URL || "http://paddleocr-pipeline-api:8120/classify-ocr";
const response = await fetch(pyServerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: imageBase64 }),
signal: AbortSignal.timeout(PIPELINE_TIMEOUT_MS)
});
if (!response.ok) {
const errText = await response.text();
throw new ClassifierError(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
const dbRes = await query("SELECT no_sku, nama_item FROM sku_master");
const skuMasterList = dbRes.rows.map(row => ({
no_sku: row.no_sku,
nama_item: row.nama_item
}));
const extractedSku = data.ocr?.extracted_sku || "";
// Re-rank the classifier's close candidates using OCR'd package text, then
// map the winner straight to its sku_master row by the SKU prefix embedded
// in the class name. The old approach (Levenshtein between top-1 class name
// and every master nama_item) lost classifier-correct results whenever a
// *different* SKU's master name happened to be textually closer.
const rerankedName = rerankClassCandidates(
data.classification?.all_probabilities || [],
data.ocr?.text_lines || []
) || data.classification?.top1_name || "";
const rerankedSku = classNameSku(rerankedName);
const matchedList: SkuMatch[] = skuMasterList.map(sku => {
const yoloSim = rerankedName ? getStringSimilarity(sku.nama_item, rerankedName) : 0;
const cleanMasterSku = sku.no_sku.trim();
const cleanExtractedSku = extractedSku.trim();
const isSkuMatch = cleanExtractedSku && cleanMasterSku === cleanExtractedSku;
const isClassifierPick = rerankedSku && cleanMasterSku === rerankedSku;
const score = isSkuMatch ? 1.0 : isClassifierPick ? 0.995 : yoloSim;
return {
no_sku: sku.no_sku,
nama_item: sku.nama_item,
score,
yoloSimilarity: yoloSim,
isBestMatch: false
};
});
matchedList.sort((a, b) => b.score - a.score);
const possibleMatches = matchedList.slice(0, 5).filter(m => m.score > 0.1);
if (possibleMatches.length > 0) {
possibleMatches[0].isBestMatch = true;
}
return {
classification: data.classification,
ocr: data.ocr,
possibleMatches
};
}
+1 -1
View File
@@ -1 +1 @@
{"filename": "do-015.jpg"}
{"filename": "do-015.jpg"}
+223 -223
View File
@@ -1,223 +1,223 @@
const fs = require('fs');
const path = require('path');
const BASE_URL = 'http://localhost:3000/api/v1';
async function runTests() {
console.log('=== STARTING BACKEND API TDD TESTS (PORT 3000) ===');
let failures = 0;
// Helper for reporting test cases
const assert = (condition, message) => {
if (condition) {
console.log(`[PASS] ${message}`);
} else {
console.error(`[FAIL] ${message}`);
failures++;
}
};
let token = '';
let uploadedDocId = '';
const testFilename = 'test-mobile-upload.jpg';
// Create a mock image file for uploading
const mockImagePath = path.join(__dirname, 'mock_upload.jpg');
fs.writeFileSync(mockImagePath, 'fake-jpeg-content-data');
try {
// -------------------------------------------------------------
// Test 1: POST /auth/login
// -------------------------------------------------------------
console.log('\n--- Test 1: Authentication Login ---');
const loginRes = await fetch(`${BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'password' })
});
assert(loginRes.status === 200, `POST /auth/login status should be 200 (got ${loginRes.status})`);
if (loginRes.ok) {
const loginData = await loginRes.json();
assert(loginData.status === 'success', 'Login response status field should be "success"');
assert(loginData.data && loginData.data.token, 'Login response should contain auth token');
token = loginData.data?.token || '';
} else {
console.log('Skipping login payload asserts due to failed request');
}
// -------------------------------------------------------------
// Test 2: POST /documents/upload
// -------------------------------------------------------------
console.log('\n--- Test 2: Document Upload ---');
const form = new FormData();
const mockFile = new File(['fake-jpeg-content-data'], testFilename, { type: 'image/jpeg' });
form.append('image', mockFile);
form.append('latitude', '-6.2134');
form.append('longitude', '106.8451');
const uploadRes = await fetch(`${BASE_URL}/documents/upload`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: form
});
assert(uploadRes.status === 201, `POST /documents/upload status should be 201 (got ${uploadRes.status})`);
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
assert(uploadData.status === 'success', 'Upload status should be "success"');
assert(uploadData.data && uploadData.data.id, 'Upload response should contain document id');
assert(uploadData.data?.latitude === -6.2134, 'Latitude should be returned correctly');
assert(uploadData.data?.longitude === 106.8451, 'Longitude should be returned correctly');
assert(uploadData.data?.header?.no_do === '', 'Header DO should be empty string initially');
assert(Array.isArray(uploadData.data?.items) && uploadData.data.items.length === 0, 'Items should be empty initially');
uploadedDocId = uploadData.data?.id || '';
} else {
console.log('Skipping upload payload asserts due to failed request');
const errText = await uploadRes.text();
console.log('Upload error response body:', errText);
}
// -------------------------------------------------------------
// -------------------------------------------------------------
// Test 2b: Duplicate Document Upload Creates New Document (Deduplication Disabled)
// -------------------------------------------------------------
console.log('\n--- Test 2b: Duplicate Document Upload Creates New Document ---');
const dupForm = new FormData();
const mockFileDup = new File(['fake-jpeg-content-data'], testFilename, { type: 'image/jpeg' });
dupForm.append('image', mockFileDup);
dupForm.append('latitude', '-6.9999');
dupForm.append('longitude', '107.9999');
const dupUploadRes = await fetch(`${BASE_URL}/documents/upload`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: dupForm
});
assert(dupUploadRes.status === 201, `POST /documents/upload (duplicate) status should be 201 (got ${dupUploadRes.status})`);
if (dupUploadRes.ok) {
const dupUploadData = await dupUploadRes.json();
assert(dupUploadData.status === 'success', 'Duplicate upload status should be "success"');
assert(dupUploadData.data?.id !== uploadedDocId, 'Duplicate upload should return a new document ID (deduplication disabled)');
assert(dupUploadData.data?.latitude === -6.9999, 'New document should return its own latitude (-6.9999)');
assert(dupUploadData.data?.longitude === 107.9999, 'New document should return its own longitude (107.9999)');
} else {
console.log('Skipping duplicate upload asserts due to failed request');
}
// -------------------------------------------------------------
// Test 3: GET /documents (List with polling wait)
// -------------------------------------------------------------
console.log('\n--- Test 3: Documents List (Waiting for background parse) ---');
let found = false;
let retries = 0;
const maxRetries = 15;
let listData;
while (!found && retries < maxRetries) {
if (retries > 0) {
console.log(`Waiting 2s for background parse... (Attempt ${retries}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, 2000));
}
const listRes = await fetch(`${BASE_URL}/documents`, {
method: 'GET',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
});
assert(listRes.status === 200, `GET /documents status should be 200 (got ${listRes.status})`);
if (listRes.ok) {
listData = await listRes.json();
assert(listData.status === 'success', 'List status should be "success"');
assert(Array.isArray(listData.data), 'List data should be an array');
found = listData.data?.some(doc => doc.id.toString() === uploadedDocId.toString());
}
retries++;
}
assert(found, `List should contain the newly uploaded document (id: ${uploadedDocId}) after background parsing`);
if (found && listData) {
const matchedDoc = listData.data?.find(doc => doc.id.toString() === uploadedDocId.toString());
if (matchedDoc) {
assert(matchedDoc.latitude === -6.2134, `Database latitude should remain -6.2134 (got ${matchedDoc.latitude})`);
assert(matchedDoc.longitude === 106.8451, `Database longitude should remain 106.8451 (got ${matchedDoc.longitude})`);
}
}
// -------------------------------------------------------------
// Test 4: PUT /documents/[id] (Confirm / Update)
// -------------------------------------------------------------
console.log('\n--- Test 4: Document Confirm/Update ---');
if (uploadedDocId) {
const updatePayload = {
tanggal: '2026-06-30',
noPo: 'PO-TEST-123',
noSo: 'SO-TEST-456',
noDo: 'DO-TEST-789',
kepadaYth: 'PT. PRIMAFOOD INTERNATIONAL',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'Jl. Ancol Barat VIII/1',
platTruk: 'B 9999 XYZ',
namaDriver: 'Budi',
namaPenerima: 'Andi',
latitude: -6.2134,
longitude: 106.8451,
items: [
{ nomor_sku: '11048006', nama_barang: 'BEBEK PARTING-NEW(*)', banyak: '10 KRG', jumlah: '10' }
]
};
const updateRes = await fetch(`${BASE_URL}/documents/${uploadedDocId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
body: JSON.stringify(updatePayload)
});
assert(updateRes.status === 200, `PUT /documents/${uploadedDocId} status should be 200 (got ${updateRes.status})`);
if (updateRes.ok) {
const updateData = await updateRes.json();
assert(updateData.status === 'success', 'Update status should be "success"');
assert(updateData.data?.header?.no_do === 'DO-TEST-789', 'Header DO should be updated');
assert(updateData.data?.shipment?.nama_penerima === 'Andi', 'Shipment recipient name should be updated');
assert(updateData.data?.items?.length === 1, 'Items array length should be 1');
assert(updateData.data?.items?.[0]?.nomor_sku === '11048006', 'Item SKU should be correctly updated');
} else {
console.log('Skipping update payload asserts due to failed request');
const errText = await updateRes.text();
console.log('Update error response body:', errText);
}
} else {
console.log('Skipping Test 4: No uploadedDocId available');
}
} catch (err) {
console.error('Unhandled exception during tests:', err);
failures++;
} finally {
// Clean up mock image
if (fs.existsSync(mockImagePath)) {
fs.unlinkSync(mockImagePath);
}
}
console.log('\n=== TDD TEST RUN COMPLETED ===');
if (failures === 0) {
console.log('ALL TESTS PASSED SUCCESSFULLY! ✅');
process.exit(0);
} else {
console.error(`${failures} TEST(S) FAILED! ❌`);
process.exit(1);
}
}
runTests();
const fs = require('fs');
const path = require('path');
const BASE_URL = 'http://localhost:3000/api/v1';
async function runTests() {
console.log('=== STARTING BACKEND API TDD TESTS (PORT 3000) ===');
let failures = 0;
// Helper for reporting test cases
const assert = (condition, message) => {
if (condition) {
console.log(`[PASS] ${message}`);
} else {
console.error(`[FAIL] ${message}`);
failures++;
}
};
let token = '';
let uploadedDocId = '';
const testFilename = 'test-mobile-upload.jpg';
// Create a mock image file for uploading
const mockImagePath = path.join(__dirname, 'mock_upload.jpg');
fs.writeFileSync(mockImagePath, 'fake-jpeg-content-data');
try {
// -------------------------------------------------------------
// Test 1: POST /auth/login
// -------------------------------------------------------------
console.log('\n--- Test 1: Authentication Login ---');
const loginRes = await fetch(`${BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'password' })
});
assert(loginRes.status === 200, `POST /auth/login status should be 200 (got ${loginRes.status})`);
if (loginRes.ok) {
const loginData = await loginRes.json();
assert(loginData.status === 'success', 'Login response status field should be "success"');
assert(loginData.data && loginData.data.token, 'Login response should contain auth token');
token = loginData.data?.token || '';
} else {
console.log('Skipping login payload asserts due to failed request');
}
// -------------------------------------------------------------
// Test 2: POST /documents/upload
// -------------------------------------------------------------
console.log('\n--- Test 2: Document Upload ---');
const form = new FormData();
const mockFile = new File(['fake-jpeg-content-data'], testFilename, { type: 'image/jpeg' });
form.append('image', mockFile);
form.append('latitude', '-6.2134');
form.append('longitude', '106.8451');
const uploadRes = await fetch(`${BASE_URL}/documents/upload`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: form
});
assert(uploadRes.status === 201, `POST /documents/upload status should be 201 (got ${uploadRes.status})`);
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
assert(uploadData.status === 'success', 'Upload status should be "success"');
assert(uploadData.data && uploadData.data.id, 'Upload response should contain document id');
assert(uploadData.data?.latitude === -6.2134, 'Latitude should be returned correctly');
assert(uploadData.data?.longitude === 106.8451, 'Longitude should be returned correctly');
assert(uploadData.data?.header?.no_do === '', 'Header DO should be empty string initially');
assert(Array.isArray(uploadData.data?.items) && uploadData.data.items.length === 0, 'Items should be empty initially');
uploadedDocId = uploadData.data?.id || '';
} else {
console.log('Skipping upload payload asserts due to failed request');
const errText = await uploadRes.text();
console.log('Upload error response body:', errText);
}
// -------------------------------------------------------------
// -------------------------------------------------------------
// Test 2b: Duplicate Document Upload Creates New Document (Deduplication Disabled)
// -------------------------------------------------------------
console.log('\n--- Test 2b: Duplicate Document Upload Creates New Document ---');
const dupForm = new FormData();
const mockFileDup = new File(['fake-jpeg-content-data'], testFilename, { type: 'image/jpeg' });
dupForm.append('image', mockFileDup);
dupForm.append('latitude', '-6.9999');
dupForm.append('longitude', '107.9999');
const dupUploadRes = await fetch(`${BASE_URL}/documents/upload`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: dupForm
});
assert(dupUploadRes.status === 201, `POST /documents/upload (duplicate) status should be 201 (got ${dupUploadRes.status})`);
if (dupUploadRes.ok) {
const dupUploadData = await dupUploadRes.json();
assert(dupUploadData.status === 'success', 'Duplicate upload status should be "success"');
assert(dupUploadData.data?.id !== uploadedDocId, 'Duplicate upload should return a new document ID (deduplication disabled)');
assert(dupUploadData.data?.latitude === -6.9999, 'New document should return its own latitude (-6.9999)');
assert(dupUploadData.data?.longitude === 107.9999, 'New document should return its own longitude (107.9999)');
} else {
console.log('Skipping duplicate upload asserts due to failed request');
}
// -------------------------------------------------------------
// Test 3: GET /documents (List with polling wait)
// -------------------------------------------------------------
console.log('\n--- Test 3: Documents List (Waiting for background parse) ---');
let found = false;
let retries = 0;
const maxRetries = 15;
let listData;
while (!found && retries < maxRetries) {
if (retries > 0) {
console.log(`Waiting 2s for background parse... (Attempt ${retries}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, 2000));
}
const listRes = await fetch(`${BASE_URL}/documents`, {
method: 'GET',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
});
assert(listRes.status === 200, `GET /documents status should be 200 (got ${listRes.status})`);
if (listRes.ok) {
listData = await listRes.json();
assert(listData.status === 'success', 'List status should be "success"');
assert(Array.isArray(listData.data), 'List data should be an array');
found = listData.data?.some(doc => doc.id.toString() === uploadedDocId.toString());
}
retries++;
}
assert(found, `List should contain the newly uploaded document (id: ${uploadedDocId}) after background parsing`);
if (found && listData) {
const matchedDoc = listData.data?.find(doc => doc.id.toString() === uploadedDocId.toString());
if (matchedDoc) {
assert(matchedDoc.latitude === -6.2134, `Database latitude should remain -6.2134 (got ${matchedDoc.latitude})`);
assert(matchedDoc.longitude === 106.8451, `Database longitude should remain 106.8451 (got ${matchedDoc.longitude})`);
}
}
// -------------------------------------------------------------
// Test 4: PUT /documents/[id] (Confirm / Update)
// -------------------------------------------------------------
console.log('\n--- Test 4: Document Confirm/Update ---');
if (uploadedDocId) {
const updatePayload = {
tanggal: '2026-06-30',
noPo: 'PO-TEST-123',
noSo: 'SO-TEST-456',
noDo: 'DO-TEST-789',
kepadaYth: 'PT. PRIMAFOOD INTERNATIONAL',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'Jl. Ancol Barat VIII/1',
platTruk: 'B 9999 XYZ',
namaDriver: 'Budi',
namaPenerima: 'Andi',
latitude: -6.2134,
longitude: 106.8451,
items: [
{ nomor_sku: '11048006', nama_barang: 'BEBEK PARTING-NEW(*)', banyak: '10 KRG', jumlah: '10' }
]
};
const updateRes = await fetch(`${BASE_URL}/documents/${uploadedDocId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
body: JSON.stringify(updatePayload)
});
assert(updateRes.status === 200, `PUT /documents/${uploadedDocId} status should be 200 (got ${updateRes.status})`);
if (updateRes.ok) {
const updateData = await updateRes.json();
assert(updateData.status === 'success', 'Update status should be "success"');
assert(updateData.data?.header?.no_do === 'DO-TEST-789', 'Header DO should be updated');
assert(updateData.data?.shipment?.nama_penerima === 'Andi', 'Shipment recipient name should be updated');
assert(updateData.data?.items?.length === 1, 'Items array length should be 1');
assert(updateData.data?.items?.[0]?.nomor_sku === '11048006', 'Item SKU should be correctly updated');
} else {
console.log('Skipping update payload asserts due to failed request');
const errText = await updateRes.text();
console.log('Update error response body:', errText);
}
} else {
console.log('Skipping Test 4: No uploadedDocId available');
}
} catch (err) {
console.error('Unhandled exception during tests:', err);
failures++;
} finally {
// Clean up mock image
if (fs.existsSync(mockImagePath)) {
fs.unlinkSync(mockImagePath);
}
}
console.log('\n=== TDD TEST RUN COMPLETED ===');
if (failures === 0) {
console.log('ALL TESTS PASSED SUCCESSFULLY! ✅');
process.exit(0);
} else {
console.error(`${failures} TEST(S) FAILED! ❌`);
process.exit(1);
}
}
runTests();
+33 -33
View File
@@ -1,33 +1,33 @@
const BASE_URL = 'http://localhost:3000/api/test-correction';
async function runCorrectionTests() {
console.log('=== RUNNING OCR INTELLIGENT CORRECTION & Auto-FILL TESTS ===');
try {
const res = await fetch(BASE_URL);
if (!res.ok) {
console.error(`Failed to hit test endpoint, status: ${res.status}`);
process.exit(1);
}
const data = await res.json();
console.log(`Test suite status: ${data.status.toUpperCase()}`);
for (const result of data.results) {
console.log(result);
}
if (data.status === 'success') {
console.log('\nALL OCR CORRECTION TESTS PASSED SUCCESSFULLY! ✅');
process.exit(0);
} else {
console.error('\nSOME TEST CASES FAILED! ❌');
process.exit(1);
}
} catch (err) {
console.error('Test run error:', err);
process.exit(1);
}
}
runCorrectionTests();
const BASE_URL = 'http://localhost:3000/api/test-correction';
async function runCorrectionTests() {
console.log('=== RUNNING OCR INTELLIGENT CORRECTION & Auto-FILL TESTS ===');
try {
const res = await fetch(BASE_URL);
if (!res.ok) {
console.error(`Failed to hit test endpoint, status: ${res.status}`);
process.exit(1);
}
const data = await res.json();
console.log(`Test suite status: ${data.status.toUpperCase()}`);
for (const result of data.results) {
console.log(result);
}
if (data.status === 'success') {
console.log('\nALL OCR CORRECTION TESTS PASSED SUCCESSFULLY! ✅');
process.exit(0);
} else {
console.error('\nSOME TEST CASES FAILED! ❌');
process.exit(1);
}
} catch (err) {
console.error('Test run error:', err);
process.exit(1);
}
}
runCorrectionTests();
+87 -87
View File
@@ -1,87 +1,87 @@
const http = require("http");
function dockerRequest(path, method, body = null) {
return new Promise((resolve, reject) => {
const options = {
socketPath: "/var/run/docker.sock",
path: path,
method: method,
headers: {
"Content-Type": "application/json",
},
};
const req = http.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(data ? JSON.parse(data) : null);
} catch (e) {
resolve(data);
}
} else {
reject(new Error(`Docker API Error ${res.statusCode}: ${data}`));
}
});
});
req.on("error", (err) => reject(err));
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
async function runExec(containerName, cmd) {
try {
// 1. Create exec instance
const execConfig = {
AttachStdout: true,
AttachStderr: true,
Cmd: cmd,
};
const createRes = await dockerRequest(`/containers/${containerName}/exec`, "POST", execConfig);
const execId = createRes.Id;
// 2. Start exec instance
// Note: Start API returns raw stream, so we use http.request directly to read it
return new Promise((resolve, reject) => {
const options = {
socketPath: "/var/run/docker.sock",
path: `/exec/${execId}/start`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
};
const req = http.request(options, (res) => {
let output = "";
res.on("data", (chunk) => (output += chunk));
res.on("end", () => {
// Docker multiplexes stdout/stderr in the stream.
// First 8 bytes of each frame contain header info: [stream_type, 0, 0, 0, size1, size2, size3, size4]
// For simple outputs, we can clean up non-printable characters or parse directly
resolve(output);
});
});
req.on("error", (err) => reject(err));
req.write(JSON.stringify({ Detach: false, Tty: false }));
req.end();
});
} catch (err) {
throw err;
}
}
async function main() {
const result = await runExec("paddleocr-vllm-server", ["nvidia-smi", "--query-gpu=index,name,utilization.gpu,utilization.memory,memory.total,memory.used,memory.free,uuid", "--format=csv,noheader,nounits"]);
console.log("Exec output:");
console.log(result);
}
main().catch(console.error);
const http = require("http");
function dockerRequest(path, method, body = null) {
return new Promise((resolve, reject) => {
const options = {
socketPath: "/var/run/docker.sock",
path: path,
method: method,
headers: {
"Content-Type": "application/json",
},
};
const req = http.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(data ? JSON.parse(data) : null);
} catch (e) {
resolve(data);
}
} else {
reject(new Error(`Docker API Error ${res.statusCode}: ${data}`));
}
});
});
req.on("error", (err) => reject(err));
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
async function runExec(containerName, cmd) {
try {
// 1. Create exec instance
const execConfig = {
AttachStdout: true,
AttachStderr: true,
Cmd: cmd,
};
const createRes = await dockerRequest(`/containers/${containerName}/exec`, "POST", execConfig);
const execId = createRes.Id;
// 2. Start exec instance
// Note: Start API returns raw stream, so we use http.request directly to read it
return new Promise((resolve, reject) => {
const options = {
socketPath: "/var/run/docker.sock",
path: `/exec/${execId}/start`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
};
const req = http.request(options, (res) => {
let output = "";
res.on("data", (chunk) => (output += chunk));
res.on("end", () => {
// Docker multiplexes stdout/stderr in the stream.
// First 8 bytes of each frame contain header info: [stream_type, 0, 0, 0, size1, size2, size3, size4]
// For simple outputs, we can clean up non-printable characters or parse directly
resolve(output);
});
});
req.on("error", (err) => reject(err));
req.write(JSON.stringify({ Detach: false, Tty: false }));
req.end();
});
} catch (err) {
throw err;
}
}
async function main() {
const result = await runExec("paddleocr-vllm-server", ["nvidia-smi", "--query-gpu=index,name,utilization.gpu,utilization.memory,memory.total,memory.used,memory.free,uuid", "--format=csv,noheader,nounits"]);
console.log("Exec output:");
console.log(result);
}
main().catch(console.error);
+113 -113
View File
@@ -1,113 +1,113 @@
const fs = require('fs');
const path = require('path');
const PROXY_URL = 'http://localhost:8000/api/vllm-proxy/v1/chat/completions';
const IMAGE_PATH = path.join(__dirname, '..', 'sources', 'test-images', 'do-001.jpg');
async function testGuidedDecoding() {
console.log('Reading test image from:', IMAGE_PATH);
if (!fs.existsSync(IMAGE_PATH)) {
console.error('Test image not found!');
process.exit(1);
}
const imageBuffer = fs.readFileSync(IMAGE_PATH);
const base64Image = imageBuffer.toString('base64');
const imageUrl = `data:image/jpeg;base64,${base64Image}`;
// JSON Schema for DO metadata
const jsonSchema = {
type: "object",
properties: {
tanggal: { type: "string" },
noPo: { type: "string" },
noSo: { type: "string" },
noDo: { type: "string" },
kepadaYth: { type: "string" },
orderUntuk: { type: "string" },
alamat: { type: "string" },
platTruk: { type: "string" },
namaDriver: { type: "string" },
namaPenerima: { type: "string" },
items: {
type: "array",
items: {
type: "object",
properties: {
nomor_sku: { type: "string" },
nama_barang: { type: "string" },
banyak: { type: "string" },
jumlah: { type: "string" }
},
required: ["nomor_sku", "nama_barang", "banyak", "jumlah"]
}
}
},
required: ["tanggal", "noPo", "noSo", "noDo", "kepadaYth", "items"]
};
const payload = {
model: "PaddleOCR-VL-1.6-0.9B",
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: imageUrl
}
},
{
type: "text",
text: "Extract all structural details from this Delivery Order. Match the requested JSON Schema exactly."
}
]
}
],
temperature: 0.1,
max_tokens: 1024,
guided_json: JSON.stringify(jsonSchema) // standard vLLM guided JSON schema format
};
console.log('Sending request to vLLM proxy with JSON Schema...');
try {
const startTime = Date.now();
const response = await fetch(PROXY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
console.log(`Response status: ${response.status} (${response.statusText})`);
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`Request completed in ${duration}s`);
const result = await response.json();
if (response.ok) {
console.log('=== SUCCESS RESPONSE ===');
console.log(JSON.stringify(result, null, 2));
const content = result.choices?.[0]?.message?.content;
console.log('\n=== EXTRACTED CONTENT ===');
console.log(content);
try {
const parsed = JSON.parse(content);
console.log('\nValid JSON parsed successfully! ✅');
console.log(parsed);
} catch (err) {
console.error('\nFailed to parse content as JSON! ❌', err.message);
}
} else {
console.error('=== ERROR RESPONSE ===');
console.error(result);
}
} catch (error) {
console.error('Request failed:', error);
}
}
testGuidedDecoding();
const fs = require('fs');
const path = require('path');
const PROXY_URL = 'http://localhost:8000/api/vllm-proxy/v1/chat/completions';
const IMAGE_PATH = path.join(__dirname, '..', 'sources', 'test-images', 'do-001.jpg');
async function testGuidedDecoding() {
console.log('Reading test image from:', IMAGE_PATH);
if (!fs.existsSync(IMAGE_PATH)) {
console.error('Test image not found!');
process.exit(1);
}
const imageBuffer = fs.readFileSync(IMAGE_PATH);
const base64Image = imageBuffer.toString('base64');
const imageUrl = `data:image/jpeg;base64,${base64Image}`;
// JSON Schema for DO metadata
const jsonSchema = {
type: "object",
properties: {
tanggal: { type: "string" },
noPo: { type: "string" },
noSo: { type: "string" },
noDo: { type: "string" },
kepadaYth: { type: "string" },
orderUntuk: { type: "string" },
alamat: { type: "string" },
platTruk: { type: "string" },
namaDriver: { type: "string" },
namaPenerima: { type: "string" },
items: {
type: "array",
items: {
type: "object",
properties: {
nomor_sku: { type: "string" },
nama_barang: { type: "string" },
banyak: { type: "string" },
jumlah: { type: "string" }
},
required: ["nomor_sku", "nama_barang", "banyak", "jumlah"]
}
}
},
required: ["tanggal", "noPo", "noSo", "noDo", "kepadaYth", "items"]
};
const payload = {
model: "PaddleOCR-VL-1.6-0.9B",
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: imageUrl
}
},
{
type: "text",
text: "Extract all structural details from this Delivery Order. Match the requested JSON Schema exactly."
}
]
}
],
temperature: 0.1,
max_tokens: 1024,
guided_json: JSON.stringify(jsonSchema) // standard vLLM guided JSON schema format
};
console.log('Sending request to vLLM proxy with JSON Schema...');
try {
const startTime = Date.now();
const response = await fetch(PROXY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
console.log(`Response status: ${response.status} (${response.statusText})`);
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`Request completed in ${duration}s`);
const result = await response.json();
if (response.ok) {
console.log('=== SUCCESS RESPONSE ===');
console.log(JSON.stringify(result, null, 2));
const content = result.choices?.[0]?.message?.content;
console.log('\n=== EXTRACTED CONTENT ===');
console.log(content);
try {
const parsed = JSON.parse(content);
console.log('\nValid JSON parsed successfully! ✅');
console.log(parsed);
} catch (err) {
console.error('\nFailed to parse content as JSON! ❌', err.message);
}
} else {
console.error('=== ERROR RESPONSE ===');
console.error(result);
}
} catch (error) {
console.error('Request failed:', error);
}
}
testGuidedDecoding();
+109 -109
View File
@@ -1,109 +1,109 @@
const fs = require('fs');
const path = require('path');
const PIPELINE_URL = 'http://localhost:8000/layout-parsing';
const PROXY_URL = 'http://localhost:8000/api/vllm-proxy/v1/chat/completions';
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
const notFoundImages = [
'1782888211457-rotated_1782888204591_rotated_1782888199962_rotated_1782888195402_CAP7202641176939787142.jpg',
'1782875064223-CAP2007290974474760139.jpg',
'1782884586047-CAP9169719214882332189.jpg',
'1782893409879-CAP4330863738757813156.jpg'
];
async function testImage(filename) {
console.log(`\n========================================`);
console.log(`TESTING FILE: ${filename}`);
console.log(`========================================`);
const filePath = path.join(UPLOADS_DIR, filename);
if (!fs.existsSync(filePath)) {
console.error(`File does not exist on disk: ${filePath}`);
return;
}
const fileBuffer = fs.readFileSync(filePath);
const base64Image = fileBuffer.toString('base64');
// Test 1: Hit the Layout Parsing Pipeline API
console.log('\n--- Test 1: Layout Parsing Pipeline (Standard) ---');
try {
const res = await fetch(PIPELINE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file: base64Image,
matchHistoryJob: false,
useLayoutDetection: true,
fileType: 1,
useDocUnwarping: false,
useDocOrientationClassify: true
})
});
if (res.ok) {
const data = await res.json();
const markdown = data.result?.layoutParsingResults?.[0]?.markdown?.text || data.layoutParsingResults?.[0]?.markdown?.text || '';
console.log('Resulting Markdown snippet (first 300 chars):');
console.log(markdown.substring(0, 300));
console.log(`\nDoes it contain PO, SO, DO or Tanggal?`);
console.log(`- "Tanggal": ${/Tanggal/i.test(markdown)}`);
console.log(`- "SO": ${/SO/i.test(markdown)}`);
console.log(`- "DO": ${/DO/i.test(markdown)}`);
console.log(`- "PO": ${/PO/i.test(markdown)}`);
} else {
console.error(`Pipeline returned status ${res.status}: ${await res.text()}`);
}
} catch (err) {
console.error('Pipeline test failed:', err.message);
}
// Test 2: Direct vLLM completions with simple extraction prompt
console.log('\n--- Test 2: Direct vLLM Simple Extraction ---');
try {
const res = await fetch(PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: "PaddleOCR-VL-1.6-0.9B",
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: { url: `data:image/jpeg;base64,${base64Image}` }
},
{
type: "text",
text: "Read the top right section of the document. Extract Tanggal, No. SO, No. DO, and No. PO."
}
]
}
],
temperature: 0.1,
max_tokens: 300
})
});
if (res.ok) {
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
console.log('vLLM Response:');
console.log(content);
} else {
console.error(`vLLM proxy returned status ${res.status}: ${await res.text()}`);
}
} catch (err) {
console.error('vLLM test failed:', err.message);
}
}
async function runAll() {
for (const filename of notFoundImages) {
await testImage(filename);
}
}
runAll();
const fs = require('fs');
const path = require('path');
const PIPELINE_URL = 'http://localhost:8000/layout-parsing';
const PROXY_URL = 'http://localhost:8000/api/vllm-proxy/v1/chat/completions';
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
const notFoundImages = [
'1782888211457-rotated_1782888204591_rotated_1782888199962_rotated_1782888195402_CAP7202641176939787142.jpg',
'1782875064223-CAP2007290974474760139.jpg',
'1782884586047-CAP9169719214882332189.jpg',
'1782893409879-CAP4330863738757813156.jpg'
];
async function testImage(filename) {
console.log(`\n========================================`);
console.log(`TESTING FILE: ${filename}`);
console.log(`========================================`);
const filePath = path.join(UPLOADS_DIR, filename);
if (!fs.existsSync(filePath)) {
console.error(`File does not exist on disk: ${filePath}`);
return;
}
const fileBuffer = fs.readFileSync(filePath);
const base64Image = fileBuffer.toString('base64');
// Test 1: Hit the Layout Parsing Pipeline API
console.log('\n--- Test 1: Layout Parsing Pipeline (Standard) ---');
try {
const res = await fetch(PIPELINE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file: base64Image,
matchHistoryJob: false,
useLayoutDetection: true,
fileType: 1,
useDocUnwarping: false,
useDocOrientationClassify: true
})
});
if (res.ok) {
const data = await res.json();
const markdown = data.result?.layoutParsingResults?.[0]?.markdown?.text || data.layoutParsingResults?.[0]?.markdown?.text || '';
console.log('Resulting Markdown snippet (first 300 chars):');
console.log(markdown.substring(0, 300));
console.log(`\nDoes it contain PO, SO, DO or Tanggal?`);
console.log(`- "Tanggal": ${/Tanggal/i.test(markdown)}`);
console.log(`- "SO": ${/SO/i.test(markdown)}`);
console.log(`- "DO": ${/DO/i.test(markdown)}`);
console.log(`- "PO": ${/PO/i.test(markdown)}`);
} else {
console.error(`Pipeline returned status ${res.status}: ${await res.text()}`);
}
} catch (err) {
console.error('Pipeline test failed:', err.message);
}
// Test 2: Direct vLLM completions with simple extraction prompt
console.log('\n--- Test 2: Direct vLLM Simple Extraction ---');
try {
const res = await fetch(PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: "PaddleOCR-VL-1.6-0.9B",
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: { url: `data:image/jpeg;base64,${base64Image}` }
},
{
type: "text",
text: "Read the top right section of the document. Extract Tanggal, No. SO, No. DO, and No. PO."
}
]
}
],
temperature: 0.1,
max_tokens: 300
})
});
if (res.ok) {
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
console.log('vLLM Response:');
console.log(content);
} else {
console.error(`vLLM proxy returned status ${res.status}: ${await res.text()}`);
}
} catch (err) {
console.error('vLLM test failed:', err.message);
}
}
async function runAll() {
for (const filename of notFoundImages) {
await testImage(filename);
}
}
runAll();
+34 -34
View File
@@ -1,34 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}