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 }