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

437 lines
15 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"bytes"
"context"
"encoding/csv"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"sync"
"time"
"dana/internal/storage"
)
const maxReportFile = 10 << 20 // 10 МБ
// Запрос на отчёт: книга 1С и необязательные файлы СберЗдоровья и ПроДокторов (до 10 МБ каждый).
const maxReportRequest = maxReportFile + (1+maxProdoctorovFiles)*maxPartnerFile + (1 << 20)
// Виды отчётов. Файл нужен для отчёта по каналам — это книга Excel с выгрузкой 1С и листами каналов.
var reportKinds = map[string]struct {
title string
needFile bool
}{
"phone_match": {"Отчёт по каналам", true},
}
// Этапы задания — как на странице «Отчёты».
const (
stageUpload = iota
stageCheck
stageProcess
stageBuild
stageFinish
stagesTotal
)
// reportJobs — отмена идущих заданий. Задания короткие (секунды), поэтому живут в памяти процесса;
// после перезапуска сервера незаконченные помечаются как прерванные.
type reportJobs struct {
mu sync.Mutex
cancels map[int64]context.CancelFunc
}
var jobs = &reportJobs{cancels: map[int64]context.CancelFunc{}}
func (j *reportJobs) add(id int64, c context.CancelFunc) {
j.mu.Lock()
j.cancels[id] = c
j.mu.Unlock()
}
func (j *reportJobs) done(id int64) { j.mu.Lock(); delete(j.cancels, id); j.mu.Unlock() }
func (j *reportJobs) cancel(id int64) {
j.mu.Lock()
c := j.cancels[id]
j.mu.Unlock()
if c != nil {
c()
}
}
// FailInterruptedReports вызывается при старте сервера.
func (a *API) FailInterruptedReports(ctx context.Context) error {
return a.store.FailInterruptedReports(ctx)
}
func (a *API) adminReports(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
clinic, valid := requestClinic(w, r)
if !valid {
return
}
list, err := a.store.Reports(r.Context(), clinic.Key, 50)
if err != nil {
handleErr(w, err)
return
}
ok(w, list, nil)
}
func (a *API) adminReport(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
}
rep, err := a.store.Report(r.Context(), id)
if err != nil {
handleErr(w, err)
return
}
ok(w, rep, nil)
}
// adminCreateReport принимает вид отчёта и книгу Excel, заводит задание
// и считает его в фоне; страница опрашивает ход через GET /admin/reports/{id}.
func (a *API) adminCreateReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
started := time.Now()
r.Body = http.MaxBytesReader(w, r.Body, maxReportRequest)
if err := r.ParseMultipartForm(maxReportFile); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Файл больше 10 МБ или запрос повреждён")
return
}
clinic, knownClinic := clinicFrom(r.FormValue("clinic"))
if !knownClinic {
fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестная клиника")
return
}
kind := r.FormValue("kind")
meta, known := reportKinds[kind]
if !known {
fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестный вид отчёта")
return
}
var data []byte
var name string
if meta.needFile {
f, hdr, err := r.FormFile("file")
if err != nil {
fail(w, http.StatusUnprocessableEntity, "validation", "Загрузите книгу Excel: лист выгрузки 1С и листы каналов с номерами")
return
}
defer f.Close()
name = path.Base(strings.ReplaceAll(hdr.Filename, "\\", "/"))
if ext := strings.ToLower(path.Ext(name)); ext != ".xlsx" && ext != ".csv" {
fail(w, http.StatusUnprocessableEntity, "validation", "Нужен файл .xlsx или .csv")
return
}
if data, err = io.ReadAll(f); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Файл не дочитался — загрузите ещё раз")
return
}
}
uploads, err := readPartnerUploads(r)
if err != nil {
fail(w, http.StatusUnprocessableEntity, "validation", err.Error())
return
}
// Даты из 1С — источник истины для всех каналов. Ручной диапазон остаётся
// запасным вариантом только для старых файлов без строки периода.
source := r.FormValue("source")
pbxWanted := source == "all" || source == "pbx"
bitrixWanted := source == "all" || source == "bitrix" || r.FormValue("bitrix") == "1"
// автовыгрузка из кабинетов площадок настроена только на кабинеты DANA
prodoctorovWanted := source == "all" && clinic.partnerCabinets && a.prodoctorov.Configured()
sberhealthWanted := source == "all" && clinic.partnerCabinets && a.sberhealth.Configured()
var period *pbxPeriod
if f, t := r.FormValue("pbx_from"), r.FormValue("pbx_to"); f != "" || t != "" {
pbxWanted = true
}
if pbxWanted || bitrixWanted || uploads.any() {
f, t := r.FormValue("pbx_from"), r.FormValue("pbx_to")
from, to, _, ok := detectPeriodFromFile(name, data)
if !ok && (f != "" || t != "") {
from, to, ok = statsRange(map[string][]string{"from": {f}, "to": {t}}, time.Now())
if !ok || to.Sub(from) > 93*24*time.Hour {
fail(w, http.StatusUnprocessableEntity, "validation", "Период — даты ГГГГ-ММ-ДД, не длиннее трёх месяцев")
return
}
}
if !ok {
fail(w, http.StatusUnprocessableEntity, "validation",
"Не нашёл период в файле: в строке 2 должна быть запись «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ»")
return
}
period = &pbxPeriod{from: from, to: to}
}
id, err := a.store.CreateReport(r.Context(), clinic.Key, kind, meta.title, name, int64(len(data)), u.ID)
if err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "create", "report", id, clientIP(r))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
jobs.add(id, cancel)
uploadMS := time.Since(started).Milliseconds()
go func() {
defer jobs.done(id)
defer cancel()
a.runReport(ctx, id, clinic, kind, name, data, uploadMS, period, pbxWanted, bitrixWanted, prodoctorovWanted, sberhealthWanted, uploads, u.ID)
}()
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
}
func (a *API) adminCancelReport(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
}
changed, err := a.store.CancelReport(r.Context(), id)
if err != nil {
handleErr(w, err)
return
}
jobs.cancel(id)
if !changed {
fail(w, http.StatusConflict, "not_running", "Отчёт уже завершён")
return
}
a.store.Audit(r.Context(), u.ID, "cancel", "report", id, clientIP(r))
ok(w, map[string]any{"id": id}, nil)
}
func (a *API) adminDeleteReport(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.DeleteReport(r.Context(), id); err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "delete", "report", id, clientIP(r))
ok(w, map[string]any{"id": id}, nil)
}
func (a *API) adminDownloadReport(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
}
name, data, err := a.store.ReportResult(r.Context(), id)
if err != nil {
handleErr(w, err)
return
}
// в файле отчёта — телефоны и имена пациентов: каждое скачивание фиксируем в аудите
a.store.Audit(r.Context(), u.ID, "download", "report", id, clientIP(r))
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="report-%d.csv"; filename*=UTF-8''%s`, id, url.PathEscape(name)))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}
// ---------- расчёт ----------
// progressTracker пишет этапы в базу: длительность каждого завершённого этапа и общий процент.
type progressTracker struct {
a *API
ctx context.Context
id int64
stage int
start time.Time
stages []int64
last int
// bounds — где начинается каждый этап на шкале 0–100: доли по реальному объёму работы.
// По умолчанию этапы равные.
bounds []int
}
func (p *progressTracker) set(stage, inStage int) {
b := p.bounds
if len(b) != stagesTotal+1 {
b = []int{0, 20, 40, 60, 80, 100}
}
pct := b[stage] + (b[stage+1]-b[stage])*inStage/100
if pct < p.last {
pct = p.last // процент не должен идти назад — выгрузка Битрикс24/АТС до этапа «Проверка
// данных» уже могла продвинуть его дальше, чем начало этого этапа
}
if stage == p.stage && pct-p.last < 1 && inStage != 0 {
return
}
p.last = pct
if p.a == nil || p.a.store == nil { // в тестах — без базы
return
}
if err := p.a.store.ReportProgress(context.Background(), p.id, stage, pct, p.stages); err != nil {
slog.Warn("report progress", "id", p.id, "err", err)
}
}
// setExact — процент внутри этапа по обработанным строкам: done из total.
func (p *progressTracker) setExact(stage, done, total int) {
if total <= 0 {
return
}
in := done * 100 / total
if in > 99 {
in = 99
}
p.set(stage, in)
}
// next закрывает текущий этап и начинает следующий.
func (p *progressTracker) next() {
p.stages = append(p.stages, time.Since(p.start).Milliseconds())
p.start = time.Now()
p.stage++
p.set(p.stage, 0)
}
func (a *API) runReport(ctx context.Context, id int64, clinic *clinicDef, kind, name string, data []byte, uploadMS int64, period *pbxPeriod, pbxWanted, bitrixWanted, prodoctorovWanted, sberhealthWanted bool, uploads partnerUploads, adminID int64) {
p := &progressTracker{a: a, ctx: ctx, id: id, stage: stageCheck, start: time.Now(), stages: []int64{uploadMS}}
p.set(stageCheck, 0)
var (
result []byte
summary any
err error
)
switch kind {
case "phone_match":
o := matchOpts{}
if pbxWanted && period != nil {
var po matchOpts
if po, err = a.pbxMatchOpts(ctx, clinic.Key, period); err == nil {
o.pbxLists = mergeChannelLists(o.pbxLists, po.pbxLists)
o.pbxInfo = po.pbxInfo
}
}
if err == nil && bitrixWanted && period != nil {
var bo matchOpts
onProgress := func(phase string, done, total int) {
if total <= 0 {
return
}
base := 0
if phase == "contacts" {
base = 50
}
p.set(stageCheck, base+done*50/total)
}
if bo, err = a.bitrixMatchOpts(ctx, clinic, period.from, period.to, onProgress); err == nil {
o.pbxLists = mergeChannelLists(o.pbxLists, bo.pbxLists)
o.bitrixInfo = bo.bitrixInfo
}
}
if err == nil && (prodoctorovWanted || len(uploads.prodoctorov) > 0) {
if period == nil {
err = errors.New("Для ПроДокторов нужен период выгрузки 1С")
} else {
// файлы, приложенные пользователем, главнее скачивания с сервера (его блокирует защита кабинета)
var po matchOpts
if len(uploads.prodoctorov) > 0 {
po, err = prodoctorovUploadOptsFor(clinic, uploads.prodoctorov, period.from, period.to)
} else {
po, err = a.prodoctorovMatchOpts(ctx, period.from, period.to)
}
if err == nil {
o.pbxLists = mergeChannelLists(o.pbxLists, po.pbxLists)
o.prodoctorovInfo = po.prodoctorovInfo
}
}
}
if err == nil && (sberhealthWanted || len(uploads.sber) > 0) {
if period == nil {
err = errors.New("Для СберЗдоровья нужен период выгрузки 1С")
} else {
var so matchOpts
if len(uploads.sber) > 0 {
so, err = sberhealthUploadOpts(uploads.sber, period.from, period.to)
} else {
so, err = a.sberhealthMatchOpts(ctx, period.from, period.to)
}
if err == nil {
o.pbxLists = mergeChannelLists(o.pbxLists, so.pbxLists)
o.sberhealthInfo = so.sberhealthInfo
}
}
}
if err == nil {
result, summary, err = a.phoneMatchOpts(ctx, p, name, data, o)
}
}
status, errText := "done", ""
switch {
case errors.Is(ctx.Err(), context.Canceled):
return // отменили — статус уже «отменён»
case errors.Is(ctx.Err(), context.DeadlineExceeded):
status, errText = "failed", "Расчёт шёл дольше 5 минут и остановлен"
case err != nil:
status, errText = "failed", err.Error()
}
if status == "done" {
p.stages = append(p.stages, time.Since(p.start).Milliseconds()) // «Завершение»
if kind == "phone_match" {
if sum, isSum := summary.(channelsSummary); isSum {
sum.Dashboard = a.saveWeeklyFromSummary(context.Background(), clinic.Key, name, data, sum, adminID)
summary = sum
}
}
}
stamp := time.Now().In(moscow).Format("2006-01-02")
resultName := "otchet-po-kanalam-" + stamp + ".csv"
if status != "done" {
result, resultName = nil, ""
}
if err := a.store.FinishReport(context.Background(), id, status, p.stages, summary, result, resultName, errText); err != nil {
slog.Error("finish report", "id", id, "err", err)
}
}
// csvBytes — CSV для Excel: UTF-8 с BOM и точкой с запятой.
func csvBytes(rows [][]string) []byte {
var buf bytes.Buffer
buf.WriteString("\ufeff")
cw := csv.NewWriter(&buf)
cw.Comma = ';'
cw.UseCRLF = true
_ = cw.WriteAll(rows)
return buf.Bytes()
}
var priceRE = regexp.MustCompile(`-?\d+(?:[.,]\d+)?`)
// parsePrice понимает «1 500», «1500,00», «1 500 ₽», «от 2000 руб.».
func parsePrice(s string) (float64, bool) {
s = strings.NewReplacer(" ", "", " ", "", " ", "").Replace(s)
m := priceRE.FindString(s)
if m == "" {
return 0, false
}
v, err := strconv.ParseFloat(strings.ReplaceAll(m, ",", "."), 64)
if err != nil || v < 0 || v > 100_000_000 {
return 0, false
}
return v, true
}
func fmtRub(v float64) string {
if v == math.Trunc(v) {
return strconv.FormatFloat(v, 'f', 0, 64)
}
return strings.ReplaceAll(strconv.FormatFloat(v, 'f', 2, 64), ".", ",")
}