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
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:
1 parent
3ddf0bfa73
commit
1d88bac52e
30 files changed
+1152
-394
No files matched your search
@@ -1075,7 +1075,10 @@ model SponsorshipOffer {
|
||||
/// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb,
|
||||
/// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil.
|
||||
productId String? @unique
|
||||
/// Honigprodukt, dessen Bestand das Paket bzw. Abo verbraucht.
|
||||
/// UNGENUTZT: Pakete und Abos bekommen ihren Honig automatisch (nie cremig,
|
||||
/// neueste Ernte, genug Bestand — lib/sponsorship/honey-selection.ts). Die
|
||||
/// Spalte wird weder gelesen noch geschrieben und bleibt nur stehen, damit
|
||||
/// die laufende Produktion keine Migration braucht; Altwerte sind bedeutungslos.
|
||||
honeyProductId String?
|
||||
/// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung.
|
||||
honeyJars Int @default(0)
|
||||
|
||||
@@ -4,17 +4,24 @@
|
||||
* Wiederholbar: legt nur an, was fehlt, und aendert nie ein Angebot, das der
|
||||
* Inhaber schon bearbeitet hat (Felder werden nur gesetzt, wenn sie leer sind).
|
||||
*
|
||||
* - verknuepft die physischen Einmal-Pakete mit einem Honigprodukt,
|
||||
* - legt die beiden Abo-Angebote INAKTIV an (Testpreise, im Admin pruefen),
|
||||
* - legt fuer jedes Abo-Angebot Stripe-Produkt, Monatspreis und
|
||||
* Traegerprodukt an (syncOfferStripePrice).
|
||||
*
|
||||
* Einen Honig verknuepft das Skript nicht mehr (frueher --honey=<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):
|
||||
* tsx scripts/sponsorship-bootstrap.ts --honey=<produkt-slug> (Probelauf)
|
||||
* tsx scripts/sponsorship-bootstrap.ts --honey=<produkt-slug> --apply (ausfuehren)
|
||||
* tsx scripts/sponsorship-bootstrap.ts (Probelauf)
|
||||
* tsx scripts/sponsorship-bootstrap.ts --apply (ausfuehren)
|
||||
*/
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
|
||||
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||
import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||
|
||||
interface AboTemplate {
|
||||
slug: string;
|
||||
@@ -70,30 +77,12 @@ const ABO_TEMPLATES: AboTemplate[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const hit = process.argv.find((value) => value.startsWith(`--${name}=`));
|
||||
return hit ? hit.slice(name.length + 3) : undefined;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const apply = process.argv.includes("--apply");
|
||||
const honeySlug = arg("honey");
|
||||
if (!honeySlug) throw new Error("--honey=<produkt-slug> fehlt.");
|
||||
|
||||
const honey = await prisma.product.findUnique({ where: { slug: honeySlug }, select: { id: true, name: true, category: true, isActive: true } });
|
||||
if (!honey || honey.category !== "HONIG" || !honey.isActive) {
|
||||
throw new Error(`Honigprodukt "${honeySlug}" nicht gefunden, nicht HONIG oder nicht aktiv.`);
|
||||
}
|
||||
console.log(`${apply ? "AUSFUEHREN" : "PROBELAUF"} — Honig: ${honey.name}`);
|
||||
|
||||
const physical = await prisma.sponsorshipOffer.findMany({
|
||||
where: { kind: "EINMALIG", isPhysical: true, honeyProductId: null },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
for (const offer of physical) {
|
||||
console.log(` Paket ${offer.slug}: Honig verknuepfen`);
|
||||
if (apply) await prisma.sponsorshipOffer.update({ where: { id: offer.id }, data: { honeyProductId: honey.id } });
|
||||
}
|
||||
const honey = pickHoneyProduct(await loadHoneyCandidates(), 1);
|
||||
console.log(
|
||||
`${apply ? "AUSFUEHREN" : "PROBELAUF"} — Honig automatisch, derzeit: ${honey ? honeyLabel(honey) : "kein nicht-cremiger Honig mit freiem Bestand"}`
|
||||
);
|
||||
|
||||
for (const template of ABO_TEMPLATES) {
|
||||
const existing = await prisma.sponsorshipOffer.findUnique({ where: { slug: template.slug }, select: { id: true, stripePriceId: true } });
|
||||
@@ -110,7 +99,6 @@ async function main(): Promise<void> {
|
||||
kind: "ABO",
|
||||
isActive: false,
|
||||
isPhysical: true,
|
||||
honeyProductId: honey.id,
|
||||
medalTier: "ABO",
|
||||
},
|
||||
});
|
||||
@@ -119,7 +107,7 @@ async function main(): Promise<void> {
|
||||
|
||||
const offers = await prisma.sponsorshipOffer.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { slug: true, kind: true, isActive: true, priceCents: true, honeyProductId: true, stripePriceId: true, productId: true },
|
||||
select: { slug: true, kind: true, isActive: true, priceCents: true, honeyJars: true, stripePriceId: true, productId: true },
|
||||
});
|
||||
console.table(offers);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { logAdminAction } from "@/lib/audit-log";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { carrierPriceCents, parseOfferForm, type OfferFormValues } from "@/app/admin/patenschaften/_lib/offer-schema";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||
import { pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||
import { isUniqueViolation, syncStripePriceSafely } from "./sponsorship-shared";
|
||||
|
||||
@@ -35,6 +39,12 @@ function carrierProductData(values: OfferFormValues) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* honeyProductId wird bewusst nicht geschrieben: der Honig wird automatisch
|
||||
* gewaehlt (lib/sponsorship/honey-selection.ts). Die Spalte bleibt nur im
|
||||
* Schema stehen, damit die laufende Produktion keine Migration braucht; ein
|
||||
* dort noch stehender Altwert wird nirgends mehr gelesen.
|
||||
*/
|
||||
function offerData(values: OfferFormValues) {
|
||||
return {
|
||||
slug: values.slug,
|
||||
@@ -48,7 +58,6 @@ function offerData(values: OfferFormValues) {
|
||||
isActive: values.isActive,
|
||||
sortOrder: values.sortOrder,
|
||||
isPhysical: values.isPhysical,
|
||||
honeyProductId: values.honeyProductId,
|
||||
honeyJars: values.honeyJars,
|
||||
honeyShareCentsPerJar: values.honeyShareCentsPerJar,
|
||||
honeyShareCentsPerMonth: values.honeyShareCentsPerMonth,
|
||||
@@ -59,10 +68,9 @@ function offerData(values: OfferFormValues) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefungen gegen die Datenbank: eindeutige Adressen, ein echtes
|
||||
* Honigprodukt — und die Aktivierungssperre: ein Angebot mit Honig laesst
|
||||
* sich nur aktivieren, wenn das Honigprodukt existiert und aktiv ist. Sonst
|
||||
* bekaeme die Kundschaft eine Urkunde ohne den bezahlten Honig.
|
||||
* Pruefungen gegen die Datenbank: eindeutige Adressen. Den Honig prueft
|
||||
* honeyWarning() — er sperrt das Aktivieren nicht mehr, weil er sich mit
|
||||
* jeder Bestellung und jeder neuen Ernte aendert.
|
||||
*/
|
||||
async function findConflict(
|
||||
values: OfferFormValues,
|
||||
@@ -77,19 +85,6 @@ async function findConflict(
|
||||
return `Die Produktadresse "${carrierSlug(values.slug)}" ist bereits vergeben. Bitte eine andere Adresse wählen.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (values.honeyProductId) {
|
||||
const honey = await prisma.product.findUnique({
|
||||
where: { id: values.honeyProductId },
|
||||
select: { category: true, isActive: true, name: true },
|
||||
});
|
||||
if (!honey || honey.category !== "HONIG") return "Bitte ein Honigprodukt aus der Liste wählen.";
|
||||
if (values.isActive && !honey.isActive) {
|
||||
return `Das Angebot kann nicht aktiviert werden: das Honigprodukt "${honey.name}" ist inaktiv.`;
|
||||
}
|
||||
} else if (values.isActive && (values.isPhysical || values.honeyJars > 0)) {
|
||||
return "Ein physisches Angebot ohne Honigprodukt kann nicht aktiviert werden.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -98,7 +93,21 @@ function auditDetail(values: OfferFormValues): string {
|
||||
return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`;
|
||||
}
|
||||
|
||||
/** Nach dem Speichern eines Abo-Angebots: Stripe-Preis nachziehen und passend melden. */
|
||||
/**
|
||||
* Aktivierungsregel fuer Angebote mit Honig: es muss einen Honig geben, den
|
||||
* die automatische Wahl nehmen darf (nicht cremig, genug freier Bestand fuer
|
||||
* die Glaeser). Fehlt er, wird trotzdem gespeichert — das Angebot steht dann
|
||||
* auf der Verkaufsseite als nicht bestellbar (isOfferAvailable), bis
|
||||
* fluessiger Honig nachgelegt ist —, aber der Admin bekommt einen Hinweis,
|
||||
* statt sich ueber ein "ausverkauftes" neues Angebot zu wundern.
|
||||
*/
|
||||
async function honeyWarning(values: OfferFormValues): Promise<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> {
|
||||
revalidatePath(OFFERS_PATH);
|
||||
revalidatePath("/patenschaft");
|
||||
@@ -109,6 +118,11 @@ async function finishSave(offerId: string, values: OfferFormValues, message: str
|
||||
return fail(target, `${message} Der Stripe-Preis konnte aber nicht angelegt werden (${stripeError}). Bitte später erneut speichern.`);
|
||||
}
|
||||
}
|
||||
const warning = await honeyWarning(values);
|
||||
if (warning) {
|
||||
await setFlash("info", `${message} ${warning}`);
|
||||
redirect(target);
|
||||
}
|
||||
return succeed(target, message);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface OfferFormDefaults {
|
||||
isActive: boolean;
|
||||
isPhysical: boolean;
|
||||
sortOrder: number;
|
||||
honeyProductId: string | null;
|
||||
honeyJars: number;
|
||||
cycle: SponsorshipCycle | null;
|
||||
maxActiveSubscriptions: number | null;
|
||||
@@ -51,7 +50,6 @@ export const EMPTY_OFFER: OfferFormDefaults = {
|
||||
isActive: false,
|
||||
isPhysical: false,
|
||||
sortOrder: 0,
|
||||
honeyProductId: null,
|
||||
honeyJars: 0,
|
||||
cycle: null,
|
||||
maxActiveSubscriptions: null,
|
||||
@@ -62,7 +60,8 @@ interface OfferFormProps {
|
||||
defaults: OfferFormDefaults;
|
||||
/** Beim Bearbeiten ist die Art fest. */
|
||||
isNew: boolean;
|
||||
honeyOptions: { id: string; name: string; isActive: boolean }[];
|
||||
/** "<Name> <Jahr>" des Honigs, den die automatische Wahl gerade nimmt; null = keiner reicht. */
|
||||
currentHoneyLabel: string | null;
|
||||
action: (formData: FormData) => void | Promise<void>;
|
||||
t: SponsorshipAdminStrings;
|
||||
}
|
||||
@@ -74,7 +73,7 @@ const euroOrEmpty = (cents: number): string => (cents > 0 ? centsToEuroInput(cen
|
||||
* stehen mit "(nur Einmalig)" bzw. "(nur Abo)" da und werden beim Speichern
|
||||
* geleert (offer-schema.ts) — so bleibt das Formular ohne Client-Logik.
|
||||
*/
|
||||
export function OfferForm({ defaults, isNew, honeyOptions, action, t }: OfferFormProps) {
|
||||
export function OfferForm({ defaults, isNew, currentHoneyLabel, action, t }: OfferFormProps) {
|
||||
const tf = t.offerForm;
|
||||
return (
|
||||
<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}>
|
||||
<AdminSwitch name="isPhysical" label={tf.isPhysical} defaultChecked={defaults.isPhysical} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AdminSelect name="honeyProductId" label={tf.honeyProduct} defaultValue={defaults.honeyProductId ?? ""}>
|
||||
<option value="">{tf.honeyProductNone}</option>
|
||||
{honeyOptions.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
{product.isActive ? "" : tf.inactiveSuffix}
|
||||
</option>
|
||||
))}
|
||||
</AdminSelect>
|
||||
{/* Keine Auswahl mehr: Pakete und Abos bekommen den Honig automatisch
|
||||
(lib/sponsorship/honey-selection.ts). Nur Anzeige, wird nicht gespeichert. */}
|
||||
<AdminField
|
||||
name="honeyDisplay"
|
||||
label={tf.honey}
|
||||
defaultValue={tf.honeyAuto(currentHoneyLabel)}
|
||||
readOnly
|
||||
hint={tf.honeyAutoHint}
|
||||
/>
|
||||
<AdminField name="honeyJars" label={tf.honeyJars} type="number" min={0} defaultValue={String(defaults.honeyJars)} />
|
||||
</div>
|
||||
</AdminFieldset>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AdminActionButton, AdminBadge } from "@/components/admin-list";
|
||||
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { liftSepaBlockAction, updateSepaHoldDaysAction } from "@/app/admin/actions";
|
||||
import { MAX_SEPA_HOLD_DAYS } from "@/lib/sponsorship/settings";
|
||||
import type { HoneyCoverageRow } from "../_lib/coverage";
|
||||
import type { HoneyCoverage } from "../_lib/coverage";
|
||||
|
||||
type Strings = SponsorshipAdminStrings["subscriptions"];
|
||||
|
||||
@@ -16,13 +16,13 @@ export function SepaBlockButton({ customerId, confirmMessage, label }: { custome
|
||||
);
|
||||
}
|
||||
|
||||
/** Honigdeckung je Produkt; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */
|
||||
export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: Strings }) {
|
||||
/** Deckung der Abos durch den automatisch gewaehlten (nicht-cremigen) Honig; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */
|
||||
export function CoveragePanel({ coverage, t }: { coverage: HoneyCoverage; t: Strings }) {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<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">
|
||||
<tr>
|
||||
<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.colReserved}</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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{rows.map((row) => (
|
||||
{coverage.rows.map((row) => (
|
||||
<tr key={row.productId}>
|
||||
<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.reserved}</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
const t = getSponsorshipAdminStrings(locale).attention;
|
||||
// Honigdeckung zaehlt hoechstens 1: Abos haben keinen festen Honig mehr,
|
||||
// gedeckt wird aus allem nicht-cremigen Honig zusammen (coverage.ts).
|
||||
const [failed, overdueToPrint, delinquent, coverage] = await Promise.all([
|
||||
prisma.sponsorship.count({ where: { status: "FEHLER" } }),
|
||||
// mailSentAt ist der Moment, in dem die Patenschaft BEREIT wurde (die
|
||||
@@ -35,7 +37,7 @@ export async function getSponsorshipAttentionItems(locale: Locale, now: Date = n
|
||||
return [
|
||||
{ count: failed, href: "/admin/patenschaften?status=FEHLER", label: t.failed(failed) },
|
||||
{ count: overdueToPrint, href: "/admin/patenschaften?status=ZU_DRUCKEN", label: t.toPrint(overdueToPrint) },
|
||||
{ count: undercovered, href: "/admin/patenschaften/abos#deckung", label: t.coverage(undercovered) },
|
||||
{ count: undercovered, href: "/admin/patenschaften/abos#deckung", label: t.coverage },
|
||||
{ count: delinquent, href: "/admin/patenschaften/abos?status=IM_VERZUG", label: t.delinquent(delinquent) },
|
||||
].filter((item) => item.count > 0);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
import { freeStock, subscriptionReservationByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
import { loadHoneyStock } from "@/lib/sponsorship/honey-stock";
|
||||
import { rankHoneyCandidates, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
||||
|
||||
/**
|
||||
* Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und
|
||||
* Kontingent"). Reserviert wird in lib/sponsorship-subscription/reservation.ts
|
||||
* berechnet; hier wird nur zusammengestellt und angezeigt.
|
||||
* Kontingent"). Pakete und Abos bekommen automatisch nicht-cremigen Honig
|
||||
* (lib/sponsorship/honey-selection.ts) — gezeigt wird deshalb genau dieser
|
||||
* Honig, in der Rangfolge der automatischen Wahl. Reserviert wird in
|
||||
* lib/sponsorship-subscription/reservation.ts berechnet; hier wird nur
|
||||
* zusammengestellt und angezeigt.
|
||||
*/
|
||||
|
||||
/** Abos, deren naechste Lieferung noch Honig braucht (wie in reservation.ts). */
|
||||
@@ -16,13 +21,20 @@ export interface HoneyStock {
|
||||
free: number;
|
||||
}
|
||||
|
||||
export interface HoneyCoverageRow extends HoneyStock {
|
||||
export interface EligibleHoneyRow extends HoneyStock {
|
||||
productId: string;
|
||||
productName: string;
|
||||
isActive: boolean;
|
||||
weightGrams: number;
|
||||
harvestYear: number;
|
||||
consistency: string;
|
||||
}
|
||||
|
||||
export interface HoneyCoverage {
|
||||
/** Nicht-cremiger, aktiver Honig in der Rangfolge der automatischen Wahl. */
|
||||
rows: EligibleHoneyRow[];
|
||||
/** Glaeser, die eine vierteljaehrliche Lieferrunde aller laufenden Abos braucht. */
|
||||
jarsPerDelivery: number;
|
||||
/** Wie viele Lieferrunden der Bestand bedient; null = kein vierteljaehrlicher Bedarf. */
|
||||
/** Wie viele Lieferrunden der gesamte passende Bestand bedient; null = kein vierteljaehrlicher Bedarf. */
|
||||
deliveriesCovered: number | null;
|
||||
}
|
||||
|
||||
@@ -32,69 +44,58 @@ export function deliveriesCovered(stock: number, jarsPerDelivery: number): numbe
|
||||
return Math.floor(Math.max(0, stock) / jarsPerDelivery);
|
||||
}
|
||||
|
||||
/** Bestand, Abo-Reservierung und freier Bestand je Produkt. */
|
||||
export async function getHoneyStock(productIds: string[], now: Date = new Date()): Promise<Map<string, HoneyStock>> {
|
||||
const ids = [...new Set(productIds)];
|
||||
if (ids.length === 0) return new Map();
|
||||
const inventories = await prisma.inventory.findMany({
|
||||
where: { productId: { in: ids } },
|
||||
select: { productId: true, quantity: true },
|
||||
});
|
||||
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity]));
|
||||
const reservedById = await reservedJarsByProduct(ids, now);
|
||||
return new Map(
|
||||
ids.map((id) => {
|
||||
const stock = stockById.get(id) ?? 0;
|
||||
const reserved = reservedById.get(id) ?? 0;
|
||||
return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const;
|
||||
})
|
||||
);
|
||||
/**
|
||||
* Passender Honig (aktiv, nicht cremig) mit Bestand, Abo-Reservierung und
|
||||
* freiem Bestand, in der Rangfolge der automatischen Wahl (bei gleicher Ernte
|
||||
* nach freiem Bestand). Eine Abfrage-Runde fuer alle Produkte.
|
||||
*/
|
||||
export async function getEligibleHoneyStock(now: Date = new Date()): Promise<EligibleHoneyRow[]> {
|
||||
const stock = await loadHoneyStock();
|
||||
const reserved = await subscriptionReservationByProduct(now, prisma, stock);
|
||||
const withFree = stock.map((candidate) => ({
|
||||
...candidate,
|
||||
freeStock: freeStock(candidate.inventory, reserved.get(candidate.id) ?? 0),
|
||||
}));
|
||||
return rankHoneyCandidates(withFree).map((candidate) => ({
|
||||
productId: candidate.id,
|
||||
productName: candidate.name,
|
||||
weightGrams: candidate.weightGrams,
|
||||
harvestYear: candidate.harvestYear,
|
||||
consistency: candidate.consistency,
|
||||
stock: candidate.inventory,
|
||||
reserved: reserved.get(candidate.id) ?? 0,
|
||||
free: candidate.freeStock,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Deckung je Honigprodukt, das mit mindestens einem Abo-Angebot verknuepft ist. */
|
||||
export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverageRow[]> {
|
||||
const offers = await prisma.sponsorshipOffer.findMany({
|
||||
where: { kind: "ABO", honeyProductId: { not: null } },
|
||||
select: { honeyProduct: { select: { id: true, name: true, isActive: true } } },
|
||||
});
|
||||
const products = new Map(
|
||||
offers.flatMap((offer) => (offer.honeyProduct ? [[offer.honeyProduct.id, offer.honeyProduct] as const] : []))
|
||||
);
|
||||
if (products.size === 0) return [];
|
||||
/** Zeilen zurueck in Kandidaten der automatischen Wahl (freier Bestand). */
|
||||
export function toHoneyCandidates(rows: readonly EligibleHoneyRow[]): HoneyCandidate[] {
|
||||
return rows.map((row) => ({
|
||||
id: row.productId,
|
||||
name: row.productName,
|
||||
weightGrams: row.weightGrams,
|
||||
harvestYear: row.harvestYear,
|
||||
consistency: row.consistency,
|
||||
inventory: row.stock,
|
||||
freeStock: row.free,
|
||||
}));
|
||||
}
|
||||
|
||||
const [stock, subscriptions] = await Promise.all([
|
||||
getHoneyStock([...products.keys()], now),
|
||||
/** Deckung der Abos durch den passenden Honig insgesamt. */
|
||||
export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverage> {
|
||||
const [rows, subscriptions] = await Promise.all([
|
||||
getEligibleHoneyStock(now),
|
||||
prisma.sponsorshipSubscription.findMany({
|
||||
where: {
|
||||
status: { in: [...DEMANDING_STATUSES] },
|
||||
offer: { cycle: "VIERTELJAEHRLICH", honeyProductId: { in: [...products.keys()] } },
|
||||
},
|
||||
select: { offer: { select: { honeyProductId: true, honeyJars: true } } },
|
||||
where: { status: { in: [...DEMANDING_STATUSES] }, offer: { cycle: "VIERTELJAEHRLICH", honeyJars: { gt: 0 } } },
|
||||
select: { offer: { select: { honeyJars: true } } },
|
||||
}),
|
||||
]);
|
||||
const demand = new Map<string, number>();
|
||||
for (const { offer } of subscriptions) {
|
||||
if (!offer.honeyProductId) continue;
|
||||
demand.set(offer.honeyProductId, (demand.get(offer.honeyProductId) ?? 0) + offer.honeyJars);
|
||||
}
|
||||
|
||||
return [...products.values()]
|
||||
.map((product) => {
|
||||
const amounts = stock.get(product.id) ?? { stock: 0, reserved: 0, free: 0 };
|
||||
const jarsPerDelivery = demand.get(product.id) ?? 0;
|
||||
return {
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
isActive: product.isActive,
|
||||
...amounts,
|
||||
jarsPerDelivery,
|
||||
deliveriesCovered: deliveriesCovered(amounts.stock, jarsPerDelivery),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.productName.localeCompare(b.productName, "de"));
|
||||
const jarsPerDelivery = subscriptions.reduce((sum, { offer }) => sum + offer.honeyJars, 0);
|
||||
const totalStock = rows.reduce((sum, row) => sum + row.stock, 0);
|
||||
return { rows, jarsPerDelivery, deliveriesCovered: deliveriesCovered(totalStock, jarsPerDelivery) };
|
||||
}
|
||||
|
||||
/** Fuer die Aufmerksamkeitsliste: Produkte, deren Bestand keine volle Lieferrunde mehr deckt. */
|
||||
export function countUndercovered(rows: HoneyCoverageRow[]): number {
|
||||
return rows.filter((row) => row.deliveriesCovered !== null && row.deliveriesCovered < 1).length;
|
||||
/** Fuer die Aufmerksamkeitsliste: 1, wenn der passende Honig keine volle Lieferrunde mehr deckt. */
|
||||
export function countUndercovered(coverage: Pick<HoneyCoverage, "deliveriesCovered">): number {
|
||||
return coverage.deliveriesCovered !== null && coverage.deliveriesCovered < 1 ? 1 : 0;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isOfferAvailable } from "@/lib/sponsorship/offers";
|
||||
import { getHoneyStock, type HoneyStock } from "./coverage";
|
||||
import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||
import { getEligibleHoneyStock, toHoneyCandidates, type EligibleHoneyRow, type HoneyStock } from "./coverage";
|
||||
|
||||
/** Abos, die einen Platz im Kontingent belegen (wie lib/sponsorship/offers.ts). */
|
||||
const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
|
||||
@@ -8,35 +9,61 @@ const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
|
||||
async function loadOffers(where: { id?: string }) {
|
||||
return prisma.sponsorshipOffer.findMany({
|
||||
where,
|
||||
include: {
|
||||
honeyProduct: { select: { id: true, name: true, isActive: true } },
|
||||
product: { select: { id: true, priceCents: true } },
|
||||
},
|
||||
include: { product: { select: { id: true, priceCents: true } } },
|
||||
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export type OfferRow = Awaited<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 {
|
||||
offer: OfferRow;
|
||||
/** Live: dieselbe Entscheidung wie auf der Verkaufsseite. */
|
||||
isAvailable: boolean;
|
||||
activeSubscriptions: number;
|
||||
honeyStock: HoneyStock | null;
|
||||
/** null bei Angeboten ohne Glaeser oder wenn kein passender Honig reicht. */
|
||||
currentHoney: CurrentHoney | null;
|
||||
}
|
||||
|
||||
export interface OfferOverview {
|
||||
rows: OfferOverviewRow[];
|
||||
/** Passender Honig (nicht cremig) in der Rangfolge der automatischen Wahl. */
|
||||
eligibleHoney: EligibleHoneyRow[];
|
||||
}
|
||||
|
||||
/** Die automatische Wahl fuer `jars` Glaeser als Anzeige-Datensatz — oder null. */
|
||||
export function currentHoneyFor(eligibleHoney: readonly EligibleHoneyRow[], jars: number): CurrentHoney | null {
|
||||
if (jars <= 0) return null;
|
||||
const picked = pickHoneyProduct(toHoneyCandidates(eligibleHoney), jars);
|
||||
const row = picked ? eligibleHoney.find((candidate) => candidate.productId === picked.id) : undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
productId: row.productId,
|
||||
label: honeyLabel({ name: row.productName, harvestYear: row.harvestYear }),
|
||||
stock: row.stock,
|
||||
reserved: row.reserved,
|
||||
free: row.free,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Angebote samt Live-Verfuegbarkeit und Honigbestand. Fuer ALLE Angebote,
|
||||
* auch inaktive — getActiveOffers() aus lib/sponsorship/offers.ts kennt nur
|
||||
* aktive, der Admin will aber sehen, ob ein Angebot nach dem Aktivieren
|
||||
* ueberhaupt bestellbar waere. Die Entscheidung selbst bleibt isOfferAvailable.
|
||||
* Angebote samt Live-Verfuegbarkeit und automatisch gewaehltem Honig. Fuer
|
||||
* ALLE Angebote, auch inaktive — getActiveOffers() aus
|
||||
* lib/sponsorship/offers.ts kennt nur aktive, der Admin will aber sehen, ob
|
||||
* ein Angebot nach dem Aktivieren ueberhaupt bestellbar waere. Die
|
||||
* Entscheidung selbst bleibt isOfferAvailable.
|
||||
*/
|
||||
export async function getOfferOverview(offerId?: string): Promise<OfferOverviewRow[]> {
|
||||
export async function getOfferOverview(offerId?: string): Promise<OfferOverview> {
|
||||
const offers = await loadOffers(offerId ? { id: offerId } : {});
|
||||
const honeyIds = offers.flatMap((offer) => (offer.honeyProductId ? [offer.honeyProductId] : []));
|
||||
const [stock, groups] = await Promise.all([
|
||||
getHoneyStock(honeyIds),
|
||||
const [eligibleHoney, groups] = await Promise.all([
|
||||
getEligibleHoneyStock(),
|
||||
prisma.sponsorshipSubscription.groupBy({
|
||||
by: ["offerId"],
|
||||
where: { offerId: { in: offers.map((offer) => offer.id) }, status: { in: [...QUOTA_STATUSES] } },
|
||||
@@ -45,35 +72,23 @@ export async function getOfferOverview(offerId?: string): Promise<OfferOverviewR
|
||||
]);
|
||||
const subscriptionCounts = new Map(groups.map((group) => [group.offerId, group._count._all]));
|
||||
|
||||
return offers.map((offer) => {
|
||||
const honeyStock = offer.honeyProductId ? (stock.get(offer.honeyProductId) ?? null) : null;
|
||||
const rows = offers.map((offer) => {
|
||||
const currentHoney = currentHoneyFor(eligibleHoney, offer.honeyJars);
|
||||
const activeSubscriptions = subscriptionCounts.get(offer.id) ?? 0;
|
||||
return {
|
||||
offer,
|
||||
activeSubscriptions,
|
||||
honeyStock,
|
||||
currentHoney,
|
||||
isAvailable: isOfferAvailable({
|
||||
isActive: offer.isActive,
|
||||
kind: offer.kind,
|
||||
hasCarrierProduct: offer.productId !== null,
|
||||
honeyJars: offer.honeyJars,
|
||||
honeyProductActive: offer.honeyProduct ? offer.honeyProduct.isActive : null,
|
||||
freeJars: honeyStock?.free ?? 0,
|
||||
hasEligibleHoney: currentHoney !== null,
|
||||
maxActiveSubscriptions: offer.maxActiveSubscriptions,
|
||||
activeSubscriptions,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Auswahl im Formular: aktive Honigprodukte, plus das aktuell verknuepfte, falls es inzwischen inaktiv ist. */
|
||||
export async function getHoneyProductOptions(currentId: string | null): Promise<{ id: string; name: string; isActive: boolean }[]> {
|
||||
return prisma.product.findMany({
|
||||
where: {
|
||||
category: "HONIG",
|
||||
OR: [{ isActive: true }, ...(currentId ? [{ id: currentId }] : [])],
|
||||
},
|
||||
select: { id: true, name: true, isActive: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return { rows, eligibleHoney };
|
||||
}
|
||||
@@ -16,7 +16,6 @@ const oneTime = {
|
||||
price: "39,00",
|
||||
honeySharePerJar: "7",
|
||||
isPhysical: "on",
|
||||
honeyProductId: "prod_1",
|
||||
honeyJars: "1",
|
||||
medalTier: "SILBER",
|
||||
};
|
||||
@@ -29,7 +28,6 @@ const subscription = {
|
||||
price: "12,50",
|
||||
honeySharePerMonth: "4",
|
||||
isPhysical: "on",
|
||||
honeyProductId: "prod_1",
|
||||
honeyJars: "2",
|
||||
cycle: "VIERTELJAEHRLICH",
|
||||
medalTier: "ABO",
|
||||
@@ -83,14 +81,20 @@ describe("parseOfferForm", () => {
|
||||
expect(result).toEqual({ ok: false, message: "Bei Gläsern im Paket bitte den Honiganteil je Glas angeben." });
|
||||
});
|
||||
|
||||
it("verlangt fuer physische Angebote Honigprodukt und Glas", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeyProductId: "", honeyJars: "0", honeySharePerJar: "" }));
|
||||
expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas." });
|
||||
it("verlangt fuer physische Angebote mindestens ein Glas", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeyJars: "0", honeySharePerJar: "" }));
|
||||
expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht mindestens ein Glas Honig." });
|
||||
});
|
||||
|
||||
it("nimmt kein festes Honigprodukt mehr an — der Honig wird automatisch gewaehlt", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeyProductId: "prod_1" }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value).not.toHaveProperty("honeyProductId");
|
||||
});
|
||||
|
||||
it("erlaubt ein rein digitales Angebot ohne Honig", () => {
|
||||
const result = parseOfferForm(
|
||||
form({ ...oneTime, isPhysical: "", honeyProductId: "", honeyJars: "0", honeySharePerJar: "", price: "19" })
|
||||
form({ ...oneTime, isPhysical: "", honeyJars: "0", honeySharePerJar: "", price: "19" })
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(carrierPriceCents(result.value)).toBe(1900);
|
||||
|
||||
@@ -96,11 +96,6 @@ const offerFormSchema = z
|
||||
isActive: z.boolean(),
|
||||
isPhysical: z.boolean(),
|
||||
sortOrder: intField("Reihenfolge", 0, 9999),
|
||||
honeyProductId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(64)
|
||||
.transform((value) => (value === "" ? null : value)),
|
||||
honeyJars: intField("Gläser", 0, 24),
|
||||
cycle: z.union([z.enum(OFFER_CYCLES), z.literal("")]).transform((value) => (value === "" ? null : value)),
|
||||
maxActiveSubscriptions: z.union([z.literal(""), intField("Höchstzahl aktiver Abos", 1, 100_000)]).transform((value) =>
|
||||
@@ -111,10 +106,10 @@ const offerFormSchema = z
|
||||
.superRefine((offer, ctx) => {
|
||||
const issue = (message: string) => ctx.addIssue({ code: "custom", message });
|
||||
if (offer.priceCents <= 0) issue("Der Preis muss größer als 0 € sein.");
|
||||
if (offer.honeyJars > 0 && !offer.honeyProductId) issue("Gläser ohne Honigprodukt: bitte ein Honigprodukt wählen.");
|
||||
if (offer.isPhysical && (!offer.honeyProductId || offer.honeyJars < 1)) {
|
||||
issue("Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas.");
|
||||
}
|
||||
// Welcher Honig, waehlt das System automatisch (nie cremig, neueste
|
||||
// Ernte — lib/sponsorship/honey-selection.ts); hier geht es nur um die
|
||||
// Menge. Ein festes Honigprodukt nimmt das Formular nicht mehr an.
|
||||
if (offer.isPhysical && offer.honeyJars < 1) issue("Ein physisches Angebot braucht mindestens ein Glas Honig.");
|
||||
if (offer.kind === "EINMALIG") {
|
||||
// Der Rest nach Abzug des Honiganteils ist der Preis des Traegerprodukts
|
||||
// (Urkunde und Leistung zum Regelsatz) — er muss positiv bleiben.
|
||||
@@ -170,7 +165,6 @@ export function parseOfferForm(
|
||||
isActive: formData.get("isActive") === "on",
|
||||
isPhysical: formData.get("isPhysical") === "on",
|
||||
sortOrder: field(formData, "sortOrder") || "0",
|
||||
honeyProductId: field(formData, "honeyProductId"),
|
||||
honeyJars: field(formData, "honeyJars") || "0",
|
||||
cycle: field(formData, "cycle"),
|
||||
maxActiveSubscriptions: field(formData, "maxActiveSubscriptions").trim(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
import { nextDeliveryView } from "./subscription-view";
|
||||
import { countUndercovered, deliveriesCovered, type HoneyCoverageRow } from "./coverage";
|
||||
import { countUndercovered, deliveriesCovered } from "./coverage";
|
||||
|
||||
describe("nextDeliveryView", () => {
|
||||
it("vierteljaehrlich: zaehlt die fehlenden bezahlten Monate", () => {
|
||||
@@ -44,17 +44,9 @@ describe("Honigdeckung", () => {
|
||||
expect(deliveriesCovered(10, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("zaehlt nur Produkte unter einer Lieferrunde", () => {
|
||||
const row = (deliveries: number | null): HoneyCoverageRow => ({
|
||||
productId: "p",
|
||||
productName: "Honig",
|
||||
isActive: true,
|
||||
stock: 0,
|
||||
reserved: 0,
|
||||
free: 0,
|
||||
jarsPerDelivery: deliveries === null ? 0 : 2,
|
||||
deliveriesCovered: deliveries,
|
||||
});
|
||||
expect(countUndercovered([row(0), row(1), row(null), row(0)])).toBe(2);
|
||||
it("meldet hoechstens einen Eintrag: wenn der passende Honig zusammen keine Lieferrunde mehr deckt", () => {
|
||||
expect(countUndercovered({ deliveriesCovered: 0 })).toBe(1);
|
||||
expect(countUndercovered({ deliveriesCovered: 1 })).toBe(0);
|
||||
expect(countUndercovered({ deliveriesCovered: null })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export default async function AdminSponsorshipSubscriptionsPage(props: PageProps
|
||||
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<CoveragePanel rows={coverage} t={ts} />
|
||||
<CoveragePanel coverage={coverage} t={ts} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 lg:col-span-2">
|
||||
<SepaSettingsPanel holdDays={holdDays} t={ts} />
|
||||
|
||||
@@ -7,16 +7,17 @@ import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { updateSponsorshipOfferAction } from "@/app/admin/actions";
|
||||
import { OfferForm } from "../../_components/offer-form";
|
||||
import { getHoneyProductOptions, getOfferOverview } from "../../_lib/offer-overview";
|
||||
import { getOfferOverview } from "../../_lib/offer-overview";
|
||||
|
||||
export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/admin/patenschaften/angebote/[id]">) {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const tf = t.offerForm;
|
||||
const { id } = await props.params;
|
||||
const [row] = await getOfferOverview(id);
|
||||
const {
|
||||
rows: [row],
|
||||
} = await getOfferOverview(id);
|
||||
if (!row) notFound();
|
||||
const { offer, isAvailable, honeyStock, activeSubscriptions } = row;
|
||||
const honeyOptions = await getHoneyProductOptions(offer.honeyProductId);
|
||||
const { offer, isAvailable, currentHoney, activeSubscriptions } = row;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<span className="text-sm text-ink/75">{isAvailable ? tf.availableNow : tf.unavailableNow}</span>
|
||||
</div>
|
||||
{honeyStock ? (
|
||||
<p className="mt-3 text-sm tabular-nums text-ink/75">{tf.stockLine(honeyStock.stock, honeyStock.reserved, honeyStock.free)}</p>
|
||||
{currentHoney ? (
|
||||
<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}
|
||||
{offer.kind === "ABO" ? (
|
||||
<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
|
||||
isNew={false}
|
||||
honeyOptions={honeyOptions}
|
||||
currentHoneyLabel={currentHoney?.label ?? null}
|
||||
action={updateSponsorshipOfferAction}
|
||||
t={t}
|
||||
defaults={{
|
||||
@@ -64,7 +69,6 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a
|
||||
isActive: offer.isActive,
|
||||
isPhysical: offer.isPhysical,
|
||||
sortOrder: offer.sortOrder,
|
||||
honeyProductId: offer.honeyProductId,
|
||||
honeyJars: offer.honeyJars,
|
||||
cycle: offer.cycle,
|
||||
maxActiveSubscriptions: offer.maxActiveSubscriptions,
|
||||
|
||||
@@ -3,16 +3,19 @@ import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { createSponsorshipOfferAction } from "@/app/admin/actions";
|
||||
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() {
|
||||
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 (
|
||||
<div className="max-w-3xl">
|
||||
<BackLink href="/admin/patenschaften/angebote" label={t.offerForm.backToOffers} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -10,23 +10,16 @@ import { formatPrice } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { SponsorshipTabs } from "../_components/sponsorship-tabs";
|
||||
import { getOfferOverview, type OfferOverviewRow } from "../_lib/offer-overview";
|
||||
|
||||
/** Je verknuepftem Honigprodukt eine Zeile (mehrere Angebote koennen dasselbe Produkt nutzen). */
|
||||
function honeyStockRows(rows: OfferOverviewRow[]) {
|
||||
const byProduct = new Map<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"));
|
||||
}
|
||||
import { getOfferOverview } from "../_lib/offer-overview";
|
||||
|
||||
export default async function AdminSponsorshipOffersPage() {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const to = t.offers;
|
||||
const rows = await getOfferOverview();
|
||||
const stockRows = honeyStockRows(rows);
|
||||
// Honig waehlt das System automatisch (nie cremig, neueste Ernte); gezeigt
|
||||
// wird hier der passende Honig in der Rangfolge der Wahl.
|
||||
const { rows, eligibleHoney } = await getOfferOverview();
|
||||
// "Erste Wahl" = der Honig, den ein Paket mit einem Glas gerade bekaeme.
|
||||
const firstChoiceId = eligibleHoney.find((row) => row.free > 0)?.productId;
|
||||
const newOffer = { href: "/admin/patenschaften/angebote/neu", label: to.newOffer };
|
||||
|
||||
return (
|
||||
@@ -50,7 +43,7 @@ export default async function AdminSponsorshipOffersPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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]">
|
||||
<td className="p-4">
|
||||
<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}
|
||||
</td>
|
||||
<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 className="p-4">{t.medals[offer.medalTier]}</td>
|
||||
<td className="p-4">
|
||||
@@ -91,27 +88,36 @@ export default async function AdminSponsorshipOffersPage() {
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-3 text-lg text-heading">{to.stockHeading}</h2>
|
||||
{stockRows.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{to.noLinkedHoney}</p>
|
||||
<h2 className="mb-1 text-lg text-heading">{to.stockHeading}</h2>
|
||||
<p className="mb-3 max-w-prose text-xs text-ink/65">{to.stockIntro}</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">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-ink/65">
|
||||
<tr>
|
||||
<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.colReserved}</th>
|
||||
<th className="p-4 text-right">{to.colFree}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{stockRows.map(([productId, row]) => (
|
||||
<tr key={productId}>
|
||||
{eligibleHoney.map((row) => (
|
||||
<tr key={row.productId}>
|
||||
<td className="p-4">
|
||||
{row.name}
|
||||
{row.isActive ? null : <span className="ml-2 text-xs text-ink/60">({to.inactive})</span>}
|
||||
{row.productName}
|
||||
{row.productId === firstChoiceId ? (
|
||||
<span className="ml-2">
|
||||
<AdminBadge tone="positive">{to.firstChoice}</AdminBadge>
|
||||
</span>
|
||||
) : null}
|
||||
</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.reserved}</td>
|
||||
<td className="p-4 text-right tabular-nums text-heading">{row.free}</td>
|
||||
|
||||
@@ -74,6 +74,8 @@ vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
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-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");
|
||||
|
||||
@@ -121,6 +123,7 @@ beforeEach(() => {
|
||||
getProductsByIds.mockResolvedValue([honey]);
|
||||
getCurrentCustomer.mockResolvedValue(null);
|
||||
getOffersByProductIds.mockResolvedValue(new Map());
|
||||
loadHoneyCandidates.mockResolvedValue([]);
|
||||
getShippingRegion.mockResolvedValue("DE");
|
||||
db.discountCode.findMany.mockResolvedValue([]);
|
||||
db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
||||
@@ -467,12 +470,45 @@ describe("Checkout-Route", () => {
|
||||
validUntil: null,
|
||||
};
|
||||
|
||||
const honeyCandidate = { id: "p1", name: honey.name, weightGrams: 500, harvestYear: 2026, consistency: "flüssig", inventory: 100, freeStock: 100 };
|
||||
|
||||
beforeEach(() => {
|
||||
getCurrentCustomer.mockResolvedValue({ id: "cust_1", emailVerifiedAt: new Date() });
|
||||
getOffersByProductIds.mockResolvedValue(new Map([["carrier", offer]]));
|
||||
loadHoneyCandidates.mockResolvedValue([honeyCandidate]);
|
||||
getProductsByIds.mockImplementation(async (ids: string[]) => [honey, carrier].filter((product) => ids.includes(product.id)));
|
||||
});
|
||||
|
||||
test("die Honigposition nimmt den automatisch gewaehlten Honig — nie cremigen — und reserviert ihn", async () => {
|
||||
const liquid = { ...honey, id: "p2", name: "Sommertracht 2026", slug: "sommertracht-2026" };
|
||||
loadHoneyCandidates.mockResolvedValue([
|
||||
{ ...honeyCandidate, consistency: "feincremig", harvestYear: 2027 },
|
||||
{ ...honeyCandidate, id: "p2", name: liquid.name },
|
||||
]);
|
||||
getProductsByIds.mockImplementation(async (ids: string[]) => [honey, liquid, carrier].filter((product) => ids.includes(product.id)));
|
||||
|
||||
const response = await post({ ...validPayload, items: [packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Nur die Produktdaten des nicht-cremigen Honigs werden nachgeladen.
|
||||
expect(getProductsByIds).toHaveBeenCalledWith(["p2"]);
|
||||
const items = db.order.create.mock.calls[0][0].data.items.create;
|
||||
expect(items).toContainEqual(expect.objectContaining({ productId: "p2", sponsorshipRole: "HONEY", quantity: 3, productName: "Sommertracht 2026 – Wabenfreund" }));
|
||||
expect(db.inventory.updateMany).toHaveBeenCalledWith({
|
||||
where: { productId: "p2", quantity: { gte: 3 } },
|
||||
data: { quantity: { decrement: 3 } },
|
||||
});
|
||||
});
|
||||
|
||||
test("ohne passenden Honig ist das Paket nicht bestellbar", async () => {
|
||||
loadHoneyCandidates.mockResolvedValue([{ ...honeyCandidate, consistency: "cremig" }]);
|
||||
|
||||
const response = await post({ ...validPayload, items: [packageItem] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(db.order.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Mengenrabatt: Glaeser aus einem Patenschaftspaket zaehlen nicht zur Schwelle", async () => {
|
||||
// 2 einzelne Glaeser + 3 Paketglaeser = 5 physische, aber nur 2 rabattfaehige.
|
||||
db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]);
|
||||
|
||||
@@ -24,6 +24,8 @@ import { vatRateForProduct } from "@/lib/vat";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
|
||||
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
||||
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||
import { isCreamy } from "@/lib/sponsorship/honey-selection";
|
||||
import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
|
||||
class OutOfStockError extends Error {}
|
||||
@@ -231,9 +233,16 @@ export async function POST(request: Request) {
|
||||
const productIds = [...new Set(input.items.map((item) => item.productId))];
|
||||
const locale = await getLocale();
|
||||
const offersByProduct = await getOffersByProductIds(productIds, locale);
|
||||
const honeyIds = [...offersByProduct.values()]
|
||||
.map((offer) => offer.honeyProduct?.id)
|
||||
.filter((id): id is string => Boolean(id) && !productIds.includes(id as string));
|
||||
// Honig fuer Pakete waehlt der Checkout selbst, im Moment der Bestellung
|
||||
// (lib/sponsorship/honey-selection.ts) — dafuer braucht er alle Kandidaten
|
||||
// mit freiem Bestand und deren Produktdaten (Charge, Gewicht, Steuersatz).
|
||||
// Cremiger Honig kommt nie in ein Paket, seine Produktdaten braucht es
|
||||
// deshalb nicht.
|
||||
const needsHoney = [...offersByProduct.values()].some((offer) => offer.honeyJars > 0);
|
||||
const honeyCandidates = needsHoney ? await loadHoneyCandidates() : [];
|
||||
const honeyIds = honeyCandidates
|
||||
.filter((candidate) => !isCreamy(candidate.consistency) && !productIds.includes(candidate.id))
|
||||
.map((candidate) => candidate.id);
|
||||
const products = await getProductsByIds(productIds);
|
||||
const honeyProducts = honeyIds.length > 0 ? await getProductsByIds(honeyIds) : [];
|
||||
|
||||
@@ -290,6 +299,7 @@ export async function POST(request: Request) {
|
||||
cartItems: input.items,
|
||||
products: productMap,
|
||||
offers: offersByProduct,
|
||||
honeyCandidates,
|
||||
vatRateFor: (product) => vatRateForProduct(product, process.env),
|
||||
locale,
|
||||
now: new Date(),
|
||||
@@ -329,9 +339,16 @@ export async function POST(request: Request) {
|
||||
|
||||
// Fuer Abos reservierte Glaeser duerfen weder einzeln noch im Paket
|
||||
// verkauft werden (lib/sponsorship-subscription/reservation.ts). Vor der
|
||||
// Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht
|
||||
// durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung
|
||||
// bleibt das bedingte Update in der Transaktion.
|
||||
// Transaktion gelesen, wie bisher: die verbindliche Bestandspruefung bleibt
|
||||
// das bedingte Update in der Transaktion. Seit die Abo-Glaeser automatisch
|
||||
// auf dem gerade gewaehlten Honig liegen, kann eine gleichzeitige Bestellung
|
||||
// die Reservierung zwar auf einen anderen Honig verschieben; das bedingte
|
||||
// Update haelt aber den Honig, auf dem sie laut dieser Momentaufnahme
|
||||
// liegt, ueber ihrem Anteil. Die Reservierung in die Serializable-
|
||||
// Transaktion zu ziehen, wuerde jede Bestellung mit jeder anderen
|
||||
// Honigbuchung kollidieren lassen — bei Andrang deutlich mehr Abbrueche
|
||||
// fuer einen Randfall, den im schlimmsten Fall eine zurueckgestellte
|
||||
// Abo-Lieferung in der Aufmerksamkeitsliste auffaengt.
|
||||
const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId));
|
||||
|
||||
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
||||
|
||||
@@ -186,17 +186,23 @@ const de = {
|
||||
available: "Bestellbar",
|
||||
unavailable: "Nicht bestellbar",
|
||||
perMonth: "/ Monat",
|
||||
jars: (n: number, product: string) => `${n} × ${product}`,
|
||||
honeyAuto: (n: number, current: string | null) =>
|
||||
`${n} × automatisch ${current ? `(aktuell: ${current})` : "(derzeit kein passender Honig)"}`,
|
||||
noHoney: "ohne Honig",
|
||||
edit: "Bearbeiten",
|
||||
harvestWindows: "Erntefenster",
|
||||
empty: "Noch keine Angebote.",
|
||||
stockHeading: "Honigbestand der verknüpften Produkte",
|
||||
stockHeading: "Honig für Pakete und Abos (automatisch gewählt)",
|
||||
stockIntro:
|
||||
"Nur nicht-cremiger, aktiver Honig. Gewählt wird die neueste Ernte mit genug freiem Bestand, bei gleicher Ernte der größere freie Bestand — bei jeder Bestellung und Abo-Lieferung neu.",
|
||||
colProduct: "Produkt",
|
||||
colYear: "Ernte",
|
||||
colConsistency: "Konsistenz",
|
||||
colStock: "Bestand",
|
||||
colReserved: "Für Abos reserviert",
|
||||
colFree: "Frei verkäuflich",
|
||||
noLinkedHoney: "Kein Angebot ist mit einem Honigprodukt verknüpft.",
|
||||
firstChoice: "erste Wahl",
|
||||
noEligibleHoney: "Kein aktiver, nicht-cremiger Honig vorhanden — Angebote mit Honig sind nicht bestellbar.",
|
||||
},
|
||||
offerForm: {
|
||||
newTitle: "Neues Patenschafts-Angebot",
|
||||
@@ -221,9 +227,11 @@ const de = {
|
||||
carrierPrice: (price: string) => `Trägerprodukt derzeit: ${price} (Paketpreis minus Honiganteil).`,
|
||||
sectionHoney: "Honig und Versand",
|
||||
isPhysical: "Physisch: gedruckte Urkunde, Honig, Versand",
|
||||
honeyProduct: "Honigprodukt",
|
||||
honeyProductNone: "Kein Honig",
|
||||
inactiveSuffix: " (inaktiv)",
|
||||
honey: "Honig",
|
||||
honeyAuto: (current: string | null) =>
|
||||
current ? `automatisch (aktuell: ${current})` : "automatisch (derzeit kein passender Honig mit genug Bestand)",
|
||||
honeyAutoHint:
|
||||
"Pakete und Abos bekommen immer nicht-cremigen Honig der neuesten Ernte mit genug freiem Bestand. Gewählt wird bei jeder Bestellung und Abo-Lieferung neu.",
|
||||
honeyJars: "Gläser (Einmalig: im Paket, Abo: je Lieferung)",
|
||||
sectionAbo: "Abo",
|
||||
cycle: "Lieferzyklus (nur Abo)",
|
||||
@@ -238,9 +246,10 @@ const de = {
|
||||
save: "Speichern",
|
||||
availabilityHeading: "Verfügbarkeit",
|
||||
availableNow: "Derzeit bestellbar.",
|
||||
unavailableNow: "Derzeit nicht bestellbar: Angebot inaktiv, Honig fehlt oder Kontingent voll.",
|
||||
stockLine: (stock: number, reserved: number, free: number) =>
|
||||
`Honigbestand ${stock}, davon ${reserved} für Abos reserviert, ${free} frei.`,
|
||||
unavailableNow: "Derzeit nicht bestellbar: Angebot inaktiv, kein passender Honig oder Kontingent voll.",
|
||||
stockLine: (label: string, stock: number, reserved: number, free: number) =>
|
||||
`Honig aktuell: ${label} – Bestand ${stock}, davon ${reserved} für Abos reserviert, ${free} frei.`,
|
||||
noEligibleHoney: "Derzeit gibt es keinen nicht-cremigen Honig mit genug freiem Bestand für dieses Angebot.",
|
||||
activeSubscriptions: (n: number, max: number | null) =>
|
||||
max === null ? `${n} aktive Abos (unbegrenzt).` : `${n} von ${max} Abo-Plätzen belegt.`,
|
||||
},
|
||||
@@ -283,16 +292,18 @@ const de = {
|
||||
empty: "Noch keine Abos.",
|
||||
emptyFiltered: "Keine Abos zu diesem Filter.",
|
||||
coverageHeading: "Honigdeckung der Abos",
|
||||
coverageIntro: "„Deckt“ = wie viele vierteljährliche Lieferrunden der Bestand noch bedient.",
|
||||
coverageIntro:
|
||||
"Abos bekommen automatisch nicht-cremigen Honig, gezeigt in der Reihenfolge der Wahl. „Deckt“ = wie viele vierteljährliche Lieferrunden dieser Honig zusammen noch bedient.",
|
||||
colProduct: "Honigprodukt",
|
||||
colYear: "Ernte",
|
||||
colStock: "Bestand",
|
||||
colReserved: "Reserviert",
|
||||
colFree: "Frei",
|
||||
colPerDelivery: "Gläser je Lieferrunde",
|
||||
colCovers: "Deckt",
|
||||
perDelivery: (n: number) => `Gläser je vierteljährlicher Lieferrunde: ${n}`,
|
||||
coversLabel: "Deckt",
|
||||
covers: (n: number) => `${n} ${plural(n, "Lieferung", "Lieferungen")}`,
|
||||
coverageWarning: "unter einer Lieferung",
|
||||
noCoverage: "Kein Abo-Angebot ist mit einem Honigprodukt verknüpft.",
|
||||
noCoverage: "Kein aktiver, nicht-cremiger Honig vorhanden — Abo-Lieferungen werden zurückgestellt.",
|
||||
sepaHeading: "SEPA-Neukunden-Frist",
|
||||
sepaIntro: "Bei SEPA zählt die erste Abo-Zahlung eines Kontos erst nach dieser Frist für den Lieferanspruch (Schutz vor Rückbuchungen in der Acht-Wochen-Frist).",
|
||||
sepaLabel: "Frist in Tagen (0 = aus)",
|
||||
@@ -323,7 +334,7 @@ const de = {
|
||||
attention: {
|
||||
failed: (n: number) => `${n} ${plural(n, "Patenschaft braucht", "Patenschaften brauchen")} Hilfe (Fehler bei der Ausstellung)`,
|
||||
toPrint: (n: number) => `${n} ${plural(n, "Urkunde wartet", "Urkunden warten")} seit über zwei Tagen auf den Druck`,
|
||||
coverage: (n: number) => `${n} ${plural(n, "Honigprodukt deckt", "Honigprodukte decken")} keine volle Abo-Lieferung mehr`,
|
||||
coverage: "Nicht-cremiger Honig deckt keine volle vierteljährliche Abo-Lieferrunde mehr",
|
||||
delinquent: (n: number) => `${n} ${plural(n, "Abo ist", "Abos sind")} im Verzug`,
|
||||
},
|
||||
order: {
|
||||
@@ -510,17 +521,22 @@ const en: SponsorshipAdminStrings = {
|
||||
available: "Available",
|
||||
unavailable: "Not available",
|
||||
perMonth: "/ month",
|
||||
jars: (n, product) => `${n} × ${product}`,
|
||||
honeyAuto: (n, current) => `${n} × automatic ${current ? `(currently: ${current})` : "(no suitable honey right now)"}`,
|
||||
noHoney: "no honey",
|
||||
edit: "Edit",
|
||||
harvestWindows: "Harvest windows",
|
||||
empty: "No offers yet.",
|
||||
stockHeading: "Honey stock of linked products",
|
||||
stockHeading: "Honey for packages and subscriptions (chosen automatically)",
|
||||
stockIntro:
|
||||
"Only active honey that is not creamy. The newest harvest with enough free stock is chosen, on equal harvests the larger free stock — anew for every order and subscription delivery.",
|
||||
colProduct: "Product",
|
||||
colYear: "Harvest",
|
||||
colConsistency: "Consistency",
|
||||
colStock: "Stock",
|
||||
colReserved: "Reserved for subscriptions",
|
||||
colFree: "Free to sell",
|
||||
noLinkedHoney: "No offer is linked to a honey product.",
|
||||
firstChoice: "first choice",
|
||||
noEligibleHoney: "No active honey that is not creamy — offers with honey are not available.",
|
||||
},
|
||||
offerForm: {
|
||||
newTitle: "New sponsorship offer",
|
||||
@@ -545,9 +561,10 @@ const en: SponsorshipAdminStrings = {
|
||||
carrierPrice: (price) => `Carrier product currently: ${price} (package price minus honey share).`,
|
||||
sectionHoney: "Honey and shipping",
|
||||
isPhysical: "Physical: printed certificate, honey, shipping",
|
||||
honeyProduct: "Honey product",
|
||||
honeyProductNone: "No honey",
|
||||
inactiveSuffix: " (inactive)",
|
||||
honey: "Honey",
|
||||
honeyAuto: (current) => (current ? `automatic (currently: ${current})` : "automatic (no suitable honey with enough stock right now)"),
|
||||
honeyAutoHint:
|
||||
"Packages and subscriptions always get non-creamy honey from the newest harvest with enough free stock. It is chosen anew for every order and subscription delivery.",
|
||||
honeyJars: "Jars (one-time: in the package, subscription: per delivery)",
|
||||
sectionAbo: "Subscription",
|
||||
cycle: "Delivery cycle (subscription only)",
|
||||
@@ -562,8 +579,10 @@ const en: SponsorshipAdminStrings = {
|
||||
save: "Save",
|
||||
availabilityHeading: "Availability",
|
||||
availableNow: "Currently available.",
|
||||
unavailableNow: "Currently not available: offer inactive, honey missing or quota full.",
|
||||
stockLine: (stock, reserved, free) => `Honey stock ${stock}, of which ${reserved} reserved for subscriptions, ${free} free.`,
|
||||
unavailableNow: "Currently not available: offer inactive, no suitable honey or quota full.",
|
||||
stockLine: (label, stock, reserved, free) =>
|
||||
`Honey currently: ${label} – stock ${stock}, of which ${reserved} reserved for subscriptions, ${free} free.`,
|
||||
noEligibleHoney: "There is currently no non-creamy honey with enough free stock for this offer.",
|
||||
activeSubscriptions: (n, max) => (max === null ? `${n} active subscriptions (unlimited).` : `${n} of ${max} subscription slots taken.`),
|
||||
},
|
||||
harvest: {
|
||||
@@ -605,16 +624,18 @@ const en: SponsorshipAdminStrings = {
|
||||
empty: "No subscriptions yet.",
|
||||
emptyFiltered: "No subscriptions match this filter.",
|
||||
coverageHeading: "Honey coverage of subscriptions",
|
||||
coverageIntro: "“Covers” = how many quarterly delivery rounds the stock can still serve.",
|
||||
coverageIntro:
|
||||
"Subscriptions automatically get honey that is not creamy, listed in the order it is chosen. “Covers” = how many quarterly delivery rounds this honey can still serve together.",
|
||||
colProduct: "Honey product",
|
||||
colYear: "Harvest",
|
||||
colStock: "Stock",
|
||||
colReserved: "Reserved",
|
||||
colFree: "Free",
|
||||
colPerDelivery: "Jars per delivery round",
|
||||
colCovers: "Covers",
|
||||
perDelivery: (n) => `Jars per quarterly delivery round: ${n}`,
|
||||
coversLabel: "Covers",
|
||||
covers: (n) => `${n} ${plural(n, "delivery", "deliveries")}`,
|
||||
coverageWarning: "below one delivery",
|
||||
noCoverage: "No subscription offer is linked to a honey product.",
|
||||
noCoverage: "No active honey that is not creamy — subscription deliveries are deferred.",
|
||||
sepaHeading: "SEPA new-customer hold",
|
||||
sepaIntro: "With SEPA, an account's first subscription payment only counts towards delivery after this period (protection against charge-backs within the eight-week window).",
|
||||
sepaLabel: "Hold in days (0 = off)",
|
||||
@@ -645,7 +666,7 @@ const en: SponsorshipAdminStrings = {
|
||||
attention: {
|
||||
failed: (n) => `${n} ${plural(n, "sponsorship needs", "sponsorships need")} help (issuing failed)`,
|
||||
toPrint: (n) => `${n} ${plural(n, "certificate has", "certificates have")} been waiting to be printed for over two days`,
|
||||
coverage: (n) => `${n} ${plural(n, "honey product no longer covers", "honey products no longer cover")} a full subscription delivery`,
|
||||
coverage: "Non-creamy honey no longer covers a full quarterly subscription delivery round",
|
||||
delinquent: (n) => `${n} ${plural(n, "subscription is", "subscriptions are")} delinquent`,
|
||||
},
|
||||
order: {
|
||||
|
||||
@@ -179,7 +179,7 @@ const de: SponsorshipShopStrings = {
|
||||
},
|
||||
{
|
||||
title: "Honig dazu, wenn du magst",
|
||||
text: "Je nach Paket gehört Wabenhain-Honig dazu: einmal im Geschenkpaket oder regelmäßig im Abo.",
|
||||
text: "Je nach Paket gehört Wabenhain-Honig der aktuellen Ernte (flüssig) dazu: einmal im Geschenkpaket oder regelmäßig im Abo.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -396,7 +396,7 @@ const en: SponsorshipShopStrings = {
|
||||
},
|
||||
{
|
||||
title: "Honey too, if you like",
|
||||
text: "Depending on the package, Wabenhain honey comes with it: once in a gift box or regularly with a subscription.",
|
||||
text: "Depending on the package, Wabenhain honey from the current harvest (liquid) comes with it: once in a gift box or regularly with a subscription.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ const m = vi.hoisted(() => ({
|
||||
sponsorshipSubscription: { findMany: vi.fn() },
|
||||
subscriptionPayment: { updateMany: vi.fn() },
|
||||
inventory: { updateMany: vi.fn() },
|
||||
product: { findUnique: vi.fn() },
|
||||
order: { create: vi.fn() },
|
||||
customer: { findUniqueOrThrow: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
@@ -17,6 +18,7 @@ const m = vi.hoisted(() => ({
|
||||
sendMail: vi.fn(),
|
||||
recordOrderSale: vi.fn(),
|
||||
scheduleApiCoreStockSync: vi.fn(),
|
||||
loadHoneyStock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
|
||||
@@ -24,6 +26,7 @@ vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail }));
|
||||
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
|
||||
vi.mock("@/lib/apicore-stock/stock-events", () => ({ recordOrderSale: m.recordOrderSale }));
|
||||
vi.mock("@/lib/apicore-stock/schedule", () => ({ scheduleApiCoreStockSync: m.scheduleApiCoreStockSync }));
|
||||
vi.mock("@/lib/sponsorship/honey-stock", () => ({ loadHoneyStock: m.loadHoneyStock }));
|
||||
vi.mock("@/lib/sponsorship-subscription/emails", () => ({
|
||||
subscriptionDeliveryEmail: vi.fn(async () => ({ subject: "Lieferung", html: "<p></p>", text: "" })),
|
||||
}));
|
||||
@@ -67,13 +70,23 @@ const delivery = {
|
||||
locale: "de",
|
||||
customerId: "cust_1",
|
||||
offerId: "offer_1",
|
||||
offer: {
|
||||
name: "Vierteljährlich",
|
||||
honeyProduct: { id: "honey_1", name: "Frühtracht 500 g", batchId: "batch_1", weightGrams: 500, category: "HONIG", vatRatePercent: null },
|
||||
},
|
||||
offer: { name: "Vierteljährlich" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Rohbestand je Honig (ohne Abo-Reservierung — die ist ja fuer diese Lieferung da). */
|
||||
const stockRow = (id: string, overrides: Record<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(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
@@ -92,6 +105,8 @@ beforeEach(() => {
|
||||
addresses: [ADDRESS],
|
||||
});
|
||||
m.db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
||||
m.loadHoneyStock.mockResolvedValue([stockRow("honey_1")]);
|
||||
m.db.product.findUnique.mockImplementation(async ({ where }: { where: { id: string } }) => honeyProduct(where.id));
|
||||
m.db.order.create.mockResolvedValue({ id: "order_9", orderNumber: "WH-2026-00000009" });
|
||||
m.sendMail.mockResolvedValue(true);
|
||||
});
|
||||
@@ -123,6 +138,37 @@ describe("createDueDeliveries", () => {
|
||||
expect(m.sendMail).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("waehlt den Honig bei der Lieferung automatisch: nie cremig, neueste Ernte mit genug Bestand", async () => {
|
||||
m.loadHoneyStock.mockResolvedValue([
|
||||
stockRow("fein", { consistency: "feincremig", harvestYear: 2027, inventory: 50, freeStock: 50 }),
|
||||
stockRow("knapp", { harvestYear: 2026, inventory: 1, freeStock: 1 }),
|
||||
stockRow("raps", { harvestYear: 2025 }),
|
||||
]);
|
||||
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 1, deferred: 0 });
|
||||
expect(m.db.inventory.updateMany).toHaveBeenCalledWith({
|
||||
where: { productId: "raps", quantity: { gte: 2 } },
|
||||
data: { quantity: { decrement: 2 } },
|
||||
});
|
||||
expect(m.db.order.create.mock.calls[0][0].data.items.create).toMatchObject({ productId: "raps", quantity: 2 });
|
||||
expect(m.scheduleApiCoreStockSync).toHaveBeenCalledWith(["raps"]);
|
||||
});
|
||||
|
||||
test("gibt es nur cremigen Honig, wird die Lieferung zurueckgestellt", async () => {
|
||||
m.loadHoneyStock.mockResolvedValue([stockRow("fein", { consistency: "cremig", inventory: 50, freeStock: 50 })]);
|
||||
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 0, deferred: 1 });
|
||||
expect(m.db.inventory.updateMany).not.toHaveBeenCalled();
|
||||
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "del_1", orderId: null },
|
||||
data: { status: "ZURUECKGESTELLT", note: NOTE_NO_STOCK },
|
||||
});
|
||||
});
|
||||
|
||||
test("zwei bezahlte Monate: keine Lieferung", async () => {
|
||||
m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription([paid("p1", 40), paid("p2", 10)])]);
|
||||
|
||||
|
||||
@@ -8,28 +8,39 @@ import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import { computeDeliveryEntitlement } from "@/lib/sponsorship-subscription/entitlement";
|
||||
import { generateOrderNumber, orderContactForCustomer } from "@/lib/sponsorship-subscription/lookup";
|
||||
import { subscriptionDeliveryEmail } from "@/lib/sponsorship-subscription/emails";
|
||||
import { pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||
import { loadHoneyStock } from "@/lib/sponsorship/honey-stock";
|
||||
import type { ProductCategory } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Honiglieferungen im Abo (Spec "Lieferauftraege").
|
||||
*
|
||||
* Ist der Anspruch erfuellt (entitlement.ts), entsteht ein
|
||||
* Lieferauftrag als normale Bestellung: Status BEZAHLT, Betrag 0 €, Versand an
|
||||
* die Standardadresse, Honigpositionen des verknuepften Produkts. Er
|
||||
* erscheint in der bestehenden Pack- und DHL-Liste; der Honig wurde mit den
|
||||
* Monatsrechnungen bereits als Vorauszahlung berechnet.
|
||||
* die Standardadresse, eine Honigposition. Er erscheint in der bestehenden
|
||||
* Pack- und DHL-Liste; der Honig wurde mit den Monatsrechnungen bereits als
|
||||
* Vorauszahlung berechnet.
|
||||
*
|
||||
* Welcher Honig, entscheidet die automatische Wahl beim Anlegen der Lieferung
|
||||
* (lib/sponsorship/honey-selection.ts: nie cremig, neueste Ernte, genug
|
||||
* Bestand) — dieselbe Regel und derselbe Rohbestand (ohne Abo-Reservierung),
|
||||
* mit denen reservation.ts die Abo-Glaeser einem Honig zuordnet. Gegen den
|
||||
* Rohbestand, weil die Reservierung genau fuer diese Lieferung da ist und
|
||||
* sie sonst selbst blockieren wuerde.
|
||||
*
|
||||
* Schutz gegen doppelte Laeufe: die Lieferung beansprucht ihre Zahlungen
|
||||
* (deliveryId) in derselben Transaktion, in der sie entsteht. Ein zweiter,
|
||||
* gleichzeitiger Lauf findet die Zahlungen verbraucht und verwirft seine
|
||||
* Lieferung. Die Bestellung wird ueber orderId = null beansprucht.
|
||||
*
|
||||
* Fehlt die Adresse oder der Bestand, bleibt die Lieferung ZURUECKGESTELLT
|
||||
* (mit Hinweis fuer die Aufmerksamkeitsliste) und haelt ihre Zahlungen; jeder
|
||||
* weitere Lauf versucht sie erneut.
|
||||
* Fehlt die Adresse oder ein passender Honig mit genug Bestand (etwa weil nur
|
||||
* noch cremiger da ist), bleibt die Lieferung ZURUECKGESTELLT (mit Hinweis
|
||||
* fuer die Aufmerksamkeitsliste) und haelt ihre Zahlungen; jeder weitere Lauf
|
||||
* versucht sie erneut — dann mit der dann gueltigen Honigwahl.
|
||||
*/
|
||||
|
||||
export const NOTE_NO_ADDRESS = "Keine Standardadresse im Kundenkonto – Lieferung zurückgestellt, bitte Adresse erfragen.";
|
||||
export const NOTE_NO_STOCK = "Bestand des Honigprodukts reicht nicht – Lieferung zurückgestellt, bitte nachlegen.";
|
||||
export const NOTE_NO_STOCK = "Kein nicht-cremiger Honig mit genug Bestand – Lieferung zurückgestellt, bitte flüssigen Honig nachlegen.";
|
||||
const NOTE_PENDING = "Wird angelegt.";
|
||||
const NOTE_PAUSED = "Abo im Verzug oder beendet – Lieferung ruht.";
|
||||
|
||||
@@ -49,20 +60,32 @@ function loadDelivery(id: string) {
|
||||
locale: true,
|
||||
customerId: true,
|
||||
offerId: true,
|
||||
offer: {
|
||||
select: {
|
||||
name: true,
|
||||
honeyProduct: {
|
||||
select: { id: true, name: true, batchId: true, weightGrams: true, category: true, vatRatePercent: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
offer: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface DeliveryHoney {
|
||||
id: string;
|
||||
name: string;
|
||||
batchId: string | null;
|
||||
weightGrams: number;
|
||||
category: ProductCategory;
|
||||
vatRatePercent: number | null;
|
||||
}
|
||||
|
||||
/** Der Honig, den die automatische Wahl fuer diese Lieferung nimmt — mit den Daten fuer die Bestellposition. */
|
||||
async function pickDeliveryHoney(jars: number): Promise<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 },
|
||||
});
|
||||
}
|
||||
|
||||
async function defer(deliveryId: string, note: string): Promise<FulfillResult> {
|
||||
await prisma.subscriptionDelivery.updateMany({
|
||||
where: { id: deliveryId, orderId: null },
|
||||
@@ -77,7 +100,7 @@ export async function fulfillDelivery(deliveryId: string): Promise<FulfillResult
|
||||
if (delivery.orderId) return "skipped";
|
||||
const { subscription } = delivery;
|
||||
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);
|
||||
|
||||
const contact = await orderContactForCustomer(subscription.customerId);
|
||||
@@ -88,7 +111,9 @@ export async function fulfillDelivery(deliveryId: string): Promise<FulfillResult
|
||||
try {
|
||||
orderNumber = await prisma.$transaction(async (tx) => {
|
||||
// Nur die eigene Menge pruefen: die Abo-Reservierung dieses Abos ist
|
||||
// genau fuer diese Lieferung da und wird hier verbraucht.
|
||||
// genau fuer diese Lieferung da und wird hier verbraucht. Hat jemand den
|
||||
// gewaehlten Honig inzwischen geleert, wird zurueckgestellt und beim
|
||||
// naechsten Lauf neu gewaehlt.
|
||||
const reserved = await tx.inventory.updateMany({
|
||||
where: { productId: honey.id, quantity: { gte: delivery.jars } },
|
||||
data: { quantity: { decrement: delivery.jars } },
|
||||
@@ -213,7 +238,7 @@ export async function createDueDeliveries(now: Date): Promise<DeliveryRunResult>
|
||||
status: { in: ["AKTIV", "GEKUENDIGT"] },
|
||||
// Hoechstens eine offene Lieferung je Abo: erst die zurueckgestellte erledigen.
|
||||
deliveries: { none: { status: "ZURUECKGESTELLT", orderId: null } },
|
||||
offer: { honeyJars: { gt: 0 }, honeyProductId: { not: null }, cycle: { not: null } },
|
||||
offer: { honeyJars: { gt: 0 }, cycle: { not: null } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -1,25 +1,50 @@
|
||||
/**
|
||||
* Abo-Reservierung im Stapel: Produktlisten brauchen den freien Bestand
|
||||
* vieler Produkte auf einmal — mit festen drei Abfragen statt drei je Produkt.
|
||||
* Abo-Reservierung: Abos haben keinen festen Honig mehr. Ihre Glaeser liegen
|
||||
* auf dem Honig, den die automatische Wahl fuer sie treffen wuerde (nie
|
||||
* cremig, neueste Ernte, genug Lagerbestand) — berechnet mit festen vier
|
||||
* Abfragen, egal wie viele Produkte gefragt sind.
|
||||
*/
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
const { reservedJarsByProduct, reservedJarsForProduct, availableForSale, availableForSaleByProduct } = await import("./reservation");
|
||||
const { reservedJarsByProduct, reservedJarsForProduct, availableForSale, availableForSaleByProduct, subscriptionReservedJars } =
|
||||
await import("./reservation");
|
||||
|
||||
const NOW = new Date("2026-10-16T12:00:00.000Z");
|
||||
|
||||
interface FakeProduct {
|
||||
id: string;
|
||||
consistency: string;
|
||||
year: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
function productRow(product: FakeProduct) {
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.id,
|
||||
weightGrams: 500,
|
||||
consistency: product.consistency,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
inventory: { quantity: product.quantity },
|
||||
harvest: { year: product.year },
|
||||
batch: null,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDb(data: {
|
||||
quarterly?: { offer: { honeyProductId: string | null; honeyJars: number } }[];
|
||||
windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number; offer: { honeyProductId: string | null } }[];
|
||||
quarterly?: { offer: { honeyJars: number } }[];
|
||||
windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number }[];
|
||||
deliveries?: { jars: number; dueAt: Date; subscription: { offerId: string } }[];
|
||||
products?: FakeProduct[];
|
||||
inventory?: { productId: string; quantity: number }[];
|
||||
}) {
|
||||
return {
|
||||
sponsorshipSubscription: { findMany: vi.fn(async () => data.quarterly ?? []) },
|
||||
harvestWindow: { findMany: vi.fn(async () => data.windows ?? []) },
|
||||
subscriptionDelivery: { findMany: vi.fn(async () => data.deliveries ?? []) },
|
||||
product: { findMany: vi.fn(async () => (data.products ?? []).map(productRow)) },
|
||||
inventory: {
|
||||
findMany: vi.fn(async () => data.inventory ?? []),
|
||||
findUnique: vi.fn(async ({ where }: { where: { productId: string } }) =>
|
||||
@@ -31,21 +56,20 @@ function fakeDb(data: {
|
||||
|
||||
type Db = Parameters<typeof reservedJarsByProduct>[2];
|
||||
|
||||
describe("reservedJarsByProduct", () => {
|
||||
test("summiert vierteljaehrliche Abos und offene Erntefenster je Produkt in drei Abfragen", async () => {
|
||||
const WALD = { id: "wald", consistency: "flüssig", year: 2026, quantity: 20 };
|
||||
const RAPS = { id: "raps", consistency: "flüssig", year: 2025, quantity: 50 };
|
||||
const FEIN = { id: "fein", consistency: "feincremig", year: 2027, quantity: 99 };
|
||||
|
||||
describe("subscriptionReservedJars", () => {
|
||||
test("summiert vierteljaehrliche Abos und den offenen Rest der Erntefenster", async () => {
|
||||
const db = fakeDb({
|
||||
quarterly: [
|
||||
{ offer: { honeyProductId: "wald", honeyJars: 2 } },
|
||||
{ offer: { honeyProductId: "wald", honeyJars: 2 } },
|
||||
{ offer: { honeyProductId: "raps", honeyJars: 3 } },
|
||||
],
|
||||
quarterly: [{ offer: { honeyJars: 2 } }, { offer: { honeyJars: 2 } }, { offer: { honeyJars: 3 } }],
|
||||
windows: [
|
||||
{
|
||||
offerId: "ernte",
|
||||
startsOn: new Date("2026-10-01T00:00:00.000Z"),
|
||||
endsOn: new Date("2026-10-31T00:00:00.000Z"),
|
||||
plannedJars: 10,
|
||||
offer: { honeyProductId: "wald" },
|
||||
},
|
||||
],
|
||||
deliveries: [
|
||||
@@ -55,33 +79,58 @@ describe("reservedJarsByProduct", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await reservedJarsByProduct(["wald", "raps", "linde"], NOW, db as unknown as Db);
|
||||
|
||||
expect(result.get("wald")).toBe(2 + 2 + (10 - 4));
|
||||
expect(result.get("raps")).toBe(3);
|
||||
expect(result.get("linde")).toBe(0);
|
||||
expect(db.sponsorshipSubscription.findMany).toHaveBeenCalledOnce();
|
||||
expect(db.harvestWindow.findMany).toHaveBeenCalledOnce();
|
||||
expect(db.subscriptionDelivery.findMany).toHaveBeenCalledOnce();
|
||||
expect(await subscriptionReservedJars(NOW, db as unknown as Db)).toBe(2 + 2 + 3 + (10 - 4));
|
||||
});
|
||||
|
||||
test("ein ueberliefertes Erntefenster reserviert nichts mehr (nie negativ)", async () => {
|
||||
const db = fakeDb({
|
||||
windows: [
|
||||
{
|
||||
offerId: "ernte",
|
||||
startsOn: new Date("2026-10-01T00:00:00.000Z"),
|
||||
endsOn: new Date("2026-10-31T00:00:00.000Z"),
|
||||
plannedJars: 3,
|
||||
offer: { honeyProductId: "wald" },
|
||||
},
|
||||
{ offerId: "ernte", startsOn: new Date("2026-10-01T00:00:00.000Z"), endsOn: new Date("2026-10-31T00:00:00.000Z"), plannedJars: 3 },
|
||||
],
|
||||
deliveries: [{ jars: 5, dueAt: new Date("2026-10-31T20:00:00.000Z"), subscription: { offerId: "ernte" } }],
|
||||
});
|
||||
|
||||
expect(await subscriptionReservedJars(NOW, db as unknown as Db)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reservedJarsByProduct", () => {
|
||||
test("legt alle Abo-Glaeser auf den automatisch gewaehlten Honig — nie auf cremigen", async () => {
|
||||
const db = fakeDb({
|
||||
quarterly: [{ offer: { honeyJars: 2 } }, { offer: { honeyJars: 2 } }],
|
||||
windows: [
|
||||
{ offerId: "ernte", startsOn: new Date("2026-10-01T00:00:00.000Z"), endsOn: new Date("2026-10-31T00:00:00.000Z"), plannedJars: 6 },
|
||||
],
|
||||
products: [WALD, RAPS, FEIN],
|
||||
});
|
||||
|
||||
const result = await reservedJarsByProduct(["wald", "raps", "fein", "linde"], NOW, db as unknown as Db);
|
||||
|
||||
expect(Object.fromEntries(result)).toEqual({ wald: 10, raps: 0, fein: 0, linde: 0 });
|
||||
expect(db.sponsorshipSubscription.findMany).toHaveBeenCalledOnce();
|
||||
expect(db.harvestWindow.findMany).toHaveBeenCalledOnce();
|
||||
expect(db.subscriptionDelivery.findMany).toHaveBeenCalledOnce();
|
||||
expect(db.product.findMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("reicht der neueste Honig nicht fuer alle Abos, liegt die Reservierung auf dem, der reicht", async () => {
|
||||
const db = fakeDb({
|
||||
quarterly: Array.from({ length: 6 }, () => ({ offer: { honeyJars: 2 } })),
|
||||
products: [{ ...WALD, quantity: 5 }, RAPS],
|
||||
});
|
||||
|
||||
const result = await reservedJarsByProduct(["wald", "raps"], NOW, db as unknown as Db);
|
||||
|
||||
expect(Object.fromEntries(result)).toEqual({ wald: 0, raps: 12 });
|
||||
});
|
||||
|
||||
test("ohne Abo-Bedarf wird der Honigbestand gar nicht erst geladen", async () => {
|
||||
const db = fakeDb({ products: [WALD] });
|
||||
|
||||
const result = await reservedJarsByProduct(["wald"], NOW, db as unknown as Db);
|
||||
|
||||
expect(result.get("wald")).toBe(0);
|
||||
expect(db.product.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ohne Produkte keine Abfrage", async () => {
|
||||
@@ -95,7 +144,8 @@ describe("reservedJarsByProduct", () => {
|
||||
|
||||
test("Einzelabfrage und freier Bestand bauen auf dem Stapel auf", async () => {
|
||||
const db = fakeDb({
|
||||
quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 2 } }],
|
||||
quarterly: [{ offer: { honeyJars: 2 } }],
|
||||
products: [{ ...WALD, quantity: 5 }],
|
||||
inventory: [{ productId: "wald", quantity: 5 }],
|
||||
});
|
||||
|
||||
@@ -105,7 +155,9 @@ describe("reservedJarsByProduct", () => {
|
||||
|
||||
test("freier Bestand im Stapel: Lager minus Reservierung, ohne Lagerzeile 0", async () => {
|
||||
const db = fakeDb({
|
||||
quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 4 } }],
|
||||
quarterly: [{ offer: { honeyJars: 4 } }],
|
||||
// Echter Fehlbestand: 4 Glaeser reserviert, nur 3 da — frei bleibt 0, nicht -1.
|
||||
products: [{ ...WALD, quantity: 3 }],
|
||||
inventory: [
|
||||
{ productId: "wald", quantity: 3 },
|
||||
{ productId: "raps", quantity: 7 },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { allocateReservedJars, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
||||
import { loadHoneyStock } from "@/lib/sponsorship/honey-stock";
|
||||
|
||||
/**
|
||||
* Abo-Reservierung von Honig.
|
||||
@@ -18,30 +20,31 @@ import { prisma } from "@/lib/prisma";
|
||||
* - Ernte: die im Admin je Erntefenster geplanten Abo-Glaeser, solange das
|
||||
* Fenster nicht vorbei ist, abzueglich der in diesem Fenster bereits
|
||||
* angelegten Lieferungen (deren Glaeser sind dann schon vom Lager gebucht).
|
||||
*
|
||||
* Auf WELCHEM Honig die Glaeser liegen, steht nicht mehr am Angebot
|
||||
* (SponsorshipOffer.honeyProductId wird nicht mehr gelesen): Abos bekommen
|
||||
* automatisch nicht-cremigen Honig der neuesten Ernte
|
||||
* (lib/sponsorship/honey-selection.ts). Die Summe aller Abo-Glaeser liegt
|
||||
* deshalb auf dem Honig, den diese Regel fuer sie waehlt — gemessen am
|
||||
* Lagerbestand OHNE Reservierung, denn die Reservierung ist ja genau fuer
|
||||
* diese Glaeser da (allocateReservedJars). Abo-Lieferungen (deliveries.ts)
|
||||
* waehlen mit derselben Regel und demselben Rohbestand.
|
||||
*
|
||||
* Folge fuer Shop und Checkout: die Reservierung kann von einem Honig auf
|
||||
* einen anderen wandern, wenn sich Bestaende aendern (neue Ernte
|
||||
* eingebucht, ein Honig leerverkauft). Weil jede Abfrage neu rechnet, bleibt
|
||||
* die Summe trotzdem geschuetzt: der Checkout bucht nur, wenn danach
|
||||
* "Bestand >= Abo-Reservierung" gilt (api/checkout/route.ts), der gewaehlte
|
||||
* Honig faellt durch Verkaeufe also nie unter seinen Abo-Anteil und bleibt
|
||||
* damit auch die Wahl der Regel.
|
||||
*/
|
||||
|
||||
const RESERVING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
|
||||
|
||||
type Db = Pick<typeof prisma, "sponsorshipSubscription" | "harvestWindow" | "subscriptionDelivery" | "inventory">;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
type Db = Pick<typeof prisma, "sponsorshipSubscription" | "harvestWindow" | "subscriptionDelivery" | "inventory" | "product">;
|
||||
|
||||
/** 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({
|
||||
where: {
|
||||
status: { in: [...RESERVING_STATUSES] },
|
||||
@@ -50,18 +53,18 @@ export async function reservedJarsByProduct(
|
||||
// Verzug wegen einer fehlgeschlagenen Zahlung haelt sie dagegen: Stripe
|
||||
// versucht es erneut, und meist klappt es.
|
||||
NOT: { status: "IM_VERZUG", payments: { some: { reversedAt: { not: null } } } },
|
||||
offer: { honeyProductId: { in: ids }, cycle: "VIERTELJAEHRLICH" },
|
||||
offer: { cycle: "VIERTELJAEHRLICH", honeyJars: { gt: 0 } },
|
||||
},
|
||||
select: { offer: { select: { honeyProductId: true, honeyJars: true } } },
|
||||
select: { offer: { select: { honeyJars: true } } },
|
||||
});
|
||||
for (const subscription of quarterly) add(subscription.offer.honeyProductId, subscription.offer.honeyJars);
|
||||
const quarterlyJars = quarterly.reduce((sum, subscription) => sum + subscription.offer.honeyJars, 0);
|
||||
|
||||
const today = startOfUtcDay(now);
|
||||
const windows = await db.harvestWindow.findMany({
|
||||
where: { endsOn: { gte: today }, offer: { honeyProductId: { in: ids }, cycle: "ERNTE" } },
|
||||
select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true, offer: { select: { honeyProductId: true } } },
|
||||
where: { endsOn: { gte: today }, offer: { cycle: "ERNTE" } },
|
||||
select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true },
|
||||
});
|
||||
if (windows.length === 0) return result;
|
||||
if (windows.length === 0) return quarterlyJars;
|
||||
|
||||
// Alle in den offenen Fenstern bereits angelegten Lieferungen in einer
|
||||
// Abfrage; die Zuordnung zum Fenster geschieht hier im Speicher.
|
||||
@@ -74,14 +77,48 @@ export async function reservedJarsByProduct(
|
||||
},
|
||||
select: { jars: true, dueAt: true, subscription: { select: { offerId: true } } },
|
||||
});
|
||||
for (const window of windows) {
|
||||
const harvestJars = windows.reduce((sum, window) => {
|
||||
const until = endOfUtcDay(window.endsOn);
|
||||
const delivered = deliveries
|
||||
.filter((d) => d.subscription.offerId === window.offerId && d.dueAt >= window.startsOn && d.dueAt <= until)
|
||||
.reduce((sum, d) => sum + d.jars, 0);
|
||||
add(window.offer.honeyProductId, Math.max(0, window.plannedJars - delivered));
|
||||
}
|
||||
return result;
|
||||
.reduce((jars, d) => jars + d.jars, 0);
|
||||
return sum + Math.max(0, window.plannedJars - delivered);
|
||||
}, 0);
|
||||
return quarterlyJars + harvestJars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abo-Reservierung je Honigprodukt (nur Produkte mit Reservierung stehen in
|
||||
* der Map). `stock` kann mitgegeben werden, wenn der Aufrufer den Rohbestand
|
||||
* ohnehin geladen hat (honey-candidates.ts); sonst wird er geladen — aber nur,
|
||||
* wenn es ueberhaupt Abo-Bedarf gibt.
|
||||
*/
|
||||
export async function subscriptionReservationByProduct(
|
||||
now: Date = new Date(),
|
||||
db: Db = prisma,
|
||||
stock?: readonly HoneyCandidate[]
|
||||
): Promise<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> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { personalizationSchema, startDateRange, toIsoDate } from "@/lib/certific
|
||||
import { checkCertificateFits } from "@/lib/certificate/layout";
|
||||
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
||||
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
|
||||
import { pickHoneyProduct, withTakenStock, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
/**
|
||||
@@ -13,12 +14,20 @@ import type { Locale } from "@/i18n/config";
|
||||
* - MAIN: die Patenschaft (Traegerprodukt, Regelsteuersatz), traegt die
|
||||
* Personalisierung. Keine Bestandsreservierung — eine Urkunde ist nie
|
||||
* ausverkauft.
|
||||
* - HONEY: der Honiganteil als Position des verknuepften Honigprodukts zum
|
||||
* - HONEY: der Honiganteil als Position eines Honigprodukts zum
|
||||
* Honig-Steuersatz. Dadurch reserviert der Checkout den Honig wie jedes
|
||||
* andere Glas, die Rechnung zeigt die Steueraufteilung, und die
|
||||
* ApiCore-Abbuchung bei Zahlung (recordOrderSale) bucht den Honig ohne
|
||||
* Sonderweg aus dem Bestand.
|
||||
*
|
||||
* Welcher Honig das ist, entscheidet die automatische Wahl im Moment der
|
||||
* Bestellung (honey-selection.ts: nie cremig, neueste Ernte, genug freier
|
||||
* Bestand). Mehrere Pakete in einem Warenkorb werden nacheinander gewaehlt,
|
||||
* jedes gegen den Bestand, den die einzeln gekauften Glaeser und die Pakete
|
||||
* davor uebrig lassen — reicht der erste Honig nicht fuer alle, bekommt das
|
||||
* naechste Paket den naechsten. Das ist einfacher als ein Paket auf zwei
|
||||
* Sorten aufzuteilen, und jedes Paket bleibt sortenrein.
|
||||
*
|
||||
* Bewusst als reine Funktion ohne Datenbankzugriff: der Checkout laedt alles
|
||||
* vorher, und diese Regeln lassen sich ohne Datenbank testen.
|
||||
*/
|
||||
@@ -76,6 +85,11 @@ export interface ExpandInput {
|
||||
products: ReadonlyMap<string, CheckoutProduct>;
|
||||
/** Angebote nach Traegerprodukt-ID. */
|
||||
offers: ReadonlyMap<string, SponsorshipOfferView>;
|
||||
/**
|
||||
* Honig-Kandidaten mit FREIEM Bestand (honey-candidates.ts). Die Produkte
|
||||
* der gewaehlten Kandidaten muessen in `products` stehen.
|
||||
*/
|
||||
honeyCandidates: readonly HoneyCandidate[];
|
||||
vatRateFor: (product: CheckoutProduct) => number;
|
||||
locale: Locale;
|
||||
now: Date;
|
||||
@@ -90,12 +104,31 @@ export function startDateWindow(now: Date): { min: string; max: string } {
|
||||
return startDateRange(toIsoDate(now));
|
||||
}
|
||||
|
||||
/**
|
||||
* Glaeser, die der Warenkorb schon einzeln kauft, je Produkt. Sie gehen vom
|
||||
* freien Bestand ab, bevor ein Paket seinen Honig waehlt — sonst fiele ein
|
||||
* Paket auf denselben Honig, und die Bestandspruefung im Checkout lehnte
|
||||
* die ganze Bestellung ab, obwohl ein anderer Honig gereicht haette.
|
||||
*/
|
||||
function individuallyTakenJars(input: ExpandInput): Map<string, number> {
|
||||
const taken = new Map<string, number>();
|
||||
for (const cartItem of input.cartItems) {
|
||||
const product = input.products.get(cartItem.productId);
|
||||
if (!product || product.category === "PATENSCHAFT") continue;
|
||||
taken.set(product.id, (taken.get(product.id) ?? 0) + cartItem.quantity);
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
export function expandCheckoutItems(input: ExpandInput): ExpandResult {
|
||||
const items: OrderItemDraft[] = [];
|
||||
const reserve: ReservationLine[] = [];
|
||||
let physicalQuantity = 0;
|
||||
let hasSponsorship = false;
|
||||
const window = startDateWindow(input.now);
|
||||
// Verplante Glaeser je Honig: erst die einzeln gekauften, dann wachsend mit
|
||||
// jedem Paket (lokaler Zaehler dieser einen Berechnung).
|
||||
const takenJars = individuallyTakenJars(input);
|
||||
|
||||
for (const cartItem of input.cartItems) {
|
||||
const product = input.products.get(cartItem.productId);
|
||||
@@ -169,10 +202,12 @@ export function expandCheckoutItems(input: ExpandInput): ExpandResult {
|
||||
});
|
||||
|
||||
if (offer.honeyJars > 0) {
|
||||
const honey = offer.honeyProduct ? input.products.get(offer.honeyProduct.id) : undefined;
|
||||
const picked = pickHoneyProduct(withTakenStock(input.honeyCandidates, takenJars), offer.honeyJars);
|
||||
const honey = picked ? input.products.get(picked.id) : undefined;
|
||||
if (!honey || !honey.isActive) {
|
||||
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
|
||||
return { ok: false, error: "Für dieses Paket ist gerade nicht genug Honig da." };
|
||||
}
|
||||
takenJars.set(honey.id, (takenJars.get(honey.id) ?? 0) + offer.honeyJars);
|
||||
items.push({
|
||||
productId: honey.id,
|
||||
batchId: honey.batchId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { certificateRelativePath } from "@/lib/sponsorship/storage";
|
||||
import { retryDelayMs, MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue";
|
||||
import { isOfferAvailable, type SponsorshipOfferView } from "@/lib/sponsorship/offers";
|
||||
import { expandCheckoutItems, startDateWindow, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
|
||||
import type { HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
||||
|
||||
describe("Patenschaftsnummer", () => {
|
||||
it("ist fuenfstellig fortlaufend je Jahr", () => {
|
||||
@@ -44,19 +45,18 @@ describe("Bestellbarkeit eines Angebots", () => {
|
||||
kind: "EINMALIG" as const,
|
||||
hasCarrierProduct: true,
|
||||
honeyJars: 3,
|
||||
honeyProductActive: true,
|
||||
freeJars: 3,
|
||||
hasEligibleHoney: true,
|
||||
maxActiveSubscriptions: null,
|
||||
activeSubscriptions: 0,
|
||||
};
|
||||
|
||||
it("braucht genug FREIEN Honig fuer ein Paket", () => {
|
||||
it("braucht einen automatisch waehlbaren Honig mit genug freiem Bestand", () => {
|
||||
expect(isOfferAvailable(base)).toBe(true);
|
||||
expect(isOfferAvailable({ ...base, freeJars: 2 })).toBe(false);
|
||||
expect(isOfferAvailable({ ...base, hasEligibleHoney: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("ist ohne Honigprodukt oder ohne Traegerprodukt nicht bestellbar", () => {
|
||||
expect(isOfferAvailable({ ...base, honeyProductActive: null })).toBe(false);
|
||||
it("braucht ohne Glaeser keinen Honig, aber immer ein Traegerprodukt", () => {
|
||||
expect(isOfferAvailable({ ...base, honeyJars: 0, hasEligibleHoney: false })).toBe(true);
|
||||
expect(isOfferAvailable({ ...base, hasCarrierProduct: false })).toBe(false);
|
||||
});
|
||||
|
||||
@@ -105,6 +105,20 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => {
|
||||
honeyProduct: { id: "honey", name: "Waldhonig 500 g", weightGrams: 500 },
|
||||
isAvailable: true,
|
||||
};
|
||||
const older: CheckoutProduct = { ...honey, id: "older", name: "Rapshonig 2025 500 g", batchId: "batch-0" };
|
||||
const honeyCandidate = (overrides: Partial<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 = {
|
||||
beeName: "Summsi",
|
||||
recipientName: "Marie-Luise Schmidt",
|
||||
@@ -117,9 +131,11 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => {
|
||||
cartItems,
|
||||
products: new Map([
|
||||
["honey", honey],
|
||||
["older", older],
|
||||
["carrier", carrier],
|
||||
]),
|
||||
offers: new Map([["carrier", offer]]),
|
||||
honeyCandidates: candidates,
|
||||
vatRateFor: (product: CheckoutProduct) => (product.category === "HONIG" ? 7 : 19),
|
||||
locale: "de" as const,
|
||||
now,
|
||||
@@ -171,6 +187,64 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("nimmt automatisch nicht-cremigen Honig der neuesten Ernte, nicht den am Angebot angezeigten", () => {
|
||||
const result = expandCheckoutItems({
|
||||
...input([{ productId: "carrier", quantity: 1, personalization }]),
|
||||
honeyCandidates: [honeyCandidate({ id: "fein", consistency: "feincremig", harvestYear: 2027 }), ...candidates],
|
||||
offers: new Map([["carrier", { ...offer, honeyProduct: { id: "older", name: older.name, weightGrams: 500 } }]]),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.items.find((item) => item.sponsorshipRole === "HONEY")).toMatchObject({
|
||||
productId: "honey",
|
||||
batchId: "batch-1",
|
||||
productName: "Waldhonig 500 g – Wabenfreund",
|
||||
});
|
||||
});
|
||||
|
||||
it("waehlt mehrere Pakete nacheinander und weicht aus, wenn der erste Honig nicht fuer alle reicht", () => {
|
||||
const result = expandCheckoutItems({
|
||||
...input([
|
||||
{ productId: "carrier", quantity: 1, personalization },
|
||||
{ productId: "carrier", quantity: 1, personalization: { ...personalization, beeName: "Brummi" } },
|
||||
]),
|
||||
honeyCandidates: [honeyCandidate({ id: "honey", freeStock: 4 }), honeyCandidate({ id: "older", harvestYear: 2025, freeStock: 10 })],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.items.filter((item) => item.sponsorshipRole === "HONEY").map((item) => item.productId)).toEqual(["honey", "older"]);
|
||||
expect(result.reserve).toEqual([
|
||||
{ productId: "honey", quantity: 3, productName: "Waldhonig 500 g" },
|
||||
{ productId: "older", quantity: 3, productName: "Rapshonig 2025 500 g" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rechnet einzeln gekaufte Glaeser im selben Warenkorb mit ein", () => {
|
||||
const result = expandCheckoutItems({
|
||||
...input([
|
||||
{ productId: "honey", quantity: 2 },
|
||||
{ productId: "carrier", quantity: 1, personalization },
|
||||
]),
|
||||
honeyCandidates: [honeyCandidate({ id: "honey", freeStock: 4 }), honeyCandidate({ id: "older", harvestYear: 2025, freeStock: 10 })],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.items.find((item) => item.sponsorshipRole === "HONEY")?.productId).toBe("older");
|
||||
});
|
||||
|
||||
it("verkauft kein Paket, wenn kein passender Honig reicht (z. B. nur cremiger da)", () => {
|
||||
const onlyCreamy = expandCheckoutItems({
|
||||
...input([{ productId: "carrier", quantity: 1, personalization }]),
|
||||
honeyCandidates: [honeyCandidate({ id: "honey", consistency: "cremig" })],
|
||||
});
|
||||
expect(onlyCreamy).toEqual({ ok: false, error: "Für dieses Paket ist gerade nicht genug Honig da." });
|
||||
const tooLittle = expandCheckoutItems({
|
||||
...input([{ productId: "carrier", quantity: 1, personalization }]),
|
||||
honeyCandidates: [honeyCandidate({ id: "honey", freeStock: 2 })],
|
||||
});
|
||||
expect(tooLittle.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("verkauft kein Paket, dessen Angebot nicht bestellbar ist", () => {
|
||||
const result = expandCheckoutItems({
|
||||
...input([{ productId: "carrier", quantity: 1, personalization }]),
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asStringArray } from "@/lib/json-array";
|
||||
import { availableForSaleByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||
import { pickHoneyProduct, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
||||
import { quotaSubscriptionWhere } from "@/lib/sponsorship-subscription/pending";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import type {
|
||||
@@ -14,11 +15,17 @@ import type {
|
||||
* Warenkorb und Checkout sie brauchen: lokalisiert, mit Leistungsliste als
|
||||
* Array und mit der Aussage, ob das Angebot gerade bestellbar ist.
|
||||
*
|
||||
* "Bestellbar" haengt am Honig: ein physisches Paket ohne genug Glaeser des
|
||||
* verknuepften Honigprodukts darf nicht verkauft werden — sonst bekaeme die
|
||||
* Kundschaft eine Urkunde ohne den bezahlten Honig. Gezaehlt wird der FREIE
|
||||
* Bestand, also abzueglich der fuer Abos reservierten Glaeser. Bei Abos
|
||||
* zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions).
|
||||
* "Bestellbar" haengt am Honig: ein Angebot mit Glaesern braucht einen Honig,
|
||||
* den die automatische Wahl nehmen darf (nie cremig, neueste Ernte, genug
|
||||
* FREIER Bestand, also abzueglich der fuer Abos reservierten Glaeser —
|
||||
* lib/sponsorship/honey-selection.ts). Gibt es keinen, ist das Angebot nicht
|
||||
* bestellbar — sonst bekaeme die Kundschaft eine Urkunde ohne den bezahlten
|
||||
* Honig. Bei Abos zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions).
|
||||
*
|
||||
* Einen fest verknuepften Honig je Angebot gibt es nicht mehr: die Spalte
|
||||
* SponsorshipOffer.honeyProductId bleibt (nullable) im Schema stehen, um
|
||||
* keine Migration auf der laufenden Produktion zu brauchen, wird aber weder
|
||||
* gelesen noch geschrieben.
|
||||
*/
|
||||
export interface SponsorshipOfferView {
|
||||
id: string;
|
||||
@@ -37,26 +44,26 @@ export interface SponsorshipOfferView {
|
||||
medalTier: MedalTier;
|
||||
/** Traegerprodukt (nur EINMALIG). */
|
||||
productId: string | null;
|
||||
/**
|
||||
* Der Honig, den die automatische Wahl GERADE fuer dieses Angebot nimmt
|
||||
* (nur zur Anzeige und Pruefung; der Checkout waehlt bei der Bestellung neu,
|
||||
* weil mehrere Pakete im Warenkorb gemeinsam reichen muessen).
|
||||
*/
|
||||
honeyProduct: { id: string; name: string; weightGrams: number } | null;
|
||||
/** false = derzeit nicht bestellbar (Honig fehlt, Kontingent voll, Angebot aus). */
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
const offerInclude = {
|
||||
honeyProduct: { select: { id: true, name: true, weightGrams: true, isActive: true } },
|
||||
} as const;
|
||||
|
||||
async function loadOfferRows(where: { isActive?: boolean; kind?: SponsorshipOfferKind; slug?: string; productId?: { in: string[] } }) {
|
||||
return prisma.sponsorshipOffer.findMany({
|
||||
where,
|
||||
include: offerInclude,
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
type OfferRow = Awaited<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 featuresEn = asStringArray(row.featuresEn);
|
||||
return {
|
||||
@@ -74,16 +81,14 @@ function localize(row: OfferRow, locale: Locale, isAvailable: boolean): Sponsors
|
||||
cycle: row.cycle,
|
||||
medalTier: row.medalTier,
|
||||
productId: row.productId,
|
||||
honeyProduct: row.honeyProduct
|
||||
? { id: row.honeyProduct.id, name: row.honeyProduct.name, weightGrams: row.honeyProduct.weightGrams }
|
||||
: null,
|
||||
honeyProduct: honey ? { id: honey.id, name: honey.name, weightGrams: honey.weightGrams } : null,
|
||||
isAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
async function freeJarsByProduct(rows: OfferRow[]): Promise<Map<string, number>> {
|
||||
const ids = [...new Set(rows.map((row) => row.honeyProduct?.id).filter((id): id is string => Boolean(id)))];
|
||||
return availableForSaleByProduct(ids);
|
||||
/** Kandidaten nur laden, wenn ein Angebot ueberhaupt Honig braucht (rein digitale Angebote nicht). */
|
||||
async function honeyCandidatesFor(rows: OfferRow[]): Promise<HoneyCandidate[]> {
|
||||
return rows.some((row) => row.honeyJars > 0) ? loadHoneyCandidates() : [];
|
||||
}
|
||||
|
||||
async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, number>> {
|
||||
@@ -105,17 +110,14 @@ export function isOfferAvailable(input: {
|
||||
kind: SponsorshipOfferKind;
|
||||
hasCarrierProduct: boolean;
|
||||
honeyJars: number;
|
||||
honeyProductActive: boolean | null;
|
||||
freeJars: number;
|
||||
/** Die automatische Honigwahl hat fuer honeyJars Glaeser einen Honig gefunden. */
|
||||
hasEligibleHoney: boolean;
|
||||
maxActiveSubscriptions: number | null;
|
||||
activeSubscriptions: number;
|
||||
}): boolean {
|
||||
if (!input.isActive) return false;
|
||||
if (input.kind === "EINMALIG" && !input.hasCarrierProduct) return false;
|
||||
if (input.honeyJars > 0) {
|
||||
if (!input.honeyProductActive) return false;
|
||||
if (input.freeJars < input.honeyJars) return false;
|
||||
}
|
||||
if (input.honeyJars > 0 && !input.hasEligibleHoney) return false;
|
||||
if (input.kind === "ABO" && input.maxActiveSubscriptions !== null) {
|
||||
if (input.activeSubscriptions >= input.maxActiveSubscriptions) return false;
|
||||
}
|
||||
@@ -123,23 +125,24 @@ export function isOfferAvailable(input: {
|
||||
}
|
||||
|
||||
async function toViews(rows: OfferRow[], locale: Locale): Promise<SponsorshipOfferView[]> {
|
||||
const [jars, subscriptionCounts] = await Promise.all([freeJarsByProduct(rows), activeSubscriptionCounts(rows)]);
|
||||
return rows.map((row) =>
|
||||
localize(
|
||||
const [candidates, subscriptionCounts] = await Promise.all([honeyCandidatesFor(rows), activeSubscriptionCounts(rows)]);
|
||||
return rows.map((row) => {
|
||||
const honey = row.honeyJars > 0 ? pickHoneyProduct(candidates, row.honeyJars) : null;
|
||||
return localize(
|
||||
row,
|
||||
locale,
|
||||
honey,
|
||||
isOfferAvailable({
|
||||
isActive: row.isActive,
|
||||
kind: row.kind,
|
||||
hasCarrierProduct: row.productId !== null,
|
||||
honeyJars: row.honeyJars,
|
||||
honeyProductActive: row.honeyProduct ? row.honeyProduct.isActive : null,
|
||||
freeJars: row.honeyProduct ? (jars.get(row.honeyProduct.id) ?? 0) : 0,
|
||||
hasEligibleHoney: honey !== null,
|
||||
maxActiveSubscriptions: row.maxActiveSubscriptions,
|
||||
activeSubscriptions: subscriptionCounts.get(row.id) ?? 0,
|
||||
})
|
||||
)
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getActiveOffers(locale: Locale, kind?: SponsorshipOfferKind): Promise<SponsorshipOfferView[]> {
|
||||
|
||||
Reference in new issue
Block a user