Add Tiny production order detail sync

This commit is contained in:
Cauê Faleiros
2026-07-23 10:42:53 -03:00
parent 615e07ad32
commit c07938c94e
6 changed files with 667 additions and 101 deletions

View File

@@ -128,6 +128,13 @@ const initDB = async () => {
quantity NUMERIC(14, 4) DEFAULT 0, quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(20) DEFAULT 'UN', unit VARCHAR(20) DEFAULT 'UN',
integration_status VARCHAR(100), integration_status VARCHAR(100),
notes TEXT,
supplier TEXT,
lot_code VARCHAR(120),
roll_quantity NUMERIC(14, 4),
fabric_kg NUMERIC(14, 4),
rib_kg NUMERIC(14, 4),
yield_pieces_per_kg NUMERIC(14, 4),
tiny_payload JSONB, tiny_payload JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
@@ -145,6 +152,36 @@ const initDB = async () => {
); );
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_components (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
component_tiny_id VARCHAR(100),
component_sku VARCHAR(255),
component_name TEXT NOT NULL,
quantity_per_unit NUMERIC(14, 4) DEFAULT 0,
total_quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(30),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_steps (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
step_number INTEGER,
name VARCHAR(160) NOT NULL,
start_date DATE,
end_date DATE,
status VARCHAR(80),
color VARCHAR(40),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS cutting_family_rules ( CREATE TABLE IF NOT EXISTS cutting_family_rules (
family_key VARCHAR(20) PRIMARY KEY, family_key VARCHAR(20) PRIMARY KEY,
@@ -277,6 +314,33 @@ const initDB = async () => {
await pool.query(` await pool.query(`
ALTER TABLE production_orders ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS supplier TEXT,
ADD COLUMN IF NOT EXISTS lot_code VARCHAR(120),
ADD COLUMN IF NOT EXISTS roll_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS fabric_kg NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS rib_kg NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS yield_pieces_per_kg NUMERIC(14, 4);
`).catch(() => {});
await pool.query(`
ALTER TABLE production_orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_order_components
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_order_steps
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP, ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo', ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
@@ -425,6 +489,9 @@ const initDB = async () => {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_steps_order_id ON production_order_steps (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);

View File

@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus } = require('../services/productionOrderService'); const { createProductionOrders, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -23,6 +23,15 @@ router.post('/production-orders', verifyToken, async (req, res) => {
} }
}); });
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
try {
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
} catch (error) {
console.error('Error syncing Tiny production order:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => { router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
try { try {
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status)); res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));

View File

@@ -31,8 +31,16 @@ const sampleSpecs = {
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'], columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
orderBy: ['created_at', 'id'] orderBy: ['created_at', 'id']
}, },
production_order_components: {
columns: ['id', 'production_order_id', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'total_quantity', 'unit', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
production_order_steps: {
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
production_orders: { production_orders: {
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'created_at', 'updated_at'], columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id'] orderBy: ['updated_at', 'created_at', 'id']
}, },
stock: { stock: {

View File

@@ -18,9 +18,22 @@ const normalizeStatus = (status) => {
const normalizeDateParam = (value) => { const normalizeDateParam = (value) => {
if (!value) return null; if (!value) return null;
const date = new Date(`${value}T00:00:00`); const normalizedValue = String(value).trim();
if (Number.isNaN(date.getTime())) return null; const isoMatch = normalizedValue.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
return value; 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) => { const formatDate = (value) => {
@@ -34,11 +47,44 @@ const formatDate = (value) => {
const normalizeText = (value) => String(value || '').trim(); const normalizeText = (value) => String(value || '').trim();
const normalizeQuantity = (value) => { const normalizeQuantity = (value) => {
const quantity = Number(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; if (!Number.isFinite(quantity) || quantity <= 0) return 0;
return quantity; 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 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 mapProductionOrderRow = (row) => {
const status = normalizeStatus(row.status); const status = normalizeStatus(row.status);
@@ -56,7 +102,16 @@ const mapProductionOrderRow = (row) => {
quantity: Number(row.quantity || 0), quantity: Number(row.quantity || 0),
unit: row.unit || 'UN', unit: row.unit || 'UN',
integrationStatus: row.integration_status || '', 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) : [], 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, createdAt: row.created_at || null,
updatedAt: row.updated_at || null updatedAt: row.updated_at || null
}; };
@@ -77,27 +132,152 @@ const getOrderById = async (id, client = pool) => {
po.quantity, po.quantity,
po.unit, po.unit,
po.integration_status, 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.created_at,
po.updated_at, po.updated_at,
COALESCE( COALESCE(
JSON_AGG( (
JSON_BUILD_OBJECT( SELECT JSON_AGG(
'label', pom.label, JSON_BUILD_OBJECT(
'color', pom.color 'label', marker.label,
'color', marker.color
)
ORDER BY marker.label
) )
ORDER BY pom.label FROM production_order_markers marker
) FILTER (WHERE pom.id IS NOT NULL), WHERE marker.production_order_id = po.id
),
'[]'::json '[]'::json
) as markers ) 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 FROM production_orders po
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id WHERE po.id = $1;
WHERE po.id = $1
GROUP BY po.id;
`, [id]); `, [id]);
return result.rows[0] ? mapProductionOrderRow(result.rows[0]) : null; 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 listProductionOrders = async (filters = {}) => {
const params = []; const params = [];
const where = []; const where = [];
@@ -126,35 +306,8 @@ const listProductionOrders = async (filters = {}) => {
} }
const result = await pool.query(` const result = await pool.query(`
SELECT ${baseProductionOrderSelect}
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.created_at,
po.updated_at,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'label', pom.label,
'color', pom.color
)
ORDER BY pom.label
) FILTER (WHERE pom.id IS NOT NULL),
'[]'::json
) as markers
FROM production_orders po
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''} ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY po.id
ORDER BY ORDER BY
COALESCE(po.issue_date, po.created_at::date) DESC, COALESCE(po.issue_date, po.created_at::date) DESC,
CASE WHEN po.number ~ '^\\d+$' THEN po.number::bigint ELSE NULL END DESC NULLS LAST, CASE WHEN po.number ~ '^\\d+$' THEN po.number::bigint ELSE NULL END DESC NULLS LAST,
@@ -285,6 +438,222 @@ const createProductionOrders = async (orders = []) => {
} }
}; };
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 upsertTinyProductionOrderDetail = async (payload = {}) => {
const order = resolveTinyOrderPayload(payload);
const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity);
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
]);
}
await client.query('COMMIT');
return {
order: await getOrderById(orderId),
componentCount: components.length,
stepCount: steps.length
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const updateProductionOrderStatus = async (id, status) => { const updateProductionOrderStatus = async (id, status) => {
const normalizedStatus = normalizeStatus(status); const normalizedStatus = normalizeStatus(status);
@@ -314,5 +683,6 @@ module.exports = {
createProductionOrders, createProductionOrders,
listProductionOrders, listProductionOrders,
normalizeStatus, normalizeStatus,
upsertTinyProductionOrderDetail,
updateProductionOrderStatus updateProductionOrderStatus
}; };

View File

@@ -1,4 +1,4 @@
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react'; import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
@@ -63,6 +63,16 @@ const formatQuantity = (value: number) => (
}).format(value) }).format(value)
); );
const formatOptionalQuantity = (value: number | null) => (
value === null ? '-' : formatQuantity(value)
);
const hasProductionOrderDetails = (order: ProductionOrderItem) => (
order.components.length > 0 ||
order.steps.length > 0 ||
Boolean(order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg)
);
const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => ( const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
statusStyles[String(status)] || { statusStyles[String(status)] || {
label: fallbackLabel || 'Em aberto', label: fallbackLabel || 'Em aberto',
@@ -500,63 +510,136 @@ const ProductionOrders = () => {
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{paginatedOrders.map((order: ProductionOrderItem) => { {paginatedOrders.map((order: ProductionOrderItem) => {
const statusStyle = getStatusStyle(order.status, order.statusLabel); const statusStyle = getStatusStyle(order.status, order.statusLabel);
const showDetails = hasProductionOrderDetails(order);
return ( return (
<tr key={order.id} className="transition-colors hover:bg-dark-input/50"> <Fragment key={order.id}>
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td> <tr className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td> <td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted"> <td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
<span className="inline-flex items-center gap-1.5"> <td className="px-6 py-3 text-xs font-semibold text-dark-muted">
<CalendarDays className="h-3.5 w-3.5" /> <span className="inline-flex items-center gap-1.5">
{formatDate(order.issueDate)} <CalendarDays className="h-3.5 w-3.5" />
</span> {formatDate(order.issueDate)}
</td> </span>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td>
<div className="font-bold text-dark-text">{order.productDescription}</div> <td className="px-6 py-3">
<div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</div> <div className="font-bold text-dark-text">{order.productDescription}</div>
</td> <div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</div>
<td className="px-6 py-3 text-right"> </td>
<span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span> <td className="px-6 py-3 text-right">
<span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span> <span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span>
</td> <span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span>
<td className="px-6 py-3"> </td>
{order.markers.length ? ( <td className="px-6 py-3">
<div className="flex max-w-52 flex-wrap gap-1.5"> {order.markers.length ? (
{order.markers.map(marker => ( <div className="flex max-w-52 flex-wrap gap-1.5">
<span {order.markers.map(marker => (
key={`${order.id}-${marker.label}`} <span
className="inline-flex items-center gap-1 rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted" key={`${order.id}-${marker.label}`}
> className="inline-flex items-center gap-1 rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted"
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} /> >
{marker.label} <span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} />
</span> {marker.label}
</span>
))}
</div>
) : (
<span className="text-xs font-semibold text-dark-muted">-</span>
)}
</td>
<td className="px-6 py-3">
<span className="inline-flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span className="h-2 w-2 rounded-full bg-sky-400" />
{order.integrationStatus || 'Tiny'}
</span>
</td>
<td className="px-6 py-3">
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
<select
id={`production-order-status-${order.id}`}
value={order.status}
disabled={busyStatusOrderId === order.id}
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
>
{editableStatusOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))} ))}
</div> </select>
) : ( </td>
<span className="text-xs font-semibold text-dark-muted">-</span> </tr>
)} {showDetails && (
</td> <tr className="bg-dark-input/20">
<td className="px-6 py-3"> <td colSpan={9} className="px-6 pb-5 pt-1">
<span className="inline-flex items-center gap-2 text-xs font-semibold text-dark-muted"> <div className="grid grid-cols-1 gap-4 rounded-xl border border-dark-border bg-dark-card/80 p-4 xl:grid-cols-[1.4fr_1fr_1fr]">
<span className="h-2 w-2 rounded-full bg-sky-400" /> <div>
{order.integrationStatus || 'Tiny'} <h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
</span> {order.components.length ? (
</td> <div className="mt-3 overflow-hidden rounded-lg border border-dark-border">
<td className="px-6 py-3"> <table className="w-full text-xs">
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label> <thead className="bg-dark-header text-dark-muted">
<select <tr>
id={`production-order-status-${order.id}`} <th className="px-3 py-2 text-left font-bold">Produto</th>
value={order.status} <th className="px-3 py-2 text-left font-bold">SKU</th>
disabled={busyStatusOrderId === order.id} <th className="px-3 py-2 text-right font-bold">Qtd.</th>
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)} <th className="px-3 py-2 text-right font-bold">Total</th>
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`} </tr>
> </thead>
{editableStatusOptions.map(option => ( <tbody className="divide-y divide-dark-border">
<option key={option.value} value={option.value}>{option.label}</option> {order.components.map(component => (
))} <tr key={component.id}>
</select> <td className="px-3 py-2 font-semibold text-dark-text">{component.componentName}</td>
</td> <td className="px-3 py-2 font-mono text-dark-muted">{component.componentSku || '-'}</td>
</tr> <td className="px-3 py-2 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td>
<td className="px-3 py-2 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem composição sincronizada.</p>
)}
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Etapas</h3>
{order.steps.length ? (
<div className="mt-3 divide-y divide-dark-border rounded-lg border border-dark-border">
{order.steps.map(step => (
<div key={step.id} className="grid grid-cols-[32px_1fr_auto] items-center gap-3 px-3 py-2 text-xs">
<span className="font-mono font-bold text-dark-muted">{step.stepNumber ?? '-'}</span>
<div>
<p className="font-bold text-dark-text">{step.name}</p>
<p className="mt-0.5 font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</p>
</div>
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} title={step.status || 'Sem status'} />
</div>
))}
</div>
) : (
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem etapas sincronizadas.</p>
)}
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Observações</h3>
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.supplier || '-'}</dd></div>
<div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.lotCode || '-'}</dd></div>
<div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.rollQuantity)}</dd></div>
<div><dt className="font-bold text-dark-muted">Malha kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.fabricKg)}</dd></div>
<div><dt className="font-bold text-dark-muted">Ribana kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.ribKg)}</dd></div>
<div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.yieldPiecesPerKg)}</dd></div>
</dl>
{order.notes && <p className="mt-3 whitespace-pre-wrap rounded-lg border border-dark-border bg-dark-input p-3 text-xs font-semibold text-dark-muted">{order.notes}</p>}
</div>
</div>
</td>
</tr>
)}
</Fragment>
); );
})} })}
</tbody> </tbody>

View File

@@ -34,6 +34,26 @@ export interface ProductionOrderMarker {
color?: string | null; color?: string | null;
} }
export interface ProductionOrderComponent {
id: number;
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: number;
totalQuantity: number;
unit: string;
}
export interface ProductionOrderStep {
id: number;
stepNumber: number | null;
name: string;
startDate: string | null;
endDate: string | null;
status: string;
color: string;
}
export interface ProductionOrderItem { export interface ProductionOrderItem {
id: number; id: number;
tinyId: string; tinyId: string;
@@ -48,7 +68,16 @@ export interface ProductionOrderItem {
quantity: number; quantity: number;
unit: string; unit: string;
integrationStatus: string; integrationStatus: string;
notes: string;
supplier: string;
lotCode: string;
rollQuantity: number | null;
fabricKg: number | null;
ribKg: number | null;
yieldPiecesPerKg: number | null;
markers: ProductionOrderMarker[]; markers: ProductionOrderMarker[];
components: ProductionOrderComponent[];
steps: ProductionOrderStep[];
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
} }