feat(sqlite): refactor orders schema + dashboard period filter

Replace import_orders (insert-per-run) with orders table (one row per
order, upsert on conflict). Eliminates dedup CTE on every dashboard
query and prevents unbounded row growth at 4-500 orders/sync.

Key changes:
- orders table: PK order_number, upsert via ON CONFLICT DO UPDATE;
  COALESCE preserves id_comanda once set; times_skipped auto-increments
- sync_run_orders: lightweight junction (sync_run_id, order_number)
  replaces sync_run_id column on orders
- order_items: PK changed to (order_number, sku), INSERT OR IGNORE
- Auto-migration in init_sqlite(): import_orders → orders on first boot,
  old table renamed to import_orders_bak
- /api/dashboard/orders: period_days param (3/7/30/0=all, default 7)
- Dashboard: period selector buttons in orders card header
- start.sh: stop existing process on port 5003 before restart;
  remove --reload (broken on WSL2 /mnt/e/)
- Add invoice_service, E2E Playwright tests, Oracle package updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 16:18:57 +02:00
parent 650e98539e
commit 82196b9dc0
32 changed files with 4164 additions and 1192 deletions

View File

@@ -15,6 +15,19 @@
</div>
</div>
<!-- Resolved toggle (R10) -->
<div class="btn-group mb-3" role="group">
<button type="button" class="btn btn-sm btn-primary" id="btnUnresolved" onclick="setResolvedFilter(0)">
Nerezolvate
</button>
<button type="button" class="btn btn-sm btn-outline-success" id="btnResolved" onclick="setResolvedFilter(1)">
Rezolvate
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnAll" onclick="setResolvedFilter(-1)">
Toate
</button>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
@@ -45,7 +58,7 @@
<ul class="pagination justify-content-center" id="paginationControls"></ul>
</nav>
<!-- Map SKU Modal -->
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
@@ -54,22 +67,16 @@
<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 class="mb-2">
<small class="text-muted">Produs web:</small> <strong id="mapProductName"></strong>
</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 id="mapCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addMapCodmatLine()">
<i class="bi bi-plus"></i> Adauga CODMAT
</button>
<div id="mapPctWarning" class="text-danger mt-2" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
@@ -83,27 +90,29 @@
{% block scripts %}
<script>
let currentMapSku = '';
let acTimeout = null;
let mapAcTimeout = null;
let currentPage = 1;
let currentResolved = 0;
const perPage = 20;
document.addEventListener('DOMContentLoaded', () => {
loadMissing(1);
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);
});
});
function setResolvedFilter(val) {
currentResolved = val;
currentPage = 1;
// Update button styles
document.getElementById('btnUnresolved').className = 'btn btn-sm ' + (val === 0 ? 'btn-primary' : 'btn-outline-primary');
document.getElementById('btnResolved').className = 'btn btn-sm ' + (val === 1 ? 'btn-success' : 'btn-outline-success');
document.getElementById('btnAll').className = 'btn btn-sm ' + (val === -1 ? 'btn-secondary' : 'btn-outline-secondary');
loadMissing(1);
}
async function loadMissing(page) {
currentPage = page || 1;
try {
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}`);
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}&resolved=${currentResolved}`);
const data = await res.json();
const tbody = document.getElementById('missingBody');
@@ -112,7 +121,9 @@ async function loadMissing(page) {
const skus = data.missing_skus || [];
if (skus.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Toate SKU-urile sunt mapate!</td></tr>';
const msg = currentResolved === 0 ? 'Toate SKU-urile sunt mapate!' :
currentResolved === 1 ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`;
renderPagination(data);
return;
}
@@ -126,7 +137,7 @@ async function loadMissing(page) {
try {
const customers = JSON.parse(s.customers || '[]');
if (customers.length > 0) firstCustomer = customers[0];
} catch (e) { /* ignore parse errors */ }
} catch (e) { /* ignore */ }
const orderCount = s.order_count != null ? s.order_count : '-';
@@ -139,9 +150,9 @@ async function loadMissing(page) {
<td>${statusBadge}</td>
<td>
${!s.resolved
? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
<i class="bi bi-link-45deg"></i> Mapeaza
</button>`
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
<i class="bi bi-link-45deg"></i>
</a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
</td>
</tr>`;
@@ -158,103 +169,160 @@ function renderPagination(data) {
const ul = document.getElementById('paginationControls');
const total = data.pages || 1;
const page = data.page || 1;
if (total <= 1) {
ul.innerHTML = '';
return;
}
if (total <= 1) { ul.innerHTML = ''; return; }
let html = '';
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a>
</li>`;
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a></li>`;
const range = 2;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
html += `<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a>
</li>`;
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a></li>`;
} else if (i === page - range - 1 || i === page + range + 1) {
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
}
}
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a>
</li>`;
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a></li>`;
ul.innerHTML = html;
}
// ── Multi-CODMAT Map Modal ───────────────────────
function openMapModal(sku, productName) {
currentMapSku = sku;
document.getElementById('mapSku').textContent = sku;
document.getElementById('mapCodmat').value = productName || '';
document.getElementById('mapCantitate').value = '1';
document.getElementById('mapProcent').value = '100';
document.getElementById('mapSelectedArticle').textContent = '';
document.getElementById('mapAutocomplete').classList.add('d-none');
document.getElementById('mapProductName').textContent = productName || '-';
document.getElementById('mapPctWarning').style.display = 'none';
const container = document.getElementById('mapCodmatLines');
container.innerHTML = '';
addMapCodmatLine();
// Pre-search with product name
if (productName) {
autocompleteMap(productName);
setTimeout(() => {
const input = container.querySelector('.mc-codmat');
if (input) {
input.value = productName;
mcAutocomplete(input,
container.querySelector('.mc-ac-dropdown'),
container.querySelector('.mc-selected'));
}
}, 100);
}
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; }
function addMapCodmatLine() {
const container = document.getElementById('mapCodmatLines');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 mc-line';
div.innerHTML = `
<div class="mb-2 position-relative">
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
<small class="text-muted mc-selected"></small>
</div>
<div class="row">
<div class="col-5">
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001">
</div>
<div class="col-5">
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
<input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100">
</div>
<div class="col-2 d-flex align-items-end">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : ''}
</div>
</div>
`;
container.appendChild(div);
const input = div.querySelector('.mc-codmat');
const dropdown = div.querySelector('.mc-ac-dropdown');
const selected = div.querySelector('.mc-selected');
input.addEventListener('input', () => {
clearTimeout(mapAcTimeout);
mapAcTimeout = setTimeout(() => mcAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function mcAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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; }
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.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="mcSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch (err) {
dropdown.classList.add('d-none');
}
} catch { 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');
function mcSelectArticle(el, codmat, label) {
const line = el.closest('.mc-line');
line.querySelector('.mc-codmat').value = codmat;
line.querySelector('.mc-selected').textContent = label;
line.querySelector('.mc-ac-dropdown').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;
const lines = document.querySelectorAll('.mc-line');
const mappings = [];
if (!codmat) { alert('Selecteaza un CODMAT'); return; }
for (const line of lines) {
const codmat = line.querySelector('.mc-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.mc-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.mc-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('mapPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('mapPctWarning').style.display = '';
return;
}
}
document.getElementById('mapPctWarning').style.display = 'none';
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
})
});
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentMapSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentMapSku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissing(currentPage);