'datetime', ]; protected $appends = ['is_archived']; /** * Archived groups are deliberately NOT hidden by a global scope. * * The tickets in an archived group are still live correspondence: their * agents must keep working them, `$ticket->group` must still resolve, and * Settings must still list the group so a manager can restore it. Only the * *pickers* — "which group should this new ticket go to", "filter by * group" — exclude archived ones, via this scope, one call site at a time. */ public function scopeActive(Builder $query): Builder { return $query->whereNull('archived_at'); } public function scopeArchived(Builder $query): Builder { return $query->whereNotNull('archived_at'); } public function getIsArchivedAttribute(): bool { return $this->archived_at !== null; } public function tickets(): HasMany { return $this->hasMany(Ticket::class, 'group_id'); } public function priorityLevels(): HasMany { return $this->hasMany(PriorityLevel::class, 'group_id'); } public function agentAccess(): HasMany { return $this->hasMany(TicketingAgentAccess::class, 'group_id'); } public function projects(): HasMany { return $this->hasMany(TicketingProject::class, 'group_id'); } public function emailConnections(): HasMany { return $this->hasMany(EmailConnection::class, 'group_id'); } /** * Issue the next ticket reference for this group. * * This used to be MAX(suffix) over the group's live tickets. Tickets are * hard-deleted, so that made the reference a *reusable* handle: delete the * newest ticket and its number went back in the pool, and the next ticket was * issued the same one. Two tickets carrying `QAX-0004` is not a cosmetic * problem — the reference is the `[QAX-0004]` token in outbound mail subjects * (so a reply threads onto the wrong ticket), the key the shell Notification * Log is audited by, and what `merge()` resolves a ticket by. * * The number now comes from `last_ticket_number`, a per-group high-water mark * that is only ever raised. Deleting a ticket cannot lower it, because it does * not consult the tickets table for the answer. * * Two details that are not decoration: * * - `lockForUpdate` inside a transaction. Read-then-write is a race: two people * submitting at the same instant would otherwise both read N and both be * issued N+1, reintroducing exactly the duplicate this method exists to * prevent. The row lock serialises them. * - The counter is raised to clear any live ticket that is already numbered * past it before being incremented. Not every row gets its number from here — * `TicketingDemoSeeder` writes them directly, and imports and manual repairs * can too — and a counter that has fallen behind such a row would issue a * number that is already in use. Taking the max of the two can only ever move * the counter forwards, so this preserves the never-goes-backwards property * rather than weakening it. */ public function nextTicketNumber(): string { $prefix = $this->prefix . '-'; $next = DB::transaction(function () use ($prefix) { $counter = (int) static::query() ->whereKey($this->getKey()) ->lockForUpdate() ->value('last_ticket_number'); $liveMax = (int) $this->tickets() ->where('number', 'like', $prefix . '%') ->max(DB::raw('CAST(SUBSTRING(number, ' . (strlen($prefix) + 1) . ') AS UNSIGNED)')); $next = max($counter, $liveMax) + 1; // Query-builder update, so `last_ticket_number` need not be fillable — // it is a sequence, never something a form should be able to set. static::query()->whereKey($this->getKey())->update(['last_ticket_number' => $next]); $this->setAttribute('last_ticket_number', $next); return $next; }); return $prefix . str_pad($next, 4, '0', STR_PAD_LEFT); } }