Fas 2 steg 4: Quick Reconciliation (motor, API, app + 12-språks i18n)

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 01:02:08 +07:00
parent 4385fe5125
commit 218bc44d08
24 changed files with 1069 additions and 17 deletions
+1
View File
@@ -4,3 +4,4 @@ export * from "./fefo.js";
export * from "./dedup.js";
export * from "./forecast.js";
export * from "./trust.js";
export * from "./reconciliation.js";
@@ -0,0 +1,142 @@
import { type InventoryItemLike, itemTrustState, TRUST_STATUS_THRESHOLDS } from "./trust.js";
export interface ReconciliationCandidate {
itemId: string;
displayName: string;
quantity: number;
unit: string;
locationName: string;
/** Prioritet: lägre = viktigare. */
priority: number;
reasons: ReconciliationReason[];
/** Förslag baserat på planerade recept, förbrukningstakt etc. */
suggestedAction: "exists" | "depleted" | "uncertain";
suggestedQuantity?: number;
}
export type ReconciliationReason =
| { kind: "planned_recipe"; recipeIds: string[] }
| { kind: "expiring_soon"; daysLeft: number | null }
| { kind: "high_value"; priceMinor: number }
| { kind: "low_confidence"; confidence: number }
| { kind: "stale_trust"; trustState: string }
| { kind: "likely_depleted"; remainingDays: number };
export interface ReconciliationContext {
/** Ingredienser som behövs i planerade recept under närmaste 7 dagarna. */
plannedRecipeIngredientIds: Map<string, string[]>;
/** Utgångsdatum per vara-id. */
daysLeftByItemId: Map<string, number | null>;
/** Estimerad förbrukning per dygn per vara (enkel heuristik). */
dailyConsumptionRate: Map<string, number>;
/** Pris i minor units per vara. */
priceMinorByItemId: Map<string, number>;
}
const MS_PER_DAY = 86_400_000;
/**
* Bygg en prioriterad lista över varor som behöver en snabb avstämning.
*
* Prioritet (lägre = viktigare, §5.4):
* 1. Behövs i planerade recept (inom 7 dagar)
* 2. Snart utgår
* 3. Högt ekonomiskt värde
* 4. Låg confidence / stale trust
* 5. Borde vara slut enligt förbrukningstakt
*
* Deterministisk, UTC, alltid samma svar för samma input.
*/
export function buildReconciliationCandidates(
items: Array<InventoryItemLike & { id: string; displayName: string; unit: string; locationName: string }>,
context: ReconciliationContext,
now: Date = new Date(),
maxItems = 15,
): ReconciliationCandidate[] {
const candidates: ReconciliationCandidate[] = [];
for (const item of items) {
if ((item.quantity <= 0 && item.depletedAt) || item.depletedAt) continue;
const reasons: ReconciliationReason[] = [];
let priorityPenalty = 0;
// 1. Planerade recept
const recipeIds = context.plannedRecipeIngredientIds.get(item.id) ?? [];
if (recipeIds.length > 0) {
reasons.push({ kind: "planned_recipe", recipeIds });
priorityPenalty += 10_000;
}
// 2. Snart utgår
const daysLeft = context.daysLeftByItemId.get(item.id) ?? null;
if (daysLeft != null && daysLeft <= 7) {
reasons.push({ kind: "expiring_soon", daysLeft });
priorityPenalty += 8_000 - Math.min(7, Math.max(0, daysLeft)) * 1_000;
}
// 3. Ekonomiskt värde
const priceMinor = context.priceMinorByItemId.get(item.id) ?? 0;
if (priceMinor > 0) {
reasons.push({ kind: "high_value", priceMinor });
priorityPenalty += Math.min(5_000, Math.floor(priceMinor / 10));
}
// 4. Låg confidence / stale trust
if (item.confidence < 0.6) {
reasons.push({ kind: "low_confidence", confidence: item.confidence });
priorityPenalty += 3_000;
}
const trust = itemTrustState(item, now, undefined);
if (trust === "stale" || trust === "decaying") {
reasons.push({ kind: "stale_trust", trustState: trust });
priorityPenalty += trust === "stale" ? 4_000 : 2_000;
}
// 5. Borde vara slut enligt förbrukningstakt
const dailyRate = context.dailyConsumptionRate.get(item.id) ?? 0;
if (dailyRate > 0 && item.quantity > 0) {
const remainingDays = item.quantity / dailyRate;
if (remainingDays < 3) {
reasons.push({ kind: "likely_depleted", remainingDays });
priorityPenalty += 2_500 - Math.min(2, Math.floor(remainingDays)) * 800;
}
}
if (reasons.length === 0) continue;
// Förslag
let suggestedAction: ReconciliationCandidate["suggestedAction"] = "uncertain";
let suggestedQuantity: number | undefined;
if (dailyRate > 0 && item.quantity > 0) {
const remainingDays = item.quantity / dailyRate;
if (remainingDays < 0.5) {
suggestedAction = "depleted";
} else {
suggestedQuantity = Math.max(0, Math.round(item.quantity * 10) / 10);
suggestedAction = "exists";
}
} else if (trust === "trusted") {
suggestedAction = "exists";
}
candidates.push({
itemId: item.id,
displayName: item.displayName,
quantity: item.quantity,
unit: item.unit,
locationName: item.locationName,
priority: -priorityPenalty,
reasons,
suggestedAction,
suggestedQuantity,
});
}
candidates.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return a.displayName.localeCompare(b.displayName, "sv");
});
return candidates.slice(0, maxItems);
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { buildReconciliationCandidates } from "../src/reconciliation.js";
const PINNED = new Date("2026-08-07T00:00:00.000Z");
function baseItem(overrides: Partial<Parameters<typeof buildReconciliationCandidates>[0][number]> = {}) {
return {
id: "itm-1",
displayName: "Mjölk",
quantity: 1,
unit: "l",
locationName: "Kylen",
confidence: 1,
verifiedByUser: true,
lastVerifiedAt: PINNED,
updatedAt: PINNED,
...overrides,
};
}
describe("buildReconciliationCandidates", () => {
it("returns empty when no items match", () => {
const result = buildReconciliationCandidates(
[baseItem({ quantity: 0, depletedAt: PINNED })],
{ plannedRecipeIngredientIds: new Map(), daysLeftByItemId: new Map(), dailyConsumptionRate: new Map(), priceMinorByItemId: new Map() },
PINNED,
);
expect(result).toEqual([]);
});
it("prioritises planned recipe ingredients", () => {
const a = baseItem({ id: "a", displayName: "Grädde", confidence: 1, verifiedByUser: true });
const b = baseItem({ id: "b", displayName: "Ost", confidence: 1, verifiedByUser: true });
const result = buildReconciliationCandidates(
[b, a],
{
plannedRecipeIngredientIds: new Map([["a", ["rec-1"]]]),
daysLeftByItemId: new Map(),
dailyConsumptionRate: new Map(),
priceMinorByItemId: new Map(),
},
PINNED,
15,
);
expect(result.map((r) => r.itemId)).toEqual(["a"]);
expect(result[0]?.reasons).toContainEqual({ kind: "planned_recipe", recipeIds: ["rec-1"] });
});
it("flags expiring soon and sorts by urgency", () => {
const a = baseItem({ id: "a", displayName: "Yoghurt", confidence: 0.5 });
const b = baseItem({ id: "b", displayName: "Juice", confidence: 0.5 });
const result = buildReconciliationCandidates(
[b, a],
{
plannedRecipeIngredientIds: new Map(),
daysLeftByItemId: new Map([
["a", 1],
["b", 6],
]),
dailyConsumptionRate: new Map(),
priceMinorByItemId: new Map(),
},
PINNED,
);
expect(result.map((r) => r.itemId)).toEqual(["a", "b"]);
});
it("flags low confidence", () => {
const item = baseItem({ confidence: 0.3, verifiedByUser: false });
const result = buildReconciliationCandidates(
[item],
{ plannedRecipeIngredientIds: new Map(), daysLeftByItemId: new Map(), dailyConsumptionRate: new Map(), priceMinorByItemId: new Map() },
PINNED,
);
expect(result[0]?.reasons).toContainEqual({ kind: "low_confidence", confidence: 0.3 });
});
it("suggests depleted when remaining days < 0.5", () => {
const item = baseItem({ quantity: 0.1 });
const result = buildReconciliationCandidates(
[item],
{
plannedRecipeIngredientIds: new Map(),
daysLeftByItemId: new Map(),
dailyConsumptionRate: new Map([["itm-1", 1]]),
priceMinorByItemId: new Map(),
},
PINNED,
);
expect(result[0]?.suggestedAction).toBe("depleted");
});
it("caps results at maxItems", () => {
const items = Array.from({ length: 20 }, (_, i) =>
baseItem({ id: `itm-${i}`, displayName: `Vara ${String(i).padStart(2, "0")}`, confidence: 0.1 }),
);
const result = buildReconciliationCandidates(
items,
{ plannedRecipeIngredientIds: new Map(), daysLeftByItemId: new Map(), dailyConsumptionRate: new Map(), priceMinorByItemId: new Map() },
PINNED,
5,
);
expect(result).toHaveLength(5);
});
});