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

236 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 (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
"dana/internal/sberhealth"
)
// Файлы кабинетов СберЗдоровья и ПроДокторов, которые пользователь скачал сам и приложил к отчёту.
// Сервер не может скачать их сам: кабинеты отвечают 403 на запросы с VPS, поэтому рабочий путь — загрузка.
// Телефоны из файлов попадают в те же каналы, что и при скачивании (сберздоровье, продокторов),
// объединяются без дублей с АТС и Битрикс24; выручка и услуги считаются только из 1С.
const (
maxPartnerFile = 10 << 20
// на каждый месяц периода — до двух файлов: онлайн-запись (МИС) и телефонная запись; три месяца — запас на стык
maxProdoctorovFiles = 6
)
type partnerUploads struct {
sber []byte
prodoctorov [][]byte
}
func (u partnerUploads) any() bool { return len(u.sber) > 0 || len(u.prodoctorov) > 0 }
var (
oleSignature = []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}
zipSignature = []byte{'P', 'K', 3, 4}
)
func readUploadedFile(h *multipart.FileHeader, ext string, signature []byte, what string) ([]byte, error) {
name := path.Base(strings.ReplaceAll(h.Filename, "\\", "/"))
if strings.ToLower(path.Ext(name)) != ext {
return nil, fmt.Errorf("%s: нужен файл %s, скачанный из кабинета", what, ext)
}
f, err := h.Open()
if err != nil {
return nil, fmt.Errorf("%s: файл не открылся", what)
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, maxPartnerFile+1))
if err != nil {
return nil, fmt.Errorf("%s: файл не дочитался", what)
}
if len(data) > maxPartnerFile {
return nil, fmt.Errorf("%s: файл больше 10 МБ", what)
}
if !bytes.HasPrefix(data, signature) {
return nil, fmt.Errorf("%s: это не файл Excel из кабинета (страница ошибки или другой формат)", what)
}
return data, nil
}
// readPartnerUploads достаёт необязательные файлы из формы отчёта. Сообщение об ошибке — для пользователя.
func readPartnerUploads(r *http.Request) (partnerUploads, error) {
var up partnerUploads
if r.MultipartForm == nil {
return up, nil
}
if files := r.MultipartForm.File["sber_file"]; len(files) > 0 {
if len(files) > 1 {
return up, fmt.Errorf("СберЗдоровье: нужен один файл за весь период отчёта")
}
data, err := readUploadedFile(files[0], ".xls", oleSignature, "СберЗдоровье")
if err != nil {
return up, err
}
up.sber = data
}
if files := r.MultipartForm.File["prodoctorov_file"]; len(files) > 0 {
if len(files) > maxProdoctorovFiles {
return up, fmt.Errorf("ПроДокторов: не больше %d файлов (по одному на месяц периода)", maxProdoctorovFiles)
}
for _, h := range files {
data, err := readUploadedFile(h, ".xlsx", zipSignature, "ПроДокторов")
if err != nil {
return up, err
}
up.prodoctorov = append(up.prodoctorov, data)
}
}
return up, nil
}
var ruMonths = map[string]time.Month{
"января": time.January, "февраля": time.February, "марта": time.March, "апреля": time.April,
"мая": time.May, "июня": time.June, "июля": time.July, "августа": time.August,
"сентября": time.September, "октября": time.October, "ноября": time.November, "декабря": time.December,
}
var prodoctorovMonthRE = regexp.MustCompile(`(?i)(января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)\s+(\d{4})`)
var ruMonthNames = [...]string{"", "январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"}
func monthTitle(m time.Time) string { return ruMonthNames[m.Month()] + " " + strconv.Itoa(m.Year()) }
// Выгрузка должна быть из кабинета своей клиники (clinicDef.prodoctorovRE): у DANA и «Очевидно»
// одинаковые Appointment-….xlsx, и файл чужого кабинета молча ушёл бы не в тот отчёт.
// prodoctorovFileMonth берёт месяц и вид выгрузки из служебной строки файла
// («запись на приём (в МИС) - октября 2026» или «запись на приём (телефон) - сентября 2026»):
// месяц, в котором созданы записи; вид — онлайн-запись или телефонная запись.
func prodoctorovFileMonth(cl *clinicDef, data []byte) (time.Time, string, error) {
sheets, err := readWorkbook("appointments.xlsx", data)
if err != nil || len(sheets) == 0 {
return time.Time{}, "", fmt.Errorf("ПроДокторов: не удалось прочитать XLSX")
}
rows := sheets[0].Rows
if len(rows) > 0 {
clinic := strings.TrimSpace(strings.Join(rows[0], " "))
if clinic != "" && !cl.prodoctorovRE.MatchString(clinic) {
if r := []rune(clinic); len(r) > 60 {
clinic = string(r[:60]) + "…"
}
return time.Time{}, "", fmt.Errorf("ПроДокторов: файл другой клиники («%s») — нужна выгрузка кабинета «%s»", clinic, cl.prodoctorovName)
}
}
for i := 0; i < len(rows) && i < 6; i++ {
line := strings.Join(rows[i], " ")
if m := prodoctorovMonthRE.FindStringSubmatch(line); m != nil {
year, _ := strconv.Atoi(m[2])
kind := "онлайн-запись"
if strings.Contains(strings.ToLower(line), "телефон") {
kind = "телефонная запись"
}
return time.Date(year, ruMonths[strings.ToLower(m[1])], 1, 0, 0, 0, 0, moscow), kind, nil
}
}
return time.Time{}, "", fmt.Errorf("ПроДокторов: в файле не найден месяц выгрузки (ожидается строка «запись на приём … месяц год»)")
}
// prodoctorovUploadOpts — то же для DANA.
func prodoctorovUploadOpts(files [][]byte, from, to time.Time) (matchOpts, error) {
return prodoctorovUploadOptsFor(clinicByKey[clinicDANA], files, from, to)
}
// prodoctorovUploadOptsFor собирает телефоны из загруженных месячных файлов кабинета клиники.
// Файлы должны покрывать все месяцы периода 1С: иначе записи нужного месяца молча пропали бы из канала.
func prodoctorovUploadOptsFor(clinic *clinicDef, files [][]byte, from, to time.Time) (matchOpts, error) {
from, to = from.In(moscow), to.In(moscow)
if !to.After(from) || to.Sub(from) > 366*24*time.Hour {
return matchOpts{}, fmt.Errorf("ПроДокторов: период должен быть не длиннее года")
}
const key = "2006-01"
need := map[string]bool{}
for m := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, moscow); m.Before(to); m = m.AddDate(0, 1, 0) {
need[m.Format(key)] = true
}
// на месяц — по одному файлу каждого вида (онлайн-запись и телефонная запись)
have := map[string][]byte{}
months := map[string]bool{}
for _, data := range files {
month, kind, err := prodoctorovFileMonth(clinic, data)
if err != nil {
return matchOpts{}, err
}
slot := month.Format(key) + "|" + kind
if _, dup := have[slot]; dup {
return matchOpts{}, fmt.Errorf("ПроДокторов: два файла за %s (%s)", monthTitle(month), kind)
}
if !need[month.Format(key)] {
return matchOpts{}, fmt.Errorf("ПроДокторов: файл за %s не относится к периоду отчёта", monthTitle(month))
}
have[slot] = data
months[month.Format(key)] = true
}
var missing []string
for m := range need {
if !months[m] {
missing = append(missing, m)
}
}
if len(missing) > 0 {
sort.Strings(missing)
for i, s := range missing {
t, _ := time.ParseInLocation(key, s, moscow)
missing[i] = monthTitle(t)
}
return matchOpts{}, fmt.Errorf("ПроДокторов: добавьте файл за %s: период отчёта захватывает этот месяц", strings.Join(missing, ", "))
}
info := &prodoctorovSourceInfo{From: from.Format("2006-01-02"), To: to.Add(-time.Nanosecond).Format("2006-01-02")}
keys := map[string]bool{}
for _, data := range have {
phones, records, err := parseProdoctorovExport(data, from, to)
if err != nil {
return matchOpts{}, err
}
info.Records += records
for _, key := range phones {
keys[key] = true
}
}
phones := make([]string, 0, len(keys))
for key := range keys {
phones = append(phones, key)
}
sort.Strings(phones)
info.Phones = len(phones)
info.Months = len(months)
return matchOpts{pbxLists: map[string][]string{"продокторов": phones}, prodoctorovInfo: info}, nil
}
// sberhealthUploadOpts собирает телефоны из загруженного XLS СберЗдоровья.
func sberhealthUploadOpts(data []byte, from, to time.Time) (matchOpts, error) {
from, to = from.In(moscow), to.In(moscow)
if !to.After(from) {
return matchOpts{}, fmt.Errorf("СберЗдоровье: некорректный период 1С")
}
rows, err := sberhealth.ReadXLS(data)
if err != nil {
return matchOpts{}, err
}
phones, records, total, err := parseSberHealthRows(rows, from, to)
if err != nil {
return matchOpts{}, err
}
if total > 0 && records == 0 {
return matchOpts{}, fmt.Errorf("СберЗдоровье: в файле нет записей за период отчёта (%s–%s); выгрузите заявки за тот же период",
from.Format("02.01.2006"), to.AddDate(0, 0, -1).Format("02.01.2006"))
}
info := &sberhealthSourceInfo{From: from.Format("2006-01-02"), To: to.AddDate(0, 0, -1).Format("2006-01-02"),
Records: records, Phones: len(phones), ExportRecords: total}
return matchOpts{pbxLists: map[string][]string{"сберздоровье": phones}, sberhealthInfo: info}, nil
}