feat(planning): sépare portions demandées et portions préparées #48

Merged
kyuno merged 1 commit from feat/leftovers-portions-foundations into main 2026-09-05 23:45:19 +02:00
21 changed files with 429 additions and 33 deletions

View file

@ -0,0 +1,27 @@
-- Splits "portions to prepare" from "portions actually needed" on a
-- planning slot, and adds the two columns a leftovers-serving item needs
-- (its source item + how much of its own pool was thrown away). Purely
-- additive — no existing column renamed. See the `PlanningItem` model doc
-- comments and issue #34.
-- `discarded_portions` and `source_planning_item_id` are trivial adds.
ALTER TABLE "planning_item"
ADD COLUMN "discarded_portions" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "source_planning_item_id" INTEGER;
-- `portions_requested` is NOT NULL but the table may have rows already:
-- add it nullable, backfill, then lock it down — same discipline as the
-- `ingredient_unit_catalog` / `planning_item_portions` migrations. Every
-- pre-existing row predates this feature, so it had no leftovers:
-- requested == prepared at the time it was added.
ALTER TABLE "planning_item" ADD COLUMN "portions_requested" INTEGER;
UPDATE "planning_item" SET "portions_requested" = "portions";
ALTER TABLE "planning_item" ALTER COLUMN "portions_requested" SET NOT NULL;
-- Self-FK for a "leftovers" item pointing back at the item it draws from.
-- ON DELETE CASCADE: removing the source item removes everything served
-- from its pool too (same as the Planning -> PlanningItem cascade).
ALTER TABLE "planning_item"
ADD CONSTRAINT "planning_item_source_planning_item_id_fkey"
FOREIGN KEY ("source_planning_item_id") REFERENCES "planning_item"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -213,17 +213,41 @@ model Planning {
}
model PlanningItem {
id Int @id @default(autoincrement())
planningId Int @map("planning_id")
weekDay String @map("week_day")
id Int @id @default(autoincrement())
planningId Int @map("planning_id")
weekDay String @map("week_day")
meal String
recipeId Int @map("recipe_id")
recipeId Int @map("recipe_id")
/// Portions actually *prepared* for this slot — always a whole multiple
/// of `recipe.portions` (a dish is cooked in whole batches). Derived from
/// `portionsRequested` + the recipe's yield via `computeBatchedPortions`
/// (`@batch-cooking/shared`) in `addPlanningItem`, never sent raw by the
/// client. On a "leftovers" item (`sourcePlanningItemId` set) it always
/// equals `portionsRequested`.
portions Int
/// How many portions this slot must actually feed. For a fresh item
/// (`sourcePlanningItemId` null), `<= portions` — the gap is this item's
/// leftover pool. For a "leftovers" item, always `== portions` (serving
/// leftovers prepares nothing new, so it can't generate more — no
/// leftovers-on-leftovers).
portionsRequested Int @map("portions_requested")
/// Non-null only on a "leftovers" item: the item whose leftover pool it
/// draws from. `onDelete: Cascade` — deleting the source item also
/// deletes everything served from its leftovers (same reasoning as the
/// existing `Planning` -> `PlanningItem` cascade).
sourcePlanningItemId Int? @map("source_planning_item_id")
/// Portions of *this* item's leftover pool explicitly thrown away (the
/// basket's "jeter" button). Always 0 on a "leftovers" item (its own
/// pool is null by construction).
discardedPortions Int @default(0) @map("discarded_portions")
/// See `Planning.createdAt` — same admin-metrics-only timestamp.
createdAt DateTime @default(now()) @map("created_at")
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id])
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id])
sourcePlanningItem PlanningItem? @relation("PlanningItemLeftoverSource", fields: [sourcePlanningItemId], references: [id], onDelete: Cascade)
/// The "leftovers" items served from this item's own pool — inverse of `sourcePlanningItem`.
leftoverUses PlanningItem[] @relation("PlanningItemLeftoverSource")
@@map("planning_item")
}

View file

@ -2,6 +2,7 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too
import { HttpError } from "@batch-cooking/error-tools";
import {
type AddPlanningItemInput,
computeBatchedPortions,
ErrorCode,
type PlanningItemView,
type PlanningView,
@ -69,6 +70,9 @@ export async function getPlanningForDate(
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
portionsRequested: item.portionsRequested,
discardedPortions: item.discardedPortions,
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
recipe: item.recipe,
})),
};
@ -144,6 +148,18 @@ export async function addPlanningItem(
}
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
// `assertRecipeVisible` already guaranteed the recipe exists and is
// visible — this second read just pulls its written yield, needed to
// turn "portions requested" into "portions to actually prepare"
// (a whole number of batches, see `computeBatchedPortions`). The
// `?? 1` never triggers in practice (the assert above threw otherwise);
// it only keeps TypeScript happy about `findUnique`'s `| null`.
const recipe = await prisma.recipe.findUnique({
where: { id: input.recipeId },
select: { portions: true },
});
const { portions } = computeBatchedPortions(recipe?.portions ?? 1, input.portionsRequested);
const weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
@ -153,14 +169,19 @@ export async function addPlanningItem(
weekDay: input.weekDay,
meal: input.meal,
recipeId: input.recipeId,
portions: input.portions,
portions,
portionsRequested: input.portionsRequested,
},
include: { recipe: { select: { id: true, name: true } } },
});
analytics.recordEvent("planning.item_added", {
actorId: viewerId,
context: { recipeId: input.recipeId, portions: input.portions },
context: {
recipeId: input.recipeId,
portions,
portionsRequested: input.portionsRequested,
},
});
return {
@ -168,6 +189,9 @@ export async function addPlanningItem(
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
portionsRequested: item.portionsRequested,
discardedPortions: item.discardedPortions,
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
recipe: item.recipe,
};
} catch (err) {

View file

@ -177,6 +177,7 @@ describe("Cooking session", () => {
meal: "dejeuner",
recipeId: soupe.id,
portions: 4,
portionsRequested: 4,
},
{
planningId: planning.id,
@ -184,6 +185,7 @@ describe("Cooking session", () => {
meal: "diner",
recipeId: tarte.id,
portions: 4,
portionsRequested: 4,
},
],
});
@ -280,6 +282,7 @@ describe("Cooking session", () => {
meal: i === 0 ? "dejeuner" : "diner",
recipeId,
portions: 4,
portionsRequested: 4,
})),
});
}

View file

@ -0,0 +1,73 @@
import { computeAvailableLeftover, computeBatchedPortions } from "@batch-cooking/shared";
import { expect } from "chai";
/**
* Pure portion arithmetic from `@batch-cooking/shared` — no DB, so this
* suite skips `resetDatabase`. The API (`addPlanningItem`, and later the
* leftover-assignment endpoints) and the web app both call these; the
* numbers below are the two worked examples from issue #34 plus the
* boundary cases.
*/
describe("planning portion arithmetic", () => {
describe("computeBatchedPortions", () => {
it("rounds up to one batch when fewer portions are requested than the recipe yields", () => {
expect(computeBatchedPortions(4, 3)).to.deep.equal({ portions: 4, leftover: 1 });
});
it("cooks several batches when more portions are requested than one batch yields", () => {
expect(computeBatchedPortions(4, 10)).to.deep.equal({ portions: 12, leftover: 2 });
});
it("leaves no leftover on an exact batch boundary", () => {
expect(computeBatchedPortions(4, 4)).to.deep.equal({ portions: 4, leftover: 0 });
expect(computeBatchedPortions(4, 8)).to.deep.equal({ portions: 8, leftover: 0 });
});
it("still prepares one batch when a single portion is requested", () => {
expect(computeBatchedPortions(6, 1)).to.deep.equal({ portions: 6, leftover: 5 });
});
it("handles a one-portion recipe (every request is an exact number of batches)", () => {
expect(computeBatchedPortions(1, 5)).to.deep.equal({ portions: 5, leftover: 0 });
});
});
describe("computeAvailableLeftover", () => {
const base = { portions: 12, portionsRequested: 10, discardedPortions: 0, leftoverUses: [] };
it("is the prepared-minus-requested pool when nothing has been discarded or served", () => {
expect(computeAvailableLeftover(base)).to.equal(2);
});
it("subtracts portions already served to other slots", () => {
expect(
computeAvailableLeftover({ ...base, leftoverUses: [{ portions: 1 }, { portions: 1 }] }),
).to.equal(0);
});
it("subtracts explicitly discarded portions", () => {
expect(computeAvailableLeftover({ ...base, discardedPortions: 2 })).to.equal(0);
});
it("never goes negative", () => {
expect(
computeAvailableLeftover({
...base,
discardedPortions: 5,
leftoverUses: [{ portions: 3 }],
}),
).to.equal(0);
});
it("is 0 for an item whose request landed exactly on a batch (no pool at all)", () => {
expect(
computeAvailableLeftover({
portions: 8,
portionsRequested: 8,
discardedPortions: 0,
leftoverUses: [],
}),
).to.equal(0);
});
});
});

View file

@ -114,6 +114,7 @@ describe("Planning", () => {
meal: "diner",
recipeId: recipe.id,
portions: 4,
portionsRequested: 3,
},
});
@ -122,7 +123,14 @@ describe("Planning", () => {
expect(res.status).to.equal(200);
expect(res.body.id).to.equal(planning.id);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0]).to.include({ weekDay: "lundi", meal: "diner" });
expect(res.body.items[0]).to.include({
weekDay: "lundi",
meal: "diner",
portions: 4,
portionsRequested: 3,
discardedPortions: 0,
});
expect(res.body.items[0].sourceItem).to.equal(null);
expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" });
});
@ -171,6 +179,7 @@ describe("Planning", () => {
meal: "dejeuner",
recipeId: recipe.id,
portions: 2,
portionsRequested: 2,
},
});
@ -183,4 +192,83 @@ describe("Planning", () => {
expect(thisWeekRes.body).to.equal(null);
});
});
describe("POST /planning/items", () => {
/** Signs up, creates a household, and returns an authenticated agent + a saved recipe with the given yield. */
async function setup(recipePortions: number) {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const recipe = await prisma.recipe.create({
data: { name: "Lasagnes", authorId: houseRes.body.adminId, portions: recipePortions },
});
return { agent, recipe };
}
it("rounds the prepared portions up to a whole batch of the recipe and reports the leftover", async () => {
const { agent, recipe } = await setup(4);
const res = await agent.post("/planning/items").send({
date: today(),
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 3,
});
expect(res.status).to.equal(201);
// 3 requested, 4-portion recipe -> one batch of 4 prepared, 1 leftover.
expect(res.body).to.include({ portions: 4, portionsRequested: 3, discardedPortions: 0 });
expect(res.body.sourceItem).to.equal(null);
const stored = await prisma.planningItem.findUnique({ where: { id: res.body.id } });
expect(stored).to.include({ portions: 4, portionsRequested: 3 });
});
it("cooks several batches when more portions are requested than one batch yields", async () => {
const { agent, recipe } = await setup(4);
const res = await agent.post("/planning/items").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
recipeId: recipe.id,
portionsRequested: 10,
});
expect(res.status).to.equal(201);
// 10 requested, 4-portion recipe -> three batches (12) prepared, 2 leftover.
expect(res.body).to.include({ portions: 12, portionsRequested: 10 });
});
it("leaves no leftover when the request lands exactly on a batch boundary", async () => {
const { agent, recipe } = await setup(4);
const res = await agent.post("/planning/items").send({
date: today(),
weekDay: "mercredi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 8,
});
expect(res.status).to.equal(201);
expect(res.body).to.include({ portions: 8, portionsRequested: 8 });
});
it("rejects a non-positive portionsRequested with 400 VALIDATION_ERROR", async () => {
const { agent, recipe } = await setup(4);
const res = await agent.post("/planning/items").send({
date: today(),
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 0,
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
});

View file

@ -1031,6 +1031,7 @@ describe("Recipes", () => {
meal: "diner",
recipeId: recipe.id,
portions: 4,
portionsRequested: 4,
},
});

View file

@ -150,6 +150,7 @@ describe("Shopping list", () => {
meal: "dejeuner",
recipeId: recipe.id,
portions: 4,
portionsRequested: 4,
},
{
planningId: planning.id,
@ -157,6 +158,7 @@ describe("Shopping list", () => {
meal: "diner",
recipeId: recipe.id,
portions: 4,
portionsRequested: 4,
},
],
});
@ -211,6 +213,7 @@ describe("Shopping list", () => {
meal: "dejeuner",
recipeId: recipeA.id,
portions: 4,
portionsRequested: 4,
},
{
planningId: planning.id,
@ -218,6 +221,7 @@ describe("Shopping list", () => {
meal: "diner",
recipeId: recipeB.id,
portions: 4,
portionsRequested: 4,
},
],
});
@ -272,6 +276,7 @@ describe("Shopping list", () => {
meal: "dejeuner",
recipeId: recipeA.id,
portions: 2,
portionsRequested: 2,
},
{
planningId: planning.id,
@ -279,6 +284,7 @@ describe("Shopping list", () => {
meal: "diner",
recipeId: recipeB.id,
portions: 2,
portionsRequested: 2,
},
],
});
@ -323,6 +329,7 @@ describe("Shopping list", () => {
meal: "dejeuner",
recipeId: recipe.id,
portions: 2,
portionsRequested: 2,
},
});

View file

@ -40,7 +40,7 @@ Feature: Adding a recipe to the planning
And I select unit "unité" for the first ingredient
And I fill in the last ingredient's quantity with "1" and unit "unité"
And I click the button "Importer"
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and requested portions 4
And the recipe picker dialog should be closed
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions
@ -54,6 +54,6 @@ Feature: Adding a recipe to the planning
And I click the source item "Ratatouille"
And I click the button "Confirmer"
Then the import request for "7777" should have included the name "Ratatouille"
And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and portions 4
And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and requested portions 4
And the recipe picker dialog should be closed
And the recipe "Ratatouille" should appear in the first planning slot with 4 portions

View file

@ -152,6 +152,9 @@ Given("adding recipe {int} to the planning will succeed", (recipeId: number) =>
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
portionsRequested: 4,
discardedPortions: 0,
sourceItem: null,
recipe: { id: recipeId, name: "Ratatouille" },
},
}).as("addPlanningItem");
@ -250,11 +253,11 @@ When("I choose an ingredient for the unresolved line {string}", (rawText: string
});
Then(
"the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}",
(recipeId: number, weekDay: string, meal: string, portions: number) => {
"the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and requested portions {int}",
(recipeId: number, weekDay: string, meal: string, portionsRequested: number) => {
cy.wait("@addPlanningItem")
.its("request.body")
.should("deep.include", { recipeId, weekDay, meal, portions });
.should("deep.include", { recipeId, weekDay, meal, portionsRequested });
},
);

View file

@ -1,4 +1,5 @@
import {
computeBatchedPortions,
type DietView,
ErrorCode,
type IngredientView,
@ -306,7 +307,9 @@ export function RecipePickerDialog({
weekDay: slot.weekDay,
meal: slot.meal,
recipeId: saved.id,
portions: payload.portions,
// A freshly imported recipe is added at exactly its own yield, so
// requested == prepared and there's no leftover to compute here.
portionsRequested: payload.portions,
});
onAdded(planningItem);
onClose();
@ -334,7 +337,7 @@ export function RecipePickerDialog({
weekDay: slot.weekDay,
meal: slot.meal,
recipeId: selectedRecipe.id,
portions: parsedPortions,
portionsRequested: parsedPortions,
});
onAdded(item);
onClose();
@ -374,13 +377,22 @@ export function RecipePickerDialog({
}
if (selectedRecipe) {
// A whole number of batches of this recipe is what actually gets
// cooked; the hint below only shows when that leaves a leftover.
const parsedRequested = Number(portions);
const batched =
Number.isInteger(parsedRequested) && parsedRequested > 0
? computeBatchedPortions(selectedRecipe.portions, parsedRequested)
: null;
return (
<Dialog
onClose={onClose}
title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })}
>
<div className="recipe-picker-confirm">
<label htmlFor="planning-picker-portions">{t("planning.picker.portionsLabel")}</label>
<label htmlFor="planning-picker-portions">
{t("planning.picker.portionsRequestedLabel")}
</label>
<input
id="planning-picker-portions"
type="number"
@ -389,6 +401,14 @@ export function RecipePickerDialog({
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
{batched && batched.leftover > 0 && (
<p className="recipe-picker-confirm__batched-hint">
{t("planning.picker.batchedHint", {
prepared: batched.portions,
leftover: batched.leftover,
})}
</p>
)}
{submitError && <p className="field-error">{submitError}</p>}
<div className="recipe-picker-confirm__actions">
<button type="button" onClick={() => setSelectedRecipe(null)} disabled={isSubmitting}>

View file

@ -147,6 +147,16 @@
}
}
// "→ N portions préparées, M en restes" — shown under the portions field
// only when the requested count doesn't land on a whole batch of the
// recipe, so the user sees the leftover a batch-cooking round-up creates
// before confirming (issue #34).
.recipe-picker-confirm__batched-hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.recipe-picker-confirm__actions {
display: flex;
justify-content: flex-end;

View file

@ -322,7 +322,10 @@ export function RecipeImportForm({
weekDay: planningSlot.weekDay,
meal: planningSlot.meal,
recipeId: saved.id,
portions: Number(portions),
// This form's `portions` field is the recipe's own yield; an
// import lands on the slot at exactly that, so requested ==
// prepared and no leftover is created.
portionsRequested: Number(portions),
});
onImported({ recipe: saved, planningItem });
return;

View file

@ -145,7 +145,8 @@
"loading": "Chargement des recettes…",
"empty": "Aucune recette ne correspond à ces filtres.",
"confirmTitle": "Ajouter {{recipe}}",
"portionsLabel": "Nombre de portions",
"portionsRequestedLabel": "Portions demandées",
"batchedHint": "→ {{prepared}} portions préparées, {{leftover}} en restes",
"backButton": "Retour",
"confirmButton": "Ajouter au planning",
"adding": "Ajout…",

View file

@ -6,6 +6,7 @@
export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js";
export * from "./logic/planning-portions.js";
export * from "./schemas/account.js";
export * from "./schemas/admin.js";
export * from "./schemas/auth.js";

View file

@ -0,0 +1,68 @@
/**
* Pure portion arithmetic for the planning grid — no I/O, so it runs
* identically in the API (deriving what to store / validating a leftover
* assignment) and the web app (the "→ prepared / leftover" hint under the
* portions field, and the leftovers basket). Colocated here in
* `@batch-cooking/shared` rather than in either app for exactly that
* "one formula, two callers" reason.
*/
/** What {@link computeBatchedPortions} returns: the portions actually prepared, and the leftover that creates. */
export interface BatchedPortions {
/** Portions actually cooked — always a whole multiple of the recipe's own yield. */
portions: number;
/** `portions - requestedPortions` — how many prepared portions nobody asked for yet. */
leftover: number;
}
/**
* Splits "portions this slot must actually feed" into "portions prepared"
* and the leftover that creates. A dish is only ever cooked in whole
* batches of its own recipe (never a fraction of a plat), so the prepared
* count is rounded **up** to the next multiple of the recipe's yield:
*
* ```
* portions = ceil(requestedPortions / recipePortions) * recipePortions
* leftover = portions - requestedPortions
* ```
*
* e.g. for a 4-portion recipe: 3 requested → 4 prepared, 1 leftover;
* 10 requested → 12 prepared, 2 leftover; 8 requested → 8 prepared,
* 0 leftover.
*
* Both inputs are expected to be positive integers (a recipe always yields
* at least 1, `addPlanningItemSchema` rejects a non-positive request) —
* `Math.max(1, …)` on the batch count is just belt and braces so a stray 0
* still produces one batch rather than `0` prepared.
*/
export function computeBatchedPortions(
recipePortions: number,
requestedPortions: number,
): BatchedPortions {
const batches = Math.max(1, Math.ceil(requestedPortions / recipePortions));
const portions = batches * recipePortions;
return { portions, leftover: portions - requestedPortions };
}
/**
* How many portions of one item's leftover pool are still available to
* assign to another slot: what it prepared beyond what it needed, minus
* whatever has since been thrown away (`discardedPortions`) or already
* served elsewhere (`leftoverUses`, the items that point back at this one).
* Never negative.
*
* Same function on both sides of the wire: the API revalidates every
* leftover assignment against it (from `leftoverUses` loaded by Prisma),
* the web basket derives each line's remaining count from it (using the
* flat `planning.items` array it already has in memory).
*/
export function computeAvailableLeftover(item: {
portions: number;
portionsRequested: number;
discardedPortions: number;
leftoverUses: { portions: number }[];
}): number {
const pool = item.portions - item.portionsRequested;
const served = item.leftoverUses.reduce((sum, use) => sum + use.portions, 0);
return Math.max(0, pool - item.discardedPortions - served);
}

View file

@ -32,8 +32,13 @@ export const addPlanningItemSchema = z.object({
weekDay: z.enum(WEEK_DAYS),
meal: z.enum(MEALS),
recipeId: z.number().int().positive(),
/** How many portions to prepare for this slot — a plain manual entry, see `PlanningItemView.portions`. */
portions: z.number().int().positive("Le nombre de portions doit être positif"),
/**
* How many portions this slot must actually feed. The server derives the
* batched `portions` to prepare from this plus the recipe's own yield
* (`computeBatchedPortions`, `@batch-cooking/shared`) — the client never
* sends `portions` directly. See `PlanningItemView.portionsRequested`.
*/
portionsRequested: z.number().int().positive("Le nombre de portions doit être positif"),
});
/** Inferred TS type for {@link addPlanningItemSchema}'s validated output. */
export type AddPlanningItemInput = z.infer<typeof addPlanningItemSchema>;

View file

@ -37,8 +37,27 @@ export interface PlanningItemView {
weekDay: string;
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
meal: string;
/** How many portions to prepare for this slot — entered by whoever assigns the recipe (`addPlanningItemSchema`'s `portions`). The picker UI pre-fills this from `Recipe.portions` (see {@link RecipeSummaryView.portions}) but it's still stored independently here, since a slot may scale the recipe up/down from its written yield. */
/**
* How many portions are actually **prepared** for this slot — always a
* whole multiple of the recipe's own yield (`Recipe.portions`), since a
* dish is only ever cooked in whole batches. Derived from
* {@link portionsRequested} + the recipe's yield via
* `computeBatchedPortions`, never entered directly. For a
* leftovers-serving item ({@link sourceItem} set) this equals
* {@link portionsRequested} — serving leftovers cooks nothing new.
*/
portions: number;
/**
* How many portions this slot is actually meant to feed — the single
* number entered when adding the recipe ("on est 3", "il en faut 10").
* For a fresh item this is `<= portions`; the gap is this item's
* leftover pool.
*/
portionsRequested: number;
/** Portions of this item's own leftover pool already explicitly thrown away (the basket's "jeter" button). Always 0 on a leftovers-serving item. */
discardedPortions: number;
/** Set only when this item serves another item's leftovers rather than a freshly cooked batch — points back at that source item. `null` for a normal item. */
sourceItem: { id: number } | null;
recipe: {
id: number;
name: string;

View file

@ -226,17 +226,26 @@ Router `/planning` (`planning.routes.ts`/`.service.ts`), `requireAuth`.
null`. `null` recouvre **deux** états normaux confondus : pas de foyer, ou
aucun `Planning` ne couvre cette date — jamais une erreur.
- `POST /planning/items` → `addPlanningItem`, 201. Body `addPlanningItemSchema`
= `{ date, weekDay, meal, recipeId, portions }` (`weekDay`/`meal` sont des
enums `WEEK_DAYS`/`MEALS` de `packages/shared`, réellement validés ici — pas
juste une convention documentée). `recipeId` doit exister et être visible par
l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404
= `{ date, weekDay, meal, recipeId, portionsRequested }` (`weekDay`/`meal` sont
des enums `WEEK_DAYS`/`MEALS` de `packages/shared`, réellement validés ici —
pas juste une convention documentée). `recipeId` doit exister et être visible
par l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404
`RECIPE_NOT_FOUND` sinon.
- `DELETE /planning/items/:id` → `removePlanningItem`, 204.
**`PlanningItem.portions`** est saisi **indépendamment** de `Recipe.portions`
(le rendement "tel qu'écrit" de la recette) — un créneau peut mettre à
l'échelle. Le picker web pré-remplit depuis `Recipe.portions` mais envoie
toujours sa propre valeur.
**Portions demandées vs préparées (issue #34)** — le client n'envoie que
`portionsRequested` (« combien ce créneau doit nourrir »). `addPlanningItem`
en dérive `portions` (ce qui est **réellement préparé**) via
`computeBatchedPortions(recipe.portions, portionsRequested)`
(`@batch-cooking/shared`, pur) : on arrondit au multiple supérieur du
rendement de la recette (`ceil(demandées / recette.portions) × recette.portions`)
— on cuisine des lots entiers, jamais une fraction de plat. L'écart
`portions − portionsRequested` est le **pool de restes** de cet item.
`getPlanningForDate` renvoie les deux valeurs plus `discardedPortions` et
`sourceItem` (dérivé de `sourcePlanningItemId`) dans chaque
`PlanningItemView`, sans `include` Prisma supplémentaire. Les routes
d'attribution/jet de restes et l'exclusion des items « restes » de la liste
de courses / session de cuisine arrivent dans une itération ultérieure.
**Création de la semaine** — `findOrCreatePlanningForWeek` est le seul
endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un

View file

@ -103,6 +103,9 @@ erDiagram
string meal
int recipeId FK
int portions
int portionsRequested
int sourcePlanningItemId FK "nullable, auto-relation"
int discardedPortions
}
SOURCE {
int id PK
@ -264,7 +267,10 @@ pas en avance.
| `weekDay` | Jour de la semaine |
| `meal` | Repas concerné |
| `recipeId` | FK → `recipe` (pas de cascade — voir `RECIPE_IN_USE` dans [error-handling.md](./error-handling.md)) |
| `portions` | Nombre de portions **pour ce créneau précis** — indépendant de `Recipe.portions` (le rendement "tel qu'écrit" de la recette) : un créneau peut mettre l'échelle à la hausse/baisse. Le picker web pré-remplit depuis `Recipe.portions` mais stocke une valeur propre |
| `portions` | Portions réellement **préparées** pour ce créneau — toujours un multiple entier de `Recipe.portions` (on cuisine des lots entiers). Dérivé de `portionsRequested` + le rendement de la recette via `computeBatchedPortions` (`@batch-cooking/shared`) dans `addPlanningItem`, jamais saisi tel quel |
| `portionsRequested` | Portions que ce créneau doit réellement nourrir — le seul nombre saisi à l'ajout. `≤ portions` pour un item frais ; l'écart est le **pool de restes** de cet item |
| `sourcePlanningItemId` | Auto-FK, non nul uniquement sur un item « restes » : l'item d'origine dont il consomme le pool. `onDelete: Cascade` |
| `discardedPortions` | Portions du pool de cet item explicitement jetées (bouton « jeter » du panier). `0` par défaut, toujours `0` sur un item « restes » |
---

View file

@ -410,8 +410,12 @@ ligne.
dialogue épinglé ("Confirmer", actif dès qu'une prévisualisation existe)
est ce qui agit réellement.
2. **Confirmer les portions** (une vraie recette est prévisualisée) — petit
formulaire "combien de portions ?" (pré-rempli depuis
`RecipeSummaryView.portions`), puis `POST /planning/items`.
formulaire "portions demandées" (pré-rempli depuis
`RecipeSummaryView.portions`), avec un indice dérivé en direct via
`computeBatchedPortions` (« → N préparées, M en restes ») affiché quand
l'arrondi au lot crée un reste (issue #34) ; le corps envoyé à
`POST /planning/items` est `portionsRequested` (le serveur calcule
`portions`).
3. **Revue intégrée** (un item de source *pas encore importé* est prévisualisé
et nécessite une intervention humaine) — rend `RecipeImportForm`
directement à l'intérieur du même `Dialog` (`planningSlot` transmis pour