146 lines
8.3 KiB
PHP
146 lines
8.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Jobs\RunSimulation;
|
|
use App\Models\Simulation;
|
|
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
|
|
{
|
|
$tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null;
|
|
$scope = $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous');
|
|
$scopeHash = hash('sha256', $scope);
|
|
$keyHash = hash('sha256', strtolower($key));
|
|
$payload = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], '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-tax-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']];
|
|
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);
|
|
}
|
|
}
|