(string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'scenario_kind' => 'custom_planning', 'organization_id' => $organizationId]; return $this->createPayload($payload, $actorId, $organizationId, $token, $key); } /** * @param array $snapshot * @return array{simulation: Simulation, token: string|null, replayed: bool} */ public function createFiscalQaPreview(array $snapshot, int $actorId, string $key): array { abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404); abort_unless(($snapshot['scenario_kind'] ?? null) === 'fiscal_rule_qa_preview' && in_array($snapshot['result']['status'] ?? null, ['calculated', 'indeterminate'], true) && ($snapshot['presentation']['rounding_applied'] ?? null) === false, 422); return $this->createPayload(['scenario_kind' => 'fiscal_rule_qa_preview', 'fiscal_preview' => $snapshot, 'organization_id' => null], $actorId, null, null, $key); } /** * @param array $input * @return array{simulation: Simulation, token: string|null, replayed: bool} */ public function createPublishedFiscal(array $input, ?int $actorId, ?string $organizationId, ?string $token, string $key): array { $payload = ['scenario_kind' => 'fiscal_rule_published', 'fiscal_input' => $input, 'organization_id' => $organizationId]; return $this->createPayload($payload, $actorId, $organizationId, $token, $key, fn (): array => app(SimulationPublishedFiscal::class)->admit($input)); } /** * @param array $payload * @return array{simulation: Simulation, token: string|null, replayed: bool} */ private function createPayload(array $payload, ?int $actorId, ?string $organizationId, ?string $token, string $key, ?callable $admit = null): array { $tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null; $scopeHash = hash('sha256', $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous')); $keyHash = hash('sha256', strtolower($key)); 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, $admit): 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]; } if ($admit !== null) { $payload['fiscal_preview'] = $admit(); } $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' => in_array($payload['scenario_kind'], ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true) ? ['engine' => $payload['fiscal_preview']['context']['engine_version'], 'mode' => $payload['fiscal_preview']['fiscal_provenance']['mode'], 'arithmetic' => 'exact_decimal_no_rounding'] : ['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); abort_if(in_array($simulation->input_snapshot['scenario_kind'] ?? null, ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true), 422, 'Crie uma nova pré-visualização fiscal; este snapshot conserva a regra e as entradas originais.'); $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); } }