dana-medical-site/internal/api/phones_parse.go
2026-10-14 13:33:13 +00:00

208 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:]
}