package api import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "sort" "strings" "sync" "time" "unicode/utf8" "dana/internal/megafon" "dana/internal/storage" ) // Звонки из Виртуальной АТС МегаФон: загрузка истории в базу, привязка номеров клиники // к каналам рекламы и отчёт по звонкам. Номера каналов из АТС можно подставить // в «Отчёт по каналам» вместо ручной вставки номеров на листы книги. // WithPBX подключает АТС; без адреса или ключа раздел работает только с уже загруженными звонками. func (a *API) WithPBX(c *megafon.Client) { a.pbx = c } // pbxSyncMu — одна загрузка за раз в каждой АТС: фоновая и ручная не пересекаются. var pbxSyncMu = struct { sync.Mutex byClinic map[string]*sync.Mutex }{byClinic: map[string]*sync.Mutex{}} func pbxClinicLock(clinic string) *sync.Mutex { pbxSyncMu.Lock() defer pbxSyncMu.Unlock() m := pbxSyncMu.byClinic[clinic] if m == nil { m = &sync.Mutex{} pbxSyncMu.byClinic[clinic] = m } return m } // pbxEnvHint — какие строки .env задают АТС клиники. func pbxEnvHint(clinic string) string { if clinic == clinicDANA { return "MEGAFON_API_URL и MEGAFON_API_KEY" } p := strings.ToUpper(clinic) + "_MEGAFON_API_" return p + "URL и " + p + "KEY" } // syncPBX забирает звонки клиники за период по суткам (чтобы ответы АТС были небольшими) и сохраняет их. func (a *API) syncPBX(ctx context.Context, clinic string, from, to time.Time, manual bool) (int, error) { pbx := a.pbxFor(clinic) if !pbx.Configured() { return 0, errors.New("АТС не подключена: задайте " + pbxEnvHint(clinic)) } mu := pbxClinicLock(clinic) mu.Lock() defer mu.Unlock() logID, err := a.store.StartPBXSync(ctx, clinic, from, to, manual) if err != nil { return 0, err } total := 0 var syncErr error for day := from; day.Before(to) && syncErr == nil; day = day.Add(24 * time.Hour) { end := day.Add(24 * time.Hour) if end.After(to) { end = to } calls, err := pbx.History(ctx, day, end, "all") if err != nil { syncErr = err break } rows := make([]storage.PBXCall, 0, len(calls)) for _, c := range calls { rows = append(rows, storage.PBXCall{ UID: c.UID, Type: c.Type, Status: c.Status, Client: c.Client, ClientKey: phoneKey(c.Client), Via: c.Via, ViaKey: phoneKey(c.Via), UserLogin: c.User, UserName: c.UserName, StartedAt: c.Start, Wait: c.Wait, Duration: c.Duration, RecordURL: c.RecordURL, Raw: c.Raw, }) } if err := a.store.UpsertPBXCalls(ctx, clinic, rows); err != nil { syncErr = err break } total += len(rows) } errText := "" if syncErr != nil { errText = pbxErrText(syncErr) } if err := a.store.FinishPBXSync(context.Background(), logID, total, errText); err != nil { slog.Warn("pbx sync log", "err", err) } if syncErr != nil { return total, errors.New(errText) } return total, nil } func pbxErrText(err error) string { if errors.Is(err, megafon.ErrUnauthorized) { return "АТС не приняла ключ API: проверьте его в ВАТС → Настройки → Интеграция с CRM → Ваша CRM / REST API" } return err.Error() } // RunPBXSync — фоновая загрузка по всем подключённым АТС клиник. func (a *API) RunPBXSync(ctx context.Context) { for _, c := range clinicList { if a.pbxFor(c.Key).Configured() { go a.runClinicPBXSync(ctx, c.Key) } } } // runClinicPBXSync — сразу при старте и каждые 10 минут забирает последние двое суток, // раз в сутки ночью — последнюю неделю (звонки, дозаписанные АТС позже, тоже попадут). func (a *API) runClinicPBXSync(ctx context.Context, clinic string) { slog.Info("pbx sync enabled", "clinic", clinic, "domain", a.pbxFor(clinic).Domain()) run := func(days int) { to := time.Now().Add(time.Minute) n, err := a.syncPBX(ctx, clinic, to.Add(-time.Duration(days)*24*time.Hour), to, false) if err != nil { slog.Warn("pbx sync", "clinic", clinic, "err", err) return } slog.Debug("pbx sync", "clinic", clinic, "calls", n) } run(2) t := time.NewTicker(10 * time.Minute) defer t.Stop() lastWeek := time.Now() for { select { case <-ctx.Done(): return case <-t.C: if time.Since(lastWeek) > 24*time.Hour && time.Now().In(moscow).Hour() == 3 { run(7) lastWeek = time.Now() } else { run(2) } } } } // ---------- HTTP ---------- func (a *API) adminPBXStatus(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, valid := requestClinic(w, r) if !valid { return } st, err := a.store.PBXStatus(r.Context(), clinic.Key) if err != nil { handleErr(w, err) return } pbx := a.pbxFor(clinic.Key) ok(w, map[string]any{ "clinic": clinic.Key, "configured": pbx.Configured(), "domain": pbx.Domain(), "env": pbxEnvHint(clinic.Key), "status": st, }, nil) } // adminPBXCheck проверяет ключ: запрашивает у АТС список сотрудников. func (a *API) adminPBXCheck(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, valid := requestClinic(w, r) if !valid { return } pbx := a.pbxFor(clinic.Key) if !pbx.Configured() { fail(w, http.StatusConflict, "pbx_off", "АТС не подключена: задайте "+pbxEnvHint(clinic.Key)+" в .env сервера") return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() users, err := pbx.Users(ctx) if err != nil { fail(w, http.StatusBadGateway, "pbx_error", pbxErrText(err)) return } ok(w, map[string]any{"users": len(users)}, nil) } type pbxPeriodRequest struct { From string `json:"from"` To string `json:"to"` } // adminPBXSync — ручная загрузка за период (даты по Москве, включительно, не больше 93 дней). func (a *API) adminPBXSync(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, valid := clinicFrom(r.URL.Query().Get("clinic")) if !valid { fail(w, http.StatusBadRequest, "bad_request", "Неизвестная клиника") return } var req pbxPeriodRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") return } from, to, valid := statsRange(map[string][]string{"from": {req.From}, "to": {req.To}}, time.Now()) if !valid || to.Sub(from) > 93*24*time.Hour { fail(w, http.StatusUnprocessableEntity, "validation", "Период — даты ГГГГ-ММ-ДД, не длиннее трёх месяцев") return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() n, err := a.syncPBX(ctx, clinic.Key, from, to, true) a.store.Audit(r.Context(), u.ID, "sync", "pbx", int64(n), clientIP(r)) if err != nil { fail(w, http.StatusBadGateway, "pbx_error", fmt.Sprintf("Загружено звонков: %d. Ошибка: %v", n, err)) return } ok(w, map[string]any{"calls": n}, nil) } func (a *API) adminPBXLines(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, valid := requestClinic(w, r) if !valid { return } lines, err := a.store.PBXLines(r.Context(), clinic.Key) if err != nil { handleErr(w, err) return } ok(w, lines, map[string]any{"channels": pbxChannelChoices()}) } type pbxLineRequest struct { ViaKey string `json:"via_key"` Channel string `json:"channel"` } func (a *API) adminSetPBXLine(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, valid := clinicFrom(r.URL.Query().Get("clinic")) if !valid { fail(w, http.StatusBadRequest, "bad_request", "Неизвестная клиника") return } var req pbxLineRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") return } key := phoneKey(req.ViaKey) if key == "" { fail(w, http.StatusUnprocessableEntity, "validation", "Номер клиники не распознан") return } ch := strings.ToLower(strings.TrimSpace(req.Channel)) if !utf8.ValidString(ch) || strings.ContainsRune(ch, utf8.RuneError) { fail(w, http.StatusBadRequest, "bad_encoding", "Название канала пришло в неверной кодировке") return } if len([]rune(ch)) > 60 { fail(w, http.StatusUnprocessableEntity, "validation", "Название канала — до 60 символов") return } if err := a.store.SetPBXLine(r.Context(), clinic.Key, key, ch); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "update", "pbx_line", 0, clientIP(r)) ok(w, map[string]any{"via_key": key, "channel": ch}, nil) } // pbxChannelChoices — каналы для привязки номеров: те же, что листы книги «отчёт по каналам». func pbxChannelChoices() []map[string]string { seen := map[string]bool{} var out []map[string]string keys := make([]string, 0, len(knownChannels)) for k := range knownChannels { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { if oi, oj := knownChannels[keys[i]].order, knownChannels[keys[j]].order; oi != oj { return oi < oj } return keys[i] < keys[j] }) for _, k := range keys { d := knownChannels[k] if seen[d.title] { continue } seen[d.title] = true out = append(out, map[string]string{"key": k, "title": d.title}) } return out } // channelTitle — название канала для отчёта по ключу привязки. func channelTitle(key string) string { if d, ok := knownChannels[key]; ok { return d.title } if key == "" { return "" } r := []rune(key) return strings.ToUpper(string(r[:1])) + string(r[1:]) } // ---------- отчёт по звонкам ---------- type pbxLineStat struct { Channel string `json:"channel"` // название канала Incoming int `json:"incoming"` // входящих звонков, включая пропущенные Clients int `json:"clients"` // разных номеров клиентов Answered int `json:"answered"` // принятых Missed int `json:"missed"` // пропущенных звонков MissedLost int `json:"missed_lost"` // клиентов, которым так и не дозвонились и которые не дозвонились сами AvgWait float64 `json:"avg_wait"` // среднее ожидание принятого звонка, сек TalkSeconds int `json:"talk_seconds"` // сумма входящих разговоров AnsweredRate float64 `json:"answered_rate"` // доля принятых, % Outgoing int `json:"outgoing"` // исходящих с линии OutTalk int `json:"out_talk"` // сумма исходящих разговоров } type pbxUserStat struct { Name string `json:"name"` Answered int `json:"answered"` Outgoing int `json:"outgoing"` TalkSeconds int `json:"talk_seconds"` } type pbxReport struct { From string `json:"from"` To string `json:"to"` Total pbxLineStat `json:"total"` Outgoing int `json:"outgoing"` Channels []pbxLineStat `json:"channels"` Users []pbxUserStat `json:"users"` Lost []pbxLostCall `json:"lost"` // пропущенные без перезвона — кому перезвонить Series pbxSeries `json:"series"` } // pbxSeries — те же показатели по дням периода (по часам, если период не длиннее недели): // мини-графики в карточках отчёта. type pbxSeries struct { Step string `json:"step"` // "day" или "hour" Labels []string `json:"labels"` All []float64 `json:"all"` Incoming []float64 `json:"incoming"` Answered []float64 `json:"answered_rate"` // %, пусто — нет входящих Missed []float64 `json:"missed"` Lost []float64 `json:"lost"` Outgoing []float64 `json:"outgoing"` } func buildPBXSeries(rep *pbxReport, calls []storage.PBXCallRow, from, to time.Time) pbxSeries { step, fmtL := 24*time.Hour, "2006-01-02" if to.Sub(from) <= 7*24*time.Hour { step, fmtL = time.Hour, "2006-01-02 15:00" } n := int(to.Sub(from) / step) if n < 1 { n = 1 } s := pbxSeries{Step: map[bool]string{true: "hour", false: "day"}[step == time.Hour]} for _, p := range []*[]float64{&s.All, &s.Incoming, &s.Answered, &s.Missed, &s.Lost, &s.Outgoing} { *p = make([]float64, n) } answered := make([]float64, n) idx := func(t time.Time) int { t = t.In(from.Location()) var i int if step == time.Hour { i = int(t.Sub(from) / time.Hour) } else { d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, from.Location()) i = int(d.Sub(from).Hours()/24 + 0.5) } if i < 0 || i >= n { return -1 } return i } for i := 0; i < n; i++ { s.Labels = append(s.Labels, from.Add(time.Duration(i)*step).Format(fmtL)) } for _, c := range calls { i := idx(c.StartedAt) if c.Channel == "" || i < 0 { continue } s.All[i]++ switch { case isOutgoing(c): s.Outgoing[i]++ case isMissed(c): s.Incoming[i]++ s.Missed[i]++ default: s.Incoming[i]++ answered[i]++ } } for _, l := range rep.Lost { if i := idx(l.LastCall); i >= 0 { s.Lost[i]++ } } for i := range answered { if s.Incoming[i] > 0 { s.Answered[i] = answered[i] / s.Incoming[i] * 100 } else if i > 0 { s.Answered[i] = s.Answered[i-1] // нет звонков — линия графика не падает в ноль } } return s } type pbxLostCall struct { Client string `json:"client"` Channel string `json:"channel"` LastCall time.Time `json:"last_call"` Attempts int `json:"attempts"` UserName string `json:"user_name"` RecordURL string `json:"-"` } func isOutgoing(c storage.PBXCallRow) bool { return c.Type == "out" || c.Type == "outgoing" } func isMissed(c storage.PBXCallRow) bool { if isOutgoing(c) { return false } return c.Type == "missed" || c.Status == "missed" || c.Status == "noanswer" || (c.Status != "success" && c.Duration == 0) } // buildPBXReport считает отчёт по звонкам периода. В отчёт входят только линии с каналом: // звонки линий без канала не считаются, но разговор на них снимает «потерянного» клиента. // «Потерянный» клиент — пропущенный входящий, после которого за период не было ни принятого // входящего, ни исходящего с разговором. func buildPBXReport(calls []storage.PBXCallRow) pbxReport { var rep pbxReport type acc struct { st pbxLineStat clients map[string]bool waitSum int } lines := map[string]*acc{} users := map[string]*pbxUserStat{} lastMiss := map[string]*pbxLostCall{} // по клиенту reached := map[string]time.Time{} // последний разговор с клиентом total := &acc{clients: map[string]bool{}} lineOf := func(c storage.PBXCallRow) *acc { name := channelTitle(c.Channel) if c.Channel == "все номера" { name = "Основной номер" // здесь это одна линия, а «все номера» — сумма в строке «Итого» } l := lines[name] if l == nil { l = &acc{st: pbxLineStat{Channel: name}, clients: map[string]bool{}} lines[name] = l } return l } for _, c := range calls { key := c.ClientKey if key == "" { key = c.Client } if isOutgoing(c) { if c.Duration > 0 { reached[key] = c.StartedAt // перезвонили с любой линии — клиент не потерян } if c.Channel == "" { continue } rep.Outgoing++ for _, l := range []*acc{lineOf(c), total} { l.st.Outgoing++ l.st.OutTalk += c.Duration } if c.UserName != "" { u := users[c.UserName] if u == nil { u = &pbxUserStat{Name: c.UserName} users[c.UserName] = u } u.Outgoing++ u.TalkSeconds += c.Duration } continue } if c.Channel == "" { // линия без канала в отчёт не входит if !isMissed(c) { reached[key] = c.StartedAt } continue } for _, l := range []*acc{lineOf(c), total} { l.st.Incoming++ l.clients[key] = true if isMissed(c) { l.st.Missed++ } else { l.st.Answered++ l.waitSum += c.Wait l.st.TalkSeconds += c.Duration } } if isMissed(c) { m := lastMiss[key] if m == nil { m = &pbxLostCall{Client: c.Client} lastMiss[key] = m } m.Attempts++ m.LastCall, m.Channel = c.StartedAt, lineOf(c).st.Channel continue } reached[key] = c.StartedAt if c.UserName != "" { u := users[c.UserName] if u == nil { u = &pbxUserStat{Name: c.UserName} users[c.UserName] = u } u.Answered++ u.TalkSeconds += c.Duration } } lostBy := map[string]int{} for key, m := range lastMiss { if t, ok := reached[key]; ok && !t.Before(m.LastCall) { continue // дозвонились после пропущенного } rep.Lost = append(rep.Lost, *m) lostBy[m.Channel]++ } sort.Slice(rep.Lost, func(i, j int) bool { return rep.Lost[i].LastCall.After(rep.Lost[j].LastCall) }) finish := func(l *acc) pbxLineStat { l.st.Clients = len(l.clients) if l.st.Answered > 0 { l.st.AvgWait = float64(l.waitSum) / float64(l.st.Answered) } if l.st.Incoming > 0 { l.st.AnsweredRate = float64(l.st.Answered) / float64(l.st.Incoming) * 100 } return l.st } for name, l := range lines { l.st.MissedLost = lostBy[name] rep.Channels = append(rep.Channels, finish(l)) } sort.Slice(rep.Channels, func(i, j int) bool { return rep.Channels[i].Incoming > rep.Channels[j].Incoming }) total.st.Channel = "Все входящие" total.st.MissedLost = len(rep.Lost) rep.Total = finish(total) for _, u := range users { rep.Users = append(rep.Users, *u) } sort.Slice(rep.Users, func(i, j int) bool { return rep.Users[i].Answered+rep.Users[i].Outgoing > rep.Users[j].Answered+rep.Users[j].Outgoing }) return rep } func (a *API) adminPBXReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, known := requestClinic(w, r) if !known { return } from, to, valid := statsRange(r.URL.Query(), time.Now()) if !valid { fail(w, http.StatusBadRequest, "bad_request", "Некорректный период: даты ГГГГ-ММ-ДД, не длиннее двух лет") return } // until=ГГГГ-ММ-ДДTЧЧ:ММ — звонки только до этого момента: прошлый период для сравнения // с периодом, который включает сегодняшний, ещё не закончившийся день end := to if v := r.URL.Query().Get("until"); v != "" { t, err := time.ParseInLocation("2006-01-02T15:04", v, moscow) if err != nil || !t.After(from) || t.After(to) { fail(w, http.StatusBadRequest, "bad_request", "Некорректное время until: ГГГГ-ММ-ДДTЧЧ:ММ внутри периода") return } end = t } calls, err := a.store.PBXCalls(r.Context(), clinic.Key, from, end) if err != nil { handleErr(w, err) return } rep := buildPBXReport(calls) rep.Series = buildPBXSeries(&rep, calls, from, to) rep.From, rep.To = from.Format("2006-01-02"), to.AddDate(0, 0, -1).Format("2006-01-02") if rep.Channels == nil { rep.Channels = []pbxLineStat{} } if rep.Users == nil { rep.Users = []pbxUserStat{} } if rep.Lost == nil { rep.Lost = []pbxLostCall{} } a.store.Audit(r.Context(), u.ID, "view", "pbx_report", 0, clientIP(r)) ok(w, rep, nil) } // pbxChannelNumbers — номера клиентов по каналам за период для «Отчёта по каналам»: // ключ — лист книги («все номера», «яндекс», «2гис»…), значения — номера входящих звонков. // «все номера» (колл-центр) — входящие всех линий с каналом. Линии без канала в отчёт не входят, // их номера — в unmapped. func pbxChannelNumbers(calls []storage.PBXCallRow) (lists map[string][]string, unmapped []string) { lists = map[string][]string{} seenLine := map[string]bool{} for _, c := range calls { if isOutgoing(c) || c.Client == "" { continue } if c.Channel == "" { if c.Via != "" && !seenLine[c.ViaKey] { seenLine[c.ViaKey] = true unmapped = append(unmapped, c.Via) } continue } lists["все номера"] = append(lists["все номера"], c.Client) if c.Channel != "все номера" { lists[c.Channel] = append(lists[c.Channel], c.Client) } } return lists, unmapped } type pbxPeriod struct{ from, to time.Time } // pbxMatchOpts — номера каналов из АТС для «Отчёта по каналам». Перед расчётом период // догружается из АТС (если она подключена), чтобы в отчёт попали свежие звонки. func (a *API) pbxMatchOpts(ctx context.Context, clinic string, per *pbxPeriod) (matchOpts, error) { if a.pbxFor(clinic).Configured() { if _, err := a.syncPBX(ctx, clinic, per.from, per.to, false); err != nil { return matchOpts{}, fmt.Errorf("Не удалось загрузить звонки из АТС: %v", err) } } calls, err := a.store.PBXCalls(ctx, clinic, per.from, per.to) if err != nil { return matchOpts{}, err } if len(calls) == 0 { return matchOpts{}, errors.New("За выбранный период в базе нет звонков АТС — загрузите их в разделе «Звонки АТС»") } lists, unmapped := pbxChannelNumbers(calls) if unmapped == nil { unmapped = []string{} } return matchOpts{pbxLists: lists, pbxInfo: &pbxSourceInfo{ From: per.from.Format("2006-01-02"), To: per.to.AddDate(0, 0, -1).Format("2006-01-02"), Calls: len(calls), Unmapped: unmapped, }}, nil }