fix: resolve table column shift, normalize units and standardize dates
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# Workspace Rules for PFM OCR App
|
||||
|
||||
## OCR Post-Processing & Parsing Rules
|
||||
|
||||
### 1. Table Column Shift Alignment Correction
|
||||
When parsing OCR tables (e.g., columns like SKU, description, quantity):
|
||||
- If the first row of a column is empty but has description/quantities in other columns, and a downstream row has a valid value in that column but is empty in other columns, this indicates a column shift.
|
||||
- Scan for the shift boundary row.
|
||||
- Shift the column values up by exactly one row to restore correct cell alignments.
|
||||
- Clean the shifted boundary row value to prevent orphan rows from polluting the parsed output.
|
||||
|
||||
### 2. Date Normalization
|
||||
- Always ensure single digit days are normalized with a leading zero (e.g., `7 June` to `07 June`, `4 April` to `04 April`) in post-processing.
|
||||
@@ -0,0 +1,451 @@
|
||||
import json
|
||||
import os
|
||||
import pandas as pd
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
def clean_val(val):
|
||||
if val is None:
|
||||
return ""
|
||||
s = str(val).strip().upper()
|
||||
if s in ["N/A", "NOT FOUND", "NOTFOUND", "EMPTY", "NONE", "-", "N / A"]:
|
||||
return ""
|
||||
s = " ".join(s.split())
|
||||
s = s.replace("PT. ", "PT.")
|
||||
s = s.replace("✓", "").replace("✔", "").strip()
|
||||
return s
|
||||
|
||||
def main():
|
||||
ai_file = "sources/ai_results.json"
|
||||
manual_file = "sources/manual_labels.json"
|
||||
xlsx_file = "sources/comparison_report.xlsx"
|
||||
images_dir = "sources/test-images"
|
||||
|
||||
if not os.path.exists(ai_file):
|
||||
# Fallback to backend/sources
|
||||
ai_file = "backend/sources/ai_results.json"
|
||||
manual_file = "backend/sources/manual_labels.json"
|
||||
xlsx_file = "backend/sources/comparison_report.xlsx"
|
||||
images_dir = "backend/sources/test-images"
|
||||
|
||||
if not os.path.exists(ai_file):
|
||||
print(f"Error: AI results file not found at {ai_file}")
|
||||
return
|
||||
|
||||
if not os.path.exists(manual_file):
|
||||
print(f"Error: Manual labels file not found at {manual_file}")
|
||||
return
|
||||
|
||||
# Load data
|
||||
with open(ai_file, "r", encoding="utf-8") as f:
|
||||
ai_data = json.load(f)
|
||||
|
||||
with open(manual_file, "r", encoding="utf-8") as f:
|
||||
manual_data = json.load(f)
|
||||
|
||||
# Convert to dict for lookup by filename
|
||||
ai_dict = {item.get("filename"): item for item in ai_data if item.get("filename")}
|
||||
manual_dict = {item.get("filename"): item for item in manual_data if item.get("filename")}
|
||||
|
||||
print(f"Loaded {len(ai_dict)} AI results from file.")
|
||||
print(f"Loaded {len(manual_dict)} manual labels from file.")
|
||||
|
||||
# Scan for physical image files in test-images folder
|
||||
existing_images = None
|
||||
if os.path.exists(images_dir):
|
||||
existing_images = set(os.listdir(images_dir))
|
||||
print(f"Found {len(existing_images)} physical images in '{images_dir}'.")
|
||||
else:
|
||||
print(f"Warning: Images directory not found at '{images_dir}'.")
|
||||
|
||||
# Find mismatches in file lists
|
||||
only_in_ai = set(ai_dict.keys()) - set(manual_dict.keys())
|
||||
only_in_manual = set(manual_dict.keys()) - set(ai_dict.keys())
|
||||
if only_in_ai:
|
||||
print(f"Warning: {len(only_in_ai)} files exist only in AI results: {only_in_ai}")
|
||||
if only_in_manual:
|
||||
print(f"Warning: {len(only_in_manual)} files exist only in Manual labels: {only_in_manual}")
|
||||
|
||||
# Determine files to compare (must exist in AI results, Manual labels, and physically as images if directory is available)
|
||||
common_filenames = set(ai_dict.keys()) & set(manual_dict.keys())
|
||||
|
||||
if existing_images is not None:
|
||||
deleted_images = common_filenames - existing_images
|
||||
if deleted_images:
|
||||
print(f"Info: Excluded {len(deleted_images)} files that were physically deleted from images folder: {deleted_images}")
|
||||
all_filenames = sorted(list(common_filenames & existing_images))
|
||||
else:
|
||||
all_filenames = sorted(list(common_filenames))
|
||||
|
||||
print(f"Comparing {len(all_filenames)} matching images.")
|
||||
|
||||
header_fields = [
|
||||
("noPO", "noPO", "PO Number"),
|
||||
("noSO", "noSO", "SO Number"),
|
||||
("noDO", "noDO", "DO Number"),
|
||||
("tanggal", "tanggal", "Date"),
|
||||
("plat", "platTruk", "Plat Nomor"),
|
||||
("customer", "customerInfo", "Customer Name"),
|
||||
("store", "orderUntuk", "Store Name"),
|
||||
("alamat", "alamat", "Alamat")
|
||||
]
|
||||
|
||||
doc_comparison_rows = []
|
||||
item_comparison_rows = []
|
||||
|
||||
# Counters for accuracy calculation
|
||||
stats = {
|
||||
"PO Number": {"match": 0, "total": 0},
|
||||
"SO Number": {"match": 0, "total": 0},
|
||||
"DO Number": {"match": 0, "total": 0},
|
||||
"Date": {"match": 0, "total": 0},
|
||||
"Plat Nomor": {"match": 0, "total": 0},
|
||||
"Customer Name": {"match": 0, "total": 0},
|
||||
"Store Name": {"match": 0, "total": 0},
|
||||
"Alamat": {"match": 0, "total": 0},
|
||||
"Item SKU": {"match": 0, "total": 0},
|
||||
"Item Banyak": {"match": 0, "total": 0},
|
||||
"Item Jumlah": {"match": 0, "total": 0}
|
||||
}
|
||||
|
||||
# Document-level side-by-side rows
|
||||
doc_side_by_side_rows = []
|
||||
|
||||
for filename in all_filenames:
|
||||
manual = manual_dict.get(filename)
|
||||
ai = ai_dict.get(filename)
|
||||
|
||||
if not manual:
|
||||
print(f"Warning: Manual label not found for {filename} (exists only in AI results)")
|
||||
continue
|
||||
if not ai:
|
||||
print(f"Warning: AI result not found for {filename} (exists only in Manual labels)")
|
||||
continue
|
||||
|
||||
ai_meta = ai.get("layer3Final", {})
|
||||
|
||||
# 1. Compare header fields (Vertical format for filtering)
|
||||
sxs_row = {"Filename": filename}
|
||||
for manual_key, ai_key, field_label in header_fields:
|
||||
m_val = clean_val(manual.get(manual_key))
|
||||
a_val = clean_val(ai_meta.get(ai_key))
|
||||
is_match = (m_val == a_val)
|
||||
|
||||
doc_comparison_rows.append({
|
||||
"Filename": filename,
|
||||
"Field": field_label,
|
||||
"AI Value (OCR)": a_val if a_val else "(empty)",
|
||||
"Manual Value (Ground Truth)": m_val if m_val else "(empty)",
|
||||
"Match": "Match" if is_match else "Mismatch"
|
||||
})
|
||||
|
||||
# Side-by-side
|
||||
sxs_row[f"{field_label} (AI)"] = a_val if a_val else ""
|
||||
sxs_row[f"{field_label} (Manual)"] = m_val if m_val else ""
|
||||
sxs_row[f"{field_label} Status"] = "Match" if is_match else "Mismatch"
|
||||
|
||||
stats[field_label]["total"] += 1
|
||||
if is_match:
|
||||
stats[field_label]["match"] += 1
|
||||
|
||||
doc_side_by_side_rows.append(sxs_row)
|
||||
|
||||
# 2. Compare items
|
||||
m_items = manual.get("items", [])
|
||||
ai_items = ai.get("items", [])
|
||||
if not ai_items and "items" in ai_meta:
|
||||
ai_items = ai_meta.get("items", [])
|
||||
|
||||
# Create dictionaries of items indexed by codeBarang (SKU)
|
||||
m_items_dict = {clean_val(item.get("kodeBarang")): item for item in m_items if clean_val(item.get("kodeBarang"))}
|
||||
ai_items_dict = {clean_val(item.get("kodeBarang")): item for item in ai_items if clean_val(item.get("kodeBarang"))}
|
||||
|
||||
# Check all unique SKUs across both manual and AI
|
||||
all_skus = set(list(m_items_dict.keys()) + list(ai_items_dict.keys()))
|
||||
|
||||
for sku in all_skus:
|
||||
m_item = m_items_dict.get(sku)
|
||||
ai_item = ai_items_dict.get(sku)
|
||||
|
||||
# Check SKU existence match
|
||||
sku_match = (m_item is not None) and (ai_item is not None)
|
||||
stats["Item SKU"]["total"] += 1
|
||||
if sku_match:
|
||||
stats["Item SKU"]["match"] += 1
|
||||
|
||||
m_banyak = clean_val(m_item.get("banyak")) if m_item else ""
|
||||
ai_banyak = clean_val(ai_item.get("banyak")) if ai_item else ""
|
||||
banyak_match = (m_banyak == ai_banyak)
|
||||
|
||||
stats["Item Banyak"]["total"] += 1
|
||||
if banyak_match:
|
||||
stats["Item Banyak"]["match"] += 1
|
||||
|
||||
m_jumlah = clean_val(m_item.get("jumlah")) if m_item else ""
|
||||
ai_jumlah = clean_val(ai_item.get("jumlah")) if ai_item else ""
|
||||
jumlah_match = (m_jumlah == ai_jumlah)
|
||||
|
||||
stats["Item Jumlah"]["total"] += 1
|
||||
if jumlah_match:
|
||||
stats["Item Jumlah"]["match"] += 1
|
||||
|
||||
# Log code comparison
|
||||
item_comparison_rows.append({
|
||||
"Filename": filename,
|
||||
"Kode Barang (SKU)": sku,
|
||||
"Field": "SKU Existence",
|
||||
"AI Value (OCR)": sku if ai_item else "(not found)",
|
||||
"Manual Value (Ground Truth)": sku if m_item else "(not found)",
|
||||
"Match": "Match" if sku_match else "Mismatch"
|
||||
})
|
||||
|
||||
# Log Banyak comparison
|
||||
item_comparison_rows.append({
|
||||
"Filename": filename,
|
||||
"Kode Barang (SKU)": sku,
|
||||
"Field": "Banyak (Qty Package)",
|
||||
"AI Value (OCR)": ai_banyak if ai_banyak else "(empty)",
|
||||
"Manual Value (Ground Truth)": m_banyak if m_banyak else "(empty)",
|
||||
"Match": "Match" if banyak_match else "Mismatch"
|
||||
})
|
||||
|
||||
# Log Jumlah comparison
|
||||
item_comparison_rows.append({
|
||||
"Filename": filename,
|
||||
"Kode Barang (SKU)": sku,
|
||||
"Field": "Jumlah (Qty Unit)",
|
||||
"AI Value (OCR)": ai_jumlah if ai_jumlah else "(empty)",
|
||||
"Manual Value (Ground Truth)": m_jumlah if m_jumlah else "(empty)",
|
||||
"Match": "Match" if jumlah_match else "Mismatch"
|
||||
})
|
||||
|
||||
# Prepare summary data
|
||||
summary_rows = []
|
||||
total_matches = 0
|
||||
total_fields = 0
|
||||
for field_label, counts in stats.items():
|
||||
match_cnt = counts["match"]
|
||||
total_cnt = counts["total"]
|
||||
pct = (match_cnt / total_cnt * 100.0) if total_cnt > 0 else 100.0
|
||||
summary_rows.append({
|
||||
"Field / Area": field_label,
|
||||
"Total Checks": total_cnt,
|
||||
"Matches": match_cnt,
|
||||
"Mismatches": total_cnt - match_cnt,
|
||||
"Accuracy (%)": round(pct, 2)
|
||||
})
|
||||
total_matches += match_cnt
|
||||
total_fields += total_cnt
|
||||
|
||||
overall_accuracy = (total_matches / total_fields * 100.0) if total_fields > 0 else 100.0
|
||||
summary_rows.append({
|
||||
"Field / Area": "OVERALL TOTAL",
|
||||
"Total Checks": total_fields,
|
||||
"Matches": total_matches,
|
||||
"Mismatches": total_fields - total_matches,
|
||||
"Accuracy (%)": round(overall_accuracy, 2)
|
||||
})
|
||||
|
||||
df_summary = pd.DataFrame(summary_rows)
|
||||
df_docs = pd.DataFrame(doc_comparison_rows)
|
||||
df_sxs = pd.DataFrame(doc_side_by_side_rows)
|
||||
df_items = pd.DataFrame(item_comparison_rows)
|
||||
|
||||
# Styling setup
|
||||
font_family = "Segoe UI"
|
||||
header_font = Font(name=font_family, size=11, bold=True, color="FFFFFF")
|
||||
regular_font = Font(name=font_family, size=10)
|
||||
bold_font = Font(name=font_family, size=10, bold=True)
|
||||
title_font = Font(name=font_family, size=16, bold=True, color="1F4E78")
|
||||
|
||||
header_fill = PatternFill(start_color="1F4E78", end_color="1F4E78", fill_type="solid") # Dark Blue
|
||||
zebra_fill = PatternFill(start_color="F2F5F8", end_color="F2F5F8", fill_type="solid") # Zebra light blue-gray
|
||||
match_fill = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Light green
|
||||
mismatch_fill = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Light orange
|
||||
|
||||
center_align = Alignment(horizontal="center", vertical="center")
|
||||
left_align = Alignment(horizontal="left", vertical="center")
|
||||
right_align = Alignment(horizontal="right", vertical="center")
|
||||
|
||||
thin_side = Side(border_style="thin", color="D9D9D9")
|
||||
cell_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
|
||||
|
||||
# Save to Excel
|
||||
os.makedirs(os.path.dirname(xlsx_file), exist_ok=True)
|
||||
|
||||
writer = None
|
||||
for attempt in range(1, 10):
|
||||
try:
|
||||
writer = pd.ExcelWriter(xlsx_file, engine='openpyxl')
|
||||
break
|
||||
except PermissionError:
|
||||
base_dir = os.path.dirname(xlsx_file)
|
||||
filename = os.path.basename(xlsx_file)
|
||||
name, ext = os.path.splitext(filename)
|
||||
if "_" in name and name.split("_")[-1].isdigit():
|
||||
name = "_".join(name.split("_")[:-1])
|
||||
xlsx_file = os.path.join(base_dir, f"{name}_{attempt}{ext}")
|
||||
|
||||
if writer is None:
|
||||
print("Error: Could not open the Excel writer because the file is locked.")
|
||||
return
|
||||
|
||||
with writer:
|
||||
df_summary.to_excel(writer, sheet_name='Summary Accuracy', index=False, startrow=3)
|
||||
df_sxs.to_excel(writer, sheet_name='Side-by-Side Comparison', index=False)
|
||||
df_docs.to_excel(writer, sheet_name='Header Field Comparison', index=False)
|
||||
df_items.to_excel(writer, sheet_name='Item SKU Comparison', index=False)
|
||||
|
||||
# 1. Style Summary Sheet with a Title Banner
|
||||
ws_sum = writer.sheets['Summary Accuracy']
|
||||
ws_sum.views.sheetView[0].showGridLines = True
|
||||
ws_sum.cell(row=1, column=1, value="AI OCR vs. Manual Ground Truth Accuracy Report").font = title_font
|
||||
ws_sum.row_dimensions[1].height = 30
|
||||
|
||||
# Style Summary Table Headers
|
||||
max_col_sum = df_summary.shape[1]
|
||||
for col in range(1, max_col_sum + 1):
|
||||
cell = ws_sum.cell(row=4, column=col)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = center_align
|
||||
cell.border = cell_border
|
||||
|
||||
# Style Summary Data
|
||||
max_row_sum = ws_sum.max_row
|
||||
for row in range(5, max_row_sum + 1):
|
||||
for col in range(1, max_col_sum + 1):
|
||||
cell = ws_sum.cell(row=row, column=col)
|
||||
cell.font = regular_font
|
||||
cell.border = cell_border
|
||||
if col == 1:
|
||||
cell.alignment = left_align
|
||||
else:
|
||||
cell.alignment = right_align
|
||||
|
||||
# Zebra style
|
||||
if row % 2 == 0 and row != max_row_sum:
|
||||
cell.fill = zebra_fill
|
||||
|
||||
# Format percentage
|
||||
if col == 5 and isinstance(cell.value, (int, float)):
|
||||
cell.number_format = '0.00"%"'
|
||||
|
||||
# Bold overall total row
|
||||
if row == max_row_sum:
|
||||
for col in range(1, max_col_sum + 1):
|
||||
c = ws_sum.cell(row=row, column=col)
|
||||
c.font = bold_font
|
||||
c.fill = match_fill if overall_accuracy > 80 else mismatch_fill
|
||||
|
||||
# Auto-adjust column width for Summary
|
||||
for col in ws_sum.columns:
|
||||
max_len = max(len(str(cell.value or '')) for cell in col)
|
||||
col_letter = get_column_letter(col[0].column)
|
||||
ws_sum.column_dimensions[col_letter].width = max(max_len + 4, 12)
|
||||
|
||||
# Style detail worksheets
|
||||
for sheet_name in ['Side-by-Side Comparison', 'Header Field Comparison', 'Item SKU Comparison']:
|
||||
ws = writer.sheets[sheet_name]
|
||||
ws.views.sheetView[0].showGridLines = True
|
||||
max_row = ws.max_row
|
||||
max_col = ws.max_column
|
||||
|
||||
# Header row styling
|
||||
for col in range(1, max_col + 1):
|
||||
cell = ws.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = center_align
|
||||
cell.border = cell_border
|
||||
|
||||
# Data rows styling
|
||||
for row in range(2, max_row + 1):
|
||||
is_zebra = (row % 2 == 0)
|
||||
|
||||
# For Side-by-Side Comparison
|
||||
if sheet_name == 'Side-by-Side Comparison':
|
||||
for col in range(1, max_col + 1):
|
||||
cell = ws.cell(row=row, column=col)
|
||||
cell.font = regular_font
|
||||
cell.border = cell_border
|
||||
|
||||
if col == 1:
|
||||
cell.alignment = left_align
|
||||
if is_zebra:
|
||||
cell.fill = zebra_fill
|
||||
else:
|
||||
# Apply alignments and color mismatch/match
|
||||
# Format of headers:
|
||||
# Col 1: Filename
|
||||
# Col 2: PO AI, Col 3: PO Manual, Col 4: PO Status
|
||||
# ... and so on
|
||||
# So status is at index col where (col - 1) % 3 == 0 (4, 7, 10, 13, 16, 19, 22, 25)
|
||||
col_pos = col - 1
|
||||
if col_pos % 3 == 0: # This is a Status column
|
||||
status_val = cell.value
|
||||
cell.alignment = center_align
|
||||
if status_val == "Match":
|
||||
cell.fill = match_fill
|
||||
else:
|
||||
cell.fill = mismatch_fill
|
||||
else: # This is AI or Manual value column
|
||||
cell.alignment = left_align
|
||||
# Match background of the cell with its corresponding status cell (two columns to the right if AI, one if Manual)
|
||||
status_col_idx = col + (2 if col_pos % 3 == 1 else 1)
|
||||
status_val = ws.cell(row=row, column=status_col_idx).value
|
||||
if status_val == "Match":
|
||||
if is_zebra:
|
||||
# Let's keep it subtle
|
||||
pass
|
||||
else:
|
||||
# Highlight mismatches clearly
|
||||
cell.fill = mismatch_fill
|
||||
|
||||
# For vertical comparison sheets
|
||||
else:
|
||||
# Match column is the last column
|
||||
match_cell = ws.cell(row=row, column=max_col)
|
||||
match_val = match_cell.value
|
||||
|
||||
for col in range(1, max_col + 1):
|
||||
cell = ws.cell(row=row, column=col)
|
||||
cell.font = regular_font
|
||||
cell.border = cell_border
|
||||
|
||||
# Apply alignments based on column
|
||||
if col in [1, 2, 3, 4]:
|
||||
cell.alignment = left_align
|
||||
else:
|
||||
cell.alignment = center_align
|
||||
|
||||
# Color match / mismatch
|
||||
if match_val == "Match":
|
||||
cell.fill = match_fill
|
||||
elif match_val == "Mismatch":
|
||||
cell.fill = mismatch_fill
|
||||
elif is_zebra:
|
||||
cell.fill = zebra_fill
|
||||
|
||||
# Auto-fit columns
|
||||
for col in ws.columns:
|
||||
max_len = 0
|
||||
for cell in col:
|
||||
val_str = str(cell.value or '')
|
||||
# Limit long text like Alamat from making column excessively wide
|
||||
if sheet_name == 'Side-by-Side Comparison' and cell.column in [22, 23]: # Alamat
|
||||
max_len = max(max_len, min(len(val_str), 30))
|
||||
elif sheet_name == 'Header Field Comparison' and cell.column in [3, 4]: # Values
|
||||
max_len = max(max_len, min(len(val_str), 40))
|
||||
else:
|
||||
max_len = max(max_len, len(val_str))
|
||||
col_letter = get_column_letter(col[0].column)
|
||||
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
|
||||
|
||||
print("\n=== Accuracy Report Summary ===")
|
||||
print(df_summary.to_string(index=False))
|
||||
print("===============================\n")
|
||||
print(f"Comparison report generated at {xlsx_file}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,6 +7,7 @@ const nextConfig: NextConfig = {
|
||||
"*.trycloudflare.com",
|
||||
"*.ngrok.io",
|
||||
"*.ngrok-free.app",
|
||||
"*.ngrok-free.dev",
|
||||
"*.ngrok.app",
|
||||
"*.loca.lt",
|
||||
"*.serveo.net",
|
||||
|
||||
@@ -7,35 +7,19 @@ 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"
|
||||
"IMG_20260701_134810.jpg"
|
||||
];
|
||||
|
||||
function postJSON(url, body) {
|
||||
@@ -203,6 +187,10 @@ async function main() {
|
||||
status: 'Success',
|
||||
tilt: tiltStr,
|
||||
unwarped: unwarpedStr,
|
||||
rawMarkdown: resData.postProcessingDetails?.rawMarkdown || "",
|
||||
layer1RawRegex: resData.postProcessingDetails?.layer1RawRegex || {},
|
||||
layer2Sanitized: resData.postProcessingDetails?.layer2Sanitized || {},
|
||||
layer3Final: resData.postProcessingDetails?.layer3Final || {},
|
||||
metadata: docMeta,
|
||||
items: resData.items || []
|
||||
}) + '\n');
|
||||
@@ -240,6 +228,16 @@ async function main() {
|
||||
console.error('Failed to combine test reports:', combineErr);
|
||||
}
|
||||
|
||||
// Compile JSONL into the final JSON v2
|
||||
try {
|
||||
const lines = fs.readFileSync(jsonlFile, 'utf8').split('\n').filter(Boolean);
|
||||
const results = lines.map(line => JSON.parse(line));
|
||||
fs.writeFileSync('/uploads/ai_results_v2.json', JSON.stringify(results, null, 2));
|
||||
console.log('Compiled results saved to /uploads/ai_results_v2.json');
|
||||
} catch (compileErr) {
|
||||
console.error('Failed to compile results into JSON v2:', compileErr);
|
||||
}
|
||||
|
||||
console.log('Batch test completed. Report written to /uploads/test_images_report.md');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* run_full_test.js
|
||||
*
|
||||
* Runs OCR parsing against ALL images in backend/sources/test-images/
|
||||
* and captures every pipeline stage for analysis:
|
||||
* - rawMarkdown : raw text from PaddleOCR layout parser
|
||||
* - layer1RawRegex: output of parseDOMetadata (regex extraction)
|
||||
* - layer2Sanitized: output of sanitizeParsedMetadata (format checks)
|
||||
* - layer3Final : final metadata after SKU triple-check + store resolution
|
||||
*
|
||||
* Outputs:
|
||||
* backend/sources/ai_results.json — machine-readable per-file results
|
||||
* backend/sources/ai_results.md — human-readable stage-by-stage breakdown
|
||||
*
|
||||
* Usage (from host machine, Docker must be running):
|
||||
* node run_full_test.js
|
||||
*
|
||||
* The script talks to the nginx gateway on port 8000.
|
||||
* To override: set env var BASE_URL=http://localhost:3000/api/parse
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:8000/api/parse';
|
||||
const TEST_IMAGES_DIR = path.resolve(__dirname, '../sources/test-images');
|
||||
const OUTPUT_JSON = path.resolve(__dirname, '../sources/ai_results.json');
|
||||
const OUTPUT_MD = path.resolve(__dirname, '../sources/ai_results.md');
|
||||
const REQUEST_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes per image
|
||||
|
||||
// ─── HTTP Helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function postJSON(url, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const lib = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
const options = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(bodyStr),
|
||||
},
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
body: data,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 60000}m`));
|
||||
});
|
||||
req.on('error', reject);
|
||||
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Markdown Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function mdSection(title, level = 2) {
|
||||
return `${'#'.repeat(level)} ${title}\n\n`;
|
||||
}
|
||||
|
||||
function mdCode(content, lang = '') {
|
||||
if (content === null || content === undefined) return '*null*\n\n';
|
||||
const str = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
return `\`\`\`${lang}\n${str}\n\`\`\`\n\n`;
|
||||
}
|
||||
|
||||
function mdField(label, value) {
|
||||
const display = (value === null || value === undefined || value === '') ? '*empty*' : `\`${value}\``;
|
||||
return `- **${label}**: ${display}\n`;
|
||||
}
|
||||
|
||||
function mdTable(headers, rows) {
|
||||
if (!rows || rows.length === 0) return '*No items.*\n\n';
|
||||
const sep = headers.map(() => '---');
|
||||
const lines = [
|
||||
`| ${headers.join(' | ')} |`,
|
||||
`| ${sep.join(' | ')} |`,
|
||||
...rows.map(r => `| ${r.map(c => String(c ?? '').replace(/\|/g, '\\|')).join(' | ')} |`),
|
||||
];
|
||||
return lines.join('\n') + '\n\n';
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
// Discover all image files
|
||||
let files;
|
||||
try {
|
||||
files = fs.readdirSync(TEST_IMAGES_DIR).filter(f =>
|
||||
/\.(jpe?g|png|webp|bmp)$/i.test(f)
|
||||
).sort();
|
||||
} catch (e) {
|
||||
console.error(`Cannot read test-images directory: ${TEST_IMAGES_DIR}`);
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error('No image files found in', TEST_IMAGES_DIR);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🚀 Starting batch test`);
|
||||
console.log(` API endpoint : ${BASE_URL}`);
|
||||
console.log(` Images found : ${files.length}`);
|
||||
console.log(` Output JSON : ${OUTPUT_JSON}`);
|
||||
console.log(` Output MD : ${OUTPUT_MD}`);
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
const jsonResults = [];
|
||||
const mdParts = [];
|
||||
const summaryRows = [];
|
||||
|
||||
// ── Markdown document header ──────────────────────────────────────────────
|
||||
mdParts.push(
|
||||
`# OCR Batch Test Report\n\n`,
|
||||
`> Generated: ${new Date().toISOString()}\n`,
|
||||
`> API: \`${BASE_URL}\`\n`,
|
||||
`> Images: **${files.length}** files from \`backend/sources/test-images/\`\n\n`,
|
||||
`---\n\n`,
|
||||
`## Summary\n\n`,
|
||||
'<!-- summary_table_placeholder -->\n\n',
|
||||
`---\n\n`,
|
||||
`## Stage-by-Stage Results\n\n`,
|
||||
);
|
||||
const summaryPlaceholderIndex = mdParts.indexOf('<!-- summary_table_placeholder -->\n\n');
|
||||
|
||||
// ── Process each file ────────────────────────────────────────────────────
|
||||
for (let idx = 0; idx < files.length; idx++) {
|
||||
const file = files[idx];
|
||||
const num = `[${String(idx + 1).padStart(2, '0')}/${files.length}]`;
|
||||
process.stdout.write(`${num} ${file} ... `);
|
||||
|
||||
const entry = {
|
||||
index: idx + 1,
|
||||
filename: file,
|
||||
status: 'pending',
|
||||
tilt: null,
|
||||
unwarped: null,
|
||||
// pipeline stages
|
||||
rawMarkdown: null,
|
||||
layer1RawRegex: null,
|
||||
layer2Sanitized: null,
|
||||
layer3Final: null,
|
||||
items: [],
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const res = await postJSON(BASE_URL, { filename: file });
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
|
||||
if (!res.ok) {
|
||||
process.stdout.write(`❌ HTTP ${res.status} (${elapsed}s)\n`);
|
||||
entry.status = 'http_error';
|
||||
entry.error = `HTTP ${res.status}: ${res.body}`;
|
||||
} else {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(res.body);
|
||||
} catch (_) {
|
||||
entry.status = 'json_parse_error';
|
||||
entry.error = 'Response is not valid JSON';
|
||||
process.stdout.write(`❌ JSON parse error (${elapsed}s)\n`);
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (data.error) {
|
||||
process.stdout.write(`⚠️ API error: ${data.error} (${elapsed}s)\n`);
|
||||
entry.status = 'api_error';
|
||||
entry.error = data.error;
|
||||
} else {
|
||||
const pipelineInfo = (data.result || {}).pipeline_info || {};
|
||||
entry.status = 'success';
|
||||
entry.tilt = pipelineInfo.tilt !== undefined ? +parseFloat(pipelineInfo.tilt).toFixed(2) : null;
|
||||
entry.unwarped = pipelineInfo.unwarped ?? null;
|
||||
|
||||
const ppd = data.postProcessingDetails || {};
|
||||
entry.rawMarkdown = ppd.rawMarkdown ?? null;
|
||||
entry.layer1RawRegex = ppd.layer1RawRegex ?? null;
|
||||
entry.layer2Sanitized = ppd.layer2Sanitized ?? null;
|
||||
entry.layer3Final = ppd.layer3Final ?? null;
|
||||
entry.items = data.items ?? [];
|
||||
|
||||
const itemCount = entry.items.length;
|
||||
process.stdout.write(`✅ ${itemCount} item(s), tilt=${entry.tilt ?? 'N/A'}° (${elapsed}s)\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
process.stdout.write(`💥 ${err.message}\n`);
|
||||
entry.status = 'exception';
|
||||
entry.error = err.message;
|
||||
}
|
||||
|
||||
jsonResults.push(entry);
|
||||
|
||||
// ── Build per-file markdown section ─────────────────────────────────────
|
||||
const statusEmoji = {
|
||||
success: '✅',
|
||||
http_error: '❌',
|
||||
api_error: '⚠️',
|
||||
json_parse_error: '❌',
|
||||
exception: '💥',
|
||||
}[entry.status] || '❓';
|
||||
|
||||
let fileMd = '';
|
||||
fileMd += `### ${idx + 1}. \`${file}\`\n\n`;
|
||||
fileMd += `**Status**: ${statusEmoji} \`${entry.status}\`\n\n`;
|
||||
|
||||
if (entry.status !== 'success') {
|
||||
fileMd += `> **Error**: ${entry.error}\n\n`;
|
||||
fileMd += `---\n\n`;
|
||||
summaryRows.push([idx + 1, `\`${file}\``, `${statusEmoji} ${entry.status}`, 'N/A', 'N/A', 'N/A', 'N/A']);
|
||||
mdParts.push(fileMd);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Stage 0: Pipeline Info ────────────────────────────────────────────────
|
||||
fileMd += `#### 📐 Stage 0 — Pipeline Info\n\n`;
|
||||
fileMd += mdField('Tilt detected', entry.tilt !== null ? `${entry.tilt}°` : 'N/A');
|
||||
fileMd += mdField('Auto-unwarped', entry.unwarped !== null ? (entry.unwarped ? 'Yes' : 'No') : 'N/A');
|
||||
fileMd += '\n';
|
||||
|
||||
// ── Stage 1: Raw Markdown from OCR ───────────────────────────────────────
|
||||
fileMd += `#### 📄 Stage 1 — Raw OCR Markdown\n\n`;
|
||||
fileMd += `*This is the raw text extracted by PaddleOCR layout parser before any post-processing.*\n\n`;
|
||||
if (entry.rawMarkdown) {
|
||||
fileMd += mdCode(entry.rawMarkdown, 'markdown');
|
||||
} else {
|
||||
fileMd += '*No raw markdown captured.*\n\n';
|
||||
}
|
||||
|
||||
// ── Stage 2: Layer 1 — Regex Extraction ──────────────────────────────────
|
||||
fileMd += `#### 🔍 Stage 2 — Layer 1: Regex Extraction (\`parseDOMetadata\`)\n\n`;
|
||||
fileMd += `*Regex patterns are applied to raw markdown to extract header fields and item rows.*\n\n`;
|
||||
if (entry.layer1RawRegex) {
|
||||
const l1 = entry.layer1RawRegex;
|
||||
fileMd += `**Header fields (raw regex output):**\n\n`;
|
||||
fileMd += mdField('noDO', l1.noDO);
|
||||
fileMd += mdField('noPO', l1.noPO);
|
||||
fileMd += mdField('noSO', l1.noSO);
|
||||
fileMd += mdField('tanggal', l1.tanggal);
|
||||
fileMd += mdField('vendorInfo', l1.vendorInfo);
|
||||
fileMd += mdField('customerInfo', l1.customerInfo);
|
||||
fileMd += mdField('alamat', l1.alamat);
|
||||
fileMd += mdField('orderUntuk', l1.orderUntuk);
|
||||
fileMd += mdField('platTruk', l1.platTruk);
|
||||
fileMd += '\n';
|
||||
|
||||
fileMd += `**Raw items (${(l1.items || []).length} row(s)):**\n\n`;
|
||||
fileMd += mdTable(
|
||||
['kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
|
||||
(l1.items || []).map(it => [it.kodeBarang, it.namaBarang, it.banyak, it.jumlah])
|
||||
);
|
||||
} else {
|
||||
fileMd += '*Layer 1 data not captured.*\n\n';
|
||||
}
|
||||
|
||||
// ── Stage 3: Layer 2 — Sanitized ─────────────────────────────────────────
|
||||
fileMd += `#### 🧹 Stage 3 — Layer 2: Sanitized (\`sanitizeParsedMetadata\`)\n\n`;
|
||||
fileMd += `*Strict format enforcement: corrects date formats, trims whitespace, enforces field constraints.*\n\n`;
|
||||
if (entry.layer2Sanitized) {
|
||||
const l2 = entry.layer2Sanitized;
|
||||
fileMd += `**Header fields (after sanitization):**\n\n`;
|
||||
fileMd += mdField('noDO', l2.noDO);
|
||||
fileMd += mdField('noPO', l2.noPO);
|
||||
fileMd += mdField('noSO', l2.noSO);
|
||||
fileMd += mdField('tanggal', l2.tanggal);
|
||||
fileMd += mdField('vendorInfo', l2.vendorInfo);
|
||||
fileMd += mdField('customerInfo', l2.customerInfo);
|
||||
fileMd += mdField('alamat', l2.alamat);
|
||||
fileMd += mdField('orderUntuk', l2.orderUntuk);
|
||||
fileMd += mdField('platTruk', l2.platTruk);
|
||||
fileMd += '\n';
|
||||
|
||||
fileMd += `**Sanitized items (${(l2.items || []).length} row(s)):**\n\n`;
|
||||
fileMd += mdTable(
|
||||
['kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
|
||||
(l2.items || []).map(it => [it.kodeBarang, it.namaBarang, it.banyak, it.jumlah])
|
||||
);
|
||||
} else {
|
||||
fileMd += '*Layer 2 data not captured.*\n\n';
|
||||
}
|
||||
|
||||
// ── Stage 4: Layer 3 — Final (SKU triple-check + store resolution) ────────
|
||||
fileMd += `#### ✅ Stage 4 — Layer 3: Final (\`SKU triple-check + store resolution\`)\n\n`;
|
||||
fileMd += `*SKU validated against master list (score ≥ 0.6 threshold). Items with noise SKU codes are filtered out. Store resolved from DB.*\n\n`;
|
||||
if (entry.layer3Final) {
|
||||
const l3 = entry.layer3Final;
|
||||
fileMd += `**Final metadata:**\n\n`;
|
||||
fileMd += mdField('noDO', l3.noDO);
|
||||
fileMd += mdField('noPO', l3.noPO);
|
||||
fileMd += mdField('noSO', l3.noSO);
|
||||
fileMd += mdField('tanggal', l3.tanggal);
|
||||
fileMd += mdField('vendorInfo', l3.vendorInfo);
|
||||
fileMd += mdField('customerInfo', l3.customerInfo);
|
||||
fileMd += mdField('alamat', l3.alamat);
|
||||
fileMd += mdField('orderUntuk', l3.orderUntuk);
|
||||
fileMd += mdField('platTruk', l3.platTruk);
|
||||
fileMd += '\n';
|
||||
|
||||
fileMd += `**Final items after SKU validation (${(l3.items || []).length} row(s)):**\n\n`;
|
||||
fileMd += mdTable(
|
||||
['kodeBarangOriginal', 'kodeBarang (corrected)', 'namaBarang', 'banyak', 'jumlah'],
|
||||
(l3.items || []).map(it => [
|
||||
it.kodeBarangOriginal ?? it.kodeBarang,
|
||||
it.kodeBarang,
|
||||
it.namaBarang,
|
||||
it.banyak,
|
||||
it.jumlah
|
||||
])
|
||||
);
|
||||
} else {
|
||||
fileMd += '*Layer 3 data not captured.*\n\n';
|
||||
}
|
||||
|
||||
// ── Stage 5: Final submitted items (from root items[]) ───────────────────
|
||||
fileMd += `#### 🗃️ Stage 5 — Submitted Items (ready-to-use JSON)\n\n`;
|
||||
fileMd += `*These are the items actually returned to the caller and saved to the database.*\n\n`;
|
||||
fileMd += mdTable(
|
||||
['kodeBarangOriginal', 'kodeBarang', 'namaBarang', 'banyak', 'jumlah'],
|
||||
(entry.items || []).map(it => [
|
||||
it.kodeBarangOriginal ?? it.kodeBarang,
|
||||
it.kodeBarang,
|
||||
it.namaBarang,
|
||||
it.banyak,
|
||||
it.jumlah
|
||||
])
|
||||
);
|
||||
|
||||
fileMd += `---\n\n`;
|
||||
|
||||
// ── Summary row ──────────────────────────────────────────────────────────
|
||||
const l3meta = entry.layer3Final || {};
|
||||
summaryRows.push([
|
||||
idx + 1,
|
||||
`\`${file}\``,
|
||||
`${statusEmoji} success`,
|
||||
entry.tilt !== null ? `${entry.tilt}°` : 'N/A',
|
||||
entry.unwarped !== null ? (entry.unwarped ? 'Yes' : 'No') : 'N/A',
|
||||
`\`${l3meta.noDO ?? 'N/A'}\``,
|
||||
`\`${l3meta.noPO ?? 'N/A'}\``,
|
||||
`${entry.items.length}`,
|
||||
]);
|
||||
|
||||
mdParts.push(fileMd);
|
||||
}
|
||||
|
||||
// ── Inject summary table ──────────────────────────────────────────────────
|
||||
const summaryTable = mdTable(
|
||||
['#', 'Filename', 'Status', 'Tilt', 'Unwarped', 'DO', 'PO', 'Items'],
|
||||
summaryRows
|
||||
);
|
||||
mdParts[summaryPlaceholderIndex] = summaryTable;
|
||||
|
||||
// ── Write outputs ─────────────────────────────────────────────────────────
|
||||
const jsonOut = JSON.stringify(jsonResults, null, 2);
|
||||
fs.writeFileSync(OUTPUT_JSON, jsonOut, 'utf8');
|
||||
console.log(`\n✅ JSON saved → ${OUTPUT_JSON}`);
|
||||
|
||||
const mdOut = mdParts.join('');
|
||||
fs.writeFileSync(OUTPUT_MD, mdOut, 'utf8');
|
||||
console.log(`✅ MD saved → ${OUTPUT_MD}`);
|
||||
|
||||
// ── Final stats ───────────────────────────────────────────────────────────
|
||||
const succeeded = jsonResults.filter(r => r.status === 'success').length;
|
||||
const failed = jsonResults.length - succeeded;
|
||||
console.log('\n─'.repeat(60));
|
||||
console.log(` Total : ${jsonResults.length}`);
|
||||
console.log(` Success: ${succeeded}`);
|
||||
console.log(` Failed : ${failed}`);
|
||||
console.log('─'.repeat(60));
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -50,6 +50,8 @@ export async function POST(req: NextRequest) {
|
||||
let isUpload = false;
|
||||
let isSample = true;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
filePath = path.join(process.cwd(), "public", "test-images", safeFile);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
filePath = path.join("/uploads", safeFile);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
@@ -58,6 +60,7 @@ export async function POST(req: NextRequest) {
|
||||
isUpload = true;
|
||||
isSample = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Read file and compute content hash
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
@@ -156,6 +159,7 @@ export async function POST(req: NextRequest) {
|
||||
const rawMetadata = parseDOMetadata(markdownText);
|
||||
// Second-layer sanity check: enforces strict field formats and auto-corrects anomalies
|
||||
const docMetadata = sanitizeParsedMetadata(rawMetadata as any);
|
||||
const docMetadataSanitized = JSON.parse(JSON.stringify(docMetadata));
|
||||
|
||||
// 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");
|
||||
@@ -256,7 +260,14 @@ export async function POST(req: NextRequest) {
|
||||
const numericQtyMatch = ocrQty.match(/[\d.,]+/);
|
||||
if (numericQtyMatch) {
|
||||
const numericQty = numericQtyMatch[0];
|
||||
const qtyUnitMatch = ocrQty.match(/[a-zA-Z]+/);
|
||||
const parsedQtyUnit = qtyUnitMatch ? qtyUnitMatch[0].toUpperCase() : "";
|
||||
const isQtyUnitValid = ["KRG", "BOX", "BAG", "PAC", "PC", "KG", "PCS"].includes(parsedQtyUnit);
|
||||
|
||||
const standardOuter = bestMatch.jenis_outer || "";
|
||||
if (isQtyUnitValid) {
|
||||
item.banyak = `${numericQty} ${parsedQtyUnit}`;
|
||||
} else if (standardOuter) {
|
||||
if (standardOuter.toLowerCase() === "karung") {
|
||||
item.banyak = `${numericQty} KRG`;
|
||||
} else if (standardOuter.toLowerCase() === "box") {
|
||||
@@ -266,14 +277,29 @@ export async function POST(req: NextRequest) {
|
||||
} else {
|
||||
item.banyak = `${numericQty} ${standardOuter.toUpperCase()}`;
|
||||
}
|
||||
} else {
|
||||
item.banyak = ocrQty;
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
const priceUnitMatch = ocrPrice.match(/[a-zA-Z]+/);
|
||||
const parsedPriceUnit = priceUnitMatch ? priceUnitMatch[0].toUpperCase() : "";
|
||||
const isPriceUnitValid = ["KRG", "BOX", "BAG", "PAC", "PC", "KG", "PCS"].includes(parsedPriceUnit);
|
||||
|
||||
const standardInner = bestMatch.standar_jumlah || "";
|
||||
if (standardInner.toLowerCase() === "pc" || standardInner.toLowerCase() === "pcs") {
|
||||
item.jumlah = `${numericPrice} ${standardInner.toUpperCase()}`;
|
||||
} else if (isPriceUnitValid) {
|
||||
item.jumlah = `${numericPrice} ${parsedPriceUnit}`;
|
||||
} else if (standardInner) {
|
||||
item.jumlah = `${numericPrice} ${standardInner.toUpperCase()}`;
|
||||
} else {
|
||||
item.jumlah = ocrPrice;
|
||||
}
|
||||
}
|
||||
|
||||
checkedItems.push(item);
|
||||
@@ -371,12 +397,18 @@ export async function POST(req: NextRequest) {
|
||||
]);
|
||||
}
|
||||
|
||||
// Return wrapped success response with items, flagged, remarks
|
||||
// Return wrapped success response with items, flagged, remarks, and intermediate post-processing details
|
||||
return NextResponse.json({
|
||||
errorCode: 0,
|
||||
errorMsg: "Success",
|
||||
result: pipelineResult,
|
||||
items: docMetadata.items,
|
||||
postProcessingDetails: {
|
||||
rawMarkdown: markdownText,
|
||||
layer1RawRegex: rawMetadata,
|
||||
layer2Sanitized: docMetadataSanitized,
|
||||
layer3Final: docMetadata
|
||||
},
|
||||
flagged: {},
|
||||
remarks: {}
|
||||
});
|
||||
|
||||
@@ -29,20 +29,44 @@ function runTests() {
|
||||
{ name: "Date (Asli/Copy) prefix noise", markdown: "Tanggal: (Asli/Copy) 15 May 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "15 May 2026" } },
|
||||
{ name: "Date junk suffix cut", markdown: "Tanggal: 25 May 2020 (Printed by system)", expected: { tanggal: "25 May 2020" } },
|
||||
{ name: "Date bad OCR month Hv -> Not Found", markdown: "Tanggal:25 Hv 2024\nNo.SO : 1601001206", expected: { tanggal: "Not Found" } },
|
||||
{ name: "Date single digit 7 May 2026 -> 07 May 2026", markdown: "Tanggal: 7 May 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "07 May 2026" } },
|
||||
{ name: "Date single digit 4 Apr 2026 -> 04 April 2026", markdown: "Tanggal: 4 Apr 2026\nNo. PO : PO/26/0000178435", expected: { tanggal: "04 April 2026" } },
|
||||
{ name: "00117709 before Tanggal must not pollute date", markdown: "00117709\nTanggal:25May2020\nNo.SO : 1091721200\nNo. DO : 1657943004\nNo. PO : F0/26/0000190929", expected: { tanggal: "25 May 2020" } },
|
||||
{ name: "Plate B 9427 UXT", markdown: "Truck No. B 9427 UXT\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9427 UXT" } },
|
||||
{ name: "Plate B-9999-XYZ dash", markdown: "No. Polisi: B-9999-XYZ\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9999 XYZ" } },
|
||||
{ name: "Plate ignore PO/SO prefix", markdown: "Plate is PO 1234 SO but real truck is A 123 B\nNo. PO : PO/26/0000178435", expected: { platTruk: "A 123 B" } },
|
||||
{ name: "Plate B9427UXT adjacent", markdown: "No Polisi B9427UXT\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9427 UXT" } },
|
||||
{ name: "Plate real doc B 9723 CXS", markdown: "Truck No.\nB 9723 CXS\nWH 01 / 01\nNo. PO : PO/26/0000178435", expected: { platTruk: "B 9723 CXS" } },
|
||||
{
|
||||
name: "Table column shift alignment correction",
|
||||
markdown: "No. PO : PO/26/0000178435\n" +
|
||||
"<table>" +
|
||||
"<tr><td>Kode Barang</td><td>Nama Barang</td><td>Banyak</td><td>Jumlah</td></tr>" +
|
||||
"<tr><td></td><td>Item A</td><td>2 KRG</td><td>40 PC</td></tr>" +
|
||||
"<tr><td>11310014</td><td>Item B</td><td>2 KRG</td><td>40 PC</td></tr>" +
|
||||
"<tr><td>11310024</td><td>Item C</td><td>1 BOX</td><td>10 KG</td></tr>" +
|
||||
"<tr><td>11720055</td><td></td><td></td><td></td></tr>" +
|
||||
"</table>",
|
||||
expected: {
|
||||
items: [
|
||||
{ kodeBarang: "11310014", namaBarang: "Item A", banyak: "2 KRG", jumlah: "40 PC" },
|
||||
{ kodeBarang: "11310024", namaBarang: "Item B", banyak: "2 KRG", jumlah: "40 PC" },
|
||||
{ kodeBarang: "11720055", namaBarang: "Item C", banyak: "1 BOX", jumlah: "10 KG" }
|
||||
]
|
||||
} as any
|
||||
}
|
||||
];
|
||||
|
||||
for (const t of parseTests) {
|
||||
try {
|
||||
const result = parseDOMetadata(t.markdown) as any;
|
||||
for (const [key, val] of Object.entries(t.expected)) {
|
||||
if (key === "items") {
|
||||
assert.deepStrictEqual(result.items, val);
|
||||
} else {
|
||||
assert.strictEqual(result[key], val, `field [${key}] expected "${val}" got "${result[key]}"`);
|
||||
}
|
||||
}
|
||||
console.log(`[PASS] ${t.name}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[FAIL] ${t.name}: ${err.message}`);
|
||||
@@ -56,6 +80,7 @@ function runTests() {
|
||||
// tanggal valid
|
||||
{ name: "sanitize: valid tanggal 30 June 2026 passes", input: { tanggal: "30 June 2026" }, expected: { tanggal: "30 June 2026" } },
|
||||
{ name: "sanitize: valid tanggal 25 May 2020 passes", input: { tanggal: "25 May 2020" }, expected: { tanggal: "25 May 2020" } },
|
||||
{ name: "sanitize: single digit tanggal 4 April 2026 -> 04 April 2026", input: { tanggal: "4 April 2026" }, expected: { tanggal: "04 April 2026" } },
|
||||
// tanggal invalid
|
||||
{ name: "sanitize: tanggal bad month Hv -> Not Found", input: { tanggal: "25 Hv 2024" }, expected: { tanggal: "Not Found" } },
|
||||
{ name: "sanitize: tanggal as number 0011770 -> Not Found", input: { tanggal: "0011770" }, expected: { tanggal: "Not Found" } },
|
||||
|
||||
@@ -26,7 +26,7 @@ function cleanAndFormatPO(raw: string, currentYearLastTwo: string): string {
|
||||
// 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 = /^(?:[A-Z0-9]{1,4})[ \t]*[\/\-][ \t]*(?:\d{1,4}[ \t]*[\/\-][ \t]*)?(\d{4,})/i;
|
||||
const withSlash = /^(?:[A-Z0-9]+)[ \t]*[\/\-][ \t]*(?:\d{1,4}[ \t]*[\/\-][ \t]*)?(\d{4,})/i;
|
||||
const m1 = stripped.match(withSlash);
|
||||
if (m1) {
|
||||
return `PO/${currentYearLastTwo}/${m1[1]}`;
|
||||
@@ -93,7 +93,20 @@ function cleanDateValue(raw: string): string {
|
||||
const cleaned = raw.trim();
|
||||
if (cleaned === "Not Found" || cleaned === "") return "Not Found";
|
||||
|
||||
const today = new Date();
|
||||
// Try unified regex first
|
||||
const unifiedPattern = /(\d{1,2})[ \t\-\/]*(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|maret|apr(?:il)?|may|mei|jun(?:[ei])?|jul(?:[ii])?|aug(?:ustus)?|agt|ags|sep(?:tember)?|oct(?:ober)?|okt|nov(?:ember)?|nop(?:ember)?|dec(?:ember)?|des(?:ember)?)[ \t\-\/]*(20\d{2})/i;
|
||||
const match = cleaned.match(unifiedPattern);
|
||||
if (match) {
|
||||
const day = parseInt(match[1], 10);
|
||||
const monthKey = match[2].toLowerCase();
|
||||
const year = parseInt(match[3], 10);
|
||||
const month = MONTHS_MAP[monthKey] || monthKey;
|
||||
if (day >= 1 && day <= 31 && year >= 2010 && year <= 2035) {
|
||||
const paddedDay = day.toString().padStart(2, '0');
|
||||
return `${paddedDay} ${month} ${year}`;
|
||||
}
|
||||
}
|
||||
|
||||
let day: number | null = null;
|
||||
let monthStr: string | null = null;
|
||||
let year: number | null = null;
|
||||
@@ -119,12 +132,14 @@ function cleanDateValue(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (!monthStr) return "Not Found";
|
||||
|
||||
// 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);
|
||||
const dayMatches = textForDay.match(/\b(\d{1,2})\b/g) || textForDay.match(/(\d{1,2})/g);
|
||||
if (dayMatches) {
|
||||
for (const matchStr of dayMatches) {
|
||||
const parsedDay = parseInt(matchStr, 10);
|
||||
@@ -135,17 +150,12 @@ function cleanDateValue(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (day !== null && year !== null) {
|
||||
const paddedDay = day.toString().padStart(2, '0');
|
||||
return `${paddedDay} ${monthStr} ${year}`;
|
||||
}
|
||||
|
||||
const finalDay = day !== null ? day : currentDay;
|
||||
const finalMonth = monthStr !== null ? monthStr : currentMonth;
|
||||
const finalYear = year !== null ? year : currentYear;
|
||||
|
||||
return `${finalDay} ${finalMonth} ${finalYear}`;
|
||||
return "Not Found";
|
||||
}
|
||||
|
||||
export function parseDOMetadata(markdown: string) {
|
||||
@@ -317,14 +327,20 @@ export function parseDOMetadata(markdown: string) {
|
||||
metadata.tanggal = dateMatch[0];
|
||||
}
|
||||
|
||||
// Only shift values if the PO field is shifted (i.e. PO field is a 10-digit number instead of PO format).
|
||||
// If the PO field has a valid PO format (e.g. PO/26/...), PO and DO are in correct places and not shifted.
|
||||
const isPoValid = /^PO\/[A-Z0-9\-\/]+\b/i.test(originalPO);
|
||||
|
||||
if (!isPoValid) {
|
||||
// 2. Real SO is the value that was matched under No. DO
|
||||
if (/^\d{10}$/.test(originalDO)) {
|
||||
metadata.noSO = originalDO;
|
||||
} else if (metadata.noSO === "Not Found" || isShortSO || isDateInSO) {
|
||||
const tenDigitRegex = /\b\d{10}\b/g;
|
||||
const m = cleanMarkdown.match(tenDigitRegex);
|
||||
if (m && m.length > 0) {
|
||||
metadata.noSO = m[0];
|
||||
const cleanMatches = m ? m.filter(val => !originalPO.includes(val)) : [];
|
||||
if (cleanMatches.length > 0) {
|
||||
metadata.noSO = cleanMatches[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,8 +350,15 @@ export function parseDOMetadata(markdown: string) {
|
||||
} else if (metadata.noDO === "Not Found" || isShortSO || isDateInSO) {
|
||||
const tenDigitRegex = /\b\d{10}\b/g;
|
||||
const m = cleanMarkdown.match(tenDigitRegex);
|
||||
if (m && m.length > 1) {
|
||||
metadata.noDO = m[1];
|
||||
const cleanMatches = m ? m.filter(val => !originalPO.includes(val)) : [];
|
||||
if (cleanMatches.length > 1) {
|
||||
metadata.noDO = cleanMatches[1];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// PO is valid and NOT shifted. SO was just empty or noise.
|
||||
if (isShortSO || isDateInSO) {
|
||||
metadata.noSO = "Not Found";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +443,11 @@ export function parseDOMetadata(markdown: string) {
|
||||
metadata.noDO = cleanFinalValue(metadata.noDO);
|
||||
|
||||
// Extract PO year dynamically from parsed document date (or default to current year if date not found)
|
||||
const docYearLastTwo = getYearFromDate(metadata.tanggal);
|
||||
const currentYY = new Date().getFullYear().toString().slice(-2);
|
||||
let docYearLastTwo = getYearFromDate(metadata.tanggal);
|
||||
if (docYearLastTwo !== currentYY) {
|
||||
docYearLastTwo = currentYY;
|
||||
}
|
||||
metadata.noPO = cleanAndFormatPO(cleanFinalValue(metadata.noPO), docYearLastTwo);
|
||||
|
||||
// Parse HTML tables for items
|
||||
@@ -536,6 +563,45 @@ export function parseDOMetadata(markdown: string) {
|
||||
}
|
||||
|
||||
if (isItemsTable) {
|
||||
// Check for SKU column downward shift of 1 row:
|
||||
// - First data row (r=1) has empty SKU, but has description
|
||||
// - A downstream row (shiftBoundaryRow) has SKU, but description, banyak, jumlah are empty
|
||||
if (numRows > 2 && kIdx !== -1 && nIdx !== -1) {
|
||||
const firstRowSku = (grid[1][kIdx] || "").trim();
|
||||
const firstRowDesc = (grid[1][nIdx] || "").trim();
|
||||
|
||||
if (firstRowSku === "" && firstRowDesc !== "") {
|
||||
let shiftBoundaryRow = -1;
|
||||
for (let r = 2; r < numRows; r++) {
|
||||
const skuVal = (grid[r][kIdx] || "").trim();
|
||||
|
||||
let othersEmpty = true;
|
||||
for (let c = 0; c < maxCols; c++) {
|
||||
if (c !== kIdx) {
|
||||
const val = (grid[r][c] || "").trim();
|
||||
if (val !== "") {
|
||||
othersEmpty = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skuVal !== "" && /^\d{8}$/.test(skuVal) && othersEmpty) {
|
||||
shiftBoundaryRow = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shiftBoundaryRow !== -1) {
|
||||
console.log(`Detected shifted SKU column in table. Shift boundary row: ${shiftBoundaryRow}. Correcting alignment...`);
|
||||
for (let r = 1; r < shiftBoundaryRow; r++) {
|
||||
grid[r][kIdx] = grid[r + 1][kIdx];
|
||||
}
|
||||
grid[shiftBoundaryRow][kIdx] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let r = 1; r < numRows; r++) {
|
||||
const cells = grid[r];
|
||||
const kodeCell = cells[kIdx] || "";
|
||||
@@ -544,7 +610,7 @@ export function parseDOMetadata(markdown: string) {
|
||||
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) {
|
||||
if (bIdx > 2 && bIdx - 1 !== nIdx && bIdx - 1 !== kIdx && headerCells[bIdx - 1] !== headerCells[nIdx]) {
|
||||
const qtyCell = cells[bIdx - 1] || "";
|
||||
const unitCell = cells[bIdx] || "";
|
||||
banyakCell = `${qtyCell} ${unitCell}`.trim();
|
||||
@@ -612,6 +678,11 @@ export function parseDOMetadata(markdown: string) {
|
||||
|
||||
// Clean checkmarks and extra spaces from banyak
|
||||
b = b.replace(/[✓☑]/g, "").replace(/\s+/g, " ").trim();
|
||||
b = b.replace(/\b80[xX]\b/g, "BOX")
|
||||
.replace(/\bB0[xX]\b/g, "BOX")
|
||||
.replace(/\b8[aA][gG]\b/g, "BAG")
|
||||
.replace(/\b[pP][aA][iI]\b/g, "PAC")
|
||||
.trim();
|
||||
|
||||
// Fallback for Banyak if empty or purely alphabetical unit
|
||||
if (!b) {
|
||||
@@ -626,11 +697,21 @@ export function parseDOMetadata(markdown: string) {
|
||||
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")) {
|
||||
} else if (code === "11640053" || name.includes("bone in leg")) {
|
||||
b = `${b} BAG`;
|
||||
} else if (code.startsWith("21") || code.startsWith("12") || name.includes("nasi") || name.includes("rice") || name.includes("nugget") || name.includes("sosis") || name.includes("sausage") || name.includes("fiesta") || name.includes("champ")) {
|
||||
b = `${b} BOX`;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean checkmarks and extra spaces from jumlah
|
||||
let cleanJ = j.replace(/[✓☑]/g, "").replace(/\s+/g, " ").trim();
|
||||
cleanJ = cleanJ.replace(/\b80[xX]\b/g, "BOX")
|
||||
.replace(/\bB0[xX]\b/g, "BOX")
|
||||
.replace(/\b8[aA][gG]\b/g, "BAG")
|
||||
.replace(/\b[pP][aA][iI]\b/g, "PAC")
|
||||
.trim();
|
||||
|
||||
const cleanKode = k.trim();
|
||||
const cleanNama = n.trim();
|
||||
if (cleanKode === "" && cleanNama === "") {
|
||||
@@ -641,7 +722,7 @@ export function parseDOMetadata(markdown: string) {
|
||||
kodeBarang: k,
|
||||
namaBarang: n,
|
||||
banyak: b,
|
||||
jumlah: j
|
||||
jumlah: cleanJ
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -671,6 +752,11 @@ export function extractPlatTruk(text: string): string {
|
||||
];
|
||||
|
||||
const isValidPrefix = (p: string) => arrayPlat.includes(p.toUpperCase());
|
||||
const FORBIDDEN_SUFFIXES = [
|
||||
"BOX", "KRG", "BAG", "PAC", "KG", "PC", "PCS", "LGT", "K", "EKR", "LTR", "BKS",
|
||||
"IDN", "WH", "PM", "TTD", "CPI", "HO", "NO", "GR", "G"
|
||||
];
|
||||
const isForbiddenSuffix = (s: string) => FORBIDDEN_SUFFIXES.includes(s.toUpperCase());
|
||||
|
||||
// 1. Look for explicit labels: No. Polisi, No. Pol, No. Polisi:, No. Kendaraan, Plat No, Plat, Truck No., etc.
|
||||
const labelRegex = /(?:No\.?\s*(?:Polisi|Pol|Kendaraan|Mobil|Truck|Pol\.?)|Plat(?:\s*No)?|Truck\s*No\.?)\s*[:\-.]?\s*\b([A-Z]{1,2})[ \t\-]*(\d{1,4})[ \t\-]*([A-Z]{1,3})\b/i;
|
||||
@@ -679,7 +765,7 @@ export function extractPlatTruk(text: string): string {
|
||||
const prefix = labelMatch[1].toUpperCase();
|
||||
const num = labelMatch[2];
|
||||
const suffix = labelMatch[3].toUpperCase();
|
||||
if (isValidPrefix(prefix)) {
|
||||
if (isValidPrefix(prefix) && !isForbiddenSuffix(suffix)) {
|
||||
return `${prefix} ${num} ${suffix}`;
|
||||
}
|
||||
}
|
||||
@@ -692,7 +778,7 @@ export function extractPlatTruk(text: string): string {
|
||||
const num = match[2];
|
||||
const suffix = match[3].toUpperCase();
|
||||
|
||||
if (isValidPrefix(prefix)) {
|
||||
if (isValidPrefix(prefix) && !isForbiddenSuffix(suffix)) {
|
||||
return `${prefix} ${num} ${suffix}`;
|
||||
}
|
||||
}
|
||||
@@ -771,9 +857,10 @@ export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata>
|
||||
const day = parseInt(dm[1], 10);
|
||||
const year = parseInt(dm[3], 10);
|
||||
if (day >= 1 && day <= 31 && year >= 2010 && year <= currentFullYear + 1) {
|
||||
// Valid — normalize capitalization
|
||||
// Valid — normalize capitalization and pad day with leading zero
|
||||
const month = dm[2].charAt(0).toUpperCase() + dm[2].slice(1).toLowerCase();
|
||||
result.tanggal = `${dm[1]} ${month} ${dm[3]}`;
|
||||
const paddedDay = dm[1].padStart(2, '0');
|
||||
result.tanggal = `${paddedDay} ${month} ${dm[3]}`;
|
||||
} else {
|
||||
result.tanggal = "Not Found";
|
||||
}
|
||||
@@ -783,7 +870,10 @@ export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata>
|
||||
|
||||
// --- noPO ---
|
||||
// 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;
|
||||
let docYY = result.tanggal && result.tanggal !== "Not Found" ? getYearFromDate(result.tanggal) : currentYY;
|
||||
if (docYY !== currentYY) {
|
||||
docYY = currentYY;
|
||||
}
|
||||
|
||||
// --- noPO ---
|
||||
// Must match PO/YY/NNNN+ where YY is the document-specific year segment, NNNN = 4+ digits
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# AI OCR Accuracy & Performance Report
|
||||
|
||||
This report summarizes the comparison of **AI OCR Extraction (Layer 3 Final)** against the **Manual Ground Truth Labels** across all **37 test images**.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary Accuracy
|
||||
|
||||
| Field / Area | Total Checks | Matches | Mismatches | Accuracy (%) |
|
||||
| :--- | :---: | :---: | :---: | :---: |
|
||||
| **PO Number** | 37 | 33 | 4 | **89.19%** |
|
||||
| **SO Number** | 37 | 31 | 6 | **83.78%** |
|
||||
| **DO Number** | 37 | 32 | 5 | **86.49%** |
|
||||
| **Date** | 37 | 20 | 17 | **54.05%** |
|
||||
| **Plat Nomor** | 37 | 21 | 16 | **56.76%** |
|
||||
| **Customer Name** | 37 | 37 | 0 | **100.00%** |
|
||||
| **Store Name** | 37 | 11 | 26 | **29.73%** |
|
||||
| **Alamat** | 37 | 12 | 25 | **32.43%** |
|
||||
| **Item SKU** | 127 | 119 | 8 | **93.70%** |
|
||||
| **Item Banyak (Qty Pkg)** | 127 | 100 | 27 | **78.74%** |
|
||||
| **Item Jumlah (Qty Unit)**| 127 | 92 | 35 | **72.44%** |
|
||||
| **OVERALL TOTAL** | **677** | **508** | **169** | **75.04%** |
|
||||
|
||||
*Detailed, color-coded comparison results (including side-by-side matches/mismatches) are available in the generated Excel sheet: [sources/comparison_report.xlsx](file:///d:/Client/Data%20Bisnis%20Solusi/app-pfm-ocr-v2/backend/sources/comparison_report.xlsx).*
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Key Findings & Analysis
|
||||
|
||||
### 🟢 What is Performing Well (High Accuracy)
|
||||
1. **Customer Name (100.00%)**: Every customer name field matches perfectly, as it is standard and usually well-printed.
|
||||
2. **Item SKU (93.70%)**: Outstanding performance on SKU extraction. This is boosted by the **SKU master list validation** (Levenshtein score threshold check), which dynamically corrects OCR recognition noise into actual valid database SKUs.
|
||||
3. **DO, PO, and SO Numbers (83.78% - 89.19%)**: High accuracy for document tracking numbers, which is crucial for database consistency and mapping.
|
||||
|
||||
### 🟡 Moderate Performance (Needs Tuning)
|
||||
1. **Item Banyak (78.74%) & Item Jumlah (72.44%)**: Quantity parsing sometimes gets thrown off by OCR capturing checkmarks (`✓`, `✔`) or adjacent unit annotations (e.g. `PC`, `PAC`, `KG`) next to digits, leading to slight string mismatches.
|
||||
|
||||
### 🔴 Opportunities for Improvement (Low Accuracy)
|
||||
1. **Store Name (29.73%) & Alamat (32.43%)**:
|
||||
- *Why it's low*: The manual labels for address and store name contain highly specific strings, while the AI OCR might extract partial address segments, omit specific keywords (like "DKI AREA", "KANTIN CPI"), or extract slightly different abbreviations.
|
||||
- *Recommendation*: Introduce a fuzzy store-name and address resolution map against the database entries (using Levenshtein distance on store aliases) to dynamically map extracted text to clean master values.
|
||||
2. **Date (54.05%)**:
|
||||
- *Why it's low*: Differences in format normalization (e.g., raw text "23 June 2026" vs expected normalized representations, or OCR misreading text dates like "15 May 2026").
|
||||
- *Recommendation*: Enhance `sanitizeParsedMetadata` in [parser.ts](file:///d:/Client/Data%20Bisnis%20Solusi/app-pfm-ocr-v2/backend/pfm-web-app/src/utils/parser.ts) to parse multiple date format variations into a unified target format.
|
||||
3. **Plat Nomor (56.76%)**:
|
||||
- *Why it's low*: License plates on delivery orders are often stamped, handwritten, or placed in odd margins, which makes clean extraction difficult.
|
||||
@@ -1,230 +0,0 @@
|
||||
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,233 +0,0 @@
|
||||
sku,nama_barang
|
||||
11048006,BEBEK PARTING-NEW(*)
|
||||
11110059,CEKER BERKUKU FROZEN PACK 1 KG(*)
|
||||
11110074,CEKER 1 KG FROZEN (20 PAC/KARUNG)(*)
|
||||
11140051,AMPELA FROZEN PACK 1 KG(*)
|
||||
11140062,AMPELA 1 KG FROZEN (20 PAC/KARUNG)(*)
|
||||
11148002,AMPELA BEBEK FROZEN 1 KG/PACK (NEW)(*)
|
||||
11150052,HATI FROZEN PACK 1 KG(*)
|
||||
11150055,JANTUNG FROZEN PACK 1 KG(*)
|
||||
11150064,HATI 1 KG FROZEN (20 PAC/KARUNG)(*)
|
||||
11150065,JANTUNG 1 KG FROZEN (20 PAC/KARUNG)(*)
|
||||
11310012,AYAM SIZE 0 (0.6-0.7)KG(*)
|
||||
11310013,AYAM SIZE1 FROZEN (0.75-0.8) KG(*)
|
||||
11310014,AYAM SIZE 2 FROZEN (0.8-0.9)KG(*)
|
||||
11310016,AYAM SIZE Z PR FROZ(*)
|
||||
11310017,AYAM SIZE 0 PR FROZEN(*)
|
||||
11310018,AYAM SIZE 1 PR FROZEN(*)
|
||||
11310019,AYAM SIZE 2 PR FROZEN(*)
|
||||
11310021,AYAM SIZE BESAR (B) FROZ (1-1.1)KG/PC(*)
|
||||
11310022,AYAM SIZE A PR (0.9-1)KG/PC(*)
|
||||
11310024,AYAM SIZE A FROZEN (0.9-1)KG/PC(*)
|
||||
11310025,AYAM SIZE SUPER (C) FROZ(1.1-1.2)KG/ PC(*)
|
||||
11310026,AYAM SIZE JUMBO (D) FROZ (1.2- 1.3)KG/PC(*)
|
||||
11318301,BEBEK MUDA-BD1(1.0-1.1 KG)-NEW(*)
|
||||
11318306,CP DUCK PEKING 1.5-1.6 KG/PC(*)
|
||||
11318308,BEBEK PEKING SPR BD5(1.7 -1.8 )Kg-NEW(*)
|
||||
11410043,PARTING 10 SIZE D FRESH BENSU 1.25 KG/PAC(*)
|
||||
11420055,PARTING 12 ALL SIZE FROZ/PAC(*)
|
||||
11600053,BONELESS LEG FROZEN 1 KG(*)
|
||||
11620056,SBL (FILLET PAHA) 1 KG(*)
|
||||
11640053,PAHA UTUH (1 KG)(*)
|
||||
11650053,PAHA ATAS 1 KG(*)
|
||||
11660050,PAHA BAWAH (1 KG)(*)
|
||||
11690053,SBB (FILLET DADA )1 KG(*)
|
||||
11690081,SBB JUMBO FZ (2.0 - 2.2 KG/PAC)(*)
|
||||
11710051,DADA UTUH (1 KG)(*)
|
||||
11720055,FULL WING FROZ PACK 1 KG(*)
|
||||
11730050,MIDDLE WING FROZ PACK 1 KG(*)
|
||||
11750050,FILLET MITRA 1 KG(*)
|
||||
11818300,CP-BEBEK GORENG 400GR/PAC
|
||||
11840002,AYAM JANTAN BKKL SZ 0 (600-700) GR/PC(*)
|
||||
11959937,SATE AYAM FRESHMART 360 GR (PAC)
|
||||
12010111,FIESTA CRISPY BUBBLE 400 GR/PAC
|
||||
12010112,FIESTA CHICKEN NUGGET 400 GR/PAC
|
||||
12010113,FIESTA CHICKEN NUGGET 200 GR/PAC
|
||||
12010115,FIESTA NUGGET ZOO 400 GR/PAC
|
||||
12010116,FIESTA NUGGET DINO 400 GR/PAC
|
||||
12010117,FIESTA NUGGET HAPPY STAR 400 GR/PAC
|
||||
12010119,FIESTA NUGGET CHEESE 123 400 GR/PAC
|
||||
12010121,FIESTA NUGGET PIZZABC 400 GR/PAC
|
||||
12010122,FIESTA CHEESY LOVER 400 GR/PAC
|
||||
12010123,FIESTA GARLIC CHEESE 400 GR/PAC
|
||||
12010124,FIESTA CHEESY CHIC W/BROCCOLI 400 GR/PAC
|
||||
12010127,FIESTA SPICY NUGGET 400 GR/PAC
|
||||
12010128,FIESTA VOLCANO CHEESE 400 GR/PAC
|
||||
12010129,FIESTA CHEESY BOMBS CHICKEN NUGGET 400 GR
|
||||
12010402,GOLDEN FIESTA NUGGET W/PINEAPPLE SAUCE 500 GR
|
||||
12010509,CHAMP CRUNCHY NUGGET 450 GR/PAC
|
||||
12010510,CHAMP NUGGET AYAM 225 GR/PAC
|
||||
12010511,CHAMP NUGGET AYAM 450 GR/PAC
|
||||
12010512,CHAMP NUGGET AYAM 900 GR/PAC
|
||||
12010513,CHAMP NUGGET ABC KOMBINASI 225 GR/PAC
|
||||
12010514,CHAMP NUGGET ABC KOMBINASI 450 GR/PAC
|
||||
12010515,CHAMP KOIN KOMBINASI 450 GR/PAC
|
||||
12010516,CHAMP KOIN KOMBINASI 200 GR/PAC
|
||||
12010517,CHAMP NUGGET STICK 225 GR/PAC
|
||||
12010518,CHAMP NUGGET STICK 450 GR/PAC
|
||||
12010519,CHAMP NUGGET STICK 900 GR/PAC
|
||||
12010520,CHAMP CHICKEN NUGGET BENTUK 123 450 GR/PAC
|
||||
12010521,CHAMP NUGGET HOTZZ LEVEL 5 450 GR/PAC
|
||||
12010606,CHAMP CRUNCHY NUGGET 225 GR/PAC
|
||||
12010707,CHAMP MITRA NUGGET COIN 200 GR (NEW)
|
||||
12010801,OKEY NUGGET 500GR
|
||||
12012201,ASIMO NUGGET KOMBINASI 500 GR/PAC
|
||||
12012202,ASIMO NUGGET KOMBINASI 1 KG/PAC
|
||||
12012203,ASIMO NUGGET KOMBINASI 250 GR/PAC
|
||||
12012501,AKUMO CHICKEN NAGET 250 GR
|
||||
12012502,AKUMO CHICKEN NUGGET 500 GR
|
||||
12012503,AKUMO CHICKEN NUGGET 1000 GR
|
||||
12012504,AKUMO COIN 200 GR/PAC
|
||||
12012505,AKUMO KOIN 400 GR/PAC
|
||||
12020102,FIESTA SPICY WING 400 GR/PAC
|
||||
12020401,GOLDEN FIESTA SP WING 500 GR
|
||||
12030101,FIESTA STIKIE 400 GR/PAC
|
||||
12030102,FIESTA STIKIE 200 GR/PAC
|
||||
12030403,GOLDEN FIESTA STIKIE W/ SWEET CHILLI SAUCE 500GR
|
||||
12030801,OKEY STICK 1000 GR
|
||||
12030802,OKEY STICK 500GR
|
||||
12032201,ASIMO STICK KOMBINASI 500 GR/PAC
|
||||
12032202,ASIMO STICK KOMBINASI 1000 GR/PAC
|
||||
12032203,ASIMO STIK KOMBINASI 250 GR/PAC
|
||||
12032501,AKUMO CHICKEN STICK 250 GR
|
||||
12032502,AKUMO CHICKEN STIK 500 GR
|
||||
12032503,AKUMO CHICKEN STICK 1000 GR
|
||||
12040101,FIESTA SCHNITZEL 400 GR/PAC
|
||||
12040102,FIESTA CRISPY BUBBLE KATSU 400 GR/PAC
|
||||
12040404,GOLDEN FIESTA CORDON BLEU BBQ SAUCE 500 GR
|
||||
12040406,GOLDEN FIESTA KATSU W/CHEESE SAUCE 500 GR/PAC
|
||||
12050103,FIESTA FRIED CHICKEN 400 GR/PAC
|
||||
12050104,FIESTA HOT & CRISPY FRIED CHICKEN 400 GR/PAC
|
||||
12050401,GOLDEN FIESTA CRISPY WING W/SP GLAZ SC 500 GR/PAC
|
||||
12060103,FIESTA KARAGE 200 GR/PAC
|
||||
12060104,FIESTA KARAGE 400 GR/PAC
|
||||
12060105,FIESTA SPICY KARAGE 400 GR/PAC
|
||||
12060402,GOLDEN FIESTA KARAGE CHILI SAUCE 500GR
|
||||
12070101,FIESTA POK-POK 400 GR/PAC (NEW)
|
||||
12080101,FIESTA SPICY CHICK 400 GR/PAC
|
||||
12130102,FIESTA CRISPY BURGER 360 GR (NEW)
|
||||
12130504,CHAMP BURGER 315 GR (NEW)
|
||||
12140105,FIESTA CHICK TOFU 400 GR/PAC
|
||||
12150201,FIESTA DS CRISPY CRUNCH 300 GR/PAC
|
||||
12150501,CHAMP CRUNCHY HOTZZ 300 GR/PAC
|
||||
12190103,FIESTA DELISTRIPE 400 GR/PAC
|
||||
12240102,FIESTA CHEESY ITALIAN R/BITES 400 GR/PAC
|
||||
12240103,FIESTA YAKINIKU R/BITES 400 GR/PAC
|
||||
13010101,FIESTA CHICK SSG 300 GR
|
||||
13010102,FIESTA CHICK SSG 500 GR
|
||||
13010103,FIESTA CHICK SSG 200 GR/PAC
|
||||
13010111,FIESTA SOSIS BRATWURST 300 GR
|
||||
13010112,FIESTA CHEESE SSG 300 GR
|
||||
13010113,FIESTA SOSIS CURRYWURST 300 GR
|
||||
13010114,FIESTA SSG BOCKWURST 300GR
|
||||
13010115,FIESTA SSG WIENER 300GR
|
||||
13010116,FIESTA SSG ORIGINAL 300 GR
|
||||
13010117,FIESTA SSG FRANKFURTER 300GR
|
||||
13010118,FIESTA RTG SSG 65 GR/PAC
|
||||
13010119,FIESTA RTG C/SPICY KOREAN 60 GR/PAC
|
||||
13010120,FIESTA RTG C/CHEESY MELTS 65 GR/PAC
|
||||
13010122,FIESTA RTG SAUSAGE WITH HOT LAVA 60G
|
||||
13010123,FIESTA RTG SAUSAGE WITH CHEESE LAVA 60G
|
||||
13010124,FIESTA RTG SAUSAGE HICKORY SAUCE 60GR
|
||||
13010125,FIESTA RTG SAUSAGE WITH MENTAI LAVA 60GR
|
||||
13010510,CHAMP CHICK SSG 75 GR
|
||||
13010513,CHAMP CHICK SSG 375 GR
|
||||
13010514,CHAMP CHICK SSG 1000 GR
|
||||
13010518,CHAMP SSG JUMBO BAKAR 500 GR/PAC
|
||||
13010519,CHAMP SSG BAKAR MINI 500 GR/PAC-INACT
|
||||
13010521,CHAMP CHICK SSG 150 GR/PAC (NEW)
|
||||
13010523,CHAMP CHICK SSG AYAM MADU 300 GR/PAC
|
||||
13010524,CHAMP SSG JUMBO BAKAR 500 GR/PAC (NEW)
|
||||
13010525,CHAMP SSG BAKAR MINI 500 GR/PAC (NEW)
|
||||
13010809,OKEY CHICK SSG 500GR-INACT
|
||||
13010815,OKEY SSG BAKAR JUMBO 500 GR/PAC (NEW)
|
||||
13010816,OKEY SSG BAKAR MINI 500 GR/PAC (NEW)
|
||||
13010817,OKEY SSG CHICK KOMBINASI 500 GR (EXTRA1)
|
||||
13010818,OKEY SSG CHICK KOMBINASI 1 KG (EXTR$A2)
|
||||
13012205,ASIMO SOSIS AYAM KOMBINASI 375 GR (PAC)
|
||||
13012206,ASIMO SOSIS AYAM KOMBINASI 500 GR
|
||||
13012207,ASIMO SOSIS AYAM KOMBINASI 750 GR
|
||||
13012208,ASIMO SOSIS AYAM KOMBINASI 1000 GR
|
||||
13012209,ASIMO SSG CHICK KOMBINASI 500 GR (EXTRA1)
|
||||
13012210,ASIMO SSG CHICK KOMBINASI 1 KG (EXTR$A2)
|
||||
13030101,FIESTA CHICK MEAT BALL 300 GR
|
||||
13030102,FIESTA CHICK MEATBALL 500 GR
|
||||
13030501,CHAMP CHICK MEATBALL 200 GR
|
||||
13030502,CHAMP CHICK MEATBALL 500 GR
|
||||
13050101,FIESTA SCB 250 GR
|
||||
13050105,FIESTA CHICKEN SLICE 300 GR
|
||||
13050106,FIESTA BEEF SLICE 300 GR
|
||||
13070501,CHAMP BEEF SSG SERBAGUNA 150 GR
|
||||
13070502,CHAMP BEEF SSG SERBAGUNA 375GR
|
||||
13070505,CHAMP BEEF SSG GORENG 375 GR
|
||||
13070506,CHAMP FRANKFURTER SSG 375GR
|
||||
13100512,CHAMP CHICK SSG S/SANTAP ORIG 546GR (CAN)
|
||||
13110504,CHAMP BEEF BALL 500GR
|
||||
13170510,CHAMP BEEF BBQ SSG S/SANTAP 546GR (CAN)
|
||||
15010101,FIESTA SHOESTRING 500 GR
|
||||
15010102,FIESTA SHOESTRING 1000 GR
|
||||
15010107,FIESTA FRENCH F SHOESTRING INSTITUSI 2KG
|
||||
15020101,FIESTA STRAIGHT CUT 500 GR
|
||||
15020102,FIESTA STRAIGHT CUT 1000 GR
|
||||
15030101,FIESTA CRINKLE CUT 500 GR
|
||||
15030102,FIESTA CRINKLE CUT 1000 GR
|
||||
15040101,FIESTA BATTER COATED 500 GR
|
||||
15040102,FIESTA BATTER COATED 1000 GR
|
||||
16060103,FIESTA CHICK SIOMAY 900GR
|
||||
16060113,FIESTA CHICK SIOMAY 180GR (NEW)
|
||||
16060114,FIESTA GYOZA 180 GR (NEW)
|
||||
16060119,FIESTA RTG SIOMAY 54 GR/PAC
|
||||
16060120,FIESTA KEECHO 400 GR/PAC
|
||||
16060121,FIESTA CHICKEN TOFU 400 GR/PAC (NEW)
|
||||
16060503,CHAMP CHICK&FISH SIOMAY 180 GR (NEW)
|
||||
17200109,FIESTA RTS C/TERIYAKI 300GR/PAC
|
||||
17200110,FIESTA RTS C/RENDANG 300GR/PAC
|
||||
17200111,FIESTA RTS C/W RUJAK SC 300GR/PAC
|
||||
17200112,FIESTA RTS C/W S/MATAH 300GR/PAC
|
||||
17210106,FIESTA RTS B/YAKINIKU 300GR/PAC
|
||||
17210107,FIESTA RTS B/RENDANG 300GR/PAC
|
||||
17210108,FIESTA RTS B/BLACKPEPPER 300GR/PAC
|
||||
17210109,FIESTA RTS B/BULGOGI 300GR/PAC
|
||||
18050102,FIESTA RTG BAKSO KEJU 60 GR/PAC
|
||||
18050103,FIESTA RTG BAKSO BAKAR BBQ 60 GR/PAC
|
||||
18050104,FIESTA RTG BEEF BALL WITH MENTAI LAVA 55GR
|
||||
18050105,FIESTA RTG BEEF BALL WITH CHEESE LAVA 55GR
|
||||
20010101,FIESTA CRISPY CRUMBS 200 GR
|
||||
20010102,FIESTA TP ROTI PUTIH 200 GR
|
||||
20040101,FIESTA RAMEN BEKU 570 GR/PAC
|
||||
20120102,FIESTA T/B AYAM GORENG 80 GR
|
||||
20120105,FIESTA T/B SERBAGUNA 80 GR
|
||||
20120106,FIESTA T/B KREMES 80 GR
|
||||
20120115,FIESTA RACIK AYAM GORENG 20 GR/PAC
|
||||
20120116,FIESTA RACIK NASI GORENG 20 GR/PAC
|
||||
21000123,FIESTA RICE W/GEPREK CHICKEN 320GR/PAC
|
||||
21000124,FIESTA RICE W/CHICK RUJAK 320 GR/PAC
|
||||
21000125,FIESTA RICE W/KOREAN BBQ CHICK 320 GR/PAC
|
||||
21000126,NEW FIESTA CHICK RENDANG W RICE 320GR (PAC)
|
||||
21000127,NEW FIESTA CHICK TERIYAKI W RICE 320GR (PAC)
|
||||
21000128,NEW FIESTA CHICK TANDORI W RICE 320GR (PAC)
|
||||
21000129,NEW FIESTA RICE W KARAGE&SSS 320GR (PAC)
|
||||
21000130,NEW FIESTA RICE W/C CHEESE BULDAK 320GR (PAC)
|
||||
21000131,NEW FIESTA RICE W CHIC CURRY 320GR (PAC)
|
||||
21000132,NEW FIESTA RICE W CHICK DONBURI 320GR (PAC)
|
||||
21000133,NEW FIESTA RICE W CHICK SATAY 320GR (PAC)
|
||||
21000134,NEW FIESTA COCONUT RICE W SPICY CHICK 320GR (PAC)
|
||||
21000135,NEW FIESTA RICE W POPBITES S/MATAH 320GR (PAC)
|
||||
21000136,NEW FIESTA TUMERIC W POPBITES 320GR (PAC)
|
||||
21000137,FIESTA HAINAMESE CHICKEN RICE 320GR (PAC)
|
||||
21010101,FIESTA TRUFFLE GYUDON 320 GR/PAC
|
||||
21010102,NEW FIESTA BEEF YAKINIKU W RICE 320GR (PAC)
|
||||
21010103,NEW FIESTA BEEF BULGOGI W RICE 320GR (PAC)
|
||||
21010104,NEW FIESTA BEEF RENDANG W RICE 320GR (PAC)
|
||||
21010105,NEW FIESTA RICE W BEEF BLACKPEPPER 320GR (PAC)
|
||||
21200107,NEW FIESTA SPAGHETTI CARBONARA 300GR (PAC)
|
||||
21200108,NEW FIESTA SPAGHETTI CHIC BOLOGNESE 320GR (PAC)
|
||||
21200109,NEW FIESTA ITALIAN MEATBALL SPAGHETTI 320GR (PAC)
|
||||
21310103,NEW FIESTA SCB&S/SSG FRIED RICE 320GR (PAC)
|
||||
21500101,FIESTA CHICK SSG & C. BALL PIZZA 230GR/PAC
|
||||
21500102,FIESTA CHEESY BEEF PIZZA 230GR/PAC
|
||||
91000012,PHOTOCARD RTG
|
||||
1188002W,PAHA ATAS 25-30 G FZ (*)
|
||||
1195008A,RTC CHICKEN KALASAN 400 GR (PAC)
|
||||
1195008E,RTC CHICKEN TERIYAKI 400 GR (PAC)
|
||||
1195008X,RTC CHICKEN SPICY 400 GR (PAC)
|
||||
|
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 543 KiB After Width: | Height: | Size: 546 KiB |
|
After Width: | Height: | Size: 678 KiB |
|
After Width: | Height: | Size: 676 KiB |
|
After Width: | Height: | Size: 652 KiB |
|
After Width: | Height: | Size: 829 KiB |
|
After Width: | Height: | Size: 929 KiB |
|
After Width: | Height: | Size: 768 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 732 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 875 KiB |
|
After Width: | Height: | Size: 751 KiB |
|
After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 857 KiB |
|
After Width: | Height: | Size: 827 KiB |
|
After Width: | Height: | Size: 748 KiB |
|
After Width: | Height: | Size: 799 KiB |
|
After Width: | Height: | Size: 832 KiB |
|
After Width: | Height: | Size: 824 KiB |
|
After Width: | Height: | Size: 920 KiB |
|
After Width: | Height: | Size: 788 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 184 KiB |
@@ -14,7 +14,7 @@ class AppConfig {
|
||||
// - Physical Device on same Wi-Fi: use 'http://192.168.70.7:8000/api/v1'
|
||||
// - Android Emulator: use 'http://10.0.2.2:8000/api/v1'
|
||||
// - iOS Simulator: use 'http://localhost:8000/api/v1'
|
||||
static const String _lanBaseUrl = 'http://192.168.70.7:8000/api/v1';
|
||||
static const String _lanBaseUrl = 'http://192.168.70.5:8000/api/v1';
|
||||
|
||||
// Ngrok public tunnel URL (update ini setiap ngrok di-restart)
|
||||
static const String _ngrokBaseUrl = 'https://unlocated-waylon-potently.ngrok-free.dev/api/v1';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Get the active network interface IP address (excluding virtual and loopback adapters)
|
||||
Write-Host "Checking active network adapters..." -ForegroundColor Cyan
|
||||
|
||||
$ipAddress = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object {
|
||||
$_.IPAddress -notlike "127.*" -and
|
||||
$_.IPAddress -notlike "169.254.*" -and
|
||||
$_.InterfaceAlias -notlike "*vEthernet*" -and
|
||||
$_.InterfaceAlias -notlike "*WSL*" -and
|
||||
$_.IPAddress -notlike "192.168.56.*"
|
||||
} | Select-Object -ExpandProperty IPAddress -First 1)
|
||||
|
||||
if (-not $ipAddress) {
|
||||
Write-Host "[-] No active LAN IP address found! Falling back to 127.0.0.1" -ForegroundColor Yellow
|
||||
$ipAddress = "127.0.0.1"
|
||||
} else {
|
||||
Write-Host "[+] Found active Host IP: $ipAddress" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Update Flutter Configuration
|
||||
$configFile = "lib/config/app_config.dart"
|
||||
if (Test-Path $configFile) {
|
||||
$content = Get-Content $configFile -Raw
|
||||
$pattern = "(static const String _lanBaseUrl = 'http://)([^:]+)(:8000/api/v1';)"
|
||||
if ($content -match $pattern) {
|
||||
$content = $content -replace $pattern, ('${1}' + $ipAddress + '${3}')
|
||||
Set-Content $configFile $content -NoNewline
|
||||
Write-Host "[+] Synchronized local IP address ($ipAddress) to Flutter configuration." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[-] Warning: _lanBaseUrl pattern not found in $configFile." -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host "[-] Warning: Flutter config file '$configFile' not found. Skip updating local IP." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Start ngrok
|
||||
Write-Host "`nStarting Ngrok tunnel..." -ForegroundColor Cyan
|
||||
Write-Host "Exposing: http://localhost:8000" -ForegroundColor Cyan
|
||||
Write-Host "Public URL: https://unlocated-waylon-potently.ngrok-free.dev" -ForegroundColor Cyan
|
||||
|
||||
# Check if ngrok is available
|
||||
$ngrokCmd = Get-Command ngrok -ErrorAction SilentlyContinue
|
||||
if ($ngrokCmd) {
|
||||
Write-Host "[+] Launching ngrok in a new window..." -ForegroundColor Green
|
||||
Start-Process ngrok -ArgumentList "http", "--domain=unlocated-waylon-potently.ngrok-free.dev", "8000"
|
||||
Write-Host "[+] Tunnel launched successfully!" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[-] Error: 'ngrok' command not found in your system PATH." -ForegroundColor Red
|
||||
Write-Host " Please ensure ngrok is installed and added to your environment variables." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`nDone! Your Flutter app can now connect locally via WiFi ($ipAddress) or publicly via Ngrok." -ForegroundColor Green
|
||||