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

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

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

No files matched your search

+33 -19
View File
@@ -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);
}
+64 -63
View File
@@ -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);
});
});
+1 -1
View File
@@ -164,7 +164,7 @@ export default async function AdminSponsorshipSubscriptionsPage(props: PageProps
<div className="mt-10 grid gap-6 lg:grid-cols-5">
<div className="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>
);
}
+28 -22
View File
@@ -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>