Files
Helpddesk/Modules/Helpdesk/Services/TicketReferenceService.php
T
2026-08-25 12:36:04 +01:00

128 lines
4.0 KiB
PHP

<?php
/**
* TicketReferenceService.php
*
* Generates collision-resistant Helpdesk ticket references.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Ticket;
final class TicketReferenceService
{
/**
* Build the next human-readable ticket reference for the current year.
*
* The preferred strategy uses a dedicated sequence row locked inside the
* ticket creation transaction. The initial sequence value is backfilled from
* all existing ticket references, including soft-deleted tickets, so a
* previously used reference can never be reused while the unique index still
* protects it.
*/
public function next(): string
{
$year = (int) now()->format('Y');
$prefix = 'HD';
$seriesPrefix = $prefix.'-'.$year.'-';
$this->lockReferenceSeries($seriesPrefix);
if (Schema::hasTable('helpdesk_ticket_sequences')) {
return $this->nextFromSequenceTable($prefix, $year, $seriesPrefix);
}
return $this->nextFromExistingReferences($seriesPrefix);
}
/**
* Return the next reference from the durable sequence table.
*/
private function nextFromSequenceTable(string $prefix, int $year, string $seriesPrefix): string
{
$sequence = DB::table('helpdesk_ticket_sequences')
->where('prefix', $prefix)
->where('year', $year)
->lockForUpdate()
->first();
if (! $sequence) {
DB::table('helpdesk_ticket_sequences')->insert([
'prefix' => $prefix,
'year' => $year,
'next_number' => $this->highestExistingSuffix($seriesPrefix) + 1,
'created_at' => now(),
'updated_at' => now(),
]);
$sequence = DB::table('helpdesk_ticket_sequences')
->where('prefix', $prefix)
->where('year', $year)
->lockForUpdate()
->first();
}
$nextNumber = max((int) $sequence->next_number, $this->highestExistingSuffix($seriesPrefix) + 1);
DB::table('helpdesk_ticket_sequences')
->where('id', $sequence->id)
->update([
'next_number' => $nextNumber + 1,
'updated_at' => now(),
]);
return $seriesPrefix.str_pad((string) $nextNumber, 5, '0', STR_PAD_LEFT);
}
/**
* Fallback for installations that have not run the sequence migration yet.
*/
private function nextFromExistingReferences(string $seriesPrefix): string
{
return $seriesPrefix.str_pad((string) ($this->highestExistingSuffix($seriesPrefix) + 1), 5, '0', STR_PAD_LEFT);
}
/**
* Find the highest used suffix across active and soft-deleted tickets.
*/
private function highestExistingSuffix(string $seriesPrefix): int
{
/** @var list<string> $existingReferences */
$existingReferences = Ticket::query()
->withoutGlobalScopes()
->withTrashed()
->where('reference', 'like', $seriesPrefix.'%')
->pluck('reference')
->filter(static fn (mixed $reference): bool => is_string($reference) && preg_match('/^'.preg_quote($seriesPrefix, '/').'\d+$/', $reference) === 1)
->values()
->all();
$highestSuffix = 0;
foreach ($existingReferences as $existingReference) {
$highestSuffix = max($highestSuffix, (int) substr($existingReference, strlen($seriesPrefix)));
}
return $highestSuffix;
}
/**
* Lock the current ticket reference series for the active transaction.
*/
private function lockReferenceSeries(string $seriesPrefix): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::select('select pg_advisory_xact_lock(hashtext(?))', [
'helpdesk-ticket-reference:'.$seriesPrefix,
]);
}
}