Module cooking-session : charge le Planning couvrant ?date= (meme requete "plage couvrante" + degradation "jamais null" que /shopping-list), mappe chaque PlanningItem vers l'entree pure de optimizeCookingPlan (ingredients/ unites/techniques/ustensiles resolus via toIngredientView/toUnitView reutilisees de recipe.service), renvoie OptimizedCookingPlanView. - cookingSessionPlanningInclude reprend le sous-arbre steps de recipeInclude. - Route requireAuth, contrat ?date= identique a /shopping-list. - Monte /cooking-session dans app.ts. - Tests d'integration Mocha (401, date invalide, plan vide sans foyer / sans planning, mutualisation d'une decoupe entre 2 recettes planifiees). - specs/batch-cooking-architecture.md : module "Calcul batch-cooking" TODO -> v1 implementee ; nouvelle section dans backend-architecture.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
168 lines
6.1 KiB
TypeScript
168 lines
6.1 KiB
TypeScript
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
|
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
|
|
import type { Prisma } from "@prisma/client";
|
|
import { prisma } from "../../db/prisma.js";
|
|
import {
|
|
type OptimizerRecipeInput,
|
|
type OptimizerStepInput,
|
|
optimizeCookingPlan,
|
|
} from "../../lib/recipe-matching/cooking-optimizer.js";
|
|
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
|
|
|
/**
|
|
* Prisma `include` for a `Planning` query that needs, for every item, its
|
|
* recipe's ordered steps with the full detected-technique tree — the raw
|
|
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
|
|
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
|
|
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
|
|
* as-is; deliberately narrower than a full `RecipeView` fetch (no
|
|
* diets/favorites/recipe-level ingredient list — the optimizer reads
|
|
* quantities off the technique clauses, not the recipe header).
|
|
*/
|
|
function cookingSessionPlanningInclude() {
|
|
return {
|
|
items: {
|
|
include: {
|
|
recipe: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
portions: true,
|
|
steps: {
|
|
orderBy: { order: "asc" },
|
|
include: {
|
|
techSteps: {
|
|
orderBy: { order: "asc" },
|
|
include: {
|
|
techStep: true,
|
|
ingredients: {
|
|
include: {
|
|
ingredient: {
|
|
include: {
|
|
allergies: { include: { allergy: { include: { category: true } } } },
|
|
diets: { include: { diet: true } },
|
|
},
|
|
},
|
|
unit: true,
|
|
},
|
|
},
|
|
utensils: { include: { utensil: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies Prisma.PlanningInclude;
|
|
}
|
|
|
|
type PlanningWithSteps = Prisma.PlanningGetPayload<{
|
|
include: ReturnType<typeof cookingSessionPlanningInclude>;
|
|
}>;
|
|
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
|
|
|
|
/**
|
|
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
|
|
* to the optimizer's pure input shape — ingredient/unit/technique/utensil
|
|
* rows resolved to their reference views here so the optimizer itself never
|
|
* touches Prisma. `Decimal` quantities become plain numbers (same
|
|
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
|
|
* unresolved-unit line keeps `unit: null`.
|
|
*/
|
|
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
|
|
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
|
|
stepId: step.id,
|
|
order: step.order,
|
|
description: step.description,
|
|
techSteps: step.techSteps.map((techStep) => {
|
|
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
|
|
ingredient: toIngredientView(line.ingredient),
|
|
quantity: line.quantity === null ? null : Number(line.quantity),
|
|
unit: line.unit === null ? null : toUnitView(line.unit),
|
|
}));
|
|
return {
|
|
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
|
order: techStep.order,
|
|
ingredients,
|
|
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
|
};
|
|
}),
|
|
}));
|
|
|
|
return {
|
|
recipeId: item.recipe.id,
|
|
name: item.recipe.name,
|
|
// The slot's own portion count vs. the recipe's as-written yield — the
|
|
// optimizer scales technique-clause quantities by the ratio, same
|
|
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
|
|
portions: item.portions,
|
|
recipePortions: item.recipe.portions,
|
|
steps,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds the household's optimized cooking plan for the week covering
|
|
* `date` — every recipe planned that week, reorganized into ordered phases
|
|
* that pool shared prep and float passive cooks into the background (see
|
|
* `cooking-optimizer.ts`). `date` follows the same convention as
|
|
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
|
|
* not necessarily a Monday).
|
|
*
|
|
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
|
|
* **never** returns `null` — no household and "no planning covers this week
|
|
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
|
|
* {@link OptimizedCookingPlanView} (the week's date range is always
|
|
* computable from `date` alone).
|
|
*/
|
|
export async function getCookingPlanForDate(
|
|
houseId: number | null,
|
|
date: DateTime,
|
|
): Promise<OptimizedCookingPlanView> {
|
|
try {
|
|
const weekStart = getWeekStart(toDateOnly(date));
|
|
const weekFinish = weekStart.plus({ days: 6 });
|
|
const emptyPlan: OptimizedCookingPlanView = {
|
|
startDate: weekStart.toJSDate().toISOString(),
|
|
finishDate: weekFinish.toJSDate().toISOString(),
|
|
recipes: [],
|
|
phases: [],
|
|
};
|
|
|
|
if (houseId === null) {
|
|
return emptyPlan;
|
|
}
|
|
|
|
// Same "covering range" lookup as getShoppingListForDate — see
|
|
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
|
|
const dateOnly = toDateOnly(date).toJSDate();
|
|
const planning = await prisma.planning.findFirst({
|
|
where: {
|
|
houseId,
|
|
startDate: { lte: dateOnly },
|
|
finishDate: { gte: dateOnly },
|
|
},
|
|
orderBy: { startDate: "desc" },
|
|
include: cookingSessionPlanningInclude(),
|
|
});
|
|
|
|
if (!planning) {
|
|
return emptyPlan;
|
|
}
|
|
|
|
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
|
|
return {
|
|
startDate: planning.startDate.toISOString(),
|
|
finishDate: planning.finishDate.toISOString(),
|
|
recipes,
|
|
phases,
|
|
};
|
|
} catch (err) {
|
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
|
// this service layer just isn't allowed a bare `await` per the repo's
|
|
// async/try-catch convention.
|
|
throw err;
|
|
}
|
|
}
|