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 }