Fas 2 steg 5: Scan-to-scan-diff + konfliktlösning, samt buggfix depleted-transaktion i reconciliation

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 01:30:31 +07:00
parent 218bc44d08
commit 4a4f448e1c
26 changed files with 10590 additions and 13 deletions
+11 -1
View File
@@ -228,7 +228,17 @@ export async function reconciliationRoutes(app: FastifyInstance) {
.returning();
if (!updated) throw errors.internal("Kunde inte uppdatera varan.");
if (input.action === "exists" || quantityChange !== 0) {
if (input.action === "depleted") {
await app.db.insert(schema.inventoryTransactions).values({
inventoryItemId: params.itemId,
householdId,
actorUserId: req.userId,
type: "correction",
quantityDelta: -item.quantity,
unit: item.unit,
note: input.note,
});
} else if (input.action === "exists" || quantityChange !== 0) {
await app.db.insert(schema.inventoryTransactions).values({
inventoryItemId: params.itemId,
householdId,
+419
View File
@@ -0,0 +1,419 @@
import type { FastifyInstance } from "fastify";
import { and, eq } from "drizzle-orm";
import { schema } from "@app/database";
import { diffScans, type ScanDiffRow, computeBalance } from "@app/inventory-engine";
import { z } from "zod";
import type { Unit } from "@app/shared-types";
import { errors, parse } from "../lib/errors.js";
import { requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js";
import { scanDiffApplyInputSchema } from "@app/validation";
import { inventoryConflictCreated, inventoryConflictResolved } from "@app/analytics";
/**
* Scan-to-scan-diff + konfliktlösning (Fas 2 §5.55.6).
*
* - ALDRIG automatiska negativa transaktioner.
* - "vanished" är förslag som kräver användarbekräftelse.
* - Varje automatisk diff skapar en undo-post (samma transaktion kan ångras via negativ delta).
*/
export async function scanDiffRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
/** Jämför nuvarande lager mot ett nytt scan-resultat. */
app.post("/v1/scans/:id/diff", auth, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1);
if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte.");
if (!job.result) throw errors.badRequest("Skanningen har inget resultat än.");
const observations = extractObservations(job.result as Record<string, unknown>);
const items = await app.db
.select()
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.householdId, householdId));
const previousItems = items
.filter((i) => i.quantity > 0 && !i.depletedAt)
.map((i) => ({
id: i.id,
displayName: i.displayName,
quantity: i.quantity,
unit: i.unit,
storageLocationId: i.storageLocationId,
bestBeforeDate: i.bestBeforeDate,
useByDate: i.useByDate,
}));
const diff = diffScans(previousItems, observations, new Date());
// Identifiera osäkra differ som konflikter (för review-vyn)
const conflicts = diff.rows
.filter((r) => r.kind === "vanished" || r.confidence < 0.75)
.map((row) => ({
kind: row.kind,
displayName: row.displayName,
previousItemId: row.previousItemId,
proposedResolution: row.kind === "vanished" ? "depleted" : { quantity: row.newQuantity, locationId: row.newLocationId },
}));
return {
scanJobId: params.id,
rows: diff.rows,
pendingVanishedIds: diff.pendingVanishedIds,
conflicts,
modelVersion: job.modelVersion,
promptVersion: job.promptVersion,
};
});
/** Applicera bekräftade diff-rader. Idempotency via idempotencyKeys. */
app.post("/v1/scans/:id/diff/apply", auth, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const input = parse(scanDiffApplyInputSchema, req.body);
// Idempotency
if (input.idempotencyKey) {
const [existing] = await app.db
.select()
.from(schema.idempotencyKeys)
.where(
and(
eq(schema.idempotencyKeys.userId, req.userId),
eq(schema.idempotencyKeys.key, input.idempotencyKey),
eq(schema.idempotencyKeys.endpoint, "POST /v1/scans/:id/diff/apply"),
),
)
.limit(1);
if (existing?.responseBody) {
return existing.responseBody as { appliedItemIds: string[] };
}
}
const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1);
if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte.");
const appliedItemIds: string[] = [];
for (const row of input.rows) {
if (row.kind === "new_item") {
const locationId = row.newLocationId ?? (await defaultLocation(app, householdId, job.scanType));
if (!locationId) continue;
const [inv] = await app.db
.insert(schema.inventoryItems)
.values({
householdId,
displayName: row.displayName,
quantity: row.newQuantity ?? 1,
unit: row.unit as Unit,
storageLocationId: locationId,
source: scanSource(job.scanType),
confidence: row.confidence,
verifiedByUser: row.confidence >= 0.9,
lastVerifiedAt: row.confidence >= 0.9 ? new Date() : null,
modelVersion: job.modelVersion,
promptVersion: job.promptVersion,
})
.returning();
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: inv!.id,
type: "purchase",
quantityDelta: row.newQuantity ?? 1,
unit: row.unit as Unit,
refType: "scan_diff",
refId: params.id,
actorUserId: req.userId,
});
appliedItemIds.push(inv!.id);
} else if (row.kind === "quantity_changed" && row.previousItemId) {
const [item] = await app.db
.select()
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, row.previousItemId))
.limit(1);
if (!item) continue;
const newQty = row.newQuantity ?? item.quantity;
const delta = newQty - item.quantity;
await app.db
.update(schema.inventoryItems)
.set({ quantity: newQty, updatedAt: new Date() })
.where(eq(schema.inventoryItems.id, item.id));
if (Math.abs(delta) > 1e-6) {
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: item.id,
type: "correction",
quantityDelta: delta,
unit: item.unit,
refType: "scan_diff",
refId: params.id,
actorUserId: req.userId,
});
}
appliedItemIds.push(item.id);
} else if (row.kind === "moved" && row.previousItemId) {
const [item] = await app.db
.select()
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, row.previousItemId))
.limit(1);
if (!item) continue;
const newLocationId = row.newLocationId ?? item.storageLocationId;
const newQty = row.newQuantity ?? item.quantity;
const delta = newQty - item.quantity;
await app.db
.update(schema.inventoryItems)
.set({ storageLocationId: newLocationId, quantity: newQty, updatedAt: new Date() })
.where(eq(schema.inventoryItems.id, item.id));
if (Math.abs(delta) > 1e-6) {
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: item.id,
type: "correction",
quantityDelta: delta,
unit: item.unit,
refType: "scan_diff",
refId: params.id,
actorUserId: req.userId,
});
}
appliedItemIds.push(item.id);
} else if (row.kind === "depleted" && row.previousItemId) {
// Användaren har explicit bekräftat "vanished" → depleted
const [item] = await app.db
.select()
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, row.previousItemId))
.limit(1);
if (!item) continue;
await app.db
.update(schema.inventoryItems)
.set({ quantity: 0, depletedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.inventoryItems.id, item.id));
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: item.id,
type: "correction",
quantityDelta: -item.quantity,
unit: item.unit,
refType: "scan_diff",
refId: params.id,
actorUserId: req.userId,
});
appliedItemIds.push(item.id);
}
}
// Transaktionsinvariant: validera att saldot stämmer för alla berörda varor
for (const itemId of appliedItemIds) {
const [item] = await app.db
.select({ quantity: schema.inventoryItems.quantity })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId))
.limit(1);
const txs = await app.db
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
const balance = computeBalance(txs);
if (Math.abs(balance.balance - (item?.quantity ?? 0)) > 1e-6) {
throw errors.internal("Transaktionsinvarianten bröts efter scan-diff.");
}
}
const response = { appliedItemIds };
if (input.idempotencyKey) {
await app.db
.insert(schema.idempotencyKeys)
.values({
userId: req.userId,
key: input.idempotencyKey,
endpoint: "POST /v1/scans/:id/diff/apply",
responseStatus: 200,
responseBody: response as Record<string, unknown>,
})
.onConflictDoNothing();
}
return response;
});
/** Lista öppna konflikter för hushållet. */
app.get("/v1/inventory/conflicts", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const conflicts = await app.db
.select()
.from(schema.inventoryConflicts)
.where(and(eq(schema.inventoryConflicts.householdId, householdId), eq(schema.inventoryConflicts.status, "open")))
.orderBy(schema.inventoryConflicts.createdAt);
return { conflicts };
});
/** Lös en konflikt med valfritt resultat; skriver alltid vanliga transaktioner. */
app.post("/v1/inventory/conflicts/:id/resolve", auth, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const body = z
.object({
resolution: z.enum(["source_a", "source_b", "manual"]),
manualQuantity: z.number().min(0).optional(),
manualLocationId: z.string().uuid().optional(),
note: z.string().max(200).optional(),
})
.parse(req.body);
const [conflict] = await app.db
.select()
.from(schema.inventoryConflicts)
.where(eq(schema.inventoryConflicts.id, params.id))
.limit(1);
if (!conflict || conflict.householdId !== householdId) throw errors.notFound("Konflikten finns inte.");
if (conflict.status !== "open") throw errors.badRequest("Konflikten är redan löst.");
const resolved = resolveConflictPayload(conflict, body);
if (resolved.quantity != null && conflict.inventoryItemId) {
const [item] = await app.db
.select()
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, conflict.inventoryItemId))
.limit(1);
if (item) {
const delta = resolved.quantity - item.quantity;
if (Math.abs(delta) > 1e-6 || resolved.status === "depleted") {
const finalQty = resolved.status === "depleted" ? 0 : resolved.quantity;
await app.db
.update(schema.inventoryItems)
.set({
quantity: finalQty,
storageLocationId: resolved.locationId ?? item.storageLocationId,
depletedAt: resolved.status === "depleted" ? new Date() : item.depletedAt,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, item.id));
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: item.id,
type: "correction",
quantityDelta: finalQty - item.quantity,
unit: item.unit,
refType: "conflict",
refId: conflict.id,
actorUserId: req.userId,
note: body.note,
});
}
}
}
await app.db
.update(schema.inventoryConflicts)
.set({
status: "resolved",
resolvedByUserId: req.userId,
resolution: { ...resolved, note: body.note } as Record<string, unknown>,
resolvedAt: new Date(),
})
.where(eq(schema.inventoryConflicts.id, params.id));
await trackProductAnalytics(
app.db,
req.userId,
inventoryConflictResolved({
householdId,
properties: { conflictId: conflict.id, resolution: body.resolution },
}),
);
return { ok: true };
});
}
function extractObservations(result: Record<string, unknown> | unknown[]): Array<{
displayName: string;
quantity: number;
unit: string;
storageLocationId: string;
bestBeforeDate?: string | null;
useByDate?: string | null;
observationConfidence: number;
}> {
const raw = Array.isArray(result) ? result : Array.isArray(result.items) ? (result.items as unknown[]) : [];
return raw
.map((r) => {
const item = r as Record<string, unknown>;
return {
displayName: String(item.displayName ?? ""),
quantity: Number(item.quantity ?? 1),
unit: String(item.unit ?? "COUNT"),
storageLocationId: String(item.storageLocationId ?? ""),
bestBeforeDate: item.bestBeforeDate ? String(item.bestBeforeDate) : null,
useByDate: item.useByDate ? String(item.useByDate) : null,
observationConfidence: Number(item.confidence ?? item.observationConfidence ?? 0.8),
};
})
.filter((r) => r.displayName && r.storageLocationId);
}
function scanSource(scanType: string) {
switch (scanType) {
case "fridge":
return "fridge_photo" as const;
case "freezer":
return "freezer_photo" as const;
case "pantry":
case "ingredients":
return "pantry_photo" as const;
default:
return "label_photo" as const;
}
}
async function defaultLocation(app: FastifyInstance, householdId: string, scanType: string) {
const wanted =
scanType === "freezer" ? "freezer" : scanType === "pantry" || scanType === "ingredients" ? "pantry" : "fridge";
const [loc] = await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(and(eq(schema.storageLocations.householdId, householdId), eq(schema.storageLocations.type, wanted)))
.limit(1);
return loc?.id ?? null;
}
function resolveConflictPayload(
conflict: typeof schema.inventoryConflicts.$inferSelect,
body: { resolution: "source_a" | "source_b" | "manual"; manualQuantity?: number; manualLocationId?: string },
): { quantity?: number; locationId?: string; status?: string } {
const a = (conflict.payloadA ?? {}) as Record<string, unknown>;
const b = (conflict.payloadB ?? {}) as Record<string, unknown>;
if (body.resolution === "source_a") {
return {
quantity: a.quantity != null ? Number(a.quantity) : undefined,
locationId: a.locationId ? String(a.locationId) : undefined,
status: a.status ? String(a.status) : undefined,
};
}
if (body.resolution === "source_b") {
return {
quantity: b.quantity != null ? Number(b.quantity) : undefined,
locationId: b.locationId ? String(b.locationId) : undefined,
status: b.status ? String(b.status) : undefined,
};
}
return {
quantity: body.manualQuantity,
locationId: body.manualLocationId,
status: body.manualQuantity === 0 ? "depleted" : "exists",
};
}
+2
View File
@@ -11,6 +11,7 @@ import { meRoutes } from "./routes/me.js";
import { householdRoutes } from "./routes/households.js";
import { inventoryRoutes } from "./routes/inventory.js";
import { scanRoutes } from "./routes/scans.js";
import { scanDiffRoutes } from "./routes/scan-diff.js";
import { recipeRoutes } from "./routes/recipes.js";
import { mealRoutes } from "./routes/meals.js";
import { shoppingRoutes } from "./routes/shopping.js";
@@ -77,6 +78,7 @@ export async function buildServer(config: AppConfig) {
await app.register(householdRoutes);
await app.register(inventoryRoutes);
await app.register(scanRoutes);
await app.register(scanDiffRoutes);
await app.register(recipeRoutes);
await app.register(mealRoutes);
await app.register(shoppingRoutes);
+53
View File
@@ -4,6 +4,7 @@ import { eq, inArray } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
import { computeBalance } from "@app/inventory-engine";
describe("quick reconciliation", () => {
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
@@ -123,4 +124,56 @@ describe("quick reconciliation", () => {
expect(body.quantity).toBe(0.5);
expect(body.verifiedByUser).toBe(true);
});
it("transaction invariant holds for exists/depleted/uncertain", async () => {
const start = await app.inject({
method: "POST",
url: "/v1/reconciliations/start",
headers: { authorization: `Bearer ${token}` },
payload: {},
});
const { candidates } = JSON.parse(start.body) as { candidates: Array<{ itemId: string }> };
const itemId = candidates[0]!.itemId;
async function assertBalanceMatches(itemId: string) {
const [item] = await testDb.db
.select({ quantity: schema.inventoryItems.quantity })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId))
.limit(1);
const txs = await testDb.db
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
const balance = computeBalance(txs.map((tx) => ({ type: tx.type, quantityDelta: tx.quantityDelta, unit: tx.unit })));
expect(balance.balance).toBeCloseTo(item!.quantity, 5);
}
// exists med justering
await app.inject({
method: "POST",
url: `/v1/reconciliations/items/${itemId}/resolve`,
headers: { authorization: `Bearer ${token}` },
payload: { action: "exists", quantity: 0.7 },
});
await assertBalanceMatches(itemId);
// depleted ska alltid skriva transaktion
await app.inject({
method: "POST",
url: `/v1/reconciliations/items/${itemId}/resolve`,
headers: { authorization: `Bearer ${token}` },
payload: { action: "depleted" },
});
await assertBalanceMatches(itemId);
// uncertain med justering
await app.inject({
method: "POST",
url: `/v1/reconciliations/items/${itemId}/resolve`,
headers: { authorization: `Bearer ${token}` },
payload: { action: "uncertain", quantity: 0.2 },
});
await assertBalanceMatches(itemId);
});
});
+192
View File
@@ -0,0 +1,192 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq, inArray } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
describe("scan-to-scan-diff", () => {
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
let app: Awaited<ReturnType<typeof buildServer>>;
let token: string;
let householdId: string;
let scanJobId: string;
const email = "scan-diff-test@example.invalid";
async function cleanup() {
const existing = await testDb.db
.select({ id: schema.users.id })
.from(schema.users)
.where(inArray(schema.users.email, [email]));
for (const u of existing) {
await testDb.db.delete(schema.idempotencyKeys).where(eq(schema.idempotencyKeys.userId, u.id));
const memberships = await testDb.db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, u.id));
for (const m of memberships) {
const items = await testDb.db
.select({ id: schema.inventoryItems.id })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.householdId, m.householdId));
for (const it of items) {
await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.inventoryItemId, it.id));
}
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.householdId, m.householdId));
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
}
await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id));
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
}
}
beforeAll(async () => {
await cleanup();
app = await buildServer(config);
await app.ready();
const res = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: { email, password: "Password123!", displayName: "Scan Diff Test" },
});
const body = JSON.parse(res.body) as { accessToken: string };
token = body.accessToken;
const quick = await app.inject({
method: "POST",
url: "/v1/onboarding/quick-start",
headers: { authorization: `Bearer ${token}` },
payload: { goals: ["less_waste"], precisionMode: "simple" },
});
householdId = (JSON.parse(quick.body) as { householdId: string }).householdId;
const [location] = await testDb.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(eq(schema.storageLocations.householdId, householdId))
.limit(1);
// Skapa två varor att diffa mot
await app.inject({
method: "POST",
url: "/v1/inventory/items",
headers: { authorization: `Bearer ${token}` },
payload: {
displayName: "Mjölk",
quantity: 1,
unit: "LITER",
storageLocationId: location!.id,
},
});
await app.inject({
method: "POST",
url: "/v1/inventory/items",
headers: { authorization: `Bearer ${token}` },
payload: {
displayName: "Yoghurt",
quantity: 2,
unit: "COUNT",
storageLocationId: location!.id,
},
});
// Skapa ett färdigt scan-jobb med resultat
const [job] = await testDb.db
.insert(schema.scanJobs)
.values({
userId: (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub,
householdId,
scanType: "fridge",
jobType: "ANALYZE_FRIDGE_IMAGE",
status: "completed",
result: [
{ displayName: "Mjölk", quantity: 0.5, unit: "LITER", storageLocationId: location!.id, confidence: 0.9 },
{ displayName: "Ost", quantity: 1, unit: "COUNT", storageLocationId: location!.id, confidence: 0.9 },
],
modelVersion: "v1",
promptVersion: "p1",
})
.returning();
scanJobId = job!.id;
});
afterAll(async () => {
await cleanup();
await closeDatabase();
await app.close();
});
it("returns diff rows with model/prompt versions", async () => {
const res = await app.inject({
method: "POST",
url: `/v1/scans/${scanJobId}/diff`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as {
rows: Array<{ kind: string; displayName: string }>;
modelVersion: string;
promptVersion: string;
};
expect(body.modelVersion).toBe("v1");
expect(body.promptVersion).toBe("p1");
const kinds = body.rows.map((r) => r.kind);
expect(kinds).toContain("quantity_changed");
expect(kinds).toContain("new_item");
expect(kinds).toContain("vanished");
});
it("applies accepted diff rows idempotently", async () => {
const diff = await app.inject({
method: "POST",
url: `/v1/scans/${scanJobId}/diff`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
const { rows } = JSON.parse(diff.body) as { rows: Array<{ kind: string; itemId?: string; previousItemId?: string; displayName: string; newQuantity?: number; newLocationId?: string; confidence: number }> };
const changed = rows.find((r) => r.kind === "quantity_changed")!;
const apply = await app.inject({
method: "POST",
url: `/v1/scans/${scanJobId}/diff/apply`,
headers: { authorization: `Bearer ${token}` },
payload: {
idempotencyKey: "diff-apply-1",
rows: [
{
kind: "quantity_changed",
previousItemId: changed.previousItemId,
displayName: changed.displayName,
unit: "LITER",
newQuantity: changed.newQuantity,
confidence: changed.confidence,
},
],
},
});
expect(apply.statusCode).toBe(200);
const applied = JSON.parse(apply.body) as { appliedItemIds: string[] };
expect(applied.appliedItemIds.length).toBeGreaterThan(0);
// Idempotency: samma nyckel ska ge samma svar
const apply2 = await app.inject({
method: "POST",
url: `/v1/scans/${scanJobId}/diff/apply`,
headers: { authorization: `Bearer ${token}` },
payload: {
idempotencyKey: "diff-apply-1",
rows: [],
},
});
expect(apply2.statusCode).toBe(200);
expect(JSON.parse(apply2.body)).toEqual(applied);
});
});
@@ -0,0 +1,130 @@
import { useMemo, useState } from "react";
import { View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
type DiffRow = {
kind: "new_item" | "moved" | "quantity_changed" | "vanished" | "unchanged";
previousItemId?: string;
displayName: string;
unit: string;
previousQuantity?: number;
newQuantity?: number;
previousLocationId?: string;
newLocationId?: string;
confidence: number;
reason: string;
};
export default function ScanDiffReviewScreen() {
const { jobId } = useLocalSearchParams<{ jobId: string }>();
const [rejectedKinds] = useState<Set<string>>(new Set());
const query = useQuery({
queryKey: ["scan-diff", jobId],
queryFn: () => api<{ rows: DiffRow[] }>(`/v1/scans/${jobId}/diff`, { method: "POST", body: {} }),
});
const apply = useMutation({
mutationFn: (rows: DiffRow[]) =>
api<{ appliedItemIds: string[] }>(`/v1/scans/${jobId}/diff/apply`, {
method: "POST",
body: { idempotencyKey: `scan-diff-${jobId}-${Date.now()}`, rows },
}),
onSuccess: () => {
router.back();
},
});
const rows = useMemo(() => query.data?.rows ?? [], [query.data]);
const actionable = rows.filter((r) => r.kind !== "unchanged");
const accepted = actionable.filter((r) => !rejectedKinds.has(rowKey(r)));
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
if (actionable.length === 0) {
return (
<Screen>
<Heading>{t("scan.diff.title")}</Heading>
<EmptyState text={t("scan.diff.noChanges")} />
<Button label={t("common.back")} variant="secondary" onPress={() => router.back()} />
</Screen>
);
}
return (
<Screen>
<Heading>{t("scan.diff.title")}</Heading>
<Small>{t("scan.diff.subtitle")}</Small>
<Spacer size={spacing.sm} />
{actionable.map((row) => (
<Card key={rowKey(row)}>
<Row style={{ justifyContent: "space-between" }}>
<Body>{row.displayName}</Body>
<Tag label={t(`scan.diff.${row.kind}`)} tone={toneForKind(row.kind)} />
</Row>
<Small>{row.reason}</Small>
{(row.kind === "quantity_changed" || row.kind === "moved") && (
<Small>
{formatQty(row.previousQuantity)} {formatQty(row.newQuantity)} {row.unit}
</Small>
)}
{row.kind === "vanished" && (
<Small style={{ opacity: 0.8 }}>{t("reconciliation.tasteHint")}</Small>
)}
</Card>
))}
<Small style={{ opacity: 0.7 }}>{t("scan.diff.undoHint")}</Small>
<Spacer size={spacing.sm} />
<Button
label={t("scan.diff.acceptAll")}
loading={apply.isPending}
onPress={() => apply.mutate(accepted)}
/>
</Screen>
);
}
function rowKey(row: DiffRow): string {
return `${row.kind}:${row.previousItemId ?? "new"}:${row.displayName}`;
}
function toneForKind(kind: DiffRow["kind"]) {
switch (kind) {
case "vanished":
return "warning";
case "new_item":
return "success";
case "quantity_changed":
case "moved":
return "accent";
default:
return "neutral";
}
}
function formatQty(q?: number): string {
if (q == null) return "-";
return Number.isInteger(q) ? String(q) : q.toFixed(1);
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Færdig",
"reconciliation.next": "Næste",
"reconciliation.empty": "Intet behøver kontrol lige nu. Godt arbejde!",
"reconciliation.tasteHint": "Overskredet mindstholdbarhedsdato? lugt og smag først smid aldrig mad ud unødigt."
"reconciliation.tasteHint": "Overskredet mindstholdbarhedsdato? lugt og smag først smid aldrig mad ud unødigt.",
"scan.diff.title": "Gennemgå forskelle",
"scan.diff.subtitle": "Bekræft ændringer fra seneste scanning.",
"scan.diff.newItem": "Ny vare",
"scan.diff.quantityChanged": "Mængde ændret",
"scan.diff.moved": "Flyttet",
"scan.diff.vanished": "Mangler på billedet",
"scan.diff.unchanged": "Uændret",
"scan.diff.accept": "Bekræft",
"scan.diff.reject": "Ignorer",
"scan.diff.acceptAll": "Bekræft alle",
"scan.diff.noChanges": "Ingen forskelle fundet.",
"scan.diff.undo": "Fortryd",
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Fertig",
"reconciliation.next": "Weiter",
"reconciliation.empty": "Aktuell muss nichts kontrolliert werden. Gut gemacht!",
"reconciliation.tasteHint": "Mindesthaltbarkeitsdatum überschritten? Zuerst riechen und schmecken: niemals unbedingt Essen wegwerfen."
"reconciliation.tasteHint": "Mindesthaltbarkeitsdatum überschritten? Zuerst riechen und schmecken: niemals unbedingt Essen wegwerfen.",
"scan.diff.title": "Unterschiede prüfen",
"scan.diff.subtitle": "Änderungen aus dem letzten Scan bestätigen.",
"scan.diff.newItem": "Neuer Artikel",
"scan.diff.quantityChanged": "Menge geändert",
"scan.diff.moved": "Verschoben",
"scan.diff.vanished": "Fehlt auf dem Foto",
"scan.diff.unchanged": "Unverändert",
"scan.diff.accept": "Bestätigen",
"scan.diff.reject": "Ignorieren",
"scan.diff.acceptAll": "Alle bestätigen",
"scan.diff.noChanges": "Keine Unterschiede gefunden.",
"scan.diff.undo": "Rückgängig",
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Done",
"reconciliation.next": "Next",
"reconciliation.empty": "Nothing needs checking right now. Well done!",
"reconciliation.tasteHint": "Past best before? Smell and taste first never waste food unnecessarily."
"reconciliation.tasteHint": "Past best before? Smell and taste first never waste food unnecessarily.",
"scan.diff.title": "Review differences",
"scan.diff.subtitle": "Confirm changes from the latest scan.",
"scan.diff.newItem": "New item",
"scan.diff.quantityChanged": "Quantity changed",
"scan.diff.moved": "Moved",
"scan.diff.vanished": "Missing in photo",
"scan.diff.unchanged": "Unchanged",
"scan.diff.accept": "Confirm",
"scan.diff.reject": "Ignore",
"scan.diff.acceptAll": "Confirm all",
"scan.diff.noChanges": "No differences found.",
"scan.diff.undo": "Undo",
"scan.diff.undoHint": "Each change can be undone from the item detail view."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Listo",
"reconciliation.next": "Siguiente",
"reconciliation.empty": "No hay nada que revisar ahora. ¡Bien hecho!",
"reconciliation.tasteHint": "¿Pasada la fecha de consumo preferente? Huele y prueba primero: nunca tires comida sin necesidad."
"reconciliation.tasteHint": "¿Pasada la fecha de consumo preferente? Huele y prueba primero: nunca tires comida sin necesidad.",
"scan.diff.title": "Revisar diferencias",
"scan.diff.subtitle": "Confirmar cambios del último escaneo.",
"scan.diff.newItem": "Nuevo producto",
"scan.diff.quantityChanged": "Cantidad cambiada",
"scan.diff.moved": "Movido",
"scan.diff.vanished": "No aparece en la foto",
"scan.diff.unchanged": "Sin cambios",
"scan.diff.accept": "Confirmar",
"scan.diff.reject": "Ignorar",
"scan.diff.acceptAll": "Confirmar todo",
"scan.diff.noChanges": "No se encontraron diferencias.",
"scan.diff.undo": "Deshacer",
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Valmis",
"reconciliation.next": "Seuraava",
"reconciliation.empty": "Mitään ei tarvitse tarkistaa juuri nyt. Hyvää työtä!",
"reconciliation.tasteHint": "Parasta ennen -päiväys umpeutunut? Haista ja maista ensin älä koskaan heitä ruokaa turhaan."
"reconciliation.tasteHint": "Parasta ennen -päiväys umpeutunut? Haista ja maista ensin älä koskaan heitä ruokaa turhaan.",
"scan.diff.title": "Tarkista erot",
"scan.diff.subtitle": "Vahvista viimeisen skannauksen muutokset.",
"scan.diff.newItem": "Uusi tuote",
"scan.diff.quantityChanged": "Määrä muuttunut",
"scan.diff.moved": "Siirretty",
"scan.diff.vanished": "Puuttuu kuvasta",
"scan.diff.unchanged": "Muuttumaton",
"scan.diff.accept": "Vahvista",
"scan.diff.reject": "Ohita",
"scan.diff.acceptAll": "Vahvista kaikki",
"scan.diff.noChanges": "Eroja ei löytynyt.",
"scan.diff.undo": "Kumoa",
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Terminé",
"reconciliation.next": "Suivant",
"reconciliation.empty": "Rien à vérifier pour l'instant. Bien joué !",
"reconciliation.tasteHint": "Dépasse la date de durabilité minimale ? Sentez et goûtez d'abord : ne gaspillez jamais de nourriture inutilement."
"reconciliation.tasteHint": "Dépasse la date de durabilité minimale ? Sentez et goûtez d'abord : ne gaspillez jamais de nourriture inutilement.",
"scan.diff.title": "Vérifier les différences",
"scan.diff.subtitle": "Confirmer les changements du dernier scan.",
"scan.diff.newItem": "Nouvel article",
"scan.diff.quantityChanged": "Quantité modifiée",
"scan.diff.moved": "Déplacé",
"scan.diff.vanished": "Absent sur la photo",
"scan.diff.unchanged": "Inchangé",
"scan.diff.accept": "Confirmer",
"scan.diff.reject": "Ignorer",
"scan.diff.acceptAll": "Tout confirmer",
"scan.diff.noChanges": "Aucune différence trouvée.",
"scan.diff.undo": "Annuler",
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Fatto",
"reconciliation.next": "Avanti",
"reconciliation.empty": "Non c'è nulla da controllare. Ottimo lavoro!",
"reconciliation.tasteHint": "Scadenza preferibile superata? Odora e assaggia prima: non buttare mai cibo inutilmente."
"reconciliation.tasteHint": "Scadenza preferibile superata? Odora e assaggia prima: non buttare mai cibo inutilmente.",
"scan.diff.title": "Rivedi differenze",
"scan.diff.subtitle": "Conferma le modifiche dall'ultima scansione.",
"scan.diff.newItem": "Nuovo articolo",
"scan.diff.quantityChanged": "Quantità modificata",
"scan.diff.moved": "Spostato",
"scan.diff.vanished": "Mancante nella foto",
"scan.diff.unchanged": "Invariato",
"scan.diff.accept": "Conferma",
"scan.diff.reject": "Ignora",
"scan.diff.acceptAll": "Conferma tutto",
"scan.diff.noChanges": "Nessuna differenza trovata.",
"scan.diff.undo": "Annulla",
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Ferdig",
"reconciliation.next": "Neste",
"reconciliation.empty": "Ingenting trenger kontroll akkurat nå. Bra jobbet!",
"reconciliation.tasteHint": "Forbi best før-dato? Lukt og smak først kast aldri mat unødvendig."
"reconciliation.tasteHint": "Forbi best før-dato? Lukt og smak først kast aldri mat unødvendig.",
"scan.diff.title": "Gå gjennom forskjeller",
"scan.diff.subtitle": "Bekreft endringer fra siste skanning.",
"scan.diff.newItem": "Ny vare",
"scan.diff.quantityChanged": "Mengde endret",
"scan.diff.moved": "Flyttet",
"scan.diff.vanished": "Mangler på bildet",
"scan.diff.unchanged": "Uendret",
"scan.diff.accept": "Bekreft",
"scan.diff.reject": "Ignorer",
"scan.diff.acceptAll": "Bekreft alle",
"scan.diff.noChanges": "Ingen forskjeller funnet.",
"scan.diff.undo": "Angre",
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Klaar",
"reconciliation.next": "Volgende",
"reconciliation.empty": "Er is momenteel niets om te controleren. Goed gedaan!",
"reconciliation.tasteHint": "Tenminste houdbaar tot verstreken? Ruik en proef eerst gooi nooit onnodig eten weg."
"reconciliation.tasteHint": "Tenminste houdbaar tot verstreken? Ruik en proef eerst gooi nooit onnodig eten weg.",
"scan.diff.title": "Verschillen controleren",
"scan.diff.subtitle": "Bevestig wijzigingen uit de laatste scan.",
"scan.diff.newItem": "Nieuw item",
"scan.diff.quantityChanged": "Hoeveelheid gewijzigd",
"scan.diff.moved": "Verplaatst",
"scan.diff.vanished": "Ontbreekt op foto",
"scan.diff.unchanged": "Ongewijzigd",
"scan.diff.accept": "Bevestig",
"scan.diff.reject": "Negeer",
"scan.diff.acceptAll": "Alles bevestigen",
"scan.diff.noChanges": "Geen verschillen gevonden.",
"scan.diff.undo": "Ongedaan maken",
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave."
}
+14 -1
View File
@@ -379,5 +379,18 @@
"reconciliation.done": "Gotowe",
"reconciliation.next": "Dalej",
"reconciliation.empty": "Teraz nic nie wymaga kontroli. Dobra robota!",
"reconciliation.tasteHint": "Po terminie przydatności do spożycia? Najpierw powąchaj i posmakuj nigdy nie wyrzucaj jedzenia bez potrzeby."
"reconciliation.tasteHint": "Po terminie przydatności do spożycia? Najpierw powąchaj i posmakuj nigdy nie wyrzucaj jedzenia bez potrzeby.",
"scan.diff.title": "Sprawdź różnice",
"scan.diff.subtitle": "Potwierdź zmiany z ostatniego skanu.",
"scan.diff.newItem": "Nowy produkt",
"scan.diff.quantityChanged": "Zmieniona ilość",
"scan.diff.moved": "Przeniesiony",
"scan.diff.vanished": "Brakuje na zdjęciu",
"scan.diff.unchanged": "Bez zmian",
"scan.diff.accept": "Potwierdź",
"scan.diff.reject": "Ignoruj",
"scan.diff.acceptAll": "Potwierdź wszystko",
"scan.diff.noChanges": "Nie znaleziono różnic.",
"scan.diff.undo": "Cofnij",
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Concluído",
"reconciliation.next": "Próximo",
"reconciliation.empty": "Nada precisa de verificação agora. Muito bem!",
"reconciliation.tasteHint": "Passou do prazo de validade? Cheire e prove primeiro nunca desperdice comida desnecessariamente."
"reconciliation.tasteHint": "Passou do prazo de validade? Cheire e prove primeiro nunca desperdice comida desnecessariamente.",
"scan.diff.title": "Rever diferenças",
"scan.diff.subtitle": "Confirmar alterações do último scan.",
"scan.diff.newItem": "Novo item",
"scan.diff.quantityChanged": "Quantidade alterada",
"scan.diff.moved": "Movido",
"scan.diff.vanished": "Ausente na foto",
"scan.diff.unchanged": "Inalterado",
"scan.diff.accept": "Confirmar",
"scan.diff.reject": "Ignorar",
"scan.diff.acceptAll": "Confirmar tudo",
"scan.diff.noChanges": "Nenhuma diferença encontrada.",
"scan.diff.undo": "Desfazer",
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Klart",
"reconciliation.next": "Nästa",
"reconciliation.empty": "Inget behöver avstämning just nu. Bra jobbat!",
"reconciliation.tasteHint": "Passerat bäst före? Lukta och smaka först mat kastas inte i onödan."
"reconciliation.tasteHint": "Passerat bäst före? Lukta och smaka först mat kastas inte i onödan.",
"scan.diff.title": "Granska skillnader",
"scan.diff.subtitle": "Bekrätta ändringar från senaste skanningen.",
"scan.diff.newItem": "Ny vara",
"scan.diff.quantityChanged": "Ändrad mängd",
"scan.diff.moved": "Flyttad",
"scan.diff.vanished": "Saknas på bilden",
"scan.diff.unchanged": "Oförändrad",
"scan.diff.accept": "Bekräfta",
"scan.diff.reject": "Ignorera",
"scan.diff.acceptAll": "Bekräfta alla",
"scan.diff.noChanges": "Inga skillnader hittades.",
"scan.diff.undo": "Ångra",
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy."
}