Files
Cibello-app/packages/inventory-engine/src/scan-diff.ts
T
Claude 316ba3cb62 feat(i18n): server-genererad prosa -> nyckel+params (min dag, ombokning, scan-diff)
- Min-dag-noten: noteKey (estimateNote/computedNote) bredvid svensk note; klient t(noteKey).
- Ombokningsskäl (planning.ts): rescheduleReasonKey + params bredvid rescheduleReasonSv;
  klient t(key, {title}). Workerns två andra ombokningstexter lämnade på svensk fallback.
- Scan-diff: reasonCode-enum (6 koder) bredvid reasonSv; klient t(scanDiff.reason.<code>).
- 8 nya nycklar i alla 12 språk. Ingen beslutslogik rörd; alla Sv-fält kvar som fallback.
- Kvar som egna spår: recept-säkerhetsmeddelanden + substitutions-data (översättningstabell).
- typecheck grönt (20/20), vakt grön, inventory-engine 53/53 tester.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 20:36:11 +00:00

262 lines
8.3 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";
/**
* Stabil, översättbar kod för varje distinkt motivering. Klienten översätter
* via i18n-nyckeln `scanDiff.reason.<code>`; `reason` (svenska) är fallback.
* En kod per unikt meddelande finare än `kind` (t.ex. flyttad vs flyttad+mängd).
*/
export type ScanDiffReasonCode =
| "moved_and_qty"
| "moved"
| "qty_changed"
| "unchanged"
| "new_item"
| "vanished";
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 (svenska) fallback när klienten saknar översättning. */
reason: string;
/** Stabil kod för klientöversättning (`scanDiff.reason.<code>`). */
reasonCode: ScanDiffReasonCode;
}
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",
reasonCode: "moved_and_qty",
});
} 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",
reasonCode: "moved",
});
} 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",
reasonCode: "qty_changed",
});
} 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",
reasonCode: "unchanged",
});
}
}
}
// 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",
reasonCode: "new_item",
});
}
// 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",
reasonCode: "vanished",
});
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;
}