feat(apicore-sync): read from ApiCore's dedicated shop feed
The sync now fetches everything from ApiCore's GET /shop-sync in one call: an allowlisted feed (harvest facts, article master data, bottlings) that never contains internal notes and needs only the single permission "shop-sync:read" instead of read access to harvests, extractions etc. - Contract version is checked; an unknown version stops the run with a clear message and writes nothing. Extra fields are ignored. - One consistent snapshot, processed only after it arrived completely: a failed fetch writes nothing (no more half state of new harvests and old batches); the batch-load-error path is gone. - Falls back to the previous per-resource endpoints when ApiCore answers 404, so both packages can be deployed independently. - .env.example documents the reduced permission. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d38f4303ff
commit
5642f0c4af
8 files changed
+224
-65
No files matched your search
@@ -6,13 +6,11 @@ import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import {
|
||||
openApiCoreSession,
|
||||
fetchApiCoreHarvests,
|
||||
fetchApiCoreProducts,
|
||||
fetchApiCoreBottlingBatches,
|
||||
fetchApiCoreShopData,
|
||||
isSellableStatus,
|
||||
ApiCoreConfigError,
|
||||
ApiCoreRequestError,
|
||||
type ApiCoreSession,
|
||||
type ApiCoreShopData,
|
||||
type ApiCoreHarvestWithApiaryName,
|
||||
type ApiCoreProduct,
|
||||
type ApiCoreBottlingBatchWithHarvestId,
|
||||
@@ -472,32 +470,23 @@ function returnTargetFrom(formData: FormData): string {
|
||||
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
||||
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
||||
*
|
||||
* Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
|
||||
* zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
|
||||
*
|
||||
* Ein Fehlschlag beim Laden der Chargen ist bewusst nicht fatal: die bereits
|
||||
* uebernommenen Ernten sollen sichtbar bleiben. Ohne Abfuellungen entfallen
|
||||
* dann allerdings auch die Produkte, denn ohne sie gibt es keine Jahrgaenge.
|
||||
* Alle Daten kommen aus EINEM Abruf (ApiCore GET /shop-sync) und werden erst
|
||||
* danach verarbeitet: scheitert der Abruf, wird gar nichts geschrieben — es
|
||||
* entsteht nie ein halber Stand aus neuen Ernten und alten Chargen.
|
||||
*/
|
||||
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const returnTo = returnTargetFrom(formData);
|
||||
|
||||
let session: ApiCoreSession;
|
||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
||||
let articles: ApiCoreProduct[];
|
||||
let source: ApiCoreShopData;
|
||||
try {
|
||||
session = await openApiCoreSession();
|
||||
[sourceHarvests, articles] = await Promise.all([
|
||||
fetchApiCoreHarvests(session),
|
||||
fetchApiCoreProducts(session),
|
||||
]);
|
||||
source = await fetchApiCoreShopData(await openApiCoreSession());
|
||||
} catch (error: unknown) {
|
||||
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
||||
}
|
||||
|
||||
// 1. Ernten
|
||||
const harvests = await syncHarvests(sourceHarvests);
|
||||
const harvests = await syncHarvests(source.harvests);
|
||||
await logAdminAction(admin, {
|
||||
action: "harvest.sync_apicore",
|
||||
entityType: "Harvest",
|
||||
@@ -506,29 +495,18 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
||||
});
|
||||
|
||||
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
||||
let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
|
||||
let batchLoadError: string | null = null;
|
||||
try {
|
||||
bottlings = await fetchApiCoreBottlingBatches(session);
|
||||
} catch (error: unknown) {
|
||||
batchLoadError = describeError(error);
|
||||
}
|
||||
|
||||
const batches: BatchSyncCounts = batchLoadError
|
||||
? { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 }
|
||||
: await syncBatches(bottlings);
|
||||
// 2. Chargen
|
||||
const batches = await syncBatches(source.bottlings);
|
||||
await logAdminAction(admin, {
|
||||
action: "batch.sync_apicore",
|
||||
entityType: "Batch",
|
||||
detail: batchLoadError
|
||||
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
||||
: `${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
|
||||
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
|
||||
detail:
|
||||
`${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
|
||||
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
|
||||
});
|
||||
|
||||
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
|
||||
const products = await syncProducts(articles, bottlings);
|
||||
const products = await syncProducts(source.products, source.bottlings);
|
||||
await logAdminAction(admin, {
|
||||
action: "product.sync_apicore",
|
||||
entityType: "Product",
|
||||
@@ -547,7 +525,6 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||
`&bNew=${batches.created}&bUpdated=${batches.updated}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||
`&bConflicts=${batches.stockConflicts}` +
|
||||
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
||||
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}`
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user