From 1d88bac52e8ef5500fcca28212215b6921e5ead0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Fri, 16 Oct 2026 18:35:04 +0200 Subject: [PATCH] feat: Patenschafts-Honig automatisch waehlen (nie cremig, neueste Ernte) 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) --- prisma/schema.prisma | 5 +- scripts/sponsorship-bootstrap.ts | 42 ++--- src/app/admin/actions/sponsorship-offers.ts | 52 ++++-- .../patenschaften/_components/offer-form.tsx | 25 ++- .../_components/subscription-panels.tsx | 40 ++-- src/app/admin/patenschaften/_lib/attention.ts | 4 +- src/app/admin/patenschaften/_lib/coverage.ts | 127 ++++++------- .../patenschaften/_lib/offer-overview.ts | 77 +++++--- .../patenschaften/_lib/offer-schema.test.ts | 16 +- .../admin/patenschaften/_lib/offer-schema.ts | 14 +- .../_lib/subscription-view.test.ts | 18 +- src/app/admin/patenschaften/abos/page.tsx | 2 +- .../patenschaften/angebote/[id]/page.tsx | 20 +- .../admin/patenschaften/angebote/neu/page.tsx | 9 +- src/app/admin/patenschaften/angebote/page.tsx | 50 ++--- src/app/api/checkout/route.test.ts | 36 ++++ src/app/api/checkout/route.ts | 29 ++- src/i18n/sponsorship/admin.ts | 75 +++++--- src/i18n/sponsorship/shop.ts | 4 +- .../deliveries.test.ts | 54 +++++- .../sponsorship-subscription/deliveries.ts | 61 ++++-- .../reservation.test.ts | 112 ++++++++--- .../sponsorship-subscription/reservation.ts | 97 +++++++--- src/lib/sponsorship/checkout-expand.ts | 41 +++- src/lib/sponsorship/core.test.ts | 86 ++++++++- src/lib/sponsorship/honey-candidates.ts | 27 +++ src/lib/sponsorship/honey-selection.test.ts | 176 ++++++++++++++++++ src/lib/sponsorship/honey-selection.ts | 136 ++++++++++++++ src/lib/sponsorship/honey-stock.ts | 46 +++++ src/lib/sponsorship/offers.ts | 65 ++++--- 30 files changed, 1152 insertions(+), 394 deletions(-) create mode 100644 src/lib/sponsorship/honey-candidates.ts create mode 100644 src/lib/sponsorship/honey-selection.test.ts create mode 100644 src/lib/sponsorship/honey-selection.ts create mode 100644 src/lib/sponsorship/honey-stock.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a3af14..3c0662a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1075,7 +1075,10 @@ model SponsorshipOffer { /// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb, /// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil. productId String? @unique - /// Honigprodukt, dessen Bestand das Paket bzw. Abo verbraucht. + /// UNGENUTZT: Pakete und Abos bekommen ihren Honig automatisch (nie cremig, + /// neueste Ernte, genug Bestand — lib/sponsorship/honey-selection.ts). Die + /// Spalte wird weder gelesen noch geschrieben und bleibt nur stehen, damit + /// die laufende Produktion keine Migration braucht; Altwerte sind bedeutungslos. honeyProductId String? /// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung. honeyJars Int @default(0) diff --git a/scripts/sponsorship-bootstrap.ts b/scripts/sponsorship-bootstrap.ts index 11a2366..6dbcc50 100644 --- a/scripts/sponsorship-bootstrap.ts +++ b/scripts/sponsorship-bootstrap.ts @@ -4,17 +4,24 @@ * Wiederholbar: legt nur an, was fehlt, und aendert nie ein Angebot, das der * Inhaber schon bearbeitet hat (Felder werden nur gesetzt, wenn sie leer sind). * - * - verknuepft die physischen Einmal-Pakete mit einem Honigprodukt, * - legt die beiden Abo-Angebote INAKTIV an (Testpreise, im Admin pruefen), * - legt fuer jedes Abo-Angebot Stripe-Produkt, Monatspreis und * Traegerprodukt an (syncOfferStripePrice). * + * Einen Honig verknuepft das Skript nicht mehr (frueher --honey=): + * Pakete und Abos bekommen ihren Honig automatisch — nie cremig, neueste + * Ernte, genug Bestand (lib/sponsorship/honey-selection.ts). Die Spalte + * honeyProductId bleibt ungenutzt im Schema; der Probelauf zeigt zur + * Kontrolle, welchen Honig die Regel gerade waehlen wuerde. + * * Aufruf (Werkzeug-Image, Umgebung wie die App): - * tsx scripts/sponsorship-bootstrap.ts --honey= (Probelauf) - * tsx scripts/sponsorship-bootstrap.ts --honey= --apply (ausfuehren) + * tsx scripts/sponsorship-bootstrap.ts (Probelauf) + * tsx scripts/sponsorship-bootstrap.ts --apply (ausfuehren) */ import { prisma } from "@/lib/prisma"; import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices"; +import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates"; +import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection"; interface AboTemplate { slug: string; @@ -70,30 +77,12 @@ const ABO_TEMPLATES: AboTemplate[] = [ }, ]; -function arg(name: string): string | undefined { - const hit = process.argv.find((value) => value.startsWith(`--${name}=`)); - return hit ? hit.slice(name.length + 3) : undefined; -} - async function main(): Promise { const apply = process.argv.includes("--apply"); - const honeySlug = arg("honey"); - if (!honeySlug) throw new Error("--honey= fehlt."); - - const honey = await prisma.product.findUnique({ where: { slug: honeySlug }, select: { id: true, name: true, category: true, isActive: true } }); - if (!honey || honey.category !== "HONIG" || !honey.isActive) { - throw new Error(`Honigprodukt "${honeySlug}" nicht gefunden, nicht HONIG oder nicht aktiv.`); - } - console.log(`${apply ? "AUSFUEHREN" : "PROBELAUF"} — Honig: ${honey.name}`); - - const physical = await prisma.sponsorshipOffer.findMany({ - where: { kind: "EINMALIG", isPhysical: true, honeyProductId: null }, - select: { id: true, slug: true }, - }); - for (const offer of physical) { - console.log(` Paket ${offer.slug}: Honig verknuepfen`); - if (apply) await prisma.sponsorshipOffer.update({ where: { id: offer.id }, data: { honeyProductId: honey.id } }); - } + const honey = pickHoneyProduct(await loadHoneyCandidates(), 1); + console.log( + `${apply ? "AUSFUEHREN" : "PROBELAUF"} — Honig automatisch, derzeit: ${honey ? honeyLabel(honey) : "kein nicht-cremiger Honig mit freiem Bestand"}` + ); for (const template of ABO_TEMPLATES) { const existing = await prisma.sponsorshipOffer.findUnique({ where: { slug: template.slug }, select: { id: true, stripePriceId: true } }); @@ -110,7 +99,6 @@ async function main(): Promise { kind: "ABO", isActive: false, isPhysical: true, - honeyProductId: honey.id, medalTier: "ABO", }, }); @@ -119,7 +107,7 @@ async function main(): Promise { const offers = await prisma.sponsorshipOffer.findMany({ orderBy: { sortOrder: "asc" }, - select: { slug: true, kind: true, isActive: true, priceCents: true, honeyProductId: true, stripePriceId: true, productId: true }, + select: { slug: true, kind: true, isActive: true, priceCents: true, honeyJars: true, stripePriceId: true, productId: true }, }); console.table(offers); } diff --git a/src/app/admin/actions/sponsorship-offers.ts b/src/app/admin/actions/sponsorship-offers.ts index 83c9a00..9b08635 100644 --- a/src/app/admin/actions/sponsorship-offers.ts +++ b/src/app/admin/actions/sponsorship-offers.ts @@ -6,6 +6,10 @@ import { logAdminAction } from "@/lib/audit-log"; import { formatPrice } from "@/lib/format"; import type { Prisma } from "@/generated/prisma/client"; import { carrierPriceCents, parseOfferForm, type OfferFormValues } from "@/app/admin/patenschaften/_lib/offer-schema"; +import { setFlash } from "@/lib/flash"; +import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates"; +import { pickHoneyProduct } from "@/lib/sponsorship/honey-selection"; +import { redirect } from "next/navigation"; import { requireAdmin, fail, succeed, str } from "./_shared"; import { isUniqueViolation, syncStripePriceSafely } from "./sponsorship-shared"; @@ -35,6 +39,12 @@ function carrierProductData(values: OfferFormValues) { }; } +/** + * honeyProductId wird bewusst nicht geschrieben: der Honig wird automatisch + * gewaehlt (lib/sponsorship/honey-selection.ts). Die Spalte bleibt nur im + * Schema stehen, damit die laufende Produktion keine Migration braucht; ein + * dort noch stehender Altwert wird nirgends mehr gelesen. + */ function offerData(values: OfferFormValues) { return { slug: values.slug, @@ -48,7 +58,6 @@ function offerData(values: OfferFormValues) { isActive: values.isActive, sortOrder: values.sortOrder, isPhysical: values.isPhysical, - honeyProductId: values.honeyProductId, honeyJars: values.honeyJars, honeyShareCentsPerJar: values.honeyShareCentsPerJar, honeyShareCentsPerMonth: values.honeyShareCentsPerMonth, @@ -59,10 +68,9 @@ function offerData(values: OfferFormValues) { } /** - * Pruefungen gegen die Datenbank: eindeutige Adressen, ein echtes - * Honigprodukt — und die Aktivierungssperre: ein Angebot mit Honig laesst - * sich nur aktivieren, wenn das Honigprodukt existiert und aktiv ist. Sonst - * bekaeme die Kundschaft eine Urkunde ohne den bezahlten Honig. + * Pruefungen gegen die Datenbank: eindeutige Adressen. Den Honig prueft + * honeyWarning() — er sperrt das Aktivieren nicht mehr, weil er sich mit + * jeder Bestellung und jeder neuen Ernte aendert. */ async function findConflict( values: OfferFormValues, @@ -77,19 +85,6 @@ async function findConflict( return `Die Produktadresse "${carrierSlug(values.slug)}" ist bereits vergeben. Bitte eine andere Adresse wählen.`; } } - - if (values.honeyProductId) { - const honey = await prisma.product.findUnique({ - where: { id: values.honeyProductId }, - select: { category: true, isActive: true, name: true }, - }); - if (!honey || honey.category !== "HONIG") return "Bitte ein Honigprodukt aus der Liste wählen."; - if (values.isActive && !honey.isActive) { - return `Das Angebot kann nicht aktiviert werden: das Honigprodukt "${honey.name}" ist inaktiv.`; - } - } else if (values.isActive && (values.isPhysical || values.honeyJars > 0)) { - return "Ein physisches Angebot ohne Honigprodukt kann nicht aktiviert werden."; - } return null; } @@ -98,7 +93,21 @@ function auditDetail(values: OfferFormValues): string { return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`; } -/** Nach dem Speichern eines Abo-Angebots: Stripe-Preis nachziehen und passend melden. */ +/** + * Aktivierungsregel fuer Angebote mit Honig: es muss einen Honig geben, den + * die automatische Wahl nehmen darf (nicht cremig, genug freier Bestand fuer + * die Glaeser). Fehlt er, wird trotzdem gespeichert — das Angebot steht dann + * auf der Verkaufsseite als nicht bestellbar (isOfferAvailable), bis + * fluessiger Honig nachgelegt ist —, aber der Admin bekommt einen Hinweis, + * statt sich ueber ein "ausverkauftes" neues Angebot zu wundern. + */ +async function honeyWarning(values: OfferFormValues): Promise { + if (!values.isActive || values.honeyJars <= 0) return null; + if (pickHoneyProduct(await loadHoneyCandidates(), values.honeyJars)) return null; + return "Hinweis: Derzeit gibt es keinen nicht-cremigen Honig mit genug freiem Bestand – bestellbar erst, wenn flüssiger Honig nachgelegt ist."; +} + +/** Nach dem Speichern: Stripe-Preis eines Abo-Angebots nachziehen, Honig pruefen und passend melden. */ async function finishSave(offerId: string, values: OfferFormValues, message: string): Promise { revalidatePath(OFFERS_PATH); revalidatePath("/patenschaft"); @@ -109,6 +118,11 @@ async function finishSave(offerId: string, values: OfferFormValues, message: str return fail(target, `${message} Der Stripe-Preis konnte aber nicht angelegt werden (${stripeError}). Bitte später erneut speichern.`); } } + const warning = await honeyWarning(values); + if (warning) { + await setFlash("info", `${message} ${warning}`); + redirect(target); + } return succeed(target, message); } diff --git a/src/app/admin/patenschaften/_components/offer-form.tsx b/src/app/admin/patenschaften/_components/offer-form.tsx index adf9cb3..ee3b353 100644 --- a/src/app/admin/patenschaften/_components/offer-form.tsx +++ b/src/app/admin/patenschaften/_components/offer-form.tsx @@ -29,7 +29,6 @@ export interface OfferFormDefaults { isActive: boolean; isPhysical: boolean; sortOrder: number; - honeyProductId: string | null; honeyJars: number; cycle: SponsorshipCycle | null; maxActiveSubscriptions: number | null; @@ -51,7 +50,6 @@ export const EMPTY_OFFER: OfferFormDefaults = { isActive: false, isPhysical: false, sortOrder: 0, - honeyProductId: null, honeyJars: 0, cycle: null, maxActiveSubscriptions: null, @@ -62,7 +60,8 @@ interface OfferFormProps { defaults: OfferFormDefaults; /** Beim Bearbeiten ist die Art fest. */ isNew: boolean; - honeyOptions: { id: string; name: string; isActive: boolean }[]; + /** " " des Honigs, den die automatische Wahl gerade nimmt; null = keiner reicht. */ + currentHoneyLabel: string | null; action: (formData: FormData) => void | Promise; t: SponsorshipAdminStrings; } @@ -74,7 +73,7 @@ const euroOrEmpty = (cents: number): string => (cents > 0 ? centsToEuroInput(cen * stehen mit "(nur Einmalig)" bzw. "(nur Abo)" da und werden beim Speichern * geleert (offer-schema.ts) — so bleibt das Formular ohne Client-Logik. */ -export function OfferForm({ defaults, isNew, honeyOptions, action, t }: OfferFormProps) { +export function OfferForm({ defaults, isNew, currentHoneyLabel, action, t }: OfferFormProps) { const tf = t.offerForm; return ( {isNew ? tf.create : tf.save}}> @@ -123,15 +122,15 @@ export function OfferForm({ defaults, isNew, honeyOptions, action, t }: OfferFor
- - - {honeyOptions.map((product) => ( - - ))} - + {/* Keine Auswahl mehr: Pakete und Abos bekommen den Honig automatisch + (lib/sponsorship/honey-selection.ts). Nur Anzeige, wird nicht gespeichert. */} +
diff --git a/src/app/admin/patenschaften/_components/subscription-panels.tsx b/src/app/admin/patenschaften/_components/subscription-panels.tsx index 3cdcd88..76151c7 100644 --- a/src/app/admin/patenschaften/_components/subscription-panels.tsx +++ b/src/app/admin/patenschaften/_components/subscription-panels.tsx @@ -3,7 +3,7 @@ import { AdminActionButton, AdminBadge } from "@/components/admin-list"; import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin"; import { liftSepaBlockAction, updateSepaHoldDaysAction } from "@/app/admin/actions"; import { MAX_SEPA_HOLD_DAYS } from "@/lib/sponsorship/settings"; -import type { HoneyCoverageRow } from "../_lib/coverage"; +import type { HoneyCoverage } from "../_lib/coverage"; type Strings = SponsorshipAdminStrings["subscriptions"]; @@ -16,13 +16,13 @@ export function SepaBlockButton({ customerId, confirmMessage, label }: { custome ); } -/** Honigdeckung je Produkt; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */ -export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: Strings }) { +/** Deckung der Abos durch den automatisch gewaehlten (nicht-cremigen) Honig; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */ +export function CoveragePanel({ coverage, t }: { coverage: HoneyCoverage; t: Strings }) { return (

{t.coverageHeading}

{t.coverageIntro}

- {rows.length === 0 ? ( + {coverage.rows.length === 0 ? (

{t.noCoverage}

) : (
@@ -30,38 +30,40 @@ export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: String {t.colProduct} + {t.colYear} {t.colStock} {t.colReserved} {t.colFree} - {t.colPerDelivery} - {t.colCovers} - {rows.map((row) => ( + {coverage.rows.map((row) => ( {row.productName} + {row.harvestYear} {row.stock} {row.reserved} {row.free} - {row.jarsPerDelivery} - - {row.deliveriesCovered === null ? ( - "—" - ) : row.deliveriesCovered < 1 ? ( - - {t.coverageWarning} - - ) : ( - {t.covers(row.deliveriesCovered)} - )} - ))}
)} + {coverage.deliveriesCovered !== null ? ( +

+ {t.perDelivery(coverage.jarsPerDelivery)} + + {t.coversLabel}: + {coverage.deliveriesCovered < 1 ? ( + + {t.coverageWarning} + + ) : ( + {t.covers(coverage.deliveriesCovered)} + )} +

+ ) : null}
); } diff --git a/src/app/admin/patenschaften/_lib/attention.ts b/src/app/admin/patenschaften/_lib/attention.ts index 6817e26..e43bc40 100644 --- a/src/app/admin/patenschaften/_lib/attention.ts +++ b/src/app/admin/patenschaften/_lib/attention.ts @@ -21,6 +21,8 @@ const PRINT_GRACE_MS = 2 * 24 * 60 * 60 * 1000; */ export async function getSponsorshipAttentionItems(locale: Locale, now: Date = new Date()): Promise { const t = getSponsorshipAdminStrings(locale).attention; + // Honigdeckung zaehlt hoechstens 1: Abos haben keinen festen Honig mehr, + // gedeckt wird aus allem nicht-cremigen Honig zusammen (coverage.ts). const [failed, overdueToPrint, delinquent, coverage] = await Promise.all([ prisma.sponsorship.count({ where: { status: "FEHLER" } }), // mailSentAt ist der Moment, in dem die Patenschaft BEREIT wurde (die @@ -35,7 +37,7 @@ export async function getSponsorshipAttentionItems(locale: Locale, now: Date = n return [ { count: failed, href: "/admin/patenschaften?status=FEHLER", label: t.failed(failed) }, { count: overdueToPrint, href: "/admin/patenschaften?status=ZU_DRUCKEN", label: t.toPrint(overdueToPrint) }, - { count: undercovered, href: "/admin/patenschaften/abos#deckung", label: t.coverage(undercovered) }, + { count: undercovered, href: "/admin/patenschaften/abos#deckung", label: t.coverage }, { count: delinquent, href: "/admin/patenschaften/abos?status=IM_VERZUG", label: t.delinquent(delinquent) }, ].filter((item) => item.count > 0); } diff --git a/src/app/admin/patenschaften/_lib/coverage.ts b/src/app/admin/patenschaften/_lib/coverage.ts index dd519f0..e0f15ae 100644 --- a/src/app/admin/patenschaften/_lib/coverage.ts +++ b/src/app/admin/patenschaften/_lib/coverage.ts @@ -1,10 +1,15 @@ import { prisma } from "@/lib/prisma"; -import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation"; +import { freeStock, subscriptionReservationByProduct } from "@/lib/sponsorship-subscription/reservation"; +import { loadHoneyStock } from "@/lib/sponsorship/honey-stock"; +import { rankHoneyCandidates, type HoneyCandidate } from "@/lib/sponsorship/honey-selection"; /** * Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und - * Kontingent"). Reserviert wird in lib/sponsorship-subscription/reservation.ts - * berechnet; hier wird nur zusammengestellt und angezeigt. + * Kontingent"). Pakete und Abos bekommen automatisch nicht-cremigen Honig + * (lib/sponsorship/honey-selection.ts) — gezeigt wird deshalb genau dieser + * Honig, in der Rangfolge der automatischen Wahl. Reserviert wird in + * lib/sponsorship-subscription/reservation.ts berechnet; hier wird nur + * zusammengestellt und angezeigt. */ /** Abos, deren naechste Lieferung noch Honig braucht (wie in reservation.ts). */ @@ -16,13 +21,20 @@ export interface HoneyStock { free: number; } -export interface HoneyCoverageRow extends HoneyStock { +export interface EligibleHoneyRow extends HoneyStock { productId: string; productName: string; - isActive: boolean; + weightGrams: number; + harvestYear: number; + consistency: string; +} + +export interface HoneyCoverage { + /** Nicht-cremiger, aktiver Honig in der Rangfolge der automatischen Wahl. */ + rows: EligibleHoneyRow[]; /** Glaeser, die eine vierteljaehrliche Lieferrunde aller laufenden Abos braucht. */ jarsPerDelivery: number; - /** Wie viele Lieferrunden der Bestand bedient; null = kein vierteljaehrlicher Bedarf. */ + /** Wie viele Lieferrunden der gesamte passende Bestand bedient; null = kein vierteljaehrlicher Bedarf. */ deliveriesCovered: number | null; } @@ -32,69 +44,58 @@ export function deliveriesCovered(stock: number, jarsPerDelivery: number): numbe return Math.floor(Math.max(0, stock) / jarsPerDelivery); } -/** Bestand, Abo-Reservierung und freier Bestand je Produkt. */ -export async function getHoneyStock(productIds: string[], now: Date = new Date()): Promise> { - const ids = [...new Set(productIds)]; - if (ids.length === 0) return new Map(); - const inventories = await prisma.inventory.findMany({ - where: { productId: { in: ids } }, - select: { productId: true, quantity: true }, - }); - const stockById = new Map(inventories.map((row) => [row.productId, row.quantity])); - const reservedById = await reservedJarsByProduct(ids, now); - return new Map( - ids.map((id) => { - const stock = stockById.get(id) ?? 0; - const reserved = reservedById.get(id) ?? 0; - return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const; - }) - ); +/** + * Passender Honig (aktiv, nicht cremig) mit Bestand, Abo-Reservierung und + * freiem Bestand, in der Rangfolge der automatischen Wahl (bei gleicher Ernte + * nach freiem Bestand). Eine Abfrage-Runde fuer alle Produkte. + */ +export async function getEligibleHoneyStock(now: Date = new Date()): Promise { + const stock = await loadHoneyStock(); + const reserved = await subscriptionReservationByProduct(now, prisma, stock); + const withFree = stock.map((candidate) => ({ + ...candidate, + freeStock: freeStock(candidate.inventory, reserved.get(candidate.id) ?? 0), + })); + return rankHoneyCandidates(withFree).map((candidate) => ({ + productId: candidate.id, + productName: candidate.name, + weightGrams: candidate.weightGrams, + harvestYear: candidate.harvestYear, + consistency: candidate.consistency, + stock: candidate.inventory, + reserved: reserved.get(candidate.id) ?? 0, + free: candidate.freeStock, + })); } -/** Deckung je Honigprodukt, das mit mindestens einem Abo-Angebot verknuepft ist. */ -export async function getHoneyCoverage(now: Date = new Date()): Promise { - const offers = await prisma.sponsorshipOffer.findMany({ - where: { kind: "ABO", honeyProductId: { not: null } }, - select: { honeyProduct: { select: { id: true, name: true, isActive: true } } }, - }); - const products = new Map( - offers.flatMap((offer) => (offer.honeyProduct ? [[offer.honeyProduct.id, offer.honeyProduct] as const] : [])) - ); - if (products.size === 0) return []; +/** Zeilen zurueck in Kandidaten der automatischen Wahl (freier Bestand). */ +export function toHoneyCandidates(rows: readonly EligibleHoneyRow[]): HoneyCandidate[] { + return rows.map((row) => ({ + id: row.productId, + name: row.productName, + weightGrams: row.weightGrams, + harvestYear: row.harvestYear, + consistency: row.consistency, + inventory: row.stock, + freeStock: row.free, + })); +} - const [stock, subscriptions] = await Promise.all([ - getHoneyStock([...products.keys()], now), +/** Deckung der Abos durch den passenden Honig insgesamt. */ +export async function getHoneyCoverage(now: Date = new Date()): Promise { + const [rows, subscriptions] = await Promise.all([ + getEligibleHoneyStock(now), prisma.sponsorshipSubscription.findMany({ - where: { - status: { in: [...DEMANDING_STATUSES] }, - offer: { cycle: "VIERTELJAEHRLICH", honeyProductId: { in: [...products.keys()] } }, - }, - select: { offer: { select: { honeyProductId: true, honeyJars: true } } }, + where: { status: { in: [...DEMANDING_STATUSES] }, offer: { cycle: "VIERTELJAEHRLICH", honeyJars: { gt: 0 } } }, + select: { offer: { select: { honeyJars: true } } }, }), ]); - const demand = new Map(); - for (const { offer } of subscriptions) { - if (!offer.honeyProductId) continue; - demand.set(offer.honeyProductId, (demand.get(offer.honeyProductId) ?? 0) + offer.honeyJars); - } - - return [...products.values()] - .map((product) => { - const amounts = stock.get(product.id) ?? { stock: 0, reserved: 0, free: 0 }; - const jarsPerDelivery = demand.get(product.id) ?? 0; - return { - productId: product.id, - productName: product.name, - isActive: product.isActive, - ...amounts, - jarsPerDelivery, - deliveriesCovered: deliveriesCovered(amounts.stock, jarsPerDelivery), - }; - }) - .sort((a, b) => a.productName.localeCompare(b.productName, "de")); + const jarsPerDelivery = subscriptions.reduce((sum, { offer }) => sum + offer.honeyJars, 0); + const totalStock = rows.reduce((sum, row) => sum + row.stock, 0); + return { rows, jarsPerDelivery, deliveriesCovered: deliveriesCovered(totalStock, jarsPerDelivery) }; } -/** Fuer die Aufmerksamkeitsliste: Produkte, deren Bestand keine volle Lieferrunde mehr deckt. */ -export function countUndercovered(rows: HoneyCoverageRow[]): number { - return rows.filter((row) => row.deliveriesCovered !== null && row.deliveriesCovered < 1).length; +/** Fuer die Aufmerksamkeitsliste: 1, wenn der passende Honig keine volle Lieferrunde mehr deckt. */ +export function countUndercovered(coverage: Pick): number { + return coverage.deliveriesCovered !== null && coverage.deliveriesCovered < 1 ? 1 : 0; } diff --git a/src/app/admin/patenschaften/_lib/offer-overview.ts b/src/app/admin/patenschaften/_lib/offer-overview.ts index df3f382..05dbf6c 100644 --- a/src/app/admin/patenschaften/_lib/offer-overview.ts +++ b/src/app/admin/patenschaften/_lib/offer-overview.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import { isOfferAvailable } from "@/lib/sponsorship/offers"; -import { getHoneyStock, type HoneyStock } from "./coverage"; +import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection"; +import { getEligibleHoneyStock, toHoneyCandidates, type EligibleHoneyRow, type HoneyStock } from "./coverage"; /** Abos, die einen Platz im Kontingent belegen (wie lib/sponsorship/offers.ts). */ const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const; @@ -8,35 +9,61 @@ const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const; async function loadOffers(where: { id?: string }) { return prisma.sponsorshipOffer.findMany({ where, - include: { - honeyProduct: { select: { id: true, name: true, isActive: true } }, - product: { select: { id: true, priceCents: true } }, - }, + include: { product: { select: { id: true, priceCents: true } } }, orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }], }); } export type OfferRow = Awaited>[number]; +/** Der Honig, den die automatische Wahl gerade fuer ein Angebot nimmt. */ +export interface CurrentHoney extends HoneyStock { + productId: string; + /** " " */ + label: string; +} + export interface OfferOverviewRow { offer: OfferRow; /** Live: dieselbe Entscheidung wie auf der Verkaufsseite. */ isAvailable: boolean; activeSubscriptions: number; - honeyStock: HoneyStock | null; + /** null bei Angeboten ohne Glaeser oder wenn kein passender Honig reicht. */ + currentHoney: CurrentHoney | null; +} + +export interface OfferOverview { + rows: OfferOverviewRow[]; + /** Passender Honig (nicht cremig) in der Rangfolge der automatischen Wahl. */ + eligibleHoney: EligibleHoneyRow[]; +} + +/** Die automatische Wahl fuer `jars` Glaeser als Anzeige-Datensatz — oder null. */ +export function currentHoneyFor(eligibleHoney: readonly EligibleHoneyRow[], jars: number): CurrentHoney | null { + if (jars <= 0) return null; + const picked = pickHoneyProduct(toHoneyCandidates(eligibleHoney), jars); + const row = picked ? eligibleHoney.find((candidate) => candidate.productId === picked.id) : undefined; + if (!row) return null; + return { + productId: row.productId, + label: honeyLabel({ name: row.productName, harvestYear: row.harvestYear }), + stock: row.stock, + reserved: row.reserved, + free: row.free, + }; } /** - * Angebote samt Live-Verfuegbarkeit und Honigbestand. Fuer ALLE Angebote, - * auch inaktive — getActiveOffers() aus lib/sponsorship/offers.ts kennt nur - * aktive, der Admin will aber sehen, ob ein Angebot nach dem Aktivieren - * ueberhaupt bestellbar waere. Die Entscheidung selbst bleibt isOfferAvailable. + * Angebote samt Live-Verfuegbarkeit und automatisch gewaehltem Honig. Fuer + * ALLE Angebote, auch inaktive — getActiveOffers() aus + * lib/sponsorship/offers.ts kennt nur aktive, der Admin will aber sehen, ob + * ein Angebot nach dem Aktivieren ueberhaupt bestellbar waere. Die + * Entscheidung selbst bleibt isOfferAvailable. */ -export async function getOfferOverview(offerId?: string): Promise { +export async function getOfferOverview(offerId?: string): Promise { const offers = await loadOffers(offerId ? { id: offerId } : {}); - const honeyIds = offers.flatMap((offer) => (offer.honeyProductId ? [offer.honeyProductId] : [])); - const [stock, groups] = await Promise.all([ - getHoneyStock(honeyIds), + const [eligibleHoney, groups] = await Promise.all([ + getEligibleHoneyStock(), prisma.sponsorshipSubscription.groupBy({ by: ["offerId"], where: { offerId: { in: offers.map((offer) => offer.id) }, status: { in: [...QUOTA_STATUSES] } }, @@ -45,35 +72,23 @@ export async function getOfferOverview(offerId?: string): Promise [group.offerId, group._count._all])); - return offers.map((offer) => { - const honeyStock = offer.honeyProductId ? (stock.get(offer.honeyProductId) ?? null) : null; + const rows = offers.map((offer) => { + const currentHoney = currentHoneyFor(eligibleHoney, offer.honeyJars); const activeSubscriptions = subscriptionCounts.get(offer.id) ?? 0; return { offer, activeSubscriptions, - honeyStock, + currentHoney, isAvailable: isOfferAvailable({ isActive: offer.isActive, kind: offer.kind, hasCarrierProduct: offer.productId !== null, honeyJars: offer.honeyJars, - honeyProductActive: offer.honeyProduct ? offer.honeyProduct.isActive : null, - freeJars: honeyStock?.free ?? 0, + hasEligibleHoney: currentHoney !== null, maxActiveSubscriptions: offer.maxActiveSubscriptions, activeSubscriptions, }), }; }); -} - -/** Auswahl im Formular: aktive Honigprodukte, plus das aktuell verknuepfte, falls es inzwischen inaktiv ist. */ -export async function getHoneyProductOptions(currentId: string | null): Promise<{ id: string; name: string; isActive: boolean }[]> { - return prisma.product.findMany({ - where: { - category: "HONIG", - OR: [{ isActive: true }, ...(currentId ? [{ id: currentId }] : [])], - }, - select: { id: true, name: true, isActive: true }, - orderBy: { name: "asc" }, - }); + return { rows, eligibleHoney }; } diff --git a/src/app/admin/patenschaften/_lib/offer-schema.test.ts b/src/app/admin/patenschaften/_lib/offer-schema.test.ts index c066f83..46e4400 100644 --- a/src/app/admin/patenschaften/_lib/offer-schema.test.ts +++ b/src/app/admin/patenschaften/_lib/offer-schema.test.ts @@ -16,7 +16,6 @@ const oneTime = { price: "39,00", honeySharePerJar: "7", isPhysical: "on", - honeyProductId: "prod_1", honeyJars: "1", medalTier: "SILBER", }; @@ -29,7 +28,6 @@ const subscription = { price: "12,50", honeySharePerMonth: "4", isPhysical: "on", - honeyProductId: "prod_1", honeyJars: "2", cycle: "VIERTELJAEHRLICH", medalTier: "ABO", @@ -83,14 +81,20 @@ describe("parseOfferForm", () => { expect(result).toEqual({ ok: false, message: "Bei Gläsern im Paket bitte den Honiganteil je Glas angeben." }); }); - it("verlangt fuer physische Angebote Honigprodukt und Glas", () => { - const result = parseOfferForm(form({ ...oneTime, honeyProductId: "", honeyJars: "0", honeySharePerJar: "" })); - expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas." }); + it("verlangt fuer physische Angebote mindestens ein Glas", () => { + const result = parseOfferForm(form({ ...oneTime, honeyJars: "0", honeySharePerJar: "" })); + expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht mindestens ein Glas Honig." }); + }); + + it("nimmt kein festes Honigprodukt mehr an — der Honig wird automatisch gewaehlt", () => { + const result = parseOfferForm(form({ ...oneTime, honeyProductId: "prod_1" })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).not.toHaveProperty("honeyProductId"); }); it("erlaubt ein rein digitales Angebot ohne Honig", () => { const result = parseOfferForm( - form({ ...oneTime, isPhysical: "", honeyProductId: "", honeyJars: "0", honeySharePerJar: "", price: "19" }) + form({ ...oneTime, isPhysical: "", honeyJars: "0", honeySharePerJar: "", price: "19" }) ); expect(result.ok).toBe(true); if (result.ok) expect(carrierPriceCents(result.value)).toBe(1900); diff --git a/src/app/admin/patenschaften/_lib/offer-schema.ts b/src/app/admin/patenschaften/_lib/offer-schema.ts index 66dc313..927a28c 100644 --- a/src/app/admin/patenschaften/_lib/offer-schema.ts +++ b/src/app/admin/patenschaften/_lib/offer-schema.ts @@ -96,11 +96,6 @@ const offerFormSchema = z isActive: z.boolean(), isPhysical: z.boolean(), sortOrder: intField("Reihenfolge", 0, 9999), - honeyProductId: z - .string() - .trim() - .max(64) - .transform((value) => (value === "" ? null : value)), honeyJars: intField("Gläser", 0, 24), cycle: z.union([z.enum(OFFER_CYCLES), z.literal("")]).transform((value) => (value === "" ? null : value)), maxActiveSubscriptions: z.union([z.literal(""), intField("Höchstzahl aktiver Abos", 1, 100_000)]).transform((value) => @@ -111,10 +106,10 @@ const offerFormSchema = z .superRefine((offer, ctx) => { const issue = (message: string) => ctx.addIssue({ code: "custom", message }); if (offer.priceCents <= 0) issue("Der Preis muss größer als 0 € sein."); - if (offer.honeyJars > 0 && !offer.honeyProductId) issue("Gläser ohne Honigprodukt: bitte ein Honigprodukt wählen."); - if (offer.isPhysical && (!offer.honeyProductId || offer.honeyJars < 1)) { - issue("Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas."); - } + // Welcher Honig, waehlt das System automatisch (nie cremig, neueste + // Ernte — lib/sponsorship/honey-selection.ts); hier geht es nur um die + // Menge. Ein festes Honigprodukt nimmt das Formular nicht mehr an. + if (offer.isPhysical && offer.honeyJars < 1) issue("Ein physisches Angebot braucht mindestens ein Glas Honig."); if (offer.kind === "EINMALIG") { // Der Rest nach Abzug des Honiganteils ist der Preis des Traegerprodukts // (Urkunde und Leistung zum Regelsatz) — er muss positiv bleiben. @@ -170,7 +165,6 @@ export function parseOfferForm( isActive: formData.get("isActive") === "on", isPhysical: formData.get("isPhysical") === "on", sortOrder: field(formData, "sortOrder") || "0", - honeyProductId: field(formData, "honeyProductId"), honeyJars: field(formData, "honeyJars") || "0", cycle: field(formData, "cycle"), maxActiveSubscriptions: field(formData, "maxActiveSubscriptions").trim(), diff --git a/src/app/admin/patenschaften/_lib/subscription-view.test.ts b/src/app/admin/patenschaften/_lib/subscription-view.test.ts index bff7623..b894212 100644 --- a/src/app/admin/patenschaften/_lib/subscription-view.test.ts +++ b/src/app/admin/patenschaften/_lib/subscription-view.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@/lib/prisma", () => ({ prisma: {} })); import { nextDeliveryView } from "./subscription-view"; -import { countUndercovered, deliveriesCovered, type HoneyCoverageRow } from "./coverage"; +import { countUndercovered, deliveriesCovered } from "./coverage"; describe("nextDeliveryView", () => { it("vierteljaehrlich: zaehlt die fehlenden bezahlten Monate", () => { @@ -44,17 +44,9 @@ describe("Honigdeckung", () => { expect(deliveriesCovered(10, 0)).toBeNull(); }); - it("zaehlt nur Produkte unter einer Lieferrunde", () => { - const row = (deliveries: number | null): HoneyCoverageRow => ({ - productId: "p", - productName: "Honig", - isActive: true, - stock: 0, - reserved: 0, - free: 0, - jarsPerDelivery: deliveries === null ? 0 : 2, - deliveriesCovered: deliveries, - }); - expect(countUndercovered([row(0), row(1), row(null), row(0)])).toBe(2); + it("meldet hoechstens einen Eintrag: wenn der passende Honig zusammen keine Lieferrunde mehr deckt", () => { + expect(countUndercovered({ deliveriesCovered: 0 })).toBe(1); + expect(countUndercovered({ deliveriesCovered: 1 })).toBe(0); + expect(countUndercovered({ deliveriesCovered: null })).toBe(0); }); }); diff --git a/src/app/admin/patenschaften/abos/page.tsx b/src/app/admin/patenschaften/abos/page.tsx index 91020f2..3764644 100644 --- a/src/app/admin/patenschaften/abos/page.tsx +++ b/src/app/admin/patenschaften/abos/page.tsx @@ -164,7 +164,7 @@ export default async function AdminSponsorshipSubscriptionsPage(props: PageProps
- +
diff --git a/src/app/admin/patenschaften/angebote/[id]/page.tsx b/src/app/admin/patenschaften/angebote/[id]/page.tsx index f84422c..d17f4ae 100644 --- a/src/app/admin/patenschaften/angebote/[id]/page.tsx +++ b/src/app/admin/patenschaften/angebote/[id]/page.tsx @@ -7,16 +7,17 @@ import { getLocale } from "@/i18n/locale"; import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin"; import { updateSponsorshipOfferAction } from "@/app/admin/actions"; import { OfferForm } from "../../_components/offer-form"; -import { getHoneyProductOptions, getOfferOverview } from "../../_lib/offer-overview"; +import { getOfferOverview } from "../../_lib/offer-overview"; export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/admin/patenschaften/angebote/[id]">) { const t = getSponsorshipAdminStrings(await getLocale()); const tf = t.offerForm; const { id } = await props.params; - const [row] = await getOfferOverview(id); + const { + rows: [row], + } = await getOfferOverview(id); if (!row) notFound(); - const { offer, isAvailable, honeyStock, activeSubscriptions } = row; - const honeyOptions = await getHoneyProductOptions(offer.honeyProductId); + const { offer, isAvailable, currentHoney, activeSubscriptions } = row; return (
@@ -30,8 +31,12 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a {isAvailable ? t.offers.available : t.offers.unavailable} {isAvailable ? tf.availableNow : tf.unavailableNow}
- {honeyStock ? ( -

{tf.stockLine(honeyStock.stock, honeyStock.reserved, honeyStock.free)}

+ {currentHoney ? ( +

+ {tf.stockLine(currentHoney.label, currentHoney.stock, currentHoney.reserved, currentHoney.free)} +

+ ) : offer.honeyJars > 0 ? ( +

{tf.noEligibleHoney}

) : null} {offer.kind === "ABO" ? (

{tf.activeSubscriptions(activeSubscriptions, offer.maxActiveSubscriptions)}

@@ -45,7 +50,7 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a

{t.offerForm.newTitle}

- +
); } diff --git a/src/app/admin/patenschaften/angebote/page.tsx b/src/app/admin/patenschaften/angebote/page.tsx index 1786104..779ca51 100644 --- a/src/app/admin/patenschaften/angebote/page.tsx +++ b/src/app/admin/patenschaften/angebote/page.tsx @@ -10,23 +10,16 @@ import { formatPrice } from "@/lib/format"; import { getLocale } from "@/i18n/locale"; import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin"; import { SponsorshipTabs } from "../_components/sponsorship-tabs"; -import { getOfferOverview, type OfferOverviewRow } from "../_lib/offer-overview"; - -/** Je verknuepftem Honigprodukt eine Zeile (mehrere Angebote koennen dasselbe Produkt nutzen). */ -function honeyStockRows(rows: OfferOverviewRow[]) { - const byProduct = new Map(); - for (const { offer, honeyStock } of rows) { - if (!offer.honeyProduct || !honeyStock || byProduct.has(offer.honeyProduct.id)) continue; - byProduct.set(offer.honeyProduct.id, { name: offer.honeyProduct.name, isActive: offer.honeyProduct.isActive, ...honeyStock }); - } - return [...byProduct.entries()].sort(([, a], [, b]) => a.name.localeCompare(b.name, "de")); -} +import { getOfferOverview } from "../_lib/offer-overview"; export default async function AdminSponsorshipOffersPage() { const t = getSponsorshipAdminStrings(await getLocale()); const to = t.offers; - const rows = await getOfferOverview(); - const stockRows = honeyStockRows(rows); + // Honig waehlt das System automatisch (nie cremig, neueste Ernte); gezeigt + // wird hier der passende Honig in der Rangfolge der Wahl. + const { rows, eligibleHoney } = await getOfferOverview(); + // "Erste Wahl" = der Honig, den ein Paket mit einem Glas gerade bekaeme. + const firstChoiceId = eligibleHoney.find((row) => row.free > 0)?.productId; const newOffer = { href: "/admin/patenschaften/angebote/neu", label: to.newOffer }; return ( @@ -50,7 +43,7 @@ export default async function AdminSponsorshipOffersPage() { - {rows.map(({ offer, isAvailable }) => ( + {rows.map(({ offer, isAvailable, currentHoney }) => ( {offer.name} @@ -66,7 +59,11 @@ export default async function AdminSponsorshipOffersPage() { {offer.kind === "ABO" ? {to.perMonth} : null} - {offer.honeyProduct && offer.honeyJars > 0 ? to.jars(offer.honeyJars, offer.honeyProduct.name) : {to.noHoney}} + {offer.honeyJars > 0 ? ( + to.honeyAuto(offer.honeyJars, currentHoney?.label ?? null) + ) : ( + {to.noHoney} + )} {t.medals[offer.medalTier]} @@ -91,27 +88,36 @@ export default async function AdminSponsorshipOffersPage() {
-

{to.stockHeading}

- {stockRows.length === 0 ? ( -

{to.noLinkedHoney}

+

{to.stockHeading}

+

{to.stockIntro}

+ {eligibleHoney.length === 0 ? ( +

{to.noEligibleHoney}

) : (
+ + - {stockRows.map(([productId, row]) => ( - + {eligibleHoney.map((row) => ( + + + diff --git a/src/app/api/checkout/route.test.ts b/src/app/api/checkout/route.test.ts index 606887a..6b02567 100644 --- a/src/app/api/checkout/route.test.ts +++ b/src/app/api/checkout/route.test.ts @@ -74,6 +74,8 @@ vi.mock("@/lib/prisma", () => ({ prisma: db })); const getOffersByProductIds = vi.fn<(ids: string[]) => Promise>>(async () => new Map()); vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: (ids: string[]) => getOffersByProductIds(ids) })); vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsByProduct: vi.fn(async () => new Map()) })); +const loadHoneyCandidates = vi.fn<() => Promise>(async () => []); +vi.mock("@/lib/sponsorship/honey-candidates", () => ({ loadHoneyCandidates: () => loadHoneyCandidates() })); const { POST } = await import("./route"); @@ -121,6 +123,7 @@ beforeEach(() => { getProductsByIds.mockResolvedValue([honey]); getCurrentCustomer.mockResolvedValue(null); getOffersByProductIds.mockResolvedValue(new Map()); + loadHoneyCandidates.mockResolvedValue([]); getShippingRegion.mockResolvedValue("DE"); db.discountCode.findMany.mockResolvedValue([]); db.inventory.updateMany.mockResolvedValue({ count: 1 }); @@ -467,12 +470,45 @@ describe("Checkout-Route", () => { validUntil: null, }; + const honeyCandidate = { id: "p1", name: honey.name, weightGrams: 500, harvestYear: 2026, consistency: "flüssig", inventory: 100, freeStock: 100 }; + beforeEach(() => { getCurrentCustomer.mockResolvedValue({ id: "cust_1", emailVerifiedAt: new Date() }); getOffersByProductIds.mockResolvedValue(new Map([["carrier", offer]])); + loadHoneyCandidates.mockResolvedValue([honeyCandidate]); getProductsByIds.mockImplementation(async (ids: string[]) => [honey, carrier].filter((product) => ids.includes(product.id))); }); + test("die Honigposition nimmt den automatisch gewaehlten Honig — nie cremigen — und reserviert ihn", async () => { + const liquid = { ...honey, id: "p2", name: "Sommertracht 2026", slug: "sommertracht-2026" }; + loadHoneyCandidates.mockResolvedValue([ + { ...honeyCandidate, consistency: "feincremig", harvestYear: 2027 }, + { ...honeyCandidate, id: "p2", name: liquid.name }, + ]); + getProductsByIds.mockImplementation(async (ids: string[]) => [honey, liquid, carrier].filter((product) => ids.includes(product.id))); + + const response = await post({ ...validPayload, items: [packageItem] }); + + expect(response.status).toBe(200); + // Nur die Produktdaten des nicht-cremigen Honigs werden nachgeladen. + expect(getProductsByIds).toHaveBeenCalledWith(["p2"]); + const items = db.order.create.mock.calls[0][0].data.items.create; + expect(items).toContainEqual(expect.objectContaining({ productId: "p2", sponsorshipRole: "HONEY", quantity: 3, productName: "Sommertracht 2026 – Wabenfreund" })); + expect(db.inventory.updateMany).toHaveBeenCalledWith({ + where: { productId: "p2", quantity: { gte: 3 } }, + data: { quantity: { decrement: 3 } }, + }); + }); + + test("ohne passenden Honig ist das Paket nicht bestellbar", async () => { + loadHoneyCandidates.mockResolvedValue([{ ...honeyCandidate, consistency: "cremig" }]); + + const response = await post({ ...validPayload, items: [packageItem] }); + + expect(response.status).toBe(400); + expect(db.order.create).not.toHaveBeenCalled(); + }); + test("Mengenrabatt: Glaeser aus einem Patenschaftspaket zaehlen nicht zur Schwelle", async () => { // 2 einzelne Glaeser + 3 Paketglaeser = 5 physische, aber nur 2 rabattfaehige. db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]); diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 7c16076..d6c42b4 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -24,6 +24,8 @@ import { vatRateForProduct } from "@/lib/vat"; import { getLocale } from "@/i18n/locale"; import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand"; import { getOffersByProductIds } from "@/lib/sponsorship/offers"; +import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates"; +import { isCreamy } from "@/lib/sponsorship/honey-selection"; import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation"; class OutOfStockError extends Error {} @@ -231,9 +233,16 @@ export async function POST(request: Request) { const productIds = [...new Set(input.items.map((item) => item.productId))]; const locale = await getLocale(); const offersByProduct = await getOffersByProductIds(productIds, locale); - const honeyIds = [...offersByProduct.values()] - .map((offer) => offer.honeyProduct?.id) - .filter((id): id is string => Boolean(id) && !productIds.includes(id as string)); + // Honig fuer Pakete waehlt der Checkout selbst, im Moment der Bestellung + // (lib/sponsorship/honey-selection.ts) — dafuer braucht er alle Kandidaten + // mit freiem Bestand und deren Produktdaten (Charge, Gewicht, Steuersatz). + // Cremiger Honig kommt nie in ein Paket, seine Produktdaten braucht es + // deshalb nicht. + const needsHoney = [...offersByProduct.values()].some((offer) => offer.honeyJars > 0); + const honeyCandidates = needsHoney ? await loadHoneyCandidates() : []; + const honeyIds = honeyCandidates + .filter((candidate) => !isCreamy(candidate.consistency) && !productIds.includes(candidate.id)) + .map((candidate) => candidate.id); const products = await getProductsByIds(productIds); const honeyProducts = honeyIds.length > 0 ? await getProductsByIds(honeyIds) : []; @@ -290,6 +299,7 @@ export async function POST(request: Request) { cartItems: input.items, products: productMap, offers: offersByProduct, + honeyCandidates, vatRateFor: (product) => vatRateForProduct(product, process.env), locale, now: new Date(), @@ -329,9 +339,16 @@ export async function POST(request: Request) { // Fuer Abos reservierte Glaeser duerfen weder einzeln noch im Paket // verkauft werden (lib/sponsorship-subscription/reservation.ts). Vor der - // Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht - // durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung - // bleibt das bedingte Update in der Transaktion. + // Transaktion gelesen, wie bisher: die verbindliche Bestandspruefung bleibt + // das bedingte Update in der Transaktion. Seit die Abo-Glaeser automatisch + // auf dem gerade gewaehlten Honig liegen, kann eine gleichzeitige Bestellung + // die Reservierung zwar auf einen anderen Honig verschieben; das bedingte + // Update haelt aber den Honig, auf dem sie laut dieser Momentaufnahme + // liegt, ueber ihrem Anteil. Die Reservierung in die Serializable- + // Transaktion zu ziehen, wuerde jede Bestellung mit jeder anderen + // Honigbuchung kollidieren lassen — bei Andrang deutlich mehr Abbrueche + // fuer einen Randfall, den im schlimmsten Fall eine zurueckgestellte + // Abo-Lieferung in der Aufmerksamkeitsliste auffaengt. const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId)); // Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand — diff --git a/src/i18n/sponsorship/admin.ts b/src/i18n/sponsorship/admin.ts index 3e7b167..af54677 100644 --- a/src/i18n/sponsorship/admin.ts +++ b/src/i18n/sponsorship/admin.ts @@ -186,17 +186,23 @@ const de = { available: "Bestellbar", unavailable: "Nicht bestellbar", perMonth: "/ Monat", - jars: (n: number, product: string) => `${n} × ${product}`, + honeyAuto: (n: number, current: string | null) => + `${n} × automatisch ${current ? `(aktuell: ${current})` : "(derzeit kein passender Honig)"}`, noHoney: "ohne Honig", edit: "Bearbeiten", harvestWindows: "Erntefenster", empty: "Noch keine Angebote.", - stockHeading: "Honigbestand der verknüpften Produkte", + stockHeading: "Honig für Pakete und Abos (automatisch gewählt)", + stockIntro: + "Nur nicht-cremiger, aktiver Honig. Gewählt wird die neueste Ernte mit genug freiem Bestand, bei gleicher Ernte der größere freie Bestand — bei jeder Bestellung und Abo-Lieferung neu.", colProduct: "Produkt", + colYear: "Ernte", + colConsistency: "Konsistenz", colStock: "Bestand", colReserved: "Für Abos reserviert", colFree: "Frei verkäuflich", - noLinkedHoney: "Kein Angebot ist mit einem Honigprodukt verknüpft.", + firstChoice: "erste Wahl", + noEligibleHoney: "Kein aktiver, nicht-cremiger Honig vorhanden — Angebote mit Honig sind nicht bestellbar.", }, offerForm: { newTitle: "Neues Patenschafts-Angebot", @@ -221,9 +227,11 @@ const de = { carrierPrice: (price: string) => `Trägerprodukt derzeit: ${price} (Paketpreis minus Honiganteil).`, sectionHoney: "Honig und Versand", isPhysical: "Physisch: gedruckte Urkunde, Honig, Versand", - honeyProduct: "Honigprodukt", - honeyProductNone: "Kein Honig", - inactiveSuffix: " (inaktiv)", + honey: "Honig", + honeyAuto: (current: string | null) => + current ? `automatisch (aktuell: ${current})` : "automatisch (derzeit kein passender Honig mit genug Bestand)", + honeyAutoHint: + "Pakete und Abos bekommen immer nicht-cremigen Honig der neuesten Ernte mit genug freiem Bestand. Gewählt wird bei jeder Bestellung und Abo-Lieferung neu.", honeyJars: "Gläser (Einmalig: im Paket, Abo: je Lieferung)", sectionAbo: "Abo", cycle: "Lieferzyklus (nur Abo)", @@ -238,9 +246,10 @@ const de = { save: "Speichern", availabilityHeading: "Verfügbarkeit", availableNow: "Derzeit bestellbar.", - unavailableNow: "Derzeit nicht bestellbar: Angebot inaktiv, Honig fehlt oder Kontingent voll.", - stockLine: (stock: number, reserved: number, free: number) => - `Honigbestand ${stock}, davon ${reserved} für Abos reserviert, ${free} frei.`, + unavailableNow: "Derzeit nicht bestellbar: Angebot inaktiv, kein passender Honig oder Kontingent voll.", + stockLine: (label: string, stock: number, reserved: number, free: number) => + `Honig aktuell: ${label} – Bestand ${stock}, davon ${reserved} für Abos reserviert, ${free} frei.`, + noEligibleHoney: "Derzeit gibt es keinen nicht-cremigen Honig mit genug freiem Bestand für dieses Angebot.", activeSubscriptions: (n: number, max: number | null) => max === null ? `${n} aktive Abos (unbegrenzt).` : `${n} von ${max} Abo-Plätzen belegt.`, }, @@ -283,16 +292,18 @@ const de = { empty: "Noch keine Abos.", emptyFiltered: "Keine Abos zu diesem Filter.", coverageHeading: "Honigdeckung der Abos", - coverageIntro: "„Deckt“ = wie viele vierteljährliche Lieferrunden der Bestand noch bedient.", + coverageIntro: + "Abos bekommen automatisch nicht-cremigen Honig, gezeigt in der Reihenfolge der Wahl. „Deckt“ = wie viele vierteljährliche Lieferrunden dieser Honig zusammen noch bedient.", colProduct: "Honigprodukt", + colYear: "Ernte", colStock: "Bestand", colReserved: "Reserviert", colFree: "Frei", - colPerDelivery: "Gläser je Lieferrunde", - colCovers: "Deckt", + perDelivery: (n: number) => `Gläser je vierteljährlicher Lieferrunde: ${n}`, + coversLabel: "Deckt", covers: (n: number) => `${n} ${plural(n, "Lieferung", "Lieferungen")}`, coverageWarning: "unter einer Lieferung", - noCoverage: "Kein Abo-Angebot ist mit einem Honigprodukt verknüpft.", + noCoverage: "Kein aktiver, nicht-cremiger Honig vorhanden — Abo-Lieferungen werden zurückgestellt.", sepaHeading: "SEPA-Neukunden-Frist", sepaIntro: "Bei SEPA zählt die erste Abo-Zahlung eines Kontos erst nach dieser Frist für den Lieferanspruch (Schutz vor Rückbuchungen in der Acht-Wochen-Frist).", sepaLabel: "Frist in Tagen (0 = aus)", @@ -323,7 +334,7 @@ const de = { attention: { failed: (n: number) => `${n} ${plural(n, "Patenschaft braucht", "Patenschaften brauchen")} Hilfe (Fehler bei der Ausstellung)`, toPrint: (n: number) => `${n} ${plural(n, "Urkunde wartet", "Urkunden warten")} seit über zwei Tagen auf den Druck`, - coverage: (n: number) => `${n} ${plural(n, "Honigprodukt deckt", "Honigprodukte decken")} keine volle Abo-Lieferung mehr`, + coverage: "Nicht-cremiger Honig deckt keine volle vierteljährliche Abo-Lieferrunde mehr", delinquent: (n: number) => `${n} ${plural(n, "Abo ist", "Abos sind")} im Verzug`, }, order: { @@ -510,17 +521,22 @@ const en: SponsorshipAdminStrings = { available: "Available", unavailable: "Not available", perMonth: "/ month", - jars: (n, product) => `${n} × ${product}`, + honeyAuto: (n, current) => `${n} × automatic ${current ? `(currently: ${current})` : "(no suitable honey right now)"}`, noHoney: "no honey", edit: "Edit", harvestWindows: "Harvest windows", empty: "No offers yet.", - stockHeading: "Honey stock of linked products", + stockHeading: "Honey for packages and subscriptions (chosen automatically)", + stockIntro: + "Only active honey that is not creamy. The newest harvest with enough free stock is chosen, on equal harvests the larger free stock — anew for every order and subscription delivery.", colProduct: "Product", + colYear: "Harvest", + colConsistency: "Consistency", colStock: "Stock", colReserved: "Reserved for subscriptions", colFree: "Free to sell", - noLinkedHoney: "No offer is linked to a honey product.", + firstChoice: "first choice", + noEligibleHoney: "No active honey that is not creamy — offers with honey are not available.", }, offerForm: { newTitle: "New sponsorship offer", @@ -545,9 +561,10 @@ const en: SponsorshipAdminStrings = { carrierPrice: (price) => `Carrier product currently: ${price} (package price minus honey share).`, sectionHoney: "Honey and shipping", isPhysical: "Physical: printed certificate, honey, shipping", - honeyProduct: "Honey product", - honeyProductNone: "No honey", - inactiveSuffix: " (inactive)", + honey: "Honey", + honeyAuto: (current) => (current ? `automatic (currently: ${current})` : "automatic (no suitable honey with enough stock right now)"), + honeyAutoHint: + "Packages and subscriptions always get non-creamy honey from the newest harvest with enough free stock. It is chosen anew for every order and subscription delivery.", honeyJars: "Jars (one-time: in the package, subscription: per delivery)", sectionAbo: "Subscription", cycle: "Delivery cycle (subscription only)", @@ -562,8 +579,10 @@ const en: SponsorshipAdminStrings = { save: "Save", availabilityHeading: "Availability", availableNow: "Currently available.", - unavailableNow: "Currently not available: offer inactive, honey missing or quota full.", - stockLine: (stock, reserved, free) => `Honey stock ${stock}, of which ${reserved} reserved for subscriptions, ${free} free.`, + unavailableNow: "Currently not available: offer inactive, no suitable honey or quota full.", + stockLine: (label, stock, reserved, free) => + `Honey currently: ${label} – stock ${stock}, of which ${reserved} reserved for subscriptions, ${free} free.`, + noEligibleHoney: "There is currently no non-creamy honey with enough free stock for this offer.", activeSubscriptions: (n, max) => (max === null ? `${n} active subscriptions (unlimited).` : `${n} of ${max} subscription slots taken.`), }, harvest: { @@ -605,16 +624,18 @@ const en: SponsorshipAdminStrings = { empty: "No subscriptions yet.", emptyFiltered: "No subscriptions match this filter.", coverageHeading: "Honey coverage of subscriptions", - coverageIntro: "“Covers” = how many quarterly delivery rounds the stock can still serve.", + coverageIntro: + "Subscriptions automatically get honey that is not creamy, listed in the order it is chosen. “Covers” = how many quarterly delivery rounds this honey can still serve together.", colProduct: "Honey product", + colYear: "Harvest", colStock: "Stock", colReserved: "Reserved", colFree: "Free", - colPerDelivery: "Jars per delivery round", - colCovers: "Covers", + perDelivery: (n) => `Jars per quarterly delivery round: ${n}`, + coversLabel: "Covers", covers: (n) => `${n} ${plural(n, "delivery", "deliveries")}`, coverageWarning: "below one delivery", - noCoverage: "No subscription offer is linked to a honey product.", + noCoverage: "No active honey that is not creamy — subscription deliveries are deferred.", sepaHeading: "SEPA new-customer hold", sepaIntro: "With SEPA, an account's first subscription payment only counts towards delivery after this period (protection against charge-backs within the eight-week window).", sepaLabel: "Hold in days (0 = off)", @@ -645,7 +666,7 @@ const en: SponsorshipAdminStrings = { attention: { failed: (n) => `${n} ${plural(n, "sponsorship needs", "sponsorships need")} help (issuing failed)`, toPrint: (n) => `${n} ${plural(n, "certificate has", "certificates have")} been waiting to be printed for over two days`, - coverage: (n) => `${n} ${plural(n, "honey product no longer covers", "honey products no longer cover")} a full subscription delivery`, + coverage: "Non-creamy honey no longer covers a full quarterly subscription delivery round", delinquent: (n) => `${n} ${plural(n, "subscription is", "subscriptions are")} delinquent`, }, order: { diff --git a/src/i18n/sponsorship/shop.ts b/src/i18n/sponsorship/shop.ts index e2132e2..06721f5 100644 --- a/src/i18n/sponsorship/shop.ts +++ b/src/i18n/sponsorship/shop.ts @@ -179,7 +179,7 @@ const de: SponsorshipShopStrings = { }, { title: "Honig dazu, wenn du magst", - text: "Je nach Paket gehört Wabenhain-Honig dazu: einmal im Geschenkpaket oder regelmäßig im Abo.", + text: "Je nach Paket gehört Wabenhain-Honig der aktuellen Ernte (flüssig) dazu: einmal im Geschenkpaket oder regelmäßig im Abo.", }, ], }, @@ -396,7 +396,7 @@ const en: SponsorshipShopStrings = { }, { title: "Honey too, if you like", - text: "Depending on the package, Wabenhain honey comes with it: once in a gift box or regularly with a subscription.", + text: "Depending on the package, Wabenhain honey from the current harvest (liquid) comes with it: once in a gift box or regularly with a subscription.", }, ], }, diff --git a/src/lib/sponsorship-subscription/deliveries.test.ts b/src/lib/sponsorship-subscription/deliveries.test.ts index c50a5ad..6d69ca5 100644 --- a/src/lib/sponsorship-subscription/deliveries.test.ts +++ b/src/lib/sponsorship-subscription/deliveries.test.ts @@ -10,6 +10,7 @@ const m = vi.hoisted(() => ({ sponsorshipSubscription: { findMany: vi.fn() }, subscriptionPayment: { updateMany: vi.fn() }, inventory: { updateMany: vi.fn() }, + product: { findUnique: vi.fn() }, order: { create: vi.fn() }, customer: { findUniqueOrThrow: vi.fn() }, $transaction: vi.fn(), @@ -17,6 +18,7 @@ const m = vi.hoisted(() => ({ sendMail: vi.fn(), recordOrderSale: vi.fn(), scheduleApiCoreStockSync: vi.fn(), + loadHoneyStock: vi.fn(), })); vi.mock("@/lib/prisma", () => ({ prisma: m.db })); @@ -24,6 +26,7 @@ vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail })); vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" })); vi.mock("@/lib/apicore-stock/stock-events", () => ({ recordOrderSale: m.recordOrderSale })); vi.mock("@/lib/apicore-stock/schedule", () => ({ scheduleApiCoreStockSync: m.scheduleApiCoreStockSync })); +vi.mock("@/lib/sponsorship/honey-stock", () => ({ loadHoneyStock: m.loadHoneyStock })); vi.mock("@/lib/sponsorship-subscription/emails", () => ({ subscriptionDeliveryEmail: vi.fn(async () => ({ subject: "Lieferung", html: "

", text: "" })), })); @@ -67,13 +70,23 @@ const delivery = { locale: "de", customerId: "cust_1", offerId: "offer_1", - offer: { - name: "Vierteljährlich", - honeyProduct: { id: "honey_1", name: "Frühtracht 500 g", batchId: "batch_1", weightGrams: 500, category: "HONIG", vatRatePercent: null }, - }, + offer: { name: "Vierteljährlich" }, }, }; +/** Rohbestand je Honig (ohne Abo-Reservierung — die ist ja fuer diese Lieferung da). */ +const stockRow = (id: string, overrides: Record = {}) => ({ + id, + name: id, + weightGrams: 500, + harvestYear: 2026, + consistency: "flüssig", + inventory: 10, + freeStock: 10, + ...overrides, +}); +const honeyProduct = (id: string) => ({ id, name: "Frühtracht 500 g", batchId: "batch_1", weightGrams: 500, category: "HONIG", vatRatePercent: null }); + beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, "info").mockImplementation(() => {}); @@ -92,6 +105,8 @@ beforeEach(() => { addresses: [ADDRESS], }); m.db.inventory.updateMany.mockResolvedValue({ count: 1 }); + m.loadHoneyStock.mockResolvedValue([stockRow("honey_1")]); + m.db.product.findUnique.mockImplementation(async ({ where }: { where: { id: string } }) => honeyProduct(where.id)); m.db.order.create.mockResolvedValue({ id: "order_9", orderNumber: "WH-2026-00000009" }); m.sendMail.mockResolvedValue(true); }); @@ -123,6 +138,37 @@ describe("createDueDeliveries", () => { expect(m.sendMail).toHaveBeenCalledOnce(); }); + test("waehlt den Honig bei der Lieferung automatisch: nie cremig, neueste Ernte mit genug Bestand", async () => { + m.loadHoneyStock.mockResolvedValue([ + stockRow("fein", { consistency: "feincremig", harvestYear: 2027, inventory: 50, freeStock: 50 }), + stockRow("knapp", { harvestYear: 2026, inventory: 1, freeStock: 1 }), + stockRow("raps", { harvestYear: 2025 }), + ]); + + const result = await createDueDeliveries(NOW); + + expect(result).toEqual({ created: 1, deferred: 0 }); + expect(m.db.inventory.updateMany).toHaveBeenCalledWith({ + where: { productId: "raps", quantity: { gte: 2 } }, + data: { quantity: { decrement: 2 } }, + }); + expect(m.db.order.create.mock.calls[0][0].data.items.create).toMatchObject({ productId: "raps", quantity: 2 }); + expect(m.scheduleApiCoreStockSync).toHaveBeenCalledWith(["raps"]); + }); + + test("gibt es nur cremigen Honig, wird die Lieferung zurueckgestellt", async () => { + m.loadHoneyStock.mockResolvedValue([stockRow("fein", { consistency: "cremig", inventory: 50, freeStock: 50 })]); + + const result = await createDueDeliveries(NOW); + + expect(result).toEqual({ created: 0, deferred: 1 }); + expect(m.db.inventory.updateMany).not.toHaveBeenCalled(); + expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({ + where: { id: "del_1", orderId: null }, + data: { status: "ZURUECKGESTELLT", note: NOTE_NO_STOCK }, + }); + }); + test("zwei bezahlte Monate: keine Lieferung", async () => { m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription([paid("p1", 40), paid("p2", 10)])]); diff --git a/src/lib/sponsorship-subscription/deliveries.ts b/src/lib/sponsorship-subscription/deliveries.ts index 9ac9edc..3a18629 100644 --- a/src/lib/sponsorship-subscription/deliveries.ts +++ b/src/lib/sponsorship-subscription/deliveries.ts @@ -8,28 +8,39 @@ import { isLocale, defaultLocale } from "@/i18n/config"; import { computeDeliveryEntitlement } from "@/lib/sponsorship-subscription/entitlement"; import { generateOrderNumber, orderContactForCustomer } from "@/lib/sponsorship-subscription/lookup"; import { subscriptionDeliveryEmail } from "@/lib/sponsorship-subscription/emails"; +import { pickHoneyProduct } from "@/lib/sponsorship/honey-selection"; +import { loadHoneyStock } from "@/lib/sponsorship/honey-stock"; +import type { ProductCategory } from "@/generated/prisma/client"; /** * Honiglieferungen im Abo (Spec "Lieferauftraege"). * * Ist der Anspruch erfuellt (entitlement.ts), entsteht ein * Lieferauftrag als normale Bestellung: Status BEZAHLT, Betrag 0 €, Versand an - * die Standardadresse, Honigpositionen des verknuepften Produkts. Er - * erscheint in der bestehenden Pack- und DHL-Liste; der Honig wurde mit den - * Monatsrechnungen bereits als Vorauszahlung berechnet. + * die Standardadresse, eine Honigposition. Er erscheint in der bestehenden + * Pack- und DHL-Liste; der Honig wurde mit den Monatsrechnungen bereits als + * Vorauszahlung berechnet. + * + * Welcher Honig, entscheidet die automatische Wahl beim Anlegen der Lieferung + * (lib/sponsorship/honey-selection.ts: nie cremig, neueste Ernte, genug + * Bestand) — dieselbe Regel und derselbe Rohbestand (ohne Abo-Reservierung), + * mit denen reservation.ts die Abo-Glaeser einem Honig zuordnet. Gegen den + * Rohbestand, weil die Reservierung genau fuer diese Lieferung da ist und + * sie sonst selbst blockieren wuerde. * * Schutz gegen doppelte Laeufe: die Lieferung beansprucht ihre Zahlungen * (deliveryId) in derselben Transaktion, in der sie entsteht. Ein zweiter, * gleichzeitiger Lauf findet die Zahlungen verbraucht und verwirft seine * Lieferung. Die Bestellung wird ueber orderId = null beansprucht. * - * Fehlt die Adresse oder der Bestand, bleibt die Lieferung ZURUECKGESTELLT - * (mit Hinweis fuer die Aufmerksamkeitsliste) und haelt ihre Zahlungen; jeder - * weitere Lauf versucht sie erneut. + * Fehlt die Adresse oder ein passender Honig mit genug Bestand (etwa weil nur + * noch cremiger da ist), bleibt die Lieferung ZURUECKGESTELLT (mit Hinweis + * fuer die Aufmerksamkeitsliste) und haelt ihre Zahlungen; jeder weitere Lauf + * versucht sie erneut — dann mit der dann gueltigen Honigwahl. */ export const NOTE_NO_ADDRESS = "Keine Standardadresse im Kundenkonto – Lieferung zurückgestellt, bitte Adresse erfragen."; -export const NOTE_NO_STOCK = "Bestand des Honigprodukts reicht nicht – Lieferung zurückgestellt, bitte nachlegen."; +export const NOTE_NO_STOCK = "Kein nicht-cremiger Honig mit genug Bestand – Lieferung zurückgestellt, bitte flüssigen Honig nachlegen."; const NOTE_PENDING = "Wird angelegt."; const NOTE_PAUSED = "Abo im Verzug oder beendet – Lieferung ruht."; @@ -49,20 +60,32 @@ function loadDelivery(id: string) { locale: true, customerId: true, offerId: true, - offer: { - select: { - name: true, - honeyProduct: { - select: { id: true, name: true, batchId: true, weightGrams: true, category: true, vatRatePercent: true }, - }, - }, - }, + offer: { select: { name: true } }, }, }, }, }); } +interface DeliveryHoney { + id: string; + name: string; + batchId: string | null; + weightGrams: number; + category: ProductCategory; + vatRatePercent: number | null; +} + +/** Der Honig, den die automatische Wahl fuer diese Lieferung nimmt — mit den Daten fuer die Bestellposition. */ +async function pickDeliveryHoney(jars: number): Promise { + const picked = pickHoneyProduct(await loadHoneyStock(), jars); + if (!picked) return null; + return prisma.product.findUnique({ + where: { id: picked.id }, + select: { id: true, name: true, batchId: true, weightGrams: true, category: true, vatRatePercent: true }, + }); +} + async function defer(deliveryId: string, note: string): Promise { await prisma.subscriptionDelivery.updateMany({ where: { id: deliveryId, orderId: null }, @@ -77,7 +100,7 @@ export async function fulfillDelivery(deliveryId: string): Promise { // Nur die eigene Menge pruefen: die Abo-Reservierung dieses Abos ist - // genau fuer diese Lieferung da und wird hier verbraucht. + // genau fuer diese Lieferung da und wird hier verbraucht. Hat jemand den + // gewaehlten Honig inzwischen geleert, wird zurueckgestellt und beim + // naechsten Lauf neu gewaehlt. const reserved = await tx.inventory.updateMany({ where: { productId: honey.id, quantity: { gte: delivery.jars } }, data: { quantity: { decrement: delivery.jars } }, @@ -213,7 +238,7 @@ export async function createDueDeliveries(now: Date): Promise status: { in: ["AKTIV", "GEKUENDIGT"] }, // Hoechstens eine offene Lieferung je Abo: erst die zurueckgestellte erledigen. deliveries: { none: { status: "ZURUECKGESTELLT", orderId: null } }, - offer: { honeyJars: { gt: 0 }, honeyProductId: { not: null }, cycle: { not: null } }, + offer: { honeyJars: { gt: 0 }, cycle: { not: null } }, }, select: { id: true, diff --git a/src/lib/sponsorship-subscription/reservation.test.ts b/src/lib/sponsorship-subscription/reservation.test.ts index 46cd946..38184ab 100644 --- a/src/lib/sponsorship-subscription/reservation.test.ts +++ b/src/lib/sponsorship-subscription/reservation.test.ts @@ -1,25 +1,50 @@ /** - * Abo-Reservierung im Stapel: Produktlisten brauchen den freien Bestand - * vieler Produkte auf einmal — mit festen drei Abfragen statt drei je Produkt. + * Abo-Reservierung: Abos haben keinen festen Honig mehr. Ihre Glaeser liegen + * auf dem Honig, den die automatische Wahl fuer sie treffen wuerde (nie + * cremig, neueste Ernte, genug Lagerbestand) — berechnet mit festen vier + * Abfragen, egal wie viele Produkte gefragt sind. */ import { describe, expect, test, vi } from "vitest"; vi.mock("@/lib/prisma", () => ({ prisma: {} })); -const { reservedJarsByProduct, reservedJarsForProduct, availableForSale, availableForSaleByProduct } = await import("./reservation"); +const { reservedJarsByProduct, reservedJarsForProduct, availableForSale, availableForSaleByProduct, subscriptionReservedJars } = + await import("./reservation"); const NOW = new Date("2026-10-16T12:00:00.000Z"); +interface FakeProduct { + id: string; + consistency: string; + year: number; + quantity: number; +} + +function productRow(product: FakeProduct) { + return { + id: product.id, + name: product.id, + weightGrams: 500, + consistency: product.consistency, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + inventory: { quantity: product.quantity }, + harvest: { year: product.year }, + batch: null, + }; +} + function fakeDb(data: { - quarterly?: { offer: { honeyProductId: string | null; honeyJars: number } }[]; - windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number; offer: { honeyProductId: string | null } }[]; + quarterly?: { offer: { honeyJars: number } }[]; + windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number }[]; deliveries?: { jars: number; dueAt: Date; subscription: { offerId: string } }[]; + products?: FakeProduct[]; inventory?: { productId: string; quantity: number }[]; }) { return { sponsorshipSubscription: { findMany: vi.fn(async () => data.quarterly ?? []) }, harvestWindow: { findMany: vi.fn(async () => data.windows ?? []) }, subscriptionDelivery: { findMany: vi.fn(async () => data.deliveries ?? []) }, + product: { findMany: vi.fn(async () => (data.products ?? []).map(productRow)) }, inventory: { findMany: vi.fn(async () => data.inventory ?? []), findUnique: vi.fn(async ({ where }: { where: { productId: string } }) => @@ -31,21 +56,20 @@ function fakeDb(data: { type Db = Parameters[2]; -describe("reservedJarsByProduct", () => { - test("summiert vierteljaehrliche Abos und offene Erntefenster je Produkt in drei Abfragen", async () => { +const WALD = { id: "wald", consistency: "flüssig", year: 2026, quantity: 20 }; +const RAPS = { id: "raps", consistency: "flüssig", year: 2025, quantity: 50 }; +const FEIN = { id: "fein", consistency: "feincremig", year: 2027, quantity: 99 }; + +describe("subscriptionReservedJars", () => { + test("summiert vierteljaehrliche Abos und den offenen Rest der Erntefenster", async () => { const db = fakeDb({ - quarterly: [ - { offer: { honeyProductId: "wald", honeyJars: 2 } }, - { offer: { honeyProductId: "wald", honeyJars: 2 } }, - { offer: { honeyProductId: "raps", honeyJars: 3 } }, - ], + quarterly: [{ offer: { honeyJars: 2 } }, { offer: { honeyJars: 2 } }, { offer: { honeyJars: 3 } }], windows: [ { offerId: "ernte", startsOn: new Date("2026-10-01T00:00:00.000Z"), endsOn: new Date("2026-10-31T00:00:00.000Z"), plannedJars: 10, - offer: { honeyProductId: "wald" }, }, ], deliveries: [ @@ -55,33 +79,58 @@ describe("reservedJarsByProduct", () => { ], }); - const result = await reservedJarsByProduct(["wald", "raps", "linde"], NOW, db as unknown as Db); - - expect(result.get("wald")).toBe(2 + 2 + (10 - 4)); - expect(result.get("raps")).toBe(3); - expect(result.get("linde")).toBe(0); - expect(db.sponsorshipSubscription.findMany).toHaveBeenCalledOnce(); - expect(db.harvestWindow.findMany).toHaveBeenCalledOnce(); - expect(db.subscriptionDelivery.findMany).toHaveBeenCalledOnce(); + expect(await subscriptionReservedJars(NOW, db as unknown as Db)).toBe(2 + 2 + 3 + (10 - 4)); }); test("ein ueberliefertes Erntefenster reserviert nichts mehr (nie negativ)", async () => { const db = fakeDb({ windows: [ - { - offerId: "ernte", - startsOn: new Date("2026-10-01T00:00:00.000Z"), - endsOn: new Date("2026-10-31T00:00:00.000Z"), - plannedJars: 3, - offer: { honeyProductId: "wald" }, - }, + { offerId: "ernte", startsOn: new Date("2026-10-01T00:00:00.000Z"), endsOn: new Date("2026-10-31T00:00:00.000Z"), plannedJars: 3 }, ], deliveries: [{ jars: 5, dueAt: new Date("2026-10-31T20:00:00.000Z"), subscription: { offerId: "ernte" } }], }); + expect(await subscriptionReservedJars(NOW, db as unknown as Db)).toBe(0); + }); +}); + +describe("reservedJarsByProduct", () => { + test("legt alle Abo-Glaeser auf den automatisch gewaehlten Honig — nie auf cremigen", async () => { + const db = fakeDb({ + quarterly: [{ offer: { honeyJars: 2 } }, { offer: { honeyJars: 2 } }], + windows: [ + { offerId: "ernte", startsOn: new Date("2026-10-01T00:00:00.000Z"), endsOn: new Date("2026-10-31T00:00:00.000Z"), plannedJars: 6 }, + ], + products: [WALD, RAPS, FEIN], + }); + + const result = await reservedJarsByProduct(["wald", "raps", "fein", "linde"], NOW, db as unknown as Db); + + expect(Object.fromEntries(result)).toEqual({ wald: 10, raps: 0, fein: 0, linde: 0 }); + expect(db.sponsorshipSubscription.findMany).toHaveBeenCalledOnce(); + expect(db.harvestWindow.findMany).toHaveBeenCalledOnce(); + expect(db.subscriptionDelivery.findMany).toHaveBeenCalledOnce(); + expect(db.product.findMany).toHaveBeenCalledOnce(); + }); + + test("reicht der neueste Honig nicht fuer alle Abos, liegt die Reservierung auf dem, der reicht", async () => { + const db = fakeDb({ + quarterly: Array.from({ length: 6 }, () => ({ offer: { honeyJars: 2 } })), + products: [{ ...WALD, quantity: 5 }, RAPS], + }); + + const result = await reservedJarsByProduct(["wald", "raps"], NOW, db as unknown as Db); + + expect(Object.fromEntries(result)).toEqual({ wald: 0, raps: 12 }); + }); + + test("ohne Abo-Bedarf wird der Honigbestand gar nicht erst geladen", async () => { + const db = fakeDb({ products: [WALD] }); + const result = await reservedJarsByProduct(["wald"], NOW, db as unknown as Db); expect(result.get("wald")).toBe(0); + expect(db.product.findMany).not.toHaveBeenCalled(); }); test("ohne Produkte keine Abfrage", async () => { @@ -95,7 +144,8 @@ describe("reservedJarsByProduct", () => { test("Einzelabfrage und freier Bestand bauen auf dem Stapel auf", async () => { const db = fakeDb({ - quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 2 } }], + quarterly: [{ offer: { honeyJars: 2 } }], + products: [{ ...WALD, quantity: 5 }], inventory: [{ productId: "wald", quantity: 5 }], }); @@ -105,7 +155,9 @@ describe("reservedJarsByProduct", () => { test("freier Bestand im Stapel: Lager minus Reservierung, ohne Lagerzeile 0", async () => { const db = fakeDb({ - quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 4 } }], + quarterly: [{ offer: { honeyJars: 4 } }], + // Echter Fehlbestand: 4 Glaeser reserviert, nur 3 da — frei bleibt 0, nicht -1. + products: [{ ...WALD, quantity: 3 }], inventory: [ { productId: "wald", quantity: 3 }, { productId: "raps", quantity: 7 }, diff --git a/src/lib/sponsorship-subscription/reservation.ts b/src/lib/sponsorship-subscription/reservation.ts index a6f0553..478ad7b 100644 --- a/src/lib/sponsorship-subscription/reservation.ts +++ b/src/lib/sponsorship-subscription/reservation.ts @@ -1,4 +1,6 @@ import { prisma } from "@/lib/prisma"; +import { allocateReservedJars, type HoneyCandidate } from "@/lib/sponsorship/honey-selection"; +import { loadHoneyStock } from "@/lib/sponsorship/honey-stock"; /** * Abo-Reservierung von Honig. @@ -18,30 +20,31 @@ import { prisma } from "@/lib/prisma"; * - Ernte: die im Admin je Erntefenster geplanten Abo-Glaeser, solange das * Fenster nicht vorbei ist, abzueglich der in diesem Fenster bereits * angelegten Lieferungen (deren Glaeser sind dann schon vom Lager gebucht). + * + * Auf WELCHEM Honig die Glaeser liegen, steht nicht mehr am Angebot + * (SponsorshipOffer.honeyProductId wird nicht mehr gelesen): Abos bekommen + * automatisch nicht-cremigen Honig der neuesten Ernte + * (lib/sponsorship/honey-selection.ts). Die Summe aller Abo-Glaeser liegt + * deshalb auf dem Honig, den diese Regel fuer sie waehlt — gemessen am + * Lagerbestand OHNE Reservierung, denn die Reservierung ist ja genau fuer + * diese Glaeser da (allocateReservedJars). Abo-Lieferungen (deliveries.ts) + * waehlen mit derselben Regel und demselben Rohbestand. + * + * Folge fuer Shop und Checkout: die Reservierung kann von einem Honig auf + * einen anderen wandern, wenn sich Bestaende aendern (neue Ernte + * eingebucht, ein Honig leerverkauft). Weil jede Abfrage neu rechnet, bleibt + * die Summe trotzdem geschuetzt: der Checkout bucht nur, wenn danach + * "Bestand >= Abo-Reservierung" gilt (api/checkout/route.ts), der gewaehlte + * Honig faellt durch Verkaeufe also nie unter seinen Abo-Anteil und bleibt + * damit auch die Wahl der Regel. */ const RESERVING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const; -type Db = Pick; - -/** - * Reservierte Glaeser fuer mehrere Produkte auf einmal — fuer Produktlisten - * und den Checkout. Immer drei Abfragen (Abos, Erntefenster, Lieferungen), - * egal wie viele Produkte: eine Liste mit 30 Honigsorten darf nicht 90 - * Abfragen ausloesen. Produkte ohne Reservierung stehen mit 0 in der Map. - */ -export async function reservedJarsByProduct( - productIds: readonly string[], - now: Date = new Date(), - db: Db = prisma -): Promise> { - const ids = [...new Set(productIds)]; - const result = new Map(ids.map((id) => [id, 0])); - if (ids.length === 0) return result; - const add = (productId: string | null, jars: number) => { - if (productId && result.has(productId)) result.set(productId, (result.get(productId) ?? 0) + jars); - }; +type Db = Pick; +/** Wie viele Glaeser laufende Abos insgesamt beanspruchen (hoechstens drei Abfragen). */ +export async function subscriptionReservedJars(now: Date = new Date(), db: Db = prisma): Promise { const quarterly = await db.sponsorshipSubscription.findMany({ where: { status: { in: [...RESERVING_STATUSES] }, @@ -50,18 +53,18 @@ export async function reservedJarsByProduct( // Verzug wegen einer fehlgeschlagenen Zahlung haelt sie dagegen: Stripe // versucht es erneut, und meist klappt es. NOT: { status: "IM_VERZUG", payments: { some: { reversedAt: { not: null } } } }, - offer: { honeyProductId: { in: ids }, cycle: "VIERTELJAEHRLICH" }, + offer: { cycle: "VIERTELJAEHRLICH", honeyJars: { gt: 0 } }, }, - select: { offer: { select: { honeyProductId: true, honeyJars: true } } }, + select: { offer: { select: { honeyJars: true } } }, }); - for (const subscription of quarterly) add(subscription.offer.honeyProductId, subscription.offer.honeyJars); + const quarterlyJars = quarterly.reduce((sum, subscription) => sum + subscription.offer.honeyJars, 0); const today = startOfUtcDay(now); const windows = await db.harvestWindow.findMany({ - where: { endsOn: { gte: today }, offer: { honeyProductId: { in: ids }, cycle: "ERNTE" } }, - select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true, offer: { select: { honeyProductId: true } } }, + where: { endsOn: { gte: today }, offer: { cycle: "ERNTE" } }, + select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true }, }); - if (windows.length === 0) return result; + if (windows.length === 0) return quarterlyJars; // Alle in den offenen Fenstern bereits angelegten Lieferungen in einer // Abfrage; die Zuordnung zum Fenster geschieht hier im Speicher. @@ -74,14 +77,48 @@ export async function reservedJarsByProduct( }, select: { jars: true, dueAt: true, subscription: { select: { offerId: true } } }, }); - for (const window of windows) { + const harvestJars = windows.reduce((sum, window) => { const until = endOfUtcDay(window.endsOn); const delivered = deliveries .filter((d) => d.subscription.offerId === window.offerId && d.dueAt >= window.startsOn && d.dueAt <= until) - .reduce((sum, d) => sum + d.jars, 0); - add(window.offer.honeyProductId, Math.max(0, window.plannedJars - delivered)); - } - return result; + .reduce((jars, d) => jars + d.jars, 0); + return sum + Math.max(0, window.plannedJars - delivered); + }, 0); + return quarterlyJars + harvestJars; +} + +/** + * Abo-Reservierung je Honigprodukt (nur Produkte mit Reservierung stehen in + * der Map). `stock` kann mitgegeben werden, wenn der Aufrufer den Rohbestand + * ohnehin geladen hat (honey-candidates.ts); sonst wird er geladen — aber nur, + * wenn es ueberhaupt Abo-Bedarf gibt. + */ +export async function subscriptionReservationByProduct( + now: Date = new Date(), + db: Db = prisma, + stock?: readonly HoneyCandidate[] +): Promise> { + const total = await subscriptionReservedJars(now, db); + if (total <= 0) return new Map(); + return allocateReservedJars(stock ?? (await loadHoneyStock(db)), total); +} + +/** + * Reservierte Glaeser fuer mehrere Produkte auf einmal — fuer Produktlisten + * und den Checkout. Hoechstens vier Abfragen (Abos, Erntefenster, + * Lieferungen, Honigbestand), egal wie viele Produkte: eine Liste mit 30 + * Honigsorten darf nicht 90 Abfragen ausloesen. Produkte ohne Reservierung + * stehen mit 0 in der Map. + */ +export async function reservedJarsByProduct( + productIds: readonly string[], + now: Date = new Date(), + db: Db = prisma +): Promise> { + const ids = [...new Set(productIds)]; + if (ids.length === 0) return new Map(); + const allocation = await subscriptionReservationByProduct(now, db); + return new Map(ids.map((id) => [id, allocation.get(id) ?? 0])); } export async function reservedJarsForProduct(productId: string, now: Date = new Date(), db: Db = prisma): Promise { diff --git a/src/lib/sponsorship/checkout-expand.ts b/src/lib/sponsorship/checkout-expand.ts index b5cb091..2ef4215 100644 --- a/src/lib/sponsorship/checkout-expand.ts +++ b/src/lib/sponsorship/checkout-expand.ts @@ -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; /** Angebote nach Traegerprodukt-ID. */ offers: ReadonlyMap; + /** + * 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 { + const taken = new Map(); + 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, diff --git a/src/lib/sponsorship/core.test.ts b/src/lib/sponsorship/core.test.ts index 6ccf2c7..038c067 100644 --- a/src/lib/sponsorship/core.test.ts +++ b/src/lib/sponsorship/core.test.ts @@ -10,6 +10,7 @@ import { certificateRelativePath } from "@/lib/sponsorship/storage"; import { retryDelayMs, MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue"; import { isOfferAvailable, type SponsorshipOfferView } from "@/lib/sponsorship/offers"; import { expandCheckoutItems, startDateWindow, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand"; +import type { HoneyCandidate } from "@/lib/sponsorship/honey-selection"; describe("Patenschaftsnummer", () => { it("ist fuenfstellig fortlaufend je Jahr", () => { @@ -44,19 +45,18 @@ describe("Bestellbarkeit eines Angebots", () => { kind: "EINMALIG" as const, hasCarrierProduct: true, honeyJars: 3, - honeyProductActive: true, - freeJars: 3, + hasEligibleHoney: true, maxActiveSubscriptions: null, activeSubscriptions: 0, }; - it("braucht genug FREIEN Honig fuer ein Paket", () => { + it("braucht einen automatisch waehlbaren Honig mit genug freiem Bestand", () => { expect(isOfferAvailable(base)).toBe(true); - expect(isOfferAvailable({ ...base, freeJars: 2 })).toBe(false); + expect(isOfferAvailable({ ...base, hasEligibleHoney: false })).toBe(false); }); - it("ist ohne Honigprodukt oder ohne Traegerprodukt nicht bestellbar", () => { - expect(isOfferAvailable({ ...base, honeyProductActive: null })).toBe(false); + it("braucht ohne Glaeser keinen Honig, aber immer ein Traegerprodukt", () => { + expect(isOfferAvailable({ ...base, honeyJars: 0, hasEligibleHoney: false })).toBe(true); expect(isOfferAvailable({ ...base, hasCarrierProduct: false })).toBe(false); }); @@ -105,6 +105,20 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => { honeyProduct: { id: "honey", name: "Waldhonig 500 g", weightGrams: 500 }, isAvailable: true, }; + const older: CheckoutProduct = { ...honey, id: "older", name: "Rapshonig 2025 500 g", batchId: "batch-0" }; + const honeyCandidate = (overrides: Partial & Pick): HoneyCandidate => ({ + name: overrides.id, + weightGrams: 500, + harvestYear: 2026, + consistency: "flüssig", + inventory: 100, + freeStock: 100, + ...overrides, + }); + const candidates: HoneyCandidate[] = [ + honeyCandidate({ id: "honey", name: honey.name }), + honeyCandidate({ id: "older", name: older.name, harvestYear: 2025 }), + ]; const personalization = { beeName: "Summsi", recipientName: "Marie-Luise Schmidt", @@ -117,9 +131,11 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => { cartItems, products: new Map([ ["honey", honey], + ["older", older], ["carrier", carrier], ]), offers: new Map([["carrier", offer]]), + honeyCandidates: candidates, vatRateFor: (product: CheckoutProduct) => (product.category === "HONIG" ? 7 : 19), locale: "de" as const, now, @@ -171,6 +187,64 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => { ).toBe(false); }); + it("nimmt automatisch nicht-cremigen Honig der neuesten Ernte, nicht den am Angebot angezeigten", () => { + const result = expandCheckoutItems({ + ...input([{ productId: "carrier", quantity: 1, personalization }]), + honeyCandidates: [honeyCandidate({ id: "fein", consistency: "feincremig", harvestYear: 2027 }), ...candidates], + offers: new Map([["carrier", { ...offer, honeyProduct: { id: "older", name: older.name, weightGrams: 500 } }]]), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.items.find((item) => item.sponsorshipRole === "HONEY")).toMatchObject({ + productId: "honey", + batchId: "batch-1", + productName: "Waldhonig 500 g – Wabenfreund", + }); + }); + + it("waehlt mehrere Pakete nacheinander und weicht aus, wenn der erste Honig nicht fuer alle reicht", () => { + const result = expandCheckoutItems({ + ...input([ + { productId: "carrier", quantity: 1, personalization }, + { productId: "carrier", quantity: 1, personalization: { ...personalization, beeName: "Brummi" } }, + ]), + honeyCandidates: [honeyCandidate({ id: "honey", freeStock: 4 }), honeyCandidate({ id: "older", harvestYear: 2025, freeStock: 10 })], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.items.filter((item) => item.sponsorshipRole === "HONEY").map((item) => item.productId)).toEqual(["honey", "older"]); + expect(result.reserve).toEqual([ + { productId: "honey", quantity: 3, productName: "Waldhonig 500 g" }, + { productId: "older", quantity: 3, productName: "Rapshonig 2025 500 g" }, + ]); + }); + + it("rechnet einzeln gekaufte Glaeser im selben Warenkorb mit ein", () => { + const result = expandCheckoutItems({ + ...input([ + { productId: "honey", quantity: 2 }, + { productId: "carrier", quantity: 1, personalization }, + ]), + honeyCandidates: [honeyCandidate({ id: "honey", freeStock: 4 }), honeyCandidate({ id: "older", harvestYear: 2025, freeStock: 10 })], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.items.find((item) => item.sponsorshipRole === "HONEY")?.productId).toBe("older"); + }); + + it("verkauft kein Paket, wenn kein passender Honig reicht (z. B. nur cremiger da)", () => { + const onlyCreamy = expandCheckoutItems({ + ...input([{ productId: "carrier", quantity: 1, personalization }]), + honeyCandidates: [honeyCandidate({ id: "honey", consistency: "cremig" })], + }); + expect(onlyCreamy).toEqual({ ok: false, error: "Für dieses Paket ist gerade nicht genug Honig da." }); + const tooLittle = expandCheckoutItems({ + ...input([{ productId: "carrier", quantity: 1, personalization }]), + honeyCandidates: [honeyCandidate({ id: "honey", freeStock: 2 })], + }); + expect(tooLittle.ok).toBe(false); + }); + it("verkauft kein Paket, dessen Angebot nicht bestellbar ist", () => { const result = expandCheckoutItems({ ...input([{ productId: "carrier", quantity: 1, personalization }]), diff --git a/src/lib/sponsorship/honey-candidates.ts b/src/lib/sponsorship/honey-candidates.ts new file mode 100644 index 0000000..d7dd542 --- /dev/null +++ b/src/lib/sponsorship/honey-candidates.ts @@ -0,0 +1,27 @@ +import { prisma } from "@/lib/prisma"; +import type { HoneyCandidate } from "@/lib/sponsorship/honey-selection"; +import { loadHoneyStock } from "@/lib/sponsorship/honey-stock"; +import { freeStock, subscriptionReservationByProduct } from "@/lib/sponsorship-subscription/reservation"; + +/** + * Honig-Kandidaten fuer Pakete und neue Abos: alle aktiven HONIG-Produkte mit + * Erntejahr, Konsistenz, Lagerbestand und FREIEM Bestand (Lager minus + * Abo-Reservierung) — genau der Bestand, den auch der Checkout als + * verkaeuflich behandelt. Welcher davon in ein Paket kommt, entscheidet + * pickHoneyProduct (honey-selection.ts). + * + * Der Rohbestand wird nur einmal geladen und an die Reservierung + * weitergereicht: hoechstens vier Abfragen fuer die ganze Liste. + */ + +type Db = NonNullable[1]>; + +export async function loadHoneyCandidates(now: Date = new Date(), db: Db = prisma): Promise { + const stock = await loadHoneyStock(db); + if (stock.length === 0) return []; + const reserved = await subscriptionReservationByProduct(now, db, stock); + return stock.map((candidate) => ({ + ...candidate, + freeStock: freeStock(candidate.inventory, reserved.get(candidate.id) ?? 0), + })); +} diff --git a/src/lib/sponsorship/honey-selection.test.ts b/src/lib/sponsorship/honey-selection.test.ts new file mode 100644 index 0000000..5045441 --- /dev/null +++ b/src/lib/sponsorship/honey-selection.test.ts @@ -0,0 +1,176 @@ +/** + * Automatische Honigwahl fuer Pakete und Abos (Entscheidung des Inhabers): + * nie cremiger Honig, die neueste Ernte zuerst, bei gleich neuer Ernte der + * groessere freie Bestand — und nur, was fuer die Glaeser auch reicht. + */ +import { describe, expect, test } from "vitest"; +import { + allocateReservedJars, + harvestYearOf, + honeyLabel, + isCreamy, + pickHoneyProduct, + rankHoneyCandidates, + withTakenStock, + type HoneyCandidate, +} from "./honey-selection"; + +function candidate(overrides: Partial & Pick): HoneyCandidate { + return { + name: overrides.id, + weightGrams: 500, + harvestYear: 2026, + consistency: "flüssig", + inventory: 10, + freeStock: 10, + ...overrides, + }; +} + +describe("isCreamy", () => { + test("erkennt jede Schreibweise von cremig", () => { + expect(isCreamy("cremig")).toBe(true); + expect(isCreamy("feincremig")).toBe(true); + expect(isCreamy("Feincremig")).toBe(true); + expect(isCreamy("CREMIG gerührt")).toBe(true); + }); + + test("laesst fluessigen und anderen Honig durch", () => { + expect(isCreamy("flüssig")).toBe(false); + expect(isCreamy("fest")).toBe(false); + expect(isCreamy("")).toBe(false); + }); +}); + +describe("harvestYearOf", () => { + const createdAt = new Date("2024-03-01T10:00:00.000Z"); + + test("nimmt das Erntejahr des Produkts", () => { + expect(harvestYearOf({ harvest: { year: 2026 }, batch: { fillDate: new Date("2025-08-01"), harvest: { year: 2025 } }, createdAt })).toBe(2026); + }); + + test("faellt auf die Ernte der Charge, dann ihr Abfuelljahr, dann das Anlagejahr zurueck", () => { + expect(harvestYearOf({ harvest: null, batch: { fillDate: new Date("2025-08-01"), harvest: { year: 2025 } }, createdAt })).toBe(2025); + expect(harvestYearOf({ harvest: null, batch: { fillDate: new Date("2025-08-01T00:00:00.000Z"), harvest: null }, createdAt })).toBe(2025); + expect(harvestYearOf({ harvest: null, batch: null, createdAt })).toBe(2024); + }); +}); + +describe("pickHoneyProduct", () => { + test("schliesst cremigen Honig aus, auch wenn er neuer ist und mehr Bestand hat", () => { + const picked = pickHoneyProduct( + [ + candidate({ id: "fein", consistency: "feincremig", harvestYear: 2027, freeStock: 99 }), + candidate({ id: "wald", consistency: "flüssig", harvestYear: 2025, freeStock: 3 }), + ], + 2 + ); + expect(picked?.id).toBe("wald"); + }); + + test("die neueste Ernte gewinnt vor dem groesseren Bestand", () => { + const picked = pickHoneyProduct( + [candidate({ id: "alt", harvestYear: 2025, freeStock: 50 }), candidate({ id: "neu", harvestYear: 2026, freeStock: 4 })], + 3 + ); + expect(picked?.id).toBe("neu"); + }); + + test("bei gleich neuer Ernte entscheidet der groessere freie Bestand", () => { + const picked = pickHoneyProduct( + [candidate({ id: "linde", freeStock: 6, inventory: 20 }), candidate({ id: "raps", freeStock: 9, inventory: 9 })], + 1 + ); + expect(picked?.id).toBe("raps"); + }); + + test("ueberspringt Honig, dessen freier Bestand nicht fuer alle Glaeser reicht", () => { + const picked = pickHoneyProduct( + [candidate({ id: "neu", harvestYear: 2026, freeStock: 2 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 3 })], + 3 + ); + expect(picked?.id).toBe("alt"); + }); + + test("ohne passenden Honig null — dann ist das Angebot nicht bestellbar", () => { + expect(pickHoneyProduct([], 1)).toBeNull(); + expect(pickHoneyProduct([candidate({ id: "fein", consistency: "cremig" })], 1)).toBeNull(); + expect(pickHoneyProduct([candidate({ id: "knapp", freeStock: 2 })], 3)).toBeNull(); + }); + + test("bleibt bei Gleichstand stabil (nach id)", () => { + const a = candidate({ id: "a" }); + const b = candidate({ id: "b" }); + expect(pickHoneyProduct([b, a], 1)?.id).toBe("a"); + }); +}); + +describe("rankHoneyCandidates", () => { + test("sortiert ohne die Eingabe zu veraendern und laesst cremigen Honig weg", () => { + const input = [ + candidate({ id: "alt", harvestYear: 2024 }), + candidate({ id: "fein", consistency: "feincremig", harvestYear: 2027 }), + candidate({ id: "neu", harvestYear: 2026 }), + ]; + expect(rankHoneyCandidates(input).map((c) => c.id)).toEqual(["neu", "alt"]); + expect(input.map((c) => c.id)).toEqual(["alt", "fein", "neu"]); + }); +}); + +describe("withTakenStock", () => { + test("zieht bereits verplante Glaeser vom freien Bestand ab, ohne die Eingabe zu veraendern", () => { + const input = [candidate({ id: "wald", freeStock: 5 }), candidate({ id: "raps", freeStock: 4 })]; + const result = withTakenStock(input, new Map([["wald", 3]])); + expect(result.map((c) => [c.id, c.freeStock])).toEqual([ + ["wald", 2], + ["raps", 4], + ]); + expect(input[0].freeStock).toBe(5); + }); + + test("nacheinander gewaehlte Pakete weichen auf den naechsten Honig aus, wenn der erste nicht mehr reicht", () => { + const candidates = [candidate({ id: "neu", harvestYear: 2026, freeStock: 4 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 10 })]; + const first = pickHoneyProduct(candidates, 3); + const second = pickHoneyProduct(withTakenStock(candidates, new Map([["neu", 3]])), 3); + expect(first?.id).toBe("neu"); + expect(second?.id).toBe("alt"); + }); +}); + +describe("allocateReservedJars", () => { + test("legt alle Abo-Glaeser auf den Honig, der fuer alle reicht", () => { + const result = allocateReservedJars( + [candidate({ id: "neu", harvestYear: 2026, freeStock: 5 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 30 })], + 8 + ); + expect(Object.fromEntries(result)).toEqual({ alt: 8 }); + }); + + test("reicht keiner allein, wird in der Rangfolge aufgefuellt", () => { + const result = allocateReservedJars( + [candidate({ id: "neu", harvestYear: 2026, freeStock: 5 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 4 })], + 8 + ); + expect(Object.fromEntries(result)).toEqual({ neu: 5, alt: 3 }); + }); + + test("ein Fehlbestand landet beim erstgewaehlten Honig, damit er sichtbar wird", () => { + const result = allocateReservedJars( + [candidate({ id: "neu", harvestYear: 2026, freeStock: 2 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 1 })], + 6 + ); + expect(Object.fromEntries(result)).toEqual({ neu: 5, alt: 1 }); + }); + + test("ohne Bedarf oder ohne fluessigen Honig keine Reservierung", () => { + expect(allocateReservedJars([candidate({ id: "wald" })], 0).size).toBe(0); + expect(allocateReservedJars([candidate({ id: "fein", consistency: "cremig" })], 4).size).toBe(0); + }); +}); + +describe("honeyLabel", () => { + test("haengt das Erntejahr an, ausser es steht schon im Namen", () => { + expect(honeyLabel({ name: "Waldhonig", harvestYear: 2026 })).toBe("Waldhonig 2026"); + expect(honeyLabel({ name: "Waldhonig 2026", harvestYear: 2026 })).toBe("Waldhonig 2026"); + }); +}); diff --git a/src/lib/sponsorship/honey-selection.ts b/src/lib/sponsorship/honey-selection.ts new file mode 100644 index 0000000..1c6a232 --- /dev/null +++ b/src/lib/sponsorship/honey-selection.ts @@ -0,0 +1,136 @@ +/** + * Automatische Honigwahl fuer Patenschaftspakete und Abos. + * + * Entscheidung des Inhabers: Pakete und Abos enthalten NIE cremigen Honig — + * der ist beim Packen deutlich mehr Arbeit. Welcher Honig verschickt wird, + * waehlt deshalb nicht der Admin je Angebot aus, sondern diese Regel, und + * zwar jedes Mal neu (beim Anzeigen, im Checkout, bei der Abo-Reservierung + * und beim Anlegen einer Abo-Lieferung): + * + * 1. nur Honig, dessen Konsistenz nicht "cremig" enthaelt ("feincremig" + * faellt also auch raus, "flüssig" ist erlaubt), + * 2. nur Honig mit genug Bestand fuer die benoetigten Glaeser, + * 3. davon die neueste Ernte, + * 4. bei gleich neuer Ernte der groessere Bestand (dann nach id, damit + * die Wahl bei Gleichstand nicht zufaellig springt). + * + * Bewusst rein (ohne Datenbank): dieselbe Regel laeuft an vier Stellen und + * muss ueberall gleich entscheiden. Die Kandidaten laedt honey-stock.ts + * (Rohbestand) bzw. honey-candidates.ts (freier Bestand). + */ + +export interface HoneyCandidate { + id: string; + name: string; + weightGrams: number; + /** Jahr der Ernte, siehe harvestYearOf. */ + harvestYear: number; + consistency: string; + /** Lagerbestand (Inventory.quantity), 0 ohne Lagerzeile. */ + inventory: number; + /** + * Bestand, der fuer den jeweiligen Zweck zur Verfuegung steht. Fuer Pakete + * und neue Abos ist das der FREIE Bestand (Lager minus Abo-Reservierung); + * fuer die Abo-Reservierung selbst und fuer Abo-Lieferungen der + * Lagerbestand — die Reservierung ist ja genau fuer sie da. + */ + freeStock: number; +} + +const CREAMY = /cremig/i; + +export function isCreamy(consistency: string): boolean { + return CREAMY.test(consistency); +} + +export interface HarvestYearSource { + harvest: { year: number } | null; + batch: { fillDate: Date; harvest: { year: number } | null } | null; + createdAt: Date; +} + +/** + * Erntejahr eines Honigprodukts. Normalfall ist Product.harvestId → Harvest.year; + * fehlt die Verknuepfung (z. B. von Hand angelegtes Produkt), zaehlt die + * Ernte der Charge, dann ihr Abfuelljahr und zuletzt das Anlagejahr des + * Produkts — irgendein Jahr braucht die Rangfolge, und ein Produkt ohne + * Ernte soll nicht automatisch als "neueste" gelten. + */ +export function harvestYearOf(product: HarvestYearSource): number { + if (product.harvest) return product.harvest.year; + if (product.batch?.harvest) return product.batch.harvest.year; + if (product.batch) return product.batch.fillDate.getUTCFullYear(); + return product.createdAt.getUTCFullYear(); +} + +/** Anzeige fuer den Admin: " ", ohne das Jahr zu verdoppeln, wenn es schon im Namen steht. */ +export function honeyLabel(candidate: Pick): string { + const year = String(candidate.harvestYear); + return candidate.name.includes(year) ? candidate.name : `${candidate.name} ${year}`; +} + +function compareCandidates(a: HoneyCandidate, b: HoneyCandidate): number { + if (a.harvestYear !== b.harvestYear) return b.harvestYear - a.harvestYear; + if (a.freeStock !== b.freeStock) return b.freeStock - a.freeStock; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** Nicht-cremiger Honig in der Rangfolge der Regel (ohne Bestandspruefung), als neue Liste. */ +export function rankHoneyCandidates(candidates: readonly HoneyCandidate[]): HoneyCandidate[] { + return candidates.filter((candidate) => !isCreamy(candidate.consistency)).sort(compareCandidates); +} + +/** Der Honig, den die Regel fuer `jarsNeeded` Glaeser waehlt — oder null, wenn keiner reicht. */ +export function pickHoneyProduct(candidates: readonly HoneyCandidate[], jarsNeeded: number): HoneyCandidate | null { + return rankHoneyCandidates(candidates).find((candidate) => candidate.freeStock >= jarsNeeded) ?? null; +} + +/** + * Zieht bereits verplante Glaeser vom freien Bestand ab (neue Liste). Damit + * waehlt der Checkout mehrere Pakete nacheinander: jedes gegen das, was die + * vorherigen Pakete und die einzeln gekauften Glaeser uebrig lassen — reicht + * der erste Honig nicht mehr, weicht das naechste Paket auf den naechsten aus. + */ +export function withTakenStock( + candidates: readonly HoneyCandidate[], + taken: ReadonlyMap +): HoneyCandidate[] { + return candidates.map((candidate) => { + const jars = taken.get(candidate.id) ?? 0; + return jars === 0 ? candidate : { ...candidate, freeStock: candidate.freeStock - jars }; + }); +} + +/** + * Verteilt die von Abos beanspruchten Glaeser auf den Honig (Bestand = + * Lagerbestand, ohne Reservierung). Abos haben keinen festen Honig mehr; ihre + * Glaeser liegen auf dem, was die Regel fuer sie waehlen wuerde: + * + * - Reicht ein Honig fuer alle, liegt alles auf ihm (so wenig Honigsorten + * wie moeglich gesperrt). + * - Sonst wird in der Rangfolge aufgefuellt. + * - Bleibt dann noch etwas uebrig (echter Fehlbestand), kommt es auf den + * erstgewaehlten Honig: dort ist der freie Bestand dann 0, und die + * Deckungsanzeige im Admin zeigt die Luecke, statt sie zu verschweigen. + * + * Ohne nicht-cremigen Honig bleibt die Map leer — es gibt dann nichts, was + * fuer Abos zurueckgehalten werden koennte. + */ +export function allocateReservedJars(candidates: readonly HoneyCandidate[], totalJars: number): Map { + if (totalJars <= 0) return new Map(); + const single = pickHoneyProduct(candidates, totalJars); + if (single) return new Map([[single.id, totalJars]]); + + const ranked = rankHoneyCandidates(candidates); + if (ranked.length === 0) return new Map(); + const allocation = new Map(); + let remaining = totalJars; + for (const candidate of ranked) { + const jars = Math.min(remaining, Math.max(0, candidate.freeStock)); + if (jars > 0) allocation.set(candidate.id, jars); + remaining -= jars; + if (remaining === 0) break; + } + if (remaining > 0) allocation.set(ranked[0].id, (allocation.get(ranked[0].id) ?? 0) + remaining); + return allocation; +} diff --git a/src/lib/sponsorship/honey-stock.ts b/src/lib/sponsorship/honey-stock.ts new file mode 100644 index 0000000..9a037ad --- /dev/null +++ b/src/lib/sponsorship/honey-stock.ts @@ -0,0 +1,46 @@ +import { prisma } from "@/lib/prisma"; +import { harvestYearOf, type HoneyCandidate } from "@/lib/sponsorship/honey-selection"; + +/** + * Honig-Kandidaten mit ROHEM Lagerbestand (freeStock = Lagerbestand, ohne + * Abo-Reservierung): Grundlage fuer die Abo-Reservierung selbst und fuer + * Abo-Lieferungen. Den freien Bestand fuer Pakete liefert honey-candidates.ts. + * + * Eigene Datei, damit reservation.ts den Rohbestand laden kann, ohne dass + * honey-candidates.ts (das seinerseits reservation.ts braucht) einen + * Import-Kreis bildet. + * + * Geladen werden alle aktiven HONIG-Produkte, auch cremige: die Regel in + * honey-selection.ts sortiert sie aus, und der Admin kann so sehen, was es + * gibt. Eine Abfrage — eine Imkerei hat eine Handvoll Honigsorten. + */ + +export type HoneyStockDb = Pick; + +export async function loadHoneyStock(db: HoneyStockDb = prisma): Promise { + const products = await db.product.findMany({ + where: { category: "HONIG", isActive: true }, + select: { + id: true, + name: true, + weightGrams: true, + consistency: true, + createdAt: true, + inventory: { select: { quantity: true } }, + harvest: { select: { year: true } }, + batch: { select: { fillDate: true, harvest: { select: { year: true } } } }, + }, + }); + return products.map((product) => { + const inventory = Math.max(0, product.inventory?.quantity ?? 0); + return { + id: product.id, + name: product.name, + weightGrams: product.weightGrams, + harvestYear: harvestYearOf(product), + consistency: product.consistency, + inventory, + freeStock: inventory, + }; + }); +} diff --git a/src/lib/sponsorship/offers.ts b/src/lib/sponsorship/offers.ts index 6df44e6..17b0a0e 100644 --- a/src/lib/sponsorship/offers.ts +++ b/src/lib/sponsorship/offers.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import { asStringArray } from "@/lib/json-array"; -import { availableForSaleByProduct } from "@/lib/sponsorship-subscription/reservation"; +import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates"; +import { pickHoneyProduct, type HoneyCandidate } from "@/lib/sponsorship/honey-selection"; import { quotaSubscriptionWhere } from "@/lib/sponsorship-subscription/pending"; import type { Locale } from "@/i18n/config"; import type { @@ -14,11 +15,17 @@ import type { * Warenkorb und Checkout sie brauchen: lokalisiert, mit Leistungsliste als * Array und mit der Aussage, ob das Angebot gerade bestellbar ist. * - * "Bestellbar" haengt am Honig: ein physisches Paket ohne genug Glaeser des - * verknuepften Honigprodukts darf nicht verkauft werden — sonst bekaeme die - * Kundschaft eine Urkunde ohne den bezahlten Honig. Gezaehlt wird der FREIE - * Bestand, also abzueglich der fuer Abos reservierten Glaeser. Bei Abos - * zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions). + * "Bestellbar" haengt am Honig: ein Angebot mit Glaesern braucht einen Honig, + * den die automatische Wahl nehmen darf (nie cremig, neueste Ernte, genug + * FREIER Bestand, also abzueglich der fuer Abos reservierten Glaeser — + * lib/sponsorship/honey-selection.ts). Gibt es keinen, ist das Angebot nicht + * bestellbar — sonst bekaeme die Kundschaft eine Urkunde ohne den bezahlten + * Honig. Bei Abos zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions). + * + * Einen fest verknuepften Honig je Angebot gibt es nicht mehr: die Spalte + * SponsorshipOffer.honeyProductId bleibt (nullable) im Schema stehen, um + * keine Migration auf der laufenden Produktion zu brauchen, wird aber weder + * gelesen noch geschrieben. */ export interface SponsorshipOfferView { id: string; @@ -37,26 +44,26 @@ export interface SponsorshipOfferView { medalTier: MedalTier; /** Traegerprodukt (nur EINMALIG). */ productId: string | null; + /** + * Der Honig, den die automatische Wahl GERADE fuer dieses Angebot nimmt + * (nur zur Anzeige und Pruefung; der Checkout waehlt bei der Bestellung neu, + * weil mehrere Pakete im Warenkorb gemeinsam reichen muessen). + */ honeyProduct: { id: string; name: string; weightGrams: number } | null; /** false = derzeit nicht bestellbar (Honig fehlt, Kontingent voll, Angebot aus). */ isAvailable: boolean; } -const offerInclude = { - honeyProduct: { select: { id: true, name: true, weightGrams: true, isActive: true } }, -} as const; - async function loadOfferRows(where: { isActive?: boolean; kind?: SponsorshipOfferKind; slug?: string; productId?: { in: string[] } }) { return prisma.sponsorshipOffer.findMany({ where, - include: offerInclude, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], }); } type OfferRow = Awaited>[number]; -function localize(row: OfferRow, locale: Locale, isAvailable: boolean): SponsorshipOfferView { +function localize(row: OfferRow, locale: Locale, honey: HoneyCandidate | null, isAvailable: boolean): SponsorshipOfferView { const en = locale === "en"; const featuresEn = asStringArray(row.featuresEn); return { @@ -74,16 +81,14 @@ function localize(row: OfferRow, locale: Locale, isAvailable: boolean): Sponsors cycle: row.cycle, medalTier: row.medalTier, productId: row.productId, - honeyProduct: row.honeyProduct - ? { id: row.honeyProduct.id, name: row.honeyProduct.name, weightGrams: row.honeyProduct.weightGrams } - : null, + honeyProduct: honey ? { id: honey.id, name: honey.name, weightGrams: honey.weightGrams } : null, isAvailable, }; } -async function freeJarsByProduct(rows: OfferRow[]): Promise> { - const ids = [...new Set(rows.map((row) => row.honeyProduct?.id).filter((id): id is string => Boolean(id)))]; - return availableForSaleByProduct(ids); +/** Kandidaten nur laden, wenn ein Angebot ueberhaupt Honig braucht (rein digitale Angebote nicht). */ +async function honeyCandidatesFor(rows: OfferRow[]): Promise { + return rows.some((row) => row.honeyJars > 0) ? loadHoneyCandidates() : []; } async function activeSubscriptionCounts(rows: OfferRow[]): Promise> { @@ -105,17 +110,14 @@ export function isOfferAvailable(input: { kind: SponsorshipOfferKind; hasCarrierProduct: boolean; honeyJars: number; - honeyProductActive: boolean | null; - freeJars: number; + /** Die automatische Honigwahl hat fuer honeyJars Glaeser einen Honig gefunden. */ + hasEligibleHoney: boolean; maxActiveSubscriptions: number | null; activeSubscriptions: number; }): boolean { if (!input.isActive) return false; if (input.kind === "EINMALIG" && !input.hasCarrierProduct) return false; - if (input.honeyJars > 0) { - if (!input.honeyProductActive) return false; - if (input.freeJars < input.honeyJars) return false; - } + if (input.honeyJars > 0 && !input.hasEligibleHoney) return false; if (input.kind === "ABO" && input.maxActiveSubscriptions !== null) { if (input.activeSubscriptions >= input.maxActiveSubscriptions) return false; } @@ -123,23 +125,24 @@ export function isOfferAvailable(input: { } async function toViews(rows: OfferRow[], locale: Locale): Promise { - const [jars, subscriptionCounts] = await Promise.all([freeJarsByProduct(rows), activeSubscriptionCounts(rows)]); - return rows.map((row) => - localize( + const [candidates, subscriptionCounts] = await Promise.all([honeyCandidatesFor(rows), activeSubscriptionCounts(rows)]); + return rows.map((row) => { + const honey = row.honeyJars > 0 ? pickHoneyProduct(candidates, row.honeyJars) : null; + return localize( row, locale, + honey, isOfferAvailable({ isActive: row.isActive, kind: row.kind, hasCarrierProduct: row.productId !== null, honeyJars: row.honeyJars, - honeyProductActive: row.honeyProduct ? row.honeyProduct.isActive : null, - freeJars: row.honeyProduct ? (jars.get(row.honeyProduct.id) ?? 0) : 0, + hasEligibleHoney: honey !== null, maxActiveSubscriptions: row.maxActiveSubscriptions, activeSubscriptions: subscriptionCounts.get(row.id) ?? 0, }) - ) - ); + ); + }); } export async function getActiveOffers(locale: Locale, kind?: SponsorshipOfferKind): Promise {
{to.colProduct}{to.colYear}{to.colConsistency} {to.colStock} {to.colReserved} {to.colFree}
- {row.name} - {row.isActive ? null : ({to.inactive})} + {row.productName} + {row.productId === firstChoiceId ? ( + + {to.firstChoice} + + ) : null} {row.harvestYear}{row.consistency} {row.stock} {row.reserved} {row.free}