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) <[email protected]>
This commit is contained in:
1 parent
5848eb6ad5
commit
d52a762e8a
4 files changed
+133
-26
No files matched your search
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
@@ -182,32 +213,50 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
||||
</p>
|
||||
<AdminFormPanel action={updateProductAction} actions={<Button type="submit">{te.save}</Button>}>
|
||||
<input type="hidden" name="id" value={product.id} />
|
||||
{/* Bestand beim Oeffnen — siehe planInventoryEdit in lib/inventory.ts */}
|
||||
<input type="hidden" name="quantityLoaded" value={String(product.inventory?.quantity ?? 0)} />
|
||||
<AdminFieldset legend={te.sectionText}>
|
||||
<AdminField name="name" label={te.nameLabel} defaultValue={product.name} required />
|
||||
<AdminTextarea name="description" label={te.descriptionLabel} defaultValue={product.description} required />
|
||||
|
||||
Reference in new issue
Block a user