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
Showing only changes of commit eba0ea70bd - Show all commits

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

@ -218,12 +218,36 @@ model PlanningItem {
weekDay String @map("week_day") weekDay String @map("week_day")
meal String 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 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. /// See `Planning.createdAt` — same admin-metrics-only timestamp.
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id]) 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") @@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 { HttpError } from "@batch-cooking/error-tools";
import { import {
type AddPlanningItemInput, type AddPlanningItemInput,
computeBatchedPortions,
ErrorCode, ErrorCode,
type PlanningItemView, type PlanningItemView,
type PlanningView, type PlanningView,
@ -69,6 +70,9 @@ export async function getPlanningForDate(
weekDay: item.weekDay, weekDay: item.weekDay,
meal: item.meal, meal: item.meal,
portions: item.portions, portions: item.portions,
portionsRequested: item.portionsRequested,
discardedPortions: item.discardedPortions,
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
recipe: item.recipe, recipe: item.recipe,
})), })),
}; };
@ -144,6 +148,18 @@ export async function addPlanningItem(
} }
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId); 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 weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart); const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
@ -153,14 +169,19 @@ export async function addPlanningItem(
weekDay: input.weekDay, weekDay: input.weekDay,
meal: input.meal, meal: input.meal,
recipeId: input.recipeId, recipeId: input.recipeId,
portions: input.portions, portions,
portionsRequested: input.portionsRequested,
}, },
include: { recipe: { select: { id: true, name: true } } }, include: { recipe: { select: { id: true, name: true } } },
}); });
analytics.recordEvent("planning.item_added", { analytics.recordEvent("planning.item_added", {
actorId: viewerId, actorId: viewerId,
context: { recipeId: input.recipeId, portions: input.portions }, context: {
recipeId: input.recipeId,
portions,
portionsRequested: input.portionsRequested,
},
}); });
return { return {
@ -168,6 +189,9 @@ export async function addPlanningItem(
weekDay: item.weekDay, weekDay: item.weekDay,
meal: item.meal, meal: item.meal,
portions: item.portions, portions: item.portions,
portionsRequested: item.portionsRequested,
discardedPortions: item.discardedPortions,
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
recipe: item.recipe, recipe: item.recipe,
}; };
} catch (err) { } catch (err) {

View file

@ -177,6 +177,7 @@ describe("Cooking session", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: soupe.id, recipeId: soupe.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
{ {
planningId: planning.id, planningId: planning.id,
@ -184,6 +185,7 @@ describe("Cooking session", () => {
meal: "diner", meal: "diner",
recipeId: tarte.id, recipeId: tarte.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
], ],
}); });
@ -280,6 +282,7 @@ describe("Cooking session", () => {
meal: i === 0 ? "dejeuner" : "diner", meal: i === 0 ? "dejeuner" : "diner",
recipeId, recipeId,
portions: 4, 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", meal: "diner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 4, portions: 4,
portionsRequested: 3,
}, },
}); });
@ -122,7 +123,14 @@ describe("Planning", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.id).to.equal(planning.id); expect(res.body.id).to.equal(planning.id);
expect(res.body.items).to.have.length(1); 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" }); expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" });
}); });
@ -171,6 +179,7 @@ describe("Planning", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 2, portions: 2,
portionsRequested: 2,
}, },
}); });
@ -183,4 +192,83 @@ describe("Planning", () => {
expect(thisWeekRes.body).to.equal(null); 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", meal: "diner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
}); });

View file

@ -150,6 +150,7 @@ describe("Shopping list", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
{ {
planningId: planning.id, planningId: planning.id,
@ -157,6 +158,7 @@ describe("Shopping list", () => {
meal: "diner", meal: "diner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
], ],
}); });
@ -211,6 +213,7 @@ describe("Shopping list", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipeA.id, recipeId: recipeA.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
{ {
planningId: planning.id, planningId: planning.id,
@ -218,6 +221,7 @@ describe("Shopping list", () => {
meal: "diner", meal: "diner",
recipeId: recipeB.id, recipeId: recipeB.id,
portions: 4, portions: 4,
portionsRequested: 4,
}, },
], ],
}); });
@ -272,6 +276,7 @@ describe("Shopping list", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipeA.id, recipeId: recipeA.id,
portions: 2, portions: 2,
portionsRequested: 2,
}, },
{ {
planningId: planning.id, planningId: planning.id,
@ -279,6 +284,7 @@ describe("Shopping list", () => {
meal: "diner", meal: "diner",
recipeId: recipeB.id, recipeId: recipeB.id,
portions: 2, portions: 2,
portionsRequested: 2,
}, },
], ],
}); });
@ -323,6 +329,7 @@ describe("Shopping list", () => {
meal: "dejeuner", meal: "dejeuner",
recipeId: recipe.id, recipeId: recipe.id,
portions: 2, 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 select unit "unité" for the first ingredient
And I fill in the last ingredient's quantity with "1" and unit "unité" And I fill in the last ingredient's quantity with "1" and unit "unité"
And I click the button "Importer" 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 picker dialog should be closed
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions 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 source item "Ratatouille"
And I click the button "Confirmer" And I click the button "Confirmer"
Then the import request for "7777" should have included the name "Ratatouille" 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 picker dialog should be closed
And the recipe "Ratatouille" should appear in the first planning slot with 4 portions 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", weekDay: "lundi",
meal: "petit-dejeuner", meal: "petit-dejeuner",
portions: 4, portions: 4,
portionsRequested: 4,
discardedPortions: 0,
sourceItem: null,
recipe: { id: recipeId, name: "Ratatouille" }, recipe: { id: recipeId, name: "Ratatouille" },
}, },
}).as("addPlanningItem"); }).as("addPlanningItem");
@ -250,11 +253,11 @@ When("I choose an ingredient for the unresolved line {string}", (rawText: string
}); });
Then( Then(
"the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}", "the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and requested portions {int}",
(recipeId: number, weekDay: string, meal: string, portions: number) => { (recipeId: number, weekDay: string, meal: string, portionsRequested: number) => {
cy.wait("@addPlanningItem") cy.wait("@addPlanningItem")
.its("request.body") .its("request.body")
.should("deep.include", { recipeId, weekDay, meal, portions }); .should("deep.include", { recipeId, weekDay, meal, portionsRequested });
}, },
); );

View file

@ -1,4 +1,5 @@
import { import {
computeBatchedPortions,
type DietView, type DietView,
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
@ -306,7 +307,9 @@ export function RecipePickerDialog({
weekDay: slot.weekDay, weekDay: slot.weekDay,
meal: slot.meal, meal: slot.meal,
recipeId: saved.id, 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); onAdded(planningItem);
onClose(); onClose();
@ -334,7 +337,7 @@ export function RecipePickerDialog({
weekDay: slot.weekDay, weekDay: slot.weekDay,
meal: slot.meal, meal: slot.meal,
recipeId: selectedRecipe.id, recipeId: selectedRecipe.id,
portions: parsedPortions, portionsRequested: parsedPortions,
}); });
onAdded(item); onAdded(item);
onClose(); onClose();
@ -374,13 +377,22 @@ export function RecipePickerDialog({
} }
if (selectedRecipe) { 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 ( return (
<Dialog <Dialog
onClose={onClose} onClose={onClose}
title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })} title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })}
> >
<div className="recipe-picker-confirm"> <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 <input
id="planning-picker-portions" id="planning-picker-portions"
type="number" type="number"
@ -389,6 +401,14 @@ export function RecipePickerDialog({
value={portions} value={portions}
onChange={(e) => setPortions(e.target.value)} 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>} {submitError && <p className="field-error">{submitError}</p>}
<div className="recipe-picker-confirm__actions"> <div className="recipe-picker-confirm__actions">
<button type="button" onClick={() => setSelectedRecipe(null)} disabled={isSubmitting}> <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 { .recipe-picker-confirm__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View file

@ -322,7 +322,10 @@ export function RecipeImportForm({
weekDay: planningSlot.weekDay, weekDay: planningSlot.weekDay,
meal: planningSlot.meal, meal: planningSlot.meal,
recipeId: saved.id, 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 }); onImported({ recipe: saved, planningItem });
return; return;

View file

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

View file

@ -6,6 +6,7 @@
export * from "./data/catalog-labels-en.js"; export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js"; export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./logic/planning-portions.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/admin.js"; export * from "./schemas/admin.js";
export * from "./schemas/auth.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), weekDay: z.enum(WEEK_DAYS),
meal: z.enum(MEALS), meal: z.enum(MEALS),
recipeId: z.number().int().positive(), 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. */ /** Inferred TS type for {@link addPlanningItemSchema}'s validated output. */
export type AddPlanningItemInput = z.infer<typeof addPlanningItemSchema>; export type AddPlanningItemInput = z.infer<typeof addPlanningItemSchema>;

View file

@ -37,8 +37,27 @@ export interface PlanningItemView {
weekDay: string; weekDay: string;
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */ /** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
meal: string; 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; 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: { recipe: {
id: number; id: number;
name: string; 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 null`. `null` recouvre **deux** états normaux confondus : pas de foyer, ou
aucun `Planning` ne couvre cette date — jamais une erreur. aucun `Planning` ne couvre cette date — jamais une erreur.
- `POST /planning/items` → `addPlanningItem`, 201. Body `addPlanningItemSchema` - `POST /planning/items` → `addPlanningItem`, 201. Body `addPlanningItemSchema`
= `{ date, weekDay, meal, recipeId, portions }` (`weekDay`/`meal` sont des = `{ date, weekDay, meal, recipeId, portionsRequested }` (`weekDay`/`meal` sont
enums `WEEK_DAYS`/`MEALS` de `packages/shared`, réellement validés ici — pas des enums `WEEK_DAYS`/`MEALS` de `packages/shared`, réellement validés ici —
juste une convention documentée). `recipeId` doit exister et être visible par pas juste une convention documentée). `recipeId` doit exister et être visible
l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404 par l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404
`RECIPE_NOT_FOUND` sinon. `RECIPE_NOT_FOUND` sinon.
- `DELETE /planning/items/:id` → `removePlanningItem`, 204. - `DELETE /planning/items/:id` → `removePlanningItem`, 204.
**`PlanningItem.portions`** est saisi **indépendamment** de `Recipe.portions` **Portions demandées vs préparées (issue #34)** — le client n'envoie que
(le rendement "tel qu'écrit" de la recette) — un créneau peut mettre à `portionsRequested` (« combien ce créneau doit nourrir »). `addPlanningItem`
l'échelle. Le picker web pré-remplit depuis `Recipe.portions` mais envoie en dérive `portions` (ce qui est **réellement préparé**) via
toujours sa propre valeur. `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 **Création de la semaine** — `findOrCreatePlanningForWeek` est le seul
endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un

View file

@ -103,6 +103,9 @@ erDiagram
string meal string meal
int recipeId FK int recipeId FK
int portions int portions
int portionsRequested
int sourcePlanningItemId FK "nullable, auto-relation"
int discardedPortions
} }
SOURCE { SOURCE {
int id PK int id PK
@ -264,7 +267,10 @@ pas en avance.
| `weekDay` | Jour de la semaine | | `weekDay` | Jour de la semaine |
| `meal` | Repas concerné | | `meal` | Repas concerné |
| `recipeId` | FK → `recipe` (pas de cascade — voir `RECIPE_IN_USE` dans [error-handling.md](./error-handling.md)) | | `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) dialogue épinglé ("Confirmer", actif dès qu'une prévisualisation existe)
est ce qui agit réellement. est ce qui agit réellement.
2. **Confirmer les portions** (une vraie recette est prévisualisée) — petit 2. **Confirmer les portions** (une vraie recette est prévisualisée) — petit
formulaire "combien de portions ?" (pré-rempli depuis formulaire "portions demandées" (pré-rempli depuis
`RecipeSummaryView.portions`), puis `POST /planning/items`. `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é 3. **Revue intégrée** (un item de source *pas encore importé* est prévisualisé
et nécessite une intervention humaine) — rend `RecipeImportForm` et nécessite une intervention humaine) — rend `RecipeImportForm`
directement à l'intérieur du même `Dialog` (`planningSlot` transmis pour directement à l'intérieur du même `Dialog` (`planningSlot` transmis pour