feat: update backend OCR parser, web app, mobile app camera/preview UI, tests, and documentation with sample images

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-02 13:33:57 +07:00
1 parent aa3233e411
commit bdb3a49742
71 files changed
+3348 -752

No files matched your search

+8 -8
View File
@@ -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
View File
@@ -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;
+114
View File
@@ -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();
+15 -1
View File
@@ -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"]
};
+1 -1
View File
@@ -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"
+246
View File
@@ -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();
+201 -60
View File
@@ -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
});
}
+18 -27
View File
@@ -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
+38 -7
View File
@@ -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>
+42 -14
View File
@@ -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})`);
}
}
+331 -185
View File
@@ -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 {
+37
View File
@@ -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)
// -------------------------------------------------------------
+33
View File
@@ -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();
+230
View File
@@ -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
1 PT. Primafood International-PFM Jakarta
2 Rekap SKU Aktif CPI Cikande
3 Per April 2026
4
5 No No SKU Nama Item Standar Jumlah Berat per kemasan (kg) Isi per outer (kg) Isi per outer (pac) Jenis Outer (karung/box)
6 1 11110059 CEKER BERKUKU FROZEN PACK 1 KG(*) bag 1 10 10 Box
7 2 11110074 CEKER 1 KG FROZEN (20 PAC/KARUNG)(*) PAC 1 20 20 Karung
8 3 11140051 AMPELA FROZEN PACK 1 KG(*) kg 1 10 10 Box
9 4 11140062 AMPELA 1 KG FROZEN (20 PAC/KARUNG)(*) PAC 1 20 20 Karung
10 5 11150052 HATI FROZEN PACK 1 KG(*) kg 1 10 10 Karung
11 6 11150055 JANTUNG FROZEN PACK 1 KG(*) kg 1 10 10 Karung
12 7 11150064 HATI 1 KG FROZEN (20 PAC/KARUNG)(*) PAC 1 20 20 Karung
13 8 11150065 JANTUNG 1 KG FROZEN (20 PAC/KARUNG)(*) PAC 1 20 20 Karung
14 9 11310012 AYAM SIZE 0 (0.6-0.7)KG(*) PC 0.65 26 40 Karung
15 10 11310013 AYAM SIZE1 FROZEN (0.75-0.8) KG(*) PC 0.75 22.5 30 Karung
16 11 11310014 AYAM SIZE 2 FROZEN (0.8-0.9)KG(*) PC 0.85 17 20 Karung
17 12 11310016 AYAM SIZE Z PR FROZ(*) PC 0.58 23.2 40 Karung
18 13 11310017 AYAM SIZE 0 PR FROZEN(*) PC 0.65 26 40 Karung
19 14 11310018 AYAM SIZE 1 PR FROZEN(*) PC 0.75 22.5 30 Karung
20 15 11310019 AYAM SIZE 2 PR FROZEN(*) PC 0.85 17 20 Karung
21 16 11310021 AYAM SIZE BESAR (B) FROZ (1-1.1)KG/PC(*) PC 1.05 21 20 Karung
22 17 11310022 AYAM SIZE A PR (0.9-1)KG/PC(*) PC 0.95 19 20 Karung
23 18 11310024 AYAM SIZE A FROZEN (0.9-1)KG/PC(*) PC 0.95 19 20 Karung
24 19 11310025 AYAM SIZE SUPER (C) FROZ(1.1-1.2)KG/ PC(*) PC 1.15 23 20 Karung
25 20 11310026 AYAM SIZE JUMBO (D) FROZ (1.2- 1.3)KG/PC(*) pc 1.25 20 16 Karung
26 21 11410043 PARTING 10 SIZE D FRESH BENSU 1.25 KG/PAC(*) PAC 1.25 12.5 10 Bag
27 22 11420055 PARTING 12 ALL SIZE FROZ/PAC(*) PAC 1 10 10 Box
28 23 11600053 BONELESS LEG FROZEN 1 KG(*) kg 1 10 10 Box
29 24 11620056 SBL (FILLET PAHA) 1 KG(*) kg 1 10 10 Box
30 25 11640053 PAHA UTUH (1 KG)(*) PAC 1 10 10 Box
31 26 11650053 PAHA ATAS 1 KG(*) kg 1 10 10 Box
32 27 11660050 PAHA BAWAH (1 KG)(*) PAC 1 10 10 Box
33 28 11690053 SBB (FILLET DADA )1 KG(*) kg 1 10 10 Box
34 29 11690081 SBB JUMBO FZ (2.0 - 2.2 KG/PAC)(*) PAC 2 20 10 Karung
35 30 11710051 DADA UTUH (1 KG)(*) kg 1 10 10 Box
36 31 11720055 FULL WING FROZ PACK 1 KG(*) kg 1 10 10 Box
37 32 11730050 MIDDLE WING FROZ PACK 1 KG(*) PAC 1 10 10 Box
38 33 11750050 FILLET MITRA 1 KG(*) KG 1 10 10 Box
39 34 11840002 AYAM JANTAN BKKL SZ 0 (600-700) GR/PC(*) PC 0 0 0 Karung
40 35 11959937 SATE AYAM FRESHMART 360 GR (PAC) PAC 0.36 7.2 20 Box
41 36 12010111 FIESTA CRISPY BUBBLE 400 GR/PAC PAC 0.4 4.8 12 Box
42 37 12010112 FIESTA CHICKEN NUGGET 400 GR/PAC PAC 0.4 4.8 12 Box
43 38 12010113 FIESTA CHICKEN NUGGET 200 GR/PAC PAC 0.2 4.8 24 Box
44 39 12010115 FIESTA NUGGET ZOO 400 GR/PAC PAC 0.4 4.8 12 Box
45 40 12010116 FIESTA NUGGET DINO 400 GR/PAC PAC 0.4 4.8 12 Box
46 41 12010117 FIESTA NUGGET HAPPY STAR 400 GR/PAC PAC 0.4 4.8 12 Box
47 42 12010119 FIESTA NUGGET CHEESE 123 400 GR/PAC PAC 0.4 4.8 12 Box
48 43 12010121 FIESTA NUGGET PIZZABC 400 GR/PAC PAC 0.4 4.8 12 Box
49 44 12010122 FIESTA CHEESY LOVER 400 GR/PAC PAC 0.4 4.8 12 Box
50 45 12010123 FIESTA GARLIC CHEESE 400 GR/PAC PAC 0.4 4.8 12 Box
51 46 12010124 FIESTA CHEESY CHIC W/BROCCOLI 400 GR/PAC PAC 0.4 4.8 12 Box
52 47 12010127 FIESTA SPICY NUGGET 400 GR/PAC PAC 0.4 4.8 12 Box
53 48 12010128 FIESTA VOLCANO CHEESE 400 GR/PAC PAC 0.4 4.8 12 Box
54 49 12010129 FIESTA CHEESY BOMBS CHICKEN NUGGET 400 GR PAC 0.4 4.8 12 Box
55 50 12010402 GOLDEN FIESTA NUGGET W/PINEAPPLE SAUCE 500 GR PAC 0.5 5 10 Box
56 51 12010509 CHAMP CRUNCHY NUGGET 450 GR/PAC PAC 0.45 4.5 10 Box
57 52 12010510 CHAMP NUGGET AYAM 225 GR/PAC PAC 0.225 4.95 22 Box
58 53 12010511 CHAMP NUGGET AYAM 450 GR/PAC PAC 0.45 5.4 12 Box
59 54 12010512 CHAMP NUGGET AYAM 900 GR/PAC PAC 0.9 5.4 6 Box
60 55 12010513 CHAMP NUGGET ABC KOMBINASI 225 GR/PAC PAC 0.225 4.95 22 Box
61 56 12010514 CHAMP NUGGET ABC KOMBINASI 450 GR/PAC PAC 0.45 5.4 12 Box
62 57 12010515 CHAMP KOIN KOMBINASI 450 GR/PAC PAC 0.45 5.4 12 Box
63 58 12010516 CHAMP KOIN KOMBINASI 200 GR/PAC PAC 0.2 6 30 Box
64 59 12010517 CHAMP NUGGET STICK 225 GR/PAC PAC 0.225 4.95 22 Box
65 60 12010518 CHAMP NUGGET STICK 450 GR/PAC PAC 0.45 5.4 12 Box
66 61 12010519 CHAMP NUGGET STICK 900 GR/PAC PAC 0.9 5.4 6 Box
67 62 12010520 CHAMP CHICKEN NUGGET BENTUK 123 450 GR/PAC PAC 0.45 5.4 12 Box
68 63 12010521 CHAMP NUGGET HOTZZ LEVEL 5 450 GR/PAC PAC 0.45 5.4 12 Box
69 64 12010606 CHAMP CRUNCHY NUGGET 225 GR/PAC PAC 0.225 5.625 25 Box
70 65 12010707 CHAMP MITRA NUGGET COIN 200 GR (NEW) PAC 0.2 6 30 Box
71 66 12010801 OKEY NUGGET 500GR PAC 0.5 5 10 Box
72 67 12012201 ASIMO NUGGET KOMBINASI 500 GR/PAC PAC 0.5 5 10 Box
73 68 12012202 ASIMO NUGGET KOMBINASI 1 KG/PAC PAC 1 5 5 Box
74 69 12012203 ASIMO NUGGET KOMBINASI 250 GR/PAC PAC 0.25 5 20 Box
75 70 12012501 AKUMO CHICKEN NAGET 250 GR PAC 0.25 5 20 Box
76 71 12012502 AKUMO CHICKEN NUGGET 500 GR PAC 0.5 5 10 Box
77 72 12012503 AKUMO CHICKEN NUGGET 1000 GR PAC 1 5 5 Box
78 73 12012504 AKUMO COIN 200 GR/PAC pack 0.2 6 30 Box
79 74 12012505 AKUMO KOIN 400 GR/PAC PAC 0.4 6 15 Box
80 75 12020102 FIESTA SPICY WING 400 GR/PAC PAC 0.4 4.8 12 Box
81 76 12020401 GOLDEN FIESTA SP WING 500 GR PAC 0.5 5 10 Box
82 77 12030101 FIESTA STIKIE 400 GR/PAC PAC 0.4 4.8 12 Box
83 78 12030102 FIESTA STIKIE 200 GR/PAC PAC 0.2 4.8 24 Box
84 79 12030403 GOLDEN FIESTA STIKIE W/ SWEET CHILLI SAUCE 500GR PAC 0.5 5 10 Box
85 80 12030801 OKEY STICK 1000 GR PAC 1 5 5 Box
86 81 12030802 OKEY STICK 500GR PAC 0.5 5 10 Box
87 82 12032201 ASIMO STICK KOMBINASI 500 GR/PAC PAC 0.5 5 10 Box
88 83 12032202 ASIMO STICK KOMBINASI 1000 GR/PAC PAC 1 5 5 Box
89 84 12032203 ASIMO STIK KOMBINASI 250 GR/PAC PAC 0.25 5 20 Box
90 85 12032501 AKUMO CHICKEN STICK 250 GR PAC 0.25 5 20 Box
91 86 12032502 AKUMO CHICKEN STIK 500 GR PAC 0.5 5 10 Box
92 87 12032503 AKUMO CHICKEN STICK 1000 GR PAC 1 5 5 Box
93 88 12040101 FIESTA SCHNITZEL 400 GR/PAC PAC 0.4 4.8 12 Box
94 89 12040102 FIESTA CRISPY BUBBLE KATSU 400 GR/PAC PAC 0.4 4.8 12 Box
95 90 12040404 GOLDEN FIESTA CORDON BLEU BBQ SAUCE 500 GR PAC 0.5 5 10 Box
96 91 12040406 GOLDEN FIESTA KATSU W/CHEESE SAUCE 500 GR/PAC PAC 0.5 5 10 Box
97 92 12050103 FIESTA FRIED CHICKEN 400 GR/PAC PAC 0.4 4.8 12 Box
98 93 12050104 FIESTA HOT & CRISPY FRIED CHICKEN 400 GR/PAC PAC 0.4 4.8 12 Box
99 94 12050401 GOLDEN FIESTA CRISPY WING W/SP GLAZ SC 500 GR/PAC PAC 0.5 5 10 Box
100 95 12060103 FIESTA KARAGE 200 GR/PAC PAC 0.2 4.8 24 Box
101 96 12060104 FIESTA KARAGE 400 GR/PAC PAC 0.4 4.8 12 Box
102 97 12060105 FIESTA SPICY KARAGE 400 GR/PAC PAC 0.4 4.8 12 Box
103 98 12060402 GOLDEN FIESTA KARAGE CHILI SAUCE 500GR PAC 0.5 5 10 Box
104 99 12070101 FIESTA POK-POK 400 GR/PAC (NEW) PAC 0.4 4.8 12 Box
105 100 12080101 FIESTA SPICY CHICK 400 GR/PAC PAC 0.4 4.8 12 Box
106 101 12130102 FIESTA CRISPY BURGER 360 GR (NEW) PAC 0.36 7.2 20 Box
107 102 12130504 CHAMP BURGER 315 GR (NEW) PAC 0.315 6.3 20 Box
108 103 12140105 FIESTA CHICK TOFU 400 GR/PAC PAC 0.4 4.8 12 Box
109 104 12150201 FIESTA DS CRISPY CRUNCH 300 GR/PAC PAC 0.3 3 10 Box
110 105 12150501 CHAMP CRUNCHY HOTZZ 300 GR/PAC PAC 0.3 3 10 Box
111 106 12190103 FIESTA DELISTRIPE 400 GR/PAC PAC 0.4 4.8 12 Box
112 107 12240102 FIESTA CHEESY ITALIAN R/BITES 400 GR/PAC PAC 0.4 4.8 12 Box
113 108 12240103 FIESTA YAKINIKU R/BITES 400 GR/PAC PAC 0.4 4.8 12 Box
114 109 13010101 FIESTA CHICK SSG 300 GR PAC 0.3 6 20 Box
115 110 13010102 FIESTA CHICK SSG 500 GR PAC 0.5 5 10 Box
116 111 13010103 FIESTA CHICK SSG 200 GR/PAC PAC 0.2 5 25 Box
117 112 13010111 FIESTA SOSIS BRATWURST 300 GR PAC 0.3 6 20 Box
118 113 13010112 FIESTA CHEESE SSG 300 GR PAC 0.3 6 20 Box
119 114 13010113 FIESTA SOSIS CURRYWURST 300 GR PAC 0.3 6 20 Box
120 115 13010114 FIESTA SSG BOCKWURST 300GR PAC 0.3 6 20 Box
121 116 13010115 FIESTA SSG WIENER 300GR PAC 0.3 6 20 Box
122 117 13010116 FIESTA SSG ORIGINAL 300 GR PAC 0.3 6 20 Box
123 118 13010117 FIESTA SSG FRANKFURTER 300GR PAC 0.3 6 20 Box
124 119 13010118 FIESTA RTG SSG 65 GR/PAC PAC 0.065 4.68 72 Box
125 120 13010119 FIESTA RTG C/SPICY KOREAN 60 GR/PAC PAC 0.06 4.32 72 Box
126 121 13010120 FIESTA RTG C/CHEESY MELTS 65 GR/PAC PAC 0.065 4.68 72 Box
127 122 13010122 FIESTA RTG SAUSAGE WITH HOT LAVA 60G PAC 0.06 4.32 72 Box
128 123 13010123 FIESTA RTG SAUSAGE WITH CHEESE LAVA 60G PAC 0.06 4.32 72 Box
129 124 13010124 FIESTA RTG SAUSAGE HICKORY SAUCE 60GR PAC 0.06 4.32 72 Box
130 125 13010125 FIESTA RTG SAUSAGE WITH MENTAI LAVA 60GR PAC 0.06 4.32 72 Box
131 126 13010510 CHAMP CHICK SSG 75 GR PAC 0.075 6 80 Box
132 127 13010513 CHAMP CHICK SSG 375 GR PAC 0.375 5.625 15 Box
133 128 13010514 CHAMP CHICK SSG 1000 GR PAC 1 10 10 Box
134 129 13010518 CHAMP SSG JUMBO BAKAR 500 GR/PAC PAC 0.5 5 10 Box
135 130 13010519 CHAMP SSG BAKAR MINI 500 GR/PAC-INACT PAC 0.5 5 10 Box
136 131 13010521 CHAMP CHICK SSG 150 GR/PAC (NEW) PAC 0.15 6.75 45 Box
137 132 13010523 CHAMP CHICK SSG AYAM MADU 300 GR/PAC PAC 0.3 6 20 Box
138 133 13010524 CHAMP SSG JUMBO BAKAR 500 GR/PAC (NEW) PAC 0.5 5 10 Box
139 134 13010525 CHAMP SSG BAKAR MINI 500 GR/PAC (NEW) PAC 0.5 5 10 Box
140 135 13010809 OKEY CHICK SSG 500GR-INACT PAC 0.5 10 20 Box
141 136 13010815 OKEY SSG BAKAR JUMBO 500 GR/PAC (NEW) PAC 0.5 5 10 Box
142 137 13010816 OKEY SSG BAKAR MINI 500 GR/PAC (NEW) PAC 0.5 5 10 Box
143 138 13010817 OKEY SSG CHICK KOMBINASI 500 GR (EXTRA1) PAC 0.5 10 20 Box
144 139 13010818 OKEY SSG CHICK KOMBINASI 1 KG (EXTR$A2) PAC 1 10 10 Box
145 140 13012205 ASIMO SOSIS AYAM KOMBINASI 375 GR (PAC) PAC 0.375 5.625 15 Box
146 141 13012206 ASIMO SOSIS AYAM KOMBINASI 500 GR PAC 0.5 10 20 Box
147 142 13012207 ASIMO SOSIS AYAM KOMBINASI 750 GR PAC 0.75 6 8 Box
148 143 13012208 ASIMO SOSIS AYAM KOMBINASI 1000 GR PAC 1 10 10 Box
149 144 13012209 ASIMO SSG CHICK KOMBINASI 500 GR (EXTRA1) PAC 0.5 10 20 Box
150 145 13012210 ASIMO SSG CHICK KOMBINASI 1 KG (EXTR$A2) PAC 1 10 10 Box
151 146 13030101 FIESTA CHICK MEAT BALL 300 GR PAC 0.3 6 20 Box
152 147 13030102 FIESTA CHICK MEATBALL 500 GR PAC 0.5 5 10 Box
153 148 13030501 CHAMP CHICK MEATBALL 200 GR PAC 0.2 5 25 Box
154 149 13030502 CHAMP CHICK MEATBALL 500 GR PAC 0.5 5 10 Box
155 150 13050101 FIESTA SCB 250 GR PAC 0.25 5 20 Box
156 151 13050105 FIESTA CHICKEN SLICE 300 GR PAC 0.3 6 20 Box
157 152 13050106 FIESTA BEEF SLICE 300 GR PAC 0.3 6 20 Box
158 153 13070501 CHAMP BEEF SSG SERBAGUNA 150 GR PAC 0.15 6.75 45 Box
159 154 13070502 CHAMP BEEF SSG SERBAGUNA 375GR PAC 0.375 9 24 Box
160 155 13070505 CHAMP BEEF SSG GORENG 375 GR PAC 0.375 5.625 15 Box
161 156 13070506 CHAMP FRANKFURTER SSG 375GR PAC 0.375 5.625 15 Box
162 157 13100512 CHAMP CHICK SSG S/SANTAP ORIG 546GR (CAN) can 0.546 3.276 6 Box
163 158 13110504 CHAMP BEEF BALL 500GR PAC 0.5 5 10 Box
164 159 13170510 CHAMP BEEF BBQ SSG S/SANTAP 546GR (CAN) can 0.546 3.276 6 Box
165 160 15010101 FIESTA SHOESTRING 500 GR PAC 0.5 6 12 Box
166 161 15010102 FIESTA SHOESTRING 1000 GR PAC 1 12 12 Box
167 162 15010107 FIESTA FRENCH F SHOESTRING INSTITUSI 2KG PAC 2 12 6 Box
168 163 15020101 FIESTA STRAIGHT CUT 500 GR PAC 0.5 6 12 Box
169 164 15020102 FIESTA STRAIGHT CUT 1000 GR PAC 1 12 12 Box
170 165 15030101 FIESTA CRINKLE CUT 500 GR PAC 0.5 6 12 Box
171 166 15030102 FIESTA CRINKLE CUT 1000 GR PAC 1 12 12 Box
172 167 15040101 FIESTA BATTER COATED 500 GR PAC 0.5 6 12 Box
173 168 15040102 FIESTA BATTER COATED 1000 GR PAC 1 12 12 Box
174 169 16060103 FIESTA CHICK SIOMAY 900GR PAC 0.9 10.8 12 Box
175 170 16060113 FIESTA CHICK SIOMAY 180GR (NEW) PAC 0.18 5.4 30 Box
176 171 16060114 FIESTA GYOZA 180 GR (NEW) PAC 0.18 5.4 30 Box
177 172 16060119 FIESTA RTG SIOMAY 54 GR/PAC PAC 0.054 3.888 72 Box
178 173 16060120 FIESTA KEECHO 400 GR/PAC PAC 0.4 4.8 12 Box
179 174 16060121 FIESTA CHICKEN TOFU 400 GR/PAC (NEW) PAC 0.4 4.8 12 Box
180 175 16060503 CHAMP CHICK&FISH SIOMAY 180 GR (NEW) PAC 0.18 5.4 30 Box
181 176 17200109 FIESTA RTS C/TERIYAKI 300GR/PAC PAC 0.3 2.4 8 Box
182 177 17200110 FIESTA RTS C/RENDANG 300GR/PAC PAC 0.3 2.4 8 Box
183 178 17200111 FIESTA RTS C/W RUJAK SC 300GR/PAC PAC 0.3 2.4 8 Box
184 179 17200112 FIESTA RTS C/W S/MATAH 300GR/PAC PAC 0.3 2.4 8 Box
185 180 17210106 FIESTA RTS B/YAKINIKU 300GR/PAC PAC 0.3 2.4 8 Box
186 181 17210107 FIESTA RTS B/RENDANG 300GR/PAC PAC 0.3 2.4 8 Box
187 182 17210108 FIESTA RTS B/BLACKPEPPER 300GR/PAC PAC 0.3 2.4 8 Box
188 183 17210109 FIESTA RTS B/BULGOGI 300GR/PAC PAC 0.3 2.4 8 Box
189 184 18050102 FIESTA RTG BAKSO KEJU 60 GR/PAC PAC 0.06 4.32 72 Box
190 185 18050103 FIESTA RTG BAKSO BAKAR BBQ 60 GR/PAC PAC 0.06 4.32 72 Box
191 186 18050104 FIESTA RTG BEEF BALL WITH MENTAI LAVA 55GR PAC 0.055 3.96 72 Box
192 187 18050105 FIESTA RTG BEEF BALL WITH CHEESE LAVA 55GR PAC 0.055 3.96 72 Box
193 188 20010101 FIESTA CRISPY CRUMBS 200 GR PAC 0.2 4.8 24 Box
194 189 20010102 FIESTA TP ROTI PUTIH 200 GR PAC 0.2 4.8 24 Box
195 190 20040101 FIESTA RAMEN BEKU 570 GR/PAC PAC 0.57 6.84 12 Box
196 191 20120102 FIESTA T/B AYAM GORENG 80 GR PAC 0.08 3.2 40 Box
197 192 20120105 FIESTA T/B SERBAGUNA  80 GR PAC 0.08 3.2 40 Box
198 193 20120106 FIESTA T/B KREMES 80 GR PAC 0.08 3.2 40 Box
199 194 20120115 FIESTA RACIK AYAM GORENG 20 GR/PAC PAC 0.02 1.6 80 Box
200 195 20120116 FIESTA RACIK NASI GORENG 20 GR/PAC PAC 0.02 1.6 80 Box
201 196 21000123 FIESTA RICE W/GEPREK CHICKEN 320GR/PAC PAC 0.32 1.92 6 Box
202 197 21000124 FIESTA RICE W/CHICK RUJAK 320 GR/PAC PAC 0.32 1.92 6 Box
203 198 21000125 FIESTA RICE W/KOREAN BBQ CHICK 320 GR/PAC PAC 0.32 1.92 6 Box
204 199 21000126 NEW FIESTA CHICK RENDANG W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
205 200 21000127 NEW FIESTA CHICK TERIYAKI W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
206 201 21000128 NEW FIESTA CHICK TANDORI W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
207 202 21000129 NEW FIESTA RICE W KARAGE&SSS 320GR (PAC) PAC 0.32 1.92 6 Box
208 203 21000130 NEW FIESTA RICE W/C CHEESE BULDAK 320GR (PAC) PAC 0.32 1.92 6 Box
209 204 21000131 NEW FIESTA RICE W CHIC CURRY 320GR (PAC) PAC 0.32 1.92 6 Box
210 205 21000132 NEW FIESTA RICE W CHICK DONBURI 320GR (PAC) PAC 0.32 1.92 6 Box
211 206 21000133 NEW FIESTA RICE W CHICK SATAY 320GR (PAC) PAC 0.32 1.92 6 Box
212 207 21000134 NEW FIESTA COCONUT RICE W SPICY CHICK 320GR (PAC) PAC 0.32 1.92 6 Box
213 208 21000135 NEW FIESTA RICE W POPBITES S/MATAH 320GR (PAC) PAC 0.32 1.92 6 Box
214 209 21000136 NEW FIESTA TUMERIC W POPBITES 320GR (PAC) PAC 0.32 1.92 6 Box
215 210 21000137 FIESTA HAINAMESE CHICKEN RICE 320GR (PAC) PAC 0.32 1.92 6 Box
216 211 21010101 FIESTA TRUFFLE GYUDON 320 GR/PAC PAC 0.32 1.92 6 Box
217 212 21010102 NEW FIESTA BEEF YAKINIKU W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
218 213 21010103 NEW FIESTA BEEF BULGOGI W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
219 214 21010104 NEW FIESTA BEEF RENDANG W RICE 320GR (PAC) PAC 0.32 1.92 6 Box
220 215 21010105 NEW FIESTA RICE W BEEF BLACKPEPPER 320GR (PAC) PAC 0.32 1.92 6 Box
221 216 21200107 NEW FIESTA SPAGHETTI CARBONARA 300GR (PAC) PAC 0.3 1.8 6 Box
222 217 21200108 NEW FIESTA SPAGHETTI CHIC BOLOGNESE 320GR (PAC) PAC 0.32 1.92 6 Box
223 218 21200109 NEW FIESTA ITALIAN MEATBALL SPAGHETTI 320GR (PAC) PAC 0.32 1.92 6 Box
224 219 21310103 NEW FIESTA SCB&S/SSG FRIED RICE 320GR (PAC) PAC 0.32 1.92 6 Box
225 220 21500101 FIESTA CHICK SSG & C. BALL PIZZA 230GR/PAC PAC 0.23 1.15 5 Box
226 221 21500102 FIESTA CHEESY BEEF PIZZA 230GR/PAC PAC 0.23 1.15 5 Box
227 222 1188002W PAHA ATAS 25-30 G FZ (*) PAC 1 10 10 Box
228 223 1195008A RTC CHICKEN KALASAN 400 GR (PAC) PAC 0.41 4.1 10 Box
229 224 1195008E RTC CHICKEN TERIYAKI 400 GR (PAC) PAC 0.4 3.2 8 Box
230 225 1195008X RTC CHICKEN SPICY 400 GR (PAC) PAC 0.4 3.2 8 Box
File renamed without changes.
Internal Server Error - Gitea: Git with a cup of tea
500 Internal Server Error

An error occurred:

An error occurred

Gitea Version: 28.0.0