Import Tiny product compositions
This commit is contained in:
@@ -12,6 +12,7 @@ const {
|
|||||||
listConsumptionReferences,
|
listConsumptionReferences,
|
||||||
listProducts
|
listProducts
|
||||||
} = require('../services/catalogService');
|
} = require('../services/catalogService');
|
||||||
|
const { importTinyProductCompositionExport } = require('../services/productionOrderService');
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await deleteConsumptionReference(req.params.id);
|
await deleteConsumptionReference(req.params.id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
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();
|
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) => {
|
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
|
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const createApp = () => {
|
|||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
app.use('/api', authRoutes);
|
app.use('/api', authRoutes);
|
||||||
app.use('/api', dataRoutes);
|
app.use('/api', dataRoutes);
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ const sampleSpecs = {
|
|||||||
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
|
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
|
||||||
orderBy: ['updated_at', 'created_at', 'id']
|
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: {
|
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'],
|
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']
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ const upsertCatalogProductFromSync = async (client, { sku, name, type, categoryN
|
|||||||
return result.rows[0].id;
|
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) {
|
if (!order.productSku || !components.length) {
|
||||||
return { referenceCount: 0, skippedReferenceCount: components.length };
|
return { referenceCount: 0, skippedReferenceCount: components.length };
|
||||||
}
|
}
|
||||||
@@ -142,7 +145,7 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
|
|||||||
name: order.productDescription,
|
name: order.productDescription,
|
||||||
type: 'finished_product',
|
type: 'finished_product',
|
||||||
categoryName: classifyCatalogCategoryName(order.productDescription, '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 };
|
if (!productId) return { referenceCount: 0, skippedReferenceCount: components.length };
|
||||||
@@ -156,7 +159,7 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
|
|||||||
name: component.componentName,
|
name: component.componentName,
|
||||||
type: 'raw_material',
|
type: 'raw_material',
|
||||||
categoryName: classifyCatalogCategoryName(component.componentName, '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) {
|
if (!materialId || !component.quantityPerUnit) {
|
||||||
@@ -173,9 +176,9 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
|
|||||||
WHERE product_id = $1
|
WHERE product_id = $1
|
||||||
AND COALESCE(material_product_id, 0) = $2
|
AND COALESCE(material_product_id, 0) = $2
|
||||||
AND COALESCE(color, '') = ''
|
AND COALESCE(color, '') = ''
|
||||||
AND COALESCE(source, 'manual') = 'tiny_op'
|
AND COALESCE(source, 'manual') = $3
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`, [productId, materialId]);
|
`, [productId, materialId, source]);
|
||||||
|
|
||||||
if (existingReferenceResult.rows.length) {
|
if (existingReferenceResult.rows.length) {
|
||||||
await client.query(`
|
await client.query(`
|
||||||
@@ -184,13 +187,15 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
|
|||||||
consumption_quantity = $2,
|
consumption_quantity = $2,
|
||||||
consumption_unit = $3,
|
consumption_unit = $3,
|
||||||
last_production_order_id = $4,
|
last_production_order_id = $4,
|
||||||
|
source = $5,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $5;
|
WHERE id = $6;
|
||||||
`, [
|
`, [
|
||||||
generalYield,
|
generalYield,
|
||||||
component.quantityPerUnit,
|
component.quantityPerUnit,
|
||||||
component.unit || null,
|
component.unit || null,
|
||||||
orderId,
|
orderId,
|
||||||
|
source,
|
||||||
existingReferenceResult.rows[0].id
|
existingReferenceResult.rows[0].id
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
@@ -199,13 +204,14 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
|
|||||||
product_id, material_product_id, general_yield, consumption_quantity,
|
product_id, material_product_id, general_yield, consumption_quantity,
|
||||||
consumption_unit, source, last_production_order_id, updated_at
|
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,
|
productId,
|
||||||
materialId,
|
materialId,
|
||||||
generalYield,
|
generalYield,
|
||||||
component.quantityPerUnit,
|
component.quantityPerUnit,
|
||||||
component.unit || null,
|
component.unit || null,
|
||||||
|
source,
|
||||||
orderId
|
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 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) => (
|
const getTinyProductIdFromCompositionReference = (tinyId) => (
|
||||||
tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length)
|
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 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 upsertTinyProductComposition = async (order, components) => {
|
||||||
const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId);
|
const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId);
|
||||||
const finishedProductSku = normalizeSku(order.productSku);
|
const finishedProductSku = normalizeSku(order.productSku);
|
||||||
@@ -808,6 +898,7 @@ const upsertTinyProductComposition = async (order, components) => {
|
|||||||
await client.query(`
|
await client.query(`
|
||||||
UPDATE product_compositions
|
UPDATE product_compositions
|
||||||
SET external_source_id = $1,
|
SET external_source_id = $1,
|
||||||
|
finished_product_identity = $2,
|
||||||
finished_product_sku = $3,
|
finished_product_sku = $3,
|
||||||
finished_product_description = $4,
|
finished_product_description = $4,
|
||||||
finished_product_unit = $5,
|
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');
|
await client.query('COMMIT');
|
||||||
return {
|
return {
|
||||||
composition: await getProductComposition(finishedTinyProductId || finishedProductSku),
|
composition: await getProductComposition(finishedTinyProductId || finishedProductSku),
|
||||||
componentCount: normalizedComponents.length
|
componentCount: normalizedComponents.length,
|
||||||
|
referenceCount: referenceSync.referenceCount,
|
||||||
|
skippedReferenceCount: referenceSync.skippedReferenceCount
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
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 upsertTinyProductionOrderDetail = async (payload = {}) => {
|
||||||
const order = resolveTinyOrderPayload(payload);
|
const order = resolveTinyOrderPayload(payload);
|
||||||
const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity);
|
const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity);
|
||||||
@@ -1057,6 +1206,7 @@ const updateProductionOrderStatus = async (id, status) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
createProductionOrders,
|
createProductionOrders,
|
||||||
getProductComposition,
|
getProductComposition,
|
||||||
|
importTinyProductCompositionExport,
|
||||||
listProductCompositions,
|
listProductCompositions,
|
||||||
listProductionOrders,
|
listProductionOrders,
|
||||||
normalizeStatus,
|
normalizeStatus,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const getRouteHandler = (router, method, path) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
|
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 productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
|
||||||
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
|
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
|
||||||
|
|
||||||
@@ -70,9 +71,15 @@ const withFakeDatabase = async (run) => {
|
|||||||
const state = {
|
const state = {
|
||||||
nextCompositionId: 1,
|
nextCompositionId: 1,
|
||||||
nextOrderId: 1,
|
nextOrderId: 1,
|
||||||
|
nextCategoryId: 1,
|
||||||
|
nextCatalogProductId: 1,
|
||||||
|
nextConsumptionReferenceId: 1,
|
||||||
compositions: [],
|
compositions: [],
|
||||||
compositionComponents: [],
|
compositionComponents: [],
|
||||||
productionOrders: []
|
productionOrders: [],
|
||||||
|
categories: [],
|
||||||
|
catalogProducts: [],
|
||||||
|
consumptionReferences: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = async (sql, params = []) => {
|
const query = async (sql, params = []) => {
|
||||||
@@ -80,6 +87,72 @@ const withFakeDatabase = async (run) => {
|
|||||||
|
|
||||||
if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] };
|
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')) {
|
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]);
|
const composition = state.compositions.find(item => item.source === params[0] && item.finished_product_identity === params[1]);
|
||||||
return { rows: composition ? [{ id: composition.id }] : [] };
|
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.compositions.length, 1);
|
||||||
assert.equal(state.compositionComponents.length, 2);
|
assert.equal(state.compositionComponents.length, 2);
|
||||||
assert.equal(state.productionOrders.length, 0);
|
assert.equal(state.productionOrders.length, 0);
|
||||||
|
assert.equal(state.consumptionReferences.length, 2);
|
||||||
assert.equal(result.composition.finishedTinyProductId, '976058813');
|
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 () => {
|
test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => {
|
||||||
await withFakeDatabase(async (state) => {
|
await withFakeDatabase(async (state) => {
|
||||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||||
|
|||||||
@@ -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';
|
import { formatDateParam } from './dateRanges';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
@@ -345,6 +345,22 @@ export const deleteConsumptionReference = async (id: number): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const importProductCompositions = async (payload: unknown): Promise<ProductCompositionImportSummary> => {
|
||||||
|
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<SupplySummary> => {
|
export const fetchSupplySummary = async (): Promise<SupplySummary> => {
|
||||||
const emptySummary: SupplySummary = {
|
const emptySummary: SupplySummary = {
|
||||||
receipts: [],
|
receipts: [],
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
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 {
|
import {
|
||||||
deleteCatalogCategory,
|
deleteCatalogCategory,
|
||||||
deleteCatalogProduct,
|
deleteCatalogProduct,
|
||||||
deleteConsumptionReference,
|
deleteConsumptionReference,
|
||||||
fetchCatalogSummary,
|
fetchCatalogSummary,
|
||||||
|
importProductCompositions,
|
||||||
saveCatalogCategory,
|
saveCatalogCategory,
|
||||||
saveCatalogProduct,
|
saveCatalogProduct,
|
||||||
saveConsumptionReference
|
saveConsumptionReference
|
||||||
@@ -93,7 +94,9 @@ const getAverageYield = (reference: ConsumptionReference) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getReferenceSourceLabel = (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) => {
|
const formatConsumptionPerPiece = (reference: ConsumptionReference) => {
|
||||||
@@ -111,6 +114,7 @@ const Registrations = () => {
|
|||||||
const [status, setStatus] = useState<SaveStatus>('idle');
|
const [status, setStatus] = useState<SaveStatus>('idle');
|
||||||
const [feedback, setFeedback] = useState('');
|
const [feedback, setFeedback] = useState('');
|
||||||
const appliedSkuPrefillRef = useRef('');
|
const appliedSkuPrefillRef = useRef('');
|
||||||
|
const compositionFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [categoryForm, setCategoryForm] = useState({ name: '', description: '' });
|
const [categoryForm, setCategoryForm] = useState({ name: '', description: '' });
|
||||||
const [productForm, setProductForm] = useState(defaultProductForm);
|
const [productForm, setProductForm] = useState(defaultProductForm);
|
||||||
@@ -323,6 +327,29 @@ const Registrations = () => {
|
|||||||
setSizeYields({});
|
setSizeYields({});
|
||||||
}, 'Referencia salva.');
|
}, '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(
|
const removeCategory = (category: CatalogCategory) => runAction(
|
||||||
() => deleteCatalogCategory(category.id),
|
() => deleteCatalogCategory(category.id),
|
||||||
`Categoria ${category.name} excluida.`
|
`Categoria ${category.name} excluida.`
|
||||||
@@ -363,6 +390,22 @@ const Registrations = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
ref={compositionFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => void importCompositionFile(event.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => compositionFileInputRef.current?.click()}
|
||||||
|
disabled={status === 'saving'}
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 text-brand-primary" />
|
||||||
|
Importar composições
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/planning-issues"
|
to="/planning-issues"
|
||||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||||
@@ -674,7 +717,7 @@ const Registrations = () => {
|
|||||||
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
|
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
|
||||||
</span>
|
</span>
|
||||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
|
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
|
||||||
reference.source === 'tiny_op'
|
reference.source === 'tiny_op' || reference.source === 'tiny_structure'
|
||||||
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
||||||
: 'border-dark-border bg-dark-input text-dark-muted'
|
: 'border-dark-border bg-dark-input text-dark-muted'
|
||||||
}`}>
|
}`}>
|
||||||
|
|||||||
19
src/types.ts
19
src/types.ts
@@ -498,6 +498,25 @@ export interface ProductComposition {
|
|||||||
components: ProductCompositionComponent[];
|
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 {
|
export interface ClientAnalyticsItem {
|
||||||
customerKey: string;
|
customerKey: string;
|
||||||
clientToken: string;
|
clientToken: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user