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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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
|
||||
@@ -682,6 +683,13 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
||||
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
|
||||
clear_error;
|
||||
@@ -727,6 +735,53 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
||||
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');
|
||||
|
||||
|
||||
@@ -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)
|
||||
51
scripts/find_pf_name_duplicates.sql
Normal file
51
scripts/find_pf_name_duplicates.sql
Normal 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;
|
||||
Reference in New Issue
Block a user