From e0b47faf45309358391df804ef7e70b3e9a61ed6 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 27 Sep 2026 01:14:12 +0200 Subject: [PATCH] feat(orders): reopen cancelled orders only after confirmation; accurate sales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelled orders: - Moving an order out of STORNIERT is possible again, but only after a warning is confirmed in the status form (OrderStatusForm); without the confirmation the server changes nothing. - Reopening books the counter-entry to the cancellation: goods that the cancellation restocked are deducted again (atomically, only if enough stock is left, otherwise the order stays cancelled). A later second cancellation therefore restocks +1 again instead of leaving +2. - The warning states that no new payment is collected. Dashboard figures: - One definition of a counted sale (lib/sales.ts): not unpaid (NEU), not cancelled, no refunded return. Used for revenue today/month, sold glasses, average cart, top products/categories, revenue trend and repeat customers. - "Verkaufte Gläser" previously summed every order item, including cancelled orders and refunded returns. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/admin/actions/orders.ts | 62 +++++++++++++++---- src/app/admin/bestellungen/[id]/page.tsx | 30 +++------ src/app/admin/bestellungen/page.tsx | 35 ++++------- src/app/admin/page.tsx | 15 ++--- src/components/order-status-form.tsx | 77 ++++++++++++++++++++++++ src/i18n/dictionaries/de.ts | 2 + src/i18n/dictionaries/en.ts | 2 + src/i18n/dictionary-type.ts | 1 + src/lib/inventory.test.ts | 33 ++++++---- src/lib/inventory.ts | 50 ++++++++++++--- src/lib/sales.ts | 18 ++++++ 11 files changed, 243 insertions(+), 82 deletions(-) create mode 100644 src/components/order-status-form.tsx create mode 100644 src/lib/sales.ts diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index 2d84210..1ec50cc 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -6,7 +6,14 @@ import { logAdminAction } from "@/lib/audit-log"; import { sendMail } from "@/lib/mail"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; -import { isOrderStatusChangeAllowed, needsRestockOnCancel, restockOrderItems } from "@/lib/inventory"; +import { + InsufficientStockError, + needsConfirmToChangeStatus, + needsReserveOnReopen, + needsRestockOnCancel, + reserveOrderItems, + restockOrderItems, +} from "@/lib/inventory"; import { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete"; import { getStripeClient } from "@/lib/stripe"; import { isLocale, defaultLocale } from "@/i18n/config"; @@ -123,6 +130,8 @@ export async function updateOrderStatusAction(formData: FormData): Promise const orderId = str(formData, "orderId"); const status = str(formData, "status") as (typeof ORDER_STATUSES)[number]; const detailPath = `/admin/bestellungen/${orderId}`; + // Gesetzt vom Bestaetigungsdialog der Statusauswahl, nur bei stornierten Bestellungen. + const confirmedReopen = formData.get("confirmReopen") === "1"; if (!ORDER_STATUSES.includes(status)) { return fail(detailPath, "Ungültiger Status."); } @@ -138,17 +147,18 @@ export async function updateOrderStatusAction(formData: FormData): Promise locale: true, stripeCheckoutSessionId: true, payment: { select: { status: true, stripePaymentIntentId: true } }, - items: { select: { productId: true, quantity: true } }, + items: { select: { productId: true, quantity: true, productName: true } }, returnRequest: { select: { status: true } }, }, }); if (!previous) return null; - // Stornierte Bestellungen bleiben storniert (siehe - // isOrderStatusChangeAllowed) — sonst bucht ein erneutes Stornieren die - // Ware ein zweites Mal zurueck. - if (!isOrderStatusChangeAllowed(previous.status, status)) { - return { ...previous, shipment: null, cancelledNow: false, blocked: true }; + // Eine stornierte Bestellung wieder zu oeffnen verlangt die ausdrueckliche + // Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne + // sie wird nichts veraendert. + const reopening = needsConfirmToChangeStatus(previous.status, status); + if (reopening && !confirmedReopen) { + return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const }; } // Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat. @@ -176,6 +186,23 @@ export async function updateOrderStatusAction(formData: FormData): Promise await restockOrderItems(tx, previous.items); } } + } else if (reopening) { + // Gegenbuchung zur Stornierung: die dabei zurueckgebuchte Ware wird + // wieder abgezogen — sonst buchte ein spaeteres erneutes Stornieren sie + // ein zweites Mal zurueck (+2 statt +1). Atomar: nur, wenn die + // Bestellung in diesem Moment noch storniert ist. + const reopened = await tx.order.updateMany({ + where: { id: orderId, status: "STORNIERT" }, + data: { status }, + }); + if (reopened.count === 0) { + return { ...previous, shipment: null, cancelledNow: false, blocked: "changed" as const }; + } + if (needsReserveOnReopen(previous.returnRequest?.status ?? null)) { + // Wirft InsufficientStockError, wenn die Ware inzwischen verkauft ist — + // die Transaktion nimmt dann auch den Statuswechsel zurueck. + await reserveOrderItems(tx, previous.items); + } } else { // Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die // Bestellung zwischen Lesen und Schreiben storniert, bleibt sie das. @@ -184,7 +211,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise data: { status }, }); if (moved.count === 0) { - return { ...previous, shipment: null, cancelledNow: false, blocked: true }; + return { ...previous, shipment: null, cancelledNow: false, blocked: "changed" as const }; } } @@ -212,18 +239,31 @@ export async function updateOrderStatusAction(formData: FormData): Promise }); } - return { ...previous, shipment, cancelledNow, blocked: false }; + return { ...previous, shipment, cancelledNow, blocked: false as const }; + }).catch((error: unknown) => { + if (error instanceof InsufficientStockError) return { insufficientStock: error.productName }; + throw error; }); + if (result && "insufficientStock" in result) { + return fail( + detailPath, + `Die Bestellung kann nicht wieder geöffnet werden: für „${result.insufficientStock}“ ist nicht mehr genug Bestand da. Sie bleibt storniert.` + ); + } + if (!result) { return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht."); } - if (result.blocked) { + if (result.blocked === "needs-confirm") { return fail( detailPath, - "Diese Bestellung ist storniert: die Ware ist zurückgebucht und die Zahlung erstattet. Sie lässt sich nicht wieder öffnen — soll die Ware doch raus, bitte eine neue Bestellung anlegen." + "Diese Bestellung ist storniert. Zum Wiederöffnen muss die Warnung bestätigt werden — der Status wurde nicht geändert." ); } + if (result.blocked === "changed") { + return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen."); + } // undefined = keine E-Mail faellig; false = faellig, aber nicht versendet. let mailSent: boolean | undefined; diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx index 2c6785d..20b80d7 100644 --- a/src/app/admin/bestellungen/[id]/page.tsx +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -10,6 +10,7 @@ import { deleteOrderAction, } from "../../actions"; import { ConfirmForm } from "@/components/confirm-form"; +import { OrderStatusForm } from "@/components/order-status-form"; import { checkOrderDeletable } from "@/lib/order-delete"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -57,26 +58,15 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best

{formatDate(order.createdAt)}

- {/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */} - {order.status !== "STORNIERT" ? ( -
- - - -
- ) : null} + {/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */} diff --git a/src/app/admin/bestellungen/page.tsx b/src/app/admin/bestellungen/page.tsx index 1c6b7f9..d50d547 100644 --- a/src/app/admin/bestellungen/page.tsx +++ b/src/app/admin/bestellungen/page.tsx @@ -4,6 +4,7 @@ import { BackLink } from "@/components/back-link"; import { formatPrice, formatDate } from "@/lib/format"; import { OrderStatusBadge } from "@/components/account-ui"; import { updateOrderStatusAction } from "../actions"; +import { OrderStatusForm } from "@/components/order-status-form"; import type { OrderStatus } from "@/generated/prisma/client"; import { insensitiveContains } from "@/lib/prisma-search"; import { getLocale } from "@/i18n/locale"; @@ -139,30 +140,16 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun {formatPrice(order.totalCents, order.currency)} - {/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */} - {order.status !== "STORNIERT" ? ( -
- - - -
- ) : null} + {order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index bc5a83e..d9ad670 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel"; import { prisma } from "@/lib/prisma"; +import { COUNTED_SALE_WHERE } from "@/lib/sales"; import { formatPrice } from "@/lib/format"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -119,21 +120,21 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) { ] = await Promise.all([ prisma.order.aggregate({ _sum: { totalCents: true }, - where: { createdAt: { gte: startOfDay() }, status: { not: "STORNIERT" } }, + where: { createdAt: { gte: startOfDay() }, ...COUNTED_SALE_WHERE }, }), prisma.order.aggregate({ _sum: { totalCents: true }, - where: { createdAt: { gte: startOfMonth() }, status: { not: "STORNIERT" } }, + where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE }, }), prisma.order.count({ where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } }, }), prisma.inventory.aggregate({ _sum: { quantity: true } }), - prisma.orderItem.aggregate({ _sum: { quantity: true } }), + prisma.orderItem.aggregate({ _sum: { quantity: true }, where: { order: COUNTED_SALE_WHERE } }), prisma.order.aggregate({ _avg: { totalCents: true }, _count: true, - where: { status: { not: "STORNIERT" } }, + where: COUNTED_SALE_WHERE, }), // Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage // JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher, @@ -150,11 +151,11 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) { // unten); ueber eine Relation laesst sich nicht gruppieren. prisma.orderItem.groupBy({ by: ["productId", "productName"], - where: { order: { status: { not: "STORNIERT" } } }, + where: { order: COUNTED_SALE_WHERE }, _sum: { quantity: true, totalCents: true }, }), prisma.order.findMany({ - where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, status: { not: "STORNIERT" } }, + where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, ...COUNTED_SALE_WHERE }, select: { createdAt: true, totalCents: true }, }), // Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung. @@ -164,7 +165,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) { // machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann. prisma.order.groupBy({ by: ["customerId"], - where: { customerId: { not: null }, status: { not: "STORNIERT" } }, + where: { customerId: { not: null }, ...COUNTED_SALE_WHERE }, having: { customerId: { _count: { gt: 1 } } }, }), prisma.discountCode.findMany({ diff --git a/src/components/order-status-form.tsx b/src/components/order-status-form.tsx new file mode 100644 index 0000000..40314b5 --- /dev/null +++ b/src/components/order-status-form.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useRef, type FormEvent } from "react"; +import { Button } from "@/components/ui"; + +/** + * Statusauswahl einer Bestellung (Admin-Liste und -Detailseite). Ist die + * Bestellung storniert und wird ein anderer Status gewaehlt, muss erst eine + * Warnung bestaetigt werden — erst dann geht die Bestaetigung (confirmReopen) + * mit, ohne die der Server nichts aendert (updateOrderStatusAction). + */ +export function OrderStatusForm({ + action, + orderId, + currentStatus, + labels, + saveLabel, + statusAriaLabel, + reopenConfirmMessage, + compact = false, +}: { + action: (formData: FormData) => void | Promise; + orderId: string; + currentStatus: string; + labels: Record; + saveLabel: string; + statusAriaLabel: string; + reopenConfirmMessage: string; + compact?: boolean; +}) { + const confirmRef = useRef(null); + + function handleSubmit(event: FormEvent) { + const chosen = new FormData(event.currentTarget).get("status"); + const reopening = currentStatus === "STORNIERT" && chosen !== "STORNIERT"; + if (confirmRef.current) confirmRef.current.value = ""; + if (!reopening) return; + if (!window.confirm(reopenConfirmMessage)) { + event.preventDefault(); + // Fuer SubmitFeedback: dieser Klick loest keine Aktion aus. + event.currentTarget.dataset.submitCancelled = "true"; + return; + } + if (confirmRef.current) confirmRef.current.value = "1"; + } + + return ( +
+ + + + {compact ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 7f4aa7a..ad22068 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1382,6 +1382,8 @@ export const de: Dictionary = { "Diese Bestellung kann nicht gelöscht werden — es hängt eine Zahlung oder eine Rechnung daran, oder sie ist bereits in Bearbeitung. Rechnungen und Zahlungsbelege sind aufbewahrungspflichtig. Storniere die Bestellung stattdessen: das erstattet das Geld, bucht die Ware zurück und lässt den Beleg stehen.", }, adminOrdersPage: { + reopenConfirm: + "Diese Bestellung ist storniert: Die Ware wurde zurückgebucht und eine bezahlte Summe erstattet. Beim Wiederöffnen wird die Ware erneut vom Bestand abgezogen — eine erneute Zahlung löst das NICHT aus, die musst du selbst einholen. Wirklich wieder öffnen?", backToDashboard: "Zurück zum Dashboard", title: "Bestellungen", searchLabel: "Suche (Nummer, E-Mail, Nachname)", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index f521ab8..6c3a82e 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1356,6 +1356,8 @@ export const en: Dictionary = { "This order cannot be deleted — a payment or an invoice is attached to it, or it is already being processed. Invoices and payment records are subject to statutory retention. Cancel the order instead: that refunds the money, returns the goods to stock and keeps the record.", }, adminOrdersPage: { + reopenConfirm: + "This order is cancelled: the goods were restocked and any payment refunded. Reopening deducts the goods from stock again — it does NOT collect a new payment, you have to arrange that yourself. Really reopen?", backToDashboard: "Back to dashboard", title: "Orders", searchLabel: "Search (number, email, last name)", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 13da0ea..0c8f209 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -941,6 +941,7 @@ export interface Dictionary { dhlNotConfigured: string; }; adminOrdersPage: { + reopenConfirm: string; backToDashboard: string; title: string; searchLabel: string; diff --git a/src/lib/inventory.test.ts b/src/lib/inventory.test.ts index 0a752e3..00407eb 100644 --- a/src/lib/inventory.test.ts +++ b/src/lib/inventory.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { isOrderStatusChangeAllowed, needsRestockOnCancel, planInventoryEdit } from "./inventory"; +import { needsConfirmToChangeStatus, needsReserveOnReopen, needsRestockOnCancel, planInventoryEdit } from "./inventory"; describe("planInventoryEdit", () => { test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => { @@ -44,20 +44,29 @@ describe("needsRestockOnCancel", () => { }); }); -describe("isOrderStatusChangeAllowed", () => { - test("a cancelled order cannot be reopened (else a second cancel restocks again: +2 instead of +1)", () => { - expect(isOrderStatusChangeAllowed("STORNIERT", "BEZAHLT")).toBe(false); - expect(isOrderStatusChangeAllowed("STORNIERT", "NEU")).toBe(false); - expect(isOrderStatusChangeAllowed("STORNIERT", "VERSENDET")).toBe(false); +describe("needsConfirmToChangeStatus", () => { + test("leaving a cancelled order requires an explicit confirmation", () => { + expect(needsConfirmToChangeStatus("STORNIERT", "BEZAHLT")).toBe(true); + expect(needsConfirmToChangeStatus("STORNIERT", "NEU")).toBe(true); + expect(needsConfirmToChangeStatus("STORNIERT", "VERSENDET")).toBe(true); }); - test("re-selecting the cancelled status is a harmless no-op", () => { - expect(isOrderStatusChangeAllowed("STORNIERT", "STORNIERT")).toBe(true); + test("re-selecting the cancelled status or moving active orders needs no confirmation", () => { + expect(needsConfirmToChangeStatus("STORNIERT", "STORNIERT")).toBe(false); + expect(needsConfirmToChangeStatus("BEZAHLT", "VERSENDET")).toBe(false); + expect(needsConfirmToChangeStatus("VERSENDET", "STORNIERT")).toBe(false); }); +}); - test("active orders can move between statuses and be cancelled", () => { - expect(isOrderStatusChangeAllowed("BEZAHLT", "VERSENDET")).toBe(true); - expect(isOrderStatusChangeAllowed("VERSENDET", "STORNIERT")).toBe(true); - expect(isOrderStatusChangeAllowed("NEU", "BEZAHLT")).toBe(true); +describe("needsReserveOnReopen", () => { + test("reopening takes back exactly what the cancellation restocked (no +2 on the next cancel)", () => { + for (const status of [null, "ANGEFRAGT", "ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] as const) { + expect(needsReserveOnReopen(status)).toBe(needsRestockOnCancel(status)); + } + }); + + test("an order whose return was refunded was not restocked on cancel, so nothing is deducted", () => { + expect(needsReserveOnReopen("ERSTATTET")).toBe(false); + expect(needsReserveOnReopen(null)).toBe(true); }); }); diff --git a/src/lib/inventory.ts b/src/lib/inventory.ts index 8da0503..b9bf1e0 100644 --- a/src/lib/inventory.ts +++ b/src/lib/inventory.ts @@ -43,15 +43,49 @@ export function needsRestockOnCancel(returnStatus: ReturnStatus | null): boolean } /** - * "STORNIERT" ist ein Endzustand: beim Stornieren wurde die Ware - * zurueckgebucht und die Zahlung erstattet. Liesse sich eine stornierte - * Bestellung wieder auf einen aktiven Status setzen, stuende sie ohne - * reservierte Ware und ohne Geld da — und ein erneutes Stornieren buchte die - * Ware ein ZWEITES Mal zurueck (so geschehen: +2 statt +1 Glas). Wer die - * Bestellung doch ausliefern will, legt eine neue an. + * Eine stornierte Bestellung wieder zu oeffnen braucht eine ausdrueckliche + * Bestaetigung: beim Stornieren wurde die Ware zurueckgebucht und die Zahlung + * erstattet. Frueher ging das mit einem Klick — und ohne Gegenbuchung. Ein + * erneutes Stornieren buchte die Ware dann ein ZWEITES Mal zurueck (so + * geschehen: +2 statt +1 Glas). */ -export function isOrderStatusChangeAllowed(from: OrderStatus, to: OrderStatus): boolean { - return from !== "STORNIERT" || to === "STORNIERT"; +export function needsConfirmToChangeStatus(from: OrderStatus, to: OrderStatus): boolean { + return from === "STORNIERT" && to !== "STORNIERT"; +} + +/** + * Wird beim Wiederoeffnen Ware vom Bestand abgezogen? Genau dann, wenn das + * Stornieren sie zurueckgebucht hatte (needsRestockOnCancel) — Buchung und + * Gegenbuchung bleiben so immer paarweise. + */ +export function needsReserveOnReopen(returnStatus: ReturnStatus | null): boolean { + return needsRestockOnCancel(returnStatus); +} + +export class InsufficientStockError extends Error { + constructor(readonly productName: string) { + super(`Nicht genug Bestand fuer ${productName}`); + this.name = "InsufficientStockError"; + } +} + +/** + * Zieht die Ware einer Bestellung vom Bestand ab — atomar je Produkt und nur, + * wenn genug da ist (wie die Reservierung im Checkout). Reicht es fuer ein + * Produkt nicht, wirft InsufficientStockError; die umgebende Transaktion + * macht dann auch die schon abgezogenen Positionen rueckgaengig. + */ +export async function reserveOrderItems( + tx: Prisma.TransactionClient, + items: { productId: string; quantity: number; productName: string }[] +): Promise { + for (const item of items) { + const reserved = await tx.inventory.updateMany({ + where: { productId: item.productId, quantity: { gte: item.quantity } }, + data: { quantity: { decrement: item.quantity } }, + }); + if (reserved.count === 0) throw new InsufficientStockError(item.productName); + } } /** diff --git a/src/lib/sales.ts b/src/lib/sales.ts new file mode 100644 index 0000000..3d58354 --- /dev/null +++ b/src/lib/sales.ts @@ -0,0 +1,18 @@ +import type { Prisma } from "@/generated/prisma/client"; + +/** + * Welche Bestellungen zaehlen in der Auswertung (Admin-Dashboard) als Verkauf? + * + * - nicht "NEU": noch nicht bezahlt — die Ware ist nur reserviert, nicht verkauft + * - nicht "STORNIERT": Ware zurueckgebucht, Geld erstattet + * - keine erstattete Retoure: die Glaeser sind zurueck im Bestand und das Geld + * bei der Kundschaft; sie weiter als verkauft zu zaehlen, blaehte Umsatz und + * "Verkaufte Glaeser" auf + * + * EINE Definition fuer alle Kennzahlen, damit Umsatz, Glaeser, Warenkorbwert + * und Top-Produkte nicht auseinanderlaufen. + */ +export const COUNTED_SALE_WHERE = { + status: { notIn: ["NEU", "STORNIERT"] }, + NOT: { returnRequest: { is: { status: "ERSTATTET" } } }, +} satisfies Prisma.OrderWhereInput;