319 lines
14 KiB
Go
319 lines
14 KiB
Go
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)
|
||
}
|
||
}
|