diff --git a/api/app/routers/validation.py b/api/app/routers/validation.py index bc9622a..cabda1b 100644 --- a/api/app/routers/validation.py +++ b/api/app/routers/validation.py @@ -27,7 +27,7 @@ async def scan_and_validate(): # Build SKU context from skipped orders and track missing SKUs sku_context = {} # sku -> {order_numbers: [], customers: []} for order, missing_list in skipped: - customer = order.billing.company_name or f"{order.billing.firstname} {order.billing.lastname}" + customer = order.billing.company_name or f"{order.billing.lastname} {order.billing.firstname}" for sku in missing_list: if sku not in sku_context: sku_context[sku] = {"order_numbers": [], "customers": []} @@ -86,7 +86,7 @@ async def scan_and_validate(): "skipped_orders": [ { "number": order.number, - "customer": order.billing.company_name or f"{order.billing.firstname} {order.billing.lastname}", + "customer": order.billing.company_name or f"{order.billing.lastname} {order.billing.firstname}", "items_count": len(order.items), "missing_skus": missing } diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index 52e586b..a3a1ed4 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -244,14 +244,16 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se 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): - denumire = clean_web_text( + raw_name = clean_web_text( f"{order.shipping.lastname} {order.shipping.firstname}" ).upper() else: - denumire = clean_web_text( + 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 diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index fb8c736..e28e34e 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -93,8 +93,8 @@ def _derive_customer_info(order): """ shipping_name = "" if order.shipping: - shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip() - billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip() + shipping_name = f"{getattr(order.shipping, 'lastname', '') or ''} {getattr(order.shipping, 'firstname', '') or ''}".strip() + billing_name = f"{getattr(order.billing, 'lastname', '') or ''} {getattr(order.billing, 'firstname', '') or ''}".strip() if not shipping_name: shipping_name = billing_name if order.billing.is_company and order.billing.company_name: @@ -382,8 +382,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None else: ship_name = "" if order.shipping: - ship_name = f"{order.shipping.firstname} {order.shipping.lastname}".strip() - customer = ship_name or f"{order.billing.firstname} {order.billing.lastname}" + ship_name = f"{order.shipping.lastname} {order.shipping.firstname}".strip() + customer = ship_name or f"{order.billing.lastname} {order.billing.firstname}" for sku in missing_skus_list: if sku not in sku_context: sku_context[sku] = {"orders": [], "customers": []} diff --git a/api/database-scripts/05_pack_import_parteneri.pck b/api/database-scripts/05_pack_import_parteneri.pck index 7e7b233..74cb5a0 100644 --- a/api/database-scripts/05_pack_import_parteneri.pck +++ b/api/database-scripts/05_pack_import_parteneri.pck @@ -5,6 +5,7 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS -- 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") + -- 02.04.2026 - fallback cautare PF cu permutari nume (evita duplicate la swap firstname/lastname) -- ==================================================================== -- CONSTANTS @@ -677,10 +678,17 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS v_este_persoana_fizica NUMBER; v_nume VARCHAR2(50); v_prenume VARCHAR2(50); - + -- Date pentru pack_def v_cod_fiscal_curat VARCHAR2(50); v_denumire_curata VARCHAR2(200); + + -- Permutari nume PF (Step 2b) + v_word1 VARCHAR2(100); + v_word2 VARCHAR2(100); + v_word3 VARCHAR2(100); + v_pos1 NUMBER; + v_pos2 NUMBER; BEGIN -- Resetare eroare la inceputul procesarii @@ -726,7 +734,54 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS RETURN; END IF; END IF; - + + -- STEP 2b: Cautare cu permutari nume (doar persoane fizice, 2-3 cuvinte) + -- Rezolva cazul cand clientul inverseaza firstname/lastname in GoMag + IF p_strict_search IS NULL AND + (p_is_persoana_juridica IS NOT NULL AND p_is_persoana_juridica = 0) THEN + + v_pos1 := INSTR(v_denumire_curata, ' '); + IF v_pos1 > 0 THEN + v_word1 := TRIM(SUBSTR(v_denumire_curata, 1, v_pos1 - 1)); + v_pos2 := INSTR(v_denumire_curata, ' ', v_pos1 + 1); + + IF v_pos2 = 0 THEN + -- 2 cuvinte: incearca inversarea "WORD2 WORD1" + v_word2 := TRIM(SUBSTR(v_denumire_curata, v_pos1 + 1)); + v_id_part := cauta_partener_dupa_denumire(v_word2 || ' ' || v_word1); + IF v_id_part IS NOT NULL THEN + pINFO('Partener PF gasit prin inversare nume: ' || v_denumire_curata || + ' -> ID_PART=' || v_id_part, 'IMPORT_PARTENERI'); + p_id_partener := v_id_part; + RETURN; + END IF; + ELSE + -- 3 cuvinte: incearca toate permutatiile (5 ramase, originala deja incercata) + v_word2 := TRIM(SUBSTR(v_denumire_curata, v_pos1 + 1, v_pos2 - v_pos1 - 1)); + v_word3 := TRIM(SUBSTR(v_denumire_curata, v_pos2 + 1)); + + -- Permutari: W1 W3 W2, W2 W1 W3, W2 W3 W1, W3 W1 W2, W3 W2 W1 + FOR i IN 1..5 LOOP + v_id_part := cauta_partener_dupa_denumire( + CASE i + WHEN 1 THEN v_word1 || ' ' || v_word3 || ' ' || v_word2 + WHEN 2 THEN v_word2 || ' ' || v_word1 || ' ' || v_word3 + WHEN 3 THEN v_word2 || ' ' || v_word3 || ' ' || v_word1 + WHEN 4 THEN v_word3 || ' ' || v_word1 || ' ' || v_word2 + WHEN 5 THEN v_word3 || ' ' || v_word2 || ' ' || v_word1 + END + ); + IF v_id_part IS NOT NULL THEN + pINFO('Partener PF gasit prin permutare nume: ' || v_denumire_curata || + ' -> ID_PART=' || v_id_part, 'IMPORT_PARTENERI'); + p_id_partener := v_id_part; + RETURN; + END IF; + END LOOP; + END IF; + END IF; + END IF; + -- STEP 3: Creare partener nou -- pINFO('Nu s-a gasit partener existent. Se creeaza unul nou...', 'IMPORT_PARTENERI'); diff --git a/api/tests/test_complete_import.py b/api/tests/test_complete_import.py index ddbe625..a446e6b 100644 --- a/api/tests/test_complete_import.py +++ b/api/tests/test_complete_import.py @@ -901,6 +901,93 @@ def test_duplicate_codmat_different_prices(): return False +def test_pf_reverse_name_dedup(): + """Test that PF partner with reversed name order is found, not duplicated. + Creates partner 'POPESCU ION', then searches for 'ION POPESCU' โ€” should return same id_part. + """ + print("\n๐Ÿ”„ TEST: PF Reverse Name Deduplication") + print("=" * 50) + + try: + conn = oracledb.connect(user=user, password=password, dsn=dsn) + with conn.cursor() as cur: + timestamp = datetime.now().strftime('%H%M%S') + unique_suffix = random.randint(1000, 9999) + + # Step 1: Create partner with name "TESTPF_{unique} POPESCU ION" + # Using unique prefix to avoid collision with real data + name_original = f'ZZTEST{unique_suffix} POPESCU ION' + id_partener_var = cur.var(oracledb.NUMBER) + + cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [ + None, # p_cod_fiscal + name_original, # p_denumire + None, # p_registru + 0, # p_is_persoana_juridica = 0 (PF) + None, # p_strict_search + id_partener_var # p_id_partener OUT + ]) + conn.commit() + + id_original = id_partener_var.getvalue() + if not id_original or id_original <= 0: + print(f" โŒ Failed to create original partner: {name_original}") + return False + print(f" โœ… Created partner '{name_original}' โ†’ ID_PART={int(id_original)}") + + # Step 2: Search with reversed name "ZZTEST{unique} ION POPESCU" + name_reversed = f'ZZTEST{unique_suffix} ION POPESCU' + id_reversed_var = cur.var(oracledb.NUMBER) + + cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [ + None, # p_cod_fiscal + name_reversed, # p_denumire (reversed) + None, # p_registru + 0, # p_is_persoana_juridica = 0 (PF) + None, # p_strict_search + id_reversed_var # p_id_partener OUT + ]) + + id_reversed = id_reversed_var.getvalue() + print(f" Searched for '{name_reversed}' โ†’ ID_PART={int(id_reversed) if id_reversed else 'NULL'}") + + if id_reversed == id_original: + print(f" โœ… PASS: Same partner found (no duplicate created)") + success = True + else: + print(f" โŒ FAIL: Different partner returned! Original={int(id_original)}, Reversed={int(id_reversed)}") + print(f" Duplicate was created instead of matching existing partner") + success = False + # Cleanup the duplicate too + if id_reversed and id_reversed > 0: + try: + cur.execute("DELETE FROM nom_parteneri WHERE id_part = :1", [int(id_reversed)]) + except Exception: + pass + + # Cleanup: delete the test partner + try: + cur.execute("DELETE FROM nom_parteneri WHERE id_part = :1", [int(id_original)]) + conn.commit() + print(f" ๐Ÿงน Cleaned up test partner ID_PART={int(id_original)}") + except Exception as e: + print(f" โš ๏ธ Cleanup warning: {e}") + conn.rollback() + + return success + + except Exception as e: + print(f" โŒ Test error: {e}") + import traceback + traceback.print_exc() + return False + finally: + try: + conn.close() # noqa: F821 + except Exception: + pass + + if __name__ == "__main__": print("Starting complete order import test...") print(f"Timestamp: {datetime.now()}") @@ -937,4 +1024,12 @@ if __name__ == "__main__": biz_passed += 1 print(f"\nBusiness rule tests: {biz_passed}/{len(biz_tests)} passed") + # Run PF reverse name dedup test + print("\n") + dedup_success = test_pf_reverse_name_dedup() + if dedup_success: + print("PF REVERSE NAME DEDUP: SUCCESSFUL") + else: + print("PF REVERSE NAME DEDUP: NEEDS ATTENTION") + exit(0 if success else 1) \ No newline at end of file diff --git a/scripts/find_pf_name_duplicates.sql b/scripts/find_pf_name_duplicates.sql new file mode 100644 index 0000000..6f6a502 --- /dev/null +++ b/scripts/find_pf_name_duplicates.sql @@ -0,0 +1,51 @@ +-- Find PF partners with same name words in different order +-- (e.g., "COLILIE DANIELA" vs "DANIELA COLILIE") +-- Run on prod to assess scope of firstname/lastname swap duplicates +-- +-- 02.04.2026 - diagnostic script for PF name dedup fix +-- 06.04.2026 - adaugat adrese pentru verificare duplicate reale + +SELECT a.id_part AS id1, + a.denumire AS name1, + a.dataora AS dataora1, + addr1.judet AS judet1, + addr1.localitate AS localitate1, + addr1.strada AS strada1, + b.id_part AS id2, + b.denumire AS name2, + b.dataora AS dataora2, + addr2.judet AS judet2, + addr2.localitate AS localitate2, + addr2.strada AS strada2, + CASE WHEN UPPER(TRIM(addr1.judet)) = UPPER(TRIM(addr2.judet)) + AND UPPER(TRIM(addr1.localitate)) = UPPER(TRIM(addr2.localitate)) + AND UPPER(TRIM(addr1.strada)) = UPPER(TRIM(addr2.strada)) + THEN 'DA - DUPLICAT REAL' + WHEN UPPER(TRIM(addr1.judet)) = UPPER(TRIM(addr2.judet)) + AND UPPER(TRIM(addr1.localitate)) = UPPER(TRIM(addr2.localitate)) + THEN 'POSIBIL - acelas judet+localitate' + ELSE 'NU - adrese diferite' + END AS duplicat_real + FROM nom_parteneri a + JOIN nom_parteneri b + ON a.id_part < b.id_part + AND NVL(a.sters, 0) = 0 + AND NVL(b.sters, 0) = 0 + AND a.tip_persoana = 2 + AND b.tip_persoana = 2 + AND INSTR(UPPER(TRIM(a.denumire)), ' ') > 0 + AND INSTR(UPPER(TRIM(a.denumire)), ' ', INSTR(UPPER(TRIM(a.denumire)), ' ') + 1) = 0 + AND UPPER(TRIM(b.denumire)) = + TRIM(SUBSTR(UPPER(TRIM(a.denumire)), INSTR(UPPER(TRIM(a.denumire)), ' ') + 1)) + || ' ' || + TRIM(SUBSTR(UPPER(TRIM(a.denumire)), 1, INSTR(UPPER(TRIM(a.denumire)), ' ') - 1)) + LEFT JOIN (SELECT id_part, judet, localitate, strada, + ROW_NUMBER() OVER (PARTITION BY id_part ORDER BY principala DESC, id_adresa DESC) rn + FROM vadrese_parteneri) addr1 + ON addr1.id_part = a.id_part AND addr1.rn = 1 + LEFT JOIN (SELECT id_part, judet, localitate, strada, + ROW_NUMBER() OVER (PARTITION BY id_part ORDER BY principala DESC, id_adresa DESC) rn + FROM vadrese_parteneri) addr2 + ON addr2.id_part = b.id_part AND addr2.rn = 1 + WHERE EXTRACT(YEAR FROM a.dataora) = 2026 OR EXTRACT(YEAR FROM b.dataora) = 2026 + ORDER BY duplicat_real, a.id_part;