feat: add FastAPI admin dashboard with sync orchestration and test suite
Replace Flask admin with FastAPI app (api/app/) featuring: - Dashboard with stat cards, sync control, and history - Mappings CRUD for ARTICOLE_TERTI with CSV import/export - Article autocomplete from NOM_ARTICOLE - SKU pre-validation before import - Sync orchestration: read JSONs -> validate -> import -> log to SQLite - APScheduler for periodic sync from UI - File logging to logs/sync_comenzi_YYYYMMDD_HHMMSS.log - Oracle pool None guard (503 vs 500 on unavailable) Test suite: - test_app_basic.py: 30 tests (imports + routes) without Oracle - test_integration.py: 9 integration tests with Oracle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
215
api/app/static/js/dashboard.js
Normal file
215
api/app/static/js/dashboard.js
Normal file
@@ -0,0 +1,215 @@
|
||||
let refreshInterval = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDashboard();
|
||||
// Auto-refresh every 10 seconds
|
||||
refreshInterval = setInterval(loadDashboard, 10000);
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
await Promise.all([
|
||||
loadSyncStatus(),
|
||||
loadSyncHistory(),
|
||||
loadMissingSkus(),
|
||||
loadSchedulerStatus()
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadSyncStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/status');
|
||||
const data = await res.json();
|
||||
|
||||
// Update stats
|
||||
const stats = data.stats || {};
|
||||
document.getElementById('stat-imported').textContent = stats.imported || 0;
|
||||
document.getElementById('stat-skipped').textContent = stats.skipped || 0;
|
||||
document.getElementById('stat-missing').textContent = stats.missing_skus || 0;
|
||||
|
||||
// Update sync status badge
|
||||
const badge = document.getElementById('syncStatusBadge');
|
||||
const status = data.status || 'idle';
|
||||
badge.textContent = status;
|
||||
badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary');
|
||||
|
||||
// Show/hide start/stop buttons
|
||||
if (status === 'running') {
|
||||
document.getElementById('btnStartSync').classList.add('d-none');
|
||||
document.getElementById('btnStopSync').classList.remove('d-none');
|
||||
document.getElementById('syncProgressText').textContent = data.progress || 'Running...';
|
||||
} else {
|
||||
document.getElementById('btnStartSync').classList.remove('d-none');
|
||||
document.getElementById('btnStopSync').classList.add('d-none');
|
||||
|
||||
// Show last run info
|
||||
if (stats.last_run) {
|
||||
const lr = stats.last_run;
|
||||
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
|
||||
document.getElementById('syncProgressText').textContent =
|
||||
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} skip, ${lr.errors || 0} err`;
|
||||
} else {
|
||||
document.getElementById('syncProgressText').textContent = '';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('loadSyncStatus error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSyncHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/history?per_page=10');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('syncRunsBody');
|
||||
|
||||
if (!data.runs || data.runs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.runs.map(r => {
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
|
||||
let duration = '-';
|
||||
if (r.started_at && r.finished_at) {
|
||||
const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
|
||||
duration = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
|
||||
}
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
|
||||
return `<tr style="cursor:pointer" onclick="window.location='/sync/run/${esc(r.run_id)}'">
|
||||
<td>${started}</td>
|
||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||
<td>${r.total_orders || 0}</td>
|
||||
<td class="text-success">${r.imported || 0}</td>
|
||||
<td class="text-warning">${r.skipped || 0}</td>
|
||||
<td class="text-danger">${r.errors || 0}</td>
|
||||
<td>${duration}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('loadSyncHistory error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMissingSkus() {
|
||||
try {
|
||||
const res = await fetch('/api/validate/missing-skus');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('missingSkusBody');
|
||||
|
||||
// Update stat card
|
||||
document.getElementById('stat-missing').textContent = data.unresolved || 0;
|
||||
|
||||
const unresolved = (data.missing_skus || []).filter(s => !s.resolved);
|
||||
|
||||
if (unresolved.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = unresolved.slice(0, 10).map(s => `
|
||||
<tr>
|
||||
<td><code>${esc(s.sku)}</code></td>
|
||||
<td>${esc(s.product_name || '-')}</td>
|
||||
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
|
||||
<td>
|
||||
<a href="/mappings?sku=${encodeURIComponent(s.sku)}" class="btn btn-sm btn-outline-primary" title="Creeaza mapare">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
console.error('loadMissingSkus error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedulerStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/schedule');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('schedulerToggle').checked = data.enabled || false;
|
||||
if (data.interval_minutes) {
|
||||
document.getElementById('schedulerInterval').value = data.interval_minutes;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('loadSchedulerStatus error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startSync() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/start', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
}
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSync() {
|
||||
try {
|
||||
await fetch('/api/sync/stop', { method: 'POST' });
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanOrders() {
|
||||
const btn = document.getElementById('btnScan');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Scanning...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/validate/scan', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
// Update pending/ready stats
|
||||
document.getElementById('stat-pending').textContent = data.total_orders || 0;
|
||||
document.getElementById('stat-ready').textContent = data.importable || 0;
|
||||
|
||||
let msg = `Scan complet: ${data.total_orders || 0} comenzi, ${data.importable || 0} ready, ${data.skipped || 0} skipped`;
|
||||
if (data.skus && data.skus.missing > 0) {
|
||||
msg += `, ${data.skus.missing} SKU-uri lipsa`;
|
||||
}
|
||||
alert(msg);
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
alert('Eroare scan: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-search"></i> Scan';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScheduler() {
|
||||
const enabled = document.getElementById('schedulerToggle').checked;
|
||||
const interval = parseInt(document.getElementById('schedulerInterval').value) || 5;
|
||||
|
||||
try {
|
||||
await fetch('/api/sync/schedule', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled, interval_minutes: interval })
|
||||
});
|
||||
} catch (err) {
|
||||
alert('Eroare scheduler: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSchedulerInterval() {
|
||||
const enabled = document.getElementById('schedulerToggle').checked;
|
||||
if (enabled) {
|
||||
await toggleScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
299
api/app/static/js/mappings.js
Normal file
299
api/app/static/js/mappings.js
Normal file
@@ -0,0 +1,299 @@
|
||||
let currentPage = 1;
|
||||
let currentSearch = '';
|
||||
let searchTimeout = null;
|
||||
|
||||
// Load on page ready
|
||||
document.addEventListener('DOMContentLoaded', loadMappings);
|
||||
|
||||
function debounceSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentSearch = document.getElementById('searchInput').value;
|
||||
currentPage = 1;
|
||||
loadMappings();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function loadMappings() {
|
||||
const params = new URLSearchParams({
|
||||
search: currentSearch,
|
||||
page: currentPage,
|
||||
per_page: 50
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/mappings?${params}`);
|
||||
const data = await res.json();
|
||||
renderTable(data.mappings);
|
||||
renderPagination(data);
|
||||
} catch (err) {
|
||||
document.getElementById('mappingsBody').innerHTML =
|
||||
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(mappings) {
|
||||
const tbody = document.getElementById('mappingsBody');
|
||||
|
||||
if (!mappings || mappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Nu exista mapari</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = mappings.map(m => `
|
||||
<tr>
|
||||
<td><strong>${esc(m.sku)}</strong></td>
|
||||
<td><code>${esc(m.codmat)}</code></td>
|
||||
<td>${esc(m.denumire || '-')}</td>
|
||||
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})">${m.cantitate_roa}</td>
|
||||
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})">${m.procent_pret}%</td>
|
||||
<td>
|
||||
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" style="cursor:pointer"
|
||||
onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})">
|
||||
${m.activ ? 'Activ' : 'Inactiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Dezactiveaza">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderPagination(data) {
|
||||
const info = document.getElementById('pageInfo');
|
||||
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`;
|
||||
|
||||
const ul = document.getElementById('pagination');
|
||||
if (data.pages <= 1) { ul.innerHTML = ''; return; }
|
||||
|
||||
let html = '';
|
||||
// Previous
|
||||
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">«</a></li>`;
|
||||
|
||||
// Pages (show max 7)
|
||||
let start = Math.max(1, data.page - 3);
|
||||
let end = Math.min(data.pages, start + 6);
|
||||
start = Math.max(1, end - 6);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
html += `<li class="page-item ${i === data.page ? 'active' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
|
||||
}
|
||||
|
||||
// Next
|
||||
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">»</a></li>`;
|
||||
|
||||
ul.innerHTML = html;
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
currentPage = p;
|
||||
loadMappings();
|
||||
}
|
||||
|
||||
// Autocomplete for CODMAT
|
||||
let acTimeout = null;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const input = document.getElementById('inputCodmat');
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(acTimeout);
|
||||
acTimeout = setTimeout(() => autocomplete(input.value), 250);
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => document.getElementById('autocompleteDropdown').classList.add('d-none'), 200);
|
||||
});
|
||||
});
|
||||
|
||||
async function autocomplete(q) {
|
||||
const dropdown = document.getElementById('autocompleteDropdown');
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.results || data.results.length === 0) {
|
||||
dropdown.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = data.results.map(r => `
|
||||
<div class="autocomplete-item" onmousedown="selectArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span>
|
||||
<br><span class="denumire">${esc(r.denumire)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch (err) {
|
||||
dropdown.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function selectArticle(codmat, denumire) {
|
||||
document.getElementById('inputCodmat').value = codmat;
|
||||
document.getElementById('selectedArticle').textContent = denumire;
|
||||
document.getElementById('autocompleteDropdown').classList.add('d-none');
|
||||
}
|
||||
|
||||
// Save mapping (create)
|
||||
async function saveMapping() {
|
||||
const sku = document.getElementById('inputSku').value.trim();
|
||||
const codmat = document.getElementById('inputCodmat').value.trim();
|
||||
const cantitate = parseFloat(document.getElementById('inputCantitate').value) || 1;
|
||||
const procent = parseFloat(document.getElementById('inputProcent').value) || 100;
|
||||
|
||||
if (!sku || !codmat) { alert('SKU si CODMAT sunt obligatorii'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
|
||||
clearForm();
|
||||
loadMappings();
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
document.getElementById('inputSku').value = '';
|
||||
document.getElementById('inputCodmat').value = '';
|
||||
document.getElementById('inputCantitate').value = '1';
|
||||
document.getElementById('inputProcent').value = '100';
|
||||
document.getElementById('selectedArticle').textContent = '';
|
||||
}
|
||||
|
||||
// Inline edit
|
||||
function editCell(td, sku, codmat, field, currentValue) {
|
||||
if (td.querySelector('input')) return; // Already editing
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'form-control form-control-sm';
|
||||
input.value = currentValue;
|
||||
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
|
||||
input.style.width = '80px';
|
||||
|
||||
const originalText = td.textContent;
|
||||
td.textContent = '';
|
||||
td.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const save = async () => {
|
||||
const newValue = parseFloat(input.value);
|
||||
if (isNaN(newValue) || newValue === currentValue) {
|
||||
td.textContent = originalText;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {};
|
||||
body[field] = newValue;
|
||||
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadMappings();
|
||||
} else {
|
||||
td.textContent = originalText;
|
||||
alert('Eroare: ' + (data.error || 'Update failed'));
|
||||
}
|
||||
} catch (err) {
|
||||
td.textContent = originalText;
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('blur', save);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') save();
|
||||
if (e.key === 'Escape') { td.textContent = originalText; }
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle active
|
||||
async function toggleActive(sku, codmat, currentActive) {
|
||||
try {
|
||||
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ activ: currentActive ? 0 : 1 })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) loadMappings();
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete (soft)
|
||||
function deleteMappingConfirm(sku, codmat) {
|
||||
if (confirm(`Dezactivezi maparea ${sku} -> ${codmat}?`)) {
|
||||
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
||||
method: 'DELETE'
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.success) loadMappings();
|
||||
else alert('Eroare: ' + (d.error || 'Delete failed'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CSV import
|
||||
async function importCsv() {
|
||||
const fileInput = document.getElementById('csvFile');
|
||||
if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; }
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mappings/import-csv', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`;
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
html += `<div class="alert alert-warning">Erori: <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
|
||||
}
|
||||
document.getElementById('importResult').innerHTML = html;
|
||||
loadMappings();
|
||||
} catch (err) {
|
||||
document.getElementById('importResult').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
window.location.href = '/api/mappings/export-csv';
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
window.location.href = '/api/mappings/csv-template';
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user