Preserva B1 aceite de confirmacao interna sem validade fiscal
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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.');
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user