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