*/ 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); $resultId = (string) Str::uuid(); $engineVersion = 'portal-arithmetic-v1+simulation-v'.$simulation->version_no; if (in_array($input['scenario_kind'] ?? null, ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true)) { if ($input['scenario_kind'] === 'fiscal_rule_qa_preview') { abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404); } $result = $input['fiscal_preview']; $engineVersion = $result['context']['engine_version']; $result['context']['scenario_id'] = (string) $scenario->id; $result['context']['scenario_version'] = (int) $scenario->version_no; } else { $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'], 'scenario_kind' => 'custom_planning', 'indicative_total' => $row->value, 'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-arithmetic-v1', 'contract_version' => 1, 'context' => [ 'reference_date' => substr((string) $scenario->created_at, 0, 10), 'currency' => null, 'currency_fraction_digits' => null, 'scenario_id' => (string) $scenario->id, 'scenario_version' => (int) $scenario->version_no, 'engine_version' => $engineVersion, 'ruleset_version' => null, ], 'result' => [ 'status' => 'calculated', 'value' => (string) $row->value, 'unit' => null, 'explanation' => 'Resultado aritmético determinístico; não constitui cálculo nem conclusão fiscal.', 'missing_inputs' => [], 'source_ids' => [], ]]; } $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); 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(isset($result['fiscal_provenance']) ? [ 'base' => $result['base'], 'currency' => $result['currency'], 'reference_date' => $result['context']['reference_date'], 'context' => $result['fiscal_provenance']['context'], 'scope_status' => $result['fiscal_provenance']['scope_status'], ] : $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()]); if (isset($result['fiscal_provenance'])) { $provenance = $result['fiscal_provenance']; $provenanceJson = json_encode($provenance, JSON_THROW_ON_ERROR); $canonicalProvenance = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$provenanceJson])->canonical; DB::table('simulation_result_sources')->insert([ 'id' => (string) Str::uuid(), 'result_id' => $resultId, 'source_type' => $input['scenario_kind'] === 'fiscal_rule_published' ? 'tax_rule_published' : 'tax_rule_draft', 'source_id' => $provenance['rule']['id'], 'source_version' => (string) $provenance['rule']['rule_version'], 'source_snapshot' => $provenanceJson, 'source_sha256' => hash('sha256', $canonicalProvenance), '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()]); } }); } }