package storage import ( "context" "encoding/json" "errors" "time" "github.com/jackc/pgx/v5" ) // WeeklyChannel — одна строка канала в недельном/месячном дашборде. // Budget вводится вручную в админке и сохраняется между перезагрузками того же периода. type WeeklyChannel struct { Key string `json:"key"` Title string `json:"title"` Leads int `json:"leads"` Patients int `json:"patients"` Services float64 `json:"services"` PerPat float64 `json:"per_patient"` Revenue float64 `json:"revenue"` AvgCheck float64 `json:"avg_check"` Budget float64 `json:"budget"` } // WeeklyCallCenter — блок «Колл-ц» дашборда: весь колл-центр за период, без привязки к каналу. type WeeklyCallCenter struct { Total int `json:"total"` Incoming int `json:"incoming"` Outgoing int `json:"outgoing"` Missed int `json:"missed"` MissedPct float64 `json:"missed_pct"` Unique int `json:"unique"` // уникальных позвонивших номеров Patients int `json:"patients"` Services float64 `json:"services"` Revenue float64 `json:"revenue"` AvgCheck float64 `json:"avg_check"` TalkSec int `json:"talk_seconds"` // средняя длительность разговора по отвеченным, сек WaitSec int `json:"wait_seconds"` // среднее ожидание ответа по отвеченным, сек } type WeeklyReport struct { ID int64 `json:"id"` PeriodFrom time.Time `json:"period_from"` PeriodTo time.Time `json:"period_to"` Label string `json:"label"` SourceName string `json:"source_name"` CallCenter WeeklyCallCenter `json:"callcenter"` Channels []WeeklyChannel `json:"channels"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // UpsertWeeklyReport сохраняет расчёт за период. Если период уже загружался раньше, // значения бюджета по каналам (вводятся вручную) переносятся из прежней записи — // повторная загрузка файла 1С их не стирает. func (s *Storage) UpsertWeeklyReport(ctx context.Context, clinic string, from, to time.Time, label, sourceName string, cc WeeklyCallCenter, channels []WeeklyChannel, adminID int64) (int64, error) { var existing []byte err := s.pool.QueryRow(ctx, `SELECT channels FROM weekly_reports WHERE clinic=$1 AND period_from=$2 AND period_to=$3`, clinic, from, to).Scan(&existing) switch { case err == nil: var old []WeeklyChannel if json.Unmarshal(existing, &old) == nil { budgets := make(map[string]float64, len(old)) for _, c := range old { budgets[c.Key] = c.Budget } for i := range channels { channels[i].Budget = budgets[channels[i].Key] } } case errors.Is(err, pgx.ErrNoRows): // период новый — бюджета ещё нет, переносить нечего default: return 0, err } ccJSON, err := json.Marshal(cc) if err != nil { return 0, err } chJSON, err := json.Marshal(channels) if err != nil { return 0, err } var id int64 err = s.pool.QueryRow(ctx, ` INSERT INTO weekly_reports (clinic, period_from, period_to, label, source_name, callcenter, channels, admin_id, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,now()) ON CONFLICT (clinic, period_from, period_to) DO UPDATE SET label=EXCLUDED.label, source_name=EXCLUDED.source_name, callcenter=EXCLUDED.callcenter, channels=EXCLUDED.channels, admin_id=EXCLUDED.admin_id, updated_at=now() RETURNING id`, clinic, from, to, label, sourceName, ccJSON, chJSON, adminID).Scan(&id) return id, err } func scanWeeklyReport(row pgx.Row) (*WeeklyReport, error) { var r WeeklyReport var cc, ch []byte if err := row.Scan(&r.ID, &r.PeriodFrom, &r.PeriodTo, &r.Label, &r.SourceName, &cc, &ch, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } _ = json.Unmarshal(cc, &r.CallCenter) _ = json.Unmarshal(ch, &r.Channels) return &r, nil } const weeklyCols = `id, period_from, period_to, label, source_name, callcenter, channels, created_at, updated_at` // WeeklyReports — вся история клиники, от старых периодов к новым (для дашборда со столбцами-неделями). func (s *Storage) WeeklyReports(ctx context.Context, clinic string) ([]WeeklyReport, error) { rows, err := s.pool.Query(ctx, `SELECT `+weeklyCols+` FROM weekly_reports WHERE clinic=$1 ORDER BY period_from ASC, id ASC`, clinic) if err != nil { return nil, err } defer rows.Close() out := []WeeklyReport{} for rows.Next() { r, err := scanWeeklyReport(rows) if err != nil { return nil, err } out = append(out, *r) } return out, rows.Err() } func (s *Storage) WeeklyReport(ctx context.Context, id int64) (*WeeklyReport, error) { return scanWeeklyReport(s.pool.QueryRow(ctx, `SELECT `+weeklyCols+` FROM weekly_reports WHERE id=$1`, id)) } // SetWeeklyBudget — правка бюджета одного канала одного периода руками в админке. func (s *Storage) SetWeeklyBudget(ctx context.Context, id int64, channelKey string, amount float64) error { var raw []byte if err := s.pool.QueryRow(ctx, `SELECT channels FROM weekly_reports WHERE id=$1`, id).Scan(&raw); err != nil { return err } var channels []WeeklyChannel if err := json.Unmarshal(raw, &channels); err != nil { return err } found := false for i := range channels { if channels[i].Key == channelKey { channels[i].Budget = amount found = true break } } if !found { return pgx.ErrNoRows } chJSON, err := json.Marshal(channels) if err != nil { return err } _, err = s.pool.Exec(ctx, `UPDATE weekly_reports SET channels=$2, updated_at=now() WHERE id=$1`, id, chJSON) return err } func (s *Storage) DeleteWeeklyReport(ctx context.Context, id int64) error { _, err := s.pool.Exec(ctx, `DELETE FROM weekly_reports WHERE id=$1`, id) return err }