759 lines
29 KiB
Go
759 lines
29 KiB
Go
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
|
||
}
|