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