"""E.164 phone-number formatting shared across domains. Google Workspace ``recoveryPhone`` requires strict E.164 (``+``) or the ``users.insert`` call 400s; HRIS stores national numbers plus a country. This helper turns one into the other, and returns ``None`` when it cannot format confidently (empty input, or a country with no mapped calling code) — a wrong recovery phone is worse than none, so we never guess. Accepts ISO-2 (``US``) or ISO-3 (``USA``) country codes — HRIS (``identity_persons.country``) stores ISO-3, other callers ISO-2. """ from __future__ import annotations import re # ISO-2 → international calling code (ITU-T E.164), comprehensive global # coverage. Originally scoped to "countries PingCAP employs in", which broke # the moment a real Brazil hire arrived (recoveryPhone silently dropped) — # the employment footprint is not something a shared reference table should # guess at, so this now covers every ISO-3166 country/territory. Canonical # shared table — ``identity_sync.runners.feishu_contact`` imports it too # (that path keeps its OWN default-+86 fallback logic; only the reference # data is shared, so a new country is added in one place). # # NANP (+1) members (US/CA/Caribbean) share code "1"; the ``to_e164`` # already-international heuristic tolerates that. Where ITU assigns a country # a shared parent code (e.g. some territories under their metropole), the # mapping uses the dialable code a national number would need. DIAL_CODE_ISO2: dict[str, str] = { # North America / NANP (+1) "US": "1", "CA": "1", "BS": "1", "BB": "1", "AI": "1", "AG": "1", "VG": "1", "VI": "1", "KY": "1", "BM": "1", "GD": "1", "TC": "1", "MS": "1", "MP": "1", "GU": "1", "AS": "1", "SX": "1", "LC": "1", "DM": "1", "VC": "1", "PR": "1", "DO": "1", "TT": "1", "KN": "1", "JM": "1", # Latin America "MX": "52", "BR": "55", "AR": "54", "CL": "56", "CO": "57", "PE": "51", "VE": "58", "EC": "593", "BO": "591", "PY": "595", "UY": "598", "GT": "502", "SV": "503", "HN": "504", "NI": "505", "CR": "506", "PA": "507", "CU": "53", "GY": "592", "SR": "597", "BZ": "501", "GF": "594", "GP": "590", "MQ": "596", "HT": "509", "AW": "297", "CW": "599", "FK": "500", # Europe "GB": "44", "IE": "353", "FR": "33", "DE": "49", "NL": "31", "BE": "32", "LU": "352", "ES": "34", "PT": "351", "IT": "39", "CH": "41", "AT": "43", "SE": "46", "DK": "45", "NO": "47", "FI": "358", "IS": "354", "PL": "48", "CZ": "420", "SK": "421", "HU": "36", "RO": "40", "BG": "359", "GR": "30", "HR": "385", "SI": "386", "RS": "381", "BA": "387", "ME": "382", "MK": "389", "AL": "355", "EE": "372", "LV": "371", "LT": "370", "UA": "380", "BY": "375", "MD": "373", "RU": "7", "MT": "356", "CY": "357", "LI": "423", "MC": "377", "AD": "376", "SM": "378", "VA": "379", "XK": "383", "GI": "350", "FO": "298", "GL": "299", "JE": "44", "GG": "44", "IM": "44", # Middle East "AE": "971", "SA": "966", "IL": "972", "TR": "90", "QA": "974", "KW": "965", "BH": "973", "OM": "968", "JO": "962", "LB": "961", "IQ": "964", "IR": "98", "SY": "963", "YE": "967", "PS": "970", # Asia "CN": "86", "JP": "81", "KR": "82", "KP": "850", "HK": "852", "MO": "853", "TW": "886", "SG": "65", "MY": "60", "ID": "62", "PH": "63", "TH": "66", "VN": "84", "IN": "91", "PK": "92", "BD": "880", "LK": "94", "NP": "977", "MM": "95", "KH": "855", "LA": "856", "BN": "673", "MN": "976", "KZ": "7", "UZ": "998", "TM": "993", "KG": "996", "TJ": "992", "AF": "93", "BT": "975", "MV": "960", "AZ": "994", "AM": "374", "GE": "995", "TL": "670", # Oceania "AU": "61", "NZ": "64", "FJ": "679", "PG": "675", "NC": "687", "PF": "689", "SB": "677", "VU": "678", "WS": "685", "TO": "676", "KI": "686", "FM": "691", "MH": "692", "PW": "680", "NR": "674", "TV": "688", "CK": "682", # Africa "ZA": "27", "EG": "20", "NG": "234", "KE": "254", "GH": "233", "ET": "251", "TZ": "255", "UG": "256", "MA": "212", "DZ": "213", "TN": "216", "LY": "218", "SD": "249", "SS": "211", "CM": "237", "CI": "225", "SN": "221", "ML": "223", "BF": "226", "NE": "227", "GN": "224", "BJ": "229", "TG": "228", "SL": "232", "LR": "231", "MR": "222", "GM": "220", "GW": "245", "CV": "238", "AO": "244", "MZ": "258", "ZM": "260", "ZW": "263", "MW": "265", "BW": "267", "NA": "264", "LS": "266", "SZ": "268", "MG": "261", "MU": "230", "RW": "250", "BI": "257", "CD": "243", "CG": "242", "GA": "241", "GQ": "240", "CF": "236", "TD": "235", "DJ": "253", "SO": "252", "ER": "291", "SC": "248", "KM": "269", "ST": "239", "RE": "262", "YT": "262", } _ISO3_TO_ISO2: dict[str, str] = { "USA": "US", "CAN": "CA", "BHS": "BS", "BRB": "BB", "AIA": "AI", "ATG": "AG", "VGB": "VG", "VIR": "VI", "CYM": "KY", "BMU": "BM", "GRD": "GD", "TCA": "TC", "MSR": "MS", "MNP": "MP", "GUM": "GU", "ASM": "AS", "SXM": "SX", "LCA": "LC", "DMA": "DM", "VCT": "VC", "PRI": "PR", "DOM": "DO", "TTO": "TT", "KNA": "KN", "JAM": "JM", "MEX": "MX", "BRA": "BR", "ARG": "AR", "CHL": "CL", "COL": "CO", "PER": "PE", "VEN": "VE", "ECU": "EC", "BOL": "BO", "PRY": "PY", "URY": "UY", "GTM": "GT", "SLV": "SV", "HND": "HN", "NIC": "NI", "CRI": "CR", "PAN": "PA", "CUB": "CU", "GUY": "GY", "SUR": "SR", "BLZ": "BZ", "GUF": "GF", "GLP": "GP", "MTQ": "MQ", "HTI": "HT", "ABW": "AW", "CUW": "CW", "FLK": "FK", "GBR": "GB", "IRL": "IE", "FRA": "FR", "DEU": "DE", "NLD": "NL", "BEL": "BE", "LUX": "LU", "ESP": "ES", "PRT": "PT", "ITA": "IT", "CHE": "CH", "AUT": "AT", "SWE": "SE", "DNK": "DK", "NOR": "NO", "FIN": "FI", "ISL": "IS", "POL": "PL", "CZE": "CZ", "SVK": "SK", "HUN": "HU", "ROU": "RO", "BGR": "BG", "GRC": "GR", "HRV": "HR", "SVN": "SI", "SRB": "RS", "BIH": "BA", "MNE": "ME", "MKD": "MK", "ALB": "AL", "EST": "EE", "LVA": "LV", "LTU": "LT", "UKR": "UA", "BLR": "BY", "MDA": "MD", "RUS": "RU", "MLT": "MT", "CYP": "CY", "LIE": "LI", "MCO": "MC", "AND": "AD", "SMR": "SM", "VAT": "VA", "XKX": "XK", "GIB": "GI", "FRO": "FO", "GRL": "GL", "JEY": "JE", "GGY": "GG", "IMN": "IM", "ARE": "AE", "SAU": "SA", "ISR": "IL", "TUR": "TR", "QAT": "QA", "KWT": "KW", "BHR": "BH", "OMN": "OM", "JOR": "JO", "LBN": "LB", "IRQ": "IQ", "IRN": "IR", "SYR": "SY", "YEM": "YE", "PSE": "PS", "CHN": "CN", "JPN": "JP", "KOR": "KR", "PRK": "KP", "HKG": "HK", "MAC": "MO", "TWN": "TW", "SGP": "SG", "MYS": "MY", "IDN": "ID", "PHL": "PH", "THA": "TH", "VNM": "VN", "IND": "IN", "PAK": "PK", "BGD": "BD", "LKA": "LK", "NPL": "NP", "MMR": "MM", "KHM": "KH", "LAO": "LA", "BRN": "BN", "MNG": "MN", "KAZ": "KZ", "UZB": "UZ", "TKM": "TM", "KGZ": "KG", "TJK": "TJ", "AFG": "AF", "BTN": "BT", "MDV": "MV", "AZE": "AZ", "ARM": "AM", "GEO": "GE", "TLS": "TL", "AUS": "AU", "NZL": "NZ", "FJI": "FJ", "PNG": "PG", "NCL": "NC", "PYF": "PF", "SLB": "SB", "VUT": "VU", "WSM": "WS", "TON": "TO", "KIR": "KI", "FSM": "FM", "MHL": "MH", "PLW": "PW", "NRU": "NR", "TUV": "TV", "COK": "CK", "ZAF": "ZA", "EGY": "EG", "NGA": "NG", "KEN": "KE", "GHA": "GH", "ETH": "ET", "TZA": "TZ", "UGA": "UG", "MAR": "MA", "DZA": "DZ", "TUN": "TN", "LBY": "LY", "SDN": "SD", "SSD": "SS", "CMR": "CM", "CIV": "CI", "SEN": "SN", "MLI": "ML", "BFA": "BF", "NER": "NE", "GIN": "GN", "BEN": "BJ", "TGO": "TG", "SLE": "SL", "LBR": "LR", "MRT": "MR", "GMB": "GM", "GNB": "GW", "CPV": "CV", "AGO": "AO", "MOZ": "MZ", "ZMB": "ZM", "ZWE": "ZW", "MWI": "MW", "BWA": "BW", "NAM": "NA", "LSO": "LS", "SWZ": "SZ", "MDG": "MG", "MUS": "MU", "RWA": "RW", "BDI": "BI", "COD": "CD", "COG": "CG", "GAB": "GA", "GNQ": "GQ", "CAF": "CF", "TCD": "TD", "DJI": "DJ", "SOM": "SO", "ERI": "ER", "SYC": "SC", "COM": "KM", "STP": "ST", "REU": "RE", "MYT": "YT", } def _to_iso2(country: str | None) -> str: c = (country or "").strip().upper() if len(c) == 3 and c in _ISO3_TO_ISO2: return _ISO3_TO_ISO2[c] return c def country_dialing_code(country: str | None) -> str | None: """Return the E.164 calling code for an ISO-2 or ISO-3 country.""" return DIAL_CODE_ISO2.get(_to_iso2(country)) def to_e164(raw: str | None, country: str | None) -> str | None: """National mobile → ``+`` E.164, or ``None`` when it can't be formatted confidently. * already ``+``-prefixed → normalised digits (idempotent). * ``00`` international prefix → ``+``. * otherwise strip the trunk leading ``0`` and prepend the calling code resolved from ``country`` (ISO-2 or ISO-3). * empty / all-zero input, or an unmapped country → ``None`` (never guess a calling code onto a real number). """ if not raw: return None s = raw.strip() digits = re.sub(r"\D", "", s) if not digits.strip("0"): return None if s.startswith("+"): e164_digits = digits elif digits.startswith("00"): e164_digits = digits[2:] else: cc = DIAL_CODE_ISO2.get(_to_iso2(country)) if not cc: return None national = digits.lstrip("0") if not national: return None # Some HRIS records store the FULL international number without a # ``+`` (e.g. CN ``8613812345678``). Don't double-prefix the calling # code — that yields a malformed ``+868613…`` Google 400s on. If the # national part already begins with the calling code (and enough # digits remain to be a real subscriber number), treat it as # already-international. e164_digits = ( national if national.startswith(cc) and len(national) > len(cc) + 4 else cc + national ) # E.164 caps the total at 15 digits — anything longer is malformed; skip # rather than ship a recovery phone Google will 400 on. Applies to ALL # branches (``+`` / ``00`` / national-prepend) so a fat-fingered # international number can't slip through the fast paths. if not e164_digits or len(e164_digits) > 15: return None return "+" + e164_digits