Fas 3 steg 3a: cooking_sessions-tabell + lifecycle-endpoints + timeout-jobb, gamla /cook kvar som kortkommando
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
||||
import { schema, markMilestone } from "@app/database";
|
||||
import { allocateFefo } from "@app/inventory-engine";
|
||||
import { scaleNutrition } from "@app/nutrition-engine";
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { todayIso, emitEvent } from "./helpers.js";
|
||||
import { errors } from "./errors.js";
|
||||
import { loadFullRecipe } from "../routes/recipes.js";
|
||||
import { MEAL_TYPES } from "@app/shared-types";
|
||||
|
||||
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 }>;
|
||||
}
|
||||
|
||||
export interface CompleteCookingResult {
|
||||
ok: boolean;
|
||||
mealIds: string[];
|
||||
mealBoxId: string | null;
|
||||
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemensam kärna för att "jag har lagat". Används 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) {
|
||||
const factor = portionsCooked / 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
|
||||
await app.db
|
||||
.update(schema.cookingSessions)
|
||||
.set({
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
actualPortionsEaten: portionsCooked - mealBoxPortions,
|
||||
leftoverEstimatePortions: mealBoxPortions,
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user