feat: Patenschafts-Honig automatisch waehlen (nie cremig, neueste Ernte)
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]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 18:35:04 +02:00
1 parent 3ddf0bfa73
commit 1d88bac52e
30 files changed
+1152 -394

No files matched your search

+38 -3
View File
@@ -3,6 +3,7 @@ import { personalizationSchema, startDateRange, toIsoDate } from "@/lib/certific
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";
/**
@@ -13,12 +14,20 @@ import type { Locale } from "@/i18n/config";
* - MAIN: die Patenschaft (Traegerprodukt, Regelsteuersatz), traegt die
* Personalisierung. Keine Bestandsreservierung — eine Urkunde ist nie
* ausverkauft.
* - HONEY: der Honiganteil als Position des verknuepften Honigprodukts zum
* - 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.
*/
@@ -76,6 +85,11 @@ export interface ExpandInput {
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;
@@ -90,12 +104,31 @@ 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);
@@ -169,10 +202,12 @@ export function expandCheckoutItems(input: ExpandInput): ExpandResult {
});
if (offer.honeyJars > 0) {
const honey = offer.honeyProduct ? input.products.get(offer.honeyProduct.id) : undefined;
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: "Diese Patenschaft ist derzeit nicht bestellbar." };
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,