282 lines
10 KiB
Go
282 lines
10 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"path"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"dana/internal/storage"
|
||
)
|
||
|
||
// Еженедельный/ежемесячный дашборд по каналам: отдельной загрузки файлов у него нет —
|
||
// источник один, это обычный «Отчёт по каналам» (вкладка «Новый отчёт»). Если в загруженной
|
||
// туда выгрузке 1С есть текст «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ», посчитанный результат
|
||
// автоматически добавляется ещё и сюда, одной строкой истории. Эта страница — только
|
||
// просмотр накопленных периодов и правка бюджета по каналам.
|
||
|
||
// Dates in 1C are inclusive. Internally all integrations use [from, to).
|
||
var periodRE = regexp.MustCompile(`(?i)период\s*:?\s*(?:с\s+)?(\d{1,2}\.\d{1,2}\.\d{4})\s*(?:-|–|—|по)\s*(\d{1,2}\.\d{1,2}\.\d{4})`)
|
||
|
||
type reportFilePeriod struct {
|
||
From string `json:"from"`
|
||
To string `json:"to"` // inclusive, for the user
|
||
Label string `json:"label"`
|
||
Mode string `json:"mode"`
|
||
}
|
||
|
||
type reportDashboard struct {
|
||
reportFilePeriod
|
||
ID int64 `json:"id,omitempty"`
|
||
Warning string `json:"warning,omitempty"`
|
||
}
|
||
|
||
func describeReportPeriod(from, to time.Time) reportFilePeriod {
|
||
mode := "weeks"
|
||
if from.Day() == 1 && to.Equal(time.Date(from.Year(), from.Month()+1, 1, 0, 0, 0, 0, moscow)) {
|
||
mode = "months"
|
||
}
|
||
last := to.AddDate(0, 0, -1)
|
||
return reportFilePeriod{From: from.Format("2006-01-02"), To: last.Format("2006-01-02"),
|
||
Label: from.Format("02.01.2006") + "–" + last.Format("02.01.2006"), Mode: mode}
|
||
}
|
||
|
||
func parseReportPeriod(text string) (from, to time.Time, label string, ok bool) {
|
||
m := periodRE.FindStringSubmatch(strings.Join(strings.Fields(text), " "))
|
||
if m == nil {
|
||
return
|
||
}
|
||
f, err1 := time.ParseInLocation("2.1.2006", m[1], moscow)
|
||
t, err2 := time.ParseInLocation("2.1.2006", m[2], moscow)
|
||
if err1 != nil || err2 != nil || t.Before(f) {
|
||
return
|
||
}
|
||
end := t.AddDate(0, 0, 1)
|
||
if end.Sub(f) > 93*24*time.Hour {
|
||
return
|
||
}
|
||
return f, end, f.Format("02.01") + "–" + t.Format("02.01"), true
|
||
}
|
||
|
||
// Row 2 of the actual 1C sheet is authoritative, not the filename, channel sheets
|
||
// or the report generation timestamp. Older exports may put it above the headers elsewhere.
|
||
func detectPeriodFromFile(name string, data []byte) (from, to time.Time, label string, ok bool) {
|
||
sheets, err := readWorkbook(name, data)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, s := range sheets {
|
||
if serviceSheets[strings.ToLower(strings.TrimSpace(s.Name))] {
|
||
continue
|
||
}
|
||
cols, is1C := find1CColumns(s.Rows)
|
||
if !is1C {
|
||
continue
|
||
}
|
||
if cols.header > 1 {
|
||
text := strings.Join(s.Rows[1], " ")
|
||
if strings.Contains(strings.ToLower(text), "период") {
|
||
return parseReportPeriod(text)
|
||
}
|
||
}
|
||
for i := 0; i < cols.header; i++ {
|
||
text := strings.Join(s.Rows[i], " ")
|
||
if strings.Contains(strings.ToLower(text), "период") {
|
||
return parseReportPeriod(text)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
return
|
||
}
|
||
|
||
// saveWeeklyFromSummary — лучшая попытка: если в файле нашёлся период, кладёт уже посчитанную
|
||
// сводку «Отчёта по каналам» ещё и в историю дашборда. Ошибки сюда не прерывают основной отчёт,
|
||
// только пишутся в лог — для admin ничего не меняется, кроме того, что период не появится в дашборде.
|
||
func (a *API) saveWeeklyFromSummary(ctx context.Context, clinic, name string, data []byte, sum channelsSummary, adminID int64) *reportDashboard {
|
||
from, to, label, found := detectPeriodFromFile(name, data)
|
||
if !found {
|
||
return nil
|
||
}
|
||
result := &reportDashboard{reportFilePeriod: describeReportPeriod(from, to)}
|
||
cc, err := a.weeklyCallCenter(ctx, clinic, from, to, sum)
|
||
if err != nil {
|
||
slog.Warn("weekly dashboard: колл-ц", "err", err)
|
||
}
|
||
if id, err := a.store.UpsertWeeklyReport(ctx, clinic, from, to, label, name, cc, weeklyChannelsFromSummary(sum), adminID); err != nil {
|
||
slog.Warn("weekly dashboard: сохранить период", "err", err)
|
||
result.Warning = "Расчёт готов, но период не сохранился в дашборде. Повторите загрузку файла."
|
||
} else {
|
||
result.ID = id
|
||
}
|
||
return result
|
||
}
|
||
|
||
// Read-only upload preview: no CRM/PBX requests and no database writes.
|
||
func (a *API) adminReportPeriod(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxReportFile+(1<<20))
|
||
if err := r.ParseMultipartForm(maxReportFile); err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_request", "Файл больше 10 МБ или запрос повреждён")
|
||
return
|
||
}
|
||
if r.MultipartForm != nil {
|
||
defer r.MultipartForm.RemoveAll()
|
||
}
|
||
f, header, err := r.FormFile("file")
|
||
if err != nil {
|
||
fail(w, http.StatusUnprocessableEntity, "validation", "Выберите выгрузку 1С")
|
||
return
|
||
}
|
||
defer f.Close()
|
||
data, err := io.ReadAll(f)
|
||
if err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_request", "Файл не дочитался")
|
||
return
|
||
}
|
||
name := path.Base(strings.ReplaceAll(header.Filename, "\\", "/"))
|
||
from, to, _, found := detectPeriodFromFile(name, data)
|
||
if !found {
|
||
fail(w, http.StatusUnprocessableEntity, "validation", "Не найден корректный период в строке 2 листа 1С: Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ")
|
||
return
|
||
}
|
||
ok(w, describeReportPeriod(from, to), nil)
|
||
}
|
||
|
||
func weeklyChannelsFromSummary(sum channelsSummary) []storage.WeeklyChannel {
|
||
channels := make([]storage.WeeklyChannel, 0, len(sum.Channels)+1)
|
||
for _, c := range sum.Channels {
|
||
channels = append(channels, storage.WeeklyChannel{
|
||
Key: weeklyChannelKey(c.Name), Title: c.Name,
|
||
Leads: c.Leads, Patients: c.Patients, Services: c.Services, PerPat: c.PerPat,
|
||
Revenue: c.Revenue, AvgCheck: c.AvgCheck,
|
||
})
|
||
}
|
||
channels = append(channels, storage.WeeklyChannel{
|
||
Key: "итого-интернет", Title: sum.Internet.Name,
|
||
Leads: sum.Internet.Leads, Patients: sum.Internet.Patients, Services: sum.Internet.Services,
|
||
PerPat: sum.Internet.PerPat, Revenue: sum.Internet.Revenue, AvgCheck: sum.Internet.AvgCheck,
|
||
})
|
||
return channels
|
||
}
|
||
|
||
// weeklyChannelKey — заголовок канала (как в книге) обратно в ключ knownChannels, для стабильной
|
||
// привязки бюджета к каналу между неделями. Неизвестные каналы используют сам заголовок в нижнем регистре.
|
||
func weeklyChannelKey(title string) string {
|
||
for k, d := range knownChannels {
|
||
if d.title == title {
|
||
return k
|
||
}
|
||
}
|
||
return strings.ToLower(strings.TrimSpace(title))
|
||
}
|
||
|
||
func (a *API) adminWeeklyReports(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
clinic, valid := requestClinic(w, r)
|
||
if !valid {
|
||
return
|
||
}
|
||
list, err := a.store.WeeklyReports(r.Context(), clinic.Key)
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, list, nil)
|
||
}
|
||
|
||
func (a *API) adminDeleteWeeklyReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||
if err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
|
||
return
|
||
}
|
||
if err := a.store.DeleteWeeklyReport(r.Context(), id); err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
a.store.Audit(r.Context(), u.ID, "delete", "weekly_report", id, clientIP(r))
|
||
ok(w, map[string]any{"id": id}, nil)
|
||
}
|
||
|
||
func (a *API) adminSetWeeklyBudget(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||
if err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
|
||
return
|
||
}
|
||
var body struct {
|
||
Channel string `json:"channel"`
|
||
Amount float64 `json:"amount"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Channel == "" || body.Amount < 0 {
|
||
fail(w, http.StatusUnprocessableEntity, "validation", "Нужны channel и неотрицательный amount")
|
||
return
|
||
}
|
||
if err := a.store.SetWeeklyBudget(r.Context(), id, body.Channel, body.Amount); err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
a.store.Audit(r.Context(), u.ID, "update", "weekly_report_budget", id, clientIP(r))
|
||
ok(w, map[string]any{"id": id}, nil)
|
||
}
|
||
|
||
// weeklyCallCenterSales — пациенты/услуги/выручка блока «Колл-ц»: совпадения номеров колл-центра
|
||
// («Колл-центр (все номера)») с 1С, как в таблице «Показатели», а не вся выгрузка 1С за период.
|
||
func weeklyCallCenterSales(sum channelsSummary) storage.WeeklyCallCenter {
|
||
title := knownChannels["все номера"].title
|
||
for _, c := range sum.Channels {
|
||
if c.Name == title {
|
||
return storage.WeeklyCallCenter{Patients: c.Patients, Services: c.Services, Revenue: c.Revenue, AvgCheck: c.AvgCheck}
|
||
}
|
||
}
|
||
return storage.WeeklyCallCenter{}
|
||
}
|
||
|
||
// weeklyCallCenter — блок «Колл-ц»: весь колл-центр за период, без привязки к рекламному каналу.
|
||
// Пациенты/услуги/выручка — совпадения колл-центра из уже посчитанной сводки отчёта, звонки и время — из АТС.
|
||
func (a *API) weeklyCallCenter(ctx context.Context, clinic string, from, to time.Time, sum channelsSummary) (storage.WeeklyCallCenter, error) {
|
||
cc := weeklyCallCenterSales(sum)
|
||
if !a.pbxFor(clinic).Configured() {
|
||
return cc, nil
|
||
}
|
||
calls, err := a.store.PBXCalls(ctx, clinic, from, to)
|
||
if err != nil {
|
||
return cc, err
|
||
}
|
||
uniq := map[string]bool{}
|
||
var talkSum, waitSum, answered int
|
||
for _, c := range calls {
|
||
cc.Total++
|
||
switch {
|
||
case isOutgoing(c):
|
||
cc.Outgoing++
|
||
default:
|
||
cc.Incoming++
|
||
if c.Client != "" {
|
||
uniq[c.Client] = true
|
||
}
|
||
if isMissed(c) {
|
||
cc.Missed++
|
||
} else {
|
||
answered++
|
||
talkSum += c.Duration
|
||
waitSum += c.Wait
|
||
}
|
||
}
|
||
}
|
||
cc.Unique = len(uniq)
|
||
if cc.Incoming > 0 {
|
||
cc.MissedPct = float64(cc.Missed) / float64(cc.Incoming) * 100
|
||
}
|
||
if answered > 0 {
|
||
cc.TalkSec = talkSum / answered
|
||
cc.WaitSec = waitSum / answered
|
||
}
|
||
return cc, nil
|
||
}
|