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>
224 lines
8.3 KiB
HTML
224 lines
8.3 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
|
|
{% block nav_missing %}active{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h4 class="mb-0">SKU-uri Lipsa</h4>
|
|
<div>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
|
|
<i class="bi bi-download"></i> Export CSV
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="scanForMissing()">
|
|
<i class="bi bi-search"></i> Re-Scan
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>SKU</th>
|
|
<th>Produs</th>
|
|
<th>First Seen</th>
|
|
<th>Status</th>
|
|
<th>Actiune</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="missingBody">
|
|
<tr><td colspan="5" class="text-center text-muted py-4">Se incarca...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="card-footer">
|
|
<small class="text-muted" id="missingInfo"></small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map SKU Modal -->
|
|
<div class="modal fade" id="mapModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Mapeaza SKU: <code id="mapSku"></code></h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3 position-relative">
|
|
<label class="form-label">CODMAT (Articol ROA)</label>
|
|
<input type="text" class="form-control" id="mapCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
|
<div class="autocomplete-dropdown d-none" id="mapAutocomplete"></div>
|
|
<small class="text-muted" id="mapSelectedArticle"></small>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-6 mb-3">
|
|
<label class="form-label">Cantitate ROA</label>
|
|
<input type="number" class="form-control" id="mapCantitate" value="1" step="0.001" min="0.001">
|
|
</div>
|
|
<div class="col-6 mb-3">
|
|
<label class="form-label">Procent Pret (%)</label>
|
|
<input type="number" class="form-control" id="mapProcent" value="100" step="0.01" min="0" max="100">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveQuickMap()">Salveaza</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
let currentMapSku = '';
|
|
let acTimeout = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadMissing();
|
|
|
|
const input = document.getElementById('mapCodmat');
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(acTimeout);
|
|
acTimeout = setTimeout(() => autocompleteMap(input.value), 250);
|
|
});
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => document.getElementById('mapAutocomplete').classList.add('d-none'), 200);
|
|
});
|
|
});
|
|
|
|
async function loadMissing() {
|
|
try {
|
|
const res = await fetch('/api/validate/missing-skus');
|
|
const data = await res.json();
|
|
const tbody = document.getElementById('missingBody');
|
|
|
|
document.getElementById('missingInfo').textContent =
|
|
`Total: ${data.total || 0} | Nerezolvate: ${data.unresolved || 0}`;
|
|
|
|
const skus = data.missing_skus || [];
|
|
if (skus.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Toate SKU-urile sunt mapate!</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = skus.map(s => {
|
|
const statusBadge = s.resolved
|
|
? '<span class="badge bg-success">Rezolvat</span>'
|
|
: '<span class="badge bg-warning text-dark">Nerezolvat</span>';
|
|
|
|
return `<tr class="${s.resolved ? 'table-light' : ''}">
|
|
<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>${statusBadge}</td>
|
|
<td>
|
|
${!s.resolved ? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}')">
|
|
<i class="bi bi-link-45deg"></i> Mapeaza
|
|
</button>` : `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
} catch (err) {
|
|
document.getElementById('missingBody').innerHTML =
|
|
`<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function openMapModal(sku) {
|
|
currentMapSku = sku;
|
|
document.getElementById('mapSku').textContent = sku;
|
|
document.getElementById('mapCodmat').value = '';
|
|
document.getElementById('mapCantitate').value = '1';
|
|
document.getElementById('mapProcent').value = '100';
|
|
document.getElementById('mapSelectedArticle').textContent = '';
|
|
new bootstrap.Modal(document.getElementById('mapModal')).show();
|
|
}
|
|
|
|
async function autocompleteMap(q) {
|
|
const dropdown = document.getElementById('mapAutocomplete');
|
|
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="selectMapArticle('${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 selectMapArticle(codmat, denumire) {
|
|
document.getElementById('mapCodmat').value = codmat;
|
|
document.getElementById('mapSelectedArticle').textContent = denumire;
|
|
document.getElementById('mapAutocomplete').classList.add('d-none');
|
|
}
|
|
|
|
async function saveQuickMap() {
|
|
const codmat = document.getElementById('mapCodmat').value.trim();
|
|
const cantitate = parseFloat(document.getElementById('mapCantitate').value) || 1;
|
|
const procent = parseFloat(document.getElementById('mapProcent').value) || 100;
|
|
|
|
if (!codmat) { alert('Selecteaza un CODMAT'); return; }
|
|
|
|
try {
|
|
const res = await fetch('/api/mappings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sku: currentMapSku,
|
|
codmat: codmat,
|
|
cantitate_roa: cantitate,
|
|
procent_pret: procent
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
|
loadMissing();
|
|
} else {
|
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
|
}
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function scanForMissing() {
|
|
try {
|
|
await fetch('/api/validate/scan', { method: 'POST' });
|
|
loadMissing();
|
|
} catch (err) {
|
|
alert('Eroare scan: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function exportMissingCsv() {
|
|
window.location.href = '/api/validate/missing-skus-csv';
|
|
}
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
</script>
|
|
{% endblock %}
|