feat: update backend OCR parser, web app, mobile app camera/preview UI, tests, and documentation with sample images
This commit is contained in:
1 parent
aa3233e411
commit
bdb3a49742
71 files changed
+3348
-752
No files matched your search
@@ -10,14 +10,14 @@ use_chart_recognition: False
|
||||
use_seal_recognition: False
|
||||
format_block_content: False
|
||||
merge_layout_blocks: True
|
||||
markdown_ignore_labels:
|
||||
- number
|
||||
- footnote
|
||||
- header
|
||||
- header_image
|
||||
- footer
|
||||
- footer_image
|
||||
- aside_text
|
||||
markdown_ignore_labels: []
|
||||
# - number
|
||||
# - footnote
|
||||
# - header
|
||||
# - header_image
|
||||
# - footer
|
||||
# - footer_image
|
||||
# - aside_text
|
||||
|
||||
SubModules:
|
||||
LayoutDetection:
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ http {
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
server_name _; # accept any host — tunnel URLs, IPs, custom domains
|
||||
|
||||
# Disable body size limit for large image/pdf base64 payloads
|
||||
client_max_body_size 0;
|
||||
|
||||
@@ -0,0 +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();
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["ocr-demo-7871.demoin.id", "*.demoin.id"],
|
||||
// Allow dev requests from any host — needed for tunnel access (ngrok, cloudflare, etc.)
|
||||
// and direct LAN/WiFi IP access from Android devices.
|
||||
allowedDevOrigins: [
|
||||
"*.trycloudflare.com",
|
||||
"*.ngrok.io",
|
||||
"*.ngrok-free.app",
|
||||
"*.ngrok.app",
|
||||
"*.loca.lt",
|
||||
"*.serveo.net",
|
||||
"*.demoin.id",
|
||||
// Common LAN IP ranges (WiFi / hotspot)
|
||||
"192.168.*",
|
||||
"10.*",
|
||||
"172.*",
|
||||
],
|
||||
serverExternalPackages: ["pg"]
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
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 = [
|
||||
"1782870899198-sample_do.jpeg",
|
||||
"1782872141829-rotated_1782872118246_rotated_1782872112296_rotated_1782872107513_CAP3481952560331460805.jpg",
|
||||
"1782875271815-rotated_1782875258464_rotated_1782875254091_rotated_1782875250123_CAP1599226410575173446.jpg",
|
||||
"1782884664859-rotated_1782884661516_rotated_1782884658097_rotated_1782884654697_CAP5956452738616729026.jpg",
|
||||
"1782888127716-CAP6161747431193129837.jpg",
|
||||
"1782888211457-rotated_1782888204591_rotated_1782888199962_rotated_1782888195402_CAP7202641176939787142.jpg",
|
||||
"1782888609885-rotated_1782888593813_1000000454.jpg",
|
||||
"1782890303600-1000000465.jpg",
|
||||
"1782892728794-1000000466.jpg",
|
||||
"1782909791970-sample_do.jpeg",
|
||||
"IMG_20260630_145445.jpg",
|
||||
"IMG_20260701_134446.jpg",
|
||||
"IMG_20260701_134504.jpg",
|
||||
"IMG_20260701_134533.jpg",
|
||||
"IMG_20260701_134555.jpg",
|
||||
"IMG_20260701_134555~2.jpg",
|
||||
"IMG_20260701_134635.jpg",
|
||||
"IMG_20260701_134636.jpg",
|
||||
"IMG_20260701_134646.jpg",
|
||||
"IMG_20260701_134646~2.jpg",
|
||||
"IMG_20260701_134704.jpg",
|
||||
"IMG_20260701_134704~2.jpg",
|
||||
"IMG_20260701_134810.jpg",
|
||||
"IMG_20260701_134817.jpg",
|
||||
"IMG_20260701_135900.jpg",
|
||||
"IMG_20260701_135910.jpg",
|
||||
"IMG_20260701_135923.jpg",
|
||||
"IMG_20260701_135938.jpg",
|
||||
"IMG_20260701_135950.jpg",
|
||||
"IMG_20260701_140005.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,
|
||||
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);
|
||||
}
|
||||
|
||||
console.log('Batch test completed. Report written to /uploads/test_images_report.md');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -5,6 +5,36 @@ import crypto from "crypto";
|
||||
import { query, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
|
||||
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let safeFile = "";
|
||||
try {
|
||||
@@ -33,60 +63,6 @@ export async function POST(req: NextRequest) {
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const fileHash = crypto.createHash("sha256").update(fileBuffer).digest("hex");
|
||||
|
||||
// 2. Check if document exists in database (by filename OR hash) and is already parsed
|
||||
const checkRes = await query(
|
||||
"SELECT id, layout_parsing_result FROM documents WHERE (filename = $1 OR file_hash = $2) AND parsed = true AND layout_parsing_result IS NOT NULL",
|
||||
[safeFile, fileHash]
|
||||
);
|
||||
|
||||
if (checkRes.rowCount && checkRes.rowCount > 0) {
|
||||
const doc = checkRes.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
|
||||
});
|
||||
}
|
||||
|
||||
const b64 = fileBuffer.toString("base64");
|
||||
|
||||
// Form payload
|
||||
@@ -96,7 +72,7 @@ export async function POST(req: NextRequest) {
|
||||
useLayoutDetection: true,
|
||||
fileType: 1,
|
||||
useDocUnwarping: false,
|
||||
useDocOrientationClassify: false
|
||||
useDocOrientationClassify: true
|
||||
};
|
||||
|
||||
// Post to Pipeline API
|
||||
@@ -137,6 +113,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Check if the image is not straight (tilt > 1.0 degree)
|
||||
const tilt = calculateAverageTilt(data);
|
||||
let unwarped = false;
|
||||
if (tilt > 1.0) {
|
||||
console.log(`Parsed document ${safeFile} is not straight (average tilt: ${tilt.toFixed(2)} deg). Re-running with unwarping and orientation classification enabled...`);
|
||||
const unwarpPayload = {
|
||||
@@ -154,6 +131,7 @@ export async function POST(req: NextRequest) {
|
||||
if (unwarpResponse.ok) {
|
||||
data = await unwarpResponse.json();
|
||||
console.log(`Document unwarped successfully.`);
|
||||
unwarped = true;
|
||||
} else {
|
||||
console.error(`Unwarping failed with status ${unwarpResponse.status}`);
|
||||
}
|
||||
@@ -167,12 +145,151 @@ export async function POST(req: NextRequest) {
|
||||
// 3. Save to 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 rawMetadata = parseDOMetadata(markdownText);
|
||||
// Second-layer sanity check: enforces strict field formats and auto-corrects anomalies
|
||||
const docMetadata = sanitizeParsedMetadata(rawMetadata as any);
|
||||
|
||||
// Load SKU Master list from DB (including new packaging details for triple check validation)
|
||||
const skuDbRes = await query("SELECT no_sku, nama_item, standar_jumlah, jenis_outer FROM sku_master");
|
||||
const skuMasterList = skuDbRes.rows.map(row => ({
|
||||
no_sku: row.no_sku.toString().trim(),
|
||||
nama_item: row.nama_item.toString().trim(),
|
||||
standar_jumlah: row.standar_jumlah ? row.standar_jumlah.toString().trim() : null,
|
||||
jenis_outer: row.jenis_outer ? row.jenis_outer.toString().trim() : null
|
||||
}));
|
||||
|
||||
// Cross-check items using intelligent Triple-Check (SKU, Name, and Unit)
|
||||
const checkedItems: typeof docMetadata.items = [];
|
||||
for (const item of docMetadata.items) {
|
||||
const ocrSku = item.kodeBarang ? item.kodeBarang.trim() : "";
|
||||
const ocrName = item.namaBarang ? item.namaBarang.trim() : "";
|
||||
const ocrQty = item.banyak ? item.banyak.trim() : "";
|
||||
const ocrPrice = item.jumlah ? item.jumlah.trim() : "";
|
||||
|
||||
// Extract alphabetic characters as unit symbols
|
||||
const qtyUnitMatch = ocrQty.match(/[a-zA-Z]+/g);
|
||||
const qtyUnit = qtyUnitMatch ? qtyUnitMatch.join(" ") : "";
|
||||
|
||||
const priceUnitMatch = ocrPrice.match(/[a-zA-Z]+/g);
|
||||
const priceUnit = priceUnitMatch ? priceUnitMatch.join(" ") : "";
|
||||
|
||||
// Remember the original parsed SKU for traceability
|
||||
(item as any).kodeBarangOriginal = ocrSku;
|
||||
|
||||
// Find the best master candidate using a weighted combined score
|
||||
let bestMatch: typeof skuMasterList[0] | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
// First, check if there is an exact SKU match. If yes, trust the SKU completely and bypass fuzzy matching.
|
||||
const exactSkuMatch = skuMasterList.find(c => ocrSku === c.no_sku);
|
||||
if (exactSkuMatch) {
|
||||
bestMatch = exactSkuMatch;
|
||||
bestScore = 1.0;
|
||||
} else {
|
||||
for (const candidate of skuMasterList) {
|
||||
// 1. SKU Similarity (weight = 0.5)
|
||||
let skuSim = 0;
|
||||
if (ocrSku === candidate.no_sku) {
|
||||
skuSim = 1.0;
|
||||
} else if (ocrSku && /^\d+$/.test(ocrSku.replace(/[^0-9]/g, ''))) {
|
||||
const cleanOcrSku = ocrSku.replace(/[^0-9]/g, '');
|
||||
skuSim = getStringSimilarity(cleanOcrSku, candidate.no_sku);
|
||||
}
|
||||
|
||||
// 2. Name Similarity (weight = 0.4)
|
||||
const nameSim = getStringSimilarity(ocrName, candidate.nama_item);
|
||||
|
||||
// 3. Packaging Unit Match Bonus (weight = 0.1)
|
||||
let unitBonus = 0;
|
||||
let matchesOuter = false;
|
||||
let matchesInner = false;
|
||||
|
||||
const cleanQtyUnit = qtyUnit.toLowerCase();
|
||||
const cleanPriceUnit = priceUnit.toLowerCase();
|
||||
|
||||
if (candidate.jenis_outer) {
|
||||
const cOuter = candidate.jenis_outer.toLowerCase();
|
||||
if (cleanQtyUnit.includes(cOuter) ||
|
||||
(cOuter === "karung" && cleanQtyUnit.includes("krg")) ||
|
||||
(cOuter === "box" && cleanQtyUnit.includes("box")) ||
|
||||
(cOuter === "bag" && cleanQtyUnit.includes("bag"))) {
|
||||
matchesOuter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.standar_jumlah) {
|
||||
const cInner = candidate.standar_jumlah.toLowerCase();
|
||||
if (cleanPriceUnit.includes(cInner) ||
|
||||
(cInner === "pac" && cleanPriceUnit.includes("pai")) ||
|
||||
(cInner === "pc" && cleanPriceUnit.includes("pc")) ||
|
||||
(cInner === "kg" && cleanPriceUnit.includes("kg"))) {
|
||||
matchesInner = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchesOuter) unitBonus += 0.5;
|
||||
if (matchesInner) unitBonus += 0.5;
|
||||
|
||||
const score = (0.5 * skuSim) + (0.4 * nameSim) + (0.1 * unitBonus);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && bestScore >= 0.6) {
|
||||
// Triple-check matched! Correct the SKU and description to master values,
|
||||
// and auto-standardize the quantities units!
|
||||
item.kodeBarang = bestMatch.no_sku;
|
||||
item.namaBarang = bestMatch.nama_item;
|
||||
|
||||
// Auto-standardize Qty (banyak) units
|
||||
const numericQtyMatch = ocrQty.match(/[\d.,]+/);
|
||||
if (numericQtyMatch) {
|
||||
const numericQty = numericQtyMatch[0];
|
||||
const standardOuter = bestMatch.jenis_outer || "";
|
||||
if (standardOuter.toLowerCase() === "karung") {
|
||||
item.banyak = `${numericQty} KRG`;
|
||||
} else if (standardOuter.toLowerCase() === "box") {
|
||||
item.banyak = `${numericQty} BOX`;
|
||||
} else if (standardOuter.toLowerCase() === "bag") {
|
||||
item.banyak = `${numericQty} BAG`;
|
||||
} else {
|
||||
item.banyak = `${numericQty} ${standardOuter.toUpperCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-standardize Price/Total (jumlah) units
|
||||
const numericPriceMatch = ocrPrice.match(/[\d.,]+/);
|
||||
if (numericPriceMatch) {
|
||||
const numericPrice = numericPriceMatch[0];
|
||||
const standardInner = (bestMatch.standar_jumlah || "").toUpperCase();
|
||||
item.jumlah = `${numericPrice} ${standardInner}`;
|
||||
}
|
||||
|
||||
checkedItems.push(item);
|
||||
} else {
|
||||
// Case 3: No match by SKU/Name/Unit triple check.
|
||||
// If the original SKU is a valid 8-digit code, keep it (to allow new SKUs not yet in master list).
|
||||
// Otherwise, discard the row entirely to filter out layout parsing table noise (like signatures).
|
||||
if (/^\d{8}$/.test(ocrSku)) {
|
||||
checkedItems.push(item);
|
||||
} else {
|
||||
console.log(`Filtering out table noise row: SKU="${ocrSku}", Name="${ocrName}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
docMetadata.items = checkedItems;
|
||||
|
||||
// Resolve store information using master database
|
||||
const resolvedStore = await resolveStoreFromText(markdownText);
|
||||
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
|
||||
@@ -180,9 +297,30 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
const logsPayload = {
|
||||
filename: safeFile,
|
||||
vllm_calls: [] as any[],
|
||||
ocr_raw: pipelineResult,
|
||||
stage_1_output: docMetadata,
|
||||
stage_2_output: docMetadata,
|
||||
frontend_response: {
|
||||
filename: safeFile,
|
||||
result: {
|
||||
errorCode: 0,
|
||||
errorMsg: "Success",
|
||||
result: pipelineResult
|
||||
}
|
||||
},
|
||||
pipeline_info: {
|
||||
tilt,
|
||||
unwarped,
|
||||
original_tilt: tilt
|
||||
}
|
||||
};
|
||||
|
||||
const insertDocRes = await query(`
|
||||
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
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)
|
||||
ON CONFLICT (filename) DO UPDATE
|
||||
SET upload_time = EXCLUDED.upload_time,
|
||||
size = EXCLUDED.size,
|
||||
@@ -190,7 +328,8 @@ export async function POST(req: NextRequest) {
|
||||
metadata = EXCLUDED.metadata,
|
||||
layout_parsing_result = EXCLUDED.layout_parsing_result,
|
||||
is_sample = EXCLUDED.is_sample,
|
||||
file_hash = EXCLUDED.file_hash
|
||||
file_hash = EXCLUDED.file_hash,
|
||||
processing_logs = EXCLUDED.processing_logs
|
||||
RETURNING id
|
||||
`, [
|
||||
safeFile,
|
||||
@@ -200,7 +339,8 @@ export async function POST(req: NextRequest) {
|
||||
JSON.stringify(docMetadata),
|
||||
JSON.stringify(pipelineResult),
|
||||
isSample,
|
||||
fileHash
|
||||
fileHash,
|
||||
JSON.stringify(logsPayload)
|
||||
]);
|
||||
|
||||
const docId = insertDocRes.rows[0].id;
|
||||
@@ -219,10 +359,11 @@ export async function POST(req: NextRequest) {
|
||||
jumlah_original, jumlah,
|
||||
is_flagged, remark
|
||||
)
|
||||
VALUES ($1, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
|
||||
`, [
|
||||
docId,
|
||||
i,
|
||||
(item as any).kodeBarangOriginal || item.kodeBarang,
|
||||
item.kodeBarang,
|
||||
item.namaBarang,
|
||||
item.banyak,
|
||||
|
||||
@@ -0,0 +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
|
||||
});
|
||||
}
|
||||
@@ -35,29 +35,7 @@ export async function POST(req: NextRequest) {
|
||||
// Compute hash to check for duplicate content
|
||||
const fileHash = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
// Check if same content already exists in database
|
||||
const dupRes = await query(
|
||||
"SELECT filename, layout_parsing_result FROM documents WHERE file_hash = $1",
|
||||
[fileHash]
|
||||
);
|
||||
|
||||
if (dupRes.rowCount && dupRes.rowCount > 0) {
|
||||
const existingDoc = dupRes.rows[0];
|
||||
console.log(`Uploaded file matches existing database record (file_hash: ${fileHash}). Reusing existing file: ${existingDoc.filename}`);
|
||||
|
||||
// Reconstruct full response wrapping to match fresh API response
|
||||
const wrappedResult = {
|
||||
errorCode: 0,
|
||||
errorMsg: "Success",
|
||||
result: existingDoc.layout_parsing_result
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
filename: existingDoc.filename,
|
||||
result: wrappedResult,
|
||||
alreadyExists: true
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
@@ -71,7 +49,7 @@ export async function POST(req: NextRequest) {
|
||||
useLayoutDetection: true,
|
||||
fileType: 1,
|
||||
useDocUnwarping: false,
|
||||
useDocOrientationClassify: false
|
||||
useDocOrientationClassify: true
|
||||
};
|
||||
|
||||
const pipelineUrl = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
|
||||
@@ -87,7 +65,7 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearActiveLog();
|
||||
clearActiveLog(filename);
|
||||
const errText = await response.text();
|
||||
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
|
||||
}
|
||||
@@ -96,6 +74,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// 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 = {
|
||||
@@ -113,6 +92,7 @@ export async function POST(req: NextRequest) {
|
||||
if (unwarpResponse.ok) {
|
||||
data = await unwarpResponse.json();
|
||||
console.log(`Document unwarped successfully.`);
|
||||
unwarped = true;
|
||||
} else {
|
||||
console.error(`Unwarping failed with status ${unwarpResponse.status}`);
|
||||
}
|
||||
@@ -125,6 +105,12 @@ export async function POST(req: NextRequest) {
|
||||
// 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);
|
||||
@@ -149,16 +135,21 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
|
||||
// Retrieve and finalize active log data
|
||||
const activeLog = getActiveLog();
|
||||
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;
|
||||
logsPayload = activeLog;
|
||||
activeLog.pipeline_info = {
|
||||
tilt,
|
||||
unwarped,
|
||||
original_tilt: tilt
|
||||
};
|
||||
logsPayload = { ...activeLog };
|
||||
}
|
||||
clearActiveLog();
|
||||
clearActiveLog(filename);
|
||||
|
||||
const insertDocRes = await query(`
|
||||
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs)
|
||||
|
||||
@@ -49,42 +49,24 @@ export async function POST(req: NextRequest) {
|
||||
const latitude = latVal ? parseFloat(latVal.toString()) : null;
|
||||
const longitude = lngVal ? parseFloat(lngVal.toString()) : null;
|
||||
|
||||
// Check if the exact file content already exists in DB
|
||||
const dupRes = await query(
|
||||
"SELECT id, filename, latitude, longitude, metadata FROM documents WHERE file_hash = $1 AND is_sample = false",
|
||||
[fileHash]
|
||||
);
|
||||
|
||||
let docId: number;
|
||||
let finalFilename = filename;
|
||||
|
||||
if (dupRes.rowCount && dupRes.rowCount > 0) {
|
||||
const existingDoc = dupRes.rows[0];
|
||||
docId = existingDoc.id;
|
||||
finalFilename = existingDoc.filename;
|
||||
console.log(`Reusing existing document record (id: ${docId}) for hash match.`);
|
||||
|
||||
// Clean up the newly written file since we are reusing the existing one
|
||||
if (fs.existsSync(filePath) && finalFilename !== filename) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} else {
|
||||
const insertRes = await query(`
|
||||
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`, [
|
||||
filename,
|
||||
new Date(),
|
||||
buffer.length,
|
||||
false,
|
||||
false,
|
||||
fileHash,
|
||||
latitude,
|
||||
longitude
|
||||
]);
|
||||
docId = insertRes.rows[0].id;
|
||||
}
|
||||
const insertRes = await query(`
|
||||
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`, [
|
||||
filename,
|
||||
new Date(),
|
||||
buffer.length,
|
||||
false,
|
||||
false,
|
||||
fileHash,
|
||||
latitude,
|
||||
longitude
|
||||
]);
|
||||
docId = insertRes.rows[0].id;
|
||||
|
||||
// Trigger parsing synchronously to ensure it is processed immediately on receiving the image
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { logVllmCall } from "../../../../utils/active-log";
|
||||
import { logVllmCallToAll } from "../../../../utils/active-log";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -87,7 +87,7 @@ async function handleProxy(req: NextRequest) {
|
||||
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);
|
||||
logVllmCall(cleanReq, resBody);
|
||||
logVllmCallToAll(cleanReq, resBody);
|
||||
}
|
||||
|
||||
// Return the response back to pipeline-api
|
||||
|
||||
@@ -218,7 +218,38 @@ export default function Home() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: `2. vLLM Prompt ("Before")`,
|
||||
title: "2. Pipeline OCR (Auto-Deskew & Raw JSON)",
|
||||
description: "Inspect Pipeline API response & deskew actions",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-teal-400">Pipeline API Processing Info (B)</h4>
|
||||
{(() => {
|
||||
const res = selectedDetails?.result || selectedLogs?.ocr_raw;
|
||||
const info = res?.pipeline_info || selectedLogs?.pipeline_info;
|
||||
return (
|
||||
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs space-y-2 text-slate-300">
|
||||
<p><span className="text-slate-500">Average Tilt Detected:</span> {info?.tilt !== undefined ? `${parseFloat(info.tilt).toFixed(2)}°` : "N/A"}</p>
|
||||
<p><span className="text-slate-500">Unwarping / Deskew Triggered:</span> {info?.unwarped !== undefined ? (info.unwarped ? "Yes (Tilt > 1.0°)" : "No (Tilt <= 1.0°)") : "N/A"}</p>
|
||||
<p><span className="text-slate-500">Original Tilt:</span> {info?.original_tilt !== undefined ? `${parseFloat(info.original_tilt).toFixed(2)}°` : "N/A"}</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<h4 className="text-sm font-semibold text-teal-400">Raw Pipeline Response JSON</h4>
|
||||
{selectedDetails?.result || selectedLogs?.ocr_raw ? (
|
||||
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
|
||||
<pre>{JSON.stringify(selectedDetails?.result || selectedLogs?.ocr_raw, null, 2)}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
|
||||
No raw pipeline results parsed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: `3. vLLM Prompt ("Before")`,
|
||||
description: `Inspect prompt parameters & system role (${vllmCallsCount} call${vllmCallsCount > 1 ? "s" : ""})`,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
@@ -244,7 +275,7 @@ export default function Home() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: `3. vLLM Response ("After")`,
|
||||
title: `4. vLLM Response ("After")`,
|
||||
description: "Inspect raw output returned by VLM",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
@@ -270,7 +301,7 @@ export default function Home() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "4. Raw OCR Layout Markdown",
|
||||
title: "5. Raw OCR Layout Markdown",
|
||||
description: "View parsed layout markdown structure",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
@@ -296,7 +327,7 @@ export default function Home() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "5. Stage 1 Extracted Fields",
|
||||
title: "6. Stage 1 Extracted Fields",
|
||||
description: "View output of regex schema matching",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
@@ -314,7 +345,7 @@ export default function Home() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "6. Stage 2 Sanitized Output",
|
||||
title: "7. Stage 2 Sanitized Output",
|
||||
description: "View results after validation checks",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
@@ -332,7 +363,7 @@ export default function Home() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "7. Client Gateway Response",
|
||||
title: "8. Client Gateway Response",
|
||||
description: "Inspect JSON payload sent to frontends",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
@@ -674,7 +705,7 @@ export default function Home() {
|
||||
Layer Detail view
|
||||
</h4>
|
||||
<span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-full font-mono font-semibold">
|
||||
Layer {activeStep + 1} of 7
|
||||
Layer {activeStep + 1} of {steps.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,37 +15,65 @@ export interface ActiveUploadLog {
|
||||
frontend_response?: any;
|
||||
}
|
||||
|
||||
// Store active log in global context to persist across Next.js hot-reloads
|
||||
// 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 {
|
||||
activeLog: ActiveUploadLog | null;
|
||||
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.activeLog = {
|
||||
globalForActiveLog.activeLogs.set(filename, {
|
||||
filename,
|
||||
vllm_calls: []
|
||||
};
|
||||
});
|
||||
console.log(`[ActiveLog] Started tracking log for ${filename}`);
|
||||
}
|
||||
|
||||
export function logVllmCall(request: any, response: any) {
|
||||
if (globalForActiveLog.activeLog) {
|
||||
globalForActiveLog.activeLog.vllm_calls.push({
|
||||
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 ${globalForActiveLog.activeLog.filename} (total calls: ${globalForActiveLog.activeLog.vllm_calls.length})`);
|
||||
console.log(`[ActiveLog] Logged vLLM call for ${filename} (total calls: ${log.vllm_calls.length})`);
|
||||
} else {
|
||||
console.log("[ActiveLog] Warning: Attempted to log vLLM call but no active log session is running.");
|
||||
console.log(`[ActiveLog] Warning: Attempted to log vLLM call for "${filename}" but no active log session is running.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveLog(): ActiveUploadLog | null {
|
||||
return globalForActiveLog.activeLog;
|
||||
export function getActiveLog(filename: string): ActiveUploadLog | null {
|
||||
return globalForActiveLog.activeLogs.get(filename) || null;
|
||||
}
|
||||
|
||||
export function clearActiveLog() {
|
||||
globalForActiveLog.activeLog = null;
|
||||
console.log("[ActiveLog] Cleared active log tracking context");
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
@@ -18,31 +18,30 @@ function cleanFinalValue(val: string, preserveNewlines = false): string {
|
||||
function cleanAndFormatPO(raw: string, currentYearLastTwo: string): string {
|
||||
if (!raw || raw === "Not Found") return "Not Found";
|
||||
|
||||
// Strip leading label noise like "No. PO : " before matching
|
||||
// Strip leading label noise like "No. PO : " before matching, allowing common visual confusions
|
||||
const stripped = raw
|
||||
.replace(/^No\.?\s*PO\s*[:\-]?\s*/i, "")
|
||||
.replace(/^No\.?\s*(?:PO|P0|07|70|F0|O0)\s*[:\-]?\s*/i, "")
|
||||
.trim();
|
||||
|
||||
// Pattern 1: Any form with at least one slash — PO/26/nnn, F0/20/nnn, PO120/nnn
|
||||
// ALWAYS use currentYearLastTwo — never trust OCR year (can be corrupted)
|
||||
// Pattern 1: Any form with at least one slash — PO/26/nnn, F0/20/nnn, PO120/nnn, 07/26/nnn
|
||||
// Structure: [PREFIX][optional_noise_digits][/][optional_year_segment][/]?[NUMBER]
|
||||
// We find the LAST slash and take everything after it as the real number
|
||||
const withSlash = /^(?:PO|P0|F0|O0|Q0|D0|A0|B0|R0|S0)\d*[ \t]*[\/\-][ \t]*(?:\d{0,4}[ \t]*[\/\-][ \t]*)?(\d{4,})/i;
|
||||
const withSlash = /^(?:[A-Z0-9]{1,4})[ \t]*[\/\-][ \t]*(?:\d{1,4}[ \t]*[\/\-][ \t]*)?(\d{4,})/i;
|
||||
const m1 = stripped.match(withSlash);
|
||||
if (m1) {
|
||||
return `PO/${currentYearLastTwo}/${m1[1]}`;
|
||||
}
|
||||
|
||||
// Pattern 2: No slashes — OCR fused: PO12070000190729 or F012070000170727
|
||||
// Pattern 2: No slashes — OCR fused: PO12070000190729 or F012070000170727, 7012010000100029
|
||||
// Structure: [PREFIX][digits_with_noise][real_number_starting_0000]
|
||||
const noSlash = /^(?:PO|P0|F0|O0|Q0|D0|A0|B0|R0|S0)(\d+)$/i;
|
||||
const noSlash = /^(?:PO|P0|F0|O0|Q0|D0|A0|B0|R0|S0|07|70|11|17|76|0|7)(\d+)$/i;
|
||||
const m2 = stripped.match(noSlash);
|
||||
if (m2) {
|
||||
const digits = m2[1];
|
||||
// Real PO number starts with 0000 in observed patterns
|
||||
const numberPart = digits.replace(/^\d{2,4}(0{4}\d+)$/, "$1");
|
||||
if (numberPart && numberPart !== digits) {
|
||||
return `PO/${currentYearLastTwo}/${numberPart}`;
|
||||
// Real PO number starts with 0000 (or 000, 00)
|
||||
const matchNum = digits.match(/(0{2,}\d+)$/);
|
||||
if (matchNum) {
|
||||
return `PO/${currentYearLastTwo}/${matchNum[1]}`;
|
||||
}
|
||||
// Fallback: strip up to 4 leading noise digits
|
||||
const fallbackDigits = digits.replace(/^\d{2,4}/, "");
|
||||
@@ -52,8 +51,12 @@ function cleanAndFormatPO(raw: string, currentYearLastTwo: string): string {
|
||||
return `PO/${currentYearLastTwo}/${digits}`;
|
||||
}
|
||||
|
||||
// Pattern 3: Just a raw number (8+ digits) — not a valid PO format
|
||||
// Pattern 3: Just a raw number (8+ digits) — not a valid PO format unless it has 0000
|
||||
if (/^\d{8,}$/.test(stripped)) {
|
||||
const matchNum = stripped.match(/(0{2,}\d+)$/);
|
||||
if (matchNum) {
|
||||
return `PO/${currentYearLastTwo}/${matchNum[1]}`;
|
||||
}
|
||||
return "Not Found";
|
||||
}
|
||||
|
||||
@@ -70,26 +73,79 @@ function getYearFromDate(dateStr: string): string {
|
||||
return new Date().getFullYear().toString().slice(-2);
|
||||
}
|
||||
|
||||
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 || raw === "Not Found") return "Not Found";
|
||||
if (!raw) return "Not Found";
|
||||
const cleaned = raw.trim();
|
||||
if (cleaned === "Not Found" || cleaned === "") return "Not Found";
|
||||
|
||||
// Enforce dd Month yyyy pattern (digits, month letters, year digits)
|
||||
// Permissive of various spacing/dashes/slashes
|
||||
const pattern = /\b(\d{1,2})[ \t\-\/]*(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)([a-zA-Z]*)[ \t\-\/]*(\d{4})\b/i;
|
||||
const match = raw.match(pattern);
|
||||
if (match) {
|
||||
const day = match[1];
|
||||
const month = match[2] + match[3];
|
||||
const year = match[4];
|
||||
const today = new Date();
|
||||
let day: number | null = null;
|
||||
let monthStr: string | null = null;
|
||||
let year: number | null = null;
|
||||
|
||||
// Capitalize month first letter, keep rest lowercase (e.g. May, June)
|
||||
const formattedMonth = month.charAt(0).toUpperCase() + month.slice(1).toLowerCase();
|
||||
|
||||
return `${day} ${formattedMonth} ${year}`;
|
||||
// 1. Try to find 4-digit year (2010 to 2035)
|
||||
const yearMatch = cleaned.match(/\b(20\d{2})\b/);
|
||||
if (yearMatch) {
|
||||
const parsedYear = parseInt(yearMatch[1], 10);
|
||||
if (parsedYear >= 2010 && parsedYear <= 2035) {
|
||||
year = parsedYear;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If no standard date pattern is found, return Not Found
|
||||
return "Not Found";
|
||||
// 2. Try to find month using keywords
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try to find 1 or 2 digit day (not part of the year)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback fill-in from current date
|
||||
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 function parseDOMetadata(markdown: string) {
|
||||
@@ -363,185 +419,232 @@ export function parseDOMetadata(markdown: string) {
|
||||
metadata.noSO = cleanFinalValue(metadata.noSO);
|
||||
metadata.noDO = cleanFinalValue(metadata.noDO);
|
||||
|
||||
// Always use current year for fused (no-slash) PO patterns — OCR corrupts year digits
|
||||
const currentYearLastTwo = new Date().getFullYear().toString().slice(-2);
|
||||
metadata.noPO = cleanAndFormatPO(cleanFinalValue(metadata.noPO), currentYearLastTwo);
|
||||
// Extract PO year dynamically from parsed document date (or default to current year if date not found)
|
||||
const docYearLastTwo = getYearFromDate(metadata.tanggal);
|
||||
metadata.noPO = cleanAndFormatPO(cleanFinalValue(metadata.noPO), docYearLastTwo);
|
||||
|
||||
// Parse HTML tables for items
|
||||
const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/g;
|
||||
let match;
|
||||
while ((match = tableRegex.exec(markdown)) !== null) {
|
||||
const tableHtml = match[1];
|
||||
// Parse all raw rows and cells first to get td details, including rowspan and colspan
|
||||
const trRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
|
||||
const tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
||||
|
||||
interface CellInfo {
|
||||
text: string;
|
||||
rowspan: number;
|
||||
colspan: number;
|
||||
}
|
||||
const rawRows: CellInfo[][] = [];
|
||||
|
||||
let trMatch;
|
||||
let rowIndex = 0;
|
||||
while ((trMatch = trRegex.exec(tableHtml)) !== null) {
|
||||
const rowHtml = trMatch[1];
|
||||
const rowCells: CellInfo[] = [];
|
||||
let tdMatch;
|
||||
while ((tdMatch = tdRegex.exec(rowHtml)) !== null) {
|
||||
const tdHtml = tdMatch[0];
|
||||
const cellContent = tdMatch[1];
|
||||
|
||||
const rsMatch = tdHtml.match(/rowspan=["']?(\d+)["']?/i);
|
||||
const rowspan = rsMatch ? parseInt(rsMatch[1], 10) : 1;
|
||||
|
||||
const csMatch = tdHtml.match(/colspan=["']?(\d+)["']?/i);
|
||||
const colspan = csMatch ? parseInt(csMatch[1], 10) : 1;
|
||||
|
||||
const cellText = cellContent.replace(/<[^>]*>/g, "").trim().replace(/\\n/g, "\n");
|
||||
|
||||
rowCells.push({
|
||||
text: cellText,
|
||||
rowspan,
|
||||
colspan
|
||||
});
|
||||
}
|
||||
if (rowCells.length > 0) {
|
||||
rawRows.push(rowCells);
|
||||
}
|
||||
}
|
||||
|
||||
if (rawRows.length === 0) continue;
|
||||
|
||||
// Determine the maximum columns in the grid
|
||||
let maxCols = 0;
|
||||
for (const cell of rawRows[0]) {
|
||||
maxCols += cell.colspan;
|
||||
}
|
||||
|
||||
const numRows = rawRows.length;
|
||||
const grid: string[][] = Array.from({ length: numRows }, () => new Array(maxCols).fill(""));
|
||||
|
||||
// Fill the grid, respecting rowspan and colspan
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
const rowCells = rawRows[r];
|
||||
let cellIndex = 0;
|
||||
|
||||
for (let c = 0; c < maxCols; c++) {
|
||||
// Skip if already filled
|
||||
if (grid[r][c] !== "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cellIndex >= rowCells.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const cell = rowCells[cellIndex++];
|
||||
const lines = cell.text.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
|
||||
for (let dr = 0; dr < cell.rowspan; dr++) {
|
||||
if (r + dr >= numRows) break;
|
||||
|
||||
for (let dc = 0; dc < cell.colspan; dc++) {
|
||||
if (c + dc >= maxCols) break;
|
||||
|
||||
let cellValue = cell.text;
|
||||
if (cell.rowspan > 1 && lines.length > 0) {
|
||||
cellValue = lines[dr] ?? lines[lines.length - 1] ?? "";
|
||||
}
|
||||
|
||||
grid[r + dr][c + dc] = cellValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the grid
|
||||
let kIdx = 0;
|
||||
let nIdx = 1;
|
||||
let bIdx = 2;
|
||||
let jIdx = 3;
|
||||
let isItemsTable = false;
|
||||
|
||||
while ((trMatch = trRegex.exec(tableHtml)) !== null) {
|
||||
const rowHtml = trMatch[1];
|
||||
if (rowIndex === 0) {
|
||||
// Parse header row
|
||||
const tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
||||
let tdMatch;
|
||||
const headerCells: string[] = [];
|
||||
while ((tdMatch = tdRegex.exec(rowHtml)) !== null) {
|
||||
headerCells.push(tdMatch[1].replace(/<[^>]*>/g, "").trim().toLowerCase());
|
||||
// Check headers in grid[0]
|
||||
const headerCells = grid[0].map(h => h.toLowerCase());
|
||||
const foundKode = headerCells.findIndex(h => h.includes("kode") || h.includes("item code"));
|
||||
const foundNama = headerCells.findIndex(h => h.includes("nama") || h.includes("item name") || h.includes("description"));
|
||||
const foundBanyak = headerCells.findIndex(h => h.includes("banyak") || h.includes("qty") || h.includes("quantity"));
|
||||
const foundJumlah = headerCells.findIndex(h => h.includes("jumlah") || h.includes("total"));
|
||||
|
||||
if (foundKode !== -1 || foundNama !== -1) {
|
||||
isItemsTable = true;
|
||||
kIdx = foundKode !== -1 ? foundKode : 0;
|
||||
nIdx = foundNama !== -1 ? foundNama : 1;
|
||||
bIdx = foundBanyak !== -1 ? foundBanyak : 2;
|
||||
jIdx = foundJumlah !== -1 ? foundJumlah : 3;
|
||||
}
|
||||
|
||||
if (isItemsTable) {
|
||||
for (let r = 1; r < numRows; r++) {
|
||||
const cells = grid[r];
|
||||
const kodeCell = cells[kIdx] || "";
|
||||
const namaCell = cells[nIdx] || "";
|
||||
let banyakCell = "";
|
||||
let jumlahCell = "";
|
||||
|
||||
// Check if there is an extra column before banyak that we should merge with banyak
|
||||
if (bIdx > 2 && bIdx - 1 !== nIdx && bIdx - 1 !== kIdx) {
|
||||
const qtyCell = cells[bIdx - 1] || "";
|
||||
const unitCell = cells[bIdx] || "";
|
||||
banyakCell = `${qtyCell} ${unitCell}`.trim();
|
||||
} else {
|
||||
banyakCell = cells[bIdx] || "";
|
||||
}
|
||||
|
||||
const foundKode = headerCells.findIndex(h => h.includes("kode") || h.includes("item code"));
|
||||
const foundNama = headerCells.findIndex(h => h.includes("nama") || h.includes("item name") || h.includes("description"));
|
||||
const foundBanyak = headerCells.findIndex(h => h.includes("banyak") || h.includes("qty") || h.includes("quantity"));
|
||||
const foundJumlah = headerCells.findIndex(h => h.includes("jumlah") || h.includes("total"));
|
||||
|
||||
if (foundKode !== -1 || foundNama !== -1) {
|
||||
isItemsTable = true;
|
||||
kIdx = foundKode !== -1 ? foundKode : 0;
|
||||
nIdx = foundNama !== -1 ? foundNama : 1;
|
||||
bIdx = foundBanyak !== -1 ? foundBanyak : 2;
|
||||
jIdx = foundJumlah !== -1 ? foundJumlah : 3;
|
||||
if (jIdx !== -1) {
|
||||
jumlahCell = cells[jIdx] || "";
|
||||
} else {
|
||||
if (cells.length === 5 && bIdx === 3) {
|
||||
jumlahCell = cells[4] || "";
|
||||
} else {
|
||||
jumlahCell = cells[3] || "";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isItemsTable) {
|
||||
const tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
||||
let tdMatch;
|
||||
const cells: string[] = [];
|
||||
while ((tdMatch = tdRegex.exec(rowHtml)) !== null) {
|
||||
// Normalize literal \n text if returned as literal string "\n"
|
||||
const cellText = tdMatch[1].replace(/<[^>]*>/g, "").trim().replace(/\\n/g, "\n");
|
||||
cells.push(cellText);
|
||||
|
||||
// Split cell contents by newlines to support combined rows
|
||||
const kodeParts = kodeCell.split("\n").map(p => p.trim()).filter(Boolean);
|
||||
const namaParts = namaCell.split("\n").map(p => p.trim()).filter(Boolean);
|
||||
const banyakParts = banyakCell.split("\n").map(p => p.trim()).filter(Boolean);
|
||||
const jumlahParts = jumlahCell.split("\n").map(p => p.trim()).filter(Boolean);
|
||||
|
||||
const isWatermark = (s: string) => {
|
||||
const sl = s.toLowerCase();
|
||||
return (
|
||||
sl === "asli" ||
|
||||
sl === "copy" ||
|
||||
sl === "nama barang" ||
|
||||
sl === "tanda tangan supir" ||
|
||||
sl === "penerima barang" ||
|
||||
sl === "barang dikirim dalam keadaan baik" ||
|
||||
sl === "jumlah"
|
||||
);
|
||||
};
|
||||
|
||||
// Filter watermark keywords from each parts array
|
||||
const cleanKodes = kodeParts.filter(p => !isWatermark(p));
|
||||
const cleanNamas = namaParts.filter(p => !isWatermark(p));
|
||||
let cleanBanyaks = banyakParts.filter(p => !isWatermark(p));
|
||||
const cleanJumlahs = jumlahParts.filter(p => !isWatermark(p));
|
||||
|
||||
if (cleanBanyaks.length === 2 * cleanKodes.length) {
|
||||
const halved: string[] = [];
|
||||
const half = cleanKodes.length;
|
||||
for (let i = 0; i < half; i++) {
|
||||
const qty = cleanBanyaks[i] || "";
|
||||
const unit = cleanBanyaks[i + half] || "";
|
||||
halved.push(`${qty} ${unit}`.trim());
|
||||
}
|
||||
if (cells.length >= 3) {
|
||||
const kodeCell = cells[kIdx] || "";
|
||||
const namaCell = cells[nIdx] || "";
|
||||
let banyakCell = "";
|
||||
let jumlahCell = "";
|
||||
cleanBanyaks = halved;
|
||||
}
|
||||
|
||||
// Check if there is an extra column before banyak that we should merge with banyak
|
||||
if (bIdx > 2 && bIdx - 1 !== nIdx && bIdx - 1 !== kIdx) {
|
||||
const qtyCell = cells[bIdx - 1] || "";
|
||||
const unitCell = cells[bIdx] || "";
|
||||
const maxLen = Math.max(cleanKodes.length, cleanNamas.length, cleanBanyaks.length, cleanJumlahs.length);
|
||||
|
||||
const qtyLines = qtyCell.split("\n").map(l => l.trim());
|
||||
const unitLines = unitCell.split("\n").map(l => l.trim());
|
||||
const combinedLines: string[] = [];
|
||||
const maxQLen = Math.max(qtyLines.length, unitLines.length);
|
||||
for (let idx = 0; idx < maxQLen; idx++) {
|
||||
let q = qtyLines[idx] || "";
|
||||
const u = unitLines[idx] || "";
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const k = cleanKodes[i] || "";
|
||||
const n = cleanNamas[i] || "";
|
||||
let b = cleanBanyaks[i] || "";
|
||||
const j = cleanJumlahs[i] || "";
|
||||
|
||||
// Default to "1" if quantity is missing for a valid item row
|
||||
const numItems = kodeCell.split("\n").map(p => p.trim()).filter(Boolean).length;
|
||||
if (!q && idx < numItems) {
|
||||
q = "1";
|
||||
}
|
||||
if (isWatermark(k) || isWatermark(n)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
combinedLines.push(`${q} ${u}`.trim());
|
||||
}
|
||||
banyakCell = combinedLines.join("\n");
|
||||
} else {
|
||||
banyakCell = cells[bIdx] || "";
|
||||
}
|
||||
// Clean checkmarks and extra spaces from banyak
|
||||
b = b.replace(/[✓☑]/g, "").replace(/\s+/g, " ").trim();
|
||||
|
||||
if (jIdx !== -1) {
|
||||
jumlahCell = cells[jIdx] || "";
|
||||
} else {
|
||||
if (cells.length === 5 && bIdx === 3) {
|
||||
jumlahCell = cells[4] || "";
|
||||
} else {
|
||||
jumlahCell = cells[3] || "";
|
||||
}
|
||||
}
|
||||
// Fallback for Banyak if empty or purely alphabetical unit
|
||||
if (!b) {
|
||||
b = "1";
|
||||
} else if (/^[a-zA-Z]+$/.test(b)) {
|
||||
b = `1 ${b}`;
|
||||
}
|
||||
|
||||
// Split cell contents by newlines to support combined rows
|
||||
const kodeParts = kodeCell.split("\n").map(p => p.trim()).filter(Boolean);
|
||||
const namaParts = namaCell.split("\n").map(p => p.trim()).filter(Boolean);
|
||||
const banyakParts = banyakCell.split("\n").map(p => p.trim()).filter(Boolean);
|
||||
const jumlahParts = jumlahCell.split("\n").map(p => p.trim()).filter(Boolean);
|
||||
|
||||
const isWatermark = (s: string) => {
|
||||
const sl = s.toLowerCase();
|
||||
return (
|
||||
sl === "asli" ||
|
||||
sl === "copy" ||
|
||||
sl === "nama barang" ||
|
||||
sl === "tanda tangan supir" ||
|
||||
sl === "penerima barang" ||
|
||||
sl === "barang dikirim dalam keadaan baik" ||
|
||||
sl === "jumlah"
|
||||
);
|
||||
};
|
||||
|
||||
// Filter watermark keywords from each parts array
|
||||
const cleanKodes = kodeParts.filter(p => !isWatermark(p));
|
||||
const cleanNamas = namaParts.filter(p => !isWatermark(p));
|
||||
let cleanBanyaks = banyakParts.filter(p => !isWatermark(p));
|
||||
const cleanJumlahs = jumlahParts.filter(p => !isWatermark(p));
|
||||
|
||||
if (cleanBanyaks.length === 2 * cleanKodes.length) {
|
||||
const halved: string[] = [];
|
||||
const half = cleanKodes.length;
|
||||
for (let i = 0; i < half; i++) {
|
||||
const qty = cleanBanyaks[i] || "";
|
||||
const unit = cleanBanyaks[i + half] || "";
|
||||
halved.push(`${qty} ${unit}`.trim());
|
||||
}
|
||||
cleanBanyaks = halved;
|
||||
}
|
||||
|
||||
const maxLen = Math.max(cleanKodes.length, cleanNamas.length, cleanBanyaks.length, cleanJumlahs.length);
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const k = cleanKodes[i] || "";
|
||||
const n = cleanNamas[i] || "";
|
||||
let b = cleanBanyaks[i] || "";
|
||||
const j = cleanJumlahs[i] || "";
|
||||
|
||||
if (isWatermark(k) || isWatermark(n)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clean checkmarks and extra spaces from banyak
|
||||
b = b.replace(/[✓☑]/g, "").replace(/\s+/g, " ").trim();
|
||||
|
||||
// Fallback for Banyak if empty or purely alphabetical unit
|
||||
if (!b) {
|
||||
b = "1";
|
||||
} else if (/^[a-zA-Z]+$/.test(b)) {
|
||||
b = `1 ${b}`;
|
||||
}
|
||||
|
||||
// Autocomplete packaging units if Banyak is purely numeric
|
||||
if (b && /^\d+$/.test(b)) {
|
||||
const code = k.trim();
|
||||
const name = n.toLowerCase();
|
||||
if (code === "11310024" || name.includes("griller")) {
|
||||
b = `${b} KRG`;
|
||||
} else if (code === "11640053" || name.includes("bone in leg") || name.includes("pack")) {
|
||||
b = `${b} BAG`;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate kodeBarang: must not be blank and must match exactly 8 digits
|
||||
const cleanKode = k.trim();
|
||||
if (cleanKode === "" || !/^\d{8}$/.test(cleanKode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.items.push({
|
||||
kodeBarang: k,
|
||||
namaBarang: n,
|
||||
banyak: b,
|
||||
jumlah: j
|
||||
});
|
||||
// Autocomplete packaging units if Banyak is purely numeric
|
||||
if (b && /^\d+$/.test(b)) {
|
||||
const code = k.trim();
|
||||
const name = n.toLowerCase();
|
||||
if (code === "11310024" || name.includes("griller")) {
|
||||
b = `${b} KRG`;
|
||||
} else if (code === "11640053" || name.includes("bone in leg") || name.includes("pack")) {
|
||||
b = `${b} BAG`;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanKode = k.trim();
|
||||
const cleanNama = n.trim();
|
||||
if (cleanKode === "" && cleanNama === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.items.push({
|
||||
kodeBarang: k,
|
||||
namaBarang: n,
|
||||
banyak: b,
|
||||
jumlah: j
|
||||
});
|
||||
}
|
||||
}
|
||||
rowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,6 +718,44 @@ function formatPlatNumber(raw: string): string {
|
||||
const VALID_MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||
const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
|
||||
export function correctVisualDigits(val: string): string {
|
||||
if (!val || val === "Not Found") return "";
|
||||
let cleaned = val.trim();
|
||||
|
||||
const parts = cleaned.split(/[^a-zA-Z0-9]+/);
|
||||
let bestPart = parts[0] || "";
|
||||
let maxDigitsCount = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
const digitsCount = (part.match(/[0-9]/g) || []).length;
|
||||
if (digitsCount > maxDigitsCount) {
|
||||
maxDigitsCount = digitsCount;
|
||||
bestPart = part;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDigitsCount === 0) {
|
||||
let maxLen = 0;
|
||||
for (const part of parts) {
|
||||
if (part.length > maxLen) {
|
||||
maxLen = part.length;
|
||||
bestPart = part;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bestPart = bestPart
|
||||
.replace(/[Oo]/g, "0")
|
||||
.replace(/[Ii|l]/g, "1")
|
||||
.replace(/[Bb]/g, "8")
|
||||
.replace(/[Ss]/g, "5")
|
||||
.replace(/[Zz]/g, "2")
|
||||
.replace(/[Gg]/g, "9")
|
||||
.replace(/[^0-9]/g, "");
|
||||
|
||||
return bestPart;
|
||||
}
|
||||
|
||||
export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata> & Record<string, any>): typeof meta {
|
||||
const currentYY = new Date().getFullYear().toString().slice(-2);
|
||||
const currentFullYear = new Date().getFullYear();
|
||||
@@ -641,22 +782,26 @@ export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata>
|
||||
}
|
||||
|
||||
// --- noPO ---
|
||||
// Must match PO/YY/NNNN+ where YY = current year, NNNN = 4+ digits
|
||||
// If year segment doesn't match current year, auto-correct it (parser already forces current year,
|
||||
// but this is a safety net in case anything slipped through)
|
||||
// Get year from parsed date (or default to current year if date not found)
|
||||
const docYY = result.tanggal && result.tanggal !== "Not Found" ? getYearFromDate(result.tanggal) : currentYY;
|
||||
|
||||
// --- noPO ---
|
||||
// Must match PO/YY/NNNN+ where YY is the document-specific year segment, NNNN = 4+ digits
|
||||
const noPO = (result.noPO || "").trim();
|
||||
const poPattern = /^PO\/(\d{2})\/(\d{4,})$/i;
|
||||
const pm = noPO.match(poPattern);
|
||||
if (pm) {
|
||||
// Auto-correct year to current year regardless of what was parsed
|
||||
result.noPO = `PO/${currentYY}/${pm[2]}`;
|
||||
// Keep the parsed year segment if it matches docYY, or fall back to docYY
|
||||
const yearSegment = pm[1] === docYY ? pm[1] : docYY;
|
||||
result.noPO = `PO/${yearSegment}/${pm[2]}`;
|
||||
} else {
|
||||
result.noPO = "Not Found";
|
||||
}
|
||||
|
||||
// --- noSO ---
|
||||
// Must be numeric string, 7-12 digits
|
||||
const noSO = (result.noSO || "").trim();
|
||||
let noSO = (result.noSO || "").trim();
|
||||
noSO = correctVisualDigits(noSO);
|
||||
if (/^\d{7,12}$/.test(noSO)) {
|
||||
result.noSO = noSO;
|
||||
} else {
|
||||
@@ -665,7 +810,8 @@ export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata>
|
||||
|
||||
// --- noDO ---
|
||||
// Must be numeric string, 7-12 digits
|
||||
const noDO = (result.noDO || "").trim();
|
||||
let noDO = (result.noDO || "").trim();
|
||||
noDO = correctVisualDigits(noDO);
|
||||
if (/^\d{7,12}$/.test(noDO)) {
|
||||
result.noDO = noDO;
|
||||
} else {
|
||||
|
||||
@@ -80,6 +80,35 @@ async function runTests() {
|
||||
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)
|
||||
// -------------------------------------------------------------
|
||||
@@ -113,6 +142,14 @@ async function runTests() {
|
||||
|
||||
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)
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +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();
|
||||
@@ -0,0 +1,230 @@
|
||||
PT. Primafood International-PFM Jakarta
|
||||
Rekap SKU Aktif CPI Cikande
|
||||
Per April 2026
|
||||
|
||||
No No SKU Nama Item Standar Jumlah Berat per kemasan (kg) Isi per outer (kg) Isi per outer (pac) Jenis Outer (karung/box)
|
||||
1 11110059 CEKER BERKUKU FROZEN PACK 1 KG(*) bag 1 10 10 Box
|
||||
2 11110074 CEKER 1 KG FROZEN (20 PAC/KARUNG)(*) PAC 1 20 20 Karung
|
||||
3 11140051 AMPELA FROZEN PACK 1 KG(*) kg 1 10 10 Box
|
||||
4 11140062 AMPELA 1 KG FROZEN (20 PAC/KARUNG)(*) PAC 1 20 20 Karung
|
||||
5 11150052 HATI FROZEN PACK 1 KG(*) kg 1 10 10 Karung
|
||||
6 11150055 JANTUNG FROZEN PACK 1 KG(*) kg 1 10 10 Karung
|
||||
7 11150064 HATI 1 KG FROZEN (20 PAC/KARUNG)(*) PAC 1 20 20 Karung
|
||||
8 11150065 JANTUNG 1 KG FROZEN (20 PAC/KARUNG)(*) PAC 1 20 20 Karung
|
||||
9 11310012 AYAM SIZE 0 (0.6-0.7)KG(*) PC 0.65 26 40 Karung
|
||||
10 11310013 AYAM SIZE1 FROZEN (0.75-0.8) KG(*) PC 0.75 22.5 30 Karung
|
||||
11 11310014 AYAM SIZE 2 FROZEN (0.8-0.9)KG(*) PC 0.85 17 20 Karung
|
||||
12 11310016 AYAM SIZE Z PR FROZ(*) PC 0.58 23.2 40 Karung
|
||||
13 11310017 AYAM SIZE 0 PR FROZEN(*) PC 0.65 26 40 Karung
|
||||
14 11310018 AYAM SIZE 1 PR FROZEN(*) PC 0.75 22.5 30 Karung
|
||||
15 11310019 AYAM SIZE 2 PR FROZEN(*) PC 0.85 17 20 Karung
|
||||
16 11310021 AYAM SIZE BESAR (B) FROZ (1-1.1)KG/PC(*) PC 1.05 21 20 Karung
|
||||
17 11310022 AYAM SIZE A PR (0.9-1)KG/PC(*) PC 0.95 19 20 Karung
|
||||
18 11310024 AYAM SIZE A FROZEN (0.9-1)KG/PC(*) PC 0.95 19 20 Karung
|
||||
19 11310025 AYAM SIZE SUPER (C) FROZ(1.1-1.2)KG/ PC(*) PC 1.15 23 20 Karung
|
||||
20 11310026 AYAM SIZE JUMBO (D) FROZ (1.2- 1.3)KG/PC(*) pc 1.25 20 16 Karung
|
||||
21 11410043 PARTING 10 SIZE D FRESH BENSU 1.25 KG/PAC(*) PAC 1.25 12.5 10 Bag
|
||||
22 11420055 PARTING 12 ALL SIZE FROZ/PAC(*) PAC 1 10 10 Box
|
||||
23 11600053 BONELESS LEG FROZEN 1 KG(*) kg 1 10 10 Box
|
||||
24 11620056 SBL (FILLET PAHA) 1 KG(*) kg 1 10 10 Box
|
||||
25 11640053 PAHA UTUH (1 KG)(*) PAC 1 10 10 Box
|
||||
26 11650053 PAHA ATAS 1 KG(*) kg 1 10 10 Box
|
||||
27 11660050 PAHA BAWAH (1 KG)(*) PAC 1 10 10 Box
|
||||
28 11690053 SBB (FILLET DADA )1 KG(*) kg 1 10 10 Box
|
||||
29 11690081 SBB JUMBO FZ (2.0 - 2.2 KG/PAC)(*) PAC 2 20 10 Karung
|
||||
30 11710051 DADA UTUH (1 KG)(*) kg 1 10 10 Box
|
||||
31 11720055 FULL WING FROZ PACK 1 KG(*) kg 1 10 10 Box
|
||||
32 11730050 MIDDLE WING FROZ PACK 1 KG(*) PAC 1 10 10 Box
|
||||
33 11750050 FILLET MITRA 1 KG(*) KG 1 10 10 Box
|
||||
34 11840002 AYAM JANTAN BKKL SZ 0 (600-700) GR/PC(*) PC 0 0 0 Karung
|
||||
35 11959937 SATE AYAM FRESHMART 360 GR (PAC) PAC 0.36 7.2 20 Box
|
||||
36 12010111 FIESTA CRISPY BUBBLE 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
37 12010112 FIESTA CHICKEN NUGGET 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
38 12010113 FIESTA CHICKEN NUGGET 200 GR/PAC PAC 0.2 4.8 24 Box
|
||||
39 12010115 FIESTA NUGGET ZOO 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
40 12010116 FIESTA NUGGET DINO 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
41 12010117 FIESTA NUGGET HAPPY STAR 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
42 12010119 FIESTA NUGGET CHEESE 123 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
43 12010121 FIESTA NUGGET PIZZABC 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
44 12010122 FIESTA CHEESY LOVER 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
45 12010123 FIESTA GARLIC CHEESE 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
46 12010124 FIESTA CHEESY CHIC W/BROCCOLI 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
47 12010127 FIESTA SPICY NUGGET 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
48 12010128 FIESTA VOLCANO CHEESE 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
49 12010129 FIESTA CHEESY BOMBS CHICKEN NUGGET 400 GR PAC 0.4 4.8 12 Box
|
||||
50 12010402 GOLDEN FIESTA NUGGET W/PINEAPPLE SAUCE 500 GR PAC 0.5 5 10 Box
|
||||
51 12010509 CHAMP CRUNCHY NUGGET 450 GR/PAC PAC 0.45 4.5 10 Box
|
||||
52 12010510 CHAMP NUGGET AYAM 225 GR/PAC PAC 0.225 4.95 22 Box
|
||||
53 12010511 CHAMP NUGGET AYAM 450 GR/PAC PAC 0.45 5.4 12 Box
|
||||
54 12010512 CHAMP NUGGET AYAM 900 GR/PAC PAC 0.9 5.4 6 Box
|
||||
55 12010513 CHAMP NUGGET ABC KOMBINASI 225 GR/PAC PAC 0.225 4.95 22 Box
|
||||
56 12010514 CHAMP NUGGET ABC KOMBINASI 450 GR/PAC PAC 0.45 5.4 12 Box
|
||||
57 12010515 CHAMP KOIN KOMBINASI 450 GR/PAC PAC 0.45 5.4 12 Box
|
||||
58 12010516 CHAMP KOIN KOMBINASI 200 GR/PAC PAC 0.2 6 30 Box
|
||||
59 12010517 CHAMP NUGGET STICK 225 GR/PAC PAC 0.225 4.95 22 Box
|
||||
60 12010518 CHAMP NUGGET STICK 450 GR/PAC PAC 0.45 5.4 12 Box
|
||||
61 12010519 CHAMP NUGGET STICK 900 GR/PAC PAC 0.9 5.4 6 Box
|
||||
62 12010520 CHAMP CHICKEN NUGGET BENTUK 123 450 GR/PAC PAC 0.45 5.4 12 Box
|
||||
63 12010521 CHAMP NUGGET HOTZZ LEVEL 5 450 GR/PAC PAC 0.45 5.4 12 Box
|
||||
64 12010606 CHAMP CRUNCHY NUGGET 225 GR/PAC PAC 0.225 5.625 25 Box
|
||||
65 12010707 CHAMP MITRA NUGGET COIN 200 GR (NEW) PAC 0.2 6 30 Box
|
||||
66 12010801 OKEY NUGGET 500GR PAC 0.5 5 10 Box
|
||||
67 12012201 ASIMO NUGGET KOMBINASI 500 GR/PAC PAC 0.5 5 10 Box
|
||||
68 12012202 ASIMO NUGGET KOMBINASI 1 KG/PAC PAC 1 5 5 Box
|
||||
69 12012203 ASIMO NUGGET KOMBINASI 250 GR/PAC PAC 0.25 5 20 Box
|
||||
70 12012501 AKUMO CHICKEN NAGET 250 GR PAC 0.25 5 20 Box
|
||||
71 12012502 AKUMO CHICKEN NUGGET 500 GR PAC 0.5 5 10 Box
|
||||
72 12012503 AKUMO CHICKEN NUGGET 1000 GR PAC 1 5 5 Box
|
||||
73 12012504 AKUMO COIN 200 GR/PAC pack 0.2 6 30 Box
|
||||
74 12012505 AKUMO KOIN 400 GR/PAC PAC 0.4 6 15 Box
|
||||
75 12020102 FIESTA SPICY WING 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
76 12020401 GOLDEN FIESTA SP WING 500 GR PAC 0.5 5 10 Box
|
||||
77 12030101 FIESTA STIKIE 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
78 12030102 FIESTA STIKIE 200 GR/PAC PAC 0.2 4.8 24 Box
|
||||
79 12030403 GOLDEN FIESTA STIKIE W/ SWEET CHILLI SAUCE 500GR PAC 0.5 5 10 Box
|
||||
80 12030801 OKEY STICK 1000 GR PAC 1 5 5 Box
|
||||
81 12030802 OKEY STICK 500GR PAC 0.5 5 10 Box
|
||||
82 12032201 ASIMO STICK KOMBINASI 500 GR/PAC PAC 0.5 5 10 Box
|
||||
83 12032202 ASIMO STICK KOMBINASI 1000 GR/PAC PAC 1 5 5 Box
|
||||
84 12032203 ASIMO STIK KOMBINASI 250 GR/PAC PAC 0.25 5 20 Box
|
||||
85 12032501 AKUMO CHICKEN STICK 250 GR PAC 0.25 5 20 Box
|
||||
86 12032502 AKUMO CHICKEN STIK 500 GR PAC 0.5 5 10 Box
|
||||
87 12032503 AKUMO CHICKEN STICK 1000 GR PAC 1 5 5 Box
|
||||
88 12040101 FIESTA SCHNITZEL 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
89 12040102 FIESTA CRISPY BUBBLE KATSU 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
90 12040404 GOLDEN FIESTA CORDON BLEU BBQ SAUCE 500 GR PAC 0.5 5 10 Box
|
||||
91 12040406 GOLDEN FIESTA KATSU W/CHEESE SAUCE 500 GR/PAC PAC 0.5 5 10 Box
|
||||
92 12050103 FIESTA FRIED CHICKEN 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
93 12050104 FIESTA HOT & CRISPY FRIED CHICKEN 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
94 12050401 GOLDEN FIESTA CRISPY WING W/SP GLAZ SC 500 GR/PAC PAC 0.5 5 10 Box
|
||||
95 12060103 FIESTA KARAGE 200 GR/PAC PAC 0.2 4.8 24 Box
|
||||
96 12060104 FIESTA KARAGE 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
97 12060105 FIESTA SPICY KARAGE 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
98 12060402 GOLDEN FIESTA KARAGE CHILI SAUCE 500GR PAC 0.5 5 10 Box
|
||||
99 12070101 FIESTA POK-POK 400 GR/PAC (NEW) PAC 0.4 4.8 12 Box
|
||||
100 12080101 FIESTA SPICY CHICK 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
101 12130102 FIESTA CRISPY BURGER 360 GR (NEW) PAC 0.36 7.2 20 Box
|
||||
102 12130504 CHAMP BURGER 315 GR (NEW) PAC 0.315 6.3 20 Box
|
||||
103 12140105 FIESTA CHICK TOFU 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
104 12150201 FIESTA DS CRISPY CRUNCH 300 GR/PAC PAC 0.3 3 10 Box
|
||||
105 12150501 CHAMP CRUNCHY HOTZZ 300 GR/PAC PAC 0.3 3 10 Box
|
||||
106 12190103 FIESTA DELISTRIPE 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
107 12240102 FIESTA CHEESY ITALIAN R/BITES 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
108 12240103 FIESTA YAKINIKU R/BITES 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
109 13010101 FIESTA CHICK SSG 300 GR PAC 0.3 6 20 Box
|
||||
110 13010102 FIESTA CHICK SSG 500 GR PAC 0.5 5 10 Box
|
||||
111 13010103 FIESTA CHICK SSG 200 GR/PAC PAC 0.2 5 25 Box
|
||||
112 13010111 FIESTA SOSIS BRATWURST 300 GR PAC 0.3 6 20 Box
|
||||
113 13010112 FIESTA CHEESE SSG 300 GR PAC 0.3 6 20 Box
|
||||
114 13010113 FIESTA SOSIS CURRYWURST 300 GR PAC 0.3 6 20 Box
|
||||
115 13010114 FIESTA SSG BOCKWURST 300GR PAC 0.3 6 20 Box
|
||||
116 13010115 FIESTA SSG WIENER 300GR PAC 0.3 6 20 Box
|
||||
117 13010116 FIESTA SSG ORIGINAL 300 GR PAC 0.3 6 20 Box
|
||||
118 13010117 FIESTA SSG FRANKFURTER 300GR PAC 0.3 6 20 Box
|
||||
119 13010118 FIESTA RTG SSG 65 GR/PAC PAC 0.065 4.68 72 Box
|
||||
120 13010119 FIESTA RTG C/SPICY KOREAN 60 GR/PAC PAC 0.06 4.32 72 Box
|
||||
121 13010120 FIESTA RTG C/CHEESY MELTS 65 GR/PAC PAC 0.065 4.68 72 Box
|
||||
122 13010122 FIESTA RTG SAUSAGE WITH HOT LAVA 60G PAC 0.06 4.32 72 Box
|
||||
123 13010123 FIESTA RTG SAUSAGE WITH CHEESE LAVA 60G PAC 0.06 4.32 72 Box
|
||||
124 13010124 FIESTA RTG SAUSAGE HICKORY SAUCE 60GR PAC 0.06 4.32 72 Box
|
||||
125 13010125 FIESTA RTG SAUSAGE WITH MENTAI LAVA 60GR PAC 0.06 4.32 72 Box
|
||||
126 13010510 CHAMP CHICK SSG 75 GR PAC 0.075 6 80 Box
|
||||
127 13010513 CHAMP CHICK SSG 375 GR PAC 0.375 5.625 15 Box
|
||||
128 13010514 CHAMP CHICK SSG 1000 GR PAC 1 10 10 Box
|
||||
129 13010518 CHAMP SSG JUMBO BAKAR 500 GR/PAC PAC 0.5 5 10 Box
|
||||
130 13010519 CHAMP SSG BAKAR MINI 500 GR/PAC-INACT PAC 0.5 5 10 Box
|
||||
131 13010521 CHAMP CHICK SSG 150 GR/PAC (NEW) PAC 0.15 6.75 45 Box
|
||||
132 13010523 CHAMP CHICK SSG AYAM MADU 300 GR/PAC PAC 0.3 6 20 Box
|
||||
133 13010524 CHAMP SSG JUMBO BAKAR 500 GR/PAC (NEW) PAC 0.5 5 10 Box
|
||||
134 13010525 CHAMP SSG BAKAR MINI 500 GR/PAC (NEW) PAC 0.5 5 10 Box
|
||||
135 13010809 OKEY CHICK SSG 500GR-INACT PAC 0.5 10 20 Box
|
||||
136 13010815 OKEY SSG BAKAR JUMBO 500 GR/PAC (NEW) PAC 0.5 5 10 Box
|
||||
137 13010816 OKEY SSG BAKAR MINI 500 GR/PAC (NEW) PAC 0.5 5 10 Box
|
||||
138 13010817 OKEY SSG CHICK KOMBINASI 500 GR (EXTRA1) PAC 0.5 10 20 Box
|
||||
139 13010818 OKEY SSG CHICK KOMBINASI 1 KG (EXTR$A2) PAC 1 10 10 Box
|
||||
140 13012205 ASIMO SOSIS AYAM KOMBINASI 375 GR (PAC) PAC 0.375 5.625 15 Box
|
||||
141 13012206 ASIMO SOSIS AYAM KOMBINASI 500 GR PAC 0.5 10 20 Box
|
||||
142 13012207 ASIMO SOSIS AYAM KOMBINASI 750 GR PAC 0.75 6 8 Box
|
||||
143 13012208 ASIMO SOSIS AYAM KOMBINASI 1000 GR PAC 1 10 10 Box
|
||||
144 13012209 ASIMO SSG CHICK KOMBINASI 500 GR (EXTRA1) PAC 0.5 10 20 Box
|
||||
145 13012210 ASIMO SSG CHICK KOMBINASI 1 KG (EXTR$A2) PAC 1 10 10 Box
|
||||
146 13030101 FIESTA CHICK MEAT BALL 300 GR PAC 0.3 6 20 Box
|
||||
147 13030102 FIESTA CHICK MEATBALL 500 GR PAC 0.5 5 10 Box
|
||||
148 13030501 CHAMP CHICK MEATBALL 200 GR PAC 0.2 5 25 Box
|
||||
149 13030502 CHAMP CHICK MEATBALL 500 GR PAC 0.5 5 10 Box
|
||||
150 13050101 FIESTA SCB 250 GR PAC 0.25 5 20 Box
|
||||
151 13050105 FIESTA CHICKEN SLICE 300 GR PAC 0.3 6 20 Box
|
||||
152 13050106 FIESTA BEEF SLICE 300 GR PAC 0.3 6 20 Box
|
||||
153 13070501 CHAMP BEEF SSG SERBAGUNA 150 GR PAC 0.15 6.75 45 Box
|
||||
154 13070502 CHAMP BEEF SSG SERBAGUNA 375GR PAC 0.375 9 24 Box
|
||||
155 13070505 CHAMP BEEF SSG GORENG 375 GR PAC 0.375 5.625 15 Box
|
||||
156 13070506 CHAMP FRANKFURTER SSG 375GR PAC 0.375 5.625 15 Box
|
||||
157 13100512 CHAMP CHICK SSG S/SANTAP ORIG 546GR (CAN) can 0.546 3.276 6 Box
|
||||
158 13110504 CHAMP BEEF BALL 500GR PAC 0.5 5 10 Box
|
||||
159 13170510 CHAMP BEEF BBQ SSG S/SANTAP 546GR (CAN) can 0.546 3.276 6 Box
|
||||
160 15010101 FIESTA SHOESTRING 500 GR PAC 0.5 6 12 Box
|
||||
161 15010102 FIESTA SHOESTRING 1000 GR PAC 1 12 12 Box
|
||||
162 15010107 FIESTA FRENCH F SHOESTRING INSTITUSI 2KG PAC 2 12 6 Box
|
||||
163 15020101 FIESTA STRAIGHT CUT 500 GR PAC 0.5 6 12 Box
|
||||
164 15020102 FIESTA STRAIGHT CUT 1000 GR PAC 1 12 12 Box
|
||||
165 15030101 FIESTA CRINKLE CUT 500 GR PAC 0.5 6 12 Box
|
||||
166 15030102 FIESTA CRINKLE CUT 1000 GR PAC 1 12 12 Box
|
||||
167 15040101 FIESTA BATTER COATED 500 GR PAC 0.5 6 12 Box
|
||||
168 15040102 FIESTA BATTER COATED 1000 GR PAC 1 12 12 Box
|
||||
169 16060103 FIESTA CHICK SIOMAY 900GR PAC 0.9 10.8 12 Box
|
||||
170 16060113 FIESTA CHICK SIOMAY 180GR (NEW) PAC 0.18 5.4 30 Box
|
||||
171 16060114 FIESTA GYOZA 180 GR (NEW) PAC 0.18 5.4 30 Box
|
||||
172 16060119 FIESTA RTG SIOMAY 54 GR/PAC PAC 0.054 3.888 72 Box
|
||||
173 16060120 FIESTA KEECHO 400 GR/PAC PAC 0.4 4.8 12 Box
|
||||
174 16060121 FIESTA CHICKEN TOFU 400 GR/PAC (NEW) PAC 0.4 4.8 12 Box
|
||||
175 16060503 CHAMP CHICK&FISH SIOMAY 180 GR (NEW) PAC 0.18 5.4 30 Box
|
||||
176 17200109 FIESTA RTS C/TERIYAKI 300GR/PAC PAC 0.3 2.4 8 Box
|
||||
177 17200110 FIESTA RTS C/RENDANG 300GR/PAC PAC 0.3 2.4 8 Box
|
||||
178 17200111 FIESTA RTS C/W RUJAK SC 300GR/PAC PAC 0.3 2.4 8 Box
|
||||
179 17200112 FIESTA RTS C/W S/MATAH 300GR/PAC PAC 0.3 2.4 8 Box
|
||||
180 17210106 FIESTA RTS B/YAKINIKU 300GR/PAC PAC 0.3 2.4 8 Box
|
||||
181 17210107 FIESTA RTS B/RENDANG 300GR/PAC PAC 0.3 2.4 8 Box
|
||||
182 17210108 FIESTA RTS B/BLACKPEPPER 300GR/PAC PAC 0.3 2.4 8 Box
|
||||
183 17210109 FIESTA RTS B/BULGOGI 300GR/PAC PAC 0.3 2.4 8 Box
|
||||
184 18050102 FIESTA RTG BAKSO KEJU 60 GR/PAC PAC 0.06 4.32 72 Box
|
||||
185 18050103 FIESTA RTG BAKSO BAKAR BBQ 60 GR/PAC PAC 0.06 4.32 72 Box
|
||||
186 18050104 FIESTA RTG BEEF BALL WITH MENTAI LAVA 55GR PAC 0.055 3.96 72 Box
|
||||
187 18050105 FIESTA RTG BEEF BALL WITH CHEESE LAVA 55GR PAC 0.055 3.96 72 Box
|
||||
188 20010101 FIESTA CRISPY CRUMBS 200 GR PAC 0.2 4.8 24 Box
|
||||
189 20010102 FIESTA TP ROTI PUTIH 200 GR PAC 0.2 4.8 24 Box
|
||||
190 20040101 FIESTA RAMEN BEKU 570 GR/PAC PAC 0.57 6.84 12 Box
|
||||
191 20120102 FIESTA T/B AYAM GORENG 80 GR PAC 0.08 3.2 40 Box
|
||||
192 20120105 FIESTA T/B SERBAGUNA 80 GR PAC 0.08 3.2 40 Box
|
||||
193 20120106 FIESTA T/B KREMES 80 GR PAC 0.08 3.2 40 Box
|
||||
194 20120115 FIESTA RACIK AYAM GORENG 20 GR/PAC PAC 0.02 1.6 80 Box
|
||||
195 20120116 FIESTA RACIK NASI GORENG 20 GR/PAC PAC 0.02 1.6 80 Box
|
||||
196 21000123 FIESTA RICE W/GEPREK CHICKEN 320GR/PAC PAC 0.32 1.92 6 Box
|
||||
197 21000124 FIESTA RICE W/CHICK RUJAK 320 GR/PAC PAC 0.32 1.92 6 Box
|
||||
198 21000125 FIESTA RICE W/KOREAN BBQ CHICK 320 GR/PAC PAC 0.32 1.92 6 Box
|
||||
199 21000126 NEW FIESTA CHICK RENDANG W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
200 21000127 NEW FIESTA CHICK TERIYAKI W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
201 21000128 NEW FIESTA CHICK TANDORI W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
202 21000129 NEW FIESTA RICE W KARAGE&SSS 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
203 21000130 NEW FIESTA RICE W/C CHEESE BULDAK 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
204 21000131 NEW FIESTA RICE W CHIC CURRY 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
205 21000132 NEW FIESTA RICE W CHICK DONBURI 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
206 21000133 NEW FIESTA RICE W CHICK SATAY 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
207 21000134 NEW FIESTA COCONUT RICE W SPICY CHICK 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
208 21000135 NEW FIESTA RICE W POPBITES S/MATAH 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
209 21000136 NEW FIESTA TUMERIC W POPBITES 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
210 21000137 FIESTA HAINAMESE CHICKEN RICE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
211 21010101 FIESTA TRUFFLE GYUDON 320 GR/PAC PAC 0.32 1.92 6 Box
|
||||
212 21010102 NEW FIESTA BEEF YAKINIKU W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
213 21010103 NEW FIESTA BEEF BULGOGI W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
214 21010104 NEW FIESTA BEEF RENDANG W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
215 21010105 NEW FIESTA RICE W BEEF BLACKPEPPER 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
216 21200107 NEW FIESTA SPAGHETTI CARBONARA 300GR (PAC) PAC 0.3 1.8 6 Box
|
||||
217 21200108 NEW FIESTA SPAGHETTI CHIC BOLOGNESE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
218 21200109 NEW FIESTA ITALIAN MEATBALL SPAGHETTI 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
219 21310103 NEW FIESTA SCB&S/SSG FRIED RICE 320GR (PAC) PAC 0.32 1.92 6 Box
|
||||
220 21500101 FIESTA CHICK SSG & C. BALL PIZZA 230GR/PAC PAC 0.23 1.15 5 Box
|
||||
221 21500102 FIESTA CHEESY BEEF PIZZA 230GR/PAC PAC 0.23 1.15 5 Box
|
||||
222 1188002W PAHA ATAS 25-30 G FZ (*) PAC 1 10 10 Box
|
||||
223 1195008A RTC CHICKEN KALASAN 400 GR (PAC) PAC 0.41 4.1 10 Box
|
||||
224 1195008E RTC CHICKEN TERIYAKI 400 GR (PAC) PAC 0.4 3.2 8 Box
|
||||
225 1195008X RTC CHICKEN SPICY 400 GR (PAC) PAC 0.4 3.2 8 Box
|
||||
|
File renamed without changes.
|
500 Internal Server Error
An error occurred: An error occurred Gitea Version: 28.0.0 |