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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user