From 218bc44d08e026617bc1b7aa47b164fb5aad2661 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Fri, 7 Aug 2026 01:02:08 +0700 Subject: [PATCH] =?UTF-8?q?Fas=202=20steg=204:=20Quick=20Reconciliation=20?= =?UTF-8?q?(motor,=20API,=20app=20+=2012-spr=C3=A5ks=20i18n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 2 + apps/api/src/lib/helpers.ts | 30 ++ apps/api/src/routes/reconciliations.ts | 259 ++++++++++++++++++ apps/api/src/server.ts | 2 + apps/api/test/reconciliation.test.ts | 126 +++++++++ apps/mobile/src/app/(tabs)/home.tsx | 12 +- apps/mobile/src/app/reconciliation.tsx | 176 ++++++++++++ apps/mobile/src/locales/da/common.json | 18 +- apps/mobile/src/locales/de/common.json | 18 +- apps/mobile/src/locales/en/common.json | 18 +- apps/mobile/src/locales/es/common.json | 18 +- apps/mobile/src/locales/fi/common.json | 18 +- apps/mobile/src/locales/fr/common.json | 18 +- apps/mobile/src/locales/it/common.json | 18 +- apps/mobile/src/locales/nb/common.json | 18 +- apps/mobile/src/locales/nl/common.json | 18 +- apps/mobile/src/locales/pl/common.json | 18 +- apps/mobile/src/locales/pt/common.json | 18 +- apps/mobile/src/locales/sv/common.json | 18 +- packages/inventory-engine/src/index.ts | 1 + .../inventory-engine/src/reconciliation.ts | 142 ++++++++++ .../test/reconciliation.test.ts | 105 +++++++ packages/validation/src/inventory.ts | 12 + pnpm-lock.yaml | 3 + 24 files changed, 1069 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/routes/reconciliations.ts create mode 100644 apps/api/test/reconciliation.test.ts create mode 100644 apps/mobile/src/app/reconciliation.tsx create mode 100644 packages/inventory-engine/src/reconciliation.ts create mode 100644 packages/inventory-engine/test/reconciliation.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index 0aa3a16..65c8434 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@app/ai-contracts": "workspace:*", + "@app/analytics": "workspace:*", "@app/connectors": "workspace:*", "@app/database": "workspace:*", "@app/events": "workspace:*", @@ -48,6 +49,7 @@ "tsup": { "noExternal": [ "@app/ai-contracts", + "@app/analytics", "@app/connectors", "@app/database", "@app/events", diff --git a/apps/api/src/lib/helpers.ts b/apps/api/src/lib/helpers.ts index 44fe8e5..fd83a57 100644 --- a/apps/api/src/lib/helpers.ts +++ b/apps/api/src/lib/helpers.ts @@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import type { Database } from "@app/database"; import { schema } from "@app/database"; +import type { AnalyticsEvent } from "@app/analytics"; import type { DecayProfile } from "@app/inventory-engine"; import type { EventType } from "@app/shared-types"; import type { NewDomainEvent } from "@app/events"; @@ -117,6 +118,35 @@ export async function emitEvent( }); } +/** Track product analytics server-side if user opted in. */ +export async function trackProductAnalytics( + db: Database, + userId: string, + event: AnalyticsEvent, +): Promise { + 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). */ export async function audit( db: Database, diff --git a/apps/api/src/routes/reconciliations.ts b/apps/api/src/routes/reconciliations.ts new file mode 100644 index 0000000..bc0c3f6 --- /dev/null +++ b/apps/api/src/routes/reconciliations.ts @@ -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(); + 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(); + const dailyConsumptionRate = new Map(); + const daysLeftByItemId = new Map(); + + 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`COALESCE(SUM(ABS(${schema.inventoryTransactions.quantityDelta})), 0)`, + days: sql`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 = { + 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, + }; + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 52a5e7e..159e591 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -16,6 +16,7 @@ import { mealRoutes } from "./routes/meals.js"; import { shoppingRoutes } from "./routes/shopping.js"; import { planningRoutes } from "./routes/planning.js"; import { recommendationRoutes } from "./routes/recommendations.js"; +import { reconciliationRoutes } from "./routes/reconciliations.js"; import { memoryRoutes } from "./routes/memory.js"; import { budgetRoutes } from "./routes/budget.js"; import { subscriptionRoutes } from "./routes/subscriptions.js"; @@ -81,6 +82,7 @@ export async function buildServer(config: AppConfig) { await app.register(shoppingRoutes); await app.register(planningRoutes); await app.register(recommendationRoutes); + await app.register(reconciliationRoutes); await app.register(memoryRoutes); await app.register(budgetRoutes); await app.register(subscriptionRoutes); diff --git a/apps/api/test/reconciliation.test.ts b/apps/api/test/reconciliation.test.ts new file mode 100644 index 0000000..fbff25f --- /dev/null +++ b/apps/api/test/reconciliation.test.ts @@ -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>; + 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); + }); +}); diff --git a/apps/mobile/src/app/(tabs)/home.tsx b/apps/mobile/src/app/(tabs)/home.tsx index aa5c154..a8fa539 100644 --- a/apps/mobile/src/app/(tabs)/home.tsx +++ b/apps/mobile/src/app/(tabs)/home.tsx @@ -1,4 +1,4 @@ -import { View } from "react-native"; +import { Pressable, View } from "react-native"; import { router } from "expo-router"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api"; @@ -18,7 +18,7 @@ import { Tag, Title, } from "@/components/ui"; -import { spacing } from "@/lib/theme"; +import { colors, spacing } from "@/lib/theme"; import { formatQuantity } from "@/lib/units"; /** Hemma (spec §4.4): matlager, bäst före, matlådor, inköpslista, budget, hushåll. */ @@ -70,9 +70,11 @@ export default function HomeScreen() { {t("home.title")} {trustStatus && ( - - {t(`home.trustStatus.${trustStatus}`)} - + router.push("/reconciliation")}> + + {t(`home.trustStatus.${trustStatus}`)} + + )} diff --git a/apps/mobile/src/app/reconciliation.tsx b/apps/mobile/src/app/reconciliation.tsx new file mode 100644 index 0000000..661f76a --- /dev/null +++ b/apps/mobile/src/app/reconciliation.tsx @@ -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(""); + + 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 ; + if (query.isError) return void query.refetch()} />; + + const candidates = query.data?.candidates ?? []; + if (candidates.length === 0) { + return ( + + {t("reconciliation.title")} + +