fix(inventory): robustare dedup vid skann + borttag pa startsidan

Dedup vid /v1/scans/:id/confirm matchade bara pa exakt (skiftlageskansligt)
namn eller identiskt katalog-id, sa omfotografering av samma hylla skapade
dubbletter. Ny normalizeItemName (gemener, accenter, storlek bort; behaller
fetthalt-siffror) + matchning pa katalog-id ELLER normaliserat namn mot
aktivt hushallslager.

Mobil: 'Ta bort' fanns bara i Ditt kok. Lade borttag pa startsidans
lager-rader (befintlig DELETE /v1/inventory/items/:id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZdew6cWn1MeHqoYWFfxzo
This commit is contained in:
Claude
2026-08-18 14:44:03 +00:00
parent 4ca567bba3
commit 684743ae95
4 changed files with 86 additions and 23 deletions
+29 -19
View File
@@ -7,6 +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";
/**
* Skanningsflödet (spec §50):
@@ -141,6 +142,23 @@ export async function scanRoutes(app: FastifyInstance) {
const created: string[] = [];
const proposals = extractProposals(job);
// Aktiva varor i hushållet, för dubblett-hopslagning vid bekräftelse.
const activeRows = await app.db
.select({
id: schema.inventoryItems.id,
canonicalIngredientId: schema.inventoryItems.canonicalIngredientId,
displayName: schema.inventoryItems.displayName,
})
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
gt(schema.inventoryItems.quantity, 0),
),
);
const activeItems = activeRows.map((r) => ({ ...r, norm: normalizeItemName(r.displayName) }));
for (const item of input.items) {
const proposal = findProposal(proposals, item.tempId);
await recordCorrection(app, job, item, proposal);
@@ -150,25 +168,17 @@ export async function scanRoutes(app: FastifyInstance) {
if (!locationId)
throw errors.badRequest("storageLocationId saknas och ingen standardplats finns.");
// Dedup (#1): finns varan redan aktiv i hushållet? Uppdatera + hoppa över,
// överlappande foton / omfotografering inte skapar dubletter.
const dedupCond = item.canonicalIngredientId
? and(
eq(schema.inventoryItems.householdId, householdId),
eq(schema.inventoryItems.canonicalIngredientId, item.canonicalIngredientId),
gt(schema.inventoryItems.quantity, 0),
)
: and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.canonicalIngredientId),
eq(schema.inventoryItems.displayName, item.displayName),
gt(schema.inventoryItems.quantity, 0),
);
const [dupe] = await app.db
.select({ id: schema.inventoryItems.id })
.from(schema.inventoryItems)
.where(dedupCond)
.limit(1);
// Dedup (#1): slå ihop om varan redan finns aktiv i hushållet, så att
// överlappande foton / omfotografering av samma hylla inte dubbellagras.
// Matcha på katalog-id ELLER normaliserat namn — Vision skriver sällan
// exakt samma namn två gånger ("Mjölk" vs "mjölk" vs "Mjölk 1L").
const wantNorm = normalizeItemName(item.displayName);
const dupe = activeItems.find(
(r) =>
(item.canonicalIngredientId != null &&
r.canonicalIngredientId === item.canonicalIngredientId) ||
r.norm === wantNorm,
);
if (dupe) {
await app.db
.update(schema.inventoryItems)
+24 -4
View File
@@ -1,7 +1,7 @@
import { Pressable, View } from "react-native";
import { Alert, Pressable, View } from "react-native";
import { useCallback } from "react";
import { router, useFocusEffect } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { t } from "@/lib/i18n";
@@ -9,6 +9,7 @@ import * as Haptics from "expo-haptics";
import { formatMinor } from "@/lib/money";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
@@ -41,6 +42,18 @@ interface BudgetSummary {
}
export default function HomeScreen() {
const queryClient = useQueryClient();
const remove = useMutation({
mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["inventory"] }),
});
const confirmRemove = (id: string, name: string) =>
Alert.alert("Ta bort", `Ta bort ${name} ur ditt lager?`, [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(id) },
]);
const inventory = useQuery({
queryKey: ["inventory"],
queryFn: () =>
@@ -160,11 +173,18 @@ export default function HomeScreen() {
<Card key={location}>
<Heading>{location}</Heading>
{locationItems.slice(0, 12).map((item) => (
<Row key={item.id} style={{ justifyContent: "space-between" }}>
<Row key={item.id} style={{ justifyContent: "space-between", alignItems: "center" }}>
<Body>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<ExpiryTag expiry={item.expiry} />
<Row style={{ alignItems: "center" }}>
<ExpiryTag expiry={item.expiry} />
<Button
label="Ta bort"
variant="ghost"
onPress={() => confirmRemove(item.id, item.displayName)}
/>
</Row>
</Row>
))}
{locationItems.length > 12 && (
+21
View File
@@ -21,6 +21,27 @@ export interface DedupCandidate {
const WINDOW_MS = 3 * 86_400_000; // 3 dygn
/**
* Normaliserar ett produktnamn för dubblett-matchning: gemener, bort med
* accenter/å/ä/ö, storleksangivelser (500 g, 1 l) och skiljetecken — men
* BEHÅLL sifferskillnader som skiljer varianter (mjölk 3 % vs 1,5 %).
* "Mjölk", "mjölk", "MJÖLK 1L" → "mjolk"; "Mjölk 3%" → "mjolk 3".
*/
export function normalizeItemName(name: string): string {
return name
.normalize("NFC")
.toLowerCase()
.replace(/[àáâãäå]/g, "a")
.replace(/[èéêë]/g, "e")
.replace(/[ìíîï]/g, "i")
.replace(/[òóôõö]/g, "o")
.replace(/[ùúûü]/g, "u")
.replace(/\b\d+(?:[.,]\d+)?\s*(?:kg|g|ml|cl|dl|l|st|pack)\b/g, " ")
.replace(/[^a-z0-9 ]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/**
* Dubblettkandidater mellan kvitto, streckkod och bilder (spec §9).
* Ren heuristik användaren fattar alltid beslutet. AI-baserad
@@ -6,6 +6,7 @@ import {
findDuplicateCandidates,
forecastDepletion,
normalizeDelta,
normalizeItemName,
} from "../src/index.js";
const TODAY = new Date("2026-08-02T00:00:00Z"); // UTC testet ska vara sant i alla tidszoner
@@ -217,3 +218,14 @@ describe("pantry forecast (spec §40)", () => {
);
});
});
describe("normalizeItemName (dubblett-matchning vid omfoto)", () => {
it("slår ihop skiftläge, accenter och storlek", () => {
expect(normalizeItemName("Mjölk")).toBe(normalizeItemName("mjölk"));
expect(normalizeItemName("MJÖLK 1L")).toBe(normalizeItemName("Mjölk"));
expect(normalizeItemName("Vispgrädde 5 dl")).toBe(normalizeItemName("vispgrädde"));
});
it("håller isär olika fetthalt/varianter", () => {
expect(normalizeItemName("Mjölk 3%")).not.toBe(normalizeItemName("Mjölk 1,5%"));
});
});