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

146 lines
5.4 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 (
"archive/zip"
"bytes"
"context"
"fmt"
"sort"
"strings"
"time"
"dana/internal/prodoctorov"
)
// Only export metadata is stored in the summary; never cabinet credentials.
type prodoctorovSourceInfo struct {
From string `json:"from"`
To string `json:"to"`
Months int `json:"months"`
Records int `json:"records"` // bookings created inside the 1C period
Phones int `json:"phones"` // unique phone keys, before 1C stub exclusions
}
func (a *API) WithProdoctorov(c *prodoctorov.Client) { a.prodoctorov = c }
// Pull whole overlapping months, then filter by creation date (B «Дата»), not
// visit date (F «Запись на»). The 1C report's end is exclusive, in Moscow time.
func (a *API) prodoctorovMatchOpts(ctx context.Context, from, to time.Time) (matchOpts, error) {
if !a.prodoctorov.Configured() {
return matchOpts{}, fmt.Errorf("ПроДокторов не подключён")
}
from, to = from.In(moscow), to.In(moscow)
if !to.After(from) || to.Sub(from) > 366*24*time.Hour {
return matchOpts{}, fmt.Errorf("ПроДокторов: период должен быть не длиннее года")
}
info := &prodoctorovSourceInfo{From: from.Format("2006-01-02"), To: to.Add(-time.Nanosecond).Format("2006-01-02")}
keys := map[string]bool{}
for month := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, moscow); month.Before(to); month = month.AddDate(0, 1, 0) {
data, err := a.prodoctorov.Download(ctx, month.Year(), month.Month())
if err != nil {
return matchOpts{}, err
}
phones, records, err := parseProdoctorovExport(data, from, to)
if err != nil {
return matchOpts{}, err
}
info.Months++
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)
// Keep an explicit empty channel after a valid empty export, rather than
// confusing "not connected" with "connected, no online appointments".
return matchOpts{pbxLists: map[string][]string{"продокторов": phones}, prodoctorovInfo: info}, nil
}
func parseProdoctorovExport(data []byte, from, to time.Time) ([]string, int, error) {
// Bound uncompressed OOXML before invoking the shared workbook reader.
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil || len(zr.File) > 100 {
return nil, 0, fmt.Errorf("ПроДокторов: повреждённый XLSX")
}
var total uint64
for _, f := range zr.File {
if f.UncompressedSize64 > 32<<20 || total > 32<<20-f.UncompressedSize64 {
return nil, 0, fmt.Errorf("ПроДокторов: распакованная книга слишком большая")
}
total += f.UncompressedSize64
}
sheets, err := readWorkbook("appointments.xlsx", data)
if err != nil {
return nil, 0, fmt.Errorf("ПроДокторов: не удалось прочитать XLSX")
}
var rows [][]string
header, idCol, dateCol, phoneCol := -1, -1, -1, -1
for _, sheet := range sheets {
for i, row := range sheet.Rows {
if i >= 15 {
break
}
id, date, phone, visit := -1, -1, -1, -1
for j, value := range row {
switch strings.ToLower(strings.TrimSpace(value)) {
case "№":
id = j
case "дата":
date = j
case "пациент":
phone = j
// «Запись на» — у онлайн-записи (МИС), «Экспресс-запись» — у телефонной выгрузки.
// Сами эти столбцы не нужны: по ним только узнаём таблицу записей.
case "запись на", "экспресс-запись":
visit = j
}
}
if id >= 0 && date >= 0 && phone >= 0 && visit >= 0 {
if header >= 0 {
return nil, 0, fmt.Errorf("ПроДокторов: несколько таблиц записей в выгрузке")
}
rows, header, idCol, dateCol, phoneCol = sheet.Rows, i, id, date, phone
break
}
}
}
if header < 0 {
return nil, 0, fmt.Errorf("ПроДокторов: нет столбцов №, Дата, Пациент и «Запись на» (онлайн) или «Экспресс-запись» (телефон); формат выгрузки изменился")
}
keys, seenRecords := map[string]bool{}, map[string]bool{}
for i := header + 1; i < len(rows); i++ {
row := rows[i]
id := strings.TrimSpace(cell(row, idCol))
if id == "" && strings.TrimSpace(strings.Join(row, "")) == "" {
continue
}
created, err := time.ParseInLocation("02.01.2006 15:04", strings.TrimSpace(cell(row, dateCol)), moscow)
if err != nil || id == "" {
// Never include raw values in an error: they can identify a patient.
return nil, 0, fmt.Errorf("ПроДокторов: не распознана дата создания или № записи в строке %d", i+1)
}
if created.Before(from) || !created.Before(to) {
continue
}
phones, bad := phoneKeys(cell(row, phoneCol))
if len(phones) == 0 || bad {
return nil, 0, fmt.Errorf("ПроДокторов: не распознан телефон в строке %d", i+1)
}
seenRecords[id] = true
for _, phone := range phones {
keys[phone] = true
}
}
phones := make([]string, 0, len(keys))
for key := range keys {
phones = append(phones, key)
}
sort.Strings(phones)
return phones, len(seenRecords), nil
}