import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import sharp, { type OverlayOptions } from "sharp"; import type { Db } from "../db.js"; import type { AppConfig } from "../config.js"; import { approvedAssetForRole, decideBatch, listAssets, type AssetRow, type ApprovalRow } from "./assets.js"; import { listVariants, listShades } from "./fabric.js"; import { getFacts } from "./facts.js"; import { storageProtectionFor } from "./recolor.js"; import { latestVerdictsForProduct, MACIUS_PROMPT_VERSION } from "./macius.js"; export const OFFER_ROLES = ["hero", "packshot_front", "packshot_34", "side", "back", "sleep", "storage", "detail"] as const; export function contentHash(value: unknown): string { const canonical = (v: unknown): unknown => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, canonical(x)])) : v; return crypto.createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex"); } export class ReviewConflict extends Error {} type Provenance = { baseAsset?: { id?: string; sha256?: string; role?: string }; variant?: { side?: string; shade?: string; shadeName?: string | null }; anchorShade?: string; mirrored?: boolean; method?: string; mask?: { fullFrame?: boolean; protection?: { version?: string; masterSha256?: string } }; swatches?: { anchor?: string; target?: string }; factsSnapshot?: Record; images?: Array<{ assetId: string; sha256: string }>; blueprint?: { assetId: string; sha256: string }; variants?: Array<{ shade: string; thumbs: Partial> }>; }; export function buildMediaReview(db: Db, cfg: AppConfig, productId: string) { const variants = listVariants(db, productId); const assets = listAssets(db, productId); const byId = new Map(assets.map((a) => [a.id, a])); const facts = getFacts(db, productId); const factValue = (key: string) => facts.find((f) => f.key === key && f.status === "confirmed")?.value; const verdicts = latestVerdictsForProduct(db, productId); const approvals = db.prepare("SELECT * FROM approvals WHERE product_id = ? ORDER BY rowid DESC").all(productId) as unknown as ApprovalRow[]; const latest = new Map(); for (const ap of approvals) if (ap.asset_id && !latest.has(ap.asset_id)) latest.set(ap.asset_id, ap); const fileCache = new Map(); const fileCheck = (base: string, rel: string, sha256: string, bytes: number): string[] => { const abs = path.resolve(base, rel); if (!abs.startsWith(path.resolve(base) + path.sep)) return ["Plik poza dozwolonym katalogiem."]; let file = fileCache.get(abs); if (!file) { try { const buffer = fs.readFileSync(abs); file = { sha256: crypto.createHash("sha256").update(buffer).digest("hex"), bytes: buffer.length }; } catch { file = { sha256: null, bytes: null }; } fileCache.set(abs, file); } return file.sha256 === null ? ["Brak dostępnego pliku."] : file.sha256 !== sha256 || file.bytes !== bytes ? ["Plik nie odpowiada zapisanemu SHA-256 lub rozmiarowi."] : []; }; const sourceReferences = new Map(); const sourceCheck = (id: string | undefined) => { const s = id ? db.prepare("SELECT path, sha256, bytes FROM source_assets WHERE id = ?").get(id) as { path: string; sha256: string; bytes: number } | undefined : undefined; if (s && id) sourceReferences.set(id, { id, sha256: s.sha256, bytes: s.bytes }); return s ? fileCheck(cfg.originalsDir, s.path, s.sha256, s.bytes) : ["Brak źródłowej próbki."]; }; const inspect = (asset: AssetRow) => { const blockers = fileCheck(cfg.mediaDir, asset.path, asset.sha256, asset.bytes); const warnings: string[] = []; let prov: Provenance = {}; try { prov = JSON.parse(asset.provenance_json); } catch { blockers.push("Niepoprawne provenance."); } const approval = latest.get(asset.id); const approved = approvedAssetForRole(db, productId, asset.role, asset.variant_id)?.id === asset.id; if (asset.status === "approved" && !approved) blockers.push("Status approved bez ważnej ostatniej decyzji związanej z treścią."); if (asset.variant_id) { const variant = variants.find((v) => v.id === asset.variant_id); const shade = variant ? listShades(db, variant.fabric_id).find((s) => s.code === variant.shade_code) : undefined; const parent = asset.parent_asset_id ? byId.get(asset.parent_asset_id) : undefined; const currentMaster = approvedAssetForRole(db, productId, asset.role); if (!parent || !currentMaster || parent.id !== currentMaster.id || prov.baseAsset?.id !== parent.id || prov.baseAsset?.sha256 !== parent.sha256 || prov.baseAsset?.role !== asset.role) { blockers.push("Źródłowy master nie jest aktualnym zatwierdzonym ujęciem lub nie zgadza się jego SHA."); } else { blockers.push(...fileCheck(cfg.mediaDir, parent.path, parent.sha256, parent.bytes).map((x) => `Master: ${x}`)); } if (!variant || prov.variant?.side !== variant.side || prov.variant?.shade !== variant.shade_code) blockers.push("Strona lub odcień w provenance nie odpowiada wariantowi."); const photographed = factValue("sides.photographed"); if (variant?.side === "S") { if (prov.mirrored === true) blockers.push("Wariant bez stron oznaczony jako lustrzany."); } else if (!["L", "P"].includes(String(photographed)) || (prov.mirrored ?? false) !== (variant?.side !== photographed)) blockers.push("Niepotwierdzona lub niespójna orientacja lustra."); if (prov.mirrored === undefined) warnings.push("Historyczne provenance bez flagi mirrored: brak odbicia przyjęto tylko dla strony fotografowanego mastera; sprawdź obraz."); if (prov.anchorShade !== factValue("fabric.photographed_shade")) blockers.push("Zmieniono odcień sfotografowanego wzorca."); const anchor = variant ? listShades(db, variant.fabric_id).find((s) => s.code === prov.anchorShade) : undefined; if (!shade || prov.swatches?.target !== shade.official_image_path || prov.swatches?.anchor !== anchor?.official_image_path) blockers.push("Próbka w provenance nie odpowiada aktualnej bibliotece."); blockers.push(...sourceCheck(prov.swatches?.target), ...sourceCheck(prov.swatches?.anchor)); if (shade?.verification !== "confirmed") warnings.push("Nazwa i próbka odcienia wymagają potwierdzenia."); if (prov.variant?.shadeName !== shade?.name) warnings.push("Nazwa odcienia zmieniła się od utworzenia obrazu."); if (prov.mirrored) warnings.push("Lustro: sprawdź konstrukcję, napisy i asymetrię aranżacji."); if (asset.role === "storage") { warnings.push("Sprawdź elementy nietkaninowe: skrzynię, okucia i wnętrze pojemnika."); // v7: komorę odcina segmentacja silnika (negatywy + kotwica głębi), // wielokątowa ochrona LEGACY_V1 dotyczy wyłącznie wyników lab_match. if (prov.method !== "fabric_recolor_v7") { const expectedProtection = parent ? storageProtectionFor(db, parent.sha256) : null; if (!expectedProtection || prov.mask?.protection?.version !== expectedProtection.version || prov.mask.protection.masterSha256 !== parent?.sha256) blockers.push("Brak dopasowanej ochrony wnętrza pojemnika — starszy recolor obejmował także skrzynię."); } } if (asset.role === "sleep") warnings.push("Sprawdź powierzchnię spania i pozycję oparcia szezlonga."); if (asset.role === "detail") warnings.push(`Makro: sprawdź prążki i barwę; fullFrame=${prov.mask?.fullFrame === true} nie jest dowodem jakości.`); } if (asset.role === "document") { if (!prov.factsSnapshot) blockers.push("Dokument bez snapshotu faktów."); for (const [key, value] of Object.entries(prov.factsSnapshot ?? {})) { if (contentHash(factValue(key) ?? null) !== contentHash(value)) blockers.push(`Nieaktualny fakt dokumentu: ${key}.`); } for (const ref of [...(prov.images ?? []), ...(prov.blueprint ? [prov.blueprint] : [])]) { const a = byId.get(ref.assetId); if (!a || a.sha256 !== ref.sha256 || approvedAssetForRole(db, productId, a.role, a.variant_id)?.id !== a.id) blockers.push(`Nieaktualny obraz dokumentu: ${ref.assetId}.`); } if (!prov.variants?.length) blockers.push("Dokument nie zawiera przeglądu 17 odcieni i stron L/P."); for (const variant of variants) { const ref = prov.variants?.find((v) => v.shade === variant.shade_code)?.thumbs[variant.side]; const a = approvedAssetForRole(db, productId, "packshot_34", variant.id); if (!ref || !a || ref.assetId !== a.id || ref.sha256 !== a.sha256) blockers.push(`Nieaktualna lub brakująca miniatura dokumentu: ${variant.side}/${variant.shade_code}.`); } warnings.push("Karta produktu nie zastępuje dowodów materiałowych, instrukcji pielęgnacji ani danych GPSR."); } const review = verdicts[asset.id]; const qa = review?.asset_sha256 === asset.sha256 && review.prompt_version === MACIUS_PROMPT_VERSION ? { id: review.id, model: review.model, verdict: review.verdict, issues: JSON.parse(review.issues_json) as string[], justification: review.justification, } : null; if (asset.kind === "image" && !qa) warnings.push("Brak aktualnego pre-screeningu Maciusia."); if (qa && qa.verdict !== "ok") warnings.push("Maciuś zgłasza uwagi — przeczytaj je przed decyzją."); return { id: asset.id, sha256: asset.sha256, version: asset.version, status: asset.status, role: asset.role, variantId: asset.variant_id, approved, approval: approval ? { id: approval.id, decision: approval.decision, sha256: approval.asset_sha256, decidedBy: approval.decided_by, createdAt: approval.created_at } : null, parentId: asset.parent_asset_id, method: prov.method ?? null, mirrored: prov.mirrored ?? false, blockers: [...new Set(blockers)], warnings, qa, }; }; const slots = variants.flatMap((variant) => OFFER_ROLES.map((role) => { const versions = assets.filter((a) => a.variant_id === variant.id && a.role === role); const approved = approvedAssetForRole(db, productId, role, variant.id); const selected = approved ?? versions.find((a) => a.status === "candidate") ?? null; const asset = selected ? inspect(selected) : null; return { variantId: variant.id, side: variant.side, shade: variant.shade_code, role, asset, candidates: versions.filter((a) => a.status === "candidate").map(inspect), rejected: versions.filter((a) => a.status === "rejected").map((a) => ({ id: a.id, sha256: a.sha256 })), ready: !!asset?.approved && asset.blockers.length === 0, }; })); const masters = OFFER_ROLES.map((role) => approvedAssetForRole(db, productId, role)).filter((a): a is AssetRow => !!a).map(inspect); const documents = assets.filter((a) => a.role === "document" && ["approved", "candidate"].includes(a.status)).map(inspect); const state = { productId, facts, slots, masters, documents, sourceReferences: [...sourceReferences.values()] }; const prescreenJobs = db.prepare("SELECT id, status, result_json FROM jobs WHERE type = 'review_media_batch' AND json_extract(payload_json, '$.productId') = ? ORDER BY created_at DESC LIMIT 5").all(productId) as unknown as Array<{ id: string; status: string; result_json: string | null }>; return { ...state, reviewSha256: contentHash(state), prescreenJobs: prescreenJobs.map((j) => { const result = JSON.parse(j.result_json ?? "{}"); const entries = Object.entries(result.entries ?? result) as Array<[string, { error?: string }]>; return { id: j.id, status: j.status, processed: entries.length, errors: entries.filter(([, v]) => v.error).map(([id, v]) => ({ id, error: v.error! })) }; }), summary: { variants: variants.length, required: slots.length, approved: slots.filter((s) => s.asset?.approved).length, ready: slots.filter((s) => s.ready).length, candidates: slots.filter((s) => s.asset?.status === "candidate").length, missing: slots.filter((s) => !s.asset).length, blocked: slots.filter((s) => s.asset?.blockers.length).length }, }; } export type MediaReview = ReturnType; export async function buildReviewContactSheet(db: Db, cfg: AppConfig, productId: string, role: typeof OFFER_ROLES[number]) { if (!OFFER_ROLES.includes(role)) throw new ReviewConflict("Nieobsługiwane ujęcie arkusza."); const review = buildMediaReview(db, cfg, productId); const product = db.prepare("SELECT name FROM products WHERE id = ?").get(productId) as { name: string } | undefined; const fabric = db .prepare("SELECT f.name FROM variants v JOIN fabrics f ON f.id = v.fabric_id WHERE v.product_id = ? LIMIT 1") .get(productId) as { name: string } | undefined; const productLabel = product?.name?.toUpperCase() ?? "PRODUKT"; const fabricLabel = fabric?.name ?? "TKANINA"; const slots = review.slots.filter((s) => s.role === role).sort((a, b) => Number(a.shade) - Number(b.shade) || a.side.localeCompare(b.side)); const master = review.masters.find((a) => a.role === role); const items = [ ...(master ? [{ label: `MASTER ${role}`, asset: master }] : []), ...slots.map((s) => ({ label: `${fabricLabel} ${s.shade} / ${s.side}`, asset: s.asset })), ]; const columns = 4, width = 384, pictureHeight = 286, cellHeight = 354, header = 50; const layers: OverlayOptions[] = []; const esc = (s: string) => s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); layers.push({ input: Buffer.from(`${esc(productLabel)} — ${role} — przegląd wewnętrzny, nie materiał ofertyPrzegląd SHA-256: ${review.reviewSha256}`), left: 0, top: 0 }); for (const [index, item] of items.entries()) { const x = index % columns * width, y = header + Math.floor(index / columns) * cellHeight; const a = item.asset; if (a && !a.blockers.some((b) => /plik|Plik/.test(b))) { const row = db.prepare("SELECT path FROM assets WHERE id=? AND sha256=?").get(a.id, a.sha256) as { path: string } | undefined; if (!row) throw new ReviewConflict("Asset zmienił się podczas budowy arkusza."); const abs = path.resolve(cfg.mediaDir, row.path); if (!abs.startsWith(path.resolve(cfg.mediaDir) + path.sep)) throw new ReviewConflict("Nieprawidłowa ścieżka obrazu."); const input = await sharp(abs).resize(width - 8, pictureHeight, { fit: "contain", background: "#faf8f5" }).png().toBuffer(); layers.push({ input, left: x + 4, top: y }); } const status = a ? `${a.status} / ${a.qa?.verdict ?? "bez QA"}${a.blockers.length ? " / BLOKADA" : ""}` : "BRAK"; const svg = `${esc(item.label)} · ${esc(status)}${esc(a?.id ?? "brak assetu")}${a?.sha256.slice(0, 32) ?? ""}${a?.sha256.slice(32) ?? ""}`; layers.push({ input: Buffer.from(svg), left: x, top: y + pictureHeight }); } const dir = path.join(cfg.derivedDir, "reviews", review.reviewSha256); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, `${role}.png`); const manifest = { productId, role, reviewSha256: review.reviewSha256, items: items.map(({ label, asset }) => ({ label, id: asset?.id ?? null, sha256: asset?.sha256 ?? null, status: asset?.status ?? "missing" })) }; try { fs.writeFileSync(path.join(dir, `${role}.json`), JSON.stringify(manifest, null, 2), { flag: "wx" }); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; } if (!fs.existsSync(file)) { const png = await sharp({ create: { width: columns * width, height: header + Math.ceil(items.length / columns) * cellHeight, channels: 3, background: "#faf8f5" } }).composite(layers).png().toBuffer(); try { fs.writeFileSync(file, png, { flag: "wx" }); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; } } return { path: file, reviewSha256: review.reviewSha256, count: slots.length }; } export function decideMediaReview(db: Db, cfg: AppConfig, input: { productId: string; reviewSha256: string; entries: Array<{ id: string; sha256: string }>; decision: "approved" | "rejected"; actor: string; note: string; }) { if (!input.entries.length || input.entries.length > 272 || new Set(input.entries.map((x) => x.id)).size !== input.entries.length) throw new ReviewConflict("Wskaż od 1 do 272 różnych assetów."); const entries = input.entries.map((entry) => { const a = db.prepare("SELECT * FROM assets WHERE id = ? AND product_id = ?").get(entry.id, input.productId) as unknown as AssetRow | undefined; if (!a || !a.variant_id || !OFFER_ROLES.includes(a.role as typeof OFFER_ROLES[number])) throw new ReviewConflict("Zestaw zawiera asset spoza galerii wariantów produktu."); return { productId: input.productId, assetId: a.id, variantId: a.variant_id, scope: "variant_media" as const, target: a.role, decision: input.decision, expectedSha256: entry.sha256, decidedBy: input.actor, note: `${input.note}\nPrzegląd: ${input.reviewSha256}` }; }); if (input.decision === "approved" && new Set(entries.map((e) => `${e.variantId}:${e.target}`)).size !== entries.length) throw new ReviewConflict("Można zatwierdzić tylko jedną wersję tej samej roli wariantu."); return decideBatch(db, entries, () => { const review = buildMediaReview(db, cfg, input.productId); if (review.reviewSha256 !== input.reviewSha256) throw new ReviewConflict("Przegląd jest nieaktualny. Odśwież i ponownie wskaż materiały."); for (const entry of input.entries) { const asset = review.slots.flatMap((s) => s.candidates).find((a) => a.id === entry.id); if (!asset || asset.status !== "candidate" || asset.sha256 !== entry.sha256) throw new ReviewConflict("Decyzja obejmuje wyłącznie wskazanych kandydatów z tego przeglądu."); if (input.decision === "approved" && asset.blockers.length) throw new ReviewConflict("Usuń blokady integralności lub źródeł przed zatwierdzeniem."); } }); }