package api import ( "archive/zip" "bytes" "context" "fmt" "math" "os" "strconv" "strings" "testing" ) func TestPhoneKeys(t *testing.T) { nbsp := string(rune(0xa0)) for in, want := range map[string]string{ "8 (928) 047-00-55": "9280470055", "+7 928 962-00-94": "9289620094", "79289620094": "9289620094", "9289620094": "9289620094", "7.9289620094E10": "9289620094", // число в экспоненте "79289620094.0": "9289620094", // число Excel с дробной частью "79280470055¶": "9280470055", // «¶» из выгрузки 1С "8 928 047 00 55\n": "9280470055", "8" + nbsp + "928" + nbsp + "0470055": "9280470055", "'89280470055": "9280470055", // апостроф перед числом "тел. 8 928 047-00-55": "9280470055", // текст рядом с номером "8-928-047-00-55 доб. 123": "9280470055", // добавочный отбрасывается "928.047.00.55": "9280470055", "89280470055, 89289620094": "9280470055", // первый из двух "89280470055 89289620094": "9280470055", "": "", "нет": "", } { if got := phoneKey(in); got != want { t.Errorf("phoneKey(%q) = %q, want %q", in, got, want) } } // оба номера ячейки учитываются if k, _ := phoneKeys("89280470055, +7 928 962-00-94"); len(k) != 2 || k[1] != "9289620094" { t.Errorf("два номера: %v", k) } if k, _ := phoneKeys("89280470055 89289620094"); len(k) != 2 { t.Errorf("два номера без запятой: %v", k) } // не номер, но цифры есть — помечается для отчёта for _, in := range []string{"12345", "9280470", "123456789012345"} { if k, bad := phoneKeys(in); len(k) != 0 || !bad { t.Errorf("phoneKeys(%q) = %v, bad=%v", in, k, bad) } } // иностранный номер — ключ «+все цифры», не склеивается с российским по последним 10 цифрам if k := phoneKey("+33 6 12 34 56 78"); k != "+33612345678" { t.Errorf("Франция: %q", k) } if k := phoneKey("12025550123"); k != "+12025550123" { t.Errorf("США: %q", k) } // номер-заглушка if k := phoneKey("11111111111"); k != "1111111111" || !isPlaceholder(k) { t.Errorf("заглушка: %q", k) } if isPlaceholder("9280470055") || isPlaceholder("+33123") { t.Error("настоящий номер принят за заглушку") } // дата — не номер if k, _ := phoneKeys("22.07.2026"); len(k) != 0 { t.Errorf("дата стала номером: %v", k) } // режим «как формула книги» теряет «¶» — ради этого и нужен точный разбор if excelPhoneKey("79280470055¶") != "" || excelPhoneKey("8 (928) 047-00-55") != "9280470055" { t.Error("excelPhoneKey не совпадает с формулой книги") } } func TestParseAmount(t *testing.T) { for in, want := range map[string]float64{ "1500": 1500, "1 500,00 ₽": 1500, "-7 490": -7490, "1.234,56": 1234.56, "1,234.56": 1234.56, "(500)": -500, "2 000,50": 2000.5, "": 0, "−300": -300, "700 руб.": 700, } { if got, ok := parseAmount(in); !ok || math.Abs(got-want) > 1e-9 { t.Errorf("parseAmount(%q) = %v, %v; want %v", in, got, ok, want) } } if _, ok := parseAmount("бесплатно"); ok { t.Error("текст принят за число") } } func TestSurnameStem(t *testing.T) { if surnameStem("Магомедова Аминат") != surnameStem("Магомедов Али") { t.Error("Магомедова и Магомедов — одна фамилия") } if surnameStem("Алиева") == surnameStem("Алиханова") { t.Error("разные фамилии слились") } } // xlsxBook собирает минимальную книгу: листы с ячейками-строками (inlineStr) и числами. func xlsxBook(t *testing.T, sheets map[string][][]string, order []string) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) add := func(name, body string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } _, _ = w.Write([]byte(body)) } var wb, rels strings.Builder wb.WriteString(``) rels.WriteString(``) for i, name := range order { fmt.Fprintf(&wb, ``, name, i+1, i+1) fmt.Fprintf(&rels, ``, i+1, i+1) var sh strings.Builder sh.WriteString(``) for r, row := range sheets[name] { fmt.Fprintf(&sh, ``, r+1) for c, v := range row { if v == "" { continue } ref := fmt.Sprintf("%c%d", 'A'+c, r+1) if _, err := strconv.ParseFloat(v, 64); err == nil { fmt.Fprintf(&sh, `%s`, ref, v) } else { fmt.Fprintf(&sh, `%s`, ref, v) } } sh.WriteString(``) } sh.WriteString(``) add(fmt.Sprintf("xl/worksheets/sheet%d.xml", i+1), sh.String()) } wb.WriteString(``) rels.WriteString(``) add("xl/workbook.xml", wb.String()) add("xl/_rels/workbook.xml.rels", rels.String()) if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func channelByName(s channelsSummary, name string) channelRow { for _, c := range s.Channels { if c.Name == name { return c } } return channelRow{} } func TestChannelsReport(t *testing.T) { book := xlsxBook(t, map[string][][]string{ "Лист_1 (1С)": { {"Выгрузка 1С за июль"}, {"Пациент", "", "", "", "Мобильный телефон", "Количество", "СуммаИтого"}, {"Иванова Анна", "", "", "", "8 (928) 111-22-33", "1", "1500"}, {"Иванова Анна", "", "", "", "+79281112233", "2", "3000"}, // тот же пациент — второй визит {"Иванов Пётр", "", "", "", "89281112233", "1", "500"}, // семья на одном номере — не заглушка {"Петров Олег", "", "", "", "9284445566", "1", "2000"}, {"Сидорова Мария", "", "", "", "79289990000", "1", "700"}, // заглушка: три разные фамилии на одном номере {"Алиев А", "", "", "", "9280000001", "1", "100"}, {"Гасанов Б", "", "", "", "9280000001", "1", "200"}, {"Омаров В", "", "", "", "9280000001", "1", "300"}, {"Итого", "", "", "", "", "9", "8300"}, }, "все номера": {{"Номер (вставлять сюда)"}, {"89281112233"}, {"89284445566"}, {"89284445566"}, {"9280000001"}, {"9285550000"}}, "сайт": {{"Номер (вставлять сюда)"}, {"+7 928 444-55-66"}}, "яндекс": {{"Номер (вставлять сюда)"}, {"9284445566"}, {"9287770000"}}, "инстаграмм": {{"Номер (вставлять сюда)"}, {"9281112233"}}, }, []string{"Лист_1 (1С)", "все номера", "сайт", "яндекс", "инстаграмм"}) p := &progressTracker{stage: stageCheck} res, sumAny, err := (&API{}).phoneMatch(context.Background(), p, "июль.xlsx", book) if err != nil { t.Fatal(err) } s := sumAny.(channelsSummary) if !s.Control.HasTotal || s.Control.Revenue != 8300 || s.Control.RevenueTotal != 8300 || s.Control.Services != 9 { t.Fatalf("control = %+v", s.Control) } if s.Totals.Stubs != 1 || s.Totals.StubsRevenue != 600 { t.Fatalf("stubs = %d / %v", s.Totals.Stubs, s.Totals.StubsRevenue) } call := channelByName(s, "Колл-центр (все номера)") // лиды: 9281112233, 9284445566, 9285550000 (заглушка не считается) if call.Leads != 3 || call.Patients != 2 || call.Services != 5 || call.Revenue != 7000 { t.Fatalf("колл-центр = %+v", call) } site := channelByName(s, "Сайт") if site.Leads != 1 || site.Patients != 1 || site.Revenue != 2000 || site.Conv != 100 { t.Fatalf("сайт = %+v", site) } ya := channelByName(s, "Яндекс") if ya.Leads != 2 || ya.Patients != 1 || ya.Conv != 50 { t.Fatalf("яндекс = %+v", ya) } // интернет = сайт ∪ яндекс (инстаграм не входит): лиды 9284445566, 9287770000 if s.Internet.Leads != 2 || s.Internet.Patients != 1 || s.Internet.Revenue != 2000 { t.Fatalf("интернет = %+v", s.Internet) } tt := s.Totals if tt.UniquePhones != 3 || tt.UniqueFIO != 7 || tt.AdPatients != 2 || tt.CallNoChannel != 0 { t.Fatalf("totals = %+v", tt) } csv := string(res) for _, want := range []string{"Колл-центр (все номера);3;2;66,7;5;2,50;7000;3500", "ИТОГО интернет (без дублей);2;1;50,0", "Расхождение (должно быть 0);0,00"} { if !strings.Contains(csv, want) { t.Errorf("в CSV нет %q", want) } } if len(p.stages) != 3 { t.Errorf("stages = %v", p.stages) } } func TestChannelsReportErrors(t *testing.T) { no1C := xlsxBook(t, map[string][][]string{"сайт": {{"Номер"}, {"9281112233"}}}, []string{"сайт"}) if _, _, err := (&API{}).phoneMatch(context.Background(), &progressTracker{}, "b.xlsx", no1C); err == nil || !strings.Contains(err.Error(), "1С") { t.Errorf("no 1C: %v", err) } noCh := xlsxBook(t, map[string][][]string{"1С": {{"Пациент", "Телефон", "СуммаИтого"}, {"А", "9281112233", "100"}}}, []string{"1С"}) if _, _, err := (&API{}).phoneMatch(context.Background(), &progressTracker{}, "b.xlsx", noCh); err == nil || !strings.Contains(err.Error(), "каналов") { t.Errorf("no channels: %v", err) } } // TestChannelsReportMatchesWorkbook сверяет расчёт с числами, которые Excel сохранил // на листе «ОТЧЁТ» настоящей книги. Книга с данными пациентов в репозиторий не кладётся: // DANA_CHANNEL_BOOK=путь/к/книге.xlsx go test ./internal/api -run MatchesWorkbook func TestChannelsReportMatchesWorkbook(t *testing.T) { path := os.Getenv("DANA_CHANNEL_BOOK") if path == "" { t.Skip("DANA_CHANNEL_BOOK не задан") } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } // режим «как формулы книги»: сверяем логику расчёта, а не очистку номеров _, sumAny, err := (&API{}).phoneMatchOpts(context.Background(), &progressTracker{}, "book.xlsx", data, matchOpts{excel: true}) if err != nil { t.Fatal(err) } s := sumAny.(channelsSummary) sheets, _ := readWorkbook("book.xlsx", data) var rep [][]string for _, sh := range sheets { if strings.EqualFold(sh.Name, "ОТЧЁТ") { rep = sh.Rows } } if rep == nil { t.Fatal("в книге нет листа ОТЧЁТ") } near := func(a, b float64) bool { return math.Abs(a-b) < 0.01 } checked := 0 for _, row := range rep { name := strings.TrimSpace(cell(row, 0)) var got channelRow switch { case strings.HasPrefix(name, "ИТОГО интернет"): got = s.Internet default: got = channelByName(s, name) if got.Name == "" { continue } } want := []float64{cellNum(row, 1), cellNum(row, 2), cellNum(row, 3), cellNum(row, 4), cellNum(row, 5), cellNum(row, 6), cellNum(row, 7)} have := []float64{float64(got.Leads), float64(got.Patients), got.Conv, got.Services, got.PerPat, got.Revenue, got.AvgCheck} for i := range want { if !near(want[i], have[i]) { t.Errorf("%s, столбец %d: Excel %v, сервер %v", name, i+2, want[i], have[i]) } } checked++ } totals := map[string]float64{ "Уникальных обращений (телефонов)": float64(s.Totals.UniquePhones), "Пациентов (уникальных ФИО)": float64(s.Totals.UniqueFIO), "Услуг": s.Totals.Services, "Выручка, ₽": s.Totals.Revenue, "Средний чек, ₽": s.Totals.AvgCheck, "Средняя стоимость услуги, ₽": s.Totals.AvgService, "Пациентов из рекламных каналов": float64(s.Totals.AdPatients), "Доля маркетинга, %": s.Totals.AdShare, "Позвонили, но без канала (пациентов)": float64(s.Totals.CallNoChannel), "их выручка, ₽": s.Totals.CallNoChanRev, "Заглушек исключено": float64(s.Totals.Stubs), "выручка на заглушках, ₽": s.Totals.StubsRevenue, "Выручка по строкам 1С": s.Control.Revenue, "Строка «Итого» в выгрузке 1С": s.Control.RevenueTotal, "Услуг по строкам 1С": s.Control.Services, } for _, row := range rep { name := strings.TrimSpace(cell(row, 0)) if v, ok := totals[name]; ok { if !near(v, cellNum(row, 1)) { t.Errorf("%s: Excel %v, сервер %v", name, cellNum(row, 1), v) } checked++ } } t.Logf("сверено показателей: %d строк", checked) } // Выгрузка без заголовков на листах каналов: номер с первой строки тоже считается. func TestChannelsReportNoHeader(t *testing.T) { book := xlsxBook(t, map[string][][]string{ "Лист_1": {{"Пациент", "Мобильный телефон", "Количество", "СуммаИтого"}, {"Иванова Анна", "89281112233", "1", "1500"}, {"Итого", "", "1", "1500"}}, "Сайт": {{"79281112233"}, {"79284445566"}}, }, []string{"Лист_1", "Сайт"}) _, sumAny, err := (&API{}).phoneMatch(context.Background(), &progressTracker{}, "b.xlsx", book) if err != nil { t.Fatal(err) } if site := channelByName(sumAny.(channelsSummary), "Сайт"); site.Leads != 2 || site.Patients != 1 || site.Revenue != 1500 { t.Fatalf("сайт = %+v", site) } }