feat(address): ROA address cache refresh — 8-field format + manual refresh endpoint

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 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-07 12:35:18 +00:00
parent a8ad54a604
commit ecde7fe440
10 changed files with 377 additions and 10 deletions

View File

@@ -5,7 +5,7 @@ from datetime import datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from fastapi import APIRouter, Request, BackgroundTasks from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
@@ -815,6 +815,55 @@ async def refresh_invoices():
await sqlite_service.mark_order_deleted_in_roa(o["order_number"]) await sqlite_service.mark_order_deleted_in_roa(o["order_number"])
orders_deleted += 1 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) checked = len(uninvoiced) + len(invoiced) + len(all_imported)
return { return {
"checked": checked, "checked": checked,
@@ -826,6 +875,63 @@ async def refresh_invoices():
return {"error": str(e), "invoices_added": 0} 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") @router.put("/api/sync/schedule")
async def update_schedule(config: ScheduleConfig): async def update_schedule(config: ScheduleConfig):
"""Update scheduler configuration.""" """Update scheduler configuration."""

View File

@@ -57,6 +57,14 @@ def format_address_for_oracle(address: str, city: str, region: str) -> str:
region_clean = clean_web_text(region) region_clean = clean_web_text(region)
city_clean = clean_web_text(city) city_clean = clean_web_text(city)
address_clean = clean_web_text(address) 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}" 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 # Query address details from Oracle for sync back to SQLite
if addr_livr_id: 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() 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: 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() 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: elif addr_fact_id and addr_fact_id == addr_livr_id:
result["adresa_facturare_roa"] = result.get("adresa_livrare_roa") result["adresa_facturare_roa"] = result.get("adresa_livrare_roa")

View File

@@ -1214,6 +1214,59 @@ async def update_gomag_addresses_batch(updates: list[dict]):
await db.close() 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]: async def get_orders_missing_anaf() -> list[dict]:
"""Get orders with cod_fiscal_roa set but no ANAF data (for backfill).""" """Get orders with cod_fiscal_roa set but no ANAF data (for backfill)."""
db = await get_sqlite() db = await get_sqlite()

View File

@@ -40,7 +40,7 @@ def _addr_match(gomag_json, roa_json):
s = _ADDR_WORDS.sub('', s) s = _ADDR_WORDS.sub('', s)
return re.sub(r'[^A-Z0-9]', '', s) return re.sub(r'[^A-Z0-9]', '', s)
g_street = norm(g.get('address') or g.get('strada') or '') 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 '') g_city = norm(g.get('city') or g.get('localitate') or '')
r_city = norm(r.get('localitate') or '') r_city = norm(r.get('localitate') or '')
g_region = norm(g.get('region') or g.get('judet') or '') g_region = norm(g.get('region') or g.get('judet') or '')

View File

@@ -545,6 +545,26 @@ async function refreshInvoices() {
// ── Order Detail Modal ──────────────────────────── // ── Order Detail Modal ────────────────────────────
async function refreshOrderAddress(orderNumber) {
if (!orderNumber) return;
const btn = document.getElementById('refreshAddrBtn');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
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 = '<i class="bi bi-arrow-clockwise"></i>'; }
}
}
function openDashOrderDetail(orderNumber) { function openDashOrderDetail(orderNumber) {
_sharedModalQuickMapFn = openDashQuickMap; _sharedModalQuickMapFn = openDashQuickMap;
renderOrderDetailModal(orderNumber, { renderOrderDetailModal(orderNumber, {

View File

@@ -494,6 +494,8 @@ function _renderReceipt(items, order) {
async function renderOrderDetailModal(orderNumber, opts) { async function renderOrderDetailModal(orderNumber, opts) {
opts = opts || {}; opts = opts || {};
window._detailOrderNumber = orderNumber;
// Reset modal state // Reset modal state
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber; document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
document.getElementById('detailCustomer').textContent = '...'; document.getElementById('detailCustomer').textContent = '...';
@@ -831,6 +833,13 @@ function fmtAddr(a) {
if (!a) return '\u2014'; if (!a) return '\u2014';
if (typeof a === 'string') return a; if (typeof a === 'string') return a;
const parts = [a.address || a.strada || '', a.numar || ''].filter(Boolean); 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 line1 = parts.join(' ').trim();
const line2 = [a.city || a.localitate || '', a.region || a.judet || ''].filter(Boolean).join(', '); const line2 = [a.city || a.localitate || '', a.region || a.judet || ''].filter(Boolean).join(', ');
return [line1, line2].filter(Boolean).join(', '); return [line1, line2].filter(Boolean).join(', ');
@@ -845,7 +854,7 @@ function addrMatch(gomag, roa) {
.replace(/[^A-Z0-9]/g, ''); .replace(/[^A-Z0-9]/g, '');
} }
const gStreet = norm(gomag.address || gomag.strada || ''); 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 gCity = norm(gomag.city || gomag.localitate || '');
const rCity = norm(roa.localitate || ''); const rCity = norm(roa.localitate || '');
const gRegion = norm(gomag.region || gomag.judet || ''); const gRegion = norm(gomag.region || gomag.judet || '');

View File

@@ -124,7 +124,14 @@
<div id="detailDenomMismatch" style="display:none" class="mb-2"></div> <div id="detailDenomMismatch" style="display:none" class="mb-2"></div>
<!-- Compact Address Lines --> <!-- Compact Address Lines -->
<div id="detailAddressBlock" style="display:none" class="mb-3"> <div id="detailAddressBlock" style="display:none" class="mb-3">
<div class="detail-col-label" style="border-bottom:1px solid var(--border);margin-bottom:8px;padding-bottom:4px">ADRESE</div> <div class="detail-col-label d-flex align-items-center justify-content-between" style="border-bottom:1px solid var(--border);margin-bottom:8px;padding-bottom:4px">
<span>ADRESE</span>
<button id="refreshAddrBtn" class="btn btn-sm btn-outline-secondary py-0 px-1 ms-2"
onclick="refreshOrderAddress(window._detailOrderNumber)"
aria-label="Refresh adresă din Oracle" title="Refresh adresă din Oracle">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div id="detailAddressLines"></div> <div id="detailAddressLines"></div>
</div> </div>
<div class="table-responsive d-none d-md-block"> <div class="table-responsive d-none d-md-block">
@@ -161,7 +168,7 @@
<script>window.ROOT_PATH = "{{ rp }}";</script> <script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=30"></script> <script src="{{ rp }}/static/js/shared.js?v=32"></script>
<script> <script>
// Dark mode toggle // Dark mode toggle
function toggleDarkMode() { function toggleDarkMode() {

View File

@@ -114,5 +114,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=42"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=43"></script>
{% endblock %} {% endblock %}

View File

@@ -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 - 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 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) -- 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 -- CONSTANTS
@@ -585,6 +586,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
-- Tokenii sunt separati prin virgula -- Tokenii sunt separati prin virgula
-- Patterns: NR/NUMAR, BL/BLOC, SC/SCARA, AP/APART, ET/ETAJ -- 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 IF v_raw_numar IS NOT NULL THEN
-- Loop prin tokeni separati de virgula (fara BULK COLLECT — compatibil Oracle 11) -- Loop prin tokeni separati de virgula (fara BULK COLLECT — compatibil Oracle 11)
v_rest_parts := NULL; 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')); p_etaj := TRIM(REGEXP_REPLACE(v_token, '^(ETAJ|ET\.?)(\s|\.)*', '', 1, 1, 'i'));
ELSIF REGEXP_LIKE(v_token_upper, '^(NUMARUL|NUMAR|NR\.?)(\s|\.)') THEN 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')); 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 ELSE
-- Primul token necunoscut devine numar (daca numar e inca gol) -- Primul token necunoscut devine numar (daca numar e inca gol)
IF p_numar IS NULL AND v_tok_idx = 1 THEN 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_apart := UPPER(TRIM(p_apart));
p_etaj := UPPER(TRIM(p_etaj)); 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) -- Truncare de siguranta (limita coloanelor Oracle)
p_numar := SUBSTR(p_numar, 1, 10); p_numar := SUBSTR(p_numar, 1, 10);
p_bloc := SUBSTR(p_bloc, 1, 30); p_bloc := SUBSTR(p_bloc, 1, 30);

View File

@@ -716,3 +716,128 @@ class TestAddrMatch:
def test_malformed_json_returns_true(self): def test_malformed_json_returns_true(self):
from app.services.sync_service import _addr_match from app.services.sync_service import _addr_match
assert _addr_match("{bad json", '{"strada":"x"}') is True 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"