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
+2
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@app/ai-contracts": "workspace:*", "@app/ai-contracts": "workspace:*",
"@app/analytics": "workspace:*",
"@app/connectors": "workspace:*", "@app/connectors": "workspace:*",
"@app/database": "workspace:*", "@app/database": "workspace:*",
"@app/events": "workspace:*", "@app/events": "workspace:*",
@@ -48,6 +49,7 @@
"tsup": { "tsup": {
"noExternal": [ "noExternal": [
"@app/ai-contracts", "@app/ai-contracts",
"@app/analytics",
"@app/connectors", "@app/connectors",
"@app/database", "@app/database",
"@app/events", "@app/events",
+30
View File
@@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { Database } from "@app/database"; import type { Database } from "@app/database";
import { schema } from "@app/database"; import { schema } from "@app/database";
import type { AnalyticsEvent } from "@app/analytics";
import type { DecayProfile } from "@app/inventory-engine"; import type { DecayProfile } from "@app/inventory-engine";
import type { EventType } from "@app/shared-types"; import type { EventType } from "@app/shared-types";
import type { NewDomainEvent } from "@app/events"; import type { NewDomainEvent } from "@app/events";
@@ -117,6 +118,35 @@ export async function emitEvent<T extends EventType>(
}); });
} }
/** Track product analytics server-side if user opted in. */
export async function trackProductAnalytics(
db: Database,
userId: string,
event: AnalyticsEvent,
): Promise<void> {
const optedIn = await db
.select({ status: schema.userConsents.status })
.from(schema.userConsents)
.where(and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "product_analytics")))
.limit(1);
if (optedIn[0] && optedIn[0].status !== "granted") return;
await db.insert(schema.productAnalyticsEvents).values({
occurredAt: event.occurredAt ? new Date(event.occurredAt) : new Date(),
receivedAt: new Date(),
eventName: event.name,
anonymousId: event.anonymousId ?? null,
sessionId: event.sessionId ?? null,
userId,
householdId: event.householdId ?? null,
appVersion: event.appVersion ?? null,
platform: event.platform ?? null,
locale: event.locale ?? null,
experimentVariant: event.experimentVariant ?? null,
properties: event.properties ?? {},
});
}
/** Audit-logg (spec §56). */ /** Audit-logg (spec §56). */
export async function audit( export async function audit(
db: Database, db: Database,
+259
View File
@@ -0,0 +1,259 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { z } from "zod";
import { buildReconciliationCandidates, classifyExpiry, computeTrust } from "@app/inventory-engine";
import { errors, parse } from "../lib/errors.js";
import { getActiveDecayProfile, requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js";
import {
reconciliationResolveInputSchema,
reconciliationStartInputSchema,
} from "@app/validation";
import { inventoryReconciliationCompleted, inventoryReconciliationStarted } from "@app/analytics";
/** Quick Reconciliation (Fas 2 §5.4) */
export async function reconciliationRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.post("/v1/reconciliations/start", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const input = parse(reconciliationStartInputSchema, req.body);
const decayProfile = await getActiveDecayProfile(app.db);
const items = await app.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
locationName: schema.storageLocations.name,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
sql`${schema.inventoryItems.depletedAt} IS NULL`,
sql`${schema.inventoryItems.quantity} > 0`,
),
);
const itemIds = items.map((r) => r.item.id);
// Ingredienser i planerade recept närmaste 7 dagarna
const plannedRecipeIngredientIds = new Map<string, string[]>();
const upcomingEntries = await app.db
.select({ recipeId: schema.weekPlanEntries.recipeId, date: schema.weekPlanEntries.date })
.from(schema.weekPlanEntries)
.innerJoin(schema.weekPlans, eq(schema.weekPlanEntries.weekPlanId, schema.weekPlans.id))
.where(
and(
eq(schema.weekPlans.householdId, householdId),
sql`${schema.weekPlanEntries.date} >= CURRENT_DATE`,
sql`${schema.weekPlanEntries.date} <= CURRENT_DATE + INTERVAL '7 days'`,
),
);
if (upcomingEntries.length > 0) {
const recipeIds = [...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean))] as string[];
if (recipeIds.length > 0) {
const ingredients = await app.db
.select({ recipeId: schema.recipeIngredients.recipeId, canonicalId: schema.recipeIngredients.canonicalIngredientId })
.from(schema.recipeIngredients)
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
for (const ing of ingredients) {
if (!ing.canonicalId) continue;
const list = plannedRecipeIngredientIds.get(ing.canonicalId) ?? [];
if (!list.includes(ing.recipeId)) list.push(ing.recipeId);
plannedRecipeIngredientIds.set(ing.canonicalId, list);
}
}
}
const priceMinorByItemId = new Map<string, number>();
const dailyConsumptionRate = new Map<string, number>();
const daysLeftByItemId = new Map<string, number | null>();
for (const r of items) {
const expiry = classifyExpiry({
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
});
daysLeftByItemId.set(r.item.id, expiry.daysLeft);
if (r.item.priceMinor != null) {
priceMinorByItemId.set(r.item.id, r.item.priceMinor);
}
// Enkel heuristik: senaste 30 dagarnas genomsnittliga dagliga förbrukning
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
const txAgg = await app.db
.select({
total: sql<number>`COALESCE(SUM(ABS(${schema.inventoryTransactions.quantityDelta})), 0)`,
days: sql<number>`GREATEST(1, COUNT(DISTINCT DATE(${schema.inventoryTransactions.createdAt})))`,
})
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.inventoryItemId, r.item.id),
eq(schema.inventoryTransactions.type, "consume"),
sql`${schema.inventoryTransactions.createdAt} >= ${thirtyDaysAgo}`,
),
);
const rate = Number(txAgg[0]?.total ?? 0) / Number(txAgg[0]?.days ?? 1);
if (rate > 0) dailyConsumptionRate.set(r.item.id, rate);
}
const mappedItems = items.map((r) => {
const trust = computeTrust(
{
confidence: r.item.confidence,
verifiedByUser: r.item.verifiedByUser,
lastVerifiedAt: r.item.lastVerifiedAt,
quantity: r.item.quantity,
updatedAt: r.item.updatedAt,
},
new Date(),
decayProfile,
);
return {
id: r.item.id,
displayName: r.item.displayName,
quantity: r.item.quantity,
unit: r.item.unit,
locationName: r.locationName,
confidence: r.item.confidence,
verifiedByUser: r.item.verifiedByUser,
lastVerifiedAt: r.item.lastVerifiedAt,
updatedAt: r.item.updatedAt,
depletedAt: r.item.depletedAt,
canonicalIngredientId: r.item.canonicalIngredientId,
trustState: trust.state,
};
});
const candidates = buildReconciliationCandidates(
mappedItems,
{
plannedRecipeIngredientIds,
daysLeftByItemId,
dailyConsumptionRate,
priceMinorByItemId,
},
new Date(),
input.maxItems ?? 15,
);
await trackProductAnalytics(
app.db,
req.userId,
inventoryReconciliationStarted({
householdId,
properties: { candidateCount: candidates.length },
}),
);
return {
candidates: candidates.map((c) => ({
itemId: c.itemId,
displayName: c.displayName,
quantity: c.quantity,
unit: c.unit,
locationName: c.locationName,
reasons: c.reasons,
suggestedAction: c.suggestedAction,
suggestedQuantity: c.suggestedQuantity,
})),
};
});
app.post("/v1/reconciliations/items/:itemId/resolve", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const params = z.object({ itemId: z.uuid() }).parse(req.params);
const input = parse(reconciliationResolveInputSchema, req.body);
const [item] = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.id, params.itemId),
eq(schema.inventoryItems.householdId, householdId),
),
)
.limit(1);
if (!item) throw errors.notFound("Varan finns inte.");
const now = new Date();
const newQuantity = input.quantity;
const quantityChange = newQuantity != null ? newQuantity - item.quantity : 0;
const update: Partial<typeof schema.inventoryItems.$inferInsert> = {
updatedAt: now,
};
if (input.action === "exists") {
update.verifiedByUser = true;
update.lastVerifiedAt = now;
update.depletedAt = null;
if (newQuantity != null) update.quantity = newQuantity;
} else if (input.action === "depleted") {
update.quantity = 0;
update.depletedAt = now;
} else {
// uncertain: bara registrera en adjustment om användaren justerat mängd
if (newQuantity != null) update.quantity = newQuantity;
}
const [updated] = await app.db
.update(schema.inventoryItems)
.set(update)
.where(eq(schema.inventoryItems.id, params.itemId))
.returning();
if (!updated) throw errors.internal("Kunde inte uppdatera varan.");
if (input.action === "exists" || quantityChange !== 0) {
await app.db.insert(schema.inventoryTransactions).values({
inventoryItemId: params.itemId,
householdId,
actorUserId: req.userId,
type: input.action === "exists" ? "correction" : "adjust",
quantityDelta: quantityChange,
unit: item.unit,
note: input.note,
});
}
await trackProductAnalytics(
app.db,
req.userId,
inventoryReconciliationCompleted({
householdId,
properties: { itemId: params.itemId, action: input.action, hadAdjustment: quantityChange !== 0 },
}),
);
return {
itemId: params.itemId,
action: input.action,
quantity: updated.quantity,
verifiedByUser: updated.verifiedByUser,
};
});
}
+2
View File
@@ -16,6 +16,7 @@ import { mealRoutes } from "./routes/meals.js";
import { shoppingRoutes } from "./routes/shopping.js"; import { shoppingRoutes } from "./routes/shopping.js";
import { planningRoutes } from "./routes/planning.js"; import { planningRoutes } from "./routes/planning.js";
import { recommendationRoutes } from "./routes/recommendations.js"; import { recommendationRoutes } from "./routes/recommendations.js";
import { reconciliationRoutes } from "./routes/reconciliations.js";
import { memoryRoutes } from "./routes/memory.js"; import { memoryRoutes } from "./routes/memory.js";
import { budgetRoutes } from "./routes/budget.js"; import { budgetRoutes } from "./routes/budget.js";
import { subscriptionRoutes } from "./routes/subscriptions.js"; import { subscriptionRoutes } from "./routes/subscriptions.js";
@@ -81,6 +82,7 @@ export async function buildServer(config: AppConfig) {
await app.register(shoppingRoutes); await app.register(shoppingRoutes);
await app.register(planningRoutes); await app.register(planningRoutes);
await app.register(recommendationRoutes); await app.register(recommendationRoutes);
await app.register(reconciliationRoutes);
await app.register(memoryRoutes); await app.register(memoryRoutes);
await app.register(budgetRoutes); await app.register(budgetRoutes);
await app.register(subscriptionRoutes); await app.register(subscriptionRoutes);
+126
View File
@@ -0,0 +1,126 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq, inArray } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
describe("quick reconciliation", () => {
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
let app: Awaited<ReturnType<typeof buildServer>>;
let token: string;
let householdId: string;
const email = "recon-test@example.invalid";
async function cleanup() {
const existing = await testDb.db
.select({ id: schema.users.id })
.from(schema.users)
.where(inArray(schema.users.email, [email]));
for (const u of existing) {
const memberships = await testDb.db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, u.id));
for (const m of memberships) {
const items = await testDb.db
.select({ id: schema.inventoryItems.id })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.householdId, m.householdId));
for (const it of items) {
await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
}
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
}
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
}
}
beforeAll(async () => {
await cleanup();
app = await buildServer(config);
await app.ready();
const res = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: { email, password: "Password123!", displayName: "Recon Test" },
});
const body = JSON.parse(res.body) as { accessToken: string };
token = body.accessToken;
const quick = await app.inject({
method: "POST",
url: "/v1/onboarding/quick-start",
headers: { authorization: `Bearer ${token}` },
payload: { goals: ["less_waste"], precisionMode: "simple" },
});
householdId = (JSON.parse(quick.body) as { householdId: string }).householdId;
// Skapa en vara att avstämma
const locations = await testDb.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(eq(schema.storageLocations.householdId, householdId));
const [location] = locations;
await app.inject({
method: "POST",
url: "/v1/inventory/items",
headers: { authorization: `Bearer ${token}` },
payload: {
displayName: "Mjölk",
quantity: 1,
unit: "LITER",
storageLocationId: location!.id,
bestBeforeDate: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10),
},
});
});
afterAll(async () => {
await cleanup();
await closeDatabase();
await app.close();
});
it("starts reconciliation and returns candidates with reasons", async () => {
const res = await app.inject({
method: "POST",
url: "/v1/reconciliations/start",
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { candidates: Array<{ itemId: string; reasons: unknown[]; suggestedAction: string }> };
expect(body.candidates.length).toBeGreaterThan(0);
expect(body.candidates[0]?.reasons.length).toBeGreaterThan(0);
});
it("resolves 'exists' and marks item verified", async () => {
const start = await app.inject({
method: "POST",
url: "/v1/reconciliations/start",
headers: { authorization: `Bearer ${token}` },
payload: {},
});
const { candidates } = JSON.parse(start.body) as { candidates: Array<{ itemId: string }> };
const itemId = candidates[0]!.itemId;
const res = await app.inject({
method: "POST",
url: `/v1/reconciliations/items/${itemId}/resolve`,
headers: { authorization: `Bearer ${token}` },
payload: { action: "exists", quantity: 0.5 },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { action: string; quantity: number; verifiedByUser: boolean };
expect(body.action).toBe("exists");
expect(body.quantity).toBe(0.5);
expect(body.verifiedByUser).toBe(true);
});
});
+5 -3
View File
@@ -1,4 +1,4 @@
import { View } from "react-native"; import { Pressable, View } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -18,7 +18,7 @@ import {
Tag, Tag,
Title, Title,
} from "@/components/ui"; } from "@/components/ui";
import { spacing } from "@/lib/theme"; import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units"; import { formatQuantity } from "@/lib/units";
/** Hemma (spec §4.4): matlager, bäst före, matlådor, inköpslista, budget, hushåll. */ /** Hemma (spec §4.4): matlager, bäst före, matlådor, inköpslista, budget, hushåll. */
@@ -70,9 +70,11 @@ export default function HomeScreen() {
<Screen> <Screen>
<Title>{t("home.title")}</Title> <Title>{t("home.title")}</Title>
{trustStatus && ( {trustStatus && (
<Small style={{ marginBottom: spacing.xs, opacity: 0.8 }}> <Pressable onPress={() => router.push("/reconciliation")}>
<Small style={{ marginBottom: spacing.xs, opacity: 0.8, color: colors.primary }}>
{t(`home.trustStatus.${trustStatus}`)} {t(`home.trustStatus.${trustStatus}`)}
</Small> </Small>
</Pressable>
)} )}
<Row> <Row>
+176
View File
@@ -0,0 +1,176 @@
import { useState } from "react";
import { ActivityIndicator, View } from "react-native";
import { router } from "expo-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
interface Candidate {
itemId: string;
displayName: string;
quantity: number;
unit: string;
locationName: string;
reasons: Array<
| { 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 }
>;
suggestedAction: "exists" | "depleted" | "uncertain";
suggestedQuantity?: number;
}
export default function ReconciliationScreen() {
const [index, setIndex] = useState(0);
const [adjustment, setAdjustment] = useState<string>("");
const query = useQuery({
queryKey: ["reconciliation-candidates"],
queryFn: () => api<{ candidates: Candidate[] }>("/v1/reconciliations/start", { method: "POST", body: {} }),
});
const resolveMutation = useMutation({
mutationFn: (input: { itemId: string; action: Candidate["suggestedAction"]; quantity?: number }) =>
api<{ itemId: string; action: string; quantity: number; verifiedByUser: boolean }>(
`/v1/reconciliations/items/${input.itemId}/resolve`,
{ method: "POST", body: { action: input.action, quantity: input.quantity } },
),
onSuccess: () => {
if (index < (query.data?.candidates.length ?? 0) - 1) {
setIndex((i) => i + 1);
setAdjustment("");
} else {
router.back();
}
},
});
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const candidates = query.data?.candidates ?? [];
if (candidates.length === 0) {
return (
<Screen>
<Title>{t("reconciliation.title")}</Title>
<EmptyState text={t("reconciliation.empty")} />
<Button label={t("common.back")} variant="secondary" onPress={() => router.back()} />
</Screen>
);
}
const candidate = candidates[index];
const progress = `${index + 1}/${candidates.length}`;
function handleResolve(action: Candidate["suggestedAction"]) {
const qty = adjustment.trim() === "" ? undefined : parseFloat(adjustment.replace(",", "."));
resolveMutation.mutate({ itemId: candidate!.itemId, action, quantity: qty });
}
return (
<Screen>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Title>{t("reconciliation.title")}</Title>
<Small>{progress}</Small>
</Row>
<Small>{t("reconciliation.subtitle")}</Small>
<Spacer size={spacing.sm} />
<Card>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{candidate!.displayName}</Heading>
<Body>
{formatQuantity(candidate!.quantity, candidate!.unit)}
</Body>
</Row>
<Small>{candidate!.locationName}</Small>
<Spacer size={spacing.xs} />
<Row style={{ flexWrap: "wrap" }}>
{candidate!.reasons.map((reason, i) => (
<Tag key={i} label={reasonLabel(reason)} tone="neutral" />
))}
</Row>
</Card>
<Card>
<Body>{t("reconciliation.adjustQuantity")}</Body>
<Input
placeholder={formatQuantity(candidate!.quantity, candidate!.unit)}
value={adjustment}
onChangeText={setAdjustment}
keyboardType="decimal-pad"
/>
</Card>
<Small style={{ opacity: 0.8 }}>{t("reconciliation.tasteHint")}</Small>
<View style={{ flex: 1 }} />
<Row>
<Button
label={t("reconciliation.depleted")}
variant="secondary"
onPress={() => handleResolve("depleted")}
disabled={resolveMutation.isPending}
/>
<Button
label={t("reconciliation.uncertain")}
variant="secondary"
onPress={() => handleResolve("uncertain")}
disabled={resolveMutation.isPending}
/>
<Button
label={t("reconciliation.exists")}
onPress={() => handleResolve("exists")}
disabled={resolveMutation.isPending}
/>
</Row>
{resolveMutation.isPending && (
<ActivityIndicator color={colors.primary} style={{ marginTop: spacing.sm }} />
)}
</Screen>
);
}
function reasonLabel(reason: Candidate["reasons"][number]): string {
switch (reason.kind) {
case "planned_recipe":
return t("reconciliation.reason.planned_recipe");
case "expiring_soon":
return reason.daysLeft == null
? t("reconciliation.reason.expiring_soon")
: `${t("reconciliation.reason.expiring_soon")} (${reason.daysLeft}d)`;
case "high_value":
return t("reconciliation.reason.high_value");
case "low_confidence":
return t("reconciliation.reason.low_confidence");
case "stale_trust":
return t("reconciliation.reason.stale_trust");
case "likely_depleted":
return t("reconciliation.reason.likely_depleted");
default:
return "";
}
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primært", "onboarding.primaryGoal": "Primært",
"home.trustStatus.up_to_date": "Opdateret", "home.trustStatus.up_to_date": "Opdateret",
"home.trustStatus.needs_check": "Nogle varer skal tjekkes", "home.trustStatus.needs_check": "Nogle varer skal tjekkes",
"home.trustStatus.uncertain": "Lageret er usikkert lav en hurtig tjek" "home.trustStatus.uncertain": "Lageret er usikkert lav en hurtig tjek",
"reconciliation.title": "Hurtig lagerkontrol",
"reconciliation.subtitle": "Bekræft, hvad du har tilbage, for at få bedre forslag.",
"reconciliation.exists": "Har stadig",
"reconciliation.depleted": "Det er brugt op",
"reconciliation.uncertain": "Usikker",
"reconciliation.adjustQuantity": "Juster mængde",
"reconciliation.reason.planned_recipe": "Skal bruges snart til en opskrift",
"reconciliation.reason.expiring_soon": "Udløber snart",
"reconciliation.reason.high_value": "Høj værdi",
"reconciliation.reason.low_confidence": "Anslået mængde",
"reconciliation.reason.stale_trust": "Ikke tjekket længe",
"reconciliation.reason.likely_depleted": "Sandsynligvis snart brugt op",
"reconciliation.done": "Færdig",
"reconciliation.next": "Næste",
"reconciliation.empty": "Intet behøver kontrol lige nu. Godt arbejde!",
"reconciliation.tasteHint": "Overskredet mindstholdbarhedsdato? lugt og smag først smid aldrig mad ud unødigt."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primär", "onboarding.primaryGoal": "Primär",
"home.trustStatus.up_to_date": "Aktuell", "home.trustStatus.up_to_date": "Aktuell",
"home.trustStatus.needs_check": "Einige Artikel müssen geprüft werden", "home.trustStatus.needs_check": "Einige Artikel müssen geprüft werden",
"home.trustStatus.uncertain": "Bestand unsicher schnelle Kontrolle empfohlen" "home.trustStatus.uncertain": "Bestand unsicher schnelle Kontrolle empfohlen",
"reconciliation.title": "Schnelle Bestandskontrolle",
"reconciliation.subtitle": "Bestätige, was noch vorhanden ist, um bessere Vorschläge zu erhalten.",
"reconciliation.exists": "Noch vorhanden",
"reconciliation.depleted": "Ist aufgebraucht",
"reconciliation.uncertain": "Unsicher",
"reconciliation.adjustQuantity": "Menge anpassen",
"reconciliation.reason.planned_recipe": "Bald für ein Rezept benötigt",
"reconciliation.reason.expiring_soon": "Läuft bald ab",
"reconciliation.reason.high_value": "Hoher Wert",
"reconciliation.reason.low_confidence": "Geschätzte Menge",
"reconciliation.reason.stale_trust": "Schon lange nicht kontrolliert",
"reconciliation.reason.likely_depleted": "Wahrscheinlich bald aufgebraucht",
"reconciliation.done": "Fertig",
"reconciliation.next": "Weiter",
"reconciliation.empty": "Aktuell muss nichts kontrolliert werden. Gut gemacht!",
"reconciliation.tasteHint": "Mindesthaltbarkeitsdatum überschritten? Zuerst riechen und schmecken: niemals unbedingt Essen wegwerfen."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primary", "onboarding.primaryGoal": "Primary",
"home.trustStatus.up_to_date": "Up to date", "home.trustStatus.up_to_date": "Up to date",
"home.trustStatus.needs_check": "Some items need checking", "home.trustStatus.needs_check": "Some items need checking",
"home.trustStatus.uncertain": "Inventory uncertain do a quick check" "home.trustStatus.uncertain": "Inventory uncertain do a quick check",
"reconciliation.title": "Quick inventory check",
"reconciliation.subtitle": "Confirm what you have left to get better suggestions.",
"reconciliation.exists": "Still have it",
"reconciliation.depleted": "It is used up",
"reconciliation.uncertain": "Not sure",
"reconciliation.adjustQuantity": "Adjust amount",
"reconciliation.reason.planned_recipe": "Needed soon for a recipe",
"reconciliation.reason.expiring_soon": "Expires soon",
"reconciliation.reason.high_value": "High value",
"reconciliation.reason.low_confidence": "Estimated amount",
"reconciliation.reason.stale_trust": "Has not been checked in a while",
"reconciliation.reason.likely_depleted": "Likely used up soon",
"reconciliation.done": "Done",
"reconciliation.next": "Next",
"reconciliation.empty": "Nothing needs checking right now. Well done!",
"reconciliation.tasteHint": "Past best before? Smell and taste first never waste food unnecessarily."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Principal", "onboarding.primaryGoal": "Principal",
"home.trustStatus.up_to_date": "Actualizado", "home.trustStatus.up_to_date": "Actualizado",
"home.trustStatus.needs_check": "Algunos productos necesitan revisión", "home.trustStatus.needs_check": "Algunos productos necesitan revisión",
"home.trustStatus.uncertain": "Inventario incierto haz una revisión rápida" "home.trustStatus.uncertain": "Inventario incierto haz una revisión rápida",
"reconciliation.title": "Revisión rápida del inventario",
"reconciliation.subtitle": "Confirma lo que te queda para obtener mejores sugerencias.",
"reconciliation.exists": "Todavía lo tengo",
"reconciliation.depleted": "Se ha acabado",
"reconciliation.uncertain": "No estoy seguro",
"reconciliation.adjustQuantity": "Ajustar cantidad",
"reconciliation.reason.planned_recipe": "Se necesita pronto para una receta",
"reconciliation.reason.expiring_soon": "Caduca pronto",
"reconciliation.reason.high_value": "Alto valor",
"reconciliation.reason.low_confidence": "Cantidad estimada",
"reconciliation.reason.stale_trust": "No se ha comprobado en mucho tiempo",
"reconciliation.reason.likely_depleted": "Probablemente se acabe pronto",
"reconciliation.done": "Listo",
"reconciliation.next": "Siguiente",
"reconciliation.empty": "No hay nada que revisar ahora. ¡Bien hecho!",
"reconciliation.tasteHint": "¿Pasada la fecha de consumo preferente? Huele y prueba primero: nunca tires comida sin necesidad."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Ensisijainen", "onboarding.primaryGoal": "Ensisijainen",
"home.trustStatus.up_to_date": "Ajan tasalla", "home.trustStatus.up_to_date": "Ajan tasalla",
"home.trustStatus.needs_check": "Joitakin tuotteita on tarkistettava", "home.trustStatus.needs_check": "Joitakin tuotteita on tarkistettava",
"home.trustStatus.uncertain": "Varasto epävarma tee pikatarkistus" "home.trustStatus.uncertain": "Varasto epävarma tee pikatarkistus",
"reconciliation.title": "Nopea varaston tarkistus",
"reconciliation.subtitle": "Vahvista, mitä sinulla on jäljellä, jotta saat parempia ehdotuksia.",
"reconciliation.exists": "Vielä jäljellä",
"reconciliation.depleted": "Loppu",
"reconciliation.uncertain": "Epävarma",
"reconciliation.adjustQuantity": "Säädä määrää",
"reconciliation.reason.planned_recipe": "Tarvitaan pian reseptiin",
"reconciliation.reason.expiring_soon": "Vanhenee pian",
"reconciliation.reason.high_value": "Korkea arvo",
"reconciliation.reason.low_confidence": "Arvioitu määrä",
"reconciliation.reason.stale_trust": "Ei tarkistettu pitkään aikaan",
"reconciliation.reason.likely_depleted": "Todennäköisesti pian loppu",
"reconciliation.done": "Valmis",
"reconciliation.next": "Seuraava",
"reconciliation.empty": "Mitään ei tarvitse tarkistaa juuri nyt. Hyvää työtä!",
"reconciliation.tasteHint": "Parasta ennen -päiväys umpeutunut? Haista ja maista ensin älä koskaan heitä ruokaa turhaan."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Principal", "onboarding.primaryGoal": "Principal",
"home.trustStatus.up_to_date": "À jour", "home.trustStatus.up_to_date": "À jour",
"home.trustStatus.needs_check": "Certains articles doivent être vérifiés", "home.trustStatus.needs_check": "Certains articles doivent être vérifiés",
"home.trustStatus.uncertain": "Inventaire incertain faites un rapide contrôle" "home.trustStatus.uncertain": "Inventaire incertain faites un rapide contrôle",
"reconciliation.title": "Vérification rapide des stocks",
"reconciliation.subtitle": "Confirmez ce qui vous reste pour de meilleures suggestions.",
"reconciliation.exists": "Toujours en stock",
"reconciliation.depleted": "C'est fini",
"reconciliation.uncertain": "Incertain",
"reconciliation.adjustQuantity": "Ajuster la quantité",
"reconciliation.reason.planned_recipe": "Bientôt nécessaire pour une recette",
"reconciliation.reason.expiring_soon": "Expire bientôt",
"reconciliation.reason.high_value": "Haute valeur",
"reconciliation.reason.low_confidence": "Quantité estimée",
"reconciliation.reason.stale_trust": "Non vérifié depuis longtemps",
"reconciliation.reason.likely_depleted": "Probablement bientôt épuisé",
"reconciliation.done": "Terminé",
"reconciliation.next": "Suivant",
"reconciliation.empty": "Rien à vérifier pour l'instant. Bien joué !",
"reconciliation.tasteHint": "Dépasse la date de durabilité minimale ? Sentez et goûtez d'abord : ne gaspillez jamais de nourriture inutilement."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primario", "onboarding.primaryGoal": "Primario",
"home.trustStatus.up_to_date": "Aggiornato", "home.trustStatus.up_to_date": "Aggiornato",
"home.trustStatus.needs_check": "Alcuni articoli devono essere controllati", "home.trustStatus.needs_check": "Alcuni articoli devono essere controllati",
"home.trustStatus.uncertain": "Inventario incerto fai un controllo rapido" "home.trustStatus.uncertain": "Inventario incerto fai un controllo rapido",
"reconciliation.title": "Controllo rapido inventario",
"reconciliation.subtitle": "Conferma cosa ti rimane per ricevere suggerimenti migliori.",
"reconciliation.exists": "Ancora disponibile",
"reconciliation.depleted": "È finito",
"reconciliation.uncertain": "Incerto",
"reconciliation.adjustQuantity": "Regola quantità",
"reconciliation.reason.planned_recipe": "Serve presto per una ricetta",
"reconciliation.reason.expiring_soon": "Scade presto",
"reconciliation.reason.high_value": "Alto valore",
"reconciliation.reason.low_confidence": "Quantità stimata",
"reconciliation.reason.stale_trust": "Non controllato da tempo",
"reconciliation.reason.likely_depleted": "Probabilmente finirà presto",
"reconciliation.done": "Fatto",
"reconciliation.next": "Avanti",
"reconciliation.empty": "Non c'è nulla da controllare. Ottimo lavoro!",
"reconciliation.tasteHint": "Scadenza preferibile superata? Odora e assaggia prima: non buttare mai cibo inutilmente."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primært", "onboarding.primaryGoal": "Primært",
"home.trustStatus.up_to_date": "Oppdatert", "home.trustStatus.up_to_date": "Oppdatert",
"home.trustStatus.needs_check": "Noen varer må sjekkes", "home.trustStatus.needs_check": "Noen varer må sjekkes",
"home.trustStatus.uncertain": "Lageret er usikkert gjør en rask sjekk" "home.trustStatus.uncertain": "Lageret er usikkert gjør en rask sjekk",
"reconciliation.title": "Hurtig lagerkontroll",
"reconciliation.subtitle": "Bekreft hva du har igjen for å få bedre forslag.",
"reconciliation.exists": "Har fortsatt",
"reconciliation.depleted": "Det er brukt opp",
"reconciliation.uncertain": "Usikker",
"reconciliation.adjustQuantity": "Juster mengde",
"reconciliation.reason.planned_recipe": "Trengs snart til en oppskrift",
"reconciliation.reason.expiring_soon": "Går snart ut",
"reconciliation.reason.high_value": "Høy verdi",
"reconciliation.reason.low_confidence": "Anslått mengde",
"reconciliation.reason.stale_trust": "Ikke sjekket på lenge",
"reconciliation.reason.likely_depleted": "Sannsynligvis snart brukt opp",
"reconciliation.done": "Ferdig",
"reconciliation.next": "Neste",
"reconciliation.empty": "Ingenting trenger kontroll akkurat nå. Bra jobbet!",
"reconciliation.tasteHint": "Forbi best før-dato? Lukt og smak først kast aldri mat unødvendig."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primair", "onboarding.primaryGoal": "Primair",
"home.trustStatus.up_to_date": "Bijgewerkt", "home.trustStatus.up_to_date": "Bijgewerkt",
"home.trustStatus.needs_check": "Sommige items moeten worden gecontroleerd", "home.trustStatus.needs_check": "Sommige items moeten worden gecontroleerd",
"home.trustStatus.uncertain": "Voorraad onzeker doe een snelle controle" "home.trustStatus.uncertain": "Voorraad onzeker doe een snelle controle",
"reconciliation.title": "Snelle voorraadcheck",
"reconciliation.subtitle": "Bevestig wat je nog hebt voor betere suggesties.",
"reconciliation.exists": "Nog op voorraad",
"reconciliation.depleted": "Het is op",
"reconciliation.uncertain": "Onzeker",
"reconciliation.adjustQuantity": "Hoeveelheid aanpassen",
"reconciliation.reason.planned_recipe": "Binnenkort nodig voor een recept",
"reconciliation.reason.expiring_soon": "Binnenkort houdbaarheidsdatum",
"reconciliation.reason.high_value": "Hoge waarde",
"reconciliation.reason.low_confidence": "Geschatte hoeveelheid",
"reconciliation.reason.stale_trust": "Al lang niet gecontroleerd",
"reconciliation.reason.likely_depleted": "Waarschijnlijk binnenkort op",
"reconciliation.done": "Klaar",
"reconciliation.next": "Volgende",
"reconciliation.empty": "Er is momenteel niets om te controleren. Goed gedaan!",
"reconciliation.tasteHint": "Tenminste houdbaar tot verstreken? Ruik en proef eerst gooi nooit onnodig eten weg."
} }
+17 -1
View File
@@ -363,5 +363,21 @@
"onboarding.primaryGoal": "Główny", "onboarding.primaryGoal": "Główny",
"home.trustStatus.up_to_date": "Aktualne", "home.trustStatus.up_to_date": "Aktualne",
"home.trustStatus.needs_check": "Niektóre produkty wymagają sprawdzenia", "home.trustStatus.needs_check": "Niektóre produkty wymagają sprawdzenia",
"home.trustStatus.uncertain": "Stan niepewny zrób szybką kontrolę" "home.trustStatus.uncertain": "Stan niepewny zrób szybką kontrolę",
"reconciliation.title": "Szybka kontrola zapasów",
"reconciliation.subtitle": "Potwierdź, co ci zostało, aby uzyskać lepsze propozycje.",
"reconciliation.exists": "Wciąż mam",
"reconciliation.depleted": "Skończyło się",
"reconciliation.uncertain": "Niepewny",
"reconciliation.adjustQuantity": "Dostosuj ilość",
"reconciliation.reason.planned_recipe": "Wkrótce potrzebne do przepisu",
"reconciliation.reason.expiring_soon": "Wkrótce traci ważność",
"reconciliation.reason.high_value": "Wysoka wartość",
"reconciliation.reason.low_confidence": "Szacowana ilość",
"reconciliation.reason.stale_trust": "Nie sprawdzane od dawna",
"reconciliation.reason.likely_depleted": "Prawdopodobnie wkrótce się skończy",
"reconciliation.done": "Gotowe",
"reconciliation.next": "Dalej",
"reconciliation.empty": "Teraz nic nie wymaga kontroli. Dobra robota!",
"reconciliation.tasteHint": "Po terminie przydatności do spożycia? Najpierw powąchaj i posmakuj nigdy nie wyrzucaj jedzenia bez potrzeby."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Principal", "onboarding.primaryGoal": "Principal",
"home.trustStatus.up_to_date": "Atualizado", "home.trustStatus.up_to_date": "Atualizado",
"home.trustStatus.needs_check": "Alguns itens precisam de verificação", "home.trustStatus.needs_check": "Alguns itens precisam de verificação",
"home.trustStatus.uncertain": "Inventário incerto faça uma verificação rápida" "home.trustStatus.uncertain": "Inventário incerto faça uma verificação rápida",
"reconciliation.title": "Verificação rápida de stock",
"reconciliation.subtitle": "Confirme o que tem sobrando para obter melhores sugestões.",
"reconciliation.exists": "Ainda tenho",
"reconciliation.depleted": "Acabou",
"reconciliation.uncertain": "Incerto",
"reconciliation.adjustQuantity": "Ajustar quantidade",
"reconciliation.reason.planned_recipe": "Necessário em breve para uma receita",
"reconciliation.reason.expiring_soon": "Expira em breve",
"reconciliation.reason.high_value": "Alto valor",
"reconciliation.reason.low_confidence": "Quantidade estimada",
"reconciliation.reason.stale_trust": "Não verificado há algum tempo",
"reconciliation.reason.likely_depleted": "Provavelmente acaba em breve",
"reconciliation.done": "Concluído",
"reconciliation.next": "Próximo",
"reconciliation.empty": "Nada precisa de verificação agora. Muito bem!",
"reconciliation.tasteHint": "Passou do prazo de validade? Cheire e prove primeiro nunca desperdice comida desnecessariamente."
} }
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primärt", "onboarding.primaryGoal": "Primärt",
"home.trustStatus.up_to_date": "Uppdaterat", "home.trustStatus.up_to_date": "Uppdaterat",
"home.trustStatus.needs_check": "Några varor behöver kontrolleras", "home.trustStatus.needs_check": "Några varor behöver kontrolleras",
"home.trustStatus.uncertain": "Lagret är osäkert gör en snabbkoll" "home.trustStatus.uncertain": "Lagret är osäkert gör en snabbkoll",
"reconciliation.title": "Snabbkoll av lagret",
"reconciliation.subtitle": "Bekräfta vad du har kvar så blir förslagen bättre.",
"reconciliation.exists": "Finns kvar",
"reconciliation.depleted": "Är slut",
"reconciliation.uncertain": "Osäker",
"reconciliation.adjustQuantity": "Justera mängd",
"reconciliation.reason.planned_recipe": "Behövs snart till recept",
"reconciliation.reason.expiring_soon": "Går snart ut",
"reconciliation.reason.high_value": "Högt värde",
"reconciliation.reason.low_confidence": "Uppskattad mängd",
"reconciliation.reason.stale_trust": "Har inte kontrollerats på länge",
"reconciliation.reason.likely_depleted": "Borde vara slut snart",
"reconciliation.done": "Klart",
"reconciliation.next": "Nästa",
"reconciliation.empty": "Inget behöver avstämning just nu. Bra jobbat!",
"reconciliation.tasteHint": "Passerat bäst före? Lukta och smaka först mat kastas inte i onödan."
} }
+1
View File
@@ -4,3 +4,4 @@ export * from "./fefo.js";
export * from "./dedup.js"; export * from "./dedup.js";
export * from "./forecast.js"; export * from "./forecast.js";
export * from "./trust.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);
});
});
+12
View File
@@ -43,3 +43,15 @@ export const inventoryQuerySchema = z.object({
offset: z.coerce.number().int().min(0).default(0), offset: z.coerce.number().int().min(0).default(0),
}); });
export type InventoryQuery = z.infer<typeof inventoryQuerySchema>; 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>;
+3
View File
@@ -60,6 +60,9 @@ importers:
'@app/ai-contracts': '@app/ai-contracts':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ai-contracts version: link:../../packages/ai-contracts
'@app/analytics':
specifier: workspace:*
version: link:../../packages/analytics
'@app/connectors': '@app/connectors':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/connectors version: link:../../packages/connectors