Export production orders with missing data
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
This commit is contained in:
@@ -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 || {}));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user