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"; }