docs(plan): per-sale expiry tracking design — batch registry + candidate matching

Grilled 2026-07-16 with the user; full decision record in
docs/expiry-tracking-plan.md. Core reframe: expiry is captured once per
batch at DO intake (staff-typed on the stock-entry confirmation page, from
the physical packs), so the cashier scan only MATCHES OCR fragments against
the 1-3 known in-stock batch dates instead of free-reading damaged
dot-matrix prints (proven model-capability ceiling, 2026-07-15). Fallback:
auto-FEFO + 'inferred' flag, zero cashier interaction. No cloud, ever.

- docs/expiry-tracking-plan.md: architecture, matching algorithm spec
  (resolveExpiryFromEvidence), schema/API deltas, phases 1-3, testing plan
- backend plans §13 (13.1-13.4): matcher util + offline tuning, route
  wiring + expiry_source provenance, multi-frame union, dot-matrix
  recognizer fine-tune
- root plans §10 (10.1-10.3): cashier fast path, inferred badge +
  end-of-day review, burst capture for mounted camera
- stock-feature-plan.md: extension note (batch dropdown becomes the
  manual-override path)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8TumxFDnyVnfsR3mxPXfX
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Fable 5 committed 2026-07-16 17:00:36 +07:00
1 parent 721dea41dc
commit e6daa9b053
7 files changed
+350 -333

No files matched your search

-20
View File
@@ -1,20 +0,0 @@
from PIL import Image
import numpy as np
from paddleocr import PaddleOCR
ocr = PaddleOCR(use_textline_orientation=True, lang='en')
image = Image.open('/app/config/test_img.jpeg').convert('RGB')
img_arr = np.array(image)
res_list = list(ocr.predict(img_arr))
texts = res_list[0].get('rec_texts', [])
dt_polys = res_list[0].get('dt_polys', [])
for idx, (text, poly) in enumerate(zip(texts, dt_polys)):
if 'BB05032027' in text or 'BB' in text:
print(f"Match: {text}")
print("Raw poly:")
print(poly)
print("Pts computed:")
pts = [(float(p[0]), float(p[1])) for p in poly]
print(pts)
+55
View File
@@ -450,6 +450,61 @@ implemented** — no code for this feature exists in the codebase yet.
*Suggested order: 12.1 → 12.2 (needs 12.1's tables/movement helper) and 12.3
(needs 12.1's summary route) — 12.2/12.3 are independent of each other.*
## 13. Backend — Per-Sale Expiry Resolution (Candidate Matching)
`src/utils/expiry-matcher.ts`, `src/app/api/parse/route.ts`,
`src/app/api/v1/scan-product/route.ts`, `src/app/api/v1/documents/`,
`config/classify_ocr_server.py`
Added 2026-07-16 from a user-directed grilling session (ad-hoc feature per
`AGENTS.md` Part B7, like §12). **Read
[../../docs/expiry-tracking-plan.md](../../docs/expiry-tracking-plan.md) first**
— full design, confirmed decisions (full automation at cashier, no cloud ever,
auto-FEFO + `inferred` flag as the only fallback), matching algorithm spec, and
phase plan. Core idea: expiry is captured once per batch at DO intake
(staff-typed on the stock-entry page, §12/root §9), so the cashier scan only has
to **match** OCR fragments against 1–3 known candidate dates — never free-read a
damaged dot-matrix print under time pressure. **Blocked on 12.1 + 12.2** (needs
`stock_batches` + the in-stock candidate filter). Flutter counterpart: root
`plans/next-enhancements.md` §10.
- **13.1** [TODO] **`resolveExpiryFromEvidence()` matcher util + offline tuning
harness.** Pure TS util implementing the 4-stage resolution
(`single_batch` → `matched_exact` → `matched_fragment` → `inferred_fefo`)
with digit-confusion-aware fuzzy scoring of candidate date print-forms (and
batch codes) against the scan's OCR `text_lines`; thresholds
(`SCORE_MIN`/`MARGIN_MIN`) tuned offline by replaying the 79 frozen-benchmark
line-sets against synthetic candidate sets built from ground-truth labels —
tune for **zero wrong-candidate picks** (flagged FEFO beats a confident wrong
match). Includes verifying the classify server response actually carries
`text_lines` to the gateway (add to payload if not — small
`classify_ocr_server.py` change). Unit tests + harness script committed.
- **13.2** [TODO] **Wire resolution into both scan routes + persist
provenance.** `documents.expiry_source VARCHAR(20)` CHECK
(`single_batch|matched_exact|matched_fragment|inferred_fefo|manual`) +
`expiry_match_score REAL NULL`; `parse/route.ts` Product branch and
`v1/scan-product/route.ts` call the matcher after the §12.2 in-stock filter
and include `resolvedBatch`/`source`/`score` in `metadata.productScan` and
the response (no second GPU call — same pattern as 11.1); PUT persists
`expiry_source` (client override ⇒ `'manual'`); documents list gains an
`?expiry_source=` filter for the end-of-day review of `inferred_fefo` sales.
Blocked on 13.1.
- **13.3** [TODO] **Phase 2 — multi-frame evidence union.** Accept burst
uploads (N frames per scan) on the product-scan path; classify on the best
frame, union OCR `text_lines` across all frames before matching (glare moves
between frames — fragments accumulate). Pairs with a mounted camera at the
cashier (root §10.3). Blocked on 13.2.
- **13.4** [TODO] **Phase 3 — on-prem dot-matrix recognizer.** Synthetic
dot-matrix/inkjet date-crop generator (dot dropout, scratch, fade, curvature,
glare augmentation) + real-data flywheel (harvest scan crops weakly labeled
by the batch registry's staff-typed expiry values); fine-tune a small rec
model on the RTX 2060; deploy as an additional reader in
`classify_ocr_server.py` feeding the same matcher. Goal: shrink the
`inferred_fefo` residue. **No cloud — hard constraint.** Blocked on 13.2;
independent of 13.3.
*Suggested order: 13.1 → 13.2 → (13.3 and/or 13.4 as needed once Phase-1
accuracy is measured in the field).*
---
*Sections 1-4 migrated 2026-07-08 from root `plans/next-enhancements.md` sections
-178
View File
@@ -1,178 +0,0 @@
// Offline experiment: OCR-evidence re-ranking of DINOv2 top-K candidates.
//
// Reads sources/product_scan_fullcap.json (captured live responses, see
// capture-scan-responses.mjs) + sources/product_manual_labels.json (ground
// truth) and simulates candidate re-ranking without touching the GPU stack,
// reporting fixed-vs-broken counts per parameter combination. The winning
// parameters get ported into pfm-web-app/src/utils/product-scan.ts.
//
// Idea: DINOv2's near-twin confusions (same brand, different flavor/size)
// are exactly the cases where the *printed variant words* differ - and
// PaddleOCR usually reads some of them. So within a narrow similarity band
// of the top-1, prefer the candidate whose distinguishing name tokens
// actually appear in the OCR'd text.
//
// Usage: node scripts/experiment-rerank.mjs
import fs from "fs";
import path from "path";
const cap = JSON.parse(fs.readFileSync(path.join("sources", "product_scan_fullcap.json"), "utf8"));
const labels = JSON.parse(fs.readFileSync(path.join("sources", "product_manual_labels.json"), "utf8"));
const gtBySku = new Map(labels.map((l) => [l.filename, l.no_sku]));
function classSku(className) {
// Class names are foto-kemasan-v2 folder names: "<SKU> <NAME...>"
return (className || "").trim().split(/\s+/)[0] || "";
}
function tokenize(name) {
return name
.toUpperCase()
.split(/[^A-Z0-9]+/)
.filter((t) => t.length >= 2);
}
function editDistance1(a, b) {
// true if edit distance <= 1 (same length: 1 substitution; off-by-one: 1 indel)
if (a === b) return true;
const la = a.length, lb = b.length;
if (Math.abs(la - lb) > 1) return false;
if (la === lb) {
let diff = 0;
for (let i = 0; i < la; i++) if (a[i] !== b[i]) diff++;
return diff <= 1;
}
const [s, l] = la < lb ? [a, b] : [b, a];
let i = 0, j = 0, skipped = false;
while (i < s.length && j < l.length) {
if (s[i] === l[j]) { i++; j++; }
else if (!skipped) { skipped = true; j++; }
else return false;
}
return true;
}
function buildOcrIndex(textLines) {
const joined = textLines.join(" ").toUpperCase();
const squashed = joined.replace(/[^A-Z0-9]/g, "");
const tokens = new Set(tokenize(joined));
return { squashed, tokens };
}
function tokenInOcr(token, ocrIdx, fuzzy) {
if (token.length >= 4 && ocrIdx.squashed.includes(token)) return true;
if (ocrIdx.tokens.has(token)) return true;
if (fuzzy && token.length >= 5) {
for (const t of ocrIdx.tokens) {
if (Math.abs(t.length - token.length) <= 1 && editDistance1(token, t)) return true;
}
}
return false;
}
function ocrEvidenceScore(candTokens, bandTokenCounts, bandSize, ocrIdx, fuzzy) {
// Coverage-normalized, rarity-weighted evidence: fraction of this
// candidate's *distinctive* name tokens (weighted by band rarity) that
// actually appear in the OCR'd text. Normalizing by the candidate's own
// distinctive-token mass is what stops generic packaging words from
// hijacking the ranking - a candidate whose name promises FRENCH +
// INSTITUSI + 2KG but whose package shows only "French Fries" scores
// 1/3, losing to a candidate whose 2 distinctive tokens both appear.
let matched = 0;
let total = 0;
for (const tok of new Set(candTokens)) {
const nWith = bandTokenCounts.get(tok) || 1;
if (nWith >= bandSize) continue; // shared by all -> no signal
const w = 1 / nWith;
total += w;
if (tokenInOcr(tok, ocrIdx, fuzzy)) matched += w;
}
return total > 0 ? matched / total : 0;
}
function skuFuzzyBoost(extractedSku, candidateSku) {
if (!extractedSku || extractedSku.length < 7) return 0;
if (extractedSku === candidateSku) return 10; // exact (normally pinned upstream anyway)
return editDistance1(extractedSku, candidateSku) ? 1 : 0;
}
function runConfig({ K, BAND, MARGIN, FUZZY, SKU_BOOST_W }) {
let baselineCorrect = 0, rerankCorrect = 0, fixed = [], broken = [];
for (const item of cap) {
if (item.error) continue;
const gt = gtBySku.get(item.filename);
if (!gt) continue;
const probs = item.classification?.all_probabilities || [];
if (!probs.length) continue;
const top1Sku = classSku(probs[0].name);
const baselineRight = top1Sku === gt;
if (baselineRight) baselineCorrect++;
// Candidate band: within BAND of top-1 similarity, capped at K
const top1Sim = probs[0].confidence;
const band = probs.slice(0, K).filter((p) => p.confidence >= top1Sim - BAND);
const ocrIdx = buildOcrIndex(item.ocr?.text_lines || []);
const candInfos = band.map((p) => {
const sku = classSku(p.name);
const tokens = tokenize(p.name.replace(sku, ""));
return { sku, sim: p.confidence, tokens };
});
const bandTokenCounts = new Map();
for (const c of candInfos) {
for (const tok of new Set(c.tokens)) {
bandTokenCounts.set(tok, (bandTokenCounts.get(tok) || 0) + 1);
}
}
for (const c of candInfos) {
c.ocrScore = ocrEvidenceScore(c.tokens, bandTokenCounts, candInfos.length, ocrIdx, FUZZY)
+ SKU_BOOST_W * skuFuzzyBoost(item.ocr?.extracted_sku || "", c.sku);
}
// Switch away from top-1 only when a band-mate has clearly stronger OCR evidence
let chosen = candInfos[0];
for (const c of candInfos.slice(1)) {
if (c.ocrScore >= chosen.ocrScore + MARGIN) chosen = c;
}
const rerankRight = chosen.sku === gt;
if (rerankRight) rerankCorrect++;
if (!baselineRight && rerankRight) fixed.push(item.filename);
if (baselineRight && !rerankRight) broken.push(item.filename);
}
return { baselineCorrect, rerankCorrect, fixed, broken };
}
const grid = [];
for (const K of [5, 8, 12]) {
for (const BAND of [0.04, 0.06, 0.08, 0.12]) {
// Coverage scores live in [0, 1]; margin is the minimum coverage lead a
// band-mate needs over the current pick before we switch away from it.
for (const MARGIN of [0.15, 0.25, 0.35, 0.5]) {
for (const FUZZY of [true, false]) {
for (const SKU_BOOST_W of [0, 2]) {
grid.push({ K, BAND, MARGIN, FUZZY, SKU_BOOST_W });
}
}
}
}
}
const results = grid.map((cfg) => ({ cfg, ...runConfig(cfg) }));
results.sort((a, b) => (b.rerankCorrect - b.broken.length * 0.01) - (a.rerankCorrect - a.broken.length * 0.01));
console.log(`Images evaluated: ${cap.filter((i) => !i.error && gtBySku.has(i.filename)).length}`);
console.log(`Baseline (DINOv2 top-1) correct: ${results[0].baselineCorrect}\n`);
console.log("Top 12 configs by re-ranked correct count:");
for (const r of results.slice(0, 12)) {
console.log(
` correct=${r.rerankCorrect} (+${r.fixed.length}/-${r.broken.length}) ` +
`K=${r.cfg.K} BAND=${r.cfg.BAND} MARGIN=${r.cfg.MARGIN} FUZZY=${r.cfg.FUZZY} SKUW=${r.cfg.SKU_BOOST_W}`
);
}
const best = results[0];
console.log(`\nBest config detail: ${JSON.stringify(best.cfg)}`);
console.log(` fixed (${best.fixed.length}): ${best.fixed.join(", ")}`);
console.log(` broken (${best.broken.length}): ${best.broken.join(", ")}`);
-130
View File
@@ -1,130 +0,0 @@
// One-off script: fill ground-truth labels for backend/sources/product-test-images/
// (the product-scan accuracy harness's Validation Set, previously 0 labeled images).
// Run once from backend/: node scripts/seed-validation-labels.mjs
import fs from "fs";
import path from "path";
const IMAGES_DIR = path.join("sources", "product-test-images");
const LABELS_PATH = path.join("sources", "product_manual_labels.json");
// [no_sku, nama_item, expiry_date ("" = not legible in photo, needs re-shoot)]
const DATA = [
["11110059", "CEKER BERKUKU FROZEN PACK 1 KG(*)", "13/06/2027"],
["11140051", "AMPELA FROZEN PACK 1 KG(*)", "26/11/2026"],
["11620056", "SBL (FILLET PAHA) 1 KG(*)", "27/02/2027"],
["11650053", "PAHA ATAS 1 KG(*)", "26/02/2027"],
["11660050", "PAHA BAWAH (1 KG)(*)", "22/06/2027"],
["11710051", "DADA UTUH (1 KG)(*)", ""],
["11818300", "CP-BEBEK GORENG 400GR/PAC", ""],
["1195008A", "RTC CHICKEN KALASAN 400 GR (PAC)", ""],
["11959937", "SATE AYAM FRESHMART 360 GR (PAC)", "24/11/2026"],
["12010111", "FIESTA CRISPY BUBBLE 400 GR/PAC", "07/05/2027"],
["12010115", "FIESTA NUGGET ZOO 400 GR/PAC", "01/12/2026"],
["12010117", "FIESTA NUGGET HAPPY STAR 400 GR/PAC", "27/08/2026"],
["12010119", "FIESTA NUGGET CHEESE 123 400 GR/PAC", "09/04/2027"],
["12010121", "FIESTA NUGGET PIZZABC 400 GR/PAC", "12/11/2026"],
["12010127", "FIESTA SPICY NUGGET 400 GR/PAC", "09/12/2027"],
["12010509", "CHAMP CRUNCHY NUGGET 450 GR/PAC", "15/04/2027"],
["12010515", "CHAMP KOIN KOMBINASI 450 GR/PAC", "15/04/2027"],
["12010519", "CHAMP NUGGET STICK 900 GR/PAC", "08/03/2027"],
["12012202", "ASIMO NUGGET KOMBINASI 1 KG/PAC", "13/05/2027"],
["12012501", "AKUMO CHICKEN NAGET 250 GR", "21/05/2027"],
["12012503", "AKUMO CHICKEN NUGGET 1000 GR", "17/06/2027"],
["12012505", "AKUMO KOIN 400 GR/PAC", "16/11/2026"],
["12020102", "FIESTA SPICY WING 400 GR/PAC", "22/05/2027"],
["12030102", "FIESTA STIKIE 200 GR/PAC", "26/02/2027"],
["12030403", "GOLDEN FIESTA STIKIE W/ SWEET CHILLI SAUCE 500GR", "07/04/2027"],
["12032502", "AKUMO CHICKEN STIK 500 GR", "09/03/2027"],
["12040101", "FIESTA SCHNITZEL 400 GR/PAC", "09/10/2026"],
["12040102", "FIESTA CRISPY BUBBLE KATSU 400 GR/PAC", "12/04/2027"],
["12060103", "FIESTA KARAGE 200 GR/PAC", ""],
["12060402", "GOLDEN FIESTA KARAGE CHILI SAUCE 500GR", "15/05/2027"],
["12080101", "FIESTA SPICY CHICK 400 GR/PAC", "26/04/2027"],
["12130102", "FIESTA CRISPY BURGER 360 GR (NEW)", "27/04/2027"],
["12150201", "FIESTA DS CRISPY CRUNCH 300 GR/PAC", "03/06/2027"],
["12150501", "CHAMP CRUNCHY HOTZZ 300 GR/PAC", ""],
["12190103", "FIESTA DELISTRIPE 400 GR/PAC", "07/04/2027"],
["12240103", "FIESTA YAKINIKU R/BITES 400 GR/PAC", "07/05/2027"],
["13010111", "FIESTA SOSIS BRATWURST 300 GR", "25/03/2027"],
["13010116", "FIESTA SSG ORIGINAL 300 GR", "23/06/2027"],
["13010118", "FIESTA RTG SSG 65 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", "25/10/2026"],
["13010125", "FIESTA RTG SAUSAGE WITH MENTAI LAVA 60GR", "05/11/2026"],
["13010518", "CHAMP SSG JUMBO BAKAR 500 GR/PAC", ""],
["13010524", "CHAMP SSG JUMBO BAKAR 500 GR/PAC (NEW)", ""],
["13012206", "ASIMO SOSIS AYAM KOMBINASI 500 GR", "15/03/2027"],
["13030501", "CHAMP CHICK MEATBALL 200 GR", ""],
["13070506", "CHAMP FRANKFURTER SSG 375GR", "13/03/2027"],
["13100512", "CHAMP CHICK SSG S/SANTAP ORIG 546GR (CAN)", ""],
["15010101", "FIESTA SHOESTRING 500 GR", "04/06/2027"],
["15010102", "FIESTA SHOESTRING 1000 GR", "05/03/2027"],
["15010107", "FIESTA FRENCH F SHOESTRING INSTITUSI 2KG", "17/06/2027"],
["15020101", "FIESTA STRAIGHT CUT 500 GR", "19/06/2027"],
["15020102", "FIESTA STRAIGHT CUT 1000 GR", "18/05/2027"],
["15030101", "FIESTA CRINKLE CUT 500 GR", ""],
["15030102", "FIESTA CRINKLE CUT 1000 GR", "07/04/2027"],
["16060113", "FIESTA CHICK SIOMAY 180GR (NEW)", "24/02/2027"],
["16060114", "FIESTA GYOZA 180 GR (NEW)", "18/05/2027"],
["17200109", "FIESTA RTS C/TERIYAKI 300GR/PAC", ""],
["17210106", "FIESTA RTS B/YAKINIKU 300GR/PAC", "19/05/2027"],
["17210107", "FIESTA RTS B/RENDANG 300GR/PAC", "23/06/2027"],
["17210108", "FIESTA RTS B/BLACKPEPPER 300GR/PAC", "09/05/2027"],
["17210109", "FIESTA RTS B/BULGOGI 300GR/PAC", "18/05/2027"],
["20040101", "FIESTA RAMEN BEKU 570 GR/PAC", "23/06/2026"],
["20120102", "FIESTA T/B AYAM GORENG 80 GR", ""],
["20120105", "FIESTA T/B SERBAGUNA Â 80 GR", "09/03/2027"],
["20120115", "FIESTA RACIK AYAM GORENG 20 GR/PAC", ""],
["20120116", "FIESTA RACIK NASI GORENG 20 GR/PAC", "13/10/2026"],
["21000123", "FIESTA RICE W/GEPREK CHICKEN 320GR/PAC", "30/04/2027"],
["21000126", "NEW FIESTA CHICK RENDANG W RICE 320GR (PAC)", "16/04/2027"],
["21000130", "NEW FIESTA RICE W/C CHEESE BULDAK 320GR (PAC)", "09/04/2027"],
["21000137", "FIESTA HAINAMESE CHICKEN RICE 320GR (PAC)", "12/02/2027"],
["21010101", "FIESTA TRUFFLE GYUDON 320 GR/PAC", "18/03/2027"],
["21200107", "NEW FIESTA SPAGHETTI CARBONARA 300GR (PAC)", "20/05/2027"],
];
const files = fs
.readdirSync(IMAGES_DIR)
.filter((f) => f !== "README.md" && f !== "filelist.txt")
.sort();
if (files.length !== DATA.length) {
throw new Error(`File count ${files.length} != DATA count ${DATA.length}`);
}
const existing = JSON.parse(fs.readFileSync(LABELS_PATH, "utf8"));
const now = new Date().toISOString();
let added = 0;
let skipped = 0;
let unreadable = 0;
for (let i = 0; i < files.length; i++) {
const filename = files[i];
const [no_sku, nama_item, expiry_date] = DATA[i];
if (existing.some((l) => l.filename === filename)) {
skipped++;
continue;
}
if (!expiry_date) unreadable++;
existing.push({
filename,
no_sku,
nama_item,
expiry_date,
top1_confidence: null,
notes: expiry_date
? ""
: "expiry date not legible in photo (cropped/blurry/out of frame) - needs re-shoot",
saved_at: now,
});
added++;
}
fs.writeFileSync(LABELS_PATH, JSON.stringify(existing, null, 2), "utf8");
console.log(`Added ${added} labels (${unreadable} flagged with no expiry_date), skipped ${skipped} already-labeled.`);