package api import ( "context" "errors" "math" "sort" "strconv" "strings" ) // «Отчёт по каналам» — то же, что книга «Дана · отчёт по каналам» считает формулами: // // - лист 1С (выгрузка: Пациент, Мобильный телефон, Количество, СуммаИтого, строка «Итого»); // - листы каналов (все номера = колл-центр, сайт, инстаграм, яндекс, 2гис…): номера в столбце A; // - лист ЗАГЛУШКИ (необязательный): номера, исключаемые из расчёта. // // Ключ телефона — последние 10 цифр (как формула RIGHT(…;10)). Заглушка — телефон, // на который записаны 3+ разных фамилии (мужская и женская форма — одна фамилия). // Разбор номеров и сумм — phones_parse.go. // matchOpts — режим расчёта. excel: ключ телефона и числа строго как формулы книги — // только для сверки с сохранёнными значениями Excel; обычный режим точнее (см. phoneKeys). type matchOpts struct { excel bool // номера каналов из АТС: лист книги («все номера», «яндекс»…) → номера клиентов. // Добавляются к листам книги с тем же названием или становятся новыми листами. pbxLists map[string][]string pbxInfo *pbxSourceInfo bitrixInfo *bitrixSourceInfo prodoctorovInfo *prodoctorovSourceInfo sberhealthInfo *sberhealthSourceInfo } // pbxSourceInfo — откуда в отчёте номера каналов из АТС. type pbxSourceInfo struct { From string `json:"from"` To string `json:"to"` Calls int `json:"calls"` Unmapped []string `json:"unmapped"` // линии клиники без канала: их звонки в отчёт не входят } func (o matchOpts) keys(v string) ([]string, bool) { if o.excel { if k := excelPhoneKey(v); k != "" { return []string{k}, false } return nil, false } return phoneKeys(v) } func (o matchOpts) num(row []string, col int) (float64, bool) { if o.excel { return cellNum(row, col), true } return parseAmount(cell(row, col)) } // dataQuality — что в данных пришлось исправить или не удалось разобрать. type dataQuality struct { Phones1CCleaned int `json:"phones_1c_cleaned"` // номер найден, но формула книги его бы потеряла Phones1CBad int `json:"phones_1c_bad"` // в ячейке цифры, номер не распознан Phones1CMulti int `json:"phones_1c_multi"` // в ячейке несколько номеров Phones1CForeign int `json:"phones_1c_foreign"` // иностранные номера (+33, +1…) Placeholders int `json:"placeholders"` // строк 1С с номером-заглушкой 111…, 000… ChannelCleaned int `json:"channel_cleaned"` // то же на листах каналов ChannelBad int `json:"channel_bad"` ChannelMulti int `json:"channel_multi"` ChannelForeign int `json:"channel_foreign"` NumbersBad int `json:"numbers_bad"` // «Количество»/«СуммаИтого» — не число Examples []string `json:"examples,omitempty"` // вид проблемных ячеек: цифры заменены на 9 } func (q *dataQuality) example(v string) { if len(q.Examples) < 6 { q.Examples = append(q.Examples, maskShape(v)) } } // maskShape — вид ячейки без самих цифр: «8 (999) 999-99-99¶» — для примеров в отчёте. func maskShape(v string) string { r := []rune(v) for i, c := range r { if c >= '0' && c <= '9' { r[i] = '9' } } if len(r) > 40 { r = append(r[:40], '…') } return string(r) } // channelDef — известные листы каналов: как назвать в отчёте, входит ли в «интернет». type channelDef struct { title string internet bool callCtr bool order int } var knownChannels = map[string]channelDef{ "все номера": {"Колл-центр (все номера)", false, true, 0}, "сайт": {"Сайт", true, false, 1}, "инстаграмм": {"Инстаграм", false, false, 2}, "инстаграм": {"Инстаграм", false, false, 2}, "яндекс": {"Яндекс", true, false, 3}, "2гис": {"2Гис", true, false, 4}, "продокторов": {"Продокторов", true, false, 5}, "напоправку": {"НаПоправку", true, false, 6}, "сберздоровье": {"Сберздоровье", true, false, 7}, "zoon": {"ZOON", true, false, 8}, "докту": {"Докту", true, false, 9}, "тв": {"ТВ", false, false, 10}, "баннер": {"Баннера", false, false, 11}, "баннера": {"Баннера", false, false, 11}, "маршрутки": {"Маршрутки", false, false, 12}, // у «Очевидно» баннеры и маршрутки — один номер и один канал "баннера и маршрутки": {"Баннера и маршрутки", false, false, 12}, "google": {"Google", true, false, 13}, "вк": {"ВКонтакте", true, false, 14}, "telegram": {"Telegram", true, false, 15}, "max": {"MAX", true, false, 16}, "радио": {"Радио", false, false, 17}, "полиграфия": {"Полиграфия", false, false, 18}, "ролик": {"Ролик", false, false, 19}, } // служебные листы книги: результаты формул, не исходные данные var serviceSheets = map[string]bool{"отчёт": true, "отчет": true, "норм_1с": true, "интернет_свод": true, "заглушки": true} // surnameStem — фамилия без женского окончания: «Магомедова» и «Магомедов» — одна семья. func surnameStem(fio string) string { f := strings.Fields(strings.ToLower(strings.ReplaceAll(fio, "ё", "е"))) if len(f) == 0 { return "" } s := f[0] for _, p := range [][2]string{{"ова", "ов"}, {"ева", "ев"}, {"ина", "ин"}, {"ына", "ын"}, {"ская", "ский"}, {"цкая", "цкий"}, {"ая", "ий"}} { if strings.HasSuffix(s, p[0]) { return strings.TrimSuffix(s, p[0]) + p[1] } } return s } type channelRow struct { Name string `json:"name"` Leads int `json:"leads"` Patients int `json:"patients"` Conv float64 `json:"conv"` Services float64 `json:"services"` PerPat float64 `json:"per_patient"` Revenue float64 `json:"revenue"` AvgCheck float64 `json:"avg_check"` Internet bool `json:"internet,omitempty"` Unknown bool `json:"unknown,omitempty"` // лист с номерами, которого нет в списке каналов книги } type channelsTotals struct { UniquePhones int `json:"unique_phones"` UniqueFIO int `json:"unique_fio"` Services float64 `json:"services"` Revenue float64 `json:"revenue"` AvgCheck float64 `json:"avg_check"` AvgService float64 `json:"avg_service"` AdPatients int `json:"ad_patients"` AdShare float64 `json:"ad_share"` CallNoChannel int `json:"call_no_channel"` CallNoChanRev float64 `json:"call_no_channel_revenue"` Stubs int `json:"stubs"` StubsRevenue float64 `json:"stubs_revenue"` StubsSource string `json:"stubs_source"` } type channelsControl struct { Revenue float64 `json:"revenue"` RevenueTotal float64 `json:"revenue_total"` // строка «Итого» в выгрузке 1С Services float64 `json:"services"` ServicesTot float64 `json:"services_total"` HasTotal bool `json:"has_total"` } type channelsSummary struct { Dashboard *reportDashboard `json:"dashboard,omitempty"` Control channelsControl `json:"control"` Channels []channelRow `json:"channels"` Internet channelRow `json:"internet"` Totals channelsTotals `json:"totals"` Rows1C int `json:"rows_1c"` Quality dataQuality `json:"quality"` PBX *pbxSourceInfo `json:"pbx,omitempty"` Bitrix *bitrixSourceInfo `json:"bitrix,omitempty"` Prodoctorov *prodoctorovSourceInfo `json:"prodoctorov,omitempty"` SberHealth *sberhealthSourceInfo `json:"sberhealth,omitempty"` } // oneCCols — строка заголовка выгрузки 1С и нужные столбцы. type oneCCols struct{ header, patient, phone, qty, sum int } func find1CColumns(rows [][]string) (oneCCols, bool) { for i := 0; i < len(rows) && i < 15; i++ { c := oneCCols{header: i, patient: -1, phone: -1, qty: -1, sum: -1} mobile := -1 for j, v := range rows[i] { h := strings.ToLower(strings.TrimSpace(v)) switch { case c.patient < 0 && h == "пациент": c.patient = j case strings.Contains(h, "мобильный телефон"): mobile = j case c.phone < 0 && strings.Contains(h, "телефон"): c.phone = j case h == "количество": c.qty = j case h == "суммаитого" || h == "сумма итого": c.sum = j } } if mobile >= 0 { c.phone = mobile } if c.patient >= 0 && c.phone >= 0 && c.sum >= 0 { return c, true } } return oneCCols{}, false } func cellNum(row []string, col int) float64 { s := strings.NewReplacer(" ", "", " ", "", ",", ".").Replace(strings.TrimSpace(cell(row, col))) v, err := strconv.ParseFloat(s, 64) if err != nil { return 0 } return v } func cell(row []string, col int) string { if col < 0 || col >= len(row) { return "" } return row[col] } type chSheet struct { def channelDef sheet int } // строка 1С с отметками каналов (как лист НОРМ_1С) type oneCLine struct { src int // номер строки листа — «Строка 1С» name, key string // key — первый номер строки: по нему уникальность и заглушки keys []string // все номера ячейки: строка в канале, если там есть любой из них qty, sum float64 stub bool firstPhone bool firstFIO bool in []bool // по каналам ad, internet bool } func (a *API) phoneMatch(ctx context.Context, p *progressTracker, name string, data []byte) ([]byte, any, error) { return a.phoneMatchOpts(ctx, p, name, data, matchOpts{}) } func (a *API) phoneMatchOpts(ctx context.Context, p *progressTracker, name string, data []byte, o matchOpts) ([]byte, any, error) { // Доли этапов по замерам на реальной книге (10 тыс. строк 1С, 14 каналов): основное время — // чтение листов, поэтому «Проверка данных» занимает большую часть шкалы и идёт по прочитанным байтам. p.bounds = []int{0, 2, 78, 96, 99, 100} p.set(stageCheck, 0) sheets, err := readWorkbookOpts(name, data, workbookOpts{ // в служебных листах книги — результаты формул; сервер считает сам, читать их незачем Skip: func(n string) bool { k := strings.ToLower(strings.TrimSpace(n)) return serviceSheets[k] && k != "заглушки" }, Progress: func(done, total int64) { p.setExact(stageCheck, int(done/1024), int(total/1024)) }, }) if err != nil { return nil, nil, err } sheets = mergePBXLists(sheets, o.pbxLists) // --- этап «Проверка данных»: какие листы что содержат --- oneC := -1 var oc oneCCols for i, s := range sheets { if serviceSheets[strings.ToLower(strings.TrimSpace(s.Name))] { continue } if c, ok := find1CColumns(s.Rows); ok { oneC, oc = i, c break } } if oneC < 0 { return nil, nil, errors.New("Не нашёл лист выгрузки 1С: нужны столбцы «Пациент», «Мобильный телефон» (или «Телефон») и «СуммаИтого»") } var chans []chSheet var stubList []string stubsFromSheet := false for i, s := range sheets { key := strings.ToLower(strings.TrimSpace(s.Name)) if i == oneC { continue } if key == "заглушки" { stubsFromSheet = true for _, row := range s.Rows { // заголовок («Номер…») телефоном не считается — пропускается сам ks, _ := o.keys(cell(row, 0)) stubList = append(stubList, ks...) } continue } if serviceSheets[key] { continue } def, known := knownChannels[key] if !known { // лист не из списка книги — отдельный рекламный канал, если в столбце A есть номера n := 0 for _, row := range s.Rows { if ks, _ := o.keys(cell(row, 0)); len(ks) > 0 { n++ } } if n == 0 { continue } def = channelDef{title: strings.TrimSpace(s.Name), order: 100 + i} } chans = append(chans, chSheet{def, i}) } if len(chans) == 0 { return nil, nil, errors.New("В книге нет листов каналов с номерами (все номера, сайт, яндекс, 2гис…)") } sort.SliceStable(chans, func(i, j int) bool { return chans[i].def.order < chans[j].def.order }) // работа этапа «Обработка» — строки 1С и строки всех листов каналов total := len(sheets[oneC].Rows) for _, c := range chans { total += len(sheets[c.sheet].Rows) } done := 0 tick := func() { done++ if done%250 == 0 { p.setExact(stageProcess, done, total) } } p.next() // → обработка // --- строки 1С --- var lines []oneCLine sum := channelsSummary{} for r, row := range sheets[oneC].Rows { if ctx.Err() != nil { return nil, nil, ctx.Err() } tick() if r <= oc.header { continue } nm := strings.TrimSpace(cell(row, oc.patient)) if strings.EqualFold(nm, "итого") { sum.Control.HasTotal = true sum.Control.RevenueTotal, _ = o.num(row, oc.sum) sum.Control.ServicesTot, _ = o.num(row, oc.qty) continue } ph := cell(row, oc.phone) qty, okQ := o.num(row, oc.qty) amount, okS := o.num(row, oc.sum) if nm == "" && strings.TrimSpace(ph) == "" && amount == 0 { continue } q := &sum.Quality if !okQ || !okS { q.NumbersBad++ if !okS { q.example(cell(row, oc.sum)) } else { q.example(cell(row, oc.qty)) } } keys, bad := o.keys(ph) switch { case bad: q.Phones1CBad++ q.example(ph) case len(keys) > 0 && !o.excel && !isForeign(keys[0]) && excelPhoneKey(ph) != keys[0]: q.Phones1CCleaned++ q.example(ph) } if len(keys) > 1 { q.Phones1CMulti++ } if len(keys) > 0 && isForeign(keys[0]) { q.Phones1CForeign++ } if len(keys) > 0 && !o.excel && isPlaceholder(keys[0]) { q.Placeholders++ } l := oneCLine{src: r + 1, name: cell(row, oc.patient), keys: keys, qty: qty, sum: amount} if len(keys) > 0 { l.key = keys[0] } lines = append(lines, l) } sum.PBX = o.pbxInfo sum.Bitrix = o.bitrixInfo sum.Prodoctorov = o.prodoctorovInfo sum.SberHealth = o.sberhealthInfo sum.Rows1C = len(lines) // заглушки: из листа книги, иначе — по правилу «3+ разных фамилии на одном телефоне» stubs := map[string]bool{} if stubsFromSheet { for _, k := range stubList { stubs[k] = true } sum.Totals.StubsSource = "лист ЗАГЛУШКИ" } else { fam := map[string]map[string]bool{} for _, l := range lines { if l.key == "" || strings.TrimSpace(l.name) == "" { continue } if fam[l.key] == nil { fam[l.key] = map[string]bool{} } fam[l.key][surnameStem(l.name)] = true } for k, f := range fam { if len(f) >= 3 { stubs[k] = true } } sum.Totals.StubsSource = "по правилу: 3+ разных фамилии на одном телефоне" } if !o.excel { added := 0 for _, l := range lines { for _, k := range l.keys { if isPlaceholder(k) && !stubs[k] { stubs[k] = true added++ } } } if added > 0 { sum.Totals.StubsSource += " и номера из одинаковых цифр (111…, 000…)" } } sum.Totals.Stubs = len(stubs) // --- листы каналов: уникальные лиды без заглушек --- chanKeys := make([]map[string]bool, len(chans)) leads := make([]int, len(chans)) internetLeads := map[string]bool{} for ci, c := range chans { chanKeys[ci] = map[string]bool{} // номера — со строки 1: в выгрузке заголовка нет, а в шаблоне книги заголовок // «Номер (вставлять сюда)» телефоном не считается и пропускается сам for _, row := range sheets[c.sheet].Rows { if ctx.Err() != nil { return nil, nil, ctx.Err() } tick() raw := cell(row, 0) ks, bad := o.keys(raw) q := &sum.Quality switch { case bad: q.ChannelBad++ q.example(raw) case len(ks) > 0 && !o.excel && !isForeign(ks[0]) && excelPhoneKey(raw) != ks[0]: q.ChannelCleaned++ q.example(raw) } if len(ks) > 1 { q.ChannelMulti++ } if len(ks) > 0 && isForeign(ks[0]) { q.ChannelForeign++ } for _, k := range ks { if !chanKeys[ci][k] && !stubs[k] { leads[ci]++ if c.def.internet { internetLeads[k] = true } } chanKeys[ci][k] = true } } } // --- отметки у строк 1С (столбцы G…Y листа НОРМ_1С) --- seenPhone, seenFIO := map[string]bool{}, map[string]bool{} for i := range lines { l := &lines[i] l.stub = l.key != "" && stubs[l.key] if l.key != "" && !l.stub && !seenPhone[l.key] { l.firstPhone = true seenPhone[l.key] = true } // как LOWER(TRIM(…)) в Excel: TRIM схлопывает и пробелы внутри if f := strings.Join(strings.Fields(strings.ToLower(l.name)), " "); f != "" && !seenFIO[f] { l.firstFIO = true seenFIO[f] = true } l.in = make([]bool, len(chans)) if l.key == "" || l.stub { continue } for ci, c := range chans { found := false for _, k := range l.keys { if !stubs[k] && chanKeys[ci][k] { found = true break } } if found { l.in[ci] = true if !c.def.callCtr { l.ad = true } if c.def.internet { l.internet = true } } } } // --- показатели каналов (лист ОТЧЁТ) --- metric := func(name string, leadsN int, in func(l *oneCLine) bool) channelRow { cr := channelRow{Name: name, Leads: leadsN} for i := range lines { l := &lines[i] if !in(l) { continue } if l.firstPhone { cr.Patients++ } cr.Services += l.qty cr.Revenue += l.sum } if cr.Leads > 0 { cr.Conv = float64(cr.Patients) / float64(cr.Leads) * 100 } if cr.Patients > 0 { cr.PerPat = cr.Services / float64(cr.Patients) cr.AvgCheck = cr.Revenue / float64(cr.Patients) } return cr } for ci, c := range chans { ci := ci row := metric(c.def.title, leads[ci], func(l *oneCLine) bool { return l.in[ci] }) row.Internet = c.def.internet row.Unknown = c.def.order >= 100 sum.Channels = append(sum.Channels, row) } sum.Internet = metric("ИТОГО интернет (без дублей)", len(internetLeads), func(l *oneCLine) bool { return l.internet }) t := &sum.Totals callIdx := -1 for ci, c := range chans { if c.def.callCtr { callIdx = ci } } for i := range lines { l := &lines[i] sum.Control.Revenue += l.sum sum.Control.Services += l.qty called := callIdx >= 0 && l.in[callIdx] if l.firstPhone { t.UniquePhones++ if l.ad { t.AdPatients++ } if called && !l.ad { t.CallNoChannel++ } } if l.firstFIO { t.UniqueFIO++ } if called && !l.ad { t.CallNoChanRev += l.sum } if l.stub { t.StubsRevenue += l.sum } } t.Services, t.Revenue = sum.Control.Services, sum.Control.Revenue if t.UniqueFIO > 0 { t.AvgCheck = t.Revenue / float64(t.UniqueFIO) t.AdShare = float64(t.AdPatients) / float64(t.UniqueFIO) * 100 } if t.Services > 0 { t.AvgService = t.Revenue / t.Services } p.next() // → формирование tbl := channelsTable(name, &sum, chans, lines, stubs, stubsFromSheet) p.next() // → завершение return csvBytes(tbl), sum, nil } // channelsTable — таблица для Excel: как лист «ОТЧЁТ», затем строки 1С с отметками каналов. func channelsTable(name string, sum *channelsSummary, chans []chSheet, lines []oneCLine, stubs map[string]bool, stubsFromSheet bool) [][]string { num := func(v float64, prec int) string { return strings.ReplaceAll(strconv.FormatFloat(v, 'f', prec, 64), ".", ",") } f0 := func(v float64) string { return fmtRub(math.Round(v)) } t := &sum.Totals tbl := [][]string{{"Отчёт по каналам (сопоставление номеров) · " + name}, {}, {"КОНТРОЛЬ БАЗЫ"}, {"Выручка по строкам 1С", num(sum.Control.Revenue, 2)}} if sum.Control.HasTotal { tbl = append(tbl, []string{"Строка «Итого» в выгрузке 1С", num(sum.Control.RevenueTotal, 2)}, []string{"Расхождение (должно быть 0)", num(sum.Control.Revenue-sum.Control.RevenueTotal, 2)}, []string{"Услуг по строкам 1С", f0(sum.Control.Services)}, []string{"Услуг в «Итого» 1С", f0(sum.Control.ServicesTot)}) } else { tbl = append(tbl, []string{"Строки «Итого» в выгрузке нет — сверить не с чем"}) } if x := sum.Bitrix; x != nil { tbl = append(tbl, []string{}, []string{"Номера из Битрикс24 (лиды и контакты)", "записей: " + strconv.Itoa(x.Records), "с номером: " + strconv.Itoa(x.Phones), "не из DANA (пропущено): " + strconv.Itoa(x.Skipped)}) } if x := sum.Prodoctorov; x != nil { tbl = append(tbl, []string{}, []string{"ПроДокторов: онлайн-записи по дате создания", x.From + " — " + x.To, "записей: " + strconv.Itoa(x.Records), "уникальных номеров до исключения заглушек: " + strconv.Itoa(x.Phones)}) } if x := sum.PBX; x != nil { tbl = append(tbl, []string{}, []string{"Номера каналов из АТС МегаФон", x.From + " — " + x.To, "звонков: " + strconv.Itoa(x.Calls)}) if len(x.Unmapped) > 0 { tbl = append(tbl, append([]string{"Линии клиники без канала (их звонки в отчёт не входят)"}, x.Unmapped...)) } } if x := sum.SberHealth; x != nil { tbl = append(tbl, []string{}, []string{"СберЗдоровье: записи по дате создания", x.From + " — " + x.To, "записей в периоде: " + strconv.Itoa(x.Records), "уникальных номеров до исключения заглушек: " + strconv.Itoa(x.Phones), "строк в выгрузке (проверено): " + strconv.Itoa(x.ExportRecords)}) } tbl = append(tbl, []string{}, []string{"Канал", "Лиды", "Пациентов", "Конверсия, %", "Услуг", "Услуг/пациент", "Выручка, ₽", "Средний чек, ₽"}) rowOf := func(c channelRow) []string { return []string{c.Name, strconv.Itoa(c.Leads), strconv.Itoa(c.Patients), num(c.Conv, 1), f0(c.Services), num(c.PerPat, 2), f0(c.Revenue), f0(c.AvgCheck)} } for _, c := range sum.Channels { tbl = append(tbl, rowOf(c)) } tbl = append(tbl, rowOf(sum.Internet), []string{}, []string{"ВСЕ ПАЦИЕНТЫ"}, []string{"Уникальных обращений (телефонов)", strconv.Itoa(t.UniquePhones)}, []string{"Пациентов (уникальных ФИО)", strconv.Itoa(t.UniqueFIO)}, []string{"Услуг", f0(t.Services)}, []string{"Выручка, ₽", num(t.Revenue, 2)}, []string{"Средний чек, ₽", f0(t.AvgCheck)}, []string{"Средняя стоимость услуги, ₽", f0(t.AvgService)}, []string{"Пациентов из рекламных каналов", strconv.Itoa(t.AdPatients)}, []string{"Доля маркетинга, %", num(t.AdShare, 1)}, []string{"Позвонили, но без канала (пациентов)", strconv.Itoa(t.CallNoChannel)}, []string{" их выручка, ₽", f0(t.CallNoChanRev)}, []string{"Заглушек исключено (" + t.StubsSource + ")", strconv.Itoa(t.Stubs)}, []string{" выручка на заглушках, ₽", f0(t.StubsRevenue)}) if !stubsFromSheet && len(stubs) > 0 { tbl = append(tbl, []string{}, []string{"ЗАГЛУШКИ (найдены по правилу)"}) keys := make([]string, 0, len(stubs)) for k := range stubs { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { tbl = append(tbl, []string{k}) } } if q := sum.Quality; q.Phones1CCleaned+q.Phones1CBad+q.Phones1CMulti+q.Phones1CForeign+q.Placeholders+q.ChannelCleaned+q.ChannelBad+q.ChannelMulti+q.ChannelForeign+q.NumbersBad > 0 { tbl = append(tbl, []string{}, []string{"КАЧЕСТВО ДАННЫХ"}, []string{"Телефоны 1С: исправлен мусор выгрузки (¶, пробелы, текст рядом), номер учтён", strconv.Itoa(q.Phones1CCleaned)}, []string{"Телефоны 1С: несколько номеров в ячейке, учтены все", strconv.Itoa(q.Phones1CMulti)}, []string{"Телефоны 1С: иностранные номера (+33, +1…), учтены полностью", strconv.Itoa(q.Phones1CForeign)}, []string{"Телефоны 1С: номер-заглушка 111…/000… — исключён как заглушка", strconv.Itoa(q.Placeholders)}, []string{"Телефоны 1С: цифры есть, номер не распознан — строка без телефона", strconv.Itoa(q.Phones1CBad)}, []string{"Листы каналов: исправлен мусор, номер учтён", strconv.Itoa(q.ChannelCleaned)}, []string{"Листы каналов: несколько номеров в ячейке", strconv.Itoa(q.ChannelMulti)}, []string{"Листы каналов: иностранные номера", strconv.Itoa(q.ChannelForeign)}, []string{"Листы каналов: номер не распознан", strconv.Itoa(q.ChannelBad)}, []string{"«Количество» или «СуммаИтого» — не число (посчитано как 0)", strconv.Itoa(q.NumbersBad)}) if len(q.Examples) > 0 { tbl = append(tbl, append([]string{"Примеры (цифры заменены на 9)"}, q.Examples...)) } } tbl = append(tbl, []string{}, []string{"СТРОКИ 1С — в каких каналах найден телефон"}) head := []string{"Строка 1С", "Пациент", "Ключ (10 цифр)", "Количество", "СуммаИтого", "Заглушка", "Первый телефон", "Первое ФИО"} for _, c := range chans { head = append(head, "в: "+c.def.title) } tbl = append(tbl, append(head, "в интернете", "в любом канале")) b01 := func(b bool) string { if b { return "1" } return "0" } for _, l := range lines { r := []string{strconv.Itoa(l.src), l.name, l.key, num(l.qty, 0), num(l.sum, 2), b01(l.stub), b01(l.firstPhone), b01(l.firstFIO)} for _, v := range l.in { r = append(r, b01(v)) } tbl = append(tbl, append(r, b01(l.internet), b01(l.ad))) } return tbl } // mergePBXLists добавляет номера каналов из АТС к листам книги: к листу с тем же названием // или новым листом. Дубли номеров не страшны — лиды считаются по уникальным номерам. func mergePBXLists(sheets []Sheet, lists map[string][]string) []Sheet { keys := make([]string, 0, len(lists)) for k := range lists { keys = append(keys, k) } sort.Strings(keys) for _, key := range keys { rows := make([][]string, 0, len(lists[key])) for _, n := range lists[key] { rows = append(rows, []string{n}) } merged := false for i := range sheets { if sameChannel(sheets[i].Name, key) { sheets[i].Rows = append(sheets[i].Rows, rows...) merged = true break } } if !merged { sheets = append(sheets, Sheet{Name: key, Rows: rows}) } } return sheets } // sameChannel — лист книги и ключ канала АТС — один канал: «Инстаграмм» и «инстаграм», «Баннер» и «баннера». func sameChannel(sheetName, key string) bool { a := strings.ToLower(strings.TrimSpace(sheetName)) b := strings.ToLower(strings.TrimSpace(key)) if a == b { return true } da, okA := knownChannels[a] db, okB := knownChannels[b] return okA && okB && da.title == db.title }