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

+42 -8
View File
@@ -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);
}
}
/**