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

167 lines
6.1 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 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
}