From 37d4a7732ef16fb240fcf2f4cc9b249528b00fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 31 Jul 2026 11:32:49 -0300 Subject: [PATCH] Import Tiny product compositions --- backend/routes/catalogRoutes.js | 9 + backend/routes/productionOrderRoutes.js | 11 +- backend/server.js | 2 +- backend/services/databaseDiagnosticService.js | 8 + backend/services/productionOrderService.js | 166 +++++++++++++++++- .../test/productCompositionService.test.js | 101 ++++++++++- src/dataService.ts | 18 +- src/pages/Registrations.tsx | 49 +++++- src/types.ts | 19 ++ 9 files changed, 368 insertions(+), 15 deletions(-) diff --git a/backend/routes/catalogRoutes.js b/backend/routes/catalogRoutes.js index d1195a9..4c1a62c 100644 --- a/backend/routes/catalogRoutes.js +++ b/backend/routes/catalogRoutes.js @@ -12,6 +12,7 @@ const { listConsumptionReferences, listProducts } = require('../services/catalogService'); +const { importTinyProductCompositionExport } = require('../services/productionOrderService'); const router = express.Router(); @@ -89,6 +90,14 @@ router.post('/catalog/consumption-references', verifyToken, async (req, res, nex } }); +router.post('/catalog/product-compositions/import', verifyToken, async (req, res, next) => { + try { + res.status(201).json(await importTinyProductCompositionExport(req.body || {})); + } catch (error) { + next(error); + } +}); + router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => { try { await deleteConsumptionReference(req.params.id); diff --git a/backend/routes/productionOrderRoutes.js b/backend/routes/productionOrderRoutes.js index 5c5819a..fd6598b 100644 --- a/backend/routes/productionOrderRoutes.js +++ b/backend/routes/productionOrderRoutes.js @@ -1,6 +1,6 @@ const express = require('express'); const { authenticateAPIKey, verifyToken } = require('../auth'); -const { createProductionOrders, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService'); +const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService'); const router = express.Router(); @@ -32,6 +32,15 @@ router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) } }); +router.post('/production-orders/tiny-compositions/import', authenticateAPIKey, async (req, res) => { + try { + res.status(201).json(await importTinyProductCompositionExport(req.body || {})); + } catch (error) { + console.error('Error importing Tiny product compositions:', error); + res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' }); + } +}); + router.patch('/production-orders/:id/status', verifyToken, async (req, res) => { try { res.json(await updateProductionOrderStatus(req.params.id, req.body?.status)); diff --git a/backend/server.js b/backend/server.js index 020baf2..4d33666 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,7 +18,7 @@ const createApp = () => { const app = express(); app.use(cors()); - app.use(bodyParser.json()); + app.use(bodyParser.json({ limit: '10mb' })); app.use('/api', authRoutes); app.use('/api', dataRoutes); diff --git a/backend/services/databaseDiagnosticService.js b/backend/services/databaseDiagnosticService.js index 5ed3cd3..f739add 100644 --- a/backend/services/databaseDiagnosticService.js +++ b/backend/services/databaseDiagnosticService.js @@ -39,6 +39,14 @@ const sampleSpecs = { columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'], orderBy: ['updated_at', 'created_at', 'id'] }, + product_compositions: { + columns: ['id', 'source', 'external_source_id', 'finished_product_identity', 'finished_product_sku', 'finished_product_description', 'finished_product_unit', 'finished_tiny_product_id', 'last_synced_at', 'created_at', 'updated_at'], + orderBy: ['last_synced_at', 'updated_at', 'id'] + }, + product_composition_components: { + columns: ['id', 'product_composition_id', 'component_identity', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'unit', 'created_at', 'updated_at'], + orderBy: ['updated_at', 'created_at', 'id'] + }, production_orders: { columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'], orderBy: ['updated_at', 'created_at', 'id'] diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index 2876b1f..2b22c0a 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -132,7 +132,10 @@ const upsertCatalogProductFromSync = async (client, { sku, name, type, categoryN return result.rows[0].id; }; -const upsertConsumptionReferencesFromComponents = async (client, orderId, order, components) => { +const upsertConsumptionReferencesFromComponents = async (client, orderId, order, components, options = {}) => { + const source = normalizeText(options.source) || 'tiny_op'; + const sourceLabel = normalizeText(options.sourceLabel) || 'OP'; + if (!order.productSku || !components.length) { return { referenceCount: 0, skippedReferenceCount: components.length }; } @@ -142,7 +145,7 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order, name: order.productDescription, type: 'finished_product', categoryName: classifyCatalogCategoryName(order.productDescription, 'finished_product'), - notes: `Sincronizado da OP ${order.number || order.tinyId || orderId}.` + notes: `Sincronizado da ${sourceLabel} ${order.number || order.tinyId || orderId || ''}.` }); if (!productId) return { referenceCount: 0, skippedReferenceCount: components.length }; @@ -156,7 +159,7 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order, name: component.componentName, type: 'raw_material', categoryName: classifyCatalogCategoryName(component.componentName, 'raw_material'), - notes: `Sincronizado da composição da OP ${order.number || order.tinyId || orderId}.` + notes: `Sincronizado da composição da ${sourceLabel} ${order.number || order.tinyId || orderId || ''}.` }); if (!materialId || !component.quantityPerUnit) { @@ -173,9 +176,9 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order, WHERE product_id = $1 AND COALESCE(material_product_id, 0) = $2 AND COALESCE(color, '') = '' - AND COALESCE(source, 'manual') = 'tiny_op' + AND COALESCE(source, 'manual') = $3 LIMIT 1; - `, [productId, materialId]); + `, [productId, materialId, source]); if (existingReferenceResult.rows.length) { await client.query(` @@ -184,13 +187,15 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order, consumption_quantity = $2, consumption_unit = $3, last_production_order_id = $4, + source = $5, updated_at = CURRENT_TIMESTAMP - WHERE id = $5; + WHERE id = $6; `, [ generalYield, component.quantityPerUnit, component.unit || null, orderId, + source, existingReferenceResult.rows[0].id ]); } else { @@ -199,13 +204,14 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order, product_id, material_product_id, general_yield, consumption_quantity, consumption_unit, source, last_production_order_id, updated_at ) - VALUES ($1, $2, $3, $4, $5, 'tiny_op', $6, CURRENT_TIMESTAMP); + VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP); `, [ productId, materialId, generalYield, component.quantityPerUnit, component.unit || null, + source, orderId ]); } @@ -655,6 +661,16 @@ const TINY_OLIST_V3_COMPOSITION_PREFIX = 'STRUCTURE-V3-'; const isTinyOlistV3Composition = (order) => order.tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX); +const SELLABLE_PRODUCT_PATTERN = /\b(?:DTF|CAMISETA|CAMISA|MOLETOM|CANGURU|REGATA|POLO)\b/; +const NON_SELLABLE_STRUCTURE_PATTERN = /\b(?:MALHA|RIBANA|FIO|TECIDO|SERVICO|SERVIÇO|TINTURARIA|TECELAGEM|FRETE)\b/; + +const isSellableComposition = (order) => { + const unit = normalizeUnit(order.unit); + const description = normalizeCategoryName(order.productDescription); + if (unit !== 'un') return false; + return SELLABLE_PRODUCT_PATTERN.test(description) && !NON_SELLABLE_STRUCTURE_PATTERN.test(description); +}; + const getTinyProductIdFromCompositionReference = (tinyId) => ( tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length) ); @@ -766,6 +782,80 @@ const getProductComposition = async (productId, client = pool) => ( const listProductCompositions = async (client = pool) => findProductCompositions('', client); +const buildCompositionPayload = (composition) => { + const existingPayload = composition?.sourceMetadata?.payload || composition?.source_metadata?.payload; + if (existingPayload?.order) return existingPayload; + + const finishedTinyProductId = normalizeText(composition?.finishedTinyProductId || composition?.finished_tiny_product_id); + const finishedProductSku = normalizeSku(composition?.finishedProductSku || composition?.finished_product_sku); + const tinyId = normalizeText(composition?.externalSourceId || composition?.external_source_id) + || (finishedTinyProductId ? `${TINY_OLIST_V3_COMPOSITION_PREFIX}${finishedTinyProductId}` : `${TINY_OLIST_V3_COMPOSITION_PREFIX}${finishedProductSku}`); + + return { + order: { + tinyId, + number: normalizeText(composition?.externalSourceId || composition?.external_source_id) || tinyId, + status: 'completed', + productSku: finishedProductSku, + productDescription: normalizeText(composition?.finishedProductDescription || composition?.finished_product_description), + quantity: '1', + unit: normalizeText(composition?.finishedProductUnit || composition?.finished_product_unit) || 'UN', + notes: 'Composição importada de arquivo JSON.' + }, + components: Array.isArray(composition?.components) ? composition.components.map(component => ({ + componentTinyId: normalizeText(component.componentTinyId || component.component_tiny_id || component.productId || component.product_id), + componentSku: normalizeSku(component.componentSku || component.component_sku), + componentName: normalizeText(component.componentName || component.component_name), + quantityPerUnit: component.quantityPerUnit ?? component.quantity_per_unit, + totalQuantity: component.quantityPerUnit ?? component.quantity_per_unit, + unit: normalizeText(component.unit) + })) : [], + steps: [] + }; +}; + +const listCompositionImportIssues = (composition, order, components, result) => { + const issues = []; + if (!order.productSku) { + issues.push({ + type: 'missing_finished_sku', + product: order.productDescription, + tinyProductId: getTinyProductIdFromCompositionReference(order.tinyId) + }); + } + + if (!isSellableComposition(order)) { + issues.push({ + type: 'not_sellable_product', + productSku: order.productSku, + product: order.productDescription, + unit: order.unit + }); + } + + components + .filter(component => !component.componentSku || !component.quantityPerUnit) + .forEach(component => { + issues.push({ + type: !component.componentSku ? 'missing_component_sku' : 'invalid_component_quantity', + productSku: order.productSku, + component: component.componentName, + componentSku: component.componentSku + }); + }); + + if ((result?.skippedReferenceCount || 0) > 0) { + issues.push({ + type: 'skipped_references', + productSku: order.productSku, + product: order.productDescription, + count: result.skippedReferenceCount + }); + } + + return issues; +}; + const upsertTinyProductComposition = async (order, components) => { const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId); const finishedProductSku = normalizeSku(order.productSku); @@ -808,6 +898,7 @@ const upsertTinyProductComposition = async (order, components) => { await client.query(` UPDATE product_compositions SET external_source_id = $1, + finished_product_identity = $2, finished_product_sku = $3, finished_product_description = $4, finished_product_unit = $5, @@ -852,10 +943,19 @@ const upsertTinyProductComposition = async (order, components) => { ]); } + const referenceSync = isSellableComposition(order) + ? await upsertConsumptionReferencesFromComponents(client, null, order, normalizedComponents, { + source: 'tiny_structure', + sourceLabel: 'estrutura Tiny/Olist' + }) + : { referenceCount: 0, skippedReferenceCount: normalizedComponents.length }; + await client.query('COMMIT'); return { composition: await getProductComposition(finishedTinyProductId || finishedProductSku), - componentCount: normalizedComponents.length + componentCount: normalizedComponents.length, + referenceCount: referenceSync.referenceCount, + skippedReferenceCount: referenceSync.skippedReferenceCount }; } catch (error) { await client.query('ROLLBACK'); @@ -865,6 +965,55 @@ const upsertTinyProductComposition = async (order, components) => { } }; +const importTinyProductCompositionExport = async (payload = {}) => { + const compositions = Array.isArray(payload) + ? payload + : Array.isArray(payload.compositions) ? payload.compositions : []; + + if (!compositions.length) { + const error = new Error('Arquivo de composições sem itens para importar.'); + error.statusCode = 400; + throw error; + } + + const summary = { + imported: 0, + failed: 0, + componentCount: 0, + referenceCount: 0, + skippedReferenceCount: 0, + issues: [] + }; + + for (const composition of compositions) { + try { + const compositionPayload = buildCompositionPayload(composition); + const order = resolveTinyOrderPayload(compositionPayload); + const components = normalizeComponents( + compositionPayload.components || compositionPayload.composition || compositionPayload.composicao, + order.quantity + ); + const result = await upsertTinyProductComposition(order, components); + + summary.imported += 1; + summary.componentCount += result.componentCount || 0; + summary.referenceCount += result.referenceCount || 0; + summary.skippedReferenceCount += result.skippedReferenceCount || 0; + summary.issues.push(...listCompositionImportIssues(composition, order, components, result)); + } catch (error) { + summary.failed += 1; + summary.issues.push({ + type: 'import_failed', + productSku: normalizeText(composition?.finishedProductSku || composition?.finished_product_sku), + product: normalizeText(composition?.finishedProductDescription || composition?.finished_product_description), + message: error.message + }); + } + } + + return summary; +}; + const upsertTinyProductionOrderDetail = async (payload = {}) => { const order = resolveTinyOrderPayload(payload); const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity); @@ -1057,6 +1206,7 @@ const updateProductionOrderStatus = async (id, status) => { module.exports = { createProductionOrders, getProductComposition, + importTinyProductCompositionExport, listProductCompositions, listProductionOrders, normalizeStatus, diff --git a/backend/test/productCompositionService.test.js b/backend/test/productCompositionService.test.js index 675e61f..4b03fe6 100644 --- a/backend/test/productCompositionService.test.js +++ b/backend/test/productCompositionService.test.js @@ -12,6 +12,7 @@ const getRouteHandler = (router, method, path) => { }; const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync'); +const tinyCompositionImportHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-compositions/import'); const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition'); const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions'); @@ -70,9 +71,15 @@ const withFakeDatabase = async (run) => { const state = { nextCompositionId: 1, nextOrderId: 1, + nextCategoryId: 1, + nextCatalogProductId: 1, + nextConsumptionReferenceId: 1, compositions: [], compositionComponents: [], - productionOrders: [] + productionOrders: [], + categories: [], + catalogProducts: [], + consumptionReferences: [] }; const query = async (sql, params = []) => { @@ -80,6 +87,72 @@ const withFakeDatabase = async (run) => { if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] }; + if (normalizedSql.startsWith('INSERT INTO catalog_categories')) { + let category = state.categories.find(item => item.name === params[0]); + if (!category) { + category = { id: state.nextCategoryId++, name: params[0], description: params[1] }; + state.categories.push(category); + } + return { rows: [{ id: category.id }] }; + } + + if (normalizedSql.startsWith('INSERT INTO catalog_products')) { + let product = state.catalogProducts.find(item => item.sku === params[1]); + if (!product) { + product = { + id: state.nextCatalogProductId++, + type: params[0], + sku: params[1], + name: params[2], + category_id: params[3], + notes: params[4] + }; + state.catalogProducts.push(product); + } else { + product.type = params[0]; + product.name = params[2]; + product.category_id = product.category_id || params[3]; + product.notes = product.notes || params[4]; + } + return { rows: [{ id: product.id }] }; + } + + if (normalizedSql.startsWith('SELECT id FROM consumption_references')) { + const reference = state.consumptionReferences.find(item => ( + item.product_id === params[0] + && (item.material_product_id || 0) === params[1] + && (item.color || '') === '' + && (item.source || 'manual') === params[2] + )); + return { rows: reference ? [{ id: reference.id }] : [] }; + } + + if (normalizedSql.startsWith('UPDATE consumption_references')) { + const reference = state.consumptionReferences.find(item => item.id === params[5]); + if (reference) { + reference.general_yield = params[0]; + reference.consumption_quantity = params[1]; + reference.consumption_unit = params[2]; + reference.last_production_order_id = params[3]; + reference.source = params[4]; + } + return { rows: [] }; + } + + if (normalizedSql.startsWith('INSERT INTO consumption_references')) { + state.consumptionReferences.push({ + id: state.nextConsumptionReferenceId++, + product_id: params[0], + material_product_id: params[1], + general_yield: params[2], + consumption_quantity: params[3], + consumption_unit: params[4], + source: params[5], + last_production_order_id: params[6] + }); + return { rows: [] }; + } + if (normalizedSql.startsWith('SELECT id FROM product_compositions WHERE source')) { const composition = state.compositions.find(item => item.source === params[0] && item.finished_product_identity === params[1]); return { rows: composition ? [{ id: composition.id }] : [] }; @@ -197,10 +270,36 @@ test('Tiny/Olist V3 first import creates a composition without creating an OP', assert.equal(state.compositions.length, 1); assert.equal(state.compositionComponents.length, 2); assert.equal(state.productionOrders.length, 0); + assert.equal(state.consumptionReferences.length, 2); assert.equal(result.composition.finishedTinyProductId, '976058813'); }); }); +test('Tiny/Olist V3 bulk import stores compositions and consumption references', async () => { + await withFakeDatabase(async (state) => { + const response = await invokeHandler(tinyCompositionImportHandler, { + body: { + exportedAt: '2026-07-31T13:37:01.340Z', + compositions: [{ + finishedProductSku: 'BLCS.CAF.GG', + finishedProductDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG', + finishedProductUnit: 'UN', + finishedTinyProductId: '976058813', + sourceMetadata: { payload: compositionPayload() }, + components: [] + }] + } + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.body.imported, 1); + assert.equal(response.body.referenceCount, 2); + assert.equal(state.compositions.length, 1); + assert.equal(state.consumptionReferences.length, 2); + assert.equal(state.consumptionReferences[0].source, 'tiny_structure'); + }); +}); + test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => { await withFakeDatabase(async (state) => { await invokeHandler(tinySyncHandler, { body: compositionPayload() }); diff --git a/src/dataService.ts b/src/dataService.ts index ae8bace..8631139 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -345,6 +345,22 @@ export const deleteConsumptionReference = async (id: number): Promise => { } }; +export const importProductCompositions = async (payload: unknown): Promise => { + const response = await authFetch('/catalog/product-compositions/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data?.error || 'Não foi possível importar as composições.'); + } + + analyticsCache.clear(); + return data as ProductCompositionImportSummary; +}; + export const fetchSupplySummary = async (): Promise => { const emptySummary: SupplySummary = { receipts: [], diff --git a/src/pages/Registrations.tsx b/src/pages/Registrations.tsx index 2edaa7b..5eda5e7 100644 --- a/src/pages/Registrations.tsx +++ b/src/pages/Registrations.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; -import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react'; +import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react'; import { deleteCatalogCategory, deleteCatalogProduct, deleteConsumptionReference, fetchCatalogSummary, + importProductCompositions, saveCatalogCategory, saveCatalogProduct, saveConsumptionReference @@ -93,7 +94,9 @@ const getAverageYield = (reference: ConsumptionReference) => { }; const getReferenceSourceLabel = (reference: ConsumptionReference) => ( - reference.source === 'tiny_op' ? 'Tiny OP' : 'Manual' + reference.source === 'tiny_op' + ? 'Tiny OP' + : reference.source === 'tiny_structure' ? 'Tiny Estrutura' : 'Manual' ); const formatConsumptionPerPiece = (reference: ConsumptionReference) => { @@ -111,6 +114,7 @@ const Registrations = () => { const [status, setStatus] = useState('idle'); const [feedback, setFeedback] = useState(''); const appliedSkuPrefillRef = useRef(''); + const compositionFileInputRef = useRef(null); const [categoryForm, setCategoryForm] = useState({ name: '', description: '' }); const [productForm, setProductForm] = useState(defaultProductForm); @@ -323,6 +327,29 @@ const Registrations = () => { setSizeYields({}); }, 'Referencia salva.'); + const importCompositionFile = async (file: File | null) => { + if (!file) return; + + setStatus('saving'); + setFeedback('Importando composições...'); + + try { + const payload = JSON.parse(await file.text()) as unknown; + const result = await importProductCompositions(payload); + await loadCatalog(); + setStatus(result.failed ? 'error' : 'saved'); + setFeedback( + `${result.imported} composições importadas, ${result.referenceCount} referências criadas/atualizadas` + + `${result.failed ? `, ${result.failed} falhas` : ''}.` + ); + } catch (error) { + setStatus('error'); + setFeedback(error instanceof Error ? error.message : 'Não foi possível importar o arquivo.'); + } finally { + if (compositionFileInputRef.current) compositionFileInputRef.current.value = ''; + } + }; + const removeCategory = (category: CatalogCategory) => runAction( () => deleteCatalogCategory(category.id), `Categoria ${category.name} excluida.` @@ -363,6 +390,22 @@ const Registrations = () => {

+ void importCompositionFile(event.target.files?.[0] || null)} + /> + { {Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'} diff --git a/src/types.ts b/src/types.ts index f896bd2..296ef0f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -498,6 +498,25 @@ export interface ProductComposition { components: ProductCompositionComponent[]; } +export interface ProductCompositionImportSummary { + imported: number; + failed: number; + componentCount: number; + referenceCount: number; + skippedReferenceCount: number; + issues: Array<{ + type: string; + productSku?: string; + product?: string; + component?: string; + componentSku?: string; + tinyProductId?: string; + unit?: string; + count?: number; + message?: string; + }>; +} + export interface ClientAnalyticsItem { customerKey: string; clientToken: string;