From 9767299edde1204ae27d5acc7d515fcbd17432bd Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 17 Aug 2026 12:59:54 +0200 Subject: [PATCH] Fix duplicate company/service contacts: robust phone matching Firmen-/Service-Eintraege (nur Nummer + Firma, kein Name/E-Mail) wurden bei jedem Sync neu angelegt statt verknuepft, weil das Matching Rufnummern feldgenau und formatabhaengig verglich. Bei unterschiedlich formatierten Nummern (+49 vs 0) und wechselnden Feldern (geschaeftlich/privat) schlug der Abgleich fehl -> Vervielfachung. - NormalizePhone vereinheitlicht jetzt +49/0049 -> 0 (SyncEngine + ContactMerger). - Telefon-Abgleich feldUEBERGREIFEND (jede Nummer gegen jede) via SharedPhone. - "Nummer + Firma"-Treffer nur noch fuer Eintraege OHNE Personennamen, damit Kollegen mit gemeinsamer Zentrale-Nummer nicht verschmolzen werden. - Zusaetzlich: reine Nummern-Eintraege (kein Name/Firma/E-Mail) matchen ueber eine gemeinsame Rufnummer. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++ .../Services/ContactMerger.cs | 6 ++- .../Services/SyncEngine.cs | 52 +++++++++++++++---- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f352fec8..3d85a49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`). ### Behoben +- **Dubletten bei Firmen-/Service-Eintraegen (nur Nummer + Firma).** Solche + Eintraege wurden bei jedem Sync neu angelegt statt verknuepft, weil der + Nummern-Abgleich feldgenau (geschaeftlich↔geschaeftlich) und formatabhaengig + war. Jetzt werden Rufnummern feldUEBERGREIFEND und formatunabhaengig + verglichen (`+49`/`0049` == `0`), sodass diese Eintraege verknuepft werden. + Der "Nummer + Firma"-Abgleich greift bewusst nur bei Eintraegen OHNE + Personennamen, damit keine zwei Kollegen mit gleicher Zentrale-Nummer + verschmolzen werden. (Bereits entstandene Dubletten muessen einmalig manuell + bereinigt werden.) + - **Dubletten auf beiden Seiten beim Synchronisieren.** Mehrere zusammenhaengende Ursachen wurden beseitigt: - Eine unvollstaendig geladene Starface-Kontaktliste (z.B. durch einen diff --git a/src/StarfaceOutlookSync/Services/ContactMerger.cs b/src/StarfaceOutlookSync/Services/ContactMerger.cs index 4f7ce1c5..c55e8fe8 100644 --- a/src/StarfaceOutlookSync/Services/ContactMerger.cs +++ b/src/StarfaceOutlookSync/Services/ContactMerger.cs @@ -140,7 +140,11 @@ namespace StarfaceOutlookSync.Services private static string NormalizePhone(string phone) { if (string.IsNullOrEmpty(phone)) return ""; - return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray()); + var s = new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray()); + if (s.StartsWith("+49")) s = "0" + s.Substring(3); + else if (s.StartsWith("0049")) s = "0" + s.Substring(4); + else if (s.StartsWith("+")) s = s.Substring(1); + return s; } } } diff --git a/src/StarfaceOutlookSync/Services/SyncEngine.cs b/src/StarfaceOutlookSync/Services/SyncEngine.cs index 5c806215..48b9297a 100644 --- a/src/StarfaceOutlookSync/Services/SyncEngine.cs +++ b/src/StarfaceOutlookSync/Services/SyncEngine.cs @@ -67,12 +67,9 @@ namespace StarfaceOutlookSync.Services bool nameMatch = hasName && (a.FirstName ?? "").Equals(b.FirstName ?? "", StringComparison.OrdinalIgnoreCase) && (a.LastName ?? "").Equals(b.LastName ?? "", StringComparison.OrdinalIgnoreCase); - bool phoneMatch = (!string.IsNullOrEmpty(a.PhoneWork) && !string.IsNullOrEmpty(b.PhoneWork) - && NormalizePhone(a.PhoneWork) == NormalizePhone(b.PhoneWork)) - || (!string.IsNullOrEmpty(a.PhoneMobile) && !string.IsNullOrEmpty(b.PhoneMobile) - && NormalizePhone(a.PhoneMobile) == NormalizePhone(b.PhoneMobile)) - || (!string.IsNullOrEmpty(a.Fax) && !string.IsNullOrEmpty(b.Fax) - && NormalizePhone(a.Fax) == NormalizePhone(b.Fax)); + // Telefon feldUEBERGREIFEND vergleichen (die gleiche Nummer steht mal + // als geschaeftlich, mal als privat) und formatunabhaengig (+49 == 0). + bool phoneMatch = SharedPhone(a, b); bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company) && a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase); @@ -86,17 +83,54 @@ namespace StarfaceOutlookSync.Services // (Telefon-Umformatierung durch Starface darf einen Namens-Treffer NICHT verhindern.) if (nameMatch && !emailContradiction) return true; - // Schwacher Pfad: Telefon/Fax nur zusammen mit gleicher Firma und ohne E-Mail-Widerspruch. - if (phoneMatch && companyMatch && !emailContradiction) return true; + // Firma-/Service-Eintraege OHNE Personennamen: gleiche Firma + gemeinsame + // Nummer. Bewusst nur ohne Namen, damit nicht zwei Kollegen mit gleicher + // Zentrale-Nummer + Firma faelschlich verschmolzen werden. + bool aHasName = !string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName); + bool bHasName = !string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName); + if (phoneMatch && companyMatch && !aHasName && !bHasName && !emailContradiction) return true; + + // Reine Nummern-Eintraege (kein Name, keine Firma, keine E-Mail auf beiden + // Seiten): eine gemeinsame Rufnummer identifiziert den Eintrag. + if (phoneMatch && IsBareNumberEntry(a) && IsBareNumberEntry(b)) return true; return false; } + private static bool IsBareNumberEntry(UnifiedContact c) => + string.IsNullOrEmpty(c.FirstName) && string.IsNullOrEmpty(c.LastName) + && string.IsNullOrEmpty(c.Company) && string.IsNullOrEmpty(c.Email); + + /// True, wenn a und b mindestens eine (normalisierte) Rufnummer gemeinsam haben. + private static bool SharedPhone(UnifiedContact a, UnifiedContact b) + { + var pa = PhoneSet(a); + if (pa.Count == 0) return false; + var pb = PhoneSet(b); + return pb.Count > 0 && pa.Overlaps(pb); + } + + private static HashSet PhoneSet(UnifiedContact c) + { + var set = new HashSet(); + foreach (var p in new[] { c.PhoneWork, c.PhoneMobile, c.PhoneHome, c.Fax }) + { + var n = NormalizePhone(p); + if (n.Length >= 5) set.Add(n); // zu kurze (z.B. reine Durchwahlen) ignorieren + } + return set; + } + private static string NormalizePhone(string phone) { if (string.IsNullOrEmpty(phone)) return ""; // Nur Ziffern und + behalten - return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray()); + var s = new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray()); + // Landesvorwahl vereinheitlichen, damit +49/0049 und 0 gleich sind. + if (s.StartsWith("+49")) s = "0" + s.Substring(3); + else if (s.StartsWith("0049")) s = "0" + s.Substring(4); + else if (s.StartsWith("+")) s = s.Substring(1); + return s; } public async Task SyncProfileAsync(SyncProfile profile)