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
+21
-12
@@ -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);
|
||||
});
|
||||
});
|
||||
+42
-8
@@ -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<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