Compare commits
2 Commits
615e07ad32
...
800eb976ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800eb976ab | ||
|
|
c07938c94e |
@@ -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,
|
||||||
@@ -208,6 +245,10 @@ const initDB = async () => {
|
|||||||
efficiency_percent NUMERIC(7, 3),
|
efficiency_percent NUMERIC(7, 3),
|
||||||
rib_g_per_piece NUMERIC(14, 4),
|
rib_g_per_piece NUMERIC(14, 4),
|
||||||
material_cost_per_kg 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,
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -277,6 +318,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',
|
||||||
@@ -317,6 +385,14 @@ const initDB = async () => {
|
|||||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
`).catch(() => {});
|
`).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(`
|
await pool.query(`
|
||||||
ALTER TABLE consumption_references
|
ALTER TABLE consumption_references
|
||||||
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',
|
||||||
@@ -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_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);`);
|
||||||
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_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 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_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_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);`);
|
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 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));
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ const mapConsumptionReference = (row) => ({
|
|||||||
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
|
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
|
||||||
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece),
|
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),
|
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,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_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.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.color, r.general_yield, r.size_yields, r.size_areas,
|
||||||
r.gramature, r.efficiency_percent, r.rib_g_per_piece,
|
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
|
FROM consumption_references r
|
||||||
JOIN catalog_products p ON p.id = r.product_id
|
JOIN catalog_products p ON p.id = r.product_id
|
||||||
LEFT JOIN catalog_products m ON m.id = r.material_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
|
const calculatedYield = Object.values(sizeYields).length
|
||||||
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
|
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
|
||||||
: null;
|
: null;
|
||||||
|
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
|
||||||
|
|
||||||
if (!generalYield && !calculatedYield) {
|
if (!generalYield && !calculatedYield && !consumptionQuantity) {
|
||||||
const error = new Error('Informe o rendimento geral ou por tamanho.');
|
const error = new Error('Informe o rendimento ou consumo por peça.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -219,9 +225,9 @@ const createConsumptionReference = async (payload) => {
|
|||||||
INSERT INTO consumption_references (
|
INSERT INTO consumption_references (
|
||||||
product_id, material_product_id, color, general_yield, size_yields,
|
product_id, material_product_id, color, general_yield, size_yields,
|
||||||
size_areas, gramature, efficiency_percent, rib_g_per_piece,
|
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
|
RETURNING id
|
||||||
`, [
|
`, [
|
||||||
productId,
|
productId,
|
||||||
@@ -233,7 +239,9 @@ const createConsumptionReference = async (payload) => {
|
|||||||
normalizeNumber(payload.gramature),
|
normalizeNumber(payload.gramature),
|
||||||
normalizeNumber(payload.efficiencyPercent),
|
normalizeNumber(payload.efficiencyPercent),
|
||||||
normalizeNumber(payload.ribGPerPiece),
|
normalizeNumber(payload.ribGPerPiece),
|
||||||
normalizeNumber(payload.materialCostPerKg)
|
normalizeNumber(payload.materialCostPerKg),
|
||||||
|
consumptionQuantity,
|
||||||
|
normalizeText(payload.consumptionUnit) || null
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const references = await listConsumptionReferences();
|
const references = await listConsumptionReferences();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const sampleSpecs = {
|
|||||||
orderBy: ['updated_at', 'created_at', 'id']
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
},
|
},
|
||||||
consumption_references: {
|
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']
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
},
|
},
|
||||||
cutting_family_rules: {
|
cutting_family_rules: {
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,195 @@ 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 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 mapProductionOrderRow = (row) => {
|
||||||
const status = normalizeStatus(row.status);
|
const status = normalizeStatus(row.status);
|
||||||
|
|
||||||
@@ -56,7 +253,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 +283,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 +457,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 +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 updateProductionOrderStatus = async (id, status) => {
|
||||||
const normalizedStatus = normalizeStatus(status);
|
const normalizedStatus = normalizeStatus(status);
|
||||||
|
|
||||||
@@ -314,5 +838,6 @@ module.exports = {
|
|||||||
createProductionOrders,
|
createProductionOrders,
|
||||||
listProductionOrders,
|
listProductionOrders,
|
||||||
normalizeStatus,
|
normalizeStatus,
|
||||||
|
upsertTinyProductionOrderDetail,
|
||||||
updateProductionOrderStatus
|
updateProductionOrderStatus
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ const normalizeKey = (value) => normalizeText(value)
|
|||||||
|
|
||||||
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
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) => ({
|
const mapReceipt = (row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
@@ -249,7 +256,10 @@ const listConsumptionReferenceRows = async () => {
|
|||||||
m.name AS material_name,
|
m.name AS material_name,
|
||||||
r.color,
|
r.color,
|
||||||
r.general_yield,
|
r.general_yield,
|
||||||
r.size_yields
|
r.size_yields,
|
||||||
|
r.consumption_quantity,
|
||||||
|
r.consumption_unit,
|
||||||
|
r.source
|
||||||
FROM consumption_references r
|
FROM consumption_references r
|
||||||
JOIN catalog_products p ON p.id = r.product_id
|
JOIN catalog_products p ON p.id = r.product_id
|
||||||
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
||||||
@@ -296,7 +306,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
|||||||
listProjectDemandRows(),
|
listProjectDemandRows(),
|
||||||
listConsumptionReferenceRows()
|
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();
|
const needsByMaterial = new Map();
|
||||||
|
|
||||||
demandRows.forEach(row => {
|
demandRows.forEach(row => {
|
||||||
@@ -309,8 +324,8 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
|||||||
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
|
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
|
||||||
if (suggestedQuantity <= 0) return;
|
if (suggestedQuantity <= 0) return;
|
||||||
|
|
||||||
const reference = referencesBySku.get(productId);
|
const references = referencesBySku.get(productId) || [];
|
||||||
if (!reference) {
|
if (!references.length) {
|
||||||
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
|
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
|
||||||
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
|
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
|
||||||
plannedKg: suggestedQuantity,
|
plannedKg: suggestedQuantity,
|
||||||
@@ -329,56 +344,81 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const yieldPerKg = getReferenceYield(reference);
|
references.forEach(reference => {
|
||||||
if (!yieldPerKg) {
|
const consumptionQuantity = Number(reference.consumption_quantity || 0);
|
||||||
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, {
|
const consumptionUnit = normalizeUnit(reference.consumption_unit);
|
||||||
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
|
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
||||||
plannedKg: suggestedQuantity,
|
|
||||||
priority: 'Crítico',
|
if (consumptionQuantity > 0) {
|
||||||
unit: 'un.',
|
mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, {
|
||||||
source: 'project_demand',
|
material: materialName,
|
||||||
missingReference: true,
|
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}:${reference.material_product_id || 'material'}`, {
|
||||||
|
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
|
||||||
|
plannedKg: suggestedQuantity,
|
||||||
|
priority: 'Crítico',
|
||||||
|
unit: 'un.',
|
||||||
|
source: 'project_demand',
|
||||||
|
missingReference: true,
|
||||||
|
color: reference.color,
|
||||||
|
product: {
|
||||||
|
productId,
|
||||||
|
name: normalizeText(row.product_name),
|
||||||
|
suggestedQuantity,
|
||||||
|
quantitySold,
|
||||||
|
stockQuantity
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
|
||||||
|
material: materialName,
|
||||||
|
plannedKg: suggestedQuantity / yieldPerKg,
|
||||||
|
priority: 'Atenção',
|
||||||
|
unit: 'kg',
|
||||||
|
source: reference.source || 'project_demand',
|
||||||
color: reference.color,
|
color: reference.color,
|
||||||
product: {
|
product: {
|
||||||
productId,
|
productId,
|
||||||
name: normalizeText(row.product_name),
|
name: normalizeText(row.product_name),
|
||||||
suggestedQuantity,
|
suggestedQuantity,
|
||||||
quantitySold,
|
quantitySold,
|
||||||
stockQuantity
|
stockQuantity,
|
||||||
|
yieldPerKg
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
|
||||||
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
|
|
||||||
material: materialName,
|
|
||||||
plannedKg: suggestedQuantity / yieldPerKg,
|
|
||||||
priority: 'Atenção',
|
|
||||||
unit: 'kg',
|
|
||||||
source: 'project_demand',
|
|
||||||
color: reference.color,
|
|
||||||
product: {
|
|
||||||
productId,
|
|
||||||
name: normalizeText(row.product_name),
|
|
||||||
suggestedQuantity,
|
|
||||||
quantitySold,
|
|
||||||
stockQuantity,
|
|
||||||
yieldPerKg
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
lots.forEach(lot => {
|
lots.forEach(lot => {
|
||||||
if (lot.unit !== 'kg') return;
|
const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
|
||||||
const need = needsByMaterial.get(normalizeKey(lot.product));
|
if (need) need.stockKg += lot.quantity;
|
||||||
if (need && need.unit === 'kg') need.stockKg += lot.quantity;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
receipts.forEach(receipt => {
|
receipts.forEach(receipt => {
|
||||||
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
|
if (receipt.status !== 'pending') return;
|
||||||
const need = needsByMaterial.get(normalizeKey(receipt.product));
|
const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
|
||||||
if (need && need.unit === 'kg') need.pendingKg += receipt.quantity;
|
if (need) need.pendingKg += receipt.quantity;
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(needsByMaterial.values()).map(need => {
|
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 { 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>
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ const getAverageYield = (reference: ConsumptionReference) => {
|
|||||||
return reference.generalYield;
|
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 Registrations = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
|
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">
|
<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'}
|
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3>
|
<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">
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||||
@@ -680,10 +697,19 @@ const Registrations = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-start gap-3">
|
<div className="flex shrink-0 items-start gap-3">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} pç/kg</div>
|
<div className="text-lg font-bold text-emerald-300">
|
||||||
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
{formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
|
||||||
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
|
|
||||||
</div>
|
</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>
|
</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">
|
<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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
37
src/types.ts
37
src/types.ts
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -156,6 +185,10 @@ export interface ConsumptionReference {
|
|||||||
efficiencyPercent: number | null;
|
efficiencyPercent: number | null;
|
||||||
ribGPerPiece: number | null;
|
ribGPerPiece: number | null;
|
||||||
materialCostPerKg: number | null;
|
materialCostPerKg: number | null;
|
||||||
|
consumptionQuantity: number | null;
|
||||||
|
consumptionUnit: string;
|
||||||
|
source: string;
|
||||||
|
lastProductionOrderId: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -197,6 +230,8 @@ export type ConsumptionReferencePayload = {
|
|||||||
efficiencyPercent?: number | string | null;
|
efficiencyPercent?: number | string | null;
|
||||||
ribGPerPiece?: number | string | null;
|
ribGPerPiece?: number | string | null;
|
||||||
materialCostPerKg?: number | string | null;
|
materialCostPerKg?: number | string | null;
|
||||||
|
consumptionQuantity?: number | string | null;
|
||||||
|
consumptionUnit?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SupplyReceiptStatus = 'pending' | 'approved';
|
export type SupplyReceiptStatus = 'pending' | 'approved';
|
||||||
@@ -275,6 +310,8 @@ export interface SupplyPurchaseNeed {
|
|||||||
quantitySold: number;
|
quantitySold: number;
|
||||||
stockQuantity: number;
|
stockQuantity: number;
|
||||||
yieldPerKg?: number;
|
yieldPerKg?: number;
|
||||||
|
consumptionQuantity?: number;
|
||||||
|
consumptionUnit?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user