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:
1 parent
15566a6951
commit
caf8e98378
315 files changed
+86950
-86950
No files matched your search
@@ -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
|
||||
@@ -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 @@
|
||||
@AGENTS.md
|
||||
@AGENTS.md
|
||||
@@ -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
@@ -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();
|
||||
})();
|
||||
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
Generated
+7298
-7298
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "Scan with AI" 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 "Scan with AI" 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
@@ -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 };
|
||||
+562
-562
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 @@
|
||||
{"filename": "do-015.jpg"}
|
||||
{"filename": "do-015.jpg"}
|
||||
+223
-223
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user