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

@@ -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);