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