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
+1
View File
@@ -5,3 +5,4 @@ export * from "./dedup.js";
export * from "./forecast.js";
export * from "./trust.js";
export * from "./reconciliation.js";
export * from "./scan-diff.js";
+245
View File
@@ -0,0 +1,245 @@
export interface PreviousScanItem {
id: string;
displayName: string;
quantity: number;
unit: string;
storageLocationId: string;
bestBeforeDate?: string | null;
useByDate?: string | null;
}
export interface NewScanObservation {
displayName: string;
quantity: number;
unit: string;
storageLocationId: string;
bestBeforeDate?: string | null;
useByDate?: string | null;
/** Konfidens att denna observation verkligen representerar en fysisk vara. */
observationConfidence: number;
}
export type ScanDiffRowKind =
| "new_item"
| "moved"
| "quantity_changed"
| "vanished"
| "unchanged";
export interface ScanDiffRow {
kind: ScanDiffRowKind;
previousItemId?: string;
displayName: string;
unit: string;
previousQuantity?: number;
newQuantity?: number;
previousLocationId?: string;
newLocationId?: string;
/** Konfidens för slutsatsen (01). */
confidence: number;
/** Mänsklig läsbar motivering. */
reason: string;
}
export interface ScanDiffResult {
rows: ScanDiffRow[];
/** Identifierare för varor som är försvunna men ännu inte bekräftade borta. */
pendingVanishedIds: string[];
}
/**
* Jämför tidigare skannade varor med nya observationer.
*
* Regler (§5.5):
* - "new_item": ny observation som inte matchar någon tidigare vara.
* - "moved": samma vara, annan plats.
* - "quantity_changed": samma vara, annan mängd.
* - "vanished": tidigare vara saknas bland observationerna.
* - "unchanged": inget avvikande.
*
* ALDRIG automatiska negativa transaktioner för "vanished" — det är endast
* ett förslag som kräver användarbekräftelse.
*/
export function diffScans(
previousItems: PreviousScanItem[],
newObservations: NewScanObservation[],
now: Date = new Date(),
): ScanDiffResult {
const rows: ScanDiffRow[] = [];
const matchedPreviousIds = new Set<string>();
const matchedObservationIndexes = new Set<number>();
// 1. Matcha observationer mot tidigare varor (namn + plats + datummer nära)
for (let i = 0; i < newObservations.length; i++) {
const obs = newObservations[i]!;
let bestMatch: { item: PreviousScanItem; score: number } | null = null;
for (const item of previousItems) {
if (matchedPreviousIds.has(item.id)) continue;
const nameScore = nameSimilarity(item.displayName, obs.displayName);
if (nameScore < 0.6) continue;
let score = nameScore;
if (item.storageLocationId === obs.storageLocationId) score += 0.2;
if (datesEqual(item.bestBeforeDate, obs.bestBeforeDate)) score += 0.1;
if (datesEqual(item.useByDate, obs.useByDate)) score += 0.1;
if (!bestMatch || score > bestMatch.score) {
bestMatch = { item, score };
}
}
if (bestMatch && bestMatch.score >= 0.7) {
matchedPreviousIds.add(bestMatch.item.id);
matchedObservationIndexes.add(i);
const item = bestMatch.item;
const locationChanged = item.storageLocationId !== obs.storageLocationId;
const quantityChanged = Math.abs(item.quantity - obs.quantity) > 1e-6;
if (locationChanged && quantityChanged) {
rows.push({
kind: "moved",
previousItemId: item.id,
displayName: obs.displayName,
unit: obs.unit,
previousQuantity: item.quantity,
newQuantity: obs.quantity,
previousLocationId: item.storageLocationId,
newLocationId: obs.storageLocationId,
confidence: round2(bestMatch.score * obs.observationConfidence),
reason: "Flyttad och ändrad mängd",
});
} else if (locationChanged) {
rows.push({
kind: "moved",
previousItemId: item.id,
displayName: obs.displayName,
unit: obs.unit,
previousQuantity: item.quantity,
newQuantity: obs.quantity,
previousLocationId: item.storageLocationId,
newLocationId: obs.storageLocationId,
confidence: round2(bestMatch.score * obs.observationConfidence),
reason: "Flyttad till annan plats",
});
} else if (quantityChanged) {
rows.push({
kind: "quantity_changed",
previousItemId: item.id,
displayName: obs.displayName,
unit: obs.unit,
previousQuantity: item.quantity,
newQuantity: obs.quantity,
previousLocationId: item.storageLocationId,
newLocationId: obs.storageLocationId,
confidence: round2(bestMatch.score * obs.observationConfidence),
reason: "Ändrad mängd",
});
} else {
rows.push({
kind: "unchanged",
previousItemId: item.id,
displayName: obs.displayName,
unit: obs.unit,
previousQuantity: item.quantity,
newQuantity: obs.quantity,
previousLocationId: item.storageLocationId,
newLocationId: obs.storageLocationId,
confidence: round2(bestMatch.score * obs.observationConfidence),
reason: "Oförändrad",
});
}
}
}
// 2. Nya varor
for (let i = 0; i < newObservations.length; i++) {
if (matchedObservationIndexes.has(i)) continue;
const obs = newObservations[i]!;
rows.push({
kind: "new_item",
displayName: obs.displayName,
unit: obs.unit,
newQuantity: obs.quantity,
newLocationId: obs.storageLocationId,
confidence: round2(obs.observationConfidence),
reason: "Ny vara på bilden",
});
}
// 3. Försvunna varor — bara förslag, aldrig automatisk nollning
const pendingVanishedIds: string[] = [];
for (const item of previousItems) {
if (matchedPreviousIds.has(item.id)) continue;
rows.push({
kind: "vanished",
previousItemId: item.id,
displayName: item.displayName,
unit: item.unit,
previousQuantity: item.quantity,
previousLocationId: item.storageLocationId,
confidence: 0.6,
reason: "Saknas på nya bilden — bekräfta att den är slut",
});
pendingVanishedIds.push(item.id);
}
// Sortering: förändringar först, sedan nya, oförändrade sist
const kindOrder: Record<ScanDiffRowKind, number> = {
quantity_changed: 0,
moved: 1,
vanished: 2,
new_item: 3,
unchanged: 4,
};
rows.sort((a, b) => {
if (kindOrder[a.kind] !== kindOrder[b.kind]) return kindOrder[a.kind] - kindOrder[b.kind];
return a.displayName.localeCompare(b.displayName, "sv");
});
return { rows, pendingVanishedIds };
}
function nameSimilarity(a: string, b: string): number {
const aa = a.toLowerCase().trim();
const bb = b.toLowerCase().trim();
if (aa === bb) return 1;
if (aa.includes(bb) || bb.includes(aa)) return 0.85;
const dist = levenshtein(aa, bb);
const maxLen = Math.max(aa.length, bb.length);
if (maxLen === 0) return 1;
return Math.max(0, 1 - dist / maxLen);
}
function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
if (m === 0) return n;
if (n === 0) return m;
const prev: number[] = new Array(n + 1).fill(0).map((_, i) => i);
for (let i = 1; i <= m; i++) {
let curr0 = i;
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr0 = Math.min(
prev[j]! + 1, // deletion
curr0 + 1, // insertion
prev[j - 1]! + cost, // substitution
);
prev[j - 1] = curr0;
}
prev[n] = i;
}
return prev[n]!;
}
function datesEqual(a: string | null | undefined, b: string | null | undefined): boolean {
if (!a && !b) return true;
if (!a || !b) return false;
return a === b;
}
function round2(n: number): number {
return Math.round(Math.max(0, Math.min(1, n)) * 100) / 100;
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { diffScans } from "../src/scan-diff.js";
const PINNED = new Date("2026-08-07T00:00:00.000Z");
function item(overrides: Partial<Parameters<typeof diffScans>[0][number]> = {}) {
return {
id: "prev-1",
displayName: "Mjölk",
quantity: 1,
unit: "LITER",
storageLocationId: "loc-fridge",
...overrides,
};
}
function obs(overrides: Partial<Parameters<typeof diffScans>[1][number]> = {}) {
return {
displayName: "Mjölk",
quantity: 1,
unit: "LITER",
storageLocationId: "loc-fridge",
observationConfidence: 0.95,
...overrides,
};
}
describe("diffScans", () => {
it("marks identical items unchanged", () => {
const result = diffScans([item()], [obs()], PINNED);
expect(result.rows).toHaveLength(1);
expect(result.rows[0]?.kind).toBe("unchanged");
expect(result.pendingVanishedIds).toEqual([]);
});
it("detects new item", () => {
const result = diffScans([], [obs({ displayName: "Ost" })], PINNED);
expect(result.rows).toHaveLength(1);
expect(result.rows[0]?.kind).toBe("new_item");
});
it("detects vanished item as proposal only", () => {
const result = diffScans([item()], [], PINNED);
expect(result.rows).toHaveLength(1);
expect(result.rows[0]?.kind).toBe("vanished");
expect(result.pendingVanishedIds).toEqual(["prev-1"]);
});
it("detects quantity change", () => {
const result = diffScans([item()], [obs({ quantity: 0.5 })], PINNED);
expect(result.rows[0]?.kind).toBe("quantity_changed");
expect(result.rows[0]?.previousQuantity).toBe(1);
expect(result.rows[0]?.newQuantity).toBe(0.5);
});
it("detects moved item", () => {
const result = diffScans([item()], [obs({ storageLocationId: "loc-freezer" })], PINNED);
expect(result.rows[0]?.kind).toBe("moved");
});
it("sorts changes before unchanged", () => {
const result = diffScans(
[item({ id: "a", displayName: "A" }), item({ id: "b", displayName: "B" })],
[obs({ displayName: "A", quantity: 0.5 }), obs({ displayName: "B" })],
PINNED,
);
expect(result.rows.map((r) => r.kind)).toEqual(["quantity_changed", "unchanged"]);
});
});