diff --git a/.env.example b/.env.example index 7ab45e5..2665f1f 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,12 @@ SMTP_FROM="Wabenhain " # Details siehe src/lib/apicore-client.ts. APICORE_API_URL= APICORE_API_KEY= +# Bestandsabgleich: der Schluessel braucht zusaetzlich "Verkaeufe und +# Rueckbuchungen des Shops buchen" (shop-sync:write). In ApiCore einen Webhook +# fuer "stock.changed" auf https:///api/apicore/webhook anlegen und das +# dort angezeigte Secret hier eintragen. Ohne Secret wird der Webhook +# abgelehnt; der CronJob gleicht dann alle 10 Minuten ab. +APICORE_WEBHOOK_SECRET= # DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) — # ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im @@ -250,6 +256,8 @@ VAT_RATE_PERCENT=7 VAT_RATE_PERCENT_HONIG=7 VAT_RATE_PERCENT_KERZEN=19 VAT_RATE_PERCENT_PROPOLIS=19 +# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher) +VAT_RATE_PERCENT_SONSTIGES=19 # Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf # (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8. diff --git a/README.md b/README.md index 76ee430..eb13e4d 100644 --- a/README.md +++ b/README.md @@ -326,19 +326,38 @@ Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine C Wiedererkannt wird ein Jahrgang über `Product.apicoreBottlingId` (die ApiCore-ID der Abfüllung), nicht über den Slug — der ist die öffentliche Adresse und soll lesbar bleiben. - Jeder Jahrgang entsteht als deaktivierter Entwurf **ohne Preis**: ApiCore führt überhaupt keine Verkaufspreise, das ist reine Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Solange er fehlt, lässt sich das Produkt nicht aktiv schalten (`admin/actions/products.ts`), und der Checkout lehnt es zusätzlich ab (`api/checkout/route.ts`) — ein importiertes Produkt kann also nie versehentlich für 0 € verkauft werden. Gewicht kommt aus der Glasgröße der Abfüllung, nicht aus dem Artikel: sie sagt, was tatsächlich in dieses Glas kam. Der Lagerbestand startet bei 0, denn wie viele Gläser in den Webshop gehen und wie viele in Hofladen oder Wiederverkauf, entscheidet Wabenhain. + Jeder Jahrgang entsteht als deaktivierter Entwurf **ohne Preis**: ApiCore führt überhaupt keine Verkaufspreise, das ist reine Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Solange er fehlt, lässt sich das Produkt nicht aktiv schalten (`admin/actions/products.ts`), und der Checkout lehnt es zusätzlich ab (`api/checkout/route.ts`) — ein importiertes Produkt kann also nie versehentlich für 0 € verkauft werden. Gewicht kommt aus der Glasgröße der Abfüllung, nicht aus dem Artikel: sie sagt, was tatsächlich in dieses Glas kam. Der Lagerbestand kommt aus ApiCore (siehe „Bestandsabgleich" unten): der Shop verkauft aus dem gesamten Restbestand der Abfüllung. Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut. Findet sich zu einer Abfüllung keine Charge, meldet der Lauf das als Hinweis statt als Fehler — meist steht dahinter eine Ernte, die in ApiCore noch fehlt. -- **Chargen** werden ausschließlich neu angelegt, nie aktualisiert. Ein Update würde `remainingGlasses` überschreiben — das ist Wabenhains eigener, durch echte Verkäufe veränderter Lagerbestand. +- **Chargen**: Losnummer, Abfüll- und MHD-Datum und abgefüllte Menge kommen aus ApiCore. `remainingGlasses` zeigt den Rest laut ApiCore (Bestandsabgleich). +- **Hergestellte Waren** (Kerzen, Propolis-Tinkturen, Salben, … — in ApiCore Artikel der Art „Hergestellte Ware" mit Herstellungschargen): **ein** Shop-Produkt je Artikel, nicht je Charge. Neue Artikel entstehen als deaktivierte Entwürfe ohne Preis; schon vorhandene Shop-Produkte verknüpft man stattdessen im Produkt-Formular („ApiCore-Artikel") — am besten vor dem ersten Sync, sonst entsteht ein zusätzlicher Entwurf. - Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer. - Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet. -Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`. +Wiederholte Läufe sind gefahrlos: Ernten werden über `Harvest.apicoreHarvestId` wiedererkannt, Chargen über `Batch.apicoreBottlingId`, Honig-Produkte über `Product.apicoreBottlingId`, hergestellte Waren über `Product.apicoreProductId`. **Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Einen Zugang per Benutzer und Passwort gibt es nicht mehr. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`. +### Bestandsabgleich + +**ApiCore führt den Restbestand je Charge; Shop und ApiCore zeigen dieselbe Zahl.** Design: `ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md`. + +- **Verkauft = bezahlt.** Beim Bezahlen geht je Position ein Verkauf an ApiCore; Storno einer bezahlten Bestellung und erstattete Retoure buchen zurück — auf genau die Chargen, aus denen verkauft wurde. Unbezahlte Bestellungen erreichen ApiCore nie. +- **Warteschlange:** Jede Buchung landet in `apicore_stock_events`, in derselben Transaktion wie der Statuswechsel, und wird danach gesendet. Ist ApiCore nicht erreichbar, verkauft der Shop weiter; der CronJob `wabenhain-apicore-stock` (alle 10 Minuten, `pnpm stock:apicore-sync`) reicht nach. Jede Buchung trägt eine feste ID — ApiCore bucht sie nur einmal. +- **Shop-Bestand** verknüpfter Produkte = ApiCore-Rest − noch nicht bestätigte Buchungen − Reservierungen unbezahlter Bestellungen. Das Bestandsfeld im Admin ist für sie gesperrt; Entnahmen (Hofladen, Eigenbedarf, Bruch) und Inventur bucht man in ApiCore. +- **Entnahmen in ApiCore** kommen per Webhook `stock.changed` sofort an (`POST /api/apicore/webhook`, HMAC-signiert). Der Webhook nennt nur, *was* sich geändert hat — die Zahlen holt der Shop selbst über die API. +- **Chargen-Zuordnung:** Welche Charge(n) ApiCore einem Verkauf zugeordnet hat, steht an der Bestellposition (`OrderItem.apicoreAllocations`) — für Rückverfolgung und Rückruf. +- **Dashboard:** Buchungen, die länger als 15 Minuten hängen oder von ApiCore abgelehnt wurden, sowie aktive, noch nicht verknüpfte Produkte erscheinen unter „Zu tun". + +**Einrichtung zusätzlich zum Sync:** + +1. In ApiCore dem Wabenhain-API-Schlüssel die Berechtigung „Verkäufe und Rückbuchungen des Shops buchen" (`shop-sync:write`) geben. +2. In ApiCore unter Automatisierung → Webhooks einen Webhook für `stock.changed` auf `https:///api/apicore/webhook` anlegen und dessen Secret als `APICORE_WEBHOOK_SECRET` setzen. +3. CronJob `wabenhain-apicore-stock` aus `k8s/07-backup-cronjob.yaml` anwenden. +4. Kerzen/Propolis-Produkte in ApiCore als „Hergestellte Ware" mit Herstellungscharge (aktueller Bestand) anlegen, in Wabenhain verknüpfen, dann synchronisieren. + ## Vollständiger Docker-Betrieb ```bash diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index a77c940..bd3774e 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -93,6 +93,8 @@ data: VAT_RATE_PERCENT_HONIG: "7" VAT_RATE_PERCENT_KERZEN: "19" VAT_RATE_PERCENT_PROPOLIS: "19" + # Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher). + VAT_RATE_PERCENT_SONSTIGES: "19" # Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte -> # Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index 97caa18..b80e14b 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -109,6 +109,12 @@ stringData: # Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml. APICORE_API_KEY: "" + # Secret des ApiCore-Webhooks "stock.changed" (ApiCore -> Automatisierung -> + # Webhooks, Ziel https:///api/apicore/webhook). Damit kommen + # Entnahmen aus ApiCore sofort im Shop an; leer = nur der CronJob gleicht + # ab (alle 10 Minuten). + APICORE_WEBHOOK_SECRET: "" + # DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe # .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet # "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox diff --git a/k8s/07-backup-cronjob.yaml b/k8s/07-backup-cronjob.yaml index d59cae2..f7c6ba4 100644 --- a/k8s/07-backup-cronjob.yaml +++ b/k8s/07-backup-cronjob.yaml @@ -248,3 +248,55 @@ spec: # Derselbe Wert wie beim App-Container: der Lauf laedt # denselben Prisma-Client samt Treiber. memory: 512Mi +--- +# Bestandsabgleich mit ApiCore (scripts/apicore-stock-sync.ts): sendet +# liegengebliebene Verkaeufe/Rueckbuchungen an ApiCore und gleicht alle +# verknuepften Produkte gegen den ApiCore-Restbestand ab. Sicherheitsnetz +# unter dem Sofort-Versand und dem Webhook "stock.changed" — scheitert der +# Lauf (ApiCore nicht erreichbar, Buchung abgelehnt), ist der Job rot. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: wabenhain-apicore-stock + namespace: wabenhain +spec: + schedule: "*/10 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + startingDeadlineSeconds: 300 + jobTemplate: + spec: + backoffLimit: 1 + # Ein Lauf darf den naechsten nicht blockieren. + activeDeadlineSeconds: 540 + template: + spec: + restartPolicy: OnFailure + securityContext: + runAsUser: 1001 + runAsGroup: 1001 + containers: + - name: apicore-stock + # DASSELBE Image und derselbe Tag wie in 05-app-deployment.yaml. + image: git.encry.de/nick.rosenloecher/wabenhain:latest + imagePullPolicy: IfNotPresent + # Braucht DATABASE_URL, DATABASE_PROVIDER, APICORE_API_URL und + # APICORE_API_KEY — dieselben Quellen wie die App. + envFrom: + - configMapRef: + name: wabenhain-config + - secretRef: + name: wabenhain-secrets + command: ["/bin/sh", "-c"] + args: + - | + set -eu + node scripts/prisma-provider.mjs + exec tsx scripts/apicore-stock-sync.ts + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 512Mi diff --git a/package.json b/package.json index 1f63bfa..4337307 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test:e2e": "playwright test", "preflight": "tsx scripts/preflight.ts", "places:find": "tsx scripts/find-place-id.ts", - "orders:release-stale": "tsx scripts/release-stale-orders.ts" + "orders:release-stale": "tsx scripts/release-stale-orders.ts", + "stock:apicore-sync": "tsx scripts/apicore-stock-sync.ts" }, "dependencies": { "@prisma/adapter-mariadb": "^7.10.0", diff --git a/prisma/migrations/20261002120000_apicore_stock_sync/migration.sql b/prisma/migrations/20261002120000_apicore_stock_sync/migration.sql new file mode 100644 index 0000000..5188f22 --- /dev/null +++ b/prisma/migrations/20261002120000_apicore_stock_sync/migration.sql @@ -0,0 +1,63 @@ +-- Bestandsabgleich mit ApiCore (ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md): +-- ApiCore fuehrt den Restbestand je Charge; Wabenhain meldet bezahlte +-- Verkaeufe und Rueckbuchungen ueber eine Warteschlange und gleicht seinen +-- Shop-Bestand daran ab. Rein additiv. + +-- AlterEnum +ALTER TYPE "ProductCategory" ADD VALUE 'SONSTIGES'; + +-- CreateEnum +CREATE TYPE "ApiCoreStockEventKind" AS ENUM ('SALE', 'RETURN'); + +-- AlterTable +ALTER TABLE "products" ADD COLUMN "apicoreProductId" TEXT; + +-- AlterTable +ALTER TABLE "order_items" ADD COLUMN "apicoreAllocations" JSONB; + +-- CreateTable +CREATE TABLE "apicore_stock_events" ( + "id" TEXT NOT NULL, + "kind" "ApiCoreStockEventKind" NOT NULL, + "orderItemId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "apicoreBottlingId" TEXT, + "apicoreProductId" TEXT, + "quantity" INTEGER NOT NULL, + "reversesEventId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sentAt" TIMESTAMP(3), + "rejectedAt" TIMESTAMP(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "apicore_stock_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "products_apicoreProductId_key" ON "products"("apicoreProductId"); + +-- CreateIndex +CREATE INDEX "apicore_stock_events_sentAt_rejectedAt_nextAttemptAt_idx" ON "apicore_stock_events"("sentAt", "rejectedAt", "nextAttemptAt"); + +-- CreateIndex +CREATE INDEX "apicore_stock_events_orderItemId_idx" ON "apicore_stock_events"("orderItemId"); + +-- CreateIndex +CREATE INDEX "apicore_stock_events_productId_idx" ON "apicore_stock_events"("productId"); + +-- AddForeignKey +ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "order_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_reversesEventId_fkey" FOREIGN KEY ("reversesEventId") REFERENCES "apicore_stock_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Genau ein ApiCore-Ziel je Buchung. +ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_exactly_one_target" + CHECK (("apicoreBottlingId" IS NULL) <> ("apicoreProductId" IS NULL)); +ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_quantity_positive" + CHECK ("quantity" > 0); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9587df5..4b95de4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -212,6 +212,7 @@ enum ProductCategory { HONIG KERZEN PROPOLIS + SONSTIGES } model Product { @@ -235,6 +236,12 @@ model Product { /// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID /// richten. apicoreBottlingId String? @unique + /// ApiCore-ID des ARTIKELS fuer hergestellte Waren (Kerzen, Propolis- + /// Produkte, ...): deren Bestand liegt in ApiCore je Herstellungscharge und + /// wird als Summe gefuehrt, ein Shop-Produkt je Artikel. Honig nutzt + /// stattdessen apicoreBottlingId. Hoechstens eines von beiden ist gesetzt; + /// ist eines gesetzt, kommt der Bestand aus ApiCore (lib/apicore-stock). + apicoreProductId String? @unique /// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet) harvestId String? batchId String? @@ -280,6 +287,7 @@ model Product { inventory Inventory? orderItems OrderItem[] reviews Review[] + apicoreStockEvents ApiCoreStockEvent[] wishlistItems WishlistItem[] stockNotifications StockNotification[] @@ -525,9 +533,16 @@ model OrderItem { /// feststellbar ist, welcher Satz gegolten haette. vatRatePercent Int? + /// Aus welchen ApiCore-Chargen die verkaufte Ware stammt, wie ApiCore sie + /// beim Verbuchen zugeordnet hat: [{ lotNumber, quantity }]. Fuer + /// Rueckverfolgung und Rueckruf; null, solange der Verkauf noch nicht + /// bestaetigt ist oder das Produkt nicht mit ApiCore verknuepft ist. + apicoreAllocations Json? + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Restrict) batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict) + apicoreStockEvents ApiCoreStockEvent[] /// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin, /// Rechnung) ueber die order.items-Relation abgefragt. @@ -551,6 +566,55 @@ model OrderItem { @@map("order_items") } +// --------------------------------------------------------------------------- +// Bestandsabgleich mit ApiCore +// --------------------------------------------------------------------------- + +enum ApiCoreStockEventKind { + SALE + RETURN +} + +/// Warteschlange der Bestandsbuchungen an ApiCore (lib/apicore-stock). +/// +/// Geschrieben in DERSELBEN Transaktion wie die ausloesende Statusaenderung +/// (bezahlt, storniert, erstattet), gesendet danach — sofort und per +/// CronJob nachgereicht. So geht keine Buchung verloren, wenn ApiCore gerade +/// nicht erreichbar ist. Die id geht als externalId an ApiCore; ApiCore bucht +/// jede id nur einmal, Wiederholungen sind also gefahrlos. +model ApiCoreStockEvent { + id String @id @default(cuid()) + kind ApiCoreStockEventKind + orderItemId String + /// Wabenhain-Produkt, dessen Shop-Bestand von dieser Buchung abhaengt. + productId String + /// Ziel in ApiCore: eine Abfuellung (Honig) ODER ein Artikel (FIFO ueber + /// dessen Herstellungschargen). + apicoreBottlingId String? + apicoreProductId String? + quantity Int + /// Bei RETURN: der Verkauf, der zurueckgebucht wird. + reversesEventId String? + createdAt DateTime @default(now()) + /// Von ApiCore bestaetigt (neu gebucht oder schon bekannt). + sentAt DateTime? + /// Von ApiCore abgelehnt — wird nicht wiederholt, das Dashboard zeigt es. + rejectedAt DateTime? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + + orderItem OrderItem @relation(fields: [orderItemId], references: [id], onDelete: Restrict) + product Product @relation(fields: [productId], references: [id], onDelete: Restrict) + reverses ApiCoreStockEvent? @relation("StockEventReversal", fields: [reversesEventId], references: [id], onDelete: Restrict) + reversedBy ApiCoreStockEvent[] @relation("StockEventReversal") + + @@index([sentAt, rejectedAt, nextAttemptAt]) + @@index([orderItemId]) + @@index([productId]) + @@map("apicore_stock_events") +} + enum PaymentStatus { PENDING SUCCEEDED diff --git a/scripts/apicore-stock-sync.ts b/scripts/apicore-stock-sync.ts new file mode 100644 index 0000000..bbaa661 --- /dev/null +++ b/scripts/apicore-stock-sync.ts @@ -0,0 +1,45 @@ +/** + * Bestandsabgleich mit ApiCore als Sicherheitsnetz unter den Sofort-Wegen. + * + * Normalerweise geht eine Buchung direkt nach der Zahlung/Stornierung an + * ApiCore, und eine Entnahme in ApiCore kommt per Webhook sofort an. Dieser + * Lauf faengt alles ab, was dabei liegen blieb: ApiCore war nicht erreichbar, + * ein Webhook ging verloren, der Server wurde mitten im Senden neu gestartet. + * + * 1. offene Buchungen senden (mit Wartezeiten 1, 5, 15, 60 Minuten), + * 2. ALLE verknuepften Produkte gegen ApiCore abgleichen. + * + * Aufruf: pnpm stock:apicore-sync + * Im Cluster laeuft er alle 10 Minuten als CronJob (k8s/07-backup-cronjob.yaml). + * + * Exit-Code 1, wenn ApiCore nicht erreichbar war oder Buchungen abgelehnt + * hat: dann soll der Job als fehlgeschlagen sichtbar sein, nicht nur in + * einer Logzeile verschwinden. + */ +import { prisma } from "../src/lib/prisma"; +import { deliverPendingStockEvents } from "../src/lib/apicore-stock/delivery"; +import { reconcileApiCoreStock } from "../src/lib/apicore-stock/reconcile"; + +async function main(): Promise { + const delivered = await deliverPendingStockEvents(); + console.log( + `[apicore-stock] Buchungen: ${delivered.sent} bestaetigt, ${delivered.rejected} abgelehnt, ${delivered.failed} nicht zugestellt${delivered.error ? ` (${delivered.error})` : ""}` + ); + const reconciled = await reconcileApiCoreStock(); + console.log( + `[apicore-stock] Abgleich: ${reconciled.updated} Produkte aktualisiert, ${reconciled.missing} in ApiCore nicht gefunden${reconciled.error ? ` (${reconciled.error})` : ""}` + ); + const failed = delivered.failed > 0 || delivered.rejected > 0 || Boolean(reconciled.error); + return failed ? 1 : 0; +} + +main() + .then(async (code) => { + await prisma.$disconnect(); + process.exit(code); + }) + .catch(async (error: unknown) => { + console.error("[apicore-stock] Lauf abgebrochen:", error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts index 1980e42..813275c 100644 --- a/src/app/admin/actions/apicore-sync.ts +++ b/src/app/admin/actions/apicore-sync.ts @@ -8,6 +8,7 @@ import { getApiCoreConnection, fetchApiCoreShopData, isSellableStatus, + isManufactured, ApiCoreConfigError, ApiCoreRequestError, type ApiCoreShopData, @@ -23,10 +24,14 @@ import { buildProductDescription, deriveJarWeightGrams, productSlugCandidates, + slugifyProductName, harvestFactChanges, batchFactChanges, } from "@/lib/apicore-mapping"; import { requireAdmin } from "./_shared"; +import { deliverPendingStockEvents } from "@/lib/apicore-stock/delivery"; +import { reconcileApiCoreStock } from "@/lib/apicore-stock/reconcile"; +import type { ProductCategory } from "@/generated/prisma/client"; /** * Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in @@ -363,6 +368,73 @@ async function syncProducts( * niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand * hinschauen muss. */ +const SHOP_CATEGORIES: readonly ProductCategory[] = ["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"]; + +function shopCategoryOf(article: ApiCoreProduct): ProductCategory { + const category = article.shopCategory as ProductCategory | undefined; + return category && SHOP_CATEGORIES.includes(category) && category !== "HONIG" ? category : "SONSTIGES"; +} + +/** + * Hergestellte Waren (Kerzen, Propolis-Tinkturen, Salben, ...): EIN + * Shop-Produkt je ApiCore-Artikel, nicht je Charge wie beim Honig. Kundinnen + * kaufen "Bienenwachskerze klein", nicht eine bestimmte Charge — welche Charge + * verkauft wird, entscheidet ApiCore (aelteste zuerst) und meldet es zurueck. + * + * Gleiche Regeln wie bei Honig-Entwuerfen: deaktiviert, ohne Preis, nie + * aktualisiert. Ein schon in Wabenhain gepflegtes Produkt verknuepft man + * im Admin ("ApiCore-Artikel verknuepfen") statt es neu anlegen zu lassen. + */ +async function syncManufacturedProducts(articles: ApiCoreProduct[]): Promise { + const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, pending: 0, discontinued: 0 }; + for (const article of articles.filter(isManufactured)) { + try { + const linked = await prisma.product.findUnique({ + where: { apicoreProductId: article.id }, + select: { id: true }, + }); + if (linked) { + counts.skipped++; + continue; + } + if (!isSellableStatus(article.status)) { + counts.discontinued++; + continue; + } + const base = slugifyProductName(article.name) || "produkt"; + const sku = slugifyProductName(article.sku ?? ""); + const slug = await firstFreeSlug(sku ? [base, `${base}-${sku}`] : [base]); + if (!slug) { + counts.failed++; + console.error( + `[apicore-sync] Für "${article.name}" ist keine freie Adresse frei — Produkt bitte von Hand anlegen und verknüpfen.` + ); + continue; + } + await prisma.product.create({ + data: { + slug, + apicoreProductId: article.id, + name: article.name, + description: buildProductDescription(article), + weightGrams: deriveJarWeightGrams(null, article), + category: shopCategoryOf(article), + priceCents: 0, + pricePerKgCents: 0, + isActive: false, + // Der Bestand kommt im Abgleich direkt danach aus ApiCore. + inventory: { create: { quantity: 0 } }, + }, + }); + counts.created++; + } catch (error: unknown) { + counts.failed++; + console.error(`[apicore-sync] Artikel "${article.name}" konnte nicht als Produkt importiert werden: ${describeError(error)}`); + } + } + return counts; +} + async function firstFreeSlug(candidates: string[]): Promise { for (const slug of candidates) { const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } }); @@ -531,8 +603,16 @@ export async function syncApiCoreAction(formData: FormData): Promise { `${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`, }); - // 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen. - const products = await syncProducts(source.products, source.bottlings); + // 3. Produkte — Honig je Abfuellung ein Jahrgang, hergestellte Waren je Artikel. + const honeyProducts = await syncProducts(source.products, source.bottlings); + const manufacturedProducts = await syncManufacturedProducts(source.products); + const products: ProductSyncCounts = { + created: honeyProducts.created + manufacturedProducts.created, + skipped: honeyProducts.skipped + manufacturedProducts.skipped, + failed: honeyProducts.failed + manufacturedProducts.failed, + pending: honeyProducts.pending, + discontinued: honeyProducts.discontinued + manufacturedProducts.discontinued, + }; await logAdminAction(admin, { action: "product.sync_apicore", entityType: "Product", @@ -541,6 +621,23 @@ export async function syncApiCoreAction(formData: FormData): Promise { `${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`, }); + // 4. Bestand — offene Buchungen senden, dann alle verknuepften Produkte + // auf den ApiCore-Rest setzen. Scheitert das, bleibt der Sync trotzdem + // gueltig; der CronJob holt den Abgleich nach. + try { + const delivered = await deliverPendingStockEvents(); + const reconciled = await reconcileApiCoreStock(); + await logAdminAction(admin, { + action: "stock.sync_apicore", + entityType: "Inventory", + detail: + `${delivered.sent} Buchungen bestätigt, ${delivered.rejected} abgelehnt, ${delivered.failed} offen; ` + + `${reconciled.updated} Bestände abgeglichen, ${reconciled.missing} in ApiCore nicht gefunden`, + }); + } catch (error: unknown) { + console.error(`[apicore-sync] Bestandsabgleich fehlgeschlagen: ${describeError(error)}`); + } + revalidatePath("/admin/ernten"); revalidatePath("/admin/chargen"); revalidatePath("/admin/produkte"); diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index 1ec50cc..a9f94c3 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -19,6 +19,8 @@ import { getStripeClient } from "@/lib/stripe"; import { isLocale, defaultLocale } from "@/i18n/config"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import { requireAdmin, str, strOrNull, fail } from "./_shared"; +import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events"; +import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash"; const ORDER_STATUSES = [ @@ -185,6 +187,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) { await restockOrderItems(tx, previous.items); } + // War die Bestellung bezahlt, steht ein Verkauf in ApiCore — der + // wird zurueckgebucht. Unbezahlte Bestellungen haben keinen, dann + // passiert hier nichts. + await recordOrderReturn(tx, orderId); } } else if (reopening) { // Gegenbuchung zur Stornierung: die dabei zurueckgebuchte Ware wird @@ -202,6 +208,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise // Wirft InsufficientStockError, wenn die Ware inzwischen verkauft ist — // die Transaktion nimmt dann auch den Statuswechsel zurueck. await reserveOrderItems(tx, previous.items); + // Eine bezahlte Bestellung ist damit wieder verkauft. + if (previous.payment?.status === "SUCCEEDED") { + await recordOrderSale(tx, orderId); + } } } else { // Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die @@ -245,6 +255,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise throw error; }); + if (result && !("insufficientStock" in result)) { + scheduleApiCoreStockSync(result.items.map((item) => item.productId)); + } + if (result && "insufficientStock" in result) { return fail( detailPath, diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts index e16a41d..d9c17a1 100644 --- a/src/app/admin/actions/products.ts +++ b/src/app/admin/actions/products.ts @@ -10,6 +10,7 @@ import { planInventoryEdit } from "@/lib/inventory"; import type { Prisma } from "@/generated/prisma/client"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { succeed } from "@/lib/flash"; +import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; /** * Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis @@ -213,7 +214,25 @@ export async function updateProductAction(formData: FormData): Promise { const isActive = formData.get("isActive") === "on"; await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents); - const inventoryPlan = planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity }); + const current = await prisma.product.findUnique({ + where: { id }, + select: { apicoreBottlingId: true, apicoreProductId: true }, + }); + if (!current) return fail("/admin/produkte", "Produkt nicht gefunden."); + + // Verknuepfung mit einem ApiCore-Artikel (nur hergestellte Waren; Honig + // haengt fest an seiner Abfuellung). Feld fehlt = unveraendert lassen. + const requestedLink = formData.has("apicoreProductId") ? strOrNull(formData, "apicoreProductId") : undefined; + const apicoreProductId = current.apicoreBottlingId ? current.apicoreProductId : requestedLink; + const linkChanged = apicoreProductId !== undefined && apicoreProductId !== current.apicoreProductId; + const stockFromApiCore = Boolean(current.apicoreBottlingId || (apicoreProductId ?? current.apicoreProductId)); + + // Bei verknuepften Produkten gehoert der Bestand ApiCore: ein Wert aus dem + // Formular wird nie geschrieben, auch wenn jemand das gesperrte Feld per + // Entwicklerwerkzeug aendert. + const inventoryPlan = stockFromApiCore + ? ({ kind: "keep" } as const) + : planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity }); if (inventoryPlan.kind === "reload") { return fail( `/admin/produkte/${id}`, @@ -244,10 +263,27 @@ export async function updateProductAction(formData: FormData): Promise { vatRatePercent: numbers.vatRatePercent, images: strArray(formData, "images").filter(isValidUploadPath), isActive, + ...(linkChanged ? { apicoreProductId } : {}), }, }); + }).catch((error: unknown) => { + if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") { + return "link-taken" as const; + } + throw error; }); + if (product === "link-taken") { + return fail( + `/admin/produkte/${id}`, + "Dieser ApiCore-Artikel ist bereits mit einem anderen Produkt verknüpft. Nichts wurde gespeichert." + ); + } + if (linkChanged && apicoreProductId) { + // Bestand sofort aus ApiCore holen, nicht erst beim naechsten CronJob. + scheduleApiCoreStockSync([id]); + } + if (!product) { const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } }); return fail( diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 77f7bca..6525fb9 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel"; import { prisma } from "@/lib/prisma"; import { pendingReviewWhere } from "@/lib/review-status"; +import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard"; import { COUNTED_SALE_WHERE } from "@/lib/sales"; import { formatPrice } from "@/lib/format"; import { getLocale } from "@/i18n/locale"; @@ -259,6 +260,12 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) { const revenueTrend = [...revenueByDay.entries()]; const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v)); + // Bestandsabgleich mit ApiCore (nur wenn eingerichtet). + const stockIssues = await countApiCoreStockIssues(); + const stockOverdue = stockIssues.overdue; + const stockRejected = stockIssues.rejected; + const unlinkedProducts = stockIssues.unlinked; + const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([ getCurrentAdmin(), prisma.order.count({ @@ -273,6 +280,9 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) { { count: waitingReturns, href: "/admin/retouren", label: t.adminDashboardPage.toDoReturns(waitingReturns) }, { count: waitingReviews, href: "/admin/bewertungen", label: t.adminDashboardPage.toDoReviews(waitingReviews) }, { count: waitingInquiries, href: "/admin/anfragen", label: t.adminDashboardPage.toDoInquiries(waitingInquiries) }, + { count: stockRejected, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoStockRejected(stockRejected) }, + { count: stockOverdue, href: "/admin", label: t.adminDashboardPage.toDoStockOverdue(stockOverdue) }, + { count: unlinkedProducts, href: "/admin/produkte", label: t.adminDashboardPage.toDoUnlinkedProducts(unlinkedProducts) }, ].filter((item) => item.count > 0); // Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC). diff --git a/src/app/admin/produkte/[id]/page.tsx b/src/app/admin/produkte/[id]/page.tsx index 90edf50..5c6c8f1 100644 --- a/src/app/admin/produkte/[id]/page.tsx +++ b/src/app/admin/produkte/[id]/page.tsx @@ -9,9 +9,41 @@ import { AdminFieldset, AdminFormPanel, AdminHint, + AdminSelect, AdminSwitch, AdminTextarea, } from "@/components/admin-form"; +import { + fetchApiCoreShopData, + getApiCoreConnection, + isManufactured, + type ApiCoreProduct, +} from "@/lib/apicore-client"; + +/** + * ApiCore-Artikel, mit denen sich dieses Produkt verknuepfen laesst: nur + * hergestellte Waren (Honig haengt an der Abfuellung) und nur solche, die + * kein anderes Produkt schon belegt. Ist ApiCore nicht erreichbar, bleibt + * die Verknuepfung, wie sie ist — die Seite selbst funktioniert weiter. + */ +async function linkableApiCoreArticles( + productId: string +): Promise<{ articles: ApiCoreProduct[]; error: string | null }> { + try { + const feed = await fetchApiCoreShopData(getApiCoreConnection()); + const taken = await prisma.product.findMany({ + where: { apicoreProductId: { not: null }, NOT: { id: productId } }, + select: { apicoreProductId: true }, + }); + const takenIds = new Set(taken.map((row) => row.apicoreProductId)); + return { + articles: feed.products.filter((article) => isManufactured(article) && !takenIds.has(article.id)), + error: null, + }; + } catch (error: unknown) { + return { articles: [], error: error instanceof Error ? error.message : "Unbekannter Fehler" }; + } +} import { updateProductAction } from "../../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -30,6 +62,8 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod tasteTags: asStringArray(rawProduct.tasteTags), images: asStringArray(rawProduct.images), }; + const stockFromApiCore = Boolean(product.apicoreBottlingId || product.apicoreProductId); + const linkable = product.apicoreBottlingId ? null : await linkableApiCoreArticles(product.id); return (
@@ -93,8 +127,37 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod type="number" defaultValue={String(product.inventory?.quantity ?? 0)} required + readOnly={stockFromApiCore} + hint={stockFromApiCore ? te.stockFromApiCoreHint : undefined} />
+ {product.apicoreBottlingId ? ( + {te.apicoreHoneyLinked(product.apicoreBottlingId)} + ) : linkable?.error ? ( + <> + + {te.apicoreUnavailable(linkable.error)} + + ) : ( + + + {(linkable?.articles ?? []).map((article) => ( + + ))} + {product.apicoreProductId && + !(linkable?.articles ?? []).some((article) => article.id === product.apicoreProductId) ? ( + + ) : null} + + )}
{t.common.categories.HONIG} + {/* Ernte/Charge gibt es nur bei Honig. Ausgeblendet wird ueber :has() ohne Client-JS; ohne :has()-Unterstuetzung bleibt alles sichtbar und der Server ignoriert die Auswahl ohnehin (products.ts). */} -
+
{harvests.map((h) => ( @@ -72,7 +73,7 @@ export default async function AdminProductNewPage() {
diff --git a/src/app/admin/retouren/actions.ts b/src/app/admin/retouren/actions.ts index 6e6bb17..65155df 100644 --- a/src/app/admin/retouren/actions.ts +++ b/src/app/admin/retouren/actions.ts @@ -9,6 +9,8 @@ import { logAdminAction } from "@/lib/audit-log"; import { sendMail } from "@/lib/mail"; import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@/lib/email-templates"; import { restockOrderItems } from "@/lib/inventory"; +import { recordOrderReturn } from "@/lib/apicore-stock/stock-events"; +import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; import { isLocale, defaultLocale } from "@/i18n/config"; function str(formData: FormData, key: string): string { @@ -114,7 +116,11 @@ export async function refundReturnRequestAction(formData: FormData): Promise item.productId)); await logAdminAction(admin, { action: "return_request.refund", diff --git a/src/app/api/apicore/webhook/route.ts b/src/app/api/apicore/webhook/route.ts new file mode 100644 index 0000000..3a01751 --- /dev/null +++ b/src/app/api/apicore/webhook/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import { after } from "next/server"; +import { z } from "zod"; +import { getClientIp, rateLimit } from "@/lib/rate-limit"; +import { verifyApiCoreSignature } from "@/lib/apicore-stock/webhook-signature"; +import { deliverPendingStockEvents } from "@/lib/apicore-stock/delivery"; +import { reconcileApiCoreStock } from "@/lib/apicore-stock/reconcile"; + +/** + * Empfaengt ApiCore-Webhooks (Ereignis "stock.changed"): in ApiCore wurde + * entnommen, korrigiert, abgefuellt oder ein Shop-Verkauf gebucht. + * + * Der Webhook nennt nur, WAS sich geaendert hat (Abfuellungs- und + * Artikel-IDs), nie Zahlen. Die Zahlen holt der Abgleich selbst ueber die + * API — ein Webhook kann also keinen Bestand setzen, selbst wenn das Secret + * einmal in falsche Haende geriete. Fehlt ein Webhook, holt der CronJob den + * Abgleich spaetestens nach zehn Minuten nach. + */ +const WEBHOOKS_PER_MINUTE = 60; + +const payloadSchema = z.object({ + event: z.string(), + data: z + .object({ + bottlingBatchIds: z.array(z.string()).default([]), + productIds: z.array(z.string()).default([]), + }) + .partial() + .optional(), +}); + +export async function POST(request: Request): Promise { + const ip = await getClientIp(); + if (!rateLimit(`apicore-webhook:${ip}`, WEBHOOKS_PER_MINUTE, 60_000)) { + return NextResponse.json({ error: "Zu viele Anfragen" }, { status: 429 }); + } + + const secret = process.env.APICORE_WEBHOOK_SECRET?.trim(); + if (!secret) { + return NextResponse.json({ error: "Webhook nicht eingerichtet" }, { status: 503 }); + } + + const body = await request.text(); + const valid = verifyApiCoreSignature({ + secret, + timestamp: request.headers.get("x-apicore-timestamp"), + deliveryId: request.headers.get("x-apicore-delivery"), + signature: request.headers.get("x-apicore-signature"), + body, + }); + if (!valid) { + return NextResponse.json({ error: "Ungueltige Signatur" }, { status: 401 }); + } + + let parsed; + try { + parsed = payloadSchema.safeParse(JSON.parse(body)); + } catch { + return NextResponse.json({ error: "Ungueltiger Inhalt" }, { status: 400 }); + } + if (!parsed.success) { + return NextResponse.json({ error: "Ungueltiger Inhalt" }, { status: 400 }); + } + // Andere Ereignisse (falls in ApiCore mit abonniert) werden quittiert und + // ignoriert, damit ApiCore sie nicht als Fehler protokolliert. + if (parsed.data.event !== "stock.changed") { + return NextResponse.json({ ok: true, ignored: parsed.data.event }); + } + + const apicoreBottlingIds = parsed.data.data?.bottlingBatchIds ?? []; + const apicoreProductIds = parsed.data.data?.productIds ?? []; + after(async () => { + try { + await deliverPendingStockEvents(); + await reconcileApiCoreStock({ apicoreBottlingIds, apicoreProductIds }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + console.error(`[apicore-webhook] Abgleich fehlgeschlagen, der CronJob holt ihn nach: ${message}`); + } + }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/checkout/webhook/route.test.ts b/src/app/api/checkout/webhook/route.test.ts index ac3f1e2..17ee0f9 100644 --- a/src/app/api/checkout/webhook/route.test.ts +++ b/src/app/api/checkout/webhook/route.test.ts @@ -80,6 +80,18 @@ const db = { vi.mock("@/lib/prisma", () => ({ prisma: db })); +// Bestandsabgleich mit ApiCore: hier nur, DASS die bezahlte Bestellung als +// Verkauf in die Warteschlange geht; die Buchungslogik selbst testet +// lib/apicore-stock. +const recordOrderSale = vi.fn(async () => 0); +const scheduleApiCoreStockSync = vi.fn(); +vi.mock("@/lib/apicore-stock/stock-events", () => ({ + recordOrderSale: (...args: unknown[]) => recordOrderSale(...(args as [])), +})); +vi.mock("@/lib/apicore-stock/schedule", () => ({ + scheduleApiCoreStockSync: (...args: unknown[]) => scheduleApiCoreStockSync(...(args as [])), +})); + const { POST } = await import("./route"); const ORDER_ID = "order_1"; diff --git a/src/app/api/checkout/webhook/route.ts b/src/app/api/checkout/webhook/route.ts index e42f7e1..2b87d19 100644 --- a/src/app/api/checkout/webhook/route.ts +++ b/src/app/api/checkout/webhook/route.ts @@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage"; import { formatInvoiceNumber } from "@/lib/invoice-number"; import { isLocale, defaultLocale } from "@/i18n/config"; import type Stripe from "stripe"; +import { recordOrderSale } from "@/lib/apicore-stock/stock-events"; +import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; /** * Erzeugt das Rechnungs-PDF, legt es nach Jahr und Kunde sortiert ab und @@ -323,6 +325,12 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { }); } + // Bezahlt = verkauft: die Ware in ApiCore abbuchen (Warteschlange, in + // derselben Transaktion wie der Statuswechsel). recordOrderSale bucht + // je Position hoechstens einen offenen Verkauf, ein erneut zugestellter + // Webhook legt also nichts doppelt an. + await recordOrderSale(tx, orderId); + return updated; }); @@ -330,6 +338,8 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { // (z.B. bereits storniert) — nichts weiter zu tun. if (!order) return; + scheduleApiCoreStockSync(order.items.map((item) => item.productId)); + // Abgleich zwischen dem, was wir berechnet haben, und dem, was Stripe // tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle // (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx index 3877fdf..518ed27 100644 --- a/src/app/shop/page.tsx +++ b/src/app/shop/page.tsx @@ -44,6 +44,7 @@ export default async function ShopPage(props: PageProps<"/shop">) { { value: "HONIG", label: t.common.categories.HONIG }, { value: "KERZEN", label: t.common.categories.KERZEN }, { value: "PROPOLIS", label: t.common.categories.PROPOLIS }, + { value: "SONSTIGES", label: t.common.categories.SONSTIGES }, ] as const; const sortOptions: { value: ProductSort | ""; label: string }[] = [ @@ -56,7 +57,7 @@ export default async function ShopPage(props: PageProps<"/shop">) { const { q, kategorie, sortierung, verfuegbar } = await props.searchParams; const query = typeof q === "string" ? q : undefined; const category = - typeof kategorie === "string" && ["HONIG", "KERZEN", "PROPOLIS"].includes(kategorie) + typeof kategorie === "string" && ["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"].includes(kategorie) ? (kategorie as ProductCategory) : undefined; const sort = diff --git a/src/components/admin-form.tsx b/src/components/admin-form.tsx index 8bd7c98..cc7fe92 100644 --- a/src/components/admin-form.tsx +++ b/src/components/admin-form.tsx @@ -15,6 +15,7 @@ export function AdminField({ required = false, min, hint, + readOnly = false, }: { name: string; label: string; @@ -25,6 +26,8 @@ export function AdminField({ min?: number; /** Kurzer Zusatz unter dem Feld, statt ihn in die Beschriftung zu packen. */ hint?: string; + /** Wird angezeigt und mitgeschickt, laesst sich aber nicht aendern. */ + readOnly?: boolean; }) { return ( diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index e5c958b..50054bf 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -21,6 +21,7 @@ const categoryTone: Record = { HONIG: "honey", KERZEN: "forest", PROPOLIS: "cream", + SONSTIGES: "cream", }; /** diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index b964ab5..22fd7fa 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -8,6 +8,7 @@ export const de: Dictionary = { HONIG: "Honig", KERZEN: "Bienenwachskerzen", PROPOLIS: "Propolis", + SONSTIGES: "Weitere Bienenprodukte", }, turnstile: { loadError: "Die Sicherheitsprüfung konnte nicht geladen werden.", @@ -16,7 +17,7 @@ export const de: Dictionary = { }, pageOutro: { ariaLabel: "Weiterlesen", - shopText: "Honig, Bienenwachskerzen und Propolis aus eigener Imkerei — solange die Charge reicht.", + shopText: "Honig, Bienenwachskerzen und Propolis aus eigener Imkerei, solange die Charge reicht.", harvestsText: "Jede Ernte mit Datum, Standort und Charge, zurückverfolgbar bis ins Glas.", originText: "Wo unsere Bienenvölker stehen und was die Landschaft um Altenmünster prägt.", }, @@ -73,7 +74,7 @@ export const de: Dictionary = { }, orderStatusBadge: { // Nicht "Neu": dieser Status bedeutet, dass die Zahlung noch nicht - // bestaetigt ist — bei einem abgebrochenen Checkout bleibt sie es auch. + // bestaetigt ist, bei einem abgebrochenen Checkout bleibt sie es auch. NEU: "Zahlung ausstehend", BEZAHLT: "Bezahlt", IN_VORBEREITUNG: "In Vorbereitung", @@ -193,7 +194,7 @@ export const de: Dictionary = { successMailSent: "Wir haben dir soeben eine Eingangsbestätigung mit dem Inhalt deiner Erklärung sowie Datum und Uhrzeit des Eingangs per E-Mail geschickt.", successMailFailed: "Dein Widerruf ist wirksam bei uns eingegangen. Die Eingangsbestätigung per E-Mail konnte gerade nicht versendet werden; wir schicken sie dir schnellstmöglich nach. Bitte speichere diese Seite als Nachweis.", receivedAtLabel: "Eingegangen am", - errorMessage: "Dein Widerruf konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine E-Mail — auch ein Widerruf per E-Mail oder Brief ist wirksam.", + errorMessage: "Dein Widerruf konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine E-Mail, auch ein Widerruf per E-Mail oder Brief ist wirksam.", policyLink: "Zur Widerrufsbelehrung", footerButton: "Vertrag widerrufen", }, @@ -474,7 +475,7 @@ export const de: Dictionary = { // Wortlaut aus der amtlichen Muster-Widerrufsbelehrung (Anlage 1 zu // Art. 246a § 1 Abs. 2 EGBGB), bewusst unveraendert uebernommen. // Ohne diese Information traegt nach § 357 Abs. 6 BGB der Verkaeufer - // die Ruecksendekosten — die Belehrung selbst ist die Voraussetzung + // die Ruecksendekosten; die Belehrung selbst ist die Voraussetzung // dafuer, sie dem Kaeufer auferlegen zu duerfen. Die Ausnahme fuer // mangelhafte Ware steht bewusst NICHT hier: das ist Gewaehrleistung // (§ 439 BGB) und damit ein anderes Recht als der Widerruf; eine @@ -511,7 +512,7 @@ export const de: Dictionary = { pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen. Dabei fallen keine Versandkosten an.", regionHeading: "Liefergebiet", regionTextDe: "Versand ist nur innerhalb Deutschlands möglich. Bestellungen mit einer Lieferadresse außerhalb Deutschlands können wir derzeit leider nicht annehmen. Ausgenommen sind außerdem Helgoland und Büsingen am Hochrhein: beide liegen außerhalb des EU-Zollgebiets, dorthin wäre jede Sendung zollpflichtig.", - regionTextEu: "Versand ist in die gesamte Europäische Union möglich — dorthin fällt kein Zoll an. Bestellungen mit einer Lieferadresse außerhalb der EU können wir derzeit leider nicht annehmen. Das gilt auch für Gebiete, die zwar zu einem EU-Land gehören, aber außerhalb des Zollgebiets liegen: Helgoland, Büsingen am Hochrhein, die Kanarischen Inseln, Ceuta und Melilla, die französischen Überseegebiete, Livigno, Campione d’Italia, Åland und den Berg Athos.", + regionTextEu: "Versand ist in die gesamte Europäische Union möglich, dorthin fällt kein Zoll an. Bestellungen mit einer Lieferadresse außerhalb der EU können wir derzeit leider nicht annehmen. Das gilt auch für Gebiete, die zwar zu einem EU-Land gehören, aber außerhalb des Zollgebiets liegen: Helgoland, Büsingen am Hochrhein, die Kanarischen Inseln, Ceuta und Melilla, die französischen Überseegebiete, Livigno, Campione d’Italia, Åland und den Berg Athos.", paymentHeading: "Zahlungsmethoden", paymentText: "Wir bieten Kreditkarte, SEPA-Lastschrift, Klarna, PayPal, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich.", deliveryTimeHeading: "Lieferzeit", @@ -528,7 +529,7 @@ export const de: Dictionary = { // im Code (lib/session.ts, lib/theme.ts, i18n/config.ts, lib/flash-core.ts, // lib/checkout-release.ts, store/*, components/cookie-consent.tsx). Wer dort // einen Namen, eine Laufzeit oder einen neuen Speicherort einfuehrt, muss - // ihn hier nachtragen — § 25 TDDDG verlangt die Angabe fuer jeden Zugriff + // ihn hier nachtragen, § 25 TDDDG verlangt die Angabe fuer jeden Zugriff // auf das Endgeraet, nicht nur fuer Cookies. Gleiches gilt fuer en.ts. sections: [ { @@ -549,7 +550,7 @@ export const de: Dictionary = { { heading: "Überblick", paragraphs: [ - "Wir verwenden technisch notwendige Cookies und Speichereinträge und — nur mit deiner Einwilligung — einen einzigen weiteren Speichereintrag für die Liste zuletzt angesehener Produkte. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.", + "Wir verwenden technisch notwendige Cookies und Speichereinträge und nur mit deiner Einwilligung, einen einzigen weiteren Speichereintrag für die Liste zuletzt angesehener Produkte. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.", "Die meisten der unten genannten Cookies entstehen erst, wenn du eine bestimmte Funktion nutzt (z. B. Anmeldung, Bezahlung, Sprachwechsel). Beim bloßen Aufrufen der Website wird kein Cookie gesetzt.", ], }, @@ -838,7 +839,7 @@ export const de: Dictionary = { statusRejected: "Abgelehnt", reject: "Ablehnen", rejectConfirm: "Diese Bewertung ablehnen? Sie wird nicht im Shop angezeigt und landet im Archiv.", - noPendingReviews: "Keine offenen Bewertungen — alles entschieden.", + noPendingReviews: "Keine offenen Bewertungen, alles entschieden.", columnApproved: "Akzeptiert", columnRejected: "Abgelehnt", rejectedOn: (date) => `Abgelehnt am ${date}`, @@ -1049,8 +1050,8 @@ export const de: Dictionary = { glassCountLabel: "Anzahl Gläser", remainingGlassesLabel: "Verbleibender Bestand", imagesLabel: "Chargen-Bilder", - image1Label: "Bild 1 — linke Kachel auf der Chargenseite", - image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)", + image1Label: "Bild 1, linke Kachel auf der Chargenseite", + image2Label: "Bild 2, rechte Kachel auf der Chargenseite (Standort)", save: "Speichern", }, trackOrderPage: { @@ -1162,8 +1163,8 @@ export const de: Dictionary = { locationLabel: "Standort", descriptionLabel: "Beschreibung", imagesLabel: "Erntebilder", - image1Label: "Bild 1 — Ernten-Übersicht und linke Kachel auf der Chargenseite", - image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)", + image1Label: "Bild 1, Ernten-Übersicht und linke Kachel auf der Chargenseite", + image2Label: "Bild 2, rechte Kachel auf der Chargenseite (Standort)", isPublishedLabel: "Öffentlich sichtbar", isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.", draftNotice: "Diese Ernte ist aktuell nur im Admin-Bereich sichtbar (z.B. aus ApiCore importiert). Bitte Angaben prüfen und danach veröffentlichen.", @@ -1182,9 +1183,9 @@ export const de: Dictionary = { resultProducts: (created, skipped, failed) => `Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`, pending: (count) => - `${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge — meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`, + `${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge, meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`, discontinued: (count) => - `${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`, + `${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt, dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`, protectedDrafts: (count) => `${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet: Sorte, Ort und Text bleiben, nur Datum und Jahr kommen aus ApiCore.`, stockConflicts: (count) => @@ -1228,6 +1229,14 @@ export const de: Dictionary = { intensityKraeftigLabel: "kräftig (1-5)", weightLabel: "Gewicht (g)", stockLabel: "Lagerbestand (Gläser)", + stockFromApiCoreHint: + "Kommt aus ApiCore (Rest abzüglich offener Bestellungen). Entnahmen und Inventur bitte in ApiCore buchen.", + apicoreLinkLabel: "ApiCore-Artikel", + apicoreNotLinked: "— nicht verknüpft (Bestand von Hand) —", + apicoreLinkHint: + "Verknüpft: der Bestand kommt aus ApiCore (Summe aller Herstellungschargen), Verkäufe und Rückbuchungen werden dorthin gemeldet.", + apicoreUnavailable: (message) => `ApiCore-Artikel konnten nicht geladen werden: ${message}`, + apicoreHoneyLinked: (bottlingId) => `Mit der ApiCore-Abfüllung ${bottlingId} verknüpft.`, priceLabel: "Preis (Cent)", pricePerKgLabel: "Grundpreis pro kg (Cent)", vatRateLabel: "Umsatzsteuersatz (%, optional)", @@ -1306,9 +1315,9 @@ export const de: Dictionary = { statusOpen: "Offen", statusProcessed: "Erledigt", matched: "Bestellung zugeordnet", - unmatched: "Keine passende Bestellung — bitte prüfen", + unmatched: "Keine passende Bestellung, bitte prüfen", mailSent: "Eingangsbestätigung versendet", - mailNotSent: "Eingangsbestätigung NICHT versendet — bitte manuell nachholen", + mailNotSent: "Eingangsbestätigung NICHT versendet, bitte manuell nachholen", receivedAt: "Eingegangen", processedOn: (date) => `Erledigt am ${date}`, markProcessed: "Als erledigt markieren", @@ -1402,11 +1411,11 @@ export const de: Dictionary = { deleteConfirm: (orderNumber) => `Bestellung ${orderNumber} endgültig löschen? Das lässt sich nicht rückgängig machen.`, deleteBlocked: - "Diese Bestellung kann nicht gelöscht werden — es hängt eine Zahlung oder eine Rechnung daran, oder sie ist bereits in Bearbeitung. Rechnungen und Zahlungsbelege sind aufbewahrungspflichtig. Storniere die Bestellung stattdessen: das erstattet das Geld, bucht die Ware zurück und lässt den Beleg stehen.", + "Diese Bestellung kann nicht gelöscht werden, es hängt eine Zahlung oder eine Rechnung daran, oder sie ist bereits in Bearbeitung. Rechnungen und Zahlungsbelege sind aufbewahrungspflichtig. Storniere die Bestellung stattdessen: das erstattet das Geld, bucht die Ware zurück und lässt den Beleg stehen.", }, adminOrdersPage: { reopenConfirm: - "Diese Bestellung ist storniert: Die Ware wurde zurückgebucht und eine bezahlte Summe erstattet. Beim Wiederöffnen wird die Ware erneut vom Bestand abgezogen — eine erneute Zahlung löst das NICHT aus, die musst du selbst einholen. Wirklich wieder öffnen?", + "Diese Bestellung ist storniert: Die Ware wurde zurückgebucht und eine bezahlte Summe erstattet. Beim Wiederöffnen wird die Ware erneut vom Bestand abgezogen, eine erneute Zahlung löst das NICHT aus, die musst du selbst einholen. Wirklich wieder öffnen?", backToDashboard: "Zurück zum Dashboard", title: "Bestellungen", searchLabel: "Suche (Nummer, E-Mail, Nachname)", @@ -1437,7 +1446,7 @@ export const de: Dictionary = { uploadsNotPersistentHint: (uploadDir: string) => `Alle Bilder und Videos unter ${uploadDir} gehen beim nächsten Redeploy verloren. In Kubernetes muss das Volume "uploads" ein PersistentVolumeClaim sein (kubectl apply -f k8s/04-uploads-pvc.yaml und k8s/05-app-deployment.yaml), im Docker-Betrieb ein benanntes Volume. Details in k8s/README.md, Abschnitt "Bilder/Videos verschwinden nach jedem Redeploy".`, missingUploadsHint: - "Die Datenbank kennt die Pfade noch, die Dateien selbst sind weg — im Shop erscheinen dort leere Flächen. Fast immer liegt das an einem nicht dauerhaften Upload-Verzeichnis: In Kubernetes muss das Volume \"uploads\" ein PersistentVolumeClaim sein (k8s/04-uploads-pvc.yaml), kein emptyDir, sonst geht es bei jedem Redeploy verloren. Nach dem Umstellen müssen die betroffenen Dateien einmalig neu hochgeladen werden.", + "Die Datenbank kennt die Pfade noch, die Dateien selbst sind weg, im Shop erscheinen dort leere Flächen. Fast immer liegt das an einem nicht dauerhaften Upload-Verzeichnis: In Kubernetes muss das Volume \"uploads\" ein PersistentVolumeClaim sein (k8s/04-uploads-pvc.yaml), kein emptyDir, sonst geht es bei jedem Redeploy verloren. Nach dem Umstellen müssen die betroffenen Dateien einmalig neu hochgeladen werden.", title: "Dashboard", statRevenueToday: "Umsatz heute", statRevenueMonth: "Umsatz Monat", @@ -1467,6 +1476,18 @@ export const de: Dictionary = { toDoReturns: (count) => (count === 1 ? "1 neue Retoure" : `${count} neue Retouren`), toDoReviews: (count) => (count === 1 ? "1 Bewertung wartet auf Freigabe" : `${count} Bewertungen warten auf Freigabe`), toDoInquiries: (count) => (count === 1 ? "1 neue Firmenanfrage" : `${count} neue Firmenanfragen`), + toDoStockOverdue: (count) => + count === 1 + ? "1 Bestandsbuchung seit über 15 Minuten nicht bei ApiCore angekommen" + : `${count} Bestandsbuchungen seit über 15 Minuten nicht bei ApiCore angekommen`, + toDoStockRejected: (count) => + count === 1 + ? "1 Bestandsbuchung von ApiCore abgelehnt — bitte prüfen" + : `${count} Bestandsbuchungen von ApiCore abgelehnt — bitte prüfen`, + toDoUnlinkedProducts: (count) => + count === 1 + ? "1 aktives Produkt noch nicht mit ApiCore verknüpft" + : `${count} aktive Produkte noch nicht mit ApiCore verknüpft`, allDoneTitle: "Alles erledigt.", allDoneText: "Gerade wartet nichts auf dich. Zeit für die Bienen.", chartTooltip: (day, value) => `${day}: ${value}`, @@ -1490,7 +1511,7 @@ export const de: Dictionary = { statusRequired: "Pflicht", statusOptional: "Optional", warningStripeTest: "Testschlüssel aktiv — Bestellungen sehen erfolgreich aus, es fließt aber kein Geld.", - warningStripeMixed: "Geheimer und öffentlicher Schlüssel stammen aus verschiedenen Modi (test/live) — die Kasse funktioniert so nicht.", + warningStripeMixed: "Geheimer und öffentlicher Schlüssel stammen aus verschiedenen Modi (test/live), die Kasse funktioniert so nicht.", integrationNames: { stripe: "Stripe (Zahlung)", stripeWebhook: "Stripe-Webhook (Zahlungsbestätigung)", @@ -1533,12 +1554,12 @@ export const de: Dictionary = { uploadError: "Der Upload ist fehlgeschlagen.", uploadErrorStatus: (status) => status === 413 - ? `Die Datei wurde abgewiesen, weil sie zu groß ist (HTTP 413). Sie hat den Shop gar nicht erreicht — diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN.` + ? `Die Datei wurde abgewiesen, weil sie zu groß ist (HTTP 413). Sie hat den Shop gar nicht erreicht, diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN.` : status === 502 - ? `Der Server hat die Verarbeitung abgebrochen (HTTP 502). Das Backend hat die Verbindung fallen gelassen, ohne zu antworten — häufig, weil ihm der Arbeitsspeicher ausging. Bitte informiere die Technik und nenne diese Nummer.` + ? `Der Server hat die Verarbeitung abgebrochen (HTTP 502). Das Backend hat die Verbindung fallen gelassen, ohne zu antworten, häufig, weil ihm der Arbeitsspeicher ausging. Bitte informiere die Technik und nenne diese Nummer.` : status === 503 || status === 504 || status === 522 || status === 524 ? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist eine Zeitüberschreitung: die Verarbeitung dauerte länger, als der vorgeschaltete Proxy zulässt.` - : `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`, + : `Der Server hat unerwartet geantwortet (HTTP ${status}), keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`, uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.", }, adminVideoField: { @@ -1554,7 +1575,7 @@ export const de: Dictionary = { ? `Der Server hat die Verarbeitung abgebrochen (HTTP 502). Das Backend hat die Verbindung fallen gelassen, ohne zu antworten — bei Videos meist, weil ihm beim Verarbeiten der Arbeitsspeicher ausging. Bitte informiere die Technik und nenne diese Nummer.` : status === 503 || status === 504 || status === 522 || status === 524 ? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist eine Zeitüberschreitung: der Upload dauerte länger, als der vorgeschaltete Proxy zulässt. Ein kürzeres oder kleineres Video kommt in der Regel durch.` - : `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`, + : `Der Server hat unerwartet geantwortet (HTTP ${status}), keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`, uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.", }, adminShell: { @@ -1739,7 +1760,7 @@ export const de: Dictionary = { trackOrder: "Bestellung verfolgen", openEyebrow: "Zahlung nicht abgeschlossen", openTitle: "Das hat leider nicht geklappt.", - openText: "Die Zahlung wurde abgebrochen oder abgelehnt, es wurde nichts abgebucht. Deine Bestellung ist noch für dich reserviert — versuche es einfach erneut, gern auch mit einer anderen Zahlungsart.", + openText: "Die Zahlung wurde abgebrochen oder abgelehnt, es wurde nichts abgebucht. Deine Bestellung ist noch für dich reserviert, versuche es einfach erneut, gern auch mit einer anderen Zahlungsart.", cancelPayment: "Bestellung abbrechen", notCompletedEyebrow: "Nicht abgeschlossen", notCompletedTitle: "Diese Bestellung wurde nicht bezahlt.", @@ -1869,6 +1890,10 @@ export const de: Dictionary = { "schonend verarbeitet", "ohne synthetische Zusätze", ], + SONSTIGES: [ + "aus eigener Imkerei in Altenmünster", + "nachvollziehbare Charge", + ], }, detailLabels: { description: "Beschreibung", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 6a08f90..7464577 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -8,6 +8,7 @@ export const en: Dictionary = { HONIG: "Honey", KERZEN: "Beeswax candles", PROPOLIS: "Propolis", + SONSTIGES: "More bee products", }, turnstile: { loadError: "The security check could not be loaded.", @@ -1202,6 +1203,14 @@ export const en: Dictionary = { intensityKraeftigLabel: "intense (1-5)", weightLabel: "Weight (g)", stockLabel: "Inventory (jars)", + stockFromApiCoreHint: + "Comes from ApiCore (remaining stock minus open orders). Book withdrawals and stock counts in ApiCore.", + apicoreLinkLabel: "ApiCore article", + apicoreNotLinked: "— not linked (manual stock) —", + apicoreLinkHint: + "Linked: stock comes from ApiCore (sum of all production batches); sales and returns are reported there.", + apicoreUnavailable: (message) => `Could not load ApiCore articles: ${message}`, + apicoreHoneyLinked: (bottlingId) => `Linked to ApiCore bottling ${bottlingId}.`, priceLabel: "Price (cents)", pricePerKgLabel: "Base price per kg (cents)", vatRateLabel: "VAT rate (%, optional)", @@ -1441,6 +1450,18 @@ export const en: Dictionary = { toDoReturns: (count) => (count === 1 ? "1 new return" : `${count} new returns`), toDoReviews: (count) => (count === 1 ? "1 review awaiting approval" : `${count} reviews awaiting approval`), toDoInquiries: (count) => (count === 1 ? "1 new business inquiry" : `${count} new business inquiries`), + toDoStockOverdue: (count) => + count === 1 + ? "1 stock booking not delivered to ApiCore for over 15 minutes" + : `${count} stock bookings not delivered to ApiCore for over 15 minutes`, + toDoStockRejected: (count) => + count === 1 + ? "1 stock booking rejected by ApiCore — please check" + : `${count} stock bookings rejected by ApiCore — please check`, + toDoUnlinkedProducts: (count) => + count === 1 + ? "1 active product not linked to ApiCore yet" + : `${count} active products not linked to ApiCore yet`, allDoneTitle: "All done.", allDoneText: "Nothing is waiting for you right now. Time for the bees.", chartTooltip: (day, value) => `${day}: ${value}`, @@ -1843,6 +1864,10 @@ export const en: Dictionary = { "gently processed", "no synthetic additives", ], + SONSTIGES: [ + "from our own apiary in Altenmünster", + "traceable batch", + ], }, detailLabels: { description: "Description", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 3a9ce20..04833a7 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -44,6 +44,7 @@ export interface Dictionary { HONIG: string; KERZEN: string; PROPOLIS: string; + SONSTIGES: string; }; siteDescription: string; /** Sicherheitspruefung (Cloudflare Turnstile) konnte nicht geladen werden. */ @@ -783,6 +784,12 @@ export interface Dictionary { intensityKraeftigLabel: string; weightLabel: string; stockLabel: string; + stockFromApiCoreHint: string; + apicoreLinkLabel: string; + apicoreNotLinked: string; + apicoreLinkHint: string; + apicoreUnavailable: (message: string) => string; + apicoreHoneyLinked: (bottlingId: string) => string; priceLabel: string; pricePerKgLabel: string; vatRateLabel: string; @@ -1011,6 +1018,9 @@ export interface Dictionary { toDoReturns: (count: number) => string; toDoReviews: (count: number) => string; toDoInquiries: (count: number) => string; + toDoStockOverdue: (count: number) => string; + toDoStockRejected: (count: number) => string; + toDoUnlinkedProducts: (count: number) => string; allDoneTitle: string; allDoneText: string; chartTooltip: (day: string, value: string) => string; @@ -1373,6 +1383,7 @@ export interface Dictionary { HONIG: string[]; KERZEN: string[]; PROPOLIS: string[]; + SONSTIGES: string[]; }; detailLabels: { description: string; diff --git a/src/lib/apicore-client.ts b/src/lib/apicore-client.ts index 36da82f..f41ca0b 100644 --- a/src/lib/apicore-client.ts +++ b/src/lib/apicore-client.ts @@ -115,6 +115,12 @@ const productSchema = z.object({ weight: z.number().nullable().optional(), salesUnit: z.string().nullable().optional(), status: z.string(), + // Seit dem Bestandsabgleich: wo der Bestand liegt (Honig je Abfuellung, + // hergestellte Waren je Herstellungscharge), Shop-Kategorie und Rest. + // Optional, damit ein aelteres ApiCore weiter gelesen werden kann. + kind: z.string().optional(), + shopCategory: z.string().optional(), + remainingCount: z.number().nullable().optional(), }); export type ApiCoreProduct = z.infer; @@ -136,6 +142,7 @@ const bottlingSchema = z.object({ // Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema). jarSize: z.number().nullable(), jarCount: z.number(), + remainingCount: z.number().optional(), // Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein. productId: z.string().nullable(), harvestIds: z.array(z.string()), @@ -159,17 +166,34 @@ export interface ApiCoreBottlingBatchWithHarvestId { harvestSourceId: string | null; } +const productionBatchSchema = z.object({ + id: z.string(), + productId: z.string(), + lotNumber: z.string(), + quantity: z.number(), + remainingCount: z.number(), +}); + +export type ApiCoreProductionBatch = z.infer; + const shopFeedSchema = z.object({ contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT), harvests: z.array(harvestSchema), products: z.array(productSchema), bottlings: z.array(bottlingSchema), + productionBatches: z.array(productionBatchSchema).optional(), }); export interface ApiCoreShopData { harvests: ApiCoreHarvest[]; products: ApiCoreProduct[]; bottlings: ApiCoreBottlingBatchWithHarvestId[]; + productionBatches: ApiCoreProductionBatch[]; +} + +/** Artikel, deren Bestand in ApiCore je Herstellungscharge liegt (Kerzen, Propolis, ...). */ +export function isManufactured(product: ApiCoreProduct): boolean { + return product.kind === "MANUFACTURED"; } function httpErrorMessage(status: number): string { @@ -224,5 +248,84 @@ export async function fetchApiCoreShopData(connection: ApiCoreConnection): Promi ...bottling, harvestSourceId: harvestIds[0] ?? null, })), + productionBatches: parsed.data.productionBatches ?? [], }; } + +// --------------------------------------------------------------------------- +// Bestandsabgleich: Rest lesen, Verkaeufe und Rueckbuchungen buchen +// --------------------------------------------------------------------------- + +const stockSchema = z.object({ + bottlings: z.array(z.object({ id: z.string(), remainingCount: z.number() })), + products: z.array(z.object({ id: z.string(), remainingCount: z.number() })), +}); + +export type ApiCoreStock = z.infer; + +/** Nur die Restbestaende — fuer den haeufigen Abgleich (GET /shop-sync/stock). */ +export async function fetchApiCoreStock(connection: ApiCoreConnection): Promise { + let res: Response; + try { + res = await fetch(`${connection.baseUrl}/shop-sync/stock`, { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error: unknown) { + throw new ApiCoreRequestError(`ApiCore unter ${connection.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`); + } + if (!res.ok) throw new ApiCoreRequestError(httpErrorMessage(res.status)); + const parsed = stockSchema.safeParse(await res.json()); + if (!parsed.success) throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur für Bestände."); + return parsed.data; +} + +export interface ApiCoreStockRequest { + externalId: string; + kind: "SALE" | "RETURN"; + bottlingBatchId?: string; + productId?: string; + quantity: number; + reversesExternalId?: string; +} + +const movementResultSchema = z.object({ + externalId: z.string(), + status: z.enum(["created", "duplicate", "rejected"]), + error: z.string().optional(), + allocations: z.array(z.object({ lotNumber: z.string(), quantity: z.number() })), + remainingCount: z.number().nullable(), +}); + +export type ApiCoreMovementResult = z.infer; + +/** + * Bucht Verkaeufe und Rueckbuchungen in ApiCore (POST /shop-sync/movements, + * Recht shop-sync:write). Jede Anfrage traegt eine externalId — dieselbe + * Anfrage zweimal bucht ApiCore nur einmal. + */ +export async function postApiCoreStockMovements( + connection: ApiCoreConnection, + requests: ApiCoreStockRequest[] +): Promise { + let res: Response; + try { + res = await fetch(`${connection.baseUrl}/shop-sync/movements`, { + method: "POST", + headers: { Authorization: `Bearer ${connection.apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ requests }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error: unknown) { + throw new ApiCoreRequestError(`ApiCore unter ${connection.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`); + } + if (res.status === 403) { + throw new ApiCoreRequestError( + "ApiCore verweigert das Buchen (HTTP 403): Der API-Schlüssel braucht in ApiCore zusätzlich die Berechtigung „Verkäufe und Rückbuchungen des Shops buchen“ (shop-sync:write)." + ); + } + if (!res.ok) throw new ApiCoreRequestError(httpErrorMessage(res.status)); + const parsed = z.object({ results: z.array(movementResultSchema) }).safeParse(await res.json()); + if (!parsed.success) throw new ApiCoreRequestError("ApiCore: unerwartete Antwort auf Bestandsbuchungen."); + return parsed.data.results; +} diff --git a/src/lib/apicore-stock/dashboard.ts b/src/lib/apicore-stock/dashboard.ts new file mode 100644 index 0000000..bbc3f7d --- /dev/null +++ b/src/lib/apicore-stock/dashboard.ts @@ -0,0 +1,28 @@ +import { prisma } from "@/lib/prisma"; +import { OVERDUE_AFTER_MS } from "./stock-math"; + +export interface ApiCoreStockIssues { + /** Seit ueber 15 Minuten nicht bei ApiCore angekommen. */ + overdue: number; + /** Von ApiCore abgelehnt — braucht einen Menschen. */ + rejected: number; + /** Aktive Produkte, deren Bestand noch von Hand gepflegt wird. */ + unlinked: number; +} + +/** + * Stoerungen des Bestandsabgleichs fuer das Admin-Dashboard. Ohne + * ApiCore-Konfiguration gibt es nichts zu melden. + */ +export async function countApiCoreStockIssues(now: Date = new Date()): Promise { + const configured = Boolean(process.env.APICORE_API_URL?.trim() && process.env.APICORE_API_KEY?.trim()); + if (!configured) return { overdue: 0, rejected: 0, unlinked: 0 }; + const [overdue, rejected, unlinked] = await Promise.all([ + prisma.apiCoreStockEvent.count({ + where: { sentAt: null, rejectedAt: null, createdAt: { lt: new Date(now.getTime() - OVERDUE_AFTER_MS) } }, + }), + prisma.apiCoreStockEvent.count({ where: { rejectedAt: { not: null } } }), + prisma.product.count({ where: { isActive: true, apicoreBottlingId: null, apicoreProductId: null } }), + ]); + return { overdue, rejected, unlinked }; +} diff --git a/src/lib/apicore-stock/delivery.ts b/src/lib/apicore-stock/delivery.ts new file mode 100644 index 0000000..bd3916c --- /dev/null +++ b/src/lib/apicore-stock/delivery.ts @@ -0,0 +1,121 @@ +import { prisma } from "@/lib/prisma"; +import { + ApiCoreConfigError, + getApiCoreConnection, + postApiCoreStockMovements, + type ApiCoreStockRequest, +} from "@/lib/apicore-client"; +import { nextAttemptDelayMs } from "./stock-math"; + +/** Hoechstens so viele Buchungen je Aufruf — ApiCore nimmt bis zu 100. */ +const BATCH_SIZE = 100; + +export interface DeliveryResult { + sent: number; + rejected: number; + failed: number; + /** Wabenhain-Produkte, deren Bestand danach neu abgeglichen werden sollte. */ + productIds: string[]; + /** Fehlermeldung, wenn ApiCore nicht erreichbar war oder ablehnte. */ + error?: string; +} + +/** + * Sendet faellige Buchungen der Warteschlange an ApiCore. + * + * - Bestaetigt (neu gebucht oder schon bekannt): sentAt; beim Verkauf werden + * die von ApiCore zugeordneten Chargen an der Bestellposition gespeichert. + * - Abgelehnt (z. B. Abfuellung in ApiCore geloescht): rejectedAt, kein + * weiterer Versuch — das Dashboard zeigt es zur Klaerung. + * - Nicht erreichbar / HTTP-Fehler: naechster Versuch nach 1, 5, 15, 60 Min. + * + * Eine Rueckbuchung wartet, bis ihr Verkauf bestaetigt ist (ApiCore kennt + * sonst den zurueckzubuchenden Verkauf nicht) — es sei denn, beide gehen im + * selben Aufruf raus: ApiCore verarbeitet in Reihenfolge, der Verkauf steht + * vorn. + */ +export async function deliverPendingStockEvents(now: Date = new Date()): Promise { + const result: DeliveryResult = { sent: 0, rejected: 0, failed: 0, productIds: [] }; + + let connection; + try { + connection = getApiCoreConnection(); + } catch (error: unknown) { + if (error instanceof ApiCoreConfigError) return { ...result, error: error.message }; + throw error; + } + + const due = await prisma.apiCoreStockEvent.findMany({ + where: { sentAt: null, rejectedAt: null, nextAttemptAt: { lte: now } }, + orderBy: { createdAt: "asc" }, + take: BATCH_SIZE, + include: { reverses: { select: { sentAt: true } } }, + }); + const inBatch = new Set(due.map((event) => event.id)); + const ready = due.filter( + (event) => !event.reversesEventId || event.reverses?.sentAt || inBatch.has(event.reversesEventId) + ); + if (ready.length === 0) return result; + + const requests: ApiCoreStockRequest[] = ready.map((event) => ({ + externalId: event.id, + kind: event.kind, + bottlingBatchId: event.apicoreBottlingId ?? undefined, + productId: event.apicoreProductId ?? undefined, + quantity: event.quantity, + reversesExternalId: event.reversesEventId ?? undefined, + })); + + let responses; + try { + responses = await postApiCoreStockMovements(connection, requests); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + for (const event of ready) { + await prisma.apiCoreStockEvent.update({ + where: { id: event.id }, + data: { + attempts: { increment: 1 }, + lastError: message, + nextAttemptAt: new Date(now.getTime() + nextAttemptDelayMs(event.attempts)), + }, + }); + } + return { ...result, failed: ready.length, error: message }; + } + + const byId = new Map(ready.map((event) => [event.id, event])); + for (const response of responses) { + const event = byId.get(response.externalId); + if (!event) continue; + result.productIds.push(event.productId); + if (response.status === "rejected") { + await prisma.apiCoreStockEvent.update({ + where: { id: event.id }, + data: { rejectedAt: now, lastError: response.error ?? "Von ApiCore abgelehnt" }, + }); + result.rejected++; + continue; + } + await prisma.$transaction(async (tx) => { + await tx.apiCoreStockEvent.update({ + where: { id: event.id }, + data: { sentAt: now, lastError: null }, + }); + if (event.kind === "SALE") { + await tx.orderItem.update({ + where: { id: event.orderItemId }, + data: { + apicoreAllocations: response.allocations.map((allocation) => ({ + lotNumber: allocation.lotNumber, + quantity: allocation.quantity, + })), + }, + }); + } + }); + result.sent++; + } + result.productIds = [...new Set(result.productIds)]; + return result; +} diff --git a/src/lib/apicore-stock/reconcile.ts b/src/lib/apicore-stock/reconcile.ts new file mode 100644 index 0000000..e2aff41 --- /dev/null +++ b/src/lib/apicore-stock/reconcile.ts @@ -0,0 +1,137 @@ +import { prisma } from "@/lib/prisma"; +import { + ApiCoreConfigError, + fetchApiCoreStock, + getApiCoreConnection, +} from "@/lib/apicore-client"; +import { pendingNet, shopQuantity } from "./stock-math"; + +const SERIALIZATION_CONFLICT_CODE = "P2034"; +const MAX_ATTEMPTS = 4; + +function isSerializationConflict(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === SERIALIZATION_CONFLICT_CODE + ); +} + +export interface ReconcileResult { + updated: number; + /** Verknuepft, aber in ApiCore nicht (mehr) vorhanden. */ + missing: number; + error?: string; +} + +export interface ReconcileFilter { + /** Nur diese Wabenhain-Produkte; ohne Angabe alle verknuepften. */ + productIds?: string[]; + /** Nur Produkte mit diesen ApiCore-Zielen (aus einem Webhook). */ + apicoreBottlingIds?: string[]; + apicoreProductIds?: string[]; +} + +/** + * Gleicht den Shop-Bestand verknuepfter Produkte mit ApiCore ab + * (Formel: lib/apicore-stock/stock-math.ts). + * + * Je Produkt eine eigene Transaktion mit "Serializable" — dieselbe Isolation + * wie der Checkout. Reserviert ein Checkout gleichzeitig, bricht die + * Datenbank eine der beiden ab, statt dass der Abgleich die Reservierung mit + * einem veralteten Wert ueberschreibt; abgebrochene Laeufe werden wiederholt. + */ +export async function reconcileApiCoreStock(filter: ReconcileFilter = {}): Promise { + const result: ReconcileResult = { updated: 0, missing: 0 }; + + let connection; + try { + connection = getApiCoreConnection(); + } catch (error: unknown) { + if (error instanceof ApiCoreConfigError) return { ...result, error: error.message }; + throw error; + } + + const byTarget = + filter.apicoreBottlingIds || filter.apicoreProductIds + ? [ + ...(filter.apicoreBottlingIds?.length + ? [{ apicoreBottlingId: { in: filter.apicoreBottlingIds } }] + : []), + ...(filter.apicoreProductIds?.length + ? [{ apicoreProductId: { in: filter.apicoreProductIds } }] + : []), + ] + : [{ apicoreBottlingId: { not: null } }, { apicoreProductId: { not: null } }]; + if (byTarget.length === 0) return result; + + const products = await prisma.product.findMany({ + where: { OR: byTarget, ...(filter.productIds ? { id: { in: filter.productIds } } : {}) }, + select: { id: true, apicoreBottlingId: true, apicoreProductId: true }, + }); + if (products.length === 0) return result; + + const stock = await fetchApiCoreStock(connection); + const bottlingRemaining = new Map(stock.bottlings.map((b) => [b.id, b.remainingCount])); + const productRemaining = new Map(stock.products.map((p) => [p.id, p.remainingCount])); + + for (const product of products) { + const apiRemaining = product.apicoreBottlingId + ? bottlingRemaining.get(product.apicoreBottlingId) + : productRemaining.get(product.apicoreProductId!); + if (apiRemaining === undefined) { + result.missing++; + continue; + } + await syncOneProduct(product.id, product.apicoreBottlingId, apiRemaining); + result.updated++; + } + return result; +} + +async function syncOneProduct( + productId: string, + apicoreBottlingId: string | null, + apiRemaining: number +): Promise { + for (let attempt = 1; ; attempt++) { + try { + await prisma.$transaction( + async (tx) => { + const pending = await tx.apiCoreStockEvent.findMany({ + where: { productId, sentAt: null, rejectedAt: null }, + select: { kind: true, quantity: true }, + }); + const reservedRows = await tx.orderItem.aggregate({ + where: { productId, order: { status: "NEU" } }, + _sum: { quantity: true }, + }); + const quantity = shopQuantity({ + apiRemaining, + pending, + reserved: reservedRows._sum.quantity ?? 0, + }); + await tx.inventory.upsert({ + where: { productId }, + create: { productId, quantity }, + update: { quantity }, + }); + if (apicoreBottlingId) { + // Anzeige "noch verfuegbar" an Ernte und Charge: was es in + // Wirklichkeit noch gibt, ohne die Webshop-Reservierungen. + await tx.batch.updateMany({ + where: { apicoreBottlingId }, + data: { remainingGlasses: Math.max(0, apiRemaining + pendingNet(pending)) }, + }); + } + }, + { isolationLevel: "Serializable" } + ); + return; + } catch (error: unknown) { + if (!isSerializationConflict(error) || attempt >= MAX_ATTEMPTS) throw error; + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt * (1 + Math.random()))); + } + } +} diff --git a/src/lib/apicore-stock/schedule.ts b/src/lib/apicore-stock/schedule.ts new file mode 100644 index 0000000..5913b9b --- /dev/null +++ b/src/lib/apicore-stock/schedule.ts @@ -0,0 +1,29 @@ +import { after } from "next/server"; +import { deliverPendingStockEvents } from "./delivery"; +import { reconcileApiCoreStock } from "./reconcile"; + +/** + * Sendet offene Buchungen und gleicht die betroffenen Bestaende ab — nach + * der Antwort, damit weder Kundin noch Admin auf ApiCore warten. Scheitert + * es, holt der CronJob (scripts/apicore-stock-sync.ts) es spaetestens nach + * zehn Minuten nach; verloren geht nichts, die Buchung liegt in der + * Warteschlange. + */ +export function scheduleApiCoreStockSync(productIds: string[] = []): void { + const run = async () => { + try { + const delivered = await deliverPendingStockEvents(); + const affected = [...new Set([...productIds, ...delivered.productIds])]; + if (affected.length > 0) await reconcileApiCoreStock({ productIds: affected }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + console.error(`[apicore-stock] Sofortabgleich fehlgeschlagen, der CronJob holt ihn nach: ${message}`); + } + }; + try { + after(run); + } catch { + // Ausserhalb einer Anfrage (Skripte, Tests) gibt es kein after(). + void run(); + } +} diff --git a/src/lib/apicore-stock/stock-events.ts b/src/lib/apicore-stock/stock-events.ts new file mode 100644 index 0000000..8be7f73 --- /dev/null +++ b/src/lib/apicore-stock/stock-events.ts @@ -0,0 +1,89 @@ +import type { Prisma } from "@/generated/prisma/client"; + +type Tx = Prisma.TransactionClient; + +/** + * Schreibt die Bestandsbuchungen einer Bestellung in die Warteschlange + * (ApiCoreStockEvent) — immer innerhalb der Transaktion, die den Status + * aendert, damit Statuswechsel und Buchung nur gemeinsam passieren. + * + * Ob eine Rueckbuchung noetig ist, entscheidet die Warteschlange selbst, nicht + * der Bestellstatus: eine Position mit offenem (nicht zurueckgebuchtem) + * Verkauf bekommt eine Rueckbuchung, eine ohne nicht. So bucht eine + * Stornierung einer nie bezahlten Bestellung nichts, und eine erstattete + * Retoure, nach der die Bestellung noch storniert wird, nicht doppelt. + * + * Nur Positionen, deren Produkt mit ApiCore verknuepft ist (Abfuellung oder + * Artikel), werden gebucht. Eine Rueckbuchung geht immer an das Ziel des + * Verkaufs, auch wenn das Produkt inzwischen anders verknuepft ist. + */ + +async function itemsWithStockState(tx: Tx, orderId: string) { + return tx.orderItem.findMany({ + where: { orderId }, + select: { + id: true, + quantity: true, + productId: true, + product: { select: { apicoreBottlingId: true, apicoreProductId: true } }, + apicoreStockEvents: { + where: { kind: "SALE" }, + select: { + id: true, + quantity: true, + apicoreBottlingId: true, + apicoreProductId: true, + reversedBy: { select: { id: true } }, + }, + }, + }, + }); +} + +/** Verbucht die Positionen einer gerade bezahlten Bestellung als Verkauf. */ +export async function recordOrderSale(tx: Tx, orderId: string): Promise { + const items = await itemsWithStockState(tx, orderId); + let created = 0; + for (const item of items) { + const { apicoreBottlingId, apicoreProductId } = item.product; + if (!apicoreBottlingId && !apicoreProductId) continue; + const hasOpenSale = item.apicoreStockEvents.some((sale) => sale.reversedBy.length === 0); + if (hasOpenSale) continue; + await tx.apiCoreStockEvent.create({ + data: { + kind: "SALE", + orderItemId: item.id, + productId: item.productId, + apicoreBottlingId: apicoreBottlingId ?? null, + apicoreProductId: apicoreBottlingId ? null : apicoreProductId, + quantity: item.quantity, + }, + }); + created++; + } + return created; +} + +/** Bucht jeden offenen Verkauf der Bestellung zurueck (Storno, Retoure). */ +export async function recordOrderReturn(tx: Tx, orderId: string): Promise { + const items = await itemsWithStockState(tx, orderId); + let created = 0; + for (const item of items) { + for (const sale of item.apicoreStockEvents) { + if (sale.reversedBy.length > 0) continue; + await tx.apiCoreStockEvent.create({ + data: { + kind: "RETURN", + orderItemId: item.id, + productId: item.productId, + apicoreBottlingId: sale.apicoreBottlingId, + apicoreProductId: sale.apicoreProductId, + quantity: sale.quantity, + reversesEventId: sale.id, + }, + }); + created++; + } + } + return created; +} diff --git a/src/lib/apicore-stock/stock-math.test.ts b/src/lib/apicore-stock/stock-math.test.ts new file mode 100644 index 0000000..df1685d --- /dev/null +++ b/src/lib/apicore-stock/stock-math.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { isOverdue, nextAttemptDelayMs, pendingNet, shopQuantity } from "./stock-math"; + +describe("shopQuantity", () => { + it("zieht unbestaetigte Verkaeufe und offene Reservierungen vom ApiCore-Rest ab", () => { + expect( + shopQuantity({ apiRemaining: 100, pending: [{ kind: "SALE", quantity: 2 }], reserved: 1 }) + ).toBe(97); + }); + + it("bleibt gleich, sobald ApiCore den Verkauf bestaetigt hat", () => { + expect(shopQuantity({ apiRemaining: 98, pending: [], reserved: 1 })).toBe(97); + }); + + it("zaehlt unbestaetigte Rueckbuchungen wieder hinzu", () => { + expect( + shopQuantity({ apiRemaining: 10, pending: [{ kind: "RETURN", quantity: 3 }], reserved: 0 }) + ).toBe(13); + }); + + it("wird nie negativ, auch wenn ApiCore unter den Reservierungen liegt", () => { + expect(shopQuantity({ apiRemaining: 1, pending: [], reserved: 4 })).toBe(0); + }); +}); + +describe("pendingNet", () => { + it("rechnet Verkauf minus, Rueckbuchung plus", () => { + expect( + pendingNet([ + { kind: "SALE", quantity: 5 }, + { kind: "RETURN", quantity: 2 }, + ]) + ).toBe(-3); + }); +}); + +describe("nextAttemptDelayMs", () => { + it("wartet 1, 5, 15, 60 Minuten und danach stuendlich", () => { + expect([0, 1, 2, 3, 4, 10].map((n) => nextAttemptDelayMs(n) / 60_000)).toEqual([1, 5, 15, 60, 60, 60]); + }); +}); + +describe("isOverdue", () => { + it("meldet Buchungen erst nach 15 Minuten ohne Bestaetigung", () => { + const now = new Date("2026-10-02T12:00:00Z"); + expect(isOverdue(new Date("2026-10-02T11:50:00Z"), now)).toBe(false); + expect(isOverdue(new Date("2026-10-02T11:40:00Z"), now)).toBe(true); + }); +}); diff --git a/src/lib/apicore-stock/stock-math.ts b/src/lib/apicore-stock/stock-math.ts new file mode 100644 index 0000000..07d8931 --- /dev/null +++ b/src/lib/apicore-stock/stock-math.ts @@ -0,0 +1,61 @@ +/** + * Reine Rechenregeln des Bestandsabgleichs mit ApiCore — ohne Datenbank, + * damit genau die Entscheidungen testbar sind, an denen Ueberverkauf haengt. + * + * ApiCore fuehrt den Restbestand je Charge. Der Shop-Bestand (Inventory) eines + * verknuepften Produkts ist daraus abgeleitet: + * + * Shop-Bestand = ApiCore-Rest + * + noch nicht bestaetigte Buchungen (Verkauf minus, Rueckbuchung plus) + * - in unbezahlten Bestellungen (NEU) reservierte Menge + * + * Beispiel: Rest 100, zwei Glaeser bezahlt aber noch nicht gemeldet, eines + * im Checkout reserviert -> 97. Nach der Bestaetigung: Rest 98, nichts offen + * -> weiterhin 97. Die Zahl springt also nie. + */ + +export type StockEventKind = "SALE" | "RETURN"; + +export interface PendingStockEvent { + kind: StockEventKind; + quantity: number; +} + +/** Vorzeichen einer Buchung aus Sicht des Bestands. */ +export function eventDelta(event: PendingStockEvent): number { + return event.kind === "SALE" ? -event.quantity : event.quantity; +} + +export function pendingNet(events: readonly PendingStockEvent[]): number { + return events.reduce((sum, event) => sum + eventDelta(event), 0); +} + +/** + * Was der Shop noch verkaufen darf. Nie negativ: liegt ApiCore unter den + * offenen Reservierungen (z. B. Hofladen-Entnahme waehrend eines Checkouts), + * ist schlicht nichts mehr frei — die bestehenden Reservierungen bleiben + * davon unberuehrt. + */ +export function shopQuantity(input: { + apiRemaining: number; + pending: readonly PendingStockEvent[]; + reserved: number; +}): number { + return Math.max(0, input.apiRemaining + pendingNet(input.pending) - input.reserved); +} + +/** Wartezeiten zwischen Zustellversuchen: 1, 5, 15, 60 Minuten, danach stuendlich. */ +const RETRY_DELAYS_MINUTES = [1, 5, 15, 60] as const; + +export function nextAttemptDelayMs(attemptsSoFar: number): number { + const minutes = + RETRY_DELAYS_MINUTES[Math.min(attemptsSoFar, RETRY_DELAYS_MINUTES.length - 1)]; + return minutes * 60 * 1000; +} + +/** Ab wann eine unbestaetigte Buchung im Dashboard als Stoerung gilt. */ +export const OVERDUE_AFTER_MS = 15 * 60 * 1000; + +export function isOverdue(createdAt: Date, now: Date): boolean { + return now.getTime() - createdAt.getTime() > OVERDUE_AFTER_MS; +} diff --git a/src/lib/apicore-stock/webhook-signature.test.ts b/src/lib/apicore-stock/webhook-signature.test.ts new file mode 100644 index 0000000..dadadc0 --- /dev/null +++ b/src/lib/apicore-stock/webhook-signature.test.ts @@ -0,0 +1,48 @@ +import { createHmac } from "node:crypto"; +import { describe, it, expect } from "vitest"; +import { verifyApiCoreSignature } from "./webhook-signature"; + +const SECRET = "whsec_test"; +const NOW = 1_790_000_000; + +function sign(timestamp: string, deliveryId: string, body: string, secret = SECRET): string { + return "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${deliveryId}.${body}`).digest("hex"); +} + +const body = JSON.stringify({ event: "stock.changed", data: { bottlingBatchIds: ["b1"], productIds: [] } }); + +describe("verifyApiCoreSignature", () => { + it("akzeptiert eine korrekt signierte, frische Zustellung", () => { + const ts = String(NOW); + expect( + verifyApiCoreSignature({ secret: SECRET, timestamp: ts, deliveryId: "d1", signature: sign(ts, "d1", body), body, nowSeconds: NOW }) + ).toBe(true); + }); + + it("lehnt einen veraenderten Body ab", () => { + const ts = String(NOW); + expect( + verifyApiCoreSignature({ secret: SECRET, timestamp: ts, deliveryId: "d1", signature: sign(ts, "d1", body), body: body + " ", nowSeconds: NOW }) + ).toBe(false); + }); + + it("lehnt ein falsches Secret ab", () => { + const ts = String(NOW); + expect( + verifyApiCoreSignature({ secret: SECRET, timestamp: ts, deliveryId: "d1", signature: sign(ts, "d1", body, "other"), body, nowSeconds: NOW }) + ).toBe(false); + }); + + it("lehnt eine mehr als fuenf Minuten alte Zustellung ab (Replay)", () => { + const ts = String(NOW - 301); + expect( + verifyApiCoreSignature({ secret: SECRET, timestamp: ts, deliveryId: "d1", signature: sign(ts, "d1", body), body, nowSeconds: NOW }) + ).toBe(false); + }); + + it("lehnt fehlende Kopfzeilen ab", () => { + expect( + verifyApiCoreSignature({ secret: SECRET, timestamp: null, deliveryId: "d1", signature: "sha256=00", body, nowSeconds: NOW }) + ).toBe(false); + }); +}); diff --git a/src/lib/apicore-stock/webhook-signature.ts b/src/lib/apicore-stock/webhook-signature.ts new file mode 100644 index 0000000..a2780d2 --- /dev/null +++ b/src/lib/apicore-stock/webhook-signature.ts @@ -0,0 +1,37 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Prueft die Signatur eines ApiCore-Webhooks. + * + * ApiCore signiert ".." mit HMAC-SHA256 und + * schickt das Ergebnis als "X-ApiCore-Signature: sha256=" (siehe + * ApiCore WebhooksService.deliver). Der Zeitstempel ist mitsigniert; ein + * mitgeschnittener Aufruf laesst sich deshalb nach TOLERANCE_SECONDS nicht + * wiederverwenden. + * + * Selbst ein gueltiger Webhook setzt in Wabenhain keine Zahl — er loest nur + * einen Abgleich ueber die API aus. Ein gefaelschter Aufruf koennte also + * hoechstens einen ueberfluessigen Abgleich anstossen. + */ +export const TOLERANCE_SECONDS = 5 * 60; + +export function verifyApiCoreSignature(input: { + secret: string; + timestamp: string | null; + deliveryId: string | null; + signature: string | null; + body: string; + nowSeconds?: number; +}): boolean { + const { secret, timestamp, deliveryId, signature, body } = input; + if (!secret || !timestamp || !deliveryId || !signature) return false; + if (!/^\d+$/.test(timestamp)) return false; + const now = input.nowSeconds ?? Math.floor(Date.now() / 1000); + if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false; + + const match = /^sha256=([0-9a-f]{64})$/.exec(signature); + if (!match) return false; + const expected = createHmac("sha256", secret).update(`${timestamp}.${deliveryId}.${body}`).digest(); + const received = Buffer.from(match[1], "hex"); + return received.length === expected.length && timingSafeEqual(received, expected); +} diff --git a/src/lib/k8s-manifest-env.test.ts b/src/lib/k8s-manifest-env.test.ts index b559517..f8fdb9d 100644 --- a/src/lib/k8s-manifest-env.test.ts +++ b/src/lib/k8s-manifest-env.test.ts @@ -69,6 +69,7 @@ const READ_INDIRECTLY: Record = { VAT_RATE_PERCENT_HONIG: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.", VAT_RATE_PERCENT_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.", VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.", + VAT_RATE_PERCENT_SONSTIGES: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.", }; /** diff --git a/src/lib/site-config.ts b/src/lib/site-config.ts index 6afb916..f2675ca 100644 --- a/src/lib/site-config.ts +++ b/src/lib/site-config.ts @@ -8,6 +8,7 @@ export const categoryLabels = { HONIG: "Honig", KERZEN: "Bienenwachskerzen", PROPOLIS: "Propolis", + SONSTIGES: "Weitere Bienenprodukte", } as const; export const siteConfig = { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 0534ceb..0638137 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -164,7 +164,7 @@ export const adminProductIdentitySchema = z .object({ name: z.string().trim().min(1, "Bitte einen Namen angeben."), slug: z.string().trim().default(""), - category: z.enum(["HONIG", "KERZEN", "PROPOLIS"], { message: "Unbekannte Kategorie." }), + category: z.enum(["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"], { message: "Unbekannte Kategorie." }), }) .transform((input) => ({ ...input, slug: toProductSlug(input.slug || input.name) })) .refine((input) => input.slug.length > 0, { diff --git a/src/lib/vat.ts b/src/lib/vat.ts index e4d8f14..db3a332 100644 --- a/src/lib/vat.ts +++ b/src/lib/vat.ts @@ -99,6 +99,7 @@ const CATEGORY_ENV_NAMES = { HONIG: "VAT_RATE_PERCENT_HONIG", KERZEN: "VAT_RATE_PERCENT_KERZEN", PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS", + SONSTIGES: "VAT_RATE_PERCENT_SONSTIGES", } as const; export type VatCategory = keyof typeof CATEGORY_ENV_NAMES;