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), ".", ",") }