Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m18s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m32s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m41s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 20m38s
Pakete, Abo-Reservierung und Abo-Lieferungen nehmen immer den aktiven, nicht cremigen Honig der neuesten Ernte mit genug freiem Bestand, bei gleichem Jahrgang den mit dem meisten Bestand. Feste Honig-Zuordnung im Admin entfaellt (Spalte bleibt, wird nicht mehr genutzt). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
251 lines
9.3 KiB
TypeScript
251 lines
9.3 KiB
TypeScript
import type { Prisma, ProductCategory } from "@/generated/prisma/client";
|
||
import { personalizationSchema, startDateRange, toIsoDate } from "@/lib/certificate/schema";
|
||
import { checkCertificateFits } from "@/lib/certificate/layout";
|
||
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
||
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
|
||
import { pickHoneyProduct, withTakenStock, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
||
import type { Locale } from "@/i18n/config";
|
||
|
||
/**
|
||
* Wandelt die Warenkorbpositionen in Bestellpositionen um und loest dabei
|
||
* Patenschaftspakete in ihre Bestandteile auf.
|
||
*
|
||
* Ein Paket wird zu bis zu zwei Positionen:
|
||
* - MAIN: die Patenschaft (Traegerprodukt, Regelsteuersatz), traegt die
|
||
* Personalisierung. Keine Bestandsreservierung — eine Urkunde ist nie
|
||
* ausverkauft.
|
||
* - HONEY: der Honiganteil als Position eines Honigprodukts zum
|
||
* Honig-Steuersatz. Dadurch reserviert der Checkout den Honig wie jedes
|
||
* andere Glas, die Rechnung zeigt die Steueraufteilung, und die
|
||
* ApiCore-Abbuchung bei Zahlung (recordOrderSale) bucht den Honig ohne
|
||
* Sonderweg aus dem Bestand.
|
||
*
|
||
* Welcher Honig das ist, entscheidet die automatische Wahl im Moment der
|
||
* Bestellung (honey-selection.ts: nie cremig, neueste Ernte, genug freier
|
||
* Bestand). Mehrere Pakete in einem Warenkorb werden nacheinander gewaehlt,
|
||
* jedes gegen den Bestand, den die einzeln gekauften Glaeser und die Pakete
|
||
* davor uebrig lassen — reicht der erste Honig nicht fuer alle, bekommt das
|
||
* naechste Paket den naechsten. Das ist einfacher als ein Paket auf zwei
|
||
* Sorten aufzuteilen, und jedes Paket bleibt sortenrein.
|
||
*
|
||
* Bewusst als reine Funktion ohne Datenbankzugriff: der Checkout laedt alles
|
||
* vorher, und diese Regeln lassen sich ohne Datenbank testen.
|
||
*/
|
||
|
||
export interface CheckoutCartItem {
|
||
productId: string;
|
||
quantity: number;
|
||
personalization?: unknown;
|
||
}
|
||
|
||
export interface CheckoutProduct {
|
||
id: string;
|
||
name: string;
|
||
batchId: string | null;
|
||
priceCents: number;
|
||
weightGrams: number;
|
||
category: ProductCategory;
|
||
isActive: boolean;
|
||
}
|
||
|
||
export interface OrderItemDraft {
|
||
productId: string;
|
||
batchId: string | null;
|
||
productName: string;
|
||
unitPriceCents: number;
|
||
quantity: number;
|
||
weightGrams: number;
|
||
totalCents: number;
|
||
vatRatePercent: number;
|
||
personalization?: Prisma.InputJsonValue;
|
||
sponsorshipOfferId?: string;
|
||
sponsorshipRole?: "MAIN" | "HONEY";
|
||
}
|
||
|
||
export interface ReservationLine {
|
||
productId: string;
|
||
quantity: number;
|
||
productName: string;
|
||
}
|
||
|
||
export type ExpandResult =
|
||
| {
|
||
ok: true;
|
||
items: OrderItemDraft[];
|
||
reserve: ReservationLine[];
|
||
/** Menge fuer Versandstaffel und Mengenrabatte (nur physische Ware). */
|
||
physicalQuantity: number;
|
||
hasPhysical: boolean;
|
||
hasSponsorship: boolean;
|
||
}
|
||
| { ok: false; error: string };
|
||
|
||
export interface ExpandInput {
|
||
cartItems: CheckoutCartItem[];
|
||
products: ReadonlyMap<string, CheckoutProduct>;
|
||
/** Angebote nach Traegerprodukt-ID. */
|
||
offers: ReadonlyMap<string, SponsorshipOfferView>;
|
||
/**
|
||
* Honig-Kandidaten mit FREIEM Bestand (honey-candidates.ts). Die Produkte
|
||
* der gewaehlten Kandidaten muessen in `products` stehen.
|
||
*/
|
||
honeyCandidates: readonly HoneyCandidate[];
|
||
vatRateFor: (product: CheckoutProduct) => number;
|
||
locale: Locale;
|
||
now: Date;
|
||
}
|
||
|
||
/**
|
||
* Erlaubtes Startdatum: frühestens der Bestelltag, spätestens sechs Monate
|
||
* danach — dieselbe Regel wie im Formular (startDateRange), damit Grenze und
|
||
* Klemmung auf das Monatsende (31.08. → 28./29.02.) ueberall gleich sind.
|
||
*/
|
||
export function startDateWindow(now: Date): { min: string; max: string } {
|
||
return startDateRange(toIsoDate(now));
|
||
}
|
||
|
||
/**
|
||
* Glaeser, die der Warenkorb schon einzeln kauft, je Produkt. Sie gehen vom
|
||
* freien Bestand ab, bevor ein Paket seinen Honig waehlt — sonst fiele ein
|
||
* Paket auf denselben Honig, und die Bestandspruefung im Checkout lehnte
|
||
* die ganze Bestellung ab, obwohl ein anderer Honig gereicht haette.
|
||
*/
|
||
function individuallyTakenJars(input: ExpandInput): Map<string, number> {
|
||
const taken = new Map<string, number>();
|
||
for (const cartItem of input.cartItems) {
|
||
const product = input.products.get(cartItem.productId);
|
||
if (!product || product.category === "PATENSCHAFT") continue;
|
||
taken.set(product.id, (taken.get(product.id) ?? 0) + cartItem.quantity);
|
||
}
|
||
return taken;
|
||
}
|
||
|
||
export function expandCheckoutItems(input: ExpandInput): ExpandResult {
|
||
const items: OrderItemDraft[] = [];
|
||
const reserve: ReservationLine[] = [];
|
||
let physicalQuantity = 0;
|
||
let hasSponsorship = false;
|
||
const window = startDateWindow(input.now);
|
||
// Verplante Glaeser je Honig: erst die einzeln gekauften, dann wachsend mit
|
||
// jedem Paket (lokaler Zaehler dieser einen Berechnung).
|
||
const takenJars = individuallyTakenJars(input);
|
||
|
||
for (const cartItem of input.cartItems) {
|
||
const product = input.products.get(cartItem.productId);
|
||
if (!product || !product.isActive) {
|
||
return { ok: false, error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." };
|
||
}
|
||
|
||
if (product.category !== "PATENSCHAFT") {
|
||
if (cartItem.personalization !== undefined) {
|
||
return { ok: false, error: "Ungültige Eingabe." };
|
||
}
|
||
if (product.priceCents <= 0) {
|
||
return { ok: false, error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." };
|
||
}
|
||
items.push({
|
||
productId: product.id,
|
||
batchId: product.batchId,
|
||
productName: product.name,
|
||
unitPriceCents: product.priceCents,
|
||
quantity: cartItem.quantity,
|
||
weightGrams: product.weightGrams,
|
||
totalCents: product.priceCents * cartItem.quantity,
|
||
vatRatePercent: input.vatRateFor(product),
|
||
});
|
||
reserve.push({ productId: product.id, quantity: cartItem.quantity, productName: product.name });
|
||
physicalQuantity += cartItem.quantity;
|
||
continue;
|
||
}
|
||
|
||
hasSponsorship = true;
|
||
const offer = input.offers.get(product.id);
|
||
if (!offer || offer.kind !== "EINMALIG" || !offer.isAvailable) {
|
||
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
|
||
}
|
||
// Jede Patenschaft hat ihre eigene Personalisierung und ihre eigene
|
||
// Urkunde — eine Menge > 1 haette eine Widmung fuer mehrere Urkunden.
|
||
if (cartItem.quantity !== 1) {
|
||
return { ok: false, error: "Jede Patenschaft wird einzeln personalisiert." };
|
||
}
|
||
const parsed = personalizationSchema.safeParse(cartItem.personalization);
|
||
if (!parsed.success) {
|
||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Die Personalisierung ist ungültig." };
|
||
}
|
||
const personalization = parsed.data;
|
||
if (personalization.startDate < window.min || personalization.startDate > window.max) {
|
||
return { ok: false, error: "Das Startdatum muss zwischen heute und in sechs Monaten liegen." };
|
||
}
|
||
const fit = checkCertificateFits(personalization, input.locale);
|
||
if (!fit.ok) {
|
||
return { ok: false, error: fit.message };
|
||
}
|
||
|
||
const honeyTotal = offer.honeyJars * offer.honeyShareCentsPerJar;
|
||
const mainCents = offer.priceCents - honeyTotal;
|
||
if (mainCents <= 0) {
|
||
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
|
||
}
|
||
|
||
items.push({
|
||
productId: product.id,
|
||
batchId: null,
|
||
productName: offer.name,
|
||
unitPriceCents: mainCents,
|
||
quantity: 1,
|
||
weightGrams: 0,
|
||
totalCents: mainCents,
|
||
vatRatePercent: input.vatRateFor(product),
|
||
personalization: { personalization, templateVersion: CURRENT_TEMPLATE_VERSION } as unknown as Prisma.InputJsonValue,
|
||
sponsorshipOfferId: offer.id,
|
||
sponsorshipRole: "MAIN",
|
||
});
|
||
|
||
if (offer.honeyJars > 0) {
|
||
const picked = pickHoneyProduct(withTakenStock(input.honeyCandidates, takenJars), offer.honeyJars);
|
||
const honey = picked ? input.products.get(picked.id) : undefined;
|
||
if (!honey || !honey.isActive) {
|
||
return { ok: false, error: "Für dieses Paket ist gerade nicht genug Honig da." };
|
||
}
|
||
takenJars.set(honey.id, (takenJars.get(honey.id) ?? 0) + offer.honeyJars);
|
||
items.push({
|
||
productId: honey.id,
|
||
batchId: honey.batchId,
|
||
productName: `${honey.name} – ${offer.name}`,
|
||
unitPriceCents: offer.honeyShareCentsPerJar,
|
||
quantity: offer.honeyJars,
|
||
weightGrams: honey.weightGrams,
|
||
totalCents: honeyTotal,
|
||
vatRatePercent: input.vatRateFor(honey),
|
||
sponsorshipOfferId: offer.id,
|
||
sponsorshipRole: "HONEY",
|
||
});
|
||
reserve.push({ productId: honey.id, quantity: offer.honeyJars, productName: honey.name });
|
||
physicalQuantity += offer.honeyJars;
|
||
}
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
items,
|
||
reserve: mergeReservations(reserve),
|
||
physicalQuantity,
|
||
hasPhysical: physicalQuantity > 0,
|
||
hasSponsorship,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fasst Reservierungen desselben Produkts zusammen. Ohne das wuerden zwei
|
||
* Pakete mit demselben Honig zwei getrennte "noch mindestens n da?"-Pruefungen
|
||
* bestehen, obwohl zusammen zu wenig da ist.
|
||
*/
|
||
function mergeReservations(lines: ReservationLine[]): ReservationLine[] {
|
||
const merged = new Map<string, ReservationLine>();
|
||
for (const line of lines) {
|
||
const existing = merged.get(line.productId);
|
||
merged.set(line.productId, existing ? { ...existing, quantity: existing.quantity + line.quantity } : line);
|
||
}
|
||
return [...merged.values()];
|
||
}
|