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",
};
}