feat(orders): reopen cancelled orders only after confirmation; accurate sales
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s

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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 01:14:12 +02:00
1 parent dc1523aafd
commit e0b47faf45
11 files changed
+243 -82

No files matched your search

+51 -11
View File
@@ -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<void>
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<void>
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<void>
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<void>
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<void>
});
}
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;
+10 -20
View File
@@ -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
</div>
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
</div>
{/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */}
{order.status !== "STORNIERT" ? (
<form action={updateOrderStatusAction} className="flex items-center gap-2">
<input type="hidden" name="orderId" value={order.id} />
<select
name="status"
defaultValue={order.status}
className="border border-ink/20 bg-background px-3 py-2 text-sm"
>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<Button type="submit" className="px-5 py-2.5">
{t.adminOrderDetailPage.saveStatus}
</Button>
</form>
) : null}
<OrderStatusForm
action={updateOrderStatusAction}
orderId={order.id}
currentStatus={order.status}
labels={statusLabels}
saveLabel={t.adminOrderDetailPage.saveStatus}
statusAriaLabel={t.adminOrdersPage.statusLabel}
reopenConfirmMessage={t.adminOrdersPage.reopenConfirm}
/>
</div>
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
+11 -24
View File
@@ -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
<td className="p-4 whitespace-nowrap tabular-nums">{formatPrice(order.totalCents, order.currency)}</td>
<td className="p-4">
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
{/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */}
{order.status !== "STORNIERT" ? (
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
<input type="hidden" name="orderId" value={order.id} />
<select
name="status"
defaultValue={order.status}
aria-label={t.adminOrdersPage.statusLabel}
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<button
type="submit"
className="border border-heading px-3 py-1.5 text-xs text-heading transition-colors duration-300 hover:bg-forest hover:text-cream"
>
{t.adminOrdersPage.save}
</button>
</form>
) : null}
<OrderStatusForm
action={updateOrderStatusAction}
orderId={order.id}
currentStatus={order.status}
labels={statusLabels}
saveLabel={t.adminOrdersPage.save}
statusAriaLabel={t.adminOrdersPage.statusLabel}
reopenConfirmMessage={t.adminOrdersPage.reopenConfirm}
compact
/>
</td>
<td className="p-4">
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
+8 -7
View File
@@ -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({