Export production orders with missing data
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s

This commit is contained in:
Cauê Faleiros
2026-08-12 11:41:37 -03:00
parent cfdcc4f2f4
commit e7edb5c483
5 changed files with 117 additions and 5 deletions

View File

@@ -9,7 +9,7 @@ const {
requestOlistSyncStop, requestOlistSyncStop,
startOlistSync startOlistSync
} = require('../services/olistService'); } = require('../services/olistService');
const { importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService'); const { exportMissingProductionOrderDataCsv, importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService');
const router = express.Router(); const router = express.Router();
@@ -57,6 +57,14 @@ router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res,
} }
}); });
router.get('/olist/production-order-imports/pending-csv', verifySuperAdmin, async (req, res, next) => {
try {
res.json(await exportMissingProductionOrderDataCsv());
} catch (error) {
next(error);
}
});
router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => {
try { try {
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {})); res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));

View File

@@ -31,6 +31,11 @@ const normalizeHeader = (header) => String(header || '')
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '_'); .replace(/\s+/g, '_');
const csvCell = (value) => {
const normalized = value === null || value === undefined ? '' : String(value);
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
};
const parseCsv = (csv) => { const parseCsv = (csv) => {
const rows = []; const rows = [];
let row = []; let row = [];
@@ -129,6 +134,12 @@ const parseFinalizedProductionOrderCsv = (csv) => {
const familyKey = productSku.split('.')[0]; const familyKey = productSku.split('.')[0];
const extracted = extractObservationFields(notes); const extracted = extractObservationFields(notes);
const supplier = normalizeText(value('fornecedor')) || extracted.supplier;
const lotCode = normalizeText(value('lote')) || extracted.lotCode;
const rollQuantity = normalizeNumber(value('quantidade_rolos')) || extracted.rollQuantity;
const fabricKg = normalizeNumber(value('quilos_malha')) || extracted.fabricKg;
const ribKg = normalizeNumber(value('quilos_ribana')) || extracted.ribKg;
const yieldPiecesPerKg = normalizeNumber(value('rendimento')) || extracted.yieldPiecesPerKg;
records.push({ records.push({
tinyId, tinyId,
number, number,
@@ -140,7 +151,12 @@ const parseFinalizedProductionOrderCsv = (csv) => {
orderReference: normalizeText(value('pedido_relacionado')), orderReference: normalizeText(value('pedido_relacionado')),
notes, notes,
familyKey: FAMILY_KEYS.has(familyKey) ? familyKey : null, familyKey: FAMILY_KEYS.has(familyKey) ? familyKey : null,
...extracted supplier,
lotCode,
rollQuantity,
fabricKg,
ribKg,
yieldPiecesPerKg
}); });
}); });
@@ -195,6 +211,45 @@ const listProductionOrderImportData = async () => {
}; };
}; };
const exportMissingProductionOrderDataCsv = async () => {
const result = await pool.query(`
SELECT tiny_id, number, product_sku, product_description, quantity, issue_date, completed_date, notes,
order_reference, supplier, lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg
FROM production_orders
WHERE status = 'finished'
AND (
supplier IS NULL OR lot_code IS NULL OR roll_quantity IS NULL
OR fabric_kg IS NULL OR yield_pieces_per_kg IS NULL
)
ORDER BY completed_date DESC NULLS LAST, id DESC;
`);
const headers = [
'id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao',
'data_conclusao', 'situacao', 'observacoes', 'pedido_relacionado', 'fornecedor', 'lote',
'quantidade_rolos', 'quilos_malha', 'quilos_ribana', 'rendimento', 'campos_pendentes'
];
const rows = result.rows.map((row) => {
const missing = [
!row.supplier && 'fornecedor',
!row.lot_code && 'lote',
!row.roll_quantity && 'quantidade_rolos',
!row.fabric_kg && 'quilos_malha',
!row.yield_pieces_per_kg && 'rendimento'
].filter(Boolean).join(', ');
return [
row.tiny_id, row.number, row.product_sku, row.product_description, row.quantity, row.issue_date,
row.completed_date, 'Finalizada', row.notes, row.order_reference, row.supplier, row.lot_code,
row.roll_quantity, row.fabric_kg, row.rib_kg, row.yield_pieces_per_kg, missing
];
});
return {
filename: `ops-finalizadas-pendentes-${new Date().toISOString().slice(0, 10)}.csv`,
totalRows: rows.length,
csv: [headers, ...rows].map(row => row.map(csvCell).join(',')).join('\r\n')
};
};
const importFinalizedProductionOrderCsv = async ({ filename, csv }) => { const importFinalizedProductionOrderCsv = async ({ filename, csv }) => {
const parsed = parseFinalizedProductionOrderCsv(csv); const parsed = parseFinalizedProductionOrderCsv(csv);
const client = await pool.connect(); const client = await pool.connect();
@@ -260,6 +315,7 @@ const importFinalizedProductionOrderCsv = async ({ filename, csv }) => {
}; };
module.exports = { module.exports = {
exportMissingProductionOrderDataCsv,
extractObservationFields, extractObservationFields,
importFinalizedProductionOrderCsv, importFinalizedProductionOrderCsv,
listProductionOrderImportData, listProductionOrderImportData,

View File

@@ -28,3 +28,16 @@ test('parseFinalizedProductionOrderCsv supports multiline observations and skips
assert.equal(result.records[0].fabricKg, 577.9); assert.equal(result.records[0].fabricKg, 577.9);
assert.equal(result.records[0].yieldPiecesPerKg, 5.44); assert.equal(result.records[0].yieldPiecesPerKg, 5.44);
}); });
test('parseFinalizedProductionOrderCsv accepts structured production fields from a completed template', () => {
const csv = 'id_op,numero_op,sku_produto,descricao_produto,quantidade_produzida,data_emissao,data_conclusao,situacao,observacoes,fornecedor,lote,quantidade_rolos,quilos_malha,quilos_ribana,rendimento\n1,10,BLCS.PTO.G,Camiseta preta G,100,2026-08-01,2026-08-02,Finalizada,,TECEBEM,0245163,30,"577,90","28,89","5,44"';
const result = parseFinalizedProductionOrderCsv(csv);
assert.equal(result.records.length, 1);
assert.equal(result.records[0].supplier, 'TECEBEM');
assert.equal(result.records[0].lotCode, '0245163');
assert.equal(result.records[0].rollQuantity, 30);
assert.equal(result.records[0].fabricKg, 577.9);
assert.equal(result.records[0].ribKg, 28.89);
assert.equal(result.records[0].yieldPiecesPerKg, 5.44);
});

View File

@@ -321,6 +321,24 @@ export const fetchProductionOrderImportData = async (): Promise<ProductionOrderI
return data as ProductionOrderImportData; return data as ProductionOrderImportData;
}; };
export const exportMissingProductionOrderDataCsv = async (): Promise<number> => {
const response = await authFetch('/olist/production-order-imports/pending-csv', { cache: 'no-store' });
const data = await response.json().catch(() => null);
if (!response.ok || !data?.csv || !data?.filename) {
throw new Error(data?.error || 'Não foi possível exportar as pendências das OPs.');
}
const url = URL.createObjectURL(new Blob([data.csv], { type: 'text/csv;charset=utf-8' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = data.filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
return Number(data.totalRows) || 0;
};
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 }> }> => { 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', { const response = await authFetch('/olist/production-order-imports', {
method: 'POST', method: 'POST',

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, CloudDownload, Database, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react'; import { AlertTriangle, CheckCircle2, CloudDownload, Database, Download, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
import { fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService'; import { exportMissingProductionOrderDataCsv, fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types'; import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types';
const formatDateTime = (value: string | null) => { const formatDateTime = (value: string | null) => {
@@ -50,6 +50,7 @@ const OlistSync = () => {
const [isDetailsLoading, setIsDetailsLoading] = useState(false); const [isDetailsLoading, setIsDetailsLoading] = useState(false);
const [importData, setImportData] = useState<ProductionOrderImportData | null>(null); const [importData, setImportData] = useState<ProductionOrderImportData | null>(null);
const [isImportingOrders, setIsImportingOrders] = useState(false); const [isImportingOrders, setIsImportingOrders] = useState(false);
const [isExportingPendingOrders, setIsExportingPendingOrders] = useState(false);
const [importMessage, setImportMessage] = useState(''); const [importMessage, setImportMessage] = useState('');
const loadStatus = useCallback(async (initial = false) => { const loadStatus = useCallback(async (initial = false) => {
@@ -167,6 +168,19 @@ const OlistSync = () => {
} }
}; };
const exportPendingOrders = async () => {
setIsExportingPendingOrders(true);
setImportMessage('');
try {
const totalRows = await exportMissingProductionOrderDataCsv();
setImportMessage(totalRows ? `${totalRows.toLocaleString('pt-BR')} OPs com pendências exportadas para completar.` : 'Nenhuma OP finalizada possui pendências de dados.');
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível exportar as pendências das OPs.');
} finally {
setIsExportingPendingOrders(false);
}
};
const openRunDetails = (runId: number) => { const openRunDetails = (runId: number) => {
const initialPages = { events: 1, products: 1 }; const initialPages = { events: 1, products: 1 };
setDetailsPages(initialPages); setDetailsPages(initialPages);
@@ -189,6 +203,9 @@ 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"> <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 <RefreshCw className="h-4 w-4" /> Atualizar
</button> </button>
<button type="button" onClick={() => void exportPendingOrders()} disabled={isExportingPendingOrders} 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 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
{isExportingPendingOrders ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4 text-brand-primary" />} Exportar pendências
</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'}`}> <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 {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 = ''; }} /> <input type="file" accept=".csv,text/csv" className="sr-only" disabled={isImportingOrders} onChange={(event) => { void importOrders(event.target.files?.[0] || null); event.currentTarget.value = ''; }} />
@@ -216,7 +233,7 @@ const OlistSync = () => {
</div> </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>} {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>} {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">{importMessage.startsWith('Nenhuma') || importMessage.includes('exportadas') ? 'Exportação pronta: ' : '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>} {!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"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">