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 }