const { pool } = require('../db'); const PRODUCT_TYPES = new Set(['finished_product', 'raw_material']); const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); const normalizeNumber = (value) => { if (value === '' || value === null || value === undefined) return null; const number = Number(String(value).replace(',', '.')); return Number.isFinite(number) && number > 0 ? number : null; }; const normalizeProductType = (value) => { const normalized = normalizeText(value); return PRODUCT_TYPES.has(normalized) ? normalized : 'finished_product'; }; const normalizeStringArray = (value) => { if (!Array.isArray(value)) return []; return value.map(item => normalizeText(item)).filter(Boolean); }; const normalizeNumericMap = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; return Object.entries(value).reduce((normalized, [key, rawValue]) => { const normalizedKey = normalizeText(key).toUpperCase(); const number = normalizeNumber(rawValue); if (normalizedKey && number) normalized[normalizedKey] = number; return normalized; }, {}); }; const mapCategory = (row) => ({ id: row.id, name: row.name, description: row.description || '', createdAt: row.created_at, updatedAt: row.updated_at }); const mapProduct = (row) => ({ id: row.id, type: row.type, sku: row.sku, name: row.name, categoryId: row.category_id, categoryName: row.category_name || '', composition: row.composition || '', notes: row.notes || '', gramature: row.gramature === null ? null : Number(row.gramature), materialYield: row.material_yield === null ? null : Number(row.material_yield), widthCm: row.width_cm === null ? null : Number(row.width_cm), color: row.color || '', subcategory: row.subcategory || '', sizes: row.sizes || [], createdAt: row.created_at, updatedAt: row.updated_at }); const mapConsumptionReference = (row) => ({ id: row.id, productId: row.product_id, productSku: row.product_sku, productName: row.product_name, materialProductId: row.material_product_id, materialSku: row.material_sku || '', materialName: row.material_name || '', color: row.color || '', generalYield: row.general_yield === null ? null : Number(row.general_yield), sizeYields: row.size_yields || {}, sizeAreas: row.size_areas || {}, gramature: row.gramature === null ? null : Number(row.gramature), efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent), ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece), materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg), createdAt: row.created_at, updatedAt: row.updated_at }); const listCategories = async () => { const result = await pool.query(` SELECT id, name, description, created_at, updated_at FROM catalog_categories ORDER BY name `); return result.rows.map(mapCategory); }; const createCategory = async ({ name, description }) => { const normalizedName = normalizeText(name); if (!normalizedName) { const error = new Error('Nome da categoria é obrigatório.'); error.statusCode = 400; throw error; } const result = await pool.query(` INSERT INTO catalog_categories (name, description, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description, updated_at = CURRENT_TIMESTAMP RETURNING id, name, description, created_at, updated_at `, [normalizedName, normalizeText(description) || null]); return mapCategory(result.rows[0]); }; const deleteCategory = async (id) => { await pool.query('DELETE FROM catalog_categories WHERE id = $1', [id]); }; const listProducts = async () => { const result = await pool.query(` SELECT p.id, p.type, p.sku, p.name, p.category_id, c.name AS category_name, p.composition, p.notes, p.gramature, p.material_yield, p.width_cm, p.color, p.subcategory, p.sizes, p.created_at, p.updated_at FROM catalog_products p LEFT JOIN catalog_categories c ON c.id = p.category_id ORDER BY p.type, p.name, p.sku `); return result.rows.map(mapProduct); }; const createProduct = async (payload) => { const type = normalizeProductType(payload.type); const sku = normalizeText(payload.sku).toUpperCase(); const name = normalizeText(payload.name); if (!sku || !name) { const error = new Error('SKU e nome do produto são obrigatórios.'); error.statusCode = 400; throw error; } const categoryId = Number(payload.categoryId) || null; const result = await pool.query(` INSERT INTO catalog_products ( type, sku, name, category_id, composition, notes, gramature, material_yield, width_cm, color, subcategory, sizes, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP) ON CONFLICT (sku) DO UPDATE SET type = EXCLUDED.type, name = EXCLUDED.name, category_id = EXCLUDED.category_id, composition = EXCLUDED.composition, notes = EXCLUDED.notes, gramature = EXCLUDED.gramature, material_yield = EXCLUDED.material_yield, width_cm = EXCLUDED.width_cm, color = EXCLUDED.color, subcategory = EXCLUDED.subcategory, sizes = EXCLUDED.sizes, updated_at = CURRENT_TIMESTAMP RETURNING id `, [ type, sku, name, categoryId, normalizeText(payload.composition) || null, normalizeText(payload.notes) || null, normalizeNumber(payload.gramature), normalizeNumber(payload.materialYield), normalizeNumber(payload.widthCm), normalizeText(payload.color) || null, normalizeText(payload.subcategory) || null, normalizeStringArray(payload.sizes) ]); const products = await listProducts(); return products.find(product => product.id === result.rows[0].id); }; const deleteProduct = async (id) => { await pool.query('DELETE FROM catalog_products WHERE id = $1', [id]); }; const listConsumptionReferences = async () => { const result = await pool.query(` SELECT r.id, r.product_id, p.sku AS product_sku, p.name AS product_name, r.material_product_id, m.sku AS material_sku, m.name AS material_name, r.color, r.general_yield, r.size_yields, r.size_areas, r.gramature, r.efficiency_percent, r.rib_g_per_piece, r.material_cost_per_kg, r.created_at, r.updated_at FROM consumption_references r JOIN catalog_products p ON p.id = r.product_id LEFT JOIN catalog_products m ON m.id = r.material_product_id ORDER BY p.name, m.name NULLS FIRST, r.color NULLS FIRST `); return result.rows.map(mapConsumptionReference); }; const createConsumptionReference = async (payload) => { const productId = Number(payload.productId); if (!productId) { const error = new Error('Produto é obrigatório.'); error.statusCode = 400; throw error; } const sizeYields = normalizeNumericMap(payload.sizeYields); const sizeAreas = normalizeNumericMap(payload.sizeAreas); const generalYield = normalizeNumber(payload.generalYield); const calculatedYield = Object.values(sizeYields).length ? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length : null; if (!generalYield && !calculatedYield) { const error = new Error('Informe o rendimento geral ou por tamanho.'); error.statusCode = 400; throw error; } const result = await pool.query(` INSERT INTO consumption_references ( product_id, material_product_id, color, general_yield, size_yields, size_areas, gramature, efficiency_percent, rib_g_per_piece, material_cost_per_kg, updated_at ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, CURRENT_TIMESTAMP) RETURNING id `, [ productId, Number(payload.materialProductId) || null, normalizeText(payload.color) || null, generalYield || calculatedYield, JSON.stringify(sizeYields), JSON.stringify(sizeAreas), normalizeNumber(payload.gramature), normalizeNumber(payload.efficiencyPercent), normalizeNumber(payload.ribGPerPiece), normalizeNumber(payload.materialCostPerKg) ]); const references = await listConsumptionReferences(); return references.find(reference => reference.id === result.rows[0].id); }; const deleteConsumptionReference = async (id) => { await pool.query('DELETE FROM consumption_references WHERE id = $1', [id]); }; const getCatalogSummary = async () => { const [categories, products, consumptionReferences] = await Promise.all([ listCategories(), listProducts(), listConsumptionReferences() ]); return { categories, products, consumptionReferences }; }; module.exports = { createCategory, createConsumptionReference, createProduct, deleteCategory, deleteConsumptionReference, deleteProduct, getCatalogSummary, listCategories, listConsumptionReferences, listProducts };