fix(partners): prevent duplicate PF partners on firstname/lastname swap

Customers often swap firstname/lastname in GoMag forms, causing duplicate
partner creation in Oracle. Fix with two layers:

- Python: sort PF name words alphabetically before Oracle lookup
- PL/SQL: add Step 2b permutation search (2-3 word names, PF only)
- Normalize name order to lastname+firstname across all Python files
- Add diagnostic SQL for finding existing reversed-name duplicates
- Add Oracle integration test for reverse-name matching

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-06 12:06:55 +00:00
parent 0992744490
commit fc1013bff6
6 changed files with 213 additions and 10 deletions

View File

@@ -27,7 +27,7 @@ async def scan_and_validate():
# Build SKU context from skipped orders and track missing SKUs # Build SKU context from skipped orders and track missing SKUs
sku_context = {} # sku -> {order_numbers: [], customers: []} sku_context = {} # sku -> {order_numbers: [], customers: []}
for order, missing_list in skipped: 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: for sku in missing_list:
if sku not in sku_context: if sku not in sku_context:
sku_context[sku] = {"order_numbers": [], "customers": []} sku_context[sku] = {"order_numbers": [], "customers": []}
@@ -86,7 +86,7 @@ async def scan_and_validate():
"skipped_orders": [ "skipped_orders": [
{ {
"number": order.number, "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), "items_count": len(order.items),
"missing_skus": missing "missing_skus": missing
} }

View File

@@ -244,14 +244,16 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
is_pj = 1 is_pj = 1
else: else:
# Use shipping person for partner name (person on shipping label) # 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): 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}" f"{order.shipping.lastname} {order.shipping.firstname}"
).upper() ).upper()
else: else:
denumire = clean_web_text( raw_name = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}" f"{order.billing.lastname} {order.billing.firstname}"
).upper() ).upper()
denumire = " ".join(sorted(raw_name.split()))
cod_fiscal = None cod_fiscal = None
registru = None registru = None
is_pj = 0 is_pj = 0

View File

@@ -93,8 +93,8 @@ def _derive_customer_info(order):
""" """
shipping_name = "" shipping_name = ""
if order.shipping: if order.shipping:
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip() shipping_name = f"{getattr(order.shipping, 'lastname', '') or ''} {getattr(order.shipping, 'firstname', '') or ''}".strip()
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip() billing_name = f"{getattr(order.billing, 'lastname', '') or ''} {getattr(order.billing, 'firstname', '') or ''}".strip()
if not shipping_name: if not shipping_name:
shipping_name = billing_name shipping_name = billing_name
if order.billing.is_company and order.billing.company_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: else:
ship_name = "" ship_name = ""
if order.shipping: if order.shipping:
ship_name = f"{order.shipping.firstname} {order.shipping.lastname}".strip() ship_name = f"{order.shipping.lastname} {order.shipping.firstname}".strip()
customer = ship_name or f"{order.billing.firstname} {order.billing.lastname}" customer = ship_name or f"{order.billing.lastname} {order.billing.firstname}"
for sku in missing_skus_list: for sku in missing_skus_list:
if sku not in sku_context: if sku not in sku_context:
sku_context[sku] = {"orders": [], "customers": []} sku_context[sku] = {"orders": [], "customers": []}

View File

@@ -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 -- 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 - 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 - 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 -- CONSTANTS
@@ -677,10 +678,17 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
v_este_persoana_fizica NUMBER; v_este_persoana_fizica NUMBER;
v_nume VARCHAR2(50); v_nume VARCHAR2(50);
v_prenume VARCHAR2(50); v_prenume VARCHAR2(50);
-- Date pentru pack_def -- Date pentru pack_def
v_cod_fiscal_curat VARCHAR2(50); v_cod_fiscal_curat VARCHAR2(50);
v_denumire_curata VARCHAR2(200); 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 BEGIN
-- Resetare eroare la inceputul procesarii -- Resetare eroare la inceputul procesarii
@@ -726,7 +734,54 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
RETURN; RETURN;
END IF; END IF;
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 -- STEP 3: Creare partener nou
-- pINFO('Nu s-a gasit partener existent. Se creeaza unul nou...', 'IMPORT_PARTENERI'); -- pINFO('Nu s-a gasit partener existent. Se creeaza unul nou...', 'IMPORT_PARTENERI');

View File

@@ -901,6 +901,93 @@ def test_duplicate_codmat_different_prices():
return False 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__": if __name__ == "__main__":
print("Starting complete order import test...") print("Starting complete order import test...")
print(f"Timestamp: {datetime.now()}") print(f"Timestamp: {datetime.now()}")
@@ -937,4 +1024,12 @@ if __name__ == "__main__":
biz_passed += 1 biz_passed += 1
print(f"\nBusiness rule tests: {biz_passed}/{len(biz_tests)} passed") 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) exit(0 if success else 1)

View File

@@ -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;