feat(anaf-dedup): ANAF partner dedup + address fix + UI enrichment

Prevent partner duplicates via ANAF CUI verification and dual PL/SQL
search. Fix address matching with street-level comparison and diacritics
normalization. Show partner/address comparison in order detail modal.

- New anaf_service.py: batch ANAF API client with chunking, retry, cache
- PL/SQL: dual CUI search (bare/RO+bare/RO space+bare), 3-tier address
  search (street+city+id_loc → city+id_loc → create), strip_diacritics
  at storage for addresses and partner names
- SQLite: anaf_cache table, 12 new order columns for partner/address data
- import_service: cod_fiscal_override param, return partner/address from Oracle
- sync_service: ANAF batch integration, denomination mismatch detection,
  cache pre-population trigger
- Router: enriched order_detail with partner_info + addresses JSON
- UI: collapsible Detalii Partener + Adrese Comparativ sections in modal,
  auto-expand on mismatch, ANAF badges, mobile address cards
- Dashboard: address quality attention indicator
- New scan_duplicate_partners.py script for one-time duplicate audit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-01 14:36:52 +00:00
parent 3b9198d742
commit 2f593c30f6
12 changed files with 925 additions and 64 deletions

View File

@@ -1095,3 +1095,93 @@ tr.mapping-deleted td {
color: var(--info);
text-decoration: underline;
}
/* ── Partner/Address section headers (ANAF dedup) ── */
.detail-section-header {
font-family: var(--font-display);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
padding: 10px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.detail-section-header:hover { color: var(--text-primary); }
.detail-section-header .bi-chevron-right {
transition: transform 150ms ease-out;
font-size: 10px;
}
.detail-section-header[aria-expanded="true"] .bi-chevron-right {
transform: rotate(90deg);
}
.detail-section-header .alert-count {
font-family: var(--font-body);
font-size: 11px;
font-weight: 500;
background: var(--error-light);
color: var(--error-text);
padding: 2px 8px;
border-radius: 9999px;
margin-left: auto;
}
.detail-section-body { padding: 12px 0; }
.partner-row { display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 8px; }
.partner-field { min-width: 140px; }
.partner-label {
font-family: var(--font-display);
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
}
.partner-value {
font-family: var(--font-data);
font-size: 13px;
color: var(--text-primary);
}
.anaf-badge {
display: inline-block;
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 9999px;
}
.anaf-badge-ok { background: var(--success-light); color: var(--success-text); }
.anaf-badge-warn { background: var(--warning-light); color: var(--warning-text); }
.anaf-badge-gray { background: var(--cancelled-light); color: var(--text-muted); }
.addr-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.addr-table th {
font-family: var(--font-display);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
color: var(--text-muted);
padding: 6px 8px;
text-align: left;
}
.addr-table td { padding: 8px; vertical-align: top; font-family: var(--font-body); }
.addr-mismatch { background: var(--warning-light) !important; }
.addr-efactura-risk { background: var(--error-light) !important; }
.addr-label {
font-family: var(--font-display);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 4px;
}
/* Mobile address cards */
.addr-card { border: 1px solid var(--border); border-radius: var(--card-radius); margin-bottom: 8px; overflow: hidden; }
.addr-card-header { padding: 6px 10px; font-family: var(--font-display); font-size: 11px; font-weight: 500; text-transform: uppercase; color: var(--text-secondary); background: var(--surface-raised); }
.addr-card-row { padding: 8px 10px; }
.addr-card-row + .addr-card-row { border-top: 1px dashed var(--border-subtle); }
.addr-card-source { font-size: 11px; font-weight: 500; color: var(--text-muted); margin-bottom: 2px; }
.addr-card-text { font-family: var(--font-body); font-size: 13px; }
.addr-card.mismatch { border-left: 3px solid var(--warning); }
.addr-card.match .addr-match-label { font-size: 11px; color: var(--success-text); }

View File

@@ -337,13 +337,16 @@ async function loadDashOrders() {
const unmapped = c.unresolved_skus || 0;
const nefact = c.nefacturate || 0;
if (errors === 0 && unmapped === 0 && nefact === 0) {
const incompleteAddr = c.incomplete_addresses || 0;
if (errors === 0 && unmapped === 0 && nefact === 0 && incompleteAddr === 0) {
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
} else {
let items = [];
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
if (c.incomplete_addresses > 0) items.push(`<span class="attention-item attention-warning"><i class="bi bi-geo-alt"></i> ${c.incomplete_addresses} adrese incomplete</span>`);
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
}
}

View File

@@ -519,6 +519,15 @@ async function renderOrderDetailModal(orderNumber, opts) {
if (priceCheckEl) priceCheckEl.innerHTML = '';
const reconEl = document.getElementById('detailInvoiceRecon');
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
// Reset partner/address sections
const partnerSection = document.getElementById('detailPartnerSection');
if (partnerSection) partnerSection.style.display = 'none';
const addressSection = document.getElementById('detailAddressSection');
if (addressSection) addressSection.style.display = 'none';
const partnerBody = document.getElementById('partnerInfoBody');
if (partnerBody) partnerBody.innerHTML = '';
const addressBody = document.getElementById('addressInfoBody');
if (addressBody) addressBody.innerHTML = '';
const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl);
@@ -583,6 +592,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
reconEl.style.display = 'none';
}
// Render partner + address sections
_renderPartnerSection(order);
_renderAddressSection(order);
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = '';
@@ -817,3 +830,198 @@ function statusDot(status) {
return '<span class="dot dot-gray"></span>';
}
}
// ── Partner & Address Section Rendering ──────────
function _renderPartnerSection(order) {
const section = document.getElementById('detailPartnerSection');
const body = document.getElementById('partnerInfoBody');
const alertEl = document.getElementById('partnerAlertCount');
if (!section || !body) return;
const pi = order.partner_info;
if (!pi || !pi.cod_fiscal_gomag) {
section.style.display = 'none';
return;
}
section.style.display = '';
let alertCount = 0;
if (pi.anaf_cod_fiscal_adjusted) alertCount++;
if (pi.anaf_denumire_mismatch) alertCount++;
if (alertEl) {
if (alertCount > 0) {
alertEl.textContent = alertCount + (alertCount === 1 ? ' alerta' : ' alerte');
alertEl.style.display = '';
} else {
alertEl.style.display = 'none';
}
}
// ANAF badge
let anafBadge;
if (pi.anaf_platitor_tva === 1) {
anafBadge = '<span class="anaf-badge anaf-badge-ok">Platitor TVA</span>';
} else if (pi.anaf_platitor_tva === 0) {
anafBadge = '<span class="anaf-badge anaf-badge-warn">Neplatitor TVA</span>';
} else {
anafBadge = '<span class="anaf-badge anaf-badge-gray">Neverificat</span>';
}
// CUI correction badge
let cuiCorrBadge = '';
if (pi.anaf_cod_fiscal_adjusted) {
cuiCorrBadge = ' <span class="anaf-badge anaf-badge-warn"><i class="bi bi-arrow-left-right"></i> Corectat ANAF</span>';
}
// Denomination mismatch
let denomHtml = '';
if (pi.anaf_denumire_mismatch && pi.denumire_anaf) {
denomHtml = `<div style="background:var(--warning-light);padding:8px 12px;border-radius:var(--card-radius);margin-top:8px">
<span class="partner-label" style="color:var(--warning-text)"><i class="bi bi-exclamation-triangle"></i> Denumire diferita</span><br>
<span style="font-size:13px">GoMag: <strong>${esc(order.customer_name || '')}</strong></span><br>
<span style="font-size:13px">ANAF: <strong>${esc(pi.denumire_anaf)}</strong></span>
</div>`;
}
body.innerHTML = `
<div class="partner-row">
<div class="partner-field">
<div class="partner-label">CUI GoMag</div>
<div class="partner-value">${esc(pi.cod_fiscal_gomag)}</div>
</div>
<div class="partner-field">
<div class="partner-label">CUI ROA</div>
<div class="partner-value">${esc(pi.cod_fiscal_roa || '-')}${cuiCorrBadge}</div>
</div>
<div class="partner-field">
<div class="partner-label">Partener ROA</div>
<div style="font-family:var(--font-body);font-size:14px;font-weight:500">${esc(pi.denumire_roa || '-')}</div>
</div>
</div>
<div class="partner-row">
<div class="partner-field">
<div class="partner-label">ANAF</div>
<div>${anafBadge}</div>
</div>
</div>
${denomHtml}`;
// Auto-expand on mismatch
if (alertCount > 0) {
const collapseEl = document.getElementById('detailPartnerInfo');
if (collapseEl && !collapseEl.classList.contains('show')) {
new bootstrap.Collapse(collapseEl, { show: true });
}
}
}
function _renderAddressSection(order) {
const section = document.getElementById('detailAddressSection');
const body = document.getElementById('addressInfoBody');
const alertEl = document.getElementById('addressAlertCount');
if (!section || !body) return;
const addr = order.addresses;
if (!addr || (!addr.livrare_gomag && !addr.facturare_gomag)) {
section.style.display = 'none';
return;
}
section.style.display = '';
let mismatchCount = 0;
function fmtAddr(a) {
if (!a) return '-';
if (typeof a === 'string') return esc(a);
const parts = [a.address || a.strada || '', a.numar || ''].filter(Boolean);
const line1 = parts.join(' ').trim();
const line2 = [a.city || a.localitate || '', a.region || a.judet || ''].filter(Boolean).join(', ');
return esc(line1) + (line2 ? '<br>' + esc(line2) : '');
}
function addrMatch(gomag, roa) {
if (!gomag || !roa) return true; // can't compare
const g = JSON.stringify(gomag).toUpperCase().replace(/[^A-Z0-9]/g, '');
const r = JSON.stringify(roa).toUpperCase().replace(/[^A-Z0-9]/g, '');
return g === r;
}
function hasEfacturaRisk(roa) {
if (!roa || typeof roa === 'string') return false;
return !roa.judet || !roa.localitate;
}
const livrMatch = addrMatch(addr.livrare_gomag, addr.livrare_roa);
const factMatch = addrMatch(addr.facturare_gomag, addr.facturare_roa);
if (!livrMatch) mismatchCount++;
if (!factMatch) mismatchCount++;
const livrRisk = hasEfacturaRisk(addr.livrare_roa);
const factRisk = hasEfacturaRisk(addr.facturare_roa);
if (alertEl) {
if (mismatchCount > 0) {
alertEl.textContent = mismatchCount + (mismatchCount === 1 ? ' diferenta' : ' diferente');
alertEl.style.display = '';
} else {
alertEl.style.display = 'none';
}
}
// Desktop: 2-column table
const livrClass = livrRisk ? 'addr-efactura-risk' : (!livrMatch ? 'addr-mismatch' : '');
const factClass = factRisk ? 'addr-efactura-risk' : (!factMatch ? 'addr-mismatch' : '');
const desktopHtml = `
<table class="addr-table d-none d-md-table">
<thead><tr><th></th><th>GOMAG</th><th>ROA</th></tr></thead>
<tbody>
<tr class="${livrClass}">
<td><span class="addr-label">LIVRARE</span>${livrRisk ? '<br><small style="color:var(--error-text)">⚠ Risc eFactura</small>' : ''}</td>
<td>${fmtAddr(addr.livrare_gomag)}</td>
<td>${fmtAddr(addr.livrare_roa)}</td>
</tr>
<tr class="${factClass}">
<td><span class="addr-label">FACTURARE</span>${factRisk ? '<br><small style="color:var(--error-text)">⚠ Risc eFactura</small>' : ''}</td>
<td>${fmtAddr(addr.facturare_gomag)}</td>
<td>${fmtAddr(addr.facturare_roa)}</td>
</tr>
</tbody>
</table>`;
// Mobile: stacked cards
function mobileCard(label, gomag, roa, isMatch, isRisk) {
const cls = isRisk ? ' addr-efactura-risk' : (!isMatch ? ' mismatch' : ' match');
const matchLabel = isMatch ? '<div class="addr-match-label">✓ Adrese identice</div>' : '';
const riskLabel = isRisk ? '<div style="font-size:11px;color:var(--error-text)">⚠ Risc eFactura</div>' : '';
return `<div class="addr-card${cls}">
<div class="addr-card-header">${label}</div>
<div class="addr-card-row">
<div class="addr-card-source">GoMag:</div>
<div class="addr-card-text">${fmtAddr(gomag)}</div>
</div>
<div class="addr-card-row">
<div class="addr-card-source">ROA:</div>
<div class="addr-card-text">${fmtAddr(roa)}</div>
</div>
${matchLabel}${riskLabel}
</div>`;
}
const mobileHtml = `<div class="d-md-none">
${mobileCard('LIVRARE', addr.livrare_gomag, addr.livrare_roa, livrMatch, livrRisk)}
${mobileCard('FACTURARE', addr.facturare_gomag, addr.facturare_roa, factMatch, factRisk)}
</div>`;
body.innerHTML = desktopHtml + mobileHtml;
// Auto-expand on mismatch
if (mismatchCount > 0) {
const collapseEl = document.getElementById('detailAddressInfo');
if (collapseEl && !collapseEl.classList.contains('show')) {
new bootstrap.Collapse(collapseEl, { show: true });
}
}
}