Align cutting families with Olist production data
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1m17s

This commit is contained in:
Cauê Faleiros
2026-08-12 14:09:29 -03:00
parent e7edb5c483
commit a574b032e0
11 changed files with 137 additions and 30 deletions

View File

@@ -489,6 +489,22 @@ const initDB = async () => {
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_yield_supplier_recommendations (
family_key VARCHAR(20) NOT NULL,
supplier VARCHAR(255) NOT NULL,
sample_count INTEGER NOT NULL DEFAULT 0,
yield_sample_count INTEGER NOT NULL DEFAULT 0,
roll_sample_count INTEGER NOT NULL DEFAULT 0,
avg_yield_pieces_per_kg NUMERIC(14, 4),
avg_kg_per_roll NUMERIC(14, 4),
recommended_units_per_roll NUMERIC(14, 4),
last_completed_date DATE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (family_key, supplier)
);
`);
await pool.query(`
ALTER TABLE production_orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',

View File

@@ -42,7 +42,7 @@ DATE_NUM_FMT_IDS = {
57,
}
CUT_SHEETS = {"BLCS", "BLMC", "BLOS", "BLPM"}
CUT_SHEETS = {"BLCS", "BLMC", "BLOS", "BLPM", "BLRM"}
COLOR_WORDS = {
"amarelo",
"azul",

View File

@@ -1,6 +1,6 @@
const { pool } = require('../db');
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM'];
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM'];
const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
const PRODUCT_TYPE_KEYS = [
'finished_apparel',

View File

@@ -1,6 +1,6 @@
const { pool } = require('../db');
const FAMILY_KEYS = new Set(['BLCS', 'BLOS', 'BLMC', 'BLPM']);
const FAMILY_KEYS = new Set(['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM']);
const REQUIRED_HEADERS = ['id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao', 'data_conclusao', 'situacao', 'observacoes'];
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
@@ -76,12 +76,18 @@ const extractObservationFields = (notes) => {
const raw = String(notes || '');
const lineValue = (pattern) => raw.match(pattern)?.[1]?.trim() || '';
const supplier = lineValue(/(?:^|\n)\s*FORNECEDOR\s*:\s*([^\r\n]+)/i);
const lotCode = lineValue(/(?:^|\n)\s*LOTE\s*:\s*([^\r\n]+)/i);
const yieldPiecesPerKg = normalizeNumber(raw.match(/RENDIMENTO\s*:\s*([\d.,]+)/i)?.[1]);
const explicitRolls = normalizeNumber(raw.match(/(?:QUANTIDADE\s+DE\s+ROLOS|ROLOS|BOBINAS)\s*:\s*([\d.,]+)/i)?.[1]);
const lotCode = lineValue(/(?:^|\n)\s*(?:-\s*)?LOTE\s*:?[\t ]*([^\r\n]+)/i);
const yieldMatch = raw.match(/RENDIMENTO\s*:\s*([\d.,]+)(?:\s*[-–]\s*([\d.,]+)\s*KG)?/i);
const yieldPiecesPerKg = normalizeNumber(yieldMatch?.[1]);
const bobbinMatch = raw.match(/(?:QUANTIDADE\s+DE\s+ROLOS|ROLOS|BOBINAS)\s*:\s*(\d+(?:[.,]\d+)?)(?:\s*\(\s*X\s*(\d+(?:[.,]\d+)?)\s*\))?(?:\s*[-–]\s*([\d.,]+)\s*KG)?/i);
const rollBase = normalizeNumber(bobbinMatch?.[1]);
const rollMultiplier = normalizeNumber(bobbinMatch?.[2]) || 1;
const explicitRolls = rollBase ? rollBase * rollMultiplier : null;
const fabricKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+MALHA|QUILOS?\s+MALHA)\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1])
|| normalizeNumber(raw.match(/(?:ROLOS|BOBINAS)\s*:\s*[\d.,]+\s*[-–]\s*([\d.,]+)\s*KG/i)?.[1]);
const ribKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+(?:GOLA|RIBANA|VIEZ(?:\s*\([^)]*\))?))\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]);
|| normalizeNumber(bobbinMatch?.[3])
|| normalizeNumber(yieldMatch?.[2])
|| normalizeNumber(raw.match(/(?:^|\n)\s*KG\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]);
const ribKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+(?:GOLA|RIBANA|VIEZ(?:\s*\([^)]*\))?)|RIBANA)\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]);
return { supplier, lotCode, yieldPiecesPerKg, rollQuantity: explicitRolls, fabricKg, ribKg };
};
@@ -165,6 +171,7 @@ const parseFinalizedProductionOrderCsv = (csv) => {
const refreshYieldRecommendations = async (client) => {
await client.query('DELETE FROM production_yield_recommendations;');
await client.query('DELETE FROM production_yield_supplier_recommendations;');
await client.query(`
INSERT INTO production_yield_recommendations (
family_key, sample_count, yield_sample_count, roll_sample_count,
@@ -178,21 +185,44 @@ const refreshYieldRecommendations = async (client) => {
COUNT(*) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0)::int,
AVG(yield_pieces_per_kg),
AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0),
AVG(yield_pieces_per_kg) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0)
* AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0),
AVG(yield_pieces_per_kg * (fabric_kg / roll_quantity)) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0),
MAX(completed_date),
CURRENT_TIMESTAMP
FROM production_orders
WHERE status = 'finished'
AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM')
AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM')
GROUP BY split_part(product_sku, '.', 1);
`);
await client.query(`
INSERT INTO production_yield_supplier_recommendations (
family_key, supplier, sample_count, yield_sample_count, roll_sample_count,
avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll,
last_completed_date, updated_at
)
SELECT
split_part(product_sku, '.', 1) AS family_key,
supplier,
COUNT(*)::int,
COUNT(yield_pieces_per_kg)::int,
COUNT(*) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0)::int,
AVG(yield_pieces_per_kg),
AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0),
AVG(yield_pieces_per_kg * (fabric_kg / roll_quantity)) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0),
MAX(completed_date),
CURRENT_TIMESTAMP
FROM production_orders
WHERE status = 'finished'
AND supplier IS NOT NULL
AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM')
GROUP BY split_part(product_sku, '.', 1), supplier;
`);
};
const listProductionOrderImportData = async () => {
const [runs, recommendations] = await Promise.all([
const [runs, recommendations, supplierRecommendations] = await Promise.all([
pool.query(`SELECT id, filename, status, total_rows, imported_count, updated_count, skipped_count, failed_count, error_message, started_at, completed_at FROM production_order_import_runs ORDER BY started_at DESC LIMIT 10;`),
pool.query(`SELECT family_key, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_recommendations ORDER BY family_key;`)
pool.query(`SELECT family_key, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_recommendations ORDER BY family_key;`),
pool.query(`SELECT family_key, supplier, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_supplier_recommendations ORDER BY family_key, supplier;`)
]);
return {
runs: runs.rows.map(row => ({
@@ -207,6 +237,14 @@ const listProductionOrderImportData = async () => {
avgKgPerRoll: row.avg_kg_per_roll === null ? null : Number(row.avg_kg_per_roll),
recommendedUnitsPerRoll: row.recommended_units_per_roll === null ? null : Number(row.recommended_units_per_roll),
lastCompletedDate: row.last_completed_date || null, updatedAt: row.updated_at
})),
supplierRecommendations: supplierRecommendations.rows.map(row => ({
familyKey: row.family_key, supplier: row.supplier, sampleCount: Number(row.sample_count),
yieldSampleCount: Number(row.yield_sample_count), rollSampleCount: Number(row.roll_sample_count),
avgYieldPiecesPerKg: row.avg_yield_pieces_per_kg === null ? null : Number(row.avg_yield_pieces_per_kg),
avgKgPerRoll: row.avg_kg_per_roll === null ? null : Number(row.avg_kg_per_roll),
recommendedUnitsPerRoll: row.recommended_units_per_roll === null ? null : Number(row.recommended_units_per_roll),
lastCompletedDate: row.last_completed_date || null, updatedAt: row.updated_at
}))
};
};

View File

@@ -11,6 +11,7 @@ const {
test('normalizeFamilyKey accepts only known cut families', () => {
assert.equal(normalizeFamilyKey('blcs'), 'BLCS');
assert.equal(normalizeFamilyKey(' BLOS '), 'BLOS');
assert.equal(normalizeFamilyKey('blrm'), 'BLRM');
assert.equal(normalizeFamilyKey('OUTROS'), '');
assert.equal(normalizeFamilyKey('unknown'), '');
});
@@ -21,10 +22,12 @@ test('normalizeFamilyYields keeps positive numeric yield rules', () => {
BLOS: 0,
BLMC: -1,
BLPM: '12.5',
BLRM: '17',
OUTROS: 99
}), {
BLCS: 50,
BLPM: 12.5
BLPM: 12.5,
BLRM: 17
});
});

View File

@@ -16,6 +16,27 @@ test('extractObservationFields reads Brazilian production notes without using th
});
});
test('extractObservationFields supports the validated Olist bobbin multiplier, lot, kg and ribana formats', () => {
const result = extractObservationFields('FORNECEDOR: ALTUS\nBOBINAS: 07(X2) - 287KG\nRENDIMENTO: 5,55 - 494KG\n- Lote 01\nRIBANA: 3,59');
assert.deepEqual(result, {
supplier: 'ALTUS',
lotCode: '01',
yieldPiecesPerKg: 5.55,
rollQuantity: 14,
fabricKg: 287,
ribKg: 3.59
});
});
test('extractObservationFields reads standalone KG lines for fabric weight', () => {
const result = extractObservationFields('FORNECEDOR: GOL\nBOBINAS: 06\nKG : 204,38\nRENDIMENTO: 5,0\nLote 02');
assert.equal(result.rollQuantity, 6);
assert.equal(result.fabricKg, 204.38);
assert.equal(result.lotCode, '02');
});
test('parseFinalizedProductionOrderCsv supports multiline observations and skips non-finalized OPs', () => {
const csv = `id_op,numero_op,sku_produto,descricao_produto,quantidade_produzida,data_emissao,data_conclusao,situacao,observacoes,pedido_relacionado\n1,10,BLCS.PTO.G,Camiseta preta G,100,2026-08-01T00:00:00.000Z,2026-08-02T00:00:00.000Z,Finalizada,"FORNECEDOR: NEXSTAR\nRENDIMENTO: 5,44\nQUANTIDADE DE ROLOS: 30\nQUILOS DE MALHA: 577,90",PLANO-1\n2,11,BLCS.PTO.M,Camiseta preta M,100,2026-08-01,2026-08-02,Em andamento,Teste,`;
const result = parseFinalizedProductionOrderCsv(csv);