325 lines
13 KiB
Python
325 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Script pentru verificare voturi AG bleuMarin Constanța 25.11.2025
|
|
- Detectează duplicate (păstrează primul vot)
|
|
- Verifică dacă votanții sunt în lista de membri cu drept de vot (fuzzy matching)
|
|
- Generează raport unitar cu totaluri pe propuneri
|
|
"""
|
|
|
|
import pandas as pd
|
|
from difflib import SequenceMatcher
|
|
import unicodedata
|
|
import re
|
|
from pathlib import Path
|
|
|
|
# Configurare căi
|
|
INPUT_DIR = Path(__file__).parent.parent / "input"
|
|
OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
|
|
|
# Fișiere de intrare
|
|
MEMBRI_FILE = INPUT_DIR / "membri_promisiune.csv"
|
|
VOTURI_PRINCIPAL_FILE = INPUT_DIR / "AG bleuMarin Constanța 25.11.2025 (Responses) - Form Responses 1.csv"
|
|
VOTURI_SUPLIMENTAR_FILE = INPUT_DIR / "AG bleuMarin Constanța 25.11.2025 Suplimentar (Responses) - Form Responses 1.csv"
|
|
|
|
# Prag pentru fuzzy matching (60% pentru a prinde variații de nume)
|
|
FUZZY_THRESHOLD = 0.60
|
|
|
|
|
|
def normalize_name(name):
|
|
"""Normalizează numele pentru comparare"""
|
|
if pd.isna(name):
|
|
return ""
|
|
# Convertește la lowercase
|
|
name = str(name).lower().strip()
|
|
# Elimină diacritice
|
|
name = unicodedata.normalize('NFKD', name)
|
|
name = ''.join(c for c in name if not unicodedata.combining(c))
|
|
# Elimină caractere speciale (păstrează doar litere și spații)
|
|
name = re.sub(r'[^a-z\s]', '', name)
|
|
# Elimină spații multiple
|
|
name = re.sub(r'\s+', ' ', name).strip()
|
|
return name
|
|
|
|
|
|
def fuzzy_match(name1, name2):
|
|
"""Calculează similaritatea între două nume"""
|
|
n1 = normalize_name(name1)
|
|
n2 = normalize_name(name2)
|
|
|
|
if not n1 or not n2:
|
|
return 0.0
|
|
|
|
# Încearcă potrivire directă
|
|
if n1 == n2:
|
|
return 1.0
|
|
|
|
words1 = set(n1.split())
|
|
words2 = set(n2.split())
|
|
|
|
# Dacă toate cuvintele din numele scurt sunt în numele lung (ex: "Stancu Matei" in "Stancu Matei Stefan")
|
|
if words1 and words2:
|
|
if words1.issubset(words2) or words2.issubset(words1):
|
|
return 0.95
|
|
|
|
# Verifică dacă au cel puțin 2 cuvinte comune (ex: "Bardi Antonio Alexandru" și "Bardi Alexandru Antonio")
|
|
common = words1.intersection(words2)
|
|
if len(common) >= 2:
|
|
# Verifică dacă numele de familie (primul cuvânt din fiecare) se potrivește
|
|
list1 = n1.split()
|
|
list2 = n2.split()
|
|
if list1[0] == list2[0]:
|
|
return 0.92 # Același nume familie + prenume comun
|
|
return 0.85 # Cuvinte comune dar familie diferit
|
|
|
|
# Sortează cuvintele și compară (pentru nume în ordine diferită)
|
|
sorted1 = ' '.join(sorted(n1.split()))
|
|
sorted2 = ' '.join(sorted(n2.split()))
|
|
if sorted1 == sorted2:
|
|
return 0.98
|
|
|
|
# Fuzzy matching cu SequenceMatcher
|
|
base_score = SequenceMatcher(None, n1, n2).ratio()
|
|
|
|
# Bonus dacă cel puțin un cuvânt se potrivește exact
|
|
if words1.intersection(words2):
|
|
base_score = min(base_score + 0.15, 0.95)
|
|
|
|
return base_score
|
|
|
|
|
|
def find_best_match(name, membri_names):
|
|
"""Găsește cel mai bun match pentru un nume în lista de membri"""
|
|
best_score = 0
|
|
best_match = None
|
|
|
|
for membru in membri_names:
|
|
score = fuzzy_match(name, membru)
|
|
if score > best_score:
|
|
best_score = score
|
|
best_match = membru
|
|
|
|
return best_match, best_score
|
|
|
|
|
|
def remove_duplicates(df, email_col='Email Address', timestamp_col='Timestamp'):
|
|
"""Elimină duplicatele păstrând primul vot (cel mai vechi)"""
|
|
# Sortează după timestamp
|
|
df_sorted = df.sort_values(by=timestamp_col)
|
|
|
|
# Găsește duplicatele
|
|
duplicates = df_sorted[df_sorted.duplicated(subset=[email_col], keep='first')]
|
|
|
|
# Păstrează doar primul vot pentru fiecare email
|
|
df_unique = df_sorted.drop_duplicates(subset=[email_col], keep='first')
|
|
|
|
return df_unique, duplicates
|
|
|
|
|
|
def main():
|
|
print("=" * 70)
|
|
print("Verificare voturi AG bleuMarin Constanța 25.11.2025")
|
|
print("=" * 70)
|
|
|
|
# Încarcă lista de membri
|
|
print("\n[1] Încărcare date...")
|
|
membri_df = pd.read_csv(MEMBRI_FILE)
|
|
membri_names = membri_df['Nume'].dropna().tolist()
|
|
print(f" - Membri cu drept de vot: {len(membri_names)}")
|
|
|
|
# Încarcă voturile principale (fără skiprows, headerul este pe linia 1)
|
|
voturi_principal_df = pd.read_csv(VOTURI_PRINCIPAL_FILE)
|
|
print(f" - Voturi formular principal: {len(voturi_principal_df)}")
|
|
|
|
# Încarcă voturile suplimentare
|
|
voturi_suplimentar_df = pd.read_csv(VOTURI_SUPLIMENTAR_FILE)
|
|
print(f" - Voturi formular suplimentar: {len(voturi_suplimentar_df)}")
|
|
|
|
# [2] Elimină duplicatele
|
|
print("\n[2] Detectare duplicate (pe email)...")
|
|
voturi_principal_clean, duplicates_principal = remove_duplicates(voturi_principal_df)
|
|
voturi_suplimentar_clean, duplicates_suplimentar = remove_duplicates(voturi_suplimentar_df)
|
|
|
|
print(f" - Duplicate formular principal: {len(duplicates_principal)}")
|
|
if len(duplicates_principal) > 0:
|
|
for _, row in duplicates_principal.iterrows():
|
|
print(f" * {row['Nume și prenume']} ({row['Email Address']}) - {row['Timestamp']}")
|
|
|
|
print(f" - Duplicate formular suplimentar: {len(duplicates_suplimentar)}")
|
|
if len(duplicates_suplimentar) > 0:
|
|
for _, row in duplicates_suplimentar.iterrows():
|
|
print(f" * {row['Nume și prenume']} ({row['Email Address']}) - {row['Timestamp']}")
|
|
|
|
# Salvează duplicatele
|
|
all_duplicates = []
|
|
for _, row in duplicates_principal.iterrows():
|
|
all_duplicates.append({
|
|
'Formular': 'Principal',
|
|
'Nume': row['Nume și prenume'],
|
|
'Email': row['Email Address'],
|
|
'Timestamp': row['Timestamp']
|
|
})
|
|
for _, row in duplicates_suplimentar.iterrows():
|
|
all_duplicates.append({
|
|
'Formular': 'Suplimentar',
|
|
'Nume': row['Nume și prenume'],
|
|
'Email': row['Email Address'],
|
|
'Timestamp': row['Timestamp']
|
|
})
|
|
|
|
duplicates_df = pd.DataFrame(all_duplicates)
|
|
duplicates_df.to_csv(OUTPUT_DIR / "raport_duplicate.csv", index=False, encoding='utf-8-sig')
|
|
print(f" -> Salvat: output/raport_duplicate.csv")
|
|
|
|
# [3] Verificare drept de vot cu fuzzy matching
|
|
print("\n[3] Verificare drept de vot (fuzzy matching, prag {:.0%})...".format(FUZZY_THRESHOLD))
|
|
|
|
# Combinăm votanții din ambele formulare
|
|
votanti_principal = voturi_principal_clean[['Nume și prenume', 'Email Address']].copy()
|
|
votanti_principal['Formular'] = 'Principal'
|
|
|
|
votanti_suplimentar = voturi_suplimentar_clean[['Nume și prenume', 'Email Address']].copy()
|
|
votanti_suplimentar['Formular'] = 'Suplimentar'
|
|
|
|
all_votanti = pd.concat([votanti_principal, votanti_suplimentar])
|
|
all_votanti = all_votanti.drop_duplicates(subset=['Email Address'])
|
|
|
|
neautorizati = []
|
|
autorizati_mapping = {}
|
|
|
|
for _, row in all_votanti.iterrows():
|
|
nume = row['Nume și prenume']
|
|
email = row['Email Address']
|
|
|
|
best_match, score = find_best_match(nume, membri_names)
|
|
|
|
if score >= FUZZY_THRESHOLD:
|
|
autorizati_mapping[email] = {
|
|
'nume_votat': nume,
|
|
'nume_membru': best_match,
|
|
'score': score
|
|
}
|
|
else:
|
|
neautorizati.append({
|
|
'Nume votat': nume,
|
|
'Email': email,
|
|
'Cel mai apropiat match': best_match,
|
|
'Scor similaritate': f"{score:.2%}"
|
|
})
|
|
|
|
print(f" - Votanți autorizați: {len(autorizati_mapping)}")
|
|
print(f" - Votanți NEAUTORIZAȚI: {len(neautorizati)}")
|
|
|
|
if neautorizati:
|
|
print("\n Votanți fără drept de vot găsit în lista de membri:")
|
|
for v in neautorizati:
|
|
print(f" * {v['Nume votat']} -> {v['Cel mai apropiat match']} ({v['Scor similaritate']})")
|
|
|
|
neautorizati_df = pd.DataFrame(neautorizati)
|
|
neautorizati_df.to_csv(OUTPUT_DIR / "raport_neautorizati.csv", index=False, encoding='utf-8-sig')
|
|
print(f" -> Salvat: output/raport_neautorizati.csv")
|
|
|
|
# Lista de email-uri neautorizate (pentru excludere din totaluri)
|
|
emails_neautorizati = set(v['Email'] for v in neautorizati)
|
|
|
|
# [4] Calcul totaluri
|
|
print("\n[4] Calcul totaluri pe propuneri...")
|
|
|
|
# Filtrăm doar voturile autorizate
|
|
voturi_principal_valid = voturi_principal_clean[~voturi_principal_clean['Email Address'].isin(emails_neautorizati)]
|
|
voturi_suplimentar_valid = voturi_suplimentar_clean[~voturi_suplimentar_clean['Email Address'].isin(emails_neautorizati)]
|
|
|
|
print(f" - Voturi valide formular principal: {len(voturi_principal_valid)}")
|
|
print(f" - Voturi valide formular suplimentar: {len(voturi_suplimentar_valid)}")
|
|
|
|
# Găsim coloanele cu propuneri din formularul principal (cele care încep cu cifre)
|
|
propuneri_cols = []
|
|
for col in voturi_principal_valid.columns:
|
|
# Verifică dacă coloana începe cu un număr urmat de punct
|
|
if re.match(r'^\d+\.', col):
|
|
# Exclude coloanele Badge (12.x)
|
|
if not col.startswith('12.'):
|
|
propuneri_cols.append(col)
|
|
|
|
print(f" - Propuneri găsite în formular principal: {len(propuneri_cols)}")
|
|
|
|
# Calculăm totalurile
|
|
totaluri = []
|
|
|
|
# Titluri scurte pentru propuneri
|
|
titluri_scurte = {
|
|
1: "1. AG ONCR. Bugetul ONCR pe anul 2026",
|
|
2: "2. AG ONCR. Acordarea dreptului PJ pentru structurile locale",
|
|
3: "3. AG ONCR. Reglementarea transferurilor între Centre Locale",
|
|
4: "4. Reprezentant bleuMarin la AG Național (Maria Costache)",
|
|
5: "5. Cotizație: 250 lei (150 lei adulți/copii lideri, 175 lei frați)",
|
|
6: "6. Program Woodbadge: 20% participant, 80% centru local",
|
|
7: "7. Fund-raising patrule: 20% acțiuni sociale, 80% patrulă",
|
|
8: "8. Fund-raising membru: 20% acțiuni sociale, 80% membru",
|
|
9: "9. Sponsorizări membru: până la 20% pentru evenimente",
|
|
10: "10. Telefoane la activități - restricții",
|
|
11: "11. Fund-raising/caritate anunțate pe grupul de lideri"
|
|
}
|
|
|
|
# Propuneri din formularul principal
|
|
for i, col in enumerate(propuneri_cols, 1):
|
|
voturi = voturi_principal_valid[col].value_counts()
|
|
da = voturi.get('Da', 0)
|
|
nu = voturi.get('Nu', 0)
|
|
abtinere = voturi.get('Abtinere', 0)
|
|
total = da + nu + abtinere
|
|
|
|
titlu = titluri_scurte.get(i, f"{i}. {col[:50]}...")
|
|
|
|
totaluri.append({
|
|
'Nr': i,
|
|
'Propunere': titlu,
|
|
'Da': da,
|
|
'Nu': nu,
|
|
'Abtinere': abtinere,
|
|
'Total': total
|
|
})
|
|
|
|
# Propunerea din formularul suplimentar
|
|
suplimentar_cols = [col for col in voturi_suplimentar_valid.columns if re.match(r'^\d+\.', col)]
|
|
if suplimentar_cols:
|
|
suplimentar_col = suplimentar_cols[0]
|
|
voturi_supl = voturi_suplimentar_valid[suplimentar_col].value_counts()
|
|
|
|
totaluri.append({
|
|
'Nr': 12,
|
|
'Propunere': '12. AG ONCR. Înființare CL Sf. Voievod Ștefan cel Mare (desprindere)',
|
|
'Da': voturi_supl.get('Da', 0),
|
|
'Nu': voturi_supl.get('Nu', 0),
|
|
'Abtinere': voturi_supl.get('Abtinere', 0),
|
|
'Total': voturi_supl.get('Da', 0) + voturi_supl.get('Nu', 0) + voturi_supl.get('Abtinere', 0)
|
|
})
|
|
|
|
# Salvează totalurile
|
|
totaluri_df = pd.DataFrame(totaluri)
|
|
totaluri_df.to_csv(OUTPUT_DIR / "raport_voturi_AG.csv", index=False, encoding='utf-8-sig')
|
|
print(f" -> Salvat: output/raport_voturi_AG.csv")
|
|
|
|
# [5] Afișare rezultate
|
|
print("\n" + "=" * 70)
|
|
print("REZULTATE FINALE - Totaluri voturi AG")
|
|
print("=" * 70)
|
|
|
|
for t in totaluri:
|
|
print(f"\n{t['Propunere']}")
|
|
print(f" Da: {t['Da']:3d} | Nu: {t['Nu']:3d} | Abținere: {t['Abtinere']:3d} | Total: {t['Total']:3d}")
|
|
|
|
print("\n" + "=" * 70)
|
|
print("SUMAR")
|
|
print("=" * 70)
|
|
print(f"Total membri cu drept de vot: {len(membri_names)}")
|
|
print(f"Total voturi unice formular principal: {len(voturi_principal_clean)}")
|
|
print(f"Total voturi unice formular suplimentar: {len(voturi_suplimentar_clean)}")
|
|
print(f"Duplicate eliminate: {len(duplicates_principal) + len(duplicates_suplimentar)}")
|
|
print(f"Votanți neautorizați: {len(neautorizati)}")
|
|
print(f"Voturi valide formular principal: {len(voturi_principal_valid)}")
|
|
print(f"Voturi valide formular suplimentar: {len(voturi_suplimentar_valid)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|