`ticketing:auto-close` closed tickets with a single bulk UPDATE, which fires no model events. With the audit declaration in place that left one way of closing a ticket invisible: the Changes log would show a ticket going open → resolved and then nothing, while the row itself read `closed`. "Who closed this" is the question the trail exists to answer and "the nightly sweep did" is a real answer — but only if the sweep writes it. Closed one at a time through the model, chunked by id so a first sweep over a backlog does not load every resolved ticket at once. Paging by id is what makes that safe while the filtered column is being written.
64 lines
2.5 KiB
PHP
64 lines
2.5 KiB
PHP
<?php
|
|
|
|
namespace Dashboard\Ticketing\Console\Commands;
|
|
|
|
use App\Models\Setting;
|
|
use Dashboard\Ticketing\Models\Ticket;
|
|
use Illuminate\Console\Command;
|
|
|
|
class AutoCloseTickets extends Command
|
|
{
|
|
protected $signature = 'ticketing:auto-close';
|
|
protected $description = 'Close resolved tickets that have not been updated within the configured grace period.';
|
|
|
|
public function handle(): int
|
|
{
|
|
$days = (int) Setting::get('ticketing.auto_close_days', 0);
|
|
|
|
if ($days <= 0) {
|
|
$this->info('Auto-close is disabled (ticketing.auto_close_days = 0).');
|
|
return 0;
|
|
}
|
|
|
|
$cutoff = now()->subDays($days);
|
|
|
|
/*
|
|
* CLOSED ONE AT A TIME, THROUGH THE MODEL — [central-ticketing] #4781.
|
|
*
|
|
* This was a single `->update(['status' => 'closed'])`, which is one
|
|
* statement and no model events. Since #4781 a ticket's status change is
|
|
* an audited fact, and a bulk update would have left exactly one way of
|
|
* closing a ticket invisible: the Changes log would show a ticket going
|
|
* open → resolved and then nothing, while the row itself said `closed`.
|
|
* "Who closed this" is the question the audit trail exists to answer,
|
|
* and "the nightly sweep did, on this date" is a real answer — but only
|
|
* if the sweep writes it.
|
|
*
|
|
* Per-ticket rather than one summary event because the answer has to be
|
|
* on THE TICKET: the per-record history panel reads rows whose subject
|
|
* is that ticket, and an aggregate "closed 14 tickets" row would not
|
|
* appear in any of their histories.
|
|
*
|
|
* `chunkById`, so a first sweep over a long backlog does not load every
|
|
* resolved ticket into memory at once. Paging by id is what makes this
|
|
* safe while the filtered column is being written: each page continues
|
|
* from the last id seen rather than from an offset, so rows dropping out
|
|
* of `status = 'resolved'` cannot cause a page to be skipped.
|
|
*/
|
|
$count = 0;
|
|
|
|
Ticket::where('status', 'resolved')
|
|
->where('updated_at', '<=', $cutoff)
|
|
->chunkById(200, function ($tickets) use (&$count) {
|
|
foreach ($tickets as $ticket) {
|
|
$ticket->forceFill(['status' => 'closed'])->save();
|
|
$count++;
|
|
}
|
|
});
|
|
|
|
$this->info("Auto-closed {$count} ticket(s) resolved more than {$days} day(s) ago.");
|
|
|
|
return 0;
|
|
}
|
|
}
|