diff --git a/backend/db.js b/backend/db.js index 282a245..39ebeb8 100644 --- a/backend/db.js +++ b/backend/db.js @@ -182,6 +182,40 @@ const initDB = async () => { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS product_compositions ( + id SERIAL PRIMARY KEY, + source VARCHAR(60) NOT NULL, + external_source_id VARCHAR(120) NOT NULL, + finished_product_identity VARCHAR(255) NOT NULL, + finished_product_sku VARCHAR(255), + finished_product_description TEXT NOT NULL, + finished_product_unit VARCHAR(30) NOT NULL DEFAULT 'UN', + finished_tiny_product_id VARCHAR(100), + source_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + last_synced_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE (source, finished_product_identity) + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS product_composition_components ( + id SERIAL PRIMARY KEY, + product_composition_id INTEGER NOT NULL REFERENCES product_compositions(id) ON DELETE CASCADE, + component_identity VARCHAR(255) NOT NULL, + component_tiny_id VARCHAR(100), + component_sku VARCHAR(255), + component_name TEXT NOT NULL, + quantity_per_unit NUMERIC(14, 4) NOT NULL DEFAULT 0, + unit VARCHAR(30), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE (product_composition_id, component_identity) + ); + `); + await pool.query(` CREATE TABLE IF NOT EXISTS cutting_family_rules ( family_key VARCHAR(20) PRIMARY KEY, @@ -504,6 +538,8 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_steps_order_id ON production_order_steps (production_order_id);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_compositions_finished_tiny_id ON product_compositions (finished_tiny_product_id);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_composition_components_tiny_id ON product_composition_components (component_tiny_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`); diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index bb425e1..6748af9 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -10,6 +10,7 @@ const { getProductDetailsAnalytics, getRfmAnalytics } = require('../services/analyticsService'); +const { getProductComposition } = require('../services/productionOrderService'); const router = express.Router(); @@ -58,6 +59,16 @@ router.get('/analytics/products/:productId/details', verifyToken, async (req, re } }); +router.get('/analytics/products/:productId/composition', verifyToken, async (req, res) => { + try { + const composition = await getProductComposition(req.params.productId); + res.json({ composition }); + } catch (error) { + console.error('Error fetching product composition:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + router.get('/analytics/clients', verifyToken, async (req, res) => { try { res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index 7164770..301d43d 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -650,9 +650,223 @@ const normalizeSteps = (steps = []) => ( color: normalizeText(step.color || step.cor) })).filter(step => step.name); +const TINY_OLIST_V3_COMPOSITION_SOURCE = 'tiny_olist_v3'; +const TINY_OLIST_V3_COMPOSITION_PREFIX = 'STRUCTURE-V3-'; + +const isTinyOlistV3Composition = (order) => order.tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX); + +const getTinyProductIdFromCompositionReference = (tinyId) => ( + tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length) +); + +const normalizeCompositionComponents = (components) => { + const byIdentity = new Map(); + + for (const component of components) { + const componentIdentity = normalizeSku(component.componentSku) + || normalizeText(component.componentTinyId) + || normalizeText(component.componentName).toUpperCase(); + if (!componentIdentity) continue; + + byIdentity.set(componentIdentity, { + ...component, + componentSku: normalizeSku(component.componentSku), + componentIdentity + }); + } + + return [...byIdentity.values()]; +}; + +const mapProductCompositionRow = (row) => ({ + id: Number(row.id), + source: row.source, + externalSourceId: row.external_source_id, + finishedProductSku: row.finished_product_sku || '', + finishedProductDescription: row.finished_product_description || '', + finishedProductUnit: row.finished_product_unit || 'UN', + finishedTinyProductId: row.finished_tiny_product_id || '', + sourceMetadata: row.source_metadata || {}, + lastSyncedAt: row.last_synced_at || null, + components: Array.isArray(row.components) ? row.components.map(component => ({ + id: Number(component.id), + componentTinyId: component.component_tiny_id || '', + componentSku: component.component_sku || '', + componentName: component.component_name || '', + quantityPerUnit: Number(component.quantity_per_unit || 0), + unit: component.unit || '', + productId: component.component_product_id || null + })) : [] +}); + +const getProductComposition = async (productId, client = pool) => { + const identity = normalizeText(productId); + if (!identity) return null; + + const result = await client.query(` + SELECT + composition.id, + composition.source, + composition.external_source_id, + composition.finished_product_sku, + composition.finished_product_description, + composition.finished_product_unit, + composition.finished_tiny_product_id, + composition.source_metadata, + composition.last_synced_at, + COALESCE( + ( + SELECT JSON_AGG( + JSON_BUILD_OBJECT( + 'id', component.id, + 'component_tiny_id', component.component_tiny_id, + 'component_sku', component.component_sku, + 'component_name', component.component_name, + 'quantity_per_unit', component.quantity_per_unit, + 'unit', component.unit, + 'component_product_id', COALESCE( + ( + SELECT stock_match.produto_id + FROM stock stock_match + WHERE stock_match.produto_id = component.component_tiny_id + OR stock_match.produto_id = component.component_sku + LIMIT 1 + ), + ( + SELECT order_match.produto_id + FROM orders order_match + WHERE order_match.produto_id = component.component_tiny_id + OR order_match.produto_id = component.component_sku + LIMIT 1 + ) + ) + ) + ORDER BY component.id + ) + FROM product_composition_components component + WHERE component.product_composition_id = composition.id + ), + '[]'::json + ) AS components + FROM product_compositions composition + WHERE composition.source = $1 + AND ( + composition.finished_tiny_product_id = $2 + OR composition.finished_product_sku = UPPER($2) + ) + LIMIT 1; + `, [TINY_OLIST_V3_COMPOSITION_SOURCE, identity]); + + return result.rows[0] ? mapProductCompositionRow(result.rows[0]) : null; +}; + +const upsertTinyProductComposition = async (order, components) => { + const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId); + const finishedProductSku = normalizeSku(order.productSku); + const finishedProductIdentity = finishedProductSku || finishedTinyProductId; + + if (!finishedProductIdentity) { + const error = new Error('SKU ou ID Tiny do produto acabado é obrigatório para a composição.'); + error.statusCode = 400; + throw error; + } + + const normalizedComponents = normalizeCompositionComponents(components); + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + const existingResult = await client.query(` + SELECT id + FROM product_compositions + WHERE source = $1 AND finished_product_identity = $2 + LIMIT 1; + `, [TINY_OLIST_V3_COMPOSITION_SOURCE, finishedProductIdentity]); + + let compositionId = existingResult.rows[0]?.id; + const values = [ + order.tinyId, + finishedProductIdentity, + finishedProductSku || null, + order.productDescription, + order.unit, + finishedTinyProductId || null, + JSON.stringify({ + provider: 'Tiny/Olist V3', + notes: order.notes || null, + payload: order.rawPayload + }) + ]; + + if (compositionId) { + await client.query(` + UPDATE product_compositions + SET external_source_id = $1, + finished_product_sku = $3, + finished_product_description = $4, + finished_product_unit = $5, + finished_tiny_product_id = $6, + source_metadata = $7::jsonb, + last_synced_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $8; + `, [...values, compositionId]); + } else { + const insertResult = await client.query(` + INSERT INTO product_compositions ( + source, external_source_id, finished_product_identity, + finished_product_sku, finished_product_description, finished_product_unit, + finished_tiny_product_id, source_metadata, last_synced_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, CURRENT_TIMESTAMP) + RETURNING id; + `, [ + TINY_OLIST_V3_COMPOSITION_SOURCE, + ...values + ]); + compositionId = insertResult.rows[0].id; + } + + await client.query('DELETE FROM product_composition_components WHERE product_composition_id = $1;', [compositionId]); + for (const component of normalizedComponents) { + await client.query(` + INSERT INTO product_composition_components ( + product_composition_id, component_identity, component_tiny_id, + component_sku, component_name, quantity_per_unit, unit + ) + VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5, $6, NULLIF($7, '')); + `, [ + compositionId, + component.componentIdentity, + component.componentTinyId, + component.componentSku, + component.componentName, + component.quantityPerUnit, + component.unit + ]); + } + + await client.query('COMMIT'); + return { + composition: await getProductComposition(finishedTinyProductId || finishedProductSku), + componentCount: normalizedComponents.length + }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + const upsertTinyProductionOrderDetail = async (payload = {}) => { const order = resolveTinyOrderPayload(payload); const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity); + + if (isTinyOlistV3Composition(order)) { + return upsertTinyProductComposition(order, components); + } + const steps = normalizeSteps(payload.steps || payload.etapas); const client = await pool.connect(); @@ -836,6 +1050,7 @@ const updateProductionOrderStatus = async (id, status) => { module.exports = { createProductionOrders, + getProductComposition, listProductionOrders, normalizeStatus, upsertTinyProductionOrderDetail, diff --git a/backend/test/productCompositionService.test.js b/backend/test/productCompositionService.test.js new file mode 100644 index 0000000..76a6239 --- /dev/null +++ b/backend/test/productCompositionService.test.js @@ -0,0 +1,264 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { pool } = require('../db'); +const productionOrderRouter = require('../routes/productionOrderRoutes'); +const analyticsRouter = require('../routes/analyticsRoutes'); + +const getRouteHandler = (router, method, path) => { + const route = router.stack.find(layer => layer.route?.path === path && layer.route.methods[method]); + if (!route) throw new Error(`Route not found: ${method.toUpperCase()} ${path}`); + return route.route.stack.at(-1).handle; +}; + +const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync'); +const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition'); + +const invokeHandler = async (handler, req) => { + let statusCode = 200; + let body; + const res = { + status(code) { + statusCode = code; + return this; + }, + json(payload) { + body = payload; + return this; + } + }; + await handler(req, res); + return { statusCode, body }; +}; + +const compositionPayload = (components = [ + { + componentTinyId: '919498232', + componentName: 'ETIQUETA DE TAMANHO GG', + componentSku: 'ETIQ.T.GG', + quantityPerUnit: '1', + totalQuantity: '1', + unit: 'UN' + }, + { + componentTinyId: '976059144', + componentName: '6118 MALHA CAMISETA 30OE COR CAFE', + componentSku: 'MC.30.CAF', + quantityPerUnit: '0.1851', + totalQuantity: '0.1851', + unit: 'KG' + } +]) => ({ + order: { + tinyId: 'STRUCTURE-V3-976058813', + number: 'STRUCTURE-V3-BLCS.CAF.GG', + status: 'completed', + productSku: 'BLCS.CAF.GG', + productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG', + quantity: '1', + unit: 'UN', + notes: 'Composição sincronizada da API pública Olist V3.' + }, + components, + steps: [] +}); + +const withFakeDatabase = async (run) => { + const originalConnect = pool.connect; + const originalQuery = pool.query; + const state = { + nextCompositionId: 1, + nextOrderId: 1, + compositions: [], + compositionComponents: [], + productionOrders: [] + }; + + const query = async (sql, params = []) => { + const normalizedSql = String(sql).replace(/\s+/g, ' ').trim(); + + if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) 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 }] : [] }; + } + + if (normalizedSql.startsWith('INSERT INTO product_compositions')) { + const composition = { + id: state.nextCompositionId++, + source: params[0], + external_source_id: params[1], + finished_product_identity: params[2], + finished_product_sku: params[3], + finished_product_description: params[4], + finished_product_unit: params[5], + finished_tiny_product_id: params[6], + source_metadata: JSON.parse(params[7]), + last_synced_at: '2026-07-29T12:00:00.000Z' + }; + state.compositions.push(composition); + return { rows: [{ id: composition.id }] }; + } + + if (normalizedSql.startsWith('UPDATE product_compositions')) { + const composition = state.compositions.find(item => item.id === params[7]); + composition.external_source_id = params[0]; + composition.finished_product_sku = params[2]; + composition.finished_product_description = params[3]; + composition.finished_product_unit = params[4]; + composition.finished_tiny_product_id = params[5]; + composition.source_metadata = JSON.parse(params[6]); + return { rows: [] }; + } + + if (normalizedSql.startsWith('DELETE FROM product_composition_components')) { + state.compositionComponents = state.compositionComponents.filter(item => item.product_composition_id !== params[0]); + return { rows: [] }; + } + + if (normalizedSql.startsWith('INSERT INTO product_composition_components')) { + state.compositionComponents.push({ + id: state.compositionComponents.length + 1, + product_composition_id: params[0], + component_identity: params[1], + component_tiny_id: params[2], + component_sku: params[3], + component_name: params[4], + quantity_per_unit: params[5], + unit: params[6] + }); + return { rows: [] }; + } + + if (normalizedSql.includes('FROM product_compositions composition')) { + const composition = state.compositions.find(item => ( + item.source === params[0] + && (item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase()) + )); + if (!composition) return { rows: [] }; + return { + rows: [{ + ...composition, + components: state.compositionComponents + .filter(item => item.product_composition_id === composition.id) + .map(item => ({ ...item, component_product_id: null })) + }] + }; + } + + if (normalizedSql.startsWith('SELECT id FROM production_orders')) { + return { rows: [] }; + } + + if (normalizedSql.startsWith('INSERT INTO production_orders')) { + const order = { + id: state.nextOrderId++, + tiny_id: params[0], number: params[1], status: params[2], order_reference: params[3], + issue_date: params[4], expected_date: params[5], product_sku: params[6], + product_description: params[7], quantity: params[8], unit: params[9], + integration_status: params[10], notes: params[11], supplier: params[12], lot_code: params[13], + roll_quantity: params[14], fabric_kg: params[15], rib_kg: params[16], + yield_pieces_per_kg: params[17], created_at: null, updated_at: null + }; + state.productionOrders.push(order); + return { rows: [{ id: order.id }] }; + } + + if (normalizedSql.startsWith('DELETE FROM production_order_components') || normalizedSql.startsWith('DELETE FROM production_order_steps')) { + return { rows: [] }; + } + + if (normalizedSql.includes('FROM production_orders po') && normalizedSql.includes('WHERE po.id = $1')) { + const order = state.productionOrders.find(item => item.id === params[0]); + return { rows: order ? [{ ...order, markers: [], components: [], steps: [] }] : [] }; + } + + throw new Error(`Unexpected SQL in test double: ${normalizedSql.slice(0, 100)}`); + }; + + pool.query = query; + pool.connect = async () => ({ query, release() {} }); + try { + await run(state); + } finally { + pool.connect = originalConnect; + pool.query = originalQuery; + } +}; + +test('Tiny/Olist V3 first import creates a composition without creating an OP', async () => { + await withFakeDatabase(async (state) => { + const response = await invokeHandler(tinySyncHandler, { body: compositionPayload() }); + const result = response.body; + + assert.equal(response.statusCode, 201); + assert.equal(result.componentCount, 2); + assert.equal(state.compositions.length, 1); + assert.equal(state.compositionComponents.length, 2); + assert.equal(state.productionOrders.length, 0); + assert.equal(result.composition.finishedTinyProductId, '976058813'); + }); +}); + +test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => { + await withFakeDatabase(async (state) => { + await invokeHandler(tinySyncHandler, { body: compositionPayload() }); + await invokeHandler(tinySyncHandler, { body: compositionPayload([ + { + componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF', + quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG' + }, + { + componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF', + quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG' + } + ]) }); + + assert.equal(state.compositions.length, 1); + assert.equal(state.compositionComponents.length, 1); + assert.equal(state.compositionComponents[0].quantity_per_unit, 0.2); + assert.equal(state.productionOrders.length, 0); + }); +}); + +test('product composition detail lookup returns stored components', async () => { + await withFakeDatabase(async () => { + await invokeHandler(tinySyncHandler, { body: compositionPayload() }); + const response = await invokeHandler(productCompositionHandler, { params: { productId: '976058813' } }); + const composition = response.body.composition; + + assert.equal(response.statusCode, 200); + assert.equal(composition.finishedProductSku, 'BLCS.CAF.GG'); + assert.equal(composition.components.length, 2); + assert.deepEqual(composition.components[0], { + id: 1, + componentTinyId: '919498232', + componentSku: 'ETIQ.T.GG', + componentName: 'ETIQUETA DE TAMANHO GG', + quantityPerUnit: 1, + unit: 'UN', + productId: null + }); + }); +}); + +test('ordinary Tiny production-order payloads continue to create production orders', async () => { + await withFakeDatabase(async (state) => { + const response = await invokeHandler(tinySyncHandler, { body: { + order: { + tinyId: '12345', number: 'OP-12345', status: 'in_progress', productSku: 'BLCS.CAF.GG', + productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG', quantity: '10', unit: 'UN' + }, + components: [], + steps: [] + } }); + const result = response.body; + + assert.equal(response.statusCode, 201); + assert.equal(state.compositions.length, 0); + assert.equal(state.productionOrders.length, 1); + assert.equal(result.order.tinyId, '12345'); + assert.equal(result.order.number, 'OP-12345'); + }); +}); diff --git a/src/dataService.ts b/src/dataService.ts index b1ef950..12e0056 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, 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, 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'; @@ -539,6 +539,18 @@ export const fetchProductDetailsAnalytics = async (productId: string, dateRange: }); }; +export const fetchProductComposition = async (productId: string): Promise => { + try { + const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`); + if (!response.ok) return null; + const data = await response.json() as { composition?: ProductComposition | null }; + return data.composition || null; + } catch (error) { + console.error('Fetch product composition failed', error); + return null; + } +}; + const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial) => { if (!filters) return; if (filters.marketplace) params.set('marketplace', filters.marketplace); diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index 1cd1df1..f7b46f1 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -7,8 +7,8 @@ import DateRangePicker from '../components/DateRangePicker'; import SkuPlanningModal from '../components/SkuPlanningModal'; import ProductTypeBadge from '../components/ProductTypeBadge'; import RefreshStatus from '../components/RefreshStatus'; -import type { CutProductOverride, CuttingSettings, DateRange, ProductDetailsAnalytics } from '../types'; -import { fetchCuttingSettings, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService'; +import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types'; +import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService'; import { parseProductName } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils'; @@ -131,6 +131,8 @@ const ProductDetails = () => { setDateRange: (range: DateRange) => void }>(); const [details, setDetails] = useState(null); + const [composition, setComposition] = useState(null); + const [isCompositionLoading, setIsCompositionLoading] = useState(true); const [isLoading, setIsLoading] = useState(true); const [chartMetric, setChartMetric] = useState('quantity'); const [selectedProductBucket, setSelectedProductBucket] = useState(null); @@ -160,17 +162,25 @@ const ProductDetails = () => { if (!id) { if (isMounted) { setDetails(null); + setComposition(null); setIsLoading(false); + setIsCompositionLoading(false); } return; } setIsLoading(true); - const productDetails = await fetchProductDetailsAnalytics(id, dateRange); + setIsCompositionLoading(true); + const [productDetails, productComposition] = await Promise.all([ + fetchProductDetailsAnalytics(id, dateRange), + fetchProductComposition(id) + ]); if (isMounted) { setDetails(productDetails); + setComposition(productComposition); setIsLoading(false); + setIsCompositionLoading(false); } }; @@ -403,6 +413,57 @@ const ProductDetails = () => { +
+
+

Composição

+ {composition && ( +

+ Para produzir 1 UN de {composition.finishedProductSku || productInfo.id} +

+ )} +
+ {isCompositionLoading ? ( +
Carregando composição…
+ ) : !composition ? ( +
+ Nenhuma composição sincronizada para este produto. +
+ ) : composition.components.length === 0 ? ( +
+ Esta composição não possui insumos. +
+ ) : ( +
+ + + + + + + + + + + {composition.components.map(component => ( + + + + + + + ))} + +
Produto / insumoSKUQuantidade por unidadeUnidade
+ {component.productId ? ( + + {component.componentName} + + ) : component.componentName} + {component.componentSku || '—'}{formatNumber(component.quantityPerUnit)}{component.unit || '—'}
+
+ )} +
+
diff --git a/src/types.ts b/src/types.ts index a4331a2..f896bd2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -475,6 +475,29 @@ export interface ProductDetailsAnalytics { }>; } +export interface ProductCompositionComponent { + id: number; + componentTinyId: string; + componentSku: string; + componentName: string; + quantityPerUnit: number; + unit: string; + productId: string | null; +} + +export interface ProductComposition { + id: number; + source: string; + externalSourceId: string; + finishedProductSku: string; + finishedProductDescription: string; + finishedProductUnit: string; + finishedTinyProductId: string; + sourceMetadata: Record; + lastSyncedAt: string | null; + components: ProductCompositionComponent[]; +} + export interface ClientAnalyticsItem { customerKey: string; clientToken: string;