feat(stock): keep shop stock in sync with ApiCore, write sales back
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s

ApiCore now owns the remaining stock per batch; the shop sells from it
and reports every paid sale and every return back.

- Outbox apicore_stock_events, written in the same transaction as the
  status change: paid -> SALE, cancelled-after-payment / refunded return
  -> RETURN onto the batches of the sale, reopened paid order -> SALE.
  Whether a return is due is decided by the open sales in the outbox,
  so unpaid orders never book and nothing is booked twice.
- Delivery right after the response (next/server after()) plus CronJob
  wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff;
  ApiCore deduplicates by event id. Rejected bookings stop and show up
  on the dashboard.
- Reconcile: shop stock = ApiCore remaining - unconfirmed bookings -
  reservations of unpaid orders, in a serializable transaction like the
  checkout. Triggered after bookings, by the signed ApiCore webhook
  stock.changed (ids only, numbers are fetched) and by the sync button.
- Manufactured goods (candles, propolis products, ...): one shop product
  per ApiCore article via Product.apicoreProductId, drafts created by the
  sync, existing products linkable in the product form; new category
  SONSTIGES. Stock field is read-only for linked products.
- OrderItem.apicoreAllocations keeps which lots were sold (recalls).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-05 21:16:54 +02:00
1 parent 641afa1ae4
commit 93b589ff04
39 files changed
+1400 -37

No files matched your search

+8
View File
@@ -100,6 +100,12 @@ SMTP_FROM="Wabenhain <[email protected]>"
# Details siehe src/lib/apicore-client.ts. # Details siehe src/lib/apicore-client.ts.
APICORE_API_URL= APICORE_API_URL=
APICORE_API_KEY= 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://<shop>/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) — # DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im # 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_HONIG=7
VAT_RATE_PERCENT_KERZEN=19 VAT_RATE_PERCENT_KERZEN=19
VAT_RATE_PERCENT_PROPOLIS=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 # Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8. # (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
+22 -3
View File
@@ -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. 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. 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. 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. - 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. - 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-<ApiCore-ID>` 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`. **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://<shop>/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 ## Vollständiger Docker-Betrieb
```bash ```bash
+2
View File
@@ -93,6 +93,8 @@ data:
VAT_RATE_PERCENT_HONIG: "7" VAT_RATE_PERCENT_HONIG: "7"
VAT_RATE_PERCENT_KERZEN: "19" VAT_RATE_PERCENT_KERZEN: "19"
VAT_RATE_PERCENT_PROPOLIS: "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 -> # Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit # Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
+6
View File
@@ -109,6 +109,12 @@ stringData:
# Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml. # Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml.
APICORE_API_KEY: "" APICORE_API_KEY: ""
# Secret des ApiCore-Webhooks "stock.changed" (ApiCore -> Automatisierung ->
# Webhooks, Ziel https://<shop>/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 # DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet # .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
# "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox # "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox
+52
View File
@@ -248,3 +248,55 @@ spec:
# Derselbe Wert wie beim App-Container: der Lauf laedt # Derselbe Wert wie beim App-Container: der Lauf laedt
# denselben Prisma-Client samt Treiber. # denselben Prisma-Client samt Treiber.
memory: 512Mi 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
+2 -1
View File
@@ -19,7 +19,8 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"preflight": "tsx scripts/preflight.ts", "preflight": "tsx scripts/preflight.ts",
"places:find": "tsx scripts/find-place-id.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": { "dependencies": {
"@prisma/adapter-mariadb": "^7.10.0", "@prisma/adapter-mariadb": "^7.10.0",
@@ -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);
+64
View File
@@ -212,6 +212,7 @@ enum ProductCategory {
HONIG HONIG
KERZEN KERZEN
PROPOLIS PROPOLIS
SONSTIGES
} }
model Product { model Product {
@@ -235,6 +236,12 @@ model Product {
/// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID /// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
/// richten. /// richten.
apicoreBottlingId String? @unique 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) /// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
harvestId String? harvestId String?
batchId String? batchId String?
@@ -280,6 +287,7 @@ model Product {
inventory Inventory? inventory Inventory?
orderItems OrderItem[] orderItems OrderItem[]
reviews Review[] reviews Review[]
apicoreStockEvents ApiCoreStockEvent[]
wishlistItems WishlistItem[] wishlistItems WishlistItem[]
stockNotifications StockNotification[] stockNotifications StockNotification[]
@@ -525,9 +533,16 @@ model OrderItem {
/// feststellbar ist, welcher Satz gegolten haette. /// feststellbar ist, welcher Satz gegolten haette.
vatRatePercent Int? 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) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict) product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], 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, /// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
/// Rechnung) ueber die order.items-Relation abgefragt. /// Rechnung) ueber die order.items-Relation abgefragt.
@@ -551,6 +566,55 @@ model OrderItem {
@@map("order_items") @@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 { enum PaymentStatus {
PENDING PENDING
SUCCEEDED SUCCEEDED
+45
View File
@@ -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<number> {
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);
});
+99 -2
View File
@@ -8,6 +8,7 @@ import {
getApiCoreConnection, getApiCoreConnection,
fetchApiCoreShopData, fetchApiCoreShopData,
isSellableStatus, isSellableStatus,
isManufactured,
ApiCoreConfigError, ApiCoreConfigError,
ApiCoreRequestError, ApiCoreRequestError,
type ApiCoreShopData, type ApiCoreShopData,
@@ -23,10 +24,14 @@ import {
buildProductDescription, buildProductDescription,
deriveJarWeightGrams, deriveJarWeightGrams,
productSlugCandidates, productSlugCandidates,
slugifyProductName,
harvestFactChanges, harvestFactChanges,
batchFactChanges, batchFactChanges,
} from "@/lib/apicore-mapping"; } from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared"; 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 * 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 * niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand
* hinschauen muss. * 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<ProductSyncCounts> {
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<string | null> { async function firstFreeSlug(candidates: string[]): Promise<string | null> {
for (const slug of candidates) { for (const slug of candidates) {
const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } }); const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
@@ -531,8 +603,16 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`, `${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
}); });
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen. // 3. Produkte — Honig je Abfuellung ein Jahrgang, hergestellte Waren je Artikel.
const products = await syncProducts(source.products, source.bottlings); 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, { await logAdminAction(admin, {
action: "product.sync_apicore", action: "product.sync_apicore",
entityType: "Product", entityType: "Product",
@@ -541,6 +621,23 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
`${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`, `${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/ernten");
revalidatePath("/admin/chargen"); revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte"); revalidatePath("/admin/produkte");
+14
View File
@@ -19,6 +19,8 @@ import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared"; 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"; import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
const ORDER_STATUSES = [ const ORDER_STATUSES = [
@@ -185,6 +187,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) { if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
await restockOrderItems(tx, previous.items); 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) { } else if (reopening) {
// Gegenbuchung zur Stornierung: die dabei zurueckgebuchte Ware wird // Gegenbuchung zur Stornierung: die dabei zurueckgebuchte Ware wird
@@ -202,6 +208,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Wirft InsufficientStockError, wenn die Ware inzwischen verkauft ist — // Wirft InsufficientStockError, wenn die Ware inzwischen verkauft ist —
// die Transaktion nimmt dann auch den Statuswechsel zurueck. // die Transaktion nimmt dann auch den Statuswechsel zurueck.
await reserveOrderItems(tx, previous.items); await reserveOrderItems(tx, previous.items);
// Eine bezahlte Bestellung ist damit wieder verkauft.
if (previous.payment?.status === "SUCCEEDED") {
await recordOrderSale(tx, orderId);
}
} }
} else { } else {
// Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die // Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die
@@ -245,6 +255,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
throw error; throw error;
}); });
if (result && !("insufficientStock" in result)) {
scheduleApiCoreStockSync(result.items.map((item) => item.productId));
}
if (result && "insufficientStock" in result) { if (result && "insufficientStock" in result) {
return fail( return fail(
detailPath, detailPath,
+37 -1
View File
@@ -10,6 +10,7 @@ import { planInventoryEdit } from "@/lib/inventory";
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash"; import { succeed } from "@/lib/flash";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
/** /**
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis * Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
@@ -213,7 +214,25 @@ export async function updateProductAction(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") === "on"; const isActive = formData.get("isActive") === "on";
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents); 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") { if (inventoryPlan.kind === "reload") {
return fail( return fail(
`/admin/produkte/${id}`, `/admin/produkte/${id}`,
@@ -244,10 +263,27 @@ export async function updateProductAction(formData: FormData): Promise<void> {
vatRatePercent: numbers.vatRatePercent, vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath), images: strArray(formData, "images").filter(isValidUploadPath),
isActive, 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) { if (!product) {
const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } }); const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } });
return fail( return fail(
+10
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel"; import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status"; import { pendingReviewWhere } from "@/lib/review-status";
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
import { COUNTED_SALE_WHERE } from "@/lib/sales"; import { COUNTED_SALE_WHERE } from "@/lib/sales";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -259,6 +260,12 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
const revenueTrend = [...revenueByDay.entries()]; const revenueTrend = [...revenueByDay.entries()];
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v)); 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([ const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
getCurrentAdmin(), getCurrentAdmin(),
prisma.order.count({ 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: waitingReturns, href: "/admin/retouren", label: t.adminDashboardPage.toDoReturns(waitingReturns) },
{ count: waitingReviews, href: "/admin/bewertungen", label: t.adminDashboardPage.toDoReviews(waitingReviews) }, { count: waitingReviews, href: "/admin/bewertungen", label: t.adminDashboardPage.toDoReviews(waitingReviews) },
{ count: waitingInquiries, href: "/admin/anfragen", label: t.adminDashboardPage.toDoInquiries(waitingInquiries) }, { 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); ].filter((item) => item.count > 0);
// Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC). // Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
+63
View File
@@ -9,9 +9,41 @@ import {
AdminFieldset, AdminFieldset,
AdminFormPanel, AdminFormPanel,
AdminHint, AdminHint,
AdminSelect,
AdminSwitch, AdminSwitch,
AdminTextarea, AdminTextarea,
} from "@/components/admin-form"; } 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 { updateProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -30,6 +62,8 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
tasteTags: asStringArray(rawProduct.tasteTags), tasteTags: asStringArray(rawProduct.tasteTags),
images: asStringArray(rawProduct.images), images: asStringArray(rawProduct.images),
}; };
const stockFromApiCore = Boolean(product.apicoreBottlingId || product.apicoreProductId);
const linkable = product.apicoreBottlingId ? null : await linkableApiCoreArticles(product.id);
return ( return (
<div className="max-w-2xl"> <div className="max-w-2xl">
@@ -93,8 +127,37 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
type="number" type="number"
defaultValue={String(product.inventory?.quantity ?? 0)} defaultValue={String(product.inventory?.quantity ?? 0)}
required required
readOnly={stockFromApiCore}
hint={stockFromApiCore ? te.stockFromApiCoreHint : undefined}
/> />
</div> </div>
{product.apicoreBottlingId ? (
<AdminHint>{te.apicoreHoneyLinked(product.apicoreBottlingId)}</AdminHint>
) : linkable?.error ? (
<>
<input type="hidden" name="apicoreProductId" value={product.apicoreProductId ?? ""} />
<AdminHint>{te.apicoreUnavailable(linkable.error)}</AdminHint>
</>
) : (
<AdminSelect
name="apicoreProductId"
label={te.apicoreLinkLabel}
defaultValue={product.apicoreProductId ?? ""}
hint={te.apicoreLinkHint}
>
<option value="">{te.apicoreNotLinked}</option>
{(linkable?.articles ?? []).map((article) => (
<option key={article.id} value={article.id}>
{article.name}
{article.sku ? ` (${article.sku})` : ""}
</option>
))}
{product.apicoreProductId &&
!(linkable?.articles ?? []).some((article) => article.id === product.apicoreProductId) ? (
<option value={product.apicoreProductId}>{product.apicoreProductId}</option>
) : null}
</AdminSelect>
)}
<div className="grid grid-cols-2 items-end gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<AdminField <AdminField
name="priceCents" name="priceCents"
+3 -2
View File
@@ -45,11 +45,12 @@ export default async function AdminProductNewPage() {
<option value="HONIG">{t.common.categories.HONIG}</option> <option value="HONIG">{t.common.categories.HONIG}</option>
<option value="KERZEN">{t.common.categories.KERZEN}</option> <option value="KERZEN">{t.common.categories.KERZEN}</option>
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option> <option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
<option value="SONSTIGES">{t.common.categories.SONSTIGES}</option>
</AdminSelect> </AdminSelect>
{/* Ernte/Charge gibt es nur bei Honig. Ausgeblendet wird ueber :has() {/* Ernte/Charge gibt es nur bei Honig. Ausgeblendet wird ueber :has()
ohne Client-JS; ohne :has()-Unterstuetzung bleibt alles sichtbar ohne Client-JS; ohne :has()-Unterstuetzung bleibt alles sichtbar
und der Server ignoriert die Auswahl ohnehin (products.ts). */} und der Server ignoriert die Auswahl ohnehin (products.ts). */}
<div className="grid gap-4 sm:grid-cols-2 group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden"> <div className="grid gap-4 sm:grid-cols-2 group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden group-has-[option[value=SONSTIGES]:checked]/product:hidden">
<AdminSelect name="harvestId" label={tn.harvestLabel} defaultValue=""> <AdminSelect name="harvestId" label={tn.harvestLabel} defaultValue="">
<option value="">{tn.noneOption}</option> <option value="">{tn.noneOption}</option>
{harvests.map((h) => ( {harvests.map((h) => (
@@ -72,7 +73,7 @@ export default async function AdminProductNewPage() {
<AdminFieldset <AdminFieldset
legend={tn.sectionTaste} legend={tn.sectionTaste}
className="group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden" className="group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden group-has-[option[value=SONSTIGES]:checked]/product:hidden"
> >
<AdminField name="consistency" label={tn.consistencyLabel} placeholder={tn.consistencyPlaceholder} /> <AdminField name="consistency" label={tn.consistencyLabel} placeholder={tn.consistencyPlaceholder} />
<div className="grid items-end gap-4 sm:grid-cols-3"> <div className="grid items-end gap-4 sm:grid-cols-3">
+6
View File
@@ -9,6 +9,8 @@ import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail"; import { sendMail } from "@/lib/mail";
import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@/lib/email-templates"; import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@/lib/email-templates";
import { restockOrderItems } from "@/lib/inventory"; 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"; import { isLocale, defaultLocale } from "@/i18n/config";
function str(formData: FormData, key: string): string { function str(formData: FormData, key: string): string {
@@ -114,7 +116,11 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog // Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
// zur Stornierung im Checkout-Webhook. // zur Stornierung im Checkout-Webhook.
await restockOrderItems(tx, returnRequest.order.items); await restockOrderItems(tx, returnRequest.order.items);
// Die zurueckgesendete Ware geht auch in ApiCore zurueck auf die
// Chargen, aus denen sie verkauft wurde.
await recordOrderReturn(tx, returnRequest.orderId);
}); });
scheduleApiCoreStockSync(returnRequest.order.items.map((item) => item.productId));
await logAdminAction(admin, { await logAdminAction(admin, {
action: "return_request.refund", action: "return_request.refund",
+82
View File
@@ -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<NextResponse> {
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 });
}
@@ -80,6 +80,18 @@ const db = {
vi.mock("@/lib/prisma", () => ({ prisma: 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 { POST } = await import("./route");
const ORDER_ID = "order_1"; const ORDER_ID = "order_1";
+10
View File
@@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
import { formatInvoiceNumber } from "@/lib/invoice-number"; import { formatInvoiceNumber } from "@/lib/invoice-number";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type Stripe from "stripe"; 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 * 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<void> {
}); });
} }
// 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; return updated;
}); });
@@ -330,6 +338,8 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
// (z.B. bereits storniert) — nichts weiter zu tun. // (z.B. bereits storniert) — nichts weiter zu tun.
if (!order) return; if (!order) return;
scheduleApiCoreStockSync(order.items.map((item) => item.productId));
// Abgleich zwischen dem, was wir berechnet haben, und dem, was Stripe // Abgleich zwischen dem, was wir berechnet haben, und dem, was Stripe
// tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle // tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle
// (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt // (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt
+2 -1
View File
@@ -44,6 +44,7 @@ export default async function ShopPage(props: PageProps<"/shop">) {
{ value: "HONIG", label: t.common.categories.HONIG }, { value: "HONIG", label: t.common.categories.HONIG },
{ value: "KERZEN", label: t.common.categories.KERZEN }, { value: "KERZEN", label: t.common.categories.KERZEN },
{ value: "PROPOLIS", label: t.common.categories.PROPOLIS }, { value: "PROPOLIS", label: t.common.categories.PROPOLIS },
{ value: "SONSTIGES", label: t.common.categories.SONSTIGES },
] as const; ] as const;
const sortOptions: { value: ProductSort | ""; label: string }[] = [ 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 { q, kategorie, sortierung, verfuegbar } = await props.searchParams;
const query = typeof q === "string" ? q : undefined; const query = typeof q === "string" ? q : undefined;
const category = const category =
typeof kategorie === "string" && ["HONIG", "KERZEN", "PROPOLIS"].includes(kategorie) typeof kategorie === "string" && ["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"].includes(kategorie)
? (kategorie as ProductCategory) ? (kategorie as ProductCategory)
: undefined; : undefined;
const sort = const sort =
+5 -1
View File
@@ -15,6 +15,7 @@ export function AdminField({
required = false, required = false,
min, min,
hint, hint,
readOnly = false,
}: { }: {
name: string; name: string;
label: string; label: string;
@@ -25,6 +26,8 @@ export function AdminField({
min?: number; min?: number;
/** Kurzer Zusatz unter dem Feld, statt ihn in die Beschriftung zu packen. */ /** Kurzer Zusatz unter dem Feld, statt ihn in die Beschriftung zu packen. */
hint?: string; hint?: string;
/** Wird angezeigt und mitgeschickt, laesst sich aber nicht aendern. */
readOnly?: boolean;
}) { }) {
return ( return (
<label className="block text-sm"> <label className="block text-sm">
@@ -36,7 +39,8 @@ export function AdminField({
placeholder={placeholder} placeholder={placeholder}
required={required} required={required}
min={min} min={min}
className={inputClass} readOnly={readOnly}
className={readOnly ? `${inputClass} bg-ink/5 text-ink/70` : inputClass}
/> />
{hint ? <span className="mt-1 block text-xs text-ink/65">{hint}</span> : null} {hint ? <span className="mt-1 block text-xs text-ink/65">{hint}</span> : null}
</label> </label>
+1
View File
@@ -21,6 +21,7 @@ const categoryTone: Record<ProductCategory, "forest" | "cream" | "honey"> = {
HONIG: "honey", HONIG: "honey",
KERZEN: "forest", KERZEN: "forest",
PROPOLIS: "cream", PROPOLIS: "cream",
SONSTIGES: "cream",
}; };
/** /**
+50 -25
View File
@@ -8,6 +8,7 @@ export const de: Dictionary = {
HONIG: "Honig", HONIG: "Honig",
KERZEN: "Bienenwachskerzen", KERZEN: "Bienenwachskerzen",
PROPOLIS: "Propolis", PROPOLIS: "Propolis",
SONSTIGES: "Weitere Bienenprodukte",
}, },
turnstile: { turnstile: {
loadError: "Die Sicherheitsprüfung konnte nicht geladen werden.", loadError: "Die Sicherheitsprüfung konnte nicht geladen werden.",
@@ -16,7 +17,7 @@ export const de: Dictionary = {
}, },
pageOutro: { pageOutro: {
ariaLabel: "Weiterlesen", 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.", 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.", originText: "Wo unsere Bienenvölker stehen und was die Landschaft um Altenmünster prägt.",
}, },
@@ -73,7 +74,7 @@ export const de: Dictionary = {
}, },
orderStatusBadge: { orderStatusBadge: {
// Nicht "Neu": dieser Status bedeutet, dass die Zahlung noch nicht // 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", NEU: "Zahlung ausstehend",
BEZAHLT: "Bezahlt", BEZAHLT: "Bezahlt",
IN_VORBEREITUNG: "In Vorbereitung", 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.", 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.", 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", 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", policyLink: "Zur Widerrufsbelehrung",
footerButton: "Vertrag widerrufen", footerButton: "Vertrag widerrufen",
}, },
@@ -474,7 +475,7 @@ export const de: Dictionary = {
// Wortlaut aus der amtlichen Muster-Widerrufsbelehrung (Anlage 1 zu // Wortlaut aus der amtlichen Muster-Widerrufsbelehrung (Anlage 1 zu
// Art. 246a § 1 Abs. 2 EGBGB), bewusst unveraendert uebernommen. // Art. 246a § 1 Abs. 2 EGBGB), bewusst unveraendert uebernommen.
// Ohne diese Information traegt nach § 357 Abs. 6 BGB der Verkaeufer // 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 // dafuer, sie dem Kaeufer auferlegen zu duerfen. Die Ausnahme fuer
// mangelhafte Ware steht bewusst NICHT hier: das ist Gewaehrleistung // mangelhafte Ware steht bewusst NICHT hier: das ist Gewaehrleistung
// (§ 439 BGB) und damit ein anderes Recht als der Widerruf; eine // (§ 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.", pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen. Dabei fallen keine Versandkosten an.",
regionHeading: "Liefergebiet", 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.", 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", 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.", 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", 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, // 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 // lib/checkout-release.ts, store/*, components/cookie-consent.tsx). Wer dort
// einen Namen, eine Laufzeit oder einen neuen Speicherort einfuehrt, muss // 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. // auf das Endgeraet, nicht nur fuer Cookies. Gleiches gilt fuer en.ts.
sections: [ sections: [
{ {
@@ -549,7 +550,7 @@ export const de: Dictionary = {
{ {
heading: "Überblick", heading: "Überblick",
paragraphs: [ 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.", "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", statusRejected: "Abgelehnt",
reject: "Ablehnen", reject: "Ablehnen",
rejectConfirm: "Diese Bewertung ablehnen? Sie wird nicht im Shop angezeigt und landet im Archiv.", 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", columnApproved: "Akzeptiert",
columnRejected: "Abgelehnt", columnRejected: "Abgelehnt",
rejectedOn: (date) => `Abgelehnt am ${date}`, rejectedOn: (date) => `Abgelehnt am ${date}`,
@@ -1049,8 +1050,8 @@ export const de: Dictionary = {
glassCountLabel: "Anzahl Gläser", glassCountLabel: "Anzahl Gläser",
remainingGlassesLabel: "Verbleibender Bestand", remainingGlassesLabel: "Verbleibender Bestand",
imagesLabel: "Chargen-Bilder", imagesLabel: "Chargen-Bilder",
image1Label: "Bild 1 — linke Kachel auf der Chargenseite", image1Label: "Bild 1, linke Kachel auf der Chargenseite",
image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)", image2Label: "Bild 2, rechte Kachel auf der Chargenseite (Standort)",
save: "Speichern", save: "Speichern",
}, },
trackOrderPage: { trackOrderPage: {
@@ -1162,8 +1163,8 @@ export const de: Dictionary = {
locationLabel: "Standort", locationLabel: "Standort",
descriptionLabel: "Beschreibung", descriptionLabel: "Beschreibung",
imagesLabel: "Erntebilder", imagesLabel: "Erntebilder",
image1Label: "Bild 1 — Ernten-Übersicht und linke Kachel auf der Chargenseite", image1Label: "Bild 1, Ernten-Übersicht und linke Kachel auf der Chargenseite",
image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)", image2Label: "Bild 2, rechte Kachel auf der Chargenseite (Standort)",
isPublishedLabel: "Öffentlich sichtbar", isPublishedLabel: "Öffentlich sichtbar",
isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.", 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.", 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) => resultProducts: (created, skipped, failed) =>
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`, `Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
pending: (count) => 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) => 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) => 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.`, `${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) => stockConflicts: (count) =>
@@ -1228,6 +1229,14 @@ export const de: Dictionary = {
intensityKraeftigLabel: "kräftig (1-5)", intensityKraeftigLabel: "kräftig (1-5)",
weightLabel: "Gewicht (g)", weightLabel: "Gewicht (g)",
stockLabel: "Lagerbestand (Gläser)", 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)", priceLabel: "Preis (Cent)",
pricePerKgLabel: "Grundpreis pro kg (Cent)", pricePerKgLabel: "Grundpreis pro kg (Cent)",
vatRateLabel: "Umsatzsteuersatz (%, optional)", vatRateLabel: "Umsatzsteuersatz (%, optional)",
@@ -1306,9 +1315,9 @@ export const de: Dictionary = {
statusOpen: "Offen", statusOpen: "Offen",
statusProcessed: "Erledigt", statusProcessed: "Erledigt",
matched: "Bestellung zugeordnet", matched: "Bestellung zugeordnet",
unmatched: "Keine passende Bestellung — bitte prüfen", unmatched: "Keine passende Bestellung, bitte prüfen",
mailSent: "Eingangsbestätigung versendet", mailSent: "Eingangsbestätigung versendet",
mailNotSent: "Eingangsbestätigung NICHT versendet — bitte manuell nachholen", mailNotSent: "Eingangsbestätigung NICHT versendet, bitte manuell nachholen",
receivedAt: "Eingegangen", receivedAt: "Eingegangen",
processedOn: (date) => `Erledigt am ${date}`, processedOn: (date) => `Erledigt am ${date}`,
markProcessed: "Als erledigt markieren", markProcessed: "Als erledigt markieren",
@@ -1402,11 +1411,11 @@ export const de: Dictionary = {
deleteConfirm: (orderNumber) => deleteConfirm: (orderNumber) =>
`Bestellung ${orderNumber} endgültig löschen? Das lässt sich nicht rückgängig machen.`, `Bestellung ${orderNumber} endgültig löschen? Das lässt sich nicht rückgängig machen.`,
deleteBlocked: 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: { adminOrdersPage: {
reopenConfirm: 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", backToDashboard: "Zurück zum Dashboard",
title: "Bestellungen", title: "Bestellungen",
searchLabel: "Suche (Nummer, E-Mail, Nachname)", searchLabel: "Suche (Nummer, E-Mail, Nachname)",
@@ -1437,7 +1446,7 @@ export const de: Dictionary = {
uploadsNotPersistentHint: (uploadDir: string) => 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".`, `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: 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", title: "Dashboard",
statRevenueToday: "Umsatz heute", statRevenueToday: "Umsatz heute",
statRevenueMonth: "Umsatz Monat", statRevenueMonth: "Umsatz Monat",
@@ -1467,6 +1476,18 @@ export const de: Dictionary = {
toDoReturns: (count) => (count === 1 ? "1 neue Retoure" : `${count} neue Retouren`), toDoReturns: (count) => (count === 1 ? "1 neue Retoure" : `${count} neue Retouren`),
toDoReviews: (count) => (count === 1 ? "1 Bewertung wartet auf Freigabe" : `${count} Bewertungen warten auf Freigabe`), toDoReviews: (count) => (count === 1 ? "1 Bewertung wartet auf Freigabe" : `${count} Bewertungen warten auf Freigabe`),
toDoInquiries: (count) => (count === 1 ? "1 neue Firmenanfrage" : `${count} neue Firmenanfragen`), 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.", allDoneTitle: "Alles erledigt.",
allDoneText: "Gerade wartet nichts auf dich. Zeit für die Bienen.", allDoneText: "Gerade wartet nichts auf dich. Zeit für die Bienen.",
chartTooltip: (day, value) => `${day}: ${value}`, chartTooltip: (day, value) => `${day}: ${value}`,
@@ -1490,7 +1511,7 @@ export const de: Dictionary = {
statusRequired: "Pflicht", statusRequired: "Pflicht",
statusOptional: "Optional", statusOptional: "Optional",
warningStripeTest: "Testschlüssel aktiv — Bestellungen sehen erfolgreich aus, es fließt aber kein Geld.", 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: { integrationNames: {
stripe: "Stripe (Zahlung)", stripe: "Stripe (Zahlung)",
stripeWebhook: "Stripe-Webhook (Zahlungsbestätigung)", stripeWebhook: "Stripe-Webhook (Zahlungsbestätigung)",
@@ -1533,12 +1554,12 @@ export const de: Dictionary = {
uploadError: "Der Upload ist fehlgeschlagen.", uploadError: "Der Upload ist fehlgeschlagen.",
uploadErrorStatus: (status) => uploadErrorStatus: (status) =>
status === 413 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 : 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 : 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 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.", uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
}, },
adminVideoField: { 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.` ? `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 : 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 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.", uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
}, },
adminShell: { adminShell: {
@@ -1739,7 +1760,7 @@ export const de: Dictionary = {
trackOrder: "Bestellung verfolgen", trackOrder: "Bestellung verfolgen",
openEyebrow: "Zahlung nicht abgeschlossen", openEyebrow: "Zahlung nicht abgeschlossen",
openTitle: "Das hat leider nicht geklappt.", 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", cancelPayment: "Bestellung abbrechen",
notCompletedEyebrow: "Nicht abgeschlossen", notCompletedEyebrow: "Nicht abgeschlossen",
notCompletedTitle: "Diese Bestellung wurde nicht bezahlt.", notCompletedTitle: "Diese Bestellung wurde nicht bezahlt.",
@@ -1869,6 +1890,10 @@ export const de: Dictionary = {
"schonend verarbeitet", "schonend verarbeitet",
"ohne synthetische Zusätze", "ohne synthetische Zusätze",
], ],
SONSTIGES: [
"aus eigener Imkerei in Altenmünster",
"nachvollziehbare Charge",
],
}, },
detailLabels: { detailLabels: {
description: "Beschreibung", description: "Beschreibung",
+25
View File
@@ -8,6 +8,7 @@ export const en: Dictionary = {
HONIG: "Honey", HONIG: "Honey",
KERZEN: "Beeswax candles", KERZEN: "Beeswax candles",
PROPOLIS: "Propolis", PROPOLIS: "Propolis",
SONSTIGES: "More bee products",
}, },
turnstile: { turnstile: {
loadError: "The security check could not be loaded.", loadError: "The security check could not be loaded.",
@@ -1202,6 +1203,14 @@ export const en: Dictionary = {
intensityKraeftigLabel: "intense (1-5)", intensityKraeftigLabel: "intense (1-5)",
weightLabel: "Weight (g)", weightLabel: "Weight (g)",
stockLabel: "Inventory (jars)", 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)", priceLabel: "Price (cents)",
pricePerKgLabel: "Base price per kg (cents)", pricePerKgLabel: "Base price per kg (cents)",
vatRateLabel: "VAT rate (%, optional)", vatRateLabel: "VAT rate (%, optional)",
@@ -1441,6 +1450,18 @@ export const en: Dictionary = {
toDoReturns: (count) => (count === 1 ? "1 new return" : `${count} new returns`), toDoReturns: (count) => (count === 1 ? "1 new return" : `${count} new returns`),
toDoReviews: (count) => (count === 1 ? "1 review awaiting approval" : `${count} reviews awaiting approval`), toDoReviews: (count) => (count === 1 ? "1 review awaiting approval" : `${count} reviews awaiting approval`),
toDoInquiries: (count) => (count === 1 ? "1 new business inquiry" : `${count} new business inquiries`), 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.", allDoneTitle: "All done.",
allDoneText: "Nothing is waiting for you right now. Time for the bees.", allDoneText: "Nothing is waiting for you right now. Time for the bees.",
chartTooltip: (day, value) => `${day}: ${value}`, chartTooltip: (day, value) => `${day}: ${value}`,
@@ -1843,6 +1864,10 @@ export const en: Dictionary = {
"gently processed", "gently processed",
"no synthetic additives", "no synthetic additives",
], ],
SONSTIGES: [
"from our own apiary in Altenmünster",
"traceable batch",
],
}, },
detailLabels: { detailLabels: {
description: "Description", description: "Description",
+11
View File
@@ -44,6 +44,7 @@ export interface Dictionary {
HONIG: string; HONIG: string;
KERZEN: string; KERZEN: string;
PROPOLIS: string; PROPOLIS: string;
SONSTIGES: string;
}; };
siteDescription: string; siteDescription: string;
/** Sicherheitspruefung (Cloudflare Turnstile) konnte nicht geladen werden. */ /** Sicherheitspruefung (Cloudflare Turnstile) konnte nicht geladen werden. */
@@ -783,6 +784,12 @@ export interface Dictionary {
intensityKraeftigLabel: string; intensityKraeftigLabel: string;
weightLabel: string; weightLabel: string;
stockLabel: string; stockLabel: string;
stockFromApiCoreHint: string;
apicoreLinkLabel: string;
apicoreNotLinked: string;
apicoreLinkHint: string;
apicoreUnavailable: (message: string) => string;
apicoreHoneyLinked: (bottlingId: string) => string;
priceLabel: string; priceLabel: string;
pricePerKgLabel: string; pricePerKgLabel: string;
vatRateLabel: string; vatRateLabel: string;
@@ -1011,6 +1018,9 @@ export interface Dictionary {
toDoReturns: (count: number) => string; toDoReturns: (count: number) => string;
toDoReviews: (count: number) => string; toDoReviews: (count: number) => string;
toDoInquiries: (count: number) => string; toDoInquiries: (count: number) => string;
toDoStockOverdue: (count: number) => string;
toDoStockRejected: (count: number) => string;
toDoUnlinkedProducts: (count: number) => string;
allDoneTitle: string; allDoneTitle: string;
allDoneText: string; allDoneText: string;
chartTooltip: (day: string, value: string) => string; chartTooltip: (day: string, value: string) => string;
@@ -1373,6 +1383,7 @@ export interface Dictionary {
HONIG: string[]; HONIG: string[];
KERZEN: string[]; KERZEN: string[];
PROPOLIS: string[]; PROPOLIS: string[];
SONSTIGES: string[];
}; };
detailLabels: { detailLabels: {
description: string; description: string;
+103
View File
@@ -115,6 +115,12 @@ const productSchema = z.object({
weight: z.number().nullable().optional(), weight: z.number().nullable().optional(),
salesUnit: z.string().nullable().optional(), salesUnit: z.string().nullable().optional(),
status: z.string(), 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<typeof productSchema>; export type ApiCoreProduct = z.infer<typeof productSchema>;
@@ -136,6 +142,7 @@ const bottlingSchema = z.object({
// Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema). // Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema).
jarSize: z.number().nullable(), jarSize: z.number().nullable(),
jarCount: z.number(), jarCount: z.number(),
remainingCount: z.number().optional(),
// Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein. // Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein.
productId: z.string().nullable(), productId: z.string().nullable(),
harvestIds: z.array(z.string()), harvestIds: z.array(z.string()),
@@ -159,17 +166,34 @@ export interface ApiCoreBottlingBatchWithHarvestId {
harvestSourceId: string | null; 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<typeof productionBatchSchema>;
const shopFeedSchema = z.object({ const shopFeedSchema = z.object({
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT), contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
harvests: z.array(harvestSchema), harvests: z.array(harvestSchema),
products: z.array(productSchema), products: z.array(productSchema),
bottlings: z.array(bottlingSchema), bottlings: z.array(bottlingSchema),
productionBatches: z.array(productionBatchSchema).optional(),
}); });
export interface ApiCoreShopData { export interface ApiCoreShopData {
harvests: ApiCoreHarvest[]; harvests: ApiCoreHarvest[];
products: ApiCoreProduct[]; products: ApiCoreProduct[];
bottlings: ApiCoreBottlingBatchWithHarvestId[]; 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 { function httpErrorMessage(status: number): string {
@@ -224,5 +248,84 @@ export async function fetchApiCoreShopData(connection: ApiCoreConnection): Promi
...bottling, ...bottling,
harvestSourceId: harvestIds[0] ?? null, 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<typeof stockSchema>;
/** Nur die Restbestaende — fuer den haeufigen Abgleich (GET /shop-sync/stock). */
export async function fetchApiCoreStock(connection: ApiCoreConnection): Promise<ApiCoreStock> {
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<typeof movementResultSchema>;
/**
* 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<ApiCoreMovementResult[]> {
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;
}
+28
View File
@@ -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<ApiCoreStockIssues> {
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 };
}
+121
View File
@@ -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<DeliveryResult> {
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;
}
+137
View File
@@ -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<ReconcileResult> {
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<void> {
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())));
}
}
}
+29
View File
@@ -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();
}
}
+89
View File
@@ -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<number> {
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<number> {
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;
}
+49
View File
@@ -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);
});
});
+61
View File
@@ -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;
}
@@ -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);
});
});
@@ -0,0 +1,37 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Prueft die Signatur eines ApiCore-Webhooks.
*
* ApiCore signiert "<Zeitstempel>.<Zustell-ID>.<Body>" mit HMAC-SHA256 und
* schickt das Ergebnis als "X-ApiCore-Signature: sha256=<hex>" (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);
}
+1
View File
@@ -69,6 +69,7 @@ const READ_INDIRECTLY: Record<string, string> = {
VAT_RATE_PERCENT_HONIG: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.", 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_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_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_SONSTIGES: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
}; };
/** /**
+1
View File
@@ -8,6 +8,7 @@ export const categoryLabels = {
HONIG: "Honig", HONIG: "Honig",
KERZEN: "Bienenwachskerzen", KERZEN: "Bienenwachskerzen",
PROPOLIS: "Propolis", PROPOLIS: "Propolis",
SONSTIGES: "Weitere Bienenprodukte",
} as const; } as const;
export const siteConfig = { export const siteConfig = {
+1 -1
View File
@@ -164,7 +164,7 @@ export const adminProductIdentitySchema = z
.object({ .object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."), name: z.string().trim().min(1, "Bitte einen Namen angeben."),
slug: z.string().trim().default(""), 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) })) .transform((input) => ({ ...input, slug: toProductSlug(input.slug || input.name) }))
.refine((input) => input.slug.length > 0, { .refine((input) => input.slug.length > 0, {
+1
View File
@@ -99,6 +99,7 @@ const CATEGORY_ENV_NAMES = {
HONIG: "VAT_RATE_PERCENT_HONIG", HONIG: "VAT_RATE_PERCENT_HONIG",
KERZEN: "VAT_RATE_PERCENT_KERZEN", KERZEN: "VAT_RATE_PERCENT_KERZEN",
PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS", PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS",
SONSTIGES: "VAT_RATE_PERCENT_SONSTIGES",
} as const; } as const;
export type VatCategory = keyof typeof CATEGORY_ENV_NAMES; export type VatCategory = keyof typeof CATEGORY_ENV_NAMES;