From a574b032e09c324b2dff0e0b18f9c4a7463d77c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 12 Aug 2026 14:09:29 -0300 Subject: [PATCH] Align cutting families with Olist production data --- backend/db.js | 16 +++++ backend/scripts/normalize_cut_workbook.py | 2 +- backend/services/cuttingSettingsService.js | 2 +- .../productionOrderCsvImportService.js | 60 +++++++++++++++---- backend/test/cuttingSettingsService.test.js | 5 +- .../productionOrderCsvImportService.test.js | 21 +++++++ src/analytics/cutting.test.ts | 4 +- src/analytics/cutting.ts | 23 ++++--- src/pages/Cutting.tsx | 5 +- src/pages/OlistSync.tsx | 20 ++++++- src/types.ts | 9 ++- 11 files changed, 137 insertions(+), 30 deletions(-) diff --git a/backend/db.js b/backend/db.js index 2e9b117..8f132e1 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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', diff --git a/backend/scripts/normalize_cut_workbook.py b/backend/scripts/normalize_cut_workbook.py index 7b37139..368b314 100644 --- a/backend/scripts/normalize_cut_workbook.py +++ b/backend/scripts/normalize_cut_workbook.py @@ -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", diff --git a/backend/services/cuttingSettingsService.js b/backend/services/cuttingSettingsService.js index bb40ba9..3aa3f55 100644 --- a/backend/services/cuttingSettingsService.js +++ b/backend/services/cuttingSettingsService.js @@ -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', diff --git a/backend/services/productionOrderCsvImportService.js b/backend/services/productionOrderCsvImportService.js index 19db628..aa34d28 100644 --- a/backend/services/productionOrderCsvImportService.js +++ b/backend/services/productionOrderCsvImportService.js @@ -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 })) }; }; diff --git a/backend/test/cuttingSettingsService.test.js b/backend/test/cuttingSettingsService.test.js index fefe73e..c3d9307 100644 --- a/backend/test/cuttingSettingsService.test.js +++ b/backend/test/cuttingSettingsService.test.js @@ -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 }); }); diff --git a/backend/test/productionOrderCsvImportService.test.js b/backend/test/productionOrderCsvImportService.test.js index 2028549..99d429a 100644 --- a/backend/test/productionOrderCsvImportService.test.js +++ b/backend/test/productionOrderCsvImportService.test.js @@ -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); diff --git a/src/analytics/cutting.test.ts b/src/analytics/cutting.test.ts index 16251e7..77313ad 100644 --- a/src/analytics/cutting.test.ts +++ b/src/analytics/cutting.test.ts @@ -45,7 +45,9 @@ const productionOrder = (overrides: Partial): ProductionOrd test('classifyCutFamily maps known product bases to internal cut families', () => { assert.equal(classifyCutFamily('BASE LISA CAMISETA').key, 'BLCS'); assert.equal(classifyCutFamily('BASE LISA CAMISETA OVER').key, 'BLOS'); - assert.equal(classifyCutFamily('BASE LISA MOLETOM CANGURU').key, 'BLPM'); + assert.equal(classifyCutFamily('BASE LISA MOLETOM CANGURU').key, 'BLMC'); + assert.equal(classifyCutFamily('BASE LISA CAMISETA PIMA').key, 'BLPM'); + assert.equal(classifyCutFamily('BASE LISA REGATA MACHAO').key, 'BLRM'); assert.equal(classifyCutFamily('BONÉ').key, 'OUTROS'); }); diff --git a/src/analytics/cutting.ts b/src/analytics/cutting.ts index 4bf6410..db19d59 100644 --- a/src/analytics/cutting.ts +++ b/src/analytics/cutting.ts @@ -70,10 +70,10 @@ export interface CutPlan { export const CUT_FAMILY_RULES: CutFamilyRule[] = [ { - key: 'BLPM', - label: 'Moletom', - materialLabel: 'BLPM', - keywords: ['MOLETOM'], + key: 'BLMC', + label: 'Moletom canguru', + materialLabel: 'BLMC', + keywords: ['MOLETOM CANGURU', 'MOLETOM'], unitsPerRoll: null }, { @@ -84,10 +84,17 @@ export const CUT_FAMILY_RULES: CutFamilyRule[] = [ unitsPerRoll: null }, { - key: 'BLMC', - label: 'Camiseta infantil', - materialLabel: 'BLMC', - keywords: ['INFANTIL', 'KIDS'], + key: 'BLPM', + label: 'Camiseta Pima', + materialLabel: 'BLPM', + keywords: ['CAMISETA PIMA', 'PIMA'], + unitsPerRoll: null + }, + { + key: 'BLRM', + label: 'Regata', + materialLabel: 'BLRM', + keywords: ['REGATA'], unitsPerRoll: null }, { diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 50f072e..aa6ad69 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -43,7 +43,7 @@ const issueLabels: Record = { }; const issueHelp: Record = { - missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC ou BLPM.', + missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC, BLPM ou BLRM.', missing_color: 'Nome do produto não tem uma cor clara para montar matriz de corte.', missing_size: 'Nome do produto não tem tamanho claro para montar matriz de corte.', missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.' @@ -61,6 +61,7 @@ const familyStyles: Record = { BLOS: 'border-sky-400/30 bg-sky-400/10 text-sky-300', BLMC: 'border-amber-400/30 bg-amber-400/10 text-amber-300', BLPM: 'border-purple-400/30 bg-purple-400/10 text-purple-300', + BLRM: 'border-rose-400/30 bg-rose-400/10 text-rose-300', OUTROS: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300' }; @@ -1005,7 +1006,7 @@ const Cutting = () => { {settingsSection === 'rules' && ( -
+
{CUT_FAMILY_RULES.map(rule => (
-
- {(['BLCS', 'BLOS', 'BLMC', 'BLPM'] as const).map(familyKey => { +
+ {(['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM'] as const).map(familyKey => { const recommendation = importData?.recommendations.find(item => item.familyKey === familyKey); return
-

{familyKey}

+

{familyKey} · {yieldFamilyLabels[familyKey]}

{recommendation?.recommendedUnitsPerRoll ? `${Math.round(recommendation.recommendedUnitsPerRoll).toLocaleString('pt-BR')} un./rolo` : 'Sem sugestão'}

{recommendation ? `${recommendation.sampleCount.toLocaleString('pt-BR')} OPs · ${recommendation.yieldSampleCount.toLocaleString('pt-BR')} c/ rendimento` : 'Nenhuma OP importada'}

; })}
+ {!!importData?.supplierRecommendations.length &&
+

Referências por fornecedor

+
+ {importData.supplierRecommendations.filter(item => item.recommendedUnitsPerRoll).map(item => {item.familyKey} · {item.supplier}: {Math.round(item.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo)} +
+
}
{!importData?.runs.length &&

Nenhum CSV de OPs finalizadas importado.

} {importData?.runs.map(run =>
diff --git a/src/types.ts b/src/types.ts index 2128ac3..d9d9aee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -126,7 +126,7 @@ export interface CreateProductionOrdersResult { }>; } -export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS'; +export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'BLRM' | 'OUTROS'; export interface CutProductOverride { familyKey?: CutFamilyKey | ''; @@ -272,7 +272,7 @@ export interface ProductionOrderImportRun { } export interface ProductionYieldRecommendation { - familyKey: 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM'; + familyKey: 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'BLRM'; sampleCount: number; yieldSampleCount: number; rollSampleCount: number; @@ -283,9 +283,14 @@ export interface ProductionYieldRecommendation { updatedAt: string; } +export interface ProductionYieldSupplierRecommendation extends ProductionYieldRecommendation { + supplier: string; +} + export interface ProductionOrderImportData { runs: ProductionOrderImportRun[]; recommendations: ProductionYieldRecommendation[]; + supplierRecommendations: ProductionYieldSupplierRecommendation[]; } export type CatalogProductPayload = {