import type { Db } from "../db.js"; import { nowIso, tx } from "../db.js"; import { newId, ID_PREFIX } from "./ids.js"; import { audit } from "./audit.js"; import { claimNextEan, releaseEansForVariants } from "./eanPool.js"; import type { Side } from "./types.js"; export interface FabricRow { id: string; code: string; name: string; producer: string; params_json: string; card_path: string | null; ai_prompt: string | null; /** Klucz typu struktury z FABRIC_STRUCTURES (wybór operatora po polsku). */ ai_structure: string | null; /** Wolna uwaga operatora po polsku — źródło dla tłumaczenia do ai_prompt. */ ai_note_pl: string | null; verification: string; } /** * Odczyt params_json bez wyjątków — uszkodzony/nie-obiektowy zapis * zwraca pusty obiekt zamiast wysyłać 500 w widoku karty, oferty * i dokumentów. Dane nie są nadpisywane, tylko niewidoczne. */ export function parseFabricParams(paramsJson: string | null | undefined): Record { if (!paramsJson) return {}; try { const parsed: unknown = JSON.parse(paramsJson); return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch { return {}; } } export interface ShadeRow { id: string; fabric_id: string; code: string; name: string | null; color_hex: string | null; official_image_path: string | null; photo_path: string | null; verification: string; } export interface VariantRow { id: string; product_id: string; side: Side; fabric_id: string; shade_code: string; status: string; /** EAN przydzielony z ean_pool przy utworzeniu wariantu (null = brak/pula pusta). */ ean?: string | null; } export function upsertFabric( db: Db, input: { code: string; name: string; producer: string; params?: Record; cardPath?: string | null; aiPrompt?: string | null; aiStructure?: string | null; aiNotePl?: string | null; verification?: string; } ): FabricRow { const now = nowIso(); const existing = db.prepare("SELECT * FROM fabrics WHERE code = ?").get(input.code) as unknown as | FabricRow | undefined; if (existing) { db.prepare( `UPDATE fabrics SET name = ?, producer = ?, params_json = ?, card_path = COALESCE(?, card_path), ai_prompt = COALESCE(?, ai_prompt), ai_structure = COALESCE(?, ai_structure), ai_note_pl = COALESCE(?, ai_note_pl), verification = ?, updated_at = ? WHERE id = ?` ).run( input.name, input.producer, // bez podanych parametrów zachowujemy surowy zapis — nawet jeśli jest // uszkodzony, reimport nie może się wywrócić na jego parsowaniu input.params === undefined ? existing.params_json : JSON.stringify(input.params), input.cardPath ?? null, input.aiPrompt ?? null, input.aiStructure ?? null, input.aiNotePl ?? null, input.verification ?? existing.verification, now, existing.id ); return db.prepare("SELECT * FROM fabrics WHERE id = ?").get(existing.id) as unknown as FabricRow; } const id = newId(ID_PREFIX.fabric); db.prepare( `INSERT INTO fabrics (id, code, name, producer, params_json, card_path, ai_prompt, ai_structure, ai_note_pl, verification, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, input.code, input.name, input.producer, JSON.stringify(input.params ?? {}), input.cardPath ?? null, input.aiPrompt ?? null, input.aiStructure ?? null, input.aiNotePl ?? null, input.verification ?? "unverified", now, now ); return db.prepare("SELECT * FROM fabrics WHERE id = ?").get(id) as unknown as FabricRow; } /** * Dodaje odcień do biblioteki. Odcienie nigdy nie znikają przy aktualizacji * karty — brakujące zostają, nowe są dopisywane. */ export function upsertShade( db: Db, fabricId: string, input: { code: string; name?: string | null; colorHex?: string | null; officialImagePath?: string | null; photoPath?: string | null; verification?: string; } ): void { const now = nowIso(); db.prepare( `INSERT INTO fabric_shades (id, fabric_id, code, name, color_hex, official_image_path, photo_path, verification, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (fabric_id, code) DO UPDATE SET name = COALESCE(excluded.name, fabric_shades.name), color_hex = COALESCE(excluded.color_hex, fabric_shades.color_hex), official_image_path = COALESCE(excluded.official_image_path, fabric_shades.official_image_path), photo_path = COALESCE(excluded.photo_path, fabric_shades.photo_path), verification = excluded.verification` ).run( newId(ID_PREFIX.shade), fabricId, input.code, input.name ?? null, input.colorHex ?? null, input.officialImagePath ?? null, input.photoPath ?? null, input.verification ?? "unverified", now ); } /** * Typy struktury tkaniny — operator wybiera po polsku, klauzulę EN do * promptu składa system. Receptury nie opisują struktury (strukturę niosą * referencje obrazów, sloty 2–3), więc to jedyny tekstowy opis tkaniny — * wybierany wyłącznie gdy trzeba pomóc modelowi ponad to, co widać * na próbce/zdjęciu. */ export const FABRIC_STRUCTURES = [ { key: "flat", label: "gładka / drobny splot", clause: "smooth flat-woven fabric, no pile" }, { key: "boucle", label: "pętelkowa (bouclé)", clause: "looped bouclé-style pile, no ribs" }, { key: "chenille", label: "szenilowa", clause: "soft chenille pile" }, { key: "corduroy", label: "prążkowana (sztruks)", clause: "ribbed corduroy with parallel wales" }, { key: "velvet", label: "aksamitna / pluszowa", clause: "velvet plush pile" }, { key: "woven", label: "tkana / pleciona", clause: "visible woven texture" }, { key: "melange", label: "melanżowa", clause: "melange mixed-yarn weave" }, { key: "leather", label: "skórzana / ekoskóra", clause: "smooth faux leather surface" }, ] as const; export type FabricStructureKey = (typeof FABRIC_STRUCTURES)[number]["key"]; export function fabricStructureClause(key: string | null | undefined): string | null { return FABRIC_STRUCTURES.find((s) => s.key === key)?.clause ?? null; } /** * Składa klauzulę EN `fabrics.ai_prompt` z wyboru operatora: typ struktury * (deterministyczne mapowanie) + wolna uwaga — już przetłumaczona na EN * albo surowa PL, gdy tłumaczenie jest niedostępne. Obie części puste → null. */ export function composeFabricAiPrompt(structure: string | null, note: string | null): string | null { const parts = [fabricStructureClause(structure), note?.trim() || null].filter(Boolean); return parts.length ? parts.join(", ") : null; } /** * Nota struktury tkaniny — operator zapisuje wybór struktury (klucz z * FABRIC_STRUCTURES) i wolną uwagę po polsku; `noteEn` to wynik tłumaczenia * uwagi na EN wykonany w warstwie trasy (Groq, z fallbackiem na surowy PL). * Zapisuje ai_structure/ai_note_pl (stan dla UI) i złożoną klauzulę * ai_prompt (EN) trafiającą do promptu jako „Fabric note:". */ export function setFabricAiNote( db: Db, code: string, input: { structure: string | null; notePl: string | null; noteEn: string | null }, actor: string ): FabricRow | null { const fabric = db.prepare("SELECT * FROM fabrics WHERE code = ?").get(code) as unknown as FabricRow | undefined; if (!fabric) return null; const structure = input.structure || null; const notePl = input.notePl?.trim() || null; const noteEn = input.noteEn?.trim() || null; const composed = composeFabricAiPrompt(structure, noteEn ?? notePl); if (fabric.ai_structure !== structure || fabric.ai_note_pl !== notePl || fabric.ai_prompt !== composed) { db.prepare("UPDATE fabrics SET ai_structure = ?, ai_note_pl = ?, ai_prompt = ?, updated_at = ? WHERE id = ?") .run(structure, notePl, composed, nowIso(), fabric.id); audit(db, { actor, action: "fabric.ai_prompt", entity: "fabrics", entityId: fabric.id, detail: { code, structure, notePl, noteEn, from: fabric.ai_prompt, to: composed }, }); } return db.prepare("SELECT * FROM fabrics WHERE id = ?").get(fabric.id) as unknown as FabricRow; } /** Jawna decyzja człowieka o statusie weryfikacji tkaniny — wymagana przez guard recoloru. */ export function setFabricVerification( db: Db, code: string, verification: "confirmed" | "unverified", actor: string ): FabricRow | null { const fabric = db.prepare("SELECT * FROM fabrics WHERE code = ?").get(code) as unknown as FabricRow | undefined; if (!fabric) return null; if (fabric.verification !== verification) { db.prepare("UPDATE fabrics SET verification = ?, updated_at = ? WHERE id = ?").run(verification, nowIso(), fabric.id); audit(db, { actor, action: "fabric.verification", entity: "fabrics", entityId: fabric.id, detail: { code, from: fabric.verification, to: verification } }); } return db.prepare("SELECT * FROM fabrics WHERE id = ?").get(fabric.id) as unknown as FabricRow; } /** * Przypięcie/odpięcie „Zdjęcia Vilmax" odcienia (fabric_shades.photo_path). * Źródło musi istnieć i być obrazem — to referencja zachowania tkaniny * dla generacji (PRIORYTET 1; puste pole → fallback na próbkę producenta). * sourceId === null czyści slot. */ export function setShadePhoto( db: Db, fabricCode: string, shadeCode: string, sourceId: string | null, actor: string ): ShadeRow | null { const shade = db .prepare("SELECT s.* FROM fabric_shades s JOIN fabrics f ON f.id = s.fabric_id WHERE f.code = ? AND s.code = ?") .get(fabricCode, shadeCode) as unknown as ShadeRow | undefined; if (!shade) return null; if (sourceId !== null) { const src = db.prepare("SELECT mime FROM source_assets WHERE id = ?").get(sourceId) as unknown as | { mime: string } | undefined; if (!src) throw new Error("Nie znaleziono materiału źródłowego."); if (!src.mime.startsWith("image/")) throw new Error("Zdjęcie Vilmax musi być obrazem (JPEG/PNG/WebP)."); } if (shade.photo_path !== sourceId) { db.prepare("UPDATE fabric_shades SET photo_path = ? WHERE id = ?").run(sourceId, shade.id); audit(db, { actor, action: sourceId ? "fabric_shade.photo_set" : "fabric_shade.photo_cleared", entity: "fabric_shades", entityId: shade.id, detail: { fabricCode, shadeCode, from: shade.photo_path, to: sourceId }, }); } return db.prepare("SELECT * FROM fabric_shades WHERE id = ?").get(shade.id) as unknown as ShadeRow; } /** Jawna decyzja człowieka o statusie weryfikacji odcienia. */ export function setShadeVerification( db: Db, fabricCode: string, shadeCode: string, verification: "confirmed" | "unverified", actor: string ): ShadeRow | null { const shade = db .prepare("SELECT s.* FROM fabric_shades s JOIN fabrics f ON f.id = s.fabric_id WHERE f.code = ? AND s.code = ?") .get(fabricCode, shadeCode) as unknown as ShadeRow | undefined; if (!shade) return null; if (shade.verification !== verification) { db.prepare("UPDATE fabric_shades SET verification = ? WHERE id = ?").run(verification, shade.id); audit(db, { actor, action: "fabric_shade.verification", entity: "fabric_shades", entityId: shade.id, detail: { fabricCode, shadeCode, from: shade.verification, to: verification } }); } return db.prepare("SELECT * FROM fabric_shades WHERE id = ?").get(shade.id) as unknown as ShadeRow; } /** Zbiorcza decyzja człowieka: ustawia weryfikację wszystkich odcieni kolekcji * w jednej transakcji — każdy odcień dostaje osobny wpis audytowy * (jak przy wywołaniach per-odcień), nie jedna zbiorcza flaga. */ export function setAllShadesVerification( db: Db, fabricCode: string, verification: "confirmed" | "unverified", actor: string ): { total: number; changed: number } | null { const fabric = db.prepare("SELECT * FROM fabrics WHERE code = ?").get(fabricCode) as unknown as | FabricRow | undefined; if (!fabric) return null; const shades = listShades(db, fabric.id); let changed = 0; tx(db, () => { for (const shade of shades) { if (shade.verification !== verification) changed++; setShadeVerification(db, fabricCode, shade.code, verification, actor); } }); return { total: shades.length, changed }; } export function listShades(db: Db, fabricId: string): ShadeRow[] { return db .prepare("SELECT * FROM fabric_shades WHERE fabric_id = ? ORDER BY CAST(code AS INTEGER)") .all(fabricId) as unknown as ShadeRow[]; } /** * Tworzy matrycę wariantów: strony × odcienie danej tkaniny. * `shadeCodes` zawęża matrycę do podzbioru handlowego — warianty dla * reszty kolekcji nie powstają. Brak kodu w kolekcji jest błędem * (operator musi wiedzieć, że nie wszystkie warianty powstały). * Idempotentna — istniejące warianty zostają, brakujące są dopisywane. * Kombinacje spoza dozwolonego zbioru nie powstają. */ export function ensureVariantMatrix( db: Db, input: { productId: string; fabricId: string; sides: Side[]; shadeCodes?: string[] | undefined } ): { created: number; total: number } { const all = listShades(db, input.fabricId); let shades = all; if (input.shadeCodes) { const have = new Set(all.map((s) => s.code)); const missing = [...new Set(input.shadeCodes)].filter((c) => !have.has(c)); if (missing.length) { throw new Error(`Kolekcja nie zawiera odcieni: ${missing.join(", ")}. Dostępne: ${all.map((s) => s.code).join(", ")}.`); } const wanted = new Set(input.shadeCodes); shades = all.filter((s) => wanted.has(s.code)); } const now = nowIso(); let created = 0; tx(db, () => { const insert = db.prepare( `INSERT OR IGNORE INTO variants (id, product_id, side, fabric_id, shade_code, status, created_at) VALUES (?, ?, ?, ?, ?, 'draft', ?)` ); for (const side of input.sides) { for (const shade of shades) { const variantId = newId(ID_PREFIX.variant); const res = insert.run(variantId, input.productId, side, input.fabricId, shade.code, now); if (res.changes > 0) { created++; // Kolejny wolny EAN z puli (FIFO). Gdy pula pusta — wariant // powstaje bez EAN-a; przydział odnotowuje audit 'ean.assigned'. claimNextEan(db, variantId); } } } }); const total = ( db.prepare("SELECT COUNT(*) AS c FROM variants WHERE product_id = ?").get(input.productId) as unknown as { c: number } ).c; return { created, total }; } /** * Usuwa szkice wariantów na stronach spoza `keepSides` — np. po przełączeniu * produktu z narożnika (L/P) na sofę (S). Usuwane są wyłącznie warianty * w statusie 'draft' bez żadnych mediów; ich EAN-y wracają do puli. * Warianty z assetami lub innym statusem są pomijane i zgłaszane. */ export function pruneVariantSides( db: Db, input: { productId: string; keepSides: Side[]; actor?: string } ): { removed: number; releasedEans: number; skipped: Array<{ id: string; side: string; shade_code: string; reason: string }> } { return tx(db, () => { const placeholders = input.keepSides.map(() => "?").join(","); const stale = db .prepare(`SELECT id, side, shade_code, status FROM variants WHERE product_id = ? AND side NOT IN (${placeholders})`) .all(input.productId, ...input.keepSides) as unknown as Array<{ id: string; side: string; shade_code: string; status: string }>; const assetCount = db.prepare("SELECT COUNT(*) AS c FROM assets WHERE variant_id = ?"); const del = db.prepare("DELETE FROM variants WHERE id = ?"); const removable: string[] = []; const skipped: Array<{ id: string; side: string; shade_code: string; reason: string }> = []; for (const v of stale) { const assets = (assetCount.get(v.id) as unknown as { c: number }).c; if (assets > 0) skipped.push({ id: v.id, side: v.side, shade_code: v.shade_code, reason: `${assets} powiązanych mediów` }); else if (v.status !== "draft") skipped.push({ id: v.id, side: v.side, shade_code: v.shade_code, reason: `status ${v.status}` }); else removable.push(v.id); } const releasedEans = releaseEansForVariants(db, removable, input.actor ?? "system"); for (const id of removable) del.run(id); if (removable.length) { audit(db, { actor: input.actor ?? "system", action: "variants.pruned", entity: "products", entityId: input.productId, detail: { removed: removable.length, keepSides: input.keepSides, skipped: skipped.length }, }); } return { removed: removable.length, releasedEans, skipped }; }); } export class VariantDeleteError extends Error { constructor(public readonly statusCode: number, message: string) { super(message); } } /** * Usuwa jeden szkic wariantu z oferty (odznaczony odcień/strona w Kroku 1). * Dozwolone wyłącznie dla statusu 'draft' bez powiązanych mediów — inne * warianty zostają i wymagają decyzji poza matrycą. EAN wraca do puli. */ export function deleteDraftVariant( db: Db, input: { productId: string; variantId: string; actor?: string } ): { removed: boolean; releasedEans: number } { return tx(db, () => { const v = db .prepare("SELECT id, side, shade_code, status FROM variants WHERE id = ? AND product_id = ?") .get(input.variantId, input.productId) as unknown as { id: string; side: string; shade_code: string; status: string } | undefined; if (!v) throw new VariantDeleteError(404, "Nie znaleziono wariantu tego produktu."); const assets = (db.prepare("SELECT COUNT(*) AS c FROM assets WHERE variant_id = ?").get(v.id) as unknown as { c: number }).c; if (assets > 0) throw new VariantDeleteError(409, `Wariant ${v.side}/${v.shade_code} ma ${assets} powiązanych mediów — usuwanie wymaga przeglądu mediów, nie odznaczenia.`); if (v.status !== "draft") throw new VariantDeleteError(409, `Wariant ${v.side}/${v.shade_code} ma status ${v.status} — usuwalne są tylko szkice bez mediów.`); const releasedEans = releaseEansForVariants(db, [v.id], input.actor ?? "system"); db.prepare("DELETE FROM variants WHERE id = ?").run(v.id); audit(db, { actor: input.actor ?? "system", action: "variant.deleted", entity: "variants", entityId: v.id, detail: { productId: input.productId, side: v.side, shade: v.shade_code, releasedEans }, }); return { removed: true, releasedEans }; }); } export function listVariants(db: Db, productId: string): VariantRow[] { return db .prepare( `SELECT v.*, e.ean FROM variants v LEFT JOIN ean_pool e ON e.assigned_variant_id = v.id WHERE v.product_id = ? ORDER BY v.side, CAST(v.shade_code AS INTEGER)` ) .all(productId) as unknown as VariantRow[]; }