axe/internal/repository/ticket.go

660 lines
27 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"building-mgmt/internal/models"
)
// TicketRepo — заявки и переходы статусной машины. Каждый переход
// описан в самом UPDATE: `WHERE` проверяет и статус, и принадлежность,
// поэтому запрещённая операция не доходит до логики вообще.
type TicketRepo struct {
db Querier
}
// NewTicketRepo создаёт репозиторий поверх Querier (пул или транзакция).
func NewTicketRepo(db Querier) *TicketRepo {
return &TicketRepo{db: db}
}
// Create — создать заявку. Принадлежность (объект → проект → клиент)
// проверяют составные FK в БД, а не эта функция: сюда приходит уже
// проверенный хендлером объект. Событие 'created' в журнале пишется
// той же операцией — заявка без первой записи истории невозможна.
func (r *TicketRepo) Create(ctx context.Context, t *models.Ticket) error {
err := r.db.QueryRow(ctx,
`WITH ins AS (
INSERT INTO tickets (client_id, project_id, object_id, address, floor, category, description, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, status, created_at
), ev AS (
INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status)
SELECT id, $1, 'created', NULL, status FROM ins
)
SELECT id, status, created_at FROM ins`,
t.ClientID, t.ProjectID, t.ObjectID, t.Address, t.Floor, t.Category, t.Description, t.Priority,
).Scan(&t.ID, &t.Status, &t.CreatedAt)
return classify(err)
}
// Schedule — расписание правила повторения: точка отсчёта (дата-время
// первого запуска) плюс период в днях, см. models.RecurringRule.
type Schedule struct {
AnchorAt time.Time
PeriodDays int
}
// CreateWithRule — заявка + правило повторения одной операцией
// (чекбокс «Повторяющаяся заявка» в форме новой заявки): шаблон
// правила — поля самой заявки, расписание — из Schedule. Связь
// «правило ↔ первая заявка» пишется в recurring_runs тем же прогоном:
// - блок на странице заявки зацепился за правило;
// - незакрытая первая заявка блокирует следующий автозапуск
// (тот же EXISTS, что и для заявок, созданных планировщиком);
// - история срабатываний показывает «семенную» заявку.
//
// createdBy — кто поставил галочку (для журнала правила). Возвращает
// id созданного правила.
func (r *TicketRepo) CreateWithRule(ctx context.Context, t *models.Ticket, s Schedule, createdBy string) (int64, error) {
var ruleID int64
err := r.db.QueryRow(ctx,
`WITH ins AS (
INSERT INTO tickets (client_id, project_id, object_id, address, floor, category, description, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, status, created_at
), ev AS (
INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status)
SELECT id, $1, 'created', NULL, status FROM ins
), rule AS (
INSERT INTO recurring_rules
(client_id, project_id, object_id, category, description,
priority, floor, anchor_at, period_days, created_by)
VALUES ($1, $2, $3, $6, $7, $8, NULLIF($5, ''), $9, $10,
NULLIF($11, '')::uuid)
RETURNING id
), link AS (
INSERT INTO recurring_runs (rule_id, ticket_id)
SELECT rule.id, ins.id FROM rule, ins
RETURNING rule_id
)
SELECT ins.id, ins.status, ins.created_at, link.rule_id
FROM ins, link`,
t.ClientID, t.ProjectID, t.ObjectID, t.Address, t.Floor,
t.Category, t.Description, t.Priority, s.AnchorAt, s.PeriodDays, createdBy,
).Scan(&t.ID, &t.Status, &t.CreatedAt, &ruleID)
return ruleID, classify(err)
}
// ticketColumns — проекция заявки. Один источник для GetByID и List,
// чтобы новое поле не приходилось дописывать в двух запросах.
const ticketColumns = `
t.id, t.client_id, t.worker_id, t.assigned_by,
t.project_id, t.object_id,
t.address, COALESCE(t.floor, ''), t.category, t.description,
t.priority, t.status, t.created_at,
t.assigned_at, t.started_at, t.done_at, t.confirmed_at, t.closed_at,
COALESCE(t.reject_reason, ''), COALESCE(t.cancel_reason, ''),
t.rejected_at, t.closed_by, t.rework_count,
c.name AS client_name,
COALESCE(w.name, '') AS worker_name,
p.name AS project_name,
o.name AS object_name`
// ticketFrom — общий FROM с JOIN'ами, подтягивающими имена.
const ticketFrom = `
FROM tickets t
JOIN profiles c ON c.id = t.client_id
JOIN projects p ON p.id = t.project_id
JOIN project_objects o ON o.id = t.object_id
LEFT JOIN profiles w ON w.id = t.worker_id`
// scanner — общий интерфейс для pgx.Row и pgx.Rows.
type scanner interface {
Scan(dest ...any) error
}
// scanTicket читает строку в модель. Порядок Scan обязан совпадать
// с ticketColumns. extra — доп. назначения в конце SELECT (например
// COUNT(*) OVER() у списка): значение читается в вызывающий код.
func scanTicket(s scanner, extra ...any) (models.Ticket, error) {
var t models.Ticket
dest := []any{
&t.ID, &t.ClientID, &t.WorkerID, &t.AssignedBy,
&t.ProjectID, &t.ObjectID,
&t.Address, &t.Floor, &t.Category, &t.Description,
&t.Priority, &t.Status, &t.CreatedAt,
&t.AssignedAt, &t.StartedAt, &t.DoneAt, &t.ConfirmedAt, &t.ClosedAt,
&t.RejectReason, &t.CancelReason,
&t.RejectedAt, &t.ClosedBy, &t.ReworkCount,
&t.ClientName, &t.WorkerName, &t.ProjectName, &t.ObjectName,
}
dest = append(dest, extra...)
err := s.Scan(dest...)
return t, classify(err)
}
// GetByID — одна заявка с именами клиента и мастера; если заявка
// связана с правилом повторения (создана вместе с ним или планировщиком),
// подтягивается и оно — блок «Повторяющаяся заявка» на странице.
func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*models.Ticket, error) {
t, err := scanTicket(r.db.QueryRow(ctx,
`SELECT `+ticketColumns+ticketFrom+` WHERE t.id = $1`, id))
if err != nil {
return nil, fmt.Errorf("ticket %d: %w", id, err)
}
var rec models.TicketRecurring
var ref time.Time
err = r.db.QueryRow(ctx,
`SELECT r.id, r.anchor_at, r.period_days, r.is_active,
COALESCE(r.last_run_at, r.created_at)
FROM recurring_runs rr
JOIN recurring_rules r ON r.id = rr.rule_id
WHERE rr.ticket_id = $1
ORDER BY rr.id
LIMIT 1`, id).
Scan(&rec.RuleID, &rec.AnchorAt, &rec.PeriodDays, &rec.IsActive, &ref)
switch {
case errors.Is(err, pgx.ErrNoRows):
// Заявка не связана с правилом или правило удалили (каскад) —
// блок на странице не показываем.
case err != nil:
return nil, classify(err)
default:
// Ближайший запуск — та же чистая функция, что у списка правил
// (nextDue от последнего срабатывания или создания).
next := nextDue(rec.AnchorAt, rec.PeriodDays, ref)
rec.NextRunAt = &next
t.Recurring = &rec
}
return &t, nil
}
// ListFilter — какие заявки запрашивать. Все поля опциональны:
// незаданный фильтр ничего не режет, в комбинации с выборкой по роли.
type ListFilter struct {
// Если задан — только заявки этого клиента.
ClientID string
// Если задан — заявки этого мастера (назначенные ему).
WorkerID string
// Если задан — видимость мастера: назначенные ему плюс свободные
// (new) заявки проектов, где он активный участник. Свои плюс
// «можно взять самому» — ровно тот набор, что ему нужно.
FreeForWorker string
// Фильтры по полям заявки.
Status string
Category string
Priority string
// Если >0 — заявки этого проекта/объекта (структура вместо людей).
ProjectID int64
ObjectID int64
// Диапазон created_at: DateFrom включительно, DateTo исключительно.
// Хендлер кладёт в DateTo границу «следующего дня», чтобы date_to
// включал в себя весь свой день.
DateFrom *time.Time
DateTo *time.Time
// Пагинация и сортировка (см. page.go).
Page Page
}
// List — страница заявок с фильтрами плюс total: сколько строк подходило
// без LIMIT/OFFSET (COUNT(*) OVER() считает до обрезки — одним запросом).
func (r *TicketRepo) List(ctx context.Context, f ListFilter) ([]models.Ticket, int, error) {
page := f.Page.normalize()
order, err := orderBy(page, TicketSorts, "created_at", "t.id DESC")
if err != nil {
return nil, 0, err
}
where, whereArgs := ticketWhere(f)
args := append([]interface{}{}, whereArgs...) // копия: у страницы свои аргументы
query := `SELECT ` + ticketColumns + `, COUNT(*) OVER() AS total` + ticketFrom + `
WHERE ` + where + order + `
LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
args = append(args, page.Limit, page.Offset)
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, 0, classify(err)
}
defer rows.Close()
var tickets []models.Ticket
total := 0
for rows.Next() {
t, err := scanTicket(rows, &total)
if err != nil {
return nil, 0, err
}
tickets = append(tickets, t)
}
if err := rows.Err(); err != nil {
return nil, 0, classify(err)
}
if len(tickets) == 0 {
// Пустое окно (offset за концом): window-функция возвращает
// значение только вместе со строками, а страница без данных
// обязана всё равно сообщить полное число — досчитываем.
var count int
if err := r.db.QueryRow(ctx,
`SELECT COUNT(*)`+ticketFrom+` WHERE `+where, whereArgs...).Scan(&count); err != nil {
return nil, 0, classify(err)
}
total = count
}
return tickets, total, nil
}
// ticketWhere — WHERE из фильтров ListFilter. Один источник и для
// страницы, и для подсчёта total: два запроса обязаны считать
// один и тот же набор строк. Операторы — только литералы из этого
// файла, значения всегда уходят плейсхолдерами.
func ticketWhere(f ListFilter) (string, []interface{}) {
where := `1=1`
args := []interface{}{}
idx := 1
appendCond := func(column, op string, value interface{}) {
where += ` AND ` + column + ` ` + op + ` $` + itoa(idx)
args = append(args, value)
idx++
}
if f.ClientID != "" {
appendCond("t.client_id", "=", f.ClientID)
}
if f.WorkerID != "" {
appendCond("t.worker_id", "=", f.WorkerID)
}
if f.FreeForWorker != "" {
// Свободная заявка — status='new' и активное членство
// (тот же приём, что в Assign): чужие проекты не подглядеть.
where += ` AND (t.worker_id = $` + itoa(idx) + `
OR (t.status = 'new' AND EXISTS (
SELECT 1 FROM project_workers pw
WHERE pw.worker_id = $` + itoa(idx) + `
AND pw.project_id = t.project_id
AND pw.status = 'active')))`
args = append(args, f.FreeForWorker)
idx++
}
if f.Status != "" {
appendCond("t.status", "=", f.Status)
}
if f.Category != "" {
appendCond("t.category", "=", f.Category)
}
if f.Priority != "" {
appendCond("t.priority", "=", f.Priority)
}
if f.ProjectID > 0 {
appendCond("t.project_id", "=", f.ProjectID)
}
if f.ObjectID > 0 {
appendCond("t.object_id", "=", f.ObjectID)
}
if f.DateFrom != nil {
appendCond("t.created_at", ">=", *f.DateFrom)
}
if f.DateTo != nil {
appendCond("t.created_at", "<", *f.DateTo)
}
return where, args
}
// itoa — маленький хелпер, чтобы не тащить strconv ради одной цифры.
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return itoa(n/10) + string(rune('0'+n%10))
}
// Assign — назначить мастера. Только для заявок в статусе new.
//
// Членство мастера в проекте проверяется здесь же, в WHERE: это последний
// рубеж на уровне БД (см. шапку миграции 0004) — «чужой» мастер не
// назначается даже при обходе хендлера. С 0005 членство обязано быть
// активным: приглашённый, но не ответивший заявок не получает.
// С 0010 мастер обязан быть подтверждённым — хотя приглашение и не
// пускает неподтверждённых, это защита на случай ручных правок в БД.
func (r *TicketRepo) Assign(ctx context.Context, ticketID int64, workerID, assignedBy string) error {
tag, err := r.db.Exec(ctx,
`WITH upd AS (
UPDATE tickets
SET worker_id = $1,
assigned_by = $2,
status = 'assigned',
assigned_at = now()
WHERE id = $3
AND status = 'new'
AND EXISTS (
SELECT 1 FROM project_workers pw
JOIN profiles w ON w.id = pw.worker_id
WHERE pw.worker_id = $1 AND pw.project_id = tickets.project_id
AND pw.status = 'active'
AND w.confirmed_at IS NOT NULL)
RETURNING id
), ev AS (
INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status)
SELECT id, $2, 'assigned', 'new', 'assigned' FROM upd
)
SELECT id FROM upd`,
workerID, assignedBy, ticketID,
)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return r.stateError(ctx, ticketID,
"must be in 'new' status and the worker must be a member of the project")
}
return nil
}
// Start — взять в работу (или перезапустить после доработки).
// Только мастер, которому назначена. started_at не затирается:
// у второго круга остаётся время первого старта.
// prev читает статус ДО апдейта (один снапшот на всю операцию),
// чтобы в журнале стоял настоящий from_status: assigned или rejected.
func (r *TicketRepo) Start(ctx context.Context, ticketID int64, workerID string) error {
tag, err := r.db.Exec(ctx,
`WITH prev AS (
SELECT status FROM tickets WHERE id = $1
), upd AS (
UPDATE tickets
SET status = 'in_progress',
started_at = COALESCE(started_at, now())
WHERE id = $1
AND worker_id = $2
AND status IN ('assigned','in_progress','rejected')
RETURNING id, status
), ev AS (
INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status)
SELECT u.id, $2, 'started', p.status, u.status
FROM upd u JOIN prev p ON true
)
SELECT id FROM upd`,
ticketID, workerID,
)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return r.stateError(ctx, ticketID, "must be assigned to you and not finished")
}
return nil
}
// Done — завершить. Только мастер, взявший в работу.
func (r *TicketRepo) Done(ctx context.Context, ticketID int64, workerID string) error {
tag, err := r.db.Exec(ctx,
`WITH upd AS (
UPDATE tickets
SET status = 'done',
done_at = now()
WHERE id = $1
AND worker_id = $2
AND status = 'in_progress'
RETURNING id
), ev AS (
INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status)
SELECT id, $2, 'done', 'in_progress', 'done' FROM upd
)
SELECT id FROM upd`,
ticketID, workerID,
)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return r.stateError(ctx, ticketID, "must be in progress by you")
}
return nil
}
// Confirm — клиент принял работу: финальное закрытие заявки.
// Фиксируем и факт принятия (confirmed_at), и само закрытие (closed_at).
func (r *TicketRepo) Confirm(ctx context.Context, ticketID int64, clientID string) error {
tag, err := r.db.Exec(ctx,
`WITH upd AS (
UPDATE tickets
SET status = 'closed',
confirmed_at = now(),
closed_at = now()
WHERE id = $1
AND client_id = $2
AND status = 'done'
RETURNING id
), ev AS (
INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status)
SELECT id, $2, 'confirmed', 'done', 'closed' FROM upd
)
SELECT id FROM upd`,
ticketID, clientID,
)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return r.stateError(ctx, ticketID, "must be done and belong to the client")
}
return nil
}
// Reject — клиент отклонил выполненную работу: заявка уходит на доработку.
// Это не финал: статус 'rejected' открывает второй круг, причина обязательна
// (нарушение проверит констрейнт tickets_rejected_reason) и попадает
// в журнал как comment.
func (r *TicketRepo) Reject(ctx context.Context, ticketID int64, clientID, reason string) error {
tag, err := r.db.Exec(ctx,
`WITH upd AS (
UPDATE tickets
SET status = 'rejected',
rejected_at = now(),
reject_reason = $3,
rework_count = rework_count + 1
WHERE id = $1
AND client_id = $2
AND status = 'done'
RETURNING id
), ev AS (
INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status, comment)
SELECT id, $2, 'rejected', 'done', 'rejected', $3 FROM upd
)
SELECT id FROM upd`,
ticketID, clientID, reason,
)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return r.stateError(ctx, ticketID, "must be done and belong to the client")
}
return nil
}
// Close — окончательный отказ/отмена. В отличие от Reject это финал:
// заявка уходит в 'closed' с cancel_reason и закрывается тем, кто отменил.
// from — из каких статусов разрешено (клиенту нельзя трогать чужую работу
// в процессе, админу можно закрыть застрявшую). prev даёт журналу
// настоящий from_status, а не один из вариантов списка.
func (r *TicketRepo) Close(ctx context.Context, ticketID int64, actorID, reason string, from []string) error {
tag, err := r.db.Exec(ctx,
`WITH prev AS (
SELECT status FROM tickets WHERE id = $1
), upd AS (
UPDATE tickets
SET status = 'closed',
cancel_reason = $2,
closed_by = $3,
closed_at = now()
WHERE id = $1 AND status = ANY($4)
RETURNING id, status
), ev AS (
INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status, comment)
SELECT u.id, $3, 'canceled', p.status, u.status, $2
FROM upd u JOIN prev p ON true
)
SELECT id FROM upd`,
ticketID, reason, actorID, from,
)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return r.stateError(ctx, ticketID, "cannot be closed in its current status")
}
return nil
}
// ListEvents — страница журнала заявки: хронология событий с именами
// авторов (LEFT JOIN — автор мог быть удалён, событие останется).
// Сортировка по умолчанию — id ASC, то есть от создания вперёд.
func (r *TicketRepo) ListEvents(ctx context.Context, ticketID int64, page Page) ([]models.TicketEvent, int, error) {
page = page.normalize()
order, err := orderBy(page, EventSorts, "id", "e.id ASC")
if err != nil {
return nil, 0, err
}
rows, err := r.db.Query(ctx,
`SELECT e.id, e.ticket_id, COALESCE(a.id::text, ''), COALESCE(a.name, ''),
e.action, COALESCE(e.from_status, ''), e.to_status,
COALESCE(e.comment, ''), e.created_at,
COUNT(*) OVER() AS total
FROM ticket_events e
LEFT JOIN profiles a ON a.id = e.actor_id
WHERE e.ticket_id = $1`+order+`
LIMIT $2 OFFSET $3`,
ticketID, page.Limit, page.Offset)
if err != nil {
return nil, 0, classify(err)
}
defer rows.Close()
var events []models.TicketEvent
total := 0
for rows.Next() {
var ev models.TicketEvent
if err := rows.Scan(&ev.ID, &ev.TicketID, &ev.ActorID, &ev.ActorName,
&ev.Action, &ev.FromStatus, &ev.ToStatus, &ev.Comment,
&ev.CreatedAt, &total); err != nil {
return nil, 0, classify(err)
}
events = append(events, ev)
}
if err := rows.Err(); err != nil {
return nil, 0, classify(err)
}
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
if len(events) == 0 {
if err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM ticket_events WHERE ticket_id = $1`,
ticketID).Scan(&total); err != nil {
return nil, 0, classify(err)
}
}
return events, total, nil
}
// DeleteExpired — автоочистка: удалить закрытые заявки, закрытые раньше
// before. Сначала снимаются ключи объектов фото — после каскада строк
// на месте уже не будет, — затем один DELETE: события журнала и строки
// фото уходят каскадом (ON DELETE CASCADE, миграции 0006/0008), а
// сироты истории повторяющихся заявок (recurring_runs без ticket_id) —
// вторым DELETE в конце метода.
//
// Порядок «сначала строка, потом объект» — тот же, что в чистке фото:
// сбой удаления объекта остаётся в логе вызывающей стороны, строка в БД
// уже удалена и разъезда с журналом нет. Транзакция не нужна: closed —
// терминальный статус, фото в закрытую заявку не добавить (409), набор
// кандидатов между двумя шагами не меняется.
//
// Возвращает id удалённых заявок и ключи их объектов в хранилище
// (пустые срезы, если удалять нечего).
func (r *TicketRepo) DeleteExpired(ctx context.Context, before time.Time) ([]int64, []string, error) {
photoRows, err := r.db.Query(ctx,
`SELECT ph.object_key
FROM ticket_photos ph
JOIN tickets t ON t.id = ph.ticket_id
WHERE t.status = 'closed' AND t.closed_at < $1`, before)
if err != nil {
return nil, nil, classify(err)
}
defer photoRows.Close()
var keys []string
for photoRows.Next() {
var key string
if err := photoRows.Scan(&key); err != nil {
return nil, nil, classify(err)
}
keys = append(keys, key)
}
if err := photoRows.Err(); err != nil {
return nil, nil, classify(err)
}
delRows, err := r.db.Query(ctx,
`DELETE FROM tickets
WHERE status = 'closed' AND closed_at < $1
RETURNING id`, before)
if err != nil {
return nil, nil, classify(err)
}
defer delRows.Close()
var ids []int64
for delRows.Next() {
var id int64
if err := delRows.Scan(&id); err != nil {
return nil, nil, classify(err)
}
ids = append(ids, id)
}
if err := delRows.Err(); err != nil {
return nil, nil, classify(err)
}
// Сироты истории повторяющихся заявок: у удалённой заявки строка
// recurring_runs не удаляется (ticket_id → SET NULL, миграция 0014),
// а заявки удаляются только здесь (ручного DELETE нет, каскады
// проектов уносят историю вместе с правилами). Жить без заявки
// строке не на что — уходит тем же прогоном автоочистки.
if _, err := r.db.Exec(ctx,
`DELETE FROM recurring_runs WHERE ticket_id IS NULL`); err != nil {
return nil, nil, classify(err)
}
return ids, keys, nil
}
// exists — есть ли заявка с таким id.
func (r *TicketRepo) exists(ctx context.Context, id int64) (bool, error) {
var ok bool
if err := r.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM tickets WHERE id = $1)`, id).Scan(&ok); err != nil {
return false, classify(err)
}
return ok, nil
}
// stateError — UPDATE не применился. Разделяем «заявки нет» (404)
// и «заявка в другом статусе / назначена не тебе» (409),
// не выдавая наружу текстов ошибок БД.
func (r *TicketRepo) stateError(ctx context.Context, id int64, expectation string) error {
ok, err := r.exists(ctx, id)
if err != nil {
return err
}
if !ok {
return notFound("ticket %d", id)
}
return fmt.Errorf("%w: ticket %d %s", ErrConflict, id, expectation)
}