fix: Ernte-Abos reservieren das naechste Fenster nach laufenden Abos
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m10s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m38s
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m14s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 12m1s

Bedarf je Erntefenster = max(geplante Glaeser, laufende Abos x Glaeser),
und nur das naechste Fenster je Angebot. Vorher hielten alle kuenftigen
Fenster ihre geplante Menge sofort fest, und ohne Planung blieben neue
Ernte-Abos unreserviert.

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 19:25:18 +02:00
1 parent c4b0212004
commit 68fe297da6
2 files changed
+63 -10

No files matched your search

@@ -35,14 +35,21 @@ function productRow(product: FakeProduct) {
function fakeDb(data: {
quarterly?: { offer: { honeyJars: number } }[];
windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number }[];
windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number; offer?: { honeyJars: number } }[];
deliveries?: { jars: number; dueAt: Date; subscription: { offerId: string } }[];
/** Laufende Abos je Ernte-Angebot (groupBy). */
ernteActive?: { offerId: string; count: number }[];
products?: FakeProduct[];
inventory?: { productId: string; quantity: number }[];
}) {
return {
sponsorshipSubscription: { findMany: vi.fn(async () => data.quarterly ?? []) },
harvestWindow: { findMany: vi.fn(async () => data.windows ?? []) },
sponsorshipSubscription: {
findMany: vi.fn(async () => data.quarterly ?? []),
groupBy: vi.fn(async () => (data.ernteActive ?? []).map((row) => ({ offerId: row.offerId, _count: { _all: row.count } }))),
},
harvestWindow: {
findMany: vi.fn(async () => (data.windows ?? []).map((window) => ({ offer: { honeyJars: 3 }, ...window }))),
},
subscriptionDelivery: { findMany: vi.fn(async () => data.deliveries ?? []) },
product: { findMany: vi.fn(async () => (data.products ?? []).map(productRow)) },
inventory: {
@@ -82,6 +89,28 @@ describe("subscriptionReservedJars", () => {
expect(await subscriptionReservedJars(NOW, db as unknown as Db)).toBe(2 + 2 + 3 + (10 - 4));
});
test("Erntefenster: laufende Abos reservieren mindestens ihren Bedarf, auch ohne Planung", 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: 0 },
],
ernteActive: [{ offerId: "ernte", count: 4 }],
});
expect(await subscriptionReservedJars(NOW, db as unknown as Db)).toBe(4 * 3);
});
test("Erntefenster: nur das naechste Fenster je Angebot zaehlt", 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: 6 },
{ offerId: "ernte", startsOn: new Date("2027-06-01T00:00:00.000Z"), endsOn: new Date("2027-06-30T00:00:00.000Z"), plannedJars: 30 },
],
});
expect(await subscriptionReservedJars(NOW, db as unknown as Db)).toBe(6);
});
test("ein ueberliefertes Erntefenster reserviert nichts mehr (nie negativ)", async () => {
const db = fakeDb({
windows: [
@@ -60,29 +60,53 @@ export async function subscriptionReservedJars(now: Date = new Date(), db: Db =
const quarterlyJars = quarterly.reduce((sum, subscription) => sum + subscription.offer.honeyJars, 0);
const today = startOfUtcDay(now);
const windows = await db.harvestWindow.findMany({
const allWindows = await db.harvestWindow.findMany({
where: { endsOn: { gte: today }, offer: { cycle: "ERNTE" } },
select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true },
select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true, offer: { select: { honeyJars: true } } },
orderBy: { startsOn: "asc" },
});
if (windows.length === 0) return quarterlyJars;
// Nur das jeweils NAECHSTE Fenster je Angebot zaehlt: jedes Abo bekommt
// hoechstens eine Lieferung je Fenster, und spaetere Fenster werden aus
// spaeterer Ernte bedient. Alle offenen Fenster zu reservieren, hielte heute
// Honig fuer eine Ernte fest, die es noch gar nicht gibt.
const nextByOffer = new Map<string, (typeof allWindows)[number]>();
for (const window of allWindows) if (!nextByOffer.has(window.offerId)) nextByOffer.set(window.offerId, window);
if (nextByOffer.size === 0) return quarterlyJars;
// Bedarf des Fensters: was der Inhaber geplant hat, mindestens aber, was
// die laufenden Abos dieses Angebots im Fenster beanspruchen koennen. So
// bleibt kein neues Abo ohne Honig, nur weil die Planung noch nicht
// nachgezogen wurde.
const ernteSubscriptions = await db.sponsorshipSubscription.groupBy({
by: ["offerId"],
where: {
status: { in: [...RESERVING_STATUSES] },
NOT: { status: "IM_VERZUG", payments: { some: { reversedAt: { not: null } } } },
offerId: { in: [...nextByOffer.keys()] },
},
_count: { _all: true },
});
const activeByOffer = new Map(ernteSubscriptions.map((group) => [group.offerId, group._count._all]));
// Alle in den offenen Fenstern bereits angelegten Lieferungen in einer
// Abfrage; die Zuordnung zum Fenster geschieht hier im Speicher.
const earliest = new Date(Math.min(...windows.map((window) => window.startsOn.getTime())));
const nextWindows = [...nextByOffer.values()];
const earliest = new Date(Math.min(...nextWindows.map((window) => window.startsOn.getTime())));
const deliveries = await db.subscriptionDelivery.findMany({
where: {
status: "ANGELEGT",
subscription: { offerId: { in: [...new Set(windows.map((window) => window.offerId))] } },
subscription: { offerId: { in: [...nextByOffer.keys()] } },
dueAt: { gte: earliest },
},
select: { jars: true, dueAt: true, subscription: { select: { offerId: true } } },
});
const harvestJars = windows.reduce((sum, window) => {
const harvestJars = nextWindows.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((jars, d) => jars + d.jars, 0);
return sum + Math.max(0, window.plannedJars - delivered);
const needed = Math.max(window.plannedJars, (activeByOffer.get(window.offerId) ?? 0) * window.offer.honeyJars);
return sum + Math.max(0, needed - delivered);
}, 0);
return quarterlyJars + harvestJars;
}