From d52a762e8a5fbce689b836353030da0527fc7e49 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 26 Sep 2026 05:55:36 +0200 Subject: [PATCH] fix: Produkt-Formular ueberschreibt keinen zwischenzeitlich gebuchten Bestand Das Speichern schrieb den Formularwert immer fest in den Lagerbestand. Wurde zwischen Oeffnen und Speichern ein Glas verkauft, kam es dadurch wieder in den Bestand, ohne dass es je zurueckkam. - Das Formular schickt den Bestand beim Oeffnen mit (quantityLoaded) - Feld unveraendert: Bestand wird nicht angefasst - Feld geaendert: Schreiben nur, wenn der Bestand noch dem geladenen Stand entspricht (Bedingung atomar im UPDATE); sonst wird nichts gespeichert und der aktuelle Wert angezeigt - Fehlt quantityLoaded (alter Tab), muss neu geladen werden Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/admin/actions/products.ts | 100 ++++++++++++++++++++------- src/app/admin/produkte/[id]/page.tsx | 2 + src/lib/inventory.test.ts | 29 +++++++- src/lib/inventory.ts | 28 ++++++++ 4 files changed, 133 insertions(+), 26 deletions(-) diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts index e9450e6..e16a41d 100644 --- a/src/app/admin/actions/products.ts +++ b/src/app/admin/actions/products.ts @@ -6,6 +6,8 @@ import { logAdminAction } from "@/lib/audit-log"; import { notifyStockAvailable } from "@/lib/stock-notifications"; import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation"; import { isValidUploadPath } from "@/lib/uploads"; +import { planInventoryEdit } from "@/lib/inventory"; +import type { Prisma } from "@/generated/prisma/client"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { succeed } from "@/lib/flash"; @@ -160,6 +162,35 @@ export async function deleteProductAction(formData: FormData): Promise { return succeed("/admin/produkte", "Produkt gelöscht."); } +/** + * Schreibt den Bestand nur, wenn er noch dem Stand beim Oeffnen des + * Formulars entspricht. Die Bedingung steht im UPDATE selbst und wird damit + * atomar gegen den aktuellen Zeilenstand geprueft — ein vorheriges Lesen + * koennte eine gleichzeitige Bestellung nicht ausschliessen. + * + * @returns false, wenn sich der Bestand inzwischen geaendert hat. + */ +async function setInventoryIfUnchanged( + tx: Prisma.TransactionClient, + productId: string, + plan: { expected: number; quantity: number } +): Promise { + const updated = await tx.inventory.updateMany({ + where: { productId, quantity: plan.expected }, + data: { quantity: plan.quantity }, + }); + if (updated.count === 1) return true; + // Kein Bestands-Datensatz gilt im Formular als 0 (siehe produkte/[id]). + // Nur dann anlegen; existiert der Datensatz mit anderem Wert, ueberspringt + // skipDuplicates das Anlegen und count bleibt 0 — also ein Konflikt. + if (plan.expected !== 0) return false; + const created = await tx.inventory.createMany({ + data: [{ productId, quantity: plan.quantity }], + skipDuplicates: true, + }); + return created.count === 1; +} + export async function updateProductAction(formData: FormData): Promise { const admin = await requireAdmin(); const id = str(formData, "id"); @@ -182,32 +213,50 @@ export async function updateProductAction(formData: FormData): Promise { const isActive = formData.get("isActive") === "on"; await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents); - const previousInventory = await prisma.inventory.findUnique({ - where: { productId: id }, - select: { quantity: true }, + const inventoryPlan = planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity }); + if (inventoryPlan.kind === "reload") { + return fail( + `/admin/produkte/${id}`, + "Das Formular ist veraltet. Bitte die Seite neu laden und die Änderung erneut eintragen." + ); + } + + // Bestand und Produktdaten in einer Transaktion: passt der Bestand nicht + // mehr zum geladenen Stand, wird auch der Rest nicht gespeichert — sonst + // haette die Imkerei halb gespeicherte Aenderungen, ohne es zu merken. + const product = await prisma.$transaction(async (tx) => { + if (inventoryPlan.kind === "set" && !(await setInventoryIfUnchanged(tx, id, inventoryPlan))) { + return null; + } + return tx.product.update({ + where: { id }, + data: { + name: str(formData, "name"), + description: str(formData, "description"), + tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean), + intensityMild: numbers.intensityMild, + intensityFloral: numbers.intensityFloral, + intensityKraeftig: numbers.intensityKraeftig, + consistency: str(formData, "consistency") || "feincremig", + weightGrams: numbers.weightGrams, + priceCents: numbers.priceCents, + pricePerKgCents: numbers.pricePerKgCents, + vatRatePercent: numbers.vatRatePercent, + images: strArray(formData, "images").filter(isValidUploadPath), + isActive, + }, + }); }); - const product = await prisma.product.update({ - where: { id }, - data: { - name: str(formData, "name"), - description: str(formData, "description"), - tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean), - intensityMild: numbers.intensityMild, - intensityFloral: numbers.intensityFloral, - intensityKraeftig: numbers.intensityKraeftig, - consistency: str(formData, "consistency") || "feincremig", - weightGrams: numbers.weightGrams, - priceCents: numbers.priceCents, - pricePerKgCents: numbers.pricePerKgCents, - vatRatePercent: numbers.vatRatePercent, - images: strArray(formData, "images").filter(isValidUploadPath), - isActive, - inventory: { - upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } }, - }, - }, - }); + if (!product) { + const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } }); + return fail( + `/admin/produkte/${id}`, + `Der Bestand hat sich seit dem Öffnen geändert (jetzt ${current?.quantity ?? 0} statt ${ + inventoryPlan.kind === "set" ? inventoryPlan.expected : 0 + }), vermutlich durch eine Bestellung. Nichts wurde gespeichert — bitte die Seite neu laden und erneut eintragen.` + ); + } await logAdminAction(admin, { action: "product.update", @@ -218,7 +267,8 @@ export async function updateProductAction(formData: FormData): Promise { // Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und // ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden. - if ((previousInventory?.quantity ?? 0) === 0 && numbers.quantity > 0) { + // Bei "set" ist expected garantiert der Stand direkt vor dem Schreiben. + if (inventoryPlan.kind === "set" && inventoryPlan.expected === 0 && inventoryPlan.quantity > 0) { await notifyStockAvailable(id); } diff --git a/src/app/admin/produkte/[id]/page.tsx b/src/app/admin/produkte/[id]/page.tsx index 6c8386e..1518488 100644 --- a/src/app/admin/produkte/[id]/page.tsx +++ b/src/app/admin/produkte/[id]/page.tsx @@ -43,6 +43,8 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod

{te.save}}> + {/* Bestand beim Oeffnen — siehe planInventoryEdit in lib/inventory.ts */} + diff --git a/src/lib/inventory.test.ts b/src/lib/inventory.test.ts index f7987e0..4189127 100644 --- a/src/lib/inventory.test.ts +++ b/src/lib/inventory.test.ts @@ -1,5 +1,32 @@ import { describe, test, expect } from "vitest"; -import { needsRestockOnCancel } from "./inventory"; +import { needsRestockOnCancel, planInventoryEdit } from "./inventory"; + +describe("planInventoryEdit", () => { + test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => { + // Zwischen Oeffnen und Speichern verkaufte Glaeser duerfen nicht + // mit dem alten Formularwert ueberschrieben werden. + expect(planInventoryEdit({ loaded: "105", submitted: 105 })).toEqual({ kind: "keep" }); + }); + + test("setzt den Bestand nur unter der Bedingung, dass er noch dem geladenen Wert entspricht", () => { + expect(planInventoryEdit({ loaded: "105", submitted: 120 })).toEqual({ + kind: "set", + expected: 105, + quantity: 120, + }); + }); + + test("verlangt ein Neuladen, wenn der geladene Wert fehlt", () => { + // Formular aus einem Tab, der vor dem Deployment geoeffnet wurde. + expect(planInventoryEdit({ loaded: "", submitted: 105 })).toEqual({ kind: "reload" }); + }); + + test("verlangt ein Neuladen bei einem manipulierten geladenen Wert", () => { + expect(planInventoryEdit({ loaded: "-1", submitted: 105 })).toEqual({ kind: "reload" }); + expect(planInventoryEdit({ loaded: "1.5", submitted: 105 })).toEqual({ kind: "reload" }); + expect(planInventoryEdit({ loaded: "abc", submitted: 105 })).toEqual({ kind: "reload" }); + }); +}); describe("needsRestockOnCancel", () => { test("bucht zurueck, wenn es keine Retoure gibt", () => { diff --git a/src/lib/inventory.ts b/src/lib/inventory.ts index bc82077..e916ae3 100644 --- a/src/lib/inventory.ts +++ b/src/lib/inventory.ts @@ -10,6 +10,34 @@ import type { Prisma, ReturnStatus } from "@/generated/prisma/client"; * passiert (Retoure erstattet, danach "STORNIERT" gesetzt: +1 Glas zu viel). * Gegenstueck zu needsRestockOnDelete in order-delete.ts. */ +export type InventoryEditPlan = + | { kind: "keep" } + | { kind: "set"; expected: number; quantity: number } + | { kind: "reload" }; + +/** + * Was soll das Speichern des Produkt-Formulars mit dem Lagerbestand tun? + * + * Das Formular schickt neben dem eingegebenen Bestand ("submitted") den + * Bestand mit, der beim Oeffnen angezeigt wurde ("loaded", verstecktes Feld). + * Frueher wurde der Formularwert immer fest geschrieben: wurde zwischen + * Oeffnen und Speichern ein Glas verkauft, kam es durch das Speichern + * wieder in den Bestand, ohne dass es je zurueckkam. + * + * - Feld unveraendert: Bestand nicht anfassen ("keep"). + * - Feld geaendert: nur schreiben, solange der Bestand noch "loaded" ist + * ("set"; die Bedingung prueft updateProductAction atomar im UPDATE). + * - "loaded" fehlt oder ist ungueltig: neu laden lassen ("reload"), statt + * zu raten, welchen Stand die Imkerei vor Augen hatte. + */ +export function planInventoryEdit(input: { loaded: string; submitted: number }): InventoryEditPlan { + if (!/^\d+$/.test(input.loaded)) return { kind: "reload" }; + const loaded = Number(input.loaded); + if (!Number.isSafeInteger(loaded)) return { kind: "reload" }; + if (loaded === input.submitted) return { kind: "keep" }; + return { kind: "set", expected: loaded, quantity: input.submitted }; +} + export function needsRestockOnCancel(returnStatus: ReturnStatus | null): boolean { return returnStatus !== "ERSTATTET"; }