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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user