feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification

Fixes reported from APK field testing: DO/Product scan mode was inconsistent
between the camera drawer and documents screen (now one shared provider,
with an orange/green color cue); unconfirmed scans leaked into history with
placeholder data before the user tapped confirm (backend now gates
GET /documents on a new `confirmed` column, flipped only by PUT); and
Product Scan ran the GPU classifier twice, once at upload and again on
review (now a single pass at upload, persisted and read directly by the
editor). Also removes the unused "Hubungkan ke PO" field and fabricated
PO/SO/DO placeholder values from the Product Scan flow, closes out the
per-document-polling and save-recovery tasks (6.1/6.3), and splits several
touched files to stay under the repo's 256-line guideline.

Full detail in docs/iteration-log.md and backend/docs/iteration-log.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5705 -1142

No files matched your search

@@ -0,0 +1,122 @@
import { query } from "../db";
// Bounds the classifier call so a wedged GPU container fails fast instead of
// hanging indefinitely - matches the bound `api/parse/route.ts` used to apply
// to its own separate inline classify call before it started sharing this
// function (see docs/api-contract-map.md G3).
const PIPELINE_TIMEOUT_MS = 90_000;
// Thrown when the Python classifier service itself returns a non-2xx response,
// so callers can forward its actual status instead of collapsing everything to 500.
export class ClassifierError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export interface SkuMatch {
no_sku: string;
nama_item: string;
score: number;
yoloSimilarity: number;
isBestMatch: boolean;
}
export interface ProductScanResult {
classification: any;
ocr: any;
possibleMatches: SkuMatch[];
}
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
// Shared by the classic /api/scan-pfm dev route and the authenticated
// /api/v1/scan-product route: calls the Python classifier, then matches the
// result against sku_master, returning the top-5 candidates.
export async function classifyAndMatchProduct(imageBase64: string): Promise<ProductScanResult> {
const pyServerUrl = process.env.CLASSIFIER_SERVER_URL || "http://paddleocr-pipeline-api:8120/classify-ocr";
const response = await fetch(pyServerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: imageBase64 }),
signal: AbortSignal.timeout(PIPELINE_TIMEOUT_MS)
});
if (!response.ok) {
const errText = await response.text();
throw new ClassifierError(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
const dbRes = await query("SELECT no_sku, nama_item FROM sku_master");
const skuMasterList = dbRes.rows.map(row => ({
no_sku: row.no_sku,
nama_item: row.nama_item
}));
const top1Name = data.classification?.top1_name || "";
const extractedSku = data.ocr?.extracted_sku || "";
const matchedList: SkuMatch[] = skuMasterList.map(sku => {
const yoloSim = top1Name ? getStringSimilarity(sku.nama_item, top1Name) : 0;
const cleanMasterSku = sku.no_sku.trim();
const cleanExtractedSku = extractedSku.trim();
const isSkuMatch = cleanExtractedSku && cleanMasterSku === cleanExtractedSku;
const score = isSkuMatch ? 1.0 : yoloSim;
return {
no_sku: sku.no_sku,
nama_item: sku.nama_item,
score,
yoloSimilarity: yoloSim,
isBestMatch: false
};
});
matchedList.sort((a, b) => b.score - a.score);
const possibleMatches = matchedList.slice(0, 5).filter(m => m.score > 0.1);
if (possibleMatches.length > 0) {
possibleMatches[0].isBestMatch = true;
}
return {
classification: data.classification,
ocr: data.ocr,
possibleMatches
};
}