From 2aaa7f9b3f9da137744e6366110c74a4697ba15b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 18:57:18 +0000 Subject: [PATCH] feat(skann): markera troliga dubbletter i granskningen innan bekraftelse Enligt Johans onskan: appen forstar sjalv nar en vara troligen redan finns (samma katalog-id ELLER liknande namn, aven om AI:n laser den olika fran en annan vinkel) och markerar den i skann-granskningen. Hog sakerhet (>=0.7) = avmarkerad som standard men syns tydligt och gar att angra; lagre = markerad men kvar sa du valjer. Jamfor bade mot aktivt lager och tidigare varor i samma skanning. Ingen auto-hopslagning utan bekraftelse (spec 9). GET /v1/scans/:id annoterar varje forslag med possibleDuplicate {name, reason, score}. --- apps/api/src/routes/scans.ts | 78 ++++++++++++++++++++- apps/mobile/src/app/scan-review/[jobId].tsx | 22 ++++-- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/apps/api/src/routes/scans.ts b/apps/api/src/routes/scans.ts index 1c215c3..e85489e 100644 --- a/apps/api/src/routes/scans.ts +++ b/apps/api/src/routes/scans.ts @@ -7,7 +7,7 @@ import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@a import { errors, parse } from "../lib/errors.js"; import { emitEvent, requireActiveHousehold } from "../lib/helpers.js"; import { consumeAiScan } from "../lib/entitlements.js"; -import { normalizeItemName } from "@app/inventory-engine"; +import { findDuplicateCandidates, normalizeItemName } from "@app/inventory-engine"; /** * Skanningsflödet (spec §50): @@ -125,7 +125,8 @@ export async function scanRoutes(app: FastifyInstance) { app.get("/v1/scans/:id", auth, async (req) => { const { id } = parse(idParamSchema, req.params); - return getOwnedScan(app, id, req.userId); + const job = await getOwnedScan(app, id, req.userId); + return annotateScanDuplicates(app, job); }); app.post("/v1/scans/:id/confirm", auth, async (req) => { @@ -293,6 +294,79 @@ async function getOwnedScan(app: FastifyInstance, id: string, userId: string) { return job; } +/** + * Flaggar troliga dubbletter i ett skann-resultat INNAN användaren bekräftar: + * jämför varje föreslagen vara mot aktivt lager OCH mot tidigare varor i samma + * skanning (så samma hylla från två vinklar fångas). Ren hint – appen slår + * aldrig ihop utan bekräftelse (spec §9); användaren väljer i granskningen. + */ +async function annotateScanDuplicates( + app: FastifyInstance, + job: Awaited>, +) { + const result = job.result as { items?: Array> } | null; + if (!result?.items?.length || !job.householdId) return job; + + const rows = await app.db + .select({ + id: schema.inventoryItems.id, + canonicalIngredientId: schema.inventoryItems.canonicalIngredientId, + displayName: schema.inventoryItems.displayName, + brand: schema.inventoryItems.brand, + quantity: schema.inventoryItems.quantity, + source: schema.inventoryItems.source, + createdAt: schema.inventoryItems.createdAt, + }) + .from(schema.inventoryItems) + .where( + and( + eq(schema.inventoryItems.householdId, job.householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ), + ); + + const existing = rows.map((r) => ({ + id: r.id, + canonicalIngredientId: r.canonicalIngredientId, + displayName: r.displayName, + brand: r.brand, + quantity: Number(r.quantity) || 0, + source: r.source, + createdAt: r.createdAt.toISOString(), + })); + + const source = scanSource(job.scanType); + const now = new Date().toISOString(); + const seen: typeof existing = []; + + const items = result.items.map((it, idx) => { + const input = { + canonicalIngredientId: (it.canonicalIngredientId as string | null) ?? null, + displayName: String(it.detectedName ?? ""), + brand: (it.brand as string | null) ?? null, + quantity: typeof it.estimatedQuantity === "number" ? it.estimatedQuantity : 1, + source, + createdAt: now, + }; + const pool = [...existing, ...seen]; + const [top] = findDuplicateCandidates(input, pool); + seen.push({ id: String(it.tempId ?? idx), ...input }); + if (!top) return { ...it, possibleDuplicate: null }; + const match = pool.find((p) => p.id === top.itemId); + return { + ...it, + possibleDuplicate: { + name: match?.displayName ?? "", + reason: top.reasons[0] ?? "liknande vara", + score: top.score, + }, + }; + }); + + return { ...job, result: { ...result, items } }; +} + async function lookupBarcode(app: FastifyInstance, gtin: string) { // 1. Egen produktdatabas (aktuell version) const [own] = await app.db diff --git a/apps/mobile/src/app/scan-review/[jobId].tsx b/apps/mobile/src/app/scan-review/[jobId].tsx index eac82ba..3c810ff 100644 --- a/apps/mobile/src/app/scan-review/[jobId].tsx +++ b/apps/mobile/src/app/scan-review/[jobId].tsx @@ -39,6 +39,7 @@ interface DetectedItem { bestBeforeDate: string | null; confidence: number; requiresConfirmation: boolean; + possibleDuplicate?: { name: string; reason: string; score: number } | null; } interface ScanJob { id: string; @@ -65,6 +66,7 @@ interface EditableItem { dateIsUseBy: boolean; confidence: number; rejected: boolean; + duplicate: { name: string; reason: string; score: number } | null; } export default function ScanReviewScreen() { @@ -103,7 +105,9 @@ export default function ScanReviewScreen() { date: item.bestBeforeDate ?? "", dateIsUseBy: false, confidence: item.confidence, - rejected: false, + duplicate: item.possibleDuplicate ?? null, + // Hög säkerhet på dubblett → avmarkerad som standard (syns tydligt + går att ångra). + rejected: (item.possibleDuplicate?.score ?? 0) >= 0.7, })), ); } @@ -125,6 +129,7 @@ export default function ScanReviewScreen() { date: "", dateIsUseBy: false, confidence: 1, + duplicate: null, rejected: false, }, ]); @@ -196,12 +201,15 @@ export default function ScanReviewScreen() { {(items ?? []).map((item) => ( - - {item.confidence < 0.7 && !item.rejected ? ( - - ) : ( - - )} + + + {item.duplicate && ( + + )} + {item.confidence < 0.7 && !item.rejected && ( + + )} +