diff --git a/api/app/database.py b/api/app/database.py index 8cca003..0129293 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -352,6 +352,7 @@ def init_sqlite(): ("adresa_facturare_roa", "TEXT"), ("anaf_denumire_mismatch", "INTEGER DEFAULT 0"), ("denumire_anaf", "TEXT"), + ("address_mismatch", "INTEGER DEFAULT 0"), ]: if col not in order_cols: conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") diff --git a/api/app/services/anaf_service.py b/api/app/services/anaf_service.py index 63acfbe..02f296a 100644 --- a/api/app/services/anaf_service.py +++ b/api/app/services/anaf_service.py @@ -183,8 +183,10 @@ def normalize_company_name(name: str) -> str: result = name.strip().upper() # Strip diacritics result = result.translate(_DIACRITICS) - # Remove common suffixes - result = re.sub(r'\b(S\.?R\.?L\.?|S\.?A\.?|S\.?C\.?|S\.?N\.?C\.?|S\.?C\.?S\.?)\b', '', result) + # Remove common suffixes and legal forms + result = re.sub(r'\b(S\.?R\.?L\.?|S\.?A\.?|S\.?C\.?|S\.?N\.?C\.?|S\.?C\.?S\.?|P\.?F\.?A\.?|INTREPRINDERE\s+INDIVIDUALA)\b', '', result) + # Strip II only at start of name (avoid matching Roman numeral II in "TEHNICA II SRL") + result = re.sub(r'^I\.?I\.?\s+', '', result) # Remove punctuation and extra spaces result = re.sub(r'[^\w\s]', '', result) result = re.sub(r'\s+', ' ', result).strip() diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 0c65cc3..37573ea 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -697,7 +697,7 @@ async def get_orders(page: int = 1, per_page: int = 50, if status_filter.upper() == "IMPORTED": data_clauses.append("UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')") elif status_filter.upper() == "DIFFS": - data_clauses.append("(anaf_cod_fiscal_adjusted = 1 OR anaf_denumire_mismatch = 1)") + data_clauses.append("(anaf_cod_fiscal_adjusted = 1 OR anaf_denumire_mismatch = 1 OR address_mismatch = 1)") else: data_clauses.append("UPPER(status) = ?") data_params.append(status_filter.upper()) @@ -751,9 +751,9 @@ async def get_orders(page: int = 1, per_page: int = 50, cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params) uninvoiced_old = (await cursor.fetchone())[0] - # Diffs count: orders with ANAF adjustments + # Diffs count: orders with ANAF adjustments or address mismatches diffs_clauses = list(base_clauses) + [ - "(anaf_cod_fiscal_adjusted = 1 OR anaf_denumire_mismatch = 1)" + "(anaf_cod_fiscal_adjusted = 1 OR anaf_denumire_mismatch = 1 OR address_mismatch = 1)" ] diffs_where = "WHERE " + " AND ".join(diffs_clauses) cursor = await db.execute(f"SELECT COUNT(*) FROM orders {diffs_where}", base_params) @@ -1138,6 +1138,7 @@ async def update_order_partner_data(order_number: str, partner_data: dict): adresa_facturare_roa = ?, anaf_denumire_mismatch = ?, denumire_anaf = ?, + address_mismatch = ?, updated_at = datetime('now') WHERE order_number = ? """, ( @@ -1153,6 +1154,7 @@ async def update_order_partner_data(order_number: str, partner_data: dict): partner_data.get("adresa_facturare_roa"), partner_data.get("anaf_denumire_mismatch", 0), partner_data.get("denumire_anaf"), + partner_data.get("address_mismatch", 0), order_number, )) await db.commit() diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index e28e34e..aa15898 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -1,6 +1,8 @@ import asyncio import json import logging +import re +import unicodedata import uuid from datetime import datetime, timedelta from zoneinfo import ZoneInfo @@ -18,6 +20,34 @@ from .. import database logger = logging.getLogger(__name__) + +def _addr_match(gomag_json, roa_json): + """Server-side address comparison matching JS addrMatch().""" + if not gomag_json or not roa_json: + return True + try: + g = json.loads(gomag_json) if isinstance(gomag_json, str) else gomag_json + r = json.loads(roa_json) if isinstance(roa_json, str) else roa_json + except (json.JSONDecodeError, TypeError): + return True + _ADDR_WORDS = re.compile( + r'\b(STR|STRADA|NR|NUMAR|NUMARUL|BL|BLOC|SC|SCARA|AP|APART|APARTAMENT|' + r'ET|ETAJ|COM|COMUNA|SAT|MUN|MUNICIPIUL|JUD|JUDETUL|CARTIER|PARTER|SECTOR|ORAS)\b' + ) + def norm(s): + s = unicodedata.normalize('NFD', s or '') + s = re.sub(r'[\u0300-\u036f]', '', s).upper() + s = _ADDR_WORDS.sub('', s) + return re.sub(r'[^A-Z0-9]', '', s) + g_street = norm(g.get('address') or g.get('strada') or '') + r_street = norm((r.get('strada') or '') + (r.get('numar') or '')) + g_city = norm(g.get('city') or g.get('localitate') or '') + r_city = norm(r.get('localitate') or '') + g_region = norm(g.get('region') or g.get('judet') or '') + r_region = norm(r.get('judet') or '') + return g_street == r_street and g_city == r_city and g_region == r_region + + # Sync state _sync_lock = asyncio.Lock() _current_sync = None # dict with run_id, status, progress info @@ -802,6 +832,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None partner_data["anaf_denumire_mismatch"] = 1 partner_data["denumire_anaf"] = anaf_data_for_order["denumire_anaf"] + # Address mismatch check (server-side, mirrors JS addrMatch) + livr_match = _addr_match(partner_data.get("adresa_livrare_gomag"), partner_data.get("adresa_livrare_roa")) + fact_match = _addr_match(partner_data.get("adresa_facturare_gomag"), partner_data.get("adresa_facturare_roa")) + partner_data["address_mismatch"] = 1 if (not livr_match or not fact_match) else 0 + await sqlite_service.update_order_partner_data(order.number, partner_data) if not result["success"]: diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css index 68399d1..c738698 100644 --- a/api/app/static/css/style.css +++ b/api/app/static/css/style.css @@ -1176,6 +1176,11 @@ tr.mapping-deleted td { .anaf-badge-warn { background: var(--warning-light); color: var(--warning-text); } .anaf-badge-gray { background: var(--cancelled-light); color: var(--text-muted); } +/* Diff-type badges (reuses .anaf-badge sizing per DESIGN.md type scale minimum) */ +.diff-badge { display:inline-block; font-family:var(--font-body); font-size:12px; font-weight:500; padding:2px 8px; border-radius:9999px; margin-left:4px; vertical-align:middle; } +.diff-badge-anaf { background:var(--error-light); color:var(--error-text); } +.diff-badge-info { background:var(--warning-light); color:var(--warning-text); } + /* ── Compact order detail layout ──────────────── */ .detail-col-label { font-family: var(--font-display); diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 7d2cebd..9b320bb 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -368,7 +368,7 @@ async function loadDashOrders() { ${statusDot(o.status)} ${dateStr} ${renderClientCell(o)} - ${esc(o.order_number)} + ${esc(o.order_number)}${(o.anaf_cod_fiscal_adjusted===1||o.anaf_denumire_mismatch===1||(o.cod_fiscal_gomag&&o.anaf_platitor_tva===0))?'':''}${(o.address_mismatch===1||o.price_match===false)?'':''} ${o.items_count || 0} ${fmtCost(o.delivery_cost)} ${fmtCost(o.discount_total)} @@ -394,12 +394,14 @@ async function loadDashOrders() { } const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014'; const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : ''; + const anafDiffDot = (o.anaf_cod_fiscal_adjusted===1||o.anaf_denumire_mismatch===1||(o.cod_fiscal_gomag&&o.anaf_platitor_tva===0)) ? '' : ''; + const addrDiffDot = o.address_mismatch===1 ? '' : ''; const priceMismatch = o.price_match === false ? ' ' : ''; return `
${statusDot(o.status)} ${dateFmt} ${esc(name)} - x${o.items_count || 0}${totalStr ? ' · ' + priceMismatch + '' + totalStr + '' : ''} + x${o.items_count || 0}${totalStr ? ' · ' + anafDiffDot + addrDiffDot + priceMismatch + '' + totalStr + '' : ''}
`; }).join(''); } diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js index 38ddd1b..d7e4969 100644 --- a/api/app/static/js/shared.js +++ b/api/app/static/js/shared.js @@ -856,7 +856,7 @@ function addrMatch(gomag, roa) { function norm(s) { return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '') .toUpperCase() - .replace(/\b(STR|NR|BL|SC|AP|ET|ETAJ|APART)\b/g, '') + .replace(/\b(STR|STRADA|NR|NUMAR|NUMARUL|BL|BLOC|SC|SCARA|AP|APART|APARTAMENT|ET|ETAJ|COM|COMUNA|SAT|MUN|MUNICIPIUL|JUD|JUDETUL|CARTIER|PARTER|SECTOR|ORAS)\b/g, '') .replace(/[^A-Z0-9]/g, ''); } const gStreet = norm(gomag.address || gomag.strada || ''); @@ -1013,22 +1013,25 @@ function _renderHeaderInfo(order) { addressLines.innerHTML = html; - // Diff summary badge in modal header - let diffCount = 0; - if (isPJ && pi.anaf_denumire_mismatch) diffCount++; - if (order.price_check && order.price_check.mismatches > 0) diffCount += order.price_check.mismatches; - if (addr) { - if (addr.livrare_roa && !addrMatch(addr.livrare_gomag, addr.livrare_roa)) diffCount++; - if (addr.facturare_roa && !addrMatch(addr.facturare_gomag, addr.facturare_roa)) diffCount++; - } + // Typed diff badges in modal header const orderNumEl = document.getElementById('detailOrderNumber'); - if (orderNumEl && diffCount > 0) { - const existing = orderNumEl.querySelector('.diff-badge'); - if (existing) existing.remove(); - const badge = document.createElement('span'); - badge.className = 'diff-badge badge ms-2'; - badge.style.cssText = 'background:var(--warning-light);color:var(--warning-text);font-size:11px;vertical-align:middle'; - badge.textContent = diffCount + ' diferente'; - orderNumEl.parentNode.insertBefore(badge, orderNumEl.nextSibling); + if (orderNumEl) { + orderNumEl.parentNode.querySelectorAll('.diff-badge').forEach(b => b.remove()); + const badges = []; + if (isPJ && pi.anaf_cod_fiscal_adjusted) badges.push({label:'CUI', cls:'diff-badge-anaf', aria:'CUI ajustat conform ANAF'}); + if (isPJ && pi.anaf_denumire_mismatch) badges.push({label:'Denumire', cls:'diff-badge-anaf', aria:'Denumire diferita fata de ANAF'}); + if (isPJ && pi.anaf_platitor_tva === 0) badges.push({label:'TVA', cls:'diff-badge-anaf', aria:'Neplatitor TVA conform ANAF'}); + if (addr && addr.livrare_roa && !addrMatch(addr.livrare_gomag, addr.livrare_roa)) badges.push({label:'Adr. livr.', cls:'diff-badge-info', aria:'Adresa livrare diferita'}); + if (addr && addr.facturare_roa && !addrMatch(addr.facturare_gomag, addr.facturare_roa)) badges.push({label:'Adr. fact.', cls:'diff-badge-info', aria:'Adresa facturare diferita'}); + if (order.price_check && order.price_check.mismatches > 0) badges.push({label:'Preturi (' + order.price_check.mismatches + ')', cls:'diff-badge-info', aria:'Preturi diferite: ' + order.price_check.mismatches}); + let insertAfter = orderNumEl; + badges.forEach(b => { + const el = document.createElement('span'); + el.className = 'diff-badge ' + b.cls; + el.setAttribute('aria-label', b.aria); + el.textContent = b.label; + insertAfter.parentNode.insertBefore(el, insertAfter.nextSibling); + insertAfter = el; + }); } } diff --git a/api/app/templates/base.html b/api/app/templates/base.html index cae4a68..7a6277f 100644 --- a/api/app/templates/base.html +++ b/api/app/templates/base.html @@ -19,7 +19,7 @@ {% set rp = request.scope.get('root_path', '') %} - + @@ -161,7 +161,7 @@ - + + {% endblock %} diff --git a/api/database-scripts/05_pack_import_parteneri.pck b/api/database-scripts/05_pack_import_parteneri.pck index 74cb5a0..6fe66a2 100644 --- a/api/database-scripts/05_pack_import_parteneri.pck +++ b/api/database-scripts/05_pack_import_parteneri.pck @@ -6,6 +6,7 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS -- 02.04.2026 - cautare CUI strict (p_strict_search=1) sau dual anti-dedup (NULL) -- 02.04.2026 - parser adrese: extrage APARTAMENT/SCARA/ETAJ embedded in strada (fix "Nr17 apartament 8") -- 02.04.2026 - fallback cautare PF cu permutari nume (evita duplicate la swap firstname/lastname) + -- 06.04.2026 - eliminat TIER 2 cautare adresa (judet+loc fara strada) — creeaza adresa noua cand strada difera -- ==================================================================== -- CONSTANTS @@ -948,23 +949,6 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS when NO_DATA_FOUND then p_id_adresa := null; end; - -- TIER 2: county + city (no street) but ONLY with valid id_loc - if p_id_adresa is null then - begin - select id_adresa into p_id_adresa from ( - select id_adresa - from vadrese_parteneri - where id_part = p_id_part - and judet = v_judet - and localitate = v_localitate - and id_loc IS NOT NULL - order by principala desc, id_adresa desc - ) where rownum = 1; - exception - when NO_DATA_FOUND then p_id_adresa := null; - end; - end if; - -- Adaug o adresa if p_id_adresa is null then -- caut judetul diff --git a/api/tests/test_business_rules.py b/api/tests/test_business_rules.py index d83f600..d352a44 100644 --- a/api/tests/test_business_rules.py +++ b/api/tests/test_business_rules.py @@ -634,3 +634,85 @@ class TestGetPricesForOrderCantitateRoa: assert result["items"][0]["match"] is None assert result["items"][0]["kit"] is True assert result["summary"]["mismatches"] == 0 + + +# ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ── + + +class TestNormalizeCompanyNameExtended: + """Tests for extended legal form stripping in normalize_company_name.""" + + def test_strip_ii_at_start(self): + from app.services.anaf_service import normalize_company_name + assert normalize_company_name("II CHIRITA N ION") == "CHIRITA N ION" + + def test_no_strip_ii_mid_name(self): + from app.services.anaf_service import normalize_company_name + result = normalize_company_name("TEHNICA II SRL") + assert "II" in result # II should remain (Roman numeral) + + def test_strip_pfa(self): + from app.services.anaf_service import normalize_company_name + assert normalize_company_name("PFA POPESCU ION") == "POPESCU ION" + + def test_strip_intreprindere_individuala(self): + from app.services.anaf_service import normalize_company_name + result = normalize_company_name("CHIRIȚĂ N. ION ÎNTREPRINDERE INDIVIDUALĂ") + assert "CHIRITA N ION" == result + + def test_strip_ii_with_dots(self): + from app.services.anaf_service import normalize_company_name + assert normalize_company_name("I.I. CHIRITA N ION") == "CHIRITA N ION" + + +# ── _addr_match (server-side address comparison) ─��� + + +class TestAddrMatch: + """Tests for _addr_match server-side address comparison.""" + + def test_matching_addresses(self): + from app.services.sync_service import _addr_match + import json + g = json.dumps({"address": "Str. Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"}) + r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"}) + assert _addr_match(g, r) is True + + def test_street_mismatch(self): + from app.services.sync_service import _addr_match + import json + g = json.dumps({"address": "Str. Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"}) + r = json.dumps({"strada": "Victoriei", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"}) + assert _addr_match(g, r) is False + + def test_city_mismatch(self): + from app.services.sync_service import _addr_match + import json + g = json.dumps({"address": "Elisabeta 10", "city": "Brasov", "region": "Brasov"}) + r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Cluj", "judet": "Brasov"}) + assert _addr_match(g, r) is False + + def test_county_mismatch(self): + from app.services.sync_service import _addr_match + import json + g = json.dumps({"address": "Elisabeta 10", "city": "Brasov", "region": "Brasov"}) + r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Brasov", "judet": "Cluj"}) + assert _addr_match(g, r) is False + + def test_none_input_returns_true(self): + from app.services.sync_service import _addr_match + assert _addr_match(None, None) is True + assert _addr_match(None, '{"strada":"x"}') is True + assert _addr_match('{"address":"x"}', None) is True + + def test_strada_prefix_stripping(self): + """'Strada Elisabeta' should match 'ELISABETA' after normalization.""" + from app.services.sync_service import _addr_match + import json + g = json.dumps({"address": "Strada Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"}) + r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"}) + assert _addr_match(g, r) is True + + def test_malformed_json_returns_true(self): + from app.services.sync_service import _addr_match + assert _addr_match("{bad json", '{"strada":"x"}') is True