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

759 lines
29 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"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
}