axe/internal/repository/recurring.go
2026-10-18 22:07:25 +03:00

580 lines
24 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 repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"building-mgmt/internal/models"
)
// RecurringRepo — правила повторяющихся заявок и их срабатывание
// (миграции 0014/0016). Правило — шаблон будущей заявки (объект,
// категория, описание, приоритет) плюс расписание «каждые period_days
// дней/недель от anchor_at»; фоновый планировщик в cmd/api зовёт
// RunDueRules и создаёт заявки по расписанию.
//
// Проверка владельца встроена в запросы, как у проектов: clientID != ""
// ограничивает выборку, чужое правило неотличимо от несуществующего.
type RecurringRepo struct {
db Querier
}
// NewRecurringRepo создаёт репозиторий поверх Querier (пул или транзакция).
func NewRecurringRepo(db Querier) *RecurringRepo {
return &RecurringRepo{db: db}
}
// nextDue — ближайший запуск правила: наименьшая точка расписания
// anchor + k·period (k ≥ 0), строго после отправной точки ref.
//
// Сетка сроков строится от anchor_at и не «плывёт» после каждого
// срабатывания: опоздание на тик не накапливается. Отправная точка —
// COALESCE(last_run_at, created_at): у правила, которое ещё ни разу не
// срабатывало, первая заявка — первая точка сетки после создания
// (anchor в будущем — это сам anchor: пользователь выбрал первый запуск).
//
// periodDays ≥ 1 держится CHECK'ом в БД; защита ниже — чтобы деление
// на ноль при битых данных не уронило планировщик.
func nextDue(anchor time.Time, periodDays int, ref time.Time) time.Time {
if periodDays < 1 {
periodDays = 1
}
a, r := anchor.UTC(), ref.UTC()
if r.Before(a) {
return a
}
period := time.Duration(periodDays) * 24 * time.Hour
// Сколько полных периодов прошло между anchor и ref, плюс один:
// срок обязан быть строго после ref (ровно на ref — уже прошло).
return a.Add(time.Duration(int64(r.Sub(a)/period)+1) * period)
}
// recurringColumns и recurringFrom — одна проекция для всех выборок:
// правило плюс денормализации для UI (проект, объект, адрес, имена).
const recurringColumns = `
r.id, r.client_id, r.project_id, r.object_id,
r.category, r.description, r.priority, COALESCE(r.floor, ''),
r.anchor_at, r.period_days, r.is_active, r.last_run_at, r.created_at,
COALESCE(r.created_by::text, ''),
p.name, o.name, o.address,
COALESCE(c.name, ''), COALESCE(cb.name, '')`
const recurringFrom = `
FROM recurring_rules r
JOIN projects p ON p.id = r.project_id
JOIN project_objects o ON o.id = r.object_id
LEFT JOIN profiles c ON c.id = r.client_id
LEFT JOIN profiles cb ON cb.id = r.created_by`
// scanRule читает строку в модель; порядок Scan = recurringColumns.
// extra — доп. назначения в конце SELECT (COUNT(*) OVER() у списка).
func scanRule(s scanner, extra ...any) (models.RecurringRule, error) {
var rule models.RecurringRule
dest := []any{
&rule.ID, &rule.ClientID, &rule.ProjectID, &rule.ObjectID,
&rule.Category, &rule.Description, &rule.Priority, &rule.Floor,
&rule.AnchorAt, &rule.PeriodDays, &rule.IsActive, &rule.LastRunAt, &rule.CreatedAt,
&rule.CreatedBy,
&rule.ProjectName, &rule.ObjectName, &rule.Address,
&rule.ClientName, &rule.CreatedByName,
}
dest = append(dest, extra...)
if err := s.Scan(dest...); err != nil {
return rule, classify(err)
}
// Ближайший запуск — чистая функция расписания и отправной точки,
// поэтому считается здесь, а не хранится (миграция бы устарела
// при правке периода или после каждого срабатывания).
next := nextDue(rule.AnchorAt, rule.PeriodDays, dueRef(rule))
rule.NextRunAt = &next
return rule, nil
}
// dueRef — отправная точка срока правила: последнее срабатывание
// или создание, если оно ещё ни разу не срабатывало.
func dueRef(rule models.RecurringRule) time.Time {
if rule.LastRunAt != nil {
return *rule.LastRunAt
}
return rule.CreatedAt
}
// CreateRule — новое правило. Вызывающий обязан уже проверить владение
// объектом (OwnedObject) и активность проекта/объекта — как при создании
// заявки (handlers.TicketHandler.Create).
func (r *RecurringRepo) CreateRule(ctx context.Context, rule *models.RecurringRule) error {
err := r.db.QueryRow(ctx,
`INSERT INTO recurring_rules
(client_id, project_id, object_id, category, description,
priority, floor, anchor_at, period_days, created_by)
VALUES ($1, $2, $3, $4, $5, $6, NULLIF($7, ''), $8, $9, NULLIF($10, '')::uuid)
RETURNING id, created_at`,
rule.ClientID, rule.ProjectID, rule.ObjectID, rule.Category,
rule.Description, rule.Priority, rule.Floor, rule.AnchorAt,
rule.PeriodDays, rule.CreatedBy,
).Scan(&rule.ID, &rule.CreatedAt)
return classify(err)
}
// GetRule — правило по id; clientID != "" ограничивает выборку
// правилами этого клиента (чужое → ErrNotFound).
func (r *RecurringRepo) GetRule(ctx context.Context, id int64, clientID string) (*models.RecurringRule, error) {
query := `SELECT ` + recurringColumns + recurringFrom + ` WHERE r.id = $1`
args := []any{id}
if clientID != "" {
query += ` AND r.client_id = $2`
args = append(args, clientID)
}
rule, err := scanRule(r.db.QueryRow(ctx, query, args...))
if err != nil {
return nil, fmt.Errorf("recurring rule %d: %w", id, err)
}
return &rule, nil
}
// ListRules — страница правил: все (clientID пуст, для админа) или только
// свои; projectID != 0 сужает до одного проекта (секция в проекте).
// Пустое окно досчитывается тем же WHERE: окно total его не знает.
func (r *RecurringRepo) ListRules(ctx context.Context, clientID string, projectID int64, page Page) ([]models.RecurringRule, int, error) {
page = page.normalize()
order, err := orderBy(page, RecurringSorts, "created_at", "r.id DESC")
if err != nil {
return nil, 0, err
}
where := `1=1`
var whereArgs []any
if clientID != "" {
where += ` AND r.client_id = $` + itoa(len(whereArgs)+1)
whereArgs = append(whereArgs, clientID)
}
if projectID != 0 {
where += ` AND r.project_id = $` + itoa(len(whereArgs)+1)
whereArgs = append(whereArgs, projectID)
}
query := `SELECT ` + recurringColumns + `, COUNT(*) OVER() AS total` + recurringFrom + `
WHERE ` + where + order +
` LIMIT $` + itoa(len(whereArgs)+1) + ` OFFSET $` + itoa(len(whereArgs)+2)
args := append(append([]any{}, whereArgs...), page.Limit, page.Offset)
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, 0, classify(err)
}
defer rows.Close()
var rules []models.RecurringRule
total := 0
for rows.Next() {
rule, err := scanRule(rows, &total)
if err != nil {
return nil, 0, err
}
rules = append(rules, rule)
}
if err := rows.Err(); err != nil {
return nil, 0, classify(err)
}
if len(rules) == 0 {
if err := r.db.QueryRow(ctx,
`SELECT COUNT(*)`+recurringFrom+` WHERE `+where, whereArgs...).Scan(&total); err != nil {
return nil, 0, classify(err)
}
}
return rules, total, nil
}
// RulePatch — изменяемые поля правила в UpdateRule: nil означает
// «поле не трогаем» (COALESCE в самом UPDATE). Объект (object_id) не
// входит: перенос правила в другой проект — это удаление и создание,
// иначе легко увести правило в чужой проект.
type RulePatch struct {
Floor *string
Category *string
Description *string
Priority *string
AnchorAt *time.Time
PeriodDays *int
IsActive *bool
}
// UpdateRule — частичное обновление правила: применятся только
// переданные поля. Чужое или несуществующее → ErrNotFound: выборка
// уже подрезана clientID. Срок не трогается — он пересчитывается сам
// от COALESCE(last_run_at, created_at) с новым расписанием (nextDue).
func (r *RecurringRepo) UpdateRule(ctx context.Context, id int64, clientID string, patch RulePatch) error {
query := `UPDATE recurring_rules
SET floor = NULLIF(COALESCE($2, floor), ''),
category = COALESCE($3, category),
description = COALESCE($4, description),
priority = COALESCE($5, priority),
anchor_at = COALESCE($6, anchor_at),
period_days = COALESCE($7, period_days),
is_active = COALESCE($8, is_active)
WHERE id = $1`
args := []any{id, patch.Floor, patch.Category, patch.Description,
patch.Priority, patch.AnchorAt, patch.PeriodDays, patch.IsActive}
if clientID != "" {
query += ` AND client_id = $9`
args = append(args, clientID)
}
tag, err := r.db.Exec(ctx, query, args...)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return notFound("recurring rule %d", id)
}
return nil
}
// DeleteRule — удалить правило; чужое или несуществующее → ErrNotFound.
// История срабатываний уходит каскадом (0014).
func (r *RecurringRepo) DeleteRule(ctx context.Context, id int64, clientID string) error {
query := `DELETE FROM recurring_rules WHERE id = $1`
args := []any{id}
if clientID != "" {
query += ` AND client_id = $2`
args = append(args, clientID)
}
tag, err := r.db.Exec(ctx, query, args...)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return notFound("recurring rule %d", id)
}
return nil
}
// ============ история срабатываний ============
// ListRuns — история срабатываний правила (сначала свежие). Заявку
// показываем с адресом/категорией на момент создания; если её удалили
// каскадом (проект), строка истории остаётся, поля пустые (LEFT JOIN).
// Владение проверяет вызывающий — GetRule до этого, здесь только правило.
func (r *RecurringRepo) ListRuns(ctx context.Context, ruleID int64, page Page) ([]models.RecurringRun, int, error) {
page = page.normalize()
order, err := orderBy(page, RecurringRunSorts, "run_at", "rr.id DESC")
if err != nil {
return nil, 0, err
}
rows, err := r.db.Query(ctx,
`SELECT rr.id, rr.rule_id, rr.ticket_id, rr.run_at,
COALESCE(t.address, ''), COALESCE(t.category, ''),
COUNT(*) OVER() AS total
FROM recurring_runs rr
LEFT JOIN tickets t ON t.id = rr.ticket_id
WHERE rr.rule_id = $1`+order+`
LIMIT $2 OFFSET $3`,
ruleID, page.Limit, page.Offset)
if err != nil {
return nil, 0, classify(err)
}
defer rows.Close()
var runs []models.RecurringRun
total := 0
for rows.Next() {
var run models.RecurringRun
if err := rows.Scan(&run.ID, &run.RuleID, &run.TicketID, &run.RunAt,
&run.Address, &run.Category, &total); err != nil {
return nil, 0, classify(err)
}
runs = append(runs, run)
}
if err := rows.Err(); err != nil {
return nil, 0, classify(err)
}
if len(runs) == 0 {
if err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM recurring_runs WHERE rule_id = $1`,
ruleID).Scan(&total); err != nil {
return nil, 0, classify(err)
}
}
return runs, total, nil
}
// ============ планировщик ============
// DueRun — результат срабатывания одного правила: что создалось и куда
// слать уведомление (Target — profiles.telegram_target, "" — не настроено).
type DueRun struct {
RuleID int64
TicketID int64
ClientID string
Target string
Address string
Category string
// Description — текст правила: в уведомлении он объясняет, зачем
// заявка появилась («осмотр вентиляции по графику»).
Description string
Priority string
}
// RunDueRules — главный метод планировщика: создаёт заявки по всем
// правилам, у которых наступил срок (точка сетки anchor_at + k·period).
//
// Условия срабатывания (всё перепроверяется под блокировкой строки
// правила, поэтому параллельный прогон не создаст дубль):
// - правило активно, проект и объект включены;
// - срок наступил: nextDue(anchor_at, period_days,
// COALESCE(last_run_at, created_at)) <= now — отправная точка —
// создание, поэтому первая заявка появится первой точкой сетки
// после заведения правила, а не сразу;
// - прошлая заявка по этому правилу закрыта — пока открыта, новую не
// создаём (ждём закрытия), срок при этом не сдвигается: после
// закрытия следующий тик создаст заявку сразу.
//
// Каждое правило обрабатывается в своей транзакции: ошибка одного не
// откатывает остальные. Возвращает созданные заявки и первую ошибку
// (если была) — вызывающий шлёт уведомления по created, ошибки идут в лог.
func (r *RecurringRepo) RunDueRules(ctx context.Context, now time.Time) ([]DueRun, error) {
b, ok := r.db.(Beginer)
if !ok {
return nil, errors.New("repository: querier cannot begin a transaction")
}
ids, err := r.dueRuleIDs(ctx, now)
if err != nil {
return nil, err
}
var runs []DueRun
var firstErr error
for _, id := range ids {
run, err := r.runRule(ctx, b, id, "", false, now)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if run != nil {
runs = append(runs, *run)
}
}
return runs, firstErr
}
// RunRuleNow — ручной запуск одного правила (POST /recurring/:id/run).
// То же срабатывание, что у планировщика, но без ожидания срока:
// заявка создаётся сразу. Остальные рубежи остаются — правило, проект
// и объект включены, прошлая заявка закрыта (иначе дубль): нарушение —
// ErrConflict с причиной, чужое правило — ErrNotFound (clientID).
// last_run_at сдвигается как обычно — следующий срок отсчитывается
// от ручного запуска.
func (r *RecurringRepo) RunRuleNow(ctx context.Context, id int64, clientID string, now time.Time) (*DueRun, error) {
b, ok := r.db.(Beginer)
if !ok {
return nil, errors.New("repository: querier cannot begin a transaction")
}
return r.runRule(ctx, b, id, clientID, true, now)
}
// dueRuleIDs — кандидаты на срабатывание: активные правила, чей проект
// и объект включены, у которых срок наступил (созревание —
// в Go: сетку сроков anchor_at + k·period не выразить в SQL без
// прижатия дней). Проверка «нет открытой заявки» стоит в runRule —
// там она атомарна вместе с созданием.
func (r *RecurringRepo) dueRuleIDs(ctx context.Context, now time.Time) ([]int64, error) {
rows, err := r.db.Query(ctx,
`SELECT r.id, r.anchor_at, r.period_days,
COALESCE(r.last_run_at, r.created_at)
FROM recurring_rules r
JOIN projects p ON p.id = r.project_id
JOIN project_objects o ON o.id = r.object_id
WHERE r.is_active AND p.is_active AND o.is_active
ORDER BY r.id`)
if err != nil {
return nil, classify(err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
var anchor, ref time.Time
var period int
if err := rows.Scan(&id, &anchor, &period, &ref); err != nil {
return nil, classify(err)
}
if !nextDue(anchor, period, ref).After(now) {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
// runRule — срабатывание одного правила в транзакции.
//
// Условия рубежей общие для планировщика и ручного запуска, реакция —
// разная: планировщик (force=false) пропускает правило молча (nil, nil),
// срок не сдвигается, следующий тик проверит снова; ручной запуск
// (force=true) получает ErrNotFound или ErrConflict с причиной — её
// хендлер отдаёт клиенту.
//
// «Срок ещё не наступил» — условие только планировщика
// (nextDue(...) <= now), поэтому ручной запуск созревания не ждёт.
// Отправная точка — COALESCE(last_run_at, created_at): правило, которое
// ещё ни разу не срабатывало, создаёт первую заявку первой точкой
// сетки после создания.
//
// clientID != "" ограничивает выборку правилами клиента — чужое
// неотличимо от несуществующего на этом шаге, а не «прочитал и потом
// запустил». FOR UPDATE сериализует параллельные запуски (второй
// инстанс, два ручных запроса): проверка «нет открытой заявки» и
// создание заявки — одна атомарная операция.
func (r *RecurringRepo) runRule(ctx context.Context, b Beginer, ruleID int64, clientID string, force bool, now time.Time) (*DueRun, error) {
tx, err := b.Begin(ctx)
if err != nil {
return nil, classify(err)
}
defer func() { _ = tx.Rollback(ctx) }() // после Commit — безопасный no-op
// Причина конфликта: планировщику — молчаливый пропуск, ручному
// запуску — ErrConflict (→ 409) с текстом причины.
conflict := func(reason error) (*DueRun, error) {
if !force {
return nil, nil
}
return nil, fmt.Errorf("%w: %w", ErrConflict, reason)
}
query := `SELECT r.id, r.client_id, r.project_id, r.object_id,
r.category, r.description, r.priority, COALESCE(r.floor, ''),
COALESCE(r.created_by::text, ''), o.address,
COALESCE(pf.telegram_target, ''),
r.is_active, p.is_active, o.is_active,
r.anchor_at, r.period_days,
COALESCE(r.last_run_at, r.created_at)
FROM recurring_rules r
JOIN projects p ON p.id = r.project_id
JOIN project_objects o ON o.id = r.object_id
LEFT JOIN profiles pf ON pf.id = r.client_id
WHERE r.id = $1`
args := []any{ruleID}
if clientID != "" {
query += ` AND r.client_id = $` + itoa(len(args)+1)
args = append(args, clientID)
}
query += ` FOR UPDATE OF r`
var run DueRun
var projectID, objectID int64
var floor, createdBy string
var ruleActive, projectActive, objectActive bool
var anchor, ref time.Time
var period int
err = tx.QueryRow(ctx, query, args...).
Scan(&run.RuleID, &run.ClientID, &projectID, &objectID,
&run.Category, &run.Description, &run.Priority, &floor,
&createdBy, &run.Address, &run.Target,
&ruleActive, &projectActive, &objectActive,
&anchor, &period, &ref)
if errors.Is(err, pgx.ErrNoRows) {
// Планировщику: правила нет или чужое (для него созревание
// уже отфильтровано в dueRuleIDs).
if !force {
return nil, nil
}
return nil, notFound("recurring rule %d", ruleID)
}
if err != nil {
return nil, classify(err)
}
// Рубежи активности: правило выключено — пауза, проект/объект
// выключены — заявки некуда ставить.
if !ruleActive {
return conflict(ErrRuleNotActive)
}
if !projectActive {
return conflict(ErrProjectNotActive)
}
if !objectActive {
return conflict(ErrObjectNotActive)
}
// Срок не наступил — планировщик ждёт (ручной запуск эту
// проверку обходит: force). Проверка под блокировкой строки —
// после сдвига срока другим инстансом она не «протухает».
if !force && nextDue(anchor, period, ref).After(now) {
return nil, nil
}
// Прошлая заявка по правилу ещё открыта: ждём её закрытия — без
// дублей. Срок при этом не сдвигается: после закрытия следующий
// тик создаст заявку сразу же.
var open bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (
SELECT 1 FROM recurring_runs rr
JOIN tickets t ON t.id = rr.ticket_id
WHERE rr.rule_id = $1 AND t.status <> 'closed')`,
ruleID).Scan(&open); err != nil {
return nil, classify(err)
}
if open {
return conflict(ErrRuleOpen)
}
// Заявка: снимок адреса с объекта, статус new (дефолт колонки).
var ticketID int64
err = tx.QueryRow(ctx,
`INSERT INTO tickets
(client_id, project_id, object_id, address, floor,
category, description, priority)
VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7, $8)
RETURNING id`,
run.ClientID, projectID, objectID, run.Address, floor,
run.Category, run.Description, run.Priority,
).Scan(&ticketID)
if err != nil {
return nil, classify(err)
}
run.TicketID = ticketID
// Журнал: автор — тот, кто завёл правило (fallback — владелец),
// чтобы «кто создал» читалось в истории заявки.
actor := createdBy
if actor == "" {
actor = run.ClientID
}
if _, err := tx.Exec(ctx,
`INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status)
VALUES ($1, $2, 'created', NULL, 'new')`,
ticketID, actor); err != nil {
return nil, classify(err)
}
// История срабатывания и сдвиг срока — в той же транзакции, что и
// заявка: «заявка есть, а правило думает, что не запускалось»
// невозможно.
if _, err := tx.Exec(ctx,
`INSERT INTO recurring_runs (rule_id, ticket_id, run_at)
VALUES ($1, $2, $3)`,
ruleID, ticketID, now); err != nil {
return nil, classify(err)
}
if _, err := tx.Exec(ctx,
`UPDATE recurring_rules SET last_run_at = $2 WHERE id = $1`,
ruleID, now); err != nil {
return nil, classify(err)
}
if err := tx.Commit(ctx); err != nil {
return nil, classify(err)
}
return &run, nil
}