Fas 2 steg 4: Quick Reconciliation (motor, API, app + 12-språks i18n)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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")}>
|
||||||
{t(`home.trustStatus.${trustStatus}`)}
|
<Small style={{ marginBottom: spacing.xs, opacity: 0.8, color: colors.primary }}>
|
||||||
</Small>
|
{t(`home.trustStatus.${trustStatus}`)}
|
||||||
|
</Small>
|
||||||
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
|
|||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user