241 lines
7.5 KiB
TypeScript
241 lines
7.5 KiB
TypeScript
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;
|
||
}
|