Compare commits
15 Commits
3b9198d742
...
e75d40fcde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e75d40fcde | ||
|
|
060b63bce9 | ||
|
|
e8c5398499 | ||
|
|
388bb8544a | ||
|
|
a5548f9c14 | ||
|
|
84c38e3641 | ||
|
|
ffd4cc0800 | ||
|
|
219c821df4 | ||
|
|
74209ed266 | ||
|
|
b6a265dee6 | ||
|
|
7079e7a66a | ||
|
|
8b547f96de | ||
|
|
5a515e371e | ||
|
|
e8b42088e3 | ||
|
|
2f593c30f6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,6 +47,7 @@ api/api/
|
|||||||
# Logs directory
|
# Logs directory
|
||||||
logs/
|
logs/
|
||||||
.gstack/
|
.gstack/
|
||||||
|
.gstack-audit/
|
||||||
|
|
||||||
# QA Reports (generated by test suite)
|
# QA Reports (generated by test suite)
|
||||||
qa-reports/
|
qa-reports/
|
||||||
|
|||||||
@@ -179,6 +179,13 @@ CREATE TABLE IF NOT EXISTS order_items (
|
|||||||
PRIMARY KEY (order_number, sku)
|
PRIMARY KEY (order_number, sku)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_number);
|
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_number);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS anaf_cache (
|
||||||
|
cui TEXT PRIMARY KEY,
|
||||||
|
scp_tva INTEGER,
|
||||||
|
denumire_anaf TEXT,
|
||||||
|
checked_at TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_sqlite_db_path = None
|
_sqlite_db_path = None
|
||||||
@@ -333,6 +340,18 @@ def init_sqlite():
|
|||||||
("web_status", "TEXT"),
|
("web_status", "TEXT"),
|
||||||
("discount_split", "TEXT"),
|
("discount_split", "TEXT"),
|
||||||
("price_match", "INTEGER"),
|
("price_match", "INTEGER"),
|
||||||
|
("cod_fiscal_gomag", "TEXT"),
|
||||||
|
("cod_fiscal_roa", "TEXT"),
|
||||||
|
("denumire_roa", "TEXT"),
|
||||||
|
("anaf_platitor_tva", "INTEGER"),
|
||||||
|
("anaf_checked_at", "TEXT"),
|
||||||
|
("anaf_cod_fiscal_adjusted", "INTEGER DEFAULT 0"),
|
||||||
|
("adresa_livrare_gomag", "TEXT"),
|
||||||
|
("adresa_facturare_gomag", "TEXT"),
|
||||||
|
("adresa_livrare_roa", "TEXT"),
|
||||||
|
("adresa_facturare_roa", "TEXT"),
|
||||||
|
("anaf_denumire_mismatch", "INTEGER DEFAULT 0"),
|
||||||
|
("denumire_anaf", "TEXT"),
|
||||||
]:
|
]:
|
||||||
if col not in order_cols:
|
if col not in order_cols:
|
||||||
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||||
|
|||||||
@@ -463,6 +463,8 @@ async def order_detail(order_number: str):
|
|||||||
if pi:
|
if pi:
|
||||||
item["pret_roa"] = pi.get("pret_roa")
|
item["pret_roa"] = pi.get("pret_roa")
|
||||||
item["price_match"] = pi.get("match")
|
item["price_match"] = pi.get("match")
|
||||||
|
if pi.get("kit"):
|
||||||
|
item["kit"] = True
|
||||||
order_price_check = price_data.get("summary", {})
|
order_price_check = price_data.get("summary", {})
|
||||||
# Cache price_match in SQLite if changed
|
# Cache price_match in SQLite if changed
|
||||||
if order_price_check.get("oracle_available") is not False:
|
if order_price_check.get("oracle_available") is not False:
|
||||||
@@ -529,6 +531,33 @@ async def order_detail(order_number: str):
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Partner info
|
||||||
|
order["partner_info"] = {
|
||||||
|
"cod_fiscal_gomag": order.get("cod_fiscal_gomag"),
|
||||||
|
"cod_fiscal_roa": order.get("cod_fiscal_roa"),
|
||||||
|
"denumire_roa": order.get("denumire_roa"),
|
||||||
|
"anaf_platitor_tva": order.get("anaf_platitor_tva"),
|
||||||
|
"anaf_checked_at": order.get("anaf_checked_at"),
|
||||||
|
"anaf_cod_fiscal_adjusted": order.get("anaf_cod_fiscal_adjusted") == 1,
|
||||||
|
"anaf_denumire_mismatch": order.get("anaf_denumire_mismatch") == 1,
|
||||||
|
"denumire_anaf": order.get("denumire_anaf"),
|
||||||
|
}
|
||||||
|
# Parse JSON address strings
|
||||||
|
for key in ("adresa_livrare_gomag", "adresa_facturare_gomag",
|
||||||
|
"adresa_livrare_roa", "adresa_facturare_roa"):
|
||||||
|
val = order.get(key)
|
||||||
|
if val and isinstance(val, str):
|
||||||
|
try:
|
||||||
|
order[key] = json.loads(val)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
order["addresses"] = {
|
||||||
|
"livrare_gomag": order.get("adresa_livrare_gomag"),
|
||||||
|
"facturare_gomag": order.get("adresa_facturare_gomag"),
|
||||||
|
"livrare_roa": order.get("adresa_livrare_roa"),
|
||||||
|
"facturare_roa": order.get("adresa_facturare_roa"),
|
||||||
|
}
|
||||||
|
|
||||||
# Add settings for receipt display (app_settings already fetched above)
|
# Add settings for receipt display (app_settings already fetched above)
|
||||||
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
||||||
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||||
@@ -684,6 +713,16 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
except Exception:
|
except Exception:
|
||||||
counts["unresolved_skus"] = 0
|
counts["unresolved_skus"] = 0
|
||||||
|
|
||||||
|
# Address quality: count orders with incomplete ROA addresses
|
||||||
|
try:
|
||||||
|
addr_count = await sqlite_service.get_incomplete_addresses_count()
|
||||||
|
if addr_count == -1: # stale cache — skip
|
||||||
|
counts["incomplete_addresses"] = 0
|
||||||
|
else:
|
||||||
|
counts["incomplete_addresses"] = addr_count
|
||||||
|
except Exception:
|
||||||
|
counts["incomplete_addresses"] = 0
|
||||||
|
|
||||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||||
if is_uninvoiced_filter:
|
if is_uninvoiced_filter:
|
||||||
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
|
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
|
||||||
|
|||||||
143
api/app/services/anaf_service.py
Normal file
143
api/app/services/anaf_service.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Romanian diacritics to ASCII mapping (same 14 chars as import_service)
|
||||||
|
_DIACRITICS = str.maketrans('ĂăÂâÎîȘșȚțŞşŢţ', 'AAAAIISSTTSSTT')
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ro_prefix(cod_fiscal: str) -> str:
|
||||||
|
"""Normalize CUI: strip whitespace, uppercase, remove 'RO' prefix."""
|
||||||
|
if not cod_fiscal:
|
||||||
|
return ""
|
||||||
|
cleaned = cod_fiscal.strip().upper()
|
||||||
|
return re.sub(r'^RO\s*', '', cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cui(bare_cui: str) -> bool:
|
||||||
|
"""Validate bare CUI: digits only, length 1-13."""
|
||||||
|
if not bare_cui:
|
||||||
|
return False
|
||||||
|
return bare_cui.isdigit() and 1 <= len(bare_cui) <= 13
|
||||||
|
|
||||||
|
|
||||||
|
async def check_vat_status_batch(cui_list: list[str], date: str = None) -> dict[str, dict]:
|
||||||
|
"""POST to ANAF API to check VAT status for a batch of CUIs.
|
||||||
|
|
||||||
|
Chunks in batches of 500 (ANAF API limit).
|
||||||
|
Returns {cui_str: {"scpTVA": bool|None, "denumire_anaf": str, "checked_at": str}, ...}
|
||||||
|
"""
|
||||||
|
if not cui_list:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
check_date = date or datetime.now().strftime("%Y-%m-%d")
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for i in range(0, len(cui_list), 500):
|
||||||
|
chunk = cui_list[i:i+500]
|
||||||
|
body = [{"cui": int(cui), "data": check_date} for cui in chunk if cui.isdigit()]
|
||||||
|
if not body:
|
||||||
|
continue
|
||||||
|
|
||||||
|
chunk_results = await _call_anaf_api(body)
|
||||||
|
results.update(chunk_results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]:
|
||||||
|
"""Internal: single ANAF API call with retry logic."""
|
||||||
|
url = "https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva"
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(url, json=body)
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
if retry < 1:
|
||||||
|
logger.warning("ANAF API rate limited (429), retrying in 10s...")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
return await _call_anaf_api(body, retry + 1)
|
||||||
|
logger.error("ANAF API rate limited after retry")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if response.status_code >= 500:
|
||||||
|
if retry < 1:
|
||||||
|
logger.warning(f"ANAF API server error ({response.status_code}), retrying in 3s...")
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
return await _call_anaf_api(body, retry + 1)
|
||||||
|
logger.error(f"ANAF API server error after retry: {response.status_code}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
checked_at = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Parse ANAF response
|
||||||
|
found_list = data.get("found", [])
|
||||||
|
for item in found_list:
|
||||||
|
date_generals = item.get("date_generale", {})
|
||||||
|
cui_str = str(date_generals.get("cui", ""))
|
||||||
|
results[cui_str] = {
|
||||||
|
"scpTVA": item.get("inregistrare_scop_Tva", {}).get("scpTVA"),
|
||||||
|
"denumire_anaf": date_generals.get("denumire", ""),
|
||||||
|
"checked_at": checked_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Not found CUIs
|
||||||
|
notfound_list = data.get("notFound", [])
|
||||||
|
for item in notfound_list:
|
||||||
|
date_gen = item.get("date_generale", {})
|
||||||
|
cui_str = str(date_gen.get("cui", item.get("cui", "")))
|
||||||
|
results[cui_str] = {
|
||||||
|
"scpTVA": None,
|
||||||
|
"denumire_anaf": "",
|
||||||
|
"checked_at": checked_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"ANAF batch: {len(body)} CUIs → {len(found_list)} found, {len(notfound_list)} not found")
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
if retry < 1:
|
||||||
|
logger.warning("ANAF API timeout, retrying in 3s...")
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
return await _call_anaf_api(body, retry + 1)
|
||||||
|
logger.error("ANAF API timeout after retry")
|
||||||
|
except Exception as e:
|
||||||
|
if retry < 1:
|
||||||
|
logger.warning(f"ANAF API error: {e}, retrying in 3s...")
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
return await _call_anaf_api(body, retry + 1)
|
||||||
|
logger.error(f"ANAF API error after retry: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def determine_correct_cod_fiscal(bare_cui: str, is_vat_payer: bool | None) -> str:
|
||||||
|
"""Determine the correct cod_fiscal format based on ANAF VAT status.
|
||||||
|
True → "RO" + bare, False → bare, None → bare (conservative)
|
||||||
|
"""
|
||||||
|
if is_vat_payer is True:
|
||||||
|
return "RO" + bare_cui
|
||||||
|
return bare_cui
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_company_name(name: str) -> str:
|
||||||
|
"""Normalize company name for comparison: strip SRL/SA suffixes, diacritics, punctuation."""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
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 punctuation and extra spaces
|
||||||
|
result = re.sub(r'[^\w\s]', '', result)
|
||||||
|
result = re.sub(r'\s+', ' ', result).strip()
|
||||||
|
return result
|
||||||
@@ -201,7 +201,7 @@ def build_articles_json(items, order=None, settings=None) -> str:
|
|||||||
return json.dumps(articles)
|
return json.dumps(articles)
|
||||||
|
|
||||||
|
|
||||||
def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None, id_gestiuni: list[int] = None) -> dict:
|
def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None, id_gestiuni: list[int] = None, cod_fiscal_override: str = None, anaf_strict: int = None) -> dict:
|
||||||
"""Import a single order into Oracle ROA.
|
"""Import a single order into Oracle ROA.
|
||||||
|
|
||||||
Returns dict with:
|
Returns dict with:
|
||||||
@@ -239,7 +239,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
|
|||||||
|
|
||||||
if order.billing.is_company:
|
if order.billing.is_company:
|
||||||
denumire = clean_web_text(order.billing.company_name).upper()
|
denumire = clean_web_text(order.billing.company_name).upper()
|
||||||
cod_fiscal = clean_web_text(order.billing.company_code) or None
|
cod_fiscal = cod_fiscal_override or clean_web_text(order.billing.company_code) or None
|
||||||
registru = clean_web_text(order.billing.company_reg) or None
|
registru = clean_web_text(order.billing.company_reg) or None
|
||||||
is_pj = 1
|
is_pj = 1
|
||||||
else:
|
else:
|
||||||
@@ -257,7 +257,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
|
|||||||
is_pj = 0
|
is_pj = 0
|
||||||
|
|
||||||
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [
|
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [
|
||||||
cod_fiscal, denumire, registru, is_pj, id_partener
|
cod_fiscal, denumire, registru, is_pj, anaf_strict, id_partener
|
||||||
])
|
])
|
||||||
|
|
||||||
partner_id = id_partener.getvalue()
|
partner_id = id_partener.getvalue()
|
||||||
@@ -267,6 +267,12 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
|
|||||||
|
|
||||||
result["id_partener"] = int(partner_id)
|
result["id_partener"] = int(partner_id)
|
||||||
|
|
||||||
|
# Query partner data from Oracle for sync back to SQLite
|
||||||
|
cur.execute("SELECT denumire, cod_fiscal FROM nom_parteneri WHERE id_part = :1", [partner_id])
|
||||||
|
row = cur.fetchone()
|
||||||
|
result["denumire_roa"] = row[0] if row else None
|
||||||
|
result["cod_fiscal_roa"] = row[1] if row else None
|
||||||
|
|
||||||
# Determine if billing and shipping are different persons
|
# Determine if billing and shipping are different persons
|
||||||
billing_name = clean_web_text(
|
billing_name = clean_web_text(
|
||||||
f"{order.billing.lastname} {order.billing.firstname}"
|
f"{order.billing.lastname} {order.billing.firstname}"
|
||||||
@@ -350,6 +356,18 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
|
|||||||
if addr_livr_id is not None:
|
if addr_livr_id is not None:
|
||||||
result["id_adresa_livrare"] = int(addr_livr_id)
|
result["id_adresa_livrare"] = int(addr_livr_id)
|
||||||
|
|
||||||
|
# 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)])
|
||||||
|
row = cur.fetchone()
|
||||||
|
result["adresa_livrare_roa"] = {"strada": row[0], "numar": row[1], "localitate": row[2], "judet": row[3]} 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)])
|
||||||
|
row = cur.fetchone()
|
||||||
|
result["adresa_facturare_roa"] = {"strada": row[0], "numar": row[1], "localitate": row[2], "judet": row[3]} if row else None
|
||||||
|
elif addr_fact_id and addr_fact_id == addr_livr_id:
|
||||||
|
result["adresa_facturare_roa"] = result.get("adresa_livrare_roa")
|
||||||
|
|
||||||
# Step 4: Build articles JSON and import order
|
# Step 4: Build articles JSON and import order
|
||||||
articles_json = build_articles_json(order.items, order, app_settings)
|
articles_json = build_articles_json(order.items, order, app_settings)
|
||||||
|
|
||||||
|
|||||||
@@ -696,6 +696,8 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
|||||||
if status_filter and status_filter not in ("all", "UNINVOICED"):
|
if status_filter and status_filter not in ("all", "UNINVOICED"):
|
||||||
if status_filter.upper() == "IMPORTED":
|
if status_filter.upper() == "IMPORTED":
|
||||||
data_clauses.append("UPPER(status) IN ('IMPORTED', 'ALREADY_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)")
|
||||||
else:
|
else:
|
||||||
data_clauses.append("UPPER(status) = ?")
|
data_clauses.append("UPPER(status) = ?")
|
||||||
data_params.append(status_filter.upper())
|
data_params.append(status_filter.upper())
|
||||||
@@ -749,6 +751,14 @@ 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)
|
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params)
|
||||||
uninvoiced_old = (await cursor.fetchone())[0]
|
uninvoiced_old = (await cursor.fetchone())[0]
|
||||||
|
|
||||||
|
# Diffs count: orders with ANAF adjustments
|
||||||
|
diffs_clauses = list(base_clauses) + [
|
||||||
|
"(anaf_cod_fiscal_adjusted = 1 OR anaf_denumire_mismatch = 1)"
|
||||||
|
]
|
||||||
|
diffs_where = "WHERE " + " AND ".join(diffs_clauses)
|
||||||
|
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {diffs_where}", base_params)
|
||||||
|
diffs_count = (await cursor.fetchone())[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"orders": [dict(r) for r in rows],
|
"orders": [dict(r) for r in rows],
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -765,6 +775,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
|||||||
"total": sum(status_counts.values()),
|
"total": sum(status_counts.values()),
|
||||||
"uninvoiced_sqlite": uninvoiced_sqlite,
|
"uninvoiced_sqlite": uninvoiced_sqlite,
|
||||||
"uninvoiced_old": uninvoiced_old,
|
"uninvoiced_old": uninvoiced_old,
|
||||||
|
"diffs": diffs_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
@@ -1009,3 +1020,190 @@ async def get_price_sync_runs(page: int = 1, per_page: int = 20):
|
|||||||
return {"runs": runs, "total": total, "page": page, "pages": (total + per_page - 1) // per_page}
|
return {"runs": runs, "total": total, "page": page, "pages": (total + per_page - 1) // per_page}
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── ANAF Cache ───────────────────────────────────
|
||||||
|
|
||||||
|
async def get_anaf_cache(bare_cui: str) -> dict | None:
|
||||||
|
"""Get cached ANAF data for a CUI (valid for 7 days)."""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT scp_tva, denumire_anaf, checked_at
|
||||||
|
FROM anaf_cache
|
||||||
|
WHERE cui = ? AND checked_at > datetime('now', '-7 days')
|
||||||
|
""", (bare_cui,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"scpTVA": bool(row["scp_tva"]) if row["scp_tva"] is not None else None,
|
||||||
|
"denumire_anaf": row["denumire_anaf"] or "",
|
||||||
|
"checked_at": row["checked_at"],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_anaf_cache(cui: str, scp_tva: int | None, denumire_anaf: str):
|
||||||
|
"""Insert or update ANAF cache entry."""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
await db.execute("""
|
||||||
|
INSERT OR REPLACE INTO anaf_cache (cui, scp_tva, denumire_anaf, checked_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
""", (cui, scp_tva, denumire_anaf))
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_populate_anaf_cache(results: dict[str, dict]):
|
||||||
|
"""Batch insert/update ANAF cache entries.
|
||||||
|
results format: {cui: {"scpTVA": bool|None, "denumire_anaf": str, "checked_at": str}, ...}
|
||||||
|
"""
|
||||||
|
if not results:
|
||||||
|
return
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
rows = []
|
||||||
|
for cui, data in results.items():
|
||||||
|
scp = None
|
||||||
|
if data.get("scpTVA") is True:
|
||||||
|
scp = 1
|
||||||
|
elif data.get("scpTVA") is False:
|
||||||
|
scp = 0
|
||||||
|
rows.append((cui, scp, data.get("denumire_anaf", ""), data.get("checked_at", _now_str())))
|
||||||
|
await db.executemany("""
|
||||||
|
INSERT OR REPLACE INTO anaf_cache (cui, scp_tva, denumire_anaf, checked_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", rows)
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_expired_cuis_for_prepopulate() -> list[str]:
|
||||||
|
"""Get CUIs from recent orders that need ANAF cache refresh."""
|
||||||
|
from ..services import anaf_service
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT DISTINCT cod_fiscal_gomag FROM orders
|
||||||
|
WHERE cod_fiscal_gomag IS NOT NULL
|
||||||
|
AND cod_fiscal_gomag != ''
|
||||||
|
AND order_date >= date('now', '-3 months')
|
||||||
|
""")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
cuis_to_check = []
|
||||||
|
for row in rows:
|
||||||
|
raw = row["cod_fiscal_gomag"]
|
||||||
|
bare = anaf_service.strip_ro_prefix(raw)
|
||||||
|
if not anaf_service.validate_cui(bare):
|
||||||
|
continue
|
||||||
|
# Check if cache is valid
|
||||||
|
cached = await get_anaf_cache(bare)
|
||||||
|
if cached is None:
|
||||||
|
cuis_to_check.append(bare)
|
||||||
|
|
||||||
|
return cuis_to_check
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Partner/Address Data on Orders ─────────────────
|
||||||
|
|
||||||
|
async def update_order_partner_data(order_number: str, partner_data: dict):
|
||||||
|
"""Update order with partner/ANAF/address comparison data.
|
||||||
|
|
||||||
|
partner_data keys: cod_fiscal_gomag, cod_fiscal_roa, denumire_roa,
|
||||||
|
anaf_platitor_tva, anaf_checked_at, anaf_cod_fiscal_adjusted,
|
||||||
|
adresa_livrare_gomag, adresa_facturare_gomag, adresa_livrare_roa,
|
||||||
|
adresa_facturare_roa, anaf_denumire_mismatch, denumire_anaf
|
||||||
|
"""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE orders SET
|
||||||
|
cod_fiscal_gomag = ?,
|
||||||
|
cod_fiscal_roa = ?,
|
||||||
|
denumire_roa = ?,
|
||||||
|
anaf_platitor_tva = ?,
|
||||||
|
anaf_checked_at = ?,
|
||||||
|
anaf_cod_fiscal_adjusted = ?,
|
||||||
|
adresa_livrare_gomag = ?,
|
||||||
|
adresa_facturare_gomag = ?,
|
||||||
|
adresa_livrare_roa = ?,
|
||||||
|
adresa_facturare_roa = ?,
|
||||||
|
anaf_denumire_mismatch = ?,
|
||||||
|
denumire_anaf = ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE order_number = ?
|
||||||
|
""", (
|
||||||
|
partner_data.get("cod_fiscal_gomag"),
|
||||||
|
partner_data.get("cod_fiscal_roa"),
|
||||||
|
partner_data.get("denumire_roa"),
|
||||||
|
partner_data.get("anaf_platitor_tva"),
|
||||||
|
partner_data.get("anaf_checked_at"),
|
||||||
|
partner_data.get("anaf_cod_fiscal_adjusted", 0),
|
||||||
|
partner_data.get("adresa_livrare_gomag"),
|
||||||
|
partner_data.get("adresa_facturare_gomag"),
|
||||||
|
partner_data.get("adresa_livrare_roa"),
|
||||||
|
partner_data.get("adresa_facturare_roa"),
|
||||||
|
partner_data.get("anaf_denumire_mismatch", 0),
|
||||||
|
partner_data.get("denumire_anaf"),
|
||||||
|
order_number,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Address Quality Cache (via app_settings) ──────
|
||||||
|
|
||||||
|
async def get_incomplete_addresses_count() -> int:
|
||||||
|
"""Get cached count of orders with incomplete ROA addresses.
|
||||||
|
Returns -1 if cache is stale (> 1 hour old) or not set.
|
||||||
|
"""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT value FROM app_settings WHERE key = 'incomplete_addresses_checked_at'"
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row or not row["value"]:
|
||||||
|
return -1
|
||||||
|
# Check freshness
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
try:
|
||||||
|
checked_at = datetime.fromisoformat(row["value"])
|
||||||
|
if datetime.now() - checked_at > timedelta(hours=1):
|
||||||
|
return -1
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT value FROM app_settings WHERE key = 'incomplete_addresses_count'"
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return int(row["value"]) if row and row["value"] else 0
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def set_incomplete_addresses_count(count: int):
|
||||||
|
"""Cache incomplete addresses count in app_settings."""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('incomplete_addresses_count', ?)",
|
||||||
|
(str(count),)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('incomplete_addresses_checked_at', ?)",
|
||||||
|
(_now_str(),)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def _now():
|
|||||||
"""Return current time in Bucharest timezone (naive, for display/storage)."""
|
"""Return current time in Bucharest timezone (naive, for display/storage)."""
|
||||||
return datetime.now(_tz_bucharest).replace(tzinfo=None)
|
return datetime.now(_tz_bucharest).replace(tzinfo=None)
|
||||||
|
|
||||||
from . import order_reader, validation_service, import_service, sqlite_service, invoice_service, gomag_client
|
from . import order_reader, validation_service, import_service, sqlite_service, invoice_service, gomag_client, anaf_service
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from .. import database
|
from .. import database
|
||||||
|
|
||||||
@@ -638,7 +638,52 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
0, len(truly_importable),
|
0, len(truly_importable),
|
||||||
{"imported": 0, "skipped": skipped_count, "errors": 0, "already_imported": already_imported_count})
|
{"imported": 0, "skipped": skipped_count, "errors": 0, "already_imported": already_imported_count})
|
||||||
|
|
||||||
# Step 4: Import only truly new orders
|
# ANAF cache pre-population: CUIs from last 3 months with expired/missing cache
|
||||||
|
try:
|
||||||
|
prepop_cuis = await sqlite_service.get_expired_cuis_for_prepopulate()
|
||||||
|
if prepop_cuis:
|
||||||
|
_log_line(run_id, f"ANAF pre-populare: {len(prepop_cuis)} CUI-uri cu cache expirat")
|
||||||
|
prepop_results = await anaf_service.check_vat_status_batch(prepop_cuis)
|
||||||
|
if prepop_results:
|
||||||
|
await sqlite_service.bulk_populate_anaf_cache(prepop_results)
|
||||||
|
_log_line(run_id, f"ANAF pre-populare: {len(prepop_results)} rezultate stocate")
|
||||||
|
else:
|
||||||
|
_log_line(run_id, "ANAF pre-populare: cache complet")
|
||||||
|
except Exception as e:
|
||||||
|
_log_line(run_id, f"ANAF pre-populare eroare: {e}")
|
||||||
|
logger.warning(f"ANAF cache pre-population failed: {e}")
|
||||||
|
|
||||||
|
# Step 4: ANAF batch verification for company CUIs (RO companies only)
|
||||||
|
company_cuis = set()
|
||||||
|
for order in truly_importable:
|
||||||
|
is_ro = (order.billing.country or "").strip().lower() == "romania"
|
||||||
|
if order.billing.is_company and order.billing.company_code and is_ro:
|
||||||
|
raw_cf = import_service.clean_web_text(order.billing.company_code) or ""
|
||||||
|
bare = anaf_service.strip_ro_prefix(raw_cf)
|
||||||
|
if anaf_service.validate_cui(bare):
|
||||||
|
company_cuis.add(bare)
|
||||||
|
|
||||||
|
# Check anaf_cache for already-known CUIs (7-day validity)
|
||||||
|
uncached_cuis = []
|
||||||
|
cached_results = {}
|
||||||
|
for cui in company_cuis:
|
||||||
|
cached = await sqlite_service.get_anaf_cache(cui)
|
||||||
|
if cached:
|
||||||
|
cached_results[cui] = cached
|
||||||
|
else:
|
||||||
|
uncached_cuis.append(cui)
|
||||||
|
|
||||||
|
# Batch ANAF call for uncached CUIs only
|
||||||
|
if uncached_cuis:
|
||||||
|
_log_line(run_id, f"ANAF: verificare {len(uncached_cuis)} CUI-uri noi...")
|
||||||
|
anaf_results = await anaf_service.check_vat_status_batch(uncached_cuis)
|
||||||
|
if anaf_results:
|
||||||
|
await sqlite_service.bulk_populate_anaf_cache(anaf_results)
|
||||||
|
cached_results.update(anaf_results)
|
||||||
|
else:
|
||||||
|
_log_line(run_id, "ANAF: batch call esuat, continua fara corectie CUI")
|
||||||
|
|
||||||
|
# Step 5: Import only truly new orders
|
||||||
imported_count = 0
|
imported_count = 0
|
||||||
error_count = 0
|
error_count = 0
|
||||||
|
|
||||||
@@ -651,10 +696,33 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
{"imported": imported_count, "skipped": len(skipped), "errors": error_count,
|
{"imported": imported_count, "skipped": len(skipped), "errors": error_count,
|
||||||
"already_imported": already_imported_count})
|
"already_imported": already_imported_count})
|
||||||
|
|
||||||
|
# Determine cod_fiscal override from ANAF data
|
||||||
|
cod_fiscal_override = None
|
||||||
|
anaf_data_for_order = None
|
||||||
|
raw_cf = ""
|
||||||
|
if order.billing.is_company and order.billing.company_code:
|
||||||
|
raw_cf = import_service.clean_web_text(order.billing.company_code) or ""
|
||||||
|
bare_cui = anaf_service.strip_ro_prefix(raw_cf)
|
||||||
|
anaf_data_for_order = cached_results.get(bare_cui)
|
||||||
|
if anaf_data_for_order and anaf_data_for_order.get("scpTVA") is not None:
|
||||||
|
correct_cf = anaf_service.determine_correct_cod_fiscal(bare_cui, anaf_data_for_order["scpTVA"])
|
||||||
|
if correct_cf != raw_cf:
|
||||||
|
_log_line(run_id, f"#{order.number} CUI corectat: {raw_cf} → {correct_cf}")
|
||||||
|
cod_fiscal_override = correct_cf
|
||||||
|
|
||||||
|
# Determine strict search mode: only when RO company + ANAF data available
|
||||||
|
is_ro_company = (order.billing.is_company
|
||||||
|
and (order.billing.country or "").strip().lower() == "romania")
|
||||||
|
anaf_strict = None
|
||||||
|
if is_ro_company and anaf_data_for_order and anaf_data_for_order.get("scpTVA") is not None:
|
||||||
|
anaf_strict = 1 # ANAF data available → strict search
|
||||||
|
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
import_service.import_single_order,
|
import_service.import_single_order,
|
||||||
order, id_pol=id_pol, id_sectie=id_sectie,
|
order, id_pol=id_pol, id_sectie=id_sectie,
|
||||||
app_settings=app_settings, id_gestiuni=id_gestiuni
|
app_settings=app_settings, id_gestiuni=id_gestiuni,
|
||||||
|
cod_fiscal_override=cod_fiscal_override,
|
||||||
|
anaf_strict=anaf_strict
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build order items data for storage (R9)
|
# Build order items data for storage (R9)
|
||||||
@@ -702,7 +770,39 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
)
|
)
|
||||||
await sqlite_service.add_order_items(order.number, order_items_data)
|
await sqlite_service.add_order_items(order.number, order_items_data)
|
||||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})")
|
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})")
|
||||||
else:
|
|
||||||
|
# Save partner + ANAF + address data to SQLite
|
||||||
|
if result["success"] or result.get("id_partener"):
|
||||||
|
partner_data = {
|
||||||
|
"cod_fiscal_gomag": raw_cf if order.billing.is_company else None,
|
||||||
|
"cod_fiscal_roa": result.get("cod_fiscal_roa"),
|
||||||
|
"denumire_roa": result.get("denumire_roa"),
|
||||||
|
"anaf_platitor_tva": (1 if anaf_data_for_order.get("scpTVA") else 0) if anaf_data_for_order and anaf_data_for_order.get("scpTVA") is not None else None,
|
||||||
|
"anaf_checked_at": anaf_data_for_order.get("checked_at") if anaf_data_for_order else None,
|
||||||
|
"anaf_cod_fiscal_adjusted": 1 if (
|
||||||
|
cod_fiscal_override
|
||||||
|
and result.get("cod_fiscal_roa")
|
||||||
|
and anaf_service.strip_ro_prefix(result["cod_fiscal_roa"]) == anaf_service.strip_ro_prefix(raw_cf)
|
||||||
|
and result["cod_fiscal_roa"].strip().upper().replace("RO ", "RO") != raw_cf.strip().upper().replace("RO ", "RO")
|
||||||
|
) else 0,
|
||||||
|
"adresa_livrare_gomag": json.dumps({"address": order.shipping.address, "city": order.shipping.city, "region": order.shipping.region}) if order.shipping else None,
|
||||||
|
"adresa_facturare_gomag": json.dumps({"address": order.billing.address, "city": order.billing.city, "region": order.billing.region}),
|
||||||
|
"adresa_livrare_roa": json.dumps(result.get("adresa_livrare_roa")) if result.get("adresa_livrare_roa") else None,
|
||||||
|
"adresa_facturare_roa": json.dumps(result.get("adresa_facturare_roa")) if result.get("adresa_facturare_roa") else None,
|
||||||
|
"anaf_denumire_mismatch": 0,
|
||||||
|
"denumire_anaf": None,
|
||||||
|
}
|
||||||
|
# Denomination mismatch check
|
||||||
|
if anaf_data_for_order and anaf_data_for_order.get("denumire_anaf") and order.billing.is_company:
|
||||||
|
norm_gomag = anaf_service.normalize_company_name(order.billing.company_name or "")
|
||||||
|
norm_anaf = anaf_service.normalize_company_name(anaf_data_for_order["denumire_anaf"])
|
||||||
|
if norm_gomag and norm_anaf and norm_gomag != norm_anaf:
|
||||||
|
partner_data["anaf_denumire_mismatch"] = 1
|
||||||
|
partner_data["denumire_anaf"] = anaf_data_for_order["denumire_anaf"]
|
||||||
|
|
||||||
|
await sqlite_service.update_order_partner_data(order.number, partner_data)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
error_count += 1
|
error_count += 1
|
||||||
await sqlite_service.upsert_order(
|
await sqlite_service.upsert_order(
|
||||||
sync_run_id=run_id,
|
sync_run_id=run_id,
|
||||||
|
|||||||
@@ -544,7 +544,7 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
|
|||||||
|
|
||||||
# Build set of kit/bax SKUs (>1 component, or single component with cantitate_roa > 1)
|
# Build set of kit/bax SKUs (>1 component, or single component with cantitate_roa > 1)
|
||||||
kit_skus = {sku for sku, comps in mapped_codmat_data.items()
|
kit_skus = {sku for sku, comps in mapped_codmat_data.items()
|
||||||
if len(comps) > 1 or (len(comps) == 1 and (comps[0].get("cantitate_roa") or 1) > 1)}
|
if len(comps) > 1 or (len(comps) == 1 and float(comps[0].get("cantitate_roa") or 1) != 1)}
|
||||||
|
|
||||||
updated = []
|
updated = []
|
||||||
own_conn = conn is None
|
own_conn = conn is None
|
||||||
@@ -708,6 +708,12 @@ def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> di
|
|||||||
and float(codmat_details[0].get("cantitate_roa") or 1) != 1
|
and float(codmat_details[0].get("cantitate_roa") or 1) != 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_kit:
|
||||||
|
# Kit/pachet: prețul GoMag e comercial, ROA e suma componente din lista
|
||||||
|
# de prețuri — diferența e gestionată de discount line
|
||||||
|
result_items[idx]["kit"] = True
|
||||||
|
continue
|
||||||
|
|
||||||
pret_roa_total = 0.0
|
pret_roa_total = 0.0
|
||||||
all_resolved = True
|
all_resolved = True
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,18 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page titles — DESIGN.md: 18px/600 Display */
|
||||||
|
h4 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section/modal titles — DESIGN.md: 16px/600 Display */
|
||||||
|
h5 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Data font — selective: codes, numbers, sums, dates. NOT text names. */
|
/* Data font — selective: codes, numbers, sums, dates. NOT text names. */
|
||||||
.font-data, code, .dif-sku, .detail-item-card .card-sku {
|
.font-data, code, .dif-sku, .detail-item-card .card-sku {
|
||||||
font-family: var(--font-data);
|
font-family: var(--font-data);
|
||||||
@@ -281,7 +293,7 @@ input[type="checkbox"] {
|
|||||||
|
|
||||||
/* ── Tables ──────────────────────────────────────── */
|
/* ── Tables ──────────────────────────────────────── */
|
||||||
.table {
|
.table {
|
||||||
font-size: 1rem;
|
font-size: 0.875rem; /* 14px — DESIGN.md Body */
|
||||||
}
|
}
|
||||||
|
|
||||||
.table th {
|
.table th {
|
||||||
@@ -299,7 +311,7 @@ input[type="checkbox"] {
|
|||||||
.table td {
|
.table td {
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem 1rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 1rem;
|
font-size: 0.875rem; /* 14px — DESIGN.md Body */
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +463,13 @@ input[type="checkbox"] {
|
|||||||
.fc-neutral { color: var(--text-muted); }
|
.fc-neutral { color: var(--text-muted); }
|
||||||
.fc-blue { color: var(--info); }
|
.fc-blue { color: var(--info); }
|
||||||
.fc-dark { color: var(--text-secondary); }
|
.fc-dark { color: var(--text-secondary); }
|
||||||
|
.fc-orange { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Client diff indicator (billing ≠ shipping) ──── */
|
||||||
|
.client-diff-indicator {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Log viewer (dark theme — keep as-is) ────────── */
|
/* ── Log viewer (dark theme — keep as-is) ────────── */
|
||||||
.log-viewer {
|
.log-viewer {
|
||||||
@@ -1047,9 +1066,10 @@ tr.mapping-deleted td {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
|
min-height: 32px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.preset-btn:hover {
|
.preset-btn:hover {
|
||||||
@@ -1095,3 +1115,155 @@ tr.mapping-deleted td {
|
|||||||
color: var(--info);
|
color: var(--info);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Partner/Address section headers (ANAF dedup) ── */
|
||||||
|
.detail-section-header {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.detail-section-header:hover { color: var(--text-primary); }
|
||||||
|
.detail-section-header .bi-chevron-right {
|
||||||
|
transition: transform 150ms ease-out;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.detail-section-header[aria-expanded="true"] .bi-chevron-right {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.detail-section-header .alert-count {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--error-light);
|
||||||
|
color: var(--error-text);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.detail-section-body { padding: 12px 0; }
|
||||||
|
.partner-row { display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.partner-field { min-width: 140px; }
|
||||||
|
.partner-label {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.partner-value {
|
||||||
|
font-family: var(--font-data);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.anaf-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.anaf-badge-ok { background: var(--success-light); color: var(--success-text); }
|
||||||
|
.anaf-badge-warn { background: var(--warning-light); color: var(--warning-text); }
|
||||||
|
.anaf-badge-gray { background: var(--cancelled-light); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ── Compact order detail layout ──────────────── */
|
||||||
|
.detail-col-label {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.detail-client-name {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.detail-cui-line {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.detail-roa-id {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Address compact lines */
|
||||||
|
.addr-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
.addr-line-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 120px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
.addr-line-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.addr-line .bi-check-lg {
|
||||||
|
color: var(--success);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.addr-line .bi-exclamation-triangle {
|
||||||
|
color: var(--warning);
|
||||||
|
flex-shrink: 0;
|
||||||
|
filter: drop-shadow(0 0 3px rgba(202,138,4,0.3));
|
||||||
|
}
|
||||||
|
.addr-line .bi-exclamation-octagon {
|
||||||
|
color: var(--error-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
filter: drop-shadow(0 0 3px rgba(220,38,38,0.3));
|
||||||
|
}
|
||||||
|
/* Denomination mismatch alert */
|
||||||
|
.denom-mismatch {
|
||||||
|
background: var(--warning-light);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
}
|
||||||
|
.denom-mismatch-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--warning-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
/* Mobile address cards */
|
||||||
|
.addr-card { border: 1px solid var(--border); border-radius: var(--card-radius); margin-bottom: 8px; overflow: hidden; }
|
||||||
|
.addr-card-header { padding: 6px 10px; font-family: var(--font-display); font-size: 11px; font-weight: 500; text-transform: uppercase; color: var(--text-secondary); background: var(--surface-raised); }
|
||||||
|
.addr-card-row { padding: 8px 10px; }
|
||||||
|
.addr-card-row + .addr-card-row { border-top: 1px dashed var(--border-subtle); }
|
||||||
|
.addr-card-source { font-size: 11px; font-weight: 500; color: var(--text-muted); margin-bottom: 2px; }
|
||||||
|
.addr-card-text { font-family: var(--font-body); font-size: 13px; }
|
||||||
|
.addr-card.mismatch { background: var(--warning-light); }
|
||||||
|
.addr-card.match .addr-match-label { font-size: 11px; color: var(--success-text); }
|
||||||
|
|
||||||
|
/* ── Mobile touch targets (must be AFTER base rules for cascade) ── */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.preset-btn { min-height: 44px; padding: 8px 12px; }
|
||||||
|
.btn-sm { min-height: 44px; }
|
||||||
|
input[type="checkbox"] { min-width: 20px; min-height: 20px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ async function loadDashOrders() {
|
|||||||
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
||||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
||||||
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
|
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
|
||||||
|
if (el('cntDiff')) el('cntDiff').textContent = c.diffs || 0;
|
||||||
|
|
||||||
// Attention card
|
// Attention card
|
||||||
const attnEl = document.getElementById('attentionCard');
|
const attnEl = document.getElementById('attentionCard');
|
||||||
@@ -336,14 +337,19 @@ async function loadDashOrders() {
|
|||||||
const errors = c.error || 0;
|
const errors = c.error || 0;
|
||||||
const unmapped = c.unresolved_skus || 0;
|
const unmapped = c.unresolved_skus || 0;
|
||||||
const nefact = c.nefacturate || 0;
|
const nefact = c.nefacturate || 0;
|
||||||
|
const diffs = c.diffs || 0;
|
||||||
|
|
||||||
if (errors === 0 && unmapped === 0 && nefact === 0) {
|
const incompleteAddr = c.incomplete_addresses || 0;
|
||||||
|
|
||||||
|
if (errors === 0 && unmapped === 0 && nefact === 0 && incompleteAddr === 0 && diffs === 0) {
|
||||||
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
|
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
|
||||||
} else {
|
} else {
|
||||||
let items = [];
|
let items = [];
|
||||||
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
|
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
|
||||||
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
|
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
|
||||||
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
|
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
|
||||||
|
if (c.incomplete_addresses > 0) items.push(`<span class="attention-item attention-warning"><i class="bi bi-geo-alt"></i> ${c.incomplete_addresses} adrese incomplete</span>`);
|
||||||
|
if (diffs > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=DIFFS]')?.click()"><i class="bi bi-exclamation-diamond"></i> ${diffs} diferente ANAF</span>`);
|
||||||
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
|
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,7 +413,8 @@ async function loadDashOrders() {
|
|||||||
{ label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
|
{ label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
|
||||||
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
|
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
|
||||||
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' },
|
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' },
|
||||||
{ label: 'Anulate', count: c.cancelled || 0, value: 'CANCELLED', active: activeStatus === 'CANCELLED', colorClass: 'fc-dark' }
|
{ label: 'Anulate', count: c.cancelled || 0, value: 'CANCELLED', active: activeStatus === 'CANCELLED', colorClass: 'fc-dark' },
|
||||||
|
{ label: 'Dif.', count: c.diffs || 0, value: 'DIFFS', active: activeStatus === 'DIFFS', colorClass: 'fc-orange' }
|
||||||
], (val) => {
|
], (val) => {
|
||||||
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
||||||
const pill = document.querySelector(`.filter-pill[data-status="${val}"]`);
|
const pill = document.querySelector(`.filter-pill[data-status="${val}"]`);
|
||||||
@@ -458,7 +465,7 @@ function renderClientCell(order) {
|
|||||||
const shipping = (order.shipping_name || '').trim();
|
const shipping = (order.shipping_name || '').trim();
|
||||||
const isDiff = display !== shipping && shipping;
|
const isDiff = display !== shipping && shipping;
|
||||||
if (isDiff) {
|
if (isDiff) {
|
||||||
return `<td class="tooltip-cont fw-bold" data-tooltip="Livrare: ${escHtml(shipping)}">${escHtml(display)} <sup style="color:#6b7280;font-size:0.65rem">▲</sup></td>`;
|
return `<td class="tooltip-cont fw-bold" data-tooltip="Livrare: ${escHtml(shipping)}">${escHtml(display)} <sup class="client-diff-indicator">▲</sup></td>`;
|
||||||
}
|
}
|
||||||
return `<td class="fw-bold">${escHtml(display || billing || '\u2014')}</td>`;
|
return `<td class="fw-bold">${escHtml(display || billing || '\u2014')}</td>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,12 +95,15 @@ async function selectRun(runId) {
|
|||||||
const ddMobile = document.getElementById('runsDropdownMobile');
|
const ddMobile = document.getElementById('runsDropdownMobile');
|
||||||
if (ddMobile && ddMobile.value !== runId) ddMobile.value = runId;
|
if (ddMobile && ddMobile.value !== runId) ddMobile.value = runId;
|
||||||
|
|
||||||
|
const emptyState = document.getElementById('logEmptyState');
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
document.getElementById('logViewerSection').style.display = 'none';
|
document.getElementById('logViewerSection').style.display = 'none';
|
||||||
|
if (emptyState) emptyState.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('logViewerSection').style.display = '';
|
document.getElementById('logViewerSection').style.display = '';
|
||||||
|
if (emptyState) emptyState.style.display = 'none';
|
||||||
const logRunIdEl = document.getElementById('logRunId'); if (logRunIdEl) logRunIdEl.textContent = runId;
|
const logRunIdEl = document.getElementById('logRunId'); if (logRunIdEl) logRunIdEl.textContent = runId;
|
||||||
document.getElementById('logStatusBadge').innerHTML = '...';
|
document.getElementById('logStatusBadge').innerHTML = '...';
|
||||||
document.getElementById('textLogSection').style.display = 'none';
|
document.getElementById('textLogSection').style.display = 'none';
|
||||||
|
|||||||
@@ -501,8 +501,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
document.getElementById('detailStatus').innerHTML = '';
|
document.getElementById('detailStatus').innerHTML = '';
|
||||||
document.getElementById('detailIdComanda').textContent = '-';
|
document.getElementById('detailIdComanda').textContent = '-';
|
||||||
document.getElementById('detailIdPartener').textContent = '-';
|
document.getElementById('detailIdPartener').textContent = '-';
|
||||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
|
||||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center">Se incarca...</td></tr>';
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center">Se incarca...</td></tr>';
|
||||||
document.getElementById('detailError').style.display = 'none';
|
document.getElementById('detailError').style.display = 'none';
|
||||||
const retryBtn = document.getElementById('detailRetryBtn');
|
const retryBtn = document.getElementById('detailRetryBtn');
|
||||||
@@ -519,6 +517,26 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
if (priceCheckEl) priceCheckEl.innerHTML = '';
|
if (priceCheckEl) priceCheckEl.innerHTML = '';
|
||||||
const reconEl = document.getElementById('detailInvoiceRecon');
|
const reconEl = document.getElementById('detailInvoiceRecon');
|
||||||
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
|
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
|
||||||
|
// Remove diff badge from previous render
|
||||||
|
const prevDiffBadge = document.querySelector('.diff-badge');
|
||||||
|
if (prevDiffBadge) prevDiffBadge.remove();
|
||||||
|
// Reset compact header elements
|
||||||
|
const partenerRoa = document.getElementById('detailPartenerRoa');
|
||||||
|
if (partenerRoa) { partenerRoa.style.display = 'none'; partenerRoa.textContent = ''; }
|
||||||
|
const cuiGomag = document.getElementById('detailCuiGomag');
|
||||||
|
if (cuiGomag) cuiGomag.style.display = 'none';
|
||||||
|
const cuiRoa = document.getElementById('detailCuiRoa');
|
||||||
|
if (cuiRoa) {
|
||||||
|
cuiRoa.style.display = 'none';
|
||||||
|
// Restore original structure (may have been replaced by PF indicator)
|
||||||
|
cuiRoa.innerHTML = '<small class="text-muted">CUI:</small> <span class="font-data" id="detailCuiRoaVal"></span><span id="detailPartnerAnafArea"></span>';
|
||||||
|
}
|
||||||
|
const denomMismatch = document.getElementById('detailDenomMismatch');
|
||||||
|
if (denomMismatch) { denomMismatch.style.display = 'none'; denomMismatch.innerHTML = ''; }
|
||||||
|
const addressBlock = document.getElementById('detailAddressBlock');
|
||||||
|
if (addressBlock) addressBlock.style.display = 'none';
|
||||||
|
const addressLines = document.getElementById('detailAddressLines');
|
||||||
|
if (addressLines) addressLines.innerHTML = '';
|
||||||
|
|
||||||
const modalEl = document.getElementById('orderDetailModal');
|
const modalEl = document.getElementById('orderDetailModal');
|
||||||
const existing = bootstrap.Modal.getInstance(modalEl);
|
const existing = bootstrap.Modal.getInstance(modalEl);
|
||||||
@@ -554,8 +572,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
|
|
||||||
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
||||||
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
||||||
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
|
||||||
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
|
|
||||||
|
|
||||||
// Invoice info
|
// Invoice info
|
||||||
const inv = order.invoice;
|
const inv = order.invoice;
|
||||||
@@ -583,6 +599,9 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
reconEl.style.display = 'none';
|
reconEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render compact header info (partner + addresses)
|
||||||
|
_renderHeaderInfo(order);
|
||||||
|
|
||||||
if (order.error_message) {
|
if (order.error_message) {
|
||||||
document.getElementById('detailError').textContent = order.error_message;
|
document.getElementById('detailError').textContent = order.error_message;
|
||||||
document.getElementById('detailError').style.display = '';
|
document.getElementById('detailError').style.display = '';
|
||||||
@@ -679,7 +698,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
|
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
|
||||||
const pretRoaHtml = priceInfo.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '–';
|
const pretRoaHtml = priceInfo.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '–';
|
||||||
let matchDot, rowStyle;
|
let matchDot, rowStyle;
|
||||||
if (priceInfo.pret_roa == null && priceInfo.match == null) {
|
if (item.kit) {
|
||||||
|
matchDot = '<span class="badge" style="background:var(--info-light);color:var(--info-text);font-size:10px;padding:2px 6px">Kit</span>';
|
||||||
|
rowStyle = '';
|
||||||
|
} else if (priceInfo.pret_roa == null && priceInfo.match == null) {
|
||||||
matchDot = '<span class="dot dot-gray"></span>';
|
matchDot = '<span class="dot dot-gray"></span>';
|
||||||
rowStyle = '';
|
rowStyle = '';
|
||||||
} else if (priceInfo.match === false) {
|
} else if (priceInfo.match === false) {
|
||||||
@@ -817,3 +839,196 @@ function statusDot(status) {
|
|||||||
return '<span class="dot dot-gray"></span>';
|
return '<span class="dot dot-gray"></span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Address helpers (module scope) ───────────────
|
||||||
|
|
||||||
|
function fmtAddr(a) {
|
||||||
|
if (!a) return '\u2014';
|
||||||
|
if (typeof a === 'string') return a;
|
||||||
|
const parts = [a.address || a.strada || '', a.numar || ''].filter(Boolean);
|
||||||
|
const line1 = parts.join(' ').trim();
|
||||||
|
const line2 = [a.city || a.localitate || '', a.region || a.judet || ''].filter(Boolean).join(', ');
|
||||||
|
return [line1, line2].filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addrMatch(gomag, roa) {
|
||||||
|
if (!gomag || !roa) return true; // can't compare
|
||||||
|
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(/[^A-Z0-9]/g, '');
|
||||||
|
}
|
||||||
|
const gStreet = norm(gomag.address || gomag.strada || '');
|
||||||
|
const rStreet = norm((roa.strada || '') + (roa.numar || ''));
|
||||||
|
const gCity = norm(gomag.city || gomag.localitate || '');
|
||||||
|
const rCity = norm(roa.localitate || '');
|
||||||
|
const gRegion = norm(gomag.region || gomag.judet || '');
|
||||||
|
const rRegion = norm(roa.judet || '');
|
||||||
|
return gStreet === rStreet && gCity === rCity && gRegion === rRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEfacturaRisk(roa) {
|
||||||
|
if (!roa || typeof roa === 'string') return false;
|
||||||
|
return !roa.judet || !roa.localitate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compact Header Info Rendering ────────────────
|
||||||
|
|
||||||
|
function _renderHeaderInfo(order) {
|
||||||
|
const pi = order.partner_info;
|
||||||
|
const isPJ = pi && pi.cod_fiscal_gomag;
|
||||||
|
|
||||||
|
// GoMag CUI (PJ only)
|
||||||
|
if (isPJ) {
|
||||||
|
const cuiGomagEl = document.getElementById('detailCuiGomag');
|
||||||
|
if (cuiGomagEl) {
|
||||||
|
document.getElementById('detailCuiGomagVal').textContent = pi.cod_fiscal_gomag;
|
||||||
|
cuiGomagEl.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ROA column — show partner name for both PJ and PF
|
||||||
|
if (pi && pi.denumire_roa) {
|
||||||
|
const partenerRoa = document.getElementById('detailPartenerRoa');
|
||||||
|
if (partenerRoa) {
|
||||||
|
partenerRoa.textContent = pi.denumire_roa;
|
||||||
|
partenerRoa.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPJ) {
|
||||||
|
const cuiRoaEl = document.getElementById('detailCuiRoa');
|
||||||
|
if (cuiRoaEl) {
|
||||||
|
document.getElementById('detailCuiRoaVal').textContent = pi.cod_fiscal_roa || '\u2014';
|
||||||
|
cuiRoaEl.style.display = '';
|
||||||
|
|
||||||
|
// CUI correction badge: +RO (added prefix) or −RO (removed prefix)
|
||||||
|
let cuiCorrHtml = '';
|
||||||
|
if (pi.anaf_cod_fiscal_adjusted && pi.cod_fiscal_gomag && pi.cod_fiscal_roa) {
|
||||||
|
const gomagHasRO = /^RO\s*/i.test(pi.cod_fiscal_gomag);
|
||||||
|
const roaHasRO = /^RO\s*/i.test(pi.cod_fiscal_roa);
|
||||||
|
if (!gomagHasRO && roaHasRO) {
|
||||||
|
cuiCorrHtml = ' <span class="anaf-badge anaf-badge-ok" aria-label="Prefix RO adaugat conform ANAF"><i class="bi bi-plus-circle"></i> +RO</span>';
|
||||||
|
} else if (gomagHasRO && !roaHasRO) {
|
||||||
|
cuiCorrHtml = ' <span class="anaf-badge anaf-badge-warn" aria-label="Prefix RO eliminat conform ANAF"><i class="bi bi-dash-circle"></i> −RO</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANAF badge
|
||||||
|
const anafArea = document.getElementById('detailPartnerAnafArea');
|
||||||
|
if (anafArea) {
|
||||||
|
let anafBadge;
|
||||||
|
if (pi.anaf_platitor_tva === 1) {
|
||||||
|
anafBadge = '<span class="anaf-badge anaf-badge-ok" aria-label="Platitor TVA">Platitor TVA</span>';
|
||||||
|
} else if (pi.anaf_platitor_tva === 0) {
|
||||||
|
anafBadge = '<span class="anaf-badge anaf-badge-warn" aria-label="Neplatitor TVA">Neplatitor TVA</span>';
|
||||||
|
} else {
|
||||||
|
anafBadge = '<span class="anaf-badge anaf-badge-gray" aria-label="Status TVA necunoscut">?</span>';
|
||||||
|
}
|
||||||
|
anafArea.innerHTML = cuiCorrHtml + ' ' + anafBadge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// PF indicator — show muted text in CUI area
|
||||||
|
const cuiRoaEl = document.getElementById('detailCuiRoa');
|
||||||
|
if (cuiRoaEl) {
|
||||||
|
document.getElementById('detailCuiRoaVal').textContent = '';
|
||||||
|
document.getElementById('detailPartnerAnafArea').innerHTML = '';
|
||||||
|
cuiRoaEl.innerHTML = '<small class="text-muted" style="font-style:italic">Persoana fizica</small>';
|
||||||
|
cuiRoaEl.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ERROR orders: muted dashes for ROA fields
|
||||||
|
if (order.status === 'ERROR' && !order.id_comanda) {
|
||||||
|
document.getElementById('detailIdComanda').innerHTML = '<span class="text-muted">\u2014</span>';
|
||||||
|
document.getElementById('detailIdPartener').innerHTML = '<span class="text-muted">\u2014</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denomination mismatch alert
|
||||||
|
if (isPJ && pi.anaf_denumire_mismatch && pi.denumire_anaf) {
|
||||||
|
const denomEl = document.getElementById('detailDenomMismatch');
|
||||||
|
if (denomEl) {
|
||||||
|
denomEl.innerHTML = `<div class="denom-mismatch">
|
||||||
|
<span class="denom-mismatch-title"><i class="bi bi-exclamation-triangle"></i> Denumire diferita</span><br>
|
||||||
|
<span style="font-size:13px">GoMag: <strong>${esc(order.customer_name || '')}</strong></span><br>
|
||||||
|
<span style="font-size:13px">ANAF: <strong>${esc(pi.denumire_anaf)}</strong></span>
|
||||||
|
</div>`;
|
||||||
|
denomEl.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact address lines
|
||||||
|
const addr = order.addresses;
|
||||||
|
if (!addr || (!addr.livrare_gomag && !addr.facturare_gomag)) return;
|
||||||
|
|
||||||
|
const addressBlock = document.getElementById('detailAddressBlock');
|
||||||
|
const addressLines = document.getElementById('detailAddressLines');
|
||||||
|
if (!addressBlock || !addressLines) return;
|
||||||
|
|
||||||
|
addressBlock.style.display = '';
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
function addrLine(label, addrObj, matchIcon) {
|
||||||
|
const text = fmtAddr(addrObj);
|
||||||
|
const escaped = esc(text);
|
||||||
|
let icon = '';
|
||||||
|
if (matchIcon === 'match') {
|
||||||
|
icon = ' <i class="bi bi-check-lg addr-line-match" title="Adrese identice" role="img" aria-label="Adrese identice"></i>';
|
||||||
|
} else if (matchIcon === 'mismatch') {
|
||||||
|
icon = ' <i class="bi bi-exclamation-triangle" title="Adrese diferite" role="img" aria-label="Adrese diferite"></i>';
|
||||||
|
} else if (matchIcon === 'risk') {
|
||||||
|
icon = ' <i class="bi bi-exclamation-octagon" title="Risc eFactura" role="img" aria-label="Risc eFactura" style="color:var(--error-text)"></i>';
|
||||||
|
}
|
||||||
|
return `<div class="addr-line">
|
||||||
|
<span class="addr-line-label">${label}</span>
|
||||||
|
<span class="addr-line-text" title="${escaped}">${escaped}</span>${icon}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Livrare
|
||||||
|
if (addr.livrare_gomag || addr.livrare_roa) {
|
||||||
|
html += addrLine('Livrare GoMag:', addr.livrare_gomag, null);
|
||||||
|
const livrRisk = hasEfacturaRisk(addr.livrare_roa);
|
||||||
|
const livrMatch = addrMatch(addr.livrare_gomag, addr.livrare_roa);
|
||||||
|
let matchType = null;
|
||||||
|
if (addr.livrare_roa) {
|
||||||
|
matchType = livrRisk ? 'risk' : (livrMatch ? 'match' : 'mismatch');
|
||||||
|
}
|
||||||
|
html += addrLine('Livrare ROA:', addr.livrare_roa, matchType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facturare
|
||||||
|
if (addr.facturare_gomag || addr.facturare_roa) {
|
||||||
|
html += addrLine('Facturare GoMag:', addr.facturare_gomag, null);
|
||||||
|
const factRisk = hasEfacturaRisk(addr.facturare_roa);
|
||||||
|
const factMatch = addrMatch(addr.facturare_gomag, addr.facturare_roa);
|
||||||
|
let matchType = null;
|
||||||
|
if (addr.facturare_roa) {
|
||||||
|
matchType = factRisk ? 'risk' : (factMatch ? 'match' : 'mismatch');
|
||||||
|
}
|
||||||
|
html += addrLine('Facturare ROA:', addr.facturare_roa, matchType);
|
||||||
|
}
|
||||||
|
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
{% set rp = request.scope.get('root_path', '') %}
|
{% set rp = request.scope.get('root_path', '') %}
|
||||||
<link href="{{ rp }}/static/css/style.css?v=25" rel="stylesheet">
|
<link href="{{ rp }}/static/css/style.css?v=34" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Top Navbar (hidden on mobile via CSS) -->
|
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||||
@@ -93,16 +93,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
|
<!-- GOMAG Column -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
|
<div class="detail-col-label">GOMAG</div>
|
||||||
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
|
<div class="detail-client-name" id="detailCustomer">...</div>
|
||||||
<small class="text-muted">Status:</small> <span id="detailStatus"></span><span id="detailPriceCheck" class="ms-2"></span>
|
<div class="detail-cui-line" id="detailCuiGomag" style="display:none">
|
||||||
|
<small class="text-muted">CUI:</small> <span class="font-data" id="detailCuiGomagVal"></span>
|
||||||
|
</div>
|
||||||
|
<div><small class="text-muted">Data:</small> <span id="detailDate"></span></div>
|
||||||
|
<div><small class="text-muted">Status:</small> <span id="detailStatus"></span><span id="detailPriceCheck" class="ms-2"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ROA Column -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
|
<div class="detail-col-label">ROA</div>
|
||||||
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
|
<div class="detail-client-name" id="detailPartenerRoa" style="display:none"></div>
|
||||||
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
|
<div class="detail-cui-line" id="detailCuiRoa" style="display:none">
|
||||||
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
|
<small class="text-muted">CUI:</small> <span class="font-data" id="detailCuiRoaVal"></span>
|
||||||
|
<span id="detailPartnerAnafArea"></span>
|
||||||
|
</div>
|
||||||
|
<div><small class="text-muted">ID Comanda:</small> <span class="font-data detail-roa-id" id="detailIdComanda">-</span></div>
|
||||||
|
<div><small class="text-muted">ID Partener:</small> <span class="font-data detail-roa-id" id="detailIdPartener">-</span></div>
|
||||||
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
|
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
|
||||||
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
|
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
|
||||||
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
|
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
|
||||||
@@ -110,6 +120,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Denomination mismatch alert -->
|
||||||
|
<div id="detailDenomMismatch" style="display:none" class="mb-2"></div>
|
||||||
|
<!-- Compact Address Lines -->
|
||||||
|
<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 id="detailAddressLines"></div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive d-none d-md-block">
|
<div class="table-responsive d-none d-md-block">
|
||||||
<table class="table table-sm table-bordered mb-0">
|
<table class="table table-sm table-bordered mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
@@ -144,7 +161,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=20"></script>
|
<script src="{{ rp }}/static/js/shared.js?v=25"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
<button class="filter-pill d-none d-md-inline-flex" data-status="INVOICED">Facturate <span class="filter-count fc-green" id="cntFact">0</span></button>
|
<button class="filter-pill d-none d-md-inline-flex" data-status="INVOICED">Facturate <span class="filter-count fc-green" id="cntFact">0</span></button>
|
||||||
<button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefacturate <span class="filter-count fc-red" id="cntNef">0</span></button>
|
<button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefacturate <span class="filter-count fc-red" id="cntNef">0</span></button>
|
||||||
<button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
|
<button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
|
||||||
|
<button class="filter-pill d-none d-md-inline-flex" data-status="DIFFS">Diferente <span class="filter-count fc-orange" id="cntDiff">0</span></button>
|
||||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">↻</button>
|
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">↻</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-md-none mb-2 d-flex align-items-center gap-2" style="max-width:100%;overflow:hidden">
|
<div class="d-md-none mb-2 d-flex align-items-center gap-2" style="max-width:100%;overflow:hidden">
|
||||||
@@ -114,5 +115,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=34"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=36"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state (shown when no run selected) -->
|
||||||
|
<div id="logEmptyState" class="text-center py-5" style="color:var(--text-muted)">
|
||||||
|
<i class="bi bi-journal-text" style="font-size:2.5rem;opacity:0.4"></i>
|
||||||
|
<p class="mt-3 mb-1" style="font-size:0.9375rem">Selecteaza un sync run din lista de mai sus</p>
|
||||||
|
<p style="font-size:0.8125rem">Jurnalele arata detalii pentru fiecare sincronizare: comenzi importate, omise, erori.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Detail Viewer (shown when run selected) -->
|
<!-- Detail Viewer (shown when run selected) -->
|
||||||
<div id="logViewerSection" style="display:none;">
|
<div id="logViewerSection" style="display:none;">
|
||||||
<!-- Filter pills -->
|
<!-- Filter pills -->
|
||||||
@@ -102,5 +109,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=14"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=15"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS
|
|||||||
|
|
||||||
-- 20.03.2026 - import parteneri GoMag: PJ/PF, shipping/billing, cautare/creare automata
|
-- 20.03.2026 - import parteneri GoMag: PJ/PF, shipping/billing, cautare/creare automata
|
||||||
-- 31.03.2026 - parser inteligent adrese: split numar in bloc/scara/apart/etaj (fix ORA-12899 pe NUMAR max 10 chars)
|
-- 31.03.2026 - parser inteligent adrese: split numar in bloc/scara/apart/etaj (fix ORA-12899 pe NUMAR max 10 chars)
|
||||||
|
-- 01.04.2026 - ANAF dedup: cautare duala CUI, adrese pe strada+diacritics, strip diacritics la stocare
|
||||||
|
-- 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")
|
||||||
|
|
||||||
-- ====================================================================
|
-- ====================================================================
|
||||||
-- CONSTANTS
|
-- CONSTANTS
|
||||||
@@ -65,6 +68,7 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS
|
|||||||
p_denumire IN VARCHAR2,
|
p_denumire IN VARCHAR2,
|
||||||
p_registru IN VARCHAR2,
|
p_registru IN VARCHAR2,
|
||||||
p_is_persoana_juridica IN NUMBER DEFAULT NULL,
|
p_is_persoana_juridica IN NUMBER DEFAULT NULL,
|
||||||
|
p_strict_search IN NUMBER DEFAULT NULL,
|
||||||
p_id_partener OUT NUMBER);
|
p_id_partener OUT NUMBER);
|
||||||
|
|
||||||
procedure cauta_sau_creeaza_adresa(p_id_part IN NUMBER,
|
procedure cauta_sau_creeaza_adresa(p_id_part IN NUMBER,
|
||||||
@@ -105,7 +109,8 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS
|
|||||||
* @param p_cod_fiscal Codul fiscal de cautat
|
* @param p_cod_fiscal Codul fiscal de cautat
|
||||||
* @return ID_PART sau NULL daca nu gaseste
|
* @return ID_PART sau NULL daca nu gaseste
|
||||||
*/
|
*/
|
||||||
FUNCTION cauta_partener_dupa_cod_fiscal(p_cod_fiscal IN VARCHAR2)
|
FUNCTION cauta_partener_dupa_cod_fiscal(p_cod_fiscal IN VARCHAR2,
|
||||||
|
p_strict_search IN NUMBER DEFAULT NULL)
|
||||||
RETURN NUMBER;
|
RETURN NUMBER;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,10 +151,25 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS
|
|||||||
*/
|
*/
|
||||||
PROCEDURE clear_error;
|
PROCEDURE clear_error;
|
||||||
|
|
||||||
|
FUNCTION strip_diacritics(p_text IN VARCHAR2) RETURN VARCHAR2;
|
||||||
|
|
||||||
END PACK_IMPORT_PARTENERI;
|
END PACK_IMPORT_PARTENERI;
|
||||||
/
|
/
|
||||||
CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
||||||
|
|
||||||
|
-- 01.04.2026 - strip_diacritics la stocare adrese si parteneri
|
||||||
|
FUNCTION strip_diacritics(p_text IN VARCHAR2) RETURN VARCHAR2 IS
|
||||||
|
BEGIN
|
||||||
|
IF p_text IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
RETURN TRANSLATE(
|
||||||
|
UPPER(TRIM(p_text)),
|
||||||
|
'ĂăÂâÎîȘșȚțŞşŢţ',
|
||||||
|
'AAAAIISSTTSSTT'
|
||||||
|
);
|
||||||
|
END strip_diacritics;
|
||||||
|
|
||||||
-- ================================================================
|
-- ================================================================
|
||||||
-- ERROR MANAGEMENT FUNCTIONS IMPLEMENTATION
|
-- ERROR MANAGEMENT FUNCTIONS IMPLEMENTATION
|
||||||
-- ================================================================
|
-- ================================================================
|
||||||
@@ -212,57 +232,77 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
-- PUBLIC FUNCTIONS IMPLEMENTATION
|
-- PUBLIC FUNCTIONS IMPLEMENTATION
|
||||||
-- ====================================================================
|
-- ====================================================================
|
||||||
|
|
||||||
FUNCTION cauta_partener_dupa_cod_fiscal(p_cod_fiscal IN VARCHAR2)
|
-- 01.04.2026 - cautare duala cod_fiscal cu/fara prefix RO (anti-duplicare parteneri)
|
||||||
|
-- 02.04.2026 - p_strict_search=1: cautare doar forma exacta (+ varianta cu spatiu pt RO)
|
||||||
|
FUNCTION cauta_partener_dupa_cod_fiscal(p_cod_fiscal IN VARCHAR2,
|
||||||
|
p_strict_search IN NUMBER DEFAULT NULL)
|
||||||
RETURN NUMBER IS
|
RETURN NUMBER IS
|
||||||
v_id_part NUMBER;
|
v_id_part NUMBER;
|
||||||
v_cod_fiscal_curat VARCHAR2(50);
|
v_cod_fiscal_curat VARCHAR2(50);
|
||||||
|
v_bare_cui VARCHAR2(50);
|
||||||
|
v_ro_cui VARCHAR2(52);
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Validare input
|
IF p_cod_fiscal IS NULL OR LENGTH(TRIM(p_cod_fiscal)) < C_MIN_COD_FISCAL THEN
|
||||||
IF p_cod_fiscal IS NULL OR
|
|
||||||
LENGTH(TRIM(p_cod_fiscal)) < C_MIN_COD_FISCAL THEN
|
|
||||||
RETURN NULL;
|
RETURN NULL;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
v_cod_fiscal_curat := curata_text_cautare(p_cod_fiscal);
|
v_cod_fiscal_curat := UPPER(TRIM(p_cod_fiscal));
|
||||||
|
|
||||||
-- pINFO('Cautare partener dupa cod_fiscal: ' || v_cod_fiscal_curat, 'IMPORT_PARTENERI');
|
-- Extract bare CUI (without RO prefix)
|
||||||
|
IF REGEXP_LIKE(v_cod_fiscal_curat, '^RO\s*\d') THEN
|
||||||
|
v_bare_cui := TRIM(REGEXP_REPLACE(v_cod_fiscal_curat, '^RO\s*', ''));
|
||||||
|
ELSE
|
||||||
|
v_bare_cui := v_cod_fiscal_curat;
|
||||||
|
END IF;
|
||||||
|
v_ro_cui := 'RO' || v_bare_cui;
|
||||||
|
|
||||||
-- Cautare in NOM_PARTENERI
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT id_part
|
IF p_strict_search = 1 THEN
|
||||||
INTO v_id_part
|
-- Cautare STRICT: doar forma primita + varianta cu spatiu
|
||||||
FROM nom_parteneri
|
IF REGEXP_LIKE(v_cod_fiscal_curat, '^RO\d') THEN
|
||||||
WHERE UPPER(TRIM(cod_fiscal)) = v_cod_fiscal_curat
|
-- Input "RO123" → cauta si "RO 123"
|
||||||
AND ROWNUM = 1; -- In caz de duplicate, luam primul
|
SELECT id_part INTO v_id_part FROM (
|
||||||
|
SELECT id_part
|
||||||
|
FROM nom_parteneri
|
||||||
|
WHERE UPPER(TRIM(cod_fiscal)) IN (v_cod_fiscal_curat, 'RO ' || v_bare_cui)
|
||||||
|
AND NVL(sters, 0) = 0
|
||||||
|
ORDER BY NVL(inactiv, 0) ASC, id_part DESC
|
||||||
|
) WHERE ROWNUM = 1;
|
||||||
|
ELSE
|
||||||
|
-- Input "123" → cauta doar "123"
|
||||||
|
SELECT id_part INTO v_id_part FROM (
|
||||||
|
SELECT id_part
|
||||||
|
FROM nom_parteneri
|
||||||
|
WHERE UPPER(TRIM(cod_fiscal)) = v_bare_cui
|
||||||
|
AND NVL(sters, 0) = 0
|
||||||
|
ORDER BY NVL(inactiv, 0) ASC, id_part DESC
|
||||||
|
) WHERE ROWNUM = 1;
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
-- Cautare DUALA anti-dedup: toate formele (comportament original)
|
||||||
|
-- Search 3 forms: bare, RO+bare, RO+space+bare (index-friendly)
|
||||||
|
-- Priority: active + exact form > active + alternate > inactive
|
||||||
|
SELECT id_part INTO v_id_part FROM (
|
||||||
|
SELECT id_part
|
||||||
|
FROM nom_parteneri
|
||||||
|
WHERE UPPER(TRIM(cod_fiscal)) IN (v_bare_cui, v_ro_cui, 'RO ' || v_bare_cui)
|
||||||
|
AND NVL(sters, 0) = 0
|
||||||
|
ORDER BY NVL(inactiv, 0) ASC,
|
||||||
|
CASE WHEN UPPER(TRIM(cod_fiscal)) = v_cod_fiscal_curat THEN 0 ELSE 1 END ASC,
|
||||||
|
id_part DESC
|
||||||
|
) WHERE ROWNUM = 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- pINFO('Gasit partener cu cod_fiscal ' || v_cod_fiscal_curat || ': ID_PART=' || v_id_part, 'IMPORT_PARTENERI');
|
|
||||||
RETURN v_id_part;
|
RETURN v_id_part;
|
||||||
|
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
WHEN NO_DATA_FOUND THEN
|
WHEN NO_DATA_FOUND THEN
|
||||||
-- pINFO('Nu s-a gasit partener cu cod_fiscal: ' || v_cod_fiscal_curat, 'IMPORT_PARTENERI');
|
|
||||||
RETURN NULL;
|
RETURN NULL;
|
||||||
|
|
||||||
WHEN TOO_MANY_ROWS THEN
|
|
||||||
-- Luam primul gasit
|
|
||||||
SELECT id_part
|
|
||||||
INTO v_id_part
|
|
||||||
FROM (SELECT id_part
|
|
||||||
FROM nom_parteneri
|
|
||||||
WHERE UPPER(TRIM(cod_fiscal)) = v_cod_fiscal_curat
|
|
||||||
ORDER BY id_part)
|
|
||||||
WHERE ROWNUM = 1;
|
|
||||||
|
|
||||||
pINFO('WARNING: Multiple parteneri cu acelasi cod_fiscal ' ||
|
|
||||||
v_cod_fiscal_curat || '. Selectat ID_PART=' || v_id_part,
|
|
||||||
'IMPORT_PARTENERI');
|
|
||||||
RETURN v_id_part;
|
|
||||||
END;
|
END;
|
||||||
|
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
WHEN OTHERS THEN
|
WHEN OTHERS THEN
|
||||||
pINFO('ERROR in cauta_partener_dupa_cod_fiscal: ' || SQLERRM,
|
pINFO('ERROR in cauta_partener_dupa_cod_fiscal: ' || SQLERRM, 'IMPORT_PARTENERI');
|
||||||
'IMPORT_PARTENERI');
|
|
||||||
RAISE;
|
RAISE;
|
||||||
END cauta_partener_dupa_cod_fiscal;
|
END cauta_partener_dupa_cod_fiscal;
|
||||||
|
|
||||||
@@ -494,6 +534,38 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
p_bloc := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(BLOC|BL\.?)\s*(\S+).*', '\3', 1, 1));
|
p_bloc := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(BLOC|BL\.?)\s*(\S+).*', '\3', 1, 1));
|
||||||
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(BLOC|BL\.?)\s*\S+', '', 1, 1, 'i'));
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(BLOC|BL\.?)\s*\S+', '', 1, 1, 'i'));
|
||||||
END IF;
|
END IF;
|
||||||
|
-- Re-read v_token_upper after BLOC removal may have changed p_strada
|
||||||
|
v_token_upper := UPPER(p_strada);
|
||||||
|
-- Extrage APARTAMENT din strada (ex: "George Enescu apartament 8")
|
||||||
|
-- Separator [\s.:] obligatoriu dupa prefix scurt (AP) pt a evita false-positives (ex: "APATEULUI")
|
||||||
|
IF REGEXP_LIKE(v_token_upper, '(\s)(APARTAMENT|APART\.?|AP\.?)[\s.:]+(\S+)') THEN
|
||||||
|
p_apart := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(APARTAMENT|APART\.?|AP\.?)[\s.:]+(\S+).*', '\3', 1, 1));
|
||||||
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(APARTAMENT|APART\.?|AP\.?)[\s.:]+\S+', '', 1, 1, 'i'));
|
||||||
|
-- Fallback: "apart14" sau "ap14" — keyword lipit direct de cifra (sigur, nu exista cuvinte AP+cifra)
|
||||||
|
ELSIF REGEXP_LIKE(v_token_upper, '(\s)(APART|AP)(\d\S*)') THEN
|
||||||
|
p_apart := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(APART|AP)(\d\S*).*', '\3', 1, 1));
|
||||||
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(APART|AP)\d\S*', '', 1, 1, 'i'));
|
||||||
|
END IF;
|
||||||
|
v_token_upper := UPPER(p_strada);
|
||||||
|
-- Extrage SCARA din strada (ex: "Str Dacia Nr5 scara B")
|
||||||
|
IF REGEXP_LIKE(v_token_upper, '(\s)(SCARA|SC\.?)[\s.:]+(\S+)') THEN
|
||||||
|
p_scara := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(SCARA|SC\.?)[\s.:]+(\S+).*', '\3', 1, 1));
|
||||||
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(SCARA|SC\.?)[\s.:]+\S+', '', 1, 1, 'i'));
|
||||||
|
-- Fallback: "scara3" sau "sc1" — keyword lipit direct de cifra
|
||||||
|
ELSIF REGEXP_LIKE(v_token_upper, '(\s)(SCARA|SC)(\d\S*)') THEN
|
||||||
|
p_scara := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(SCARA|SC)(\d\S*).*', '\3', 1, 1));
|
||||||
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(SCARA|SC)\d\S*', '', 1, 1, 'i'));
|
||||||
|
END IF;
|
||||||
|
v_token_upper := UPPER(p_strada);
|
||||||
|
-- Extrage ETAJ din strada (ex: "Str Dacia Nr5 etaj 2")
|
||||||
|
IF REGEXP_LIKE(v_token_upper, '(\s)(ETAJ|ET\.?)[\s.:]+(\S+)') THEN
|
||||||
|
p_etaj := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(ETAJ|ET\.?)[\s.:]+(\S+).*', '\3', 1, 1));
|
||||||
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(ETAJ|ET\.?)[\s.:]+\S+', '', 1, 1, 'i'));
|
||||||
|
-- Fallback: "etaj2" sau "et2" — keyword lipit direct de cifra
|
||||||
|
ELSIF REGEXP_LIKE(v_token_upper, '(\s)(ETAJ|ET)(\d\S*)') THEN
|
||||||
|
p_etaj := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(ETAJ|ET)(\d\S*).*', '\3', 1, 1));
|
||||||
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(ETAJ|ET)\d\S*', '', 1, 1, 'i'));
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- ================================================================
|
-- ================================================================
|
||||||
@@ -598,6 +670,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
p_denumire IN VARCHAR2,
|
p_denumire IN VARCHAR2,
|
||||||
p_registru IN VARCHAR2,
|
p_registru IN VARCHAR2,
|
||||||
p_is_persoana_juridica IN NUMBER DEFAULT NULL,
|
p_is_persoana_juridica IN NUMBER DEFAULT NULL,
|
||||||
|
p_strict_search IN NUMBER DEFAULT NULL,
|
||||||
p_id_partener OUT NUMBER) IS
|
p_id_partener OUT NUMBER) IS
|
||||||
|
|
||||||
v_id_part NUMBER;
|
v_id_part NUMBER;
|
||||||
@@ -631,7 +704,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
-- STEP 1: Cautare dupa cod fiscal (prioritate 1)
|
-- STEP 1: Cautare dupa cod fiscal (prioritate 1)
|
||||||
IF v_cod_fiscal_curat IS NOT NULL AND
|
IF v_cod_fiscal_curat IS NOT NULL AND
|
||||||
LENGTH(v_cod_fiscal_curat) >= C_MIN_COD_FISCAL THEN
|
LENGTH(v_cod_fiscal_curat) >= C_MIN_COD_FISCAL THEN
|
||||||
v_id_part := cauta_partener_dupa_cod_fiscal(v_cod_fiscal_curat);
|
v_id_part := cauta_partener_dupa_cod_fiscal(v_cod_fiscal_curat, p_strict_search);
|
||||||
|
|
||||||
IF v_id_part IS NOT NULL THEN
|
IF v_id_part IS NOT NULL THEN
|
||||||
-- pINFO('Partener gasit dupa cod_fiscal. ID_PART=' || v_id_part, 'IMPORT_PARTENERI');
|
-- pINFO('Partener gasit dupa cod_fiscal. ID_PART=' || v_id_part, 'IMPORT_PARTENERI');
|
||||||
@@ -642,13 +715,16 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- STEP 2: Cautare dupa denumire exacta (prioritate 2)
|
-- STEP 2: Cautare dupa denumire exacta (prioritate 2)
|
||||||
v_id_part := cauta_partener_dupa_denumire(v_denumire_curata);
|
-- Skip cand cautare stricta ANAF — vrem partener nou cu CUI corect
|
||||||
|
IF p_strict_search IS NULL THEN
|
||||||
|
v_id_part := cauta_partener_dupa_denumire(v_denumire_curata);
|
||||||
|
|
||||||
IF v_id_part IS NOT NULL THEN
|
IF v_id_part IS NOT NULL THEN
|
||||||
-- pINFO('Partener gasit dupa denumire. ID_PART=' || v_id_part, 'IMPORT_PARTENERI');
|
-- pINFO('Partener gasit dupa denumire. ID_PART=' || v_id_part, 'IMPORT_PARTENERI');
|
||||||
-- pINFO('=== SFARSIT cauta_sau_creeaza_partener ===', 'IMPORT_PARTENERI');
|
-- pINFO('=== SFARSIT cauta_sau_creeaza_partener ===', 'IMPORT_PARTENERI');
|
||||||
p_id_partener := v_id_part;
|
p_id_partener := v_id_part;
|
||||||
RETURN;
|
RETURN;
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- STEP 3: Creare partener nou
|
-- STEP 3: Creare partener nou
|
||||||
@@ -677,6 +753,9 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
-- pINFO('Nume separat: NUME=' || NVL(v_nume, 'NULL') || ', PRENUME=' || NVL(v_prenume, 'NULL'), 'IMPORT_PARTENERI');
|
-- pINFO('Nume separat: NUME=' || NVL(v_nume, 'NULL') || ', PRENUME=' || NVL(v_prenume, 'NULL'), 'IMPORT_PARTENERI');
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- Strip diacritics from partner name before storage
|
||||||
|
v_denumire_curata := strip_diacritics(v_denumire_curata);
|
||||||
|
|
||||||
-- Creare partener prin pack_def
|
-- Creare partener prin pack_def
|
||||||
BEGIN
|
BEGIN
|
||||||
IF v_este_persoana_fizica = 1 THEN
|
IF v_este_persoana_fizica = 1 THEN
|
||||||
@@ -797,30 +876,37 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
v_apart,
|
v_apart,
|
||||||
v_etaj);
|
v_etaj);
|
||||||
|
|
||||||
-- caut prima adresa dupa judet si localitate, ordonate dupa principala = 1
|
-- 01.04.2026 - cautare adresa pe strada + diacritics + id_loc validation
|
||||||
|
-- TIER 1: county + city + street (diacritics normalized) + valid id_loc
|
||||||
begin
|
begin
|
||||||
select max(id_adresa) over(order by principala desc)
|
select id_adresa into p_id_adresa from (
|
||||||
into p_id_adresa
|
select id_adresa
|
||||||
from vadrese_parteneri
|
|
||||||
where id_part = p_id_part
|
|
||||||
and judet = v_judet
|
|
||||||
and localitate = v_localitate;
|
|
||||||
exception
|
|
||||||
WHEN NO_DATA_FOUND THEN
|
|
||||||
p_id_adresa := null;
|
|
||||||
end;
|
|
||||||
|
|
||||||
-- caut prima adresa dupa judet, ordonate dupa principala = 1
|
|
||||||
if p_id_adresa is null then
|
|
||||||
begin
|
|
||||||
select max(id_adresa) over(order by principala desc)
|
|
||||||
into p_id_adresa
|
|
||||||
from vadrese_parteneri
|
from vadrese_parteneri
|
||||||
where id_part = p_id_part
|
where id_part = p_id_part
|
||||||
and judet = v_judet;
|
and judet = v_judet
|
||||||
|
and localitate = v_localitate
|
||||||
|
and strip_diacritics(strada) = strip_diacritics(v_strada)
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- 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
|
exception
|
||||||
WHEN NO_DATA_FOUND THEN
|
when NO_DATA_FOUND then p_id_adresa := null;
|
||||||
p_id_adresa := null;
|
|
||||||
end;
|
end;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
@@ -870,6 +956,12 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
-- 01.04.2026 - strip_diacritics la stocare adrese
|
||||||
|
v_strada := strip_diacritics(v_strada);
|
||||||
|
v_localitate := strip_diacritics(v_localitate);
|
||||||
|
v_numar := strip_diacritics(v_numar);
|
||||||
|
v_bloc := strip_diacritics(v_bloc);
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
pack_def.adauga_adresa_partener2(tnId_part => p_id_part,
|
pack_def.adauga_adresa_partener2(tnId_part => p_id_part,
|
||||||
tcDenumire_adresa => NULL,
|
tcDenumire_adresa => NULL,
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ def test_order_detail_modal_has_roa_ids(page: Page, app_url: str):
|
|||||||
expect(modal).to_be_attached()
|
expect(modal).to_be_attached()
|
||||||
|
|
||||||
modal_html = modal.inner_html()
|
modal_html = modal.inner_html()
|
||||||
assert "ID Comanda ROA" in modal_html, "Missing 'ID Comanda ROA' label in order detail modal"
|
assert "ID Comanda" in modal_html, "Missing 'ID Comanda' label in order detail modal"
|
||||||
assert "ID Partener" in modal_html, "Missing 'ID Partener' label in order detail modal"
|
assert "ID Partener" in modal_html, "Missing 'ID Partener' label in order detail modal"
|
||||||
assert "ID Adr. Facturare" in modal_html, "Missing 'ID Adr. Facturare' label in order detail modal"
|
assert "GOMAG" in modal_html, "Missing 'GOMAG' column label in order detail modal"
|
||||||
assert "ID Adr. Livrare" in modal_html, "Missing 'ID Adr. Livrare' label in order detail modal"
|
assert "ROA" in modal_html, "Missing 'ROA' column label in order detail modal"
|
||||||
|
|
||||||
|
|
||||||
def test_order_detail_items_table_columns(page: Page, app_url: str):
|
def test_order_detail_items_table_columns(page: Page, app_url: str):
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ class TestGetPricesForOrderCantitateRoa:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def test_cantitate_roa_half_matches(self):
|
def test_cantitate_roa_half_matches(self):
|
||||||
"""cantitate_roa=0.5: ROA price 14.00 * 0.5 = 7.00 should match GoMag 7.00."""
|
"""cantitate_roa=0.5: kit item — price check skipped entirely."""
|
||||||
items = [{
|
items = [{
|
||||||
"sku": "1057308134545",
|
"sku": "1057308134545",
|
||||||
"price": 7.00,
|
"price": 7.00,
|
||||||
@@ -554,12 +554,12 @@ class TestGetPricesForOrderCantitateRoa:
|
|||||||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={100: (14.00, 1.19)})
|
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={100: (14.00, 1.19)})
|
||||||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||||||
|
|
||||||
assert result["items"][0]["match"] is True
|
assert result["items"][0]["match"] is None
|
||||||
assert abs(result["items"][0]["pret_roa"] - 7.00) < 0.01
|
assert result["items"][0]["kit"] is True
|
||||||
assert result["summary"]["mismatches"] == 0
|
assert result["summary"]["mismatches"] == 0
|
||||||
|
|
||||||
def test_cantitate_roa_half_mismatch(self):
|
def test_cantitate_roa_half_mismatch(self):
|
||||||
"""cantitate_roa=0.5: ROA price 10.00 * 0.5 = 5.00 != GoMag 7.00."""
|
"""cantitate_roa=0.5: kit item — price check skipped even if prices differ."""
|
||||||
items = [{
|
items = [{
|
||||||
"sku": "SKU-HALF",
|
"sku": "SKU-HALF",
|
||||||
"price": 7.00,
|
"price": 7.00,
|
||||||
@@ -574,9 +574,9 @@ class TestGetPricesForOrderCantitateRoa:
|
|||||||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={200: (10.00, 1.19)})
|
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={200: (10.00, 1.19)})
|
||||||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||||||
|
|
||||||
assert result["items"][0]["match"] is False
|
assert result["items"][0]["match"] is None
|
||||||
assert abs(result["items"][0]["pret_roa"] - 5.00) < 0.01
|
assert result["items"][0]["kit"] is True
|
||||||
assert result["summary"]["mismatches"] == 1
|
assert result["summary"]["mismatches"] == 0
|
||||||
|
|
||||||
def test_cantitate_roa_one_simple_item(self):
|
def test_cantitate_roa_one_simple_item(self):
|
||||||
"""cantitate_roa=1 (default): simple item, direct price comparison."""
|
"""cantitate_roa=1 (default): simple item, direct price comparison."""
|
||||||
@@ -598,7 +598,7 @@ class TestGetPricesForOrderCantitateRoa:
|
|||||||
assert result["summary"]["mismatches"] == 0
|
assert result["summary"]["mismatches"] == 0
|
||||||
|
|
||||||
def test_cantitate_roa_gt1_kit(self):
|
def test_cantitate_roa_gt1_kit(self):
|
||||||
"""cantitate_roa=2: kit with 2 ROA units per GoMag unit."""
|
"""cantitate_roa=2: kit item — price check skipped."""
|
||||||
items = [{
|
items = [{
|
||||||
"sku": "SKU-KIT2",
|
"sku": "SKU-KIT2",
|
||||||
"price": 20.00,
|
"price": 20.00,
|
||||||
@@ -613,5 +613,24 @@ class TestGetPricesForOrderCantitateRoa:
|
|||||||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={400: (10.00, 1.19)})
|
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={400: (10.00, 1.19)})
|
||||||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||||||
|
|
||||||
assert result["items"][0]["match"] is True
|
assert result["items"][0]["match"] is None
|
||||||
assert abs(result["items"][0]["pret_roa"] - 20.00) < 0.01
|
assert result["items"][0]["kit"] is True
|
||||||
|
assert result["summary"]["mismatches"] == 0
|
||||||
|
|
||||||
|
def test_multi_component_kit_skipped(self):
|
||||||
|
"""Multi-component kit (2 CODMATs): price check skipped, kit=True."""
|
||||||
|
items = [{
|
||||||
|
"sku": "SKU-MULTI",
|
||||||
|
"price": 15.00,
|
||||||
|
"quantity": 1,
|
||||||
|
"codmat_details": [
|
||||||
|
{"codmat": "COMP-A", "cantitate_roa": 1, "id_articol": 500, "cont": "345"},
|
||||||
|
{"codmat": "COMP-B", "cantitate_roa": 1, "id_articol": 501, "cont": "345"},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={500: (8.00, 1.19), 501: (9.00, 1.19)})
|
||||||
|
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||||||
|
|
||||||
|
assert result["items"][0]["match"] is None
|
||||||
|
assert result["items"][0]["kit"] is True
|
||||||
|
assert result["summary"]["mismatches"] == 0
|
||||||
|
|||||||
49
scripts/scan_duplicate_partners.py
Normal file
49
scripts/scan_duplicate_partners.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-time script to find duplicate partners by CUI (bare number, ignoring RO prefix)."""
|
||||||
|
import sys, os, csv
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
# Setup Oracle env same as start.sh
|
||||||
|
from api.app import database
|
||||||
|
|
||||||
|
|
||||||
|
def scan_duplicates():
|
||||||
|
database.init_oracle()
|
||||||
|
conn = database.get_oracle_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT bare_cui, COUNT(*) as cnt,
|
||||||
|
LISTAGG(id_part||':'||denumire, ', ') WITHIN GROUP (ORDER BY id_part) as partners
|
||||||
|
FROM (SELECT id_part, denumire,
|
||||||
|
TRIM(REGEXP_REPLACE(UPPER(TRIM(cod_fiscal)), '^RO\\s*', '')) as bare_cui
|
||||||
|
FROM nom_parteneri WHERE NVL(sters,0)=0
|
||||||
|
AND cod_fiscal IS NOT NULL AND LENGTH(TRIM(cod_fiscal)) >= 3)
|
||||||
|
GROUP BY bare_cui HAVING COUNT(*) > 1
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
database.pool.release(conn)
|
||||||
|
database.close_oracle()
|
||||||
|
|
||||||
|
# Output markdown + CSV
|
||||||
|
print(f"\n## Duplicate Partners Report\n")
|
||||||
|
print(f"Found {len(rows)} CUIs with duplicate partners.\n")
|
||||||
|
print("| CUI | Count | Partners |")
|
||||||
|
print("|-----|-------|----------|")
|
||||||
|
for row in rows:
|
||||||
|
print(f"| {row[0]} | {row[1]} | {row[2][:100]} |")
|
||||||
|
|
||||||
|
# CSV output
|
||||||
|
csv_path = os.path.join(os.path.dirname(__file__), 'duplicate_partners.csv')
|
||||||
|
with open(csv_path, 'w', newline='') as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(['bare_cui', 'count', 'partners'])
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(row)
|
||||||
|
print(f"\nCSV saved: {csv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
scan_duplicates()
|
||||||
Reference in New Issue
Block a user