L'optimiseur ne mutualisait que le pré-découpage. Il regroupe maintenant aussi les actions SETUP (préchauffage, ébullition) entre recettes, mais seulement quand elles sont compatibles : deux préchauffages ne fusionnent que s'ils visent la même température détectée. Deux préchauffages à des températures différentes restent séparés, ordonnés du plus froid au plus chaud, et la mise en place porte une note « Four requis à N températures — enchaîner … ». Défaut optimiste : deux préchauffages sans température détectée fusionnent quand même (supposés compatibles). - cooking-optimizer.ts : temperatureKey (comparaison exacte, aucune conversion inter-représentations), isPureSetupStep / setupSignature / extractMergedSetup en miroir du chemin merged-prep ; comparableTemp pour l'ordre seul ; detectApplianceTemperatureConflicts → CookingPhaseView.notes - cooking-session.service.ts : `temperatures` dans l'include + mapping - packages/shared : CookingTemperatureView, kind « merged-setup », CookingTaskView.temperatures, CookingPhaseNoteView, CookingPhaseView.notes - web : temperature-label.ts extrait de StepDescription et réutilisé ; taskHeadline gère merged-setup ; phaseNoteText ; bandeau .cooking-phase__note - i18n : cookingSession.task.mergedSetupLabel, cookingSession.note.* - tests : 7 cas Mocha « setup pooling » + test DB cooking-session, cas Cypress mode layout + scénario Gherkin Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
182 lines
6.8 KiB
TypeScript
182 lines
6.8 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 } },
|
|
temperatures: 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 })),
|
|
// Structured value only — `Decimal` °C → plain number, `qualitative`
|
|
// re-narrowed to the literal union (same posture as
|
|
// `recipe.service.ts`'s `toStepTechStepViews`).
|
|
temperatures: techStep.temperatures.map((temperature) => ({
|
|
celsius: temperature.celsius === null ? null : Number(temperature.celsius),
|
|
gasMark: temperature.gasMark,
|
|
qualitative:
|
|
temperature.qualitative === "low" ||
|
|
temperature.qualitative === "medium" ||
|
|
temperature.qualitative === "high"
|
|
? temperature.qualitative
|
|
: null,
|
|
})),
|
|
};
|
|
}),
|
|
}));
|
|
|
|
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;
|
|
}
|
|
}
|