From ecde7fe4405887433fad4f32cee24b6775478f6a Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 7 Apr 2026 12:35:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(address):=20ROA=20address=20cache=20refres?= =?UTF-8?q?h=20=E2=80=94=208-field=20format=20+=20manual=20refresh=20endpo?= =?UTF-8?q?int?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 address format upgrade (pre-existing working tree changes): - import_service: extend vadrese_parteneri query to 8 fields (strada/numar/bloc/scara/apart/etaj/localitate/judet); strip trailing city name from address string passed to Oracle - sync_service: extend _addr_match to compare bloc/scara/apart in addition to strada/numar - 05_pack_import_parteneri.pck: updated PL/SQL package New: address cache refresh mechanism: - sqlite_service: add get_order_address_ids(), update_order_address_cache() (targeted 3-column update, no ANAF fields touched), get_orders_with_address_ids() - sync.py: POST /api/orders/{order_number}/refresh-address endpoint (404/422/503/200); batch Oracle address refresh in refresh_invoices (single IN roundtrip, per-order mismatch recomputed) - UI: refresh button (⟳) in ADRESE modal header (base.html); refreshOrderAddress() with loading state + toast (dashboard.js v43); window._detailOrderNumber global (shared.js v32) - tests: TestRefreshOrderAddress — 4 tests (404, 422, 503, 200 with 8-field assert) Oracle prod fix applied directly: ADRESE_PARTENERI id_adresa=4116 STRADA VASILE→VASILE GOLDIS Co-Authored-By: Claude Sonnet 4.6 --- api/app/routers/sync.py | 108 ++++++++++++++- api/app/services/import_service.py | 24 +++- api/app/services/sqlite_service.py | 53 ++++++++ api/app/services/sync_service.py | 2 +- api/app/static/js/dashboard.js | 20 +++ api/app/static/js/shared.js | 11 +- api/app/templates/base.html | 11 +- api/app/templates/dashboard.html | 2 +- .../05_pack_import_parteneri.pck | 31 +++++ api/tests/test_business_rules.py | 125 ++++++++++++++++++ 10 files changed, 377 insertions(+), 10 deletions(-) diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index b148a21..bbb2420 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -5,7 +5,7 @@ from datetime import datetime logger = logging.getLogger(__name__) -from fastapi import APIRouter, Request, BackgroundTasks +from fastapi import APIRouter, HTTPException, Request, BackgroundTasks from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse from pydantic import BaseModel @@ -815,6 +815,55 @@ async def refresh_invoices(): await sqlite_service.mark_order_deleted_in_roa(o["order_number"]) orders_deleted += 1 + # Cherry-pick A: Batch refresh Oracle addresses for all orders with stored address IDs + addr_rows = await sqlite_service.get_orders_with_address_ids() + if addr_rows: + def _fetch_addresses(rows): + unique_ids = list( + {r["id_adresa_livrare"] for r in rows if r.get("id_adresa_livrare")} + | {r["id_adresa_facturare"] for r in rows if r.get("id_adresa_facturare")} + ) + conn = database.get_oracle_connection() + try: + with conn.cursor() as cur: + placeholders = ",".join([f":{i}" for i in range(len(unique_ids))]) + cur.execute( + f"SELECT id_adresa, strada, numar, bloc, scara, apart, etaj, localitate, judet" + f" FROM vadrese_parteneri WHERE id_adresa IN ({placeholders})", + unique_ids, + ) + return {row[0]: row for row in cur.fetchall()} + finally: + database.pool.release(conn) + + try: + addr_map = await asyncio.to_thread(_fetch_addresses, addr_rows) + + def _row_to_dict(r): + return {"strada": r[1], "numar": r[2], "bloc": r[3], "scara": r[4], + "apart": r[5], "etaj": r[6], "localitate": r[7], "judet": r[8]} + + addresses_refreshed = 0 + for row in addr_rows: + livr_id = row.get("id_adresa_livrare") + fact_id = row.get("id_adresa_facturare") + livr_raw = addr_map.get(livr_id) + fact_raw = addr_map.get(fact_id) if fact_id and fact_id != livr_id else livr_raw + if not livr_raw: + continue + livr_roa = _row_to_dict(livr_raw) + fact_roa = _row_to_dict(fact_raw) if fact_raw else livr_roa + mismatch = not sync_service._addr_match( + row.get("adresa_livrare_gomag"), json.dumps(livr_roa) + ) + await sqlite_service.update_order_address_cache( + row["order_number"], livr_roa, fact_roa, mismatch + ) + addresses_refreshed += 1 + logger.info(f"refresh_invoices: refreshed {addresses_refreshed} order addresses from Oracle") + except Exception as addr_err: + logger.warning(f"refresh_invoices: address batch refresh failed: {addr_err}") + checked = len(uninvoiced) + len(invoiced) + len(all_imported) return { "checked": checked, @@ -826,6 +875,63 @@ async def refresh_invoices(): return {"error": str(e), "invoices_added": 0} +@router.post("/api/orders/{order_number}/refresh-address") +async def refresh_order_address(order_number: str): + """Re-fetch ROA address from Oracle for an existing order and update SQLite cache.""" + row = await sqlite_service.get_order_address_ids(order_number) + if not row: + raise HTTPException(status_code=404, detail="Order not found") + + id_livr = row.get("id_adresa_livrare") + id_fact = row.get("id_adresa_facturare") + + if not id_livr and not id_fact: + raise HTTPException(status_code=422, detail="Order has no Oracle address IDs") + + def _fetch(): + conn = database.get_oracle_connection() + try: + with conn.cursor() as cur: + def fetch_one(id_adresa): + if not id_adresa: + return None + cur.execute( + "SELECT strada, numar, bloc, scara, apart, etaj, localitate, judet" + " FROM vadrese_parteneri WHERE id_adresa = :1", + [id_adresa], + ) + r = cur.fetchone() + if not r: + return None + return {"strada": r[0], "numar": r[1], "bloc": r[2], "scara": r[3], + "apart": r[4], "etaj": r[5], "localitate": r[6], "judet": r[7]} + + livr = fetch_one(id_livr) + fact = fetch_one(id_fact) if id_fact and id_fact != id_livr else livr + return livr, fact + finally: + database.pool.release(conn) + + try: + livr_roa, fact_roa = await asyncio.to_thread(_fetch) + except Exception as e: + raise HTTPException(status_code=503, detail=f"Oracle unavailable: {e}") + + old_livr = row.get("adresa_livrare_roa") + mismatch = not sync_service._addr_match( + row.get("adresa_livrare_gomag"), json.dumps(livr_roa) + ) if livr_roa else True + + if livr_roa: + old_strada = json.loads(old_livr or "{}").get("strada", "?") + logger.info( + f"refresh_address: {order_number} strada {old_strada!r}→{livr_roa['strada']!r} mismatch→{mismatch}" + ) + + await sqlite_service.update_order_address_cache(order_number, livr_roa, fact_roa, mismatch) + return {"adresa_livrare_roa": livr_roa, "adresa_facturare_roa": fact_roa, "address_mismatch": mismatch} + + @router.put("/api/sync/schedule") async def update_schedule(config: ScheduleConfig): """Update scheduler configuration.""" diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index a3a1ed4..ca5c932 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -57,6 +57,14 @@ def format_address_for_oracle(address: str, city: str, region: str) -> str: region_clean = clean_web_text(region) city_clean = clean_web_text(city) address_clean = clean_web_text(address) + # Strip city name from end of address (users often type it) + if city_clean: + addr_upper = address_clean.upper().rstrip() + city_upper = city_clean.upper().strip() + if addr_upper.endswith(city_upper): + stripped = address_clean[:len(address_clean.rstrip()) - len(city_upper)].rstrip() + if stripped: # don't strip if nothing remains + address_clean = stripped return f"JUD:{region_clean};{city_clean};{address_clean}" @@ -360,13 +368,21 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se # Query address details from Oracle for sync back to SQLite if addr_livr_id: - cur.execute("SELECT strada, numar, localitate, judet FROM vadrese_parteneri WHERE id_adresa = :1", [int(addr_livr_id)]) + cur.execute("""SELECT strada, numar, bloc, scara, apart, etaj, localitate, judet + FROM vadrese_parteneri WHERE id_adresa = :1""", [int(addr_livr_id)]) row = cur.fetchone() - result["adresa_livrare_roa"] = {"strada": row[0], "numar": row[1], "localitate": row[2], "judet": row[3]} if row else None + result["adresa_livrare_roa"] = { + "strada": row[0], "numar": row[1], "bloc": row[2], "scara": row[3], + "apart": row[4], "etaj": row[5], "localitate": row[6], "judet": row[7] + } if row else None if addr_fact_id and addr_fact_id != addr_livr_id: - cur.execute("SELECT strada, numar, localitate, judet FROM vadrese_parteneri WHERE id_adresa = :1", [int(addr_fact_id)]) + cur.execute("""SELECT strada, numar, bloc, scara, apart, etaj, localitate, judet + FROM vadrese_parteneri WHERE id_adresa = :1""", [int(addr_fact_id)]) row = cur.fetchone() - result["adresa_facturare_roa"] = {"strada": row[0], "numar": row[1], "localitate": row[2], "judet": row[3]} if row else None + result["adresa_facturare_roa"] = { + "strada": row[0], "numar": row[1], "bloc": row[2], "scara": row[3], + "apart": row[4], "etaj": row[5], "localitate": row[6], "judet": row[7] + } if row else None elif addr_fact_id and addr_fact_id == addr_livr_id: result["adresa_facturare_roa"] = result.get("adresa_livrare_roa") diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index bb5f179..061549d 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -1214,6 +1214,59 @@ async def update_gomag_addresses_batch(updates: list[dict]): await db.close() +async def get_order_address_ids(order_number: str) -> dict | None: + """Return id_adresa_livrare, id_adresa_facturare, adresa_*_gomag for an order.""" + db = await get_sqlite() + try: + cursor = await db.execute("""SELECT id_adresa_livrare, id_adresa_facturare, + adresa_livrare_gomag, adresa_facturare_gomag, + adresa_livrare_roa + FROM orders WHERE order_number = ?""", [order_number]) + row = await cursor.fetchone() + return dict(row) if row else None + finally: + await db.close() + + +async def update_order_address_cache(order_number: str, livr_roa: dict | None, + fact_roa: dict | None, mismatch: bool): + """Update ONLY the 3 address-cache columns — does NOT touch ANAF/partner fields.""" + db = await get_sqlite() + try: + await db.execute(""" + UPDATE orders SET + adresa_livrare_roa = ?, + adresa_facturare_roa = ?, + address_mismatch = ?, + updated_at = datetime('now') + WHERE order_number = ? + """, ( + json.dumps(livr_roa) if livr_roa else None, + json.dumps(fact_roa) if fact_roa else None, + 1 if mismatch else 0, + order_number, + )) + await db.commit() + finally: + await db.close() + + +async def get_orders_with_address_ids() -> list[dict]: + """Get all orders that have Oracle address IDs stored (for batch refresh).""" + db = await get_sqlite() + try: + cursor = await db.execute(""" + SELECT order_number, id_adresa_livrare, id_adresa_facturare, + adresa_livrare_gomag, adresa_facturare_gomag + FROM orders + WHERE id_adresa_livrare IS NOT NULL OR id_adresa_facturare IS NOT NULL + """) + rows = await cursor.fetchall() + return [dict(r) for r in rows] + finally: + await db.close() + + async def get_orders_missing_anaf() -> list[dict]: """Get orders with cod_fiscal_roa set but no ANAF data (for backfill).""" db = await get_sqlite() diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index f9773b5..e5f6f65 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -40,7 +40,7 @@ def _addr_match(gomag_json, roa_json): 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 '')) + r_street = norm((r.get('strada') or '') + (r.get('numar') or '') + (r.get('bloc') or '') + (r.get('scara') or '') + (r.get('apart') 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 '') diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 33c5d4a..974f70b 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -545,6 +545,26 @@ async function refreshInvoices() { // ── Order Detail Modal ──────────────────────────── +async function refreshOrderAddress(orderNumber) { + if (!orderNumber) return; + const btn = document.getElementById('refreshAddrBtn'); + if (btn) { btn.disabled = true; btn.innerHTML = ''; } + try { + const res = await fetch(`/api/orders/${orderNumber}/refresh-address`, {method: 'POST'}); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + showToast('Eroare refresh adresă: ' + (err.detail || res.status), 'danger'); + return; + } + showToast('Adresă actualizată din Oracle', 'success'); + renderOrderDetailModal(orderNumber, {onQuickMap: openDashQuickMap}); + } catch (e) { + showToast('Eroare conexiune', 'danger'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ''; } + } +} + function openDashOrderDetail(orderNumber) { _sharedModalQuickMapFn = openDashQuickMap; renderOrderDetailModal(orderNumber, { diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js index a68d2a2..72cebe1 100644 --- a/api/app/static/js/shared.js +++ b/api/app/static/js/shared.js @@ -494,6 +494,8 @@ function _renderReceipt(items, order) { async function renderOrderDetailModal(orderNumber, opts) { opts = opts || {}; + window._detailOrderNumber = orderNumber; + // Reset modal state document.getElementById('detailOrderNumber').textContent = '#' + orderNumber; document.getElementById('detailCustomer').textContent = '...'; @@ -831,6 +833,13 @@ function fmtAddr(a) { if (!a) return '\u2014'; if (typeof a === 'string') return a; const parts = [a.address || a.strada || '', a.numar || ''].filter(Boolean); + const extras = [ + a.bloc ? 'Bl.' + a.bloc : '', + a.scara ? 'Sc.' + a.scara : '', + a.apart ? 'Ap.' + a.apart : '', + a.etaj ? 'Et.' + a.etaj : '', + ].filter(Boolean).join(' '); + if (extras) parts.push(extras); const line1 = parts.join(' ').trim(); const line2 = [a.city || a.localitate || '', a.region || a.judet || ''].filter(Boolean).join(', '); return [line1, line2].filter(Boolean).join(', '); @@ -845,7 +854,7 @@ function addrMatch(gomag, roa) { .replace(/[^A-Z0-9]/g, ''); } const gStreet = norm(gomag.address || gomag.strada || ''); - const rStreet = norm((roa.strada || '') + (roa.numar || '')); + const rStreet = norm((roa.strada||'') + (roa.numar||'') + (roa.bloc||'') + (roa.scara||'') + (roa.apart||'')); const gCity = norm(gomag.city || gomag.localitate || ''); const rCity = norm(roa.localitate || ''); const gRegion = norm(gomag.region || gomag.judet || ''); diff --git a/api/app/templates/base.html b/api/app/templates/base.html index 0c81224..03aa49d 100644 --- a/api/app/templates/base.html +++ b/api/app/templates/base.html @@ -124,7 +124,14 @@
@@ -161,7 +168,7 @@ - + + {% endblock %} diff --git a/api/database-scripts/05_pack_import_parteneri.pck b/api/database-scripts/05_pack_import_parteneri.pck index 28b94c9..4f72c75 100644 --- a/api/database-scripts/05_pack_import_parteneri.pck +++ b/api/database-scripts/05_pack_import_parteneri.pck @@ -9,6 +9,7 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS -- 06.04.2026 - eliminat TIER 2 cautare adresa (judet+loc fara strada) — creeaza adresa noua cand strada difera -- 06.04.2026 - fix strip_diacritics: UNISTR encoding-safe (TRANSLATE cu UTF-8 literal se corupea pe Windows) -- 06.04.2026 - fix TIER 1: strip_diacritics si pe localitate (nu doar strada) + -- 07.04.2026 - fix parser adrese: inserare virgule inaintea keywords, tokeni lipiti (Ap78), strip localitate din strada -- ==================================================================== -- CONSTANTS @@ -585,6 +586,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS -- Tokenii sunt separati prin virgula -- Patterns: NR/NUMAR, BL/BLOC, SC/SCARA, AP/APART, ET/ETAJ -- ================================================================ + -- Insert commas before address keywords to create proper tokens + -- No guard on existing commas — double commas produce empty tokens (harmless) + IF v_raw_numar IS NOT NULL THEN + v_raw_numar := REGEXP_REPLACE(v_raw_numar, + '(\s)(BLOC|BL|SCARA|SC|APARTAMENT|APART|AP|ETAJ|ET|NUMARUL|NUMAR|NR)(\s|\.|\d)', + ',\2\3', 1, 0, 'i'); + v_raw_numar := LTRIM(v_raw_numar, ', '); + END IF; + IF v_raw_numar IS NOT NULL THEN -- Loop prin tokeni separati de virgula (fara BULK COLLECT — compatibil Oracle 11) v_rest_parts := NULL; @@ -616,6 +626,17 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS p_etaj := TRIM(REGEXP_REPLACE(v_token, '^(ETAJ|ET\.?)(\s|\.)*', '', 1, 1, 'i')); ELSIF REGEXP_LIKE(v_token_upper, '^(NUMARUL|NUMAR|NR\.?)(\s|\.)') THEN p_numar := TRIM(REGEXP_REPLACE(v_token, '^(NUMARUL|NUMAR|NR\.?)(\s|\.)*', '', 1, 1, 'i')); + -- Glued tokens: Ap78, BL30, SC2, ET3, NR15 (no separator between keyword and digit) + ELSIF REGEXP_LIKE(v_token_upper, '^(BLOC|BL)(\d)') THEN + p_bloc := TRIM(REGEXP_REPLACE(v_token, '^(BLOC|BL)', '', 1, 1, 'i')); + ELSIF REGEXP_LIKE(v_token_upper, '^(SCARA|SC)(\d)') THEN + p_scara := TRIM(REGEXP_REPLACE(v_token, '^(SCARA|SC)', '', 1, 1, 'i')); + ELSIF REGEXP_LIKE(v_token_upper, '^(APARTAMENT|APART|AP)(\d)') THEN + p_apart := TRIM(REGEXP_REPLACE(v_token, '^(APARTAMENT|APART|AP)', '', 1, 1, 'i')); + ELSIF REGEXP_LIKE(v_token_upper, '^(ETAJ|ET)(\d)') THEN + p_etaj := TRIM(REGEXP_REPLACE(v_token, '^(ETAJ|ET)', '', 1, 1, 'i')); + ELSIF REGEXP_LIKE(v_token_upper, '^(NUMARUL|NUMAR|NR)(\d)') THEN + p_numar := TRIM(REGEXP_REPLACE(v_token, '^(NUMARUL|NUMAR|NR)', '', 1, 1, 'i')); ELSE -- Primul token necunoscut devine numar (daca numar e inca gol) IF p_numar IS NULL AND v_tok_idx = 1 THEN @@ -648,6 +669,16 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS p_apart := UPPER(TRIM(p_apart)); p_etaj := UPPER(TRIM(p_etaj)); + -- Strip localitate from end of strada (users type city into address) + IF p_strada IS NOT NULL AND p_localitate IS NOT NULL THEN + IF p_strada LIKE '%' || p_localitate THEN + v_token := RTRIM(SUBSTR(p_strada, 1, LENGTH(p_strada) - LENGTH(p_localitate))); + IF v_token IS NOT NULL THEN + p_strada := v_token; + END IF; + END IF; + END IF; + -- Truncare de siguranta (limita coloanelor Oracle) p_numar := SUBSTR(p_numar, 1, 10); p_bloc := SUBSTR(p_bloc, 1, 30); diff --git a/api/tests/test_business_rules.py b/api/tests/test_business_rules.py index d352a44..59dade6 100644 --- a/api/tests/test_business_rules.py +++ b/api/tests/test_business_rules.py @@ -716,3 +716,128 @@ class TestAddrMatch: def test_malformed_json_returns_true(self): from app.services.sync_service import _addr_match assert _addr_match("{bad json", '{"strada":"x"}') is True + + def test_addr_match_structured_fields(self): + """addrMatch compares GoMag free-text vs ROA structured fields.""" + from app.services.sync_service import _addr_match + import json + gomag = json.dumps({"address": "Str Vasile Goldis Nr 19 Bl 30 Sc D Ap 78", "city": "Alba Iulia", "region": "Alba"}) + roa = json.dumps({"strada": "STRADA VASILE GOLDIS", "numar": "19", "bloc": "30", "scara": "D", "apart": "78", "localitate": "ALBA IULIA", "judet": "ALBA"}) + assert _addr_match(gomag, roa) is True + + def test_addr_match_mismatch_structured(self): + """addrMatch detects real mismatches with structured ROA fields.""" + from app.services.sync_service import _addr_match + import json + gomag = json.dumps({"address": "Str Dacia 10", "city": "Bucuresti", "region": "Bucuresti"}) + roa = json.dumps({"strada": "STRADA VASILE GOLDIS", "numar": "19", "bloc": "", "scara": "", "apart": "", "localitate": "ALBA IULIA", "judet": "ALBA"}) + assert _addr_match(gomag, roa) is False + + +class TestFormatAddressForOracle: + """Tests for format_address_for_oracle city stripping.""" + + def test_city_strip_from_address_end(self): + """City name at end of address is stripped.""" + from app.services.import_service import format_address_for_oracle + result = format_address_for_oracle("Strada Vasile Goldis nr 19 Alba Iulia", "Alba Iulia", "Alba") + assert result == "JUD:Alba;Alba Iulia;Strada Vasile Goldis nr 19" + + def test_city_strip_case_insensitive(self): + """City strip works regardless of case.""" + from app.services.import_service import format_address_for_oracle + result = format_address_for_oracle("Str Dacia alba iulia", "Alba Iulia", "Alba") + assert result == "JUD:Alba;Alba Iulia;Str Dacia" + + def test_city_no_strip_when_not_at_end(self): + """Don't strip city if it's in the middle of the address.""" + from app.services.import_service import format_address_for_oracle + result = format_address_for_oracle("Alba Iulia Str Dacia 5", "Alba Iulia", "Alba") + assert "Alba Iulia Str Dacia 5" in result + + def test_city_no_strip_when_empty_remains(self): + """Don't strip if it would leave empty address.""" + from app.services.import_service import format_address_for_oracle + result = format_address_for_oracle("Alba Iulia", "Alba Iulia", "Alba") + assert "Alba Iulia" in result # address preserved + + +# =========================================================================== +# Group 11: TestRefreshOrderAddress +# =========================================================================== + +import sqlite3 + +@pytest.fixture(scope="module") +def client(): + """TestClient for refresh-address endpoint tests (Oracle mocked via FORCE_THIN_MODE).""" + from fastapi.testclient import TestClient + from app.main import app + with TestClient(app, raise_server_exceptions=False) as c: + yield c + + +@pytest.fixture +def db(): + """Synchronous SQLite connection to the test DB.""" + conn = sqlite3.connect(_sqlite_path) + conn.row_factory = sqlite3.Row + yield conn + # Clean up test rows inserted during the test + conn.execute("DELETE FROM orders WHERE order_number LIKE 'test-%'") + conn.commit() + conn.close() + + +class TestRefreshOrderAddress: + """Tests for POST /api/orders/{order_number}/refresh-address endpoint.""" + + def test_order_not_found_returns_404(self, client): + """Non-existent order_number returns 404.""" + res = client.post("/api/orders/nonexistent-99999/refresh-address") + assert res.status_code == 404 + + def test_null_address_ids_returns_422(self, client, db): + """Orders without Oracle address IDs return 422.""" + db.execute("INSERT OR IGNORE INTO orders (order_number, status) VALUES ('test-no-addr', 'IMPORTED')") + db.commit() + res = client.post("/api/orders/test-no-addr/refresh-address") + assert res.status_code == 422 + + def test_oracle_unavailable_returns_503(self, client, db, monkeypatch): + """Oracle connection failure returns 503.""" + db.execute("INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-oracle-fail', 'IMPORTED', 4116)") + db.commit() + + import asyncio as _asyncio + + async def _mock_to_thread(fn, *args, **kwargs): + raise Exception("Oracle down") + + monkeypatch.setattr(_asyncio, "to_thread", _mock_to_thread) + res = client.post("/api/orders/test-oracle-fail/refresh-address") + assert res.status_code == 503 + + def test_refresh_returns_8_fields(self, client, db, monkeypatch): + """Successful refresh returns 8-field address dict.""" + db.execute("INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-refresh-ok', 'IMPORTED', 4116)") + db.commit() + + mock_result = ( + {"strada": "VASILE GOLDIS", "numar": "19", "bloc": "30", + "scara": "D", "apart": "78", "etaj": None, + "localitate": "ALBA IULIA", "judet": "ALBA"}, + None, + ) + + import asyncio as _asyncio + + async def _mock_to_thread(fn, *args, **kwargs): + return mock_result + + monkeypatch.setattr(_asyncio, "to_thread", _mock_to_thread) + res = client.post("/api/orders/test-refresh-ok/refresh-address") + assert res.status_code == 200 + data = res.json() + assert data["adresa_livrare_roa"]["strada"] == "VASILE GOLDIS" + assert data["adresa_livrare_roa"]["bloc"] == "30"