236 lines
10 KiB
Go
236 lines
10 KiB
Go
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
|
||
}
|