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

282 lines
10 KiB
Go
Raw Permalink 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"
"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
}