All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
1066 lines
40 KiB
JavaScript
1066 lines
40 KiB
JavaScript
const { pool } = require('../db');
|
|
|
|
const STATUS_LABELS = {
|
|
open: 'Em aberto',
|
|
in_progress: 'Em andamento',
|
|
finished: 'Finalizada',
|
|
canceled: 'Cancelada'
|
|
};
|
|
|
|
const normalizeStatus = (status) => {
|
|
const normalizedStatus = String(status || 'open').trim().toLowerCase();
|
|
if (['open', 'em_aberto', 'em aberto', 'aberta'].includes(normalizedStatus)) return 'open';
|
|
if (['in_progress', 'andamento', 'em andamento'].includes(normalizedStatus)) return 'in_progress';
|
|
if (['finished', 'finalizada', 'finalizado'].includes(normalizedStatus)) return 'finished';
|
|
if (['canceled', 'cancelada', 'cancelado', 'cancelled'].includes(normalizedStatus)) return 'canceled';
|
|
return normalizedStatus || 'open';
|
|
};
|
|
|
|
const normalizeDateParam = (value) => {
|
|
if (!value) return null;
|
|
const normalizedValue = String(value).trim();
|
|
const isoMatch = normalizedValue.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
|
|
if (isoMatch) {
|
|
const [, year, month, day] = isoMatch;
|
|
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T00:00:00`);
|
|
return Number.isNaN(date.getTime()) ? null : `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
|
}
|
|
|
|
const brMatch = normalizedValue.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})/);
|
|
if (brMatch) {
|
|
const [, day, month, year] = brMatch;
|
|
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T00:00:00`);
|
|
return Number.isNaN(date.getTime()) ? null : `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const formatDate = (value) => {
|
|
if (!value) return null;
|
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
return value.toISOString().slice(0, 10);
|
|
}
|
|
return String(value).slice(0, 10);
|
|
};
|
|
|
|
const normalizeText = (value) => String(value || '').trim();
|
|
|
|
const normalizeQuantity = (value) => {
|
|
const normalizedValue = typeof value === 'string' && value.includes(',')
|
|
? value.replace(/\./g, '').replace(',', '.')
|
|
: value;
|
|
const quantity = Number(normalizedValue);
|
|
if (!Number.isFinite(quantity) || quantity <= 0) return 0;
|
|
return quantity;
|
|
};
|
|
|
|
const normalizeNullableQuantity = (value) => {
|
|
const quantity = normalizeQuantity(value);
|
|
return quantity || null;
|
|
};
|
|
|
|
const normalizeInteger = (value) => {
|
|
const number = Number(value);
|
|
return Number.isInteger(number) ? number : null;
|
|
};
|
|
|
|
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
|
|
|
const normalizeUnit = (value) => normalizeText(value).toLowerCase();
|
|
|
|
const normalizeCategoryName = (name) => normalizeText(name)
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.toUpperCase();
|
|
|
|
const classifyCatalogCategoryName = (name, type) => {
|
|
const normalizedName = normalizeCategoryName(name);
|
|
if (type === 'finished_product') {
|
|
if (/\bDTF\b/.test(normalizedName)) return 'DTF';
|
|
if (/\b(?:MOLETOM|CANGURU)\b/.test(normalizedName)) return 'Moletom';
|
|
if (/\b(?:OVERSIZE|OVERSIZED)\b/.test(normalizedName)) return 'Oversized';
|
|
if (/\bINFANTIL\b/.test(normalizedName)) return 'Camiseta infantil';
|
|
if (/\b(?:BONE|ACESSORIO|ACESSORIOS)\b/.test(normalizedName)) return 'Acessórios';
|
|
return 'Camiseta regular';
|
|
}
|
|
if (/\b(?:MALHA|TECIDO|RIBANA|FIO)\b/.test(normalizedName)) return 'Malha';
|
|
if (/\b(?:ETIQUETA|TAG|EMBALAGEM|SACO|SACOLA)\b/.test(normalizedName)) return 'Embalagens';
|
|
if (/\b(?:ATACADOR|ILHOS|LINHA|FITA)\b/.test(normalizedName)) return 'Aviamentos';
|
|
if (/\bDTF\b/.test(normalizedName)) return 'DTF';
|
|
return 'Insumos gerais';
|
|
};
|
|
|
|
const ensureCategoryId = async (client, name) => {
|
|
const result = await client.query(`
|
|
INSERT INTO catalog_categories (name, description, updated_at)
|
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (name) DO UPDATE
|
|
SET updated_at = catalog_categories.updated_at
|
|
RETURNING id;
|
|
`, [name, 'Categoria criada automaticamente pela sincronização de produção.']);
|
|
|
|
return result.rows[0].id;
|
|
};
|
|
|
|
const upsertCatalogProductFromSync = async (client, { sku, name, type, categoryName, notes }) => {
|
|
const normalizedSku = normalizeSku(sku);
|
|
const normalizedName = normalizeText(name);
|
|
if (!normalizedSku || !normalizedName) return null;
|
|
|
|
const categoryId = await ensureCategoryId(client, categoryName || classifyCatalogCategoryName(normalizedName, type));
|
|
const result = await client.query(`
|
|
INSERT INTO catalog_products (
|
|
type, sku, name, category_id, notes, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (sku) DO UPDATE
|
|
SET type = EXCLUDED.type,
|
|
name = EXCLUDED.name,
|
|
category_id = COALESCE(catalog_products.category_id, EXCLUDED.category_id),
|
|
notes = COALESCE(NULLIF(catalog_products.notes, ''), EXCLUDED.notes),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
RETURNING id;
|
|
`, [
|
|
type,
|
|
normalizedSku,
|
|
normalizedName,
|
|
categoryId,
|
|
normalizeText(notes) || null
|
|
]);
|
|
|
|
return result.rows[0].id;
|
|
};
|
|
|
|
const upsertConsumptionReferencesFromComponents = async (client, orderId, order, components) => {
|
|
if (!order.productSku || !components.length) {
|
|
return { referenceCount: 0, skippedReferenceCount: components.length };
|
|
}
|
|
|
|
const productId = await upsertCatalogProductFromSync(client, {
|
|
sku: order.productSku,
|
|
name: order.productDescription,
|
|
type: 'finished_product',
|
|
categoryName: classifyCatalogCategoryName(order.productDescription, 'finished_product'),
|
|
notes: `Sincronizado da OP ${order.number || order.tinyId || orderId}.`
|
|
});
|
|
|
|
if (!productId) return { referenceCount: 0, skippedReferenceCount: components.length };
|
|
|
|
let referenceCount = 0;
|
|
let skippedReferenceCount = 0;
|
|
|
|
for (const component of components) {
|
|
const materialId = await upsertCatalogProductFromSync(client, {
|
|
sku: component.componentSku,
|
|
name: component.componentName,
|
|
type: 'raw_material',
|
|
categoryName: classifyCatalogCategoryName(component.componentName, 'raw_material'),
|
|
notes: `Sincronizado da composição da OP ${order.number || order.tinyId || orderId}.`
|
|
});
|
|
|
|
if (!materialId || !component.quantityPerUnit) {
|
|
skippedReferenceCount += 1;
|
|
continue;
|
|
}
|
|
|
|
const unit = normalizeUnit(component.unit);
|
|
const generalYield = unit === 'kg' ? 1 / component.quantityPerUnit : null;
|
|
|
|
const existingReferenceResult = await client.query(`
|
|
SELECT id
|
|
FROM consumption_references
|
|
WHERE product_id = $1
|
|
AND COALESCE(material_product_id, 0) = $2
|
|
AND COALESCE(color, '') = ''
|
|
AND COALESCE(source, 'manual') = 'tiny_op'
|
|
LIMIT 1;
|
|
`, [productId, materialId]);
|
|
|
|
if (existingReferenceResult.rows.length) {
|
|
await client.query(`
|
|
UPDATE consumption_references
|
|
SET general_yield = $1,
|
|
consumption_quantity = $2,
|
|
consumption_unit = $3,
|
|
last_production_order_id = $4,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $5;
|
|
`, [
|
|
generalYield,
|
|
component.quantityPerUnit,
|
|
component.unit || null,
|
|
orderId,
|
|
existingReferenceResult.rows[0].id
|
|
]);
|
|
} else {
|
|
await client.query(`
|
|
INSERT INTO consumption_references (
|
|
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);
|
|
`, [
|
|
productId,
|
|
materialId,
|
|
generalYield,
|
|
component.quantityPerUnit,
|
|
component.unit || null,
|
|
orderId
|
|
]);
|
|
}
|
|
|
|
referenceCount += 1;
|
|
}
|
|
|
|
return { referenceCount, skippedReferenceCount };
|
|
};
|
|
|
|
const mapComponent = (component) => ({
|
|
id: component.id,
|
|
componentTinyId: component.component_tiny_id || '',
|
|
componentSku: component.component_sku || '',
|
|
componentName: component.component_name || '',
|
|
quantityPerUnit: Number(component.quantity_per_unit || 0),
|
|
totalQuantity: Number(component.total_quantity || 0),
|
|
unit: component.unit || ''
|
|
});
|
|
|
|
const mapStep = (step) => ({
|
|
id: step.id,
|
|
stepNumber: step.step_number === null ? null : Number(step.step_number),
|
|
name: step.name || '',
|
|
startDate: formatDate(step.start_date),
|
|
endDate: formatDate(step.end_date),
|
|
status: step.status || '',
|
|
color: step.color || ''
|
|
});
|
|
|
|
const mapProductionOrderRow = (row) => {
|
|
const status = normalizeStatus(row.status);
|
|
|
|
return {
|
|
id: row.id,
|
|
tinyId: row.tiny_id || '',
|
|
number: row.number || '',
|
|
status,
|
|
statusLabel: STATUS_LABELS[status] || row.status || 'Em aberto',
|
|
orderReference: row.order_reference || '',
|
|
issueDate: formatDate(row.issue_date),
|
|
expectedDate: formatDate(row.expected_date),
|
|
productSku: row.product_sku || '',
|
|
productDescription: row.product_description || '',
|
|
quantity: Number(row.quantity || 0),
|
|
unit: row.unit || 'UN',
|
|
integrationStatus: row.integration_status || '',
|
|
notes: row.notes || '',
|
|
supplier: row.supplier || '',
|
|
lotCode: row.lot_code || '',
|
|
rollQuantity: row.roll_quantity === null ? null : Number(row.roll_quantity),
|
|
fabricKg: row.fabric_kg === null ? null : Number(row.fabric_kg),
|
|
ribKg: row.rib_kg === null ? null : Number(row.rib_kg),
|
|
yieldPiecesPerKg: row.yield_pieces_per_kg === null ? null : Number(row.yield_pieces_per_kg),
|
|
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
|
|
components: Array.isArray(row.components) ? row.components.map(mapComponent) : [],
|
|
steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [],
|
|
createdAt: row.created_at || null,
|
|
updatedAt: row.updated_at || null
|
|
};
|
|
};
|
|
|
|
const getOrderById = async (id, client = pool) => {
|
|
const result = await client.query(`
|
|
SELECT
|
|
po.id,
|
|
po.tiny_id,
|
|
po.number,
|
|
po.status,
|
|
po.order_reference,
|
|
po.issue_date,
|
|
po.expected_date,
|
|
po.product_sku,
|
|
po.product_description,
|
|
po.quantity,
|
|
po.unit,
|
|
po.integration_status,
|
|
po.notes,
|
|
po.supplier,
|
|
po.lot_code,
|
|
po.roll_quantity,
|
|
po.fabric_kg,
|
|
po.rib_kg,
|
|
po.yield_pieces_per_kg,
|
|
po.created_at,
|
|
po.updated_at,
|
|
COALESCE(
|
|
(
|
|
SELECT JSON_AGG(
|
|
JSON_BUILD_OBJECT(
|
|
'label', marker.label,
|
|
'color', marker.color
|
|
)
|
|
ORDER BY marker.label
|
|
)
|
|
FROM production_order_markers marker
|
|
WHERE marker.production_order_id = po.id
|
|
),
|
|
'[]'::json
|
|
) as markers,
|
|
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,
|
|
'total_quantity', component.total_quantity,
|
|
'unit', component.unit
|
|
)
|
|
ORDER BY component.id
|
|
)
|
|
FROM production_order_components component
|
|
WHERE component.production_order_id = po.id
|
|
),
|
|
'[]'::json
|
|
) as components,
|
|
COALESCE(
|
|
(
|
|
SELECT JSON_AGG(
|
|
JSON_BUILD_OBJECT(
|
|
'id', step.id,
|
|
'step_number', step.step_number,
|
|
'name', step.name,
|
|
'start_date', step.start_date,
|
|
'end_date', step.end_date,
|
|
'status', step.status,
|
|
'color', step.color
|
|
)
|
|
ORDER BY step.step_number NULLS LAST, step.id
|
|
)
|
|
FROM production_order_steps step
|
|
WHERE step.production_order_id = po.id
|
|
),
|
|
'[]'::json
|
|
) as steps
|
|
FROM production_orders po
|
|
WHERE po.id = $1;
|
|
`, [id]);
|
|
|
|
return result.rows[0] ? mapProductionOrderRow(result.rows[0]) : null;
|
|
};
|
|
|
|
const baseProductionOrderSelect = `
|
|
SELECT
|
|
po.id,
|
|
po.tiny_id,
|
|
po.number,
|
|
po.status,
|
|
po.order_reference,
|
|
po.issue_date,
|
|
po.expected_date,
|
|
po.product_sku,
|
|
po.product_description,
|
|
po.quantity,
|
|
po.unit,
|
|
po.integration_status,
|
|
po.notes,
|
|
po.supplier,
|
|
po.lot_code,
|
|
po.roll_quantity,
|
|
po.fabric_kg,
|
|
po.rib_kg,
|
|
po.yield_pieces_per_kg,
|
|
po.created_at,
|
|
po.updated_at,
|
|
COALESCE(
|
|
(
|
|
SELECT JSON_AGG(
|
|
JSON_BUILD_OBJECT(
|
|
'label', marker.label,
|
|
'color', marker.color
|
|
)
|
|
ORDER BY marker.label
|
|
)
|
|
FROM production_order_markers marker
|
|
WHERE marker.production_order_id = po.id
|
|
),
|
|
'[]'::json
|
|
) as markers,
|
|
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,
|
|
'total_quantity', component.total_quantity,
|
|
'unit', component.unit
|
|
)
|
|
ORDER BY component.id
|
|
)
|
|
FROM production_order_components component
|
|
WHERE component.production_order_id = po.id
|
|
),
|
|
'[]'::json
|
|
) as components,
|
|
COALESCE(
|
|
(
|
|
SELECT JSON_AGG(
|
|
JSON_BUILD_OBJECT(
|
|
'id', step.id,
|
|
'step_number', step.step_number,
|
|
'name', step.name,
|
|
'start_date', step.start_date,
|
|
'end_date', step.end_date,
|
|
'status', step.status,
|
|
'color', step.color
|
|
)
|
|
ORDER BY step.step_number NULLS LAST, step.id
|
|
)
|
|
FROM production_order_steps step
|
|
WHERE step.production_order_id = po.id
|
|
),
|
|
'[]'::json
|
|
) as steps
|
|
FROM production_orders po
|
|
`;
|
|
|
|
const listProductionOrders = async (filters = {}) => {
|
|
const params = [];
|
|
const where = [];
|
|
const normalizedStart = normalizeDateParam(filters.start);
|
|
const normalizedEnd = normalizeDateParam(filters.end);
|
|
const normalizedSearch = String(filters.search || '').trim();
|
|
|
|
if (normalizedStart) {
|
|
params.push(normalizedStart);
|
|
where.push(`COALESCE(po.issue_date, po.expected_date, po.created_at::date) >= $${params.length}::date`);
|
|
}
|
|
|
|
if (normalizedEnd) {
|
|
params.push(normalizedEnd);
|
|
where.push(`COALESCE(po.issue_date, po.expected_date, po.created_at::date) <= $${params.length}::date`);
|
|
}
|
|
|
|
if (normalizedSearch) {
|
|
params.push(`%${normalizedSearch}%`);
|
|
where.push(`(
|
|
po.number ILIKE $${params.length}
|
|
OR po.order_reference ILIKE $${params.length}
|
|
OR po.product_sku ILIKE $${params.length}
|
|
OR po.product_description ILIKE $${params.length}
|
|
)`);
|
|
}
|
|
|
|
const result = await pool.query(`
|
|
${baseProductionOrderSelect}
|
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
ORDER BY
|
|
COALESCE(po.issue_date, po.created_at::date) DESC,
|
|
CASE WHEN po.number ~ '^\\d+$' THEN po.number::bigint ELSE NULL END DESC NULLS LAST,
|
|
po.id DESC;
|
|
`, params);
|
|
|
|
const orders = result.rows.map(mapProductionOrderRow);
|
|
const counts = orders.reduce((nextCounts, order) => {
|
|
nextCounts.all += 1;
|
|
nextCounts[order.status] = (nextCounts[order.status] || 0) + 1;
|
|
return nextCounts;
|
|
}, {
|
|
all: 0,
|
|
open: 0,
|
|
in_progress: 0,
|
|
finished: 0,
|
|
canceled: 0
|
|
});
|
|
|
|
return { orders, counts };
|
|
};
|
|
|
|
const createProductionOrders = async (orders = []) => {
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
const created = [];
|
|
const skipped = [];
|
|
|
|
for (const order of orders) {
|
|
const productDescription = normalizeText(order.productDescription);
|
|
const productSku = normalizeText(order.productSku);
|
|
const orderReference = normalizeText(order.orderReference);
|
|
const quantity = normalizeQuantity(order.quantity);
|
|
|
|
if (!productDescription || !quantity) {
|
|
skipped.push({ productSku, productDescription, reason: 'invalid_payload' });
|
|
continue;
|
|
}
|
|
|
|
const duplicateResult = await client.query(`
|
|
SELECT id
|
|
FROM production_orders
|
|
WHERE COALESCE(order_reference, '') = $1
|
|
AND COALESCE(product_sku, '') = $2
|
|
AND status IN ('open', 'in_progress')
|
|
LIMIT 1;
|
|
`, [orderReference, productSku]);
|
|
|
|
if (duplicateResult.rows.length) {
|
|
skipped.push({ productSku, productDescription, reason: 'duplicate_open_order' });
|
|
continue;
|
|
}
|
|
|
|
const insertResult = await client.query(`
|
|
INSERT INTO production_orders (
|
|
tiny_id,
|
|
number,
|
|
status,
|
|
order_reference,
|
|
issue_date,
|
|
expected_date,
|
|
product_sku,
|
|
product_description,
|
|
quantity,
|
|
unit,
|
|
integration_status,
|
|
tiny_payload
|
|
)
|
|
VALUES (
|
|
NULL,
|
|
NULL,
|
|
$1,
|
|
$2,
|
|
$3,
|
|
$4,
|
|
$5,
|
|
$6,
|
|
$7,
|
|
$8,
|
|
$9,
|
|
$10::jsonb
|
|
)
|
|
RETURNING id;
|
|
`, [
|
|
normalizeStatus(order.status || 'open'),
|
|
orderReference,
|
|
normalizeDateParam(order.issueDate) || formatDate(new Date()),
|
|
normalizeDateParam(order.expectedDate),
|
|
productSku,
|
|
productDescription,
|
|
quantity,
|
|
normalizeText(order.unit) || 'UN',
|
|
normalizeText(order.integrationStatus) || 'Local',
|
|
JSON.stringify(order.metadata || {})
|
|
]);
|
|
|
|
const orderId = insertResult.rows[0].id;
|
|
await client.query(`
|
|
UPDATE production_orders
|
|
SET number = $1, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $2;
|
|
`, [normalizeText(order.number) || `OP-${String(orderId).padStart(5, '0')}`, orderId]);
|
|
|
|
const markers = Array.isArray(order.markers) ? order.markers : [];
|
|
for (const marker of markers) {
|
|
const label = normalizeText(marker.label);
|
|
if (!label) continue;
|
|
await client.query(`
|
|
INSERT INTO production_order_markers (production_order_id, label, color)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (production_order_id, label) DO UPDATE SET color = EXCLUDED.color;
|
|
`, [orderId, label, normalizeText(marker.color) || null]);
|
|
}
|
|
|
|
const createdOrder = await getOrderById(orderId, client);
|
|
if (createdOrder) created.push(createdOrder);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
return { created, skipped };
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
};
|
|
|
|
const resolveTinyOrderPayload = (payload = {}) => {
|
|
const order = payload.order && typeof payload.order === 'object' ? payload.order : payload;
|
|
const quantity = normalizeQuantity(order.quantity);
|
|
const productDescription = normalizeText(order.productDescription || order.productName || order.produto || order.descricao);
|
|
|
|
if (!productDescription || !quantity) {
|
|
const error = new Error('Produto e quantidade da OP são obrigatórios.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
tinyId: normalizeText(order.tinyId || order.tiny_id || order.idTiny || order.id),
|
|
number: normalizeText(order.number || order.numero || order.numeroOp || order.productionOrderNumber),
|
|
status: normalizeStatus(order.status || order.situacao || 'in_progress'),
|
|
orderReference: normalizeText(order.orderReference || order.reference || order.plano || order.pedidos),
|
|
issueDate: normalizeDateParam(order.issueDate || order.date || order.data),
|
|
expectedDate: normalizeDateParam(order.expectedDate || order.dataPrevista),
|
|
productSku: normalizeText(order.productSku || order.sku || order.codigo || order.codigoSku),
|
|
productDescription,
|
|
quantity,
|
|
unit: normalizeText(order.unit || order.unidade) || 'UN',
|
|
integrationStatus: normalizeText(order.integrationStatus) || 'Tiny',
|
|
notes: normalizeText(order.notes || order.observations || order.observacoes),
|
|
supplier: normalizeText(order.supplier || order.fornecedor),
|
|
lotCode: normalizeText(order.lotCode || order.lote),
|
|
rollQuantity: normalizeNullableQuantity(order.rollQuantity || order.quantidadeRolos),
|
|
fabricKg: normalizeNullableQuantity(order.fabricKg || order.quilosMalha),
|
|
ribKg: normalizeNullableQuantity(order.ribKg || order.quilosRibana),
|
|
yieldPiecesPerKg: normalizeNullableQuantity(order.yieldPiecesPerKg || order.rendimento),
|
|
rawPayload: payload
|
|
};
|
|
};
|
|
|
|
const normalizeComponents = (components = [], orderQuantity = 0) => (
|
|
Array.isArray(components) ? components : []
|
|
).map(component => {
|
|
const quantityPerUnit = normalizeQuantity(component.quantityPerUnit || component.quantity || component.quantidade);
|
|
const totalQuantity = normalizeQuantity(component.totalQuantity || component.quantidadeTotal) || quantityPerUnit * orderQuantity;
|
|
|
|
return {
|
|
componentTinyId: normalizeText(component.componentTinyId || component.idComponente || component.id_componente || component.id),
|
|
componentSku: normalizeText(component.componentSku || component.sku || component.codigo || component.code),
|
|
componentName: normalizeText(component.componentName || component.name || component.nome || component.produto),
|
|
quantityPerUnit,
|
|
totalQuantity,
|
|
unit: normalizeText(component.unit || component.unidade)
|
|
};
|
|
}).filter(component => component.componentName);
|
|
|
|
const normalizeSteps = (steps = []) => (
|
|
Array.isArray(steps) ? steps : []
|
|
).map(step => ({
|
|
stepNumber: normalizeInteger(step.stepNumber || step.number || step.nro || step.numero),
|
|
name: normalizeText(step.name || step.etapa || step.posto || step.description),
|
|
startDate: normalizeDateParam(step.startDate || step.dataInicio),
|
|
endDate: normalizeDateParam(step.endDate || step.dataFim),
|
|
status: normalizeText(step.status || step.situacao),
|
|
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 findProductCompositions = async (productId, client = pool) => {
|
|
const identity = normalizeText(productId);
|
|
|
|
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 (
|
|
$2 = ''
|
|
OR composition.finished_tiny_product_id = $2
|
|
OR composition.finished_product_sku = UPPER($2)
|
|
)
|
|
ORDER BY composition.finished_product_sku, composition.finished_product_description;
|
|
`, [TINY_OLIST_V3_COMPOSITION_SOURCE, identity]);
|
|
|
|
return result.rows.map(mapProductCompositionRow);
|
|
};
|
|
|
|
const getProductComposition = async (productId, client = pool) => (
|
|
(await findProductCompositions(productId, client))[0] || null
|
|
);
|
|
|
|
const listProductCompositions = async (client = pool) => findProductCompositions('', client);
|
|
|
|
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();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
const existingResult = await client.query(`
|
|
SELECT id
|
|
FROM production_orders
|
|
WHERE ($1 <> '' AND tiny_id = $1)
|
|
OR ($2 <> '' AND number = $2)
|
|
ORDER BY CASE WHEN tiny_id = $1 THEN 0 ELSE 1 END
|
|
LIMIT 1;
|
|
`, [order.tinyId, order.number]);
|
|
|
|
let orderId = existingResult.rows[0]?.id;
|
|
|
|
if (orderId) {
|
|
await client.query(`
|
|
UPDATE production_orders
|
|
SET tiny_id = COALESCE(NULLIF($1, ''), tiny_id),
|
|
number = COALESCE(NULLIF($2, ''), number),
|
|
status = $3,
|
|
order_reference = NULLIF($4, ''),
|
|
issue_date = $5,
|
|
expected_date = $6,
|
|
product_sku = NULLIF($7, ''),
|
|
product_description = $8,
|
|
quantity = $9,
|
|
unit = $10,
|
|
integration_status = $11,
|
|
notes = NULLIF($12, ''),
|
|
supplier = NULLIF($13, ''),
|
|
lot_code = NULLIF($14, ''),
|
|
roll_quantity = $15,
|
|
fabric_kg = $16,
|
|
rib_kg = $17,
|
|
yield_pieces_per_kg = $18,
|
|
tiny_payload = $19::jsonb,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $20;
|
|
`, [
|
|
order.tinyId,
|
|
order.number,
|
|
order.status,
|
|
order.orderReference,
|
|
order.issueDate,
|
|
order.expectedDate,
|
|
order.productSku,
|
|
order.productDescription,
|
|
order.quantity,
|
|
order.unit,
|
|
order.integrationStatus,
|
|
order.notes,
|
|
order.supplier,
|
|
order.lotCode,
|
|
order.rollQuantity,
|
|
order.fabricKg,
|
|
order.ribKg,
|
|
order.yieldPiecesPerKg,
|
|
JSON.stringify(order.rawPayload),
|
|
orderId
|
|
]);
|
|
} else {
|
|
const insertResult = await client.query(`
|
|
INSERT INTO production_orders (
|
|
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, tiny_payload
|
|
)
|
|
VALUES (
|
|
NULLIF($1, ''), NULLIF($2, ''), $3, NULLIF($4, ''), $5, $6,
|
|
NULLIF($7, ''), $8, $9, $10, $11, NULLIF($12, ''),
|
|
NULLIF($13, ''), NULLIF($14, ''), $15, $16, $17, $18, $19::jsonb
|
|
)
|
|
RETURNING id;
|
|
`, [
|
|
order.tinyId,
|
|
order.number,
|
|
order.status,
|
|
order.orderReference,
|
|
order.issueDate,
|
|
order.expectedDate,
|
|
order.productSku,
|
|
order.productDescription,
|
|
order.quantity,
|
|
order.unit,
|
|
order.integrationStatus,
|
|
order.notes,
|
|
order.supplier,
|
|
order.lotCode,
|
|
order.rollQuantity,
|
|
order.fabricKg,
|
|
order.ribKg,
|
|
order.yieldPiecesPerKg,
|
|
JSON.stringify(order.rawPayload)
|
|
]);
|
|
orderId = insertResult.rows[0].id;
|
|
}
|
|
|
|
await client.query('DELETE FROM production_order_components WHERE production_order_id = $1;', [orderId]);
|
|
for (const component of components) {
|
|
await client.query(`
|
|
INSERT INTO production_order_components (
|
|
production_order_id, component_tiny_id, component_sku, component_name,
|
|
quantity_per_unit, total_quantity, unit
|
|
)
|
|
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, $5, $6, NULLIF($7, ''));
|
|
`, [
|
|
orderId,
|
|
component.componentTinyId,
|
|
component.componentSku,
|
|
component.componentName,
|
|
component.quantityPerUnit,
|
|
component.totalQuantity,
|
|
component.unit
|
|
]);
|
|
}
|
|
|
|
await client.query('DELETE FROM production_order_steps WHERE production_order_id = $1;', [orderId]);
|
|
for (const step of steps) {
|
|
await client.query(`
|
|
INSERT INTO production_order_steps (
|
|
production_order_id, step_number, name, start_date, end_date, status, color
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''));
|
|
`, [
|
|
orderId,
|
|
step.stepNumber,
|
|
step.name,
|
|
step.startDate,
|
|
step.endDate,
|
|
step.status,
|
|
step.color
|
|
]);
|
|
}
|
|
|
|
const referenceSync = await upsertConsumptionReferencesFromComponents(client, orderId, order, components);
|
|
|
|
await client.query('COMMIT');
|
|
return {
|
|
order: await getOrderById(orderId),
|
|
componentCount: components.length,
|
|
stepCount: steps.length,
|
|
referenceCount: referenceSync.referenceCount,
|
|
skippedReferenceCount: referenceSync.skippedReferenceCount
|
|
};
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
};
|
|
|
|
const updateProductionOrderStatus = async (id, status) => {
|
|
const normalizedStatus = normalizeStatus(status);
|
|
|
|
if (!STATUS_LABELS[normalizedStatus]) {
|
|
const error = new Error('Invalid production order status');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const result = await pool.query(`
|
|
UPDATE production_orders
|
|
SET status = $1, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $2
|
|
RETURNING id;
|
|
`, [normalizedStatus, id]);
|
|
|
|
if (!result.rows.length) {
|
|
const error = new Error('Production order not found');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
return getOrderById(id);
|
|
};
|
|
|
|
module.exports = {
|
|
createProductionOrders,
|
|
getProductComposition,
|
|
listProductCompositions,
|
|
listProductionOrders,
|
|
normalizeStatus,
|
|
upsertTinyProductionOrderDetail,
|
|
updateProductionOrderStatus
|
|
};
|