diff --git a/src/lib/sponsorship-subscription/reservation.test.ts b/src/lib/sponsorship-subscription/reservation.test.ts index 38184ab..0fd897a 100644 --- a/src/lib/sponsorship-subscription/reservation.test.ts +++ b/src/lib/sponsorship-subscription/reservation.test.ts @@ -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: [ diff --git a/src/lib/sponsorship-subscription/reservation.ts b/src/lib/sponsorship-subscription/reservation.ts index 478ad7b..921e215 100644 --- a/src/lib/sponsorship-subscription/reservation.ts +++ b/src/lib/sponsorship-subscription/reservation.ts @@ -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(); + 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; }