import { z } from "zod"; /** * Tłumaczenie podglądu paczki (`basePreview` z offerPackage) na format * `addInventoryProduct` wg docs/BASE-KONTRAKT.md §5. Funkcja czysta — * bez DB, sieci i sekretów. Media pozostają referencjami (renditionId+SHA); * bajty `data:` dokłada dopiero wysyłka po weryfikacji SHA pliku. */ const sha = z.string().regex(/^[0-9a-f]{64}$/i); // Schematy podglądu paczki — walidują wymagane pola i odcinają dekoracje // UI (`mode`, `send`, `delivery`, `operation`), które snapshot może trzymać. export const previewTargetSchema = z.object({ inventoryId: z.number().int().positive().nullable(), categoryId: z.number().int().positive().nullable(), manufacturerId: z.number().int().positive().nullable(), warehouseId: z.string().nullable(), priceGroupId: z.number().int().positive().nullable(), allegroAccount: z.string().nullable(), allegroCategoryId: z.string().nullable(), shippingTemplateId: z.string().nullable(), variantModel: z.string().nullable(), }); const imageRefSchema = z.object({ position: z.number().int().min(0).max(15), role: z.string(), renditionId: z.string().nullable(), sha256: sha.nullable(), }); const videoRefSchema = z.object({ position: z.number().int().min(0).max(5), assetId: z.string(), clip: z.string().nullable(), sha256: sha, }); const previewParamsSchema = z.object({ parent_id: z.unknown().optional(), is_bundle: z.boolean().optional(), sku: z.string().max(50).nullable(), ean: z.string().max(32).nullable(), tax_rate: z.number().nullable(), weight: z.number().nullable().optional(), height: z.number().nullable().optional(), width: z.number().nullable().optional(), length: z.number().nullable().optional(), category_id: z.number().int().nullable(), manufacturer_id: z.number().int().nullable(), text_fields: z.record(z.string(), z.unknown()), prices: z.array(z.object({ priceGroupId: z.number().int(), grossPln: z.number() })).optional(), stock: z.array(z.object({ warehouseId: z.string(), quantity: z.number() })).optional(), images: z.array(imageRefSchema).optional(), videos: z.array(videoRefSchema).optional(), }); const channelTextSchema = z.object({ allegro: z.object({ title: z.string().nullable(), description: z.string().nullable().optional(), targetField: z.string().nullable(), parameters: z.record(z.string(), z.unknown()), }), }); export const previewItemSchema = z.object({ variantId: z.string(), side: z.string(), shade: z.string(), sku: z.string().nullable(), parameters: previewParamsSchema, channelText: channelTextSchema, }); export const previewParentSchema = z.object({ operation: z.string(), parameters: previewParamsSchema, }); export const previewBaseSchema = z.object({ method: z.literal("addInventoryProduct"), target: previewTargetSchema, parent: previewParentSchema, variants: z.array(previewItemSchema), unresolved: z.array(z.string()), }); export type PreviewBase = z.infer; export type PreviewItem = z.infer; export type PreviewParams = z.infer; export class ContractError extends Error {} function requireTarget(value: T | null | undefined, field: string): T { if (value === null || value === undefined) throw new ContractError(`Brak potwierdzonej konfiguracji ${field} w snapshotcie.`); return value; } /** klucz mapy stock: „bl_"; fakt może trzymać samo ID lub już z prefiksem */ function warehouseKey(warehouseId: string): string { return warehouseId.startsWith("bl_") ? warehouseId : `bl_${warehouseId}`; } function priceMap(prices: PreviewParams["prices"]): Record | undefined { if (!prices?.length) return undefined; const map: Record = {}; for (const p of prices) map[String(p.priceGroupId)] = p.grossPln; return map; } function stockMap(stock: PreviewParams["stock"]): Record | undefined { if (!stock?.length) return undefined; const map: Record = {}; for (const s of stock) map[warehouseKey(s.warehouseId)] = s.quantity; return map; } function channelTextFields(item: PreviewItem, target: PreviewBase["target"]): Record { const fields: Record = {}; const allegro = item.channelText.allegro; const account = target.allegroAccount; if (account && allegro.description) fields[`description|pl|${account}`] = allegro.description; if (account && allegro.title) fields[`name|pl|${account}`] = allegro.title; if (account && target.allegroCategoryId) fields[`marketplace_category_id|${account}`] = target.allegroCategoryId; return fields; } function baseParams(params: PreviewParams, target: PreviewBase["target"]): Record { const out: Record = { inventory_id: requireTarget(target.inventoryId, "base.inventory_id"), is_bundle: params.is_bundle ?? false, category_id: params.category_id ?? requireTarget(target.categoryId, "base.category_id"), manufacturer_id: params.manufacturer_id ?? requireTarget(target.manufacturerId, "base.manufacturer_id"), tax_rate: params.tax_rate, }; for (const key of ["sku", "ean", "weight", "height", "width", "length"] as const) { if (params[key] !== null && params[key] !== undefined) out[key] = params[key]; } return out; } /** * Parametry rodzica wariantów. Bez stanu i ceny — rodzic grupuje warianty * (BASE-KONTRAKT §2). Brak SKU rodzica jest błędem kontraktu. */ export function translateParentParams(base: PreviewBase): Record { const { parameters } = base.parent; const out = baseParams(parameters, base.target); if (!out.sku) throw new ContractError("Rodzic wariantów wymaga potwierdzonego faktu base.parent_sku."); return { ...out, text_fields: { ...parameters.text_fields } }; } /** * Parametry wariantu. `parent_id` zostaje null — wysyłka wstawia * zweryfikowane external_id rodzica tuż przed żądaniem. Media są * referencjami {position, renditionId/assetId, sha256}; format `data:` * powstaje dopiero w materializeMedia. */ export function translateVariantParams(item: PreviewItem, base: PreviewBase): Record { const { parameters } = item; const target = base.target; const out: Record = { ...baseParams(parameters, target), parent_id: null, text_fields: { ...parameters.text_fields, ...channelTextFields(item, target), }, }; if (!out.sku) throw new ContractError(`Wariant ${item.side}/${item.shade} nie ma SKU — potwierdź commerce.sku_pattern.`); const prices = priceMap(parameters.prices); if (prices) out.prices = prices; const stock = stockMap(parameters.stock); if (stock) out.stock = stock; if (target.allegroAccount && target.shippingTemplateId) { out.shipping_template = { [target.allegroAccount]: target.shippingTemplateId }; } if (parameters.images?.length) out.images = parameters.images; if (parameters.videos?.length) out.videos = parameters.videos; return out; } export const MAX_IMAGE_BYTES = 2 * 1024 * 1024 - 4096; export const MAX_VIDEO_BYTES = 15 * 1024 * 1024; /** * Zamienia referencje mediów na format transportowy `data:`. * `loadImage`/`loadVideo` czytają plik po zweryfikowaniu SHA-256 zapisanym * w pozycji outboxu — wysyłka nigdy nie podeśle zmienionej treści. */ export async function materializeMedia( params: Record, loadImage: (ref: { renditionId: string; sha256: string }) => Promise<{ data: string; bytes: number }>, loadVideo: (ref: { assetId: string; sha256: string }) => Promise<{ data: string; bytes: number }> ): Promise> { const out = { ...params }; if (Array.isArray(out.images)) { const map: Record = {}; for (const ref of out.images as Array<{ position: number; renditionId: string | null; sha256: string | null }>) { if (!ref.renditionId || !ref.sha256) throw new ContractError(`Pozycja zdjęcia ${ref.position} nie ma renditionu z SHA — paczka jest niekompletna.`); const media = await loadImage({ renditionId: ref.renditionId, sha256: ref.sha256 }); if (media.bytes > MAX_IMAGE_BYTES) throw new ContractError(`Zdjęcie w pozycji ${ref.position} przekracza limit ~2 MB transportu Base.`); map[String(ref.position)] = `data:${media.data}`; } out.images = map; } if (Array.isArray(out.videos)) { const map: Record = {}; for (const ref of out.videos as Array<{ position: number; assetId: string; sha256: string }>) { const media = await loadVideo({ assetId: ref.assetId, sha256: ref.sha256 }); if (media.bytes > MAX_VIDEO_BYTES) throw new ContractError(`Klip w pozycji ${ref.position} przekracza limit 15 MB wideo Base.`); map[String(ref.position)] = `data:${media.data}`; } out.videos = map; } return out; }