package api import ( "regexp" "strconv" "strings" ) // Разбор телефонов и сумм из ячеек выгрузки 1С и листов каналов. // // Формула книги («последние 10 символов, если это цифры») теряет настоящие номера, // когда в ячейке есть мусор выгрузки или текст рядом с номером. Здесь номер ищется // точно: из ячейки вырезаются все группы цифр с разделителями, каждая проверяется // по длине. Что не удалось распознать — не выбрасывается молча, а считается в отчёте. // невидимые и служебные символы, которые встречаются в выгрузках: ¶ из 1С, мягкий перенос, // символы нулевой ширины, BOM; пробелы разных видов, переводы строк, табуляция var phoneJunk = strings.NewReplacer( "\u00b6", " ", "\u00ad", "", "\u200b", "", "\u200c", "", "\u200d", "", "\u2060", "", "\ufeff", "", "\u00a0", " ", "\u2007", " ", "\u202f", " ", "\u2009", " ", "\r", " ", "\n", " ", "\t", " ", "\u2010", "-", "\u2011", "-", "\u2012", "-", "\u2013", "-", "\u2014", "-", "\u2212", "-", "'", "", "\"", "", "«", "", "»", "", ) var ( // добавочный номер: «доб. 123», «доб 12», «ext 5», «вн. 204» phoneExtRE = regexp.MustCompile(`(?i)(доб|ext|вн)\.?\s*\d{1,6}`) // группа цифр с разделителями внутри номера phoneRunRE = regexp.MustCompile(`\+?\d[\d\s().\-]*\d`) // число, записанное Excel как дробь: 79281234567.0 excelIntRE = regexp.MustCompile(`^\d{9,15}\.0+$`) dateRE = regexp.MustCompile(`\b\d{1,2}\.\d{1,2}\.\d{2,4}\b`) ) // phoneFromDigits — номер из цифр одной группы: // - 10 цифр, или 11 с 7/8 впереди — российский номер, ключ — 10 цифр; // - 11–13 цифр из одной цифры (111…, 000…) — номер-заглушка, ключ — последние 10 (как в книге); // - 11–13 цифр с другим началом — иностранный номер (+33 Франция, +1 США…), ключ «+» и все цифры: // так он не совпадёт с российским номером с теми же последними 10 цифрами; // - 20–22 цифры — два номера подряд без запятой: делим, если обе части — номера. func phoneFromDigits(d string) []string { switch n := len(d); { case n == 10: return []string{d} case n == 11 && (d[0] == '7' || d[0] == '8'): return []string{d[1:]} case n >= 11 && n <= 13 && sameDigits(d): return []string{d[n-10:]} case n >= 11 && n <= 13: return []string{"+" + d} case n >= 20 && n <= 22: // обе части — российские номера (10 цифр или 11 с 7/8); сначала 11+11 ru := func(p string) string { if len(p) == 10 { return p } if len(p) == 11 && (p[0] == '7' || p[0] == '8') { return p[1:] } return "" } for _, cut := range []int{11, 10} { if cut < n { if a, b := ru(d[:cut]), ru(d[cut:]); a != "" && b != "" { return []string{a, b} } } } } return nil } func sameDigits(d string) bool { return strings.Count(d, d[:1]) == len(d) } // isPlaceholder — ключ-заглушка вместо настоящего номера: 1111111111, 0000000000, 1234567890. func isPlaceholder(k string) bool { return k != "" && k[0] != '+' && (sameDigits(k) || k == "1234567890" || k == "0123456789") } // isForeign — иностранный номер (ключ «+цифры»). func isForeign(k string) bool { return strings.HasPrefix(k, "+") } func onlyDigits(s string) string { b := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { if s[i] >= '0' && s[i] <= '9' { b = append(b, s[i]) } } return string(b) } // phoneKeys — все телефоны ячейки (ключ — 10 цифр без 7/8) и признак «в ячейке есть цифры, // похожие на номер, но номер не распознан» — такие ячейки показываются в отчёте. func phoneKeys(v string) (keys []string, unreadable bool) { v = strings.TrimSpace(phoneJunk.Replace(v)) if v == "" { return nil, false } // число из Excel: 7.9281234567E10 или 79281234567.0 if strings.ContainsAny(v, "eE") { if f, err := strconv.ParseFloat(v, 64); err == nil && f > 1e9 && f < 1e15 { v = strconv.FormatFloat(f, 'f', 0, 64) } } if excelIntRE.MatchString(v) { v = v[:strings.IndexByte(v, '.')] } v = phoneExtRE.ReplaceAllString(v, " ") seen := map[string]bool{} digitsSeen := 0 for _, run := range phoneRunRE.FindAllString(v, -1) { if dateRE.MatchString(run) { continue // дата, а не номер: 22.07.2026 } d := onlyDigits(run) digitsSeen += len(d) for _, k := range phoneFromDigits(d) { if !seen[k] { seen[k] = true keys = append(keys, k) } } } return keys, len(keys) == 0 && digitsSeen >= 5 } // excelPhoneKey — ключ ровно как формула книги (для сверки с Excel): убрать «+ ( ) - пробел»; // если осталось ≥10 символов, последние 10 — цифры и нет «.» или «,» — последние 10. func excelPhoneKey(v string) string { v = strings.TrimSpace(v) if strings.ContainsAny(v, "eE") { if f, err := strconv.ParseFloat(v, 64); err == nil && f > 1e9 && f < 1e15 { v = strconv.FormatFloat(f, 'f', 0, 64) } } b := strings.NewReplacer("+", "", "(", "", ")", "", "-", "", " ", "").Replace(v) if len(b) < 10 || strings.ContainsAny(b, ".,") { return "" } last := b[len(b)-10:] if onlyDigits(last) != last { return "" } return last } // phoneKey — основной ключ ячейки (первый распознанный номер) или "". func phoneKey(v string) string { if k, _ := phoneKeys(v); len(k) > 0 { return k[0] } return "" } var amountJunk = strings.NewReplacer("\u00a0", "", "\u202f", "", "\u2009", "", " ", "", "\u00b6", "", "₽", "", "руб.", "", "руб", "", "р.", "", "\u2212", "-", "\r", "", "\n", "", "\t", "") // parseAmount — число из ячейки суммы или количества: «1 500,00 ₽», «-7 490», «1.234,56», «(500)». // ok=false — в ячейке что-то есть, но это не число; пустая ячейка — 0 и ok. func parseAmount(s string) (float64, bool) { s = strings.TrimSpace(amountJunk.Replace(strings.ToLower(s))) if s == "" { return 0, true } neg := false if strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") { // бухгалтерская запись отрицательного neg, s = true, s[1:len(s)-1] } // «1.234,56» и «1,234.56»: десятичный разделитель — последний из двух if strings.Contains(s, ",") && strings.Contains(s, ".") { if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") { s = strings.ReplaceAll(s, ".", "") } else { s = strings.ReplaceAll(s, ",", "") } } s = strings.ReplaceAll(s, ",", ".") v, err := strconv.ParseFloat(s, 64) if err != nil { return 0, false } if neg { v = -v } return v, true } // lastDigits — последние n цифр телефона: ключ лимитов, одинаковый для «8 928…» и «+7 928…». func lastDigits(phone string, n int) string { d := digitsRE.ReplaceAllString(phone, "") if len(d) > n { d = d[len(d)-n:] } return d } // maskPhone прячет номер для журналов: остаются последние 4 цифры. func maskPhone(phone string) string { d := digitsRE.ReplaceAllString(phone, "") if len(d) <= 4 { return "***" } return "***" + d[len(d)-4:] }