670 lines
22 KiB
Go
670 lines
22 KiB
Go
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
|
||
}
|