This commit is contained in:
2026-10-29 18:43:29 +02:00
parent f77900e198
commit 78e89a51d2
75 changed files with 2881 additions and 14 deletions
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__.'/src/Payables.php';
require_once __DIR__.'/src/Workspace.php';
+17
View File
@@ -0,0 +1,17 @@
<?php
// Explicitly called by disposable/new QA setup only, never registered in host migrations.
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
$db=DB::connection();
if($db->getDriverName()!=='pgsql'||!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$db->getDatabaseName())throw new LogicException('Payables schema refused outside isolated QA.');
$scope=function(Blueprint $t){foreach(['tenant_id','company_id','unit_id'] as $k)$t->unsignedBigInteger($k);};
Schema::create('pa_access',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->unsignedBigInteger('user_id');$t->string('person');$t->string('action');$t->unique(['tenant_id','company_id','unit_id','user_id','action']);});
Schema::create('pa_sources',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->string('source_key',64)->unique();$t->unsignedBigInteger('local_source_id');$t->timestamp('created_at');});
Schema::create('pa_proposals',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->constrained('pa_sources');$t->string('source_revision',64);$t->integer('capture_version');$t->jsonb('snapshot');$t->jsonb('missing');$t->unsignedBigInteger('prepared_by');$t->string('prepared_person');$t->text('reason');$t->timestamp('created_at');$t->unique(['source_id','capture_version']);});
Schema::create('pa_obligations',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->unique()->constrained('pa_sources');$t->foreignId('proposal_id')->constrained('pa_proposals');$t->string('source_revision',64);$t->bigInteger('principal_minor');$t->string('currency',3);$t->date('due_on')->nullable();$t->unsignedBigInteger('recognised_by');$t->string('recognised_person');$t->text('reason');$t->timestamp('created_at');});
Schema::create('pa_commands',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->unsignedBigInteger('actor_id');$t->uuid('command_key');$t->string('request_hash',64);$t->jsonb('result');$t->timestamp('created_at');$t->unique(['tenant_id','company_id','unit_id','actor_id','command_key']);});
Schema::create('pa_events',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->constrained('pa_sources');$t->unsignedBigInteger('actor_id');$t->string('kind');$t->jsonb('payload');$t->timestamp('created_at');});
Schema::create('pa_outbox',function(Blueprint $t){$t->foreignId('event_id')->primary()->constrained('pa_events');$t->string('state');});
DB::unprepared("CREATE FUNCTION pa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'PA_APPEND_ONLY'; END $$");
foreach(['pa_sources','pa_proposals','pa_obligations','pa_events','pa_outbox','pa_commands'] as $t)DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$t} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
+221
View File
@@ -0,0 +1,221 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\ConnectionInterface;
/** One payable per document identity. No session, money rounding, settlement or budget effects. */
final class Payables
{
public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1';
public function __construct(private ConnectionInterface $db, private int $actor, private string $person, private array $scope,
private \Closure $authorize, private \Closure $loadSource) {}
public static function canonical(array $data): string
{
$sort = function ($v) use (&$sort) {
if (! is_array($v)) {
return $v;
} if (! array_is_list($v)) {
ksort($v);
}
return array_map($sort, $v);
};
return json_encode($sort($data), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
public static function identity(array $s): string
{
return hash('sha256', self::canonical(array_intersect_key($s, array_flip(['instance', 'company', 'document']))));
}
public static function revision(array $s): string
{
unset($s['payment_count'],$s['local_id']);
return hash('sha256', self::canonical($s));
}
public static function missing(array $s): array
{
$m = [];
foreach (['instance', 'company', 'document', 'number', 'supplier_id', 'supplier_name', 'order_id', 'validated_by', 'validated_at', 'validation_audit_id'] as $k) {
if (empty($s[$k])) {
$m[] = 'Origem/decisão em falta: '.$k;
}
}
if (($s['status'] ?? null) !== 'validated') {
$m[] = 'Factura ainda não conferida.';
}
if (($s['currency'] ?? null) !== 'MZN') {
$m[] = 'Moeda ausente ou incompatível; apenas MZN neste recorte.';
}
foreach (['subtotal_minor', 'tax_minor', 'total_minor'] as $k) {
if (! isset($s[$k]) || ! is_int($s[$k]) || $s[$k] < 0) {
$m[] = 'Montante sustentado em falta: '.$k;
}
}
if (isset($s['subtotal_minor'],$s['tax_minor'],$s['total_minor']) && $s['total_minor'] !== $s['subtotal_minor'] + $s['tax_minor']) {
$m[] = 'Componentes documentais incompatíveis; sem recálculo.';
}
if (empty($s['lines'])) {
$m[] = 'Linhas conferidas em falta.';
}
if (! array_key_exists('payment_count', $s) || $s['payment_count'] !== 0) {
$m[] = 'Histórico de pagamentos existente ou desconhecido; migração fora do âmbito.';
}
return $m;
}
private function q(string $table)
{
return $this->db->table('pa_'.$table)->where($this->scope);
}
private function event(string $kind, int $source, array $data): void
{
$id = $this->db->table('pa_events')->insertGetId($this->scope + ['source_id' => $source, 'actor_id' => $this->actor, 'kind' => $kind, 'payload' => self::canonical($data), 'created_at' => date('Y-m-d H:i:s')]);
$this->db->table('pa_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_settlement_or_accounting']);
}
private function source(int $local): array
{
$s = ($this->loadSource)($local);
foreach (['instance', 'company', 'document'] as $key) {
if (empty($s[$key])) {
throw new \DomainException('Identidade de origem insuficiente.');
}
}
return $s;
}
private function locked(array $s): object
{
$key = self::identity($s);
$this->db->table('pa_sources')->insertOrIgnore($this->scope + ['source_key' => $key, 'local_source_id' => $s['local_id'], 'created_at' => date('Y-m-d H:i:s')]);
$row = $this->db->table('pa_sources')->where('source_key', $key)->lockForUpdate()->first();
if (! $row) {
throw new \DomainException('Identidade documental comercial já associada; revisão necessária.');
}
foreach ($this->scope as $k => $v) {
if ((int) $row->$k !== $v) {
throw new \DomainException('Origem já associada a outro contexto.');
}
}
return $row;
}
public function command(string $action, int $id, string $reason, string $key): array
{
if (! in_array($action, ['prepare', 'validate'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) {
throw new \InvalidArgumentException('Acção, chave ou fundamento inválidos.');
}
return $this->db->transaction(function () use ($action, $id, $reason, $key) {
($this->authorize)($action);
$hash = hash('sha256', self::canonical([$action, $id, $reason]));
$old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
if ($old) {
if ($old->request_hash !== $hash) {
throw new \DomainException('Chave usada com conteúdo diferente.');
}
// Even replay rechecks the current source/revision/payment eligibility.
$result = json_decode($old->result, true, 32, JSON_THROW_ON_ERROR);
$local = $result['local_source_id'];
$s = $this->source($local);
$row = $this->locked($s);
$material = self::revision($s);
if (($result['source_revision'] ?? null) !== $material || self::missing($s) !== ($result['missing'] ?? [])) {
return $this->conflict($row, $local, 'A origem/eligibilidade mudou após o comando; original preservado.');
}
return $result + ['replayed' => true];
}
if ($action === 'prepare') {
$s = $this->source($id);
$row = $this->locked($s);
$revision = self::revision($s);
$missing = self::missing($s);
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
if ($ob) {
$result = $ob->source_revision === $revision && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento alterado ou pagamentos associados; nenhuma dívida adicional.');
} else {
$candidate = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing) {
$pid = $this->db->table('pa_proposals')->insertGetId($this->scope + ['source_id' => $row->id, 'source_revision' => $revision, 'capture_version' => ($candidate ? (int) $candidate->capture_version : 0) + 1, 'snapshot' => self::canonical($s), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
$this->event('payable.proposed', $row->id, ['proposal_id' => $pid, 'source_revision' => $revision, 'missing' => $missing]);
} else {
$pid = $candidate->id;
}
$result = ['state' => $missing === [] ? 'prepared' : 'pending_source', 'proposal_id' => $pid, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => $missing];
}
} else {
$p = $this->q('proposals')->where('id', $id)->first();
if (! $p) {
throw new \DomainException('Proposta fora do âmbito.');
}
if ((int) $p->prepared_by === $this->actor || $p->prepared_person === $this->person) {
throw new \DomainException('Validação exige pessoa distinta da preparação.');
}
$row = $this->q('sources')->where('id', $p->source_id)->lockForUpdate()->first();
$local = (int) $row->local_source_id;
$s = $this->source($local);
$revision = self::revision($s);
$missing = self::missing($s);
$latest = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
if ($revision !== $p->source_revision || $latest->id !== $p->id || $missing !== []) {
$result = $this->conflict($row, $local, 'Revisão/decisão/pagamentos incompatíveis ou informação insuficiente.');
} elseif ($ob) {
$result = ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
} else {
$oid = $this->db->table('pa_obligations')->insertGetId($this->scope + ['source_id' => $row->id, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'currency' => 'MZN', 'due_on' => null, 'recognised_by' => $this->actor, 'recognised_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
$this->event('payable.recognised', $row->id, ['obligation_id' => $oid, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'due_on' => null]);
$result = ['state' => 'recognised', 'obligation_id' => $oid, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
}
}
$this->db->table('pa_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => self::canonical($result), 'created_at' => date('Y-m-d H:i:s')]);
return $result + ['replayed' => false];
});
}
private function conflict(object $row, int $local, string $reason): array
{
$this->event('payable.source_conflict', $row->id, ['reason' => $reason, 'observed_revision' => self::revision($this->source($local))]);
return ['state' => 'conflict', 'local_source_id' => $local, 'reason' => $reason];
}
public function listing(array $localSources): array
{
return $this->db->transaction(function () use ($localSources) {
($this->authorize)('view');
$documents = [];
foreach ($localSources as $id) {
$s = $this->source($id);
$documents[] = $s + ['revision' => self::revision($s), 'missing' => self::missing($s)];
}
$current = [];
foreach ($documents as $doc) {
$current[self::identity($doc)] = $doc;
}
$obs = $this->q('obligations')->orderBy('id')->get()->map(function ($o) use ($current) {
$origin = $this->q('sources')->where('id', $o->source_id)->first();
$doc = $current[$origin->source_key] ?? null;
// Additional UI-derived revision/missing fields are not part of the material source hash.
$compatible = $doc && $doc['revision'] === $o->source_revision && $doc['missing'] === [];
return (array) $o + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $o->principal_minor : null, 'source_conflict' => ! $compatible, 'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'], $compatible ? [] : ['Origem/eligibilidade divergente; saldo não presumido.'])];
})->all();
return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $obs, 'proposals' => $this->q('proposals')->orderBy('id')->get(), 'events' => $this->q('events')->orderBy('id')->get(), 'permissions' => ['prepare' => false, 'validate' => false], 'current_actor' => $this->actor];
});
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Tempo\Finance\Payables;
final class Workspace
{
public static function render(array $data, array $settings): string
{
ob_start();
require __DIR__.'/../ui/page.php';
return ob_get_clean();
}
}
@@ -0,0 +1,50 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\CostCenter;
use App\Models\Department;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\Supplier;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Warehouse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** Synthetic fixture: callable only on in-memory SQLite or the dedicated PostgreSQL test database. */
function payableReceiptFixture(string $quantity = '1.000', string $purchaseType = 'consumable'): array
{
$connection = DB::connection();
$allowed = ($connection->getDriverName() === 'sqlite' && $connection->getDatabaseName() === ':memory:') ||
($connection->getDriverName() === 'pgsql' && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $connection->getDatabaseName()));
if (! app()->environment('testing') || ! $allowed) {
throw new LogicException('Fixtures de recepção proibidas fora de uma base de testes isolada.');
}
$tenant = Tenant::query()->create(['name' => 'Horizonte Sintética — Obrigações']);
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => 'Operações']);
$center = CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'OPS', 'name' => 'Operações']);
$issuer = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Procurement]);
$receiver = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
$otherReceiver = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
auth()->guard()->setUser($receiver);
$r = Requisition::query()->create(['tenant_id' => $tenant->id, 'requester_id' => $issuer->id, 'department_id' => $department->id, 'cost_center_id' => $center->id, 'number' => 'REQ-TEST-'.Str::random(10), 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Fixture de ordem já emitida para testes de recepção.', 'status' => 'approved', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000]);
$requested = $r->items()->create(['tenant_id' => $tenant->id, 'description' => 'Artigo fictício', 'category' => 'Material', 'purchase_type' => $purchaseType, 'quantity' => $quantity, 'unit' => 'cx', 'unit_price_minor' => 1000000, 'tax_rate' => '16', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000]);
$supplier = Supplier::query()->create(['tenant_id' => $tenant->id, 'created_by' => $issuer->id, 'name' => 'Fornecedor Alfa Sintético', 'nuit' => '123456789', 'contact' => 'Contacto de teste', 'email' => 'fixture@example.invalid', 'phone' => '000000000', 'address' => 'Morada fictícia', 'category' => 'Material', 'beneficiaries' => 'Beneficiário fictício', 'document_references' => 'Documento fictício', 'diligence_notes' => 'Somente para a base de testes.', 'status' => 'active', 'next_review' => now()->addYear()]);
$quote = Quotation::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'supplier_id' => $supplier->id, 'created_by' => $issuer->id, 'reference' => 'PROP-TEST', 'delivery_days' => 5, 'valid_until' => now()->addWeek(), 'payment_terms' => '30 dias', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000, 'weights' => ['price' => 40, 'quality' => 20, 'delivery' => 15, 'history' => 15, 'payment' => 10]]);
$award = PurchaseAward::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'quotation_id' => $quote->id, 'created_by' => $issuer->id, 'justification' => 'Adjudicação de fixture, não repetir o fluxo anterior.', 'delivery_date' => now()->addWeek(), 'delivery_location' => 'Armazém fictício', 'status' => 'approved', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000, 'approval_snapshot' => [], 'decision_matrix' => [], 'comparison_hash' => str_repeat('0', 64)]);
$order = PurchaseOrder::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'purchase_award_id' => $award->id, 'supplier_id' => $supplier->id, 'issued_by' => $issuer->id, 'number' => 'OC-TEST-'.Str::random(10), 'status' => 'issued', 'currency' => 'MZN', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000, 'issued_at' => now(), 'snapshot' => ['supplier' => $supplier->only(['name', 'nuit']), 'company' => ['name' => $tenant->name], 'development_only' => true, 'budget_checked' => false]]);
$line = $order->items()->create(['tenant_id' => $tenant->id, 'requisition_item_id' => $requested->id, 'description' => 'Artigo fictício', 'category' => 'Material', 'purchase_type' => $purchaseType, 'unit' => 'cx', 'quantity' => $quantity, 'unit_price_minor' => 1000000, 'tax_rate' => '16', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000]);
$warehouse = Warehouse::query()->create(['tenant_id' => $tenant->id, 'created_by' => $receiver->id, 'code' => 'ARM-TEST', 'name' => 'Armazém fictício']);
$stock = StockItem::query()->create(['tenant_id' => $tenant->id, 'created_by' => $receiver->id, 'code' => 'ART-TEST', 'name' => 'Artigo de stock fictício', 'unit' => 'cx']);
return compact('tenant', 'department', 'issuer', 'receiver', 'otherReceiver', 'r', 'order', 'line', 'warehouse', 'stock');
}
function payableReceiptPayload(array $fixture, string $quantity = '0.600'): array
{
return ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-'.Str::random(10), 'received_on' => now()->format('Y-m-d'), 'warehouse_id' => $fixture['warehouse']->id, 'items' => [['purchase_order_item_id' => $fixture['line']->id, 'received_quantity' => $quantity, 'accepted_quantity' => $quantity, 'quality_status' => 'compliant', 'stock_item_id' => $fixture['stock']->id, 'notes' => null]]];
}
+56
View File
@@ -0,0 +1,56 @@
<?php
// Called only after a fresh, restricted QA database has been provisioned.
$mode=$argv[1]??'';$root=$mode==='compras'?'C:/laragon/www/compras':'C:/laragon/www/sgf-tempo';
require $root.'/vendor/autoload.php';$app=require $root.'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
$db=DB::connection();$name=$db->getDatabaseName();
if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$name)||$db->selectOne('select current_database() as d')->d!==$name||Schema::hasTable('users'))throw new LogicException('Fresh QA only.');
$file=getenv('PAYABLE_ACCESS_FILE');$bundle=getenv('PAYABLE_BUNDLE');
if($mode==='compras'){
\Illuminate\Support\Facades\Artisan::call('migrate',['--force'=>true]);
require __DIR__.'/../qa-schema.php';require __DIR__.'/ReceiptFixture.php';
$f=payableReceiptFixture();
app(\App\Services\ReceiptWorkflow::class)->confirm($f['receiver'],$f['order'],payableReceiptPayload($f,'1.000'));
$p=\App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'finance']);
$v=\App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'finance']);
$q=\App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'admin']);
auth()->guard()->setUser($p);
\App\Models\Budget::query()->create(['tenant_id'=>$f['tenant']->id,'cost_center_id'=>$f['r']->cost_center_id,'year'=>now()->year,'amount_minor'=>2000000,'includes_tax'=>true,'approved_by'=>$v->id,'created_by'=>$p->id,'reason'=>'Only synthetic source preparation']);
DB::transaction(function()use($p,$f){app(\App\Services\BudgetWorkflow::class)->record($p,$f['r'],'reserve',1160000,1000000);app(\App\Services\BudgetWorkflow::class)->record($p,$f['r'],'commit',1160000,1000000);});
$invoice=app(\App\Services\InvoiceWorkflow::class)->register($p,$f['order'],['number'=>'FT-SIM-11600','issued_on'=>now()->format('Y-m-d'),'subtotal'=>'10000.00','tax'=>'1600.00','total'=>'11600.00','items'=>[['purchase_order_item_id'=>$f['line']->id,'quantity'=>'1.000','unit'=>'cx','unit_price'=>'10000.00','tax_rate'=>'16']]]);
auth()->guard()->setUser($v);app(\App\Services\InvoiceWorkflow::class)->validate($v,$invoice);
$scope=['tenant_id'=>$f['tenant']->id,'company_id'=>0,'unit_id'=>0];
auth()->guard()->setUser($p);$source=app(\App\Services\SupplierPayables::class)->document($p,$invoice->id);
if($source['total_minor']!==1160000||$source['tax_minor']!==160000||$source['status']!=='validated'||!$source['validation_audit_id'])throw new RuntimeException('Source scenario mismatch.');
file_put_contents($bundle,\Tempo\Finance\Payables\Payables::canonical($source),LOCK_EX);
$sourceId=$invoice->id;
}else{
foreach(['0001_01_01_000000_create_users_table.php','2026_10_25_000000_create_identity_foundation.php','2026_10_25_010000_add_identity_workflow.php'] as $m)(require $root.'/database/migrations/'.$m)->up();
require __DIR__.'/../qa-schema.php';
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR (permission IN ('unit.view','unit.edit','supplier_payables.view') AND unit_id IS NOT NULL))");
$now=now();$tenant=DB::table('tenants')->insertGetId(['name'=>'Tenant sintético','created_at'=>$now,'updated_at'=>$now]);
DB::table('companies')->insert(['tenant_id'=>$tenant,'name'=>'Empresa não autorizada','created_at'=>$now,'updated_at'=>$now]);
$company=DB::table('companies')->insertGetId(['tenant_id'=>$tenant,'name'=>'Horizonte Sintética — Obrigações','created_at'=>$now,'updated_at'=>$now]);
DB::table('units')->insert(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Unidade reservada','created_at'=>$now,'updated_at'=>$now]);
$unit=DB::table('units')->insertGetId(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Unidade de obrigações','created_at'=>$now,'updated_at'=>$now]);
$scope=['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit];
$p=\App\Models\User::create(['name'=>'Preparador','email'=>'seed-p@example.test','password'=>bin2hex(random_bytes(20))]);
$v=\App\Models\User::create(['name'=>'Validador','email'=>'seed-v@example.test','password'=>bin2hex(random_bytes(20))]);
$q=\App\Models\User::create(['name'=>'Consulta','email'=>'seed-q@example.test','password'=>bin2hex(random_bytes(20))]);
foreach([$p,$v,$q] as $u){DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company]);foreach(['company.view'=>null,'unit.view'=>$unit,'supplier_payables.view'=>$unit] as $right=>$which)DB::table('permission_grants')->insert(['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$which,'user_id'=>$u->id,'permission'=>$right,'created_at'=>$now,'updated_at'=>$now]);}
Schema::create('pa_imported_sources',function(Blueprint $t){$t->id();foreach(['tenant_id','company_id','unit_id'] as $k)$t->unsignedBigInteger($k);$t->jsonb('payload');$t->string('receipt_hash',64);});
$source=json_decode(file_get_contents($bundle),true,64,JSON_THROW_ON_ERROR);
if(\Tempo\Finance\Payables\Payables::missing($source)!==[])throw new RuntimeException('Conferred Compras source required.');
$sourceId=DB::table('pa_imported_sources')->insertGetId($scope+['payload'=>\Tempo\Finance\Payables\Payables::canonical($source),'receipt_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($source))]);
}
$accounts=[];
foreach(['preparacao'=>[$p,['view','prepare']],'validacao'=>[$v,['view','validate']],'consulta'=>[$q,['view']]] as $role=>[$user,$rights]){
$password=bin2hex(random_bytes(20));$user->forceFill(['name'=>'Obrigações — '.$role,'email'=>$role.'-obrigacoes@example.test','password'=>$password]);$user->save();
foreach($rights as $right)DB::table('pa_access')->insert($scope+['user_id'=>$user->id,'person'=>'synthetic-person-'.$role,'action'=>$right]);
$accounts[$role]=['id'=>$user->id,'email'=>$user->email,'password'=>$password];
}
file_put_contents($file,json_encode(['mode'=>$mode,'scope'=>$scope,'source_id'=>$sourceId,'accounts'=>$accounts],JSON_THROW_ON_ERROR),LOCK_EX);
echo "Synthetic conferred-source scenario prepared; obligations remain unrecognised for team testing.\n";
+21
View File
@@ -0,0 +1,21 @@
<?php $e=fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $money=fn($v,$currency='MZN')=>number_format(intdiv((int)$v,100),0,'.',' ').'.'.str_pad((string)abs((int)$v%100),2,'0',STR_PAD_LEFT).' '.$e($currency); ?>
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Obrigações a fornecedores — ensaio</title>
<style>body{font:16px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:1100px;margin:auto;padding:24px}header,section{background:white;padding:24px;margin:18px 0;border:1px solid #d5e0dc;border-radius:12px}h1{font-size:28px}h2{font-size:20px}.notice{padding:16px;background:#fff2cc}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}label{display:block;margin:12px 0}select,textarea,button{font:inherit;padding:10px;max-width:100%}textarea{display:block;width:90%}button{background:#125a4b;color:white;border:0;border-radius:6px;cursor:pointer}button:disabled{opacity:.5}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}.scroll{overflow:auto}.amount{font-size:26px;font-weight:bold}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#125a4b}</style></head><body><main>
<header><a href="/">← Voltar à aplicação</a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Obrigações a fornecedores</h1><p><?= $e($settings['organisation']) ?></p><p class="notice">ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso só reconhece o principal.</p><p>Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.</p></header>
<p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
<section><h2>Facturas de origem</h2><div class="scroll"><table><thead><tr><th>Documento</th><th>Fornecedor</th><th>Base</th><th>Imposto documental</th><th>Total</th><th>Conferência / revisão</th></tr></thead><tbody>
<?php foreach($data['documents'] as $d): ?><tr><td><?= $e($d['number']??'Indeterminado') ?></td><td><?= $e($d['supplier_name']??'Indeterminado') ?></td><td><?= isset($d['subtotal_minor'])?$money($d['subtotal_minor']):'Indeterminado' ?></td><td><?= isset($d['tax_minor'])?$money($d['tax_minor']):'Indeterminado' ?></td><td><?= isset($d['total_minor'])?$money($d['total_minor']):'Indeterminado' ?></td><td><?= $e($d['status']) ?><details><summary>Origem e pendências</summary><pre><?= $e(json_encode($d,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></td></tr><?php endforeach ?>
</tbody></table></div></section>
<?php if($data['permissions']['prepare']): ?><section><h2>Preparar a obrigação</h2><form data-action="prepare"><label>Factura existente<select name="id"><?php foreach($data['documents'] as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Proposta sintética com base na factura conferida.</textarea></label><p>Sem edição de fornecedor, valor ou imposto. Vencimento mantém-se desconhecido neste recorte.</p><button>Preparar da origem</button></form></section><?php endif ?>
<section><h2>Propostas e decisões</h2><?php if(!$data['proposals']->count()): ?><p>Ainda não há propostas.</p><?php endif ?>
<?php foreach($data['proposals'] as $p): ?><article><h3>Proposta #<?= $e($p->id) ?> · captura <?= $e($p->capture_version) ?></h3><p>Preparador #<?= $e($p->prepared_by) ?> · <?= $e($p->created_at) ?></p><p><?= $e($p->reason) ?></p><details><summary>Fotografia e pendências</summary><pre><?= $e($p->snapshot) ?></pre><pre><?= $e($p->missing) ?></pre></details>
<?php if($data['permissions']['validate']): ?><form data-action="validate"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Validar revisão exacta e reconhecer</button></form><?php endif ?></article><?php endforeach ?></section>
<section><h2>Obrigações reconhecidas</h2><?php if(!$data['obligations']): ?><p>Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.</p><?php endif ?><div class="grid">
<?php foreach($data['obligations'] as $o): ?><article><h3>Obrigação #<?= $e($o['id']) ?></h3><p class="amount"><?= $money($o['principal_minor']) ?></p><p>Principal reconhecido — vencimento pendente</p><p>Atraso indeterminado · Dias de atraso: sem valor</p><p>Validador #<?= $e($o['recognised_by']) ?> · <?= $e($o['created_at']) ?></p><details><summary>Origem, versão e decisão</summary><pre><?= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></article><?php endforeach ?></div></section>
<section><h2>Histórico durável</h2><?php foreach($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section>
<p>Sem pagamento, cobrança, afectação, stock, novo consumo orçamental ou lançamento automático. A consulta não marca o documento como pago.</p>
</main><script>
const settings=<?= json_encode($settings,JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?>;
document.querySelector('#reload').onclick=()=>location.reload();
for(const form of document.querySelectorAll('form[data-action]'))form.onsubmit=async event=>{event.preventDefault();const body=Object.fromEntries(new FormData(form));body.id=Number(body.id);const signature=JSON.stringify(body);if(form.dataset.signature!==signature){form.dataset.signature=signature;form.dataset.key=crypto.randomUUID();}const button=form.querySelector('button');button.disabled=true;document.querySelector('#error').textContent='';try{const r=await fetch(settings.api+'/'+form.dataset.action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf,'X-SGF-Context':settings.context??'','Idempotency-Key':form.dataset.key},body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw new Error(d.message??Object.values(d.errors??{}).flat().join(' '));if(d.state==='conflict'){document.querySelector('#error').textContent=d.reason+' Consulte o histórico.';return;}location.reload();}catch(e){document.querySelector('#error').textContent=e.message;}finally{button.disabled=false;}};
</script></body></html>