From 8637eaa6c312f10c50fe5c366a456151977772c4 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Fri, 7 Aug 2026 15:35:25 +0700 Subject: [PATCH] 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. --- apps/api/src/lib/cooking.ts | 24 +-- apps/api/src/lib/i18n.ts | 19 +++ apps/api/test/cooking-sessions.test.ts | 193 ++++++++++++++++++++++++- apps/mobile/src/locales/da/common.json | 3 +- apps/mobile/src/locales/de/common.json | 3 +- apps/mobile/src/locales/en/common.json | 3 +- apps/mobile/src/locales/es/common.json | 3 +- apps/mobile/src/locales/fi/common.json | 3 +- apps/mobile/src/locales/fr/common.json | 3 +- apps/mobile/src/locales/it/common.json | 3 +- apps/mobile/src/locales/nb/common.json | 3 +- apps/mobile/src/locales/nl/common.json | 3 +- apps/mobile/src/locales/pl/common.json | 3 +- apps/mobile/src/locales/pt/common.json | 3 +- apps/mobile/src/locales/sv/common.json | 3 +- docs/FAS3-COOKING-SESSIONS-AUDIT.md | 10 +- 16 files changed, 257 insertions(+), 25 deletions(-) diff --git a/apps/api/src/lib/cooking.ts b/apps/api/src/lib/cooking.ts index ab4fc53..7a5dbd5 100644 --- a/apps/api/src/lib/cooking.ts +++ b/apps/api/src/lib/cooking.ts @@ -9,11 +9,7 @@ import { } from "@app/inventory-engine"; import { scaleNutrition } from "@app/nutrition-engine"; import type { Unit } from "@app/shared-types"; -import { - COOKING_SESSION_STATUSES, - MEAL_BOX_STATUSES, - MEAL_TYPES, -} from "@app/shared-types"; +import { MEAL_TYPES } from "@app/shared-types"; import { cookingSessionStarted, cookingSessionCompleted, @@ -76,6 +72,11 @@ export async function completeCookingSession( 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") { await trackProductAnalytics( app.db, @@ -204,10 +205,15 @@ export async function completeCookingSessionCore( // 1. FEFO-avdrag const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = []; if (input.deductInventory !== false) { - // Partiell förbrukning (Fas 3 §6.3): om actualPortionsEaten anges drar vi - // endast råvaror för de faktiskt ätna portionerna. Rester/överskott stannar - // kvar som råvara i lagret tills de förbrukas på annat sätt. - const consumptionPortions = input.actualPortionsEaten ?? portionsCooked; + // Partiell förbrukning (Fas 3 §6.3): råvaror dras för de tillagade + // portionerna = ätna + rester (inklusive matlådor). Endast portioner som + // aldrig tillagats stannar kvar som råvara i lagret. + const actualPortionsEaten = input.actualPortionsEaten ?? portionsCooked; + const leftoverEstimatePortions = input.leftoverEstimatePortions ?? 0; + const consumptionPortions = Math.min( + portionsCooked, + actualPortionsEaten + leftoverEstimatePortions, + ); const factor = consumptionPortions / recipe.portions; const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []); diff --git a/apps/api/src/lib/i18n.ts b/apps/api/src/lib/i18n.ts index 100bd12..c900826 100644 --- a/apps/api/src/lib/i18n.ts +++ b/apps/api/src/lib/i18n.ts @@ -49,6 +49,21 @@ const COOKED_UNDO_WINDOW_EXPIRED: Record = { sv: "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut.", }; +const COOKED_LEFTOVER_LESS_THAN_BOX: Record = { + 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, languageTag: string): string { const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase(); 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); } + if (key === "cooked.leftoverLessThanBox") { + return resolve(COOKED_LEFTOVER_LESS_THAN_BOX, languageTag); + } + if (key !== "onboarding.householdDefaultName") { // No other server-side keys are supported yet; fall back to a safe default. return HOUSEHOLD_DEFAULT_NAMES["sv"]!; diff --git a/apps/api/test/cooking-sessions.test.ts b/apps/api/test/cooking-sessions.test.ts index fb08509..59e0de5 100644 --- a/apps/api/test/cooking-sessions.test.ts +++ b/apps/api/test/cooking-sessions.test.ts @@ -73,6 +73,10 @@ describe("cooking sessions", () => { .where(eq(schema.householdMembers.userId, u.id)); for (const m of memberships) { 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.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.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 }, }); - // 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 .select({ quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) @@ -839,6 +843,193 @@ describe("cooking sessions", () => { 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 () => { const [recipe] = await testDb.db .insert(schema.recipes) diff --git a/apps/mobile/src/locales/da/common.json b/apps/mobile/src/locales/da/common.json index 8b2fb66..c0ec182 100644 --- a/apps/mobile/src/locales/da/common.json +++ b/apps/mobile/src/locales/da/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Fortryd", "scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.", "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." } diff --git a/apps/mobile/src/locales/de/common.json b/apps/mobile/src/locales/de/common.json index 0e4eef3..36ed557 100644 --- a/apps/mobile/src/locales/de/common.json +++ b/apps/mobile/src/locales/de/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Rückgängig", "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.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." } diff --git a/apps/mobile/src/locales/en/common.json b/apps/mobile/src/locales/en/common.json index 59e73ed..eee7f6a 100644 --- a/apps/mobile/src/locales/en/common.json +++ b/apps/mobile/src/locales/en/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Undo", "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.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." } diff --git a/apps/mobile/src/locales/es/common.json b/apps/mobile/src/locales/es/common.json index e383886..0b6a905 100644 --- a/apps/mobile/src/locales/es/common.json +++ b/apps/mobile/src/locales/es/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Deshacer", "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.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." } diff --git a/apps/mobile/src/locales/fi/common.json b/apps/mobile/src/locales/fi/common.json index 90898ff..38cb903 100644 --- a/apps/mobile/src/locales/fi/common.json +++ b/apps/mobile/src/locales/fi/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Kumoa", "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.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ä." } diff --git a/apps/mobile/src/locales/fr/common.json b/apps/mobile/src/locales/fr/common.json index 054f9bb..a6a64ee 100644 --- a/apps/mobile/src/locales/fr/common.json +++ b/apps/mobile/src/locales/fr/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Annuler", "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.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." } diff --git a/apps/mobile/src/locales/it/common.json b/apps/mobile/src/locales/it/common.json index 718ea4c..ecc2c5b 100644 --- a/apps/mobile/src/locales/it/common.json +++ b/apps/mobile/src/locales/it/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Annulla", "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.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." } diff --git a/apps/mobile/src/locales/nb/common.json b/apps/mobile/src/locales/nb/common.json index ad50d0c..5c426b1 100644 --- a/apps/mobile/src/locales/nb/common.json +++ b/apps/mobile/src/locales/nb/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Angre", "scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.", "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." } diff --git a/apps/mobile/src/locales/nl/common.json b/apps/mobile/src/locales/nl/common.json index 663adf0..31ffdef 100644 --- a/apps/mobile/src/locales/nl/common.json +++ b/apps/mobile/src/locales/nl/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Ongedaan maken", "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.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." } diff --git a/apps/mobile/src/locales/pl/common.json b/apps/mobile/src/locales/pl/common.json index c9ec0d5..f72e5e8 100644 --- a/apps/mobile/src/locales/pl/common.json +++ b/apps/mobile/src/locales/pl/common.json @@ -398,5 +398,6 @@ "scan.diff.undo": "Cofnij", "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.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." } diff --git a/apps/mobile/src/locales/pt/common.json b/apps/mobile/src/locales/pt/common.json index fb6dbbf..91c62ff 100644 --- a/apps/mobile/src/locales/pt/common.json +++ b/apps/mobile/src/locales/pt/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Desfazer", "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.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." } diff --git a/apps/mobile/src/locales/sv/common.json b/apps/mobile/src/locales/sv/common.json index 9920f77..bf0de74 100644 --- a/apps/mobile/src/locales/sv/common.json +++ b/apps/mobile/src/locales/sv/common.json @@ -384,5 +384,6 @@ "scan.diff.undo": "Ångra", "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.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." } diff --git a/docs/FAS3-COOKING-SESSIONS-AUDIT.md b/docs/FAS3-COOKING-SESSIONS-AUDIT.md index 6a5b9be..2766470 100644 --- a/docs/FAS3-COOKING-SESSIONS-AUDIT.md +++ b/docs/FAS3-COOKING-SESSIONS-AUDIT.md @@ -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. **Ändringar:** -1. Vid complete: använd `actualPortionsEaten` för att räkna om råvaror. - - Exempel: 4 planerade portioner → 3 ätna = använd 75 % av varje ingrediens. - - Om `actualPortionsEaten` är null, använd `portionsCooked` (dagens beteende). +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 + 1 rest = 100 % avdrag. + - 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). + - Validera `leftoverEstimatePortions >= mealBoxPortions`; annars 400 med `cooked.leftoverLessThanBox`. 3. `POST /v1/cooking-sessions/:id/undo`: - Status måste vara `completed`. - `undo_until = completedAt + 24 h` (server-side, UTC). Efter fönstret: 409 med i18n-nyckeln `cooked.undoWindowExpired`.