feat(orders): reopen cancelled orders only after confirmation; accurate sales
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
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:
1 parent
dc1523aafd
commit
e0b47faf45
11 files changed
+243
-82
No files matched your search
@@ -6,7 +6,14 @@ import { logAdminAction } from "@/lib/audit-log";
|
|||||||
import { sendMail } from "@/lib/mail";
|
import { sendMail } from "@/lib/mail";
|
||||||
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
||||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
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 { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete";
|
||||||
import { getStripeClient } from "@/lib/stripe";
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
@@ -123,6 +130,8 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
const orderId = str(formData, "orderId");
|
const orderId = str(formData, "orderId");
|
||||||
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
||||||
const detailPath = `/admin/bestellungen/${orderId}`;
|
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)) {
|
if (!ORDER_STATUSES.includes(status)) {
|
||||||
return fail(detailPath, "Ungültiger Status.");
|
return fail(detailPath, "Ungültiger Status.");
|
||||||
}
|
}
|
||||||
@@ -138,17 +147,18 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
locale: true,
|
locale: true,
|
||||||
stripeCheckoutSessionId: true,
|
stripeCheckoutSessionId: true,
|
||||||
payment: { select: { status: true, stripePaymentIntentId: 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 } },
|
returnRequest: { select: { status: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!previous) return null;
|
if (!previous) return null;
|
||||||
|
|
||||||
// Stornierte Bestellungen bleiben storniert (siehe
|
// Eine stornierte Bestellung wieder zu oeffnen verlangt die ausdrueckliche
|
||||||
// isOrderStatusChangeAllowed) — sonst bucht ein erneutes Stornieren die
|
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
|
||||||
// Ware ein zweites Mal zurueck.
|
// sie wird nichts veraendert.
|
||||||
if (!isOrderStatusChangeAllowed(previous.status, status)) {
|
const reopening = needsConfirmToChangeStatus(previous.status, status);
|
||||||
return { ...previous, shipment: null, cancelledNow: false, blocked: true };
|
if (reopening && !confirmedReopen) {
|
||||||
|
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
|
// 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);
|
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 {
|
} else {
|
||||||
// Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die
|
// Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die
|
||||||
// Bestellung zwischen Lesen und Schreiben storniert, bleibt sie das.
|
// Bestellung zwischen Lesen und Schreiben storniert, bleibt sie das.
|
||||||
@@ -184,7 +211,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
data: { status },
|
data: { status },
|
||||||
});
|
});
|
||||||
if (moved.count === 0) {
|
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) {
|
if (!result) {
|
||||||
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
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(
|
return fail(
|
||||||
detailPath,
|
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.
|
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
|
||||||
let mailSent: boolean | undefined;
|
let mailSent: boolean | undefined;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
deleteOrderAction,
|
deleteOrderAction,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { OrderStatusForm } from "@/components/order-status-form";
|
||||||
import { checkOrderDeletable } from "@/lib/order-delete";
|
import { checkOrderDeletable } from "@/lib/order-delete";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -57,26 +58,15 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */}
|
<OrderStatusForm
|
||||||
{order.status !== "STORNIERT" ? (
|
action={updateOrderStatusAction}
|
||||||
<form action={updateOrderStatusAction} className="flex items-center gap-2">
|
orderId={order.id}
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
currentStatus={order.status}
|
||||||
<select
|
labels={statusLabels}
|
||||||
name="status"
|
saveLabel={t.adminOrderDetailPage.saveStatus}
|
||||||
defaultValue={order.status}
|
statusAriaLabel={t.adminOrdersPage.statusLabel}
|
||||||
className="border border-ink/20 bg-background px-3 py-2 text-sm"
|
reopenConfirmMessage={t.adminOrdersPage.reopenConfirm}
|
||||||
>
|
/>
|
||||||
{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}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
|
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BackLink } from "@/components/back-link";
|
|||||||
import { formatPrice, formatDate } from "@/lib/format";
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
import { OrderStatusBadge } from "@/components/account-ui";
|
import { OrderStatusBadge } from "@/components/account-ui";
|
||||||
import { updateOrderStatusAction } from "../actions";
|
import { updateOrderStatusAction } from "../actions";
|
||||||
|
import { OrderStatusForm } from "@/components/order-status-form";
|
||||||
import type { OrderStatus } from "@/generated/prisma/client";
|
import type { OrderStatus } from "@/generated/prisma/client";
|
||||||
import { insensitiveContains } from "@/lib/prisma-search";
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
import { getLocale } from "@/i18n/locale";
|
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 whitespace-nowrap tabular-nums">{formatPrice(order.totalCents, order.currency)}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||||
{/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */}
|
<OrderStatusForm
|
||||||
{order.status !== "STORNIERT" ? (
|
action={updateOrderStatusAction}
|
||||||
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
|
orderId={order.id}
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
currentStatus={order.status}
|
||||||
<select
|
labels={statusLabels}
|
||||||
name="status"
|
saveLabel={t.adminOrdersPage.save}
|
||||||
defaultValue={order.status}
|
statusAriaLabel={t.adminOrdersPage.statusLabel}
|
||||||
aria-label={t.adminOrdersPage.statusLabel}
|
reopenConfirmMessage={t.adminOrdersPage.reopenConfirm}
|
||||||
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
|
compact
|
||||||
>
|
/>
|
||||||
{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}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { COUNTED_SALE_WHERE } from "@/lib/sales";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -119,21 +120,21 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.order.aggregate({
|
prisma.order.aggregate({
|
||||||
_sum: { totalCents: true },
|
_sum: { totalCents: true },
|
||||||
where: { createdAt: { gte: startOfDay() }, status: { not: "STORNIERT" } },
|
where: { createdAt: { gte: startOfDay() }, ...COUNTED_SALE_WHERE },
|
||||||
}),
|
}),
|
||||||
prisma.order.aggregate({
|
prisma.order.aggregate({
|
||||||
_sum: { totalCents: true },
|
_sum: { totalCents: true },
|
||||||
where: { createdAt: { gte: startOfMonth() }, status: { not: "STORNIERT" } },
|
where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE },
|
||||||
}),
|
}),
|
||||||
prisma.order.count({
|
prisma.order.count({
|
||||||
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
||||||
}),
|
}),
|
||||||
prisma.inventory.aggregate({ _sum: { quantity: true } }),
|
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({
|
prisma.order.aggregate({
|
||||||
_avg: { totalCents: true },
|
_avg: { totalCents: true },
|
||||||
_count: true,
|
_count: true,
|
||||||
where: { status: { not: "STORNIERT" } },
|
where: COUNTED_SALE_WHERE,
|
||||||
}),
|
}),
|
||||||
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
|
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
|
||||||
// JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher,
|
// 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.
|
// unten); ueber eine Relation laesst sich nicht gruppieren.
|
||||||
prisma.orderItem.groupBy({
|
prisma.orderItem.groupBy({
|
||||||
by: ["productId", "productName"],
|
by: ["productId", "productName"],
|
||||||
where: { order: { status: { not: "STORNIERT" } } },
|
where: { order: COUNTED_SALE_WHERE },
|
||||||
_sum: { quantity: true, totalCents: true },
|
_sum: { quantity: true, totalCents: true },
|
||||||
}),
|
}),
|
||||||
prisma.order.findMany({
|
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 },
|
select: { createdAt: true, totalCents: true },
|
||||||
}),
|
}),
|
||||||
// Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung.
|
// 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.
|
// machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann.
|
||||||
prisma.order.groupBy({
|
prisma.order.groupBy({
|
||||||
by: ["customerId"],
|
by: ["customerId"],
|
||||||
where: { customerId: { not: null }, status: { not: "STORNIERT" } },
|
where: { customerId: { not: null }, ...COUNTED_SALE_WHERE },
|
||||||
having: { customerId: { _count: { gt: 1 } } },
|
having: { customerId: { _count: { gt: 1 } } },
|
||||||
}),
|
}),
|
||||||
prisma.discountCode.findMany({
|
prisma.discountCode.findMany({
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
orderId: string;
|
||||||
|
currentStatus: string;
|
||||||
|
labels: Record<string, string>;
|
||||||
|
saveLabel: string;
|
||||||
|
statusAriaLabel: string;
|
||||||
|
reopenConfirmMessage: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const confirmRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<form action={action} onSubmit={handleSubmit} className={compact ? "mt-2 flex items-center gap-2" : "flex items-center gap-2"}>
|
||||||
|
<input type="hidden" name="orderId" value={orderId} />
|
||||||
|
<input ref={confirmRef} type="hidden" name="confirmReopen" defaultValue="" />
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={currentStatus}
|
||||||
|
aria-label={statusAriaLabel}
|
||||||
|
className={compact ? "border border-ink/20 bg-background px-2 py-1.5 text-xs" : "border border-ink/20 bg-background px-3 py-2 text-sm"}
|
||||||
|
>
|
||||||
|
{Object.entries(labels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{compact ? (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Button type="submit" className="px-5 py-2.5">
|
||||||
|
{saveLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.",
|
"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: {
|
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",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
title: "Bestellungen",
|
title: "Bestellungen",
|
||||||
searchLabel: "Suche (Nummer, E-Mail, Nachname)",
|
searchLabel: "Suche (Nummer, E-Mail, Nachname)",
|
||||||
|
|||||||
@@ -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.",
|
"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: {
|
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",
|
backToDashboard: "Back to dashboard",
|
||||||
title: "Orders",
|
title: "Orders",
|
||||||
searchLabel: "Search (number, email, last name)",
|
searchLabel: "Search (number, email, last name)",
|
||||||
|
|||||||
@@ -941,6 +941,7 @@ export interface Dictionary {
|
|||||||
dhlNotConfigured: string;
|
dhlNotConfigured: string;
|
||||||
};
|
};
|
||||||
adminOrdersPage: {
|
adminOrdersPage: {
|
||||||
|
reopenConfirm: string;
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
title: string;
|
title: string;
|
||||||
searchLabel: string;
|
searchLabel: string;
|
||||||
|
|||||||
+21
-12
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
import { describe, test, expect } from "vitest";
|
||||||
import { isOrderStatusChangeAllowed, needsRestockOnCancel, planInventoryEdit } from "./inventory";
|
import { needsConfirmToChangeStatus, needsReserveOnReopen, needsRestockOnCancel, planInventoryEdit } from "./inventory";
|
||||||
|
|
||||||
describe("planInventoryEdit", () => {
|
describe("planInventoryEdit", () => {
|
||||||
test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => {
|
test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => {
|
||||||
@@ -44,20 +44,29 @@ describe("needsRestockOnCancel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isOrderStatusChangeAllowed", () => {
|
describe("needsConfirmToChangeStatus", () => {
|
||||||
test("a cancelled order cannot be reopened (else a second cancel restocks again: +2 instead of +1)", () => {
|
test("leaving a cancelled order requires an explicit confirmation", () => {
|
||||||
expect(isOrderStatusChangeAllowed("STORNIERT", "BEZAHLT")).toBe(false);
|
expect(needsConfirmToChangeStatus("STORNIERT", "BEZAHLT")).toBe(true);
|
||||||
expect(isOrderStatusChangeAllowed("STORNIERT", "NEU")).toBe(false);
|
expect(needsConfirmToChangeStatus("STORNIERT", "NEU")).toBe(true);
|
||||||
expect(isOrderStatusChangeAllowed("STORNIERT", "VERSENDET")).toBe(false);
|
expect(needsConfirmToChangeStatus("STORNIERT", "VERSENDET")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("re-selecting the cancelled status is a harmless no-op", () => {
|
test("re-selecting the cancelled status or moving active orders needs no confirmation", () => {
|
||||||
expect(isOrderStatusChangeAllowed("STORNIERT", "STORNIERT")).toBe(true);
|
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", () => {
|
describe("needsReserveOnReopen", () => {
|
||||||
expect(isOrderStatusChangeAllowed("BEZAHLT", "VERSENDET")).toBe(true);
|
test("reopening takes back exactly what the cancellation restocked (no +2 on the next cancel)", () => {
|
||||||
expect(isOrderStatusChangeAllowed("VERSENDET", "STORNIERT")).toBe(true);
|
for (const status of [null, "ANGEFRAGT", "ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] as const) {
|
||||||
expect(isOrderStatusChangeAllowed("NEU", "BEZAHLT")).toBe(true);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+42
-8
@@ -43,15 +43,49 @@ export function needsRestockOnCancel(returnStatus: ReturnStatus | null): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "STORNIERT" ist ein Endzustand: beim Stornieren wurde die Ware
|
* Eine stornierte Bestellung wieder zu oeffnen braucht eine ausdrueckliche
|
||||||
* zurueckgebucht und die Zahlung erstattet. Liesse sich eine stornierte
|
* Bestaetigung: beim Stornieren wurde die Ware zurueckgebucht und die Zahlung
|
||||||
* Bestellung wieder auf einen aktiven Status setzen, stuende sie ohne
|
* erstattet. Frueher ging das mit einem Klick — und ohne Gegenbuchung. Ein
|
||||||
* reservierte Ware und ohne Geld da — und ein erneutes Stornieren buchte die
|
* erneutes Stornieren buchte die Ware dann ein ZWEITES Mal zurueck (so
|
||||||
* Ware ein ZWEITES Mal zurueck (so geschehen: +2 statt +1 Glas). Wer die
|
* geschehen: +2 statt +1 Glas).
|
||||||
* Bestellung doch ausliefern will, legt eine neue an.
|
|
||||||
*/
|
*/
|
||||||
export function isOrderStatusChangeAllowed(from: OrderStatus, to: OrderStatus): boolean {
|
export function needsConfirmToChangeStatus(from: OrderStatus, to: OrderStatus): boolean {
|
||||||
return from !== "STORNIERT" || to === "STORNIERT";
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in new issue
Block a user