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

+99 -2
View File
@@ -8,6 +8,7 @@ import {
getApiCoreConnection,
fetchApiCoreShopData,
isSellableStatus,
isManufactured,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreShopData,
@@ -23,10 +24,14 @@ import {
buildProductDescription,
deriveJarWeightGrams,
productSlugCandidates,
slugifyProductName,
harvestFactChanges,
batchFactChanges,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared";
import { deliverPendingStockEvents } from "@/lib/apicore-stock/delivery";
import { reconcileApiCoreStock } from "@/lib/apicore-stock/reconcile";
import type { ProductCategory } from "@/generated/prisma/client";
/**
* Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
@@ -363,6 +368,73 @@ async function syncProducts(
* niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand
* hinschauen muss.
*/
const SHOP_CATEGORIES: readonly ProductCategory[] = ["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"];
function shopCategoryOf(article: ApiCoreProduct): ProductCategory {
const category = article.shopCategory as ProductCategory | undefined;
return category && SHOP_CATEGORIES.includes(category) && category !== "HONIG" ? category : "SONSTIGES";
}
/**
* Hergestellte Waren (Kerzen, Propolis-Tinkturen, Salben, ...): EIN
* Shop-Produkt je ApiCore-Artikel, nicht je Charge wie beim Honig. Kundinnen
* kaufen "Bienenwachskerze klein", nicht eine bestimmte Charge — welche Charge
* verkauft wird, entscheidet ApiCore (aelteste zuerst) und meldet es zurueck.
*
* Gleiche Regeln wie bei Honig-Entwuerfen: deaktiviert, ohne Preis, nie
* aktualisiert. Ein schon in Wabenhain gepflegtes Produkt verknuepft man
* im Admin ("ApiCore-Artikel verknuepfen") statt es neu anlegen zu lassen.
*/
async function syncManufacturedProducts(articles: ApiCoreProduct[]): Promise<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> {
for (const slug of candidates) {
const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
@@ -531,8 +603,16 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
});
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
const products = await syncProducts(source.products, source.bottlings);
// 3. Produkte — Honig je Abfuellung ein Jahrgang, hergestellte Waren je Artikel.
const honeyProducts = await syncProducts(source.products, source.bottlings);
const manufacturedProducts = await syncManufacturedProducts(source.products);
const products: ProductSyncCounts = {
created: honeyProducts.created + manufacturedProducts.created,
skipped: honeyProducts.skipped + manufacturedProducts.skipped,
failed: honeyProducts.failed + manufacturedProducts.failed,
pending: honeyProducts.pending,
discontinued: honeyProducts.discontinued + manufacturedProducts.discontinued,
};
await logAdminAction(admin, {
action: "product.sync_apicore",
entityType: "Product",
@@ -541,6 +621,23 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
`${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`,
});
// 4. Bestand — offene Buchungen senden, dann alle verknuepften Produkte
// auf den ApiCore-Rest setzen. Scheitert das, bleibt der Sync trotzdem
// gueltig; der CronJob holt den Abgleich nach.
try {
const delivered = await deliverPendingStockEvents();
const reconciled = await reconcileApiCoreStock();
await logAdminAction(admin, {
action: "stock.sync_apicore",
entityType: "Inventory",
detail:
`${delivered.sent} Buchungen bestätigt, ${delivered.rejected} abgelehnt, ${delivered.failed} offen; ` +
`${reconciled.updated} Bestände abgeglichen, ${reconciled.missing} in ApiCore nicht gefunden`,
});
} catch (error: unknown) {
console.error(`[apicore-sync] Bestandsabgleich fehlgeschlagen: ${describeError(error)}`);
}
revalidatePath("/admin/ernten");
revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte");
+14
View File
@@ -19,6 +19,8 @@ import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared";
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
const ORDER_STATUSES = [
@@ -185,6 +187,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
await restockOrderItems(tx, previous.items);
}
// War die Bestellung bezahlt, steht ein Verkauf in ApiCore — der
// wird zurueckgebucht. Unbezahlte Bestellungen haben keinen, dann
// passiert hier nichts.
await recordOrderReturn(tx, orderId);
}
} else if (reopening) {
// Gegenbuchung zur Stornierung: die dabei zurueckgebuchte Ware wird
@@ -202,6 +208,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Wirft InsufficientStockError, wenn die Ware inzwischen verkauft ist —
// die Transaktion nimmt dann auch den Statuswechsel zurueck.
await reserveOrderItems(tx, previous.items);
// Eine bezahlte Bestellung ist damit wieder verkauft.
if (previous.payment?.status === "SUCCEEDED") {
await recordOrderSale(tx, orderId);
}
}
} else {
// Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die
@@ -245,6 +255,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
throw error;
});
if (result && !("insufficientStock" in result)) {
scheduleApiCoreStockSync(result.items.map((item) => item.productId));
}
if (result && "insufficientStock" in result) {
return fail(
detailPath,
+37 -1
View File
@@ -10,6 +10,7 @@ import { planInventoryEdit } from "@/lib/inventory";
import type { Prisma } from "@/generated/prisma/client";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
/**
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
@@ -213,7 +214,25 @@ export async function updateProductAction(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") === "on";
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
const inventoryPlan = planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity });
const current = await prisma.product.findUnique({
where: { id },
select: { apicoreBottlingId: true, apicoreProductId: true },
});
if (!current) return fail("/admin/produkte", "Produkt nicht gefunden.");
// Verknuepfung mit einem ApiCore-Artikel (nur hergestellte Waren; Honig
// haengt fest an seiner Abfuellung). Feld fehlt = unveraendert lassen.
const requestedLink = formData.has("apicoreProductId") ? strOrNull(formData, "apicoreProductId") : undefined;
const apicoreProductId = current.apicoreBottlingId ? current.apicoreProductId : requestedLink;
const linkChanged = apicoreProductId !== undefined && apicoreProductId !== current.apicoreProductId;
const stockFromApiCore = Boolean(current.apicoreBottlingId || (apicoreProductId ?? current.apicoreProductId));
// Bei verknuepften Produkten gehoert der Bestand ApiCore: ein Wert aus dem
// Formular wird nie geschrieben, auch wenn jemand das gesperrte Feld per
// Entwicklerwerkzeug aendert.
const inventoryPlan = stockFromApiCore
? ({ kind: "keep" } as const)
: planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity });
if (inventoryPlan.kind === "reload") {
return fail(
`/admin/produkte/${id}`,
@@ -244,10 +263,27 @@ export async function updateProductAction(formData: FormData): Promise<void> {
vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive,
...(linkChanged ? { apicoreProductId } : {}),
},
});
}).catch((error: unknown) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return "link-taken" as const;
}
throw error;
});
if (product === "link-taken") {
return fail(
`/admin/produkte/${id}`,
"Dieser ApiCore-Artikel ist bereits mit einem anderen Produkt verknüpft. Nichts wurde gespeichert."
);
}
if (linkChanged && apicoreProductId) {
// Bestand sofort aus ApiCore holen, nicht erst beim naechsten CronJob.
scheduleApiCoreStockSync([id]);
}
if (!product) {
const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } });
return fail(
+10
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status";
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
import { COUNTED_SALE_WHERE } from "@/lib/sales";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
@@ -259,6 +260,12 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
const revenueTrend = [...revenueByDay.entries()];
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
// Bestandsabgleich mit ApiCore (nur wenn eingerichtet).
const stockIssues = await countApiCoreStockIssues();
const stockOverdue = stockIssues.overdue;
const stockRejected = stockIssues.rejected;
const unlinkedProducts = stockIssues.unlinked;
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
getCurrentAdmin(),
prisma.order.count({
@@ -273,6 +280,9 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
{ count: waitingReturns, href: "/admin/retouren", label: t.adminDashboardPage.toDoReturns(waitingReturns) },
{ count: waitingReviews, href: "/admin/bewertungen", label: t.adminDashboardPage.toDoReviews(waitingReviews) },
{ count: waitingInquiries, href: "/admin/anfragen", label: t.adminDashboardPage.toDoInquiries(waitingInquiries) },
{ count: stockRejected, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoStockRejected(stockRejected) },
{ count: stockOverdue, href: "/admin", label: t.adminDashboardPage.toDoStockOverdue(stockOverdue) },
{ count: unlinkedProducts, href: "/admin/produkte", label: t.adminDashboardPage.toDoUnlinkedProducts(unlinkedProducts) },
].filter((item) => item.count > 0);
// Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
+63
View File
@@ -9,9 +9,41 @@ import {
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSelect,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import {
fetchApiCoreShopData,
getApiCoreConnection,
isManufactured,
type ApiCoreProduct,
} from "@/lib/apicore-client";
/**
* ApiCore-Artikel, mit denen sich dieses Produkt verknuepfen laesst: nur
* hergestellte Waren (Honig haengt an der Abfuellung) und nur solche, die
* kein anderes Produkt schon belegt. Ist ApiCore nicht erreichbar, bleibt
* die Verknuepfung, wie sie ist — die Seite selbst funktioniert weiter.
*/
async function linkableApiCoreArticles(
productId: string
): Promise<{ articles: ApiCoreProduct[]; error: string | null }> {
try {
const feed = await fetchApiCoreShopData(getApiCoreConnection());
const taken = await prisma.product.findMany({
where: { apicoreProductId: { not: null }, NOT: { id: productId } },
select: { apicoreProductId: true },
});
const takenIds = new Set(taken.map((row) => row.apicoreProductId));
return {
articles: feed.products.filter((article) => isManufactured(article) && !takenIds.has(article.id)),
error: null,
};
} catch (error: unknown) {
return { articles: [], error: error instanceof Error ? error.message : "Unbekannter Fehler" };
}
}
import { updateProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -30,6 +62,8 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
tasteTags: asStringArray(rawProduct.tasteTags),
images: asStringArray(rawProduct.images),
};
const stockFromApiCore = Boolean(product.apicoreBottlingId || product.apicoreProductId);
const linkable = product.apicoreBottlingId ? null : await linkableApiCoreArticles(product.id);
return (
<div className="max-w-2xl">
@@ -93,8 +127,37 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
type="number"
defaultValue={String(product.inventory?.quantity ?? 0)}
required
readOnly={stockFromApiCore}
hint={stockFromApiCore ? te.stockFromApiCoreHint : undefined}
/>
</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">
<AdminField
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="KERZEN">{t.common.categories.KERZEN}</option>
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
<option value="SONSTIGES">{t.common.categories.SONSTIGES}</option>
</AdminSelect>
{/* Ernte/Charge gibt es nur bei Honig. Ausgeblendet wird ueber :has()
ohne Client-JS; ohne :has()-Unterstuetzung bleibt alles sichtbar
und der Server ignoriert die Auswahl ohnehin (products.ts). */}
<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="">
<option value="">{tn.noneOption}</option>
{harvests.map((h) => (
@@ -72,7 +73,7 @@ export default async function AdminProductNewPage() {
<AdminFieldset
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} />
<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 { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@/lib/email-templates";
import { restockOrderItems } from "@/lib/inventory";
import { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { isLocale, defaultLocale } from "@/i18n/config";
function str(formData: FormData, key: string): string {
@@ -114,7 +116,11 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
// zur Stornierung im Checkout-Webhook.
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, {
action: "return_request.refund",