208 lines
8.0 KiB
Go
208 lines
8.0 KiB
Go
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:]
|
||
}
|