(string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'scenario_kind' => 'custom_planning', 'organization_id' => $organizationId]; ksort($payload); $payloadHash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($actorId, $organizationId, $token, $tokenHash, $scopeHash, $keyHash, $payloadHash, $payload): array { $this->setContext($actorId, $tokenHash); DB::select('select pg_advisory_xact_lock(261001, hashtext(?))', [$scopeHash.$keyHash]); $receipt = DB::table('simulation_idempotency')->where('scope_hash', $scopeHash)->where('key_hash', $keyHash)->first(); if ($receipt) { if (! hash_equals($receipt->payload_hash, $payloadHash)) { throw new HttpException(409, 'Idempotency-Key já foi utilizada com outros parâmetros.'); } return ['simulation' => Simulation::with(['scenarios', 'reports'])->whereKey((string) $receipt->simulation_id)->firstOrFail(), 'token' => null, 'replayed' => true]; } $plainToken = $actorId === null ? $token : null; $tokenHash = $actorId === null ? hash_hmac('sha256', $plainToken, (string) config('app.key')) : null; $this->setContext($actorId, $tokenHash); $id = (string) Str::uuid(); $simulation = Simulation::create([ 'id' => $id, 'questionnaire_version_id' => '61000100-0000-4000-8000-000000000001', 'owner_user_id' => $actorId, 'organization_id' => $organizationId, 'anonymous_token_hash' => $tokenHash, 'status' => 'queued', 'version_no' => 1, 'input_snapshot' => $payload, 'calculation_snapshot' => ['engine' => 'portal-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null], ]); $scenarioId = (string) Str::uuid(); DB::table('simulation_scenarios')->insert([ 'id' => $scenarioId, 'simulation_id' => $id, 'code' => 'baseline', 'label' => 'Cenário base', 'version_no' => 1, 'parameters' => json_encode($payload, JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(), ]); $jobId = (string) Str::uuid(); DB::table('simulation_jobs')->insert(['id' => $jobId, 'simulation_id' => $id, 'job_type' => 'execute', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]); DB::table('simulation_idempotency')->insert([ 'scope_hash' => $scopeHash, 'key_hash' => $keyHash, 'payload_hash' => $payloadHash, 'simulation_id' => $id, 'response_snapshot' => json_encode(['simulation_id' => $id], JSON_THROW_ON_ERROR), 'created_at' => now(), ]); DB::table('simulation_audit_events')->insert([ 'simulation_id' => $id, 'actor_user_id' => $actorId, 'action' => 'simulation.created', 'correlation_id' => $id, 'data' => json_encode(['status' => 'queued', 'scenario_count' => 1], JSON_THROW_ON_ERROR), 'occurred_at' => now(), ]); $simulation->load(['scenarios', 'reports']); RunSimulation::dispatch($id, $actorId, $tokenHash)->afterCommit(); return ['simulation' => $simulation, 'token' => $plainToken, 'replayed' => false]; }, 3); } public function find(string $id, Request $request): Simulation { return DB::transaction(function () use ($id, $request): Simulation { $this->setContext($request->user()?->id, $this->tokenHash($request)); return Simulation::with(['scenarios', 'reports'])->whereNull('deleted_at')->findOrFail($id); }); } /** @param array{label: string, base: int|float|string, rate: int|float|string, reduction: int|float|string} $data */ public function updateScenario(string $id, string $scenarioId, array $data, Request $request): Simulation { return DB::transaction(function () use ($id, $scenarioId, $data, $request): Simulation { $this->setContext($request->user()?->id, $this->tokenHash($request)); $simulation = Simulation::whereNull('deleted_at')->lockForUpdate()->findOrFail($id); $this->requireMatch($request, $simulation); $scenario = DB::table('simulation_scenarios')->where('simulation_id', $id)->where('id', $scenarioId)->lockForUpdate()->first(); abort_if($scenario === null, 404); $parameters = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'scenario_kind' => 'custom_planning']; DB::table('simulation_scenarios')->where('id', $scenarioId)->update([ 'label' => $data['label'], 'parameters' => json_encode($parameters, JSON_THROW_ON_ERROR), 'version_no' => $scenario->version_no + 1, 'updated_at' => now(), ]); $simulation->update(['version_no' => $simulation->version_no + 1, 'status' => 'queued', 'input_snapshot' => $parameters, 'result_snapshot' => null, 'error_code' => null]); DB::table('simulation_jobs')->where('simulation_id', $id)->where('job_type', 'execute')->update([ 'status' => 'pending', 'attempts' => 0, 'available_at' => now(), 'started_at' => null, 'finished_at' => null, 'error_code' => null, 'updated_at' => now(), ]); DB::table('simulation_audit_events')->insert([ 'simulation_id' => $id, 'actor_user_id' => $request->user()?->id, 'action' => 'scenario.updated', 'correlation_id' => $id, 'data' => json_encode(['scenario_id' => $scenarioId, 'version_no' => $scenario->version_no + 1], JSON_THROW_ON_ERROR), 'occurred_at' => now(), ]); RunSimulation::dispatch($id, $simulation->owner_user_id, $simulation->anonymous_token_hash)->afterCommit(); return $simulation->refresh()->load(['scenarios', 'reports']); }, 3); } public function tokenHash(Request $request): ?string { $token = $request->header('X-Simulation-Token'); return is_string($token) && $token !== '' ? hash_hmac('sha256', $token, (string) config('app.key')) : null; } public function setContext(?int $actorId, ?string $tokenHash): void { DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_token_hash', ?, true)", [(string) ($actorId ?? ''), (string) ($tokenHash ?? '')]); } private function requireMatch(Request $request, Simulation $simulation): void { $provided = $request->header('If-Match'); if (! $provided) { throw new HttpException(428, 'If-Match é obrigatório.'); } if (! hash_equals($this->etag($simulation), trim($provided))) { throw new HttpException(412, 'A simulação foi alterada desde a última leitura.'); } } public function etag(Simulation $simulation): string { return '"sim-'.$simulation->id.'-v'.$simulation->version_no.'"'; } public function authorizeOrganization(?int $actorId, ?string $organizationId): void { if ($organizationId === null) { return; } abort_unless($actorId !== null && DB::table('memberships')->where('user_id', $actorId)->where('organization_id', $organizationId) ->whereNull('deleted_at')->whereIn('role', ['admin', 'manager', 'officer'])->exists(), 403); } }