diff --git a/apps/api/src/routes/reconciliations.ts b/apps/api/src/routes/reconciliations.ts index bc0c3f6..b9633b8 100644 --- a/apps/api/src/routes/reconciliations.ts +++ b/apps/api/src/routes/reconciliations.ts @@ -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, diff --git a/apps/api/src/routes/scan-diff.ts b/apps/api/src/routes/scan-diff.ts new file mode 100644 index 0000000..b8ca246 --- /dev/null +++ b/apps/api/src/routes/scan-diff.ts @@ -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.5–5.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); + + 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, + }) + .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, + 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 | 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; + 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; + const b = (conflict.payloadB ?? {}) as Record; + 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", + }; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 159e591..a5cfd85 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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); diff --git a/apps/api/test/reconciliation.test.ts b/apps/api/test/reconciliation.test.ts index fbff25f..a25cf6f 100644 --- a/apps/api/test/reconciliation.test.ts +++ b/apps/api/test/reconciliation.test.ts @@ -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); + }); }); diff --git a/apps/api/test/scan-diff.test.ts b/apps/api/test/scan-diff.test.ts new file mode 100644 index 0000000..01f01d7 --- /dev/null +++ b/apps/api/test/scan-diff.test.ts @@ -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>; + 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); + }); +}); diff --git a/apps/mobile/src/app/scan-diff-review/[jobId].tsx b/apps/mobile/src/app/scan-diff-review/[jobId].tsx new file mode 100644 index 0000000..895af35 --- /dev/null +++ b/apps/mobile/src/app/scan-diff-review/[jobId].tsx @@ -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>(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 ; + if (query.isError) return void query.refetch()} />; + + if (actionable.length === 0) { + return ( + + {t("scan.diff.title")} + +