Fas 2 steg 5: Scan-to-scan-diff + konfliktlösning, samt buggfix depleted-transaktion i reconciliation
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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 (0–1). */
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user