Compare commits
2 commits
f01ac58fec
...
5e34a250df
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e34a250df | |||
| eba0ea70bd |
21 changed files with 429 additions and 33 deletions
|
|
@ -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;
|
||||
|
|
@ -218,12 +218,36 @@ model PlanningItem {
|
|||
weekDay String @map("week_day")
|
||||
meal String
|
||||
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])
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
73
apps/api/test/planning-portions.test.ts
Normal file
73
apps/api/test/planning-portions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1031,6 +1031,7 @@ describe("Recipes", () => {
|
|||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
68
packages/shared/src/logic/planning-portions.ts
Normal file
68
packages/shared/src/logic/planning-portions.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 » |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue