146 lines
5.4 KiB
Go
146 lines
5.4 KiB
Go
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
|
||
}
|