Files
Cibello-app/apps/api/src/lib/cooking.ts
T
Sven (AAMOS AI) 8637eaa6c3 3c-fix: partial consumption now deducts eaten + leftovers
- consumptionPortions = min(planned, actualPortionsEaten + leftoverEstimatePortions).
  Meal boxes are a subset of leftovers; raw inventory is only retained for
  portions that were never cooked.
- Added validation leftoverEstimatePortions >= mealBoxPortions with localized
  400 error (cooked.leftoverLessThanBox) in server i18n + all 12 locales.
- Removed unused COOKING_SESSION_STATUSES / MEAL_BOX_STATUSES imports from
  apps/api/src/lib/cooking.ts.
- Hardened test cleanup to delete mealBoxes/meals/recipeCooks/cookingSessions
  by household before dropping storageLocations.
- New regression tests: meal-box double-counting, eaten+leftover split, and
  leftover < box rejection.
- Updated FAS3-COOKING-SESSIONS-AUDIT.md with corrected physics semantics.
2026-08-07 15:35:25 +07:00

662 lines
22 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { and, eq, gt, isNull, sql } from "drizzle-orm";
import { schema, markMilestone } from "@app/database";
import {
allocateFefo,
computeBalance,
updateCookingAssumptionProfile,
rollbackCookingAssumptionProfile,
} from "@app/inventory-engine";
import { scaleNutrition } from "@app/nutrition-engine";
import type { Unit } from "@app/shared-types";
import { MEAL_TYPES } from "@app/shared-types";
import {
cookingSessionStarted,
cookingSessionCompleted,
cookingSessionUndone,
} from "@app/analytics";
import { todayIso, emitEvent, trackProductAnalytics } from "./helpers.js";
import { errors } from "./errors.js";
import { loadFullRecipe } from "../routes/recipes.js";
import { userLanguageTag } from "./contentLanguage.js";
import { t } from "./i18n.js";
export interface CompleteCookingInput {
portionsCooked?: number;
mealBoxPortions?: number;
mealBoxFrozen?: boolean;
mealBoxStorageLocationId?: string;
deductInventory?: boolean;
eaters?: Array<{ userId: string; portionFraction: number }>;
date?: string;
mealType?: string;
inventoryOverrides?: Array<{ canonicalIngredientId: string; quantityUsed: number; unit: string }>;
actualPortionsEaten?: number;
leftoverEstimatePortions?: number;
leftoverNote?: string;
}
export interface CompleteCookingSessionOptions {
/** Sant för legacy POST /v1/recipes/:id/cook där session skapas och startas i ett steg. */
emitStartedEvent?: boolean;
}
export interface CompleteCookingResult {
ok: boolean;
mealIds: string[];
mealBoxId: string | null;
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>;
}
/**
* Delad kompletteringsväg för både POST /v1/recipes/:id/cook (legacy)
* och POST /v1/cooking-sessions/:id/complete. Hanterar summavalidering,
* persistens av svar, profiluppdateringar och analytics.
*/
export async function completeCookingSession(
app: FastifyInstance,
session: typeof schema.cookingSessions.$inferSelect,
userId: string,
input: CompleteCookingInput,
correlationId: string,
options: CompleteCookingSessionOptions = {},
): Promise<CompleteCookingResult & { session: typeof schema.cookingSessions.$inferSelect }> {
const plannedPortions = input.portionsCooked ?? session.plannedPortions;
const mealBoxPortions = input.mealBoxPortions ?? 0;
const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions;
if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) {
const languageTag = await userLanguageTag(app.db, userId);
throw errors.badRequest(t("cooked.portionsSumExceedsPlanned", languageTag));
}
if (leftoverEstimatePortions < mealBoxPortions) {
const languageTag = await userLanguageTag(app.db, userId);
throw errors.badRequest(t("cooked.leftoverLessThanBox", languageTag));
}
if (options.emitStartedEvent && session.status === "started") {
await trackProductAnalytics(
app.db,
userId,
cookingSessionStarted({
householdId: session.householdId,
properties: {
cookingSessionId: session.id,
recipeId: session.recipeId,
status: session.status,
plannedPortions,
},
}),
);
}
const result = await completeCookingSessionCore(
app,
session,
userId,
{ ...input, portionsCooked: plannedPortions, mealBoxPortions },
correlationId,
);
// Persistera användarens svar (ersätter kärnans default-värden).
const [updated] = await app.db
.update(schema.cookingSessions)
.set({
actualPortionsEaten,
leftoverEstimatePortions,
leftoverNote: input.leftoverNote ?? null,
updatedAt: new Date(),
})
.where(eq(schema.cookingSessions.id, session.id))
.returning();
if (!updated) throw errors.internal("Kunde inte uppdatera cooking session.");
// Uppdatera antagandeprofiler per icke-valfri ingrediens (hushållsnivå).
const date = new Date().toISOString().slice(0, 10);
for (const ing of result.recipeIngredients) {
if (ing.optional) continue;
const [existing] = await app.db
.select()
.from(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, session.householdId),
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId),
),
)
.limit(1);
const updatedProfile = updateCookingAssumptionProfile(
{
householdId: session.householdId,
canonicalIngredientId: ing.canonicalIngredientId,
plannedPortions,
actualPortionsEaten,
leftoverEstimatePortions,
sessionId: session.id,
date,
},
existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
);
await app.db
.insert(schema.cookingAssumptionProfiles)
.values({
householdId: session.householdId,
canonicalIngredientId: ing.canonicalIngredientId,
...updatedProfile,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [
schema.cookingAssumptionProfiles.householdId,
schema.cookingAssumptionProfiles.canonicalIngredientId,
],
set: {
averageEatenPortions: updatedProfile.averageEatenPortions,
averageLeftoverPortions: updatedProfile.averageLeftoverPortions,
observationCount: updatedProfile.observationCount,
lastSessionAnswers: updatedProfile.lastSessionAnswers,
updatedAt: new Date(),
},
});
}
await trackProductAnalytics(
app.db,
userId,
cookingSessionCompleted({
householdId: session.householdId,
properties: {
cookingSessionId: session.id,
recipeId: session.recipeId,
portionsCooked: plannedPortions,
actualPortionsEaten,
leftoverEstimatePortions,
mealBoxPortions,
},
}),
);
return { ...result, session: updated };
}
/**
* Lågnivå-kärna för att "jag har lagat". Används via completeCookingSession
* av både POST /v1/recipes/:id/cook (legacy shortcut) och
* POST /v1/cooking-sessions/:id/complete.
*/
export async function completeCookingSessionCore(
app: FastifyInstance,
session: typeof schema.cookingSessions.$inferSelect,
userId: string,
input: CompleteCookingInput,
correlationId: string,
): Promise<CompleteCookingResult> {
const recipe = await loadFullRecipe(app, session.recipeId);
const householdId = session.householdId;
const portionsCooked = input.portionsCooked ?? session.plannedPortions;
const date = input.date ?? todayIso();
const mealType = (input.mealType ?? session.plannedMealType) as (typeof MEAL_TYPES)[number];
// 1. FEFO-avdrag
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
if (input.deductInventory !== false) {
// Partiell förbrukning (Fas 3 §6.3): råvaror dras för de tillagade
// portionerna = ätna + rester (inklusive matlådor). Endast portioner som
// aldrig tillagats stannar kvar som råvara i lagret.
const actualPortionsEaten = input.actualPortionsEaten ?? portionsCooked;
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? 0;
const consumptionPortions = Math.min(
portionsCooked,
actualPortionsEaten + leftoverEstimatePortions,
);
const factor = consumptionPortions / recipe.portions;
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
for (const ing of recipe.ingredients) {
if (ing.optional) continue;
const override = overrides.get(ing.canonicalIngredientId);
const requiredQty = override ? override.quantityUsed : ing.quantity * factor;
const requiredUnit = override ? override.unit : ing.unit;
if (requiredQty <= 0) continue;
const stock = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
eq(schema.inventoryItems.canonicalIngredientId, ing.canonicalIngredientId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
if (stock.length === 0) continue;
const [info] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, ing.canonicalIngredientId))
.limit(1);
const allocation = allocateFefo(
requiredQty,
requiredUnit as Unit,
stock.map((s) => ({
id: s.id,
canonicalIngredientId: s.canonicalIngredientId,
quantity: s.quantity,
unit: s.unit,
bestBeforeDate: s.bestBeforeDate,
useByDate: s.useByDate,
openedAt: s.openedAt,
frozenAt: s.frozenAt,
thawedAt: s.thawedAt,
purchasedAt: s.purchasedAt,
})),
{ densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece },
);
for (const alloc of allocation.allocations) {
const item = stock.find((s) => s.id === alloc.itemId)!;
const newQty = Math.max(0, Math.round((item.quantity - alloc.quantity) * 1000) / 1000);
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: alloc.itemId,
cookingSessionId: session.id,
type: "cook_use",
quantityDelta: -(item.quantity - newQty),
unit: item.unit,
refType: "recipe_cook",
refId: session.recipeId,
actorUserId: userId,
});
await app.db
.update(schema.inventoryItems)
.set({
quantity: newQty,
depletedAt: newQty <= 0 ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, alloc.itemId));
deductions.push({
itemId: alloc.itemId,
quantity: alloc.quantity,
unit: alloc.unit,
name: item.displayName,
});
}
}
}
// 2. Måltider
const eaters =
input.eaters && input.eaters.length > 0
? input.eaters
: [{ userId, portionFraction: 1 }];
const mealIds: string[] = [];
for (const eater of eaters) {
const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction);
const [meal] = await app.db
.insert(schema.meals)
.values({
userId: eater.userId,
householdId,
cookingSessionId: session.id,
date,
mealType,
source: "cooked_recipe",
recipeId: session.recipeId,
titleSv: recipe.titleSv,
portionFraction: eater.portionFraction,
nutrition,
nutritionIsEstimate: false,
})
.returning();
mealIds.push(meal!.id);
await emitEvent(app.db, {
type: "MEAL_LOGGED",
payload: { mealId: meal!.id, mealType, kcal: nutrition.kcal, source: "cooked_recipe" },
userId: eater.userId,
householdId,
correlationId,
});
}
// 3. Matlådor
let mealBoxId: string | null = null;
const mealBoxPortions = input.mealBoxPortions ?? 0;
if (mealBoxPortions > 0) {
const locationId =
input.mealBoxStorageLocationId ??
(
await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.householdId, householdId),
eq(schema.storageLocations.type, input.mealBoxFrozen ? "freezer" : "fridge"),
),
)
.limit(1)
)[0]?.id;
if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades.");
const useByDays = input.mealBoxFrozen ? 90 : 3;
const recommendedUseBy = new Date(Date.parse(date) + useByDays * 86_400_000)
.toISOString()
.slice(0, 10);
const [box] = await app.db
.insert(schema.mealBoxes)
.values({
householdId,
recipeId: session.recipeId,
cookingSessionId: session.id,
titleSv: recipe.titleSv,
portions: mealBoxPortions,
portionsRemaining: mealBoxPortions,
nutritionPerPortion: recipe.nutritionPerPortion,
cookedAt: date,
storageLocationId: locationId,
frozen: input.mealBoxFrozen ?? false,
recommendedUseBy,
})
.returning();
mealBoxId = box!.id;
await emitEvent(app.db, {
type: "MEAL_BOX_CREATED",
payload: { mealBoxId: box!.id, portions: mealBoxPortions },
userId,
householdId,
correlationId,
});
}
// 4. recipe_cooks + statistik
await app.db.insert(schema.recipeCooks).values({
recipeId: session.recipeId,
userId,
householdId,
cookingSessionId: session.id,
portionsCooked,
cookedAt: new Date(),
});
await app.db
.update(schema.recipes)
.set({ cookCount: sql`${schema.recipes.cookCount} + 1` })
.where(eq(schema.recipes.id, session.recipeId));
await emitEvent(app.db, {
type: "RECIPE_COOKED",
payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions },
userId,
householdId,
correlationId,
});
// 5. Uppdatera session (svaren skrivs över av completeCookingSession).
await app.db
.update(schema.cookingSessions)
.set({
status: "completed",
completedAt: new Date(),
plannedDeductions: deductions,
updatedAt: new Date(),
})
.where(eq(schema.cookingSessions.id, session.id));
await markMilestone(app.db, householdId, "firstCookingSessionCompletedAt");
if (deductions.length > 0) {
await markMilestone(app.db, householdId, "inventoryUpdatedAfterCookingAt");
}
return {
ok: true,
mealIds,
mealBoxId,
inventoryDeductions: deductions,
recipeIngredients: recipe.ingredients.map((ing) => ({
canonicalIngredientId: ing.canonicalIngredientId,
displayName: ing.displayNameSv,
quantity: ing.quantity,
unit: ing.unit,
optional: ing.optional,
})),
};
}
export interface UndoCookingSessionResult {
ok: boolean;
reversedTransactions: number;
restoredItemIds: string[];
removedMealIds: string[];
discardedMealBoxIds: string[];
}
/**
* Ångra en completed cooking session (Fas 3 §6.3).
*
* - Ledger är append-only: befintliga transaktioner rörs inte; istället skrivs
* reversal-transaktioner (type='correction') med motsatt delta.
* - Inventory-saldo räknas om från transaktionshistoriken; depletedAt nollas där
* saldot blir > 0 igen.
* - meals och meal_boxes som skapades av sessionen tas bort resp. markeras
* discarded; recipe_cooks-raden tas bort och cookCount backas.
* - Aktiveringsmilstolpar rörs inte (once-ever).
* - Antagandeprofiler rullas tillbaka deterministiskt via lastSessionAnswers.
* - undo_until = completedAt + 24 h, verkställt server-side i UTC.
*/
export async function undoCookingSession(
app: FastifyInstance,
session: typeof schema.cookingSessions.$inferSelect,
userId: string,
correlationId: string,
): Promise<UndoCookingSessionResult> {
if (session.status !== "completed") {
throw errors.conflict("Sessionen måste vara avslutad för att kunna ångras.");
}
if (!session.completedAt) {
throw errors.internal("Sessionen saknar completedAt.");
}
const now = new Date();
const deadline = new Date(session.completedAt.getTime() + 24 * 60 * 60 * 1000);
if (now > deadline) {
const languageTag = await userLanguageTag(app.db, userId);
throw errors.conflict(t("cooked.undoWindowExpired", languageTag));
}
// 1. Reversera inventory-transaktioner (append-only).
const cookUses = await app.db
.select()
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.cookingSessionId, session.id),
eq(schema.inventoryTransactions.type, "cook_use"),
),
);
const affectedItemIds = new Set<string>();
for (const tx of cookUses) {
await app.db.insert(schema.inventoryTransactions).values({
householdId: tx.householdId,
inventoryItemId: tx.inventoryItemId,
cookingSessionId: session.id,
type: "correction",
quantityDelta: -tx.quantityDelta,
unit: tx.unit,
refType: "cooking_session_undo",
refId: session.id,
actorUserId: userId,
note: "Ångrat cook_use",
});
affectedItemIds.add(tx.inventoryItemId);
}
// 2. Återställ inventory-saldon från transaktionerna.
for (const itemId of affectedItemIds) {
const txs = await app.db
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
const balance = computeBalance(txs);
await app.db
.update(schema.inventoryItems)
.set({
quantity: balance.balance,
depletedAt: balance.balance <= 1e-9 ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, itemId));
}
// 3. Ta bort måltider.
const mealsToRemove = await app.db
.select()
.from(schema.meals)
.where(eq(schema.meals.cookingSessionId, session.id));
for (const meal of mealsToRemove) {
await emitEvent(app.db, {
type: "MEAL_REMOVED",
payload: { mealId: meal.id, recipeId: session.recipeId, source: "cooking_session_undo" },
userId: meal.userId,
householdId: session.householdId,
correlationId,
});
}
await app.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, session.id));
// 4. Markera matlådor som discarded.
const boxesToDiscard = await app.db
.select()
.from(schema.mealBoxes)
.where(eq(schema.mealBoxes.cookingSessionId, session.id));
for (const box of boxesToDiscard) {
await emitEvent(app.db, {
type: "MEAL_BOX_DISCARDED",
payload: { mealBoxId: box.id, portions: box.portions, source: "cooking_session_undo" },
userId,
householdId: session.householdId,
correlationId,
});
}
await app.db
.update(schema.mealBoxes)
.set({ status: "discarded", portionsRemaining: 0 })
.where(eq(schema.mealBoxes.cookingSessionId, session.id));
// 5. Ta bort recipe_cooks-raden och backa cookCount.
await app.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, session.id));
await app.db
.update(schema.recipes)
.set({ cookCount: sql`GREATEST(${schema.recipes.cookCount} - 1, 0)` })
.where(eq(schema.recipes.id, session.recipeId));
// 6. Rulla tillbaka antagandeprofiler.
const recipe = await loadFullRecipe(app, session.recipeId);
for (const ing of recipe.ingredients) {
if (ing.optional) continue;
const [existing] = await app.db
.select()
.from(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, session.householdId),
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId),
),
)
.limit(1);
if (!existing) continue;
const rolledBack = rollbackCookingAssumptionProfile(session.id, {
averageEatenPortions: existing.averageEatenPortions,
averageLeftoverPortions: existing.averageLeftoverPortions,
observationCount: existing.observationCount,
lastSessionAnswers: existing.lastSessionAnswers,
});
if (rolledBack.observationCount === 0) {
await app.db
.delete(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, session.householdId),
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId),
),
);
} else {
await app.db
.insert(schema.cookingAssumptionProfiles)
.values({
householdId: session.householdId,
canonicalIngredientId: ing.canonicalIngredientId,
averageEatenPortions: rolledBack.averageEatenPortions,
averageLeftoverPortions: rolledBack.averageLeftoverPortions,
observationCount: rolledBack.observationCount,
lastSessionAnswers: rolledBack.lastSessionAnswers,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [
schema.cookingAssumptionProfiles.householdId,
schema.cookingAssumptionProfiles.canonicalIngredientId,
],
set: {
averageEatenPortions: rolledBack.averageEatenPortions,
averageLeftoverPortions: rolledBack.averageLeftoverPortions,
observationCount: rolledBack.observationCount,
lastSessionAnswers: rolledBack.lastSessionAnswers,
updatedAt: new Date(),
},
});
}
}
// 7. Sätt sessionstatus till undone.
await app.db
.update(schema.cookingSessions)
.set({
status: "undone",
updatedAt: new Date(),
})
.where(eq(schema.cookingSessions.id, session.id));
await trackProductAnalytics(
app.db,
userId,
cookingSessionUndone({
householdId: session.householdId,
properties: {
cookingSessionId: session.id,
recipeId: session.recipeId,
reversedTransactions: cookUses.length,
},
}),
);
await emitEvent(app.db, {
type: "COOKING_SESSION_UNDONE",
payload: {
cookingSessionId: session.id,
recipeId: session.recipeId,
reversedTransactions: cookUses.length,
},
userId,
householdId: session.householdId,
correlationId,
});
return {
ok: true,
reversedTransactions: cookUses.length,
restoredItemIds: [...affectedItemIds],
removedMealIds: mealsToRemove.map((m) => m.id),
discardedMealBoxIds: boxesToDiscard.map((b) => b.id),
};
}