Fas 2 steg 4: Quick Reconciliation (motor, API, app + 12-språks i18n)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -43,3 +43,15 @@ export const inventoryQuerySchema = z.object({
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
export type InventoryQuery = z.infer<typeof inventoryQuerySchema>;
|
||||
|
||||
export const reconciliationStartInputSchema = z.object({
|
||||
maxItems: z.coerce.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
export type ReconciliationStartInput = z.infer<typeof reconciliationStartInputSchema>;
|
||||
|
||||
export const reconciliationResolveInputSchema = z.object({
|
||||
action: z.enum(["exists", "depleted", "uncertain"]),
|
||||
quantity: z.number().min(0).optional(),
|
||||
note: z.string().max(200).optional(),
|
||||
});
|
||||
export type ReconciliationResolveInput = z.infer<typeof reconciliationResolveInputSchema>;
|
||||
|
||||
Reference in New Issue
Block a user