Files
Investidor-V2/app/Services/SimulationExecution.php
T

191 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Jobs\RunSimulation;
use App\Models\Simulation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class SimulationExecution
{
/**
* @param array{base: int|float|string, rate: int|float|string, reduction: int|float|string, organization_id?: string|null} $data
* @return array{simulation: Simulation, token: string|null, replayed: bool}
*/
public function create(array $data, ?int $actorId, ?string $organizationId, ?string $token, string $key): array
{
$payload = ['base' => (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<array-key, mixed> $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<array-key, mixed> $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<array-key, mixed> $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);
}
}