3c-fix: partial consumption now deducts eaten + leftovers
- consumptionPortions = min(planned, actualPortionsEaten + leftoverEstimatePortions). Meal boxes are a subset of leftovers; raw inventory is only retained for portions that were never cooked. - Added validation leftoverEstimatePortions >= mealBoxPortions with localized 400 error (cooked.leftoverLessThanBox) in server i18n + all 12 locales. - Removed unused COOKING_SESSION_STATUSES / MEAL_BOX_STATUSES imports from apps/api/src/lib/cooking.ts. - Hardened test cleanup to delete mealBoxes/meals/recipeCooks/cookingSessions by household before dropping storageLocations. - New regression tests: meal-box double-counting, eaten+leftover split, and leftover < box rejection. - Updated FAS3-COOKING-SESSIONS-AUDIT.md with corrected physics semantics.
This commit is contained in:
@@ -9,11 +9,7 @@ import {
|
|||||||
} from "@app/inventory-engine";
|
} from "@app/inventory-engine";
|
||||||
import { scaleNutrition } from "@app/nutrition-engine";
|
import { scaleNutrition } from "@app/nutrition-engine";
|
||||||
import type { Unit } from "@app/shared-types";
|
import type { Unit } from "@app/shared-types";
|
||||||
import {
|
import { MEAL_TYPES } from "@app/shared-types";
|
||||||
COOKING_SESSION_STATUSES,
|
|
||||||
MEAL_BOX_STATUSES,
|
|
||||||
MEAL_TYPES,
|
|
||||||
} from "@app/shared-types";
|
|
||||||
import {
|
import {
|
||||||
cookingSessionStarted,
|
cookingSessionStarted,
|
||||||
cookingSessionCompleted,
|
cookingSessionCompleted,
|
||||||
@@ -76,6 +72,11 @@ export async function completeCookingSession(
|
|||||||
throw errors.badRequest(t("cooked.portionsSumExceedsPlanned", languageTag));
|
throw errors.badRequest(t("cooked.portionsSumExceedsPlanned", languageTag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (leftoverEstimatePortions < mealBoxPortions) {
|
||||||
|
const languageTag = await userLanguageTag(app.db, userId);
|
||||||
|
throw errors.badRequest(t("cooked.leftoverLessThanBox", languageTag));
|
||||||
|
}
|
||||||
|
|
||||||
if (options.emitStartedEvent && session.status === "started") {
|
if (options.emitStartedEvent && session.status === "started") {
|
||||||
await trackProductAnalytics(
|
await trackProductAnalytics(
|
||||||
app.db,
|
app.db,
|
||||||
@@ -204,10 +205,15 @@ export async function completeCookingSessionCore(
|
|||||||
// 1. FEFO-avdrag
|
// 1. FEFO-avdrag
|
||||||
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
|
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
|
||||||
if (input.deductInventory !== false) {
|
if (input.deductInventory !== false) {
|
||||||
// Partiell förbrukning (Fas 3 §6.3): om actualPortionsEaten anges drar vi
|
// Partiell förbrukning (Fas 3 §6.3): råvaror dras för de tillagade
|
||||||
// endast råvaror för de faktiskt ätna portionerna. Rester/överskott stannar
|
// portionerna = ätna + rester (inklusive matlådor). Endast portioner som
|
||||||
// kvar som råvara i lagret tills de förbrukas på annat sätt.
|
// aldrig tillagats stannar kvar som råvara i lagret.
|
||||||
const consumptionPortions = input.actualPortionsEaten ?? portionsCooked;
|
const actualPortionsEaten = input.actualPortionsEaten ?? portionsCooked;
|
||||||
|
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? 0;
|
||||||
|
const consumptionPortions = Math.min(
|
||||||
|
portionsCooked,
|
||||||
|
actualPortionsEaten + leftoverEstimatePortions,
|
||||||
|
);
|
||||||
const factor = consumptionPortions / recipe.portions;
|
const factor = consumptionPortions / recipe.portions;
|
||||||
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
|
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ const COOKED_UNDO_WINDOW_EXPIRED: Record<string, string> = {
|
|||||||
sv: "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut.",
|
sv: "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COOKED_LEFTOVER_LESS_THAN_BOX: Record<string, string> = {
|
||||||
|
da: "Resterne må være mindst lige så mange som antallet af madkasseportioner.",
|
||||||
|
de: "Die Reste müssen mindestens so viele sein wie die Anzahl der Lunchbox-Portionen.",
|
||||||
|
en: "Leftovers must be at least as many as the number of meal-box portions.",
|
||||||
|
es: "Las sobras deben ser al menos tantas como el número de porciones de tupper.",
|
||||||
|
fi: "Tähteitä on oltava vähintään yhtä paljon kuin eväsrasioiden annosten määrä.",
|
||||||
|
fr: "Les restes doivent être au moins aussi nombreux que le nombre de portions de lunch-box.",
|
||||||
|
it: "Gli avanzi devono essere almeno tanti quanto il numero di porzioni dei contenitori per il pranzo.",
|
||||||
|
nb: "Restene må være minst like mange som antallet matboksporsjoner.",
|
||||||
|
nl: "De restjes moeten minstens even veel zijn als het aantal lunchboxporties.",
|
||||||
|
pl: "Pozostałości muszą być co najmniej tak liczne jak liczba porcji w lunchboxie.",
|
||||||
|
pt: "As sobras têm de ser pelo menos tantas quanto o número de porções da marmita.",
|
||||||
|
sv: "Rester måste vara minst lika många som antalet matlådeportioner.",
|
||||||
|
};
|
||||||
|
|
||||||
function resolve(catalog: Record<string, string>, languageTag: string): string {
|
function resolve(catalog: Record<string, string>, languageTag: string): string {
|
||||||
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
|
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
|
||||||
return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!;
|
return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!;
|
||||||
@@ -63,6 +78,10 @@ export function t(key: string, languageTag: string): string {
|
|||||||
return resolve(COOKED_UNDO_WINDOW_EXPIRED, languageTag);
|
return resolve(COOKED_UNDO_WINDOW_EXPIRED, languageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "cooked.leftoverLessThanBox") {
|
||||||
|
return resolve(COOKED_LEFTOVER_LESS_THAN_BOX, languageTag);
|
||||||
|
}
|
||||||
|
|
||||||
if (key !== "onboarding.householdDefaultName") {
|
if (key !== "onboarding.householdDefaultName") {
|
||||||
// No other server-side keys are supported yet; fall back to a safe default.
|
// No other server-side keys are supported yet; fall back to a safe default.
|
||||||
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;
|
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ describe("cooking sessions", () => {
|
|||||||
.where(eq(schema.householdMembers.userId, u.id));
|
.where(eq(schema.householdMembers.userId, u.id));
|
||||||
for (const m of memberships) {
|
for (const m of memberships) {
|
||||||
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
|
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||||
|
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, m.householdId));
|
||||||
|
await testDb.db.delete(schema.meals).where(eq(schema.meals.householdId, m.householdId));
|
||||||
|
await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.householdId, m.householdId));
|
||||||
|
await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.householdId, m.householdId));
|
||||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
|
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
|
||||||
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId));
|
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId));
|
||||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
|
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
|
||||||
@@ -808,7 +812,7 @@ describe("cooking sessions", () => {
|
|||||||
payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
|
payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4 portioner → 400 g, 2 ätna → 200 g, alltså 200 g kvar.
|
// 4 portioner → 400 g, 2 ätna + 0 rester = 2 tillagade → 200 g borta, 200 g kvar.
|
||||||
const afterComplete = await testDb.db
|
const afterComplete = await testDb.db
|
||||||
.select({ quantity: schema.inventoryItems.quantity })
|
.select({ quantity: schema.inventoryItems.quantity })
|
||||||
.from(schema.inventoryItems)
|
.from(schema.inventoryItems)
|
||||||
@@ -839,6 +843,193 @@ describe("cooking sessions", () => {
|
|||||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leftover estimate must be at least meal box portions", async () => {
|
||||||
|
const start = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook/start`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { startNow: true, portions: 4 },
|
||||||
|
});
|
||||||
|
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||||
|
|
||||||
|
const complete = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { mealBoxPortions: 2, actualPortionsEaten: 1, leftoverEstimatePortions: 1 },
|
||||||
|
});
|
||||||
|
expect(complete.statusCode).toBe(400);
|
||||||
|
expect(complete.body).toContain("BAD_REQUEST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meal boxes are part of cooked food: 2 eaten + 2 boxed = 100% inventory deduction", async () => {
|
||||||
|
const [recipe] = await testDb.db
|
||||||
|
.insert(schema.recipes)
|
||||||
|
.values({
|
||||||
|
slug: `boxreg-${Date.now()}`,
|
||||||
|
titleSv: "Box regression test",
|
||||||
|
descriptionSv: "",
|
||||||
|
cuisine: "international",
|
||||||
|
mealTypes: ["dinner"],
|
||||||
|
tags: [],
|
||||||
|
methods: [],
|
||||||
|
equipment: [],
|
||||||
|
difficulty: "easy",
|
||||||
|
prepTimeMinutes: 5,
|
||||||
|
cookTimeMinutes: 10,
|
||||||
|
totalTimeMinutes: 15,
|
||||||
|
portions: 4,
|
||||||
|
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||||
|
allergens: [],
|
||||||
|
spiceLevel: 0,
|
||||||
|
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||||
|
status: "published",
|
||||||
|
verificationStatus: "unverified",
|
||||||
|
sourceType: "own_editorial",
|
||||||
|
creatorDisplayName: "Test",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const testRecipeId = recipe!.id;
|
||||||
|
await testDb.db.insert(schema.recipeIngredients).values({
|
||||||
|
recipeId: testRecipeId,
|
||||||
|
canonicalIngredientId: "chicken_breast",
|
||||||
|
displayNameSv: "Kyckling",
|
||||||
|
quantity: 400,
|
||||||
|
unit: "GRAM",
|
||||||
|
optional: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDb.db
|
||||||
|
.delete(schema.inventoryItems)
|
||||||
|
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast")));
|
||||||
|
const itemId = await createItemWithPurchase("chicken_breast", "Kyckling", 400, "GRAM");
|
||||||
|
|
||||||
|
const start = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${testRecipeId}/cook/start`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { startNow: true, portions: 4 },
|
||||||
|
});
|
||||||
|
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||||
|
|
||||||
|
const complete = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { mealBoxPortions: 2, actualPortionsEaten: 2, leftoverEstimatePortions: 2 },
|
||||||
|
});
|
||||||
|
expect(complete.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Alla 400 g ska vara borta (ätet + matlådor = tillagat).
|
||||||
|
const item = await testDb.db
|
||||||
|
.select({ quantity: schema.inventoryItems.quantity })
|
||||||
|
.from(schema.inventoryItems)
|
||||||
|
.where(eq(schema.inventoryItems.id, itemId))
|
||||||
|
.limit(1);
|
||||||
|
expect(item[0]!.quantity).toBeCloseTo(0, 1);
|
||||||
|
|
||||||
|
// Matlådan ska ha 2 portioner.
|
||||||
|
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||||
|
expect(boxes.length).toBe(1);
|
||||||
|
expect(boxes[0]!.portions).toBe(2);
|
||||||
|
|
||||||
|
// Invariant.
|
||||||
|
const txs = await testDb.db
|
||||||
|
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||||
|
.from(schema.inventoryTransactions)
|
||||||
|
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||||
|
const balance = computeBalance(txs);
|
||||||
|
expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);
|
||||||
|
|
||||||
|
await testDb.db
|
||||||
|
.delete(schema.inventoryItems)
|
||||||
|
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast")));
|
||||||
|
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||||
|
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("2 eaten + 1 leftover = 75% deduction; 1 portion never cooked stays raw", async () => {
|
||||||
|
const [recipe] = await testDb.db
|
||||||
|
.insert(schema.recipes)
|
||||||
|
.values({
|
||||||
|
slug: `leftover-${Date.now()}`,
|
||||||
|
titleSv: "Leftover test",
|
||||||
|
descriptionSv: "",
|
||||||
|
cuisine: "international",
|
||||||
|
mealTypes: ["dinner"],
|
||||||
|
tags: [],
|
||||||
|
methods: [],
|
||||||
|
equipment: [],
|
||||||
|
difficulty: "easy",
|
||||||
|
prepTimeMinutes: 5,
|
||||||
|
cookTimeMinutes: 10,
|
||||||
|
totalTimeMinutes: 15,
|
||||||
|
portions: 4,
|
||||||
|
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||||
|
allergens: [],
|
||||||
|
spiceLevel: 0,
|
||||||
|
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||||
|
status: "published",
|
||||||
|
verificationStatus: "unverified",
|
||||||
|
sourceType: "own_editorial",
|
||||||
|
creatorDisplayName: "Test",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const testRecipeId = recipe!.id;
|
||||||
|
await testDb.db.insert(schema.recipeIngredients).values({
|
||||||
|
recipeId: testRecipeId,
|
||||||
|
canonicalIngredientId: "carrot",
|
||||||
|
displayNameSv: "Morötter",
|
||||||
|
quantity: 400,
|
||||||
|
unit: "GRAM",
|
||||||
|
optional: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDb.db
|
||||||
|
.delete(schema.inventoryItems)
|
||||||
|
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot")));
|
||||||
|
const itemId = await createItemWithPurchase("carrot", "Morötter", 400, "GRAM");
|
||||||
|
|
||||||
|
const start = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${testRecipeId}/cook/start`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { startNow: true, portions: 4 },
|
||||||
|
});
|
||||||
|
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||||
|
|
||||||
|
const complete = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
|
||||||
|
});
|
||||||
|
expect(complete.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 3 av 4 tillagade → 300 g borta, 100 g kvar som aldrig tillagats.
|
||||||
|
const item = await testDb.db
|
||||||
|
.select({ quantity: schema.inventoryItems.quantity })
|
||||||
|
.from(schema.inventoryItems)
|
||||||
|
.where(eq(schema.inventoryItems.id, itemId))
|
||||||
|
.limit(1);
|
||||||
|
expect(item[0]!.quantity).toBeCloseTo(100, 1);
|
||||||
|
|
||||||
|
const txs = await testDb.db
|
||||||
|
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||||
|
.from(schema.inventoryTransactions)
|
||||||
|
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||||
|
const balance = computeBalance(txs);
|
||||||
|
expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);
|
||||||
|
|
||||||
|
await testDb.db
|
||||||
|
.delete(schema.inventoryItems)
|
||||||
|
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot")));
|
||||||
|
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||||
|
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||||
|
});
|
||||||
|
|
||||||
it("undo + new complete maintains inventory invariant", async () => {
|
it("undo + new complete maintains inventory invariant", async () => {
|
||||||
const [recipe] = await testDb.db
|
const [recipe] = await testDb.db
|
||||||
.insert(schema.recipes)
|
.insert(schema.recipes)
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Fortryd",
|
"scan.diff.undo": "Fortryd",
|
||||||
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.",
|
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner.",
|
"cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner.",
|
||||||
"cooked.undoWindowExpired": "Det er ikke længere muligt at fortryde denne madlavningssession. Tidsfristen på 24 timer er udløbet."
|
"cooked.undoWindowExpired": "Det er ikke længere muligt at fortryde denne madlavningssession. Tidsfristen på 24 timer er udløbet.",
|
||||||
|
"cooked.leftoverLessThanBox": "Resterne må være mindst lige så mange som antallet af madkasseportioner."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Rückgängig",
|
"scan.diff.undo": "Rückgängig",
|
||||||
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden.",
|
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten.",
|
"cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten.",
|
||||||
"cooked.undoWindowExpired": "Dieser Kochvorgang kann nicht mehr rückgängig gemacht werden. Das 24-Stunden-Fenster ist abgelaufen."
|
"cooked.undoWindowExpired": "Dieser Kochvorgang kann nicht mehr rückgängig gemacht werden. Das 24-Stunden-Fenster ist abgelaufen.",
|
||||||
|
"cooked.leftoverLessThanBox": "Die Reste müssen mindestens so viele sein wie die Anzahl der Lunchbox-Portionen."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Undo",
|
"scan.diff.undo": "Undo",
|
||||||
"scan.diff.undoHint": "Each change can be undone from the item detail view.",
|
"scan.diff.undoHint": "Each change can be undone from the item detail view.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions.",
|
"cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions.",
|
||||||
"cooked.undoWindowExpired": "This cooking session can no longer be undone. The 24-hour window has expired."
|
"cooked.undoWindowExpired": "This cooking session can no longer be undone. The 24-hour window has expired.",
|
||||||
|
"cooked.leftoverLessThanBox": "Leftovers must be at least as many as the number of meal-box portions."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Deshacer",
|
"scan.diff.undo": "Deshacer",
|
||||||
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto.",
|
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones.",
|
"cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones.",
|
||||||
"cooked.undoWindowExpired": "Ya no se puede deshacer esta sesión de cocina. Ha expirado la ventana de 24 horas."
|
"cooked.undoWindowExpired": "Ya no se puede deshacer esta sesión de cocina. Ha expirado la ventana de 24 horas.",
|
||||||
|
"cooked.leftoverLessThanBox": "Las sobras deben ser al menos tantas como el número de porciones de tupper."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Kumoa",
|
"scan.diff.undo": "Kumoa",
|
||||||
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä.",
|
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää.",
|
"cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää.",
|
||||||
"cooked.undoWindowExpired": "Tätä ruoanlaittokertaa ei voi enää kumota. 24 tunnin ikkuna on umpeutunut."
|
"cooked.undoWindowExpired": "Tätä ruoanlaittokertaa ei voi enää kumota. 24 tunnin ikkuna on umpeutunut.",
|
||||||
|
"cooked.leftoverLessThanBox": "Tähteitä on oltava vähintään yhtä paljon kuin eväsrasioiden annosten määrä."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Annuler",
|
"scan.diff.undo": "Annuler",
|
||||||
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article.",
|
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions.",
|
"cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions.",
|
||||||
"cooked.undoWindowExpired": "Cette session de cuisine ne peut plus être annulée. La fenêtre de 24 heures a expiré."
|
"cooked.undoWindowExpired": "Cette session de cuisine ne peut plus être annulée. La fenêtre de 24 heures a expiré.",
|
||||||
|
"cooked.leftoverLessThanBox": "Les restes doivent être au moins aussi nombreux que le nombre de portions de lunch-box."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Annulla",
|
"scan.diff.undo": "Annulla",
|
||||||
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo.",
|
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni.",
|
"cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni.",
|
||||||
"cooked.undoWindowExpired": "Non è più possibile annullare questa sessione di cucina. La finestra di 24 ore è scaduta."
|
"cooked.undoWindowExpired": "Non è più possibile annullare questa sessione di cucina. La finestra di 24 ore è scaduta.",
|
||||||
|
"cooked.leftoverLessThanBox": "Gli avanzi devono essere almeno tanti quanto il numero di porzioni dei contenitori per il pranzo."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Angre",
|
"scan.diff.undo": "Angre",
|
||||||
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.",
|
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner.",
|
"cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner.",
|
||||||
"cooked.undoWindowExpired": "Denne matlagingsøkten kan ikke lenger angres. Vinduet på 24 timer har utløpt."
|
"cooked.undoWindowExpired": "Denne matlagingsøkten kan ikke lenger angres. Vinduet på 24 timer har utløpt.",
|
||||||
|
"cooked.leftoverLessThanBox": "Restene må være minst like mange som antallet matboksporsjoner."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Ongedaan maken",
|
"scan.diff.undo": "Ongedaan maken",
|
||||||
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave.",
|
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden.",
|
"cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden.",
|
||||||
"cooked.undoWindowExpired": "Deze kooksessie kan niet meer ongedaan worden gemaakt. Het venster van 24 uur is verstreken."
|
"cooked.undoWindowExpired": "Deze kooksessie kan niet meer ongedaan worden gemaakt. Het venster van 24 uur is verstreken.",
|
||||||
|
"cooked.leftoverLessThanBox": "De restjes moeten minstens even veel zijn als het aantal lunchboxporties."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,5 +398,6 @@
|
|||||||
"scan.diff.undo": "Cofnij",
|
"scan.diff.undo": "Cofnij",
|
||||||
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu.",
|
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji.",
|
"cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji.",
|
||||||
"cooked.undoWindowExpired": "Tej sesji gotowania nie można już cofnąć. Okno 24-godzinne wygasło."
|
"cooked.undoWindowExpired": "Tej sesji gotowania nie można już cofnąć. Okno 24-godzinne wygasło.",
|
||||||
|
"cooked.leftoverLessThanBox": "Pozostałości muszą być co najmniej tak liczne jak liczba porcji w lunchboxie."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Desfazer",
|
"scan.diff.undo": "Desfazer",
|
||||||
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item.",
|
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item.",
|
||||||
"cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções.",
|
"cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções.",
|
||||||
"cooked.undoWindowExpired": "Esta sessão de cozinha já não pode ser desfeita. A janela de 24 horas expirou."
|
"cooked.undoWindowExpired": "Esta sessão de cozinha já não pode ser desfeita. A janela de 24 horas expirou.",
|
||||||
|
"cooked.leftoverLessThanBox": "As sobras têm de ser pelo menos tantas quanto o número de porções da marmita."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,5 +384,6 @@
|
|||||||
"scan.diff.undo": "Ångra",
|
"scan.diff.undo": "Ångra",
|
||||||
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy.",
|
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy.",
|
||||||
"cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.",
|
"cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.",
|
||||||
"cooked.undoWindowExpired": "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut."
|
"cooked.undoWindowExpired": "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut.",
|
||||||
|
"cooked.leftoverLessThanBox": "Rester måste vara minst lika många som antalet matlådeportioner."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,10 +143,14 @@ Att lägga till `prepared_food_batches` skulle skapa:
|
|||||||
**Mål:** Om användaren säger att de bara åt 3 av 4 portioner, ska FEFO-avdraget justeras så att motsvarande råvaror återstår. En completed session kan ångras inom 24 h via append-only reverseringstransaktioner.
|
**Mål:** Om användaren säger att de bara åt 3 av 4 portioner, ska FEFO-avdraget justeras så att motsvarande råvaror återstår. En completed session kan ångras inom 24 h via append-only reverseringstransaktioner.
|
||||||
|
|
||||||
**Ändringar:**
|
**Ändringar:**
|
||||||
1. Vid complete: använd `actualPortionsEaten` för att räkna om råvaror.
|
1. Vid complete: råvaror dras för de **tillagade** portionerna = `actualPortionsEaten + leftoverEstimatePortions`, begränsat uppåt till `plannedPortions`.
|
||||||
- Exempel: 4 planerade portioner → 3 ätna = använd 75 % av varje ingrediens.
|
- Exempel: 4 planerade portioner → 3 ätna + 1 rest = 100 % avdrag.
|
||||||
- Om `actualPortionsEaten` är null, använd `portionsCooked` (dagens beteende).
|
- Exempel: 4 planerade portioner → 3 ätna + 0 rester = 75 % avdrag.
|
||||||
|
- Exempel: 4 planerade portioner → 2 ätna + 2 matlådor = 100 % avdrag; matlådorna är en delmängd av resterna.
|
||||||
|
- Endast `plannedPortions - actualPortionsEaten - leftoverEstimatePortions` stannar kvar som råvara (aldrig tillagat).
|
||||||
|
- Om svaren saknas (legacy/skip): defaults ger `actualPortionsEaten = plannedPortions - mealBoxPortions` och `leftoverEstimatePortions = mealBoxPortions`, vilket bevarar exakt pre-3c-beteende.
|
||||||
2. Lagra ursprungligt FEFO-avdrag i `cooking_sessions.plannedDeductions` (jsonb).
|
2. Lagra ursprungligt FEFO-avdrag i `cooking_sessions.plannedDeductions` (jsonb).
|
||||||
|
- Validera `leftoverEstimatePortions >= mealBoxPortions`; annars 400 med `cooked.leftoverLessThanBox`.
|
||||||
3. `POST /v1/cooking-sessions/:id/undo`:
|
3. `POST /v1/cooking-sessions/:id/undo`:
|
||||||
- Status måste vara `completed`.
|
- Status måste vara `completed`.
|
||||||
- `undo_until = completedAt + 24 h` (server-side, UTC). Efter fönstret: 409 med i18n-nyckeln `cooked.undoWindowExpired`.
|
- `undo_until = completedAt + 24 h` (server-side, UTC). Efter fönstret: 409 med i18n-nyckeln `cooked.undoWindowExpired`.
|
||||||
|
|||||||
Reference in New Issue
Block a user