Import finalized Olist production orders
This commit is contained in:
@@ -454,6 +454,41 @@ const initDB = async () => {
|
||||
ADD COLUMN IF NOT EXISTS yield_pieces_per_kg NUMERIC(14, 4);
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ADD COLUMN IF NOT EXISTS completed_date DATE;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS production_order_import_runs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'running',
|
||||
total_rows INTEGER NOT NULL DEFAULT 0,
|
||||
imported_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS production_yield_recommendations (
|
||||
family_key VARCHAR(20) PRIMARY KEY,
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -627,6 +662,8 @@ const initDB = async () => {
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`);
|
||||
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_completed_date ON production_orders (completed_date DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_import_runs_started_at ON production_order_import_runs (started_at DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`);
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
requestOlistSyncStop,
|
||||
startOlistSync
|
||||
} = require('../services/olistService');
|
||||
const { importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -48,6 +49,22 @@ router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listProductionOrderImportData());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/olist/runs/:runId', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await getOlistRunDetails(req.params.runId, {
|
||||
|
||||
@@ -19,7 +19,7 @@ const createApp = () => {
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
app.use(bodyParser.json({ limit: '25mb' }));
|
||||
|
||||
app.use('/api', authRoutes);
|
||||
app.use('/api', dataRoutes);
|
||||
|
||||
268
backend/services/productionOrderCsvImportService.js
Normal file
268
backend/services/productionOrderCsvImportService.js
Normal file
@@ -0,0 +1,268 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const FAMILY_KEYS = new Set(['BLCS', 'BLOS', 'BLMC', 'BLPM']);
|
||||
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();
|
||||
|
||||
const normalizeNumber = (value) => {
|
||||
const normalized = String(value || '').trim().replace(/\s/g, '');
|
||||
if (!normalized) return null;
|
||||
const decimal = normalized.includes(',')
|
||||
? normalized.replace(/\./g, '').replace(',', '.')
|
||||
: normalized;
|
||||
const number = Number(decimal);
|
||||
return Number.isFinite(number) && number > 0 ? number : null;
|
||||
};
|
||||
|
||||
const normalizeDate = (value) => {
|
||||
const raw = String(value || '').trim();
|
||||
const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
|
||||
const br = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
|
||||
return br ? `${br[3]}-${br[2].padStart(2, '0')}-${br[1].padStart(2, '0')}` : null;
|
||||
};
|
||||
|
||||
const normalizeHeader = (header) => String(header || '')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_');
|
||||
|
||||
const parseCsv = (csv) => {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
|
||||
for (let index = 0; index < csv.length; index += 1) {
|
||||
const character = csv[index];
|
||||
const next = csv[index + 1];
|
||||
if (character === '"') {
|
||||
if (quoted && next === '"') {
|
||||
field += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
quoted = !quoted;
|
||||
}
|
||||
} else if (character === ',' && !quoted) {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if ((character === '\n' || character === '\r') && !quoted) {
|
||||
if (character === '\r' && next === '\n') index += 1;
|
||||
row.push(field);
|
||||
if (row.some(value => value.length > 0)) rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
} else {
|
||||
field += character;
|
||||
}
|
||||
}
|
||||
|
||||
row.push(field);
|
||||
if (row.some(value => value.length > 0)) rows.push(row);
|
||||
if (quoted) throw new Error('CSV possui aspas não finalizadas.');
|
||||
return rows;
|
||||
};
|
||||
|
||||
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 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]);
|
||||
|
||||
return { supplier, lotCode, yieldPiecesPerKg, rollQuantity: explicitRolls, fabricKg, ribKg };
|
||||
};
|
||||
|
||||
const parseFinalizedProductionOrderCsv = (csv) => {
|
||||
if (typeof csv !== 'string' || !csv.trim()) {
|
||||
const error = new Error('Selecione um arquivo CSV de OPs finalizadas.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rows = parseCsv(csv);
|
||||
if (rows.length < 2) {
|
||||
const error = new Error('O CSV não possui OPs para importar.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const headers = rows[0].map(normalizeHeader);
|
||||
const missingHeaders = REQUIRED_HEADERS.filter(header => !headers.includes(header));
|
||||
if (missingHeaders.length) {
|
||||
const error = new Error(`CSV sem colunas obrigatórias: ${missingHeaders.join(', ')}.`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const headerIndex = Object.fromEntries(headers.map((header, index) => [header, index]));
|
||||
const records = [];
|
||||
const issues = [];
|
||||
|
||||
rows.slice(1).forEach((row, rowOffset) => {
|
||||
const line = rowOffset + 2;
|
||||
const value = (header) => row[headerIndex[header]] || '';
|
||||
const tinyId = normalizeText(value('id_op'));
|
||||
const number = normalizeText(value('numero_op'));
|
||||
const productSku = normalizeText(value('sku_produto')).toUpperCase();
|
||||
const productDescription = normalizeText(value('descricao_produto'));
|
||||
const quantity = normalizeNumber(value('quantidade_produzida'));
|
||||
const status = normalizeText(value('situacao')).toLowerCase();
|
||||
const notes = String(value('observacoes') || '').trim();
|
||||
|
||||
if (!tinyId || !number || !productSku || !productDescription || !quantity) {
|
||||
issues.push({ line, message: 'OP sem identificação, SKU, produto ou quantidade válida.' });
|
||||
return;
|
||||
}
|
||||
if (!/^finalizad[ao]$/.test(status)) {
|
||||
issues.push({ line, number, message: 'Ignorada: a OP não está Finalizada.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const familyKey = productSku.split('.')[0];
|
||||
const extracted = extractObservationFields(notes);
|
||||
records.push({
|
||||
tinyId,
|
||||
number,
|
||||
productSku,
|
||||
productDescription,
|
||||
quantity,
|
||||
issueDate: normalizeDate(value('data_emissao')),
|
||||
completedDate: normalizeDate(value('data_conclusao')),
|
||||
orderReference: normalizeText(value('pedido_relacionado')),
|
||||
notes,
|
||||
familyKey: FAMILY_KEYS.has(familyKey) ? familyKey : null,
|
||||
...extracted
|
||||
});
|
||||
});
|
||||
|
||||
return { records, issues, totalRows: rows.length - 1 };
|
||||
};
|
||||
|
||||
const refreshYieldRecommendations = async (client) => {
|
||||
await client.query('DELETE FROM production_yield_recommendations;');
|
||||
await client.query(`
|
||||
INSERT INTO production_yield_recommendations (
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
split_part(product_sku, '.', 1) AS family_key,
|
||||
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) 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),
|
||||
MAX(completed_date),
|
||||
CURRENT_TIMESTAMP
|
||||
FROM production_orders
|
||||
WHERE status = 'finished'
|
||||
AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM')
|
||||
GROUP BY split_part(product_sku, '.', 1);
|
||||
`);
|
||||
};
|
||||
|
||||
const listProductionOrderImportData = async () => {
|
||||
const [runs, recommendations] = 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;`)
|
||||
]);
|
||||
return {
|
||||
runs: runs.rows.map(row => ({
|
||||
id: row.id, filename: row.filename, status: row.status, totalRows: row.total_rows,
|
||||
imported: row.imported_count, updated: row.updated_count, skipped: row.skipped_count,
|
||||
failed: row.failed_count, errorMessage: row.error_message || '', startedAt: row.started_at,
|
||||
completedAt: row.completed_at
|
||||
})),
|
||||
recommendations: recommendations.rows.map(row => ({
|
||||
familyKey: row.family_key, 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
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
const importFinalizedProductionOrderCsv = async ({ filename, csv }) => {
|
||||
const parsed = parseFinalizedProductionOrderCsv(csv);
|
||||
const client = await pool.connect();
|
||||
const safeFilename = normalizeText(filename) || 'ops-finalizadas.csv';
|
||||
let runId;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const run = await client.query(`
|
||||
INSERT INTO production_order_import_runs (filename, status, total_rows, skipped_count)
|
||||
VALUES ($1, 'running', $2, $3)
|
||||
RETURNING id;
|
||||
`, [safeFilename, parsed.totalRows, parsed.issues.length]);
|
||||
runId = run.rows[0].id;
|
||||
|
||||
for (const record of parsed.records) {
|
||||
try {
|
||||
await client.query('SAVEPOINT production_order_import_record;');
|
||||
const existing = await client.query('SELECT id FROM production_orders WHERE tiny_id = $1 OR number = $2 LIMIT 1;', [record.tinyId, record.number]);
|
||||
const payload = JSON.stringify({ source: 'olist_finalized_op_csv', importedAt: new Date().toISOString(), record });
|
||||
if (existing.rows[0]) {
|
||||
await client.query(`
|
||||
UPDATE production_orders SET tiny_id = $1, number = $2, status = 'finished', order_reference = NULLIF($3, ''), issue_date = $4,
|
||||
completed_date = $5, product_sku = $6, product_description = $7, quantity = $8, unit = 'UN', integration_status = 'Olist CSV',
|
||||
notes = NULLIF($9, ''), supplier = NULLIF($10, ''), lot_code = NULLIF($11, ''), roll_quantity = $12, fabric_kg = $13, rib_kg = $14,
|
||||
yield_pieces_per_kg = $15, tiny_payload = $16::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $17;
|
||||
`, [record.tinyId, record.number, record.orderReference, record.issueDate, record.completedDate, record.productSku, record.productDescription, record.quantity, record.notes, record.supplier, record.lotCode, record.rollQuantity, record.fabricKg, record.ribKg, record.yieldPiecesPerKg, payload, existing.rows[0].id]);
|
||||
updated += 1;
|
||||
} else {
|
||||
await client.query(`
|
||||
INSERT INTO production_orders (tiny_id, number, status, order_reference, issue_date, completed_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 ($1, $2, 'finished', NULLIF($3, ''), $4, $5, $6, $7, $8, 'UN', 'Olist CSV', NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), $12, $13, $14, $15, $16::jsonb);
|
||||
`, [record.tinyId, record.number, record.orderReference, record.issueDate, record.completedDate, record.productSku, record.productDescription, record.quantity, record.notes, record.supplier, record.lotCode, record.rollQuantity, record.fabricKg, record.ribKg, record.yieldPiecesPerKg, payload]);
|
||||
imported += 1;
|
||||
}
|
||||
await client.query('RELEASE SAVEPOINT production_order_import_record;');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK TO SAVEPOINT production_order_import_record;');
|
||||
failed += 1;
|
||||
parsed.issues.push({ number: record.number, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
await refreshYieldRecommendations(client);
|
||||
await client.query(`
|
||||
UPDATE production_order_import_runs
|
||||
SET status = $1, imported_count = $2, updated_count = $3, skipped_count = $4, failed_count = $5, error_message = $6, completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7;
|
||||
`, [failed ? 'completed_with_errors' : 'completed', imported, updated, parsed.issues.length, failed, parsed.issues.slice(0, 20).map(issue => `OP ${issue.number || `linha ${issue.line}`}: ${issue.message}`).join('\n') || null, runId]);
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return { runId, totalRows: parsed.totalRows, imported, updated, skipped: parsed.issues.length, failed, issues: parsed.issues.slice(0, 20), ...(await listProductionOrderImportData()) };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
extractObservationFields,
|
||||
importFinalizedProductionOrderCsv,
|
||||
listProductionOrderImportData,
|
||||
parseCsv,
|
||||
parseFinalizedProductionOrderCsv
|
||||
};
|
||||
@@ -254,6 +254,7 @@ const mapProductionOrderRow = (row) => {
|
||||
orderReference: row.order_reference || '',
|
||||
issueDate: formatDate(row.issue_date),
|
||||
expectedDate: formatDate(row.expected_date),
|
||||
completedDate: formatDate(row.completed_date),
|
||||
productSku: row.product_sku || '',
|
||||
productDescription: row.product_description || '',
|
||||
quantity: Number(row.quantity || 0),
|
||||
@@ -284,6 +285,7 @@ const getOrderById = async (id, client = pool) => {
|
||||
po.order_reference,
|
||||
po.issue_date,
|
||||
po.expected_date,
|
||||
po.completed_date,
|
||||
po.product_sku,
|
||||
po.product_description,
|
||||
po.quantity,
|
||||
@@ -366,6 +368,7 @@ const baseProductionOrderSelect = `
|
||||
po.order_reference,
|
||||
po.issue_date,
|
||||
po.expected_date,
|
||||
po.completed_date,
|
||||
po.product_sku,
|
||||
po.product_description,
|
||||
po.quantity,
|
||||
|
||||
30
backend/test/productionOrderCsvImportService.test.js
Normal file
30
backend/test/productionOrderCsvImportService.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { extractObservationFields, parseFinalizedProductionOrderCsv } = require('../services/productionOrderCsvImportService');
|
||||
|
||||
test('extractObservationFields reads Brazilian production notes without using the OP quantity as rolls', () => {
|
||||
const result = extractObservationFields(`MALHA\nFORNECEDOR: TECEBEM\nBOBINAS: 10 - 209,94KG\nRENDIMENTO: 3,6\nLOTE: 0245163`);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
supplier: 'TECEBEM',
|
||||
lotCode: '0245163',
|
||||
yieldPiecesPerKg: 3.6,
|
||||
rollQuantity: 10,
|
||||
fabricKg: 209.94,
|
||||
ribKg: null
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
assert.equal(result.totalRows, 2);
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.equal(result.issues.length, 1);
|
||||
assert.equal(result.records[0].familyKey, 'BLCS');
|
||||
assert.equal(result.records[0].rollQuantity, 30);
|
||||
assert.equal(result.records[0].fabricKg, 577.9);
|
||||
assert.equal(result.records[0].yieldPiecesPerKg, 5.44);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -314,6 +314,24 @@ export const fetchOlistRunDetails = async (runId: number, eventsPage = 1, produc
|
||||
return data as OlistRunDetails;
|
||||
};
|
||||
|
||||
export const fetchProductionOrderImportData = async (): Promise<ProductionOrderImportData> => {
|
||||
const response = await authFetch('/olist/production-order-imports', { cache: 'no-store' });
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar as importações de OP.');
|
||||
return data as ProductionOrderImportData;
|
||||
};
|
||||
|
||||
export const importFinalizedProductionOrderCsv = async (filename: string, csv: string): Promise<ProductionOrderImportData & { runId: number; totalRows: number; imported: number; updated: number; skipped: number; failed: number; issues: Array<{ line?: number; number?: string; message: string }> }> => {
|
||||
const response = await authFetch('/olist/production-order-imports', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename, csv })
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(data?.error || 'Não foi possível importar as OPs finalizadas.');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const saveCatalogCategory = async (payload: CatalogCategoryPayload): Promise<CatalogCategory> => {
|
||||
const response = await authFetch('/catalog/categories', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -6,8 +6,8 @@ import PaginationControls from '../components/PaginationControls';
|
||||
import ProductColorBadge from '../components/ProductColorBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
|
||||
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData } from '../types';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||
@@ -151,6 +151,7 @@ const Cutting = () => {
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
|
||||
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
||||
const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
|
||||
@@ -195,6 +196,16 @@ const Cutting = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
void fetchProductionOrderImportData().then(data => {
|
||||
if (isMounted) setYieldRecommendations(data.recommendations);
|
||||
}).catch(() => {
|
||||
if (isMounted) setYieldRecommendations([]);
|
||||
});
|
||||
return () => { isMounted = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -1010,6 +1021,10 @@ const Cutting = () => {
|
||||
placeholder="un./rolo"
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary"
|
||||
/>
|
||||
{yieldRecommendations.find(item => item.familyKey === rule.key)?.recommendedUnitsPerRoll && (() => {
|
||||
const recommendation = yieldRecommendations.find(item => item.familyKey === rule.key)!;
|
||||
return <div className="mt-2 flex items-center justify-between gap-2 text-[11px] font-semibold text-dark-muted"><span>{Math.round(recommendation.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo · {recommendation.sampleCount} OPs</span><button type="button" onClick={(event) => { event.preventDefault(); updateFamilyYield(rule.key, String(Math.round(recommendation.recommendedUnitsPerRoll!))); }} className="text-brand-primary hover:underline cursor-pointer">Usar sugestão</button></div>;
|
||||
})()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, CloudDownload, Database, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
|
||||
import { fetchOlistRunDetails, fetchOlistStatus, getOlistAuthorizationUrl, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
|
||||
import type { OlistRunDetails, OlistStatus, OlistSyncRun } from '../types';
|
||||
import { AlertTriangle, CheckCircle2, CloudDownload, Database, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
|
||||
import { fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
|
||||
import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types';
|
||||
|
||||
const formatDateTime = (value: string | null) => {
|
||||
if (!value) return '—';
|
||||
@@ -48,6 +48,9 @@ const OlistSync = () => {
|
||||
const [runDetails, setRunDetails] = useState<OlistRunDetails | null>(null);
|
||||
const [detailsPages, setDetailsPages] = useState({ events: 1, products: 1 });
|
||||
const [isDetailsLoading, setIsDetailsLoading] = useState(false);
|
||||
const [importData, setImportData] = useState<ProductionOrderImportData | null>(null);
|
||||
const [isImportingOrders, setIsImportingOrders] = useState(false);
|
||||
const [importMessage, setImportMessage] = useState('');
|
||||
|
||||
const loadStatus = useCallback(async (initial = false) => {
|
||||
if (initial) setIsLoading(true);
|
||||
@@ -83,6 +86,19 @@ const OlistSync = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadImportData = useCallback(async () => {
|
||||
try {
|
||||
setImportData(await fetchProductionOrderImportData());
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar as importações de OP.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => void loadImportData(), 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadImportData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRunId || runDetails?.run.status !== 'running') return;
|
||||
const intervalId = window.setInterval(() => void loadRunDetails(selectedRunId, detailsPages), 5_000);
|
||||
@@ -131,6 +147,25 @@ const OlistSync = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const importOrders = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
setError('Selecione um arquivo CSV de OPs finalizadas.');
|
||||
return;
|
||||
}
|
||||
setIsImportingOrders(true);
|
||||
setImportMessage('');
|
||||
try {
|
||||
const result = await importFinalizedProductionOrderCsv(file.name, await file.text());
|
||||
setImportData({ runs: result.runs, recommendations: result.recommendations });
|
||||
setImportMessage(`${result.imported.toLocaleString('pt-BR')} novas · ${result.updated.toLocaleString('pt-BR')} atualizadas · ${result.skipped.toLocaleString('pt-BR')} ignoradas.`);
|
||||
} catch (actionError) {
|
||||
setError(actionError instanceof Error ? actionError.message : 'Não foi possível importar as OPs finalizadas.');
|
||||
} finally {
|
||||
setIsImportingOrders(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openRunDetails = (runId: number) => {
|
||||
const initialPages = { events: 1, products: 1 };
|
||||
setDetailsPages(initialPages);
|
||||
@@ -153,6 +188,10 @@ const OlistSync = () => {
|
||||
<button type="button" onClick={() => void loadStatus()} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">
|
||||
<RefreshCw className="h-4 w-4" /> Atualizar
|
||||
</button>
|
||||
<label className={`inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary ${isImportingOrders ? 'cursor-wait opacity-60' : 'cursor-pointer'}`}>
|
||||
{isImportingOrders ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileUp className="h-4 w-4 text-brand-primary" />} Importar OPs finalizadas
|
||||
<input type="file" accept=".csv,text/csv" className="sr-only" disabled={isImportingOrders} onChange={(event) => { void importOrders(event.target.files?.[0] || null); event.currentTarget.value = ''; }} />
|
||||
</label>
|
||||
{!status?.connected ? (
|
||||
<button type="button" onClick={() => void connect()} disabled={!status?.configured || isActionLoading} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
|
||||
{isActionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />} Conectar Olist
|
||||
@@ -173,6 +212,7 @@ const OlistSync = () => {
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm font-bold text-red-300">{error}</div>}
|
||||
{importMessage && <div className="rounded-2xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm font-bold text-emerald-300">Importação concluída: {importMessage}</div>}
|
||||
{!status?.configured && <div className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm font-bold text-amber-200">Configure as credenciais Olist V3 no ambiente antes de conectar.</div>}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
@@ -221,6 +261,33 @@ const OlistSync = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
|
||||
<div className="flex flex-col gap-2 border-b border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-dark-text">OPs finalizadas importadas</h2>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">CSV histórico para leitura de observações e recomendação de rendimento. Reimportar o mesmo arquivo atualiza as OPs sem duplicá-las.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void loadImportData()} title="Atualizar importações" aria-label="Atualizar importações" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><RefreshCw className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 p-5 md:grid-cols-4">
|
||||
{(['BLCS', 'BLOS', 'BLMC', 'BLPM'] as const).map(familyKey => {
|
||||
const recommendation = importData?.recommendations.find(item => item.familyKey === familyKey);
|
||||
return <div key={familyKey} className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{familyKey}</p>
|
||||
<p className="mt-2 text-lg font-bold text-dark-text">{recommendation?.recommendedUnitsPerRoll ? `${Math.round(recommendation.recommendedUnitsPerRoll).toLocaleString('pt-BR')} un./rolo` : 'Sem sugestão'}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{recommendation ? `${recommendation.sampleCount.toLocaleString('pt-BR')} OPs · ${recommendation.yieldSampleCount.toLocaleString('pt-BR')} c/ rendimento` : 'Nenhuma OP importada'}</p>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{!importData?.runs.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum CSV de OPs finalizadas importado.</p>}
|
||||
{importData?.runs.map(run => <div key={run.id} className="flex flex-col gap-2 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div><p className="text-sm font-bold text-dark-text">{run.filename}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · {run.totalRows.toLocaleString('pt-BR')} linhas</p>{run.errorMessage && <p className="mt-1 line-clamp-1 text-xs font-semibold text-amber-200">{run.errorMessage}</p>}</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} novas</span><span>{run.updated} atualizadas</span><span>{run.skipped} ignoradas</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span></div>
|
||||
</div>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
|
||||
<div className="border-b border-dark-border p-5"><h2 className="text-sm font-bold text-dark-text">Histórico de sincronizações</h2></div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
|
||||
32
src/types.ts
32
src/types.ts
@@ -63,6 +63,7 @@ export interface ProductionOrderItem {
|
||||
orderReference: string;
|
||||
issueDate: string | null;
|
||||
expectedDate: string | null;
|
||||
completedDate: string | null;
|
||||
productSku: string;
|
||||
productDescription: string;
|
||||
quantity: number;
|
||||
@@ -256,6 +257,37 @@ export interface OlistStatus {
|
||||
runs: OlistSyncRun[];
|
||||
}
|
||||
|
||||
export interface ProductionOrderImportRun {
|
||||
id: number;
|
||||
filename: string;
|
||||
status: 'running' | 'completed' | 'completed_with_errors' | 'failed';
|
||||
totalRows: number;
|
||||
imported: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errorMessage: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProductionYieldRecommendation {
|
||||
familyKey: 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM';
|
||||
sampleCount: number;
|
||||
yieldSampleCount: number;
|
||||
rollSampleCount: number;
|
||||
avgYieldPiecesPerKg: number | null;
|
||||
avgKgPerRoll: number | null;
|
||||
recommendedUnitsPerRoll: number | null;
|
||||
lastCompletedDate: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProductionOrderImportData {
|
||||
runs: ProductionOrderImportRun[];
|
||||
recommendations: ProductionYieldRecommendation[];
|
||||
}
|
||||
|
||||
export type CatalogProductPayload = {
|
||||
type: CatalogProductType;
|
||||
sku: string;
|
||||
|
||||
Reference in New Issue
Block a user