feat: Patenschafts-Honig automatisch waehlen (nie cremig, neueste Ernte)
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m18s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m32s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m41s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 20m38s

Pakete, Abo-Reservierung und Abo-Lieferungen nehmen immer den aktiven,
nicht cremigen Honig der neuesten Ernte mit genug freiem Bestand, bei
gleichem Jahrgang den mit dem meisten Bestand. Feste Honig-Zuordnung
im Admin entfaellt (Spalte bleibt, wird nicht mehr genutzt).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 18:35:04 +02:00
1 parent 3ddf0bfa73
commit 1d88bac52e
30 files changed
+1153 -395

No files matched your search

+4 -1
View File
@@ -1075,7 +1075,10 @@ model SponsorshipOffer {
/// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb, /// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb,
/// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil. /// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil.
productId String? @unique 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? honeyProductId String?
/// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung. /// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung.
honeyJars Int @default(0) honeyJars Int @default(0)
+15 -27
View File
@@ -4,17 +4,24 @@
* Wiederholbar: legt nur an, was fehlt, und aendert nie ein Angebot, das der * 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). * 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 die beiden Abo-Angebote INAKTIV an (Testpreise, im Admin pruefen),
* - legt fuer jedes Abo-Angebot Stripe-Produkt, Monatspreis und * - legt fuer jedes Abo-Angebot Stripe-Produkt, Monatspreis und
* Traegerprodukt an (syncOfferStripePrice). * Traegerprodukt an (syncOfferStripePrice).
* *
* Einen Honig verknuepft das Skript nicht mehr (frueher --honey=<slug>):
* 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): * Aufruf (Werkzeug-Image, Umgebung wie die App):
* tsx scripts/sponsorship-bootstrap.ts --honey=<produkt-slug> (Probelauf) * tsx scripts/sponsorship-bootstrap.ts (Probelauf)
* tsx scripts/sponsorship-bootstrap.ts --honey=<produkt-slug> --apply (ausfuehren) * tsx scripts/sponsorship-bootstrap.ts --apply (ausfuehren)
*/ */
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices"; 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 { interface AboTemplate {
slug: string; 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<void> { async function main(): Promise<void> {
const apply = process.argv.includes("--apply"); const apply = process.argv.includes("--apply");
const honeySlug = arg("honey"); const honey = pickHoneyProduct(await loadHoneyCandidates(), 1);
if (!honeySlug) throw new Error("--honey=<produkt-slug> fehlt."); console.log(
`${apply ? "AUSFUEHREN" : "PROBELAUF"} — Honig automatisch, derzeit: ${honey ? honeyLabel(honey) : "kein nicht-cremiger Honig mit freiem Bestand"}`
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 } });
}
for (const template of ABO_TEMPLATES) { for (const template of ABO_TEMPLATES) {
const existing = await prisma.sponsorshipOffer.findUnique({ where: { slug: template.slug }, select: { id: true, stripePriceId: true } }); const existing = await prisma.sponsorshipOffer.findUnique({ where: { slug: template.slug }, select: { id: true, stripePriceId: true } });
@@ -110,7 +99,6 @@ async function main(): Promise<void> {
kind: "ABO", kind: "ABO",
isActive: false, isActive: false,
isPhysical: true, isPhysical: true,
honeyProductId: honey.id,
medalTier: "ABO", medalTier: "ABO",
}, },
}); });
@@ -119,7 +107,7 @@ async function main(): Promise<void> {
const offers = await prisma.sponsorshipOffer.findMany({ const offers = await prisma.sponsorshipOffer.findMany({
orderBy: { sortOrder: "asc" }, 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); console.table(offers);
} }
+33 -19
View File
@@ -6,6 +6,10 @@ import { logAdminAction } from "@/lib/audit-log";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { carrierPriceCents, parseOfferForm, type OfferFormValues } from "@/app/admin/patenschaften/_lib/offer-schema"; 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 { requireAdmin, fail, succeed, str } from "./_shared";
import { isUniqueViolation, syncStripePriceSafely } from "./sponsorship-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) { function offerData(values: OfferFormValues) {
return { return {
slug: values.slug, slug: values.slug,
@@ -48,7 +58,6 @@ function offerData(values: OfferFormValues) {
isActive: values.isActive, isActive: values.isActive,
sortOrder: values.sortOrder, sortOrder: values.sortOrder,
isPhysical: values.isPhysical, isPhysical: values.isPhysical,
honeyProductId: values.honeyProductId,
honeyJars: values.honeyJars, honeyJars: values.honeyJars,
honeyShareCentsPerJar: values.honeyShareCentsPerJar, honeyShareCentsPerJar: values.honeyShareCentsPerJar,
honeyShareCentsPerMonth: values.honeyShareCentsPerMonth, honeyShareCentsPerMonth: values.honeyShareCentsPerMonth,
@@ -59,10 +68,9 @@ function offerData(values: OfferFormValues) {
} }
/** /**
* Pruefungen gegen die Datenbank: eindeutige Adressen, ein echtes * Pruefungen gegen die Datenbank: eindeutige Adressen. Den Honig prueft
* Honigprodukt — und die Aktivierungssperre: ein Angebot mit Honig laesst * honeyWarning() — er sperrt das Aktivieren nicht mehr, weil er sich mit
* sich nur aktivieren, wenn das Honigprodukt existiert und aktiv ist. Sonst * jeder Bestellung und jeder neuen Ernte aendert.
* bekaeme die Kundschaft eine Urkunde ohne den bezahlten Honig.
*/ */
async function findConflict( async function findConflict(
values: OfferFormValues, values: OfferFormValues,
@@ -77,19 +85,6 @@ async function findConflict(
return `Die Produktadresse "${carrierSlug(values.slug)}" ist bereits vergeben. Bitte eine andere Adresse wählen.`; 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; return null;
} }
@@ -98,7 +93,21 @@ function auditDetail(values: OfferFormValues): string {
return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`; 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<string | null> {
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<never> { async function finishSave(offerId: string, values: OfferFormValues, message: string): Promise<never> {
revalidatePath(OFFERS_PATH); revalidatePath(OFFERS_PATH);
revalidatePath("/patenschaft"); 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.`); 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); return succeed(target, message);
} }
@@ -29,7 +29,6 @@ export interface OfferFormDefaults {
isActive: boolean; isActive: boolean;
isPhysical: boolean; isPhysical: boolean;
sortOrder: number; sortOrder: number;
honeyProductId: string | null;
honeyJars: number; honeyJars: number;
cycle: SponsorshipCycle | null; cycle: SponsorshipCycle | null;
maxActiveSubscriptions: number | null; maxActiveSubscriptions: number | null;
@@ -51,7 +50,6 @@ export const EMPTY_OFFER: OfferFormDefaults = {
isActive: false, isActive: false,
isPhysical: false, isPhysical: false,
sortOrder: 0, sortOrder: 0,
honeyProductId: null,
honeyJars: 0, honeyJars: 0,
cycle: null, cycle: null,
maxActiveSubscriptions: null, maxActiveSubscriptions: null,
@@ -62,7 +60,8 @@ interface OfferFormProps {
defaults: OfferFormDefaults; defaults: OfferFormDefaults;
/** Beim Bearbeiten ist die Art fest. */ /** Beim Bearbeiten ist die Art fest. */
isNew: boolean; isNew: boolean;
honeyOptions: { id: string; name: string; isActive: boolean }[]; /** "<Name> <Jahr>" des Honigs, den die automatische Wahl gerade nimmt; null = keiner reicht. */
currentHoneyLabel: string | null;
action: (formData: FormData) => void | Promise<void>; action: (formData: FormData) => void | Promise<void>;
t: SponsorshipAdminStrings; 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 * stehen mit "(nur Einmalig)" bzw. "(nur Abo)" da und werden beim Speichern
* geleert (offer-schema.ts) — so bleibt das Formular ohne Client-Logik. * 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; const tf = t.offerForm;
return ( return (
<AdminFormPanel action={action} actions={<Button type="submit">{isNew ? tf.create : tf.save}</Button>}> <AdminFormPanel action={action} actions={<Button type="submit">{isNew ? tf.create : tf.save}</Button>}>
@@ -123,15 +122,15 @@ export function OfferForm({ defaults, isNew, honeyOptions, action, t }: OfferFor
<AdminFieldset legend={tf.sectionHoney}> <AdminFieldset legend={tf.sectionHoney}>
<AdminSwitch name="isPhysical" label={tf.isPhysical} defaultChecked={defaults.isPhysical} /> <AdminSwitch name="isPhysical" label={tf.isPhysical} defaultChecked={defaults.isPhysical} />
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<AdminSelect name="honeyProductId" label={tf.honeyProduct} defaultValue={defaults.honeyProductId ?? ""}> {/* Keine Auswahl mehr: Pakete und Abos bekommen den Honig automatisch
<option value="">{tf.honeyProductNone}</option> (lib/sponsorship/honey-selection.ts). Nur Anzeige, wird nicht gespeichert. */}
{honeyOptions.map((product) => ( <AdminField
<option key={product.id} value={product.id}> name="honeyDisplay"
{product.name} label={tf.honey}
{product.isActive ? "" : tf.inactiveSuffix} defaultValue={tf.honeyAuto(currentHoneyLabel)}
</option> readOnly
))} hint={tf.honeyAutoHint}
</AdminSelect> />
<AdminField name="honeyJars" label={tf.honeyJars} type="number" min={0} defaultValue={String(defaults.honeyJars)} /> <AdminField name="honeyJars" label={tf.honeyJars} type="number" min={0} defaultValue={String(defaults.honeyJars)} />
</div> </div>
</AdminFieldset> </AdminFieldset>
@@ -3,7 +3,7 @@ import { AdminActionButton, AdminBadge } from "@/components/admin-list";
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin"; import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { liftSepaBlockAction, updateSepaHoldDaysAction } from "@/app/admin/actions"; import { liftSepaBlockAction, updateSepaHoldDaysAction } from "@/app/admin/actions";
import { MAX_SEPA_HOLD_DAYS } from "@/lib/sponsorship/settings"; 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"]; 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). */ /** Deckung der Abos durch den automatisch gewaehlten (nicht-cremigen) Honig; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */
export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: Strings }) { export function CoveragePanel({ coverage, t }: { coverage: HoneyCoverage; t: Strings }) {
return ( return (
<section id="deckung" className="border border-ink/10 bg-background p-6"> <section id="deckung" className="border border-ink/10 bg-background p-6">
<h2 className="mb-2 font-serif text-xl text-heading">{t.coverageHeading}</h2> <h2 className="mb-2 font-serif text-xl text-heading">{t.coverageHeading}</h2>
<p className="mb-4 text-xs text-ink/65">{t.coverageIntro}</p> <p className="mb-4 text-xs text-ink/65">{t.coverageIntro}</p>
{rows.length === 0 ? ( {coverage.rows.length === 0 ? (
<p className="text-sm text-ink/65">{t.noCoverage}</p> <p className="text-sm text-ink/65">{t.noCoverage}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -30,38 +30,40 @@ export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: String
<thead className="border-b border-ink/10 text-left text-xs text-ink/65"> <thead className="border-b border-ink/10 text-left text-xs text-ink/65">
<tr> <tr>
<th className="p-3 font-normal">{t.colProduct}</th> <th className="p-3 font-normal">{t.colProduct}</th>
<th className="p-3 text-right font-normal">{t.colYear}</th>
<th className="p-3 text-right font-normal">{t.colStock}</th> <th className="p-3 text-right font-normal">{t.colStock}</th>
<th className="p-3 text-right font-normal">{t.colReserved}</th> <th className="p-3 text-right font-normal">{t.colReserved}</th>
<th className="p-3 text-right font-normal">{t.colFree}</th> <th className="p-3 text-right font-normal">{t.colFree}</th>
<th className="p-3 text-right font-normal">{t.colPerDelivery}</th>
<th className="p-3 font-normal">{t.colCovers}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{rows.map((row) => ( {coverage.rows.map((row) => (
<tr key={row.productId}> <tr key={row.productId}>
<td className="p-3">{row.productName}</td> <td className="p-3">{row.productName}</td>
<td className="p-3 text-right tabular-nums">{row.harvestYear}</td>
<td className="p-3 text-right tabular-nums">{row.stock}</td> <td className="p-3 text-right tabular-nums">{row.stock}</td>
<td className="p-3 text-right tabular-nums">{row.reserved}</td> <td className="p-3 text-right tabular-nums">{row.reserved}</td>
<td className="p-3 text-right tabular-nums">{row.free}</td> <td className="p-3 text-right tabular-nums">{row.free}</td>
<td className="p-3 text-right tabular-nums">{row.jarsPerDelivery}</td>
<td className="p-3">
{row.deliveriesCovered === null ? (
"—"
) : row.deliveriesCovered < 1 ? (
<AdminBadge tone="danger" pulse>
{t.coverageWarning}
</AdminBadge>
) : (
<span className="tabular-nums">{t.covers(row.deliveriesCovered)}</span>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
{coverage.deliveriesCovered !== null ? (
<p className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink/75">
<span className="tabular-nums">{t.perDelivery(coverage.jarsPerDelivery)}</span>
<span aria-hidden="true">·</span>
<span>{t.coversLabel}:</span>
{coverage.deliveriesCovered < 1 ? (
<AdminBadge tone="danger" pulse>
{t.coverageWarning}
</AdminBadge>
) : (
<span className="tabular-nums text-heading">{t.covers(coverage.deliveriesCovered)}</span>
)}
</p>
) : null}
</section> </section>
); );
} }
@@ -21,6 +21,8 @@ const PRINT_GRACE_MS = 2 * 24 * 60 * 60 * 1000;
*/ */
export async function getSponsorshipAttentionItems(locale: Locale, now: Date = new Date()): Promise<AttentionItem[]> { export async function getSponsorshipAttentionItems(locale: Locale, now: Date = new Date()): Promise<AttentionItem[]> {
const t = getSponsorshipAdminStrings(locale).attention; 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([ const [failed, overdueToPrint, delinquent, coverage] = await Promise.all([
prisma.sponsorship.count({ where: { status: "FEHLER" } }), prisma.sponsorship.count({ where: { status: "FEHLER" } }),
// mailSentAt ist der Moment, in dem die Patenschaft BEREIT wurde (die // 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 [ return [
{ count: failed, href: "/admin/patenschaften?status=FEHLER", label: t.failed(failed) }, { count: failed, href: "/admin/patenschaften?status=FEHLER", label: t.failed(failed) },
{ count: overdueToPrint, href: "/admin/patenschaften?status=ZU_DRUCKEN", label: t.toPrint(overdueToPrint) }, { 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) }, { count: delinquent, href: "/admin/patenschaften/abos?status=IM_VERZUG", label: t.delinquent(delinquent) },
].filter((item) => item.count > 0); ].filter((item) => item.count > 0);
} }
+64 -63
View File
@@ -1,10 +1,15 @@
import { prisma } from "@/lib/prisma"; 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 * Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und
* Kontingent"). Reserviert wird in lib/sponsorship-subscription/reservation.ts * Kontingent"). Pakete und Abos bekommen automatisch nicht-cremigen Honig
* berechnet; hier wird nur zusammengestellt und angezeigt. * (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). */ /** Abos, deren naechste Lieferung noch Honig braucht (wie in reservation.ts). */
@@ -16,13 +21,20 @@ export interface HoneyStock {
free: number; free: number;
} }
export interface HoneyCoverageRow extends HoneyStock { export interface EligibleHoneyRow extends HoneyStock {
productId: string; productId: string;
productName: 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. */ /** Glaeser, die eine vierteljaehrliche Lieferrunde aller laufenden Abos braucht. */
jarsPerDelivery: number; 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; deliveriesCovered: number | null;
} }
@@ -32,69 +44,58 @@ export function deliveriesCovered(stock: number, jarsPerDelivery: number): numbe
return Math.floor(Math.max(0, stock) / jarsPerDelivery); 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<Map<string, HoneyStock>> { * Passender Honig (aktiv, nicht cremig) mit Bestand, Abo-Reservierung und
const ids = [...new Set(productIds)]; * freiem Bestand, in der Rangfolge der automatischen Wahl (bei gleicher Ernte
if (ids.length === 0) return new Map(); * nach freiem Bestand). Eine Abfrage-Runde fuer alle Produkte.
const inventories = await prisma.inventory.findMany({ */
where: { productId: { in: ids } }, export async function getEligibleHoneyStock(now: Date = new Date()): Promise<EligibleHoneyRow[]> {
select: { productId: true, quantity: true }, const stock = await loadHoneyStock();
}); const reserved = await subscriptionReservationByProduct(now, prisma, stock);
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity])); const withFree = stock.map((candidate) => ({
const reservedById = await reservedJarsByProduct(ids, now); ...candidate,
return new Map( freeStock: freeStock(candidate.inventory, reserved.get(candidate.id) ?? 0),
ids.map((id) => { }));
const stock = stockById.get(id) ?? 0; return rankHoneyCandidates(withFree).map((candidate) => ({
const reserved = reservedById.get(id) ?? 0; productId: candidate.id,
return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const; 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. */ /** Zeilen zurueck in Kandidaten der automatischen Wahl (freier Bestand). */
export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverageRow[]> { export function toHoneyCandidates(rows: readonly EligibleHoneyRow[]): HoneyCandidate[] {
const offers = await prisma.sponsorshipOffer.findMany({ return rows.map((row) => ({
where: { kind: "ABO", honeyProductId: { not: null } }, id: row.productId,
select: { honeyProduct: { select: { id: true, name: true, isActive: true } } }, name: row.productName,
}); weightGrams: row.weightGrams,
const products = new Map( harvestYear: row.harvestYear,
offers.flatMap((offer) => (offer.honeyProduct ? [[offer.honeyProduct.id, offer.honeyProduct] as const] : [])) consistency: row.consistency,
); inventory: row.stock,
if (products.size === 0) return []; freeStock: row.free,
}));
}
const [stock, subscriptions] = await Promise.all([ /** Deckung der Abos durch den passenden Honig insgesamt. */
getHoneyStock([...products.keys()], now), export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverage> {
const [rows, subscriptions] = await Promise.all([
getEligibleHoneyStock(now),
prisma.sponsorshipSubscription.findMany({ prisma.sponsorshipSubscription.findMany({
where: { where: { status: { in: [...DEMANDING_STATUSES] }, offer: { cycle: "VIERTELJAEHRLICH", honeyJars: { gt: 0 } } },
status: { in: [...DEMANDING_STATUSES] }, select: { offer: { select: { honeyJars: true } } },
offer: { cycle: "VIERTELJAEHRLICH", honeyProductId: { in: [...products.keys()] } },
},
select: { offer: { select: { honeyProductId: true, honeyJars: true } } },
}), }),
]); ]);
const demand = new Map<string, number>(); const jarsPerDelivery = subscriptions.reduce((sum, { offer }) => sum + offer.honeyJars, 0);
for (const { offer } of subscriptions) { const totalStock = rows.reduce((sum, row) => sum + row.stock, 0);
if (!offer.honeyProductId) continue; return { rows, jarsPerDelivery, deliveriesCovered: deliveriesCovered(totalStock, jarsPerDelivery) };
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"));
} }
/** Fuer die Aufmerksamkeitsliste: Produkte, deren Bestand keine volle Lieferrunde mehr deckt. */ /** Fuer die Aufmerksamkeitsliste: 1, wenn der passende Honig keine volle Lieferrunde mehr deckt. */
export function countUndercovered(rows: HoneyCoverageRow[]): number { export function countUndercovered(coverage: Pick<HoneyCoverage, "deliveriesCovered">): number {
return rows.filter((row) => row.deliveriesCovered !== null && row.deliveriesCovered < 1).length; return coverage.deliveriesCovered !== null && coverage.deliveriesCovered < 1 ? 1 : 0;
} }
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { isOfferAvailable } from "@/lib/sponsorship/offers"; 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). */ /** Abos, die einen Platz im Kontingent belegen (wie lib/sponsorship/offers.ts). */
const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const; 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 }) { async function loadOffers(where: { id?: string }) {
return prisma.sponsorshipOffer.findMany({ return prisma.sponsorshipOffer.findMany({
where, where,
include: { include: { product: { select: { id: true, priceCents: true } } },
honeyProduct: { select: { id: true, name: true, isActive: true } },
product: { select: { id: true, priceCents: true } },
},
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
}); });
} }
export type OfferRow = Awaited<ReturnType<typeof loadOffers>>[number]; export type OfferRow = Awaited<ReturnType<typeof loadOffers>>[number];
/** Der Honig, den die automatische Wahl gerade fuer ein Angebot nimmt. */
export interface CurrentHoney extends HoneyStock {
productId: string;
/** "<Name> <Jahr>" */
label: string;
}
export interface OfferOverviewRow { export interface OfferOverviewRow {
offer: OfferRow; offer: OfferRow;
/** Live: dieselbe Entscheidung wie auf der Verkaufsseite. */ /** Live: dieselbe Entscheidung wie auf der Verkaufsseite. */
isAvailable: boolean; isAvailable: boolean;
activeSubscriptions: number; 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, * Angebote samt Live-Verfuegbarkeit und automatisch gewaehltem Honig. Fuer
* auch inaktive — getActiveOffers() aus lib/sponsorship/offers.ts kennt nur * ALLE Angebote, auch inaktive — getActiveOffers() aus
* aktive, der Admin will aber sehen, ob ein Angebot nach dem Aktivieren * lib/sponsorship/offers.ts kennt nur aktive, der Admin will aber sehen, ob
* ueberhaupt bestellbar waere. Die Entscheidung selbst bleibt isOfferAvailable. * ein Angebot nach dem Aktivieren ueberhaupt bestellbar waere. Die
* Entscheidung selbst bleibt isOfferAvailable.
*/ */
export async function getOfferOverview(offerId?: string): Promise<OfferOverviewRow[]> { export async function getOfferOverview(offerId?: string): Promise<OfferOverview> {
const offers = await loadOffers(offerId ? { id: offerId } : {}); const offers = await loadOffers(offerId ? { id: offerId } : {});
const honeyIds = offers.flatMap((offer) => (offer.honeyProductId ? [offer.honeyProductId] : [])); const [eligibleHoney, groups] = await Promise.all([
const [stock, groups] = await Promise.all([ getEligibleHoneyStock(),
getHoneyStock(honeyIds),
prisma.sponsorshipSubscription.groupBy({ prisma.sponsorshipSubscription.groupBy({
by: ["offerId"], by: ["offerId"],
where: { offerId: { in: offers.map((offer) => offer.id) }, status: { in: [...QUOTA_STATUSES] } }, where: { offerId: { in: offers.map((offer) => offer.id) }, status: { in: [...QUOTA_STATUSES] } },
@@ -45,35 +72,23 @@ export async function getOfferOverview(offerId?: string): Promise<OfferOverviewR
]); ]);
const subscriptionCounts = new Map(groups.map((group) => [group.offerId, group._count._all])); const subscriptionCounts = new Map(groups.map((group) => [group.offerId, group._count._all]));
return offers.map((offer) => { const rows = offers.map((offer) => {
const honeyStock = offer.honeyProductId ? (stock.get(offer.honeyProductId) ?? null) : null; const currentHoney = currentHoneyFor(eligibleHoney, offer.honeyJars);
const activeSubscriptions = subscriptionCounts.get(offer.id) ?? 0; const activeSubscriptions = subscriptionCounts.get(offer.id) ?? 0;
return { return {
offer, offer,
activeSubscriptions, activeSubscriptions,
honeyStock, currentHoney,
isAvailable: isOfferAvailable({ isAvailable: isOfferAvailable({
isActive: offer.isActive, isActive: offer.isActive,
kind: offer.kind, kind: offer.kind,
hasCarrierProduct: offer.productId !== null, hasCarrierProduct: offer.productId !== null,
honeyJars: offer.honeyJars, honeyJars: offer.honeyJars,
honeyProductActive: offer.honeyProduct ? offer.honeyProduct.isActive : null, hasEligibleHoney: currentHoney !== null,
freeJars: honeyStock?.free ?? 0,
maxActiveSubscriptions: offer.maxActiveSubscriptions, maxActiveSubscriptions: offer.maxActiveSubscriptions,
activeSubscriptions, activeSubscriptions,
}), }),
}; };
}); });
} return { rows, eligibleHoney };
/** 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" },
});
} }
@@ -16,7 +16,6 @@ const oneTime = {
price: "39,00", price: "39,00",
honeySharePerJar: "7", honeySharePerJar: "7",
isPhysical: "on", isPhysical: "on",
honeyProductId: "prod_1",
honeyJars: "1", honeyJars: "1",
medalTier: "SILBER", medalTier: "SILBER",
}; };
@@ -29,7 +28,6 @@ const subscription = {
price: "12,50", price: "12,50",
honeySharePerMonth: "4", honeySharePerMonth: "4",
isPhysical: "on", isPhysical: "on",
honeyProductId: "prod_1",
honeyJars: "2", honeyJars: "2",
cycle: "VIERTELJAEHRLICH", cycle: "VIERTELJAEHRLICH",
medalTier: "ABO", 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." }); 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", () => { it("verlangt fuer physische Angebote mindestens ein Glas", () => {
const result = parseOfferForm(form({ ...oneTime, honeyProductId: "", honeyJars: "0", honeySharePerJar: "" })); const result = parseOfferForm(form({ ...oneTime, honeyJars: "0", honeySharePerJar: "" }));
expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas." }); 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", () => { it("erlaubt ein rein digitales Angebot ohne Honig", () => {
const result = parseOfferForm( 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); expect(result.ok).toBe(true);
if (result.ok) expect(carrierPriceCents(result.value)).toBe(1900); if (result.ok) expect(carrierPriceCents(result.value)).toBe(1900);
@@ -96,11 +96,6 @@ const offerFormSchema = z
isActive: z.boolean(), isActive: z.boolean(),
isPhysical: z.boolean(), isPhysical: z.boolean(),
sortOrder: intField("Reihenfolge", 0, 9999), sortOrder: intField("Reihenfolge", 0, 9999),
honeyProductId: z
.string()
.trim()
.max(64)
.transform((value) => (value === "" ? null : value)),
honeyJars: intField("Gläser", 0, 24), honeyJars: intField("Gläser", 0, 24),
cycle: z.union([z.enum(OFFER_CYCLES), z.literal("")]).transform((value) => (value === "" ? null : value)), 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) => 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) => { .superRefine((offer, ctx) => {
const issue = (message: string) => ctx.addIssue({ code: "custom", message }); const issue = (message: string) => ctx.addIssue({ code: "custom", message });
if (offer.priceCents <= 0) issue("Der Preis muss größer als 0 € sein."); 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."); // Welcher Honig, waehlt das System automatisch (nie cremig, neueste
if (offer.isPhysical && (!offer.honeyProductId || offer.honeyJars < 1)) { // Ernte — lib/sponsorship/honey-selection.ts); hier geht es nur um die
issue("Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas."); // 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") { if (offer.kind === "EINMALIG") {
// Der Rest nach Abzug des Honiganteils ist der Preis des Traegerprodukts // Der Rest nach Abzug des Honiganteils ist der Preis des Traegerprodukts
// (Urkunde und Leistung zum Regelsatz) — er muss positiv bleiben. // (Urkunde und Leistung zum Regelsatz) — er muss positiv bleiben.
@@ -170,7 +165,6 @@ export function parseOfferForm(
isActive: formData.get("isActive") === "on", isActive: formData.get("isActive") === "on",
isPhysical: formData.get("isPhysical") === "on", isPhysical: formData.get("isPhysical") === "on",
sortOrder: field(formData, "sortOrder") || "0", sortOrder: field(formData, "sortOrder") || "0",
honeyProductId: field(formData, "honeyProductId"),
honeyJars: field(formData, "honeyJars") || "0", honeyJars: field(formData, "honeyJars") || "0",
cycle: field(formData, "cycle"), cycle: field(formData, "cycle"),
maxActiveSubscriptions: field(formData, "maxActiveSubscriptions").trim(), maxActiveSubscriptions: field(formData, "maxActiveSubscriptions").trim(),
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
import { nextDeliveryView } from "./subscription-view"; import { nextDeliveryView } from "./subscription-view";
import { countUndercovered, deliveriesCovered, type HoneyCoverageRow } from "./coverage"; import { countUndercovered, deliveriesCovered } from "./coverage";
describe("nextDeliveryView", () => { describe("nextDeliveryView", () => {
it("vierteljaehrlich: zaehlt die fehlenden bezahlten Monate", () => { it("vierteljaehrlich: zaehlt die fehlenden bezahlten Monate", () => {
@@ -44,17 +44,9 @@ describe("Honigdeckung", () => {
expect(deliveriesCovered(10, 0)).toBeNull(); expect(deliveriesCovered(10, 0)).toBeNull();
}); });
it("zaehlt nur Produkte unter einer Lieferrunde", () => { it("meldet hoechstens einen Eintrag: wenn der passende Honig zusammen keine Lieferrunde mehr deckt", () => {
const row = (deliveries: number | null): HoneyCoverageRow => ({ expect(countUndercovered({ deliveriesCovered: 0 })).toBe(1);
productId: "p", expect(countUndercovered({ deliveriesCovered: 1 })).toBe(0);
productName: "Honig", expect(countUndercovered({ deliveriesCovered: null })).toBe(0);
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);
}); });
}); });
+1 -1
View File
@@ -164,7 +164,7 @@ export default async function AdminSponsorshipSubscriptionsPage(props: PageProps
<div className="mt-10 grid gap-6 lg:grid-cols-5"> <div className="mt-10 grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<CoveragePanel rows={coverage} t={ts} /> <CoveragePanel coverage={coverage} t={ts} />
</div> </div>
<div className="flex flex-col gap-6 lg:col-span-2"> <div className="flex flex-col gap-6 lg:col-span-2">
<SepaSettingsPanel holdDays={holdDays} t={ts} /> <SepaSettingsPanel holdDays={holdDays} t={ts} />
@@ -7,16 +7,17 @@ import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin"; import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { updateSponsorshipOfferAction } from "@/app/admin/actions"; import { updateSponsorshipOfferAction } from "@/app/admin/actions";
import { OfferForm } from "../../_components/offer-form"; 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]">) { export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/admin/patenschaften/angebote/[id]">) {
const t = getSponsorshipAdminStrings(await getLocale()); const t = getSponsorshipAdminStrings(await getLocale());
const tf = t.offerForm; const tf = t.offerForm;
const { id } = await props.params; const { id } = await props.params;
const [row] = await getOfferOverview(id); const {
rows: [row],
} = await getOfferOverview(id);
if (!row) notFound(); if (!row) notFound();
const { offer, isAvailable, honeyStock, activeSubscriptions } = row; const { offer, isAvailable, currentHoney, activeSubscriptions } = row;
const honeyOptions = await getHoneyProductOptions(offer.honeyProductId);
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
@@ -30,8 +31,12 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a
<AdminBadge tone={isAvailable ? "positive" : "pending"}>{isAvailable ? t.offers.available : t.offers.unavailable}</AdminBadge> <AdminBadge tone={isAvailable ? "positive" : "pending"}>{isAvailable ? t.offers.available : t.offers.unavailable}</AdminBadge>
<span className="text-sm text-ink/75">{isAvailable ? tf.availableNow : tf.unavailableNow}</span> <span className="text-sm text-ink/75">{isAvailable ? tf.availableNow : tf.unavailableNow}</span>
</div> </div>
{honeyStock ? ( {currentHoney ? (
<p className="mt-3 text-sm tabular-nums text-ink/75">{tf.stockLine(honeyStock.stock, honeyStock.reserved, honeyStock.free)}</p> <p className="mt-3 text-sm tabular-nums text-ink/75">
{tf.stockLine(currentHoney.label, currentHoney.stock, currentHoney.reserved, currentHoney.free)}
</p>
) : offer.honeyJars > 0 ? (
<p className="mt-3 text-sm text-ink/75">{tf.noEligibleHoney}</p>
) : null} ) : null}
{offer.kind === "ABO" ? ( {offer.kind === "ABO" ? (
<p className="mt-1 text-sm tabular-nums text-ink/75">{tf.activeSubscriptions(activeSubscriptions, offer.maxActiveSubscriptions)}</p> <p className="mt-1 text-sm tabular-nums text-ink/75">{tf.activeSubscriptions(activeSubscriptions, offer.maxActiveSubscriptions)}</p>
@@ -45,7 +50,7 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a
<OfferForm <OfferForm
isNew={false} isNew={false}
honeyOptions={honeyOptions} currentHoneyLabel={currentHoney?.label ?? null}
action={updateSponsorshipOfferAction} action={updateSponsorshipOfferAction}
t={t} t={t}
defaults={{ defaults={{
@@ -64,7 +69,6 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a
isActive: offer.isActive, isActive: offer.isActive,
isPhysical: offer.isPhysical, isPhysical: offer.isPhysical,
sortOrder: offer.sortOrder, sortOrder: offer.sortOrder,
honeyProductId: offer.honeyProductId,
honeyJars: offer.honeyJars, honeyJars: offer.honeyJars,
cycle: offer.cycle, cycle: offer.cycle,
maxActiveSubscriptions: offer.maxActiveSubscriptions, maxActiveSubscriptions: offer.maxActiveSubscriptions,
@@ -3,16 +3,19 @@ import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin"; import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { createSponsorshipOfferAction } from "@/app/admin/actions"; import { createSponsorshipOfferAction } from "@/app/admin/actions";
import { EMPTY_OFFER, OfferForm } from "../../_components/offer-form"; import { EMPTY_OFFER, OfferForm } from "../../_components/offer-form";
import { getHoneyProductOptions } from "../../_lib/offer-overview"; import { currentHoneyFor } from "../../_lib/offer-overview";
import { getEligibleHoneyStock } from "../../_lib/coverage";
export default async function AdminSponsorshipOfferNewPage() { export default async function AdminSponsorshipOfferNewPage() {
const t = getSponsorshipAdminStrings(await getLocale()); const t = getSponsorshipAdminStrings(await getLocale());
const honeyOptions = await getHoneyProductOptions(null); // Vorschau der automatischen Wahl fuer ein Glas — die echte Wahl richtet
// sich nach den Glaesern des Angebots und faellt bei jeder Bestellung neu.
const currentHoney = currentHoneyFor(await getEligibleHoneyStock(), 1);
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<BackLink href="/admin/patenschaften/angebote" label={t.offerForm.backToOffers} /> <BackLink href="/admin/patenschaften/angebote" label={t.offerForm.backToOffers} />
<h1 className="mb-8 text-2xl text-heading">{t.offerForm.newTitle}</h1> <h1 className="mb-8 text-2xl text-heading">{t.offerForm.newTitle}</h1>
<OfferForm defaults={EMPTY_OFFER} isNew honeyOptions={honeyOptions} action={createSponsorshipOfferAction} t={t} /> <OfferForm defaults={EMPTY_OFFER} isNew currentHoneyLabel={currentHoney?.label ?? null} action={createSponsorshipOfferAction} t={t} />
</div> </div>
); );
} }
+28 -22
View File
@@ -10,23 +10,16 @@ import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin"; import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { SponsorshipTabs } from "../_components/sponsorship-tabs"; import { SponsorshipTabs } from "../_components/sponsorship-tabs";
import { getOfferOverview, type OfferOverviewRow } from "../_lib/offer-overview"; import { getOfferOverview } from "../_lib/offer-overview";
/** Je verknuepftem Honigprodukt eine Zeile (mehrere Angebote koennen dasselbe Produkt nutzen). */
function honeyStockRows(rows: OfferOverviewRow[]) {
const byProduct = new Map<string, { name: string; isActive: boolean; stock: number; reserved: number; free: number }>();
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"));
}
export default async function AdminSponsorshipOffersPage() { export default async function AdminSponsorshipOffersPage() {
const t = getSponsorshipAdminStrings(await getLocale()); const t = getSponsorshipAdminStrings(await getLocale());
const to = t.offers; const to = t.offers;
const rows = await getOfferOverview(); // Honig waehlt das System automatisch (nie cremig, neueste Ernte); gezeigt
const stockRows = honeyStockRows(rows); // 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 }; const newOffer = { href: "/admin/patenschaften/angebote/neu", label: to.newOffer };
return ( return (
@@ -50,7 +43,7 @@ export default async function AdminSponsorshipOffersPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{rows.map(({ offer, isAvailable }) => ( {rows.map(({ offer, isAvailable, currentHoney }) => (
<tr key={offer.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={offer.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4"> <td className="p-4">
<span className="text-heading">{offer.name}</span> <span className="text-heading">{offer.name}</span>
@@ -66,7 +59,11 @@ export default async function AdminSponsorshipOffersPage() {
{offer.kind === "ABO" ? <span className="text-ink/65"> {to.perMonth}</span> : null} {offer.kind === "ABO" ? <span className="text-ink/65"> {to.perMonth}</span> : null}
</td> </td>
<td className="p-4"> <td className="p-4">
{offer.honeyProduct && offer.honeyJars > 0 ? to.jars(offer.honeyJars, offer.honeyProduct.name) : <span className="text-ink/60">{to.noHoney}</span>} {offer.honeyJars > 0 ? (
to.honeyAuto(offer.honeyJars, currentHoney?.label ?? null)
) : (
<span className="text-ink/60">{to.noHoney}</span>
)}
</td> </td>
<td className="p-4">{t.medals[offer.medalTier]}</td> <td className="p-4">{t.medals[offer.medalTier]}</td>
<td className="p-4"> <td className="p-4">
@@ -91,27 +88,36 @@ export default async function AdminSponsorshipOffersPage() {
</div> </div>
<section className="mt-10"> <section className="mt-10">
<h2 className="mb-3 text-lg text-heading">{to.stockHeading}</h2> <h2 className="mb-1 text-lg text-heading">{to.stockHeading}</h2>
{stockRows.length === 0 ? ( <p className="mb-3 max-w-prose text-xs text-ink/65">{to.stockIntro}</p>
<p className="text-sm text-ink/65">{to.noLinkedHoney}</p> {eligibleHoney.length === 0 ? (
<p className="text-sm text-ink/65">{to.noEligibleHoney}</p>
) : ( ) : (
<div className="overflow-x-auto border border-ink/10 bg-background"> <div className="overflow-x-auto border border-ink/10 bg-background">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b border-ink/10 text-left text-ink/65"> <thead className="border-b border-ink/10 text-left text-ink/65">
<tr> <tr>
<th className="p-4">{to.colProduct}</th> <th className="p-4">{to.colProduct}</th>
<th className="p-4 text-right">{to.colYear}</th>
<th className="p-4">{to.colConsistency}</th>
<th className="p-4 text-right">{to.colStock}</th> <th className="p-4 text-right">{to.colStock}</th>
<th className="p-4 text-right">{to.colReserved}</th> <th className="p-4 text-right">{to.colReserved}</th>
<th className="p-4 text-right">{to.colFree}</th> <th className="p-4 text-right">{to.colFree}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{stockRows.map(([productId, row]) => ( {eligibleHoney.map((row) => (
<tr key={productId}> <tr key={row.productId}>
<td className="p-4"> <td className="p-4">
{row.name} {row.productName}
{row.isActive ? null : <span className="ml-2 text-xs text-ink/60">({to.inactive})</span>} {row.productId === firstChoiceId ? (
<span className="ml-2">
<AdminBadge tone="positive">{to.firstChoice}</AdminBadge>
</span>
) : null}
</td> </td>
<td className="p-4 text-right tabular-nums">{row.harvestYear}</td>
<td className="p-4">{row.consistency}</td>
<td className="p-4 text-right tabular-nums">{row.stock}</td> <td className="p-4 text-right tabular-nums">{row.stock}</td>
<td className="p-4 text-right tabular-nums">{row.reserved}</td> <td className="p-4 text-right tabular-nums">{row.reserved}</td>
<td className="p-4 text-right tabular-nums text-heading">{row.free}</td> <td className="p-4 text-right tabular-nums text-heading">{row.free}</td>
+36
View File
@@ -74,6 +74,8 @@ vi.mock("@/lib/prisma", () => ({ prisma: db }));
const getOffersByProductIds = vi.fn<(ids: string[]) => Promise<Map<string, unknown>>>(async () => new Map()); const getOffersByProductIds = vi.fn<(ids: string[]) => Promise<Map<string, unknown>>>(async () => new Map());
vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: (ids: string[]) => getOffersByProductIds(ids) })); vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: (ids: string[]) => getOffersByProductIds(ids) }));
vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsByProduct: vi.fn(async () => new Map()) })); vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsByProduct: vi.fn(async () => new Map()) }));
const loadHoneyCandidates = vi.fn<() => Promise<unknown[]>>(async () => []);
vi.mock("@/lib/sponsorship/honey-candidates", () => ({ loadHoneyCandidates: () => loadHoneyCandidates() }));
const { POST } = await import("./route"); const { POST } = await import("./route");
@@ -121,6 +123,7 @@ beforeEach(() => {
getProductsByIds.mockResolvedValue([honey]); getProductsByIds.mockResolvedValue([honey]);
getCurrentCustomer.mockResolvedValue(null); getCurrentCustomer.mockResolvedValue(null);
getOffersByProductIds.mockResolvedValue(new Map()); getOffersByProductIds.mockResolvedValue(new Map());
loadHoneyCandidates.mockResolvedValue([]);
getShippingRegion.mockResolvedValue("DE"); getShippingRegion.mockResolvedValue("DE");
db.discountCode.findMany.mockResolvedValue([]); db.discountCode.findMany.mockResolvedValue([]);
db.inventory.updateMany.mockResolvedValue({ count: 1 }); db.inventory.updateMany.mockResolvedValue({ count: 1 });
@@ -467,12 +470,45 @@ describe("Checkout-Route", () => {
validUntil: null, validUntil: null,
}; };
const honeyCandidate = { id: "p1", name: honey.name, weightGrams: 500, harvestYear: 2026, consistency: "flüssig", inventory: 100, freeStock: 100 };
beforeEach(() => { beforeEach(() => {
getCurrentCustomer.mockResolvedValue({ id: "cust_1", emailVerifiedAt: new Date() }); getCurrentCustomer.mockResolvedValue({ id: "cust_1", emailVerifiedAt: new Date() });
getOffersByProductIds.mockResolvedValue(new Map([["carrier", offer]])); getOffersByProductIds.mockResolvedValue(new Map([["carrier", offer]]));
loadHoneyCandidates.mockResolvedValue([honeyCandidate]);
getProductsByIds.mockImplementation(async (ids: string[]) => [honey, carrier].filter((product) => ids.includes(product.id))); 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 () => { test("Mengenrabatt: Glaeser aus einem Patenschaftspaket zaehlen nicht zur Schwelle", async () => {
// 2 einzelne Glaeser + 3 Paketglaeser = 5 physische, aber nur 2 rabattfaehige. // 2 einzelne Glaeser + 3 Paketglaeser = 5 physische, aber nur 2 rabattfaehige.
db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]); db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]);
+23 -6
View File
@@ -24,6 +24,8 @@ import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand"; import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
import { getOffersByProductIds } from "@/lib/sponsorship/offers"; 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"; import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
class OutOfStockError extends Error {} 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 productIds = [...new Set(input.items.map((item) => item.productId))];
const locale = await getLocale(); const locale = await getLocale();
const offersByProduct = await getOffersByProductIds(productIds, locale); const offersByProduct = await getOffersByProductIds(productIds, locale);
const honeyIds = [...offersByProduct.values()] // Honig fuer Pakete waehlt der Checkout selbst, im Moment der Bestellung
.map((offer) => offer.honeyProduct?.id) // (lib/sponsorship/honey-selection.ts) — dafuer braucht er alle Kandidaten
.filter((id): id is string => Boolean(id) && !productIds.includes(id as string)); // 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 products = await getProductsByIds(productIds);
const honeyProducts = honeyIds.length > 0 ? await getProductsByIds(honeyIds) : []; const honeyProducts = honeyIds.length > 0 ? await getProductsByIds(honeyIds) : [];
@@ -290,6 +299,7 @@ export async function POST(request: Request) {
cartItems: input.items, cartItems: input.items,
products: productMap, products: productMap,
offers: offersByProduct, offers: offersByProduct,
honeyCandidates,
vatRateFor: (product) => vatRateForProduct(product, process.env), vatRateFor: (product) => vatRateForProduct(product, process.env),
locale, locale,
now: new Date(), now: new Date(),
@@ -329,9 +339,16 @@ export async function POST(request: Request) {
// Fuer Abos reservierte Glaeser duerfen weder einzeln noch im Paket // Fuer Abos reservierte Glaeser duerfen weder einzeln noch im Paket
// verkauft werden (lib/sponsorship-subscription/reservation.ts). Vor der // verkauft werden (lib/sponsorship-subscription/reservation.ts). Vor der
// Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht // Transaktion gelesen, wie bisher: die verbindliche Bestandspruefung bleibt
// durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung // das bedingte Update in der Transaktion. Seit die Abo-Glaeser automatisch
// bleibt das bedingte Update in der Transaktion. // 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)); const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId));
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand — // Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
+48 -27
View File
@@ -186,17 +186,23 @@ const de = {
available: "Bestellbar", available: "Bestellbar",
unavailable: "Nicht bestellbar", unavailable: "Nicht bestellbar",
perMonth: "/ Monat", 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", noHoney: "ohne Honig",
edit: "Bearbeiten", edit: "Bearbeiten",
harvestWindows: "Erntefenster", harvestWindows: "Erntefenster",
empty: "Noch keine Angebote.", 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", colProduct: "Produkt",
colYear: "Ernte",
colConsistency: "Konsistenz",
colStock: "Bestand", colStock: "Bestand",
colReserved: "Für Abos reserviert", colReserved: "Für Abos reserviert",
colFree: "Frei verkäuflich", 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: { offerForm: {
newTitle: "Neues Patenschafts-Angebot", newTitle: "Neues Patenschafts-Angebot",
@@ -221,9 +227,11 @@ const de = {
carrierPrice: (price: string) => `Trägerprodukt derzeit: ${price} (Paketpreis minus Honiganteil).`, carrierPrice: (price: string) => `Trägerprodukt derzeit: ${price} (Paketpreis minus Honiganteil).`,
sectionHoney: "Honig und Versand", sectionHoney: "Honig und Versand",
isPhysical: "Physisch: gedruckte Urkunde, Honig, Versand", isPhysical: "Physisch: gedruckte Urkunde, Honig, Versand",
honeyProduct: "Honigprodukt", honey: "Honig",
honeyProductNone: "Kein Honig", honeyAuto: (current: string | null) =>
inactiveSuffix: " (inaktiv)", 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)", honeyJars: "Gläser (Einmalig: im Paket, Abo: je Lieferung)",
sectionAbo: "Abo", sectionAbo: "Abo",
cycle: "Lieferzyklus (nur Abo)", cycle: "Lieferzyklus (nur Abo)",
@@ -238,9 +246,10 @@ const de = {
save: "Speichern", save: "Speichern",
availabilityHeading: "Verfügbarkeit", availabilityHeading: "Verfügbarkeit",
availableNow: "Derzeit bestellbar.", availableNow: "Derzeit bestellbar.",
unavailableNow: "Derzeit nicht bestellbar: Angebot inaktiv, Honig fehlt oder Kontingent voll.", unavailableNow: "Derzeit nicht bestellbar: Angebot inaktiv, kein passender Honig oder Kontingent voll.",
stockLine: (stock: number, reserved: number, free: number) => stockLine: (label: string, stock: number, reserved: number, free: number) =>
`Honigbestand ${stock}, davon ${reserved} für Abos reserviert, ${free} frei.`, `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) => activeSubscriptions: (n: number, max: number | null) =>
max === null ? `${n} aktive Abos (unbegrenzt).` : `${n} von ${max} Abo-Plätzen belegt.`, max === null ? `${n} aktive Abos (unbegrenzt).` : `${n} von ${max} Abo-Plätzen belegt.`,
}, },
@@ -283,16 +292,18 @@ const de = {
empty: "Noch keine Abos.", empty: "Noch keine Abos.",
emptyFiltered: "Keine Abos zu diesem Filter.", emptyFiltered: "Keine Abos zu diesem Filter.",
coverageHeading: "Honigdeckung der Abos", 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", colProduct: "Honigprodukt",
colYear: "Ernte",
colStock: "Bestand", colStock: "Bestand",
colReserved: "Reserviert", colReserved: "Reserviert",
colFree: "Frei", colFree: "Frei",
colPerDelivery: "Gläser je Lieferrunde", perDelivery: (n: number) => `Gläser je vierteljährlicher Lieferrunde: ${n}`,
colCovers: "Deckt", coversLabel: "Deckt",
covers: (n: number) => `${n} ${plural(n, "Lieferung", "Lieferungen")}`, covers: (n: number) => `${n} ${plural(n, "Lieferung", "Lieferungen")}`,
coverageWarning: "unter einer Lieferung", 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", 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).", 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)", sepaLabel: "Frist in Tagen (0 = aus)",
@@ -323,7 +334,7 @@ const de = {
attention: { attention: {
failed: (n: number) => `${n} ${plural(n, "Patenschaft braucht", "Patenschaften brauchen")} Hilfe (Fehler bei der Ausstellung)`, 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`, 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`, delinquent: (n: number) => `${n} ${plural(n, "Abo ist", "Abos sind")} im Verzug`,
}, },
order: { order: {
@@ -510,17 +521,22 @@ const en: SponsorshipAdminStrings = {
available: "Available", available: "Available",
unavailable: "Not available", unavailable: "Not available",
perMonth: "/ month", perMonth: "/ month",
jars: (n, product) => `${n} × ${product}`, honeyAuto: (n, current) => `${n} × automatic ${current ? `(currently: ${current})` : "(no suitable honey right now)"}`,
noHoney: "no honey", noHoney: "no honey",
edit: "Edit", edit: "Edit",
harvestWindows: "Harvest windows", harvestWindows: "Harvest windows",
empty: "No offers yet.", 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", colProduct: "Product",
colYear: "Harvest",
colConsistency: "Consistency",
colStock: "Stock", colStock: "Stock",
colReserved: "Reserved for subscriptions", colReserved: "Reserved for subscriptions",
colFree: "Free to sell", 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: { offerForm: {
newTitle: "New sponsorship offer", newTitle: "New sponsorship offer",
@@ -545,9 +561,10 @@ const en: SponsorshipAdminStrings = {
carrierPrice: (price) => `Carrier product currently: ${price} (package price minus honey share).`, carrierPrice: (price) => `Carrier product currently: ${price} (package price minus honey share).`,
sectionHoney: "Honey and shipping", sectionHoney: "Honey and shipping",
isPhysical: "Physical: printed certificate, honey, shipping", isPhysical: "Physical: printed certificate, honey, shipping",
honeyProduct: "Honey product", honey: "Honey",
honeyProductNone: "No honey", honeyAuto: (current) => (current ? `automatic (currently: ${current})` : "automatic (no suitable honey with enough stock right now)"),
inactiveSuffix: " (inactive)", 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)", honeyJars: "Jars (one-time: in the package, subscription: per delivery)",
sectionAbo: "Subscription", sectionAbo: "Subscription",
cycle: "Delivery cycle (subscription only)", cycle: "Delivery cycle (subscription only)",
@@ -562,8 +579,10 @@ const en: SponsorshipAdminStrings = {
save: "Save", save: "Save",
availabilityHeading: "Availability", availabilityHeading: "Availability",
availableNow: "Currently available.", availableNow: "Currently available.",
unavailableNow: "Currently not available: offer inactive, honey missing or quota full.", unavailableNow: "Currently not available: offer inactive, no suitable honey or quota full.",
stockLine: (stock, reserved, free) => `Honey stock ${stock}, of which ${reserved} reserved for subscriptions, ${free} free.`, 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.`), activeSubscriptions: (n, max) => (max === null ? `${n} active subscriptions (unlimited).` : `${n} of ${max} subscription slots taken.`),
}, },
harvest: { harvest: {
@@ -605,16 +624,18 @@ const en: SponsorshipAdminStrings = {
empty: "No subscriptions yet.", empty: "No subscriptions yet.",
emptyFiltered: "No subscriptions match this filter.", emptyFiltered: "No subscriptions match this filter.",
coverageHeading: "Honey coverage of subscriptions", 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", colProduct: "Honey product",
colYear: "Harvest",
colStock: "Stock", colStock: "Stock",
colReserved: "Reserved", colReserved: "Reserved",
colFree: "Free", colFree: "Free",
colPerDelivery: "Jars per delivery round", perDelivery: (n) => `Jars per quarterly delivery round: ${n}`,
colCovers: "Covers", coversLabel: "Covers",
covers: (n) => `${n} ${plural(n, "delivery", "deliveries")}`, covers: (n) => `${n} ${plural(n, "delivery", "deliveries")}`,
coverageWarning: "below one delivery", 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", 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).", 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)", sepaLabel: "Hold in days (0 = off)",
@@ -645,7 +666,7 @@ const en: SponsorshipAdminStrings = {
attention: { attention: {
failed: (n) => `${n} ${plural(n, "sponsorship needs", "sponsorships need")} help (issuing failed)`, 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`, 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`, delinquent: (n) => `${n} ${plural(n, "subscription is", "subscriptions are")} delinquent`,
}, },
order: { order: {
+2 -2
View File
@@ -179,7 +179,7 @@ const de: SponsorshipShopStrings = {
}, },
{ {
title: "Honig dazu, wenn du magst", 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", 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.",
}, },
], ],
}, },
@@ -10,6 +10,7 @@ const m = vi.hoisted(() => ({
sponsorshipSubscription: { findMany: vi.fn() }, sponsorshipSubscription: { findMany: vi.fn() },
subscriptionPayment: { updateMany: vi.fn() }, subscriptionPayment: { updateMany: vi.fn() },
inventory: { updateMany: vi.fn() }, inventory: { updateMany: vi.fn() },
product: { findUnique: vi.fn() },
order: { create: vi.fn() }, order: { create: vi.fn() },
customer: { findUniqueOrThrow: vi.fn() }, customer: { findUniqueOrThrow: vi.fn() },
$transaction: vi.fn(), $transaction: vi.fn(),
@@ -17,6 +18,7 @@ const m = vi.hoisted(() => ({
sendMail: vi.fn(), sendMail: vi.fn(),
recordOrderSale: vi.fn(), recordOrderSale: vi.fn(),
scheduleApiCoreStockSync: vi.fn(), scheduleApiCoreStockSync: vi.fn(),
loadHoneyStock: vi.fn(),
})); }));
vi.mock("@/lib/prisma", () => ({ prisma: m.db })); 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/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
vi.mock("@/lib/apicore-stock/stock-events", () => ({ recordOrderSale: m.recordOrderSale })); vi.mock("@/lib/apicore-stock/stock-events", () => ({ recordOrderSale: m.recordOrderSale }));
vi.mock("@/lib/apicore-stock/schedule", () => ({ scheduleApiCoreStockSync: m.scheduleApiCoreStockSync })); vi.mock("@/lib/apicore-stock/schedule", () => ({ scheduleApiCoreStockSync: m.scheduleApiCoreStockSync }));
vi.mock("@/lib/sponsorship/honey-stock", () => ({ loadHoneyStock: m.loadHoneyStock }));
vi.mock("@/lib/sponsorship-subscription/emails", () => ({ vi.mock("@/lib/sponsorship-subscription/emails", () => ({
subscriptionDeliveryEmail: vi.fn(async () => ({ subject: "Lieferung", html: "<p></p>", text: "" })), subscriptionDeliveryEmail: vi.fn(async () => ({ subject: "Lieferung", html: "<p></p>", text: "" })),
})); }));
@@ -67,13 +70,23 @@ const delivery = {
locale: "de", locale: "de",
customerId: "cust_1", customerId: "cust_1",
offerId: "offer_1", offerId: "offer_1",
offer: { offer: { name: "Vierteljährlich" },
name: "Vierteljährlich",
honeyProduct: { id: "honey_1", name: "Frühtracht 500 g", batchId: "batch_1", weightGrams: 500, category: "HONIG", vatRatePercent: null },
},
}, },
}; };
/** Rohbestand je Honig (ohne Abo-Reservierung — die ist ja fuer diese Lieferung da). */
const stockRow = (id: string, overrides: Record<string, unknown> = {}) => ({
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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.spyOn(console, "info").mockImplementation(() => {}); vi.spyOn(console, "info").mockImplementation(() => {});
@@ -92,6 +105,8 @@ beforeEach(() => {
addresses: [ADDRESS], addresses: [ADDRESS],
}); });
m.db.inventory.updateMany.mockResolvedValue({ count: 1 }); 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.db.order.create.mockResolvedValue({ id: "order_9", orderNumber: "WH-2026-00000009" });
m.sendMail.mockResolvedValue(true); m.sendMail.mockResolvedValue(true);
}); });
@@ -123,6 +138,37 @@ describe("createDueDeliveries", () => {
expect(m.sendMail).toHaveBeenCalledOnce(); 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 () => { test("zwei bezahlte Monate: keine Lieferung", async () => {
m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription([paid("p1", 40), paid("p2", 10)])]); m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription([paid("p1", 40), paid("p2", 10)])]);
+45 -20
View File
@@ -8,28 +8,39 @@ import { isLocale, defaultLocale } from "@/i18n/config";
import { computeDeliveryEntitlement } from "@/lib/sponsorship-subscription/entitlement"; import { computeDeliveryEntitlement } from "@/lib/sponsorship-subscription/entitlement";
import { generateOrderNumber, orderContactForCustomer } from "@/lib/sponsorship-subscription/lookup"; import { generateOrderNumber, orderContactForCustomer } from "@/lib/sponsorship-subscription/lookup";
import { subscriptionDeliveryEmail } from "@/lib/sponsorship-subscription/emails"; 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"). * Honiglieferungen im Abo (Spec "Lieferauftraege").
* *
* Ist der Anspruch erfuellt (entitlement.ts), entsteht ein * Ist der Anspruch erfuellt (entitlement.ts), entsteht ein
* Lieferauftrag als normale Bestellung: Status BEZAHLT, Betrag 0 €, Versand an * Lieferauftrag als normale Bestellung: Status BEZAHLT, Betrag 0 €, Versand an
* die Standardadresse, Honigpositionen des verknuepften Produkts. Er * die Standardadresse, eine Honigposition. Er erscheint in der bestehenden
* erscheint in der bestehenden Pack- und DHL-Liste; der Honig wurde mit den * Pack- und DHL-Liste; der Honig wurde mit den Monatsrechnungen bereits als
* Monatsrechnungen bereits als Vorauszahlung berechnet. * 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 * Schutz gegen doppelte Laeufe: die Lieferung beansprucht ihre Zahlungen
* (deliveryId) in derselben Transaktion, in der sie entsteht. Ein zweiter, * (deliveryId) in derselben Transaktion, in der sie entsteht. Ein zweiter,
* gleichzeitiger Lauf findet die Zahlungen verbraucht und verwirft seine * gleichzeitiger Lauf findet die Zahlungen verbraucht und verwirft seine
* Lieferung. Die Bestellung wird ueber orderId = null beansprucht. * Lieferung. Die Bestellung wird ueber orderId = null beansprucht.
* *
* Fehlt die Adresse oder der Bestand, bleibt die Lieferung ZURUECKGESTELLT * Fehlt die Adresse oder ein passender Honig mit genug Bestand (etwa weil nur
* (mit Hinweis fuer die Aufmerksamkeitsliste) und haelt ihre Zahlungen; jeder * noch cremiger da ist), bleibt die Lieferung ZURUECKGESTELLT (mit Hinweis
* weitere Lauf versucht sie erneut. * 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_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_PENDING = "Wird angelegt.";
const NOTE_PAUSED = "Abo im Verzug oder beendet – Lieferung ruht."; const NOTE_PAUSED = "Abo im Verzug oder beendet – Lieferung ruht.";
@@ -49,17 +60,29 @@ function loadDelivery(id: string) {
locale: true, locale: true,
customerId: true, customerId: true,
offerId: true, offerId: true,
offer: { offer: { select: { name: true } },
select: { },
name: true, },
honeyProduct: { },
});
}
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<DeliveryHoney | null> {
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 }, select: { id: true, name: true, batchId: true, weightGrams: true, category: true, vatRatePercent: true },
},
},
},
},
},
},
}); });
} }
@@ -77,7 +100,7 @@ export async function fulfillDelivery(deliveryId: string): Promise<FulfillResult
if (delivery.orderId) return "skipped"; if (delivery.orderId) return "skipped";
const { subscription } = delivery; const { subscription } = delivery;
if (subscription.status !== "AKTIV" && subscription.status !== "GEKUENDIGT") return defer(deliveryId, NOTE_PAUSED); if (subscription.status !== "AKTIV" && subscription.status !== "GEKUENDIGT") return defer(deliveryId, NOTE_PAUSED);
const honey = subscription.offer.honeyProduct; const honey = await pickDeliveryHoney(delivery.jars);
if (!honey) return defer(deliveryId, NOTE_NO_STOCK); if (!honey) return defer(deliveryId, NOTE_NO_STOCK);
const contact = await orderContactForCustomer(subscription.customerId); const contact = await orderContactForCustomer(subscription.customerId);
@@ -88,7 +111,9 @@ export async function fulfillDelivery(deliveryId: string): Promise<FulfillResult
try { try {
orderNumber = await prisma.$transaction(async (tx) => { orderNumber = await prisma.$transaction(async (tx) => {
// Nur die eigene Menge pruefen: die Abo-Reservierung dieses Abos ist // 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({ const reserved = await tx.inventory.updateMany({
where: { productId: honey.id, quantity: { gte: delivery.jars } }, where: { productId: honey.id, quantity: { gte: delivery.jars } },
data: { quantity: { decrement: delivery.jars } }, data: { quantity: { decrement: delivery.jars } },
@@ -213,7 +238,7 @@ export async function createDueDeliveries(now: Date): Promise<DeliveryRunResult>
status: { in: ["AKTIV", "GEKUENDIGT"] }, status: { in: ["AKTIV", "GEKUENDIGT"] },
// Hoechstens eine offene Lieferung je Abo: erst die zurueckgestellte erledigen. // Hoechstens eine offene Lieferung je Abo: erst die zurueckgestellte erledigen.
deliveries: { none: { status: "ZURUECKGESTELLT", orderId: null } }, deliveries: { none: { status: "ZURUECKGESTELLT", orderId: null } },
offer: { honeyJars: { gt: 0 }, honeyProductId: { not: null }, cycle: { not: null } }, offer: { honeyJars: { gt: 0 }, cycle: { not: null } },
}, },
select: { select: {
id: true, id: true,
@@ -1,25 +1,50 @@
/** /**
* Abo-Reservierung im Stapel: Produktlisten brauchen den freien Bestand * Abo-Reservierung: Abos haben keinen festen Honig mehr. Ihre Glaeser liegen
* vieler Produkte auf einmal — mit festen drei Abfragen statt drei je Produkt. * 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"; import { describe, expect, test, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} })); 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"); 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: { function fakeDb(data: {
quarterly?: { offer: { honeyProductId: string | null; honeyJars: number } }[]; quarterly?: { offer: { honeyJars: number } }[];
windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number; offer: { honeyProductId: string | null } }[]; windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number }[];
deliveries?: { jars: number; dueAt: Date; subscription: { offerId: string } }[]; deliveries?: { jars: number; dueAt: Date; subscription: { offerId: string } }[];
products?: FakeProduct[];
inventory?: { productId: string; quantity: number }[]; inventory?: { productId: string; quantity: number }[];
}) { }) {
return { return {
sponsorshipSubscription: { findMany: vi.fn(async () => data.quarterly ?? []) }, sponsorshipSubscription: { findMany: vi.fn(async () => data.quarterly ?? []) },
harvestWindow: { findMany: vi.fn(async () => data.windows ?? []) }, harvestWindow: { findMany: vi.fn(async () => data.windows ?? []) },
subscriptionDelivery: { findMany: vi.fn(async () => data.deliveries ?? []) }, subscriptionDelivery: { findMany: vi.fn(async () => data.deliveries ?? []) },
product: { findMany: vi.fn(async () => (data.products ?? []).map(productRow)) },
inventory: { inventory: {
findMany: vi.fn(async () => data.inventory ?? []), findMany: vi.fn(async () => data.inventory ?? []),
findUnique: vi.fn(async ({ where }: { where: { productId: string } }) => findUnique: vi.fn(async ({ where }: { where: { productId: string } }) =>
@@ -31,21 +56,20 @@ function fakeDb(data: {
type Db = Parameters<typeof reservedJarsByProduct>[2]; type Db = Parameters<typeof reservedJarsByProduct>[2];
describe("reservedJarsByProduct", () => { const WALD = { id: "wald", consistency: "flüssig", year: 2026, quantity: 20 };
test("summiert vierteljaehrliche Abos und offene Erntefenster je Produkt in drei Abfragen", async () => { 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({ const db = fakeDb({
quarterly: [ quarterly: [{ offer: { honeyJars: 2 } }, { offer: { honeyJars: 2 } }, { offer: { honeyJars: 3 } }],
{ offer: { honeyProductId: "wald", honeyJars: 2 } },
{ offer: { honeyProductId: "wald", honeyJars: 2 } },
{ offer: { honeyProductId: "raps", honeyJars: 3 } },
],
windows: [ windows: [
{ {
offerId: "ernte", offerId: "ernte",
startsOn: new Date("2026-10-01T00:00:00.000Z"), startsOn: new Date("2026-10-01T00:00:00.000Z"),
endsOn: new Date("2026-10-31T00:00:00.000Z"), endsOn: new Date("2026-10-31T00:00:00.000Z"),
plannedJars: 10, plannedJars: 10,
offer: { honeyProductId: "wald" },
}, },
], ],
deliveries: [ deliveries: [
@@ -55,33 +79,58 @@ describe("reservedJarsByProduct", () => {
], ],
}); });
const result = await reservedJarsByProduct(["wald", "raps", "linde"], NOW, db as unknown as Db); expect(await subscriptionReservedJars(NOW, db as unknown as Db)).toBe(2 + 2 + 3 + (10 - 4));
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();
}); });
test("ein ueberliefertes Erntefenster reserviert nichts mehr (nie negativ)", async () => { test("ein ueberliefertes Erntefenster reserviert nichts mehr (nie negativ)", async () => {
const db = fakeDb({ const db = fakeDb({
windows: [ windows: [
{ { offerId: "ernte", startsOn: new Date("2026-10-01T00:00:00.000Z"), endsOn: new Date("2026-10-31T00:00:00.000Z"), plannedJars: 3 },
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" },
},
], ],
deliveries: [{ jars: 5, dueAt: new Date("2026-10-31T20:00:00.000Z"), subscription: { offerId: "ernte" } }], 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); const result = await reservedJarsByProduct(["wald"], NOW, db as unknown as Db);
expect(result.get("wald")).toBe(0); expect(result.get("wald")).toBe(0);
expect(db.product.findMany).not.toHaveBeenCalled();
}); });
test("ohne Produkte keine Abfrage", async () => { test("ohne Produkte keine Abfrage", async () => {
@@ -95,7 +144,8 @@ describe("reservedJarsByProduct", () => {
test("Einzelabfrage und freier Bestand bauen auf dem Stapel auf", async () => { test("Einzelabfrage und freier Bestand bauen auf dem Stapel auf", async () => {
const db = fakeDb({ const db = fakeDb({
quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 2 } }], quarterly: [{ offer: { honeyJars: 2 } }],
products: [{ ...WALD, quantity: 5 }],
inventory: [{ productId: "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 () => { test("freier Bestand im Stapel: Lager minus Reservierung, ohne Lagerzeile 0", async () => {
const db = fakeDb({ 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: [ inventory: [
{ productId: "wald", quantity: 3 }, { productId: "wald", quantity: 3 },
{ productId: "raps", quantity: 7 }, { productId: "raps", quantity: 7 },
+67 -30
View File
@@ -1,4 +1,6 @@
import { prisma } from "@/lib/prisma"; 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. * Abo-Reservierung von Honig.
@@ -18,30 +20,31 @@ import { prisma } from "@/lib/prisma";
* - Ernte: die im Admin je Erntefenster geplanten Abo-Glaeser, solange das * - Ernte: die im Admin je Erntefenster geplanten Abo-Glaeser, solange das
* Fenster nicht vorbei ist, abzueglich der in diesem Fenster bereits * Fenster nicht vorbei ist, abzueglich der in diesem Fenster bereits
* angelegten Lieferungen (deren Glaeser sind dann schon vom Lager gebucht). * 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; const RESERVING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
type Db = Pick<typeof prisma, "sponsorshipSubscription" | "harvestWindow" | "subscriptionDelivery" | "inventory">; type Db = Pick<typeof prisma, "sponsorshipSubscription" | "harvestWindow" | "subscriptionDelivery" | "inventory" | "product">;
/**
* 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<Map<string, number>> {
const ids = [...new Set(productIds)];
const result = new Map<string, number>(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);
};
/** Wie viele Glaeser laufende Abos insgesamt beanspruchen (hoechstens drei Abfragen). */
export async function subscriptionReservedJars(now: Date = new Date(), db: Db = prisma): Promise<number> {
const quarterly = await db.sponsorshipSubscription.findMany({ const quarterly = await db.sponsorshipSubscription.findMany({
where: { where: {
status: { in: [...RESERVING_STATUSES] }, status: { in: [...RESERVING_STATUSES] },
@@ -50,18 +53,18 @@ export async function reservedJarsByProduct(
// Verzug wegen einer fehlgeschlagenen Zahlung haelt sie dagegen: Stripe // Verzug wegen einer fehlgeschlagenen Zahlung haelt sie dagegen: Stripe
// versucht es erneut, und meist klappt es. // versucht es erneut, und meist klappt es.
NOT: { status: "IM_VERZUG", payments: { some: { reversedAt: { not: null } } } }, 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 today = startOfUtcDay(now);
const windows = await db.harvestWindow.findMany({ const windows = await db.harvestWindow.findMany({
where: { endsOn: { gte: today }, offer: { honeyProductId: { in: ids }, cycle: "ERNTE" } }, where: { endsOn: { gte: today }, offer: { cycle: "ERNTE" } },
select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true, offer: { select: { honeyProductId: true } } }, 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 // Alle in den offenen Fenstern bereits angelegten Lieferungen in einer
// Abfrage; die Zuordnung zum Fenster geschieht hier im Speicher. // 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 } } }, 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 until = endOfUtcDay(window.endsOn);
const delivered = deliveries const delivered = deliveries
.filter((d) => d.subscription.offerId === window.offerId && d.dueAt >= window.startsOn && d.dueAt <= until) .filter((d) => d.subscription.offerId === window.offerId && d.dueAt >= window.startsOn && d.dueAt <= until)
.reduce((sum, d) => sum + d.jars, 0); .reduce((jars, d) => jars + d.jars, 0);
add(window.offer.honeyProductId, Math.max(0, window.plannedJars - delivered)); return sum + Math.max(0, window.plannedJars - delivered);
} }, 0);
return result; 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<Map<string, number>> {
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<Map<string, number>> {
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<number> { export async function reservedJarsForProduct(productId: string, now: Date = new Date(), db: Db = prisma): Promise<number> {
+38 -3
View File
@@ -3,6 +3,7 @@ import { personalizationSchema, startDateRange, toIsoDate } from "@/lib/certific
import { checkCertificateFits } from "@/lib/certificate/layout"; import { checkCertificateFits } from "@/lib/certificate/layout";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates"; import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers"; import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
import { pickHoneyProduct, withTakenStock, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
/** /**
@@ -13,12 +14,20 @@ import type { Locale } from "@/i18n/config";
* - MAIN: die Patenschaft (Traegerprodukt, Regelsteuersatz), traegt die * - MAIN: die Patenschaft (Traegerprodukt, Regelsteuersatz), traegt die
* Personalisierung. Keine Bestandsreservierung — eine Urkunde ist nie * Personalisierung. Keine Bestandsreservierung — eine Urkunde ist nie
* ausverkauft. * 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 * Honig-Steuersatz. Dadurch reserviert der Checkout den Honig wie jedes
* andere Glas, die Rechnung zeigt die Steueraufteilung, und die * andere Glas, die Rechnung zeigt die Steueraufteilung, und die
* ApiCore-Abbuchung bei Zahlung (recordOrderSale) bucht den Honig ohne * ApiCore-Abbuchung bei Zahlung (recordOrderSale) bucht den Honig ohne
* Sonderweg aus dem Bestand. * 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 * Bewusst als reine Funktion ohne Datenbankzugriff: der Checkout laedt alles
* vorher, und diese Regeln lassen sich ohne Datenbank testen. * vorher, und diese Regeln lassen sich ohne Datenbank testen.
*/ */
@@ -76,6 +85,11 @@ export interface ExpandInput {
products: ReadonlyMap<string, CheckoutProduct>; products: ReadonlyMap<string, CheckoutProduct>;
/** Angebote nach Traegerprodukt-ID. */ /** Angebote nach Traegerprodukt-ID. */
offers: ReadonlyMap<string, SponsorshipOfferView>; offers: ReadonlyMap<string, SponsorshipOfferView>;
/**
* Honig-Kandidaten mit FREIEM Bestand (honey-candidates.ts). Die Produkte
* der gewaehlten Kandidaten muessen in `products` stehen.
*/
honeyCandidates: readonly HoneyCandidate[];
vatRateFor: (product: CheckoutProduct) => number; vatRateFor: (product: CheckoutProduct) => number;
locale: Locale; locale: Locale;
now: Date; now: Date;
@@ -90,12 +104,31 @@ export function startDateWindow(now: Date): { min: string; max: string } {
return startDateRange(toIsoDate(now)); return startDateRange(toIsoDate(now));
} }
/**
* Glaeser, die der Warenkorb schon einzeln kauft, je Produkt. Sie gehen vom
* freien Bestand ab, bevor ein Paket seinen Honig waehlt — sonst fiele ein
* Paket auf denselben Honig, und die Bestandspruefung im Checkout lehnte
* die ganze Bestellung ab, obwohl ein anderer Honig gereicht haette.
*/
function individuallyTakenJars(input: ExpandInput): Map<string, number> {
const taken = new Map<string, number>();
for (const cartItem of input.cartItems) {
const product = input.products.get(cartItem.productId);
if (!product || product.category === "PATENSCHAFT") continue;
taken.set(product.id, (taken.get(product.id) ?? 0) + cartItem.quantity);
}
return taken;
}
export function expandCheckoutItems(input: ExpandInput): ExpandResult { export function expandCheckoutItems(input: ExpandInput): ExpandResult {
const items: OrderItemDraft[] = []; const items: OrderItemDraft[] = [];
const reserve: ReservationLine[] = []; const reserve: ReservationLine[] = [];
let physicalQuantity = 0; let physicalQuantity = 0;
let hasSponsorship = false; let hasSponsorship = false;
const window = startDateWindow(input.now); 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) { for (const cartItem of input.cartItems) {
const product = input.products.get(cartItem.productId); const product = input.products.get(cartItem.productId);
@@ -169,10 +202,12 @@ export function expandCheckoutItems(input: ExpandInput): ExpandResult {
}); });
if (offer.honeyJars > 0) { 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) { 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({ items.push({
productId: honey.id, productId: honey.id,
batchId: honey.batchId, batchId: honey.batchId,
+80 -6
View File
@@ -10,6 +10,7 @@ import { certificateRelativePath } from "@/lib/sponsorship/storage";
import { retryDelayMs, MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue"; import { retryDelayMs, MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue";
import { isOfferAvailable, type SponsorshipOfferView } from "@/lib/sponsorship/offers"; import { isOfferAvailable, type SponsorshipOfferView } from "@/lib/sponsorship/offers";
import { expandCheckoutItems, startDateWindow, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand"; import { expandCheckoutItems, startDateWindow, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
import type { HoneyCandidate } from "@/lib/sponsorship/honey-selection";
describe("Patenschaftsnummer", () => { describe("Patenschaftsnummer", () => {
it("ist fuenfstellig fortlaufend je Jahr", () => { it("ist fuenfstellig fortlaufend je Jahr", () => {
@@ -44,19 +45,18 @@ describe("Bestellbarkeit eines Angebots", () => {
kind: "EINMALIG" as const, kind: "EINMALIG" as const,
hasCarrierProduct: true, hasCarrierProduct: true,
honeyJars: 3, honeyJars: 3,
honeyProductActive: true, hasEligibleHoney: true,
freeJars: 3,
maxActiveSubscriptions: null, maxActiveSubscriptions: null,
activeSubscriptions: 0, 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)).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", () => { it("braucht ohne Glaeser keinen Honig, aber immer ein Traegerprodukt", () => {
expect(isOfferAvailable({ ...base, honeyProductActive: null })).toBe(false); expect(isOfferAvailable({ ...base, honeyJars: 0, hasEligibleHoney: false })).toBe(true);
expect(isOfferAvailable({ ...base, hasCarrierProduct: false })).toBe(false); 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 }, honeyProduct: { id: "honey", name: "Waldhonig 500 g", weightGrams: 500 },
isAvailable: true, isAvailable: true,
}; };
const older: CheckoutProduct = { ...honey, id: "older", name: "Rapshonig 2025 500 g", batchId: "batch-0" };
const honeyCandidate = (overrides: Partial<HoneyCandidate> & Pick<HoneyCandidate, "id">): 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 = { const personalization = {
beeName: "Summsi", beeName: "Summsi",
recipientName: "Marie-Luise Schmidt", recipientName: "Marie-Luise Schmidt",
@@ -117,9 +131,11 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => {
cartItems, cartItems,
products: new Map([ products: new Map([
["honey", honey], ["honey", honey],
["older", older],
["carrier", carrier], ["carrier", carrier],
]), ]),
offers: new Map([["carrier", offer]]), offers: new Map([["carrier", offer]]),
honeyCandidates: candidates,
vatRateFor: (product: CheckoutProduct) => (product.category === "HONIG" ? 7 : 19), vatRateFor: (product: CheckoutProduct) => (product.category === "HONIG" ? 7 : 19),
locale: "de" as const, locale: "de" as const,
now, now,
@@ -171,6 +187,64 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => {
).toBe(false); ).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", () => { it("verkauft kein Paket, dessen Angebot nicht bestellbar ist", () => {
const result = expandCheckoutItems({ const result = expandCheckoutItems({
...input([{ productId: "carrier", quantity: 1, personalization }]), ...input([{ productId: "carrier", quantity: 1, personalization }]),
+27
View File
@@ -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<Parameters<typeof subscriptionReservationByProduct>[1]>;
export async function loadHoneyCandidates(now: Date = new Date(), db: Db = prisma): Promise<HoneyCandidate[]> {
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),
}));
}
+176
View File
@@ -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<HoneyCandidate> & Pick<HoneyCandidate, "id">): 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");
});
});
+136
View File
@@ -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: "<Name> <Jahr>", ohne das Jahr zu verdoppeln, wenn es schon im Namen steht. */
export function honeyLabel(candidate: Pick<HoneyCandidate, "name" | "harvestYear">): 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<string, number>
): 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<string, number> {
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<string, number>();
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;
}
+46
View File
@@ -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<typeof prisma, "product">;
export async function loadHoneyStock(db: HoneyStockDb = prisma): Promise<HoneyCandidate[]> {
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,
};
});
}
+33 -30
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array"; 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 { quotaSubscriptionWhere } from "@/lib/sponsorship-subscription/pending";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
import type { import type {
@@ -14,11 +15,17 @@ import type {
* Warenkorb und Checkout sie brauchen: lokalisiert, mit Leistungsliste als * Warenkorb und Checkout sie brauchen: lokalisiert, mit Leistungsliste als
* Array und mit der Aussage, ob das Angebot gerade bestellbar ist. * Array und mit der Aussage, ob das Angebot gerade bestellbar ist.
* *
* "Bestellbar" haengt am Honig: ein physisches Paket ohne genug Glaeser des * "Bestellbar" haengt am Honig: ein Angebot mit Glaesern braucht einen Honig,
* verknuepften Honigprodukts darf nicht verkauft werden — sonst bekaeme die * den die automatische Wahl nehmen darf (nie cremig, neueste Ernte, genug
* Kundschaft eine Urkunde ohne den bezahlten Honig. Gezaehlt wird der FREIE * FREIER Bestand, also abzueglich der fuer Abos reservierten Glaeser —
* Bestand, also abzueglich der fuer Abos reservierten Glaeser. Bei Abos * lib/sponsorship/honey-selection.ts). Gibt es keinen, ist das Angebot nicht
* zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions). * 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 { export interface SponsorshipOfferView {
id: string; id: string;
@@ -37,26 +44,26 @@ export interface SponsorshipOfferView {
medalTier: MedalTier; medalTier: MedalTier;
/** Traegerprodukt (nur EINMALIG). */ /** Traegerprodukt (nur EINMALIG). */
productId: string | null; 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; honeyProduct: { id: string; name: string; weightGrams: number } | null;
/** false = derzeit nicht bestellbar (Honig fehlt, Kontingent voll, Angebot aus). */ /** false = derzeit nicht bestellbar (Honig fehlt, Kontingent voll, Angebot aus). */
isAvailable: boolean; 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[] } }) { async function loadOfferRows(where: { isActive?: boolean; kind?: SponsorshipOfferKind; slug?: string; productId?: { in: string[] } }) {
return prisma.sponsorshipOffer.findMany({ return prisma.sponsorshipOffer.findMany({
where, where,
include: offerInclude,
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
}); });
} }
type OfferRow = Awaited<ReturnType<typeof loadOfferRows>>[number]; type OfferRow = Awaited<ReturnType<typeof loadOfferRows>>[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 en = locale === "en";
const featuresEn = asStringArray(row.featuresEn); const featuresEn = asStringArray(row.featuresEn);
return { return {
@@ -74,16 +81,14 @@ function localize(row: OfferRow, locale: Locale, isAvailable: boolean): Sponsors
cycle: row.cycle, cycle: row.cycle,
medalTier: row.medalTier, medalTier: row.medalTier,
productId: row.productId, productId: row.productId,
honeyProduct: row.honeyProduct honeyProduct: honey ? { id: honey.id, name: honey.name, weightGrams: honey.weightGrams } : null,
? { id: row.honeyProduct.id, name: row.honeyProduct.name, weightGrams: row.honeyProduct.weightGrams }
: null,
isAvailable, isAvailable,
}; };
} }
async function freeJarsByProduct(rows: OfferRow[]): Promise<Map<string, number>> { /** Kandidaten nur laden, wenn ein Angebot ueberhaupt Honig braucht (rein digitale Angebote nicht). */
const ids = [...new Set(rows.map((row) => row.honeyProduct?.id).filter((id): id is string => Boolean(id)))]; async function honeyCandidatesFor(rows: OfferRow[]): Promise<HoneyCandidate[]> {
return availableForSaleByProduct(ids); return rows.some((row) => row.honeyJars > 0) ? loadHoneyCandidates() : [];
} }
async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, number>> { async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, number>> {
@@ -105,17 +110,14 @@ export function isOfferAvailable(input: {
kind: SponsorshipOfferKind; kind: SponsorshipOfferKind;
hasCarrierProduct: boolean; hasCarrierProduct: boolean;
honeyJars: number; honeyJars: number;
honeyProductActive: boolean | null; /** Die automatische Honigwahl hat fuer honeyJars Glaeser einen Honig gefunden. */
freeJars: number; hasEligibleHoney: boolean;
maxActiveSubscriptions: number | null; maxActiveSubscriptions: number | null;
activeSubscriptions: number; activeSubscriptions: number;
}): boolean { }): boolean {
if (!input.isActive) return false; if (!input.isActive) return false;
if (input.kind === "EINMALIG" && !input.hasCarrierProduct) return false; if (input.kind === "EINMALIG" && !input.hasCarrierProduct) return false;
if (input.honeyJars > 0) { if (input.honeyJars > 0 && !input.hasEligibleHoney) return false;
if (!input.honeyProductActive) return false;
if (input.freeJars < input.honeyJars) return false;
}
if (input.kind === "ABO" && input.maxActiveSubscriptions !== null) { if (input.kind === "ABO" && input.maxActiveSubscriptions !== null) {
if (input.activeSubscriptions >= input.maxActiveSubscriptions) return false; if (input.activeSubscriptions >= input.maxActiveSubscriptions) return false;
} }
@@ -123,23 +125,24 @@ export function isOfferAvailable(input: {
} }
async function toViews(rows: OfferRow[], locale: Locale): Promise<SponsorshipOfferView[]> { async function toViews(rows: OfferRow[], locale: Locale): Promise<SponsorshipOfferView[]> {
const [jars, subscriptionCounts] = await Promise.all([freeJarsByProduct(rows), activeSubscriptionCounts(rows)]); const [candidates, subscriptionCounts] = await Promise.all([honeyCandidatesFor(rows), activeSubscriptionCounts(rows)]);
return rows.map((row) => return rows.map((row) => {
localize( const honey = row.honeyJars > 0 ? pickHoneyProduct(candidates, row.honeyJars) : null;
return localize(
row, row,
locale, locale,
honey,
isOfferAvailable({ isOfferAvailable({
isActive: row.isActive, isActive: row.isActive,
kind: row.kind, kind: row.kind,
hasCarrierProduct: row.productId !== null, hasCarrierProduct: row.productId !== null,
honeyJars: row.honeyJars, honeyJars: row.honeyJars,
honeyProductActive: row.honeyProduct ? row.honeyProduct.isActive : null, hasEligibleHoney: honey !== null,
freeJars: row.honeyProduct ? (jars.get(row.honeyProduct.id) ?? 0) : 0,
maxActiveSubscriptions: row.maxActiveSubscriptions, maxActiveSubscriptions: row.maxActiveSubscriptions,
activeSubscriptions: subscriptionCounts.get(row.id) ?? 0, activeSubscriptions: subscriptionCounts.get(row.id) ?? 0,
}) })
)
); );
});
} }
export async function getActiveOffers(locale: Locale, kind?: SponsorshipOfferKind): Promise<SponsorshipOfferView[]> { export async function getActiveOffers(locale: Locale, kind?: SponsorshipOfferKind): Promise<SponsorshipOfferView[]> {