import { z } from "zod"; import type { Db } from "../db.js"; import type { AppConfig } from "../config.js"; import { nowIso, tx } from "../db.js"; import { newId, ID_PREFIX } from "./ids.js"; import { audit } from "./audit.js"; import { getFacts, type PublicFact } from "./facts.js"; import { listVariants, listShades, type VariantRow } from "./fabric.js"; import { buildMediaReview, contentHash, OFFER_ROLES, type MediaReview } from "./mediaReview.js"; import { buildOfferContent, listContentRevisions, type ChannelContent } from "./offerContent.js"; import { offerProfile, type ProductProfile } from "./productProfile.js"; import { BASE_RENDITION_PROFILE, buildRenditionManifest } from "./renditions.js"; import { getAppSetting } from "./settings.js"; export class PackageConflict extends Error {} const PACKAGE_SCHEMA = "offer-package-v1"; const REQUIRED_EVIDENCE_FACTS = [ "care.instructions", "gpsr.manufacturer", "gpsr.responsible_person", "gpsr.safety_information", ] as const; const REQUIRED_BASE_FACTS = [ "base.inventory_id", "base.category_id", "base.manufacturer_id", "base.warehouse_id", "base.price_group_id", "base.variant_model", "base.allegro_account", "base.shipping_template_id", ] as const; export const saveSnapshotSchema = z.object({ expectedSourceSha256: z.string().regex(/^[0-9a-f]{64}$/i), expectedSnapshotSha256: z.string().regex(/^[0-9a-f]{64}$/i), note: z.string().trim().min(3).max(2000), }); export const snapshotDecisionSchema = z.object({ expectedSha256: z.string().regex(/^[0-9a-f]{64}$/i), decision: z.enum(["approved", "rejected"]), note: z.string().trim().min(3).max(2000), }); interface SnapshotRow { id: string; product_id: string; version: number; source_sha256: string; snapshot_sha256: string; payload_json: string; created_by: string; note: string; created_at: string; } interface SnapshotDecisionRow { id: string; snapshot_id: string; snapshot_sha256: string; decision: "approved" | "rejected"; decided_by: string; note: string; created_at: string; } interface SourceAssetRow { id: string; role: string; sha256: string; mime: string; bytes: number; original_name: string; note: string | null; created_at: string; } interface VideoAssetRow { id: string; role: string; sha256: string; mime: string; bytes: number; status: string; provenance_json: string; created_at: string; } function factValue(facts: PublicFact[], key: string): unknown { return facts.find((f) => f.key === key && f.status === "confirmed" && !!f.sourceRef)?.value; } function hasConfirmedFact(facts: PublicFact[], key: string): boolean { const fact = facts.find((f) => f.key === key); return fact?.status === "confirmed" && fact.value !== null && !!fact.sourceRef; } function numberFact(facts: PublicFact[], key: string): number | null { const value = factValue(facts, key); return typeof value === "number" && Number.isFinite(value) ? value : null; } function textFact(facts: PublicFact[], key: string): string | null { const value = factValue(facts, key); return typeof value === "string" && value.trim() ? value.trim() : null; } function mapTaxRate(facts: PublicFact[]): number | null { const value = factValue(facts, "commerce.vat_rate"); if (typeof value === "number" && value >= -1 && value <= 100) return value; const text = typeof value === "string" ? value.trim().toLowerCase() : ""; if (["zw", "zwolniony", "zwolnienie", "zwolnienie podmiotowe", "expt"].includes(text)) return -1; if (["np", "nie podlega"].includes(text)) return -0.02; if (["oo", "odwrotne obciążenie"].includes(text)) return -0.03; const numeric = Number(text.replace(",", ".").replace("%", "")); return Number.isFinite(numeric) ? numeric : null; } function sectionText(content: ChannelContent | undefined): string { if (!content) return ""; return content.sections.map((section) => `${section.heading}\n${section.paragraphs.join("\n")}`).join("\n\n"); } function featureMap(content: ChannelContent | undefined): Record { const parameters = content?.extras.parameters; return parameters && typeof parameters === "object" && !Array.isArray(parameters) ? parameters as Record : {}; } function latestSnapshotDecision(db: Db, snapshotId: string): SnapshotDecisionRow | null { const row = db .prepare("SELECT * FROM offer_snapshot_decisions WHERE snapshot_id = ? ORDER BY rowid DESC LIMIT 1") .get(snapshotId) as unknown as SnapshotDecisionRow | undefined; return row ?? null; } function snapshotInfo(db: Db, row: SnapshotRow, current: boolean, includePayload = false) { const decision = latestSnapshotDecision(db, row.id); return { id: row.id, version: row.version, sha256: row.snapshot_sha256, sourceSha256: row.source_sha256, current, createdBy: row.created_by, createdAt: row.created_at, note: row.note, decision: decision ? { id: decision.id, snapshot_sha256: decision.snapshot_sha256, decision: decision.decision, decided_by: decision.decided_by, note: decision.note, created_at: decision.created_at, } : null, ...(includePayload ? { payload: JSON.parse(row.payload_json) } : {}), }; } function videoAssets(db: Db, productId: string) { const rows = db .prepare("SELECT id, role, sha256, mime, bytes, status, provenance_json, created_at FROM assets WHERE product_id = ? AND kind = 'video' ORDER BY created_at") .all(productId) as unknown as VideoAssetRow[]; return rows.map((row) => { const approval = db .prepare( `SELECT * FROM approvals WHERE asset_id = ? AND product_id = ? ORDER BY rowid DESC LIMIT 1` ) .get(row.id, productId) as unknown as { asset_sha256: string | null; decision: string; decided_by: string | null; note: string | null; created_at: string } | undefined; let clip: string | null = null; try { const provenance = JSON.parse(row.provenance_json) as { clip?: string; clipType?: string }; clip = typeof provenance.clipType === "string" ? provenance.clipType : typeof provenance.clip === "string" ? provenance.clip : null; } catch { /* zły JSON zgłosi niżej jako niegotowy */ } const approved = row.status === "approved" && approval?.decision === "approved" && approval.asset_sha256 === row.sha256; return { id: row.id, clip, role: row.role, sha256: row.sha256, mime: row.mime, bytes: row.bytes, approved, approval }; }); } function sourceAssets(db: Db, productId: string): SourceAssetRow[] { return db .prepare("SELECT id, role, sha256, mime, bytes, original_name, note, created_at FROM source_assets WHERE product_id = ? ORDER BY role, created_at, id") .all(productId) as unknown as SourceAssetRow[]; } function evidenceState(facts: PublicFact[], sources: SourceAssetRow[]) { const required = REQUIRED_EVIDENCE_FACTS.map((key) => ({ key, status: facts.find((f) => f.key === key)?.status ?? "missing", confirmed: hasConfirmedFact(facts, key) })); return { required, sourceDocuments: sources.filter((s) => s.role === "source_document").map((s) => ({ id: s.id, sha256: s.sha256, mime: s.mime, originalName: s.original_name, note: s.note })), }; } function buildBasePreview( db: Db, variants: VariantRow[], contentVariants: Array<{ id: string; side: string; shade: string; name: string; sku: string | null; channels: Record; }>, facts: PublicFact[], renditionItems: ReturnType["items"], videos: ReturnType, profile: ProductProfile ) { // Potwierdzony fakt produktu wygrywa; przy jego braku fallback do // domyślnych z Ustawień (app_settings, klucz base.* — np. inventory 62360). const numTarget = (key: string) => { const fromFact = numberFact(facts, key); if (fromFact !== null) return fromFact; const v = getAppSetting(db, key); const n = v === null ? NaN : Number(v); return Number.isInteger(n) ? n : null; }; const textTarget = (key: string) => textFact(facts, key) ?? getAppSetting(db, key); const inventoryId = numTarget("base.inventory_id"); const categoryId = numTarget("base.category_id"); const manufacturerId = numTarget("base.manufacturer_id"); const warehouseId = textTarget("base.warehouse_id"); const priceGroupId = numTarget("base.price_group_id"); const variantModel = textTarget("base.variant_model"); const allegroAccount = textTarget("base.allegro_account"); const allegroCategoryId = textTarget("base.allegro_category_id"); const shippingTemplateId = textTarget("base.shipping_template_id"); const price = numberFact(facts, "commerce.price_pln"); const taxRate = mapTaxRate(facts); const stockQuantity = numberFact(facts, "commerce.stock_quantity"); const ean = textFact(facts, "commerce.ean"); const eanException = textFact(facts, "commerce.ean_exception"); // EAN wariantu: przydział z ean_pool (variant.ean) wygrywa, fakt // produktu commerce.ean jest fallbackiem; wyjątek zwalnia z wymogu. const variantEansReady = variants.length > 0 && variants.every((v) => Boolean(v.ean ?? ean)); const unresolved = [ inventoryId === null && "base.inventory_id", categoryId === null && "base.category_id", manufacturerId === null && "base.manufacturer_id", !warehouseId && "base.warehouse_id", priceGroupId === null && "base.price_group_id", !variantModel && "base.variant_model", !allegroAccount && "base.allegro_account", !shippingTemplateId && "base.shipping_template_id", stockQuantity === null && "commerce.stock_quantity", !(variantEansReady || ean || eanException) && "commerce.ean_or_exception", ].filter((x): x is string => Boolean(x)); const items = variants.map((variant) => { const content = contentVariants.find((v) => v.id === variant.id); const images = renditionItems .filter((r) => r.variantId === variant.id) .sort((a, b) => OFFER_ROLES.indexOf(a.role as typeof OFFER_ROLES[number]) - OFFER_ROLES.indexOf(b.role as typeof OFFER_ROLES[number])) .map((r, index) => ({ position: index, role: r.role, renditionId: r.rendition?.id ?? null, sha256: r.rendition?.sha256 ?? null, delivery: "data:jpeg — bajty dołączy dopiero zatwierdzony adapter live", })); return { variantId: variant.id, side: variant.side, shade: variant.shade_code, sku: content?.sku ?? null, operation: "addInventoryProduct/create-variant", parameters: { parent_id: "{{BASE_PARENT_PRODUCT_ID}}", is_bundle: false, sku: content?.sku ?? null, ean: variant.ean ?? ean ?? null, tax_rate: taxRate, weight: numberFact(facts, "packages.total_weight_kg"), height: numberFact(facts, "dimensions.height_with_cushions_cm"), width: numberFact(facts, "dimensions.width_cm"), length: numberFact(facts, "dimensions.depth_cm"), category_id: categoryId, manufacturer_id: manufacturerId, text_fields: { name: content?.channels.main?.title ?? content?.name ?? null, description: sectionText(content?.channels.main), features: featureMap(content?.channels.allegro), }, prices: priceGroupId === null || price === null ? [] : [{ priceGroupId, grossPln: price }], stock: warehouseId === null || stockQuantity === null ? [] : [{ warehouseId, quantity: stockQuantity }], images, videos: videos.filter((v) => v.approved).map((v, index) => ({ position: index, assetId: v.id, clip: v.clip, sha256: v.sha256, delivery: "data:video — tylko po zgodzie na live" })), }, channelText: { allegro: { title: content?.channels.allegro?.title ?? null, description: sectionText(content?.channels.allegro) || null, targetField: allegroAccount ? `description|pl|${allegroAccount}` : null, parameters: featureMap(content?.channels.allegro), }, }, }; }); return { method: "addInventoryProduct", mode: "dry-run-preview", send: false, network: "none", contractCheckedAt: "2026-09-16", apiReference: "https://api.baselinker.com/index.php?method=addInventoryProduct", target: { inventoryId, categoryId, manufacturerId, warehouseId, priceGroupId, allegroAccount, allegroCategoryId, shippingTemplateId, variantModel, }, parent: { operation: "addInventoryProduct/create-parent", parameters: { is_bundle: false, sku: textFact(facts, "base.parent_sku"), ean: ean ?? null, tax_rate: taxRate, category_id: categoryId, manufacturer_id: manufacturerId, text_fields: { name: `${profile.kindLabel} ${profile.model} ${profile.fabricName} — warianty L/P`, description: `Rodzic wariantów ${profile.model}. Ostateczna struktura parent/variant wymaga potwierdzonego modelu Base.`, }, }, }, variants: items, unresolved, }; } /** Buduje bieżący podgląd paczki. Nie zapisuje nic i nie wywołuje sieci. */ export function buildOfferPackage(db: Db, cfg: AppConfig, productId: string) { const product = db.prepare("SELECT id, slug, name, kind FROM products WHERE id = ?").get(productId) as { id: string; slug: string; name: string; kind: string } | undefined; if (!product) throw new PackageConflict("Nie znaleziono produktu."); const profile = offerProfile(product, product.kind); const facts = getFacts(db, productId); const variants = listVariants(db, productId); const review = buildMediaReview(db, cfg, productId); const contentDraft = buildOfferContent(db, productId); const revisions = listContentRevisions(db, productId); const approvedContent = revisions.find((r) => r.current && r.decision?.decision === "approved") ?? null; const approvedRow = approvedContent ? db.prepare("SELECT content_json FROM offer_content_revisions WHERE id = ? AND product_id = ?").get(approvedContent.id, productId) as { content_json: string } | undefined : undefined; const contentForPreview = approvedRow ? JSON.parse(approvedRow.content_json) as ReturnType : contentDraft; const manifest = buildRenditionManifest(db, cfg, review); const sources = sourceAssets(db, productId); const evidence = evidenceState(facts, sources); const videos = videoAssets(db, productId); const documents = review.documents.map((d) => ({ id: d.id, version: d.version, sha256: d.sha256, status: d.status, approved: d.approved, blockers: d.blockers, warnings: d.warnings })); const document = documents.find((d) => d.approved && d.blockers.length === 0) ?? null; const media = review.slots.map((slot) => ({ variantId: slot.variantId, side: slot.side, shade: slot.shade, role: slot.role, ready: slot.ready, asset: slot.asset ? { id: slot.asset.id, sha256: slot.asset.sha256, version: slot.asset.version, approved: slot.asset.approved, blockers: slot.asset.blockers, warnings: slot.asset.warnings } : null, })); const blockers = new Set(); if (!approvedContent) blockers.add("Brak zatwierdzonej i aktualnej wersji treści 34 × 5."); if (review.summary.ready !== review.summary.required) blockers.add(`Galeria gotowa ${review.summary.ready}/${review.summary.required}; paczka wymaga wszystkich wybranych SHA.`); if (!document) blockers.add("Brak zatwierdzonej aktualnej karty produktu PDF bez blokad zależności."); if (manifest.ready !== manifest.expected) blockers.add(`Renditions Base gotowe ${manifest.ready}/${manifest.expected}; uruchom lokalne uzupełnienie po zamknięciu galerii.`); const contentBlockers = new Set(contentForPreview.variants.flatMap((v) => v.blockers)); if (approvedContent) contentBlockers.delete("Konwencja stron L/P i zgodność z galerią wymagają przeglądu człowieka."); for (const blocker of contentBlockers) blockers.add(`Treści: ${blocker}`); for (const requirement of evidence.required) if (!requirement.confirmed) blockers.add(`Brak potwierdzonego faktu ${requirement.key}.`); const eanReady = Boolean( textFact(facts, "commerce.ean") || textFact(facts, "commerce.ean_exception") || (variants.length > 0 && variants.every((v) => v.ean)) ); if (!eanReady) blockers.add("Brak EAN wariantów albo potwierdzonego wyjątku identyfikatorów."); if (numberFact(facts, "commerce.stock_quantity") === null) blockers.add("Brak potwierdzonego stanu magazynowego dla wariantów."); for (const key of REQUIRED_BASE_FACTS) if (!hasConfirmedFact(facts, key) && getAppSetting(db, key) === null) blockers.add(`Brak potwierdzonej konfiguracji ${key}.`); const readyVideos = videos.filter((v) => v.approved); const videoDeferred = hasConfirmedFact(facts, "offer.video_exception"); if (readyVideos.length < 2 && !videoDeferred) blockers.add(`Wideo gotowe ${readyVideos.length}/2; brak zatwierdzonych klipów użytkowania i pielęgnacji.`); if (!textFact(facts, "care.instructions")) blockers.add("Klip pielęgnacji pozostaje zablokowany do potwierdzenia instrukcji producenta."); const basePreview = buildBasePreview(db, variants, contentForPreview.variants, facts, manifest.items, videos, profile); const payload = { schema: PACKAGE_SCHEMA, product: { id: product.id, slug: product.slug, name: product.name }, content: approvedContent ? { revisionId: approvedContent.id, version: approvedContent.version, sha256: approvedContent.sha256, sourceSha256: approvedContent.sourceSha256, approved: true } : { revisionId: null, version: null, sha256: contentForPreview.draftSha256, sourceSha256: null, approved: false }, media, document, documents, renditions: manifest, evidence, videos: { required: ["use", "care"], ready: readyVideos.map((v) => ({ id: v.id, clip: v.clip, sha256: v.sha256 })), all: videos }, base: basePreview, exportGate: { packageApprovalRequired: true, baseExportApprovalRequired: true, liveWriteAllowed: false, readBackRequiredAfterApprovedWrite: true, tokenIncluded: false, }, blockers: [...blockers].sort((a, b) => a.localeCompare(b, "pl")), }; const sourceSha256 = contentHash({ schema: PACKAGE_SCHEMA, product: { id: product.id, slug: product.slug }, facts: facts.map(({ key, value, unit, status, sourceKind, sourceRef, confirmedAt, note }) => ({ key, value, unit, status, sourceKind, sourceRef, confirmedAt, note })), variants: variants.map(({ id, side, shade_code, fabric_id, status }) => ({ id, side, shade_code, fabric_id, status })), reviewSha256: review.reviewSha256, media: media.map((m) => ({ variantId: m.variantId, role: m.role, asset: m.asset ? { id: m.asset.id, sha256: m.asset.sha256 } : null })), document, content: payload.content, renditions: manifest.items, evidence, videos: payload.videos, sources: sources.map(({ id, role, sha256 }) => ({ id, role, sha256 })), base: basePreview, }); const snapshotSha256 = contentHash({ sourceSha256, payload }); return { ...payload, sourceSha256, snapshotSha256, current: true, publishable: false, exportAllowed: false }; } export type OfferPackage = ReturnType; export function listOfferSnapshots(db: Db, cfg: AppConfig, productId: string, currentSourceSha256?: string) { const current = currentSourceSha256 ?? buildOfferPackage(db, cfg, productId).sourceSha256; const rows = db.prepare("SELECT * FROM offer_snapshots WHERE product_id = ? ORDER BY version DESC").all(productId) as unknown as SnapshotRow[]; return { currentSourceSha256: current, snapshots: rows.map((row) => snapshotInfo(db, row, row.source_sha256 === current)) }; } export function getOfferSnapshot(db: Db, cfg: AppConfig, productId: string, id: string, currentSourceSha256?: string) { const row = db.prepare("SELECT * FROM offer_snapshots WHERE id = ? AND product_id = ?").get(id, productId) as unknown as SnapshotRow | undefined; if (!row) throw new PackageConflict("Nie znaleziono snapshotu paczki."); const current = currentSourceSha256 ?? buildOfferPackage(db, cfg, productId).sourceSha256; return snapshotInfo(db, row, row.source_sha256 === current, true); } export function saveOfferSnapshot( db: Db, cfg: AppConfig, input: { productId: string; expectedSourceSha256: string; expectedSnapshotSha256: string; note: string; actor: string } ) { const current = buildOfferPackage(db, cfg, input.productId); if (current.sourceSha256 !== input.expectedSourceSha256 || current.snapshotSha256 !== input.expectedSnapshotSha256) { throw new PackageConflict("Podgląd paczki jest nieaktualny. Odśwież przed zapisem snapshotu."); } const existing = db .prepare("SELECT * FROM offer_snapshots WHERE product_id = ? AND source_sha256 = ? AND snapshot_sha256 = ?") .get(input.productId, current.sourceSha256, current.snapshotSha256) as unknown as SnapshotRow | undefined; if (existing) return snapshotInfo(db, existing, true, true); return tx(db, () => { const version = ((db.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM offer_snapshots WHERE product_id = ?").get(input.productId) as { v: number }).v) + 1; const id = newId(ID_PREFIX.snapshot); db.prepare( `INSERT INTO offer_snapshots (id, product_id, version, source_sha256, snapshot_sha256, payload_json, created_by, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run(id, input.productId, version, current.sourceSha256, current.snapshotSha256, JSON.stringify(current), input.actor, input.note, nowIso()); audit(db, { actor: input.actor, action: "offer.snapshot.save", entity: "offer_snapshots", entityId: id, detail: { productId: input.productId, version, sha256: current.snapshotSha256 } }); const row = db.prepare("SELECT * FROM offer_snapshots WHERE id = ?").get(id) as unknown as SnapshotRow; return snapshotInfo(db, row, true, true); }); } export function decideOfferSnapshot( db: Db, cfg: AppConfig, input: { productId: string; snapshotId: string; expectedSha256: string; decision: "approved" | "rejected"; note: string; actor: string } ) { const row = db.prepare("SELECT * FROM offer_snapshots WHERE id = ? AND product_id = ?").get(input.snapshotId, input.productId) as unknown as SnapshotRow | undefined; if (!row) throw new PackageConflict("Nie znaleziono snapshotu paczki."); if (row.snapshot_sha256 !== input.expectedSha256) throw new PackageConflict("SHA snapshotu zmienił się od podglądu."); const current = buildOfferPackage(db, cfg, input.productId); if (row.source_sha256 !== current.sourceSha256) throw new PackageConflict("Źródła paczki są nieaktualne — snapshot wymaga nowego przeglądu."); const payload = JSON.parse(row.payload_json) as { blockers?: string[] }; if (input.decision === "approved" && (payload.blockers?.length ?? 0) > 0) { throw new PackageConflict("Snapshot z blokadami nie może zostać zatwierdzony jako paczka."); } const latest = latestSnapshotDecision(db, row.id); if (latest?.decision === input.decision && latest.snapshot_sha256 === row.snapshot_sha256) { return snapshotInfo(db, row, true, true); } return tx(db, () => { const id = newId(ID_PREFIX.approval); db.prepare( `INSERT INTO offer_snapshot_decisions (id, snapshot_id, snapshot_sha256, decision, decided_by, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)` ).run(id, row.id, row.snapshot_sha256, input.decision, input.actor, input.note, nowIso()); audit(db, { actor: input.actor, action: `offer.snapshot.${input.decision}`, entity: "offer_snapshots", entityId: row.id, detail: { productId: input.productId, sha256: row.snapshot_sha256 } }); return snapshotInfo(db, row, true, true); }); }