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

@@ -116,7 +116,7 @@ export async function GET(req: NextRequest) {
if (inferredSku) {
try {
const dbRes = await query("SELECT nama_item FROM sku_master WHERE no_sku = $1", [inferredSku]);
if (dbRes.rowCount > 0) {
if (dbRes.rowCount && dbRes.rowCount > 0) {
inferredNamaItem = dbRes.rows[0].nama_item;
}
} catch (dbErr) {
+137 -5
View File
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query, withTransaction, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { errorResponse } from "@/utils/api-error";
import { classifyAndMatchProduct } from "@/utils/product-scan";
// Bounds each pipeline call so a wedged GPU container fails fast into the existing
// graceful fallback path instead of hanging the request indefinitely.
@@ -43,7 +44,8 @@ function getStringSimilarity(s1: string, s2: string): number {
export async function POST(req: NextRequest) {
let safeFile = "";
try {
const { filename, kodeToko } = await req.json();
const { filename, kodeToko, scanMode } = await req.json();
console.log(`[Parse] Received payload - filename: "${filename}", scanMode: "${scanMode}"`);
if (!filename) {
return errorResponse(400, "Filename is required");
}
@@ -73,6 +75,133 @@ export async function POST(req: NextRequest) {
const b64 = fileBuffer.toString("base64");
if (scanMode === "Product") {
// Single classify+OCR+match pass, shared with POST /api/v1/scan-product
// (task 9.3) - this used to be a separate, poorer inline fetch that only
// kept top1_name/extracted_sku, forcing the Flutter editor to re-run the
// entire GPU pass a second time just to get the top-5 candidates and the
// extracted expiry date. Both are captured here now and persisted below
// (metadata.productScan) so the editor can read them from the document
// instead of re-classifying. See docs/api-contract-map.md G3.
let scanResult: Awaited<ReturnType<typeof classifyAndMatchProduct>> | null = null;
try {
scanResult = await classifyAndMatchProduct(b64);
} catch (err) {
console.error("Classifier service error:", err);
}
const bestMatch = scanResult?.possibleMatches?.[0];
const top1Name = bestMatch?.nama_item || "Unknown Product";
const extractedSku = bestMatch?.no_sku || "12010119";
const extractedExpiryDate = scanResult?.ocr?.extracted_expired_date || "";
let currentStoreName = "PM KELAPA DUA KARAWACI";
let storeAlamat = "Jakarta";
if (kodeToko) {
const storeRes = await query("SELECT nama_toko, alamat FROM store_master WHERE kode_toko = $1", [kodeToko]);
if (storeRes.rowCount && storeRes.rowCount > 0) {
currentStoreName = storeRes.rows[0].nama_toko;
storeAlamat = storeRes.rows[0].alamat;
}
}
// noPO/noSO/noDO are DO-specific concepts that don't apply to a product
// verification scan - left empty rather than fabricated placeholders
// (was "PO-PRODUCT-001"/"1002003004"/"DO-PRODUCT-999"). Not user-facing:
// the editor's _submit() builds its own noPo/noSo/noDo from the user's
// PO-link/batch selection, and the printed receipt never reads these.
// See docs/api-contract-map.md G12.
const docMetadata = {
tanggal: new Date().toLocaleDateString("id-ID"),
noPO: "",
noSO: "",
noDO: "",
vendorInfo: "PRODUCT SCAN",
customerInfo: currentStoreName,
header: {
tanggal: new Date().toLocaleDateString("id-ID"),
no_po: "",
no_so: "",
no_do: ""
},
shipment: {
kepada_yth: currentStoreName,
order_untuk: "PRODUCT SCAN",
alamat: storeAlamat,
plat_truk: "B 1234 PFM",
nama_driver: "PRODUCT SCAN",
nama_penerima: "STORE STAFF"
},
items: [
{
kodeBarang: extractedSku,
namaBarang: top1Name,
banyak: "1",
jumlah: "1"
}
],
// Full classify+OCR result from the single pass above, so the Flutter
// editor can read it directly instead of re-running the GPU pipeline
// a second time on review (gap G3). `possibleMatches` is the top-5
// candidate list (may be empty if nothing scored above the match
// threshold); `extractedExpiryDate` is the raw OCR-extracted date, if
// any. `null` for documents parsed before this change existed.
productScan: {
possibleMatches: scanResult?.possibleMatches || [],
extractedExpiryDate
}
};
const stats = fs.statSync(filePath);
const insertDocRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, is_sample, file_hash, kode_toko, scan_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (filename) DO UPDATE
SET upload_time = EXCLUDED.upload_time,
size = EXCLUDED.size,
parsed = EXCLUDED.parsed,
metadata = EXCLUDED.metadata,
is_sample = EXCLUDED.is_sample,
file_hash = EXCLUDED.file_hash,
kode_toko = COALESCE(EXCLUDED.kode_toko, documents.kode_toko),
scan_mode = COALESCE(EXCLUDED.scan_mode, documents.scan_mode),
parse_error = NULL
RETURNING id
`, [
safeFile,
stats.mtime,
stats.size,
true,
JSON.stringify(docMetadata),
isSample,
fileHash,
kodeToko || null,
"Product"
]);
const docId = insertDocRes.rows[0].id;
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
await client.query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang, banyak_original, banyak,
jumlah_original, jumlah, is_flagged, remark
)
VALUES ($1, 0, $2, $2, $3, '1', '1', '1', '1', false, '')
`, [docId, extractedSku, top1Name]);
});
return NextResponse.json({
errorCode: 0,
errorMsg: "Success",
items: docMetadata.items
});
}
// Check if we already have a parsed document in the database with the exact filename (and has valid layout_parsing_result)
const cachedDoc = await query(
"SELECT layout_parsing_result, processing_logs FROM documents WHERE filename = $1 AND parsed = true",
@@ -433,8 +562,8 @@ export async function POST(req: NextRequest) {
};
const insertDocRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs, kode_toko)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs, kode_toko, scan_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (filename) DO UPDATE
SET upload_time = EXCLUDED.upload_time,
size = EXCLUDED.size,
@@ -444,7 +573,9 @@ export async function POST(req: NextRequest) {
is_sample = EXCLUDED.is_sample,
file_hash = EXCLUDED.file_hash,
processing_logs = EXCLUDED.processing_logs,
kode_toko = COALESCE(EXCLUDED.kode_toko, documents.kode_toko)
kode_toko = COALESCE(EXCLUDED.kode_toko, documents.kode_toko),
scan_mode = COALESCE(EXCLUDED.scan_mode, documents.scan_mode),
parse_error = NULL
RETURNING id
`, [
safeFile,
@@ -456,7 +587,8 @@ export async function POST(req: NextRequest) {
isSample,
fileHash,
JSON.stringify(logsPayload),
kodeToko || null
kodeToko || null,
scanMode || "DO"
]);
const docId = insertDocRes.rows[0].id;
@@ -1,66 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
export const dynamic = "force-dynamic";
function levenshteinDistance(s1: string, s2: string): number {
const len1 = s1.length;
const len2 = s2.length;
const matrix = Array.from({ length: len1 + 1 }, () => new Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
function getStringSimilarity(s1: string, s2: string): number {
const clean1 = s1.toLowerCase().replace(/[^a-z0-9]/g, '');
const clean2 = s2.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!clean1 || !clean2) return 0;
const distance = levenshteinDistance(clean1, clean2);
const maxLength = Math.max(clean1.length, clean2.length);
return (maxLength - distance) / maxLength;
}
export async function POST(req: NextRequest) {
try {
const { image_base64 } = await req.json();
const body = await req.json();
const image_base64 = body.image_base64 || body.image;
if (!image_base64) {
return errorResponse(400, "Image is required");
}
// Call Python FastAPI server inside the container
const pyServerUrl = process.env.CLASSIFIER_SERVER_URL || "http://paddleocr-pipeline-api:8120/classify-ocr";
console.log(`Forwarding scan request to classifier server: ${pyServerUrl}`);
const response = await fetch(pyServerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ image_base64 })
});
const result = await classifyAndMatchProduct(image_base64);
if (!response.ok) {
const errText = await response.text();
return errorResponse(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
// Layout-parsing visualization (same pipeline as DO-PFM Visual Grid)
// Layout-parsing visualization (same pipeline as DO-PFM Visual Grid) - only
// used by this desktop test page, not part of the shared classify+match logic.
let layoutParsingResult: { layoutParsingResults?: Array<{ outputImages?: Record<string, string> }> } | null = null;
const rawB64 = image_base64.includes(",") ? image_base64.split(",")[1] : image_base64;
const pipelineUrl = process.env.PIPELINE_URL || "http://localhost:7871/layout-parsing";
@@ -89,48 +44,18 @@ export async function POST(req: NextRequest) {
console.warn("Layout parsing for visualization unavailable:", layoutErr);
}
// Now query the SKU master from database
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
}));
// Find matches
const top1Name = data.classification?.top1_name || "";
const extractedSku = data.ocr?.extracted_sku || "";
const extractedProductName = data.ocr?.extracted_product_name || "";
const matchedList = skuMasterList.map(sku => {
const yoloSim = top1Name ? getStringSimilarity(sku.nama_item, top1Name) : 0;
return {
no_sku: sku.no_sku,
nama_item: sku.nama_item,
score: yoloSim,
yoloSimilarity: yoloSim,
isBestMatch: false
};
});
// Sort by score descending
matchedList.sort((a, b) => b.score - a.score);
// Take top 5 possible matches
const possibleMatches = matchedList.slice(0, 5).filter(m => m.score > 0.1);
if (possibleMatches.length > 0) {
possibleMatches[0].isBestMatch = true;
}
return NextResponse.json({
classification: data.classification,
ocr: data.ocr,
possibleMatches,
classification: result.classification,
ocr: result.ocr,
possibleMatches: result.possibleMatches,
layoutParsingResult
});
} catch (error: unknown) {
console.error("Error in scan-pfm API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message);
}
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -13,6 +14,60 @@ export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const params = await context.params;
const docId = parseInt(params.id);
if (isNaN(docId)) {
return errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Deliberately not filtering on `parsed = true` here (unlike the list route) -
// the whole point of this endpoint is to let the poller see pending/failed
// documents, not just done ones.
const docRes = await query(`
SELECT id, filename, upload_time, parsed, is_sample, metadata, latitude, longitude, kode_toko, scan_mode, parse_error, confirmed
FROM documents
WHERE id = $1
`, [docId]);
if (!docRes.rowCount || docRes.rowCount === 0) {
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = docRes.rows[0];
if (account.role !== 'admin' && doc.kode_toko !== account.kodeToko) {
return errorResponse(403, "Forbidden: You do not have permission to view this document", { headers: corsHeaders });
}
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [docId]);
return NextResponse.json({
status: "success",
data: mapDocumentRow(doc, itemsRes.rows)
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in get document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
export async function PUT(
req: NextRequest,
context: { params: Promise<{ id: string }> }
@@ -90,10 +145,13 @@ export async function PUT(
const latFloat = latitude ? parseFloat(latitude.toString()) : null;
const lngFloat = longitude ? parseFloat(longitude.toString()) : null;
// Update document record
// Update document record. `confirmed = true` is the one and only place
// this flips - this PUT is literally "the user tapped Simpan & Konfirmasi"
// (see docs/api-contract-map.md G11).
await query(`
UPDATE documents
SET parsed = true,
confirmed = true,
latitude = $2,
longitude = $3,
metadata = $4
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -22,9 +23,9 @@ export async function GET(req: NextRequest) {
// Retrieve all custom-uploaded documents
let docsQuery = `
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude, scan_mode, parse_error, confirmed
FROM documents
WHERE is_sample = false AND parsed = true
WHERE is_sample = false AND parsed = true AND confirmed = true
`;
const queryParams: any[] = [];
@@ -41,85 +42,15 @@ export async function GET(req: NextRequest) {
const mappedList = [];
for (const doc of documents) {
const docId = doc.id;
const metadata = doc.metadata || {};
// Retrieve items from ocr_items
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [docId]);
`, [doc.id]);
const items = itemsRes.rows.map(item => ({
nomor_sku: item.kode_barang || "",
nama_barang: item.nama_barang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
}));
// Determine header and shipment mapping
let header = {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
};
let shipment = {
kepada_yth: "",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
};
if (metadata.header) {
// Document was updated via mobile app
header = {
tanggal: metadata.header.tanggal || "",
no_po: metadata.header.no_po || "",
no_so: metadata.header.no_so || "",
no_do: metadata.header.no_do || ""
};
shipment = {
kepada_yth: metadata.shipment?.kepada_yth || "",
order_untuk: metadata.shipment?.order_untuk || "",
alamat: metadata.shipment?.alamat || "",
plat_truk: metadata.shipment?.plat_truk || "",
nama_driver: metadata.shipment?.nama_driver || "",
nama_penerima: metadata.shipment?.nama_penerima || ""
};
} else {
// Document was freshly uploaded / parsed via web
header = {
tanggal: metadata.tanggal || "",
no_po: metadata.noPO || "",
no_so: metadata.noSO || "",
no_do: metadata.noDO || doc.filename || ""
};
shipment = {
kepada_yth: metadata.customerInfo || "",
order_untuk: metadata.orderUntuk || "",
alamat: metadata.alamat || "",
plat_truk: metadata.platTruk || "",
nama_driver: "",
nama_penerima: metadata.headerRemark || ""
};
}
mappedList.push({
id: docId.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header,
shipment,
items,
latitude: doc.latitude ? parseFloat(doc.latitude.toString()) : null,
longitude: doc.longitude ? parseFloat(doc.longitude.toString()) : null
});
mappedList.push(mapDocumentRow(doc, itemsRes.rows));
}
return NextResponse.json({
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { mapDocumentRow } from "@/utils/document-mapper";
const UPLOADS_DIR = "/uploads";
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
const scanMode = formData.get("scan_mode")?.toString() || "DO";
console.log(`[Upload] Received scan_mode: "${scanMode}"`);
if (!file) {
return errorResponse(400, "No file uploaded", { headers: corsHeaders });
@@ -60,8 +63,8 @@ export async function POST(req: NextRequest) {
// Basic dedup
const dedupQuery = account?.kodeToko
? "SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 AND kode_toko = $2 ORDER BY upload_time ASC LIMIT 1"
: "SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 AND kode_toko IS NULL ORDER BY upload_time ASC LIMIT 1";
? "SELECT id, filename, upload_time, parsed, metadata, latitude, longitude, scan_mode, parse_error, confirmed FROM documents WHERE file_hash = $1 AND kode_toko = $2 ORDER BY upload_time ASC LIMIT 1"
: "SELECT id, filename, upload_time, parsed, metadata, latitude, longitude, scan_mode, parse_error, confirmed FROM documents WHERE file_hash = $1 AND kode_toko IS NULL ORDER BY upload_time ASC LIMIT 1";
const dedupParams = account?.kodeToko ? [fileHash, account.kodeToko] : [fileHash];
const existing = await query(dedupQuery, dedupParams);
@@ -70,22 +73,19 @@ export async function POST(req: NextRequest) {
const existingDoc = existing.rows[0];
console.log(`[Dedup] Identical content already uploaded as document ${existingDoc.id}. Skipping duplicate insert and re-parse.`);
const mappedData = {
id: existingDoc.id.toString(),
header: { tanggal: "", no_po: "", no_so: "", no_do: "" },
shipment: {
kepada_yth: "PT.PRIMAFOOD INTERNATIONAL",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
},
items: [] as any[],
latitude: existingDoc.latitude ? parseFloat(existingDoc.latitude.toString()) : latitude,
longitude: existingDoc.longitude ? parseFloat(existingDoc.longitude.toString()) : longitude,
createdAt: new Date(existingDoc.upload_time || Date.now()).toISOString()
};
// Return the original document's actual current parse state instead of an
// always-empty stub, so a retried upload doesn't look permanently "fresh."
const itemsRes = await query(`
SELECT row_index, kode_barang, nama_barang, banyak, jumlah
FROM ocr_items
WHERE document_id = $1
ORDER BY row_index
`, [existingDoc.id]);
const mappedData = mapDocumentRow(existingDoc, itemsRes.rows);
// Fall back to this retry's own GPS tag if the original document never got one.
if (mappedData.latitude === null) mappedData.latitude = latitude;
if (mappedData.longitude === null) mappedData.longitude = longitude;
return NextResponse.json({
status: "success",
@@ -100,9 +100,11 @@ export async function POST(req: NextRequest) {
let docId: number;
let finalFilename = filename;
// `confirmed = false`: this row isn't visible via GET /api/v1/documents
// until the user's editor PUT confirms it (see docs/api-contract-map.md G11).
const insertRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko, scan_mode, confirmed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id
`, [
filename,
@@ -113,7 +115,9 @@ export async function POST(req: NextRequest) {
fileHash,
latitude,
longitude,
account?.kodeToko || null
account?.kodeToko || null,
scanMode,
false
]);
docId = insertRes.rows[0].id;
@@ -122,15 +126,28 @@ export async function POST(req: NextRequest) {
// wedged GPU container doesn't hang this request forever - it still won't fit under the
// mobile client's 2-minute receive timeout in the worst case, but bounds the hang to a fixed,
// known ceiling instead of an indefinite one.
//
// /api/parse has its own error handlers that mark the document parsed=true with
// "Not Found" placeholder metadata on a pipeline failure - so those cases already
// resolve out of "pending". The one gap is this call itself never completing
// (network error / the 210s abort firing): /api/parse's handlers never even run,
// so the document is otherwise silently stuck at parsed=false forever. Record
// that case explicitly so GET /api/v1/documents/:id can report parseStatus "failed"
// instead of the client burning its own full timeout waiting on "pending".
try {
await fetch("http://127.0.0.1:3000/api/parse", {
const parseRes = await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko }),
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko, scanMode }),
signal: AbortSignal.timeout(210_000)
});
if (!parseRes.ok) {
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [`Pipeline error: HTTP ${parseRes.status}`, docId]);
}
} catch (err) {
console.error("Error triggering parse synchronously:", err);
const message = err instanceof Error ? err.message : "Parse request failed";
await query("UPDATE documents SET parse_error = $1 WHERE id = $2", [message, docId]);
}
// Return the response structured as DocumentModel.fromJson format
@@ -5,9 +5,12 @@ import { getAccountFromAuthHeader } from "@/utils/auth";
export async function GET(req: NextRequest) {
try {
// Read access is open to any authenticated account (task 9.2) - the
// Flutter product editor needs this to populate its SKU dropdown, and
// has no admin role of its own. Writes below stay admin-gated.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
if (!account) {
return errorResponse(401, "Unauthorized");
}
const res = await query(`
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { classifyAndMatchProduct, ClassifierError } from "@/utils/product-scan";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
// Any authenticated account may scan - unlike sku_master writes, this is the
// route the mobile app itself calls to do a product scan, not an admin tool.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
let imageBase64: string | null = null;
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("multipart/form-data")) {
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const buffer = Buffer.from(await file.arrayBuffer());
imageBase64 = buffer.toString("base64");
} else {
const body = await req.json();
imageBase64 = body.image_base64 || body.image || null;
}
if (!imageBase64) {
return errorResponse(400, "Image is required", { headers: corsHeaders });
}
const result = await classifyAndMatchProduct(imageBase64);
return NextResponse.json({
status: "success",
data: result
}, { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in v1 scan-product API route:", error);
if (error instanceof ClassifierError) {
return errorResponse(error.status, error.message, { headers: corsHeaders });
}
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
+49 -15
View File
@@ -194,9 +194,11 @@ export default function ScanPfmPage() {
setEditedSku("");
setActiveTab("summary");
const reader = new FileReader();
reader.onload = () => {
setSelectedImage(reader.result as string);
reader.onload = async () => {
const base64Image = reader.result as string;
setSelectedImage(base64Image);
setSelectedProduct(null);
await runScanForImage(base64Image);
};
reader.onerror = () => setError("Failed to read file");
reader.readAsDataURL(file);
@@ -257,21 +259,13 @@ export default function ScanPfmPage() {
setIsEditingProductName(false);
};
const handleScan = async () => {
if (!selectedImage) {
setError("Please select or upload an image first.");
return;
}
const runScanForImage = async (base64Image: string) => {
setScanning(true);
setError("");
setScanResult(null);
setEditedSku("");
setActiveTab("summary");
try {
let base64Image = selectedImage;
if (selectedImage.startsWith("/produk-pfm")) {
base64Image = await convertUrlToBase64(selectedImage);
}
const res = await fetch("/api/scan-pfm", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -300,6 +294,25 @@ export default function ScanPfmPage() {
}
};
const handleScan = async () => {
if (!selectedImage) {
setError("Please select or upload an image first.");
return;
}
let base64Image = selectedImage;
if (selectedImage.startsWith("/produk-pfm")) {
try {
setScanning(true);
base64Image = await convertUrlToBase64(selectedImage);
} catch (err) {
setError(getErrorMessage(err));
setScanning(false);
return;
}
}
await runScanForImage(base64Image);
};
const handleRotate = async () => {
if (!selectedImage) return;
setRotating(true);
@@ -323,7 +336,9 @@ export default function ScanPfmPage() {
ctx.translate(canvas.width, 0);
ctx.rotate((90 * Math.PI) / 180);
ctx.drawImage(img, 0, 0);
setSelectedImage(canvas.toDataURL("image/jpeg", 0.95));
const rotatedBase64 = canvas.toDataURL("image/jpeg", 0.95);
setSelectedImage(rotatedBase64);
await runScanForImage(rotatedBase64);
} catch (err: unknown) {
setError(getErrorMessage(err));
} finally {
@@ -447,13 +462,24 @@ export default function ScanPfmPage() {
<button
key={item.productName}
id={`product-btn-${item.productName.replace(/\s+/g, "-").toLowerCase()}`}
onClick={() => {
onClick={async () => {
setSelectedProduct(item);
if (item.images.length > 0) setSelectedImage(item.images[0]);
setScanResult(null);
setEditedSku("");
setError("");
setActiveTab("summary");
if (item.images.length > 0) {
const imgUrl = item.images[0];
setSelectedImage(imgUrl);
try {
setScanning(true);
const base64Image = await convertUrlToBase64(imgUrl);
await runScanForImage(base64Image);
} catch (err) {
setError(getErrorMessage(err));
setScanning(false);
}
}
}}
className={`w-full text-left p-2.5 rounded-xl border transition-all text-xs flex flex-col gap-1 cursor-pointer ${
isSelected
@@ -542,11 +568,19 @@ export default function ScanPfmPage() {
{selectedProduct.images.map((img) => (
<button
key={img}
onClick={() => {
onClick={async () => {
setSelectedImage(img);
setScanResult(null);
setError("");
setActiveTab("summary");
try {
setScanning(true);
const base64Image = await convertUrlToBase64(img);
await runScanForImage(base64Image);
} catch (err) {
setError(getErrorMessage(err));
setScanning(false);
}
}}
className={`w-16 h-16 rounded-lg border-2 overflow-hidden flex-shrink-0 cursor-pointer transition-all ${
selectedImage === img ? "border-teal-500 scale-95 shadow-md" : "border-slate-800 hover:border-slate-600"
+12 -1
View File
@@ -22,7 +22,10 @@ export async function initDb(pool: Pool) {
is_sample BOOLEAN NOT NULL DEFAULT FALSE,
file_hash VARCHAR(64),
processing_logs JSONB,
kode_toko VARCHAR(255)
kode_toko VARCHAR(255),
scan_mode VARCHAR(20),
parse_error TEXT,
confirmed BOOLEAN NOT NULL DEFAULT TRUE
);
`);
@@ -31,6 +34,14 @@ export async function initDb(pool: Pool) {
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_logs JSONB;");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS kode_toko VARCHAR(255);");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS scan_mode VARCHAR(20);");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS parse_error TEXT;");
// DEFAULT TRUE grandfathers every pre-existing row (today's history stays
// visible after this migration) - only new uploads explicitly insert
// `confirmed = false` (v1/documents/upload/route.ts) so a document only
// re-enters `GET /api/v1/documents` once the user PUTs (confirms) it.
// See docs/api-contract-map.md G11.
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS confirmed BOOLEAN NOT NULL DEFAULT TRUE;");
await pool.query("CREATE INDEX IF NOT EXISTS idx_documents_file_hash ON documents(file_hash);");
} catch (alterErr) {
console.error("Failed to alter documents table for schema upgrade:", alterErr);
@@ -0,0 +1,117 @@
export type ParseStatus = "pending" | "done" | "failed";
export interface DocumentRow {
id: number;
filename: string;
upload_time: Date;
parsed: boolean;
metadata: any;
latitude: any;
longitude: any;
scan_mode: string | null;
parse_error: string | null;
confirmed: boolean;
}
export interface OcrItemRow {
kode_barang: string | null;
nama_barang: string | null;
banyak: string | null;
jumlah: string | null;
}
// Shared by GET /api/v1/documents (list), GET /api/v1/documents/:id, and the
// upload route's dedup-return branch, so the header/shipment/status mapping
// only lives in one place.
export function mapDocumentRow(doc: DocumentRow, itemRows: OcrItemRow[]) {
const metadata = doc.metadata || {};
const items = itemRows.map((item) => ({
nomor_sku: item.kode_barang || "",
nama_barang: item.nama_barang || "",
banyak: item.banyak || "",
jumlah: item.jumlah || ""
}));
let header = {
tanggal: "",
no_po: "",
no_so: "",
no_do: ""
};
let shipment = {
kepada_yth: "",
order_untuk: "",
alamat: "",
plat_truk: "",
nama_driver: "",
nama_penerima: ""
};
if (metadata.header) {
// Document was updated via mobile app
header = {
tanggal: metadata.header.tanggal || "",
no_po: metadata.header.no_po || "",
no_so: metadata.header.no_so || "",
no_do: metadata.header.no_do || ""
};
shipment = {
kepada_yth: metadata.shipment?.kepada_yth || "",
order_untuk: metadata.shipment?.order_untuk || "",
alamat: metadata.shipment?.alamat || "",
plat_truk: metadata.shipment?.plat_truk || "",
nama_driver: metadata.shipment?.nama_driver || "",
nama_penerima: metadata.shipment?.nama_penerima || ""
};
} else {
// Document was freshly uploaded / parsed via web
header = {
tanggal: metadata.tanggal || "",
no_po: metadata.noPO || "",
no_so: metadata.noSO || "",
no_do: metadata.noDO || doc.filename || ""
};
shipment = {
kepada_yth: metadata.customerInfo || "",
order_untuk: metadata.orderUntuk || "",
alamat: metadata.alamat || "",
plat_truk: metadata.platTruk || "",
nama_driver: "",
nama_penerima: metadata.headerRemark || ""
};
}
const parseStatus: ParseStatus = doc.parsed
? "done"
: doc.parse_error
? "failed"
: "pending";
// scan_mode is the source of truth once persisted (task 9.1); fall back to the
// legacy metadata sentinel for rows created before that column existed.
const docType = doc.scan_mode || (shipment.order_untuk === "PRODUCT SCAN" ? "Product" : "DO");
return {
id: doc.id.toString(),
filePath: doc.filename,
createdAt: doc.upload_time.toISOString(),
header,
shipment,
items,
parsed: doc.parsed,
latitude: doc.latitude ? parseFloat(doc.latitude.toString()) : null,
longitude: doc.longitude ? parseFloat(doc.longitude.toString()) : null,
parseStatus,
docType,
confirmed: doc.confirmed,
// Full classify+OCR result captured at upload time for Product Scan
// documents (gap G3) - lets the editor render immediately instead of
// re-running the GPU pipeline on review. `null` for DO documents, and
// for Product documents parsed before this existed or already PUT
// (the PUT route rebuilds `metadata` from scratch without this key,
// which is fine - the editor only needs it during the initial review).
productScan: metadata.productScan || null
};
}
@@ -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
};
}