Add database diagnostic export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
This commit is contained in:
15
backend/routes/databaseDiagnosticRoutes.js
Normal file
15
backend/routes/databaseDiagnosticRoutes.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { verifySuperAdmin } = require('../auth');
|
||||||
|
const { buildDatabaseDiagnostic } = require('../services/databaseDiagnosticService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/admin/database-diagnostic', verifySuperAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await buildDatabaseDiagnostic(req.user));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -12,6 +12,7 @@ const productionOrderRoutes = require('./routes/productionOrderRoutes');
|
|||||||
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
||||||
const catalogRoutes = require('./routes/catalogRoutes');
|
const catalogRoutes = require('./routes/catalogRoutes');
|
||||||
const supplyRoutes = require('./routes/supplyRoutes');
|
const supplyRoutes = require('./routes/supplyRoutes');
|
||||||
|
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -27,6 +28,7 @@ const createApp = () => {
|
|||||||
app.use('/api', cuttingSettingsRoutes);
|
app.use('/api', cuttingSettingsRoutes);
|
||||||
app.use('/api', catalogRoutes);
|
app.use('/api', catalogRoutes);
|
||||||
app.use('/api', supplyRoutes);
|
app.use('/api', supplyRoutes);
|
||||||
|
app.use('/api', databaseDiagnosticRoutes);
|
||||||
app.use('/api', analyticsRoutes);
|
app.use('/api', analyticsRoutes);
|
||||||
app.use('/api', userRoutes);
|
app.use('/api', userRoutes);
|
||||||
app.use('/api/internal', internalRoutes);
|
app.use('/api/internal', internalRoutes);
|
||||||
|
|||||||
169
backend/services/databaseDiagnosticService.js
Normal file
169
backend/services/databaseDiagnosticService.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
const { pool } = require('../db');
|
||||||
|
|
||||||
|
const SAMPLE_LIMIT = 50;
|
||||||
|
|
||||||
|
const sampleSpecs = {
|
||||||
|
catalog_categories: {
|
||||||
|
columns: ['id', 'name', 'description', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
catalog_products: {
|
||||||
|
columns: ['id', 'type', 'sku', 'name', 'category_id', 'composition', 'gramature', 'material_yield', 'width_cm', 'color', 'subcategory', 'sizes', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
consumption_references: {
|
||||||
|
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
cutting_family_rules: {
|
||||||
|
columns: ['family_key', 'units_per_roll', 'updated_at'],
|
||||||
|
orderBy: ['family_key']
|
||||||
|
},
|
||||||
|
cutting_product_overrides: {
|
||||||
|
columns: ['product_id', 'family_key', 'color', 'size', 'product_type', 'planning_notes', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'product_id']
|
||||||
|
},
|
||||||
|
orders: {
|
||||||
|
columns: ['id', 'pedido_id', 'data_pedido', 'data_pedido_date', 'valor_pedido', 'produto_id', 'produto_descricao', 'quantidade', 'valor_unitario', 'id_vendedor', 'nome_vendedor', 'marketplace', 'canal_venda', 'numero_ecommerce', 'created_at'],
|
||||||
|
orderBy: ['data_pedido_date', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
production_order_markers: {
|
||||||
|
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
|
||||||
|
orderBy: ['created_at', 'id']
|
||||||
|
},
|
||||||
|
production_orders: {
|
||||||
|
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
stock: {
|
||||||
|
columns: ['produto_id', 'nome', 'saldo', 'delta_estoque', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'produto_id']
|
||||||
|
},
|
||||||
|
stock_campaign_queue: {
|
||||||
|
columns: ['id', 'base_product_name', 'produto_id', 'nome', 'saldo', 'delta_estoque', 'status', 'attempts', 'last_error', 'created_at', 'updated_at', 'sent_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
supply_fabric_plans: {
|
||||||
|
columns: ['id', 'material', 'color', 'quantity_kg', 'supplier', 'priority', 'status', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
supply_movements: {
|
||||||
|
columns: ['id', 'receipt_id', 'lot_id', 'type', 'category', 'product', 'quantity', 'unit', 'reason', 'created_at'],
|
||||||
|
orderBy: ['created_at', 'id']
|
||||||
|
},
|
||||||
|
supply_receipts: {
|
||||||
|
columns: ['id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'notes', 'status', 'created_at', 'updated_at', 'approved_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
supply_stock_lots: {
|
||||||
|
columns: ['id', 'receipt_id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'status', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const quoteIdentifier = (identifier) => {
|
||||||
|
if (!/^[a-z_][a-z0-9_]*$/.test(identifier)) {
|
||||||
|
throw new Error(`Unsafe database identifier: ${identifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"${identifier}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPublicTables = async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
return result.rows.map(row => row.table_name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPublicColumns = async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT table_name, column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name, ordinal_position
|
||||||
|
`);
|
||||||
|
|
||||||
|
return result.rows.reduce((columnsByTable, row) => {
|
||||||
|
if (!columnsByTable[row.table_name]) columnsByTable[row.table_name] = [];
|
||||||
|
columnsByTable[row.table_name].push(row.column_name);
|
||||||
|
return columnsByTable;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const countTableRows = async (tableName) => {
|
||||||
|
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${quoteIdentifier(tableName)}`);
|
||||||
|
return result.rows[0]?.count || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildOrderClause = (spec, availableColumns) => {
|
||||||
|
const orderColumns = spec.orderBy.filter(column => availableColumns.includes(column));
|
||||||
|
if (!orderColumns.length) return '';
|
||||||
|
|
||||||
|
const clauses = orderColumns.map(column => {
|
||||||
|
const direction = column === 'family_key' || column === 'product_id' || column === 'produto_id' ? 'ASC' : 'DESC';
|
||||||
|
return `${quoteIdentifier(column)} ${direction}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ` ORDER BY ${clauses.join(', ')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleTableRows = async (tableName, availableColumns) => {
|
||||||
|
const spec = sampleSpecs[tableName];
|
||||||
|
if (!spec) return null;
|
||||||
|
|
||||||
|
const selectedColumns = spec.columns.filter(column => availableColumns.includes(column));
|
||||||
|
if (!selectedColumns.length) return null;
|
||||||
|
|
||||||
|
const selectClause = selectedColumns.map(quoteIdentifier).join(', ');
|
||||||
|
const orderClause = buildOrderClause(spec, availableColumns);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT ${selectClause} FROM ${quoteIdentifier(tableName)}${orderClause} LIMIT $1`,
|
||||||
|
[SAMPLE_LIMIT]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDatabaseDiagnostic = async (user) => {
|
||||||
|
const [tables, columnsByTable] = await Promise.all([
|
||||||
|
listPublicTables(),
|
||||||
|
listPublicColumns()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const counts = {};
|
||||||
|
const samples = {};
|
||||||
|
|
||||||
|
for (const tableName of tables) {
|
||||||
|
counts[tableName] = await countTableRows(tableName);
|
||||||
|
|
||||||
|
const sampleRows = await sampleTableRows(tableName, columnsByTable[tableName] || []);
|
||||||
|
if (sampleRows) samples[tableName] = sampleRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
generatedBy: {
|
||||||
|
role: user?.role || null,
|
||||||
|
userId: user?.userId || null
|
||||||
|
},
|
||||||
|
sampleLimit: SAMPLE_LIMIT,
|
||||||
|
privacy: {
|
||||||
|
countsIncludeAllPublicTables: true,
|
||||||
|
samplesExcludeTables: ['app_users', 'client_identity_tokens'],
|
||||||
|
ordersSampleExcludesCustomerNameAndPhone: true,
|
||||||
|
productionOrdersSampleExcludesTinyPayload: true
|
||||||
|
},
|
||||||
|
counts,
|
||||||
|
samples
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDatabaseDiagnostic
|
||||||
|
};
|
||||||
@@ -161,6 +161,23 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const downloadDatabaseDiagnostic = async (): Promise<void> => {
|
||||||
|
const response = await authFetch('/admin/database-diagnostic', { cache: 'no-store' });
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Não foi possível exportar o diagnóstico do banco.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `graphs-db-diagnostic-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchProductionOrders = async (
|
export const fetchProductionOrders = async (
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
filters?: { search?: string },
|
filters?: { search?: string },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import DateRangePicker from '../components/DateRangePicker';
|
|||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import { classifyCutFamily } from '../analytics/cutting';
|
import { classifyCutFamily } from '../analytics/cutting';
|
||||||
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary } from '../dataService';
|
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary, isSuperAdmin } from '../dataService';
|
||||||
import { parseProductName } from '../productParsing';
|
import { parseProductName } from '../productParsing';
|
||||||
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||||
import { getPlanningStock } from '../planningStock';
|
import { getPlanningStock } from '../planningStock';
|
||||||
@@ -192,9 +192,44 @@ const ModuleCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SuppliesHub = () => (
|
const SuppliesHub = () => {
|
||||||
|
const [isExportingDiagnostic, setIsExportingDiagnostic] = useState(false);
|
||||||
|
const [diagnosticError, setDiagnosticError] = useState('');
|
||||||
|
const canExportDiagnostic = isSuperAdmin();
|
||||||
|
|
||||||
|
const handleExportDiagnostic = async () => {
|
||||||
|
setIsExportingDiagnostic(true);
|
||||||
|
setDiagnosticError('');
|
||||||
|
try {
|
||||||
|
await downloadDatabaseDiagnostic();
|
||||||
|
} catch (error) {
|
||||||
|
setDiagnosticError(error instanceof Error ? error.message : 'Não foi possível exportar o diagnóstico do banco.');
|
||||||
|
} finally {
|
||||||
|
setIsExportingDiagnostic(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<div className={pageClassName}>
|
<div className={pageClassName}>
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
|
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
|
||||||
|
{canExportDiagnostic && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportDiagnostic}
|
||||||
|
disabled={isExportingDiagnostic}
|
||||||
|
className={`${buttonClassName} w-fit disabled:cursor-not-allowed disabled:opacity-60`}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{isExportingDiagnostic ? 'Exportando...' : 'Exportar diagnóstico DB'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{diagnosticError && (
|
||||||
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
|
||||||
|
{diagnosticError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<ModuleCard title="Planejamento Operacional" description="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." icon={PackageSearch} to="/supplies/current-planning" />
|
<ModuleCard title="Planejamento Operacional" description="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." icon={PackageSearch} to="/supplies/current-planning" />
|
||||||
<ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" />
|
<ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" />
|
||||||
@@ -204,7 +239,8 @@ const SuppliesHub = () => (
|
|||||||
<ModuleCard title="Necessidade de Compra" description="Itens abaixo do mínimo e necessidade projetada para compra." icon={BarChart3} to="/supplies/purchase-needs" />
|
<ModuleCard title="Necessidade de Compra" description="Itens abaixo do mínimo e necessidade projetada para compra." icon={BarChart3} to="/supplies/purchase-needs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getRangeDays = (range: DateRange) => {
|
const getRangeDays = (range: DateRange) => {
|
||||||
const start = new Date(range.start);
|
const start = new Date(range.start);
|
||||||
|
|||||||
Reference in New Issue
Block a user