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
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
}

View File

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

View File

@@ -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": []}

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
-- 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');

View File

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