Compare commits
2 Commits
615e07ad32
...
800eb976ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800eb976ab | ||
|
|
c07938c94e |
@@ -128,6 +128,13 @@ const initDB = async () => {
|
||||
quantity NUMERIC(14, 4) DEFAULT 0,
|
||||
unit VARCHAR(20) DEFAULT 'UN',
|
||||
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,
|
||||
created_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(`
|
||||
CREATE TABLE IF NOT EXISTS cutting_family_rules (
|
||||
family_key VARCHAR(20) PRIMARY KEY,
|
||||
@@ -208,6 +245,10 @@ const initDB = async () => {
|
||||
efficiency_percent NUMERIC(7, 3),
|
||||
rib_g_per_piece NUMERIC(14, 4),
|
||||
material_cost_per_kg NUMERIC(14, 4),
|
||||
consumption_quantity NUMERIC(14, 4),
|
||||
consumption_unit VARCHAR(30),
|
||||
source VARCHAR(60) DEFAULT 'manual',
|
||||
last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -277,6 +318,33 @@ const initDB = async () => {
|
||||
|
||||
await pool.query(`
|
||||
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 SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -317,6 +385,14 @@ const initDB = async () => {
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE consumption_references
|
||||
ADD COLUMN IF NOT EXISTS consumption_quantity NUMERIC(14, 4),
|
||||
ADD COLUMN IF NOT EXISTS consumption_unit VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS source VARCHAR(60) DEFAULT 'manual',
|
||||
ADD COLUMN IF NOT EXISTS last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE consumption_references
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -425,11 +501,25 @@ 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_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_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_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_consumption_references_product_id ON consumption_references (product_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
|
||||
await pool.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS unique_consumption_reference_source
|
||||
ON consumption_references (
|
||||
product_id,
|
||||
COALESCE(material_product_id, 0),
|
||||
COALESCE(color, ''),
|
||||
COALESCE(source, 'manual')
|
||||
);
|
||||
`).catch(err => {
|
||||
console.error('Notice: Could not create unique consumption reference source index:', err.message);
|
||||
});
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_status ON supply_receipts (status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus } = require('../services/productionOrderService');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
|
||||
|
||||
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) => {
|
||||
try {
|
||||
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
|
||||
|
||||
@@ -73,6 +73,10 @@ const mapConsumptionReference = (row) => ({
|
||||
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
|
||||
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece),
|
||||
materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg),
|
||||
consumptionQuantity: row.consumption_quantity === null ? null : Number(row.consumption_quantity),
|
||||
consumptionUnit: row.consumption_unit || '',
|
||||
source: row.source || 'manual',
|
||||
lastProductionOrderId: row.last_production_order_id === null ? null : Number(row.last_production_order_id),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
@@ -185,7 +189,8 @@ const listConsumptionReferences = async () => {
|
||||
r.material_product_id, m.sku AS material_sku, m.name AS material_name,
|
||||
r.color, r.general_yield, r.size_yields, r.size_areas,
|
||||
r.gramature, r.efficiency_percent, r.rib_g_per_piece,
|
||||
r.material_cost_per_kg, r.created_at, r.updated_at
|
||||
r.material_cost_per_kg, r.consumption_quantity, r.consumption_unit,
|
||||
r.source, r.last_production_order_id, r.created_at, r.updated_at
|
||||
FROM consumption_references r
|
||||
JOIN catalog_products p ON p.id = r.product_id
|
||||
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
||||
@@ -208,9 +213,10 @@ const createConsumptionReference = async (payload) => {
|
||||
const calculatedYield = Object.values(sizeYields).length
|
||||
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
|
||||
: null;
|
||||
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
|
||||
|
||||
if (!generalYield && !calculatedYield) {
|
||||
const error = new Error('Informe o rendimento geral ou por tamanho.');
|
||||
if (!generalYield && !calculatedYield && !consumptionQuantity) {
|
||||
const error = new Error('Informe o rendimento ou consumo por peça.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
@@ -219,9 +225,9 @@ const createConsumptionReference = async (payload) => {
|
||||
INSERT INTO consumption_references (
|
||||
product_id, material_product_id, color, general_yield, size_yields,
|
||||
size_areas, gramature, efficiency_percent, rib_g_per_piece,
|
||||
material_cost_per_kg, updated_at
|
||||
material_cost_per_kg, consumption_quantity, consumption_unit, source, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, CURRENT_TIMESTAMP)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12, 'manual', CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
`, [
|
||||
productId,
|
||||
@@ -233,7 +239,9 @@ const createConsumptionReference = async (payload) => {
|
||||
normalizeNumber(payload.gramature),
|
||||
normalizeNumber(payload.efficiencyPercent),
|
||||
normalizeNumber(payload.ribGPerPiece),
|
||||
normalizeNumber(payload.materialCostPerKg)
|
||||
normalizeNumber(payload.materialCostPerKg),
|
||||
consumptionQuantity,
|
||||
normalizeText(payload.consumptionUnit) || null
|
||||
]);
|
||||
|
||||
const references = await listConsumptionReferences();
|
||||
|
||||
@@ -12,7 +12,7 @@ const sampleSpecs = {
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
consumption_references: {
|
||||
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'created_at', 'updated_at'],
|
||||
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'consumption_quantity', 'consumption_unit', 'source', 'last_production_order_id', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
cutting_family_rules: {
|
||||
@@ -31,8 +31,16 @@ const sampleSpecs = {
|
||||
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
|
||||
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: {
|
||||
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']
|
||||
},
|
||||
stock: {
|
||||
|
||||
@@ -18,9 +18,22 @@ const normalizeStatus = (status) => {
|
||||
|
||||
const normalizeDateParam = (value) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return value;
|
||||
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) => {
|
||||
@@ -34,11 +47,195 @@ const formatDate = (value) => {
|
||||
const normalizeText = (value) => String(value || '').trim();
|
||||
|
||||
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;
|
||||
return quantity;
|
||||
};
|
||||
|
||||
const normalizeNullableQuantity = (value) => {
|
||||
const quantity = normalizeQuantity(value);
|
||||
return quantity || null;
|
||||
};
|
||||
|
||||
const normalizeInteger = (value) => {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) ? number : null;
|
||||
};
|
||||
|
||||
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
||||
|
||||
const normalizeUnit = (value) => normalizeText(value).toLowerCase();
|
||||
|
||||
const normalizeCategoryName = (name) => normalizeText(name)
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toUpperCase();
|
||||
|
||||
const classifyCatalogCategoryName = (name, type) => {
|
||||
const normalizedName = normalizeCategoryName(name);
|
||||
if (type === 'finished_product') {
|
||||
if (/\bDTF\b/.test(normalizedName)) return 'DTF';
|
||||
if (/\b(?:MOLETOM|CANGURU)\b/.test(normalizedName)) return 'Moletom';
|
||||
if (/\b(?:OVERSIZE|OVERSIZED)\b/.test(normalizedName)) return 'Oversized';
|
||||
if (/\bINFANTIL\b/.test(normalizedName)) return 'Camiseta infantil';
|
||||
if (/\b(?:BONE|ACESSORIO|ACESSORIOS)\b/.test(normalizedName)) return 'Acessórios';
|
||||
return 'Camiseta regular';
|
||||
}
|
||||
if (/\b(?:MALHA|TECIDO|RIBANA|FIO)\b/.test(normalizedName)) return 'Malha';
|
||||
if (/\b(?:ETIQUETA|TAG|EMBALAGEM|SACO|SACOLA)\b/.test(normalizedName)) return 'Embalagens';
|
||||
if (/\b(?:ATACADOR|ILHOS|LINHA|FITA)\b/.test(normalizedName)) return 'Aviamentos';
|
||||
if (/\bDTF\b/.test(normalizedName)) return 'DTF';
|
||||
return 'Insumos gerais';
|
||||
};
|
||||
|
||||
const ensureCategoryId = async (client, name) => {
|
||||
const result = await client.query(`
|
||||
INSERT INTO catalog_categories (name, description, updated_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (name) DO UPDATE
|
||||
SET updated_at = catalog_categories.updated_at
|
||||
RETURNING id;
|
||||
`, [name, 'Categoria criada automaticamente pela sincronização de produção.']);
|
||||
|
||||
return result.rows[0].id;
|
||||
};
|
||||
|
||||
const upsertCatalogProductFromSync = async (client, { sku, name, type, categoryName, notes }) => {
|
||||
const normalizedSku = normalizeSku(sku);
|
||||
const normalizedName = normalizeText(name);
|
||||
if (!normalizedSku || !normalizedName) return null;
|
||||
|
||||
const categoryId = await ensureCategoryId(client, categoryName || classifyCatalogCategoryName(normalizedName, type));
|
||||
const result = await client.query(`
|
||||
INSERT INTO catalog_products (
|
||||
type, sku, name, category_id, notes, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (sku) DO UPDATE
|
||||
SET type = EXCLUDED.type,
|
||||
name = EXCLUDED.name,
|
||||
category_id = COALESCE(catalog_products.category_id, EXCLUDED.category_id),
|
||||
notes = COALESCE(NULLIF(catalog_products.notes, ''), EXCLUDED.notes),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING id;
|
||||
`, [
|
||||
type,
|
||||
normalizedSku,
|
||||
normalizedName,
|
||||
categoryId,
|
||||
normalizeText(notes) || null
|
||||
]);
|
||||
|
||||
return result.rows[0].id;
|
||||
};
|
||||
|
||||
const upsertConsumptionReferencesFromComponents = async (client, orderId, order, components) => {
|
||||
if (!order.productSku || !components.length) {
|
||||
return { referenceCount: 0, skippedReferenceCount: components.length };
|
||||
}
|
||||
|
||||
const productId = await upsertCatalogProductFromSync(client, {
|
||||
sku: order.productSku,
|
||||
name: order.productDescription,
|
||||
type: 'finished_product',
|
||||
categoryName: classifyCatalogCategoryName(order.productDescription, 'finished_product'),
|
||||
notes: `Sincronizado da OP ${order.number || order.tinyId || orderId}.`
|
||||
});
|
||||
|
||||
if (!productId) return { referenceCount: 0, skippedReferenceCount: components.length };
|
||||
|
||||
let referenceCount = 0;
|
||||
let skippedReferenceCount = 0;
|
||||
|
||||
for (const component of components) {
|
||||
const materialId = await upsertCatalogProductFromSync(client, {
|
||||
sku: component.componentSku,
|
||||
name: component.componentName,
|
||||
type: 'raw_material',
|
||||
categoryName: classifyCatalogCategoryName(component.componentName, 'raw_material'),
|
||||
notes: `Sincronizado da composição da OP ${order.number || order.tinyId || orderId}.`
|
||||
});
|
||||
|
||||
if (!materialId || !component.quantityPerUnit) {
|
||||
skippedReferenceCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const unit = normalizeUnit(component.unit);
|
||||
const generalYield = unit === 'kg' ? 1 / component.quantityPerUnit : null;
|
||||
|
||||
const existingReferenceResult = await client.query(`
|
||||
SELECT id
|
||||
FROM consumption_references
|
||||
WHERE product_id = $1
|
||||
AND COALESCE(material_product_id, 0) = $2
|
||||
AND COALESCE(color, '') = ''
|
||||
AND COALESCE(source, 'manual') = 'tiny_op'
|
||||
LIMIT 1;
|
||||
`, [productId, materialId]);
|
||||
|
||||
if (existingReferenceResult.rows.length) {
|
||||
await client.query(`
|
||||
UPDATE consumption_references
|
||||
SET general_yield = $1,
|
||||
consumption_quantity = $2,
|
||||
consumption_unit = $3,
|
||||
last_production_order_id = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5;
|
||||
`, [
|
||||
generalYield,
|
||||
component.quantityPerUnit,
|
||||
component.unit || null,
|
||||
orderId,
|
||||
existingReferenceResult.rows[0].id
|
||||
]);
|
||||
} else {
|
||||
await client.query(`
|
||||
INSERT INTO consumption_references (
|
||||
product_id, material_product_id, general_yield, consumption_quantity,
|
||||
consumption_unit, source, last_production_order_id, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'tiny_op', $6, CURRENT_TIMESTAMP);
|
||||
`, [
|
||||
productId,
|
||||
materialId,
|
||||
generalYield,
|
||||
component.quantityPerUnit,
|
||||
component.unit || null,
|
||||
orderId
|
||||
]);
|
||||
}
|
||||
|
||||
referenceCount += 1;
|
||||
}
|
||||
|
||||
return { referenceCount, skippedReferenceCount };
|
||||
};
|
||||
|
||||
const mapComponent = (component) => ({
|
||||
id: component.id,
|
||||
componentTinyId: component.component_tiny_id || '',
|
||||
componentSku: component.component_sku || '',
|
||||
componentName: component.component_name || '',
|
||||
quantityPerUnit: Number(component.quantity_per_unit || 0),
|
||||
totalQuantity: Number(component.total_quantity || 0),
|
||||
unit: component.unit || ''
|
||||
});
|
||||
|
||||
const mapStep = (step) => ({
|
||||
id: step.id,
|
||||
stepNumber: step.step_number === null ? null : Number(step.step_number),
|
||||
name: step.name || '',
|
||||
startDate: formatDate(step.start_date),
|
||||
endDate: formatDate(step.end_date),
|
||||
status: step.status || '',
|
||||
color: step.color || ''
|
||||
});
|
||||
|
||||
const mapProductionOrderRow = (row) => {
|
||||
const status = normalizeStatus(row.status);
|
||||
|
||||
@@ -56,7 +253,16 @@ const mapProductionOrderRow = (row) => {
|
||||
quantity: Number(row.quantity || 0),
|
||||
unit: row.unit || 'UN',
|
||||
integrationStatus: row.integration_status || '',
|
||||
notes: row.notes || '',
|
||||
supplier: row.supplier || '',
|
||||
lotCode: row.lot_code || '',
|
||||
rollQuantity: row.roll_quantity === null ? null : Number(row.roll_quantity),
|
||||
fabricKg: row.fabric_kg === null ? null : Number(row.fabric_kg),
|
||||
ribKg: row.rib_kg === null ? null : Number(row.rib_kg),
|
||||
yieldPiecesPerKg: row.yield_pieces_per_kg === null ? null : Number(row.yield_pieces_per_kg),
|
||||
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
|
||||
components: Array.isArray(row.components) ? row.components.map(mapComponent) : [],
|
||||
steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [],
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null
|
||||
};
|
||||
@@ -77,27 +283,152 @@ const getOrderById = async (id, client = pool) => {
|
||||
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(
|
||||
JSON_AGG(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'label', pom.label,
|
||||
'color', pom.color
|
||||
'label', marker.label,
|
||||
'color', marker.color
|
||||
)
|
||||
ORDER BY pom.label
|
||||
) FILTER (WHERE pom.id IS NOT NULL),
|
||||
ORDER BY marker.label
|
||||
)
|
||||
FROM production_order_markers marker
|
||||
WHERE marker.production_order_id = po.id
|
||||
),
|
||||
'[]'::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
|
||||
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id
|
||||
WHERE po.id = $1
|
||||
GROUP BY po.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.order_reference,
|
||||
po.issue_date,
|
||||
po.expected_date,
|
||||
po.product_sku,
|
||||
po.product_description,
|
||||
po.quantity,
|
||||
po.unit,
|
||||
po.integration_status,
|
||||
po.notes,
|
||||
po.supplier,
|
||||
po.lot_code,
|
||||
po.roll_quantity,
|
||||
po.fabric_kg,
|
||||
po.rib_kg,
|
||||
po.yield_pieces_per_kg,
|
||||
po.created_at,
|
||||
po.updated_at,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'label', marker.label,
|
||||
'color', marker.color
|
||||
)
|
||||
ORDER BY marker.label
|
||||
)
|
||||
FROM production_order_markers marker
|
||||
WHERE marker.production_order_id = po.id
|
||||
),
|
||||
'[]'::json
|
||||
) as markers,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'id', component.id,
|
||||
'component_tiny_id', component.component_tiny_id,
|
||||
'component_sku', component.component_sku,
|
||||
'component_name', component.component_name,
|
||||
'quantity_per_unit', component.quantity_per_unit,
|
||||
'total_quantity', component.total_quantity,
|
||||
'unit', component.unit
|
||||
)
|
||||
ORDER BY component.id
|
||||
)
|
||||
FROM production_order_components component
|
||||
WHERE component.production_order_id = po.id
|
||||
),
|
||||
'[]'::json
|
||||
) as components,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'id', step.id,
|
||||
'step_number', step.step_number,
|
||||
'name', step.name,
|
||||
'start_date', step.start_date,
|
||||
'end_date', step.end_date,
|
||||
'status', step.status,
|
||||
'color', step.color
|
||||
)
|
||||
ORDER BY step.step_number NULLS LAST, step.id
|
||||
)
|
||||
FROM production_order_steps step
|
||||
WHERE step.production_order_id = po.id
|
||||
),
|
||||
'[]'::json
|
||||
) as steps
|
||||
FROM production_orders po
|
||||
`;
|
||||
|
||||
const listProductionOrders = async (filters = {}) => {
|
||||
const params = [];
|
||||
const where = [];
|
||||
@@ -126,35 +457,8 @@ const listProductionOrders = async (filters = {}) => {
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
po.id,
|
||||
po.tiny_id,
|
||||
po.number,
|
||||
po.status,
|
||||
po.order_reference,
|
||||
po.issue_date,
|
||||
po.expected_date,
|
||||
po.product_sku,
|
||||
po.product_description,
|
||||
po.quantity,
|
||||
po.unit,
|
||||
po.integration_status,
|
||||
po.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
|
||||
${baseProductionOrderSelect}
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
GROUP BY po.id
|
||||
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,
|
||||
@@ -285,6 +589,226 @@ 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
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -314,5 +838,6 @@ module.exports = {
|
||||
createProductionOrders,
|
||||
listProductionOrders,
|
||||
normalizeStatus,
|
||||
upsertTinyProductionOrderDetail,
|
||||
updateProductionOrderStatus
|
||||
};
|
||||
|
||||
@@ -24,6 +24,13 @@ const normalizeKey = (value) => normalizeText(value)
|
||||
|
||||
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
||||
|
||||
const normalizeUnit = (value) => {
|
||||
const unit = normalizeText(value).toLowerCase();
|
||||
if (['kg', 'quilo', 'quilos'].includes(unit)) return 'kg';
|
||||
if (['un', 'und', 'un.', 'unidade', 'unidades'].includes(unit)) return 'un.';
|
||||
return unit || 'un.';
|
||||
};
|
||||
|
||||
const mapReceipt = (row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
@@ -249,7 +256,10 @@ const listConsumptionReferenceRows = async () => {
|
||||
m.name AS material_name,
|
||||
r.color,
|
||||
r.general_yield,
|
||||
r.size_yields
|
||||
r.size_yields,
|
||||
r.consumption_quantity,
|
||||
r.consumption_unit,
|
||||
r.source
|
||||
FROM consumption_references r
|
||||
JOIN catalog_products p ON p.id = r.product_id
|
||||
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
||||
@@ -296,7 +306,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
listProjectDemandRows(),
|
||||
listConsumptionReferenceRows()
|
||||
]);
|
||||
const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference]));
|
||||
const referencesBySku = referenceRows.reduce((references, reference) => {
|
||||
const sku = normalizeSku(reference.product_sku);
|
||||
if (!sku) return references;
|
||||
references.set(sku, [...(references.get(sku) || []), reference]);
|
||||
return references;
|
||||
}, new Map());
|
||||
const needsByMaterial = new Map();
|
||||
|
||||
demandRows.forEach(row => {
|
||||
@@ -309,8 +324,8 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
|
||||
if (suggestedQuantity <= 0) return;
|
||||
|
||||
const reference = referencesBySku.get(productId);
|
||||
if (!reference) {
|
||||
const references = referencesBySku.get(productId) || [];
|
||||
if (!references.length) {
|
||||
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
|
||||
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
@@ -329,9 +344,35 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
return;
|
||||
}
|
||||
|
||||
references.forEach(reference => {
|
||||
const consumptionQuantity = Number(reference.consumption_quantity || 0);
|
||||
const consumptionUnit = normalizeUnit(reference.consumption_unit);
|
||||
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
||||
|
||||
if (consumptionQuantity > 0) {
|
||||
mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, {
|
||||
material: materialName,
|
||||
plannedKg: suggestedQuantity * consumptionQuantity,
|
||||
priority: 'Atenção',
|
||||
unit: consumptionUnit,
|
||||
source: reference.source || 'project_demand',
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity,
|
||||
consumptionQuantity,
|
||||
consumptionUnit
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const yieldPerKg = getReferenceYield(reference);
|
||||
if (!yieldPerKg) {
|
||||
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, {
|
||||
mergeNeedLine(needsByMaterial, `missing-yield:${productId}:${reference.material_product_id || 'material'}`, {
|
||||
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
@@ -350,13 +391,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
||||
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
|
||||
mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
|
||||
material: materialName,
|
||||
plannedKg: suggestedQuantity / yieldPerKg,
|
||||
priority: 'Atenção',
|
||||
unit: 'kg',
|
||||
source: 'project_demand',
|
||||
source: reference.source || 'project_demand',
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
@@ -368,17 +408,17 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
lots.forEach(lot => {
|
||||
if (lot.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(lot.product));
|
||||
if (need && need.unit === 'kg') need.stockKg += lot.quantity;
|
||||
const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(receipt.product));
|
||||
if (need && need.unit === 'kg') need.pendingKg += receipt.quantity;
|
||||
if (receipt.status !== 'pending') return;
|
||||
const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
|
||||
if (need) need.pendingKg += receipt.quantity;
|
||||
});
|
||||
|
||||
return Array.from(needsByMaterial.values()).map(need => {
|
||||
|
||||
@@ -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 { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
@@ -63,6 +63,16 @@ const formatQuantity = (value: number) => (
|
||||
}).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) => (
|
||||
statusStyles[String(status)] || {
|
||||
label: fallbackLabel || 'Em aberto',
|
||||
@@ -500,8 +510,10 @@ const ProductionOrders = () => {
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{paginatedOrders.map((order: ProductionOrderItem) => {
|
||||
const statusStyle = getStatusStyle(order.status, order.statusLabel);
|
||||
const showDetails = hasProductionOrderDetails(order);
|
||||
return (
|
||||
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
|
||||
<Fragment key={order.id}>
|
||||
<tr className="transition-colors hover:bg-dark-input/50">
|
||||
<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">{order.orderReference || '-'}</td>
|
||||
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">
|
||||
@@ -557,6 +569,77 @@ const ProductionOrders = () => {
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{showDetails && (
|
||||
<tr className="bg-dark-input/20">
|
||||
<td colSpan={9} className="px-6 pb-5 pt-1">
|
||||
<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]">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
|
||||
{order.components.length ? (
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-dark-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-bold">Produto</th>
|
||||
<th className="px-3 py-2 text-left font-bold">SKU</th>
|
||||
<th className="px-3 py-2 text-right font-bold">Qtd.</th>
|
||||
<th className="px-3 py-2 text-right font-bold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{order.components.map(component => (
|
||||
<tr key={component.id}>
|
||||
<td className="px-3 py-2 font-semibold text-dark-text">{component.componentName}</td>
|
||||
<td className="px-3 py-2 font-mono text-dark-muted">{component.componentSku || '-'}</td>
|
||||
<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>
|
||||
|
||||
@@ -92,6 +92,16 @@ const getAverageYield = (reference: ConsumptionReference) => {
|
||||
return reference.generalYield;
|
||||
};
|
||||
|
||||
const getReferenceSourceLabel = (reference: ConsumptionReference) => (
|
||||
reference.source === 'tiny_op' ? 'Tiny OP' : 'Manual'
|
||||
);
|
||||
|
||||
const formatConsumptionPerPiece = (reference: ConsumptionReference) => {
|
||||
if (!reference.consumptionQuantity) return '';
|
||||
const unit = reference.consumptionUnit || 'un.';
|
||||
return `${formatNumber(reference.consumptionQuantity, 4)} ${unit}/peça`;
|
||||
};
|
||||
|
||||
const Registrations = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
|
||||
@@ -663,6 +673,13 @@ const Registrations = () => {
|
||||
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
|
||||
</span>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
|
||||
reference.source === 'tiny_op'
|
||||
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
||||
: 'border-dark-border bg-dark-input text-dark-muted'
|
||||
}`}>
|
||||
{getReferenceSourceLabel(reference)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
@@ -680,10 +697,19 @@ const Registrations = () => {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} pç/kg</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
|
||||
<div className="text-lg font-bold text-emerald-300">
|
||||
{formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
{reference.consumptionQuantity
|
||||
? 'consumo'
|
||||
: Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
|
||||
</div>
|
||||
{reference.consumptionQuantity && getAverageYield(reference) ? (
|
||||
<div className="mt-1 text-[11px] font-semibold text-dark-muted">
|
||||
{formatNumber(getAverageYield(reference), 3)} pç/kg
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={() => void removeReference(reference)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
37
src/types.ts
37
src/types.ts
@@ -34,6 +34,26 @@ export interface ProductionOrderMarker {
|
||||
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 {
|
||||
id: number;
|
||||
tinyId: string;
|
||||
@@ -48,7 +68,16 @@ export interface ProductionOrderItem {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
integrationStatus: string;
|
||||
notes: string;
|
||||
supplier: string;
|
||||
lotCode: string;
|
||||
rollQuantity: number | null;
|
||||
fabricKg: number | null;
|
||||
ribKg: number | null;
|
||||
yieldPiecesPerKg: number | null;
|
||||
markers: ProductionOrderMarker[];
|
||||
components: ProductionOrderComponent[];
|
||||
steps: ProductionOrderStep[];
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
@@ -156,6 +185,10 @@ export interface ConsumptionReference {
|
||||
efficiencyPercent: number | null;
|
||||
ribGPerPiece: number | null;
|
||||
materialCostPerKg: number | null;
|
||||
consumptionQuantity: number | null;
|
||||
consumptionUnit: string;
|
||||
source: string;
|
||||
lastProductionOrderId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -197,6 +230,8 @@ export type ConsumptionReferencePayload = {
|
||||
efficiencyPercent?: number | string | null;
|
||||
ribGPerPiece?: number | string | null;
|
||||
materialCostPerKg?: number | string | null;
|
||||
consumptionQuantity?: number | string | null;
|
||||
consumptionUnit?: string | null;
|
||||
};
|
||||
|
||||
export type SupplyReceiptStatus = 'pending' | 'approved';
|
||||
@@ -275,6 +310,8 @@ export interface SupplyPurchaseNeed {
|
||||
quantitySold: number;
|
||||
stockQuantity: number;
|
||||
yieldPerKg?: number;
|
||||
consumptionQuantity?: number;
|
||||
consumptionUnit?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user