package storage import ( "context" "encoding/json" "errors" "time" "github.com/jackc/pgx/v5" ) type Report struct { ID int64 `json:"id"` Kind string `json:"kind"` Clinic string `json:"clinic"` Title string `json:"title"` Status string `json:"status"` Stage int `json:"stage"` Progress int `json:"progress"` Stages json.RawMessage `json:"stages"` SourceName string `json:"source_name"` SourceSize int64 `json:"source_size"` Summary json.RawMessage `json:"summary"` ResultName string `json:"result_name"` ResultSize int `json:"result_size"` Error string `json:"error"` CreatedAt time.Time `json:"created_at"` FinishedAt *time.Time `json:"finished_at"` } const reportCols = `id, kind, clinic, title, status, stage, progress, stages, source_name, source_size, summary, result_name, COALESCE(length(result), 0), error, created_at, finished_at` func scanReport(row pgx.Row) (*Report, error) { var r Report err := row.Scan(&r.ID, &r.Kind, &r.Clinic, &r.Title, &r.Status, &r.Stage, &r.Progress, &r.Stages, &r.SourceName, &r.SourceSize, &r.Summary, &r.ResultName, &r.ResultSize, &r.Error, &r.CreatedAt, &r.FinishedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return &r, err } func (s *Storage) CreateReport(ctx context.Context, clinic, kind, title, sourceName string, sourceSize int64, adminID int64) (int64, error) { var id int64 err := s.pool.QueryRow(ctx, ` INSERT INTO reports (clinic, kind, title, source_name, source_size, admin_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, clinic, kind, title, sourceName, sourceSize, adminID).Scan(&id) return id, err } // ReportProgress записывает текущий этап и длительности завершённых этапов. func (s *Storage) ReportProgress(ctx context.Context, id int64, stage, progress int, stagesMS []int64) error { st, _ := json.Marshal(stagesMS) _, err := s.pool.Exec(ctx, `UPDATE reports SET stage=$2, progress=$3, stages=$4 WHERE id=$1 AND status='running'`, id, stage, progress, st) return err } // FinishReport закрывает задание; статус меняется, только пока оно выполняется — // отменённое не станет «готовым», даже если расчёт успел закончиться. func (s *Storage) FinishReport(ctx context.Context, id int64, status string, stagesMS []int64, summary any, result []byte, resultName, errText string) error { st, _ := json.Marshal(stagesMS) sm, _ := json.Marshal(summary) _, err := s.pool.Exec(ctx, ` UPDATE reports SET status=$2, stages=$3, summary=$4, result=$5, result_name=$6, error=$7, progress=CASE WHEN $2 = 'done' THEN 100 ELSE progress END, finished_at=now() WHERE id=$1 AND status='running'`, id, status, st, sm, result, resultName, errText) return err } func (s *Storage) CancelReport(ctx context.Context, id int64) (bool, error) { tag, err := s.pool.Exec(ctx, `UPDATE reports SET status='cancelled', finished_at=now() WHERE id=$1 AND status='running'`, id) return tag.RowsAffected() > 0, err } func (s *Storage) Report(ctx context.Context, id int64) (*Report, error) { return scanReport(s.pool.QueryRow(ctx, `SELECT `+reportCols+` FROM reports WHERE id=$1`, id)) } func (s *Storage) Reports(ctx context.Context, clinic string, limit int) ([]Report, error) { rows, err := s.pool.Query(ctx, `SELECT `+reportCols+` FROM reports WHERE clinic=$1 ORDER BY created_at DESC, id DESC LIMIT $2`, clinic, limit) if err != nil { return nil, err } defer rows.Close() out := []Report{} for rows.Next() { r, err := scanReport(rows) if err != nil { return nil, err } out = append(out, *r) } return out, rows.Err() } func (s *Storage) ReportResult(ctx context.Context, id int64) (name string, data []byte, err error) { err = s.pool.QueryRow(ctx, `SELECT result_name, result FROM reports WHERE id=$1 AND status='done' AND result IS NOT NULL`, id).Scan(&name, &data) if errors.Is(err, pgx.ErrNoRows) { return "", nil, ErrNotFound } return name, data, err } func (s *Storage) DeleteReport(ctx context.Context, id int64) error { tag, err := s.pool.Exec(ctx, `DELETE FROM reports WHERE id=$1 AND status <> 'running'`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // FailInterruptedReports — после перезапуска сервера задания, которые шли, уже не закончатся. // Готовые отчёты хранятся без срока: удаляются только вручную из админки. func (s *Storage) FailInterruptedReports(ctx context.Context) error { _, err := s.pool.Exec(ctx, ` UPDATE reports SET status='failed', error='Сервер перезапускался во время расчёта — запустите отчёт ещё раз', finished_at=now() WHERE status='running'`) return err }