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

+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[]> {