437 lines
15 KiB
Go
437 lines
15 KiB
Go
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), ".", ",")
|
||
}
|