feat: Produkte aus ApiCore synchronisieren
Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas. ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe. Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben: genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur gemeldet, damit der Restbestand abverkauft werden kann. Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte Eingaben landeten wortlos wieder auf dem Formular. Der Login gegen ApiCore ist jetzt von den Abfragen getrennt (openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung auskommt statt mit dreien. Die Feld-Abbildung liegt neu in lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server" zu verschwinden. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c61ae92390
commit
78c19f1b73
15 files changed
+908
-226
No files matched your search
+153
-90
@@ -3,8 +3,8 @@ import { z } from "zod";
|
||||
/**
|
||||
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
|
||||
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
|
||||
* die die "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt
|
||||
* (admin/actions/apicore-sync.ts).
|
||||
* die die Rohdaten fuer den Admin-Sync bereitstellt
|
||||
* (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
|
||||
*
|
||||
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
|
||||
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
|
||||
@@ -13,9 +13,9 @@ import { z } from "zod";
|
||||
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
|
||||
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
||||
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
|
||||
* mit ausschliesslich den Berechtigungen "harvests:read" (und optional
|
||||
* "apiaries:read", fuer Standortnamen) empfohlen statt eines echten
|
||||
* Admin-Zugangs.
|
||||
* mit ausschliesslich Lese-Berechtigungen empfohlen statt eines echten
|
||||
* Admin-Zugangs: "harvests:read", "products:read", "extractions:read",
|
||||
* "bottling:read" und optional "apiaries:read" (nur fuer Standortnamen).
|
||||
*
|
||||
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
||||
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
|
||||
@@ -23,10 +23,15 @@ import { z } from "zod";
|
||||
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
|
||||
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
|
||||
*
|
||||
* Ohne diese drei Variablen wirft fetchApiCoreHarvests()/
|
||||
* fetchApiCoreBottlingBatches() sofort einen ApiCoreConfigError — der
|
||||
* Aufrufer (apicore-sync.ts) zeigt dessen Message dem Admin an, statt die
|
||||
* Aktion stillschweigend nichts tun zu lassen.
|
||||
* Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
|
||||
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
|
||||
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
|
||||
*
|
||||
* Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt
|
||||
* (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten,
|
||||
* Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins
|
||||
* gegen dasselbe Konto — bei ApiCores Anmelde-Bremse ein unnoetiges Risiko,
|
||||
* den eigenen Service-Zugang auszusperren.
|
||||
*
|
||||
* Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT
|
||||
* ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor
|
||||
@@ -37,8 +42,6 @@ import { z } from "zod";
|
||||
* ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId
|
||||
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
|
||||
* Schleuderungen geladen (GET /extractions) und clientseitig verknuepft.
|
||||
* Das erfordert zusaetzlich die Berechtigungen "extractions:read" und
|
||||
* "bottling:read" fuer das Sync-Service-Konto.
|
||||
*/
|
||||
|
||||
export class ApiCoreConfigError extends Error {}
|
||||
@@ -49,23 +52,10 @@ const loginResponseSchema = z.union([
|
||||
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
||||
]);
|
||||
|
||||
const apiarySchema = z.object({ id: z.string(), name: z.string() });
|
||||
|
||||
const harvestSchema = z.object({
|
||||
id: z.string(),
|
||||
apiaryId: z.string(),
|
||||
date: z.string(),
|
||||
honeyType: z.string().nullable().optional(),
|
||||
honeySuperCount: z.number().nullable().optional(),
|
||||
grossWeight: z.number().nullable().optional(),
|
||||
netWeight: z.number().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
|
||||
|
||||
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
|
||||
apiaryName: string | null;
|
||||
/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
|
||||
export interface ApiCoreSession {
|
||||
readonly baseUrl: string;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
function getConfig(): { baseUrl: string; email: string; password: string } {
|
||||
@@ -80,7 +70,9 @@ function getConfig(): { baseUrl: string; email: string; password: string } {
|
||||
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password };
|
||||
}
|
||||
|
||||
async function login(baseUrl: string, email: string, password: string): Promise<string> {
|
||||
export async function openApiCoreSession(): Promise<ApiCoreSession> {
|
||||
const { baseUrl, email, password } = getConfig();
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/auth/login`, {
|
||||
@@ -104,48 +96,139 @@ async function login(baseUrl: string, email: string, password: string): Promise<
|
||||
"Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)."
|
||||
);
|
||||
}
|
||||
return parsed.data.accessToken;
|
||||
return { baseUrl, token: parsed.data.accessToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine GET-Abfrage inklusive Schema-Pruefung. Die drei Fehlerfaelle (nicht
|
||||
* erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt
|
||||
* gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche
|
||||
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
|
||||
* ApiCore-Version.
|
||||
*/
|
||||
async function getJson<T>(
|
||||
session: ApiCoreSession,
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
label: string
|
||||
): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${session.baseUrl}${path}`, {
|
||||
headers: { Authorization: `Bearer ${session.token}` },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiCoreRequestError(
|
||||
res.status === 403
|
||||
? `ApiCore: ${label} konnten nicht geladen werden (HTTP 403) — dem Sync-Konto fehlt vermutlich die noetige Leseberechtigung, siehe .env.example.`
|
||||
: `ApiCore: ${label} konnten nicht geladen werden (HTTP ${res.status}).`
|
||||
);
|
||||
}
|
||||
const parsed = schema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new ApiCoreRequestError(`ApiCore: unerwartete Antwortstruktur fuer ${label}.`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ernten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const apiarySchema = z.object({ id: z.string(), name: z.string() });
|
||||
|
||||
const harvestSchema = z.object({
|
||||
id: z.string(),
|
||||
apiaryId: z.string(),
|
||||
date: z.string(),
|
||||
honeyType: z.string().nullable().optional(),
|
||||
honeySuperCount: z.number().nullable().optional(),
|
||||
grossWeight: z.number().nullable().optional(),
|
||||
netWeight: z.number().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
|
||||
|
||||
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
|
||||
apiaryName: string | null;
|
||||
}
|
||||
|
||||
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
|
||||
async function fetchApiaryNames(baseUrl: string, token: string): Promise<Map<string, string>> {
|
||||
async function fetchApiaryNames(session: ApiCoreSession): Promise<Map<string, string>> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) return new Map();
|
||||
const parsed = z.array(apiarySchema).safeParse(await res.json());
|
||||
if (!parsed.success) return new Map();
|
||||
return new Map(parsed.data.map((a) => [a.id, a.name]));
|
||||
const apiaries = await getJson(session, "/apiaries", z.array(apiarySchema), "Standorte");
|
||||
return new Map(apiaries.map((a) => [a.id, a.name]));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryName[]> {
|
||||
const { baseUrl, email, password } = getConfig();
|
||||
const token = await login(baseUrl, email, password);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/harvests`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore: Ernten konnten nicht geladen werden (HTTP ${res.status}).`);
|
||||
}
|
||||
const parsed = z.array(harvestSchema).safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Ernten.");
|
||||
}
|
||||
|
||||
const apiaryNames = await fetchApiaryNames(baseUrl, token);
|
||||
return parsed.data.map((harvest) => ({
|
||||
export async function fetchApiCoreHarvests(
|
||||
session: ApiCoreSession
|
||||
): Promise<ApiCoreHarvestWithApiaryName[]> {
|
||||
const harvests = await getJson(session, "/harvests", z.array(harvestSchema), "Ernten");
|
||||
const apiaryNames = await fetchApiaryNames(session);
|
||||
return harvests.map((harvest) => ({
|
||||
...harvest,
|
||||
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Produkte
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* ApiCores Product (ApiCore-Backend/prisma/schema.prisma). Zwei bewusste
|
||||
* Abweichungen vom dortigen Prisma-Modell:
|
||||
*
|
||||
* - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores
|
||||
* ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein
|
||||
* dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen
|
||||
* und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den
|
||||
* einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus().
|
||||
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit
|
||||
* — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse",
|
||||
* "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil
|
||||
* ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit
|
||||
* Werten wie 500 fuehrt und sie in seiner eigenen Oberflaeche als "g"
|
||||
* ausgibt (ApiCore-Frontend .../herkunft/[lot]/page.tsx). Die Annahme ist
|
||||
* damit begruendet, aber von ApiCore nicht garantiert — deshalb ist das
|
||||
* uebernommene Gewicht am importierten Entwurf im Admin sichtbar und
|
||||
* aenderbar, bevor das Produkt ueberhaupt verkauft werden kann.
|
||||
*/
|
||||
const productSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
sku: z.string().nullable().optional(),
|
||||
packagingSize: z.number().nullable().optional(),
|
||||
weight: z.number().nullable().optional(),
|
||||
salesUnit: z.string().nullable().optional(),
|
||||
status: z.string(),
|
||||
});
|
||||
|
||||
export type ApiCoreProduct = z.infer<typeof productSchema>;
|
||||
|
||||
/**
|
||||
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED,
|
||||
* morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig"
|
||||
* behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel
|
||||
* ungeprueft als Shop-Entwurf auftaucht.
|
||||
*/
|
||||
export function isSellableStatus(status: string): boolean {
|
||||
return status === "ACTIVE";
|
||||
}
|
||||
|
||||
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
|
||||
return getJson(session, "/products", z.array(productSchema), "Produkte");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chargen (BottlingBatch, siehe Modul-Kommentar oben)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -164,6 +247,10 @@ const bottlingBatchSchema = z.object({
|
||||
date: z.string(),
|
||||
bestBeforeDate: z.string(),
|
||||
jarCount: z.number(),
|
||||
// Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein.
|
||||
// Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge
|
||||
// mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts).
|
||||
productId: z.string().nullable().optional(),
|
||||
honeyBatch: z.object({
|
||||
id: z.string(),
|
||||
extractionId: z.string(),
|
||||
@@ -185,43 +272,19 @@ export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch
|
||||
harvestSourceId: string | null;
|
||||
}
|
||||
|
||||
export async function fetchApiCoreBottlingBatches(): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
||||
const { baseUrl, email, password } = getConfig();
|
||||
const token = await login(baseUrl, email, password);
|
||||
const authHeader = { Authorization: `Bearer ${token}` };
|
||||
|
||||
let extractionsRes: Response;
|
||||
let batchesRes: Response;
|
||||
try {
|
||||
[extractionsRes, batchesRes] = await Promise.all([
|
||||
fetch(`${baseUrl}/extractions`, { headers: authHeader }),
|
||||
fetch(`${baseUrl}/bottling-batches`, { headers: authHeader }),
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||
}
|
||||
if (!extractionsRes.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore: Schleuderungen konnten nicht geladen werden (HTTP ${extractionsRes.status}).`);
|
||||
}
|
||||
if (!batchesRes.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore: Chargen konnten nicht geladen werden (HTTP ${batchesRes.status}).`);
|
||||
}
|
||||
|
||||
const parsedExtractions = z.array(extractionSchema).safeParse(await extractionsRes.json());
|
||||
if (!parsedExtractions.success) {
|
||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Schleuderungen.");
|
||||
}
|
||||
const parsedBatches = z.array(bottlingBatchSchema).safeParse(await batchesRes.json());
|
||||
if (!parsedBatches.success) {
|
||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Chargen.");
|
||||
}
|
||||
export async function fetchApiCoreBottlingBatches(
|
||||
session: ApiCoreSession
|
||||
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
||||
const [extractions, batches] = await Promise.all([
|
||||
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
|
||||
getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
|
||||
]);
|
||||
|
||||
const firstHarvestByExtraction = new Map<string, string | null>(
|
||||
parsedExtractions.data.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
|
||||
extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
|
||||
);
|
||||
|
||||
return parsedBatches.data.map((batch) => ({
|
||||
return batches.map((batch) => ({
|
||||
...batch,
|
||||
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user