Files
graphs/backend/services/productionOrderService.js
2026-08-26 16:30:36 -03:00

1959 lines
80 KiB
JavaScript

const { pool } = require('../db');
const { getActiveOperationalUserById, SYSTEM_OPERATIONAL_USER_NAME } = require('./userService');
const STATUS_LABELS = {
open: 'Em aberto',
cutting: 'Em corte',
sewing: 'Em costura',
review: 'Em revisão',
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', 'em corte', 'corte', 'cutting'].includes(normalizedStatus)) return 'cutting';
if (['em costura', 'costura', 'sewing'].includes(normalizedStatus)) return 'sewing';
if (['em revisao', 'em revisão', 'revisao', 'revisão', 'review'].includes(normalizedStatus)) return 'review';
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 normalizeWorkstationCode = (value) => normalizeText(value)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
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 normalizeNonNegativeQuantity = (value) => {
if (value === '' || value === null || value === undefined) return null;
const normalizedValue = typeof value === 'string' && value.includes(',')
? value.replace(/\./g, '').replace(',', '.')
: value;
const quantity = Number(normalizedValue);
return Number.isFinite(quantity) && quantity >= 0 ? 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, 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 };
}
const productId = await upsertCatalogProductFromSync(client, {
sku: order.productSku,
name: order.productDescription,
type: 'finished_product',
categoryName: classifyCatalogCategoryName(order.productDescription, 'finished_product'),
notes: `Sincronizado da ${sourceLabel} ${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 ${sourceLabel} ${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') = $3
LIMIT 1;
`, [productId, materialId, source]);
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,
source = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $6;
`, [
generalYield,
component.quantityPerUnit,
component.unit || null,
orderId,
source,
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, $6, $7, CURRENT_TIMESTAMP);
`, [
productId,
materialId,
generalYield,
component.quantityPerUnit,
component.unit || null,
source,
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 mapLotReservation = (reservation) => ({
id: reservation.id,
lotId: Number(reservation.supply_lot_id),
materialKey: reservation.material_key || '',
quantity: Number(reservation.quantity || 0),
consumedQuantity: Number(reservation.consumed_quantity || 0),
status: reservation.status || '',
lotProduct: reservation.lot_product || '',
lotUnit: reservation.lot_unit || '',
lotSupplier: reservation.lot_supplier || ''
});
const mapDefect = (defect) => ({
id: Number(defect.id),
workstationId: defect.workstation_id === null ? null : Number(defect.workstation_id),
workstationName: defect.workstation_name || '',
defectType: defect.defect_type || '',
origin: defect.origin || '',
quantity: Number(defect.quantity || 0),
lotCode: defect.lot_code || '',
notes: defect.notes || '',
occurredAt: defect.occurred_at || null
});
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',
productionPlanId: row.production_plan_id === null || row.production_plan_id === undefined ? null : Number(row.production_plan_id),
productionPlanNumber: row.production_plan_number || '',
productionPlanName: row.production_plan_name || '',
productionPlanStatus: row.production_plan_status || '',
contractor: row.contractor || '',
orderReference: row.order_reference || '',
issueDate: formatDate(row.issue_date),
expectedDate: formatDate(row.expected_date),
completedDate: formatDate(row.completed_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),
actualCutQuantity: row.actual_cut_quantity === null ? null : Number(row.actual_cut_quantity),
rejectedQuantity: Number(row.rejected_quantity || 0),
finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity),
qualityNotes: row.quality_notes || '',
defects: Array.isArray(row.defects) ? row.defects.map(mapDefect) : [],
lotReservations: Array.isArray(row.lot_reservations) ? row.lot_reservations.map(mapLotReservation) : [],
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 mapProductionPlanRow = (row) => ({
id: Number(row.id),
number: row.number || '',
name: row.name || '',
status: normalizeStatus(row.status),
statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto',
contractor: row.contractor || '',
responsibleUserId: row.responsible_user_id === null || row.responsible_user_id === undefined ? null : Number(row.responsible_user_id),
responsibleName: row.responsible_user_name || row.responsible_name || '',
plannedStartDate: formatDate(row.planned_start_date),
expectedDate: formatDate(row.expected_date),
completedAt: row.completed_at || null,
notes: row.notes || '',
source: row.source || 'cut_plan',
orderCount: Number(row.order_count || 0),
totalQuantity: Number(row.total_quantity || 0),
actualCutQuantity: Number(row.actual_cut_quantity || 0),
finishedQuantity: Number(row.finished_quantity || 0),
statusCounts: row.status_counts || {},
createdAt: row.created_at || null,
updatedAt: row.updated_at || null
});
const normalizePlanPayload = (payload = {}, fallback = {}) => ({
id: normalizeInteger(payload.id),
name: normalizeText(payload.name) || normalizeText(fallback.name),
contractor: normalizeText(payload.contractor),
responsibleUserId: payload.responsibleUserId === undefined
? normalizeInteger(fallback.responsibleUserId)
: normalizeInteger(payload.responsibleUserId),
responsibleName: normalizeText(payload.responsibleName) || normalizeText(fallback.responsibleName),
plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate),
expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate),
notes: normalizeText(payload.notes),
source: normalizeText(payload.source) || 'cut_plan'
});
const getProductionPlanById = async (id, client = pool) => {
const result = await client.query(`
SELECT
plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id,
responsible.name AS responsible_user_name, plan.responsible_name,
plan.planned_start_date, plan.expected_date, plan.completed_at,
plan.notes, plan.source, plan.created_at, plan.updated_at,
COUNT(po.id)::int AS order_count,
COALESCE(SUM(po.quantity), 0) AS total_quantity,
COALESCE(SUM(po.actual_cut_quantity), 0) AS actual_cut_quantity,
COALESCE(SUM(po.finished_quantity), 0) AS finished_quantity,
COALESCE((
SELECT JSON_OBJECT_AGG(status, count)
FROM (
SELECT status, COUNT(*)::int AS count
FROM production_orders
WHERE production_plan_id = plan.id
GROUP BY status
) grouped_statuses
), '{}'::json) AS status_counts
FROM production_plans plan
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
WHERE plan.id = $1
GROUP BY plan.id, responsible.id;
`, [id]);
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
};
const resolvePlanResponsible = async (plan, client = pool) => {
if (plan.responsibleUserId) {
const user = await getActiveOperationalUserById(plan.responsibleUserId, client);
if (!user) {
const error = new Error('O responsável selecionado não está disponível.');
error.statusCode = 400;
throw error;
}
return { userId: Number(user.id), name: user.name };
}
if (plan.responsibleName && plan.responsibleName !== SYSTEM_OPERATIONAL_USER_NAME) {
const error = new Error('O responsável selecionado não está disponível.');
error.statusCode = 400;
throw error;
}
return { userId: null, name: plan.responsibleName };
};
const createOrGetProductionPlan = async (client, payload, fallback) => {
const plan = normalizePlanPayload(payload, fallback);
if (plan.id) {
const existing = await getProductionPlanById(plan.id, client);
if (!existing) {
const error = new Error('Plano de produção não encontrado.');
error.statusCode = 404;
throw error;
}
return existing;
}
if (!plan.name) return null;
const responsible = await resolvePlanResponsible(plan, client);
const inserted = await client.query(`
INSERT INTO production_plans (name, status, contractor, responsible_user_id, responsible_name, planned_start_date, expected_date, notes, source)
VALUES ($1, 'open', NULLIF($2, ''), $3, NULLIF($4, ''), $5, $6, NULLIF($7, ''), $8)
RETURNING id;
`, [plan.name, plan.contractor, responsible.userId, responsible.name, plan.plannedStartDate, plan.expectedDate, plan.notes, plan.source]);
const id = Number(inserted.rows[0].id);
await client.query(`UPDATE production_plans SET number = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2;`, [`PP-${String(id).padStart(5, '0')}`, id]);
return getProductionPlanById(id, client);
};
const getOrderById = async (id, client = pool) => {
const result = await client.query(`
SELECT
po.id,
po.tiny_id,
po.number,
po.status,
po.production_plan_id,
plan.number AS production_plan_number,
plan.name AS production_plan_name,
plan.status AS production_plan_status,
po.contractor,
po.order_reference,
po.issue_date,
po.expected_date,
po.completed_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.actual_cut_quantity,
po.rejected_quantity,
po.finished_quantity,
po.quality_notes,
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', reservation.id,
'supply_lot_id', reservation.supply_lot_id,
'material_key', reservation.material_key,
'quantity', reservation.quantity,
'consumed_quantity', reservation.consumed_quantity,
'status', reservation.status,
'lot_product', lot.product,
'lot_unit', lot.unit,
'lot_supplier', lot.supplier
)
ORDER BY reservation.created_at, reservation.id
)
FROM cut_plan_lot_reservations reservation
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id
WHERE (
reservation.production_plan_id = po.production_plan_id
OR (reservation.production_plan_id IS NULL AND reservation.cut_plan_reference = po.order_reference)
)
AND reservation.status IN ('active', 'consumed')
),
'[]'::json
) as lot_reservations,
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,
COALESCE(
(
SELECT JSON_AGG(JSON_BUILD_OBJECT(
'id', defect.id,
'workstation_id', defect.workstation_id,
'workstation_name', workstation.name,
'defect_type', defect.defect_type,
'origin', defect.origin,
'quantity', defect.quantity,
'lot_code', defect.lot_code,
'notes', defect.notes,
'occurred_at', defect.occurred_at
) ORDER BY defect.occurred_at DESC, defect.id DESC)
FROM production_order_defects defect
LEFT JOIN production_workstations workstation ON workstation.id = defect.workstation_id
WHERE defect.production_order_id = po.id
),
'[]'::json
) as defects
FROM production_orders po
LEFT JOIN production_plans plan ON plan.id = po.production_plan_id
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.production_plan_id,
plan.number AS production_plan_number,
plan.name AS production_plan_name,
plan.status AS production_plan_status,
po.contractor,
po.order_reference,
po.issue_date,
po.expected_date,
po.completed_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.actual_cut_quantity,
po.rejected_quantity,
po.finished_quantity,
po.quality_notes,
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', reservation.id,
'supply_lot_id', reservation.supply_lot_id,
'material_key', reservation.material_key,
'quantity', reservation.quantity,
'consumed_quantity', reservation.consumed_quantity,
'status', reservation.status,
'lot_product', lot.product,
'lot_unit', lot.unit,
'lot_supplier', lot.supplier
)
ORDER BY reservation.created_at, reservation.id
)
FROM cut_plan_lot_reservations reservation
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id
WHERE (
reservation.production_plan_id = po.production_plan_id
OR (reservation.production_plan_id IS NULL AND reservation.cut_plan_reference = po.order_reference)
)
AND reservation.status IN ('active', 'consumed')
),
'[]'::json
) as lot_reservations,
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,
COALESCE(
(
SELECT JSON_AGG(JSON_BUILD_OBJECT(
'id', defect.id,
'workstation_id', defect.workstation_id,
'workstation_name', workstation.name,
'defect_type', defect.defect_type,
'origin', defect.origin,
'quantity', defect.quantity,
'lot_code', defect.lot_code,
'notes', defect.notes,
'occurred_at', defect.occurred_at
) ORDER BY defect.occurred_at DESC, defect.id DESC)
FROM production_order_defects defect
LEFT JOIN production_workstations workstation ON workstation.id = defect.workstation_id
WHERE defect.production_order_id = po.id
),
'[]'::json
) as defects
FROM production_orders po
LEFT JOIN production_plans plan ON plan.id = po.production_plan_id
`;
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,
cutting: 0,
sewing: 0,
review: 0,
finished: 0,
canceled: 0
});
return { orders, counts };
};
const listProductionPlans = async () => {
const result = await pool.query(`
SELECT
plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id,
responsible.name AS responsible_user_name, plan.responsible_name,
plan.planned_start_date, plan.expected_date, plan.completed_at,
plan.notes, plan.source, plan.created_at, plan.updated_at,
COUNT(po.id)::int AS order_count,
COALESCE(SUM(po.quantity), 0) AS total_quantity,
COALESCE(SUM(po.actual_cut_quantity), 0) AS actual_cut_quantity,
COALESCE(SUM(po.finished_quantity), 0) AS finished_quantity,
COALESCE((
SELECT JSON_OBJECT_AGG(status, count)
FROM (
SELECT status, COUNT(*)::int AS count
FROM production_orders
WHERE production_plan_id = plan.id
GROUP BY status
) grouped_statuses
), '{}'::json) AS status_counts
FROM production_plans plan
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
GROUP BY plan.id, responsible.id
ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC;
`);
return result.rows.map(mapProductionPlanRow);
};
const updateProductionPlan = async (id, payload = {}) => {
const plan = normalizePlanPayload(payload);
if (!plan.name) {
const error = new Error('O nome do plano é obrigatório.');
error.statusCode = 400;
throw error;
}
const responsible = await resolvePlanResponsible(plan);
const result = await pool.query(`
UPDATE production_plans
SET name = $1,
contractor = NULLIF($2, ''),
responsible_user_id = $3,
responsible_name = NULLIF($4, ''),
planned_start_date = $5,
expected_date = $6,
notes = NULLIF($7, ''),
updated_at = CURRENT_TIMESTAMP
WHERE id = $8
RETURNING id;
`, [plan.name, plan.contractor, responsible.userId, responsible.name, plan.plannedStartDate, plan.expectedDate, plan.notes, id]);
if (!result.rowCount) {
const error = new Error('Plano de produção não encontrado.');
error.statusCode = 404;
throw error;
}
return getProductionPlanById(id);
};
const updateProductionPlanStatus = async (id, status) => {
const normalizedStatus = normalizeStatus(status);
if (!STATUS_LABELS[normalizedStatus]) {
const error = new Error('Invalid production plan status');
error.statusCode = 400;
throw error;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const planResult = await client.query(`
UPDATE production_plans
SET status = $1,
completed_at = CASE WHEN $1 = 'finished' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) ELSE NULL END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING id;
`, [normalizedStatus, id]);
if (!planResult.rowCount) {
const error = new Error('Plano de produção não encontrado.');
error.statusCode = 404;
throw error;
}
await client.query(`
UPDATE production_orders
SET status = $1,
completed_date = CASE WHEN $1 = 'finished' THEN COALESCE(completed_date, CURRENT_DATE) ELSE completed_date END,
updated_at = CURRENT_TIMESTAMP
WHERE production_plan_id = $2
AND status NOT IN ('finished', 'canceled');
`, [normalizedStatus, id]);
await client.query('COMMIT');
return getProductionPlanById(id);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const createProductionOrders = async (orders = [], lotReservations = [], planPayload = null) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const created = [];
const skipped = [];
const fallbackPlan = {
name: normalizeText(orders[0]?.orderReference),
plannedStartDate: orders[0]?.issueDate,
expectedDate: orders[0]?.expectedDate
};
const plan = planPayload ? await createOrGetProductionPlan(client, planPayload, fallbackPlan) : null;
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);
const components = normalizeComponents(order.components, 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', 'cutting', 'sewing', 'review')
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,
production_plan_id,
contractor,
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,
$11,
$12::jsonb
)
RETURNING id;
`, [
normalizeStatus(order.status || 'open'),
plan?.id || null,
normalizeText(order.contractor) || plan?.contractor || null,
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]);
}
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
]);
}
const createdOrder = await getOrderById(orderId, client);
if (createdOrder) created.push(createdOrder);
}
if (created.length && Array.isArray(lotReservations)) {
for (const reservation of lotReservations) {
const lotId = Number(reservation?.lotId);
const quantity = normalizeQuantity(reservation?.quantity);
const materialKey = normalizeText(reservation?.materialKey);
const cutPlanReference = normalizeText(reservation?.cutPlanReference) || normalizeText(created[0]?.orderReference);
if (!Number.isInteger(lotId) || !quantity || !materialKey || !cutPlanReference) {
const error = new Error('Reserva de lote inválida.');
error.statusCode = 400;
throw error;
}
const lotResult = await client.query(`
SELECT id, quantity, unit, status
FROM supply_stock_lots
WHERE id = $1
FOR UPDATE;
`, [lotId]);
if (!lotResult.rowCount || lotResult.rows[0].status !== 'active') {
const error = new Error('O lote selecionado não está disponível para reserva.');
error.statusCode = 400;
throw error;
}
const activeReservations = await client.query(`
SELECT quantity, consumed_quantity
FROM cut_plan_lot_reservations
WHERE supply_lot_id = $1
AND status = 'active'
FOR UPDATE;
`, [lotId]);
const alreadyReserved = activeReservations.rows.reduce((total, row) => (
total + Number(row.quantity || 0) - Number(row.consumed_quantity || 0)
), 0);
const availableQuantity = Math.max(0, Number(lotResult.rows[0].quantity || 0) - alreadyReserved);
if (quantity > availableQuantity) {
const error = new Error(`O lote #${lotId} possui apenas ${availableQuantity} ${lotResult.rows[0].unit} disponível para nova reserva.`);
error.statusCode = 400;
throw error;
}
await client.query(`
INSERT INTO cut_plan_lot_reservations (
cut_plan_reference, production_plan_id, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at
)
VALUES ($1, $2, $3, $4, $5, 0, 'active', CURRENT_TIMESTAMP);
`, [cutPlanReference, plan?.id || null, lotId, materialKey, quantity]);
}
}
await client.query('COMMIT');
return { created, skipped, plan };
} 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 MANUAL_COMPOSITION_SOURCE = 'manual';
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)
);
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 findProductCompositionsBySource = async (source, 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;
`, [source, identity]);
return result.rows.map(mapProductCompositionRow);
};
const findProductCompositions = async (productId, client = pool) => {
const [manualCompositions, syncedCompositions] = await Promise.all([
findProductCompositionsBySource(MANUAL_COMPOSITION_SOURCE, productId, client),
findProductCompositionsBySource(TINY_OLIST_V3_COMPOSITION_SOURCE, productId, client)
]);
const compositionsByProduct = new Map();
[...manualCompositions, ...syncedCompositions].forEach(composition => {
const identity = composition.finishedTinyProductId || composition.finishedProductSku;
if (identity && !compositionsByProduct.has(identity)) compositionsByProduct.set(identity, composition);
});
return [...compositionsByProduct.values()];
};
const getProductComposition = async (productId, client = pool) => (
(await findProductCompositions(productId, client))[0] || null
);
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);
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_identity = $2,
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
]);
}
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,
referenceCount: referenceSync.referenceCount,
skippedReferenceCount: referenceSync.skippedReferenceCount
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const saveManualProductComposition = async (productId, payload = {}) => {
const finishedProductIdentity = normalizeText(productId) || normalizeSku(payload.productSku);
const finishedProductSku = normalizeSku(payload.productSku);
const finishedProductDescription = normalizeText(payload.productDescription);
const finishedProductUnit = normalizeText(payload.productUnit) || 'UN';
const rawComponents = normalizeCompositionComponents(payload.components);
const components = rawComponents.map(component => ({
...component,
componentName: normalizeText(component.componentName),
unit: normalizeText(component.unit).toUpperCase(),
quantityPerUnit: Number(component.quantityPerUnit)
}));
if (!finishedProductIdentity || !finishedProductDescription) {
const error = new Error('Produto da composição inválido.');
error.statusCode = 400;
throw error;
}
if (!components.length || components.some(component => !component.componentName || !component.unit || !Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0)) {
const error = new Error('Adicione pelo menos um insumo à composição.');
error.statusCode = 400;
throw error;
}
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;
`, [MANUAL_COMPOSITION_SOURCE, finishedProductIdentity]);
const values = [
`MANUAL-${finishedProductIdentity}`,
finishedProductIdentity,
finishedProductSku || null,
finishedProductDescription,
finishedProductUnit,
finishedProductIdentity,
JSON.stringify({ provider: 'Graphs', notes: 'Composição cadastrada manualmente.' })
];
let compositionId = existingResult.rows[0]?.id;
if (compositionId) {
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, 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;
`, [MANUAL_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 components) {
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 getProductComposition(finishedProductIdentity);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const deleteManualProductComposition = async (productId) => {
const identity = normalizeText(productId);
const result = await pool.query(`
DELETE FROM product_compositions
WHERE source = $1
AND (finished_tiny_product_id = $2 OR finished_product_sku = UPPER($2))
RETURNING id;
`, [MANUAL_COMPOSITION_SOURCE, identity]);
return { deleted: result.rows.length > 0 };
};
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);
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::varchar,
completed_date = CASE
WHEN $1::text = 'finished' THEN COALESCE(completed_date, CURRENT_DATE)
WHEN status = 'finished' THEN NULL
ELSE completed_date
END,
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);
};
const updateProductionOrderExecution = async (id, payload = {}) => {
const actualCutQuantity = normalizeNonNegativeQuantity(payload.actualCutQuantity);
const rejectedQuantity = normalizeNonNegativeQuantity(payload.rejectedQuantity);
const finishedQuantity = normalizeNonNegativeQuantity(payload.finishedQuantity);
const qualityNotes = normalizeText(payload.qualityNotes);
if (actualCutQuantity === null && rejectedQuantity === null && finishedQuantity === null && !qualityNotes) {
const error = new Error('Informe uma quantidade de execução ou uma observação de qualidade.');
error.statusCode = 400;
throw error;
}
const currentResult = await pool.query(`
SELECT actual_cut_quantity, rejected_quantity, finished_quantity
FROM production_orders
WHERE id = $1;
`, [id]);
if (!currentResult.rowCount) {
const error = new Error('Production order not found');
error.statusCode = 404;
throw error;
}
const current = currentResult.rows[0];
const effectiveActual = actualCutQuantity === null ? (current.actual_cut_quantity === null ? null : Number(current.actual_cut_quantity)) : actualCutQuantity;
const effectiveRejected = rejectedQuantity === null ? Number(current.rejected_quantity || 0) : rejectedQuantity;
const effectiveFinished = finishedQuantity === null ? (current.finished_quantity === null ? null : Number(current.finished_quantity)) : finishedQuantity;
if (effectiveActual !== null && effectiveRejected > effectiveActual) {
const error = new Error('As perdas não podem ser maiores que a quantidade cortada.');
error.statusCode = 400;
throw error;
}
if (effectiveActual !== null && effectiveFinished !== null && effectiveFinished > effectiveActual - effectiveRejected) {
const error = new Error('A quantidade final não pode ser maior que o saldo após as perdas.');
error.statusCode = 400;
throw error;
}
await pool.query(`
UPDATE production_orders
SET actual_cut_quantity = $1,
rejected_quantity = $2,
finished_quantity = $3,
quality_notes = NULLIF($4, ''),
updated_at = CURRENT_TIMESTAMP
WHERE id = $5;
`, [effectiveActual, effectiveRejected, effectiveFinished, qualityNotes, id]);
return getOrderById(id);
};
const mapWorkstation = (row) => ({
id: Number(row.id),
code: row.code || '',
name: row.name || '',
color: row.color || '#38bdf8',
sortOrder: Number(row.sort_order || 0),
isActive: Boolean(row.is_active)
});
const listProductionWorkstations = async ({ includeInactive = false } = {}) => {
const result = await pool.query(`
SELECT id, code, name, color, sort_order, is_active
FROM production_workstations
${includeInactive ? '' : 'WHERE is_active = TRUE'}
ORDER BY sort_order, name;
`);
return result.rows.map(mapWorkstation);
};
const normalizeWorkstationPayload = (payload = {}) => {
const name = normalizeText(payload.name).slice(0, 160);
const code = normalizeWorkstationCode(payload.code || name);
const color = /^#[0-9a-f]{6}$/i.test(normalizeText(payload.color)) ? normalizeText(payload.color) : '#38bdf8';
const sortOrder = Number.isFinite(Number(payload.sortOrder)) ? Math.max(0, Math.trunc(Number(payload.sortOrder))) : 0;
const isActive = payload.isActive !== false;
if (!name || !code) {
const error = new Error('Informe o nome do posto de trabalho.');
error.statusCode = 400;
throw error;
}
return { name, code, color, sortOrder, isActive };
};
const createProductionWorkstation = async (payload = {}) => {
const workstation = normalizeWorkstationPayload(payload);
try {
const result = await pool.query(`
INSERT INTO production_workstations (code, name, color, sort_order, is_active, updated_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
RETURNING id, code, name, color, sort_order, is_active;
`, [workstation.code, workstation.name, workstation.color, workstation.sortOrder, workstation.isActive]);
return mapWorkstation(result.rows[0]);
} catch (error) {
if (error?.code === '23505') {
error.message = 'Já existe um posto de trabalho com este código.';
error.statusCode = 409;
}
throw error;
}
};
const updateProductionWorkstation = async (id, payload = {}) => {
const workstation = normalizeWorkstationPayload(payload);
try {
const result = await pool.query(`
UPDATE production_workstations
SET code = $1, name = $2, color = $3, sort_order = $4, is_active = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING id, code, name, color, sort_order, is_active;
`, [workstation.code, workstation.name, workstation.color, workstation.sortOrder, workstation.isActive, id]);
if (!result.rowCount) {
const error = new Error('Posto de trabalho não encontrado.');
error.statusCode = 404;
throw error;
}
return mapWorkstation(result.rows[0]);
} catch (error) {
if (error?.code === '23505') {
error.message = 'Já existe um posto de trabalho com este código.';
error.statusCode = 409;
}
throw error;
}
};
const addProductionOrderDefect = async (id, payload = {}) => {
const defectType = normalizeText(payload.defectType);
const quantity = normalizeQuantity(payload.quantity);
const workstationId = normalizeInteger(payload.workstationId);
if (!defectType || !quantity) {
const error = new Error('Informe o tipo e a quantidade do defeito.');
error.statusCode = 400;
throw error;
}
if (workstationId) {
const workstation = await pool.query('SELECT id FROM production_workstations WHERE id = $1 AND is_active = TRUE;', [workstationId]);
if (!workstation.rowCount) {
const error = new Error('Posto de trabalho inválido.');
error.statusCode = 400;
throw error;
}
}
const result = await pool.query(`
INSERT INTO production_order_defects (production_order_id, workstation_id, defect_type, origin, quantity, lot_code, notes)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, NULLIF($6, ''), NULLIF($7, ''))
RETURNING id;
`, [id, workstationId, defectType, normalizeText(payload.origin), quantity, normalizeText(payload.lotCode), normalizeText(payload.notes)]);
if (!result.rowCount) throw new Error('Não foi possível registrar o defeito.');
return getOrderById(id);
};
module.exports = {
createProductionOrders,
createProductionWorkstation,
deleteManualProductComposition,
getProductComposition,
importTinyProductCompositionExport,
listProductionPlans,
listProductionWorkstations,
listProductCompositions,
listProductionOrders,
normalizeStatus,
saveManualProductComposition,
upsertTinyProductComposition,
upsertTinyProductionOrderDetail,
updateProductionPlan,
updateProductionPlanStatus,
updateProductionWorkstation,
addProductionOrderDefect,
updateProductionOrderExecution,
updateProductionOrderStatus
};