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:
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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user