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

319 lines
14 KiB
Go
Raw 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 (
"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(`<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>`)
rels.WriteString(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`)
for i, name := range order {
fmt.Fprintf(&wb, `<sheet name="%s" sheetId="%d" r:id="rId%d"/>`, name, i+1, i+1)
fmt.Fprintf(&rels, `<Relationship Id="rId%d" Target="worksheets/sheet%d.xml"/>`, i+1, i+1)
var sh strings.Builder
sh.WriteString(`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>`)
for r, row := range sheets[name] {
fmt.Fprintf(&sh, `<row r="%d">`, 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, `<c r="%s"><v>%s</v></c>`, ref, v)
} else {
fmt.Fprintf(&sh, `<c r="%s" t="inlineStr"><is><t>%s</t></is></c>`, ref, v)
}
}
sh.WriteString(`</row>`)
}
sh.WriteString(`</sheetData></worksheet>`)
add(fmt.Sprintf("xl/worksheets/sheet%d.xml", i+1), sh.String())
}
wb.WriteString(`</sheets></workbook>`)
rels.WriteString(`</Relationships>`)
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)
}
}