diff --git a/CLAUDE.md b/CLAUDE.md index 18c5fcf..438c09f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,9 +73,10 @@ Documentatie completa: [README.md](README.md) - Recovery: la fiecare sync, comenzile ERROR sunt reverificate in Oracle ### Parteneri -- Prioritate: **companie** (PJ, cod_fiscal + registru) daca exista in GoMag, altfel persoana fizica cu **shipping name** +- Prioritate: **companie** (PJ, cod_fiscal + registru) daca exista in GoMag (name SAU code), altfel persoana fizica cu **shipping name** - Adresa livrare: intotdeauna GoMag shipping -- Adresa facturare: daca shipping ≠ billing person → shipping pt ambele; altfel → billing din GoMag +- Adresa facturare PJ: adresa billing din GoMag (sediul firmei) +- Adresa facturare PF: adresa shipping din GoMag (ramburs curier pe numele destinatarului) ### Preturi - Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie) diff --git a/api/app/database.py b/api/app/database.py index 0dc2d1f..cb3584b 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -353,6 +353,7 @@ def init_sqlite(): ("anaf_denumire_mismatch", "INTEGER DEFAULT 0"), ("denumire_anaf", "TEXT"), ("address_mismatch", "INTEGER DEFAULT 0"), + ("partner_mismatch", "INTEGER DEFAULT 0"), ]: if col not in order_cols: conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index b41513b..0551c1a 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -544,6 +544,7 @@ async def order_detail(order_number: str): "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"), + "partner_mismatch": order.get("partner_mismatch") == 1, } # Parse JSON address strings for key in ("adresa_livrare_gomag", "adresa_facturare_gomag", @@ -579,6 +580,80 @@ async def retry_order(order_number: str): return result +@router.post("/api/orders/{order_number}/resync-partner") +async def resync_partner(order_number: str): + """Manual partner resync for invoiced orders with partner_mismatch=1. + + Auto-resync handles uninvoiced orders during sync loop. + This endpoint is for edge case: operator wants to fix an already-invoiced order. + """ + detail = await sqlite_service.get_order_detail(order_number) + if not detail: + raise HTTPException(status_code=404, detail="Comanda nu a fost gasita") + + order_data = detail["order"] + if not order_data.get("partner_mismatch"): + return {"success": False, "message": "Comanda nu are mismatch de partener"} + + if sync_service._sync_lock.locked(): + return {"success": False, "message": "Sync in curs — asteapta finalizarea"} + + stored = { + "id_comanda": order_data.get("id_comanda"), + "id_partener": order_data.get("id_partener"), + "denumire_roa": order_data.get("denumire_roa"), + "cod_fiscal_gomag": order_data.get("cod_fiscal_gomag"), + "factura_numar": order_data.get("factura_numar"), + } + + # Download order from GoMag to get current data + import tempfile + from ..services import order_reader, gomag_client + app_settings = await sqlite_service.get_app_settings() + gomag_key = app_settings.get("gomag_api_key") or None + gomag_shop = app_settings.get("gomag_api_shop") or None + + from datetime import datetime, timedelta + order_date_str = order_data.get("order_date", "") + try: + order_date = datetime.fromisoformat(order_date_str.replace("Z", "+00:00")).date() + except (ValueError, AttributeError): + order_date = datetime.now().date() - timedelta(days=1) + + with tempfile.TemporaryDirectory() as tmp_dir: + try: + days_back = (datetime.now().date() - order_date).days + 2 + await gomag_client.download_orders( + tmp_dir, days_back=days_back, + api_key=gomag_key, api_shop=gomag_shop, limit=200, + ) + except Exception as e: + return {"success": False, "message": f"Eroare download GoMag: {e}"} + + target_order = None + orders, _ = order_reader.read_json_orders(json_dir=tmp_dir) + for o in orders: + if str(o.number) == str(order_number): + target_order = o + break + + if not target_order: + return {"success": False, "message": f"Comanda {order_number} nu a fost gasita in GoMag API"} + + run_id = f"resync_{order_number}" + try: + await sync_service._resync_partner_for_order( + order=target_order, + stored=stored, + app_settings=app_settings, + run_id=run_id, + ) + return {"success": True, "message": "Partener actualizat in ROA"} + except Exception as e: + logger.error(f"Manual resync failed for {order_number}: {e}") + return {"success": False, "message": str(e)} + + @router.get("/api/orders/by-sku/{sku}/pending") async def get_pending_orders_for_sku(sku: str): """Get SKIPPED orders that contain the given SKU.""" diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index 020a3fa..eaf1c44 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -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) diff --git a/api/app/services/order_reader.py b/api/app/services/order_reader.py index 576c2ad..8d2527e 100644 --- a/api/app/services/order_reader.py +++ b/api/app/services/order_reader.py @@ -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", "")), diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index af68c49..da209d4 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -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() diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index 736946c..64f5952 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -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})" + ) diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 773ec74..44dc3ce 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -340,8 +340,9 @@ async function loadDashOrders() { const diffs = c.diffs || 0; const incompleteAddr = c.incomplete_addresses || 0; + const partnerMismatches = c.partner_mismatches || 0; - if (errors === 0 && unmapped === 0 && nefact === 0 && incompleteAddr === 0 && diffs === 0) { + if (errors === 0 && unmapped === 0 && nefact === 0 && incompleteAddr === 0 && diffs === 0 && partnerMismatches === 0) { attnEl.innerHTML = '
Totul in ordine
'; } else { let items = []; @@ -350,6 +351,7 @@ async function loadDashOrders() { if (nefact > 0) items.push(` ${nefact} nefacturate`); if (c.incomplete_addresses > 0) items.push(` ${c.incomplete_addresses} adrese incomplete`); if (diffs > 0) items.push(` ${diffs} diferente ANAF`); + if (partnerMismatches > 0) items.push(` ${partnerMismatches} partener schimbat`); attnEl.innerHTML = '
' + items.join('') + '
'; } } @@ -509,6 +511,8 @@ function diffDots(o, mobile) { d += ``; if (o.address_mismatch===1) d += ``; + if (o.partner_mismatch===1) + d += ``; if (o.price_match===false) d += ``; return d; diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js index 0a4829d..ce40e10 100644 --- a/api/app/static/js/shared.js +++ b/api/app/static/js/shared.js @@ -531,6 +531,8 @@ async function renderOrderDetailModal(orderNumber, opts) { // Restore original structure (may have been replaced by PF indicator) cuiRoa.innerHTML = 'CUI: '; } + const partnerMismatchEl = document.getElementById('detailPartnerMismatch'); + if (partnerMismatchEl) { partnerMismatchEl.style.display = 'none'; partnerMismatchEl.innerHTML = ''; } const denomMismatch = document.getElementById('detailDenomMismatch'); if (denomMismatch) { denomMismatch.style.display = 'none'; denomMismatch.innerHTML = ''; } const addressBlock = document.getElementById('detailAddressBlock'); @@ -940,6 +942,33 @@ function _renderHeaderInfo(order) { document.getElementById('detailIdPartener').innerHTML = '\u2014'; } + // Partner mismatch alert + if (pi && pi.partner_mismatch) { + const pmEl = document.getElementById('detailPartnerMismatch'); + if (pmEl) { + const isInvoiced = !!(order.invoice && order.invoice.facturat); + let mismatchType = ''; + if (pi.cod_fiscal_gomag && !pi.cod_fiscal_roa) { + mismatchType = 'PF → PJ: comanda importata ca persoana fizica, acum are CUI in GoMag.'; + } else if (!pi.cod_fiscal_gomag && pi.cod_fiscal_roa) { + mismatchType = 'PJ → PF: comanda importata cu CUI, acum GoMag nu mai are companie.'; + } else if (pi.cod_fiscal_gomag && pi.cod_fiscal_roa && pi.cod_fiscal_gomag !== pi.cod_fiscal_roa) { + mismatchType = `CUI schimbat: GoMag are ${esc(pi.cod_fiscal_gomag)}, ROA are ${esc(pi.cod_fiscal_roa)}.`; + } else { + mismatchType = 'Date partener diferite fata de momentul importului.'; + } + const resyncBtn = isInvoiced + ? `` + : ''; + pmEl.innerHTML = `
+ Partener schimbat in GoMag
+ ${mismatchType} + ${resyncBtn} +
`; + pmEl.style.display = ''; + } + } + // Denomination mismatch alert if (isPJ && pi.anaf_denumire_mismatch && pi.denumire_anaf) { const denomEl = document.getElementById('detailDenomMismatch'); @@ -1022,6 +1051,7 @@ function _renderHeaderInfo(order) { if (addr && addr.livrare_roa && !addrMatch(addr.livrare_gomag, addr.livrare_roa)) badges.push({label:'Adr. livr.', cls:'diff-badge-addr', aria:'Adresa livrare diferita'}); if (addr && addr.facturare_roa && !addrMatch(addr.facturare_gomag, addr.facturare_roa)) badges.push({label:'Adr. fact.', cls:'diff-badge-addr', aria:'Adresa facturare diferita'}); if (order.price_check && order.price_check.mismatches > 0) badges.push({label:'Preturi (' + order.price_check.mismatches + ')', cls:'diff-badge-price', aria:'Preturi diferite: ' + order.price_check.mismatches}); + if (pi && pi.partner_mismatch) badges.push({label:'Partener', cls:'diff-badge-anaf', aria:'Partener schimbat in GoMag'}); let insertAfter = orderNumEl; badges.forEach(b => { const el = document.createElement('span'); @@ -1033,3 +1063,24 @@ function _renderHeaderInfo(order) { }); } } + +// ── Partner Resync ──────────────────────────────── + +async function resyncPartner(orderNumber, btnEl) { + if (!confirm('Actualizeaza partenerul acestei comenzi in ROA Oracle?\n\nAtentie: Comanda este facturata. Verificati manual dupa actualizare.')) return; + if (btnEl) { btnEl.disabled = true; btnEl.innerHTML = ' Se actualizeaza...'; } + try { + const res = await fetch(`${window.ROOT_PATH || ''}/api/orders/${encodeURIComponent(orderNumber)}/resync-partner`, { method: 'POST' }); + const data = await res.json(); + if (data.success) { + if (btnEl) { btnEl.innerHTML = ' Actualizat'; btnEl.className = 'btn btn-sm btn-success mt-1'; } + setTimeout(() => renderOrderDetailModal(orderNumber, {}), 1500); + } else { + if (btnEl) { btnEl.disabled = false; btnEl.innerHTML = ' Actualizeaza partener in ROA'; } + alert('Eroare: ' + (data.message || 'Resync esuat')); + } + } catch(e) { + if (btnEl) { btnEl.disabled = false; btnEl.innerHTML = ' Actualizeaza partener in ROA'; } + alert('Eroare de retea: ' + e.message); + } +} diff --git a/api/app/templates/base.html b/api/app/templates/base.html index 10d34bf..3651c70 100644 --- a/api/app/templates/base.html +++ b/api/app/templates/base.html @@ -120,6 +120,8 @@ + + diff --git a/api/tests/test_business_rules.py b/api/tests/test_business_rules.py index 4e15105..3ef0570 100644 --- a/api/tests/test_business_rules.py +++ b/api/tests/test_business_rules.py @@ -809,6 +809,126 @@ class TestAddrMatch: addr_livr_id = 123 assert not (addr_livr_id and billing_addr == shipping_addr) + def test_pf_billing_address_equals_shipping(self): + """PF (individual): is_pj=0 → billing address = shipping (ramburs curier).""" + from app.services.import_service import determine_partner_data + from app.services.order_reader import OrderBilling, OrderShipping, OrderData + billing = OrderBilling( + firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com", + address="Str Victoriei 10", city="Cluj", region="Cluj", country="RO", + company_name="", company_code="", company_reg="", is_company=False + ) + shipping = OrderShipping( + firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com", + address="Str Victoriei 10", city="Cluj", region="Cluj", country="RO" + ) + order = OrderData(id="PF001", number="PF001", date="2024-01-01T10:00:00", + billing=billing, shipping=shipping) + pdata = determine_partner_data(order) + assert pdata["is_pj"] == 0, "PF order must have is_pj=0" + + def test_pj_uses_billing_from_gomag(self): + """PJ (company): is_pj=1 → billing address from GoMag billing.""" + from app.services.import_service import determine_partner_data + from app.services.order_reader import OrderBilling, OrderShipping, OrderData + billing = OrderBilling( + firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com", + address="Bld Unirii 5", city="Bucuresti", region="Bucuresti", country="RO", + company_name="FIRMA SRL", company_code="RO12345678", company_reg="J40/1234/2020", + is_company=True + ) + shipping = OrderShipping( + firstname="Mihai", lastname="Ionescu", phone="0711111111", email="mihai@test.com", + address="Str Libertatii 20", city="Ploiesti", region="Prahova", country="RO" + ) + order = OrderData(id="PJ001", number="PJ001", date="2024-01-01T10:00:00", + billing=billing, shipping=shipping) + pdata = determine_partner_data(order) + assert pdata["is_pj"] == 1, "PJ order must have is_pj=1" + assert pdata["denumire"] == "FIRMA SRL" + assert pdata["cod_fiscal"] == "RO12345678" + + def test_pj_different_person_still_uses_billing(self): + """Regression: PJ with different billing/shipping persons → still is_pj=1 (billing addr used).""" + from app.services.import_service import determine_partner_data + from app.services.order_reader import OrderBilling, OrderShipping, OrderData + billing = OrderBilling( + firstname="Secretara", lastname="Firma", phone="0700000000", email="office@firma.ro", + address="Calea Victoriei 1", city="Bucuresti", region="Bucuresti", country="RO", + company_name="FIRMA SA", company_code="RO99999999", company_reg="J40/9999/2019", + is_company=True + ) + shipping = OrderShipping( + firstname="Curier", lastname="Destinatar", phone="0799999999", email="d@test.com", + address="Str Livrare 5", city="Iasi", region="Iasi", country="RO" + ) + order = OrderData(id="PJ002", number="PJ002", date="2024-01-01T10:00:00", + billing=billing, shipping=shipping) + pdata = determine_partner_data(order) + assert pdata["is_pj"] == 1, "PJ with different persons must still be is_pj=1" + + def test_pf_different_billing_still_uses_shipping(self): + """Regression: PF with different billing address → still is_pj=0 (shipping addr used for billing).""" + from app.services.import_service import determine_partner_data + from app.services.order_reader import OrderBilling, OrderShipping, OrderData + billing = OrderBilling( + firstname="Ana", lastname="Gheorghe", phone="0700000000", email="ana@test.com", + address="Str Alta 99", city="Timisoara", region="Timis", country="RO", + company_name="", company_code="", company_reg="", is_company=False + ) + shipping = OrderShipping( + firstname="Ana", lastname="Gheorghe", phone="0700000000", email="ana@test.com", + address="Str Livrare 7", city="Cluj", region="Cluj", country="RO" + ) + order = OrderData(id="PF002", number="PF002", date="2024-01-01T10:00:00", + billing=billing, shipping=shipping) + pdata = determine_partner_data(order) + assert pdata["is_pj"] == 0, "PF must remain is_pj=0 regardless of billing address" + + def test_is_company_cui_fallback(self): + """Company with no name but CUI populated → is_company=True (order_reader parsing).""" + from app.services.order_reader import _parse_order + order_data = { + "number": "CUI001", + "date": "2024-01-01T10:00:00", + "statusId": 1, + "status": "new", + "billing": { + "firstname": "Ion", + "lastname": "Popescu", + "phone": "0700000000", + "email": "ion@test.com", + "address": "Str Test 1", + "city": "Bucuresti", + "region": "Bucuresti", + "country": "RO", + "company": {"name": "", "code": "RO12345678", "registrationNo": ""} + }, + "items": [], + "total": 100.0, + "discountTotal": 0.0, + "shippingTotal": 0.0 + } + order = _parse_order("CUI001", order_data, "test.json") + assert order.billing.is_company is True, "CUI-only company must be detected as is_company" + assert order.billing.company_code == "RO12345678" + + def test_pj_denomination_fallback_empty_company_name(self): + """PJ with CUI but no company_name → denumire falls back to billing person name.""" + from app.services.import_service import determine_partner_data + from app.services.order_reader import OrderBilling, OrderData + billing = OrderBilling( + firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com", + address="Str Test 1", city="Bucuresti", region="Bucuresti", country="RO", + company_name="", company_code="RO12345678", company_reg="", is_company=True + ) + order = OrderData(id="CUI002", number="CUI002", date="2024-01-01T10:00:00", + billing=billing, shipping=None) + pdata = determine_partner_data(order) + assert pdata["is_pj"] == 1 + assert pdata["denumire"] == "POPESCU ION", "Fallback denumire must use billing person name" + assert pdata["cod_fiscal"] == "RO12345678" + class TestFormatAddressForOracle: """Tests for format_address_for_oracle city stripping."""