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 }