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
|
# 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": []}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -682,6 +683,13 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
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
|
||||||
clear_error;
|
clear_error;
|
||||||
@@ -727,6 +735,53 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
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');
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
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