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

670 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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