import crypto from "node:crypto"; import { z } from "zod"; import type { Db } from "../db.js"; import { tx, nowIso } from "../db.js"; import type { AppConfig } from "../config.js"; import { newId } from "./ids.js"; import { audit } from "./audit.js"; import { enqueueJob, getJob, finishJob } from "./jobs.js"; import { createProduct, getProductBySlug } from "./product.js"; import { setFact, getFact, type FactInput } from "./facts.js"; import { ensureVariantMatrix } from "./fabric.js"; import { ensureImportSnapshot, markImportStored, handleImportFailure, getSourceImport, readImportSnapshot, SourceImportError, type SourceImportRow } from "./sourceImports.js"; import { normalizeSourceUrl, fetchSource, SourceFetchError, type SourceFetcher } from "../media/sourceFetch.js"; import { ingestUpload } from "../media/ingest.js"; import { parseBobochicProduct, extractBobochicImages, BobochicParseError, type BobochicProduct } from "../import/bobochic.js"; import { assignShotRef } from "./shotRefs.js"; import type { ShotRole } from "./types.js"; export class ProductImportError extends Error { constructor(public readonly statusCode: number, message: string) { super(message); } } export const enqueueProductImportSchema = z.object({ url: z.string().min(1).max(2048), name: z.string().min(1).max(200).optional(), slug: z.string().regex(/^[\w-]{1,120}$/).optional(), fabricCode: z.string().regex(/^[\w-]{1,40}$/).optional(), /** false = import tylko faktów/wymiarów/opisów, bez galerii zdjęć dostawcy i bez auto-referencji ujęć */ importImages: z.boolean().optional(), /** deprecated alias importImages — zostaje w schemacie dla zgodności API */ fetchImages: z.boolean().optional(), idempotencyKey: z.string().regex(/^[\w-]{1,128}$/), }).strict(); const hash = (value: string) => crypto.createHash("sha256").update(value).digest("hex"); function slugify(text: string): string { const s = text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase() .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80); return s || "produkt"; } function uniqueSlug(db: Db, base: string): string { let slug = base; for (let i = 2; getProductBySlug(db, slug); i++) slug = `${base}-${i}`; return slug; } function kindForCategory(categorySlug: string): string { if (categorySlug.includes("angle")) return "narożnik"; if (categorySlug.startsWith("canape") || categorySlug === "ensemble-de-canape") return "sofa"; if (categorySlug.startsWith("fauteuil")) return "fotel"; if (categorySlug.startsWith("pouf")) return "pufa"; if (categorySlug.startsWith("lit") || categorySlug.includes("lit")) return "łóżko"; return "mebel tapicerowany"; } /** * Zleca import produktu z URL dostawcy (dziś: Bobochic). * Atomowo: produkt (szkic) + wiersz source_imports + job import_product. * Parser działa w workerze na utrwalonym snapshocie — żadna wartość * z URL nie staje się faktem bez etapu weryfikacji (wszystko unverified). */ export function enqueueProductImport( db: Db, input: { url: string; name?: string | undefined; slug?: string | undefined; fabricCode?: string | undefined; importImages?: boolean | undefined; fetchImages?: boolean | undefined; idempotencyKey: string; actor: string }, ) { const normalized = normalizeSourceUrl(input.url, "product_page"); // Domyślnie pobieramy zdjęcia (zgodność wsteczna); importImages:false = same fakty. const importImages = input.importImages ?? input.fetchImages ?? true; const fingerprint = hash(JSON.stringify([normalized.requestedUrl, input.name ?? null, input.slug ?? null, input.fabricCode ?? null, importImages])); const requestKey = hash(JSON.stringify([input.actor, "product", input.idempotencyKey])); return tx(db, () => { const prior = db.prepare("SELECT id, request_fingerprint, product_id FROM source_imports WHERE request_key = ?").get(requestKey) as { id: string; request_fingerprint: string; product_id: string | null } | undefined; if (prior) { if (prior.request_fingerprint !== fingerprint) throw new ProductImportError(409, "Ten klucz idempotencji został już użyty z innymi danymi."); const existing = getSourceImport(db, prior.id)!; return { import: existing, product: prior.product_id ? db.prepare("SELECT * FROM products WHERE id = ?").get(prior.product_id) : null, created: false }; } const pending = db.prepare("SELECT COUNT(*) n FROM source_imports i JOIN jobs j ON j.id = i.job_id WHERE j.status IN ('queued','running','waiting')").get() as { n: number }; if (pending.n >= 20) throw new SourceImportError(429, "Kolejka importu jest pełna. Poczekaj na zakończenie istniejących zadań."); const categorySlug = new URL(normalized.fetchUrl).pathname.split("/").filter(Boolean)[1] ?? ""; const slug = uniqueSlug(db, input.slug ?? slugify(input.name ?? (categorySlug || "produkt"))); const product = createProduct(db, { slug, name: input.name ?? slug, kind: kindForCategory(categorySlug) }); let fabricId: string | null = null; if (input.fabricCode) { const fabric = db.prepare("SELECT id FROM fabrics WHERE code = ?").get(input.fabricCode) as { id: string } | undefined; if (!fabric) throw new ProductImportError(404, "Nie znaleziono tkaniny."); fabricId = fabric.id; } const job = enqueueJob(db, { type: "import_product", // importImages=false → worker zapisuje wyłącznie fakty/wymiary/opisy; // galeria vendora nie trafia do materiałów ani do shot_refs. payload: { productId: product.id, autoName: !input.name, fabricId, importImages }, provider: "source-fetch", idempotencyKey: `product-import:${requestKey}`, maxAttempts: 3, }); const importId = newId("imp"); const now = nowIso(); db.prepare(`INSERT INTO source_imports (id,job_id,product_id,kind,requested_url,fetch_url,fragment,request_key,request_fingerprint,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) .run(importId, job.id, product.id, "product_page", normalized.requestedUrl, normalized.fetchUrl, normalized.fragment, requestKey, fingerprint, input.actor, now, now); audit(db, { actor: input.actor, action: "product_import.queued", entity: "products", entityId: product.id, detail: { jobId: job.id, importId, url: normalized.requestedUrl } }); return { import: getSourceImport(db, importId)!, product, created: true }; }); } /** * Reimport specyfikacji (i opcjonalnie zdjęć) z URL dostawcy do ISTNIEJĄCEGO * produktu — tryby "Import Bobochic" i "Hybryda" w Kroku 1. Ten sam job * import_product i runProductImport: fakty schodzą jako unverified, * confirmed są pomijane, nazwa produktu nietknięta (autoName: false). * Atomowo: wiersz source_imports (product_id = produkt) + job. */ export function enqueueProductReimport( db: Db, input: { productId: string; url: string; importImages?: boolean | undefined; fabricCode?: string | undefined; idempotencyKey: string; actor: string }, ) { const normalized = normalizeSourceUrl(input.url, "product_page"); const importImages = input.importImages ?? true; const fingerprint = hash(JSON.stringify([normalized.requestedUrl, input.productId, importImages, input.fabricCode ?? null])); const requestKey = hash(JSON.stringify([input.actor, "product-reimport", input.idempotencyKey])); return tx(db, () => { const prior = db.prepare("SELECT id, request_fingerprint FROM source_imports WHERE request_key = ?").get(requestKey) as { id: string; request_fingerprint: string } | undefined; if (prior) { if (prior.request_fingerprint !== fingerprint) throw new ProductImportError(409, "Ten klucz idempotencji został już użyty z innymi danymi."); return { import: getSourceImport(db, prior.id)!, created: false }; } const pending = db.prepare("SELECT COUNT(*) n FROM source_imports i JOIN jobs j ON j.id = i.job_id WHERE j.status IN ('queued','running','waiting')").get() as { n: number }; if (pending.n >= 20) throw new SourceImportError(429, "Kolejka importu jest pełna. Poczekaj na zakończenie istniejących zadań."); let fabricId: string | null = null; if (input.fabricCode) { const fabric = db.prepare("SELECT id FROM fabrics WHERE code = ?").get(input.fabricCode) as { id: string } | undefined; if (!fabric) throw new ProductImportError(404, "Nie znaleziono tkaniny."); fabricId = fabric.id; } const job = enqueueJob(db, { type: "import_product", payload: { productId: input.productId, autoName: false, fabricId, importImages }, provider: "source-fetch", idempotencyKey: `product-reimport:${requestKey}`, maxAttempts: 3, }); const importId = newId("imp"); const now = nowIso(); db.prepare(`INSERT INTO source_imports (id,job_id,product_id,kind,requested_url,fetch_url,fragment,request_key,request_fingerprint,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) .run(importId, job.id, input.productId, "product_page", normalized.requestedUrl, normalized.fetchUrl, normalized.fragment, requestKey, fingerprint, input.actor, now, now); audit(db, { actor: input.actor, action: "product_reimport.queued", entity: "products", entityId: input.productId, detail: { jobId: job.id, importId, url: normalized.requestedUrl, importImages } }); return { import: getSourceImport(db, importId)!, created: true }; }); } /** Klucze faktów zapisywane z karty vendora (unverified → człowiek potwierdza). */ function dimsItem(product: BobochicProduct, label: string): number { const values = (product.dimensionsItems[label] ?? "").replace(/cm/gi, "").split("/") .map((p) => Number(String(p).replace(/[^\d.,]/g, "").replace(",", "."))) .filter((v) => Number.isFinite(v) && v > 0); return values.length ? Math.max(...values) : 0; } function cmTriple(text: string): number[] | null { const m = String(text ?? "").match(/([\d,]+)\s*x\s*([\d,]+)\s*x\s*([\d,]+)/i); if (!m) return null; return [1, 2, 3].map((i) => Number(m[i]!.replace(",", "."))); } export function vendorFacts(product: BobochicProduct, sourceRef: string): FactInput[] { const p = product.params; const f = (key: string, value: unknown, opts: { unit?: string; note?: string } = {}): FactInput | null => { const empty = value === "" || value === 0 || value === null || value === undefined || (Array.isArray(value) && !value.length); if (empty && typeof value !== "boolean") return null; return { key, value, unit: opts.unit ?? undefined, status: "unverified", sourceKind: "manufacturer_sheet", sourceRef, note: opts.note ?? undefined }; }; const facts = [ f("vendor.brand", "Bobochic"), f("vendor.product_url", product.requestedUrl), f("vendor.product_id", product.productRef), f("vendor.collection", product.collection), f("vendor.ean", product.gtin, { note: "EAN vendora dla wybranej kombinacji — NIE jest EAN-em Vilmax" }), f("vendor.price_eur", product.priceEur, { unit: "EUR" }), f("vendor.color", product.color), f("vendor.type_angle", product.typeAngle), f("vendor.variant_count", product.variants.length), f("vendor.variants", product.variants.map((v) => ({ color: v.color, typeAngle: v.typeAngle, gtin: v.gtin, availability: v.availability, priceEur: v.priceEur }))), f("vendor.image_url", product.imageUrl, { note: "referencja vendora — nie trafia do galerii Vilmax" }), f("vendor.resolved_variant_guessed", product.resolvedVariant?.guessed === true, { note: "true = fragment URL nie pasował do żadnej kombinacji; użyto pierwszej" }), f("dimensions.width_cm", p.widthCm, { unit: "cm", note: "Longueur totale" }), f("dimensions.depth_cm", p.depthCm, { unit: "cm", note: "Largeur/Profondeur totale — max z wartości" }), f("dimensions.depth_values_cm", p.depthValuesCm, { unit: "cm", note: "korpus / szezlong gdy dwie wartości" }), f("dimensions.depth_body_cm", p.depthBodyCm, { unit: "cm" }), f("dimensions.chaise_depth_cm", p.depthChaiseCm, { unit: "cm" }), f("dimensions.height_with_cushions_cm", p.heightCm, { unit: "cm" }), f("dimensions.height_without_cushions_cm", p.heightWithoutCushionsCm, { unit: "cm" }), f("dimensions.seat_height_cm", p.seatHeightCm, { unit: "cm" }), f("dimensions.seat_depth_cm", p.seatDepthCm || dimsItem(product, "Profondeur d'assise de la partie centrale avec coussins"), { unit: "cm" }), f("dimensions.seat_depth_without_cushions_cm", p.seatDepthWithoutCushionsCm, { unit: "cm" }), f("dimensions.meridian_seat_depth_cm", p.seatDepthMeridienneCm || p.meridianSeatDepthWithCushionsCm, { unit: "cm" }), f("dimensions.meridian_seat_depth_without_cushions_cm", p.meridianSeatDepthWithoutCushionsCm, { unit: "cm" }), f("dimensions.seat_width_cm", p.seatWidthCm, { unit: "cm" }), f("dimensions.center_seat_width_cm", p.centerSeatWidthCm, { unit: "cm" }), f("dimensions.meridian_seat_width_cm", p.meridianSeatWidthCm, { unit: "cm" }), f("dimensions.back_height_cm", p.backHeightCm, { unit: "cm" }), f("dimensions.armrest_height_cm", p.armrestHeightCm, { unit: "cm" }), f("dimensions.armrest_length_cm", p.armrestLengthCm, { unit: "cm" }), f("dimensions.armrest_width_cm", p.armrestWidthCm, { unit: "cm" }), f("sleeping.width_cm", p.sleepWidthCm, { unit: "cm" }), f("sleeping.length_cm", p.sleepLengthCm, { unit: "cm" }), f("sleeping.type", p.couchingType), f("mechanism.sleep", p.convertibleType, { note: "typ vendora (FR) — do weryfikacji na materiałach" }), f("mechanism.convertible", p.convertibleOui), f("features.bedding_container", p.coffre || Boolean(p.coffreDimensions)), f("storage.dimensions_cm", cmTriple(p.coffreDimensions), { unit: "cm" }), f("storage.volume_liters", p.storageVolumeLiters, { unit: "l" }), f("cushions.count", p.nombreCoussins), f("cushions.deco_included", p.decoCushionsIncluded), f("cushions.dims_cm", cmTriple(p.cushionDimsCm), { unit: "cm" }), f("cushions.dims_medium_cm", cmTriple(p.cushionDimsMediumCm), { unit: "cm" }), f("cushions.dims_large_cm", cmTriple(p.cushionDimsLargeCm), { unit: "cm" }), f("legs.included", p.feetIncluded), f("legs.count", p.feetCount), f("legs.material", p.feetMaterial.toLowerCase() || undefined), f("legs.height_cm", p.feetHeightCm, { unit: "cm" }), f("structure.material", p.structure.toLowerCase() || undefined), f("structure.wood_types", p.typeBois.toLowerCase() || undefined), f("filling.seat", p.seatFilling.toLowerCase() || undefined), f("filling.seat_density_kgm3", p.seatDensity, { unit: "kg/m3" }), f("filling.back", p.backFilling.toLowerCase() || undefined), f("filling.back_density_kgm3", p.backDensity, { unit: "kg/m3" }), f("filling.cushions", p.cushionFilling.toLowerCase() || undefined), f("filling.armrests", p.armrestFilling.toLowerCase() || undefined), f("filling.armrest_density_kgm3", p.armrestDensity, { unit: "kg/m3" }), f("suspension.seat", p.seatSuspension.toLowerCase() || undefined), f("suspension.back", p.backSuspension.toLowerCase() || undefined), f("seats.count", p.places), f("comfort.seat", p.comfortSeat.toLowerCase() || undefined), f("comfort.back_support", p.backSupport.toLowerCase() || undefined), f("reversible", p.reversible), f("removable_cover", p.removableCover), f("assembly.by_customer", p.selfAssembly.toLowerCase() || undefined), f("warranty.months", p.garantieAns > 0 ? p.garantieAns * 12 : 0, { unit: "months", note: "gwarancja vendora — warunki Vilmax do decyzji" }), f("load.max_kg", p.maxLoadKg, { unit: "kg" }), f("weight.product_kg", p.weightKg, { unit: "kg" }), f("packages.items", product.packages.map((b) => ({ dimensions_cm: [b.lengthCm, b.widthCm, b.heightCm], weight_kg: b.weightKg }))), f("packages.total_weight_kg", product.packages.reduce((s, b) => s + b.weightKg, 0) || 0, { unit: "kg" }), f("sides.available", product.sides, { note: "z kombinacji angle gauche/droit na stronie produktu" }), f("vendor_fabric.type", p.coating.toLowerCase() || undefined, { note: "tkanina na zdjęciach vendora — NIE jest tkaniną Vilmax" }), f("vendor_fabric.composition", p.fabricComposition.toLowerCase() || undefined), f("vendor_fabric.martindale_cycles", p.martindale, { unit: "cycles", note: "test tkaniny vendora" }), f("vendor_fabric.anti_pilling", p.antiBouloches), f("vendor_fabric.snag_resistant", p.resistantAccrocs), f("vendor_fabric.water_repellent", p.deperlant), ]; return facts.filter((x): x is FactInput => x !== null); } /** * Auto-przypisanie referencji ujęć po imporcie zdjęć vendora: kolejność * zdjęć w galerii vendora → kolejność ról planu ujęć. To heurystyka * pozycyjna — przypisania są oznaczone w `note`, a operator może je * skorygować w sekcji „Nasze kadry wzorcowe". Zdjęcia ponad plan zostają * w materiałach bez przypisania. */ const IMPORT_SHOT_ROLES = [ "hero", "packshot_front", "packshot_34", "side", "back", "sleep", "storage", "detail", ] as const satisfies readonly ShotRole[]; function autoAssignShotRefs( db: Db, productId: string, sourceIds: string[], actor: string, warnings: string[], ): string[] { const assigned: string[] = []; sourceIds.forEach((sourceId, i) => { const shotRole = IMPORT_SHOT_ROLES[i]; if (!shotRole) return; try { assignShotRef(db, { productId, shotRole, sourceId, refKind: "primary", note: `Auto z importu vendora (poz. ${i + 1} galerii) — zweryfikuj dopasowanie ujęcia.`, actor, }); assigned.push(shotRole); } catch (err) { warnings.push(`przypisanie ujęcia ${shotRole}: ${err instanceof Error ? err.message : String(err)}`); } }); return assigned; } /** * Job import_product: snapshot (wspólny rdzeń z import_source) → parser * Bobochic → fakty unverified. Nie nadpisuje faktów confirmed; nie tworzy * mediów; nie potwierdza niczego za człowieka. */ export async function runProductImport( db: Db, cfg: AppConfig, jobId: string, fetcher: SourceFetcher = fetchSource, stopSignal?: AbortSignal, ): Promise { const row = db.prepare( `SELECT i.*, j.status AS job_status, j.error AS job_error, j.attempts FROM source_imports i JOIN jobs j ON j.id = i.job_id WHERE i.job_id = ?` ).get(jobId) as unknown as SourceImportRow | undefined; if (!row || row.job_status !== "running" || !row.product_id) return; const job = getJob(db, jobId); const payload = JSON.parse(job?.payload_json ?? "{}") as { productId?: string; autoName?: boolean; fabricId?: string; importImages?: boolean; fetchImages?: boolean }; // fetchImages = nazwa pola w payloadach zleconych przed zmianą nazwy. const importImages = payload.importImages ?? payload.fetchImages ?? true; const cancel = new AbortController(); const signal = stopSignal ? AbortSignal.any([stopSignal, cancel.signal]) : cancel.signal; const timer = setInterval(() => { if (getJob(db, jobId)?.status === "cancelled") cancel.abort(); }, 100); try { const snap = await ensureImportSnapshot(db, cfg, row, jobId, fetcher, signal); if (!snap) return; signal.throwIfAborted(); if (getJob(db, jobId)?.status === "cancelled") return; if (!["text/html", "application/xhtml+xml"].includes(snap.manifest.mime)) { throw new ProductImportError(422, "Snapshot produktu nie jest dokumentem HTML."); } const parsed = parseBobochicProduct(snap.buffer.toString("utf8"), row.requested_url); signal.throwIfAborted(); if (getJob(db, jobId)?.status === "cancelled") return; const sourceRef = `bobochicparis.com product ${parsed.productRef || "?"} — snapshot ${row.id}`; const facts = vendorFacts(parsed, sourceRef); // Brak zewnętrznej transakcji: setFact/ensureVariantMatrix mają własne tx, // a wznowienie po przerwaniu jest bezpieczne (zapis faktu jest idempotentny). markImportStored(db, row, snap.manifest, snap.recovered); if (payload.autoName) { const name = parsed.collection || parsed.name; if (name) db.prepare("UPDATE products SET name = ?, updated_at = ? WHERE id = ?").run(name, nowIso(), row.product_id); } let written = 0; let skippedConfirmed = 0; for (const fact of facts) { const existing = getFact(db, row.product_id, fact.key); if (existing?.status === "confirmed") { skippedConfirmed++; continue; } setFact(db, row.product_id, { ...fact, changedBy: `import:${row.id}` }); written++; } let variants: { created: number; total: number } | null = null; if (payload.fabricId && parsed.sides.length) { variants = ensureVariantMatrix(db, { productId: row.product_id, fabricId: payload.fabricId, sides: parsed.sides }); // Kotwica recoloru — patrz komentarz przy POST /api/products/:slug/fabrics. if (!getFact(db, row.product_id, "fabric.photographed_shade")) { setFact(db, row.product_id, { key: "fabric.photographed_shade", value: null, status: "unknown", sourceKind: "owner", sourceRef: null, note: "Odcień fotografowanego egzemplarza — wybierz z katalogu kolekcji (kotwica recoloru).", changedBy: `import:${row.id}`, }); } } // Zdjęcia galerii vendora → media/originals + source_assets // (vendor_reference) + automatyczne referencje ujęć (shot_refs), żeby // zdjęcia były od razu widoczne jako robocze kadry produktu. Błąd // pojedynczego zdjęcia = warning, nie przerywa importu faktów. let images: { found: number; stored: number; duplicates: number; linked?: number; warnings: string[]; ambiguous?: boolean; availableVariants?: Array<{ color: string; typeAngle: string; url: string; thumbnailUrl: string }>; refsAssigned?: string[]; } | null = null; if (importImages) { const gallery = extractBobochicImages(snap.buffer.toString("utf8"), parsed); const warnings: string[] = []; const ambiguous = gallery.scope === "ambiguous"; images = { found: gallery.images.length, stored: 0, duplicates: 0, warnings, ambiguous, ...(gallery.availableVariants ? { availableVariants: gallery.availableVariants } : {}), }; if (ambiguous) { // Adres nie wskazuje jednoznacznie koloru/kąta, a produkt ma kilka // wariantów — NIE pobieramy zdjęć, żeby nie zapisać złego/wymieszanego // koloru (patrz .devin/STAN.md, zgłoszenie operatora o niekompletnej // i wielokolorowej galerii). Operator dostaje listę adresów do wyboru // i musi zaimportować ponownie z konkretnym wariantem. const example = gallery.availableVariants?.[0]?.url; warnings.push( "Adres nie wskazuje jednoznacznie wariantu kolorystycznego — zdjęcia NIE zostały pobrane, aby nie zapisać złego koloru." + (example ? ` Zaimportuj ponownie z adresem konkretnego wariantu, np.: ${example}` : ""), ); } if (!ambiguous && !gallery.images.length) warnings.push("Snapshot nie zawiera galerii zdjęć dostawcy."); const baseName = slugify(parsed.collection || parsed.productSlug || "bobochic"); const refSourceIds: string[] = []; for (const img of gallery.images) { signal.throwIfAborted(); if (getJob(db, jobId)?.status === "cancelled") return; try { let fetched; try { fetched = await fetcher(img.url, "product_image", signal); } catch (err) { if (!img.fallbackUrl) throw err; fetched = await fetcher(img.fallbackUrl, "product_image", signal); } const out = await ingestUpload(db, cfg, { buffer: fetched.buffer, originalName: `${baseName}-${img.imageId || `poz${img.position}`}.jpg`, productId: row.product_id, role: "vendor_reference", note: `Zdjęcie z galerii dostawcy Bobochic (kombinacja ${gallery.combinationId ?? "strona"}, poz. ${img.position}/${gallery.images.length}).${img.legend ? ` Podpis: ${img.legend}.` : ""} Import ${row.id}. Referencja vendora — roboczy materiał produktu, nie zatwierdzone ujęcie Vilmax.`, uploadedBy: `import:${row.id}`, sourceUrl: fetched.finalUrl, fetchedAt: fetched.fetchedAt, }); if (out.duplicate) { images.duplicates++; const prev = db.prepare("SELECT product_id FROM source_assets WHERE id = ?").get(out.id) as { product_id: string | null } | undefined; if (prev && !prev.product_id) { db.prepare("UPDATE source_assets SET product_id = ? WHERE id = ?").run(row.product_id, out.id); } else if (prev && prev.product_id !== row.product_id) { // Ten sam plik już jest przy innym produkcie — ingest dopiął // link w source_asset_products; galeria tego produktu go widzi. images.linked = (images.linked ?? 0) + 1; } refSourceIds.push(out.id); } else { images.stored++; refSourceIds.push(out.id); } } catch (err) { warnings.push(`zdjęcie poz. ${img.position}: ${err instanceof Error ? err.message : String(err)}`); } } // Auto-przypisanie referencji ujęć: tylko gdy galeria jest jednoznacznie // przypisana do wariantu (scope "combination") albo produkt ma jeden // wariant — scope "page" przy wielu wariantach mieszałby kolory. if (refSourceIds.length && (gallery.scope === "combination" || (gallery.scope === "page" && parsed.variants.length <= 1))) { images.refsAssigned = autoAssignShotRefs(db, row.product_id!, refSourceIds, `import:${row.id}`, warnings); } audit(db, { actor: row.created_by, action: "product_import.images", entity: "products", entityId: row.product_id!, detail: { importId: row.id, found: images.found, stored: images.stored, duplicates: images.duplicates, refsAssigned: images.refsAssigned ?? [], warnings } }); } tx(db, () => { finishJob(db, jobId, { ok: true, result: { importId: row.id, productId: row.product_id, sha256: snap.manifest.sha256, name: parsed.name, collection: parsed.collection, productRef: parsed.productRef, variantCombinations: parsed.variants.length, resolvedVariantGuessed: parsed.resolvedVariant?.guessed === true, factsWritten: written, skippedConfirmed, variantMatrix: variants, images, imagesSkipped: !importImages, }, }); audit(db, { actor: row.created_by, action: "product_import.done", entity: "products", entityId: row.product_id!, detail: { importId: row.id, factsWritten: written, skippedConfirmed, collection: parsed.collection, imagesStored: images?.stored ?? 0 } }); }); } catch (err) { if (err instanceof BobochicParseError || err instanceof ProductImportError) { // Błąd parsowania/walidacji jest deterministyczny — bez retry. // Snapshot na dysku zostaje (checkpoint), ale nie jest oznaczany jako stored. if (getJob(db, jobId)?.status === "cancelled" || stopSignal?.aborted) { handleImportFailure(db, row, jobId, err, stopSignal); return; } tx(db, () => { finishJob(db, jobId, { ok: false, error: err.message, retryable: false }); db.prepare("UPDATE source_imports SET phase='failed', error_code=?, updated_at=? WHERE id=?") .run(err instanceof BobochicParseError ? `parse_${err.code}` : "import_error", nowIso(), row.id); audit(db, { actor: row.created_by, action: "product_import.failed", entity: "source_imports", entityId: row.id, detail: { error: err.message } }); }); return; } handleImportFailure(db, row, jobId, err, stopSignal); } finally { clearInterval(timer); } } export interface VendorVariantImagesResult { found: number; stored: number; duplicates: number; linked: number; warnings: string[]; pendingFabricChange: boolean; refsAssigned: string[]; } /** * Dogrywa zdjęcia KONKRETNEGO wariantu kolorystycznego do już istniejącego * produktu, na podstawie snapshotu HTML zapisanego przy pierwszym imporcie * (bez ponownego pobierania strony z sieci). Operator wybiera wariant * wizualnie z miniatur (patrz Krok 1 UI) po tym, jak pierwszy import zwrócił * `images.ambiguous`. `noFabricMatch` = operator nie znalazł swojego * koloru/tkaniny na stronie dostawcy — bierzemy pierwszy wariant WYŁĄCZNIE * dla geometrii (bryła, kąt), a zapisane zdjęcia oznaczamy jawnie jako * wymagające pełnej zmiany tkaniny (nie tylko odcienia) w dalszym etapie. */ export async function fetchVendorVariantImages( db: Db, cfg: AppConfig, input: { productId: string; variantUrl?: string; noFabricMatch?: boolean; actor: string }, fetcher: SourceFetcher = fetchSource, ): Promise { const importRow = db.prepare( `SELECT id, requested_url FROM source_imports WHERE product_id = ? AND kind = 'product_page' AND phase = 'stored' ORDER BY created_at DESC LIMIT 1` ).get(input.productId) as { id: string; requested_url: string } | undefined; if (!importRow) { throw new ProductImportError(404, "Brak zapisanego snapshotu strony dostawcy dla tego produktu — zaimportuj produkt ponownie."); } const snap = await readImportSnapshot(db, cfg, importRow.id); if (!snap) throw new ProductImportError(422, "Nie udało się odczytać zapisanego snapshotu strony dostawcy."); const html = snap.buffer.toString("utf8"); const basePath = importRow.requested_url.split("#")[0]!; let targetUrl: string; const pendingFabricChange = input.noFabricMatch === true; if (pendingFabricChange) { const probe = extractBobochicImages(html, parseBobochicProduct(html, basePath)); const first = probe.availableVariants?.[0]; // Produkt bez wariantów na stronie: nie ma czego wybierać — zdjęcia // scope "page" należą do jedynej kombinacji i są jednoznaczne. targetUrl = first?.url ?? basePath; } else if (input.variantUrl) { if (!input.variantUrl.startsWith(basePath)) { throw new ProductImportError(400, "Adres wariantu nie odpowiada zaimportowanej stronie tego produktu."); } targetUrl = input.variantUrl; } else { throw new ProductImportError(400, "Podaj variantUrl albo noFabricMatch."); } const parsed = parseBobochicProduct(html, targetUrl); const gallery = extractBobochicImages(html, parsed); // scope "page" jest bezpieczny tylko gdy produkt ma jeden wariant — // przy wielu wariantach zdjęcia strony mieszałyby kolory. const usableScope = gallery.scope === "combination" || (gallery.scope === "page" && parsed.variants.length <= 1); if (!usableScope || !gallery.images.length) { throw new ProductImportError(422, "Nie udało się rozstrzygnąć galerii tego wariantu ze snapshotu."); } const warnings: string[] = []; let stored = 0; let duplicates = 0; let linked = 0; const baseName = slugify(parsed.collection || parsed.productSlug || "bobochic"); const fabricNote = pendingFabricChange ? " [fabric_pending_change] Kolor/tkanina NIE dopasowana do fizycznego egzemplarza — wymaga pełnej zmiany tkaniny (nie tylko odcienia) w dalszym etapie." : " Wariant wybrany ręcznie przez operatora z miniatur."; const refSourceIds: string[] = []; for (const img of gallery.images) { try { let fetched; try { fetched = await fetcher(img.url, "product_image"); } catch (err) { if (!img.fallbackUrl) throw err; fetched = await fetcher(img.fallbackUrl, "product_image"); } const out = await ingestUpload(db, cfg, { buffer: fetched.buffer, originalName: `${baseName}-${img.imageId || `poz${img.position}`}.jpg`, productId: input.productId, role: "vendor_reference", note: `Zdjęcie z galerii dostawcy Bobochic (kombinacja ${gallery.combinationId ?? "strona"}, poz. ${img.position}/${gallery.images.length}).${fabricNote}`, uploadedBy: `manual-variant:${input.actor}`, sourceUrl: fetched.finalUrl, fetchedAt: fetched.fetchedAt, }); if (out.duplicate) { duplicates++; const prev = db.prepare("SELECT product_id FROM source_assets WHERE id = ?").get(out.id) as { product_id: string | null } | undefined; if (prev && !prev.product_id) { db.prepare("UPDATE source_assets SET product_id = ? WHERE id = ?").run(input.productId, out.id); } else if (prev && prev.product_id !== input.productId) { linked++; } refSourceIds.push(out.id); } else { stored++; refSourceIds.push(out.id); } } catch (err) { warnings.push(`zdjęcie poz. ${img.position}: ${err instanceof Error ? err.message : String(err)}`); } } const refsAssigned = autoAssignShotRefs(db, input.productId, refSourceIds, `manual-variant:${input.actor}`, warnings); audit(db, { actor: input.actor, action: "product_import.variant_images", entity: "products", entityId: input.productId, detail: { importId: importRow.id, variantUrl: targetUrl, found: gallery.images.length, stored, duplicates, pendingFabricChange, refsAssigned, warnings }, }); return { found: gallery.images.length, stored, duplicates, linked, warnings, pendingFabricChange, refsAssigned }; } /** * Ponowne wyciągnięcie zdjęć z JUŻ zapisanego snapshotu strony produktu — * bez ponownego pobierania strony i bez ruszania faktów. Dla produktów, * których pierwszy import zakończył się pustą galerią (duplikaty z innego * produktu — teraz linkowane — albo błąd pobierania pojedynczych plików). * Niejednoznaczny wariant → zwraca listę do wyboru (jak przy imporcie), * bez pobierania zdjęć. */ export async function refetchProductImages( db: Db, cfg: AppConfig, input: { productId: string; actor: string }, fetcher: SourceFetcher = fetchSource, ): Promise }> { const importRow = db.prepare( `SELECT id, requested_url FROM source_imports WHERE product_id = ? AND kind = 'product_page' AND phase = 'stored' ORDER BY created_at DESC LIMIT 1` ).get(input.productId) as { id: string; requested_url: string } | undefined; if (!importRow) { throw new ProductImportError(404, "Brak zapisanego snapshotu strony dostawcy dla tego produktu — zaimportuj produkt ponownie."); } const snap = await readImportSnapshot(db, cfg, importRow.id); if (!snap) throw new ProductImportError(422, "Nie udało się odczytać zapisanego snapshotu strony dostawcy."); const html = snap.buffer.toString("utf8"); // Pełny requested_url (z fragmentem wariantu) — ta sama rozdzielczość // kombinacji co przy pierwszym imporcie; fragment nie idzie do sieci. const parsed = parseBobochicProduct(html, importRow.requested_url); const gallery = extractBobochicImages(html, parsed); const warnings: string[] = []; const ambiguous = gallery.scope === "ambiguous"; if (ambiguous) { warnings.push( "Adres nie wskazuje jednoznacznie wariantu kolorystycznego — wybierz wariant z listy poniżej, aby pobrać właściwe zdjęcia." ); audit(db, { actor: input.actor, action: "product_import.refetch_images", entity: "products", entityId: input.productId, detail: { importId: importRow.id, found: 0, stored: 0, linked: 0, ambiguous: true, warnings }, }); return { found: 0, stored: 0, duplicates: 0, linked: 0, warnings, pendingFabricChange: false, refsAssigned: [], ambiguous: true, ...(gallery.availableVariants ? { availableVariants: gallery.availableVariants } : {}), }; } if (!gallery.images.length) { warnings.push("Snapshot nie zawiera galerii zdjęć dostawcy."); return { found: 0, stored: 0, duplicates: 0, linked: 0, warnings, pendingFabricChange: false, refsAssigned: [], ambiguous: false }; } const baseName = slugify(parsed.collection || parsed.productSlug || "bobochic"); let stored = 0; let duplicates = 0; let linked = 0; const refSourceIds: string[] = []; for (const img of gallery.images) { try { let fetched; try { fetched = await fetcher(img.url, "product_image"); } catch (err) { if (!img.fallbackUrl) throw err; fetched = await fetcher(img.fallbackUrl, "product_image"); } const out = await ingestUpload(db, cfg, { buffer: fetched.buffer, originalName: `${baseName}-${img.imageId || `poz${img.position}`}.jpg`, productId: input.productId, role: "vendor_reference", note: `Zdjęcie z galerii dostawcy Bobochic (kombinacja ${gallery.combinationId ?? "strona"}, poz. ${img.position}/${gallery.images.length}). Ponowne pobranie ze snapshotu ${importRow.id}.`, uploadedBy: `refetch:${input.actor}`, sourceUrl: fetched.finalUrl, fetchedAt: fetched.fetchedAt, }); if (out.duplicate) { duplicates++; const prev = db.prepare("SELECT product_id FROM source_assets WHERE id = ?").get(out.id) as { product_id: string | null } | undefined; if (prev && !prev.product_id) { db.prepare("UPDATE source_assets SET product_id = ? WHERE id = ?").run(input.productId, out.id); } else if (prev && prev.product_id !== input.productId) { linked++; } refSourceIds.push(out.id); } else { stored++; refSourceIds.push(out.id); } } catch (err) { warnings.push(`zdjęcie poz. ${img.position}: ${err instanceof Error ? err.message : String(err)}`); } } const usableScope = gallery.scope === "combination" || (gallery.scope === "page" && parsed.variants.length <= 1); const refsAssigned = refSourceIds.length && usableScope ? autoAssignShotRefs(db, input.productId, refSourceIds, `refetch:${input.actor}`, warnings) : []; audit(db, { actor: input.actor, action: "product_import.refetch_images", entity: "products", entityId: input.productId, detail: { importId: importRow.id, found: gallery.images.length, stored, duplicates, linked, refsAssigned, warnings }, }); return { found: gallery.images.length, stored, duplicates, linked, warnings, pendingFabricChange: false, refsAssigned, ambiguous: false }; }