Preserva B1 aceite de confirmacao interna sem validade fiscal

This commit is contained in:
2026-10-25 22:22:41 +02:00
parent ca13acb74e
commit 8fcd52c31d
26 changed files with 724 additions and 10 deletions
+2
View File
@@ -20,3 +20,5 @@
- 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.
- Autorização posterior delimitada: B1, apenas confirmação interna dos rascunhos de facturação, fotografia imutável e captura transaccional de eventos/pendências. Mostrar «Confirmado internamente — sem validade fiscal». Não emitir documentos comerciais/fiscais, reconhecer receita, produzir lançamentos, receber, reconciliar, fechar ou publicar. A nova concessão billing_drafts.confirm_internal foi especificamente confirmada para a conta existente ID 1, empresa 1/unidade 1; não implica gestão de novas concessões nem altera a consulta da conta ID 2. B1 não conclui B integralmente; ver docs/SERVICES_B1_DELIVERY.md.
- Em 25 de Outubro de 2026, o utilizador aceitou B1 no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos, e autorizou um checkpoint Git local sem credenciais ou dados locais. Preservar B1 e os checkpoints anteriores. Não avançar para os restantes incrementos nem publicar.
+2
View File
@@ -8,6 +8,8 @@ Primeiro percurso funcional local, sobre a fundação existente e o roteiro v0.9
## Usar a entrega A
**Incremento posterior B1:** disponível a confirmação interna do rascunho de facturação para quem possua `billing_drafts.confirm_internal`. Abrir o rascunho, escolher **Rever confirmação interna**, conferir versão/contexto e confirmar. Quantidades/preços e referências devem estar completos e actuais; moeda ou regras desconhecidas permanecem pendentes. A fotografia é imutável e o rascunho confirmado deixa de poder ser editado ou eliminado. A referência `INT-B1-…` não é numeração fiscal. Não há emissão, impressão de factura, reconhecimento ou lançamento contabilístico. [Relatório B1](docs/SERVICES_B1_DELIVERY.md). A mantém-se aceite no checkpoint `ca13acb`; B1 foi aceite em 25 de Outubro de 2026 no âmbito demonstrado, sem validade fiscal nem lançamentos contabilísticos. Não avançar para os restantes incrementos nem publicar.
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.
@@ -11,7 +11,18 @@ 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 show(Request $request, string $area, int $id, ServiceWorkflow $workflow)
{
$record = $workflow->show($area, $id, $request->user()->id);
if ($area === 'billing_drafts') { $record['confirmation'] = app(\App\Modules\Services\InternalConfirmation::class)->show($id, $request->user()->id); }
return response()->json($record);
}
public function confirmInternal(Request $request, int $id, \App\Modules\Services\InternalConfirmation $confirmation)
{
abort_if(array_diff(array_keys($request->all()), ['version']) !== [], 422, 'Campo não suportado ou identificador protegido.');
$data = $request->validate(['version' => 'required|integer|min:1']);
return response()->json($confirmation->confirm($id, $request->user()->id, (int) $data['version'], (string) $request->header('Idempotency-Key', '')));
}
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); }
@@ -0,0 +1,86 @@
<?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 InternalConfirmation
{
public const PERMISSION = 'billing_drafts.confirm_internal';
public const LABEL = 'Confirmado internamente — sem validade fiscal';
public function __construct(private CompanyContext $context, private Permissions $permissions, private ServiceWorkflow $workflow) {}
public function show(int $draft, int $actor): ?array
{
$scope = $this->workflow->scope('billing_drafts', $actor, 'view');
$row = DB::table('b_internal_confirmations')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('billing_draft_id', $draft)->first();
if (! $row) { return null; }
$history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $row->id)->value('id');
$event = DB::table('a_events as e')->join('a_outbox as o', 'e.id', '=', 'o.event_id')->where('history_id', $history)->first(['e.id','e.type','o.state']);
return ['id' => $row->id, 'internal_reference' => 'INT-B1-'.$row->id, 'label' => self::LABEL, 'snapshot' => json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR), 'event' => $event];
}
public function confirm(int $draft, int $actor, int $version, string $key): array
{
$scope = $this->workflow->scope('billing_drafts', $actor, 'view');
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::PERMISSION, $scope['unit_id']);
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(['b1.confirm_internal', $draft, $scope, $version], JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($scope, $draft, $actor, $version, $key, $hash): 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'], $scope['unit_id']);
$this->workflow->scope('billing_drafts', $actor, 'view');
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::PERMISSION, $scope['unit_id']);
$old = DB::table('a_commands')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('command_key', $key)->first();
if ($old) {
abort_unless((int) $old->actor_id === $actor && hash_equals($old->request_hash, $hash), 409, 'Chave repetida com conteúdo ou autor diferente.');
return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true];
}
$billing = DB::table('a_billing_drafts')->where('id', $draft)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->lockForUpdate()->first();
abort_unless($billing, 403, 'Rascunho fora do âmbito autorizado.');
abort_unless((int) $billing->version === $version, 409, 'A versão mudou; reabra o rascunho.');
$existing = DB::table('b_internal_confirmations')->where('billing_draft_id', $draft)->first();
if ($existing) {
$history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $existing->id)->value('id');
$event = DB::table('a_events')->where('history_id', $history)->value('id');
$result = ['id' => $existing->id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$existing->id, 'event_id' => $event];
} else {
$data = json_decode($billing->data, true, 32, JSON_THROW_ON_ERROR);
$source = DB::table('a_service_drafts')->where('id', $billing->source_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->first();
abort_unless($source && (int) $source->version === (int) $data['source_version'], 409, 'A prestação mudou; reveja e actualize explicitamente o rascunho antes de confirmar.');
$client = DB::table('a_clients')->where('id', $billing->client_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->first();
abort_unless($client && trim($client->name) !== '' && trim($billing->name) !== '', 422, 'Complete o cliente e a designação do percurso.');
abort_unless((int) $source->client_id === (int) $billing->client_id, 422, 'O cliente diverge da prestação de origem.');
$data['lines'] = DraftValues::lines($data['lines'], $scope);
foreach ($data['lines'] as $line) { abort_if($line['quantity'] === null || $line['unit_price'] === null || trim($line['service_name']) === '', 422, 'Complete quantidade, preço e serviço de todas as linhas.'); }
$pending = DraftValues::pending($data) + ['accounts_pending' => 'Contas e classificação por validar; contabilização bloqueada.', 'recognition_pending' => 'Momento e regras de reconhecimento por validar; contabilização bloqueada.'];
$catalogue = [];
foreach (array_unique(array_column($data['lines'], 'service_id')) as $id) { $service = DB::table('a_services')->where('id', $id)->first(); $catalogue[] = $this->record($service); }
$data['client_name'] = $client->name;
$snapshot = ['label' => self::LABEL, 'scope' => $scope, 'billing_draft' => $this->record($billing), 'service_draft' => $this->record($source),
'client' => $this->record($client), 'services' => $catalogue, 'data' => $data, 'base_amount' => DraftValues::base($data),
'pending' => $pending, 'actor_id' => $actor, 'confirmed_at' => now()->toIso8601String(), 'accounting_status' => 'blocked_pending', 'fiscal_status' => 'not_issued'];
$id = DB::table('b_internal_confirmations')->insertGetId($scope + ['billing_draft_id' => $draft, 'billing_version' => $version, 'version' => 1, 'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]);
$history = DB::table('a_history')->insertGetId($scope + ['area' => 'internal_confirmations', 'record_id' => $id, 'version' => 1, '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()]); }
$event = DB::table('a_events')->insertGetId(['history_id' => $history, 'type' => 'b1.billing_draft.confirmed_internal', 'schema_version' => 1, 'payload' => json_encode(['confirmation_id' => $id, 'snapshot' => $snapshot], JSON_THROW_ON_ERROR), 'created_at' => now()]);
DB::table('a_outbox')->insert(['event_id' => $event, 'state' => 'captured', 'created_at' => now()]);
Audit::record('b1.billing_draft.confirmed_internal', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['area' => 'internal_confirmations', 'record_id' => $id, 'version' => 1, 'event_id' => $event]);
$result = ['id' => $id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$id, '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' => isset($existing) && $existing !== null];
});
}
private function record(object $record): array
{
$data = (array) $record;
if (isset($data['data'])) { $data['data'] = json_decode($data['data'], true, 32, JSON_THROW_ON_ERROR); }
return $data;
}
}
+6
View File
@@ -55,6 +55,10 @@ final class ServiceWorkflow
$result = (array) $row;
$result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
$result['version'] = (int) $row->version;
if ($area === 'billing_drafts') {
$confirmationId = DB::table('b_internal_confirmations')->where('billing_draft_id', $row->id)->value('id');
$result['internal_confirmation'] = $confirmationId ? ['reference' => 'INT-B1-'.$confirmationId, 'label' => InternalConfirmation::LABEL] : null;
}
$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() : [];
@@ -80,6 +84,7 @@ final class ServiceWorkflow
$old = $id ? $this->query($area, $scope)->where('id', $id)->first() : null;
if ($id) {
abort_unless($old, 403, 'Registo fora do âmbito autorizado.');
abort_if($area === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'Confirmado internamente — sem validade fiscal. O registo está bloqueado para edição.');
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;
@@ -102,6 +107,7 @@ final class ServiceWorkflow
$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) {
if (DB::table('b_internal_confirmations')->where('billing_draft_id', $billing->id)->exists()) { continue; }
$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;
@@ -0,0 +1,61 @@
<?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
{
Schema::create('b_internal_confirmations', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id'); $table->unsignedBigInteger('unit_id');
$table->foreignId('billing_draft_id')->unique()->constrained('a_billing_drafts')->restrictOnDelete();
$table->unsignedInteger('billing_version'); $table->unsignedInteger('version')->default(1);
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$table->jsonb('snapshot'); $table->timestampTz('created_at');
$table->foreign(['tenant_id','company_id','unit_id','billing_draft_id'])->references(['tenant_id','company_id','unit_id','id'])->on('a_billing_drafts')->restrictOnDelete();
});
DB::statement('ALTER TABLE b_internal_confirmations ADD CHECK (version = 1 AND billing_version > 0 AND jsonb_typeof(snapshot) = \'object\')');
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION sgf_b_confirmation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM 1 FROM a_billing_drafts WHERE id = NEW.billing_draft_id AND version = NEW.billing_version FOR UPDATE;
IF NOT FOUND THEN RAISE EXCEPTION 'SGF_B_STALE_DRAFT'; END IF;
RETURN NEW;
END; $$;
CREATE OR REPLACE FUNCTION sgf_b_draft_locked() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (SELECT 1 FROM b_internal_confirmations WHERE billing_draft_id = OLD.id) THEN RAISE EXCEPTION 'SGF_B_CONFIRMED_IMMUTABLE'; END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; 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' WHEN 'internal_confirmations' THEN 'b_internal_confirmations' 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; $$;
CREATE TRIGGER b_confirmation_valid BEFORE INSERT ON b_internal_confirmations FOR EACH ROW EXECUTE FUNCTION sgf_b_confirmation_guard();
CREATE TRIGGER b_confirmation_immutable BEFORE UPDATE OR DELETE ON b_internal_confirmations FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();
CREATE TRIGGER b_billing_locked BEFORE UPDATE OR DELETE ON a_billing_drafts FOR EACH ROW EXECUTE FUNCTION sgf_b_draft_locked();
SQL);
$company = ['company.view','company.edit','unit.create','access.manage']; $unit = ['unit.view','unit.edit','billing_drafts.confirm_internal'];
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
{
throw new RuntimeException('Internal confirmations are immutable; rollback requires a separately reviewed preservation procedure.');
}
};
+78
View File
@@ -0,0 +1,78 @@
# B1 — confirmação interna e fotografia histórica
Data: 25 de Outubro de 2026 (Africa/Johannesburg). Estado: B1 aceite pelo utilizador no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos. Não constitui conclusão integral de B. A permanece aceite no checkpoint `ca13acb`, com antecessor `26b80df`; nenhum foi substituído ou reaberto.
## Aceitação
Em 25 de Outubro de 2026, o utilizador declarou: «Aceito B1 no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos. Regista a aceitação e preserva um checkpoint Git local, sem credenciais ou dados locais. Não avances para os restantes incrementos nem publiques.»
O checkpoint preserva código, migração, documentação e provas sintéticas. Exclui credenciais, configurações privadas, bases, sessões e logs. As contagens de desenvolvimento neste relatório são evidência histórica da entrega, não uma cópia dos dados locais. A aceitação encerra apenas B1 e mantém os limites abaixo.
## O que ficou funcional
Um utilizador com `billing_drafts.view` e a nova concessão explícita `billing_drafts.confirm_internal`, na empresa/unidade seleccionadas, pode abrir o rascunho, rever a operação e confirmar uma versão actual. A interface apresenta **«Confirmado internamente — sem validade fiscal»** e referência técnica `INT-B1-<id>`, distinta de numeração fiscal.
A confirmação exige cliente/serviços válidos na empresa, prestação na mesma unidade, versão da prestação correspondente à origem guardada, designação e linhas com quantidade positiva/preço não negativo completos. Reutiliza as validações decimais de A. Campos e identificadores protegidos enviados pelo navegador são recusados. Empresa/unidade, pertenças e permissão são revalidadas no servidor dentro da transacção, após bloquear a empresa e antes da operação. A sessão e o token de contexto existentes continuam em uso.
Moeda desconhecida é conservada como `null`, com pendência explícita. Imposto permanece pendente e não é convertido em zero. Mesmo uma moeda preenchida continua sujeita a validação. A base aritmética usa decimais exactos e não se apresenta como total monetário definitivo ou fiscal; não há agregação de moedas distintas.
A fotografia conserva o rascunho e a prestação com versões/dados originais, cliente e serviços com versões/dados observados, linhas e valores usados, moeda conhecida ou desconhecida, pendências, âmbito, autor e instante. Os nomes nas linhas e identificação do cliente usados na confirmação são conferidos no cadastro actual, que também fica fotografado; a versão original do rascunho continua preservada separadamente. Alterações posteriores dos cadastros ou da prestação não reescrevem a fotografia.
A confirmação cria uma identidade própria, sem transformar a prestação em serviço confirmado. O rascunho de origem mantém a versão fixada e fica bloqueado para edição/eliminação. API e triggers SQL impedem actualizações em massa ou eliminação do rascunho confirmado; a fotografia tem protecção append-only. Não há reabertura, correcção ou anulação. A prestação e os cadastros continuam com o comportamento de A, preservando as suas versões.
## Captura e repetição
Confirmação, fotografia, histórico, evento `b1.billing_draft.confirmed_internal`, pendências, auditoria e resultado do comando são persistidos na mesma transacção. A captura reutiliza `a_history`, `a_events`, `a_outbox`, `a_pending` e `a_commands`; os eventos de A não são reconstruídos ou alterados.
O outbox permanece `captured`, sem consumidor contabilístico. A fotografia mostra `not_issued` para fiscalidade e `blocked_pending` para contabilidade. Além das pendências de A, contas/classificação e reconhecimento ficam explicitamente pendentes. Não há lançamentos, receita reconhecida ou estado contabilizado.
Repetição com mesma chave/autor/conteúdo recupera o resultado. Conteúdo diferente com a mesma chave produz 409. Outra chave para o mesmo rascunho/versão recupera a confirmação existente, sem novo evento. A unicidade por rascunho e bloqueios transaccionais impedem confirmações concorrentes duplicadas. Como não há reabertura, não se confirmam versões posteriores do rascunho já fixado. Resolução futura de pendências exigirá registos/versionamento próprios, sem editar a fotografia.
## Migração e acesso aplicados
O delta foi apresentado antes da aplicação. Migração aditiva `2026_10_25_040000_create_internal_confirmations`, aplicada em `sgf_tempo_test` pelas provas e depois em `sgf_tempo_dev` pelo wrapper, com proprietário separado:
- Cria apenas `b_internal_confirmations`, com referência única ao rascunho, versão, âmbito composto, fotografia, autor e instante.
- Acrescenta triggers de validade/imutabilidade e bloqueio do rascunho confirmado; amplia a validação de `a_history` para a nova origem sem remover as origens de A.
- Acrescenta `billing_drafts.confirm_internal` ao catálogo SQL permitido, sempre com unidade explícita. Não concede esse direito pela migração, por nome/email ou por direitos de A.
- Não modifica dados históricos de A nem cria tabelas de lançamentos, numeração fiscal ou processamento contabilístico. O rollback automático desta migração é recusado para não eliminar confirmações históricas; qualquer reversão requer procedimento de preservação revisto.
A conta existente foi conferida e apresentada: ID 1, Osmane Nala, na empresa Tempo Lda ID 1 / SEGI ID 1. O utilizador confirmou exactamente a nova concessão antes da aplicação. Foi aplicada **uma** concessão `billing_drafts.confirm_internal`, com auditoria `b1.access.initial_grant`. Samira mantém apenas consulta; os restantes direitos de A foram preservados. Total de concessões em desenvolvimento: 29. Não foram criadas contas, nem poderes de gestão da nova concessão, emissão, contabilização, recebimento ou fecho.
O procedimento local `scripts/b1-access.php user_id company_id unit_id` permite pré-visualizar a conta/âmbito. `--apply` só deve ser usado depois de confirmação específica da operação apresentada. Não o executar como seed ou criação automática de direitos. A interface de gestão de A não permite delegar `confirm_internal`; alterações futuras desta concessão continuam a exigir procedimento local autorizado e auditado. `scripts/b1-status.php` verifica contagens e a concessão sem mostrar credenciais.
Depois da aplicação: duas contas, um tenant, uma empresa e uma unidade preservados; clientes, serviços, prestações, rascunhos e confirmações em desenvolvimento continuavam vazios. Não foi criado qualquer documento interno real ou de demonstração na Tempo Lda/SEGI. A aplicação normal conserva `sgf_dev_app`, sem administração PostgreSQL, e a ligação à base de desenvolvimento.
## Provas realizadas
Suite completa: **58 testes / 442 asserções aprovados**, incluindo regressões de identidade e A, sem reabrir a sua aceitação. Compilação Vite aprovada, sem alteração de versões, manifests ou lockfiles.
Os 14 testes de B1 cobrem confirmação autorizada; consulta e gestor de A sem nova permissão recusados; revogação efectiva no pedido seguinte da mesma sessão; impossibilidade de delegar o novo direito pela gestão de A; âmbito manipulado; acesso cruzado entre unidades e entre empresas do mesmo tenant; versão/contexto antigos; fotografia preservada após alterações de cliente, serviço e prestação; moeda/regra desconhecidas; linhas incompletas; origem desactualizada; repetição/conflito; bloqueio API e SQL de edição/eliminação; fotografia append-only; falha injectada no outbox com rollback integral, incluindo auditoria e comando.
Prova de concorrência com **dois processos PHP reais**, sobre PostgreSQL e com sobreposição demonstrada de bloqueios: chaves iguais e chaves diferentes produziram apenas uma confirmação e um evento por rascunho. [Resultado](proofs/b1-concurrency.json).
Prova HTTP com autenticação real de contas sintéticas, CSRF activo e sessões distintas: confirmação autorizada, leitor recusado 403, contexto/versão antigos 409, repetição recuperada, conteúdo diferente 409, alteração de rascunho confirmado 409 e fotografia inalterada após edição dos cadastros. [Resultado](proofs/b1-http-results.json).
No navegador isolado em http://127.0.0.1:8001, ligado exclusivamente a `sgf_tempo_test`, o gestor entrou pelo formulário, preparou um rascunho e confirmou-o pela revisão explícita. A interface mostrou moeda desconhecida, referência interna, fotografia e pendências. Após saída, a conta de consulta entrou pelo formulário e consultou a fotografia sem botões de criação, edição, gestão ou confirmação. Não foi apenas uma prova com `actingAs`.
Provas visuais: [confirmação interna e pendências](proofs/b1-01-internal-confirmation.png) e [consulta com contexto sintético](proofs/b1-02-read-only-context.png). [Métricas de captura](proofs/b1-capture.json) verificam correspondência entre confirmações, históricos, eventos e outbox.
A instância de prova foi parada; a suite final limpa os dados sintéticos apenas na base de testes. Não se alterou a ligação da aplicação normal, não se terminaram processos de outros projectos e não se publicaram conteúdos.
## Ficheiros e comandos
Novo workflow `app/Modules/Services/InternalConfirmation.php`, integração em `ServiceController.php`, `ServiceWorkflow.php`, `routes/web.php`, `ServicesWorkspace.jsx` e rodapé de `app.jsx`; migração indicada acima. A integração acrescenta estado de confirmação à lista, protege o rascunho e evita pendências posteriores de origem reescreverem a captura já confirmada.
Testes em `tests/Feature/InternalConfirmationTest.php`, fixture `tests/Support/InternalConfirmationFixture.php`; preparação de navegador adaptada e worker de concorrência existente reutilizado. Scripts próprios: `b1-access.php`, `b1-status.php`, `b1-concurrency-proof.php`, `b1-http-proof.mjs` e `b1-proof-metrics.php`. Documentação actualizada em AGENTS, README e especificações, respeitando as alterações posteriores a A.
```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/b1-status.php
```
Os scripts de concorrência/métricas verificam explicitamente o destino `sgf_tempo_test`. Parar a instância de navegador antes de executar testes que recriam essa base. Recuperação de acesso mantém o procedimento assistido de IDENTITY_WORKFLOW.md, sem fornecedor ou recuperação por email.
## Limites remanescentes
B1 fixa dados internos; não emite documento comercial/fiscal, não imprime/exporta uma factura, não confirma execução do serviço, não reconhece receita nem produz lançamentos. Não foi criado consumidor contabilístico. Contas, reconhecimento, moeda funcional, regras fiscais e arredondamento fiscal continuam por validar. Não foram implementados recebimentos, reconciliação, fecho, reabertura, correcção ou anulação. O restante B e C dependem de âmbito e autorização próprios. B1 foi aceite com estes limites; A permanece encerrada. Não avançar para os restantes incrementos nem publicar.
+83
View File
@@ -0,0 +1,83 @@
# Entrega B — delimitação para revisão
Data: 25 de Outubro de 2026 (Africa/Johannesburg).
Estado: proposta de âmbito; não autoriza implementação, migrações, novas concessões, emissão fiscal ou publicação. Base: SERVICES_WORKFLOW_SPEC.md e entrega A aceite em SERVICES_A_DELIVERY.md, checkpoint local `ca13acb`, antecedido por `26b80df`. A permanece encerrada e não é reaberta nesta revisão. Este documento não constitui validação jurídica, fiscal ou contabilística.
Actualização posterior: o utilizador autorizou exclusivamente B1 — confirmação interna, fotografia imutável e captura das pendências — pelo prompt anexado em 25 de Outubro de 2026. A confirmação interna não é emissão comercial/fiscal, reconhecimento ou contabilização. Moeda/tratamento fiscal desconhecidos ficam preservados e não bloqueiam, por si só, esta confirmação interna. A permissão específica foi conferida e confirmada separadamente antes da concessão. Ver SERVICES_B1_DELIVERY.md. As restantes propostas e pendências de B abaixo continuam sem autorização de implementação.
## Regras já validadas que B deve preservar
| Regra | Consequência para B |
| --- | --- |
| Tenant, empresa e unidade distintos; contexto e permissões explícitos | Confirmar ou processar exige autorização própria; os direitos de A não bastam. A pertença ao tenant não concede acesso às empresas. |
| Clientes e serviços da empresa; rascunhos da unidade | Reutilizar as origens existentes, sem duplicar cadastros ou transferir registos entre âmbitos. |
| Prestação, documento e recebimento são acontecimentos distintos | Confirmar um documento não confirma automaticamente a execução do serviço, não recebe dinheiro nem liquida saldo. |
| Histórico e captura durável transaccionais desde A | Consumir acontecimentos preservados, sem reconstruir o passado a partir do último estado do cadastro ou rascunho. |
| Decimais exactos e ausência de pressupostos fiscais | Não presumir moeda, imposto, contas ou arredondamento. A precisão de entrada de A não aprova uma política monetária/fiscal para B. |
| Idempotência, contexto/versionamento, auditoria e protecção SQL | Repetições e concorrência não podem duplicar confirmações, eventos ou efeitos contabilísticos. |
| Provas sintéticas apenas em sgf_tempo_test | Nenhuma demonstração na Tempo Lda/SEGI; manter a aplicação normal e os seus dados. |
A arquitectura de contabilidade permanente e o processamento independente da contratação da interface comercial constam da especificação geral. Os mecanismos concretos abaixo ainda são propostas; não estão aprovadas regras de lançamento ou conformidade fiscal.
## Âmbito mínimo proposto
1. Pré-validar uma versão exacta do rascunho: origens, dados, contexto, permissões, regras aplicáveis e pendências. Mostrar separadamente bloqueios documentais e bloqueios contabilísticos.
2. Confirmar documentalmente apenas quando os requisitos dessa operação estiverem aprovados e satisfeitos. Criar identidade própria e fotografia histórica imutável; não apresentar uma confirmação interna como factura fiscal sem validação específica.
3. Capturar confirmação, fotografia, auditoria, evento e trabalho durável atomicamente, reutilizando a base de A. Uma confirmação repetida da mesma origem deve recuperar o resultado; alteração de conteúdo/versão deve gerar conflito.
4. Processar eventos por consumidor repetível, com classificação explícita: sem efeito contabilístico justificado, pendente de regra/informação, falha técnica recuperável ou contabilizado segundo perfil aprovado.
5. Quando elegível, produzir lançamentos rastreáveis ao evento, documento e versão das regras, com débitos/créditos equilibrados segundo as regras aprovadas. Persistir o efeito e a marca de processamento na mesma transacção.
6. Permitir consulta do documento histórico, origens, pendências, tentativas e efeitos. Nenhum lançamento definitivo deve ser editável pelo formulário de rascunhos.
Não incluir recebimentos, afectações, correcções financeiras, anulações, reconciliação, fecho ou reabertura de períodos. Estas operações permanecem em C ou exigem delimitação própria. B não deve usar uma rotina de reversão improvisada para compensar decisões ainda pendentes.
## Preservação histórica proposta
- A fotografia conserva os dados efectivamente utilizados: identificação das partes exigida pelo tipo documental aprovado, empresa/unidade, origem e versão, linhas, quantidades/preços, moeda, datas aplicáveis, cálculos e versões das regras. Só incorporar campos fiscais depois de definida a sua necessidade.
- Alterações posteriores do nome do cliente, serviço ou rascunho não alteram o documento confirmado. Ligações ao cadastro permitem navegação; não substituem a fotografia.
- Guardar separadamente datas de prestação, documento, registo e reconhecimento quando aprovadas; não inferir uma da outra. Guardar autor e instante de confirmação.
- Fotografias e eventos são imutáveis, com protecção também contra SQL em massa. O estado do consumidor fica em registos próprios, preservando os eventos/outbox append-only de A.
- Consumir eventos antigos de A com classificação explícita. Uma edição de rascunho não se torna receita por existir no outbox. Não alterar nem regenerar esses eventos para simular acontecimentos financeiros.
## Pendências que impedem fechar o âmbito de implementação
| Decisão necessária | Quem deve validar / efeito enquanto faltar |
| --- | --- |
| Significado de confirmação: interna, documento comercial ou emissão fiscal; tipos abrangidos | Utilizador, com validação fiscal competente quando aplicável. Sem definição, não implementar um botão ambíguo de confirmar/emitir. |
| Campos obrigatórios, identificação fiscal, datas, séries, numeração, unicidade, momento de atribuição e tratamento de falhas/lacunas | Validação fiscal/documental. Um identificador técnico não substitui numeração legal. |
| Regime, impostos, isenções, evidências e cálculo por linha/documento | Validação fiscal. Ausência permanece pendência, nunca taxa zero. |
| Moeda documental e funcional, eventual câmbio, escalas e política de arredondamento | Validação contabilística/fiscal. A aritmética exacta de A mantém-se; não converter automaticamente em totais fiscais. |
| Momento de reconhecimento e sua relação com prestação e documento; datas de competência | Validação contabilística. Confirmar documento não implica reconhecer receita automaticamente. |
| Plano de contas, classificações, dimensões, regras de débito/crédito e vigência das versões | Validação contabilística. Não inventar contas transitórias ou mapear nomes para contas. |
| Pode haver documento confirmado com contabilização pendente? Quais bloqueios impedem cada transição? | Utilizador e responsáveis contabilístico/fiscal. Proposta: separar estados e não chamar contabilizado ao que está apenas confirmado. |
| Período admissível e tratamento de eventos tardios ou datas não autorizadas | Validação contabilística. B deve respeitar restrições aplicáveis sem implementar o fecho de C. |
| Quem confirma prestação/documento, consulta contabilidade, processa/reprocessa ou gere estes poderes; eventual separação de funções | Utilizador. Novos acessos negados por omissão, sem herança dos direitos de A; aplicação efectiva exige confirmação específica. |
| Perfil sintético exclusivo de testes para demonstrar lançamentos e recuperação | Utilizador. Deve ser identificado como artificial, isolado de desenvolvimento e incapaz de activar regras reais. Não valida conformidade. |
Não é necessário resolver agora as políticas de recebimento, reconciliação ou correcção de C. É necessário definir como impedir que uma confirmação de B seja posteriormente alterada por caminhos não autorizados.
## Ecrãs mínimos propostos
- Pré-validação/confirmacão: versão e contexto explícitos, dados históricos a fixar, bloqueios e efeito exacto da operação.
- Documento confirmado: fotografia preservada, origem, identidade, datas e estado documental, sem edição directa.
- Acompanhamento contabilístico: evento, estado, motivos, tentativas e ligação aos lançamentos; reprocessamento apenas autorizado.
- Detalhe de lançamento: regra/versão, origens, moeda, débitos/créditos e evidência de equilíbrio, quando essas regras estiverem aprovadas.
Reutilizar navegação e contexto de A. Não disponibilizar botões financeiros simulados nem expandir o sistema de parâmetros para além dos dados necessários às regras aprovadas.
## Provas de aceitação a exigir à implementação futura
1. Documento confirmado conserva a fotografia após alterações de cadastros e rascunhos; SQL em massa não a transfere nem reescreve.
2. Falta de configuração gera recusa ou pendência conforme a matriz aprovada, sem moeda/imposto/conta inventados.
3. Dois comandos concorrentes não confirmam duas vezes a mesma operação. Definir antes a relação permitida entre prestação e documentos; não presumir facturação parcial ou múltipla.
4. Consumidor repetido, concorrente ou interrompido antes/depois do commit não duplica efeitos. Falha técnica distingue-se de pendência de negócio e deixa evidência recuperável.
5. Cada lançamento liga-se ao evento e perfil versionado, é equilibrado nas unidades monetárias aprovadas e não se apresenta como definitivo com configuração insuficiente.
6. Eventos históricos de rascunho conservam o conteúdo e recebem classificação rastreável, sem receita artificial. Activar posteriormente a interface comercial não repete o processamento.
7. Direitos de A não autorizam confirmação/processamento; revogação produz efeito no pedido seguinte. Testar acesso cruzado entre empresas do mesmo tenant, unidades, URLs e tarefas.
8. Prova no navegador com login real sintético em instância isolada; suite e operações destrutivas apenas em sgf_tempo_test. Dados reais e checkpoint A preservados.
## Próximo passo
Rever as decisões da tabela antes de redigir o prompt de execução de B. A primeira decisão é o significado da confirmação e se se pretende um ensaio exclusivamente sintético ou uma capacidade documental real com requisitos fiscais validados. A existência deste documento não concede autorização de implementação ou de novos acessos.
Aceitação posterior: em 25 de Outubro de 2026, B1 foi aceite no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos, com checkpoint Git exclusivamente local. O restante B/C continua sem autorização; não avançar nem publicar.
+7 -1
View File
@@ -190,6 +190,10 @@ A não emite facturas nem produz lançamentos contabilísticos. Documentos confi
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.
Delimitação para revisão em [SERVICES_B_SCOPE.md](SERVICES_B_SCOPE.md): separa a base aceite de A, propostas técnicas e decisões documentais/fiscais/contabilísticas pendentes. Apenas especificação, sem autorização de implementação.
Autorização posterior exclusiva de B1: confirmação interna do rascunho, fotografia imutável e captura de pendências, sem emissão comercial/fiscal, reconhecimento ou lançamentos. Implementação delimitada em [SERVICES_B1_DELIVERY.md](SERVICES_B1_DELIVERY.md). Não conclui B integralmente, não reabre A e não autoriza C.
### C Recebimentos correcção reconciliação e fecho
Completar o cenário sintético, incluindo os dois recebimentos, correcção ligada ao original, reconciliação explicável e tentativas de fecho com e sem pendências. Demonstrar concorrência, interrupções e recuperação. Utilização real fica dependente de regras e responsáveis aprovados.
@@ -224,4 +228,6 @@ Não seleccionar fornecedor, API bancária ou integração de pagamento. Não co
## Estado desta etapa
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.
Entrega A implementada e aceite pelo utilizador em 25 de Outubro de 2026, com os limites do relatório SERVICES_A_DELIVERY.md. A e a base de identidade foram preservadas. Posteriormente foi autorizado apenas B1, de confirmação interna, fotografia histórica e captura de pendências. O restante B e C permanecem em especificação: sem emissão comercial/fiscal, lançamentos, recebimentos, correcções, reconciliação ou fecho. Não avançar além de B1 nem publicar.
B1 foi aceite pelo utilizador em 25 de Outubro de 2026 no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos. Preservar o checkpoint local desta entrega e os antecessores; não avançar para os restantes incrementos nem publicar.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"database": "sgf_tempo_test",
"confirmations": 4,
"histories": 4,
"confirmation_events": 4,
"captured_outbox": 4,
"missing_history": 0,
"fiscal_states": [
"not_issued"
],
"accounting_states": [
"blocked_pending"
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"database": "sgf_tempo_test",
"results": {
"same_key": {
"parallel_processes": true,
"company_lock_overlap": true,
"same_confirmation": true,
"new_confirmations": 1,
"new_events": 1,
"second_replayed": true
},
"different_keys": {
"parallel_processes": true,
"company_lock_overlap": true,
"same_confirmation": true,
"new_confirmations": 1,
"new_events": 1,
"second_replayed": true
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"database": "sgf_tempo_test",
"real_synthetic_login": true,
"results": [
"Confirmation authorized; reader denied 403.",
"Old context and version denied 409.",
"Identical command recovers confirmation; different content conflicts 409.",
"Snapshot unchanged after client/service edits.",
"Confirmed draft edit denied 409; reader consultation 200.",
"Currency remains unknown, tax/accounts/recognition pending; captured, not issued or accounted."
]
}
+14 -6
View File
@@ -15,10 +15,11 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
const [error, setError] = useState('');
const [key, setKey] = useState(() => crypto.randomUUID());
const [access, setAccess] = useState(false);
const [reviewConfirmation, setReviewConfirmation] = 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); } }
async function open(id) { setError(''); setReviewConfirmation(false); 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) {
@@ -67,11 +68,11 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
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>
<div className="a-heading"><div><p className="eyebrow">SERVIÇOS · PREPARAÇÃO E CONFIRMAÇÃO INTERNA</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>}
{isDraft(area) && <div className="a-draft-notice">{area === 'billing_drafts' ? 'Rascunhos e confirmação interna — sem validade fiscal. ' : '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">
<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}{record.internal_confirmation ? ' · ' + record.internal_confirmation.label : 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) && <>
@@ -82,12 +83,14 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
{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>
</form> : selected ? <><div className="a-heading"><h2>{selected.name}</h2>{allowed('edit') && !selected.confirmation && <button disabled={busy} onClick={() => start(selected)}>Editar registo</button>}</div><p className="help">Identificador interno #{selected.id} · Versão {selected.version}{isDraft(area) ? ' · Origem em 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>}
{area === 'billing_drafts' && allowed('edit') && !selected.confirmation && <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>}
{area === 'billing_drafts' && !selected.confirmation && can('billing_drafts.confirm_internal', context.unit?.id) && <div className="a-pending"><h3>Confirmação interna</h3><p>Fixa esta versão e os dados que a sustentam. Bloqueia a edição deste rascunho. Não confirma a execução do serviço, não emite factura nem reconhece receita.</p>{reviewConfirmation ? <><p>Confirmar a versão {selected.version} neste contexto: {context.company.name} / {context.unit?.name}? Quantidades e preços devem estar completos; moeda e regras desconhecidas ficam pendentes na fotografia.</p><div className="actions"><button className="primary" disabled={busy} onClick={() => perform(async () => { await api(`/api/b1/billing-drafts/${selected.id}/confirm-internal`, 'POST', { version: selected.version }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Confirmado internamente — sem validade fiscal')}>Confirmar internamente esta versão</button><button onClick={() => setReviewConfirmation(false)}>Voltar ao rascunho</button></div></> : <button disabled={busy} onClick={() => { setKey(crypto.randomUUID()); setReviewConfirmation(true); }}>Rever confirmação interna</button>}</div>}
{selected.confirmation && <InternalDocument confirmation={selected.confirmation} />}
</>}
<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></>}
@@ -95,6 +98,11 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
</>;
}
function InternalDocument({ confirmation }) {
const snapshot = confirmation.snapshot;
return <section className="a-pending"><h3>{confirmation.label}</h3><p><strong>Referência interna:</strong> {confirmation.internal_reference} (não é numeração fiscal)</p><p>Autor #{snapshot.actor_id} · {snapshot.confirmed_at}</p><p><strong>Cliente preservado:</strong> {snapshot.client.name} · versão {snapshot.client.version}</p><p><strong>Origem preservada:</strong> prestação #{snapshot.service_draft.id}, versão {snapshot.service_draft.version}; rascunho #{snapshot.billing_draft.id}, versão {snapshot.billing_draft.version}</p><p><strong>Moeda preservada:</strong> {snapshot.data.currency || 'Desconhecida — pendente'}</p><ul>{snapshot.data.lines.map((line, i) => <li key={i}>{line.service_name} · quantidade {line.quantity} · preço {line.unit_price}</li>)}</ul><p>Base aritmética exacta: {snapshot.base_amount}. Não é total monetário definitivo ou fiscal.</p><p>Evento #{confirmation.event?.id} · Capturado · Contabilização bloqueada por pendências. Nenhum lançamento produzido.</p><h4>Pendências preservadas</h4><ul>{Object.entries(snapshot.pending).map(([code, message]) => <li key={code}>{message}</li>)}</ul><p>Fotografia imutável. Não há edição, eliminação, reabertura, correcção ou anulação neste incremento.</p><details><summary>Fotografia histórica completa</summary><pre>{JSON.stringify(snapshot, null, 2)}</pre></details></section>;
}
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('');
+1 -1
View File
@@ -56,7 +56,7 @@ function App() {
{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 · Entrega A: preparação em rascunho · Sem emissão fiscal ou lançamentos</footer></div></div>;
</main><footer>SGF TEMPO · Rascunhos e confirmação interna · Sem emissão fiscal ou lançamentos</footer></div></div>;
}
function Login({ busy, onSubmit }) {
const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
+1
View File
@@ -15,6 +15,7 @@ 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::post('/api/b1/billing-drafts/{id}/confirm-internal', [\App\Modules\Services\Http\ServiceController::class, 'confirmInternal'])->whereNumber('id');
Route::prefix('api/a/{area}')->group(function (): void {
$controller = \App\Modules\Services\Http\ServiceController::class;
Route::get('access', [$controller, 'accesses']);
+1
View File
@@ -13,6 +13,7 @@ try {
\Illuminate\Support\Facades\DB::table('companies')->where('id', $input['company'])->lockForUpdate()->firstOrFail();
echo "LOCK_ACQUIRED\n"; flush(); usleep(2000000);
}
if (($input['operation'] ?? '') === 'b1.confirm') { return app(\App\Modules\Services\InternalConfirmation::class)->confirm($input['id'], $input['actor'], $input['version'], $input['key']); }
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;
+25
View File
@@ -0,0 +1,25 @@
<?php
// Local operator procedure: preview before applying; obtain specific user confirmation.
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()) { throw new RuntimeException('Own local development database required.'); }
$args = array_values(array_filter(array_slice($argv, 1), fn ($arg) => $arg !== '--apply'));
if (count($args) !== 3 || array_filter($args, fn ($id) => ! ctype_digit($id))) { throw new RuntimeException('Usage: b1-access.php user_id company_id unit_id [--apply]'); }
[$userId, $companyId, $unitId] = array_map('intval', $args);
$user = \Illuminate\Support\Facades\DB::table('users')->where('id', $userId)->first(['id','name','email']);
$unit = \Illuminate\Support\Facades\DB::table('units as n')->join('companies as c', 'c.id', '=', 'n.company_id')->where('n.id', $unitId)->where('c.id', $companyId)->first(['n.tenant_id','n.company_id','n.id as unit_id','n.name as unit_name','c.name as company_name']);
if (! $user || ! $unit) { throw new RuntimeException('Existing account or scope not found.'); }
$permission = \App\Modules\Services\InternalConfirmation::PERMISSION;
$plan = ['user' => $user, 'scope' => $unit, 'permission' => $permission];
if (! in_array('--apply', $argv, true)) { echo json_encode($plan, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL; exit(0); }
\Illuminate\Support\Facades\DB::transaction(function () use ($unit, $userId, $permission): void {
\Illuminate\Support\Facades\DB::table('companies')->where('id', $unit->company_id)->lockForUpdate()->firstOrFail();
foreach (['company.view','unit.view','billing_drafts.view'] as $required) { app(\App\Modules\Identity\Services\Permissions::class)->require($userId, $unit->tenant_id, $unit->company_id, $required, $required === 'company.view' ? null : $unit->unit_id); }
$row = ['user_id' => $userId, 'tenant_id' => $unit->tenant_id, 'company_id' => $unit->company_id, 'unit_id' => $unit->unit_id, 'permission' => $permission];
if (\Illuminate\Support\Facades\DB::table('permission_grants')->insertOrIgnore($row + ['created_at' => now(), 'updated_at' => now()])) {
\App\Modules\Identity\Services\Audit::record('b1.access.initial_grant', null, $unit->tenant_id, $unit->company_id, $unit->unit_id, $userId, ['permission' => $permission, 'operator' => 'Codex por confirmação específica do utilizador', 'reason' => 'Confirmação interna B1, sem poderes fiscais ou contabilísticos.']);
}
});
echo "One specifically confirmed internal-confirmation grant applied; no other rights changed.\n";
+40
View File
@@ -0,0 +1,40 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
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 = ['operation' => 'b1.confirm', 'actor' => $fixture['manager_id'], 'tenant' => $fixture['tenant'], 'company' => $fixture['company'], 'unit' => $fixture['unit'], 'version' => 1];
app(\App\Shared\Context\CompanyContext::class)->selectFor($base['actor'], $base['tenant'], $base['company'], $base['unit']);
$workflow = app(\App\Modules\Services\ServiceWorkflow::class);
$save = fn ($area, $data) => $workflow->save($area, null, $base['actor'], $data, (string) Str::uuid())['id'];
$client = $save('clients', ['name' => 'Cliente concorrência B1 sintético']);
$service = $save('services', ['name' => 'Serviço concorrência B1 sintético']);
$source = $save('service_drafts', ['client_id' => $client, 'lines' => [['service_id' => $service, 'quantity' => '1', 'unit_price' => '12.50']]]);
$reports = [];
foreach (['same_key', 'different_keys'] as $mode) {
$draft = $save('billing_drafts', ['source_id' => $source]);
$key = (string) Str::uuid();
$input = $base + ['id' => $draft, 'key' => $key];
$first = new Process([PHP_BINARY, 'scripts/a-concurrency-worker.php', '--env=testing'], base_path(), null, json_encode($input + ['hold' => true]));
$secondInput = $input; if ($mode === 'different_keys') { $secondInput['key'] = (string) Str::uuid(); }
$second = new Process([PHP_BINARY, 'scripts/a-concurrency-worker.php', '--env=testing'], base_path(), null, json_encode($secondInput));
$before = DB::table('a_events')->where('type', 'b1.billing_draft.confirmed_internal')->count();
$first->start();
if (! $first->waitUntil(fn ($type, $buffer) => str_contains($buffer, 'LOCK_ACQUIRED'))) { throw new RuntimeException('Concurrent lock not established.'); }
$second->start(); $first->wait(); $second->wait(); $results = [];
foreach ([$first, $second] as $process) {
if (! $process->isSuccessful()) { throw new RuntimeException('Synthetic worker 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);
}
if ($results[0]['status'] !== 200 || $results[1]['status'] !== 200 || $results[0]['result']['id'] !== $results[1]['result']['id'] || ! $results[1]['result']['replayed'] || DB::table('a_events')->where('type', 'b1.billing_draft.confirmed_internal')->count() !== $before + 1 || DB::table('b_internal_confirmations')->where('billing_draft_id', $draft)->count() !== 1) { throw new RuntimeException('Confirmation deduplication failed.'); }
$reports[$mode] = ['parallel_processes' => true, 'company_lock_overlap' => true, 'same_confirmation' => true, 'new_confirmations' => 1, 'new_events' => 1, 'second_replayed' => true];
}
file_put_contents(base_path('docs/proofs/b1-concurrency.json'), json_encode(['database' => 'sgf_tempo_test', 'results' => $reports], JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo "B1 concurrent confirmations passed with identical and different keys: one confirmation/event per draft.\n";
+47
View File
@@ -0,0 +1,47 @@
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 {
async request(path, method = 'GET', body, key = randomUUID(), token = context) {
const headers = { Accept: 'application/json', 'Content-Type': 'application/json', Cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; '), 'X-CSRF-TOKEN': csrf, 'X-SGF-Context': token, 'Idempotency-Key': key };
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); await this.select(); },
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'); await reader.login('consulta@example.test');
async function create(area, data) { const result = await manager.request('/api/a/' + area, 'POST', data); assert.equal(result.status, 201); return result.data.id; }
const customer = await create('clients', { name: 'Cliente fotografia B1 HTTP' });
const service = await create('services', { name: 'Serviço fotografia B1 HTTP' });
const source = await create('service_drafts', { client_id: customer, lines: [{ service_id: service, quantity: '1.25', unit_price: '0.00000001' }] });
const draft = await create('billing_drafts', { source_id: source });
const endpoint = `/api/b1/billing-drafts/${draft}/confirm-internal`, record = `/api/a/billing_drafts/${draft}`;
assert.equal((await reader.request(endpoint, 'POST', { version: 1 })).status, 403);
assert.equal((await manager.request(endpoint, 'POST', { version: 2 })).status, 409);
const oldToken = await manager.select(); await manager.select(null);
assert.equal((await manager.request(endpoint, 'POST', { version: 1 }, randomUUID(), oldToken)).status, 409); await manager.select();
const key = randomUUID(), first = await manager.request(endpoint, 'POST', { version: 1 }, key); assert.equal(first.status, 200);
const replay = await manager.request(endpoint, 'POST', { version: 1 }, key); assert.equal(replay.status, 200); assert.equal(replay.data.id, first.data.id); assert.equal(replay.data.replayed, true);
assert.equal((await manager.request(endpoint, 'POST', { version: 2 }, key)).status, 409);
const before = (await manager.request(record)).data.confirmation;
assert.equal(before.snapshot.data.currency, null); assert.equal(before.snapshot.base_amount, '0.0000000125');
for (const pending of ['currency_missing', 'tax_rule_pending', 'accounting_rule_pending', 'accounts_pending', 'recognition_pending']) assert.ok(before.snapshot.pending[pending]);
assert.equal((await manager.request(`/api/a/clients/${customer}`, 'PATCH', { name: 'Cliente alterado após confirmação HTTP', version: 1 })).status, 200);
assert.equal((await manager.request(`/api/a/services/${service}`, 'PATCH', { name: 'Serviço alterado após confirmação HTTP', version: 1 })).status, 200);
assert.deepEqual((await manager.request(record)).data.confirmation, before);
assert.equal((await manager.request(record, 'PATCH', { version: 1, reload_source: true })).status, 409);
assert.equal((await reader.request(record)).status, 200);
assert.equal(before.event.type, 'b1.billing_draft.confirmed_internal'); assert.equal(before.event.state, 'captured'); assert.equal(before.snapshot.fiscal_status, 'not_issued'); assert.equal(before.snapshot.accounting_status, 'blocked_pending');
await manager.request('/api/logout', 'POST'); await reader.request('/api/logout', 'POST');
await fs.writeFile('docs/proofs/b1-http-results.json', JSON.stringify({ database: 'sgf_tempo_test', real_synthetic_login: true, results: ['Confirmation authorized; reader denied 403.', 'Old context and version denied 409.', 'Identical command recovers confirmation; different content conflicts 409.', 'Snapshot unchanged after client/service edits.', 'Confirmed draft edit denied 409; reader consultation 200.', 'Currency remains unknown, tax/accounts/recognition pending; captured, not issued or accounted.'] }, null, 2));
console.log('B1 isolated HTTP proof passed; no credentials displayed.');
+18
View File
@@ -0,0 +1,18 @@
<?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();
$db = \Illuminate\Support\Facades\DB::class;
$count = $db::table('b_internal_confirmations')->count();
$histories = $db::table('a_history')->where('area', 'internal_confirmations')->count();
$events = $db::table('a_events')->where('type', 'b1.billing_draft.confirmed_internal')->count();
$captured = $db::table('a_events as e')->join('a_outbox as o', 'e.id', '=', 'o.event_id')->where('e.type', 'b1.billing_draft.confirmed_internal')->where('o.state', 'captured')->count();
$missingHistory = $db::table('b_internal_confirmations as b')->whereNotExists(fn ($q) => $q->selectRaw('1')->from('a_history as h')->where('h.area', 'internal_confirmations')->whereColumn('h.record_id', 'b.id'))->count();
if ($count < 1 || $count !== $histories || $count !== $events || $count !== $captured || $missingHistory !== 0) { throw new RuntimeException('Incomplete B1 capture.'); }
$report = ['database' => 'sgf_tempo_test', 'confirmations' => $count, 'histories' => $histories, 'confirmation_events' => $events, 'captured_outbox' => $captured, 'missing_history' => $missingHistory,
'fiscal_states' => $db::table('b_internal_confirmations')->selectRaw("DISTINCT snapshot->>'fiscal_status' AS state")->pluck('state')->all(),
'accounting_states' => $db::table('b_internal_confirmations')->selectRaw("DISTINCT snapshot->>'accounting_status' AS state")->pluck('state')->all()];
file_put_contents(base_path('docs/proofs/b1-capture.json'), json_encode($report, JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo "B1 confirmation/history/event/outbox counts match; no missing historical capture.\n";
+10
View File
@@ -0,0 +1,10 @@
<?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()) { throw new RuntimeException('Own local development database required.'); }
$counts = [];
foreach (['users','tenants','companies','units','permission_grants','a_clients','a_services','a_service_drafts','a_billing_drafts','b_internal_confirmations'] as $table) { $counts[$table] = \Illuminate\Support\Facades\DB::table($table)->count(); }
$grants = \Illuminate\Support\Facades\DB::table('permission_grants')->where('permission', \App\Modules\Services\InternalConfirmation::PERMISSION)->get(['user_id','tenant_id','company_id','unit_id','permission']);
echo json_encode(['database' => 'sgf_tempo_dev', 'role' => 'sgf_dev_app', 'counts' => $counts, 'b1_grants' => $grants], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL;
+1 -1
View File
@@ -9,7 +9,7 @@ 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\ServicesFixture::create());
$fixture = \Illuminate\Support\Facades\DB::transaction(fn () => \Tests\Support\InternalConfirmationFixture::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;
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Modules\Services\InternalConfirmation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\InternalConfirmationFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class InternalConfirmationTest extends TestCase
{
use RefreshDatabase;
private array $fixture;
private string $token;
private int $client;
private int $service;
private int $source;
private int $billing;
protected function setUp(): void
{
parent::setUp(); $this->fixture = InternalConfirmationFixture::create(); $this->login();
$this->client = $this->write('clients', ['name' => 'Cliente histórico sintético'])->assertCreated()->json('id');
$this->service = $this->write('services', ['name' => 'Serviço histórico sintético'])->assertCreated()->json('id');
$this->source = $this->write('service_drafts', ['client_id' => $this->client, 'lines' => [['service_id' => $this->service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]])->assertCreated()->json('id');
$this->billing = $this->write('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id');
}
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 $input, ?int $id = null)
{
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()]);
return $id ? $this->patchJson("/api/a/$area/$id", $input) : $this->postJson("/api/a/$area", $input);
}
private function confirm(int $version = 1, ?string $key = null, ?int $id = null)
{
return $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => $key ?? (string) Str::uuid()])->postJson('/api/b1/billing-drafts/'.($id ?? $this->billing).'/confirm-internal', ['version' => $version]);
}
private function document(): array
{
return $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->json('confirmation');
}
public function test_internal_confirmation_captures_unknowns_without_financial_effects(): void
{
$this->confirm()->assertOk()->assertJsonPath('replayed', false);
$doc = $this->document();
$this->assertSame(InternalConfirmation::LABEL, $doc['label']);
$this->assertStringStartsWith('INT-B1-', $doc['internal_reference']);
$this->assertNull($doc['snapshot']['data']['currency']);
$this->assertSame('0.0000000125', $doc['snapshot']['base_amount']);
$this->assertSame('not_issued', $doc['snapshot']['fiscal_status']);
$this->assertSame('blocked_pending', $doc['snapshot']['accounting_status']);
foreach (['currency_missing','tax_rule_pending','accounting_rule_pending','accounts_pending','recognition_pending'] as $code) { $this->assertArrayHasKey($code, $doc['snapshot']['pending']); }
$this->assertSame('b1.billing_draft.confirmed_internal', $doc['event']['type']);
$this->assertSame('captured', $doc['event']['state']);
$this->assertDatabaseHas('a_service_drafts', ['id' => $this->source, 'state' => 'draft', 'version' => 1]);
$this->assertDatabaseHas('a_billing_drafts', ['id' => $this->billing, 'state' => 'draft', 'version' => 1]);
$this->assertSame(5, DB::table('a_events')->count());
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('journal_entries'));
}
public function test_snapshot_preserves_catalogues_and_source_after_later_changes(): void
{
$this->confirm()->assertOk(); $before = $this->document();
$this->write('clients', ['name' => 'Cliente alterado', 'version' => 1], $this->client)->assertOk();
$this->write('services', ['name' => 'Serviço alterado', 'version' => 1], $this->service)->assertOk();
$this->write('service_drafts', ['client_id' => $this->client, 'version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => '2', 'unit_price' => '3']]], $this->source)->assertOk();
$this->assertSame($before, $this->document());
$this->assertSame('Cliente histórico sintético', $before['snapshot']['client']['name']);
}
public function test_reader_and_a_manager_without_new_permission_cannot_confirm(): void
{
$this->login('reader'); $this->confirm()->assertForbidden();
$this->login();
DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', InternalConfirmation::PERMISSION)->delete();
$this->confirm()->assertForbidden(); // same session, next request
$this->assertSame(0, DB::table('b_internal_confirmations')->count());
$this->write('clients', ['name' => 'A continua autorizada'])->assertCreated();
}
public function test_new_permission_cannot_be_delegated_via_a_access_management(): void
{
$this->withHeader('X-SGF-Context', $this->token)->postJson('/api/a/billing_drafts/access', ['email' => $this->fixture['reader']->email, 'action' => 'confirm_internal', 'grant' => true, 'reason' => 'Não autorizado'])->assertUnprocessable();
}
public function test_old_version_old_context_and_manipulated_scope_are_refused(): void
{
$this->confirm(2)->assertStatus(409);
$old = $this->token; $this->select(null);
$this->withHeaders(['X-SGF-Context' => $old, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertStatus(409);
$this->select($this->fixture['unit']);
$this->withHeaders(['X-SGF-Context' => $this->token])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1, 'company_id' => $this->fixture['sibling']])->assertUnprocessable();
}
public function test_cross_unit_and_same_tenant_other_company_are_refused(): void
{
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']);
foreach (['billing_drafts.view', InternalConfirmation::PERMISSION] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); }
$this->select($this->fixture['hidden']); $this->confirm()->assertForbidden();
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], 'company.view');
foreach (['unit.view','billing_drafts.view', InternalConfirmation::PERMISSION] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], $permission, $this->fixture['siblingUnit']); }
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => $this->fixture['siblingUnit']])->assertOk()->json('context.token');
$this->confirm()->assertForbidden();
$this->getJson("/api/a/billing_drafts/$this->billing")->assertForbidden();
}
public function test_repetition_recovers_result_and_changed_key_content_conflicts(): void
{
$key = (string) Str::uuid(); $first = $this->confirm(key: $key)->assertOk()->json();
$this->confirm(key: $key)->assertOk()->assertJsonPath('id', $first['id'])->assertJsonPath('replayed', true);
$this->confirm(2, $key)->assertStatus(409);
$this->confirm()->assertOk()->assertJsonPath('id', $first['id']);
$this->assertSame(1, DB::table('b_internal_confirmations')->count());
$this->assertSame(1, DB::table('a_events')->where('type', 'b1.billing_draft.confirmed_internal')->count());
}
public function test_incomplete_lines_and_changed_source_block_confirmation_but_not_unknown_currency(): void
{
$this->write('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => null, 'unit_price' => null]]], $this->billing)->assertOk();
$this->confirm(2)->assertUnprocessable();
$this->write('billing_drafts', ['version' => 2, 'reload_source' => true], $this->billing)->assertOk();
$this->write('service_drafts', ['client_id' => $this->client, 'version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '2']]], $this->source)->assertOk();
$this->confirm(3)->assertStatus(409);
$this->assertSame(0, DB::table('b_internal_confirmations')->count());
}
public function test_confirmed_draft_cannot_be_edited_via_api(): void
{
$this->confirm()->assertOk();
$this->write('billing_drafts', ['version' => 1, 'reload_source' => true], $this->billing)->assertStatus(409);
$this->deleteJson("/api/a/billing_drafts/$this->billing")->assertStatus(405);
}
public function test_sql_bulk_update_of_confirmed_draft_is_refused(): void
{
$this->confirm()->assertOk();
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_B_CONFIRMED_IMMUTABLE');
DB::table('a_billing_drafts')->update(['name' => 'Manipulado', 'version' => 2]);
}
public function test_sql_delete_of_confirmed_draft_is_refused(): void
{
$this->confirm()->assertOk();
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_B_CONFIRMED_IMMUTABLE');
DB::table('a_billing_drafts')->delete();
}
public function test_sql_snapshot_update_is_refused(): void
{
$this->confirm()->assertOk();
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
DB::table('b_internal_confirmations')->update(['snapshot' => '{}']);
}
public function test_sql_snapshot_delete_is_refused(): void
{
$this->confirm()->assertOk();
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
DB::table('b_internal_confirmations')->delete();
}
public function test_outbox_failure_rolls_back_entire_confirmation(): void
{
$counts = []; foreach (['a_history','a_events','a_outbox','a_pending','a_commands','identity_audit'] as $table) { $counts[$table] = DB::table($table)->count(); }
DB::unprepared("CREATE OR REPLACE FUNCTION sgf_b1_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_B1_FAILURE'; END; \$\$; CREATE TRIGGER b1_synthetic_failure BEFORE INSERT ON a_outbox FOR EACH ROW EXECUTE FUNCTION sgf_b1_test_fail();");
$this->confirm()->assertStatus(500);
$this->assertSame(0, DB::table('b_internal_confirmations')->count());
foreach ($counts as $table => $count) { $this->assertSame($count, DB::table($table)->count()); }
}
}
@@ -0,0 +1,13 @@
<?php
namespace Tests\Support;
final class InternalConfirmationFixture
{
public static function create(): array
{
$fixture = ServicesFixture::create();
IdentityFixture::grant($fixture['manager']->id, $fixture['tenant'], $fixture['company'], \App\Modules\Services\InternalConfirmation::PERMISSION, $fixture['unit']);
return $fixture;
}
}