diff --git a/AGENTS.md b/AGENTS.md index 4914ae9..1ced4e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,7 @@ - As alterações usam o token do contexto revisto, validação de permissões no servidor e auditoria na mesma transacção. Não remover os triggers de imutabilidade ou de auditoria append-only. - Provas de navegador usam browser-testing na porta 8001 e apenas sgf_tempo_test, com sessões/cache isolados e CSRF activo. Parar a prova antes de executar testes que recriam a base de testes. - Em 25 de Outubro de 2026, o utilizador aceitou e deu por concluído o incremento local de entrada, contexto e permissões no âmbito demonstrado e com os limites de docs/IDENTITY_DELIVERY.md. Preservar essa entrega como base. Serviços permanece apenas em especificação; não há autorização para implementação financeira ou publicação. +- Autorização posterior: implementar exclusivamente a entrega A de docs/SERVICES_WORKFLOW_SPEC.md. Clientes/serviços são da empresa; prestações e facturação permanecem rascunhos da unidade. A autorização não abrange B/C, emissão fiscal, confirmação de operações, lançamentos, recebimentos, reconciliação, fecho ou publicação. +- As novas concessões de A foram especificamente confirmadas para as contas existentes ID 1 e ID 2, na empresa ID 1 / unidade ID 1, após conferência. Não inferir direitos dos emails, nomes ou concessões de identidade; qualquer nova aplicação inicial exige confirmação específica. +- Em A, quantidades/preços são cadeias decimais exactas com até 16 algarismos inteiros e 8 decimais. Não usar float, moeda por omissão, imposto desconhecido como zero ou arredondamentos silenciosos. Histórico/eventos/outbox/pendências são transaccionais; não processar contabilidade nesta entrega. +- Em 25 de Outubro de 2026, o utilizador aceitou a entrega A no âmbito demonstrado e com os limites de docs/SERVICES_A_DELIVERY.md e autorizou um checkpoint Git exclusivamente local, sem credenciais ou dados locais. Preservar esta base aceite. Não avançar para B/C nem publicar. diff --git a/README.md b/README.md index 819c1fc..b5a6301 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,27 @@ Primeiro percurso funcional local, sobre a fundação existente e o roteiro v0.9 de 25 de Outubro de 2026. Laravel modular com React, independente do InvesteMoz. Versões e lockfiles preservados; sem módulos financeiros, integrações externas ou publicação. -**Estado:** incremento local de entrada, contexto e permissões aceite e concluído pelo utilizador em 25 de Outubro de 2026, com os limites de [IDENTITY_DELIVERY.md](docs/IDENTITY_DELIVERY.md). Preservar esta base. Serviços mantém-se apenas em especificação; implementação financeira e publicação não estão autorizadas. +**Base aceite:** incremento local de entrada, contexto e permissões aceite e concluído pelo utilizador em 25 de Outubro de 2026, com os limites de [IDENTITY_DELIVERY.md](docs/IDENTITY_DELIVERY.md). Preservar esta base. A autorização posterior de serviços está delimitada abaixo. + +**Entrega A aceite em 25 de Outubro de 2026:** serviços em rascunho, no âmbito demonstrado e com os limites de [SERVICES_A_DELIVERY.md](docs/SERVICES_A_DELIVERY.md). B/C, operações financeiras confirmadas e publicação continuam sem autorização. O checkpoint aceite `26b80df` permanece preservado. + +## Usar a entrega A + +Actualizar o navegador e entrar normalmente. Seleccionar a empresa e a unidade autorizadas. Os novos menus são **Clientes**, **Serviços**, **Prestações em rascunho** e **Rascunhos de facturação**. + +1. Criar o cliente (apenas nome obrigatório) e o serviço. São cadastros da empresa, reutilizados entre unidades. +2. Criar uma prestação em rascunho na unidade seleccionada, escolhendo o cliente e as linhas de serviço existentes. +3. Preencher quantidades/preços como texto decimal com ponto, até 16 algarismos inteiros e 8 decimais. Valores vazios ficam pendentes. Não há moeda por omissão. +4. Guardar e usar **Preparar rascunho de facturação**. O servidor reutiliza cliente, linhas, moeda indicada e versão da prestação, sem emitir factura. +5. Consultar pendências e **Histórico e captura durável**. A base aritmética não é um total fiscal ou valor definitivo a pagar. + +Uma prestação alterada não reescreve silenciosamente o rascunho de facturação: aparece uma pendência. **Actualizar explicitamente da prestação** cria outra versão com os dados de origem revistos. + +**Gerir acessos desta área** permite conceder/revogar apenas acções que o gestor possui no mesmo âmbito. Clientes/serviços têm âmbito de empresa; os dois tipos de rascunho têm âmbito de unidade. Os acessos de identidade continuam no menu anterior **Acessos**. + +As novas concessões foram confirmadas especificamente para os registos existentes de Osmane (16 concessões) e Samira (4 de consulta), na Tempo Lda/SEGI. Nenhuma conta foi criada nesta entrega e nenhum dado de demonstração foi colocado em desenvolvimento. + +Consultar estado local, sem mostrar segredos: `sgf.ps1 php scripts/a-status.php`. Não repetir `a-access-plan.php --apply` sem nova confirmação específica caso os direitos tenham sido entretanto alterados. ## Abrir e parar diff --git a/app/Modules/Identity/Services/Audit.php b/app/Modules/Identity/Services/Audit.php index c3cb728..d58cca1 100644 --- a/app/Modules/Identity/Services/Audit.php +++ b/app/Modules/Identity/Services/Audit.php @@ -9,7 +9,7 @@ final class Audit public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void { // Explicit allowlist: credentials, session/context tokens and request bodies never enter audit. - $safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants'])); + $safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id'])); DB::table('identity_audit')->insert([ 'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject, diff --git a/app/Modules/Identity/Services/IdentityWorkflow.php b/app/Modules/Identity/Services/IdentityWorkflow.php index c81eed0..f53714e 100644 --- a/app/Modules/Identity/Services/IdentityWorkflow.php +++ b/app/Modules/Identity/Services/IdentityWorkflow.php @@ -112,6 +112,7 @@ final class IdentityWorkflow $members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id') ->where('m.tenant_id', $ids['tenant_id'])->where('m.company_id', $ids['company_id'])->select('u.id', 'u.name', 'u.email')->orderBy('u.name')->get(); $grants = DB::table('permission_grants')->where('tenant_id', $ids['tenant_id'])->where('company_id', $ids['company_id']) + ->whereIn('permission', array_merge(Permissions::COMPANY, Permissions::UNIT)) ->where(fn ($q) => $q->whereNull('unit_id')->orWhereIn('unit_id', $allowedUnits))->get(['id', 'user_id', 'unit_id', 'permission']); return ['members' => $members, 'grants' => $grants, 'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT]]; } diff --git a/app/Modules/Services/DraftValues.php b/app/Modules/Services/DraftValues.php new file mode 100644 index 0000000..4446688 --- /dev/null +++ b/app/Modules/Services/DraftValues.php @@ -0,0 +1,58 @@ + 'Use uma cadeia decimal com ponto, até 16 algarismos inteiros e 8 decimais; sem expoentes ou arredondamento.']); + } + $decimal = BigDecimal::of($value); + if ($positive && $decimal->isLessThanOrEqualTo(BigDecimal::zero())) { throw ValidationException::withMessages([$field => 'A quantidade deve ser superior a zero quando preenchida.']); } + return (string) $decimal->stripTrailingZeros(); + } + + public static function lines(array $lines, array $scope): array + { + $result = []; + foreach ($lines as $index => $line) { + $service = DB::table('a_services')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $line['service_id'])->first(); + abort_unless($service, 403, 'Serviço fora da empresa autorizada.'); + $result[] = ['service_id' => (int) $service->id, 'service_name' => $service->name, + 'description' => $line['description'] ?? null, + 'quantity' => self::decimal($line['quantity'] ?? null, "lines.$index.quantity", true), + 'unit_price' => self::decimal($line['unit_price'] ?? null, "lines.$index.unit_price")]; + } + return $result; + } + + public static function pending(array $data): array + { + $pending = ['tax_rule_pending' => 'Imposto por definir e validar; não foi considerado zero.', + 'accounting_rule_pending' => 'Regras contabilísticas por validar; nenhum lançamento foi produzido.']; + if (empty($data['currency'])) { $pending['currency_missing'] = 'Moeda não indicada; nenhuma moeda foi presumida.'; } + else { $pending['currency_validation_pending'] = 'Moeda indicada pelo utilizador; a sua aplicabilidade e a moeda funcional continuam por validar.'; } + foreach ($data['lines'] as $index => $line) { + if ($line['quantity'] === null) { $pending["quantity_missing_$index"] = 'Quantidade por preencher na linha '.($index + 1).'.'; } + if ($line['unit_price'] === null) { $pending["price_missing_$index"] = 'Preço proposto por preencher na linha '.($index + 1).'.'; } + } + return $pending; + } + + public static function base(array $data): ?string + { + $sum = BigDecimal::zero(); + foreach ($data['lines'] ?? [] as $line) { + if ($line['quantity'] === null || $line['unit_price'] === null) { return null; } + $sum = $sum->plus(BigDecimal::of($line['quantity'])->multipliedBy($line['unit_price'])); + } + return (string) $sum->stripTrailingZeros(); + } +} diff --git a/app/Modules/Services/Http/ServiceController.php b/app/Modules/Services/Http/ServiceController.php new file mode 100644 index 0000000..ca18f25 --- /dev/null +++ b/app/Modules/Services/Http/ServiceController.php @@ -0,0 +1,46 @@ +json(['records' => $workflow->list($area, $request->user()->id)]); } + public function show(Request $request, string $area, int $id, ServiceWorkflow $workflow) { return response()->json($workflow->show($area, $id, $request->user()->id)); } + public function create(Request $request, string $area, ServiceWorkflow $workflow) { return $this->save($request, $area, null, $workflow); } + public function update(Request $request, string $area, int $id, ServiceWorkflow $workflow) { return $this->save($request, $area, $id, $workflow); } + + private function save(Request $request, string $area, ?int $id, ServiceWorkflow $workflow) + { + abort_unless(in_array($area, ServicePermissions::AREAS, true), 404); + $rules = ['name' => ServicePermissions::unitScoped($area) ? 'sometimes|required|string|max:255' : 'required|string|max:255', + 'description' => 'nullable|string|max:2000', 'version' => $id ? 'required|integer|min:1' : 'prohibited']; + if ($area === 'clients') { unset($rules['description']); } + if (ServicePermissions::unitScoped($area)) { + $rules += ['currency' => 'nullable|string|regex:/^[A-Z]{3}$/', 'lines' => $area === 'service_drafts' ? 'required|array|min:1|max:100' : 'sometimes|array|min:1|max:100', + 'lines.*' => 'array:service_id,service_name,description,quantity,unit_price', 'lines.*.service_id' => 'required|integer|min:1', + 'lines.*.service_name' => 'sometimes|string|max:255', 'lines.*.description' => 'nullable|string|max:2000', 'lines.*.quantity' => 'nullable|string|max:30', 'lines.*.unit_price' => 'nullable|string|max:30']; + if ($area === 'service_drafts') { $rules['client_id'] = 'required|integer|min:1'; } + else { $rules['source_id'] = $id ? 'prohibited' : 'required|integer|min:1'; $rules['reload_source'] = 'sometimes|boolean'; } + } + $unknown = array_diff(array_keys($request->all()), array_keys($rules)); + abort_if($unknown !== [], 422, 'Campo não suportado ou identificador protegido.'); + $data = $request->validate($rules); + $key = (string) $request->header('Idempotency-Key', ''); + return response()->json($workflow->save($area, $id, $request->user()->id, $data, $key), $id ? 200 : 201); + } + + public function accesses(Request $request, string $area, ServiceAccess $access) { return response()->json($access->listing($area, $request->user()->id)); } + public function changeAccess(Request $request, string $area, ServiceAccess $access) + { + $data = $request->validate(['email' => 'required|email|max:255', 'action' => 'required|string', 'grant' => 'required|boolean', 'reason' => 'required|string|max:500']); + abort_if(array_diff(array_keys($request->all()), ['email','action','grant','reason']) !== [], 422); + $access->change($area, $request->user()->id, $data); + return response()->json(['ok' => true]); + } +} diff --git a/app/Modules/Services/ServiceAccess.php b/app/Modules/Services/ServiceAccess.php new file mode 100644 index 0000000..3b058a6 --- /dev/null +++ b/app/Modules/Services/ServiceAccess.php @@ -0,0 +1,52 @@ +workflow->scope($area, $actor, 'manage'); + $grants = DB::table('permission_grants as g')->join('users as u', 'u.id', '=', 'g.user_id')->where('g.tenant_id', $scope['tenant_id'])->where('g.company_id', $scope['company_id'])->where('g.unit_id', $scope['unit_id'])->whereIn('g.permission', array_map(fn ($action) => "$area.$action", ServicePermissions::ACTIONS)) + ->orderBy('u.name')->get(['g.id','g.permission','g.unit_id','u.name','u.email']); + $actions = array_values(array_filter(ServicePermissions::ACTIONS, fn ($action) => $this->permissions->has($actor, $scope['tenant_id'], $scope['company_id'], "$area.$action", $scope['unit_id']))); + return ['grants' => $grants, 'actions' => $actions, 'scope' => $scope]; + } + + public function change(string $area, int $actor, array $input): void + { + $scope = $this->workflow->scope($area, $actor, 'manage'); $selected = $this->context->unitId(); + DB::transaction(function () use ($area, $actor, $input, $scope, $selected): void { + DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail(); + $this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selected); + $this->workflow->scope($area, $actor, 'manage'); + $permission = "$area.".$input['action']; + abort_unless(in_array($input['action'], ServicePermissions::ACTIONS, true), 422); + $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], $permission, $scope['unit_id']); + $target = DB::table('users')->where('email', mb_strtolower($input['email']))->first(['id']); + abort_unless($target, 422, 'Utilizador inexistente; nenhuma conta é criada por concessão.'); + $this->permissions->require($target->id, $scope['tenant_id'], $scope['company_id'], 'company.view'); + if ($scope['unit_id']) { $this->permissions->require($target->id, $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']); } + $query = DB::table('permission_grants')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('user_id', $target->id); + if ($input['grant']) { + if ($input['action'] !== 'view') { $this->permissions->require($target->id, $scope['tenant_id'], $scope['company_id'], "$area.view", $scope['unit_id']); } + DB::table('permission_grants')->insertOrIgnore($scope + ['user_id' => $target->id, 'permission' => $permission, 'created_at' => now(), 'updated_at' => now()]); + $changed = [['permission' => $permission, 'unit_id' => $scope['unit_id']]]; + } else { + $query->whereIn('permission', $input['action'] === 'view' ? array_map(fn ($action) => "$area.$action", ServicePermissions::ACTIONS) : [$permission]); + $changed = $query->get(['permission','unit_id'])->map(fn ($row) => (array) $row)->all(); + foreach ($changed as $row) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], $row['permission'], $scope['unit_id']); } + $query->delete(); + } + Audit::record($input['grant'] ? 'a.access.granted' : 'a.access.revoked', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], $target->id, + ['permission' => $permission, 'reason' => $input['reason'], 'grants' => $changed]); + }); + } +} diff --git a/app/Modules/Services/ServicePermissions.php b/app/Modules/Services/ServicePermissions.php new file mode 100644 index 0000000..21deacd --- /dev/null +++ b/app/Modules/Services/ServicePermissions.php @@ -0,0 +1,22 @@ + 'Clientes', 'services' => 'Serviços', 'service_drafts' => 'Prestações em rascunho', 'billing_drafts' => 'Rascunhos de facturação']; + + public static function unitScoped(string $area): bool { return in_array($area, ['service_drafts', 'billing_drafts'], true); } + public static function catalogue(bool $unit): array + { + $result = []; + foreach (self::AREAS as $area) { + if (self::unitScoped($area) === $unit) { + foreach (self::ACTIONS as $action) { $result[] = "$area.$action"; } + } + } + return $result; + } +} diff --git a/app/Modules/Services/ServiceWorkflow.php b/app/Modules/Services/ServiceWorkflow.php new file mode 100644 index 0000000..d70ac81 --- /dev/null +++ b/app/Modules/Services/ServiceWorkflow.php @@ -0,0 +1,163 @@ +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); + } +} diff --git a/database/migrations/2026_10_25_030000_create_services_delivery_a.php b/database/migrations/2026_10_25_030000_create_services_delivery_a.php new file mode 100644 index 0000000..ee01a87 --- /dev/null +++ b/database/migrations/2026_10_25_030000_create_services_delivery_a.php @@ -0,0 +1,140 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('company_id'); + $table->unsignedBigInteger('unit_id')->nullable(); + $table->foreign(['tenant_id', 'company_id'])->references(['tenant_id', 'id'])->on('companies')->restrictOnDelete(); + $table->foreign(['tenant_id', 'company_id', 'unit_id'])->references(['tenant_id', 'company_id', 'id'])->on('units')->restrictOnDelete(); + $table->string('name'); + $table->unsignedInteger('version')->default(1); + $table->jsonb('data'); + if (in_array($area, ['service_drafts', 'billing_drafts'], true)) { + $table->unsignedBigInteger('client_id'); + $table->foreign(['tenant_id', 'company_id', 'client_id'])->references(['tenant_id', 'company_id', 'id'])->on('a_clients')->restrictOnDelete(); + $table->string('state')->default('draft'); + } + if ($area === 'billing_drafts') { + $table->unsignedBigInteger('source_id'); + $table->foreign(['tenant_id', 'company_id', 'unit_id', 'source_id'])->references(['tenant_id', 'company_id', 'unit_id', 'id'])->on('a_service_drafts')->restrictOnDelete(); + } + $table->timestampsTz(); + $table->unique(['tenant_id', 'company_id', 'id']); + $table->unique(['tenant_id', 'company_id', 'unit_id', 'id']); + }); + $unitCheck = in_array($area, ['service_drafts', 'billing_drafts'], true) ? 'IS NOT NULL' : 'IS NULL'; + DB::statement("ALTER TABLE a_$area ADD CHECK (unit_id $unitCheck AND version > 0 AND jsonb_typeof(data) = 'object')"); + if (in_array($area, ['service_drafts', 'billing_drafts'], true)) { DB::statement("ALTER TABLE a_$area ADD CHECK (state = 'draft')"); } + } + Schema::create('a_history', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id'); $table->unsignedBigInteger('unit_id')->nullable(); + $table->string('area', 30); $table->unsignedBigInteger('record_id'); $table->unsignedInteger('version'); + $table->unsignedBigInteger('actor_id'); $table->jsonb('snapshot'); $table->timestampTz('created_at')->useCurrent(); + $table->unique(['area', 'record_id', 'version']); + $table->foreign(['tenant_id', 'company_id'])->references(['tenant_id', 'id'])->on('companies')->restrictOnDelete(); + }); + Schema::create('a_events', function (Blueprint $table): void { + $table->id(); $table->foreignId('history_id')->unique()->constrained('a_history')->restrictOnDelete(); + $table->string('type', 80); $table->unsignedInteger('schema_version')->default(1); + $table->jsonb('payload'); $table->timestampTz('created_at')->useCurrent(); + }); + Schema::create('a_outbox', function (Blueprint $table): void { + $table->foreignId('event_id')->primary()->constrained('a_events')->restrictOnDelete(); + $table->string('state')->default('captured'); $table->timestampTz('created_at')->useCurrent(); + }); + DB::statement("ALTER TABLE a_outbox ADD CHECK (state = 'captured')"); + Schema::create('a_pending', function (Blueprint $table): void { + $table->id(); $table->foreignId('history_id')->constrained('a_history')->restrictOnDelete(); + $table->string('code', 80); $table->string('message', 500); $table->timestampTz('created_at')->useCurrent(); + $table->unique(['history_id', 'code']); + }); + Schema::create('a_commands', function (Blueprint $table): void { + $table->id(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id'); + $table->unsignedBigInteger('actor_id'); $table->string('command_key', 100); $table->string('request_hash', 64); + $table->jsonb('result'); $table->timestampTz('created_at')->useCurrent(); + $table->unique(['tenant_id', 'company_id', 'command_key']); + }); + DB::unprepared(<<<'SQL' + CREATE OR REPLACE FUNCTION sgf_a_record_guard() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE line jsonb; field text; value text; + BEGIN + IF TG_OP = 'INSERT' AND NEW.version <> 1 THEN RAISE EXCEPTION 'SGF_A_INITIAL_VERSION'; END IF; + IF TG_OP = 'UPDATE' THEN + IF NEW.id IS DISTINCT FROM OLD.id OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id OR NEW.company_id IS DISTINCT FROM OLD.company_id OR NEW.unit_id IS DISTINCT FROM OLD.unit_id THEN + RAISE EXCEPTION 'SGF_A_IMMUTABLE_SCOPE'; + END IF; + IF NEW.version <> OLD.version + 1 THEN RAISE EXCEPTION 'SGF_A_VERSION_REQUIRED'; END IF; + IF TG_TABLE_NAME = 'a_billing_drafts' THEN + IF NEW.source_id IS DISTINCT FROM OLD.source_id THEN RAISE EXCEPTION 'SGF_A_IMMUTABLE_SOURCE'; END IF; + END IF; + END IF; + IF TG_TABLE_NAME IN ('a_service_drafts','a_billing_drafts') THEN + IF (NEW.data - ARRAY['description','currency','lines','client_name','source_version','tax_status','accounting_status']) <> '{}'::jsonb THEN RAISE EXCEPTION 'SGF_A_UNSUPPORTED_DATA'; END IF; + IF NEW.data->>'tax_status' IS DISTINCT FROM 'pending' OR NEW.data->>'accounting_status' IS DISTINCT FROM 'not_processed' THEN RAISE EXCEPTION 'SGF_A_DRAFT_ONLY'; END IF; + IF NEW.data->>'currency' IS NOT NULL AND NEW.data->>'currency' !~ '^[A-Z]{3}$' THEN RAISE EXCEPTION 'SGF_A_CURRENCY'; END IF; + IF jsonb_typeof(NEW.data->'lines') IS DISTINCT FROM 'array' OR jsonb_array_length(NEW.data->'lines') < 1 THEN RAISE EXCEPTION 'SGF_A_LINES'; END IF; + FOR line IN SELECT * FROM jsonb_array_elements(NEW.data->'lines') LOOP + IF (line - ARRAY['service_id','service_name','description','quantity','unit_price']) <> '{}'::jsonb THEN RAISE EXCEPTION 'SGF_A_UNSUPPORTED_LINE_DATA'; END IF; + IF NOT EXISTS(SELECT 1 FROM a_services s WHERE s.id = (line->>'service_id')::bigint AND s.tenant_id = NEW.tenant_id AND s.company_id = NEW.company_id) THEN RAISE EXCEPTION 'SGF_A_FOREIGN_SERVICE'; END IF; + FOREACH field IN ARRAY ARRAY['quantity','unit_price'] LOOP + value := line->>field; + IF value IS NOT NULL THEN + IF jsonb_typeof(line->field) <> 'string' OR value !~ '^(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?$' THEN RAISE EXCEPTION 'SGF_A_DECIMAL_PRECISION'; END IF; + IF field = 'quantity' AND value::numeric <= 0 THEN RAISE EXCEPTION 'SGF_A_QUANTITY'; END IF; + END IF; + END LOOP; + END LOOP; + END IF; + RETURN NEW; + END; $$; + CREATE OR REPLACE FUNCTION sgf_a_history_guard() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE item record; target text; + BEGIN + target := CASE NEW.area WHEN 'clients' THEN 'a_clients' WHEN 'services' THEN 'a_services' WHEN 'service_drafts' THEN 'a_service_drafts' WHEN 'billing_drafts' THEN 'a_billing_drafts' ELSE NULL END; + IF target IS NULL THEN RAISE EXCEPTION 'SGF_A_AREA'; END IF; + EXECUTE format('SELECT tenant_id,company_id,unit_id,version FROM %I WHERE id=$1', target) INTO item USING NEW.record_id; + IF item IS NULL OR item.tenant_id IS DISTINCT FROM NEW.tenant_id OR item.company_id IS DISTINCT FROM NEW.company_id OR item.unit_id IS DISTINCT FROM NEW.unit_id OR item.version IS DISTINCT FROM NEW.version THEN RAISE EXCEPTION 'SGF_A_HISTORY_SCOPE'; END IF; + RETURN NEW; + END; $$; + SQL); + foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) { + DB::statement("CREATE TRIGGER a_{$area}_guard BEFORE INSERT OR UPDATE ON a_$area FOR EACH ROW EXECUTE FUNCTION sgf_a_record_guard()"); + } + DB::statement('CREATE TRIGGER a_history_scope BEFORE INSERT ON a_history FOR EACH ROW EXECUTE FUNCTION sgf_a_history_guard()'); + foreach (['a_history', 'a_events', 'a_outbox', 'a_pending', 'a_commands'] as $table) { + DB::statement("CREATE TRIGGER {$table}_append_only BEFORE UPDATE OR DELETE ON $table FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()"); + } + $company = ['company.view','company.edit','unit.create','access.manage']; $unit = ['unit.view','unit.edit']; + foreach (['clients','services','service_drafts','billing_drafts'] as $area) { + foreach (['view','create','edit','manage'] as $action) { + if (in_array($area, ['clients','services'], true)) { $company[] = "$area.$action"; } else { $unit[] = "$area.$action"; } + } + } + $companySql = "'".implode("','", $company)."'"; $unitSql = "'".implode("','", $unit)."'"; + DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check'); + DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ($companySql) AND unit_id IS NULL) OR (permission IN ($unitSql) AND unit_id IS NOT NULL))"); + } + + public function down(): void + { + if (DB::table('permission_grants')->whereNotIn('permission', ['company.view','company.edit','unit.create','access.manage','unit.view','unit.edit'])->exists()) { + throw new RuntimeException('Remove explicit A grants through an approved procedure before rolling back.'); + } + foreach (['a_commands','a_pending','a_outbox','a_events','a_history','a_billing_drafts','a_service_drafts','a_services','a_clients'] as $table) { Schema::dropIfExists($table); } + DB::statement('DROP FUNCTION IF EXISTS sgf_a_record_guard()'); DB::statement('DROP FUNCTION IF EXISTS sgf_a_history_guard()'); + DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check'); + DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR (permission IN ('unit.view','unit.edit') AND unit_id IS NOT NULL))"); + } +}; diff --git a/docs/SERVICES_A_DELIVERY.md b/docs/SERVICES_A_DELIVERY.md new file mode 100644 index 0000000..ea89248 --- /dev/null +++ b/docs/SERVICES_A_DELIVERY.md @@ -0,0 +1,82 @@ +# Entrega A — serviços em rascunho + +25 de Outubro de 2026. Entrega A aceite pelo utilizador no âmbito demonstrado e com os limites documentados. O checkpoint `26b80df` foi preservado; versões, manifests e lockfiles não foram alterados. + +## Aceitação + +Em 25 de Outubro de 2026 (Africa/Johannesburg), o utilizador declarou: «Aceito a entrega A no âmbito demonstrado e com os limites documentados. Regista a aceitação e preserva um checkpoint Git local, sem credenciais ou dados locais. Não avances ainda para B/C nem publiques.» + +Esta aceitação encerra apenas A. O checkpoint contém código, migração, documentação e provas sintéticas; exclui configurações privadas, credenciais, bases, sessões, logs e o plano operacional específico de concessões locais. Não autoriza B/C ou publicação. + +## Percurso funcional + +Depois de entrar e seleccionar empresa/unidade autorizadas, estão disponíveis Clientes, Serviços, Prestações em rascunho e Rascunhos de facturação, conforme concessões explícitas. + +- Clientes e serviços pertencem à empresa e são reutilizados entre unidades. Nome é o único campo obrigatório do cliente. +- Prestações e rascunhos de facturação pertencem à unidade autorizada. A facturação reutiliza cliente, linhas, moeda indicada e versão da prestação. +- Consulta, criação, edição e gestão de concessões são separadas nas quatro áreas. Direitos de identidade não concedem automaticamente direitos de A. Gestores só delegam direitos que possuem no mesmo âmbito. +- Alterações preservam versões, histórico, eventos duráveis, outbox, pendências e resultado idempotente na mesma transacção. Repetir o comando não duplica o registo ou evento; versões e contextos antigos são recusados. +- Alterar a prestação gera uma pendência nos rascunhos de facturação associados. Actualizar explicitamente da prestação cria outra versão, preservando a anterior. + +Quantidades e preços são cadeias decimais não negativas, com até 16 algarismos inteiros e 8 decimais; quantidade preenchida deve ser positiva. Entradas excedentes são recusadas, sem arredondamento. Produtos e somas derivados usam aritmética decimal exacta, podendo exigir mais casas. A base aritmética não é total fiscal nem valor definitivo a pagar. Moeda não é presumida; imposto desconhecido não é zero. Mesmo com código de moeda preenchido permanece pendente a sua validação. Regras fiscais e contabilísticas continuam explicitamente pendentes. + +Não há confirmação financeira, emissão de factura, lançamento contabilístico, recebimento, correcção financeira, reconciliação ou fecho. O outbox fica em captura durável, sem processamento contabilístico. B/C dependem de regras e autorização próprias. + +## Migração e preservação de dados + +Aplicada em `sgf_tempo_dev`, pelo wrapper de migrações e proprietário separado, a migração aditiva `2026_10_25_030000_create_services_delivery_a` (batch 4). A aplicação conserva o papel `sgf_dev_app`, sem administração PostgreSQL. + +O delta cria nove tabelas: `a_clients`, `a_services`, `a_service_drafts`, `a_billing_drafts`, `a_history`, `a_events`, `a_outbox`, `a_pending` e `a_commands`. Amplia o catálogo permitido de concessões, sem conceder acessos automaticamente. Inclui relações de âmbito, protecção SQL contra transferências de pertença, validação de dados e protecção append-only. O escopo Eloquent não é a única fronteira de isolamento. + +Não foi executado reset/fresh/wipe em desenvolvimento. As duas contas, tenant, empresa, unidade e oito concessões de identidade foram preservados. Após a implementação, as nove tabelas de A estavam vazias em desenvolvimento: as demonstrações usaram exclusivamente `sgf_tempo_test`. + +## Concessões especificamente confirmadas + +As contas foram conferidas antes da aplicação; nenhuma foi criada ou associada por suposição. O utilizador confirmou exactamente estas contas e concessões: + +| Conta existente | Clientes / serviços — Tempo Lda, empresa 1 | Prestações / facturação em rascunho — SEGI, unidade 1 | +| --- | --- | --- | +| ID 1 — Osmane Nala, nala@tempo.co.mz | Consulta, criação, edição e gestão em cada área | Consulta, criação, edição e gestão em cada área | +| ID 2 — Samira Nal, onala@me.com | Consulta em cada área | Consulta em cada área | + +Foram aplicadas e auditadas 16 concessões para Osmane e 4 para Samira, mantendo as oito anteriores (28 no total). Não há direitos implícitos noutras unidades ou poderes financeiros. Os emails apenas identificaram as contas nesta operação confirmada; não determinam privilégios durante a execução da aplicação. + +O plano operacional local `scripts/a-access-plan.php` conserva a pré-visualização e as verificações da operação, mas fica excluído do checkpoint por conter identificações locais. Não repetir `--apply` após alterações posteriores dos direitos sem nova confirmação específica. `scripts/a-status.php` permite verificar estado sem apresentar segredos. + +## Verificação realizada + +- Suite completa: **44 testes, 273 asserções, todos aprovados**, incluindo a fundação anterior. +- Compilação Vite: aprovada. Nenhuma dependência ou versão foi substituída. +- Testes de isolamento entre empresas, inclusive do mesmo tenant, e unidades; identificadores manipulados; consulta sem escrita; ausência de herança implícita; limites de delegação; versões/contextos antigos; alterações SQL em massa; histórico imutável; rollback integral com falha injectada no outbox. +- Dois processos PHP concorrentes: criação repetida produziu um registo/evento; edição concorrente terminou em 200/409, com apenas uma nova versão/evento. [Resultado](proofs/a-concurrency.json). +- HTTP com login real de contas sintéticas: leitura 200, alteração/gestão recusadas 403, concessão/revogação efectivas no pedido seguinte da mesma sessão, contexto/versão antigos 409, protecção CSRF 419 e cookie de sessão terminada 401. [Resultado](proofs/a-http-results.json). +- Captura durável na prova: 9 versões, 9 eventos, 9 entradas de outbox, 9 resultados de comandos, sem eventos órfãos; estado apenas `captured`. [Métricas](proofs/a-durable-capture.json). +- `verify-foundation.php` confirmou isolamento de papéis e ausência de contas sintéticas na base de desenvolvimento. + +A prova visual utilizou uma instância isolada na porta 8001, ligada apenas a `sgf_tempo_test`, com sessões/cache próprios, CSRF activo e utilizadores sintéticos equivalentes. Incluiu login pelo formulário, criação de cliente/serviço/prestação, recusa de precisão excessiva, preparação e edição de rascunho de facturação, histórico e gestão delimitada de acessos. O utilizador de consulta viu os registos sem botões de criação, edição ou gestão. Não se tratou apenas de `actingAs`. + +Provas: [precisão recusada](proofs/a-01-precision-refused.png), [rascunho](proofs/a-02-draft-pending.png), [histórico e pendências](proofs/a-03-history-and-pending.png), [consulta](proofs/a-04-read-only.png). + +A instância de prova foi parada e a suite executada novamente para limpar os dados sintéticos na base de testes. A aplicação normal permaneceu na porta 8000, sem alteração de ligação ou dados de demonstração na Tempo Lda/SEGI. Não foram terminados processos de outros projectos. + +## Ficheiros e utilização + +Implementação em `app/Modules/Services` (permissões, valores, workflow, acessos e controlador), migração acima, `resources/js/ServicesWorkspace.jsx`, integração em `app.jsx`, CSS e rotas. A fundação de identidade foi reutilizada; a auditoria recebeu metadados de A e a listagem antiga de acessos ficou delimitada ao seu catálogo. + +Testes em `tests/Feature/ServicesDeliveryATest.php` e `tests/Support/ServicesFixture.php`; procedimentos de prova em `scripts/a-concurrency-proof.php`, `a-concurrency-worker.php`, `a-http-proof.mjs` e `a-proof-metrics.php`, com preparação de fixtures sintéticas adaptada. README e especificação foram actualizados. + +Para utilizar, actualizar o navegador em http://127.0.0.1:8000, entrar normalmente e seleccionar Tempo Lda/SEGI. Seguir o percurso do [README](../README.md). Não executar os scripts de fixtures para criar dados reais. A recuperação local assistida da identidade mantém-se documentada em [IDENTITY_WORKFLOW.md](IDENTITY_WORKFLOW.md); não há recuperação por email. + +Comandos de verificação, a partir do projecto: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 npm run build +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php scripts/a-status.php +``` + +As provas destrutivas continuam bloqueadas fora de `sgf_tempo_test`. Parar a instância de navegador de testes antes de executar a suite que recria essa base. Os scripts de prova de concorrência e métricas têm também verificação própria do destino. Não mudar a aplicação normal para a base de testes. + +## Limites e próxima decisão + +A entrega A foi aceite com estes limites. Não define regime fiscal, numeração, moeda funcional, plano de contas, reconhecimento ou arredondamento fiscal. Não inclui emissão, processamento contabilístico ou processamento externo do outbox. Não foi publicada nem iniciado outro incremento. A especificação B/C permanece como proposta, sem autorização de execução. diff --git a/docs/SERVICES_WORKFLOW_SPEC.md b/docs/SERVICES_WORKFLOW_SPEC.md index 3b88349..649644c 100644 --- a/docs/SERVICES_WORKFLOW_SPEC.md +++ b/docs/SERVICES_WORKFLOW_SPEC.md @@ -19,7 +19,7 @@ O incremento de entrada, contexto e permissões tem implementação e provas té O utilizador aceitou e deu por concluído o incremento local de entrada, contexto e permissões em 25 de Outubro de 2026, no âmbito demonstrado e com os limites de IDENTITY_DELIVERY.md. Preservar essa entrega como base. Limites conhecidos: âmbito local, sem MFA/RLS de produção, sem criação geral de contas pela interface e com procedimentos locais assistidos de recuperação. A aceitação está registada separadamente do cenário de serviços. -Nesta etapa está autorizada apenas a especificação de serviços. As entregas A/B/C abaixo são um plano para decisões posteriores, não uma ordem para as implementar agora. +Na revisão inicial estava autorizada apenas a especificação. Posteriormente o utilizador autorizou executar exclusivamente A através do prompt de execução anexado, mantendo B/C fora do âmbito. A implementação de A e as provas estão documentadas em SERVICES_A_DELIVERY.md; isso não constitui aceitação automática de A nem autoriza B/C ou publicação. ## Resultado pretendido @@ -108,6 +108,10 @@ Não acrescentar já contratos recorrentes, horas, equipas de projecto, custos, Identificadores de tenant, empresa e unidade vêm do contexto autorizado. Cliente e serviço devem pertencer à empresa da prestação. Uma escolha de cliente reutiliza o cadastro; documentos confirmados futuros preservam a fotografia histórica necessária em vez de dependerem do cadastro mutável. +Na entrega A, clientes e serviços pertencem à empresa e são reutilizados entre as suas unidades. Não criar cópias por unidade nem exigir unit_id nesses cadastros. Prestações e rascunhos de facturação pertencem à empresa e à unidade autorizada: o vínculo à unidade não pode ser transferido por edição de um formulário. + +O nome será o único campo obrigatório introduzido pelo utilizador no cadastro de cliente nesta fase. Identificadores, empresa, papel de cliente e metadados de autoria/versão são controlados pelo servidor. Não exigir dados fiscais ou contactos para guardar esse cadastro mínimo; a ausência dos dados necessários a documentos futuros permanece uma pendência, não uma indicação de validade fiscal. + ## Estados propostos - Prestação: rascunho, confirmada ou cancelada antes de confirmação. Uma correcção posterior referencia a prestação confirmada; não elimina a origem. @@ -122,11 +126,11 @@ O primeiro incremento não deve apresentar os estados futuros como capacidades j ## Regras técnicas propostas 1. Guardar preços, quantidades e montantes com precisão controlada; usar brick/math existente no servidor e cadeias decimais nas APIs. Não usar o JavaScript como autoridade financeira. -2. Validar escala e arredondamento explicitamente. A política financeira/fiscal precisa de aprovação; não depender de coerção silenciosa da base. +2. Validar escala explicitamente e rejeitar valores que excedam a precisão suportada antes de persistir, sem arredondamento silencioso. Não presumir moeda nem converter imposto desconhecido em zero. Guardar o rascunho incompleto com pendências explícitas quando necessário. A política financeira/fiscal de arredondamento continua por validar; não depender de coerção silenciosa da base. 3. Negar por omissão cada acção e verificar empresa, unidade, registo e permissão no servidor. As concessões actuais de identidade não concedem acesso automático às novas áreas. 4. Recusar um formulário com contexto ou versão desactualizados. Mudança de empresa/unidade não relocaliza registos nem reaproveita silenciosamente autorizações anteriores. 5. Usar chaves de idempotência por empresa/operação/conteúdo para comandos repetíveis. Repetição idêntica recupera o resultado; conteúdo diferente com a mesma chave origina conflito. -6. Confirmar alteração, evento durável e trabalho pendente na mesma transacção. A outbox é uma proposta técnica a implementar no núcleo, sem exigir Redis ou novo fornecedor. +6. Confirmar alteração, histórico, evento durável e trabalho pendente na mesma transacção desde a entrega A. A outbox integra a proposta de captura inicial, sem exigir Redis ou novo fornecedor. B acrescenta processamento contabilístico sobre essa base, sem reconstruir eventos históricos ou inferir acontecimentos a partir do último estado de um rascunho. 7. Não duplicar efeitos contabilísticos quando um evento é repetido ou uma interface comercial é activada posteriormente. 8. Sem plano de contas, classificação, imposto, moeda ou reconhecimento aprovados, criar pendências explícitas. Não escolher contas transitórias para ocultar lacunas. 9. Manter auditoria das alterações relevantes, sem palavras-passe, tokens ou raciocínio interno da IA. @@ -151,21 +155,37 @@ Separar consulta e alteração de cadastros, criação/edição/confirmação de As concessões serão dados parametrizados, mas o catálogo cobre apenas acções suportadas. Gestão de acessos não implica capacidade financeira. Não ampliar automaticamente os direitos de Osmane ou Samira. A matriz concreta e a autoridade inicial de concessão das novas acções precisam de aprovação antes da disponibilização. +### Matriz delimitada para a entrega A + +| Área | Âmbito dos registos e das concessões | Osmane | Samira | +| --- | --- | --- | --- | +| Clientes | Tempo Lda, cadastro partilhado pelas unidades | Consultar, criar, editar e gerir estas concessões | Apenas consultar | +| Serviços | Tempo Lda, catálogo partilhado pelas unidades | Consultar, criar, editar e gerir estas concessões | Apenas consultar | +| Prestações em rascunho | Tempo Lda / SEGI | Consultar, criar, editar e gerir estas concessões | Apenas consultar | +| Rascunhos de facturação | Tempo Lda / SEGI | Consultar, criar, editar e gerir estas concessões | Apenas consultar | + +Separar consulta, criação, edição e gestão de concessões em cada área; não introduzir um papel universal. A autoridade sobre os cadastros de empresa não concede autoridade sobre prestações/rascunhos de outras unidades. Os clientes e serviços, sendo cadastros de empresa, não ficam artificialmente privados da SEGI. + +Estas são novas concessões, cuja atribuição inicial deve ser explícita e auditada. Não as derivar dos actuais company.edit/access.manage ou da identidade/email das pessoas. O gestor só pode delegar direitos que possui, no mesmo âmbito. Não incluir confirmação financeira/documental, recebimentos, correcções, reconciliação, lançamentos ou fecho. Após conferir as contas existentes e obter confirmação específica, foram aplicadas 16 concessões à conta ID 1 (Osmane Nala) e 4 de consulta à conta ID 2 (Samira Nal), sem criar ou associar outra conta por suposição. Ver SERVICES_A_DELIVERY.md para âmbitos e evidências. + ## Entregas verificáveis ### A Cadastros e preparação operacional -Proposta para a primeira implementação: +Âmbito delimitado para a primeira implementação, sujeito à autorização de execução: -- Contraparte/cliente e serviço mínimos. -- Prestação em rascunho, reutilizando esses cadastros e o contexto existente. -- Rascunho de facturação ligado à prestação, sem emissão fiscal ou numeração definitiva. -- Captura durável dos acontecimentos abrangidos e pendências explícitas de classificação, sem inventar lançamentos. +- Contraparte/cliente e serviço mínimos, partilhados pela empresa; nome como único campo obrigatório do cliente. +- Prestação em rascunho na unidade autorizada, reutilizando esses cadastros e o contexto existente. +- Rascunho de facturação na mesma unidade, ligado à prestação, sem emissão fiscal ou numeração definitiva. +- Histórico, captura durável dos acontecimentos abrangidos e pendências explícitas de classificação desde A, sem inventar lançamentos. B reutiliza essa base e acrescenta o processamento contabilístico. +- Valores de precisão controlada, sem arredondamentos silenciosos, moeda presumida ou imposto desconhecido convertido em zero. - Permissões, auditoria, controlo de versão/contexto e repetição segura desde estes primeiros comandos. - Dados sintéticos e provas apenas na base de testes; nenhuma factura ou recebimento real em desenvolvimento. Critério: percurso cliente → serviço → prestação → rascunho reproduzível, com origem preservada, acessos indevidos recusados e informação insuficiente assinalada. Isto não é ainda o circuito financeiro completo. +A não emite facturas nem produz lançamentos contabilísticos. Documentos confirmados, recebimentos, correcções, reconciliação e fecho permanecem exclusivamente em B/C. + ### B Documentos e contabilidade permanente Após validar campos/regras aplicáveis: confirmação documental, fotografia histórica, eventos/outbox, consumidor repetível, regras de contabilização e pendências. Demonstrar contabilidade permanente mesmo sem interface comercial de Contabilidade contratada. @@ -196,7 +216,7 @@ Estas entregas partilham o mesmo núcleo. Não criam três produtos ou versões ## Decisões pendentes antes das entregas correspondentes -Para A: aprovar esta delimitação e a matriz de permissões/autoridade inicial; confirmar quais dados mínimos do cliente são indispensáveis para um rascunho (a proposta inicial exige apenas nome). +Para A: o utilizador autorizou a execução e confirmou especificamente a aplicação das novas concessões após conferência das contas. A implementação usa nome como único campo obrigatório do cliente, cadeias decimais até 16 algarismos inteiros/8 decimais, validação anterior à persistência e protecção também na base, sem arredondamento silencioso. O delta aditivo e os resultados constam de SERVICES_A_DELIVERY.md. A aceitação deste incremento é distinta da aceitação anterior de identidade. Para B/C: responsáveis de validação contabilística/fiscal, documentos e numeração, regime/impostos aplicáveis, moeda funcional, plano de contas, reconhecimento, arredondamento, evidências de recebimento/reconciliação, corte e regras de fecho/correcção. O exemplo de teste não decide estas matérias. @@ -204,4 +224,4 @@ Não seleccionar fornecedor, API bancária ou integração de pagamento. Não co ## Estado desta etapa -Especificação proposta para revisão. Nenhuma migração, cadastro, documento, evento financeiro ou lançamento foi criado nesta etapa. O incremento de identidade está aceite e concluído; qualquer implementação posterior exige autorização sobre uma entrega concreta. O utilizador manteve expressamente serviços apenas em especificação e não autorizou implementação financeira ou publicação. Não implementar a entrega A nesta revisão documental. +Entrega A implementada após autorização concreta e aceite pelo utilizador em 25 de Outubro de 2026, no âmbito demonstrado e com os limites do relatório SERVICES_A_DELIVERY.md. A base de identidade aceite foi preservada. B/C permanecem apenas em especificação: não foram implementados documentos confirmados, emissão fiscal, lançamentos, recebimentos, correcções, reconciliação ou fecho. Não há autorização para avançar para B/C ou publicar. diff --git a/docs/proofs/a-01-precision-refused.png b/docs/proofs/a-01-precision-refused.png new file mode 100644 index 0000000..f73ad9c Binary files /dev/null and b/docs/proofs/a-01-precision-refused.png differ diff --git a/docs/proofs/a-02-draft-pending.png b/docs/proofs/a-02-draft-pending.png new file mode 100644 index 0000000..5538a32 Binary files /dev/null and b/docs/proofs/a-02-draft-pending.png differ diff --git a/docs/proofs/a-03-history-and-pending.png b/docs/proofs/a-03-history-and-pending.png new file mode 100644 index 0000000..daa4060 Binary files /dev/null and b/docs/proofs/a-03-history-and-pending.png differ diff --git a/docs/proofs/a-04-read-only.png b/docs/proofs/a-04-read-only.png new file mode 100644 index 0000000..6b7fb95 Binary files /dev/null and b/docs/proofs/a-04-read-only.png differ diff --git a/docs/proofs/a-concurrency.json b/docs/proofs/a-concurrency.json new file mode 100644 index 0000000..51b374d --- /dev/null +++ b/docs/proofs/a-concurrency.json @@ -0,0 +1,16 @@ +{ + "environment": "sgf_tempo_test only", + "parallel_php_processes": true, + "company_lock_overlap": true, + "identical_create": { + "same_record": true, + "second_replayed": true, + "events_created": 1 + }, + "concurrent_edit": { + "first_status": 200, + "second_status": 409, + "version_after": 2, + "events_created": 1 + } +} \ No newline at end of file diff --git a/docs/proofs/a-durable-capture.json b/docs/proofs/a-durable-capture.json new file mode 100644 index 0000000..b294dbc --- /dev/null +++ b/docs/proofs/a-durable-capture.json @@ -0,0 +1,18 @@ +{ + "database": "sgf_tempo_test", + "counts": { + "a_clients": 3, + "a_services": 1, + "a_service_drafts": 1, + "a_billing_drafts": 1, + "a_history": 9, + "a_events": 9, + "a_outbox": 9, + "a_pending": 9, + "a_commands": 9 + }, + "events_without_outbox": 0, + "outbox_states": [ + "captured" + ] +} \ No newline at end of file diff --git a/docs/proofs/a-http-results.json b/docs/proofs/a-http-results.json new file mode 100644 index 0000000..75df29b --- /dev/null +++ b/docs/proofs/a-http-results.json @@ -0,0 +1,10 @@ +{ + "environment": "browser-testing / sgf_tempo_test only", + "results": [ + "Criação repetida recupera o mesmo registo/evento; conteúdo diferente com a mesma chave é recusado (409).", + "Consulta por credenciais reais sintéticas: leitura 200, edição e gestão 403.", + "Concessão/revogação de edição têm efeito no pedido seguinte, mantendo a mesma sessão.", + "Versão antiga e contexto antigo recusados (409); alteração sem protecção CSRF/origem recusada (419).", + "Cookie anterior à saída não recupera acesso aos dados de A (401)." + ] +} \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 9f5ca09..905e591 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,2 +1,3 @@ :root{font-family:Inter,Segoe UI,system-ui,sans-serif;color:#20393e;background:#f4f6f5;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer;border:1px solid #c5d6d2;background:#fff;color:#21564c;border-radius:7px;padding:10px 15px;font-weight:600}button:hover{background:#edf6f2}button:disabled{opacity:.5;cursor:not-allowed}button.primary{background:#176b55;border-color:#176b55;color:white}button.primary:hover{background:#125440}input,select,textarea{display:block;width:100%;border:1px solid #c6d3cf;border-radius:7px;padding:12px;background:white;color:#20393e;margin-top:8px}input:focus,select:focus,textarea:focus{outline:3px solid #cbe8dc;outline-offset:1px}input[readonly]{background:#f4f6f5}label{display:block;font-size:.9rem;font-weight:600;margin-bottom:20px}h1{font-size:2rem;line-height:1.2;letter-spacing:-.04em}h2{font-size:1.25rem}p{line-height:1.65}.muted,.help{color:#5d716d}.help{font-size:.85rem}.eyebrow{font-size:.7rem;font-weight:750;letter-spacing:.13em;color:#347968}.brand{font-size:1.6rem;letter-spacing:.15em;font-weight:750;color:inherit;text-decoration:none}.brand span{display:block;font-size:.68rem;letter-spacing:.38em;margin-top:5px}.login-layout{min-height:100vh;display:grid;grid-template-columns:1fr 1fr}.login-layout>aside{background:#123f35;color:#f7fcf9;padding:70px 12%;display:flex;flex-direction:column;justify-content:center}.login-layout>aside h1{font-size:clamp(2.2rem,4vw,3.8rem);margin:80px 0 20px}.login-layout>aside p{color:#bddecf;max-width:350px}.login-layout>aside small{color:#8ab8a4;margin-top:auto;padding-top:60px}.login-card{align-self:center;padding:60px 12%;max-width:650px}.login-card h2{font-size:2rem;letter-spacing:-.04em}.login-card form{margin-top:30px}.login-card .primary{width:100%;padding:14px}.shell{display:flex;min-height:100vh}.sidebar{width:240px;flex-shrink:0;background:#123f35;color:#f5fbf7;padding:32px 20px;display:flex;flex-direction:column}.sidebar .brand{padding:0 12px}.sidebar-label{font-size:.63rem;letter-spacing:.12em;color:#8bb7a6;margin:55px 12px 12px}.sidebar nav{display:flex;flex-direction:column;gap:6px}.sidebar nav button{background:none;border:none;color:#b9d5c9;text-align:left;padding:13px 14px}.sidebar nav button.active{background:#275b4b;color:#fff}.sidebar-bottom{margin-top:auto;padding:40px 10px 0}.sidebar-bottom strong,.sidebar-bottom small{display:block;overflow-wrap:anywhere}.sidebar-bottom small{font-size:.75rem;color:#9cbeb0;margin:6px 0 20px}.sidebar-bottom button{background:none;color:#c3ddcf;border-color:#477667;font-size:.8rem}.workspace{flex:1;min-width:0}.workspace header{background:white;border-bottom:1px solid #dce6e1;padding:24px 40px;display:flex;justify-content:space-between;gap:15px}.workspace header strong,.workspace header .muted{display:block;margin-top:5px}.workspace header strong{font-size:1.15rem}.workspace header .muted{font-size:.85rem}.local-badge{align-self:flex-start;font-size:.7rem;padding:7px 10px;border:1px solid #d8e5de;border-radius:20px;color:#58746a;white-space:nowrap}.workspace main{padding:30px 40px;max-width:1250px}.context-card{background:#e9f0ec;border:1px solid #d9e5df;border-radius:10px;padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:0 20px;margin-bottom:35px}.context-card label{margin-bottom:4px}.context-card p{grid-column:1/-1;font-size:.75rem;color:#64796f;margin-bottom:0}.panel{background:white;border:1px solid #dce6e1;border-radius:10px;padding:28px;margin-bottom:22px}.page-title{margin:35px 0 25px}.page-title h1{margin:10px 0}.summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}.panel form{max-width:650px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.actions{display:flex;gap:12px;flex-wrap:wrap}.alert{border-radius:8px;padding:14px 18px;margin-bottom:20px;font-size:.9rem}.error{background:#fff0ec;border:1px solid #edc5b8;color:#943d27}.success{background:#e9f5ec;border:1px solid #bfdec8;color:#2a6540}.unit-list{list-style:none;padding:0;margin-bottom:30px}.unit-list li{display:flex;align-items:center;justify-content:space-between;padding:14px 0;gap:15px;border-bottom:1px solid #e7ede9}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;text-align:left;font-size:.85rem}th{background:#f3f7f4;color:#5d7468;font-size:.75rem}th,td{padding:14px;border-bottom:1px solid #e3eae5}td small{display:block;color:#6d8176;margin-top:4px}footer{padding:10px 40px 30px;color:#7a8d82;font-size:.72rem}.loading{padding:15vh 10%}@media(max-width:800px){.login-layout{grid-template-columns:1fr}.login-layout>aside{padding:35px}.login-layout>aside h1{font-size:2rem;margin:30px 0 10px}.login-layout>aside small{padding-top:20px}.login-card{padding:35px;max-width:none}.sidebar{width:170px;padding:25px 10px}.workspace header{padding:22px}.workspace main{padding:22px}.summary-grid{grid-template-columns:1fr}}@media(max-width:560px){.shell{display:block}.sidebar{width:100%;padding:15px}.sidebar .brand{font-size:1.1rem}.sidebar .brand span,.sidebar-label,.sidebar-bottom strong,.sidebar-bottom small{display:none}.sidebar nav{flex-direction:row;flex-wrap:wrap;margin-top:15px}.sidebar nav button{padding:8px}.sidebar-bottom{padding:5px 0 0}.sidebar-bottom button{font-size:.7rem}.context-card,.form-grid{grid-template-columns:1fr}.workspace header{padding:18px}.workspace main{padding:18px}.panel{padding:20px}.local-badge{display:none}footer{padding:18px}} .setup-pending{max-width:900px;margin:10vh auto;padding:25px}.setup-pending pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#edf3ef;border:1px solid #d6e3da;padding:18px;border-radius:7px;font-size:.85rem;line-height:1.6} +.a-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:22px 0}.a-heading h1{margin:8px 0}.a-grid{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.a-list{padding:18px}.a-list>button{display:block;width:100%;text-align:left;margin:8px 0;font-weight:400}.a-list>button strong,.a-list>button small{display:block;overflow-wrap:anywhere}.a-list>button small{font-size:.7rem;color:#6b7e76;margin-top:6px}.a-list>button.chosen{border-color:#26775b;background:#eff8f2}.a-detail{min-width:0}.a-detail h2{overflow-wrap:anywhere}.a-draft-notice{background:#fff7e6;border:1px solid #ead7ad;color:#7d6125;padding:14px 18px;border-radius:8px;margin-bottom:20px;font-size:.85rem}.a-line{border:1px solid #d7e4dc;border-radius:8px;margin:22px 0;padding:18px}.a-line legend{font-size:.8rem;color:#567563}.a-pending{background:#fff8eb;border-left:3px solid #d2a34c;padding:12px 20px;margin:22px 0}.a-pending h3{font-size:.9rem}.a-pending li{font-size:.85rem;line-height:1.7}.a-form-actions{margin-top:25px}.a-history{margin-top:28px;border-top:1px solid #e0e9e3;padding-top:18px}.a-history summary{cursor:pointer;color:#31654c;font-size:.85rem}.a-history>div{padding:18px 0;border-bottom:1px solid #e4ece6}.a-history pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.72rem;background:#f4f7f4;padding:12px}@media(max-width:1100px){.a-grid{grid-template-columns:1fr}.a-list{display:flex;flex-wrap:wrap;gap:8px}.a-list h2{width:100%}.a-list>button{width:auto;max-width:100%;flex:1 1 180px}}@media(max-width:700px){.a-heading{display:block}.a-heading>.actions{margin-bottom:20px}} diff --git a/resources/js/ServicesWorkspace.jsx b/resources/js/ServicesWorkspace.jsx new file mode 100644 index 0000000..dc20e9f --- /dev/null +++ b/resources/js/ServicesWorkspace.jsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react'; + +export const serviceAreas = { clients: 'Clientes', services: 'Serviços', service_drafts: 'Prestações em rascunho', billing_drafts: 'Rascunhos de facturação' }; +const actions = { view: 'Consultar', create: 'Criar', edit: 'Editar', manage: 'Gerir concessões' }; +const isDraft = area => area === 'service_drafts' || area === 'billing_drafts'; +const newLine = () => ({ service_id: '', quantity: '', unit_price: '', description: '' }); + +export default function ServicesWorkspace({ area, context, can, api, perform, busy, initialId, navigate }) { + const unit = isDraft(area) ? context.unit?.id ?? null : null; + const allowed = action => can(`${area}.${action}`, unit); + const [records, setRecords] = useState([]); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState(null); + const [references, setReferences] = useState({ clients: [], services: [], source: [] }); + const [error, setError] = useState(''); + const [key, setKey] = useState(() => crypto.randomUUID()); + const [access, setAccess] = useState(false); + const path = `/api/a/${area}`; + + async function load() { const result = await api(path); setRecords(result.records); } + async function open(id) { setError(''); try { setSelected(await api(`${path}/${id}`)); setForm(null); } catch (e) { setError(e.message); } } + useEffect(() => { load().catch(e => setError(e.message)); if (initialId) open(initialId); }, [area, context.token]); + + async function start(record = null) { + setError(''); setAccess(false); + try { + if (isDraft(area)) { + const [clients, services, source] = await Promise.all([ + api('/api/a/clients'), api('/api/a/services'), area === 'billing_drafts' ? api('/api/a/service_drafts') : Promise.resolve({ records: [] }) + ]); + setReferences({ clients: clients.records, services: services.records, source: source.records }); + } + setSelected(record); + setForm(record ? { name: record.name, description: record.data.description || '', client_id: record.client_id || '', source_id: record.source_id || '', currency: record.data.currency || '', lines: record.data.lines?.map(line => ({ ...line, quantity: line.quantity ?? '', unit_price: line.unit_price ?? '' })) || [newLine()] } + : { name: '', description: '', client_id: '', source_id: '', currency: '', lines: [newLine()] }); + setKey(crypto.randomUUID()); + } catch (e) { setError(e.message); } + } + + async function save(event) { + event.preventDefault(); + let body = { name: form.name.trim() }; + if (area === 'services') body.description = form.description || null; + if (isDraft(area)) { + body = { ...(form.name.trim() ? { name: form.name.trim() } : {}), description: form.description || null }; + if (area === 'billing_drafts' && !selected) body.source_id = Number(form.source_id); + else { + body.currency = form.currency || null; + body.lines = form.lines.map(line => ({ service_id: Number(line.service_id), description: line.description || null, quantity: line.quantity || null, unit_price: line.unit_price || null })); + if (area === 'service_drafts') body.client_id = Number(form.client_id); + } + } + if (selected) body.version = selected.version; + await perform(async () => { + const result = await api(selected ? `${path}/${selected.id}` : path, selected ? 'PATCH' : 'POST', body, context.token, key); + await load(); await open(result.id); setKey(crypto.randomUUID()); + }, 'Rascunho ou cadastro guardado com histórico e captura durável.'); + } + + async function prepareBilling() { + await perform(async () => { + const result = await api('/api/a/billing_drafts', 'POST', { source_id: selected.id }, context.token, key); + navigate('billing_drafts', result.id); + }, 'Rascunho de facturação preparado a partir da prestação. Nenhuma factura foi emitida.'); + } + function changeLine(index, field, value) { setForm({ ...form, lines: form.lines.map((line, n) => n === index ? { ...line, [field]: value } : line) }); } + + if (!allowed('view')) return

{serviceAreas[area]}

Sem consulta autorizada neste contexto.

; + return <> +

ENTREGA A · PREPARAÇÃO

{serviceAreas[area]}

{isDraft(area) ? `Unidade: ${context.unit?.name || 'não seleccionada'}` : 'Cadastro partilhado pelas unidades da empresa'}

{allowed('create') && }{allowed('manage') && }
+ {error &&
{error}
} + {isDraft(area) &&
Apenas rascunhos. Sem emissão fiscal, confirmação de serviço ou lançamentos contabilísticos.
} + {access ? : <> +

Registos

{!records.length &&

Ainda não há registos neste âmbito.

}{records.map(record => )}
+ {form ?

{selected ? 'Editar' : 'Criar'} {isDraft(area) ? 'rascunho' : 'cadastro'}

+ {area !== 'clients' &&