Import Tiny product compositions
This commit is contained in:
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user