Import Tiny product compositions

This commit is contained in:
Cauê Faleiros
2026-07-31 11:32:49 -03:00
parent b431802d50
commit 37d4a7732e
9 changed files with 368 additions and 15 deletions

View File

@@ -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']

View File

@@ -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,