111 lines
6.5 KiB
PHP
111 lines
6.5 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\Simulation;
|
|
use App\Services\SimulationExecution;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
class RunSimulation implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public int $tries = 3;
|
|
|
|
public int $timeout = 30;
|
|
|
|
public function __construct(public string $simulationId, public ?int $actorId, public ?string $tokenHash) {}
|
|
|
|
/** @return list<int> */
|
|
public function backoff(): array
|
|
{
|
|
return [10, 60];
|
|
}
|
|
|
|
public function handle(SimulationExecution $execution): void
|
|
{
|
|
$shouldRun = DB::transaction(function () use ($execution): bool {
|
|
$execution->setContext($this->actorId, $this->tokenHash);
|
|
$job = DB::table('simulation_jobs')->where('simulation_id', $this->simulationId)->where('job_type', 'execute')->lockForUpdate()->first();
|
|
if (! $job || in_array($job->status, ['succeeded', 'failed'], true)) {
|
|
return false;
|
|
}
|
|
DB::table('simulation_jobs')->where('id', $job->id)->update([
|
|
'status' => 'running', 'attempts' => min(3, max((int) $job->attempts + 1, (int) $this->attempts())),
|
|
'started_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
|
|
return true;
|
|
});
|
|
if (! $shouldRun) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($execution): void {
|
|
$execution->setContext($this->actorId, $this->tokenHash);
|
|
$simulation = Simulation::whereNull('deleted_at')->lockForUpdate()->find($this->simulationId);
|
|
if (! $simulation || $simulation->status === 'completed') {
|
|
return;
|
|
}
|
|
$job = DB::table('simulation_jobs')->where('simulation_id', $this->simulationId)->where('job_type', 'execute')->lockForUpdate()->first();
|
|
if (! $job || in_array($job->status, ['succeeded', 'failed'], true)) {
|
|
return;
|
|
}
|
|
$scenario = DB::table('simulation_scenarios')->where('simulation_id', $simulation->id)->orderBy('code')->first();
|
|
if (! $scenario) {
|
|
throw new \RuntimeException('Simulation has no scenario.');
|
|
}
|
|
$input = json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR);
|
|
$row = DB::selectOne('select (CAST(? AS numeric) * CAST(? AS numeric) / 100 * (1 - CAST(? AS numeric) / 100))::text as value', [$input['base'], $input['rate'], $input['reduction']]);
|
|
$result = ['base' => $input['base'], 'rate_percent' => $input['rate'], 'reduction_percent' => $input['reduction'], 'indicative_total' => $row->value,
|
|
'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-tax-arithmetic-v1'];
|
|
$json = json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
|
$canonicalResult = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$json])->canonical;
|
|
$resultHash = hash('sha256', $canonicalResult);
|
|
$resultId = (string) Str::uuid();
|
|
$engineVersion = 'portal-tax-arithmetic-v1+simulation-v'.$simulation->version_no;
|
|
DB::table('simulation_results')->insert([
|
|
'id' => $resultId, 'simulation_id' => $simulation->id, 'scenario_id' => $scenario->id, 'engine_version' => $engineVersion,
|
|
'result_snapshot' => $json, 'result_sha256' => $resultHash, 'created_at' => now(),
|
|
]);
|
|
$simulation->update(['status' => 'completed', 'version_no' => $simulation->version_no + 1, 'result_snapshot' => $result, 'error_code' => null]);
|
|
DB::table('simulation_jobs')->where('id', $job->id)->update(['status' => 'succeeded', 'finished_at' => now(), 'updated_at' => now()]);
|
|
DB::table('simulation_reports')->insert(['id' => (string) Str::uuid(), 'simulation_id' => $simulation->id, 'version_no' => $scenario->version_no,
|
|
'status' => 'ready', 'report_snapshot' => $json, 'report_sha256' => $resultHash, 'created_at' => now(), 'updated_at' => now()]);
|
|
$sourceJson = json_encode($input, JSON_THROW_ON_ERROR);
|
|
$canonicalSource = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$sourceJson])->canonical;
|
|
DB::table('simulation_result_sources')->insertOrIgnore(['id' => (string) Str::uuid(), 'result_id' => $resultId,
|
|
'source_type' => 'user_input', 'source_id' => $scenario->id, 'source_version' => (string) $scenario->version_no,
|
|
'source_snapshot' => $sourceJson, 'source_sha256' => hash('sha256', $canonicalSource), 'created_at' => now()]);
|
|
DB::table('simulation_audit_events')->insert(['simulation_id' => $simulation->id, 'actor_user_id' => $simulation->owner_user_id,
|
|
'action' => 'simulation.completed', 'correlation_id' => $simulation->id, 'data' => json_encode(['result_sha256' => hash('sha256', $json), 'attempt' => $job->attempts + 1], JSON_THROW_ON_ERROR), 'occurred_at' => now()]);
|
|
}, 3);
|
|
}
|
|
|
|
public function failed(\Throwable $exception): void
|
|
{
|
|
DB::transaction(function (): void {
|
|
app(SimulationExecution::class)->setContext($this->actorId, $this->tokenHash);
|
|
$job = DB::table('simulation_jobs')->where('simulation_id', $this->simulationId)->where('job_type', 'execute')->lockForUpdate()->first();
|
|
if (! $job || $job->status === 'succeeded') {
|
|
return;
|
|
}
|
|
DB::table('simulation_jobs')->where('id', $job->id)->update(['status' => 'failed', 'attempts' => 3, 'finished_at' => now(), 'error_code' => 'execution_failed', 'updated_at' => now()]);
|
|
$simulation = Simulation::where('id', $this->simulationId)->lockForUpdate()->first();
|
|
if ($simulation) {
|
|
if ($simulation->status !== 'failed') {
|
|
$simulation->update(['status' => 'failed', 'version_no' => $simulation->version_no + 1, 'error_code' => 'execution_failed']);
|
|
}
|
|
DB::table('simulation_audit_events')->insert(['simulation_id' => $simulation->id, 'actor_user_id' => $this->actorId,
|
|
'action' => 'simulation.failed', 'correlation_id' => $simulation->id, 'data' => json_encode(['error_code' => 'execution_failed'], JSON_THROW_ON_ERROR), 'occurred_at' => now()]);
|
|
}
|
|
});
|
|
}
|
|
}
|