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:
@@ -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,
|
||||
// så ö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)
|
||||
|
||||
@@ -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,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%"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user