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:
@@ -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."""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 '')
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 || '');
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user