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

@@ -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)])]);
+43 -18
View File
@@ -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 },
+67 -30
View File
@@ -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> {
+38 -3
View File
@@ -3,6 +3,7 @@ import { personalizationSchema, startDateRange, toIsoDate } from "@/lib/certific
import { checkCertificateFits } from "@/lib/certificate/layout";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
import { pickHoneyProduct, withTakenStock, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
import type { Locale } from "@/i18n/config";
/**
@@ -13,12 +14,20 @@ import type { Locale } from "@/i18n/config";
* - MAIN: die Patenschaft (Traegerprodukt, Regelsteuersatz), traegt die
* Personalisierung. Keine Bestandsreservierung — eine Urkunde ist nie
* ausverkauft.
* - HONEY: der Honiganteil als Position des verknuepften Honigprodukts zum
* - HONEY: der Honiganteil als Position eines Honigprodukts zum
* Honig-Steuersatz. Dadurch reserviert der Checkout den Honig wie jedes
* andere Glas, die Rechnung zeigt die Steueraufteilung, und die
* ApiCore-Abbuchung bei Zahlung (recordOrderSale) bucht den Honig ohne
* Sonderweg aus dem Bestand.
*
* Welcher Honig das ist, entscheidet die automatische Wahl im Moment der
* Bestellung (honey-selection.ts: nie cremig, neueste Ernte, genug freier
* Bestand). Mehrere Pakete in einem Warenkorb werden nacheinander gewaehlt,
* jedes gegen den Bestand, den die einzeln gekauften Glaeser und die Pakete
* davor uebrig lassen — reicht der erste Honig nicht fuer alle, bekommt das
* naechste Paket den naechsten. Das ist einfacher als ein Paket auf zwei
* Sorten aufzuteilen, und jedes Paket bleibt sortenrein.
*
* Bewusst als reine Funktion ohne Datenbankzugriff: der Checkout laedt alles
* vorher, und diese Regeln lassen sich ohne Datenbank testen.
*/
@@ -76,6 +85,11 @@ export interface ExpandInput {
products: ReadonlyMap<string, CheckoutProduct>;
/** Angebote nach Traegerprodukt-ID. */
offers: ReadonlyMap<string, SponsorshipOfferView>;
/**
* Honig-Kandidaten mit FREIEM Bestand (honey-candidates.ts). Die Produkte
* der gewaehlten Kandidaten muessen in `products` stehen.
*/
honeyCandidates: readonly HoneyCandidate[];
vatRateFor: (product: CheckoutProduct) => number;
locale: Locale;
now: Date;
@@ -90,12 +104,31 @@ export function startDateWindow(now: Date): { min: string; max: string } {
return startDateRange(toIsoDate(now));
}
/**
* Glaeser, die der Warenkorb schon einzeln kauft, je Produkt. Sie gehen vom
* freien Bestand ab, bevor ein Paket seinen Honig waehlt — sonst fiele ein
* Paket auf denselben Honig, und die Bestandspruefung im Checkout lehnte
* die ganze Bestellung ab, obwohl ein anderer Honig gereicht haette.
*/
function individuallyTakenJars(input: ExpandInput): Map<string, number> {
const taken = new Map<string, number>();
for (const cartItem of input.cartItems) {
const product = input.products.get(cartItem.productId);
if (!product || product.category === "PATENSCHAFT") continue;
taken.set(product.id, (taken.get(product.id) ?? 0) + cartItem.quantity);
}
return taken;
}
export function expandCheckoutItems(input: ExpandInput): ExpandResult {
const items: OrderItemDraft[] = [];
const reserve: ReservationLine[] = [];
let physicalQuantity = 0;
let hasSponsorship = false;
const window = startDateWindow(input.now);
// Verplante Glaeser je Honig: erst die einzeln gekauften, dann wachsend mit
// jedem Paket (lokaler Zaehler dieser einen Berechnung).
const takenJars = individuallyTakenJars(input);
for (const cartItem of input.cartItems) {
const product = input.products.get(cartItem.productId);
@@ -169,10 +202,12 @@ export function expandCheckoutItems(input: ExpandInput): ExpandResult {
});
if (offer.honeyJars > 0) {
const honey = offer.honeyProduct ? input.products.get(offer.honeyProduct.id) : undefined;
const picked = pickHoneyProduct(withTakenStock(input.honeyCandidates, takenJars), offer.honeyJars);
const honey = picked ? input.products.get(picked.id) : undefined;
if (!honey || !honey.isActive) {
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
return { ok: false, error: "Für dieses Paket ist gerade nicht genug Honig da." };
}
takenJars.set(honey.id, (takenJars.get(honey.id) ?? 0) + offer.honeyJars);
items.push({
productId: honey.id,
batchId: honey.batchId,
+80 -6
View File
@@ -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 }]),
+27
View File
@@ -0,0 +1,27 @@
import { prisma } from "@/lib/prisma";
import type { HoneyCandidate } from "@/lib/sponsorship/honey-selection";
import { loadHoneyStock } from "@/lib/sponsorship/honey-stock";
import { freeStock, subscriptionReservationByProduct } from "@/lib/sponsorship-subscription/reservation";
/**
* Honig-Kandidaten fuer Pakete und neue Abos: alle aktiven HONIG-Produkte mit
* Erntejahr, Konsistenz, Lagerbestand und FREIEM Bestand (Lager minus
* Abo-Reservierung) — genau der Bestand, den auch der Checkout als
* verkaeuflich behandelt. Welcher davon in ein Paket kommt, entscheidet
* pickHoneyProduct (honey-selection.ts).
*
* Der Rohbestand wird nur einmal geladen und an die Reservierung
* weitergereicht: hoechstens vier Abfragen fuer die ganze Liste.
*/
type Db = NonNullable<Parameters<typeof subscriptionReservationByProduct>[1]>;
export async function loadHoneyCandidates(now: Date = new Date(), db: Db = prisma): Promise<HoneyCandidate[]> {
const stock = await loadHoneyStock(db);
if (stock.length === 0) return [];
const reserved = await subscriptionReservationByProduct(now, db, stock);
return stock.map((candidate) => ({
...candidate,
freeStock: freeStock(candidate.inventory, reserved.get(candidate.id) ?? 0),
}));
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Automatische Honigwahl fuer Pakete und Abos (Entscheidung des Inhabers):
* nie cremiger Honig, die neueste Ernte zuerst, bei gleich neuer Ernte der
* groessere freie Bestand — und nur, was fuer die Glaeser auch reicht.
*/
import { describe, expect, test } from "vitest";
import {
allocateReservedJars,
harvestYearOf,
honeyLabel,
isCreamy,
pickHoneyProduct,
rankHoneyCandidates,
withTakenStock,
type HoneyCandidate,
} from "./honey-selection";
function candidate(overrides: Partial<HoneyCandidate> & Pick<HoneyCandidate, "id">): HoneyCandidate {
return {
name: overrides.id,
weightGrams: 500,
harvestYear: 2026,
consistency: "flüssig",
inventory: 10,
freeStock: 10,
...overrides,
};
}
describe("isCreamy", () => {
test("erkennt jede Schreibweise von cremig", () => {
expect(isCreamy("cremig")).toBe(true);
expect(isCreamy("feincremig")).toBe(true);
expect(isCreamy("Feincremig")).toBe(true);
expect(isCreamy("CREMIG gerührt")).toBe(true);
});
test("laesst fluessigen und anderen Honig durch", () => {
expect(isCreamy("flüssig")).toBe(false);
expect(isCreamy("fest")).toBe(false);
expect(isCreamy("")).toBe(false);
});
});
describe("harvestYearOf", () => {
const createdAt = new Date("2024-03-01T10:00:00.000Z");
test("nimmt das Erntejahr des Produkts", () => {
expect(harvestYearOf({ harvest: { year: 2026 }, batch: { fillDate: new Date("2025-08-01"), harvest: { year: 2025 } }, createdAt })).toBe(2026);
});
test("faellt auf die Ernte der Charge, dann ihr Abfuelljahr, dann das Anlagejahr zurueck", () => {
expect(harvestYearOf({ harvest: null, batch: { fillDate: new Date("2025-08-01"), harvest: { year: 2025 } }, createdAt })).toBe(2025);
expect(harvestYearOf({ harvest: null, batch: { fillDate: new Date("2025-08-01T00:00:00.000Z"), harvest: null }, createdAt })).toBe(2025);
expect(harvestYearOf({ harvest: null, batch: null, createdAt })).toBe(2024);
});
});
describe("pickHoneyProduct", () => {
test("schliesst cremigen Honig aus, auch wenn er neuer ist und mehr Bestand hat", () => {
const picked = pickHoneyProduct(
[
candidate({ id: "fein", consistency: "feincremig", harvestYear: 2027, freeStock: 99 }),
candidate({ id: "wald", consistency: "flüssig", harvestYear: 2025, freeStock: 3 }),
],
2
);
expect(picked?.id).toBe("wald");
});
test("die neueste Ernte gewinnt vor dem groesseren Bestand", () => {
const picked = pickHoneyProduct(
[candidate({ id: "alt", harvestYear: 2025, freeStock: 50 }), candidate({ id: "neu", harvestYear: 2026, freeStock: 4 })],
3
);
expect(picked?.id).toBe("neu");
});
test("bei gleich neuer Ernte entscheidet der groessere freie Bestand", () => {
const picked = pickHoneyProduct(
[candidate({ id: "linde", freeStock: 6, inventory: 20 }), candidate({ id: "raps", freeStock: 9, inventory: 9 })],
1
);
expect(picked?.id).toBe("raps");
});
test("ueberspringt Honig, dessen freier Bestand nicht fuer alle Glaeser reicht", () => {
const picked = pickHoneyProduct(
[candidate({ id: "neu", harvestYear: 2026, freeStock: 2 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 3 })],
3
);
expect(picked?.id).toBe("alt");
});
test("ohne passenden Honig null — dann ist das Angebot nicht bestellbar", () => {
expect(pickHoneyProduct([], 1)).toBeNull();
expect(pickHoneyProduct([candidate({ id: "fein", consistency: "cremig" })], 1)).toBeNull();
expect(pickHoneyProduct([candidate({ id: "knapp", freeStock: 2 })], 3)).toBeNull();
});
test("bleibt bei Gleichstand stabil (nach id)", () => {
const a = candidate({ id: "a" });
const b = candidate({ id: "b" });
expect(pickHoneyProduct([b, a], 1)?.id).toBe("a");
});
});
describe("rankHoneyCandidates", () => {
test("sortiert ohne die Eingabe zu veraendern und laesst cremigen Honig weg", () => {
const input = [
candidate({ id: "alt", harvestYear: 2024 }),
candidate({ id: "fein", consistency: "feincremig", harvestYear: 2027 }),
candidate({ id: "neu", harvestYear: 2026 }),
];
expect(rankHoneyCandidates(input).map((c) => c.id)).toEqual(["neu", "alt"]);
expect(input.map((c) => c.id)).toEqual(["alt", "fein", "neu"]);
});
});
describe("withTakenStock", () => {
test("zieht bereits verplante Glaeser vom freien Bestand ab, ohne die Eingabe zu veraendern", () => {
const input = [candidate({ id: "wald", freeStock: 5 }), candidate({ id: "raps", freeStock: 4 })];
const result = withTakenStock(input, new Map([["wald", 3]]));
expect(result.map((c) => [c.id, c.freeStock])).toEqual([
["wald", 2],
["raps", 4],
]);
expect(input[0].freeStock).toBe(5);
});
test("nacheinander gewaehlte Pakete weichen auf den naechsten Honig aus, wenn der erste nicht mehr reicht", () => {
const candidates = [candidate({ id: "neu", harvestYear: 2026, freeStock: 4 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 10 })];
const first = pickHoneyProduct(candidates, 3);
const second = pickHoneyProduct(withTakenStock(candidates, new Map([["neu", 3]])), 3);
expect(first?.id).toBe("neu");
expect(second?.id).toBe("alt");
});
});
describe("allocateReservedJars", () => {
test("legt alle Abo-Glaeser auf den Honig, der fuer alle reicht", () => {
const result = allocateReservedJars(
[candidate({ id: "neu", harvestYear: 2026, freeStock: 5 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 30 })],
8
);
expect(Object.fromEntries(result)).toEqual({ alt: 8 });
});
test("reicht keiner allein, wird in der Rangfolge aufgefuellt", () => {
const result = allocateReservedJars(
[candidate({ id: "neu", harvestYear: 2026, freeStock: 5 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 4 })],
8
);
expect(Object.fromEntries(result)).toEqual({ neu: 5, alt: 3 });
});
test("ein Fehlbestand landet beim erstgewaehlten Honig, damit er sichtbar wird", () => {
const result = allocateReservedJars(
[candidate({ id: "neu", harvestYear: 2026, freeStock: 2 }), candidate({ id: "alt", harvestYear: 2025, freeStock: 1 })],
6
);
expect(Object.fromEntries(result)).toEqual({ neu: 5, alt: 1 });
});
test("ohne Bedarf oder ohne fluessigen Honig keine Reservierung", () => {
expect(allocateReservedJars([candidate({ id: "wald" })], 0).size).toBe(0);
expect(allocateReservedJars([candidate({ id: "fein", consistency: "cremig" })], 4).size).toBe(0);
});
});
describe("honeyLabel", () => {
test("haengt das Erntejahr an, ausser es steht schon im Namen", () => {
expect(honeyLabel({ name: "Waldhonig", harvestYear: 2026 })).toBe("Waldhonig 2026");
expect(honeyLabel({ name: "Waldhonig 2026", harvestYear: 2026 })).toBe("Waldhonig 2026");
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Automatische Honigwahl fuer Patenschaftspakete und Abos.
*
* Entscheidung des Inhabers: Pakete und Abos enthalten NIE cremigen Honig —
* der ist beim Packen deutlich mehr Arbeit. Welcher Honig verschickt wird,
* waehlt deshalb nicht der Admin je Angebot aus, sondern diese Regel, und
* zwar jedes Mal neu (beim Anzeigen, im Checkout, bei der Abo-Reservierung
* und beim Anlegen einer Abo-Lieferung):
*
* 1. nur Honig, dessen Konsistenz nicht "cremig" enthaelt ("feincremig"
* faellt also auch raus, "flüssig" ist erlaubt),
* 2. nur Honig mit genug Bestand fuer die benoetigten Glaeser,
* 3. davon die neueste Ernte,
* 4. bei gleich neuer Ernte der groessere Bestand (dann nach id, damit
* die Wahl bei Gleichstand nicht zufaellig springt).
*
* Bewusst rein (ohne Datenbank): dieselbe Regel laeuft an vier Stellen und
* muss ueberall gleich entscheiden. Die Kandidaten laedt honey-stock.ts
* (Rohbestand) bzw. honey-candidates.ts (freier Bestand).
*/
export interface HoneyCandidate {
id: string;
name: string;
weightGrams: number;
/** Jahr der Ernte, siehe harvestYearOf. */
harvestYear: number;
consistency: string;
/** Lagerbestand (Inventory.quantity), 0 ohne Lagerzeile. */
inventory: number;
/**
* Bestand, der fuer den jeweiligen Zweck zur Verfuegung steht. Fuer Pakete
* und neue Abos ist das der FREIE Bestand (Lager minus Abo-Reservierung);
* fuer die Abo-Reservierung selbst und fuer Abo-Lieferungen der
* Lagerbestand — die Reservierung ist ja genau fuer sie da.
*/
freeStock: number;
}
const CREAMY = /cremig/i;
export function isCreamy(consistency: string): boolean {
return CREAMY.test(consistency);
}
export interface HarvestYearSource {
harvest: { year: number } | null;
batch: { fillDate: Date; harvest: { year: number } | null } | null;
createdAt: Date;
}
/**
* Erntejahr eines Honigprodukts. Normalfall ist Product.harvestId → Harvest.year;
* fehlt die Verknuepfung (z. B. von Hand angelegtes Produkt), zaehlt die
* Ernte der Charge, dann ihr Abfuelljahr und zuletzt das Anlagejahr des
* Produkts — irgendein Jahr braucht die Rangfolge, und ein Produkt ohne
* Ernte soll nicht automatisch als "neueste" gelten.
*/
export function harvestYearOf(product: HarvestYearSource): number {
if (product.harvest) return product.harvest.year;
if (product.batch?.harvest) return product.batch.harvest.year;
if (product.batch) return product.batch.fillDate.getUTCFullYear();
return product.createdAt.getUTCFullYear();
}
/** Anzeige fuer den Admin: "<Name> <Jahr>", ohne das Jahr zu verdoppeln, wenn es schon im Namen steht. */
export function honeyLabel(candidate: Pick<HoneyCandidate, "name" | "harvestYear">): string {
const year = String(candidate.harvestYear);
return candidate.name.includes(year) ? candidate.name : `${candidate.name} ${year}`;
}
function compareCandidates(a: HoneyCandidate, b: HoneyCandidate): number {
if (a.harvestYear !== b.harvestYear) return b.harvestYear - a.harvestYear;
if (a.freeStock !== b.freeStock) return b.freeStock - a.freeStock;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
/** Nicht-cremiger Honig in der Rangfolge der Regel (ohne Bestandspruefung), als neue Liste. */
export function rankHoneyCandidates(candidates: readonly HoneyCandidate[]): HoneyCandidate[] {
return candidates.filter((candidate) => !isCreamy(candidate.consistency)).sort(compareCandidates);
}
/** Der Honig, den die Regel fuer `jarsNeeded` Glaeser waehlt — oder null, wenn keiner reicht. */
export function pickHoneyProduct(candidates: readonly HoneyCandidate[], jarsNeeded: number): HoneyCandidate | null {
return rankHoneyCandidates(candidates).find((candidate) => candidate.freeStock >= jarsNeeded) ?? null;
}
/**
* Zieht bereits verplante Glaeser vom freien Bestand ab (neue Liste). Damit
* waehlt der Checkout mehrere Pakete nacheinander: jedes gegen das, was die
* vorherigen Pakete und die einzeln gekauften Glaeser uebrig lassen — reicht
* der erste Honig nicht mehr, weicht das naechste Paket auf den naechsten aus.
*/
export function withTakenStock(
candidates: readonly HoneyCandidate[],
taken: ReadonlyMap<string, number>
): HoneyCandidate[] {
return candidates.map((candidate) => {
const jars = taken.get(candidate.id) ?? 0;
return jars === 0 ? candidate : { ...candidate, freeStock: candidate.freeStock - jars };
});
}
/**
* Verteilt die von Abos beanspruchten Glaeser auf den Honig (Bestand =
* Lagerbestand, ohne Reservierung). Abos haben keinen festen Honig mehr; ihre
* Glaeser liegen auf dem, was die Regel fuer sie waehlen wuerde:
*
* - Reicht ein Honig fuer alle, liegt alles auf ihm (so wenig Honigsorten
* wie moeglich gesperrt).
* - Sonst wird in der Rangfolge aufgefuellt.
* - Bleibt dann noch etwas uebrig (echter Fehlbestand), kommt es auf den
* erstgewaehlten Honig: dort ist der freie Bestand dann 0, und die
* Deckungsanzeige im Admin zeigt die Luecke, statt sie zu verschweigen.
*
* Ohne nicht-cremigen Honig bleibt die Map leer — es gibt dann nichts, was
* fuer Abos zurueckgehalten werden koennte.
*/
export function allocateReservedJars(candidates: readonly HoneyCandidate[], totalJars: number): Map<string, number> {
if (totalJars <= 0) return new Map();
const single = pickHoneyProduct(candidates, totalJars);
if (single) return new Map([[single.id, totalJars]]);
const ranked = rankHoneyCandidates(candidates);
if (ranked.length === 0) return new Map();
const allocation = new Map<string, number>();
let remaining = totalJars;
for (const candidate of ranked) {
const jars = Math.min(remaining, Math.max(0, candidate.freeStock));
if (jars > 0) allocation.set(candidate.id, jars);
remaining -= jars;
if (remaining === 0) break;
}
if (remaining > 0) allocation.set(ranked[0].id, (allocation.get(ranked[0].id) ?? 0) + remaining);
return allocation;
}
+46
View File
@@ -0,0 +1,46 @@
import { prisma } from "@/lib/prisma";
import { harvestYearOf, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
/**
* Honig-Kandidaten mit ROHEM Lagerbestand (freeStock = Lagerbestand, ohne
* Abo-Reservierung): Grundlage fuer die Abo-Reservierung selbst und fuer
* Abo-Lieferungen. Den freien Bestand fuer Pakete liefert honey-candidates.ts.
*
* Eigene Datei, damit reservation.ts den Rohbestand laden kann, ohne dass
* honey-candidates.ts (das seinerseits reservation.ts braucht) einen
* Import-Kreis bildet.
*
* Geladen werden alle aktiven HONIG-Produkte, auch cremige: die Regel in
* honey-selection.ts sortiert sie aus, und der Admin kann so sehen, was es
* gibt. Eine Abfrage — eine Imkerei hat eine Handvoll Honigsorten.
*/
export type HoneyStockDb = Pick<typeof prisma, "product">;
export async function loadHoneyStock(db: HoneyStockDb = prisma): Promise<HoneyCandidate[]> {
const products = await db.product.findMany({
where: { category: "HONIG", isActive: true },
select: {
id: true,
name: true,
weightGrams: true,
consistency: true,
createdAt: true,
inventory: { select: { quantity: true } },
harvest: { select: { year: true } },
batch: { select: { fillDate: true, harvest: { select: { year: true } } } },
},
});
return products.map((product) => {
const inventory = Math.max(0, product.inventory?.quantity ?? 0);
return {
id: product.id,
name: product.name,
weightGrams: product.weightGrams,
harvestYear: harvestYearOf(product),
consistency: product.consistency,
inventory,
freeStock: inventory,
};
});
}
+34 -31
View File
@@ -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[]> {