Preserva entrega A aceite de servicos em rascunho

This commit is contained in:
2026-10-25 22:02:44 +02:00
parent 26b80df545
commit ca13acb74e
31 changed files with 1152 additions and 17 deletions
+4
View File
@@ -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.
+21 -1
View File
@@ -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
+1 -1
View File
@@ -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,
@@ -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]];
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Modules\Services;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class DraftValues
{
public static function decimal(mixed $value, string $field, bool $positive = false): ?string
{
if ($value === null || $value === '') { return null; }
if (! is_string($value) || ! preg_match('/^(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?$/D', $value)) {
throw ValidationException::withMessages([$field => '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();
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Modules\Services\Http;
use App\Http\Controllers\Controller;
use App\Modules\Services\ServiceWorkflow;
use App\Modules\Services\ServiceAccess;
use App\Modules\Services\ServicePermissions;
use Illuminate\Http\Request;
final class ServiceController extends Controller
{
public function index(Request $request, string $area, ServiceWorkflow $workflow) { return response()->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]);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
final class ServiceAccess
{
public function __construct(private ServiceWorkflow $workflow, private CompanyContext $context, private Permissions $permissions) {}
public function listing(string $area, int $actor): array
{
$scope = $this->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]);
});
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Modules\Services;
final class ServicePermissions
{
public const AREAS = ['clients', 'services', 'service_drafts', 'billing_drafts'];
public const ACTIONS = ['view', 'create', 'edit', 'manage'];
public const LABELS = ['clients' => '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;
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
final class ServiceWorkflow
{
public function __construct(private CompanyContext $context, private Permissions $permissions) {}
public function scope(string $area, int $actor, string $action): array
{
abort_unless(in_array($area, ServicePermissions::AREAS, true) && in_array($action, ServicePermissions::ACTIONS, true), 404);
$ids = $this->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);
}
}
@@ -0,0 +1,140 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) {
Schema::create('a_'.$area, function (Blueprint $table) use ($area): void {
$table->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))");
}
};
+82
View File
@@ -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.
+30 -10
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+16
View File
@@ -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
}
}
+18
View File
@@ -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"
]
}
+10
View File
@@ -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)."
]
}
File diff suppressed because one or more lines are too long
+106
View File
@@ -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 <section className="panel"><h1>{serviceAreas[area]}</h1><p>Sem consulta autorizada neste contexto.</p></section>;
return <>
<div className="a-heading"><div><p className="eyebrow">ENTREGA A · PREPARAÇÃO</p><h1>{serviceAreas[area]}</h1><p className="muted">{isDraft(area) ? `Unidade: ${context.unit?.name || 'não seleccionada'}` : 'Cadastro partilhado pelas unidades da empresa'}</p></div><div className="actions">{allowed('create') && <button className="primary" disabled={busy} onClick={() => start()}>Novo registo</button>}{allowed('manage') && <button onClick={() => { setAccess(!access); setForm(null); }}>Gerir acessos desta área</button>}</div></div>
{error && <div role="alert" className="alert error">{error}</div>}
{isDraft(area) && <div className="a-draft-notice">Apenas rascunhos. Sem emissão fiscal, confirmação de serviço ou lançamentos contabilísticos.</div>}
{access ? <AreaAccess area={area} context={context} api={api} perform={perform} busy={busy} /> : <>
<div className="a-grid"><section className="panel a-list"><h2>Registos</h2>{!records.length && <p className="muted">Ainda não há registos neste âmbito.</p>}{records.map(record => <button key={record.id} className={selected?.id === record.id ? 'chosen' : ''} onClick={() => open(record.id)}><strong>{record.name}</strong><small>#{record.id} · Versão {record.version}{isDraft(area) ? ' · Rascunho' : ''}</small></button>)}</section><section className="panel a-detail">
{form ? <form onSubmit={save}><h2>{selected ? 'Editar' : 'Criar'} {isDraft(area) ? 'rascunho' : 'cadastro'}</h2><label>{area === 'clients' ? 'Nome do cliente' : area === 'services' ? 'Nome do serviço' : 'Designação do rascunho (opcional)'}<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} maxLength={255} required={!isDraft(area)} /></label>
{area !== 'clients' && <label>Descrição (opcional)<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} maxLength={2000} /></label>}
{area === 'billing_drafts' && !selected ? <><label>Prestação de origem<select value={form.source_id} onChange={e => setForm({ ...form, source_id: e.target.value })} required><option value="">Seleccionar prestação em rascunho</option>{references.source.map(record => <option key={record.id} value={record.id}>{record.name} · versão {record.version}</option>)}</select></label><p className="help">Cliente, linhas e moeda serão reutilizados da prestação, sem redigitação. Poderá editar o rascunho depois de o preparar.</p></> : isDraft(area) && <>
{area === 'service_drafts' && <label>Cliente<select value={form.client_id} onChange={e => setForm({ ...form, client_id: e.target.value })} required><option value="">Seleccionar cliente da empresa</option>{references.clients.map(client => <option key={client.id} value={client.id}>{client.name}</option>)}</select></label>}
<label>Moeda indicada (opcional)<input value={form.currency} placeholder="Código de 3 letras; vazio = pendente" maxLength={3} pattern="[A-Z]{3}" onChange={e => setForm({ ...form, currency: e.target.value.toUpperCase() })} /></label>
<p className="help">Quantidades e preços usam ponto decimal, até 16 algarismos inteiros e 8 decimais. Campos vazios geram pendências. O imposto continua por validar.</p>
{form.lines.map((line, index) => <fieldset className="a-line" key={index}><legend>Linha {index + 1}</legend><label>Serviço<select value={line.service_id} onChange={e => changeLine(index, 'service_id', e.target.value)} required disabled={area === 'billing_drafts'}><option value="">Seleccionar serviço</option>{references.services.map(service => <option key={service.id} value={service.id}>{service.name}</option>)}</select></label><div className="form-grid"><label>Quantidade (opcional)<input inputMode="decimal" value={line.quantity} onChange={e => changeLine(index, 'quantity', e.target.value)} placeholder="Ex.: 1.25" /></label><label>Preço proposto (opcional)<input inputMode="decimal" value={line.unit_price} onChange={e => changeLine(index, 'unit_price', e.target.value)} placeholder="Sem moeda presumida" /></label></div>{area === 'service_drafts' && form.lines.length > 1 && <button type="button" onClick={() => setForm({ ...form, lines: form.lines.filter((_, n) => n !== index) })}>Retirar linha do rascunho</button>}</fieldset>)}
{area === 'service_drafts' && form.lines.length < 100 && <button type="button" onClick={() => setForm({ ...form, lines: [...form.lines, newLine()] })}>Adicionar linha</button>}
</>}
<div className="actions a-form-actions"><button className="primary" disabled={busy}>Guardar {isDraft(area) ? 'rascunho' : 'cadastro'}</button><button type="button" onClick={() => setForm(null)}>Cancelar edição</button></div>
</form> : selected ? <><div className="a-heading"><h2>{selected.name}</h2>{allowed('edit') && <button disabled={busy} onClick={() => start(selected)}>Editar registo</button>}</div><p className="help">Identificador interno #{selected.id} · Versão {selected.version}{isDraft(area) ? ' · Estado: rascunho' : ''}</p>
{selected.data.description && <p>{selected.data.description}</p>}
{isDraft(area) && <><p><strong>Cliente:</strong> {selected.data.client_name}</p>{selected.source_id && <p>Origem: prestação #{selected.source_id}, versão {selected.data.source_version}</p>}<p><strong>Moeda:</strong> {selected.data.currency || 'Não indicada — pendente'}</p><div className="table-scroll"><table><thead><tr><th>Serviço</th><th>Quantidade</th><th>Preço proposto</th></tr></thead><tbody>{selected.data.lines.map((line, i) => <tr key={i}><td>{line.service_name}</td><td>{line.quantity ?? 'Por preencher'}</td><td>{line.unit_price ?? 'Por preencher'}</td></tr>)}</tbody></table></div><p><strong>Base aritmética:</strong> {selected.base_amount ?? 'Incompleta'} {selected.data.currency || '(moeda pendente)'}</p><p className="help">Esta base não é um total fiscal ou valor definitivo a pagar. Imposto por validar; contabilidade não processada.</p>
{Object.keys(selected.pending).length > 0 && <div className="a-pending"><h3>Pendências</h3><ul>{Object.entries(selected.pending).map(([code, message]) => <li key={code}>{message}</li>)}</ul></div>}
{area === 'service_drafts' && can('billing_drafts.create', context.unit?.id) && <button className="primary" disabled={busy} onClick={prepareBilling}>Preparar rascunho de facturação</button>}
{area === 'billing_drafts' && allowed('edit') && <button disabled={busy} onClick={() => perform(async () => { await api(`${path}/${selected.id}`, 'PATCH', { version: selected.version, reload_source: true }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Dados da prestação reutilizados explicitamente numa nova versão do rascunho.')}>Actualizar explicitamente da prestação</button>}
</>}
<details className="a-history"><summary>Histórico e captura durável ({selected.history?.length || 0} versões)</summary>{selected.history?.map(entry => <div key={entry.id}><strong>Versão {entry.version}</strong><p className="help">{entry.created_at} · Autor #{entry.actor_id} · Evento #{entry.event?.id} · Capturado, sem processamento contabilístico</p><details><summary>Dados preservados nesta versão</summary><pre>{JSON.stringify(entry.snapshot, null, 2)}</pre></details></div>)}</details>
</> : <><h2>Seleccione um registo</h2><p>Os dados e o histórico aparecem aqui. As operações disponíveis dependem das suas concessões explícitas.</p></>}
</section></div></>}
</>;
}
function AreaAccess({ area, context, api, perform, busy }) {
const [state, setState] = useState(null); const [error, setError] = useState('');
const [email, setEmail] = useState(''); const [action, setAction] = useState('view'); const [reason, setReason] = useState('');
const path = `/api/a/${area}/access`;
async function load() { setState(await api(path)); }
useEffect(() => { load().catch(e => setError(e.message)); }, [area, context.token]);
async function change(grant) { await perform(async () => { await api(path, 'POST', { email, action, reason, grant }); try { await load(); } catch { setState(null); } }, grant ? 'Concessão desta área registada.' : 'Revogação desta área registada.'); }
return <section className="panel"><h2>Acessos de {serviceAreas[area]}</h2><p>Âmbito: {context.company.name}{isDraft(area) ? ` / ${context.unit?.name}` : ' — cadastro da empresa, partilhado entre unidades'}. Apenas pode delegar direitos que possui neste âmbito.</p>{error && <p role="alert">{error}</p>}{state && <><form onSubmit={e => { e.preventDefault(); change(true); }}><label>Email do utilizador existente<input type="email" value={email} onChange={e => setEmail(e.target.value)} required /></label><label>Acção<select value={action} onChange={e => setAction(e.target.value)}>{state.actions.map(a => <option key={a} value={a}>{actions[a]}</option>)}</select></label><label>Motivo da concessão ou revogação<textarea value={reason} onChange={e => setReason(e.target.value)} maxLength={500} required /></label><div className="actions"><button className="primary" disabled={busy || !state.actions.includes(action)}>Conceder nesta área</button><button type="button" disabled={busy || !email || !reason || !state.actions.includes(action)} onClick={() => change(false)}>Revogar nesta área</button></div></form><div className="table-scroll"><table><thead><tr><th>Utilizador</th><th>Concessão</th></tr></thead><tbody>{state.grants.map(grant => <tr key={grant.id}><td>{grant.name}<small>{grant.email}</small></td><td>{actions[grant.permission.split('.').at(-1)]}</td></tr>)}</tbody></table></div></>}</section>;
}
+7 -4
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '../css/app.css';
import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
const labels = { 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
@@ -10,11 +11,12 @@ function App() {
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [page, setPage] = useState('home');
const [aSelection, setASelection] = useState(null);
const context = state?.context;
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
async function api(path, method = 'GET', body, token = context?.token) {
const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
async function api(path, method = 'GET', body, token = context?.token, commandKey = null) {
const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '', ...(commandKey ? { 'Idempotency-Key': commandKey } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 401) { await refresh(); }
@@ -44,16 +46,17 @@ function App() {
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : [])].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
{page === 'home' && <><div className="page-title"><p className="eyebrow">ÁREA INICIAL</p><h1>Bem-vindo, {state.user.name.split(' ')[0]}.</h1><p className="muted">O contexto permanece visível e cada operação é validada pelo servidor.</p></div><div className="summary-grid"><section className="panel"><span className="eyebrow">EMPRESA ACTIVA</span><h2>{context.company.name}</h2><p>Cliente contratante #{context.company.tenant_id} · Empresa #{context.company.id}</p><button onClick={() => setPage('company')}>Consultar empresa</button></section><section className="panel"><span className="eyebrow">UNIDADE ACTIVA</span><h2>{context.unit?.name || 'Ainda não seleccionada'}</h2><p>{context.units.length} unidade(s) com consulta autorizada</p><button onClick={() => setPage('units')}>Ver unidades</button></section></div></>}
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
</div>}
</main><footer>SGF TEMPO · Primeiro percurso funcional · Sem módulos financeiros nesta etapa</footer></div></div>;
</main><footer>SGF TEMPO · Entrega A: preparação em rascunho · Sem emissão fiscal ou lançamentos</footer></div></div>;
}
function Login({ busy, onSubmit }) {
const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
+9
View File
@@ -15,6 +15,15 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
Route::post('/api/logout', [IdentityController::class, 'logout']);
Route::post('/api/context', [IdentityController::class, 'select']);
Route::middleware(RequireCompanyContext::class)->group(function (): void {
Route::prefix('api/a/{area}')->group(function (): void {
$controller = \App\Modules\Services\Http\ServiceController::class;
Route::get('access', [$controller, 'accesses']);
Route::post('access', [$controller, 'changeAccess']);
Route::get('', [$controller, 'index']);
Route::post('', [$controller, 'create']);
Route::get('{id}', [$controller, 'show'])->whereNumber('id');
Route::patch('{id}', [$controller, 'update'])->whereNumber('id');
});
Route::get('/api/context', fn (\App\Shared\Context\CompanyContext $context) => response()->json($context->identifiers() + ['unit_id' => $context->unitId()]));
Route::patch('/api/companies/{company}', [IdentityController::class, 'renameCompany']);
Route::get('/api/companies/{company}', [IdentityController::class, 'company']);
+39
View File
@@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$fixture = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'), true, 32, JSON_THROW_ON_ERROR);
$base = ['actor' => $fixture['manager_id'], 'tenant' => $fixture['tenant'], 'company' => $fixture['company'], 'unit' => $fixture['unit']];
function pair(array $firstInput, array $secondInput): array {
$first = new Process([PHP_BINARY, 'scripts/a-concurrency-worker.php', '--env=testing'], base_path(), null, json_encode($firstInput + ['hold' => true]));
$second = new Process([PHP_BINARY, 'scripts/a-concurrency-worker.php', '--env=testing'], base_path(), null, json_encode($secondInput));
$first->start();
if (! $first->waitUntil(fn ($type, $buffer) => str_contains($buffer, 'LOCK_ACQUIRED'))) { throw new RuntimeException('Could not establish concurrent lock test.'); }
$second->start(); $first->wait(); $second->wait();
$results = [];
foreach ([$first, $second] as $process) {
if (! $process->isSuccessful()) { throw new RuntimeException('Synthetic concurrency process failed.'); }
$lines = array_values(array_filter(explode("\n", trim($process->getOutput())), fn ($line) => str_starts_with($line, '{')));
$results[] = json_decode(end($lines), true, 32, JSON_THROW_ON_ERROR);
}
return $results;
}
$beforeEvents = DB::table('a_events')->count();
$key = (string) \Illuminate\Support\Str::uuid();
$input = $base + ['data' => ['name' => 'Cliente sintético de concorrência'], 'key' => $key];
[$first, $second] = pair($input, $input);
if ($first['status'] !== 200 || $second['status'] !== 200 || $first['result']['id'] !== $second['result']['id'] || ! $second['result']['replayed'] || DB::table('a_events')->count() !== $beforeEvents + 1) { throw new RuntimeException('Concurrent repetition was not deduplicated.'); }
$id = $first['result']['id'];
[$edit, $conflict] = pair($base + ['id' => $id, 'data' => ['name' => 'Versão concorrente preservada', 'version' => 1], 'key' => (string) \Illuminate\Support\Str::uuid()],
$base + ['id' => $id, 'data' => ['name' => 'Escrita antiga recusada', 'version' => 1], 'key' => (string) \Illuminate\Support\Str::uuid()]);
if ($edit['status'] !== 200 || $conflict['status'] !== 409 || DB::table('a_events')->count() !== $beforeEvents + 2 || DB::table('a_clients')->where('id', $id)->value('name') !== 'Versão concorrente preservada') { throw new RuntimeException('Concurrent version conflict was not protected.'); }
$report = ['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]];
file_put_contents(base_path('docs/proofs/a-concurrency.json'), json_encode($report, JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo "Concurrent PHP/PostgreSQL proof passed: one repeated creation, one version-conflicting edit, no duplicated events.\n";
+21
View File
@@ -0,0 +1,21 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$input = json_decode(stream_get_contents(STDIN), true, 32, JSON_THROW_ON_ERROR);
$context = app(\App\Shared\Context\CompanyContext::class);
$context->selectFor($input['actor'], $input['tenant'], $input['company'], $input['unit']);
try {
$result = \Illuminate\Support\Facades\DB::transaction(function () use ($input) {
if ($input['hold'] ?? false) {
\Illuminate\Support\Facades\DB::table('companies')->where('id', $input['company'])->lockForUpdate()->firstOrFail();
echo "LOCK_ACQUIRED\n"; flush(); usleep(2000000);
}
return app(\App\Modules\Services\ServiceWorkflow::class)->save('clients', $input['id'] ?? null, $input['actor'], $input['data'], $input['key']);
});
echo json_encode(['status' => 200, 'result' => $result], JSON_THROW_ON_ERROR).PHP_EOL;
} catch (\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $exception) {
echo json_encode(['status' => $exception->getStatusCode()], JSON_THROW_ON_ERROR).PHP_EOL;
} catch (\Throwable) { fwrite(STDERR, "Synthetic concurrency worker failed; no request payload displayed.\n"); exit(1); }
+50
View File
@@ -0,0 +1,50 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
const fixture = JSON.parse(await fs.readFile('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json','utf8'));
const base = 'http://127.0.0.1:8001';
function client() {
const cookies = new Map(); let csrf = ''; let context = '';
return {
cookies,
async request(path, method='GET', body, {key=randomUUID(), token=context, useCsrf=true}={}) {
const headers={Accept:'application/json','Content-Type':'application/json', Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-SGF-Context':token,'Idempotency-Key':key};
if(useCsrf) headers['X-CSRF-TOKEN']=csrf;
const response=await fetch(base+path,{method,headers,...(body===undefined?{}:{body:JSON.stringify(body)})});
for(const cookie of response.headers.getSetCookie()){const pair=cookie.split(';',1)[0], split=pair.indexOf('=');cookies.set(pair.slice(0,split),pair.slice(split+1));}
const data=await response.json().catch(()=>({}));
if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;
return {status:response.status,data};
},
async login(email){await this.request('/api/session');assert.equal((await this.request('/api/login','POST',{email,password:fixture.password})).status,200);},
async select(unit=fixture.unit){const result=await this.request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:unit});assert.equal(result.status,200);return result.data.context.token;}
};
}
const manager=client(),reader=client(); await manager.login('gestor@example.test');const oldToken=await manager.select();await reader.login('consulta@example.test');await reader.select();
const results=[];
const key=randomUUID();const created=await manager.request('/api/a/clients','POST',{name:'Cliente sintético HTTP A'},{key});assert.equal(created.status,201);
const replay=await manager.request('/api/a/clients','POST',{name:'Cliente sintético HTTP A'},{key});assert.equal(replay.status,201);assert.equal(replay.data.id,created.data.id);assert.equal(replay.data.event_id,created.data.event_id);assert.equal(replay.data.replayed,true);
assert.equal((await manager.request('/api/a/clients','POST',{name:'Conteúdo alterado'},{key})).status,409);
results.push('Criação repetida recupera o mesmo registo/evento; conteúdo diferente com a mesma chave é recusado (409).');
const path=`/api/a/clients/${created.data.id}`;
assert.equal((await reader.request(path)).status,200);
assert.equal((await reader.request(path,'PATCH',{name:'Não autorizado',version:1})).status,403);
assert.equal((await reader.request('/api/a/clients/access')).status,403);
results.push('Consulta por credenciais reais sintéticas: leitura 200, edição e gestão 403.');
const grant={email:'consulta@example.test',action:'edit',grant:true,reason:'Prova HTTP A, exclusivamente sintética'};
assert.equal((await manager.request('/api/a/clients/access','POST',grant)).status,200);
assert.equal((await reader.request(path,'PATCH',{name:'Edição sintética autorizada',version:1})).status,200);
assert.equal((await manager.request('/api/a/clients/access','POST',{...grant,grant:false})).status,200);
assert.equal((await reader.request(path,'PATCH',{name:'Direito revogado',version:2})).status,403);
results.push('Concessão/revogação de edição têm efeito no pedido seguinte, mantendo a mesma sessão.');
assert.equal((await manager.request(path,'PATCH',{name:'Versão antiga',version:1})).status,409);
await manager.select(null);
assert.equal((await manager.request(path,'PATCH',{name:'Contexto antigo',version:2},{token:oldToken})).status,409);
assert.equal((await manager.request('/api/a/clients','POST',{name:'Sem CSRF'},{useCsrf:false})).status,419);
results.push('Versão antiga e contexto antigo recusados (409); alteração sem protecção CSRF/origem recusada (419).');
const previousCookie=new Map(reader.cookies);assert.equal((await reader.request('/api/logout','POST')).status,200);reader.cookies.clear();for(const [k,v] of previousCookie)reader.cookies.set(k,v);
assert.equal((await reader.request('/api/a/clients')).status,401);await manager.request('/api/logout','POST');
results.push('Cookie anterior à saída não recupera acesso aos dados de A (401).');
await fs.writeFile(new URL('../docs/proofs/a-http-results.json',import.meta.url),JSON.stringify({environment:'browser-testing / sgf_tempo_test only',results},null,2));
console.log(results.join('\n'));
+13
View File
@@ -0,0 +1,13 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$counts = [];
foreach (['a_clients','a_services','a_service_drafts','a_billing_drafts','a_history','a_events','a_outbox','a_pending','a_commands'] as $table) { $counts[$table] = \Illuminate\Support\Facades\DB::table($table)->count(); }
$missing = \Illuminate\Support\Facades\DB::table('a_events as e')->leftJoin('a_outbox as o', 'o.event_id', '=', 'e.id')->whereNull('o.event_id')->count();
if ($missing !== 0 || $counts['a_history'] !== $counts['a_events'] || $counts['a_events'] !== $counts['a_outbox']) { throw new RuntimeException('Incomplete durable capture.'); }
$report = ['database' => 'sgf_tempo_test', 'counts' => $counts, 'events_without_outbox' => $missing, 'outbox_states' => \Illuminate\Support\Facades\DB::table('a_outbox')->distinct()->pluck('state')->all()];
file_put_contents(base_path('docs/proofs/a-durable-capture.json'), json_encode($report, JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo json_encode($report, JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
+11
View File
@@ -0,0 +1,11 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { exit(1); }
$counts = [];
foreach (['a_clients','a_services','a_service_drafts','a_billing_drafts','a_history','a_events','a_outbox','a_pending','a_commands'] as $table) { $counts[$table] = \Illuminate\Support\Facades\DB::table($table)->count(); }
$permissions = array_merge(\App\Modules\Services\ServicePermissions::catalogue(false), \App\Modules\Services\ServicePermissions::catalogue(true));
$grants = \Illuminate\Support\Facades\DB::table('permission_grants as g')->join('users as u', 'u.id', '=', 'g.user_id')->whereIn('g.permission', $permissions)->orderBy('u.id')->orderBy('g.permission')->get(['u.id as user_id','u.name','g.tenant_id','g.company_id','g.unit_id','g.permission']);
echo json_encode(['database' => 'sgf_tempo_dev', 'role' => 'sgf_dev_app', 'records' => $counts, 'explicit_grants' => $grants], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL;
+3 -1
View File
@@ -9,7 +9,9 @@ foreach (['users', 'tenants', 'companies', 'units'] as $table) {
fwrite(STDERR, "Test database is not empty. No data was deleted. Stop other test workflows and prepare the test database explicitly.\n"); exit(1);
}
}
$fixture = \Illuminate\Support\Facades\DB::transaction(fn () => \Tests\Support\IdentityFixture::create());
$fixture = \Illuminate\Support\Facades\DB::transaction(fn () => \Tests\Support\ServicesFixture::create());
$safe = array_intersect_key($fixture, array_flip(['tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'password']));
$safe['manager_id'] = $fixture['manager']->id;
$safe['reader_id'] = $fixture['reader']->id;
file_put_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json', json_encode($safe, JSON_THROW_ON_ERROR));
echo "Synthetic browser fixtures prepared only in sgf_tempo_test. Credentials stored locally with restricted ACL; not displayed.\n";
+207
View File
@@ -0,0 +1,207 @@
<?php
namespace Tests\Feature;
use App\Modules\Services\ServicePermissions;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Support\ServicesFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class ServicesDeliveryATest extends TestCase
{
use RefreshDatabase;
private array $fixture;
private string $token;
protected function setUp(): void
{
parent::setUp(); $this->fixture = ServicesFixture::create(); $this->login();
}
private function login(string $kind = 'manager'): void
{
$this->postJson('/api/login', ['email' => $this->fixture[$kind]->email, 'password' => $this->fixture['password']])->assertOk();
$this->select($this->fixture['unit']);
}
private function select(?int $unit): void
{
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $unit])->assertOk()->json('context.token');
}
private function write(string $area, array $data, ?int $id = null, ?string $key = null)
{
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => $key ?? (string) \Illuminate\Support\Str::uuid()]);
return $id ? $this->patchJson("/api/a/$area/$id", $data) : $this->postJson("/api/a/$area", $data);
}
private function prepared(): array
{
$client = $this->write('clients', ['name' => 'Cliente sintético'])->assertCreated()->json('id');
$service = $this->write('services', ['name' => 'Serviço sintético'])->assertCreated()->json('id');
$payload = ['client_id' => $client, 'lines' => [['service_id' => $service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]];
return [$client, $service, $payload];
}
public function test_complete_a_workflow_reuses_source_with_exact_values_pending_and_durable_history(): void
{
[$client, $service, $payload] = $this->prepared();
$draft = $this->write('service_drafts', $payload)->assertCreated()->json('id');
$billing = $this->write('billing_drafts', ['source_id' => $draft])->assertCreated()->json('id');
$record = $this->getJson("/api/a/billing_drafts/$billing")->assertOk();
$record->assertJsonPath('client_id', $client)->assertJsonPath('source_id', $draft)->assertJsonPath('data.source_version', 1)
->assertJsonPath('data.lines.0.service_id', $service)->assertJsonPath('base_amount', '0.0000000125')
->assertJsonPath('data.currency', null)->assertJsonPath('state', 'draft')->assertJsonPath('data.tax_status', 'pending');
$this->assertArrayHasKey('currency_missing', $record->json('pending'));
$this->assertArrayHasKey('tax_rule_pending', $record->json('pending'));
$this->assertSame(4, DB::table('a_history')->count());
$this->assertSame(4, DB::table('a_events')->count());
$this->assertSame(4, DB::table('a_outbox')->where('state', 'captured')->count());
$this->assertSame(1, DB::table('a_clients')->count());
$this->assertSame(1, DB::table('a_services')->count());
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('journal_entries'));
}
public function test_catalogues_are_shared_between_authorized_units_but_drafts_are_not(): void
{
[, , $payload] = $this->prepared();
$first = $this->write('service_drafts', $payload)->assertCreated()->json('id');
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']);
foreach (['service_drafts','billing_drafts'] as $area) { foreach (ServicePermissions::ACTIONS as $action) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], "$area.$action", $this->fixture['hidden']); } }
$this->select($this->fixture['hidden']);
$this->assertCount(1, $this->getJson('/api/a/clients')->assertOk()->json('records'));
$this->assertCount(1, $this->getJson('/api/a/services')->assertOk()->json('records'));
$this->getJson("/api/a/service_drafts/$first")->assertForbidden();
$this->write('billing_drafts', ['source_id' => $first])->assertForbidden();
$this->write('service_drafts', $payload)->assertCreated();
$this->assertSame(1, DB::table('a_clients')->count());
$this->assertSame(2, DB::table('a_service_drafts')->count());
}
public function test_company_catalogue_is_available_without_selected_unit(): void
{
$this->select(null);
$this->write('clients', ['name' => 'Cliente sem unidade'])->assertCreated();
$this->getJson('/api/a/clients')->assertOk();
$this->getJson('/api/a/service_drafts')->assertForbidden();
}
public function test_reader_can_consult_all_four_areas_but_cannot_write_or_manage(): void
{
[, , $payload] = $this->prepared();
$draft = $this->write('service_drafts', $payload)->assertCreated()->json('id');
$this->write('billing_drafts', ['source_id' => $draft])->assertCreated();
$this->login('reader');
foreach (ServicePermissions::AREAS as $area) {
$this->getJson("/api/a/$area")->assertOk();
$this->getJson("/api/a/$area/access")->assertForbidden();
$this->write($area, $area === 'clients' || $area === 'services' ? ['name' => 'Denied'] : ($area === 'service_drafts' ? $payload : ['source_id' => $draft]))->assertForbidden();
}
$client = DB::table('a_clients')->value('id');
$this->write('clients', ['name' => 'Denied', 'version' => 1], $client)->assertForbidden();
}
public function test_idempotency_replays_once_and_conflicting_content_is_refused(): void
{
$key = (string) \Illuminate\Support\Str::uuid();
$first = $this->write('clients', ['name' => 'Único'], key: $key)->assertCreated()->json();
$this->write('clients', ['name' => 'Único'], key: $key)->assertCreated()->assertJsonPath('id', $first['id'])->assertJsonPath('replayed', true);
$this->write('clients', ['name' => 'Outro'], key: $key)->assertStatus(409);
$this->assertSame(1, DB::table('a_clients')->count());
$this->assertSame(1, DB::table('a_events')->count());
$this->assertSame(1, DB::table('a_commands')->count());
}
public function test_stale_version_and_context_are_refused(): void
{
$id = $this->write('clients', ['name' => 'Inicial'])->assertCreated()->json('id');
$this->write('clients', ['name' => 'Nova versão', 'version' => 1], $id)->assertOk();
$this->write('clients', ['name' => 'Antigo', 'version' => 1], $id)->assertStatus(409);
$old = $this->token; $this->select(null);
$this->withHeaders(['X-SGF-Context' => $old, 'Idempotency-Key' => (string) \Illuminate\Support\Str::uuid()])
->patchJson("/api/a/clients/$id", ['name' => 'Contexto antigo', 'version' => 2])->assertStatus(409);
}
public function test_foreign_client_and_service_and_scope_payload_are_refused(): void
{
[, , $payload] = $this->prepared();
$foreign = DB::table('a_clients')->insertGetId(['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign'], 'unit_id' => null, 'name' => 'Outro cliente', 'data' => '{}', 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
$this->write('service_drafts', array_replace($payload, ['client_id' => $foreign]))->assertForbidden();
$foreignService = DB::table('a_services')->insertGetId(['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign'], 'unit_id' => null, 'name' => 'Outro serviço', 'data' => '{}', 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
$this->write('service_drafts', ['client_id' => $payload['client_id'], 'lines' => [['service_id' => $foreignService]]])->assertForbidden();
$this->write('clients', ['name' => 'Manipulado', 'unit_id' => $this->fixture['hidden']])->assertUnprocessable();
}
public function test_precision_is_rejected_without_rounding_and_missing_values_are_not_zero(): void
{
[, , $payload] = $this->prepared();
foreach (['0.123456789', '10000000000000000', '1e3'] as $invalid) {
$bad = $payload; $bad['lines'][0]['unit_price'] = $invalid;
$this->write('service_drafts', $bad)->assertUnprocessable();
}
$payload['lines'][0]['quantity'] = null; $payload['lines'][0]['unit_price'] = null;
$id = $this->write('service_drafts', $payload)->assertCreated()->json('id');
$result = $this->getJson("/api/a/service_drafts/$id")->assertOk();
$result->assertJsonPath('base_amount', null)->assertJsonPath('data.lines.0.unit_price', null)->assertJsonPath('data.currency', null);
$this->assertArrayHasKey('quantity_missing_0', $result->json('pending'));
$this->assertArrayHasKey('price_missing_0', $result->json('pending'));
}
public function test_source_changes_preserve_billing_snapshot_and_create_pending_until_explicit_reload(): void
{
[, , $payload] = $this->prepared();
$draft = $this->write('service_drafts', $payload)->assertCreated()->json('id');
$billing = $this->write('billing_drafts', ['source_id' => $draft])->assertCreated()->json('id');
$changed = $payload; $changed['version'] = 1; $changed['lines'][0]['quantity'] = '2';
$this->write('service_drafts', $changed, $draft)->assertOk();
$old = $this->getJson("/api/a/billing_drafts/$billing")->assertOk();
$old->assertJsonPath('data.lines.0.quantity', '1.25')->assertJsonPath('version', 1);
$this->assertArrayHasKey('source_changed', $old->json('pending'));
$this->write('billing_drafts', ['version' => 1, 'reload_source' => true], $billing)->assertOk();
$this->getJson("/api/a/billing_drafts/$billing")->assertOk()->assertJsonPath('data.lines.0.quantity', '2')->assertJsonPath('data.source_version', 2)->assertJsonCount(2, 'history');
}
public function test_transaction_failure_rolls_back_record_history_event_pending_and_command(): void
{
DB::unprepared("CREATE OR REPLACE FUNCTION sgf_test_fail_outbox() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_OUTBOX_FAILURE'; END; \$\$; CREATE TRIGGER synthetic_failure BEFORE INSERT ON a_outbox FOR EACH ROW EXECUTE FUNCTION sgf_test_fail_outbox();");
$this->write('clients', ['name' => 'Rollback'])->assertStatus(500);
foreach (['a_clients','a_history','a_events','a_outbox','a_pending','a_commands'] as $table) { $this->assertSame(0, DB::table($table)->count()); }
}
public function test_limited_manager_cannot_grant_above_authority_and_revocation_is_immediate(): void
{
$client = $this->write('clients', ['name' => 'Consulta'])->assertCreated()->json('id');
$this->login('limited');
$this->withHeader('X-SGF-Context', $this->token)->postJson('/api/a/clients/access', ['email' => $this->fixture['reader']->email, 'action' => 'edit', 'grant' => true, 'reason' => 'Teste'])->assertForbidden();
$this->withHeader('X-SGF-Context', $this->token)->postJson('/api/a/clients/access', ['email' => $this->fixture['reader']->email, 'action' => 'view', 'grant' => false, 'reason' => 'Teste'])->assertOk();
$this->login('reader');
$this->getJson("/api/a/clients/$client")->assertForbidden();
$this->getJson('/api/a/services')->assertOk();
}
public function test_database_blocks_mass_scope_transfer_and_history_changes(): void
{
$id = $this->write('clients', ['name' => 'Protegido'])->assertCreated()->json('id');
$this->expectException(\Illuminate\Database\QueryException::class);
$this->expectExceptionMessage('SGF_A_IMMUTABLE_SCOPE');
DB::table('a_clients')->where('id', $id)->update(['company_id' => $this->fixture['sibling'], 'version' => 2]);
}
public function test_database_rejects_excess_precision_even_when_bypassing_the_http_validator(): void
{
[, , $payload] = $this->prepared();
$id = $this->write('service_drafts', $payload)->assertCreated()->json('id');
$data = json_decode(DB::table('a_service_drafts')->where('id', $id)->value('data'), true);
$data['lines'][0]['unit_price'] = '0.123456789';
$this->expectException(\Illuminate\Database\QueryException::class);
$this->expectExceptionMessage('SGF_A_DECIMAL_PRECISION');
DB::table('a_service_drafts')->where('id', $id)->update(['data' => json_encode($data), 'version' => 2]);
}
public function test_history_cannot_be_rewritten_in_bulk(): void
{
$this->write('clients', ['name' => 'Histórico preservado'])->assertCreated();
$this->expectException(\Illuminate\Database\QueryException::class);
$this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
DB::table('a_history')->update(['snapshot' => '{}']);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Tests\Support;
use App\Modules\Services\ServicePermissions;
final class ServicesFixture
{
public static function create(): array
{
$fixture = IdentityFixture::create();
foreach (ServicePermissions::AREAS as $area) {
$unit = ServicePermissions::unitScoped($area) ? $fixture['unit'] : null;
foreach (ServicePermissions::ACTIONS as $action) { IdentityFixture::grant($fixture['manager']->id, $fixture['tenant'], $fixture['company'], "$area.$action", $unit); }
IdentityFixture::grant($fixture['reader']->id, $fixture['tenant'], $fixture['company'], "$area.view", $unit);
}
IdentityFixture::grant($fixture['limited']->id, $fixture['tenant'], $fixture['company'], 'clients.view');
IdentityFixture::grant($fixture['limited']->id, $fixture['tenant'], $fixture['company'], 'clients.manage');
return $fixture;
}
}