Merge pull request 'feat(planning): sépare portions demandées et portions préparées' (#48) from feat/leftovers-portions-foundations into main
This commit is contained in:
commit
5e34a250df
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")
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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",
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1031,6 +1031,7 @@ describe("Recipes", () => {
|
||||||
meal: "diner",
|
meal: "diner",
|
||||||
recipeId: recipe.id,
|
recipeId: recipe.id,
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
portionsRequested: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
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),
|
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>;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 » |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue