feat(partner): detect and resync partner mismatches on already-imported orders

Detects PF↔PJ transitions and CUI changes after import; auto-resyncs
uninvoiced orders (max 5/cycle) and shows visual alert for invoiced ones.
- SQLite: partner_mismatch column + batch helpers
- sync_service: detection loop + _resync_partner_for_order
- dashboard: red dot + attention card indicator
- modal: alert with contextual message and resync button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-08 16:19:26 +00:00
parent bf194eb088
commit 89c3d1d07f
11 changed files with 639 additions and 44 deletions

View File

@@ -52,6 +52,38 @@ def convert_web_date(date_str: str) -> datetime:
return datetime.now()
def determine_partner_data(order) -> dict:
"""Extract partner identification from a GoMag order (no Oracle calls).
Returns: {denumire, cod_fiscal, registru, is_pj}
Identical logic to import_single_order partner block — reuse to avoid drift.
"""
if order.billing.is_company:
denumire = clean_web_text(order.billing.company_name).upper()
if not denumire:
# CUI-only fallback: company has code but no name → use billing person name
denumire = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).upper()
cod_fiscal = clean_web_text(order.billing.company_code) or None
registru = clean_web_text(order.billing.company_reg) or None
is_pj = 1
else:
if order.shipping and (order.shipping.lastname or order.shipping.firstname):
raw_name = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).upper()
else:
raw_name = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).upper()
denumire = " ".join(sorted(raw_name.split()))
cod_fiscal = None
registru = None
is_pj = 0
return {"denumire": denumire, "cod_fiscal": cod_fiscal, "registru": registru, "is_pj": is_pj}
def format_address_for_oracle(address: str, city: str, region: str) -> str:
"""Port of VFP FormatAddressForOracle."""
region_clean = clean_web_text(region)
@@ -245,26 +277,11 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
# Step 1: Process partner — use shipping person data for name
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
if order.billing.is_company:
denumire = clean_web_text(order.billing.company_name).upper()
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
is_pj = 1
else:
# Use shipping person for partner name (person on shipping label)
# Sort words alphabetically to normalize firstname/lastname swap
if order.shipping and (order.shipping.lastname or order.shipping.firstname):
raw_name = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).upper()
else:
raw_name = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).upper()
denumire = " ".join(sorted(raw_name.split()))
cod_fiscal = None
registru = None
is_pj = 0
_pdata = determine_partner_data(order)
denumire = _pdata["denumire"]
cod_fiscal = (cod_fiscal_override or _pdata["cod_fiscal"]) if _pdata["is_pj"] else None
registru = _pdata["registru"]
is_pj = _pdata["is_pj"]
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [
cod_fiscal, denumire, registru, is_pj, anaf_strict, id_partener
@@ -283,19 +300,6 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
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
billing_name = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).strip().upper()
shipping_name = ""
if order.shipping:
shipping_name = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).strip().upper()
different_person = bool(
shipping_name and billing_name and shipping_name != billing_name
)
# Step 2: Process shipping address (primary — person on shipping label)
# Use shipping person phone/email for partner contact
shipping_phone = ""
@@ -333,12 +337,9 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
result["error"] = err_msg
return result
# Step 3: Process billing address
if different_person:
# Different person: use shipping address for BOTH billing and shipping in ROA
addr_fact_id = addr_livr_id
else:
# Same person: compute billing addr, short-circuit if identical to shipping
# Step 3: Process billing address — PJ vs PF rule
if is_pj:
# PJ (company): billing address = GoMag billing (company HQ)
billing_addr = format_address_for_oracle(
order.billing.address, order.billing.city, order.billing.region
)
@@ -364,6 +365,9 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
logger.error(f"Order {order_number}: {err_msg}")
result["error"] = err_msg
return result
else:
# PF (individual): billing = shipping (ramburs curier pe numele destinatarului)
addr_fact_id = addr_livr_id
if addr_fact_id is not None:
result["id_adresa_facturare"] = int(addr_fact_id)

View File

@@ -124,7 +124,9 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
# Parse billing
billing_data = data.get("billing", {}) or {}
company = billing_data.get("company")
is_company = isinstance(company, dict) and bool(company.get("name"))
is_company = isinstance(company, dict) and (
bool(company.get("name")) or bool(company.get("code"))
)
billing = OrderBilling(
firstname=str(billing_data.get("firstname", "")),

View File

@@ -701,6 +701,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
elif status_filter.upper() == "DIFFS":
data_clauses.append(
"(anaf_cod_fiscal_adjusted = 1 OR anaf_denumire_mismatch = 1"
" OR partner_mismatch = 1"
" OR (cod_fiscal_gomag IS NOT NULL AND cod_fiscal_gomag != '' AND anaf_platitor_tva IS NOT NULL"
" AND anaf_cod_fiscal_adjusted != 1"
" AND ((UPPER(cod_fiscal_gomag) LIKE 'RO%' AND anaf_platitor_tva = 0)"
@@ -759,9 +760,10 @@ async def get_orders(page: int = 1, per_page: int = 50,
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params)
uninvoiced_old = (await cursor.fetchone())[0]
# Diffs count: orders with ANAF adjustments or TVA mismatch (not address)
# Diffs count: orders with ANAF adjustments, TVA mismatch, or partner mismatch
diffs_clauses = list(base_clauses) + [
"(anaf_cod_fiscal_adjusted = 1 OR anaf_denumire_mismatch = 1"
" OR partner_mismatch = 1"
" OR (cod_fiscal_gomag IS NOT NULL AND cod_fiscal_gomag != '' AND anaf_platitor_tva IS NOT NULL"
" AND anaf_cod_fiscal_adjusted != 1"
" AND ((UPPER(cod_fiscal_gomag) LIKE 'RO%' AND anaf_platitor_tva = 0)"
@@ -771,6 +773,12 @@ async def get_orders(page: int = 1, per_page: int = 50,
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {diffs_where}", base_params)
diffs_count = (await cursor.fetchone())[0]
# Partner mismatches count
pm_clauses = list(base_clauses) + ["partner_mismatch = 1"]
pm_where = "WHERE " + " AND ".join(pm_clauses)
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {pm_where}", base_params)
partner_mismatches_count = (await cursor.fetchone())[0]
return {
"orders": [dict(r) for r in rows],
"total": total,
@@ -788,6 +796,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
"uninvoiced_sqlite": uninvoiced_sqlite,
"uninvoiced_old": uninvoiced_old,
"diffs": diffs_count,
"partner_mismatches": partner_mismatches_count,
}
}
finally:
@@ -1382,3 +1391,76 @@ async def set_incomplete_addresses_count(count: int):
await db.commit()
finally:
await db.close()
# ── Partner Mismatch ──────────────────────────────
async def get_orders_partner_data_batch(order_numbers: list) -> dict:
"""Return {order_number: {cod_fiscal_gomag, denumire_roa, id_partener, factura_numar, id_comanda}}."""
if not order_numbers:
return {}
db = await get_sqlite()
try:
result = {}
for i in range(0, len(order_numbers), 500):
batch = order_numbers[i:i+500]
placeholders = ",".join("?" * len(batch))
cursor = await db.execute(
f"SELECT order_number, cod_fiscal_gomag, denumire_roa, id_partener, "
f"factura_numar, id_comanda FROM orders WHERE order_number IN ({placeholders})",
batch
)
for row in await cursor.fetchall():
result[row[0]] = {
"cod_fiscal_gomag": row[1],
"denumire_roa": row[2],
"id_partener": row[3],
"factura_numar": row[4],
"id_comanda": row[5],
}
return result
finally:
await db.close()
async def update_partner_mismatch_batch(updates: list) -> None:
"""Update partner_mismatch flag for a batch of orders.
Each item: {order_number, partner_mismatch: 0|1}
"""
if not updates:
return
db = await get_sqlite()
try:
await db.executemany(
"UPDATE orders SET partner_mismatch = ?, updated_at = datetime('now') WHERE order_number = ?",
[(u["partner_mismatch"], u["order_number"]) for u in updates]
)
await db.commit()
finally:
await db.close()
async def update_partner_resync_data(order_number: str, data: dict) -> None:
"""Update partner fields + clear partner_mismatch after a successful resync."""
db = await get_sqlite()
try:
await db.execute("""
UPDATE orders SET
id_partener = ?,
cod_fiscal_gomag = ?,
cod_fiscal_roa = ?,
denumire_roa = ?,
partner_mismatch = ?,
updated_at = datetime('now')
WHERE order_number = ?
""", (
data.get("id_partener"),
data.get("cod_fiscal_gomag"),
data.get("cod_fiscal_roa"),
data.get("denumire_roa"),
data.get("partner_mismatch", 0),
order_number,
))
await db.commit()
finally:
await db.close()

View File

@@ -625,6 +625,63 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
})
await sqlite_service.update_gomag_addresses_batch(addr_updates)
# Detect partner mismatches for already-imported orders
if already_in_roa:
stored_partner_data = await sqlite_service.get_orders_partner_data_batch(
[o.number for o in already_in_roa]
)
mismatch_map = {}
mismatch_updates = []
for order in already_in_roa:
stored = stored_partner_data.get(order.number, {})
stored_cf = stored.get("cod_fiscal_gomag")
new_data = import_service.determine_partner_data(order)
new_cf = new_data["cod_fiscal"]
def _strip_ro(cf):
if not cf:
return ""
return re.sub(r'^RO', '', cf.strip().upper())
is_mismatch = False
if new_data["is_pj"] and not stored_cf:
is_mismatch = True # PF→PJ
elif not new_data["is_pj"] and stored_cf:
is_mismatch = True # PJ→PF
elif new_data["is_pj"] and stored_cf and _strip_ro(new_cf) != _strip_ro(stored_cf):
is_mismatch = True # CUI schimbat
val = 1 if is_mismatch else 0
mismatch_map[order.number] = val
mismatch_updates.append({"order_number": order.number, "partner_mismatch": val})
await sqlite_service.update_partner_mismatch_batch(mismatch_updates)
# Auto-resync uninvoiced orders with partner mismatch (max 5/cycle)
MAX_PARTNER_RESYNC_PER_CYCLE = 5
mismatched_uninvoiced = [
o for o in already_in_roa
if mismatch_map.get(o.number) == 1
and not stored_partner_data.get(o.number, {}).get("factura_numar")
][:MAX_PARTNER_RESYNC_PER_CYCLE]
if mismatched_uninvoiced:
resync_ok = 0
for _order in mismatched_uninvoiced:
try:
await _resync_partner_for_order(
order=_order,
stored=stored_partner_data.get(_order.number, {}),
app_settings=app_settings,
run_id=run_id,
)
resync_ok += 1
except Exception as _e:
_log_line(run_id, f"#{_order.number} EROARE resync partener: {_e}")
logger.error(f"Partner resync error for {_order.number}: {_e}")
if resync_ok:
_log_line(run_id, f"Resync parteneri: {resync_ok} comenzi actualizate")
# Step 3b: Record skipped orders + store items (batch)
skipped_count = len(skipped)
skipped_batch = []
@@ -1076,3 +1133,199 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
def stop_sync():
"""Signal sync to stop. Currently sync runs to completion."""
pass
async def _resync_partner_for_order(order, stored: dict, app_settings: dict, run_id: str) -> None:
"""Resync partner for a single already-imported uninvoiced order.
Safety: double-checks factura_numar before Oracle call.
Reads existing comanda row and calls PACK_COMENZI.modifica_comanda.
"""
import oracledb
order_number = order.number
id_comanda = stored.get("id_comanda")
if not id_comanda:
_log_line(run_id, f"#{order_number} SKIP resync partener: id_comanda lipsa")
return
# Double-check factura_numar — may have been invoiced since mismatch detection
current_detail = await sqlite_service.get_order_detail(order_number)
if current_detail and current_detail.get("order", {}).get("factura_numar"):
_log_line(run_id, f"#{order_number} SKIP resync partener: comanda facturata in tranzit")
return
old_partner_id = stored.get("id_partener")
old_partner_name = stored.get("denumire_roa") or "?"
new_partner_data = import_service.determine_partner_data(order)
# ANAF check for PF→PJ transition
cod_fiscal_override = None
anaf_data = None
if new_partner_data["is_pj"] and new_partner_data["cod_fiscal"]:
raw_cf = new_partner_data["cod_fiscal"]
bare_cui, _ = anaf_service.sanitize_cui(raw_cf)
if bare_cui:
anaf_data = await sqlite_service.get_anaf_cache(bare_cui)
if not anaf_data:
try:
fresh = await anaf_service.check_vat_status_batch([bare_cui])
if fresh:
await sqlite_service.bulk_populate_anaf_cache(fresh)
anaf_data = fresh.get(bare_cui)
except Exception as e:
logger.warning(f"ANAF check failed for {bare_cui}: {e}")
if anaf_data and anaf_data.get("scpTVA") is not None:
cod_fiscal_override = anaf_service.determine_correct_cod_fiscal(
bare_cui, anaf_data["scpTVA"]
)
def _do_resync():
if database.pool is None:
raise RuntimeError("Oracle pool not initialized")
conn = database.pool.acquire()
try:
with conn.cursor() as cur:
# Create/find partner
id_partener_var = cur.var(oracledb.DB_TYPE_NUMBER)
anaf_strict = 1 if (anaf_data and anaf_data.get("scpTVA") is not None) else None
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [
cod_fiscal_override or new_partner_data["cod_fiscal"],
new_partner_data["denumire"],
new_partner_data["registru"],
new_partner_data["is_pj"],
anaf_strict,
id_partener_var,
])
new_partner_id = id_partener_var.getvalue()
if not new_partner_id or new_partner_id <= 0:
raise RuntimeError(f"Partner creation failed for {new_partner_data['denumire']}")
new_partner_id = int(new_partner_id)
# Same partner — just clear mismatch
if new_partner_id == (old_partner_id or -1):
return {"same_partner": True, "new_partner_id": new_partner_id}
# Get new partner details for audit log
cur.execute(
"SELECT denumire, cod_fiscal FROM nom_parteneri WHERE id_part = :1",
[new_partner_id]
)
row = cur.fetchone()
new_partner_name = row[0] if row else new_partner_data["denumire"]
new_cod_fiscal_roa = row[1] if row else None
# Create addresses under new partner
addr_livr_id = None
shipping_addr = None
if order.shipping:
id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
shipping_addr = import_service.format_address_for_oracle(
order.shipping.address, order.shipping.city, order.shipping.region
)
shipping_phone = order.shipping.phone or order.billing.phone or ""
shipping_email = order.shipping.email or order.billing.email or ""
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
new_partner_id, shipping_addr, shipping_phone, shipping_email, id_adresa_livr
])
addr_livr_id = id_adresa_livr.getvalue()
if addr_livr_id is None:
raise RuntimeError(f"Shipping address creation failed for partner {new_partner_id}")
addr_livr_id = int(addr_livr_id)
billing_name_str = import_service.clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).strip().upper()
ship_name_str = ""
if order.shipping:
ship_name_str = import_service.clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).strip().upper()
different_person = bool(ship_name_str and billing_name_str and ship_name_str != billing_name_str)
if different_person and addr_livr_id:
addr_fact_id = addr_livr_id
else:
billing_addr = import_service.format_address_for_oracle(
order.billing.address, order.billing.city, order.billing.region
)
if addr_livr_id and order.shipping and billing_addr == shipping_addr:
addr_fact_id = addr_livr_id
else:
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
new_partner_id, billing_addr,
order.billing.phone or "",
order.billing.email or "",
id_adresa_fact,
])
addr_fact_id = id_adresa_fact.getvalue()
if addr_fact_id is None:
raise RuntimeError(f"Billing address creation failed for partner {new_partner_id}")
addr_fact_id = int(addr_fact_id)
# Read existing comanda row for modifica_comanda params
cur.execute("""
SELECT nr_comanda, data_comanda, data_livrare, proc_discount,
interna, id_util_um, id_codclient, comanda_externa, id_ctr
FROM comenzi WHERE id_comanda = :1
""", [id_comanda])
row = cur.fetchone()
if not row:
raise RuntimeError(f"Comanda {id_comanda} not found in Oracle")
nr_comanda, data_comanda, data_livrare, proc_discount, interna, id_util_um, id_codclient, comanda_externa, id_ctr = row
cur.callproc("PACK_COMENZI.modifica_comanda", [
id_comanda,
nr_comanda,
data_comanda,
new_partner_id,
data_livrare,
proc_discount,
interna,
id_util_um,
addr_fact_id,
addr_livr_id,
id_codclient,
comanda_externa,
id_ctr,
])
conn.commit()
return {
"same_partner": False,
"new_partner_id": new_partner_id,
"new_partner_name": new_partner_name,
"new_cod_fiscal_roa": new_cod_fiscal_roa,
}
except Exception:
try:
conn.rollback()
except Exception:
pass
raise
finally:
database.pool.release(conn)
resync_result = await asyncio.to_thread(_do_resync)
if resync_result.get("same_partner"):
await sqlite_service.update_partner_mismatch_batch([
{"order_number": order_number, "partner_mismatch": 0}
])
_log_line(run_id, f"#{order_number} RESYNC: partener neschimbat, mismatch cleared")
else:
new_partner_id = resync_result["new_partner_id"]
new_partner_name = resync_result.get("new_partner_name", "?")
new_cod_fiscal_roa = resync_result.get("new_cod_fiscal_roa")
await sqlite_service.update_partner_resync_data(order_number, {
"id_partener": new_partner_id,
"cod_fiscal_gomag": cod_fiscal_override or new_partner_data["cod_fiscal"],
"cod_fiscal_roa": new_cod_fiscal_roa,
"denumire_roa": new_partner_name,
"partner_mismatch": 0,
})
_log_line(
run_id,
f"#{order_number} RESYNC partener: {old_partner_id} ({old_partner_name}) → {new_partner_id} ({new_partner_name})"
)