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

660 lines
27 KiB
Go
Raw 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"
)
// 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)
}