Files
Cibello-app/packages/inventory-engine/src/scan-diff.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

241 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}