feat(stock): keep shop stock in sync with ApiCore, write sales back
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
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:
1 parent
641afa1ae4
commit
93b589ff04
39 files changed
+1400
-37
No files matched your search
@@ -115,6 +115,12 @@ const productSchema = z.object({
|
||||
weight: z.number().nullable().optional(),
|
||||
salesUnit: z.string().nullable().optional(),
|
||||
status: z.string(),
|
||||
// Seit dem Bestandsabgleich: wo der Bestand liegt (Honig je Abfuellung,
|
||||
// hergestellte Waren je Herstellungscharge), Shop-Kategorie und Rest.
|
||||
// Optional, damit ein aelteres ApiCore weiter gelesen werden kann.
|
||||
kind: z.string().optional(),
|
||||
shopCategory: z.string().optional(),
|
||||
remainingCount: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type ApiCoreProduct = z.infer<typeof productSchema>;
|
||||
@@ -136,6 +142,7 @@ const bottlingSchema = z.object({
|
||||
// Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema).
|
||||
jarSize: z.number().nullable(),
|
||||
jarCount: z.number(),
|
||||
remainingCount: z.number().optional(),
|
||||
// Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein.
|
||||
productId: z.string().nullable(),
|
||||
harvestIds: z.array(z.string()),
|
||||
@@ -159,17 +166,34 @@ export interface ApiCoreBottlingBatchWithHarvestId {
|
||||
harvestSourceId: string | null;
|
||||
}
|
||||
|
||||
const productionBatchSchema = z.object({
|
||||
id: z.string(),
|
||||
productId: z.string(),
|
||||
lotNumber: z.string(),
|
||||
quantity: z.number(),
|
||||
remainingCount: z.number(),
|
||||
});
|
||||
|
||||
export type ApiCoreProductionBatch = z.infer<typeof productionBatchSchema>;
|
||||
|
||||
const shopFeedSchema = z.object({
|
||||
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
|
||||
harvests: z.array(harvestSchema),
|
||||
products: z.array(productSchema),
|
||||
bottlings: z.array(bottlingSchema),
|
||||
productionBatches: z.array(productionBatchSchema).optional(),
|
||||
});
|
||||
|
||||
export interface ApiCoreShopData {
|
||||
harvests: ApiCoreHarvest[];
|
||||
products: ApiCoreProduct[];
|
||||
bottlings: ApiCoreBottlingBatchWithHarvestId[];
|
||||
productionBatches: ApiCoreProductionBatch[];
|
||||
}
|
||||
|
||||
/** Artikel, deren Bestand in ApiCore je Herstellungscharge liegt (Kerzen, Propolis, ...). */
|
||||
export function isManufactured(product: ApiCoreProduct): boolean {
|
||||
return product.kind === "MANUFACTURED";
|
||||
}
|
||||
|
||||
function httpErrorMessage(status: number): string {
|
||||
@@ -224,5 +248,84 @@ export async function fetchApiCoreShopData(connection: ApiCoreConnection): Promi
|
||||
...bottling,
|
||||
harvestSourceId: harvestIds[0] ?? null,
|
||||
})),
|
||||
productionBatches: parsed.data.productionBatches ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bestandsabgleich: Rest lesen, Verkaeufe und Rueckbuchungen buchen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stockSchema = z.object({
|
||||
bottlings: z.array(z.object({ id: z.string(), remainingCount: z.number() })),
|
||||
products: z.array(z.object({ id: z.string(), remainingCount: z.number() })),
|
||||
});
|
||||
|
||||
export type ApiCoreStock = z.infer<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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
||||
VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
||||
VAT_RATE_PERCENT_SONSTIGES: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ export const categoryLabels = {
|
||||
HONIG: "Honig",
|
||||
KERZEN: "Bienenwachskerzen",
|
||||
PROPOLIS: "Propolis",
|
||||
SONSTIGES: "Weitere Bienenprodukte",
|
||||
} as const;
|
||||
|
||||
export const siteConfig = {
|
||||
|
||||
@@ -164,7 +164,7 @@ export const adminProductIdentitySchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
slug: z.string().trim().default(""),
|
||||
category: z.enum(["HONIG", "KERZEN", "PROPOLIS"], { message: "Unbekannte Kategorie." }),
|
||||
category: z.enum(["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"], { message: "Unbekannte Kategorie." }),
|
||||
})
|
||||
.transform((input) => ({ ...input, slug: toProductSlug(input.slug || input.name) }))
|
||||
.refine((input) => input.slug.length > 0, {
|
||||
|
||||
@@ -99,6 +99,7 @@ const CATEGORY_ENV_NAMES = {
|
||||
HONIG: "VAT_RATE_PERCENT_HONIG",
|
||||
KERZEN: "VAT_RATE_PERCENT_KERZEN",
|
||||
PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS",
|
||||
SONSTIGES: "VAT_RATE_PERCENT_SONSTIGES",
|
||||
} as const;
|
||||
|
||||
export type VatCategory = keyof typeof CATEGORY_ENV_NAMES;
|
||||
|
||||
Reference in new issue
Block a user