diff --git a/src/Console/Commands/AutoCloseTickets.php b/src/Console/Commands/AutoCloseTickets.php index 4f48099..fd1d529 100644 --- a/src/Console/Commands/AutoCloseTickets.php +++ b/src/Console/Commands/AutoCloseTickets.php @@ -22,9 +22,39 @@ class AutoCloseTickets extends Command $cutoff = now()->subDays($days); - $count = Ticket::where('status', 'resolved') + /* + * 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) - ->update(['status' => 'closed']); + ->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.");