`artist:demote-expired-options` artisan command finds every ArtistEngagement still in Option whose option_expires_at has passed, transitions it back to Draft via the existing state-machine (transitionStatus), and writes an `option_expired` activity entry with the original expiry timestamp captured in properties so the audit log distinguishes system-driven expiries from manual demotions. Idempotency: the state-machine bails when the engagement is no longer in Option, so a second run within the same minute is a no-op for any given row. The auto-logged `updated` row + the explicit `status_changed` + the `option_expired` entries are emitted only by the run that actually performs the transition. Scheduled in routes/console.php daily at 03:00 Europe/Amsterdam, matching the existing nightly low-traffic window. Notification (email project leader on demotion) is deferred to the notification framework that lands post-Accreditation; tracked under BACKLOG entry ART-DEMOTE-NOTIFICATION. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 lines
2.9 KiB
PHP
80 lines
2.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands\Artist;
|
|
|
|
use App\Enums\Artist\ArtistEngagementStatus;
|
|
use App\Models\ArtistEngagement;
|
|
use App\Models\Scopes\OrganisationScope;
|
|
use App\Services\Artist\ArtistEngagementService;
|
|
use Illuminate\Console\Command;
|
|
|
|
/**
|
|
* RFC v0.2 — daily option-expiry demotion.
|
|
*
|
|
* Finds every engagement with booking_status = Option whose
|
|
* option_expires_at has passed, transitions it to Draft via the state
|
|
* machine (which records the transition activity entry), and writes
|
|
* an additional `option_expired` activity event so the audit log can
|
|
* distinguish system-driven expiries from manual demotions.
|
|
*
|
|
* Idempotency: the state machine returns immediately when the
|
|
* engagement is no longer in Option (e.g. another run already
|
|
* demoted it), so a second run within the same minute is a no-op
|
|
* for any given engagement.
|
|
*
|
|
* Notification: notification framework lands post-Accreditation. For
|
|
* Session 2 the command writes activity log only; emailing the
|
|
* project leader is tracked under BACKLOG entry
|
|
* ART-DEMOTE-NOTIFICATION.
|
|
*/
|
|
final class DemoteExpiredOptions extends Command
|
|
{
|
|
protected $signature = 'artist:demote-expired-options';
|
|
|
|
protected $description = 'Demote ArtistEngagement rows whose option_expires_at has passed back to Draft.';
|
|
|
|
public function handle(ArtistEngagementService $service): int
|
|
{
|
|
$expired = ArtistEngagement::query()
|
|
->withoutGlobalScope(OrganisationScope::class)
|
|
->where('booking_status', ArtistEngagementStatus::Option->value)
|
|
->whereNotNull('option_expires_at')
|
|
->where('option_expires_at', '<=', now())
|
|
->whereNull('deleted_at')
|
|
->get();
|
|
|
|
$demotedIds = [];
|
|
foreach ($expired as $engagement) {
|
|
// Re-check status under fresh state — another worker / a
|
|
// user UI action may have already transitioned this row.
|
|
if ($engagement->booking_status !== ArtistEngagementStatus::Option) {
|
|
continue;
|
|
}
|
|
|
|
$service->transitionStatus($engagement, ArtistEngagementStatus::Draft);
|
|
|
|
activity('artist_engagement')
|
|
->performedOn($engagement)
|
|
->event('option_expired')
|
|
->withProperties([
|
|
'organisation_id' => $engagement->organisation_id,
|
|
'event_id' => $engagement->event_id,
|
|
'option_expires_at' => optional($engagement->option_expires_at)->toIso8601String(),
|
|
])
|
|
->log('option_expired');
|
|
|
|
$demotedIds[] = (string) $engagement->id;
|
|
}
|
|
|
|
$count = count($demotedIds);
|
|
$this->info("Demoted {$count} option(s) on ".now()->toDateString().'.');
|
|
if ($count > 0) {
|
|
$this->line('IDs: '.implode(', ', $demotedIds));
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
}
|