package api import ( "context" "fmt" "regexp" "sort" "strconv" "strings" "time" "dana/internal/sberhealth" ) type sberhealthSourceInfo struct { From string `json:"from"` To string `json:"to"` Records int `json:"records"` // creation date inside 1C period Phones int `json:"phones"` // unique keys before 1C stub exclusions ExportRecords int `json:"export_records"` // reconciled with XLS footer } func (a *API) WithSberHealth(c *sberhealth.Client) { a.sberhealth = c } func (a *API) sberhealthMatchOpts(ctx context.Context, from, to time.Time) (matchOpts, error) { if !a.sberhealth.Configured() { return matchOpts{}, fmt.Errorf("СберЗдоровье не подключено") } from, to = from.In(moscow), to.In(moscow) if !to.After(from) { return matchOpts{}, fmt.Errorf("СберЗдоровье: некорректный период 1С") } mode := "week" if describeReportPeriod(from, to).Mode == "months" { mode = "month" } // Confirmed sample includes requests created on crDateTill: provider's end // date is inclusive. 1C integrations internally use an exclusive end. export, err := a.sberhealth.Download(ctx, from, to.AddDate(0, 0, -1), mode) if err != nil { return matchOpts{}, err } if export.Format != "xls" { return matchOpts{}, fmt.Errorf("СберЗдоровье: формат выгрузки изменился; ожидается проверенный бинарный XLS") } rows, err := sberhealth.ReadXLS(export.Data) if err != nil { return matchOpts{}, err } phones, records, total, err := parseSberHealthRows(rows, from, to) if err != nil { return matchOpts{}, err } 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 } var sberCountRE = regexp.MustCompile(`(?i)^количество\s+элементов\s*:\s*(\d+)\s*$`) // The source sheet's schema is confirmed against Report_20261005_123725.xls: // row 7 headers; B creation date, E phone; footer declares element count. // All statuses/sources are retained, matching the user's -1 export filters. // Appointment costs/prepayments never enter revenue: money comes only from 1C. func parseSberHealthRows(rows [][]string, from, to time.Time) ([]string, int, int, error) { header, idCol, dateCol, phoneCol := -1, -1, -1, -1 for i, row := range rows { if i >= 15 { break } id, date, phone := -1, -1, -1 for j, value := range row { switch strings.ToLower(strings.TrimSpace(value)) { case "#": id = j case "дата создания записи": date = j case "телефон": phone = j } } if id >= 0 && date >= 0 && phone >= 0 { header, idCol, dateCol, phoneCol = i, id, date, phone break } } if header < 0 { return nil, 0, 0, fmt.Errorf("СберЗдоровье: нет столбцов #, Дата создания записи и Телефон") } keys, ids := map[string]bool{}, map[string]bool{} records, total, declared := 0, 0, -1 footer := false for i := header + 1; i < len(rows); i++ { row := rows[i] id, date, phone := strings.TrimSpace(cell(row, idCol)), strings.TrimSpace(cell(row, dateCol)), strings.TrimSpace(cell(row, phoneCol)) if strings.TrimSpace(strings.Join(row, "")) == "" { continue } if date == "" && phone == "" { if m := sberCountRE.FindStringSubmatch(id); len(m) > 0 && declared < 0 { declared, _ = strconv.Atoi(m[1]) footer = true continue } if footer && strings.HasPrefix(strings.ToLower(id), "дата формирования отчета:") { continue } return nil, 0, 0, fmt.Errorf("СберЗдоровье: неизвестная служебная строка %d", i+1) } if footer { return nil, 0, 0, fmt.Errorf("СберЗдоровье: записи после итоговой строки") } created, err := time.ParseInLocation("02.01.2006 15:04", date, moscow) idNumber, idErr := strconv.ParseUint(id, 10, 64) if err != nil || idErr != nil || idNumber == 0 || ids[id] { return nil, 0, 0, fmt.Errorf("СберЗдоровье: неверная дата, номер или дубль записи в строке %d", i+1) } ids[id] = true total++ if created.Before(from) || !created.Before(to) { continue } phones, bad := phoneKeys(phone) if len(phones) == 0 || bad { return nil, 0, 0, fmt.Errorf("СберЗдоровье: не распознан телефон в строке %d", i+1) } records++ for _, key := range phones { keys[key] = true } } if declared < 0 || declared != total { return nil, 0, 0, fmt.Errorf("СберЗдоровье: число строк не совпало с «Количество элементов»; проверьте полноту выгрузки") } phones := make([]string, 0, len(keys)) for key := range keys { phones = append(phones, key) } sort.Strings(phones) return phones, records, total, nil }