context->identifiers(); abort_unless($this->context->userId() === $actor, 403); $unit = ServicePermissions::unitScoped($area) ? $this->context->unitId() : null; abort_if(ServicePermissions::unitScoped($area) && $unit === null, 403, 'Seleccione uma unidade autorizada para esta área.'); $this->permissions->require($actor, $ids['tenant_id'], $ids['company_id'], "$area.view", $unit); $this->permissions->require($actor, $ids['tenant_id'], $ids['company_id'], "$area.$action", $unit); return $ids + ['unit_id' => $unit]; } private function query(string $area, array $scope) { return DB::table('a_'.$area)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id']); } public function list(string $area, int $actor): array { $scope = $this->scope($area, $actor, 'view'); return $this->query($area, $scope)->orderByDesc('id')->get()->map(fn ($item) => $this->describe($area, $item))->all(); } public function show(string $area, int $id, int $actor): array { $scope = $this->scope($area, $actor, 'view'); $record = $this->query($area, $scope)->where('id', $id)->first(); abort_unless($record, 403, 'Registo fora do âmbito autorizado.'); $result = $this->describe($area, $record); $result['history'] = DB::table('a_history')->where('area', $area)->where('record_id', $id)->orderBy('version') ->get(['id','version','actor_id','snapshot','created_at'])->map(function ($row) { $row->snapshot = json_decode($row->snapshot, true); $row->event = DB::table('a_events as e')->join('a_outbox as o', 'o.event_id', '=', 'e.id')->where('e.history_id', $row->id) ->first(['e.id','e.type','e.schema_version','o.state']); return $row; })->all(); return $result; } private function describe(string $area, object $row): array { $result = (array) $row; $result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR); $result['version'] = (int) $row->version; $result['base_amount'] = ServicePermissions::unitScoped($area) ? DraftValues::base($result['data']) : null; $history = DB::table('a_history')->where('area', $area)->where('record_id', $row->id)->where('version', $row->version)->value('id'); $result['pending'] = $history ? DB::table('a_pending')->where('history_id', $history)->pluck('message', 'code')->all() : []; return $result; } public function save(string $area, ?int $id, int $actor, array $input, string $key): array { $action = $id === null ? 'create' : 'edit'; $scope = $this->scope($area, $actor, $action); $selectedUnit = $this->context->unitId(); abort_unless((bool) preg_match('/^[a-zA-Z0-9-]{8,100}$/D', $key), 422, 'É necessária uma chave de repetição válida.'); $hash = hash('sha256', json_encode($this->canonical([$area, $id, $scope, $input]), JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($area, $id, $actor, $input, $key, $scope, $selectedUnit, $hash, $action): array { DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail(); $this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selectedUnit); $this->scope($area, $actor, $action); $previous = DB::table('a_commands')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('command_key', $key)->first(); if ($previous) { abort_unless((int) $previous->actor_id === $actor && hash_equals($previous->request_hash, $hash), 409, 'Chave repetida com conteúdo ou autor diferente.'); return json_decode($previous->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true]; } $old = $id ? $this->query($area, $scope)->where('id', $id)->first() : null; if ($id) { abort_unless($old, 403, 'Registo fora do âmbito autorizado.'); abort_unless((int) $old->version === (int) ($input['version'] ?? 0), 409, 'O registo mudou. Reabra-o antes de guardar.'); } $version = $old ? (int) $old->version + 1 : 1; $values = $this->normalise($area, $input, $scope, $old); $data = $values['data']; $values['data'] = json_encode($data === [] ? new \stdClass() : $data, JSON_THROW_ON_ERROR); $values += ['version' => $version, 'updated_at' => now()]; if ($id) { $this->query($area, $scope)->where('id', $id)->update($values); } else { $id = DB::table('a_'.$area)->insertGetId($scope + $values + ['created_at' => now()]); } $record = $this->query($area, $scope)->where('id', $id)->first(); $snapshot = (array) $record; $snapshot['data'] = $data; $pending = ServicePermissions::unitScoped($area) ? DraftValues::pending($data) : []; if ($area === 'billing_drafts') { $current = DB::table('a_service_drafts')->where('id', $record->source_id)->value('version'); if ((int) $current !== (int) $data['source_version']) { $pending['source_changed'] = 'A prestação tem uma versão mais recente. Reveja ou actualize explicitamente os dados de origem.'; } } $snapshot['pending'] = $pending; $history = DB::table('a_history')->insertGetId($scope + ['area' => $area, 'record_id' => $id, 'version' => $version, 'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]); foreach ($pending as $code => $message) { DB::table('a_pending')->insert(['history_id' => $history, 'code' => $code, 'message' => $message, 'created_at' => now()]); } $affected = []; if ($area === 'service_drafts' && $old) { foreach (DB::table('a_billing_drafts')->where('source_id', $id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->get(['id','version']) as $billing) { $billingHistory = DB::table('a_history')->where('area', 'billing_drafts')->where('record_id', $billing->id)->where('version', $billing->version)->value('id'); DB::table('a_pending')->insertOrIgnore(['history_id' => $billingHistory, 'code' => 'source_changed', 'message' => 'A prestação de origem foi alterada; os dados deste rascunho não foram reescritos.', 'created_at' => now()]); $affected[] = $billing->id; } } $event = DB::table('a_events')->insertGetId(['history_id' => $history, 'type' => "a.$area.".($old ? 'updated' : 'created'), 'schema_version' => 1, 'payload' => json_encode(['snapshot' => $snapshot, 'affected_billing_drafts' => $affected], JSON_THROW_ON_ERROR), 'created_at' => now()]); DB::table('a_outbox')->insert(['event_id' => $event, 'state' => 'captured', 'created_at' => now()]); Audit::record("a.$area.".($old ? 'updated' : 'created'), $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['area' => $area, 'record_id' => $id, 'version' => $version, 'event_id' => $event]); $result = ['id' => $id, 'version' => $version, 'event_id' => $event]; DB::table('a_commands')->insert(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'actor_id' => $actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]); return $result + ['replayed' => false]; }); } private function normalise(string $area, array $input, array $scope, ?object $old): array { if (! ServicePermissions::unitScoped($area)) { return ['name' => trim($input['name']), 'data' => $area === 'clients' ? [] : ['description' => $input['description'] ?? null]]; } $oldData = $old ? json_decode($old->data, true) : []; $actor = $this->context->userId(); foreach (['clients.view', 'services.view'] as $permission) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], $permission); } $source = null; if ($area === 'billing_drafts') { $sourceId = $old ? $old->source_id : $input['source_id']; $source = DB::table('a_service_drafts')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('id', $sourceId)->first(); abort_unless($source, 403, 'Prestação fora da unidade autorizada.'); $reload = ! $old || ($input['reload_source'] ?? false); if ($reload) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'service_drafts.view', $scope['unit_id']); } $base = $reload ? json_decode($source->data, true) : $oldData; $clientId = $reload ? $source->client_id : $old->client_id; $lines = $input['lines'] ?? $base['lines']; $allowedIds = array_column($base['lines'], 'service_id'); foreach ($lines as $line) { abort_unless(in_array((int) $line['service_id'], $allowedIds, true), 422, 'Use os serviços da prestação de origem.'); } $currency = array_key_exists('currency', $input) ? $input['currency'] : ($base['currency'] ?? null); $sourceVersion = $reload ? (int) $source->version : (int) $oldData['source_version']; } else { $clientId = $input['client_id']; $lines = $input['lines']; $currency = $input['currency'] ?? null; } $client = DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $clientId)->first(); abort_unless($client, 403, 'Cliente fora da empresa autorizada.'); $data = ['description' => $input['description'] ?? ($base['description'] ?? ($oldData['description'] ?? null)), 'currency' => $currency, 'lines' => DraftValues::lines($lines, $scope), 'client_name' => $client->name, 'tax_status' => 'pending', 'accounting_status' => 'not_processed']; $result = ['client_id' => (int) $clientId, 'state' => 'draft', 'data' => $data, 'name' => trim($input['name'] ?? ($old?->name ?? ($area === 'billing_drafts' ? 'Rascunho da prestação #'.$source->id : 'Prestação de '.$client->name)))]; if ($source) { $result['source_id'] = $source->id; $result['data']['source_version'] = $sourceVersion; } return $result; } private function canonical(mixed $value): mixed { if (! is_array($value)) { return $value; } if (! array_is_list($value)) { ksort($value); } return array_map(fn ($entry) => $this->canonical($entry), $value); } }