Derive consumption references from Tiny OP sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m16s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m16s
This commit is contained in:
@@ -245,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
|
||||
);
|
||||
@@ -381,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',
|
||||
@@ -497,6 +509,17 @@ const initDB = async () => {
|
||||
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);`);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -65,6 +65,157 @@ const normalizeInteger = (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 || '',
|
||||
@@ -640,11 +791,15 @@ const upsertTinyProductionOrderDetail = async (payload = {}) => {
|
||||
]);
|
||||
}
|
||||
|
||||
const referenceSync = await upsertConsumptionReferencesFromComponents(client, orderId, order, components);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return {
|
||||
order: await getOrderById(orderId),
|
||||
componentCount: components.length,
|
||||
stepCount: steps.length
|
||||
stepCount: steps.length,
|
||||
referenceCount: referenceSync.referenceCount,
|
||||
skippedReferenceCount: referenceSync.skippedReferenceCount
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
@@ -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,56 +344,81 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const yieldPerKg = getReferenceYield(reference);
|
||||
if (!yieldPerKg) {
|
||||
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, {
|
||||
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
unit: 'un.',
|
||||
source: 'project_demand',
|
||||
missingReference: true,
|
||||
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}:${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,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
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 => {
|
||||
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 => {
|
||||
|
||||
Reference in New Issue
Block a user