commit de compras

This commit is contained in:
2026-11-04 09:37:20 +02:00
parent fcf31e8d48
commit 0b05a1192c
741 changed files with 114127 additions and 116 deletions
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Tempo\Finance\Composition;
/** Verify against a caller-pinned manifest BEFORE loading executable package files. */
final class Bundle
{
public static function verify(string $root,string $expected):array
{
$root=realpath($root)?:throw new \LogicException('FINANCE_BUNDLE_MISSING');$manifest=$root.'/manifest.json';
if(!preg_match('/^[a-f0-9]{64}$/D',$expected)||!is_file($manifest)||is_link($manifest)||!hash_equals($expected,hash_file('sha256',$manifest)))throw new \LogicException('FINANCE_BUNDLE_MANIFEST_MISMATCH');
$m=json_decode(file_get_contents($manifest),true,64,JSON_THROW_ON_ERROR);
if(($m['contract']??null)!=='COMMON-FINANCE-BUNDLE-v1'||!is_array($m['files']??null)||count($m['files'])<3)throw new \LogicException('FINANCE_BUNDLE_CONTRACT');
$expectedFiles=['manifest.json'];$seen=[];
foreach($m['files'] as $entry){
$name=$entry['file']??'';
if(!is_string($name)||!preg_match('~^(Bundle\.php|(?:payables|general-ledger)/(?:bootstrap\.php|README\.md|(?:src|ui)/[A-Za-z0-9_-]+\.php))$~D',$name)||isset($seen[$name]))throw new \LogicException('FINANCE_BUNDLE_PATH');
$seen[$name]=true;$file=$root.'/'.$name;$actual=realpath($file);
if(!$actual||!str_starts_with($actual,$root.DIRECTORY_SEPARATOR)||is_link($file)||!is_file($actual)||!hash_equals($entry['sha256']??'',hash_file('sha256',$actual)))throw new \LogicException('FINANCE_BUNDLE_FILE_MISMATCH');
$expectedFiles[]=$name;
}
$actualFiles=[];foreach(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root,\FilesystemIterator::SKIP_DOTS)) as $file){if($file->isLink())throw new \LogicException('FINANCE_BUNDLE_SYMLINK');if($file->isFile())$actualFiles[]=str_replace('\\','/',substr($file->getPathname(),strlen($root)+1));}
sort($actualFiles);sort($expectedFiles);if($actualFiles!==$expectedFiles)throw new \LogicException('FINANCE_BUNDLE_EXTRA_OR_MISSING_FILE');
foreach(['Bundle.php','payables/bootstrap.php','general-ledger/bootstrap.php'] as $required)if(!isset($seen[$required]))throw new \LogicException('FINANCE_BUNDLE_INCOMPLETE');
return ['payables'=>$root.'/payables','ledger'=>$root.'/general-ledger','files'=>count($seen),'manifest_sha256'=>$expected];
}
}
+33
View File
@@ -0,0 +1,33 @@
# General ledger — synthetic supplier and customer cycles
Correcção industrial QA: `industry.cost.reversal/replacement`, capturadas/revistas/aplicadas apenas como grupo pelo writer existente. `IndustrialCorrectionSchema` exige livro SGF novo vazio e delega só o domínio industrial, preservando guardas fornecedor/directo. Sem activar capacidades em livros antigos ou no destino; ver a entrega industrial de correcção no SGF.
Industrial QA SGF: tipo `industry.cost.validated` em duas linhas pelo mesmo escritor, fonte/guardas explicitamente configuradas no host e identidade por lote/perímetro. Residual separado. `IndustrialSchema` só em livro SGF novo vazio. Sem dependência industrial exigida no SGC ou alteração dos pacotes congelados; ver `docs/GENERAL_LEDGER_INDUSTRIAL_DELIVERY.md` no SGF.
The separately authorised `COMMON-LEDGER-DIRECT-COST-CORRECTION-SIM-v1` composes one explicit original275 → reversal275/replacement295 cost group in NEW SGF QA books. `CostCorrectionSchema` delegates only cost results to its asymmetric, scoped, deferred pair check; supplier pair validation is retained. Public individual review/post routes refuse all correction parts. The host requires a separate default-off flag, current verified cost source, exact review and original period open; period completeness follows the applied replacement source revision. Original journals, costs and outbox remain immutable. No reopen or implicit chain. See `docs/GENERAL_LEDGER_DIRECT_COST_CORRECTION_DELIVERY.md`.
The separately authorised `COMMON-LEDGER-DIRECT-COST-SIM-v1` adds an optional SGF actual validated workshop cost source: D QA-CUSTO-DIRECTO275 / C QA-COMPENSACAO-ENSAIO275. `CostSchema` installs an explicit append-only order/perimeter mapping only in a NEW empty SGF QA book. The host owns cost completeness, current source/rate/decision validation and exact minor-unit eligibility. Stable effect identity is the selected order/perimeter, not a successor calculation ID. Corrections of already-posted costs are refused/out of scope; no automatic stock/payroll/financial effect or outbox processing. Native SGC requires no workshop schema/provider and refuses the uncomposed source. Single Sales-provider period packets retain their prior hash contract. See `docs/GENERAL_LEDGER_DIRECT_COST_DELIVERY.md`.
`LedgerReports` adds read-only trial balance and account ledger APIs over posted facts. Explicit inclusive dates, scoped account validation, 25/100 server pagination, SQL totals and running balances computed before pagination. The host supplies the same current read authorizer and a repeatable-read/read-only transaction. Three report queries serve each trial balance page; account ledger additionally validates the account and counts its entries. `ReportsWorkspace` renders the shared view without commands. No financial authority, schema installer or rights were added; unavailable capability/schema is refused by the host. See `docs/GENERAL_LEDGER_REPORTS_DELIVERY.md`.
The separately authorised `COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1` now composes actual test sales invoices and confirmed receivable allocations/reversals in SGF through optional host callbacks. `SalesSchema` installs only in a NEW empty owned SGF QA ledger: documentary recognition has three reviewed lines; allocation/reversal and supplier journals retain two. The deferred journal AND line-insert constraints enforce the exact reviewed line count, accounts and balance, including an attempted late third line on an existing two-line journal. Existing demonstrations are not upgraded. See `docs/GENERAL_LEDGER_SALES_RECEIVABLE_DELIVERY.md`.
Sales capability defaults off. Standalone SGC needs neither Sales nor its schema; its adapter explicitly refuses those source kinds. Source identifiers are typed: the legacy supplier `obligation_id` filter must never mix a customer obligation with the same integer ID. Customer reads use `sales_invoice_id`. One scoped period completeness check includes both composed sources, without creating financial effects.
Single canonical writer for `COMMON-LEDGER-PAYABLE-SIM-v1` and the separately authorised `COMMON-LEDGER-COMPLETE-CYCLE-SIM-v1`. SGF and standalone SGC adapters opt into NEW owned QA databases. No operational installer, bank execution, real chart/tax policy or production use is delivered. See `docs/GENERAL_LEDGER_COMPLETE_CYCLE_DELIVERY.md` in the canonical repository.
`PayableJournal` consumes a current recognised payable effect through a host callback. It never creates/settles a payable. `prepare` captures source/rule/period/lines; `validate` records an independent approval/refusal; `post` revalidates the exact capture and writes one journal, two lines, history and a request receipt atomically. Source effect identity is independent of request key and source revision. Different versions cannot silently book the same principal again.
The host authorizer must lock the company before source/configuration reads and recheck context, person and explicit action rights before idempotent recovery. All normal source/configuration writers must use that same serialization boundary. Account/rule editing is not exposed; reviewed period close appends a new closed version. The source provider must return scoped, compatible, current recognition identity/revision and integral principal. Do not pass report totals, an invoice status alone or a cached browser snapshot as source authority.
`effect-prepare` accepts only confirmed financial allocation/reversal IDs. The original recognition must already be posted; reversing an allocation also requires its original accounting effect. `amount_minor` is the factual allocation amount, separate from the original principal. Normal review/post commands retain exact current-state checks and the same journal writer.
`correct-prepare/validate/post` capture and apply a linked full reversal/replacement of an original recognition. The public individual routes refuse corrective parts. Both postings and their group result are atomic; deferred SQL checks refuse an orphan part. Original journals remain immutable. `period-prepare/validate/close` capture reconciled facts/configuration, require distinct review and recheck after serialization; pending effects or changed evidence refuse. No reopen command exists.
`CycleSchema::install` refuses any existing ledger proposals/journals and is never called by host boot or migrations. Old demonstration schemas are not upgraded. Cycle permissions are separately granted only in new fixtures. Both host defaults remain off.
Rules, periods, accounts, proposals, decisions, journals, lines, command receipts and events are append-only. Composite foreign keys prevent cross-scope account/line/review references. Immediate application validation plus deferred database constraints enforce the reviewed balanced journal (two lines in supplier composition; two or three in new Sales composition), exact source snapshot, preparer and accounts. Database ownership/role separation for real deployment remains unvalidated; the QA application role owns its new test database.
`listing` returns 25-row journal/proposal/event windows and filtered account/period totals over all matching journal lines, not only the visible page. The SGF host wraps reads in repeatable-read/read-only transactions. Unknown period/account is refused, not displayed as a missing-source zero.
The optional checkpoint closure is test instrumentation only, absent from HTTP/configuration. No dependencies were added. Current artificial rule: D `QA-CONTRAPARTIDA` / C `QA-FORNECEDOR`, MZN, explicit October 2026 period; it is not a client chart, expense/inventory determination or fiscal approval.
+18
View File
@@ -0,0 +1,18 @@
<?php
require_once __DIR__.'/src/LedgerSchema.php';
require_once __DIR__.'/src/LedgerCycle.php';
require_once __DIR__.'/src/LedgerSales.php';
require_once __DIR__.'/src/LedgerReports.php';
require_once __DIR__.'/src/ReportsWorkspace.php';
require_once __DIR__.'/src/SalesSchema.php';
require_once __DIR__.'/src/CostSchema.php';
require_once __DIR__.'/src/IndustrialSchema.php';
require_once __DIR__.'/src/IndustrialCorrectionSchema.php';
require_once __DIR__.'/src/CostCorrectionSchema.php';
require_once __DIR__.'/src/CycleSchema.php';
require_once __DIR__.'/src/PayableJournal.php';
require_once __DIR__.'/src/StockSchema.php';
require_once __DIR__.'/src/StockValuation.php';
require_once __DIR__.'/src/StockComposition.php';
require_once __DIR__.'/src/StockWorkspace.php';
require_once __DIR__.'/src/CycleWorkspace.php';
@@ -0,0 +1,39 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
/** New owned books only. Legacy supplier constraint remains responsible for its original domain. */
final class CostCorrectionSchema
{
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
if(!str_starts_with($db->getDatabaseName(),'payable_sgf_')||!$db->getSchemaBuilder()->hasTable('gl_cost_sources')||$db->getSchemaBuilder()->hasTable('gl_cost_correction_capability')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty SGF cost correction book required');
$db->transaction(function()use($db){
$db->statement('CREATE TABLE gl_cost_correction_capability(id int PRIMARY KEY CHECK(id=1),contract text NOT NULL)');$db->table('gl_cost_correction_capability')->insert(['id'=>1,'contract'=>'COMMON-LEDGER-DIRECT-COST-CORRECTION-SIM-v1']);$db->unprepared('CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON gl_cost_correction_capability FOR EACH ROW EXECUTE FUNCTION gl_append_only()');
$db->unprepared(<<<'SQL'
CREATE FUNCTION gl_cost_pair_assert(cid bigint) RETURNS void LANGUAGE plpgsql AS $$ BEGIN
IF NOT EXISTS(SELECT 1 FROM gl_correction_results r JOIN gl_corrections c ON c.id=r.correction_id JOIN gl_journals o ON o.id=r.original_journal_id JOIN gl_journals a ON a.id=r.reverse_journal_id JOIN gl_journals b ON b.id=r.replacement_journal_id
WHERE c.id=cid AND c.original_journal_id=r.original_journal_id AND c.prepared_by=r.actor_id AND a.proposal_id=c.reverse_proposal_id AND b.proposal_id=c.replacement_proposal_id
AND (c.tenant_id,c.company_id,c.unit_id)=(r.tenant_id,r.company_id,r.unit_id) AND (o.tenant_id,o.company_id,o.unit_id)=(r.tenant_id,r.company_id,r.unit_id) AND (a.tenant_id,a.company_id,a.unit_id)=(r.tenant_id,r.company_id,r.unit_id) AND (b.tenant_id,b.company_id,b.unit_id)=(r.tenant_id,r.company_id,r.unit_id)
AND o.snapshot->'source'->>'effect_type'='workshop.direct_cost.validated' AND a.snapshot->'source'->>'effect_type'='workshop.direct_cost.reversal' AND b.snapshot->'source'->>'effect_type'='workshop.direct_cost.replacement'
AND (a.snapshot->'source'->>'source_id')::bigint=o.id AND (b.snapshot->'source'->>'source_id')::bigint=o.id AND (a.snapshot->'source'->>'original_journal_id')::bigint=o.id AND (b.snapshot->'source'->>'original_journal_id')::bigint=o.id
AND a.snapshot->'source'->>'cost_order_id'=o.snapshot->'source'->>'cost_order_id' AND b.snapshot->'source'->>'cost_order_id'=o.snapshot->'source'->>'cost_order_id' AND a.snapshot->'source'->>'origin_key'=o.snapshot->'source'->>'origin_key' AND b.snapshot->'source'->>'origin_key'=o.snapshot->'source'->>'origin_key'
AND a.snapshot->'source'->>'replacement_source_revision'=b.snapshot->'source'->>'replacement_source_revision' AND a.debit_minor=o.debit_minor AND b.debit_minor=(b.snapshot->'source'->>'principal_minor')::bigint
AND a.snapshot->'period'->>'period_key'=o.snapshot->'period'->>'period_key' AND b.snapshot->'period'->>'period_key'=o.snapshot->'period'->>'period_key' AND a.journal_on>=o.journal_on AND b.journal_on=a.journal_on
AND NOT EXISTS(SELECT 1 FROM gl_lines l WHERE l.journal_id=o.id AND NOT EXISTS(SELECT 1 FROM gl_lines rev WHERE rev.journal_id=a.id AND rev.account_id=l.account_id AND rev.debit_minor=l.credit_minor AND rev.credit_minor=l.debit_minor))
AND b.snapshot->'lines'->0->>'account_id'=o.snapshot->'lines'->0->>'account_id' AND b.snapshot->'lines'->1->>'account_id'=o.snapshot->'lines'->1->>'account_id') THEN RAISE EXCEPTION 'GL_COST_CORRECTION_PAIR_REQUIRED' USING ERRCODE='23514'; END IF;
END $$;
CREATE FUNCTION gl_cost_pair_trigger() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE cid bigint; BEGIN
IF NEW.snapshot->'source'->>'effect_type' IN('workshop.direct_cost.reversal','workshop.direct_cost.replacement') THEN
SELECT correction_id INTO cid FROM gl_correction_results WHERE reverse_journal_id=NEW.id OR replacement_journal_id=NEW.id;
IF cid IS NULL THEN RAISE EXCEPTION 'GL_COST_CORRECTION_PAIR_REQUIRED' USING ERRCODE='23514'; END IF; PERFORM gl_cost_pair_assert(cid); END IF; RETURN NEW; END $$;
CREATE CONSTRAINT TRIGGER gl_cost_pair AFTER INSERT ON gl_journals DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION gl_cost_pair_trigger();
SQL);
$definition=$db->selectOne("SELECT pg_get_functiondef('gl_cycle_pair_integrity()'::regprocedure) d")->d;
$needle=" IF TG_TABLE_NAME='gl_journals' THEN";
if(!str_contains($definition,$needle))throw new \LogicException('Unexpected legacy pair contract');
$delegation=" IF TG_TABLE_NAME='gl_correction_results' THEN IF EXISTS(SELECT 1 FROM gl_journals WHERE id=NEW.reverse_journal_id AND snapshot->'source'->>'effect_type'='workshop.direct_cost.reversal') THEN PERFORM gl_cost_pair_assert(NEW.correction_id); RETURN NEW; END IF; END IF;\n";
$db->unprepared(str_replace($needle,$delegation.$needle,$definition));
});
}
}
@@ -0,0 +1,19 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
final class CostSchema
{
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
if(!str_starts_with($db->getDatabaseName(),'payable_sgf_')||$db->getSchemaBuilder()->hasTable('gl_cost_sources')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty SGF cost ledger required');
$db->transaction(function()use($db){
$db->statement('CREATE TABLE gl_cost_sources(id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,order_id bigint NOT NULL REFERENCES wo_orders(id),material_origin text NOT NULL,time_origin text NOT NULL,period_key varchar(80) NOT NULL,created_by bigint NOT NULL,reason text NOT NULL CHECK(length(reason)>=3),created_at timestamp NOT NULL,UNIQUE(tenant_id,company_id,unit_id,order_id))');
$db->unprepared(<<<'SQL'
CREATE FUNCTION gl_cost_source_scope() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NOT EXISTS(SELECT 1 FROM wo_orders WHERE id=NEW.order_id AND tenant_id=NEW.tenant_id AND company_id=NEW.company_id AND unit_id=NEW.unit_id) THEN RAISE EXCEPTION 'GL_COST_SCOPE'; END IF; RETURN NEW; END $$;
CREATE TRIGGER gl_cost_source_scope BEFORE INSERT ON gl_cost_sources FOR EACH ROW EXECUTE FUNCTION gl_cost_source_scope();
CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON gl_cost_sources FOR EACH ROW EXECUTE FUNCTION gl_append_only();
SQL);
});
}
}
@@ -0,0 +1,59 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
/** Explicitly installed only in NEW cycle fixtures; existing ledgers are never upgraded by boot. */
final class CycleSchema
{
public const TABLES=['gl_corrections','gl_correction_results','gl_period_checks','gl_period_reviews','gl_period_closures'];
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
foreach(self::TABLES as $t)if($db->getSchemaBuilder()->hasTable($t))throw new \LogicException('Cycle schema exists; no reset');
if($db->table('gl_journals')->exists()||$db->table('gl_proposals')->exists())throw new \LogicException('Fresh empty ledger required for cycle setup');
$s='tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL';
$db->transaction(function()use($db,$s){
$db->unprepared("CREATE TABLE gl_corrections(id bigserial PRIMARY KEY,$s,original_journal_id bigint NOT NULL,reverse_proposal_id bigint NOT NULL,replacement_proposal_id bigint NOT NULL,revision char(64) NOT NULL,prepared_by bigint NOT NULL,prepared_person varchar(160) NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,UNIQUE(id,tenant_id,company_id,unit_id),UNIQUE(reverse_proposal_id,replacement_proposal_id),FOREIGN KEY(original_journal_id,tenant_id,company_id,unit_id) REFERENCES gl_journals(id,tenant_id,company_id,unit_id),FOREIGN KEY(reverse_proposal_id,tenant_id,company_id,unit_id) REFERENCES gl_proposals(id,tenant_id,company_id,unit_id),FOREIGN KEY(replacement_proposal_id,tenant_id,company_id,unit_id) REFERENCES gl_proposals(id,tenant_id,company_id,unit_id))");
$db->unprepared("CREATE TABLE gl_correction_results(id bigserial PRIMARY KEY,$s,correction_id bigint NOT NULL,original_journal_id bigint NOT NULL,reverse_journal_id bigint NOT NULL,replacement_journal_id bigint NOT NULL,actor_id bigint NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,UNIQUE(tenant_id,company_id,unit_id,original_journal_id),UNIQUE(correction_id),FOREIGN KEY(correction_id,tenant_id,company_id,unit_id) REFERENCES gl_corrections(id,tenant_id,company_id,unit_id),FOREIGN KEY(reverse_journal_id,tenant_id,company_id,unit_id) REFERENCES gl_journals(id,tenant_id,company_id,unit_id),FOREIGN KEY(replacement_journal_id,tenant_id,company_id,unit_id) REFERENCES gl_journals(id,tenant_id,company_id,unit_id))");
$db->unprepared("CREATE TABLE gl_period_checks(id bigserial PRIMARY KEY,$s,period_key varchar(80) NOT NULL,revision char(64) NOT NULL,snapshot jsonb NOT NULL,prepared_by bigint NOT NULL,prepared_person varchar(160) NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,UNIQUE(id,tenant_id,company_id,unit_id))");
$db->unprepared("CREATE TABLE gl_period_reviews(id bigserial PRIMARY KEY,$s,check_id bigint NOT NULL,revision char(64) NOT NULL,decision varchar(10) NOT NULL CHECK(decision IN('approved','rejected')),actor_id bigint NOT NULL,person varchar(160) NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,UNIQUE(check_id),UNIQUE(id,tenant_id,company_id,unit_id),FOREIGN KEY(check_id,tenant_id,company_id,unit_id) REFERENCES gl_period_checks(id,tenant_id,company_id,unit_id))");
$db->unprepared("CREATE TABLE gl_period_closures(id bigserial PRIMARY KEY,$s,check_id bigint NOT NULL,review_id bigint NOT NULL,period_key varchar(80) NOT NULL,period_version int NOT NULL,actor_id bigint NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,UNIQUE(tenant_id,company_id,unit_id,period_key),FOREIGN KEY(check_id,tenant_id,company_id,unit_id) REFERENCES gl_period_checks(id,tenant_id,company_id,unit_id),FOREIGN KEY(review_id,tenant_id,company_id,unit_id) REFERENCES gl_period_reviews(id,tenant_id,company_id,unit_id))");
// The original definition remains structurally identical; only the explicit effect amount differs.
$definition=$db->selectOne("SELECT pg_get_functiondef('gl_assert_balanced(bigint)'::regprocedure) AS d")->d;
$definition=str_replace("(p.snapshot->'source'->>'principal_minor')::bigint","coalesce((p.snapshot->'source'->>'amount_minor')::bigint,(p.snapshot->'source'->>'principal_minor')::bigint)",$definition);
$db->unprepared($definition);
foreach(self::TABLES as $t)$db->unprepared("CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON $t FOR EACH ROW EXECUTE FUNCTION gl_append_only()");
$db->unprepared(<<<'SQL'
CREATE FUNCTION gl_cycle_pair_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE r gl_correction_results%ROWTYPE; c gl_corrections%ROWTYPE; j gl_journals%ROWTYPE;
BEGIN
IF TG_TABLE_NAME='gl_journals' THEN
j:=NEW;
IF j.snapshot->'source'->>'effect_type' NOT IN ('journal.reversal','journal.replacement') THEN RETURN NEW; END IF;
SELECT * INTO r FROM gl_correction_results WHERE reverse_journal_id=j.id OR replacement_journal_id=j.id;
ELSE r:=NEW; END IF;
SELECT * INTO c FROM gl_corrections WHERE id=r.correction_id AND (tenant_id,company_id,unit_id)=(r.tenant_id,r.company_id,r.unit_id);
IF c.id IS NULL OR c.original_journal_id<>r.original_journal_id OR c.prepared_by<>r.actor_id
OR NOT EXISTS(SELECT 1 FROM gl_journals a JOIN gl_journals b ON b.id=r.replacement_journal_id
WHERE a.id=r.reverse_journal_id AND a.proposal_id=c.reverse_proposal_id AND b.proposal_id=c.replacement_proposal_id
AND (a.tenant_id,a.company_id,a.unit_id)=(r.tenant_id,r.company_id,r.unit_id) AND (b.tenant_id,b.company_id,b.unit_id)=(r.tenant_id,r.company_id,r.unit_id)
AND a.snapshot->'source'->>'effect_type'='journal.reversal' AND b.snapshot->'source'->>'effect_type'='journal.replacement'
AND (a.snapshot->'source'->>'source_id')::bigint=r.original_journal_id AND (b.snapshot->'source'->>'source_id')::bigint=r.original_journal_id
AND a.debit_minor=b.debit_minor) THEN RAISE EXCEPTION 'GL_CORRECTION_PAIR_REQUIRED' USING ERRCODE='23514'; END IF;
RETURN NEW;
END $$;
CREATE CONSTRAINT TRIGGER gl_cycle_pair AFTER INSERT ON gl_journals DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION gl_cycle_pair_integrity();
CREATE CONSTRAINT TRIGGER gl_cycle_result AFTER INSERT ON gl_correction_results DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION gl_cycle_pair_integrity();
CREATE FUNCTION gl_close_integrity() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF NOT EXISTS(SELECT 1 FROM gl_period_checks c JOIN gl_period_reviews r ON r.check_id=c.id
JOIN gl_periods p ON p.period_key=NEW.period_key AND p.version=NEW.period_version AND (p.tenant_id,p.company_id,p.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id)
WHERE c.id=NEW.check_id AND r.id=NEW.review_id AND r.decision='approved' AND r.revision=c.revision
AND c.prepared_by=NEW.actor_id AND r.actor_id<>c.prepared_by AND r.person<>c.prepared_person AND p.status='closed'
AND (c.tenant_id,c.company_id,c.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id) AND (r.tenant_id,r.company_id,r.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id)
AND c.period_key=NEW.period_key AND (c.snapshot->'period'->>'version')::int+1=NEW.period_version)
THEN RAISE EXCEPTION 'GL_CLOSE_REVIEW_REQUIRED' USING ERRCODE='23514'; END IF; RETURN NEW; END $$;
CREATE TRIGGER gl_close_integrity BEFORE INSERT ON gl_period_closures FOR EACH ROW EXECUTE FUNCTION gl_close_integrity();
SQL);
});
}
}
@@ -0,0 +1,9 @@
<?php
namespace Tempo\Finance\GeneralLedger;
final class CycleWorkspace
{
public static function render(array $data,array $settings):string
{
ob_start();try{require __DIR__.'/../ui/cycle.php';return ob_get_clean();}catch(\Throwable $e){ob_end_clean();throw $e;}
}
}
@@ -0,0 +1,39 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
/** New owned books only. Legacy supplier constraint remains responsible for its original domain. */
final class IndustrialCorrectionSchema
{
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
if(!str_starts_with($db->getDatabaseName(),'payable_sgf_')||!$db->getSchemaBuilder()->hasTable('gl_industrial_sources')||$db->getSchemaBuilder()->hasTable('gl_industrial_correction_capability')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty SGF cost correction book required');
$db->transaction(function()use($db){
$db->statement('CREATE TABLE gl_industrial_correction_capability(id int PRIMARY KEY CHECK(id=1),contract text NOT NULL)');$db->table('gl_industrial_correction_capability')->insert(['id'=>1,'contract'=>'COMMON-LEDGER-INDUSTRIAL-CORRECTION-SIM-v1']);$db->unprepared('CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON gl_industrial_correction_capability FOR EACH ROW EXECUTE FUNCTION gl_append_only()');
$db->unprepared(<<<'SQL'
CREATE FUNCTION gl_industrial_pair_assert(cid bigint) RETURNS void LANGUAGE plpgsql AS $$ BEGIN
IF NOT EXISTS(SELECT 1 FROM gl_correction_results r JOIN gl_corrections c ON c.id=r.correction_id JOIN gl_journals o ON o.id=r.original_journal_id JOIN gl_journals a ON a.id=r.reverse_journal_id JOIN gl_journals b ON b.id=r.replacement_journal_id
WHERE c.id=cid AND c.original_journal_id=r.original_journal_id AND c.prepared_by=r.actor_id AND a.proposal_id=c.reverse_proposal_id AND b.proposal_id=c.replacement_proposal_id
AND (c.tenant_id,c.company_id,c.unit_id)=(r.tenant_id,r.company_id,r.unit_id) AND (o.tenant_id,o.company_id,o.unit_id)=(r.tenant_id,r.company_id,r.unit_id) AND (a.tenant_id,a.company_id,a.unit_id)=(r.tenant_id,r.company_id,r.unit_id) AND (b.tenant_id,b.company_id,b.unit_id)=(r.tenant_id,r.company_id,r.unit_id)
AND o.snapshot->'source'->>'effect_type'='industry.cost.validated' AND a.snapshot->'source'->>'effect_type'='industry.cost.reversal' AND b.snapshot->'source'->>'effect_type'='industry.cost.replacement'
AND (a.snapshot->'source'->>'source_id')::bigint=o.id AND (b.snapshot->'source'->>'source_id')::bigint=o.id AND (a.snapshot->'source'->>'original_journal_id')::bigint=o.id AND (b.snapshot->'source'->>'original_journal_id')::bigint=o.id
AND a.snapshot->'source'->>'industrial_lot_id'=o.snapshot->'source'->>'industrial_lot_id' AND b.snapshot->'source'->>'industrial_lot_id'=o.snapshot->'source'->>'industrial_lot_id' AND a.snapshot->'source'->>'origin_key'=o.snapshot->'source'->>'origin_key' AND b.snapshot->'source'->>'origin_key'=o.snapshot->'source'->>'origin_key'
AND a.snapshot->'source'->>'replacement_source_revision'=b.snapshot->'source'->>'replacement_source_revision' AND a.debit_minor=o.debit_minor AND b.debit_minor=(b.snapshot->'source'->>'principal_minor')::bigint
AND a.snapshot->'period'->>'period_key'=o.snapshot->'period'->>'period_key' AND b.snapshot->'period'->>'period_key'=o.snapshot->'period'->>'period_key' AND a.journal_on>=o.journal_on AND b.journal_on=a.journal_on
AND NOT EXISTS(SELECT 1 FROM gl_lines l WHERE l.journal_id=o.id AND NOT EXISTS(SELECT 1 FROM gl_lines rev WHERE rev.journal_id=a.id AND rev.account_id=l.account_id AND rev.debit_minor=l.credit_minor AND rev.credit_minor=l.debit_minor))
AND b.snapshot->'lines'->0->>'account_id'=o.snapshot->'lines'->0->>'account_id' AND b.snapshot->'lines'->1->>'account_id'=o.snapshot->'lines'->1->>'account_id') THEN RAISE EXCEPTION 'GL_INDUSTRIAL_CORRECTION_PAIR_REQUIRED' USING ERRCODE='23514'; END IF;
END $$;
CREATE FUNCTION gl_industrial_pair_trigger() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE cid bigint; BEGIN
IF NEW.snapshot->'source'->>'effect_type' IN('industry.cost.reversal','industry.cost.replacement') THEN
SELECT correction_id INTO cid FROM gl_correction_results WHERE reverse_journal_id=NEW.id OR replacement_journal_id=NEW.id;
IF cid IS NULL THEN RAISE EXCEPTION 'GL_INDUSTRIAL_CORRECTION_PAIR_REQUIRED' USING ERRCODE='23514'; END IF; PERFORM gl_industrial_pair_assert(cid); END IF; RETURN NEW; END $$;
CREATE CONSTRAINT TRIGGER gl_industrial_pair AFTER INSERT ON gl_journals DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION gl_industrial_pair_trigger();
SQL);
$definition=$db->selectOne("SELECT pg_get_functiondef('gl_cycle_pair_integrity()'::regprocedure) d")->d;
$needle=" IF TG_TABLE_NAME='gl_journals' THEN";
if(!str_contains($definition,$needle))throw new \LogicException('Unexpected legacy pair contract');
$delegation=" IF TG_TABLE_NAME='gl_correction_results' THEN IF EXISTS(SELECT 1 FROM gl_journals WHERE id=NEW.reverse_journal_id AND snapshot->'source'->>'effect_type'='industry.cost.reversal') THEN PERFORM gl_industrial_pair_assert(NEW.correction_id); RETURN NEW; END IF; END IF;\n";
$db->unprepared(str_replace($needle,$delegation.$needle,$definition));
});
}
}
@@ -0,0 +1,14 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
final class IndustrialSchema
{
public static function install(ConnectionInterface $db):void{
LedgerSchema::assertSynthetic($db);if(!str_starts_with($db->getDatabaseName(),'payable_sgf_')||$db->getSchemaBuilder()->hasTable('gl_industrial_sources')||!$db->getSchemaBuilder()->hasTable('ic_lots')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty owned industrial composition required');
$db->transaction(function()use($db){$db->statement('CREATE TABLE gl_industrial_sources(id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,lot_id bigint NOT NULL REFERENCES ic_lots(id),origins jsonb NOT NULL,period_key varchar(80) NOT NULL,created_by bigint NOT NULL,reason text NOT NULL CHECK(length(reason)>=3),created_at timestamp NOT NULL,UNIQUE(tenant_id,company_id,unit_id,lot_id))');$db->unprepared(<<<'SQL'
CREATE FUNCTION gl_industrial_scope() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NOT EXISTS(SELECT 1 FROM ic_lots WHERE id=NEW.lot_id AND tenant_id=NEW.tenant_id AND company_id=NEW.company_id AND unit_id=NEW.unit_id) OR jsonb_typeof(NEW.origins)<>'object' THEN RAISE EXCEPTION 'GL_INDUSTRIAL_SCOPE'; END IF; RETURN NEW; END $$;
CREATE TRIGGER gl_industrial_scope BEFORE INSERT ON gl_industrial_sources FOR EACH ROW EXECUTE FUNCTION gl_industrial_scope();
CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON gl_industrial_sources FOR EACH ROW EXECUTE FUNCTION gl_append_only();
SQL);});
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Tempo\Finance\Payables\Payables;
/** Additional effects use the SAME capture, review, writer, receipts and balance constraints. */
trait LedgerCycle
{
private function cycleReady():void
{
if(!$this->db->getSchemaBuilder()->hasTable('gl_corrections'))throw new \DomainException('Ciclo contabilístico não instalado neste ensaio.');
}
private function cycleSource(string $kind,int $id):array
{
$this->cycleReady();
if(in_array($kind,['allocation.confirmed','allocation.reversed'],true)){
$table=$kind==='allocation.confirmed'?'ps_allocations':'ps_reversals';
$row=$this->db->table($table)->where($this->scope)->where('id',$id)->first()??throw new \DomainException('Efeito financeiro confirmado indisponível.');
$base=($this->source)((int)$row->obligation_id);
if($kind==='allocation.confirmed'){
$decision=$this->db->table('ps_allocation_decisions')->where($this->scope)->where('id',$row->decision_id)->where('decision','confirmed')->first();
$payment=$this->db->table('ps_payment_decisions')->where($this->scope)->where('payment_id',$row->payment_id)->where('decision','confirmed')->first();
if(!$decision||!$payment)throw new \DomainException('Afectação sem confirmação financeira compatível.');
}else{
if(!$this->q('journals')->where('snapshot->source->effect_type','allocation.confirmed')->where('snapshot->source->source_id',(int)$row->allocation_id)->exists())throw new \DomainException('Contabilize primeiro a afectação original.');
}
if(!$this->q('journals')->where('obligation_id',$base['obligation_id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->exists())throw new \DomainException('Reconhecimento contabilístico original em falta.');
return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>(int)$row->amount_minor,'recognition_revision'=>$this->hash(['recognition'=>$base['recognition_revision'],'financial_fact'=>(array)$row]),'financial_fact'=>(array)$row]);
}
if(!in_array($kind,['journal.reversal','journal.replacement'],true))throw new \DomainException('Efeito fora do ciclo autorizado.');
$j=$this->q('journals')->where('id',$id)->first()??throw new \DomainException('Lançamento original indisponível.');
$snapshot=json_decode($j->snapshot,true,128,JSON_THROW_ON_ERROR);
if($snapshot['source']['effect_type']!=='supplier_payable.recognised')throw new \DomainException('Só o reconhecimento original admite esta correcção técnica.');
$base=($this->source)((int)$j->obligation_id);
return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>(int)$j->debit_minor,'recognition_revision'=>$this->hash(['recognition'=>$base['recognition_revision'],'original'=>(array)$j]),'original_journal_id'=>$id,'original_journal_sha256'=>$this->hash((array)$j)]);
}
private function cycleCommand(string $action,array $d,string $key):array
{
$this->cycleReady();
if(!preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di',$key))throw new \InvalidArgumentException('Chave de repetição inválida.');
$keys=match($action){
'effect-prepare'=>['effect_type','source_id','journal_on','period_key','rule_key','reason'],
'correct-prepare'=>['journal_id','journal_on','period_key','reason'],
'correct-validate'=>['correction_id','revision','decision','reason'],
'correct-post'=>['correction_id','revision','reason'],
'period-prepare'=>['period_key','reason'],
'period-validate'=>['check_id','revision','decision','reason'],
'period-close'=>['check_id','revision','reason']};
$this->exact($d,$keys);$reason=$this->reason($d['reason']);
return $this->db->transaction(function()use($action,$d,$key,$reason){
($this->authorize)($action==='effect-prepare'?'prepare':str_replace('-','_',$action));
$hash=$this->hash([$action,$d]);$old=$this->q('commands')->where(['actor_id'=>$this->actor,'command_key'=>$key])->first();
if($old){if(!hash_equals($old->request_hash,$hash))throw new \DomainException('Chave repetida com conteúdo diferente.');return json_decode($old->result,true,128,JSON_THROW_ON_ERROR)+['replayed'=>true];}
if($action==='effect-prepare'){
if(!in_array($d['effect_type'],['allocation.confirmed','allocation.reversed','sales.invoice','receivable.allocated','receivable.reversed','workshop.direct_cost.validated','industry.cost.validated','stock.receipt','stock.issue','stock.return'],true))throw new \InvalidArgumentException('Seleccione uma origem contabilística suportada.');
$result=$this->prepare(['obligation_id'=>$d['source_id']]+$d,$reason,$d['effect_type']);
}elseif(str_starts_with($action,'correct-'))$result=$this->correction($action,$d,$reason);
else $result=$this->periodCommand($action,$d,$reason);
$this->db->table('gl_commands')->insert($this->scope+['actor_id'=>$this->actor,'command_key'=>$key,'request_hash'=>$hash,'result'=>Payables::canonical($result),'created_at'=>date('Y-m-d H:i:s')]);$this->step('after_receipt');
return $result+['replayed'=>false];
});
}
private function correction(string $action,array $d,string $reason):array
{
if($action==='correct-prepare'){
$id=$this->id($d['journal_id']);
if($this->q('correction_results')->where('original_journal_id',$id)->exists())throw new \DomainException('Correcção já aplicada; consulte o par existente.');
$original=$this->q('journals')->where('id',$id)->first()??throw new \DomainException('Original fora do âmbito.');$isCost=json_decode($original->snapshot,true)['source']['effect_type']==='workshop.direct_cost.validated';$isIndustrial=json_decode($original->snapshot,true)['source']['effect_type']==='industry.cost.validated';
$parts=[];
foreach($isIndustrial?['industry.cost.reversal'=>'QA-INDUSTRIAL-CORRECTION-REVERSE','industry.cost.replacement'=>'QA-INDUSTRIAL-CORRECTION-REPLACE']:($isCost?['workshop.direct_cost.reversal'=>'QA-COST-CORRECTION-REVERSE','workshop.direct_cost.replacement'=>'QA-COST-CORRECTION-REPLACE']:['journal.reversal'=>'QA-CORRECTION-REVERSE','journal.replacement'=>'QA-CORRECTION-REPLACE']) as $kind=>$rule){
$parts[]=$this->prepare(['obligation_id'=>$id,'journal_on'=>$d['journal_on'],'period_key'=>$d['period_key'],'rule_key'=>$rule],$reason,$kind);
}
$revision=$this->hash($parts);
$old=$this->q('corrections')->where(['reverse_proposal_id'=>$parts[0]['proposal_id'],'replacement_proposal_id'=>$parts[1]['proposal_id']])->first();
$group=$old?->id??$this->db->table('gl_corrections')->insertGetId($this->scope+['original_journal_id'=>$id,'reverse_proposal_id'=>$parts[0]['proposal_id'],'replacement_proposal_id'=>$parts[1]['proposal_id'],'revision'=>$revision,'prepared_by'=>$this->actor,'prepared_person'=>$this->person,'reason'=>$reason,'created_at'=>date('Y-m-d H:i:s')]);
return ['state'=>'prepared','correction_id'=>$group,'revision'=>$old?->revision??$revision];
}
$group=$this->q('corrections')->where('id',$this->id($d['correction_id']))->first()??throw new \DomainException('Correcção fora do âmbito.');
if(!is_string($d['revision'])||!hash_equals($group->revision,$d['revision']))throw new \DomainException('Correcção desactualizada.');
$parts=[$this->proposal((int)$group->reverse_proposal_id),$this->proposal((int)$group->replacement_proposal_id)];
if($action==='correct-validate'){
foreach($parts as $p)$this->review(['proposal_id'=>$p->id,'revision'=>$p->revision,'decision'=>$d['decision']],$reason);
return ['state'=>$d['decision'],'correction_id'=>$group->id,'revision'=>$group->revision];
}
if((int)$group->prepared_by!==$this->actor||$group->prepared_person!==$this->person)throw new \DomainException('Só o preparador autorizado aplica o par.');
$old=$this->q('correction_results')->where('original_journal_id',$group->original_journal_id)->first();
if($old){if((int)$old->correction_id!==$group->id||$old->reason!==$reason)throw new \DomainException('Original já corrigido com outra decisão.');return ['state'=>'corrected','correction_id'=>$group->id,'result_id'=>$old->id,'reverse_journal_id'=>$old->reverse_journal_id,'replacement_journal_id'=>$old->replacement_journal_id];}
$a=$this->post(['proposal_id'=>$parts[0]->id,'revision'=>$parts[0]->revision],$reason);$this->step('after_correction_first');
$b=$this->post(['proposal_id'=>$parts[1]->id,'revision'=>$parts[1]->revision],$reason);
$id=$this->db->table('gl_correction_results')->insertGetId($this->scope+['correction_id'=>$group->id,'original_journal_id'=>$group->original_journal_id,'reverse_journal_id'=>$a['journal_id'],'replacement_journal_id'=>$b['journal_id'],'actor_id'=>$this->actor,'reason'=>$reason,'created_at'=>date('Y-m-d H:i:s')]);
$this->step('after_correction_result');
return ['state'=>'corrected','correction_id'=>$group->id,'result_id'=>$id,'reverse_journal_id'=>$a['journal_id'],'replacement_journal_id'=>$b['journal_id']];
}
private function periodSnapshot(string $key):array
{
$period=$this->latest('periods','period_key',$key);if($period->status!=='open')throw new \DomainException('Período já fechado.');
$h=hash_init('sha256');$pending=0;$facts=0;
foreach($this->db->table('pa_obligations')->where($this->scope)->orderBy('id')->cursor() as $o){
$source=($this->source)((int)$o->id);hash_update($h,Payables::canonical($source));$facts++;
if(!$this->q('journals')->where('obligation_id',$o->id)->where('snapshot->source->effect_type','supplier_payable.recognised')->exists())$pending++;
}
foreach(['ps_allocations'=>'allocation.confirmed','ps_reversals'=>'allocation.reversed'] as $table=>$type){
foreach($this->db->table($table)->where($this->scope)->orderBy('id')->cursor() as $r){$facts++;hash_update($h,Payables::canonical((array)$r));if(!$this->q('journals')->where('snapshot->source->effect_type',$type)->where('snapshot->source->source_id',(int)$r->id)->exists())$pending++;}
}
foreach(['ps_payments'=>'ps_payment_decisions','ps_proposals'=>'ps_allocation_decisions','ps_reversal_proposals'=>'ps_reversals'] as $table=>$decisions){
$fk=match($table){'ps_payments'=>'payment_id','ps_proposals'=>'proposal_id',default=>'proposal_id'};
$pending+=$this->db->table($table)->where($this->scope)->whereNotIn('id',$this->db->table($decisions)->where($this->scope)->select($fk))->count();
}
$latest=$this->q('proposals')->selectRaw('max(id)')->groupBy('effect_key');
$pending+=$this->q('proposals')->whereIn('id',$latest)->whereNotIn('id',$this->q('journals')->select('proposal_id'))->whereNotIn('id',$this->q('reviews')->where('decision','rejected')->select('proposal_id'))->count();
foreach(['gl_accounts','gl_rules','gl_journals','gl_lines','gl_proposals','gl_reviews','gl_corrections','gl_correction_results','ps_payments','ps_payment_decisions','ps_proposals','ps_allocation_decisions','ps_reversal_proposals'] as $table){
hash_update($h,$table);foreach($this->db->table($table)->where($this->scope)->orderBy('id')->cursor() as $r)hash_update($h,Payables::canonical((array)$r));
}
$sum=$this->q('lines')->whereIn('journal_id',$this->q('journals')->where('snapshot->period->period_key',$key)->select('id'))->selectRaw('coalesce(sum(debit_minor),0) d,coalesce(sum(credit_minor),0) c')->first();
if((int)$sum->d!==(int)$sum->c)throw new \DomainException('Diário desequilibrado.');
if($this->additionalPeriod!==null){$extra=($this->additionalPeriod)((array)$period);$facts+=$extra['facts'];$pending+=$extra['pending'];hash_update($h,$extra['hash']);}
return ['contract'=>'COMMON-LEDGER-COMPLETE-CYCLE-SIM-v1','period'=>(array)$period,'facts'=>$facts,'pending'=>$pending,'evidence_hash'=>hash_final($h),'debit_minor'=>(int)$sum->d,'credit_minor'=>(int)$sum->c];
}
private function periodCommand(string $action,array $d,string $reason):array
{
if($action==='period-prepare'){
if(!is_string($d['period_key'])||strlen($d['period_key'])>80)throw new \InvalidArgumentException('Período inválido.');
$snapshot=$this->periodSnapshot($d['period_key']);if($snapshot['pending'])throw new \DomainException('Há efeitos ou propostas pendentes de conferência/contabilização.');
$revision=$this->hash($snapshot);$old=$this->q('period_checks')->where(['period_key'=>$d['period_key'],'revision'=>$revision,'prepared_by'=>$this->actor,'reason'=>$reason])->orderByDesc('id')->first();
$id=$old?->id??$this->db->table('gl_period_checks')->insertGetId($this->scope+['period_key'=>$d['period_key'],'revision'=>$revision,'snapshot'=>Payables::canonical($snapshot),'prepared_by'=>$this->actor,'prepared_person'=>$this->person,'reason'=>$reason,'created_at'=>date('Y-m-d H:i:s')]);
return ['state'=>'prepared','check_id'=>$id,'revision'=>$revision];
}
$p=$this->q('period_checks')->where('id',$this->id($d['check_id']))->first()??throw new \DomainException('Conferência do período indisponível.');
if(!is_string($d['revision'])||!hash_equals($p->revision,$d['revision']))throw new \DomainException('Revisão do período desactualizada.');
$review=$this->q('period_reviews')->where('check_id',$p->id)->first();
if($action==='period-close'){
if((int)$p->prepared_by!==$this->actor||$p->prepared_person!==$this->person)throw new \DomainException('Só o preparador autorizado aplica o fecho.');
$old=$this->q('period_closures')->where('period_key',$p->period_key)->first();
if($old){if((int)$old->check_id!==$p->id||$old->reason!==$reason)throw new \DomainException('Período já fechado com outra conferência.');return ['state'=>'closed','closure_id'=>$old->id,'period_version'=>$old->period_version];}
}
$snapshot=$this->periodSnapshot($p->period_key);
if(!hash_equals($p->revision,$this->hash($snapshot)))throw new \DomainException('Factos, regras ou período mudaram; prepare nova conferência.');
if($action==='period-validate'){
if((int)$p->prepared_by===$this->actor||$p->prepared_person===$this->person)throw new \DomainException('Revisão do período exige outra identidade e pessoa.');
if(!in_array($d['decision'],['approved','rejected'],true))throw new \InvalidArgumentException('Decisão explícita inválida.');
if($review){if($review->decision!==$d['decision']||$review->reason!==$reason)throw new \DomainException('Decisão do período imutável.');}
else $this->db->table('gl_period_reviews')->insert($this->scope+['check_id'=>$p->id,'revision'=>$p->revision,'decision'=>$d['decision'],'actor_id'=>$this->actor,'person'=>$this->person,'reason'=>$reason,'created_at'=>date('Y-m-d H:i:s')]);
return ['state'=>$d['decision'],'check_id'=>$p->id,'revision'=>$p->revision];
}
if(!$review||$review->decision!=='approved'||$review->revision!==$p->revision)throw new \DomainException('Fecho exige revisão independente aprovada.');
$period=$snapshot['period'];unset($period['id']);$period['version']++;$period['status']='closed';$period['created_by']=$this->actor;$period['reason']=$reason;$period['created_at']=date('Y-m-d H:i:s');
$this->db->table('gl_periods')->insert($period);$this->step('after_period_version');
$id=$this->db->table('gl_period_closures')->insertGetId($this->scope+['check_id'=>$p->id,'review_id'=>$review->id,'period_key'=>$p->period_key,'period_version'=>$period['version'],'actor_id'=>$this->actor,'reason'=>$reason,'created_at'=>date('Y-m-d H:i:s')]);
$this->event('period.closed',$id,$reason,['period'=>$snapshot['period']],['period'=>$period,'evidence_hash'=>$snapshot['evidence_hash']]);
return ['state'=>'closed','closure_id'=>$id,'period_version'=>$period['version']];
}
public function cycleListing(int $page=1,?int $costOrder=null,?int $industrialLot=null):array
{
$this->cycleReady();$page=$this->id($page);if($page>100000)throw new \InvalidArgumentException('Página inválida.');($this->readAuthorize)();$result=[];
foreach(['corrections','correction_results','period_checks','period_reviews','period_closures','periods'] as $name){$q=$this->q($name);if($industrialLot!==null&&in_array($name,['corrections','correction_results'],true))$q->whereIn('original_journal_id',$this->q('journals')->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$this->id($industrialLot))->select('id'));if($costOrder!==null&&in_array($name,['corrections','correction_results'],true))$q->whereIn('original_journal_id',$this->q('journals')->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$this->id($costOrder))->select('id'));$p=$q->orderByDesc('id')->paginate(25,['*'],'page',$page);$result[$name]=$p->items();$result['pagination'][$name]=['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()];}
$ids=[];foreach($result['corrections'] as $c){$ids[]=$c->reverse_proposal_id;$ids[]=$c->replacement_proposal_id;}
$parts=$this->q('proposals')->whereIn('id',$ids)->get()->keyBy('id');
$partReviews=$this->q('reviews')->whereIn('proposal_id',$ids)->get()->keyBy('proposal_id');$groups=array_map(fn($c)=>$c->id,$result['corrections']);$groupResults=$this->q('correction_results')->whereIn('correction_id',$groups)->get()->keyBy('correction_id');
$latestParts=$this->q('proposals')->whereIn('effect_key',$parts->pluck('effect_key')->all())->selectRaw('effect_key,max(capture_version) v')->groupBy('effect_key')->pluck('v','effect_key');
foreach($result['corrections'] as $c){$a=$parts->get($c->reverse_proposal_id);$b=$parts->get($c->replacement_proposal_id);$c->reverse_snapshot=$a->snapshot;$c->replacement_snapshot=$b->snapshot;$c->reverse_review=$partReviews->get($c->reverse_proposal_id);$c->replacement_review=$partReviews->get($c->replacement_proposal_id);$c->result=$groupResults->get($c->id);$c->current=(int)$a->capture_version===(int)$latestParts->get($a->effect_key)&&(int)$b->capture_version===(int)$latestParts->get($b->effect_key);}
foreach(['ps_allocations'=>'allocations','ps_reversals'=>'reversals'] as $table=>$name){$p=$this->db->table($table)->where($this->scope)->orderByDesc('id')->paginate(25,['*'],'page',$page);$result[$name]=$p->items();$result['pagination'][$name]=['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()];}
$p=$this->db->table('pa_obligations')->where($this->scope)->orderByDesc('id')->paginate(25,['*'],'page',$page);$result['obligations']=$p->items();$result['pagination']['obligations']=['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()];
return $result;
}
}
@@ -0,0 +1,81 @@
<?php
namespace Tempo\Finance\GeneralLedger;
/** Scoped reads of posted facts only. Does not infer classifications or financial balances. */
trait LedgerReports
{
private function reportOptions(array $options,bool $accountRequired=false):array
{
if(array_diff(array_keys($options),['from_on','to_on','account_id','page','per_page']))throw new \InvalidArgumentException('Filtro desconhecido.');
foreach(['from_on','to_on'] as $key){$v=$options[$key]??null;if(!is_string($v)||!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D',$v,$m)||!checkdate((int)$m[2],(int)$m[3],(int)$m[1]))throw new \InvalidArgumentException('Datas de início e fim explícitas obrigatórias.');}
if($options['from_on']>$options['to_on'])throw new \InvalidArgumentException('Intervalo de datas invertido.');
$options['page']=$this->id($options['page']??1);$options['per_page']=$this->id($options['per_page']??25);
if($options['page']>100000||!in_array($options['per_page'],[25,100],true))throw new \InvalidArgumentException('Paginação inválida.');
if(isset($options['account_id']))$options['account_id']=$this->id($options['account_id']);
elseif($accountRequired)throw new \InvalidArgumentException('Conta explícita obrigatória.');
return $options;
}
private function reportAmount(mixed $value):int
{
$s=(string)$value;if(!preg_match('/^-?\d+$/D',$s)||filter_var($s,FILTER_VALIDATE_INT)===false)throw new \DomainException('Montante fora da representação exacta suportada.');return (int)$s;
}
private function reportLines(array $o)
{
$q=$this->db->table('gl_lines as l')->join('gl_journals as j','j.id','=','l.journal_id')->join('gl_accounts as a','a.id','=','l.account_id');
foreach($this->scope as $key=>$value)$q->where('l.'.$key,$value)->where('j.'.$key,$value)->where('a.'.$key,$value);
$q->where('j.journal_on','<=',$o['to_on']);if(isset($o['account_id']))$q->where('a.id',$o['account_id']);return $q;
}
private function reportAccount(array $o):?object
{
return isset($o['account_id'])?($this->q('accounts')->where('id',$o['account_id'])->first()??throw new \DomainException('Conta indisponível neste âmbito.')):null;
}
private function reportGroups(array $o)
{
return $this->reportLines($o)->select(['a.id as account_id','a.code','a.label','a.currency'])
->selectRaw('COALESCE(SUM(CASE WHEN j.journal_on < ? THEN l.debit_minor::numeric-l.credit_minor::numeric ELSE 0 END),0) AS opening_net_minor',[$o['from_on']])
->selectRaw('COALESCE(SUM(CASE WHEN j.journal_on >= ? THEN l.debit_minor::numeric ELSE 0 END),0) AS debit_minor',[$o['from_on']])
->selectRaw('COALESCE(SUM(CASE WHEN j.journal_on >= ? THEN l.credit_minor::numeric ELSE 0 END),0) AS credit_minor',[$o['from_on']])
->selectRaw('COALESCE(SUM(l.debit_minor::numeric-l.credit_minor::numeric),0) AS closing_net_minor')
->groupBy('a.id','a.code','a.label','a.currency');
}
private function reportTotals(array $o):array
{
$groups=$this->reportGroups($o);$q=$this->db->query()->fromSub($groups,'g');$v=$q->selectRaw('COUNT(*) AS accounts, COALESCE(SUM(GREATEST(opening_net_minor,0)),0) AS opening_debit_minor, COALESCE(SUM(GREATEST(-opening_net_minor,0)),0) AS opening_credit_minor, COALESCE(SUM(debit_minor),0) AS debit_minor, COALESCE(SUM(credit_minor),0) AS credit_minor, COALESCE(SUM(GREATEST(closing_net_minor,0)),0) AS closing_debit_minor, COALESCE(SUM(GREATEST(-closing_net_minor,0)),0) AS closing_credit_minor')->first();
$result=[];foreach((array)$v as $k=>$n)$result[$k]=$this->reportAmount($n);return $result;
}
private function reportBasis(array $o):array
{
// Append-only headers give a bounded snapshot marker, including backdated inserts.
$q=$this->q('journals')->where('journal_on','<=',$o['to_on']);$v=$q->selectRaw('COUNT(*) AS journals,COALESCE(MAX(id),0) AS last_journal_id')->first();
return ['scope'=>$this->scope,'through_on'=>$o['to_on'],'journals'=>(int)$v->journals,'last_journal_id'=>(int)$v->last_journal_id,'kind'=>'append_only_snapshot_marker'];
}
public function trialBalance(array $options):array
{
$o=$this->reportOptions($options);
return $this->db->transaction(function()use($o){
($this->readAuthorize)();$this->reportAccount($o);$totals=$this->reportTotals($o);
$rows=$this->reportGroups($o)->orderBy('a.code')->orderBy('a.id')->offset(($o['page']-1)*$o['per_page'])->limit($o['per_page'])->get()->map(function($row){
$r=(array)$row;foreach(['opening_net_minor','debit_minor','credit_minor','closing_net_minor'] as $k)$r[$k]=$this->reportAmount($r[$k]);$r['account_id']=(int)$r['account_id'];return $r;
})->all();
return ['synthetic_only'=>true,'currency'=>'MZN','kind'=>'trial_balance','query'=>$o,'rows'=>$rows,'totals'=>$totals,'basis'=>$this->reportBasis($o),'pagination'=>['page'=>$o['page'],'per_page'=>$o['per_page'],'total'=>$totals['accounts'],'last_page'=>max(1,(int)ceil($totals['accounts']/$o['per_page']))]];
});
}
public function accountLedger(array $options):array
{
$o=$this->reportOptions($options,true);
return $this->db->transaction(function()use($o){
($this->readAuthorize)();$account=$this->reportAccount($o);$totals=$this->reportTotals($o);$opening=$totals['opening_debit_minor']-$totals['opening_credit_minor'];
$q=$this->reportLines($o)->where('j.journal_on','>=',$o['from_on']);$total=(clone $q)->count();
$window=$q->select(['l.id as line_id','l.line_no','j.id as journal_id','j.journal_on','j.proposal_id','j.review_id','j.posted_by','j.reason','j.created_at','j.source_revision','l.debit_minor','l.credit_minor'])
->selectRaw("j.snapshot->'source'->>'effect_type' AS effect_type, j.snapshot->'source'->>'invoice_id' AS invoice_id, j.snapshot->'source'->>'cost_order_id' AS cost_order_id, j.snapshot->'source'->>'industrial_lot_id' AS industrial_lot_id, j.snapshot->'source'->>'obligation_id' AS obligation_id")
->selectRaw('SUM(l.debit_minor::numeric-l.credit_minor::numeric) OVER (ORDER BY j.journal_on,j.id,l.line_no,l.id ROWS UNBOUNDED PRECEDING) AS movement_net_minor');
// Paginate outside the window so page two retains the complete running balance.
$rows=$this->db->query()->fromSub($window,'entries')->orderBy('journal_on')->orderBy('journal_id')->orderBy('line_no')->orderBy('line_id')->offset(($o['page']-1)*$o['per_page'])->limit($o['per_page'])->get()->map(function($row)use($opening){
$r=(array)$row;foreach(['line_id','line_no','journal_id','proposal_id','review_id','posted_by','debit_minor','credit_minor','movement_net_minor'] as $k)$r[$k]=$this->reportAmount($r[$k]);
// Compute in PostgreSQL numeric above; guard the final addition before converting.
$balance=$opening+$r['movement_net_minor'];if(!is_int($balance))throw new \DomainException('Saldo fora da representação exacta suportada.');$r['balance_minor']=$balance;unset($r['movement_net_minor']);return $r;
})->all();
return ['synthetic_only'=>true,'currency'=>'MZN','kind'=>'account_ledger','query'=>$o,'account'=>(array)$account,'rows'=>$rows,'totals'=>$totals,'opening_net_minor'=>$opening,'closing_net_minor'=>$totals['closing_debit_minor']-$totals['closing_credit_minor'],'basis'=>$this->reportBasis($o),'pagination'=>['page'=>$o['page'],'per_page'=>$o['per_page'],'total'=>$total,'last_page'=>max(1,(int)ceil($total/$o['per_page']))]];
});
}
}
@@ -0,0 +1,19 @@
<?php
namespace Tempo\Finance\GeneralLedger;
trait LedgerSales
{
private function captureSales(array $s,string $date,object $period,object $rule,int $amount):array
{
if(!$this->db->getSchemaBuilder()->hasTable('gl_sales_capability'))throw new \DomainException('Schema comercial não composto.');
$kind=$s['effect_type'];$codes=match($kind){'sales.invoice'=>['QA-CLIENTES','QA-VENDAS','QA-IMPOSTO-TESTE'],'receivable.allocated'=>['QA-LIQUIDACAO-CLIENTE','QA-CLIENTES'],'receivable.reversed'=>['QA-CLIENTES','QA-LIQUIDACAO-CLIENTE']};
$ids=[$rule->debit_account_id,$rule->credit_account_id];if($kind==='sales.invoice')$ids[]=$rule->tax_account_id;
if(count(array_unique($ids))!==count($ids))throw new \DomainException('Contas de movimento distintas necessárias.');
$accounts=$this->q('accounts')->whereIn('id',$ids)->get()->keyBy('id');$lines=[];
if($kind==='sales.invoice'&&(!is_int($s['base_minor']??null)||!is_int($s['tax_minor']??null)||$s['base_minor']<=0||$s['tax_minor']<=0||$s['base_minor']+$s['tax_minor']!==$amount))throw new \DomainException('Montantes documentais incompatíveis.');
foreach($ids as $n=>$id){$a=$accounts->get($id);if(!$a||!$a->is_movement||$a->currency!=='MZN'||$a->code!==$codes[$n])throw new \DomainException('Conta artificial de vendas incompatível.');
$value=$kind==='sales.invoice'&&$n>0?($n===1?$s['base_minor']:$s['tax_minor']):$amount;
$lines[]=['account_id'=>$a->id,'account_code'=>$a->code,'debit_minor'=>$n===0?$value:0,'credit_minor'=>$n===0?0:$value];
}
return ['contract'=>'COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1','source'=>$s,'journal_on'=>$date,'currency'=>'MZN','period'=>(array)$period,'rule'=>(array)$rule,'lines'=>$lines,'impact'=>['debit_minor'=>$amount,'credit_minor'=>$amount,'financial_balance_changed'=>false,'payment_created'=>false]];
}
}
@@ -0,0 +1,61 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
/** Explicit installation in a fresh owned QA database; never a host migration. */
final class LedgerSchema
{
public const TABLES = ['gl_accounts','gl_periods','gl_rules','gl_proposals','gl_reviews','gl_journals','gl_lines','gl_commands','gl_events'];
public static function assertSynthetic(ConnectionInterface $db): void
{
$r=$db->selectOne("SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
if($db->getDriverName()!=='pgsql'||$db->getConfig('host')!=='127.0.0.1'||!preg_match('/^(payable_sgf_|compras_receipts_test_)([a-f0-9]{12})$/D',$r->db,$m)||(int)$r->port!==55439||!preg_match('/^sgf_modular_regression_[a-f0-9]{12}$/D',$r->cluster)||$r->role!=='payable_history_'.($m[1]==='payable_sgf_'?'sgf_':'native_').$m[2])throw new \LogicException('LEDGER_QA_DATABASE_REFUSED');
$role=$db->selectOne('SELECT rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls AS elevated FROM pg_roles WHERE rolname=current_user');
if($role->elevated)throw new \LogicException('LEDGER_ELEVATED_ROLE_REFUSED');
}
public static function install(ConnectionInterface $db): void
{
self::assertSynthetic($db);
foreach(self::TABLES as $t)if($db->getSchemaBuilder()->hasTable($t))throw new \LogicException('Ledger schema exists; no reset or repair');
$scope='tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL';
$db->transaction(function()use($db,$scope){
$db->unprepared("CREATE TABLE gl_accounts(id bigserial PRIMARY KEY,$scope,code varchar(80) NOT NULL,label text NOT NULL,is_movement boolean NOT NULL,currency varchar(3) NOT NULL CHECK(currency='MZN'),UNIQUE(id,tenant_id,company_id,unit_id),UNIQUE(tenant_id,company_id,unit_id,code))");
$db->unprepared("CREATE TABLE gl_periods(id bigserial PRIMARY KEY,$scope,period_key varchar(80) NOT NULL,version int NOT NULL CHECK(version>0),starts_on date NOT NULL,ends_on date NOT NULL,status varchar(10) NOT NULL CHECK(status IN ('open','closed')),created_by bigint NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,CHECK(starts_on<=ends_on),UNIQUE(id,tenant_id,company_id,unit_id),UNIQUE(tenant_id,company_id,unit_id,period_key,version))");
$db->unprepared("CREATE TABLE gl_rules(id bigserial PRIMARY KEY,$scope,rule_key varchar(80) NOT NULL,version int NOT NULL CHECK(version>0),policy varchar(100) NOT NULL,effect_type varchar(80) NOT NULL,currency varchar(3) NOT NULL,valid_from date NOT NULL,valid_to date NOT NULL,debit_account_id bigint NOT NULL,credit_account_id bigint NOT NULL,created_by bigint NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,CHECK(valid_from<=valid_to AND debit_account_id<>credit_account_id),UNIQUE(id,tenant_id,company_id,unit_id),UNIQUE(tenant_id,company_id,unit_id,rule_key,version),FOREIGN KEY(debit_account_id,tenant_id,company_id,unit_id) REFERENCES gl_accounts(id,tenant_id,company_id,unit_id),FOREIGN KEY(credit_account_id,tenant_id,company_id,unit_id) REFERENCES gl_accounts(id,tenant_id,company_id,unit_id))");
$db->unprepared("CREATE TABLE gl_proposals(id bigserial PRIMARY KEY,$scope,effect_key char(64) NOT NULL,obligation_id bigint NOT NULL,capture_version int NOT NULL CHECK(capture_version>0),revision char(64) NOT NULL,snapshot jsonb NOT NULL,prepared_by bigint NOT NULL,prepared_person varchar(160) NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,UNIQUE(id,tenant_id,company_id,unit_id),UNIQUE(tenant_id,company_id,unit_id,effect_key,capture_version))");
$db->unprepared("CREATE TABLE gl_reviews(id bigserial PRIMARY KEY,$scope,proposal_id bigint NOT NULL,revision char(64) NOT NULL,decision varchar(10) NOT NULL CHECK(decision IN ('approved','rejected')),reviewed_by bigint NOT NULL,reviewed_person varchar(160) NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,UNIQUE(id,tenant_id,company_id,unit_id),UNIQUE(proposal_id),FOREIGN KEY(proposal_id,tenant_id,company_id,unit_id) REFERENCES gl_proposals(id,tenant_id,company_id,unit_id))");
$db->unprepared("CREATE TABLE gl_journals(id bigserial PRIMARY KEY,$scope,effect_key char(64) NOT NULL,obligation_id bigint NOT NULL,proposal_id bigint NOT NULL,review_id bigint NOT NULL,source_revision char(64) NOT NULL,journal_on date NOT NULL,currency varchar(3) NOT NULL CHECK(currency='MZN'),debit_minor bigint NOT NULL,credit_minor bigint NOT NULL,snapshot jsonb NOT NULL,posted_by bigint NOT NULL,posted_person varchar(160) NOT NULL,reason text NOT NULL,created_at timestamp NOT NULL,CHECK(debit_minor>0 AND debit_minor=credit_minor),UNIQUE(id,tenant_id,company_id,unit_id),UNIQUE(tenant_id,company_id,unit_id,effect_key),UNIQUE(proposal_id),FOREIGN KEY(proposal_id,tenant_id,company_id,unit_id) REFERENCES gl_proposals(id,tenant_id,company_id,unit_id),FOREIGN KEY(review_id,tenant_id,company_id,unit_id) REFERENCES gl_reviews(id,tenant_id,company_id,unit_id))");
$db->unprepared("CREATE TABLE gl_lines(id bigserial PRIMARY KEY,$scope,journal_id bigint NOT NULL,line_no smallint NOT NULL CHECK(line_no IN(1,2)),account_id bigint NOT NULL,debit_minor bigint NOT NULL CHECK(debit_minor>=0),credit_minor bigint NOT NULL CHECK(credit_minor>=0),CHECK((debit_minor>0 AND credit_minor=0) OR (credit_minor>0 AND debit_minor=0)),UNIQUE(journal_id,line_no),FOREIGN KEY(journal_id,tenant_id,company_id,unit_id) REFERENCES gl_journals(id,tenant_id,company_id,unit_id),FOREIGN KEY(account_id,tenant_id,company_id,unit_id) REFERENCES gl_accounts(id,tenant_id,company_id,unit_id))");
$db->unprepared("CREATE TABLE gl_commands(id bigserial PRIMARY KEY,$scope,actor_id bigint NOT NULL,command_key uuid NOT NULL,request_hash char(64) NOT NULL,result jsonb NOT NULL,created_at timestamp NOT NULL,UNIQUE(tenant_id,company_id,unit_id,actor_id,command_key))");
$db->unprepared("CREATE TABLE gl_events(id bigserial PRIMARY KEY,$scope,actor_id bigint NOT NULL,person varchar(160) NOT NULL,kind varchar(80) NOT NULL,entity_id bigint NOT NULL,reason text NOT NULL,previous jsonb NOT NULL,subsequent jsonb NOT NULL,created_at timestamp NOT NULL)");
$db->unprepared(<<<'SQL'
CREATE FUNCTION gl_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'GL_APPEND_ONLY'; END $$;
CREATE FUNCTION gl_assert_balanced(journal bigint) RETURNS void LANGUAGE plpgsql AS $$
DECLARE j gl_journals%ROWTYPE; n bigint; d bigint; c bigint; bad bigint;
BEGIN
SELECT * INTO STRICT j FROM gl_journals WHERE id=journal;
IF NOT EXISTS(SELECT 1 FROM gl_proposals p JOIN gl_reviews r ON r.proposal_id=p.id AND r.tenant_id=p.tenant_id AND r.company_id=p.company_id AND r.unit_id=p.unit_id
WHERE p.id=j.proposal_id AND r.id=j.review_id AND p.tenant_id=j.tenant_id AND p.company_id=j.company_id AND p.unit_id=j.unit_id
AND r.decision='approved' AND r.revision=p.revision AND r.reviewed_by<>p.prepared_by AND r.reviewed_person<>p.prepared_person
AND j.posted_by=p.prepared_by AND j.posted_person=p.prepared_person AND j.snapshot=p.snapshot AND j.effect_key=p.effect_key
AND j.obligation_id=(p.snapshot->'source'->>'obligation_id')::bigint AND j.source_revision=p.snapshot->'source'->>'recognition_revision'
AND j.journal_on=(p.snapshot->>'journal_on')::date AND j.debit_minor=(p.snapshot->'source'->>'principal_minor')::bigint)
THEN RAISE EXCEPTION 'GL_REVIEW_OR_SOURCE_MISMATCH' USING ERRCODE='23514'; END IF;
SELECT count(*),coalesce(sum(l.debit_minor),0),coalesce(sum(l.credit_minor),0),count(*) FILTER(WHERE NOT a.is_movement OR a.currency<>j.currency
OR l.account_id<>coalesce((j.snapshot->'lines'->(l.line_no-1)->>'account_id')::bigint,0)
OR l.debit_minor<>coalesce((j.snapshot->'lines'->(l.line_no-1)->>'debit_minor')::bigint,-1)
OR l.credit_minor<>coalesce((j.snapshot->'lines'->(l.line_no-1)->>'credit_minor')::bigint,-1))
INTO n,d,c,bad FROM gl_lines l JOIN gl_accounts a ON a.id=l.account_id AND a.tenant_id=l.tenant_id AND a.company_id=l.company_id AND a.unit_id=l.unit_id
WHERE l.journal_id=journal AND l.tenant_id=j.tenant_id AND l.company_id=j.company_id AND l.unit_id=j.unit_id;
IF n<>2 OR d<>j.debit_minor OR c<>j.credit_minor OR d<>c OR bad<>0 THEN RAISE EXCEPTION 'GL_UNBALANCED_JOURNAL' USING ERRCODE='23514'; END IF;
END $$;
CREATE FUNCTION gl_balance_trigger() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM gl_assert_balanced(NEW.id); RETURN NEW; END $$;
CREATE CONSTRAINT TRIGGER gl_balanced_journal AFTER INSERT ON gl_journals DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION gl_balance_trigger();
SQL);
foreach(self::TABLES as $t)$db->unprepared("CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON $t FOR EACH ROW EXECUTE FUNCTION gl_append_only()");
});
}
}
@@ -0,0 +1,159 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
use Tempo\Finance\Payables\Payables;
/** One synthetic recognition effect, one journal; no payment, stock, bank or fiscal writer. */
final class PayableJournal
{
use LedgerCycle;
use LedgerSales;
use LedgerReports;
public const POLICY='QA-PAYABLE-JOURNAL-v1';
private const SUPPLIER_EFFECTS=['supplier_payable.recognised','allocation.confirmed','allocation.reversed','journal.reversal','journal.replacement'];
public function __construct(private ConnectionInterface $db,private array $scope,private int $actor,private string $person,
private \Closure $authorize,private \Closure $readAuthorize,private \Closure $source,private ?\Closure $checkpoint=null,private ?\Closure $additionalSource=null,private ?\Closure $additionalPeriod=null) {}
private function q(string $table){return $this->db->table('gl_'.$table)->where($this->scope);}
private function hash(array $data):string{return hash('sha256',Payables::canonical($data));}
private function step(string $name):void{if($this->checkpoint!==null)($this->checkpoint)($name);}
private function id(mixed $id):int{if(is_bool($id)||filter_var($id,FILTER_VALIDATE_INT,['options'=>['min_range'=>1]])===false)throw new \InvalidArgumentException('Identificador inválido.');return (int)$id;}
private function exact(array $data,array $keys):void{if(array_diff(array_keys($data),$keys)||array_diff($keys,array_keys($data)))throw new \InvalidArgumentException('Campos incompatíveis.');}
private function reason(mixed $value):string{if(!is_string($value)||mb_strlen(trim($value))<3||mb_strlen($value)>2000)throw new \InvalidArgumentException('Fundamento individual obrigatório.');return trim($value);}
private function latest(string $table,string $column,string $key):object{return $this->q($table)->where($column,$key)->orderByDesc('version')->first()??throw new \DomainException('Configuração não disponível neste âmbito.');}
private function effect(array $source):string{return $this->hash([$this->scope,$source['effect_type'],$source['origin_key'],$source['source_id']??$source['obligation_id']]);}
private function capture(int $obligation,string $date,string $periodKey,string $ruleKey,string $kind='supplier_payable.recognised'):array
{
if(!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D',$date,$m)||!checkdate((int)$m[2],(int)$m[3],(int)$m[1]))throw new \InvalidArgumentException('Data de lançamento explícita inválida.');
$sales=in_array($kind,['sales.invoice','receivable.allocated','receivable.reversed'],true);$cost=in_array($kind,['workshop.direct_cost.validated','workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true);
$industrial=in_array($kind,['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'],true);
$stock=in_array($kind,['stock.receipt','stock.issue','stock.return'],true);
$s=$sales||$cost||$industrial||$stock?($this->additionalSource!==null?($this->additionalSource)($kind,$obligation):throw new \DomainException('Origem adicional não composta neste host.')):($kind==='supplier_payable.recognised'?($this->source)($obligation):$this->cycleSource($kind,$obligation));
$amount=$s['amount_minor']??$s['principal_minor']??null;
if(($s['effect_type']??'')!==$kind||!($s['compatible']??false)||($s['currency']??'')!=='MZN'||!is_int($amount)||$amount<=0||(!$sales&&($s['tax_minor']??null)!==0)||($s['history_imported']??true))throw new \DomainException('Fonte incompatível com o ensaio contabilístico.');
$period=$this->latest('periods','period_key',$periodKey);$rule=$this->latest('rules','rule_key',$ruleKey);
if(($cost||$industrial)&&isset($s['original_journal_on'])&&$date<$s['original_journal_on'])throw new \DomainException('Correcção não pode anteceder o original.');
if(($cost||$industrial||$stock)&&($s['permitted_period_key']??null)!==$periodKey)throw new \DomainException('Período fora da composição seleccionada.');
if($stock){if(!$this->db->getSchemaBuilder()->hasTable('gl_stock_books')||($s['stock_movement_on']??'')!==$date)throw new \DomainException('Valorização não composta ou data física incompatível.');}
if($industrial&&($s['period_start']<$period->starts_on||$s['period_end']>$period->ends_on))throw new \DomainException('Período não contém o lote seleccionado.');
if($period->status!=='open'||$date<$period->starts_on||$date>$period->ends_on)throw new \DomainException('Período fechado, incompatível ou alterado.');
if($rule->policy!==($stock?'QA-STOCK-VALUATION-v1':($sales?'QA-SALES-JOURNAL-v1':($cost?'QA-DIRECT-COST-JOURNAL-v1':($industrial?'QA-INDUSTRIAL-JOURNAL-v1':self::POLICY))))||$rule->effect_type!==$s['effect_type']||$rule->currency!=='MZN'||$date<$rule->valid_from||$date>$rule->valid_to)throw new \DomainException('Regra contabilística ausente, incompatível ou fora de vigência.');
if($sales)return $this->captureSales($s,$date,$period,$rule,$amount);
$accounts=$this->q('accounts')->whereIn('id',[$rule->debit_account_id,$rule->credit_account_id])->get()->keyBy('id');
$debit=$accounts->get($rule->debit_account_id);$credit=$accounts->get($rule->credit_account_id);
$codes=match($kind){'stock.receipt'=>['QA-STOCK','QA-COMPENSACAO-STOCK'],'stock.issue'=>['QA-CONSUMO-STOCK','QA-STOCK'],'stock.return'=>['QA-STOCK','QA-CONSUMO-STOCK'],'industry.cost.validated','industry.cost.replacement'=>['QA-CUSTO-INDUSTRIAL','QA-COMPENSACAO-INDUSTRIAL'],'industry.cost.reversal'=>['QA-COMPENSACAO-INDUSTRIAL','QA-CUSTO-INDUSTRIAL'],'supplier_payable.recognised','journal.replacement'=>['QA-CONTRAPARTIDA','QA-FORNECEDOR'],'allocation.confirmed'=>['QA-FORNECEDOR','QA-LIQUIDACAO'],'allocation.reversed'=>['QA-LIQUIDACAO','QA-FORNECEDOR'],'journal.reversal'=>['QA-FORNECEDOR','QA-CONTRAPARTIDA'],'workshop.direct_cost.validated','workshop.direct_cost.replacement'=>['QA-CUSTO-DIRECTO','QA-COMPENSACAO-ENSAIO'],'workshop.direct_cost.reversal'=>['QA-COMPENSACAO-ENSAIO','QA-CUSTO-DIRECTO'],default=>throw new \DomainException('Tipo de efeito não autorizado.')};
if(!$debit||!$credit||!$debit->is_movement||!$credit->is_movement||$debit->currency!=='MZN'||$credit->currency!=='MZN'||$debit->id===$credit->id||[$debit->code,$credit->code]!==$codes)throw new \DomainException('Contas artificiais de movimento incompatíveis.');
return ['contract'=>$stock?'COMMON-LEDGER-STOCK-VALUATION-SIM-v1':($industrial?($kind==='industry.cost.validated'?'COMMON-LEDGER-INDUSTRIAL-SIM-v1':'COMMON-LEDGER-INDUSTRIAL-CORRECTION-SIM-v1'):($cost?($kind==='workshop.direct_cost.validated'?'COMMON-LEDGER-DIRECT-COST-SIM-v1':'COMMON-LEDGER-DIRECT-COST-CORRECTION-SIM-v1'):($kind==='supplier_payable.recognised'?'COMMON-LEDGER-PAYABLE-SIM-v1':'COMMON-LEDGER-COMPLETE-CYCLE-SIM-v1'))),'source'=>$s,'journal_on'=>$date,'currency'=>'MZN','period'=>(array)$period,'rule'=>(array)$rule,
'lines'=>[['account_id'=>$debit->id,'account_code'=>$debit->code,'debit_minor'=>$amount,'credit_minor'=>0],['account_id'=>$credit->id,'account_code'=>$credit->code,'debit_minor'=>0,'credit_minor'=>$amount]],
'impact'=>['debit_minor'=>$amount,'credit_minor'=>$amount,'financial_balance_changed'=>false,'payment_created'=>false]];
}
private function proposal(int $id):object{return $this->q('proposals')->where('id',$id)->first()??throw new \DomainException('Proposta indisponível neste contexto.');}
private function current(object $p,string $revision):array
{
if(!hash_equals($p->revision,$revision))throw new \DomainException('Versão da conferência desactualizada.');
if($this->q('proposals')->where('effect_key',$p->effect_key)->max('capture_version')!=$p->capture_version)throw new \DomainException('Há nova captura; confira a versão actual.');
$old=json_decode($p->snapshot,true,128,JSON_THROW_ON_ERROR);
$kind=$old['source']['effect_type'];$now=$this->capture((int)($old['source']['source_id']??$p->obligation_id),$old['journal_on'],$old['period']['period_key'],$old['rule']['rule_key'],$kind);
if(!hash_equals($p->revision,$this->hash($now)))throw new \DomainException('Fonte, regra ou período mudaram; prepare nova conferência.');
return $now;
}
private function event(string $kind,int $id,string $reason,array $before,array $after):void
{
$this->db->table('gl_events')->insert($this->scope+['actor_id'=>$this->actor,'person'=>$this->person,'kind'=>$kind,'entity_id'=>$id,'reason'=>$reason,'previous'=>Payables::canonical($before),'subsequent'=>Payables::canonical($after),'created_at'=>date('Y-m-d H:i:s')]);$this->step('after_event');
}
public function command(string $action,array $data,string $key):array
{
if(in_array($action,['effect-prepare','correct-prepare','correct-validate','correct-post','period-prepare','period-validate','period-close'],true))return $this->cycleCommand($action,$data,$key);
if(!in_array($action,['prepare','validate','post'],true)||!preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di',$key))throw new \InvalidArgumentException('Acção ou chave de repetição inválida.');
$keys=['prepare'=>['obligation_id','journal_on','period_key','rule_key','reason'],'validate'=>['proposal_id','revision','decision','reason'],'post'=>['proposal_id','revision','reason']];
$this->exact($data,$keys[$action]);$reason=$this->reason($data['reason']);
return $this->db->transaction(function()use($action,$data,$key,$reason):array{
($this->authorize)($action); // Host serialises the company and revalidates identity and explicit rights before replay too.
$hash=$this->hash([$action,$data]);$old=$this->q('commands')->where(['actor_id'=>$this->actor,'command_key'=>$key])->first();
if($old){if(!hash_equals($old->request_hash,$hash))throw new \DomainException('Chave repetida com conteúdo diferente.');return json_decode($old->result,true,64,JSON_THROW_ON_ERROR)+['replayed'=>true];}
if(in_array($action,['validate','post'],true)){
$candidate=$this->proposal($this->id($data['proposal_id']));
if(in_array(json_decode($candidate->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement'],true))throw new \DomainException('Use a decisão/aplicação do par de correcção completo.');
}
if($action==='prepare')$result=$this->prepare($data,$reason);
elseif($action==='validate')$result=$this->review($data,$reason);
else $result=$this->post($data,$reason);
$this->db->table('gl_commands')->insert($this->scope+['actor_id'=>$this->actor,'command_key'=>$key,'request_hash'=>$hash,'result'=>Payables::canonical($result),'created_at'=>date('Y-m-d H:i:s')]);$this->step('after_receipt');
return $result+['replayed'=>false];
});
}
private function prepare(array $d,string $reason,string $kind='supplier_payable.recognised'):array
{
foreach(['journal_on','period_key','rule_key'] as $key)if(!is_string($d[$key])||strlen($d[$key])>100)throw new \InvalidArgumentException('Critério contabilístico inválido.');
$snapshot=$this->capture($this->id($d['obligation_id']),$d['journal_on'],$d['period_key'],$d['rule_key'],$kind);$effect=$this->effect($snapshot['source']);$revision=$this->hash($snapshot);
if($this->q('journals')->where('effect_key',$effect)->exists())throw new \DomainException('Efeito já lançado; consulte o comprovativo existente.');
$last=$this->q('proposals')->where('effect_key',$effect)->orderByDesc('capture_version')->first();
if($last&&$last->revision===$revision&&(int)$last->prepared_by===$this->actor&&$last->prepared_person===$this->person&&$last->reason===$reason)return ['state'=>$this->q('reviews')->where('proposal_id',$last->id)->value('decision')??'prepared','proposal_id'=>$last->id,'revision'=>$last->revision];
$id=$this->db->table('gl_proposals')->insertGetId($this->scope+['effect_key'=>$effect,'obligation_id'=>$snapshot['source']['obligation_id'],'capture_version'=>$last?(int)$last->capture_version+1:1,'revision'=>$revision,'snapshot'=>Payables::canonical($snapshot),'prepared_by'=>$this->actor,'prepared_person'=>$this->person,'reason'=>$reason,'created_at'=>date('Y-m-d H:i:s')]);
$this->event('journal.prepared',$id,$reason,$last?['proposal_id'=>$last->id,'revision'=>$last->revision]:[],['proposal_id'=>$id,'revision'=>$revision,'snapshot'=>$snapshot]);
return ['state'=>'prepared','proposal_id'=>$id,'revision'=>$revision];
}
private function review(array $d,string $reason):array
{
if(!is_string($d['revision'])||!in_array($d['decision'],['approved','rejected'],true))throw new \InvalidArgumentException('Decisão/revisão explícita inválida.');
$p=$this->proposal($this->id($d['proposal_id']));
if(in_array(json_decode($p->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement'],true))($this->authorize)('correct_validate');
if((int)$p->prepared_by===$this->actor||$p->prepared_person===$this->person)throw new \DomainException('Revisão exige outra identidade e pessoa.');
$this->current($p,$d['revision']);$old=$this->q('reviews')->where('proposal_id',$p->id)->first();
if($old){if($old->decision!==$d['decision']||$old->reason!==$reason)throw new \DomainException('Decisão já registada; não pode ser alterada.');return ['state'=>$old->decision,'proposal_id'=>$p->id,'review_id'=>$old->id];}
$id=$this->db->table('gl_reviews')->insertGetId($this->scope+['proposal_id'=>$p->id,'revision'=>$p->revision,'decision'=>$d['decision'],'reviewed_by'=>$this->actor,'reviewed_person'=>$this->person,'reason'=>$reason,'created_at'=>date('Y-m-d H:i:s')]);
$this->event('journal.reviewed',$id,$reason,['proposal_id'=>$p->id,'state'=>'prepared'],['proposal_id'=>$p->id,'decision'=>$d['decision'],'revision'=>$p->revision]);
return ['state'=>$d['decision'],'proposal_id'=>$p->id,'review_id'=>$id];
}
private function post(array $d,string $reason):array
{
if(!is_string($d['revision']))throw new \InvalidArgumentException('Revisão inválida.');$p=$this->proposal($this->id($d['proposal_id']));
if(in_array(json_decode($p->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement'],true))($this->authorize)('correct_post');
if((int)$p->prepared_by!==$this->actor||$p->prepared_person!==$this->person)throw new \DomainException('Só o preparador autorizado aplica esta versão.');
$existing=$this->q('journals')->where('effect_key',$p->effect_key)->first();
if($existing){if((int)$existing->proposal_id!==(int)$p->id||!hash_equals($p->revision,$d['revision'])||$existing->reason!==$reason)throw new \DomainException('Efeito já lançado com conteúdo diferente.');return ['state'=>'posted','proposal_id'=>$p->id,'journal_id'=>$existing->id];}
$snapshot=$this->current($p,$d['revision']);$review=$this->q('reviews')->where('proposal_id',$p->id)->first();
if(!$review||$review->decision!=='approved'||$review->revision!==$p->revision||(int)$review->reviewed_by===(int)$p->prepared_by||$review->reviewed_person===$p->prepared_person)throw new \DomainException('Falta revisão independente desta versão exacta.');
$debit=array_sum(array_column($snapshot['lines'],'debit_minor'));$credit=array_sum(array_column($snapshot['lines'],'credit_minor'));
if($debit!==$credit||$debit!==($snapshot['source']['amount_minor']??$snapshot['source']['principal_minor'])||$debit<=0)throw new \DomainException('Lançamento desequilibrado ou montante incompatível.');
$id=$this->db->table('gl_journals')->insertGetId($this->scope+['effect_key'=>$p->effect_key,'obligation_id'=>$p->obligation_id,'proposal_id'=>$p->id,'review_id'=>$review->id,'source_revision'=>$snapshot['source']['recognition_revision'],'journal_on'=>$snapshot['journal_on'],'currency'=>'MZN','debit_minor'=>$debit,'credit_minor'=>$credit,'snapshot'=>Payables::canonical($snapshot),'posted_by'=>$this->actor,'posted_person'=>$this->person,'reason'=>$reason,'created_at'=>date('Y-m-d H:i:s')]);$this->step('after_header');
foreach($snapshot['lines'] as $n=>$line)$this->db->table('gl_lines')->insert($this->scope+['journal_id'=>$id,'line_no'=>$n+1,'account_id'=>$line['account_id'],'debit_minor'=>$line['debit_minor'],'credit_minor'=>$line['credit_minor']]);$this->step('after_lines');
$this->db->select('SELECT gl_assert_balanced(?)',[$id]);
$this->event('journal.posted',$id,$reason,['proposal_id'=>$p->id,'review_id'=>$review->id,'state'=>'approved'],['journal_id'=>$id,'snapshot'=>$snapshot]);
return ['state'=>'posted','proposal_id'=>$p->id,'journal_id'=>$id];
}
/** Historical receipt only: never replay a command or fetch another actor's receipt. */
public function receipt(string $key):?array
{
if(!preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di',$key))throw new \InvalidArgumentException('Chave de pedido inválida.');
return $this->db->transaction(function()use($key){($this->readAuthorize)();$r=$this->q('commands')->where('actor_id',$this->actor)->where('command_key',$key)->first();return $r?['command_key'=>$r->command_key,'recorded_at'=>$r->created_at,'request_hash'=>$r->request_hash,'result'=>json_decode($r->result,true,128,JSON_THROW_ON_ERROR),'historical_receipt_only'=>true,'operation_executed'=>false]:null;});
}
public function listing(array $options=[],bool $withAccounts=true):array
{
if(array_diff(array_keys($options),['page','period_key','account_id','obligation_id','sales_invoice_id','cost_order_id','industrial_lot_id']))throw new \InvalidArgumentException('Filtro desconhecido.');$page=$this->id($options['page']??1);if($page>100000)throw new \InvalidArgumentException('Página inválida.');
return $this->db->transaction(function()use($options,$page,$withAccounts):array{
($this->readAuthorize)();$journals=$this->q('journals');$focus=null;$salesInvoice=null;$costOrder=null;$industrialLot=null;
if(isset($options['industrial_lot_id'])){$industrialLot=$this->id($options['industrial_lot_id']);if($this->additionalSource===null||!$this->db->getSchemaBuilder()->hasTable('gl_industrial_sources')||!$this->db->table('gl_industrial_sources')->where($this->scope)->where('lot_id',$industrialLot)->exists())throw new \DomainException('Lote fora do âmbito composto.');$journals->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'])->where('snapshot->source->industrial_lot_id',$industrialLot);}
if(isset($options['cost_order_id'])){$costOrder=$this->id($options['cost_order_id']);if($this->additionalSource===null||!$this->db->getSchemaBuilder()->hasTable('gl_cost_sources')||!$this->db->table('gl_cost_sources')->where($this->scope)->where('order_id',$costOrder)->exists())throw new \DomainException('Custo fora do âmbito composto.');$journals->where('snapshot->source->cost_order_id',$costOrder);}
if(isset($options['sales_invoice_id'])){$salesInvoice=$this->id($options['sales_invoice_id']);if($this->additionalSource===null||!$this->db->table('si_invoices')->where($this->scope)->where('id',$salesInvoice)->exists())throw new \DomainException('Commercial source unavailable in this scope.');$journals->where('snapshot->source->invoice_id',$salesInvoice);}
if(isset($options['obligation_id'])){$focus=$this->id($options['obligation_id']);if(!$this->db->table('pa_obligations')->where($this->scope)->where('id',$focus)->exists())throw new \DomainException('Obrigação indisponível neste contexto.');$journals->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS);}
if(isset($options['period_key'])){if(!is_string($options['period_key'])||strlen($options['period_key'])>80)throw new \InvalidArgumentException('Período inválido.');$this->latest('periods','period_key',$options['period_key']);$journals->whereRaw("snapshot->'period'->>'period_key' = ?",[$options['period_key']]);}
$account=null;if(isset($options['account_id'])){$account=$this->q('accounts')->where('id',$this->id($options['account_id']))->first()??throw new \DomainException('Conta indisponível neste contexto.');$journals->whereIn('id',$this->q('lines')->where('account_id',$account->id)->select('journal_id'));}
$pager=(clone $journals)->orderByDesc('id')->paginate(25,['*'],'page',$page);$ids=array_map(fn($j)=>$j->id,$pager->items());
$lines=$this->q('lines')->whereIn('journal_id',$ids)->orderBy('line_no')->get();
$totals=$this->q('lines')->whereIn('journal_id',(clone $journals)->select('id'));if($account)$totals->where('account_id',$account->id);
$sum=$totals->selectRaw('coalesce(sum(debit_minor),0) AS debit_minor,coalesce(sum(credit_minor),0) AS credit_minor')->first();
$proposalQuery=$this->q('proposals');if($industrialLot)$proposalQuery->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement']);if($costOrder)$proposalQuery->where('snapshot->source->cost_order_id',$costOrder);if($salesInvoice)$proposalQuery->where('snapshot->source->invoice_id',$salesInvoice);if($focus)$proposalQuery->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS);$proposals=$proposalQuery->orderByDesc('id')->paginate(25,['*'],'page',$page);$proposalIds=array_map(fn($p)=>$p->id,$proposals->items());$reviews=$this->q('reviews')->whereIn('proposal_id',$proposalIds)->get();
$posted=$this->q('journals')->whereIn('proposal_id',$proposalIds)->pluck('id','proposal_id');foreach($proposals->items() as $p)$p->journal_id=$posted->get($p->id);
$eventPager=$this->q('events')->when($focus||$salesInvoice||$costOrder||$industrialLot,fn($q)=>$q->where(function($events)use($focus,$salesInvoice,$costOrder,$industrialLot){
$events->where(fn($e)=>$e->where('kind','journal.prepared')->whereIn('entity_id',$this->q('proposals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement']))->select('id')))
->orWhere(fn($e)=>$e->where('kind','journal.reviewed')->whereIn('entity_id',$this->q('reviews')->whereIn('proposal_id',$this->q('proposals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement']))->select('id'))->select('id')))
->orWhere(fn($e)=>$e->where('kind','journal.posted')->whereIn('entity_id',$this->q('journals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement']))->select('id')));
}))->orderByDesc('id')->paginate(25,['*'],'page',$page);
return ['synthetic_only'=>true,'journals'=>$pager->items(),'lines'=>$lines->all(),'proposals'=>$proposals->items(),'reviews'=>$reviews->all(),'accounts'=>$withAccounts?$this->q('accounts')->orderBy('id')->get()->all():[],
'events'=>$eventPager->items(),'totals'=>['debit_minor'=>(int)$sum->debit_minor,'credit_minor'=>(int)$sum->credit_minor,'net_minor'=>(int)$sum->debit_minor-(int)$sum->credit_minor],
'pagination'=>['page'=>$page,'total'=>$pager->total(),'last_page'=>$pager->lastPage()],'evidence_pagination'=>['proposals'=>['page'=>$page,'total'=>$proposals->total(),'last_page'=>$proposals->lastPage()],'events'=>['page'=>$page,'total'=>$eventPager->total(),'last_page'=>$eventPager->lastPage()]],'query'=>$options];
});
}
}
@@ -0,0 +1,9 @@
<?php
namespace Tempo\Finance\GeneralLedger;
final class ReportsWorkspace
{
public static function render(array $data,array $settings):string
{
ob_start();try{require __DIR__.'/../ui/reports.php';return ob_get_clean();}catch(\Throwable $e){ob_end_clean();throw $e;}
}
}
@@ -0,0 +1,26 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
final class SalesSchema
{
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
if(!str_starts_with($db->getDatabaseName(),'payable_sgf_'))throw new \LogicException('Sales host composition required');
if($db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists()||$db->getSchemaBuilder()->hasTable('gl_sales_capability'))throw new \LogicException('Fresh empty sales ledger required; no upgrade/reset');
$db->transaction(function()use($db){
$db->statement('ALTER TABLE gl_rules ADD COLUMN tax_account_id bigint NULL');
$db->statement('ALTER TABLE gl_rules ADD FOREIGN KEY(tax_account_id,tenant_id,company_id,unit_id) REFERENCES gl_accounts(id,tenant_id,company_id,unit_id)');
$db->statement('ALTER TABLE gl_lines DROP CONSTRAINT gl_lines_line_no_check');$db->statement('ALTER TABLE gl_lines ADD CHECK(line_no IN(1,2,3))');
$definition=$db->selectOne("SELECT pg_get_functiondef('gl_assert_balanced(bigint)'::regprocedure) d")->d;
if(!str_contains($definition,'n<>2'))throw new \LogicException('Unexpected balance contract');
$db->unprepared(str_replace('n<>2',"n<>jsonb_array_length(j.snapshot->'lines') OR n<>(CASE WHEN j.snapshot->'source'->>'effect_type'='sales.invoice' THEN 3 ELSE 2 END)",$definition));
$db->unprepared(<<<'SQL'
CREATE FUNCTION gl_sales_line_balance() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM gl_assert_balanced(NEW.journal_id); RETURN NEW; END $$;
CREATE CONSTRAINT TRIGGER gl_sales_line_balance AFTER INSERT ON gl_lines DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION gl_sales_line_balance();
SQL);
$db->statement('CREATE TABLE gl_sales_capability(id int PRIMARY KEY CHECK(id=1),contract text NOT NULL)');$db->table('gl_sales_capability')->insert(['id'=>1,'contract'=>'COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1']);
$db->unprepared('CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON gl_sales_capability FOR EACH ROW EXECUTE FUNCTION gl_append_only()');
});
}
}
@@ -0,0 +1,19 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
/** Host supplies current identity/rights and the SAME company lock as physical writers. */
final class StockComposition
{
public readonly StockValuation $stock;
public function __construct(private ConnectionInterface $db,private array $scope,private int $actor,private string $person,private \Closure $authorize,private \Closure $readAuthorize)
{$this->stock=new StockValuation($db,$scope,$actor,$person,$authorize,$readAuthorize);}
public function engine(?\Closure $fault=null):PayableJournal
{
LedgerSchema::assertSynthetic($this->db);
if(!$this->db->getSchemaBuilder()->hasTable('gl_stock_books'))throw new \DomainException('Composição de stock não instalada.');
return new PayableJournal($this->db,$this->scope,$this->actor,$this->person,$this->authorize,$this->readAuthorize,
fn()=>throw new \DomainException('Reconhecimento de fornecedor não composto neste livro exclusivo.'),$fault,
fn($type,$id)=>$this->stock->source($type,$id),fn($period)=>$this->stock->period($period));
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
/** Explicitly installed only in a NEW QA physical book, never at application boot. */
final class StockSchema
{
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
if(!str_starts_with($db->getDatabaseName(),'compras_receipts_test_')||!$db->getSchemaBuilder()->hasTable('stock_movements')||$db->getSchemaBuilder()->hasTable('gl_stock_books')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty physical/valuation composition required');
$db->transaction(function()use($db){
$scope='tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL';
$db->statement("CREATE TABLE gl_stock_books(id bigserial PRIMARY KEY,$scope,stock_item_id bigint NOT NULL REFERENCES stock_items(id),warehouse_id bigint NOT NULL REFERENCES warehouses(id),period_key text NOT NULL,unit_code text NOT NULL CHECK(unit_code='UN'),created_by bigint NOT NULL,reason text NOT NULL CHECK(length(reason)>=10),created_at timestamp NOT NULL,UNIQUE(tenant_id,stock_item_id,warehouse_id),UNIQUE(id,tenant_id,company_id,unit_id))");
$db->statement("CREATE TABLE gl_stock_rates(id bigserial PRIMARY KEY,$scope,book_id bigint NOT NULL,version int NOT NULL CHECK(version>0),unit_minor bigint NOT NULL CHECK(unit_minor>0),currency text NOT NULL CHECK(currency='MZN'),prepared_by bigint NOT NULL,person text NOT NULL,reason text NOT NULL CHECK(length(reason)>=10),created_at timestamp NOT NULL,FOREIGN KEY(book_id,tenant_id,company_id,unit_id) REFERENCES gl_stock_books(id,tenant_id,company_id,unit_id),UNIQUE(book_id,version),UNIQUE(id,tenant_id,company_id,unit_id))");
$db->statement("CREATE TABLE gl_stock_rate_reviews(id bigserial PRIMARY KEY,$scope,rate_id bigint NOT NULL UNIQUE,decision text NOT NULL CHECK(decision IN('approved','rejected')),reviewed_by bigint NOT NULL,person text NOT NULL,reason text NOT NULL CHECK(length(reason)>=10),created_at timestamp NOT NULL,FOREIGN KEY(rate_id,tenant_id,company_id,unit_id) REFERENCES gl_stock_rates(id,tenant_id,company_id,unit_id))");
$db->statement("CREATE TABLE gl_stock_commands(id bigserial PRIMARY KEY,$scope,actor_id bigint NOT NULL,command_key uuid NOT NULL,request_hash char(64) NOT NULL,result jsonb NOT NULL,created_at timestamp NOT NULL,UNIQUE(tenant_id,company_id,unit_id,actor_id,command_key))");
foreach(['gl_stock_books','gl_stock_rates','gl_stock_rate_reviews','gl_stock_commands']as$t)$db->unprepared("CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON $t FOR EACH ROW EXECUTE FUNCTION gl_append_only()");
$db->unprepared(<<<'SQL'
CREATE FUNCTION gl_stock_scope() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF TG_TABLE_NAME='gl_stock_books' THEN
IF NOT EXISTS(SELECT 1 FROM stock_items i JOIN warehouses w ON w.tenant_id=i.tenant_id WHERE i.id=NEW.stock_item_id AND w.id=NEW.warehouse_id AND i.tenant_id=NEW.tenant_id AND i.unit=NEW.unit_code) THEN RAISE EXCEPTION 'GL_STOCK_SCOPE'; END IF;
ELSE
IF NOT EXISTS(SELECT 1 FROM gl_stock_rates r WHERE r.id=NEW.rate_id AND (r.tenant_id,r.company_id,r.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id) AND r.prepared_by<>NEW.reviewed_by AND r.person<>NEW.person) THEN RAISE EXCEPTION 'GL_STOCK_DISTINCT_REVIEW'; END IF;
END IF; RETURN NEW; END $$;
CREATE TRIGGER gl_stock_book_scope BEFORE INSERT ON gl_stock_books FOR EACH ROW EXECUTE FUNCTION gl_stock_scope();
CREATE TRIGGER gl_stock_review_scope BEFORE INSERT ON gl_stock_rate_reviews FOR EACH ROW EXECUTE FUNCTION gl_stock_scope();
CREATE UNIQUE INDEX gl_stock_movement_once ON gl_journals(tenant_id,(snapshot->'source'->>'stock_movement_id')) WHERE snapshot->'source'->>'effect_type' IN('stock.receipt','stock.issue','stock.return');
CREATE FUNCTION gl_stock_journal_assert() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE s jsonb; expected text[]; k text; BEGIN
s:=NEW.snapshot->'source'; k:=s->>'effect_type';
IF k NOT LIKE 'stock.%' THEN RETURN NEW; END IF;
expected:=CASE k WHEN 'stock.receipt' THEN ARRAY['QA-STOCK','QA-COMPENSACAO-STOCK'] WHEN 'stock.issue' THEN ARRAY['QA-CONSUMO-STOCK','QA-STOCK'] WHEN 'stock.return' THEN ARRAY['QA-STOCK','QA-CONSUMO-STOCK'] ELSE NULL END;
IF expected IS NULL OR NEW.snapshot->'rule'->>'policy'<>'QA-STOCK-VALUATION-v1' OR NEW.snapshot->'rule'->>'effect_type'<>k
OR NEW.snapshot->'lines'->0->>'account_code'<>expected[1] OR NEW.snapshot->'lines'->1->>'account_code'<>expected[2]
OR NOT EXISTS(SELECT 1 FROM gl_stock_books b JOIN stock_movements m ON m.tenant_id=b.tenant_id AND m.stock_item_id=b.stock_item_id AND m.warehouse_id=b.warehouse_id JOIN gl_stock_rates r ON r.book_id=b.id JOIN gl_stock_rate_reviews v ON v.rate_id=r.id
WHERE b.id=(s->>'stock_book_id')::bigint AND m.id=(s->>'stock_movement_id')::bigint AND m.id=(s->>'source_id')::bigint AND NEW.obligation_id=m.id
AND (b.tenant_id,b.company_id,b.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id) AND b.period_key=NEW.snapshot->'period'->>'period_key'
AND r.id=(s->>'stock_rate_id')::bigint AND r.id=(SELECT max(rr.id) FROM gl_stock_rates rr WHERE rr.book_id=b.id)
AND v.decision='approved' AND v.reviewed_by<>r.prepared_by AND v.person<>r.person
AND abs(m.quantity_milli)*r.unit_minor=NEW.debit_minor::numeric*1000 AND m.quantity_milli=(s->>'stock_quantity_milli')::numeric
AND m.moved_at::date=NEW.journal_on AND m.movement_type=CASE k WHEN 'stock.receipt' THEN 'receipt_in' WHEN 'stock.issue' THEN 'issue' WHEN 'stock.return' THEN 'workshop_return_in' END
AND NOT EXISTS(SELECT 1 FROM stock_movements pm WHERE pm.tenant_id=b.tenant_id AND pm.stock_item_id=b.stock_item_id AND pm.warehouse_id=b.warehouse_id AND pm.id<m.id AND NOT EXISTS(SELECT 1 FROM gl_journals pj WHERE (pj.tenant_id,pj.company_id,pj.unit_id)=(b.tenant_id,b.company_id,b.unit_id) AND (pj.snapshot->'source'->>'stock_movement_id')::bigint=pm.id AND (pj.snapshot->'source'->>'stock_book_id')::bigint=b.id)))
THEN RAISE EXCEPTION 'GL_STOCK_JOURNAL_SOURCE_OR_LINES' USING ERRCODE='23514'; END IF;
RETURN NEW; END $$;
CREATE CONSTRAINT TRIGGER gl_stock_journal AFTER INSERT ON gl_journals DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION gl_stock_journal_assert();
SQL);
});
}
}
@@ -0,0 +1,166 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Brick\Math\BigRational;
use Illuminate\Database\ConnectionInterface;
use Tempo\Finance\Payables\Payables;
/** Selected QA physical facts, never a second physical writer or inventory balance. */
final class StockValuation
{
public const TYPES=['receipt_in'=>'stock.receipt','issue'=>'stock.issue','workshop_return_in'=>'stock.return'];
public function __construct(private ConnectionInterface $db,private array $scope,private int $actor,private string $person,private \Closure $authorize,private \Closure $readAuthorize){}
private function q(string $name){return $this->db->table('gl_stock_'.$name)->where($this->scope);}
private function hash(mixed $v):string{return hash('sha256',Payables::canonical($v));}
private function ready():void{
LedgerSchema::assertSynthetic($this->db);($this->readAuthorize)();
if(!$this->db->getSchemaBuilder()->hasTable('gl_stock_books'))throw new \DomainException('Valorização de stock não composta.');
if($this->db->table('gl_journals')->where($this->scope)->whereIn('snapshot->source->effect_type',['workshop.direct_cost.validated','industry.cost.validated'])->exists())throw new \DomainException('Livro exclusivo: custos directos/industriais já reconhecidos; não duplicar material.');
}
private function book(int $id):object{return $this->q('books')->where('id',$id)->first()??throw new \DomainException('Livro de stock fora do contexto.');}
private function movements(object $book){return $this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id]);}
private function priorJournals(object $book,object $movement,object $rate):array
{
if($this->db->transactionLevel()===0)return $this->db->transaction(function()use($book,$movement,$rate){
$this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
return $this->priorJournals($book,$movement,$rate);
});
// No list of all movement IDs, and no transfer of prior full snapshots.
$prior=$this->movements($book)->where('id','<',$movement->id);
$count=(clone $prior)->count();if($count===0)return [];
$qualified=[];foreach($this->scope as$key=>$value)$qualified['j.'.$key]=$value;
$valued=$this->db->table('gl_journals as j')->join('stock_movements as pm',function($join)use($book,$movement){
$join->on('pm.tenant_id','=','j.tenant_id')->where('pm.stock_item_id',$book->stock_item_id)->where('pm.warehouse_id',$book->warehouse_id)
->where('pm.id','<',$movement->id)->whereRaw(<<<'SQL'
pm.id=CASE WHEN (j.snapshot->'source'->>'stock_movement_id') ~ '^[1-9][0-9]{0,18}$'
AND (length(j.snapshot->'source'->>'stock_movement_id')<19 OR (j.snapshot->'source'->>'stock_movement_id')<='9223372036854775807')
THEN (j.snapshot->'source'->>'stock_movement_id')::bigint ELSE NULL END
SQL);
})->where($qualified)->where('j.snapshot->source->stock_book_id',$book->id);
if((clone $valued)->count()!==$count)throw new \DomainException('Contabilize primeiro os movimentos físicos anteriores deste livro; entrada, saída e devolução mantêm as suas origens.');
$metadata=(clone $valued)->select(['j.id','j.source_revision'])->selectRaw("j.snapshot->'source'->'stock_movement_id' movement_value,j.snapshot->'source'->'stock_rate_id' rate_value,j.snapshot->'source'->>'stock_fact_revision' fact_revision");
$seen=0;$first=[];$hash=hash_init('sha256');$cursor='stock_prefix_'.bin2hex(random_bytes(8));$query=$metadata->orderBy('j.id');
// A server cursor executes the prefix query once; PDO receives at most one batch.
$this->db->statement('DECLARE '.$cursor.' NO SCROLL CURSOR FOR '.$query->toSql(),$query->getBindings());$error=null;
try{while($seen<$count){
$rows=$this->db->select('FETCH FORWARD 500 FROM '.$cursor);
if($rows===[])throw new \DomainException('Dependências alteradas durante a leitura; confira novamente.');
foreach($rows as $row){
$rateValue=json_decode($row->rate_value??'null',true,2,JSON_THROW_ON_ERROR);$movementValue=json_decode($row->movement_value??'null',true,2,JSON_THROW_ON_ERROR);
if($rateValue!==$rate->id)throw new \DomainException('Origem anterior valorizada com outra taxa; correcção fora deste recorte.');
if(!preg_match('/^[a-f0-9]{64}$/D',$row->source_revision)||!preg_match('/^[a-f0-9]{64}$/D',$row->fact_revision??''))throw new \DomainException('Referência contabilística anterior incompleta.');
$ref=['journal_id'=>$row->id,'source_revision'=>$row->source_revision,'movement_id'=>$movementValue,'fact_revision'=>$row->fact_revision];
// Flat canonical frame: avoid allocating a recursive sorting closure per row.
$frame=['fact_revision'=>$row->fact_revision,'journal_id'=>$row->id,'movement_id'=>$movementValue,'source_revision'=>$row->source_revision];
hash_update($hash,json_encode($frame,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)."\n");if($seen<25)$first[]=$ref;$seen++;
}
unset($rows,$row);
}}catch(\Throwable $e){$error=$e;throw $e;}finally{try{$this->db->statement('CLOSE '.$cursor);}catch(\Throwable $close){if($error===null)throw $close;}}
if($seen!==$count)throw new \DomainException('Dependências alteradas durante a leitura; confira novamente.');
// Preserve existing small-book source fingerprints byte-for-byte.
if($count<=25)return $first;
return ['format'=>'STOCK-PRIOR-STREAM-v1','count'=>$count,'sha256'=>hash_final($hash),'first_references'=>$first,'omitted_references'=>$count-count($first)];
}
public function command(string $action,array $data,string $key):array
{
if(!in_array($action,['rate-prepare','rate-validate'],true)||!preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di',$key))throw new \InvalidArgumentException('Acção/chave inválida.');
$keys=$action==='rate-prepare'?['book_id','version','unit_minor','reason']:['rate_id','version','decision','reason'];$a=array_keys($data);sort($a);sort($keys);
if($a!==$keys||!is_string($data['reason'])||strlen(trim($data['reason']))<10||strlen($data['reason'])>4000)throw new \InvalidArgumentException('Campos/fundamento inválidos.');
return $this->db->transaction(function()use($action,$data,$key){
($this->authorize)($action==='rate-prepare'?'prepare':'validate');$this->ready();
$hash=$this->hash([$action,$data]);$old=$this->q('commands')->where(['actor_id'=>$this->actor,'command_key'=>$key])->first();
if($old){if($old->request_hash!==$hash)throw new \DomainException('Chave já usada com outro conteúdo.');return json_decode($old->result,true)+['replayed'=>true];}
if($action==='rate-prepare'){
if(!is_int($data['book_id'])||!is_int($data['version'])||!is_int($data['unit_minor'])||$data['unit_minor']<=0||$data['unit_minor']>100000000)throw new \InvalidArgumentException('Taxa/versão inválida.');
$b=$this->book($data['book_id']);$this->q('books')->where('id',$b->id)->lockForUpdate()->first();
if($this->db->table('gl_journals')->where($this->scope)->where('snapshot->source->stock_book_id',$b->id)->exists())throw new \DomainException('Correcção de taxa após lançamento fora deste recorte.');
$last=$this->q('rates')->where('book_id',$b->id)->max('version')??0;if((int)$last!==$data['version'])throw new \DomainException('Taxa alterada; confira a versão actual.');
$id=$this->db->table('gl_stock_rates')->insertGetId($this->scope+['book_id'=>$b->id,'version'=>$last+1,'unit_minor'=>$data['unit_minor'],'currency'=>'MZN','prepared_by'=>$this->actor,'person'=>$this->person,'reason'=>$data['reason'],'created_at'=>date('Y-m-d H:i:s')]);$out=['rate_id'=>$id,'version'=>$last+1];
}else{
if(!is_int($data['rate_id'])||!is_int($data['version'])||!in_array($data['decision'],['approved','rejected'],true))throw new \InvalidArgumentException('Decisão de taxa inválida.');
$rate=$this->q('rates')->where('id',$data['rate_id'])->first()??throw new \DomainException('Taxa fora do contexto.');
if($rate->version!==$data['version']||$this->q('rates')->where('book_id',$rate->book_id)->max('version')!==$rate->version)throw new \DomainException('Taxa desactualizada.');
if($rate->prepared_by===$this->actor||$rate->person===$this->person)throw new \DomainException('Revisão da taxa exige pessoa distinta.');
if($this->q('rate_reviews')->where('rate_id',$rate->id)->exists())throw new \DomainException('Taxa já decidida.');
$review=$this->db->table('gl_stock_rate_reviews')->insertGetId($this->scope+['rate_id'=>$rate->id,'decision'=>$data['decision'],'reviewed_by'=>$this->actor,'person'=>$this->person,'reason'=>$data['reason'],'created_at'=>date('Y-m-d H:i:s')]);$out=['rate_id'=>$rate->id,'review_id'=>$review,'decision'=>$data['decision']];
}
$this->db->table('gl_stock_commands')->insert($this->scope+['actor_id'=>$this->actor,'command_key'=>$key,'request_hash'=>$hash,'result'=>Payables::canonical($out),'created_at'=>date('Y-m-d H:i:s')]);return $out+['replayed'=>false];
});
}
public function source(string $kind,int $id):array
{
$this->ready();if(!in_array($kind,self::TYPES,true))throw new \DomainException('Tipo físico não suportado.');
$m=$this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$id])->first()??throw new \DomainException('Movimento físico fora do contexto.');
$b=$this->q('books')->where(['stock_item_id'=>$m->stock_item_id,'warehouse_id'=>$m->warehouse_id])->first()??throw new \DomainException('Artigo/armazém não composto neste contexto.');
if((self::TYPES[$m->movement_type]??null)!==$kind||(int)$m->quantity_milli===0||($kind==='stock.issue')!==((int)$m->quantity_milli<0))throw new \DomainException('Tipo/sinal físico incompatível.');
$rate=$this->q('rates')->where('book_id',$b->id)->orderByDesc('version')->first();$review=$rate?$this->q('rate_reviews')->where('rate_id',$rate->id)->first():null;
if(!$rate||!$review||$review->decision!=='approved'||$review->reviewed_by===$rate->prepared_by||$review->person===$rate->person)throw new \DomainException('Taxa actual completa e validada distintamente obrigatória.');
$item=$this->db->table('stock_items')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$m->stock_item_id])->first();
$warehouse=$this->db->table('warehouses')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$m->warehouse_id])->first();
if(!$item||!$warehouse||$item->unit!==$b->unit_code||$b->unit_code!=='UN')throw new \DomainException('Unidade/artigo/armazém incompatível.');
$snapshot=json_decode($m->snapshot,true,128,JSON_THROW_ON_ERROR);$dependencies=[];
if($kind==='stock.receipt'){
$line=$this->db->table('goods_receipt_items')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$m->goods_receipt_item_id])->first();
$receipt=$line?$this->db->table('goods_receipts')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$line->goods_receipt_id])->first():null;
if(!$line||!$receipt||$receipt->confirmed_at===null||$receipt->warehouse_id!==$m->warehouse_id||$receipt->received_by!==$m->moved_by||$line->stock_item_id!==$m->stock_item_id||(int)$line->accepted_milli!==(int)$m->quantity_milli||!preg_match('/^[a-f0-9]{64}$/D',$snapshot['inventory_source_hash']??''))throw new \DomainException('Recepção original incompatível.');
$dependencies=['line'=>(array)$line,'receipt'=>(array)$receipt];
}else{
$operation=$this->db->table('stock_operations')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$m->stock_operation_id])->first();
if(!$operation||$operation->stock_item_id!==$m->stock_item_id||$operation->warehouse_id!==$m->warehouse_id||(int)$operation->quantity_milli!==abs((int)$m->quantity_milli)||$operation->created_by!==$m->moved_by)throw new \DomainException('Operação física incompatível.');
$dependencies=['operation'=>(array)$operation];
if($kind==='stock.issue'){
if($operation->kind!=='issue'||!preg_match('/^[a-f0-9]{64}$/D',$snapshot['inventory_operation_hash']??''))throw new \DomainException('Saída original incompleta.');
}else{
$parentId=$snapshot['delivery_movement_id']??null;$parent=is_int($parentId)?$this->movements($b)->where('id',$parentId)->first():null;
$total=$this->movements($b)->where('movement_type','workshop_return_in')->where('snapshot->delivery_movement_id',$parentId)->sum('quantity_milli');
if(!$parent||$parent->movement_type!=='issue'||$operation->kind!=='workshop_return'||!($snapshot['received_in_warehouse']??false)||$total>abs((int)$parent->quantity_milli)||!preg_match('/^[a-f0-9]{64}$/D',$snapshot['inventory_return_hash']??''))throw new \DomainException('Devolução sem saída original/limite/recepção física.');
$declaration=$this->db->table('supply_records')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$snapshot['declaration_id']??0])->first();
$confirmation=$declaration?$this->db->table('supply_records')->where(['tenant_id'=>$this->scope['tenant_id'],'record_key'=>$declaration->record_key,'version'=>2,'state'=>'confirmed'])->first():null;
if(!$declaration||!$confirmation||$declaration->created_by===$confirmation->created_by||$confirmation->created_by!==$m->moved_by)throw new \DomainException('Devolução sem conferência distinta.');
$dependencies+=['parent'=>(array)$parent,'declaration'=>(array)$declaration,'confirmation'=>(array)$confirmation];
}
}
try{$amount=BigRational::of((string)abs((int)$m->quantity_milli))->multipliedBy($rate->unit_minor)->dividedBy(1000)->toBigInteger()->toInt();}catch(\Throwable $e){throw new \DomainException('Valor não representável em cêntimos exactos.',0,$e);}
if($amount<=0)throw new \DomainException('Valorização positiva obrigatória.');
$journalDependencies=$this->priorJournals($b,$m,$rate);
$evidence=['book'=>(array)$b,'movement'=>(array)$m,'item'=>(array)$item,'warehouse'=>(array)$warehouse,'rate'=>(array)$rate,'review'=>(array)$review,'dependencies'=>$dependencies,'prior_journals'=>$journalDependencies];
$book=$this->movements($b)->selectRaw('coalesce(max(id),0) revision,count(*) records,coalesce(sum(quantity_milli),0) quantity')->first();
$fact=$this->hash($evidence);
return ['effect_type'=>$kind,'source_id'=>$id,'obligation_id'=>$id,'origin_key'=>$this->hash(['physical_database'=>$this->db->getDatabaseName(),'tenant'=>$this->scope['tenant_id'],'movement'=>$id]),'recognition_revision'=>$this->hash([$evidence,(array)$book]),'stock_fact_revision'=>$fact,'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'stock_book_id'=>$b->id,'stock_movement_id'=>$id,'stock_movement_on'=>substr($m->moved_at,0,10),'stock_quantity_milli'=>(int)$m->quantity_milli,'stock_rate_id'=>$rate->id,'permitted_period_key'=>$b->period_key,'stock_evidence'=>$evidence,'physical_book'=>(array)$book];
}
public function period(array $period):array
{
if($this->db->transactionLevel()===0)return $this->db->transaction(function()use($period){
$this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
return $this->period($period);
});
$this->ready();$facts=0;$pending=0;$h=hash_init('sha256');
foreach($this->q('books')->where('period_key',$period['period_key'])->lazyById(100)as$b){
hash_update($h,Payables::canonical((array)$b));
foreach($this->q('rates')->where('book_id',$b->id)->lazyById(100)as$r){hash_update($h,Payables::canonical((array)$r));foreach($this->q('rate_reviews')->where('rate_id',$r->id)->lazyById(100)as$v)hash_update($h,Payables::canonical((array)$v));}
$this->movements($b)->whereDate('moved_at','>=',$period['starts_on'])->whereDate('moved_at','<=',$period['ends_on'])->chunkById(100,function($batch)use(&$facts,&$pending,$h){
// Only one projected status per physical fact; no prior full snapshots.
$posted=$this->db->table('gl_journals')->where($this->scope)->whereIn('snapshot->source->stock_movement_id',$batch->pluck('id')->all())
->selectRaw("DISTINCT ON (snapshot->'source'->>'stock_movement_id') snapshot->'source'->>'stock_movement_id' movement_id,snapshot->'source'->'stock_fact_revision' fact_revision")
->orderByRaw("snapshot->'source'->>'stock_movement_id',id")->get()->keyBy('movement_id');
foreach($batch as$m){
$facts++;hash_update($h,Payables::canonical((array)$m));
try{$s=$this->source(self::TYPES[$m->movement_type]??'',(int)$m->id);hash_update($h,$s['stock_fact_revision']);$j=$posted->get($m->id);if(!$j||json_decode($j->fact_revision??'null',true)!==$s['stock_fact_revision'])$pending++;}catch(\DomainException){$pending++;}
}
});
}
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
public function page(int $bookId,int $page=1,int $perPage=25):array
{
$this->ready();if($page<1||$page>100000||!in_array($perPage,[25,100],true))throw new \InvalidArgumentException('Página inválida.');$b=$this->book($bookId);$q=$this->movements($b);$count=(clone$q)->count();$rows=[];
foreach($q->orderBy('id')->offset(($page-1)*$perPage)->limit($perPage)->get()as$m){$row=['movement'=>(array)$m,'source'=>null,'pending'=>null];try{$row['source']=$this->source(self::TYPES[$m->movement_type]??'',(int)$m->id);}catch(\DomainException $e){$row['pending']=$e->getMessage();}$rows[]=$row;}
$rates=$this->q('rates')->where('book_id',$b->id)->orderByDesc('version')->limit(25)->get();$reviews=$this->q('rate_reviews')->whereIn('rate_id',$rates->pluck('id')->all())->get()->keyBy('rate_id');foreach($rates as$r)$r->review=$reviews->get($r->id);
return ['book'=>(array)$b,'rows'=>$rows,'pagination'=>['page'=>$page,'per_page'=>$perPage,'total'=>$count],'rates'=>$rates->all(),'physical_balance_milli'=>(int)$this->movements($b)->sum('quantity_milli'),'does_not_move_stock'=>true];
}
public function receipt(string $key):?array
{
$this->ready();if(!preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di',$key))throw new \InvalidArgumentException('Chave inválida.');
$r=$this->q('commands')->where(['actor_id'=>$this->actor,'command_key'=>$key])->first();return $r?['command_key'=>$r->command_key,'request_hash'=>$r->request_hash,'created_at'=>$r->created_at,'result'=>json_decode($r->result,true),'historical_receipt_only'=>true,'operation_executed'=>false]:null;
}
}
@@ -0,0 +1,7 @@
<?php
namespace Tempo\Finance\GeneralLedger;
final class StockWorkspace
{
public static function render(array $data,array $settings):string
{ob_start();try{require __DIR__.'/../ui/stock.php';return ob_get_clean();}catch(\Throwable $e){ob_end_clean();throw$e;}}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
$e=static fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8');
$money=static fn($v)=>($v<0?'-':'').number_format(intdiv(abs($v),100),0,',',' ').','.str_pad((string)(abs($v)%100),2,'0',STR_PAD_LEFT).' MZN';
$reason=static function(){echo '<label>Fundamento individual<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label>';};
$dates=static function(){echo '<label>Data explícita<input name="journal_on" type="date" value="2026-10-29" required></label><label>Período<input name="period_key" value="QA-2026-10" required></label>';};
$reports=class_exists(\Tempo\Finance\GeneralLedger\ReportsWorkspace::class)?'<p><a href="/general-ledger/trial-balance?from_on=2026-10-01&amp;to_on=2026-10-31">Balancete e razão por conta — Outubro de 2026 (QA)</a></p>':'';
$hidden=static function($name,$value)use($e){echo '<input type="hidden" name="'.$e($name).'" value="'.$e($value).'">';};
?><!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ciclo contabilístico sintético</title><style>body{font:17px system-ui;background:#f3f6f5;color:#142b28}main{max-width:1100px;margin:auto;padding:24px}header,section{background:white;padding:24px;border:1px solid #d5e0dc;border-radius:12px;margin:18px 0}article{border-top:1px solid #ddd;padding:16px 0}label{display:block;margin:12px 0}input,select,textarea,button{font:inherit;padding:10px;max-width:95%}textarea{display:block;width:90%}.notice{padding:16px;background:#fff2cc}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#125a4b}</style></head><body><main>
<header><a href="<?= $e($settings['return_url']) ?>">Voltar à aplicação</a><h1><?= $e($settings['host']) ?> — ciclo contabilístico QA</h1><p class="notice">Exclusivamente sintético: principal60, afectação20, reversão integral e correcção técnica sem alterar a dívida. Contas e período artificiais. Sem execução monetária, fiscalidade real ou reabertura.</p><button id="finance">Consultar/preparar liquidações financeiras</button><?php if(session('ledger_demo')): ?><p><a href="/qa/ledger-demo">Trocar função sintética</a></p><?php endif ?><p>Preparação, revisão independente e aplicação são decisões separadas. Os totais contabilísticos incluem os estornos; saldo financeiro não é soma bruta dos lançamentos.</p></header><p id="error" role="alert"></p>
<?= $reports ?><section><h2>1. Reconhecimento contabilístico da obrigação</h2><p>Obrigações financeiras reconhecidas desta página; preparar não volta a reconhecer a dívida.</p><?php if($data['permissions']['prepare']): ?><form data-action="prepare"><label>Obrigação<select name="obligation_id" required><option value="">Escolha uma obrigação</option><?php foreach($data['obligations'] as $o): ?><option value="<?= $o->id ?>">#<?= $o->id ?> · <?= $money((int)$o->principal_minor) ?></option><?php endforeach ?></select></label><?php $dates();$hidden('rule_key','QA-PAYABLE-JOURNAL');$reason(); ?><button>Capturar reconhecimento e impacto</button></form><?php endif ?></section>
<section><h2>2. Afectações e reversões financeiras confirmadas</h2><p>Um pagamento sem afectação confirmada não é origem deste lançamento. O principal da obrigação e o montante afectado permanecem distintos.</p><?php foreach(['allocations'=>['allocation.confirmed','QA-ALLOCATION'],'reversals'=>['allocation.reversed','QA-REVERSAL']] as $name=>[$type,$rule]): ?><?php foreach($data[$name] as $fact): ?><article><h3><?= $name==='allocations'?'Afectação':'Reversão' ?> #<?= $fact->id ?> · Obrigação #<?= $fact->obligation_id ?> · <?= $money((int)$fact->amount_minor) ?></h3><details><summary>Evidência financeira confirmada</summary><pre><?= $e(json_encode($fact,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details><?php if($data['permissions']['prepare']): ?><form data-action="effect-prepare"><?php $hidden('effect_type',$type);$hidden('source_id',$fact->id);$hidden('rule_key',$rule);$dates();$reason(); ?><button>Capturar este efeito — sem lançar</button></form><?php endif ?></article><?php endforeach; endforeach ?></section>
<section><h2>3. Propostas, impacto e revisão exacta</h2><?php foreach($data['book']['proposals'] as $p): $snapshot=json_decode($p->snapshot,true);$kind=$snapshot['source']['effect_type'];$correct=in_array($kind,['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement'],true);$review=null;foreach($data['book']['reviews'] as $r)if($r->proposal_id===$p->id)$review=$r; ?><article><h3>Proposta #<?= $p->id ?> · <?= $e($kind) ?> · Obrigação #<?= $p->obligation_id ?></h3><p>Impacto <?= $money($snapshot['impact']['debit_minor']) ?> débito/crédito · versão <?= $e($p->revision) ?> · autor #<?= $p->prepared_by ?></p><ul><?php foreach($snapshot['lines'] as $line): ?><li><?= $e($line['account_code']) ?> D <?= $money($line['debit_minor']) ?> / C <?= $money($line['credit_minor']) ?></li><?php endforeach ?></ul><details><summary>Fotografia original, regra e período</summary><pre><?= $e(json_encode($snapshot,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details><p><?= $e($p->reason) ?> · <?= $review?$e($review->decision):'Revisão pendente' ?><?= $p->journal_id?' · Aplicada no lançamento #'.$p->journal_id:'' ?></p>
<?php if(!$correct&&$data['permissions']['validate']&&!$review): ?><form data-action="validate"><?php $hidden('proposal_id',$p->id);$hidden('revision',$p->revision); ?><label>Decisão<select name="decision"><option value="">Escolha</option><option value="approved">Aprovar</option><option value="rejected">Recusar</option></select></label><?php $reason(); ?><button>Rever versão exacta</button></form><?php endif ?>
<?php if(!$correct&&$data['permissions']['post']&&$review&&$review->decision==='approved'&&!$p->journal_id): ?><form data-action="post"><?php $hidden('proposal_id',$p->id);$hidden('revision',$p->revision);$reason(); ?><button>Revalidar e aplicar uma vez</button></form><?php endif ?><?php if($correct): ?><p>Esta proposta só pode ser aplicada com o par de correcção abaixo.</p><?php endif ?></article><?php endforeach ?></section>
<section><h2>4. Correcção técnica por par indivisível</h2><p>O original mantém-se. Estorno60 e substituição60 são revistos e aplicados juntos; não alteram principal, imposto ou moeda.</p><?php if($data['permissions']['correct_prepare']): ?><form data-action="correct-prepare"><label>ID do lançamento original<input name="journal_id" type="number" min="1" required></label><?php $dates();$reason(); ?><button>Capturar os dois impactos</button></form><?php endif ?>
<?php foreach($data['corrections'] as $c): $result=null;foreach($data['correction_results'] as $r)if($r->correction_id===$c->id)$result=$r; ?><article><h3>Correcção #<?= $c->id ?> do original #<?= $c->original_journal_id ?></h3><p>Propostas #<?= $c->reverse_proposal_id ?> e #<?= $c->replacement_proposal_id ?> · revisão <?= $e($c->revision) ?></p><p><?= $e($c->reason) ?></p><details><summary>Impacto exacto dos dois lançamentos</summary><pre><?= $e($c->reverse_snapshot) ?></pre><pre><?= $e($c->replacement_snapshot) ?></pre></details><?php if($result): ?><p>Par aplicado: lançamentos #<?= $result->reverse_journal_id ?> / #<?= $result->replacement_journal_id ?>.</p><?php else: ?>
<?php if($data['permissions']['correct_validate']): ?><form data-action="correct-validate"><?php $hidden('correction_id',$c->id);$hidden('revision',$c->revision); ?><label>Decisão do par<select name="decision" required><option value="">Escolha</option><option value="approved">Aprovar ambos</option><option value="rejected">Recusar ambos</option></select></label><?php $reason(); ?><button>Rever o par exacto</button></form><?php endif ?>
<?php if($data['permissions']['correct_post']): ?><form data-action="correct-post"><?php $hidden('correction_id',$c->id);$hidden('revision',$c->revision);$reason(); ?><button>Revalidar e aplicar o par atómico</button></form><?php endif ?><?php endif ?></article><?php endforeach ?></section>
<section><h2>5. Conferência e fecho do período QA</h2><p>Reconhecimentos, afectações e reversões confirmados sem contabilização ou propostas necessárias não resolvidas impedem o fecho. Mudança de evidência exige nova conferência. Não existe comando de reabertura.</p><?php foreach($data['periods'] as $p): ?><p><?= $e($p->period_key) ?> v<?= $p->version ?> · <?= $e($p->status) ?> · <?= $e($p->reason) ?></p><?php endforeach ?>
<?php if($data['permissions']['period_prepare']): ?><form data-action="period-prepare"><label>Período<input name="period_key" value="QA-2026-10" required></label><?php $reason(); ?><button>Capturar evidência e totais para fecho</button></form><?php endif ?>
<?php foreach($data['period_checks'] as $c): $closed=null;foreach($data['period_closures'] as $r)if($r->check_id===$c->id)$closed=$r; ?><article><h3>Conferência #<?= $c->id ?> · <?= $e($c->period_key) ?></h3><p>Versão <?= $e($c->revision) ?> · autor #<?= $c->prepared_by ?> · <?= $e($c->reason) ?></p><pre><?= $e($c->snapshot) ?></pre><?php foreach($data['period_reviews'] as $r)if($r->check_id===$c->id): ?><p><?= $e($r->decision) ?> por #<?= $r->actor_id ?> · <?= $e($r->reason) ?></p><?php endif ?>
<?php if($closed): ?><p>Fecho #<?= $closed->id ?> · nova versão <?= $closed->period_version ?> · <?= $e($closed->created_at) ?></p><?php else: ?>
<?php if($data['permissions']['period_validate']): ?><form data-action="period-validate"><?php $hidden('check_id',$c->id);$hidden('revision',$c->revision); ?><label>Decisão<select name="decision" required><option value="">Escolha</option><option value="approved">Aprovar</option><option value="rejected">Recusar</option></select></label><?php $reason(); ?><button>Conferir fotografia do período</button></form><?php endif ?>
<?php if($data['permissions']['period_close']): ?><form data-action="period-close"><?php $hidden('check_id',$c->id);$hidden('revision',$c->revision);$reason(); ?><button>Revalidar e fechar este período</button></form><?php endif ?><?php endif ?></article><?php endforeach ?></section>
<section><h2>Diário, extracto e histórico</h2><p>Débitos <?= $money($data['book']['totals']['debit_minor']) ?> · Créditos <?= $money($data['book']['totals']['credit_minor']) ?>.</p><?php foreach($data['book']['journals'] as $j): ?><details><summary>Lançamento #<?= $j->id ?> · Obrigação #<?= $j->obligation_id ?> · D/C <?= $money((int)$j->debit_minor) ?></summary><p>Preparação #<?= $j->proposal_id ?> · revisão #<?= $j->review_id ?> · aplicador #<?= $j->posted_by ?> · <?= $e($j->reason) ?> · <?= $e($j->created_at) ?></p><pre><?= $e($j->snapshot) ?></pre></details><?php endforeach ?><?php foreach($data['book']['events'] as $event): ?><details><summary>#<?= $event->id ?> · <?= $e($event->kind) ?> · autor #<?= $event->actor_id ?> · <?= $e($event->created_at) ?></summary><p><?= $e($event->reason) ?></p><pre><?= $e($event->previous) ?></pre><pre><?= $e($event->subsequent) ?></pre></details><?php endforeach ?></section>
<nav aria-label="Páginas do ciclo"><p>Cada lista contém até25 registos. Totais por lista:</p><?php $max=max(array_column($data['book']['evidence_pagination'],'last_page'));foreach($data['pagination'] as $name=>$p){$max=max($max,$p['last_page']);echo '<p>'.$e($name).': '.$p['total'].' · página '.$p['page'].' de '.$p['last_page'].'</p>';}$current=$data['pagination']['obligations']['page'];foreach([-1=>'Anterior',1=>'Seguinte'] as $delta=>$label){$target=$current+$delta;if($target>=1&&$target<=max($max,$data['book']['pagination']['last_page']))echo '<a href="'.$e($settings['page_url'].'?page='.$target).'">'.$label.'</a> ';} ?></nav>
</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('#finance').onclick=async()=>{try{if(settings.scope){const r=await fetch('/api/context',{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf},body:JSON.stringify({tenant_id:settings.scope.tenant_id,company_id:settings.scope.company_id,unit_id:settings.scope.unit_id,module_id:'purchasing'})});if(!r.ok)throw Error('Módulo financeiro indisponível.');}location.assign(settings.financial_url);}catch(e){document.querySelector('#error').textContent=e.message;}};
for(const form of document.querySelectorAll('[data-action]'))form.onsubmit=async e=>{e.preventDefault();const body=Object.fromEntries(new FormData(form));for(const k of ['obligation_id','source_id','proposal_id','journal_id','correction_id','check_id'])if(k in body)body[k]=Number(body[k]);const payload=JSON.stringify(body);if(form.dataset.payload!==payload){form.dataset.payload=payload;form.dataset.key=crypto.randomUUID();}const button=form.querySelector('button');button.disabled=true;try{const r=await fetch('/api/general-ledger/'+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:payload});const data=await r.json();if(!r.ok)throw Error(data.message||'Decisão recusada; confira o estado actual.');location.reload();}catch(e){document.querySelector('#error').textContent=e.message+' Consulte o histórico antes de repetir.';}finally{button.disabled=false;}};
</script></body></html>
+17
View File
@@ -0,0 +1,17 @@
<?php
$e=static fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8');
$money=static fn($v)=>($v<0?'-':'').number_format(intdiv(abs($v),100),0,',',' ').','.str_pad((string)(abs($v)%100),2,'0',STR_PAD_LEFT);
$query=$data['query'];$detail=$data['kind']==='account_ledger';$base='/general-ledger/'.($detail?'account-ledger':'trial-balance');
$url=static fn($path,$changes=[])=>$path.'?'.http_build_query(array_replace($query,$changes));
?><!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><?= $detail?'Razão por conta':'Balancete' ?> de ensaio</title><style>body{font:17px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:1200px;margin:auto;padding:24px}header,section{background:white;border:1px solid #d5e0dc;border-radius:12px;padding:24px;margin:18px 0}.notice{background:#fff2cc;padding:16px}label{display:inline-block;margin:10px}input,select,button{font:inherit;padding:9px}a{color:#125a4b}table{border-collapse:collapse;width:100%}th,td{padding:10px;border-bottom:1px solid #ddd;text-align:right;white-space:nowrap}th:first-child,td:first-child{text-align:left}.scroll{overflow:auto}code{overflow-wrap:anywhere}details{white-space:normal;max-width:30rem}nav a{display:inline-block;padding:10px}</style></head><body><main>
<header><a href="<?= $e($settings['return_url']) ?>">Voltar ao diário</a><h1><?= $e($settings['host']) ?> — <?= $detail?'razão por conta':'balancete' ?> de ensaio</h1><p class="notice">Exclusivamente sintético. Contas artificiais, MZN. Consulta de lançamentos aplicados; não constitui demonstração financeira legal nem saldo por liquidar.</p><p>Datas inclusivas. Abertura: lançamentos anteriores ao início. Movimentos: intervalo seleccionado. Fecho: abertura mais movimentos. Propostas ainda sem lançamento não entram nos montantes.</p></header>
<section><form method="get"><label>Início<input name="from_on" type="date" value="<?= $e($query['from_on']) ?>" required></label><label>Fim<input name="to_on" type="date" value="<?= $e($query['to_on']) ?>" required></label><?php if(isset($query['account_id'])): ?><input type="hidden" name="account_id" value="<?= $e($query['account_id']) ?>"><?php endif ?><label>Por página<select name="per_page"><?php foreach([25,100] as $n): ?><option value="<?= $n ?>" <?= $query['per_page']===$n?'selected':'' ?>><?= $n ?></option><?php endforeach ?></select></label><button>Consultar</button></form>
<?php if($detail): ?><p><a href="<?= $e('/general-ledger/trial-balance?'.http_build_query(array_diff_key($query,['account_id'=>true,'page'=>true]))) ?>">Voltar ao balancete completo do intervalo</a></p><h2><?= $e($data['account']['code']) ?> — <?= $e($data['account']['label']) ?></h2><p>Abertura D−C <?= $money($data['opening_net_minor']) ?> · Fecho D−C <?= $money($data['closing_net_minor']) ?> MZN</p><?php endif ?>
<p>Movimentos de todo o filtro: débitos <strong><?= $money($data['totals']['debit_minor']) ?></strong> · créditos <strong><?= $money($data['totals']['credit_minor']) ?></strong> MZN. Totais independentes da página.</p>
<?php if(!$detail): ?><p>Abertura: D <?= $money($data['totals']['opening_debit_minor']) ?> / C <?= $money($data['totals']['opening_credit_minor']) ?> · Fecho: D <?= $money($data['totals']['closing_debit_minor']) ?> / C <?= $money($data['totals']['closing_credit_minor']) ?> MZN.</p><?php endif ?>
<div class="scroll"><table><?php if(!$detail): ?><caption>Contas com lançamentos até ao fim do intervalo</caption><thead><tr><th scope="col">Conta</th><th scope="col">Abertura D−C</th><th scope="col">Débitos</th><th scope="col">Créditos</th><th scope="col">Fecho D−C</th></tr></thead><tbody><?php foreach($data['rows'] as $r): ?><tr><td><a href="<?= $e($url('/general-ledger/account-ledger',['account_id'=>$r['account_id'],'page'=>1])) ?>"><?= $e($r['code']) ?></a><br><?= $e($r['label']) ?></td><td><?= $money($r['opening_net_minor']) ?></td><td><?= $money($r['debit_minor']) ?></td><td><?= $money($r['credit_minor']) ?></td><td><?= $money($r['closing_net_minor']) ?></td></tr><?php endforeach ?></tbody>
<?php else: ?><caption>Linhas aplicadas e saldo acumulado por data, lançamento e linha</caption><thead><tr><th scope="col">Data / evidência</th><th scope="col">Débito</th><th scope="col">Crédito</th><th scope="col">Saldo D−C</th></tr></thead><tbody><?php foreach($data['rows'] as $r): ?><tr><td><?= $e($r['journal_on']) ?> · lançamento #<?= $e($r['journal_id']) ?> / linha <?= $e($r['line_no']) ?><details><summary><?= $e($r['effect_type']) ?></summary><p><?= $e($r['reason']) ?></p><p>Proposta #<?= $e($r['proposal_id']) ?> · revisão #<?= $e($r['review_id']) ?> · aplicador #<?= $e($r['posted_by']) ?> · <?= $e($r['created_at']) ?></p><p>Versão da fonte <code><?= $e($r['source_revision']) ?></code></p><?php if(($settings['industrial_links']??false)&&($r['industrial_lot_id']??null)!==null): ?><a href="<?= $e('/general-ledger/industrial?lot_id='.$r['industrial_lot_id']) ?>">Conferir lote, resultado e decisões</a><?php elseif(($settings['cost_links']??false)&&$r['cost_order_id']!==null): ?><a href="<?= $e('/general-ledger/costs?order_id='.$r['cost_order_id']) ?>">Conferir ordem, custo e decisões</a><?php elseif(($settings['sales_links']??false)&&$r['invoice_id']!==null): ?><a href="<?= $e('/general-ledger/sales?invoice_id='.$r['invoice_id']) ?>">Conferir origem comercial e financeira</a><?php else: ?><p>Obrigação de origem #<?= $e($r['obligation_id']) ?>; a fotografia completa permanece no diário.</p><?php endif ?></details></td><td><?= $money($r['debit_minor']) ?></td><td><?= $money($r['credit_minor']) ?></td><td><?= $money($r['balance_minor']) ?></td></tr><?php endforeach ?></tbody><?php endif ?></table></div>
<?php if(!$data['rows']): ?><p>Sem linhas nesta página. <?= $data['basis']['journals']===0?'Não existem lançamentos aplicados até à data final neste âmbito.':'Confira o filtro e a paginação; os totais acima incluem o conjunto completo.' ?></p><?php endif ?>
<nav aria-label="Paginação do relatório"><p>Página <?= $e($data['pagination']['page']) ?> de <?= $e($data['pagination']['last_page']) ?> · <?= $e($data['pagination']['total']) ?> <?= $detail?'linhas':'contas' ?></p><?php foreach([-1=>'Anterior',1=>'Seguinte'] as $delta=>$label): $p=$query['page']+$delta;if($p>=1&&$p<=$data['pagination']['last_page']): ?><a href="<?= $e($url($base,['page'=>$p])) ?>"><?= $e($label) ?></a><?php endif;endforeach ?></nav></section>
<section><h2>Base da consulta</h2><p>Tenant #<?= $e($data['basis']['scope']['tenant_id']) ?> · empresa #<?= $e($data['basis']['scope']['company_id']) ?> · unidade #<?= $e($data['basis']['scope']['unit_id']) ?>. Até <?= $e($data['basis']['through_on']) ?>: <?= $e($data['basis']['journals']) ?> lançamentos; último identificador #<?= $e($data['basis']['last_journal_id']) ?>.</p><p>O marcador identifica o estado append-only consultado; uma inserção posterior, mesmo retrodatada, gera outro estado. Não é aprovação de fecho nem uma fotografia persistida do relatório.</p></section>
</main></body></html>
+27
View File
@@ -0,0 +1,27 @@
<?php
$e=static fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8');$json=static fn($v)=>json_encode($v,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
$money=static fn($v)=>($v<0?'-':'').intdiv(abs($v),100).','.str_pad((string)(abs($v)%100),2,'0',STR_PAD_LEFT);
$can=fn($a)=>in_array($a,$settings['can'],true);$actor=$settings['actor_id'];$open=($data['cycle']['periods'][0]->status??null)==='open';
$form=function($action,$body,$label)use($e,$json){echo '<form class="command" data-action="'.$e($action).'" data-body="'.$e($json($body)).'"><label>Fundamento individual<textarea name="reason" required minlength="10" maxlength="4000"></textarea></label><button>'.$e($label).'</button></form>';};
?><!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Stock e valorização QA</title><style>body{font:17px system-ui;background:#f3f6f5;color:#18372f;margin:0}main{max-width:1150px;margin:auto;padding:24px}section,header{background:white;border:1px solid #cedbd5;border-radius:10px;padding:22px;margin:16px 0}table{border-collapse:collapse;width:100%}th,td{padding:10px;border-bottom:1px solid #ddd;text-align:left;vertical-align:top}.scroll{overflow:auto}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-width:75ch}input,textarea,select,button{font:inherit;padding:8px}textarea{display:block;width:min(90%,40rem)}form{margin:12px 0}.notice{background:#fff0c3;padding:14px}a{color:#155644}#message{white-space:pre-wrap}</style></head><body><main>
<header><a href="<?= $e($settings['return_url']) ?>">Voltar aos percursos QA</a><h1>Stock físico → valorização → diário</h1><p class="notice">Exclusivamente sintético. Taxa de ensaio, contas artificiais e MZN. Conferir o impacto não move quantidades nem cria obrigação ou pagamento.</p><p>Livro<?= $e($data['stock']['book']['id']) ?> · artigo<?= $e($data['stock']['book']['stock_item_id']) ?> · armazém<?= $e($data['stock']['book']['warehouse_id']) ?> · unidade<?= $e($data['stock']['book']['unit_code']) ?> · período<?= $e($data['stock']['book']['period_key']) ?> · <?= $open?'aberto':'fechado' ?></p><p><a href="/stock-valuation/reports/trial-balance?from_on=2026-10-01&amp;to_on=2026-10-31">Balancete</a> · <a href="/stock-valuation/reports/account-ledger?from_on=2026-10-01&amp;to_on=2026-10-31&amp;account_id=<?= $e($settings['stock_account_id']) ?>">Razão de stock</a></p></header>
<p id="message" role="status"></p>
<section><h2>Taxa por UN, com revisão distinta</h2><p>A última versão tem de estar aprovada. Uma taxa ausente ou uma sucessora pendente deixa a valorização incompleta. Correcção após lançamento não faz parte deste ensaio.</p>
<?php if($open&&$can('prepare')&&$data['ledger']['pagination']['total']===0): ?><form id="rate"><label>Valor por UN em MZN<input name="amount" value="50,00" required inputmode="decimal"></label><label>Fundamento<textarea name="reason" minlength="10" maxlength="4000" required></textarea></label><button>Propor taxa</button></form><?php endif; ?>
<?php foreach($data['stock']['rates']as$r): ?><article><p>Versão<?= $e($r->version) ?> · <?= $e($money($r->unit_minor)) ?> MZN/UN · autor<?= $e($r->prepared_by) ?> · <?= $e($r->review->decision??'pendente de revisão') ?> · <?= $e($r->reason) ?></p><?php if($open&&!$r->review&&$can('validate')&&$r->prepared_by!==$actor){foreach(['approved'=>'Aprovar taxa','rejected'=>'Recusar taxa']as$decision=>$label)$form('rate-validate',['rate_id'=>$r->id,'version'=>$r->version,'decision'=>$decision],$label);} ?></article><?php endforeach; ?></section>
<section><h2>Movimentos efectivos e dependências</h2><p>Saldo físico:<?= $e($data['stock']['physical_balance_milli']) ?> milésimos de UN. Cada movimento é conferido individualmente; não há valorização em lote.</p><div class="scroll"><table><thead><tr><th>Origem</th><th>Quantidade em milésimos</th><th>Valor MZN</th><th>Conferência</th></tr></thead><tbody>
<?php foreach($data['stock']['rows']as$row):$m=$row['movement'];$source=$row['source']; ?><tr><td>#<?= $e($m['id']) ?> <?= $e($m['movement_type']) ?><br><?= $e($m['moved_at']) ?></td><td><?= $e($m['quantity_milli']) ?></td><td><?= $source?$e($money($source['principal_minor'])):'Incompleto' ?></td><td><?php if($source): ?><details><summary>Versões e dependências actuais</summary><pre><?= $e($json($source)) ?></pre></details><?php if($open&&$can('prepare'))$form('effect-prepare',['effect_type'=>$source['effect_type'],'source_id'=>$m['id'],'journal_on'=>$source['stock_movement_on'],'period_key'=>$source['permitted_period_key'],'rule_key'=>$source['effect_type']],'Preparar conferência deste movimento'); ?><?php else: ?><?= $e($row['pending']) ?><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div>
<nav><?php $p=$data['stock']['pagination'];if($p['page']>1): ?><a href="?book_id=<?= $e($data['stock']['book']['id']) ?>&amp;page=<?= $e($p['page']-1) ?>&amp;per_page=<?= $e($p['per_page']) ?>">Anterior</a><?php endif;if($p['page']*$p['per_page']<$p['total']): ?><a href="?book_id=<?= $e($data['stock']['book']['id']) ?>&amp;page=<?= $e($p['page']+1) ?>&amp;per_page=<?= $e($p['per_page']) ?>">Seguinte</a><?php endif; ?> Página<?= $e($p['page']) ?> · total<?= $e($p['total']) ?></nav></section>
<section><h2>Propostas e impacto contabilístico</h2><p>Conferir débito/crédito, origem, taxa, dependências e versão antes de decidir. A aplicação volta a validar o estado actual.</p>
<?php foreach($data['ledger']['proposals']as$p):$snapshot=json_decode($p->snapshot,true);$review=collect($data['ledger']['reviews'])->firstWhere('proposal_id',$p->id); ?><article><h3>Proposta<?= $e($p->id) ?> · captura<?= $e($p->capture_version) ?></h3><p>Origem<?= $e($snapshot['source']['stock_movement_id']??'') ?> · autor<?= $e($p->prepared_by) ?> · <?= $e($review->decision??'sem revisão') ?><?= $p->journal_id?' · lançamento #'.$e($p->journal_id):'' ?></p><ul><?php foreach($snapshot['lines']as$l): ?><li><?= $e($l['account_code']) ?>: débito<?= $e($money($l['debit_minor'])) ?> / crédito<?= $e($money($l['credit_minor'])) ?> MZN</li><?php endforeach; ?></ul><details><summary>Fotografia completa da conferência</summary><pre><?= $e($json($snapshot)) ?></pre></details>
<?php if($open&&!$p->journal_id&&!$review&&$can('validate')&&$p->prepared_by!==$actor){foreach(['approved'=>'Aprovar esta versão','rejected'=>'Recusar esta versão']as$decision=>$label)$form('validate',['proposal_id'=>$p->id,'revision'=>$p->revision,'decision'=>$decision],$label);}if($open&&!$p->journal_id&&($review->decision??null)==='approved'&&$can('post')&&$p->prepared_by===$actor)$form('post',['proposal_id'=>$p->id,'revision'=>$p->revision],'Aplicar exactamente este impacto'); ?></article><?php endforeach; ?></section>
<section><h2>Conferência e fecho do período QA</h2><?php if($open&&$can('period_prepare'))$form('period-prepare',['period_key'=>$data['stock']['book']['period_key']],'Preparar conferência do período'); ?>
<?php foreach($data['cycle']['period_checks']as$c):$r=collect($data['cycle']['period_reviews'])->firstWhere('check_id',$c->id); ?><article><p>Conferência<?= $e($c->id) ?> · autor<?= $e($c->prepared_by) ?> · <?= $e($r->decision??'pendente') ?></p><details><summary>Factos e impacto do fecho</summary><pre><?= $e($c->snapshot) ?></pre></details><?php if($open&&!$r&&$can('period_validate')&&$c->prepared_by!==$actor)$form('period-validate',['check_id'=>$c->id,'revision'=>$c->revision,'decision'=>'approved'],'Aprovar conferência exacta do período');if($open&&($r->decision??null)==='approved'&&$can('period_close')&&$c->prepared_by===$actor)$form('period-close',['check_id'=>$c->id,'revision'=>$c->revision],'Confirmar fecho QA'); ?></article><?php endforeach; ?></section>
<section><h2>Recuperar comprovativo sem executar</h2><p>Uma resposta perdida não autoriza repetição automática. Consulte a chave do pedido no domínio certo; ausência de comprovativo não prova falha.</p><form id="receipt"><label>Domínio<select name="domain"><option value="ledger">Diário/fecho</option><option value="rate">Taxa</option></select></label><label>Chave<input name="key" required pattern="[a-fA-F0-9-]{36}" maxlength="36"></label><button>Consultar</button></form><pre id="receipt-result"></pre></section>
<script id="stock-contract" type="application/json"><?= json_encode($settings+['book_id'=>$data['stock']['book']['id'],'rate_version'=>$data['stock']['rates'][0]->version??0],JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?></script>
<script>(()=>{const contract=JSON.parse(document.getElementById('stock-contract').textContent),message=document.getElementById('message');let busy=false;
const command=async(action,body)=>{if(busy)return;if(!confirm('Confirma esta decisão individual depois de conferir o impacto apresentado?'))return;busy=true;const key=crypto.randomUUID();message.textContent='Chave do pedido: '+key;try{const r=await fetch('/api/stock-valuation/'+action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':contract.csrf,'X-Stock-Context':contract.context,'Idempotency-Key':key},body:JSON.stringify(body)});const data=await r.json();if(!r.ok)throw Error(data.message||'Operação recusada.');message.textContent='Decisão registada. Chave: '+key;location.reload();}catch(error){message.textContent=error.message+' Chave: '+key+'. Confira o estado/comprovativo antes de repetir; não existe repetição automática.';}finally{busy=false;}};
document.querySelectorAll('form.command').forEach(form=>form.onsubmit=event=>{event.preventDefault();command(form.dataset.action,{...JSON.parse(form.dataset.body),reason:new FormData(form).get('reason')});});
const rate=document.getElementById('rate');if(rate)rate.onsubmit=event=>{event.preventDefault();const f=new FormData(rate),v=String(f.get('amount')).replace(',','.');if(!/^\d{1,6}(\.\d{1,2})?$/.test(v)){message.textContent='Valor em MZN inválido.';return;}const [whole,part='']=v.split('.'),minor=Number(whole)*100+Number(part.padEnd(2,'0'));command('rate-prepare',{book_id:contract.book_id,version:contract.rate_version,unit_minor:minor,reason:f.get('reason')});};
const form=document.getElementById('receipt'),result=document.getElementById('receipt-result');let generation=0,active=null;const invalidate=()=>{generation++;active?.abort();active=null;result.textContent='';};form.addEventListener('input',invalidate);form.addEventListener('change',invalidate);form.onsubmit=async event=>{event.preventDefault();invalidate();const serial=generation,key=form.elements.key.value.trim(),domain=form.elements.domain.value,controller=new AbortController();active=controller;const current=()=>serial===generation&&form.elements.key.value.trim()===key&&form.elements.domain.value===domain;try{const r=await fetch('/api/stock-valuation/receipts/'+encodeURIComponent(domain)+'/'+encodeURIComponent(key),{credentials:'same-origin',signal:controller.signal,headers:{Accept:'application/json','X-Stock-Context':contract.context}});const data=await r.json();if(!current())return;if(!r.ok)throw Error(data.message||'Consulta recusada.');result.textContent=JSON.stringify(data,null,2);}catch(error){if(current()&&error.name!=='AbortError')result.textContent=error.message;}finally{if(serial===generation)active=null;}};
})();</script></main></body></html>
+2
View File
@@ -1,8 +1,10 @@
<?php
require_once __DIR__.'/src/Payables.php';
require_once __DIR__.'/src/ReceiptReader.php';
require_once __DIR__.'/src/Workspace.php';
require_once __DIR__.'/src/PayablesSchema.php';
require_once __DIR__.'/src/HistoricalPayments.php';
require_once __DIR__.'/src/HistoricalCapture.php';
require_once __DIR__.'/src/HistorySchema.php';
require_once __DIR__.'/src/PaymentDecisions.php';
require_once __DIR__.'/src/PaymentDecisionSchema.php';
@@ -0,0 +1,17 @@
<?php
namespace Tempo\Finance\Payables;
/** Validated value confined to one scoped read window; never a command history source. */
final readonly class HistoricalCapture
{
private function __construct(private array $scope,private array $source,private array $capture) {}
public static function fromPayments(array $scope,array $source,array $payments):self
{
return new self($scope,$source,HistoricalPayments::capture($source,$payments));
}
public function forSource(array $scope,array $source):array
{
if($scope!==$this->scope||($source!==$this->source&&$source!==($this->source+['revision'=>Payables::revision($this->source),'missing'=>Payables::missing($this->source)])))throw new \DomainException('Captura histórica fora da origem, versão ou contexto da janela.');
return $this->capture;
}
}
+22 -5
View File
@@ -10,7 +10,8 @@ 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, private ?\Closure $loadHistory = null, private ?\Closure $authorizeRead = null) {}
private \Closure $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null, private ?\Closure $authorizeRead = null,
private ?\Closure $authorizeSettlementRead = null) {}
public static function canonical(array $data): string
{
@@ -129,7 +130,12 @@ final class Payables
throw new \DomainException('Passagem de histórico não activada nesta instalação.');
}
return HistoricalPayments::capture($source, ($this->loadHistory)($source['local_id']));
$history = ($this->loadHistory)($source['local_id']);
if ($history instanceof HistoricalCapture) {
if (! $availabilityChecked) throw new \DomainException('Capturas de janela são apenas de leitura; comandos exigem histórico actual.');
return $history->forSource($this->scope, $source);
}
return HistoricalPayments::capture($source, $history);
}
private function eligibility(array $source, bool $historical): array
@@ -176,6 +182,10 @@ final class Payables
$compatible ? [] : ['Origem ou histórico divergente; saldo não presumido.'])];
}
public function receipt(string $key):?array
{
return ReceiptReader::read($this->db,'recognition',$this->scope,$this->actor,$key,$this->authorizeRead);
}
public function settlements(): SupplierSettlements
{
return new SupplierSettlements($this->db, $this->actor, $this->person, $this->scope, $this->authorize, function (int $id): array {
@@ -195,7 +205,7 @@ final class Payables
return $position + ['supplier_key' => hash('sha256', self::canonical([$source['instance'], $source['company'], $source['supplier_id']])),
'supplier_name' => $source['supplier_name']];
});
}, $this->authorizeSettlementRead);
}
public function positionForSource(int $local): ?array
@@ -345,14 +355,14 @@ final class Payables
}
/** Bounded source window with independent proposal/event pages; commands never use these snapshots. */
public function listingPage(array $localSources, int $proposalPage = 1, int $eventPage = 1, int $perPage = 25): array
public function listingPage(array $localSources, int $proposalPage = 1, int $eventPage = 1, int $perPage = 25, bool $withEvidence = true): array
{
if (count($localSources) > 100 || ! in_array($perPage, [25, 100], true) || $proposalPage < 1 || $eventPage < 1
|| array_filter($localSources, fn ($id) => ! is_int($id) || $id < 1)) {
throw new \InvalidArgumentException('Janela de leitura financeira inválida.');
}
return $this->db->transaction(function () use ($localSources, $proposalPage, $eventPage, $perPage) {
return $this->db->transaction(function () use ($localSources, $proposalPage, $eventPage, $perPage, $withEvidence) {
$this->authorizeRead === null ? ($this->authorize)('view') : ($this->authorizeRead)();
$documents = []; $current = [];
foreach ($localSources as $id) {
@@ -376,6 +386,7 @@ final class Payables
$origin = $origins->get($obligation->source_id);
return $this->position($obligation, $origin ? ($current[$origin->source_key] ?? null) : null, $page);
})->all();
if (! $withEvidence) return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $positions];
$proposals = $this->q('proposals')->whereIn('source_id', $sources())->orderByDesc('id')->paginate($perPage, ['*'], 'proposal_page', $proposalPage);
$events = $this->q('events')->whereIn('source_id', $sources())->orderByDesc('id')->paginate($perPage, ['*'], 'event_page', $eventPage);
$meta = fn ($p) => ['page' => $p->currentPage(), 'last_page' => $p->lastPage(), 'total' => $p->total(), 'per_page' => $p->perPage()];
@@ -387,6 +398,12 @@ final class Payables
});
}
/** Same position calculation, without loading presentation-only proposal/event pages. */
public function positionsPage(array $localSources): array
{
return $this->listingPage($localSources, 1, 1, 100, false);
}
public function listing(array $localSources): array
{
return $this->db->transaction(function () use ($localSources) {
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\ConnectionInterface;
final class ReceiptReader
{
public static function read(ConnectionInterface $db,string $domain,array $scope,int $actor,string $key,?\Closure $authorize):?array
{
$table=match($domain){'recognition'=>'pa_commands','settlement'=>'ps_commands',default=>throw new \InvalidArgumentException('Domínio de comprovativo inválido.')};
if(!preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di',$key))throw new \InvalidArgumentException('Chave de pedido inválida.');
if($authorize===null)throw new \DomainException('Consulta sem autorização própria de leitura.');
return $db->transaction(function()use($db,$table,$domain,$scope,$actor,$key,$authorize){$authorize();if(!$db->getSchemaBuilder()->hasTable($table))throw new \DomainException('Comprovativos deste domínio indisponíveis.');$r=$db->table($table)->where($scope)->where('actor_id',$actor)->where('command_key',$key)->first();return $r?['domain'=>$domain,'command_key'=>$r->command_key,'recorded_at'=>$r->created_at,'request_hash'=>$r->request_hash,'result'=>json_decode($r->result,true,128,JSON_THROW_ON_ERROR),'historical_receipt_only'=>true,'operation_executed'=>false]:null;});
}
}
+46 -6
View File
@@ -16,6 +16,19 @@ abstract class SettlementHttp extends Controller
protected const SERVICE = '';
/** Host-specific document navigation; the native legacy composition keeps its existing contract. */
protected function obligationOptions(Request $request): array
{
return [];
}
/** Null preserves the native host's existing listing. */
protected function settlementOptions(Request $request): ?array
{
return null;
}
protected function ledgerSettings(Request $request):array { return []; }
protected function guard(): void
{
$db = DB::connection();
@@ -32,18 +45,40 @@ abstract class SettlementHttp extends Controller
private function data(Request $r): array
{
$this->guard();
$options = $this->obligationOptions($r);
$paymentOptions = $this->settlementOptions($r);
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($r): array {
return DB::transaction(function () use ($r, $options, $paymentOptions, $own): array {
if ($own && static::MODE === 'sgf' && $paymentOptions !== null) DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$s = app(static::SERVICE);
$user = $r->user();
if (! $user instanceof User) {
abort(401);
}
$settlements = $s->engine($user)->settlements()->listing();
$base = $s->listing($user);
$engine = $s->engine($user)->settlements();
try {
$settlements = $paymentOptions === null ? $engine->listing() : $engine->listingPage($paymentOptions);
} catch (\DomainException $e) {
if ($paymentOptions === null) throw $e;
abort(403, $e->getMessage());
}
$base = static::MODE === 'sgf' ? $s->listing($user, $options) : $s->listing($user);
$proposals = null; $sources = null;
if (static::MODE === 'sgf' && $base['obligations']) {
$scope = array_intersect_key($base['obligations'][0], array_flip(['tenant_id', 'company_id', 'unit_id']));
abort_unless(count($scope) === 3, 409, 'Âmbito da obrigação incompleto.');
foreach ($base['obligations'] as $o) abort_unless(array_intersect_key($o, $scope) === $scope, 409, 'Âmbito de obrigações divergente.');
// Recognition refers to one exact proposal, not whichever versions happen to be on the first evidence page.
$proposals = DB::table('pa_proposals')->where($scope)->whereIn('id', array_column($base['obligations'], 'proposal_id'))->get()->keyBy('id');
$sources = DB::table('pa_sources')->where($scope)->whereIn('id', array_column($base['obligations'], 'source_id'))->get()->keyBy('id');
}
foreach ($base['obligations'] as &$o) {
$proposal = $base['proposals']->first(fn ($p) => (int) $p->id === (int) $o['proposal_id']);
$proposal = $proposals === null ? $base['proposals']->first(fn ($p) => (int) $p->id === (int) $o['proposal_id']) : $proposals->get($o['proposal_id']);
if (static::MODE === 'sgf' && $proposal && ((int) $proposal->source_id !== (int) $o['source_id'] || $proposal->source_revision !== $o['source_revision'])) $proposal = null;
$o['origin_snapshot'] = $proposal ? json_decode($proposal->snapshot, true, 64, JSON_THROW_ON_ERROR) : null;
$source = $sources?->get($o['source_id']);
if ($source) $o['evidence_url'] = '/supplier-payables?'.http_build_query(['source' => $source->local_source_id], '', '&', PHP_QUERY_RFC3986);
}
unset($o);
$permissions = [];
@@ -54,7 +89,9 @@ abstract class SettlementHttp extends Controller
return ['synthetic_only' => true, 'obligations' => $base['obligations'], 'payments' => $settlements['payments'],
'allocation_proposals' => $settlements['proposals'], 'allocations' => $settlements['allocations'], 'events' => $settlements['events'],
'reversals_available' => $settlements['reversals_available'], 'reversal_proposals' => $settlements['reversal_proposals'], 'reversals' => $settlements['reversals'],
'permissions' => $permissions, 'current_actor' => $user->id];
'permissions' => $permissions, 'current_actor' => $user->id]
+ (static::MODE === 'sgf' ? ['obligation_pagination' => $base['pagination']['documents'], 'obligation_query' => $base['listing_query']] : [])
+ (isset($settlements['pagination']) ? ['settlement_pagination' => $settlements['pagination'], 'settlement_query' => array_replace($base['listing_query'], $settlements['listing_query'])] : []);
});
}
@@ -73,8 +110,11 @@ abstract class SettlementHttp extends Controller
$settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $user->name,
'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token'), 'api' => '/api/supplier-payments',
'obligations_url' => static::MODE === 'compras' ? '/obrigacoes-fornecedores' : '/supplier-payables'];
if (static::MODE === 'sgf' && isset($data['obligation_query']['source'])) {
$settings['obligations_url'] .= '?'.http_build_query(['source' => $data['obligation_query']['source']], '', '&', PHP_QUERY_RFC3986);
}
return response(Workspace::settlements($data, $settings))->header('Cache-Control', 'no-store');
return response(Workspace::settlements($data, $settings+$this->ledgerSettings($r)))->header('Cache-Control', 'no-store');
}
public function command(Request $r, string $action): JsonResponse
+86 -2
View File
@@ -12,15 +12,20 @@ final class SupplierSettlements
* @param array<string,int> $scope
* @param \Closure(string):void $authorize
* @param \Closure(int):array<string,mixed> $obligation
* @param null|\Closure():void $authorizeRead Explicit host authority for paged payment reads; never used by commands.
*/
public function __construct(private ConnectionInterface $db, private int $actor, private string $person,
private array $scope, private \Closure $authorize, private \Closure $obligation) {}
private array $scope, private \Closure $authorize, private \Closure $obligation, private ?\Closure $authorizeRead = null) {}
private function q(string $table): Builder
{
return $this->db->table('ps_'.$table)->where($this->scope);
}
public function receipt(string $key):?array
{
return ReceiptReader::read($this->db,'settlement',$this->scope,$this->actor,$key,$this->authorizeRead);
}
private function stamp(): string
{
return date('Y-m-d H:i:s');
@@ -489,7 +494,86 @@ final class SupplierSettlements
return ['proposal_id' => (int) $proposal->id, 'reversal_id' => $id];
}
/** @return array<string,mixed> */
/** Bounded read windows. Commands keep the original fresh balance and version checks.
* @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function listingPage(array $options = []): array
{
$defaults = ['payments_page' => 1, 'allocation_proposals_page' => 1, 'allocations_page' => 1, 'payment_events_page' => 1,
'payment_per_page' => 25, 'payment_q' => '', 'payment_state' => 'all'];
if (array_diff(array_keys($options), [...array_keys($defaults), 'payment'])) throw new \InvalidArgumentException('Filtro financeiro desconhecido.');
$o = array_replace($defaults, $options);
$o['payment_q'] ??= '';
foreach (['payments_page', 'allocation_proposals_page', 'allocations_page', 'payment_events_page', 'payment_per_page', 'payment'] as $key) {
if (! array_key_exists($key, $o)) continue;
$value = filter_var($o[$key], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($value === false || is_bool($o[$key]) || ($key === 'payment_per_page' ? ! in_array($value, [25, 100], true) : ($key !== 'payment' && $value > 100000))) throw new \InvalidArgumentException('Página ou pagamento inválido.');
$o[$key] = $value;
}
if (! is_string($o['payment_q']) || mb_strlen($o['payment_q']) > 100 || ! in_array($o['payment_state'], ['all', 'proposed', 'confirmed', 'rejected'], true)) throw new \InvalidArgumentException('Pesquisa ou estado inválido.');
$o['payment_q'] = trim($o['payment_q']);
$own = $this->db->transactionLevel() === 0;
return $this->db->transaction(function () use ($o, $own): array {
if ($own && $this->authorizeRead !== null) $this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
if ($this->authorizeRead !== null) ($this->authorizeRead)();
else ($this->authorize)('payments_view');
$root = $this->q('payments');
if (isset($o['payment'])) {
if (! (clone $root)->where('id', $o['payment'])->exists()) throw new \DomainException('Pagamento indisponível neste contexto.');
$root->where('id', $o['payment']);
}
if ($o['payment_q'] !== '') {
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $o['payment_q']).'%';
$root->where('fact_reference', 'ilike', $needle);
}
if ($o['payment_state'] !== 'all') {
$state = $o['payment_state'];
$method = $state === 'proposed' ? 'whereNotExists' : 'whereExists';
$root->$method(function ($q) use ($state) {
$q->selectRaw('1')->from('ps_payment_decisions')->where($this->scope)->whereColumn('ps_payment_decisions.payment_id', 'ps_payments.id');
if ($state !== 'proposed') $q->where('decision', $state);
});
}
$matching = fn () => (clone $root)->select('id');
$size = $o['payment_per_page'];
$payments = (clone $root)->orderByDesc('id')->paginate($size, ['*'], 'payments_page', $o['payments_page']);
$ids = array_map(fn ($p) => $p->id, $payments->items());
$decisions = $this->q('payment_decisions')->whereIn('payment_id', $ids)->get()->keyBy('payment_id');
$reversible = $this->reversalsAvailable();
$allocated = $this->q('allocations')->whereIn('payment_id', $ids)->selectRaw('payment_id, sum(amount_minor) AS amount')->groupBy('payment_id')->pluck('amount', 'payment_id');
$reversed = $reversible ? $this->q('reversals')->whereIn('payment_id', $ids)->selectRaw('payment_id, sum(amount_minor) AS amount')->groupBy('payment_id')->pluck('amount', 'payment_id') : collect();
$positions = array_map(function ($p) use ($decisions, $allocated, $reversed) {
$decision = $decisions->get($p->id);
return (array) $p + ['decision' => $decision, 'state' => $decision?->decision ?? 'proposed',
'unallocated_minor' => $decision?->decision === 'confirmed' ? (int) $p->amount_minor - (int) $allocated->get($p->id, 0) + (int) $reversed->get($p->id, 0) : null,
'reconciliation' => 'not_implemented'];
}, $payments->items());
$proposals = $this->q('proposals')->whereIn('payment_id', $matching())->orderByDesc('id')->paginate($size, ['*'], 'allocation_proposals_page', $o['allocation_proposals_page']);
$proposalDecisions = $this->q('allocation_decisions')->whereIn('proposal_id', array_map(fn ($p) => $p->id, $proposals->items()))->get()->keyBy('proposal_id');
$plans = array_map(fn ($p) => (array) $p + ['decision' => $proposalDecisions->get($p->id)], $proposals->items());
$allocations = $this->q('allocations')->whereIn('payment_id', $matching())->orderByDesc('id')->paginate($size, ['*'], 'allocations_page', $o['allocations_page']);
$allocationIds = array_map(fn ($a) => $a->id, $allocations->items());
$origins = $this->db->table('pa_obligations AS o')->join('pa_sources AS s', 's.id', '=', 'o.source_id')
->whereIn('o.id', array_map(fn ($a) => $a->obligation_id, $allocations->items()));
foreach ($this->scope as $key => $value) $origins->where('o.'.$key, $value)->whereColumn('s.'.$key, 'o.'.$key);
$origins = $origins->get(['o.id', 's.local_source_id'])->keyBy('id');
foreach ($allocations->items() as $a) $a->local_source_id = $origins->get($a->obligation_id)?->local_source_id;
$events = $this->q('events')->whereIn('payment_id', $matching())->orderByDesc('id')->paginate($size, ['*'], 'payment_events_page', $o['payment_events_page']);
$meta = fn ($p) => ['page' => $p->currentPage(), 'last_page' => $p->lastPage(), 'total' => $p->total(), 'per_page' => $p->perPage()];
return ['synthetic_only' => true, 'payments' => $positions, 'proposals' => $plans, 'allocations' => $allocations->items(), 'events' => $events->items(),
'reversals_available' => $reversible,
'reversal_proposals' => $reversible ? $this->q('reversal_proposals')->whereIn('allocation_id', $allocationIds)->orderBy('id')->get()->all() : [],
'reversals' => $reversible ? $this->q('reversals')->whereIn('allocation_id', $allocationIds)->orderBy('id')->get()->all() : [],
'pagination' => ['payments' => $meta($payments), 'allocation_proposals' => $meta($proposals), 'allocations' => $meta($allocations), 'payment_events' => $meta($events)],
'listing_query' => $o, 'current_actor' => $this->actor];
});
}
/** Legacy composition; deliberately unchanged until that host opts into the bounded reader.
* @return array<string,mixed>
*/
public function listing(): array
{
return $this->db->transaction(function (): array {
+7 -4
View File
@@ -3,11 +3,13 @@
<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><?php if(session()->get('payables_demo_entry')): ?><p><a href="/demo">Trocar função ou sair da demonstração</a></p><?php endif ?><a href="<?= $e($settings['return_url']??'/') ?>">← <?= $e($settings['return_label']??'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 reconhece o principal e, quando activado, preserva o histórico existente.</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>
<?php if(isset($data['history_receipt_notice'])): ?><p class="notice"><?= $e($data['history_receipt_notice']) ?></p><?php endif ?>
<?php $pageNav=function(string $key)use($data,$settings,$e){if(!isset($data['pagination'][$key]))return;$p=$data['pagination'][$key];$param=['documents'=>'page','proposals'=>'proposal_page','events'=>'event_page'][$key];echo '<nav aria-label="Paginação '.$e($key).'"><p>Página '.$e($p['page']).' de '.$e($p['last_page']).' · '.$e($p['total']).' registos</p>';foreach([-1=>'Anterior',1=>'Seguinte'] as $delta=>$label){$target=$p['page']+$delta;if($target<1||$target>$p['last_page'])continue;$query=array_replace($data['listing_query'],[$param=>$target]);if($key==='documents'){$query['proposal_page']=1;$query['event_page']=1;}$url=($settings['page_url']??'/supplier-payables').'?'.http_build_query($query,'','&',PHP_QUERY_RFC3986);echo '<a href="'.$e($url).'">'.$label.'</a> ';}echo '</nav>';}; ?>
<?php if(isset($data['pagination'])): ?><section><h2>Filtrar documentos</h2><form method="get" action="<?= $e($settings['page_url']??'/supplier-payables') ?>"><label>Número ou fornecedor<input name="q" maxlength="100" value="<?= $e($data['listing_query']['q']) ?>"></label><label>Reconhecimento<select name="recognition"><?php foreach(['all'=>'Todos','recognised'=>'Reconhecidos','unrecognised'=>'Por reconhecer'] as $v=>$label): ?><option value="<?= $e($v) ?>" <?= $data['listing_query']['recognition']===$v?'selected':'' ?>><?= $e($label) ?></option><?php endforeach ?></select></label><label>Documentos por página<select name="per_page"><?php foreach([25,100] as $size): ?><option value="<?= $size ?>" <?= $data['listing_query']['per_page']===$size?'selected':'' ?>><?= $size ?></option><?php endforeach ?></select></label><button>Aplicar filtros</button></form><p>Obrigações, propostas e eventos abaixo pertencem aos documentos desta página. A pesquisa e o estado são aplicados antes da paginação.</p></section><?php endif ?>
<?php if($data['payments_available']??false): ?><p><a href="/supplier-payments">Pagamentos e afectações sintéticas — sem execução bancária</a></p><?php endif ?><p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
<?php if(isset($settings['report_url'])): ?><p><a href="<?= $e($settings['report_url'].'?'.http_build_query(array_intersect_key($data['listing_query'],array_flip(['q','source'])),'','&',PHP_QUERY_RFC3986)) ?>">Relatório das obrigações reconhecidas abrangidas pelo filtro</a></p><?php endif ?>
<?php if(isset($data['listing_query']['source'])): ?><p>Conferência da origem #<?= $e($data['listing_query']['source']) ?>. <a href="<?= $e($settings['page_url']??'/supplier-payables') ?>">Consultar todas as origens</a></p><?php endif ?>
<?php $pageNav=function(string $key)use($data,$settings,$e){if(!isset($data['pagination'][$key]))return;$p=$data['pagination'][$key];$param=['documents'=>'page','proposals'=>'proposal_page','events'=>'event_page'][$key];$label=['documents'=>'documentos','proposals'=>'propostas','events'=>'histórico'][$key];echo '<nav aria-label="Paginação '.$e($label).'"><p>Página '.$e($p['page']).' de '.$e($p['last_page']).' · '.$e($p['total']).' registos</p>';foreach([-1=>'Anterior',1=>'Seguinte'] as $delta=>$label){$target=$p['page']+$delta;if($target<1||$target>$p['last_page'])continue;$query=array_replace($data['listing_query'],[$param=>$target]);if($key==='documents'){$query['proposal_page']=1;$query['event_page']=1;}$url=($settings['page_url']??'/supplier-payables').'?'.http_build_query($query,'','&',PHP_QUERY_RFC3986);echo '<a href="'.$e($url).'">'.$label.'</a> ';}echo '</nav>';}; ?>
<?php if(isset($data['pagination'])): ?><section><h2>Filtrar documentos</h2><form method="get" action="<?= $e($settings['page_url']??'/supplier-payables') ?>"><?php foreach(['invoice','source'] as $focus): if(isset($data['listing_query'][$focus])): ?><input type="hidden" name="<?= $focus ?>" value="<?= $e($data['listing_query'][$focus]) ?>"><?php endif; endforeach ?><label>Número ou fornecedor<input name="q" maxlength="100" value="<?= $e($data['listing_query']['q']) ?>"></label><label>Reconhecimento<select name="recognition"><?php foreach(['all'=>'Todos','recognised'=>'Reconhecidos','unrecognised'=>'Por reconhecer'] as $v=>$label): ?><option value="<?= $e($v) ?>" <?= $data['listing_query']['recognition']===$v?'selected':'' ?>><?= $e($label) ?></option><?php endforeach ?></select></label><label>Documentos por página<select name="per_page"><?php foreach([25,100] as $size): ?><option value="<?= $size ?>" <?= $data['listing_query']['per_page']===$size?'selected':'' ?>><?= $size ?></option><?php endforeach ?></select></label><button>Aplicar filtros</button></form><p>Obrigações, propostas e eventos abaixo pertencem aos documentos desta página. A pesquisa e o estado são aplicados antes da paginação.</p></section><?php endif ?>
<?php if($data['payments_available']??false): ?><p><a href="<?= $e('/supplier-payments'.(isset($data['listing_query']['source'])?'?'.http_build_query(['source'=>$data['listing_query']['source']],'','&',PHP_QUERY_RFC3986):'')) ?>">Pagamentos e afectações sintéticas — sem execução bancária</a></p><?php endif ?><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 ?>
<?php foreach($data['documents'] as $d): ?><tr><td><?= $e($d['number']??'Indeterminado') ?><?php if(($settings['document_filter']??null)==='source'): ?><p><a href="<?= $e(($settings['page_url']??'/supplier-payables').'?'.http_build_query(['source'=>$d['local_id']],'','&',PHP_QUERY_RFC3986)) ?>">Conferir evidências desta origem</a></p><?php endif ?></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><?php $pageNav('documents'); ?></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 ?>
<?php $historyDocuments=array_filter($data['documents'],fn($d)=>($d['payment_count']??0)>0); if(($data['history_import_available']??false)&&$data['permissions']['prepare']&&$historyDocuments): ?><section><h2>Incorporar factura com pagamentos anteriores</h2><p>Preserva o principal integral e liga os pagamentos originais uma única vez. Só os confirmados reduzem o saldo; pendentes e rejeitados mantêm o seu estado. Não executa nem confirma pagamentos.</p><form data-action="prepare_history"><label>Factura com histórico<select name="id"><?php foreach($historyDocuments 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">Passagem de responsabilidade com preservação dos pagamentos históricos.</textarea></label><button>Preparar passagem com histórico</button></form></section><?php endif ?>
@@ -21,6 +23,7 @@
<p>Sem execução de novos pagamentos, cobrança, efeitos de stock, novo consumo orçamental ou lançamento automático. A passagem de histórico não confirma pagamentos pendentes.</p>
</main><script>
const settings=<?= json_encode($settings,JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?>;
if(settings.ledger_flow_url){const button=document.createElement('button');button.textContent='Conferir percurso até ao lançamento';document.querySelector('#status').before(button);button.onclick=async()=>{button.disabled=true;try{const r=await fetch('/api/context',{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf},body:JSON.stringify({...settings.ledger_flow_scope,module_id:'accounting'})});const data=await r.json();if(!r.ok)throw Error(data.message||'Módulo indisponível.');location.assign(settings.ledger_flow_url);}catch(e){document.querySelector('#error').textContent=e.message;button.disabled=false;}};}
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>
@@ -0,0 +1,6 @@
<?php if (! isset($data['settlement_pagination'])) return; ?>
<section id="allocation-history"><h2>Afectações confirmadas</h2>
<?php foreach($data['allocations'] as $a): ?><article><h3>Afectação #<?= $e($a->id) ?></h3><p><a href="<?= $e($paymentFocusUrl((int)$a->payment_id)) ?>">Pagamento #<?= $e($a->payment_id) ?></a> · Obrigação #<?= $e($a->obligation_id) ?> · <?= $money($a->amount_minor) ?> · <?= $e($a->created_at) ?></p><p>Versão de origem <?= $e($a->source_revision) ?>. O original mantém-se no histórico mesmo quando existe reversão.</p>
<?php if($a->local_source_id!==null): ?><p><a href="<?= $e('/supplier-payables?'.http_build_query(['source'=>$a->local_source_id],'','&',PHP_QUERY_RFC3986)) ?>">Conferir origem e reconhecimento</a></p><?php endif ?></article><?php endforeach ?>
<?php if(!$data['allocations']): ?><p>Sem afectações confirmadas nesta janela.</p><?php endif ?>
<?php $settlementNav('allocations'); ?></section>
@@ -0,0 +1,33 @@
<?php
$settlementNav = static function (string $key) use ($data, $e): void {
if (! isset($data['settlement_pagination'][$key])) return;
$p = $data['settlement_pagination'][$key];
$param = ['payments' => 'payments_page', 'allocation_proposals' => 'allocation_proposals_page', 'allocations' => 'allocations_page', 'payment_events' => 'payment_events_page'][$key];
$anchor = ['payments' => 'payments', 'allocation_proposals' => 'allocation-proposals', 'allocations' => 'allocation-history', 'payment_events' => 'payment-history'][$key];
$label = ['payments' => 'pagamentos', 'allocation_proposals' => 'propostas de afectação', 'allocations' => 'afectações confirmadas', 'payment_events' => 'eventos de pagamentos'][$key];
echo '<nav aria-label="Paginação de '.$e($label).'"><p>Página '.$e($p['page']).' de '.$e($p['last_page']).' · '.$e($p['total']).' registos</p>';
foreach ([-1 => 'Anterior', 1 => 'Seguinte'] as $delta => $text) {
$target = $p['page'] + $delta;
if ($target < 1 || $target > $p['last_page']) continue;
$query = array_replace($data['settlement_query'], [$param => $target]);
echo '<a href="'.$e('/supplier-payments?'.http_build_query($query, '', '&', PHP_QUERY_RFC3986).'#'.$anchor).'">'.$text.'</a> ';
}
echo '</nav>';
};
$paymentFocusUrl = static function (int $id) use ($data): string {
$query = array_replace($data['settlement_query'], ['payment' => $id, 'payment_state' => 'all', 'payment_q' => '',
'payments_page' => 1, 'allocation_proposals_page' => 1, 'allocations_page' => 1, 'payment_events_page' => 1]);
return '/supplier-payments?'.http_build_query($query, '', '&', PHP_QUERY_RFC3986).'#payments';
};
if (! isset($data['settlement_pagination'])) return;
$sq = $data['settlement_query'];
?>
<section><h2>Filtrar pagamentos e histórico</h2><form method="get" action="/supplier-payments">
<?php foreach($data['obligation_query'] as $name=>$value): ?><input type="hidden" name="<?= $e($name) ?>" value="<?= $e($value) ?>"><?php endforeach ?>
<?php if(isset($sq['payment'])): ?><input type="hidden" name="payment" value="<?= $e($sq['payment']) ?>"><?php endif ?>
<label>Referência do pagamento<input name="payment_q" maxlength="100" value="<?= $e($sq['payment_q']) ?>"></label>
<label>Estado do pagamento<select name="payment_state"><?php foreach(['all'=>'Todos','proposed'=>'Propostos','confirmed'=>'Confirmados','rejected'=>'Rejeitados'] as $value=>$label): ?><option value="<?= $value ?>" <?= $sq['payment_state']===$value?'selected':'' ?>><?= $label ?></option><?php endforeach ?></select></label>
<label>Registos por página<select name="payment_per_page"><?php foreach([25,100] as $size): ?><option value="<?= $size ?>" <?= $sq['payment_per_page']===$size?'selected':'' ?>><?= $size ?></option><?php endforeach ?></select></label><button>Aplicar filtros ao histórico</button></form>
<p>Estes filtros abrangem pagamentos, propostas de afectação, afectações e eventos. Cada lista tem paginação própria. Os filtros de documentos escolhem as obrigações e não restringem este histórico. Os montantes por afectar incluem todo o histórico do pagamento, também fora da página.</p>
<?php if(isset($sq['payment'])): $all=$sq; unset($all['payment']); foreach(['payments_page','allocation_proposals_page','allocations_page','payment_events_page'] as $key)$all[$key]=1; ?><p>Conferência do pagamento #<?= $e($sq['payment']) ?> · <a href="<?= $e('/supplier-payments?'.http_build_query($all,'','&',PHP_QUERY_RFC3986)) ?>">Consultar outros pagamentos</a></p><?php endif ?>
</section>
+21 -6
View File
@@ -12,13 +12,27 @@ body{font:17px system-ui;color:#172f29;background:#f4f6f5;margin:0}main{max-widt
</style></head><body><main>
<header><a href="<?= $e($settings['obligations_url']) ?>">← Obrigações a fornecedores</a> · <a href="/demo?next=payments">Trocar função ou sair</a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Pagamentos e afectações a fornecedores</h1><p class="notice"><strong>ENSAIO SINTÉTICO — SEM MOVIMENTAÇÃO DE DINHEIRO.</strong> Propor não confirma; confirmar um pagamento não abate automaticamente uma dívida. Só uma afectação confirmada reduz o saldo. Não há execução bancária, reconciliação, stock, orçamento adicional ou lançamento contabilístico.</p><p>As referências de pagamento e evidência são artificiais e começam por SIM-. Não introduza dados bancários ou comprovativos reais.</p></header>
<p id="status" role="status"></p><p id="error" role="alert" tabindex="-1"></p><button id="reload">Actualizar consulta</button>
<section id="obligation-balances"><h2>Obrigações e saldo por liquidar</h2><div class="scroll"><table><thead><tr><th>Origem / fornecedor</th><th>Principal</th><th>Afectado neste percurso</th><th>Saldo</th><th>Vencimento</th></tr></thead><tbody><?php foreach ($data['obligations'] as $o): ?><tr><td>#<?= $e($o['id']) ?> · <?= $e($o['origin_snapshot']['number'] ?? '') ?><br><?= $e($o['origin_snapshot']['supplier_name'] ?? '') ?><br>Versão <?= $e($o['source_revision']) ?> · Validador #<?= $e($o['recognised_by']) ?><details><summary>Versão e decisão</summary><pre><?= $e(json_encode($o, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre></details></td><td><?= $money($o['principal_minor']) ?></td><td><?= $money($o['allocated_payments_minor']) ?></td><td><?= $money($o['balance_minor']) ?><?= $o['source_conflict'] ? ' — origem divergente' : '' ?></td><td>Pendente<br>Atraso indeterminado<br>Dias de atraso: sem valor</td></tr><?php endforeach ?></tbody></table></div><?php if (! $data['obligations']): ?><p>Reconheça primeiro a obrigação no percurso existente. Não é criada uma dívida por registar um pagamento.</p><?php endif ?></section>
<?php require __DIR__.'/settlement-navigation.php'; ?>
<?php if(isset($data['obligation_pagination'])): $op=$data['obligation_pagination']; $oq=$data['obligation_query']; ?>
<section><h2>Escolher origens para conferência</h2><form method="get" action="/supplier-payments">
<?php foreach(array_diff_key($data['settlement_query']??[], $oq) as $name=>$value): ?><input type="hidden" name="<?= $e($name) ?>" value="<?= $e($value) ?>"><?php endforeach ?>
<?php if(isset($oq['source'])): ?><input type="hidden" name="source" value="<?= $e($oq['source']) ?>"><?php endif ?>
<label>Número ou fornecedor<input name="q" maxlength="100" value="<?= $e($oq['q']) ?>"></label>
<label>Reconhecimento<select name="recognition"><?php foreach(['all'=>'Todos','recognised'=>'Reconhecidos','unrecognised'=>'Por reconhecer'] as $value=>$label): ?><option value="<?= $value ?>" <?= $oq['recognition']===$value?'selected':'' ?>><?= $label ?></option><?php endforeach ?></select></label>
<label>Documentos por página<select name="per_page"><?php foreach([25,100] as $size): ?><option value="<?= $size ?>" <?= $oq['per_page']===$size?'selected':'' ?>><?= $size ?></option><?php endforeach ?></select></label><button>Aplicar filtros</button></form>
<p>A selecção de obrigações abaixo acompanha esta página de documentos. O histórico de pagamentos e afectações mantém a sua consulta própria; os filtros de documentos não o restringem.</p>
<?php if(isset($oq['source'])): $allSources=array_replace($data['settlement_query']??$oq,['page'=>1,'proposal_page'=>1,'event_page'=>1,'q'=>'','recognition'=>'all']); unset($allSources['source']); ?><p>Origem #<?= $e($oq['source']) ?> · <a href="<?= $e('/supplier-payments?'.http_build_query($allSources,'','&',PHP_QUERY_RFC3986)) ?>">Consultar todas as origens</a></p><?php endif ?>
<nav aria-label="Paginação de origens para pagamentos"><p>Página <?= $e($op['page']) ?> de <?= $e($op['last_page']) ?> · <?= $e($op['total']) ?> documentos</p>
<?php foreach([-1=>'Anterior',1=>'Seguinte'] as $delta=>$label): $target=$op['page']+$delta; if($target<1||$target>$op['last_page'])continue; $query=array_replace($data['settlement_query']??$oq,['page'=>$target,'proposal_page'=>1,'event_page'=>1]); ?><a href="<?= $e('/supplier-payments?'.http_build_query($query,'','&',PHP_QUERY_RFC3986)) ?>"><?= $label ?></a> <?php endforeach ?></nav></section>
<?php endif ?>
<section id="obligation-balances"><h2>Obrigações e saldo por liquidar</h2><div class="scroll"><table><thead><tr><th>Origem / fornecedor</th><th>Principal</th><th>Afectado neste percurso</th><th>Saldo</th><th>Vencimento</th></tr></thead><tbody><?php foreach ($data['obligations'] as $o): ?><tr><td>#<?= $e($o['id']) ?> · <?= $e($o['origin_snapshot']['number'] ?? '') ?><br><?= $e($o['origin_snapshot']['supplier_name'] ?? '') ?><?php if(isset($o['evidence_url'])): ?><p><a href="<?= $e($o['evidence_url']) ?>">Conferir origem, propostas e histórico</a></p><?php endif ?><br>Versão <?= $e($o['source_revision']) ?> · Validador #<?= $e($o['recognised_by']) ?><details><summary>Versão e decisão</summary><pre><?= $e(json_encode($o, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre></details></td><td><?= $money($o['principal_minor']) ?></td><td><?= $money($o['allocated_payments_minor']) ?></td><td><?= $money($o['balance_minor']) ?><?= $o['source_conflict'] ? ' — origem divergente' : '' ?></td><td>Pendente<br>Atraso indeterminado<br>Dias de atraso: sem valor</td></tr><?php endforeach ?></tbody></table></div><?php if (! $data['obligations']): ?><p>Reconheça primeiro a obrigação no percurso existente. Não é criada uma dívida por registar um pagamento.</p><?php endif ?></section>
<?php if ($data['permissions']['payment_prepare'] && $eligible): ?><section><h2>1. Propor pagamento sintético</h2><form data-action="payment-prepare"><label>Obrigação de referência / fornecedor<select name="obligation_id" data-origin><?php $options(); ?></select></label><input type="hidden" name="source_revision"><label>Montante do pagamento (MZN)<input name="amount" required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><input type="hidden" name="currency" value="MZN"><label>Data factual do pagamento simulado<input type="date" name="paid_on" required></label><label>Referência estável do facto<input name="fact_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-PAY-001"></label><label>Evidência artificial de origem<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-EVID-001"></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Propor sem afectar dívida</button></form></section><?php endif ?>
<section id="payments"><h2>2. Pagamentos propostos e decisões</h2><p>A confirmação exige outra identidade. Depois de confirmar, troque para Preparar para propor a afectação; a dívida ainda não foi reduzida.</p><?php foreach ($data['payments'] as $p): ?><article><h3>Pagamento #<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?></h3><p><?= $money($p['amount_minor']) ?> · Data simulada <?= $e($p['paid_on']) ?> · Estado: <?= $e(['proposed'=>'Proposto','confirmed'=>'Confirmado em ensaio','rejected'=>'Rejeitado'][$p['state']]) ?></p><p>Por afectar: <?= $money($p['unallocated_minor']) ?> · Reconciliação bancária: não implementada.</p><details><summary>Preparação e evidência preservadas</summary><pre><?= $e($p['snapshot']) ?></pre><?php if ($p['decision']): ?><pre><?= $e(json_encode($p['decision'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre><?php endif ?></details>
<?php if ($data['permissions']['payment_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="payment-decide"><input type="hidden" name="payment_id" value="<?= $e($p['id']) ?>"><label>Decisão explícita<select name="decision"><option value="confirmed">Confirmar com evidência sintética</option><option value="rejected">Rejeitar</option></select></label><label>Evidência artificial da conferência<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-CONF-001"></label><label>Fundamento da conferência<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Registar decisão independente</button></form><?php endif ?></article><?php endforeach ?><?php if (! $data['payments']): ?><p>Sem pagamentos propostos neste contexto.</p><?php endif ?></section>
<section id="payments"><h2>2. Pagamentos propostos e decisões</h2><p>A confirmação exige outra identidade. Depois de confirmar, troque para Preparar para propor a afectação; a dívida ainda não foi reduzida.</p><?php foreach ($data['payments'] as $p): ?><article><h3>Pagamento #<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?></h3><p><?= $money($p['amount_minor']) ?> · Data simulada <?= $e($p['paid_on']) ?> · Estado: <?= $e(['proposed'=>'Proposto','confirmed'=>'Confirmado em ensaio','rejected'=>'Rejeitado'][$p['state']]) ?></p><?php if(isset($data['settlement_pagination'])): ?><p><a href="<?= $e($paymentFocusUrl((int)$p['id'])) ?>">Conferir este pagamento e as suas evidências</a></p><?php endif ?><p>Por afectar: <?= $money($p['unallocated_minor']) ?> · Reconciliação bancária: não implementada.</p><details><summary>Preparação e evidência preservadas</summary><pre><?= $e($p['snapshot']) ?></pre><?php if ($p['decision']): ?><pre><?= $e(json_encode($p['decision'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre><?php endif ?></details>
<?php if ($data['permissions']['payment_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="payment-decide"><input type="hidden" name="payment_id" value="<?= $e($p['id']) ?>"><label>Decisão explícita<select name="decision"><option value="confirmed">Confirmar com evidência sintética</option><option value="rejected">Rejeitar</option></select></label><label>Evidência artificial da conferência<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-CONF-001"></label><label>Fundamento da conferência<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Registar decisão independente</button></form><?php endif ?></article><?php endforeach ?><?php if (! $data['payments']): ?><p>Sem pagamentos propostos nesta janela.</p><?php endif ?><?php $settlementNav('payments'); ?></section>
<?php if ($data['permissions']['allocation_prepare'] && $confirmed && $eligible): ?><section><h2>3. Propor afectações</h2><form data-action="allocation-prepare"><label>Pagamento confirmado<select name="payment_id"><?php foreach ($confirmed as $p): ?><option value="<?= $e($p['id']) ?>">#<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?> · Por afectar <?= $money($p['unallocated_minor']) ?></option><?php endforeach ?></select></label><label>Referência estável da afectação<input name="reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-ALLOC-001"></label><div data-lines><div class="line" data-line><label>Obrigação<select data-obligation><?php $options(); ?></select></label><label>Valor a afectar (MZN)<input data-amount required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><button type="button" data-remove>Remover linha</button></div></div><button type="button" data-add>Adicionar outra obrigação</button><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><p>Mesmo fornecedor e moeda. O excedente fica por afectar. Não existe reembolso/crédito automático nem edição de uma afectação confirmada.</p><button>Submeter afectações sem abater saldo</button></form></section><?php endif ?>
<section id="allocation-proposals"><h2>4. Propostas de afectação</h2><p>A proposta não reduz a dívida. Troque para Validar para confirmar a afectação com outra identidade.</p><?php foreach ($data['allocation_proposals'] as $p): ?><article><h3>Proposta #<?= $e($p['id']) ?> · <?= $e($p['reference']) ?> · Pagamento #<?= $e($p['payment_id']) ?></h3><p>Preparador #<?= $e($p['prepared_by']) ?> · <?= $e($p['reason']) ?></p><ul><?php foreach (json_decode($p['plan'], true) as $line): ?><li>Obrigação #<?= $e($line['obligation_id']) ?>: <?= $money($line['amount_minor']) ?> · versão <?= $e($line['source_revision']) ?></li><?php endforeach ?></ul><p>Estado: <?= $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?></p>
<?php if ($data['permissions']['allocation_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="allocation-decide"><input type="hidden" name="proposal_id" value="<?= $e($p['id']) ?>"><label>Decisão<select name="decision"><option value="confirmed">Confirmar estas afectações</option><option value="rejected">Rejeitar</option></select></label><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar decisão e revalidar saldos</button></form><?php endif ?></article><?php endforeach ?></section>
<section id="allocation-proposals"><h2>4. Propostas de afectação</h2><p>A proposta não reduz a dívida. Troque para Validar para confirmar a afectação com outra identidade.</p><?php foreach ($data['allocation_proposals'] as $p): ?><article><h3>Proposta #<?= $e($p['id']) ?> · <?= $e($p['reference']) ?> · Pagamento #<?= $e($p['payment_id']) ?></h3><?php if(isset($data['settlement_pagination'])): ?><p><a href="<?= $e($paymentFocusUrl((int)$p['payment_id'])) ?>">Conferir pagamento de origem</a></p><?php endif ?><p>Preparador #<?= $e($p['prepared_by']) ?> · <?= $e($p['reason']) ?></p><ul><?php foreach (json_decode($p['plan'], true) as $line): ?><li>Obrigação #<?= $e($line['obligation_id']) ?>: <?= $money($line['amount_minor']) ?> · versão <?= $e($line['source_revision']) ?></li><?php endforeach ?></ul><p>Estado: <?= $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?></p>
<?php if ($data['permissions']['allocation_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="allocation-decide"><input type="hidden" name="proposal_id" value="<?= $e($p['id']) ?>"><label>Decisão<select name="decision"><option value="confirmed">Confirmar estas afectações</option><option value="rejected">Rejeitar</option></select></label><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar decisão e revalidar saldos</button></form><?php endif ?></article><?php endforeach ?><?php $settlementNav('allocation_proposals'); ?></section>
<?php require __DIR__.'/settlement-allocations.php'; ?>
<section id="reversals"><h2>5. Reversão integral e nova afectação</h2><p>Propor não altera saldos. Confirmar com outra identidade compensa integralmente a afectação original e volta a disponibilizar esse montante do pagamento. O pagamento continua confirmado: não há cancelamento bancário. Depois, use «Propor afectações» com uma nova referência para corrigir o valor.</p>
<?php if (! ($data['reversals_available'] ?? false)): ?><p>Actualização de reversões ainda não aplicada neste ensaio. Os saldos e o percurso anterior permanecem disponíveis.</p><?php else: ?>
<?php foreach ($data['allocations'] as $a): $reversal = null; $proposal = null; foreach ($data['reversals'] as $r) if ((int) $r->allocation_id === (int) $a->id) $reversal = $r; foreach ($data['reversal_proposals'] as $r) if ((int) $r->allocation_id === (int) $a->id) $proposal = $r; ?>
@@ -29,10 +43,11 @@ body{font:17px system-ui;color:#172f29;background:#f4f6f5;margin:0}main{max-widt
<?php elseif ($data['permissions']['reversal_prepare'] ?? false): ?><form data-action="reversal-prepare"><input type="hidden" name="allocation_id" value="<?= $e($a->id) ?>"><input type="hidden" name="source_revision" value="<?= $e($a->source_revision) ?>"><label>Motivo obrigatório da reversão integral<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Propor reversão integral sem alterar saldo</button></form><?php endif ?>
<?php if ($proposal): ?><details><summary>Proposta original de reversão preservada</summary><pre><?= $e(json_encode($proposal, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre></details><?php endif ?></article><?php endforeach ?>
<?php endif ?></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>
<section id="payment-history"><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><?php if(isset($data['settlement_pagination'])): ?><p><a href="<?= $e($paymentFocusUrl((int)$event->payment_id)) ?>">Conferir pagamento #<?= $e($event->payment_id) ?></a></p><?php endif ?><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?><?php $settlementNav('payment_events'); ?></section>
<p>Reversões parciais, cancelamento bancário, reconciliação e novos pagamentos sobre documentos com histórico importado ficam fora desta entrega. Os originais e as decisões confirmadas não são editados ou apagados.</p>
</main><script>
const settings=<?= json_encode($settings, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>;
if(settings.ledger_cycle_url){const button=document.createElement('button');button.textContent='Conferir contabilização e período QA';document.querySelector('main').prepend(button);button.onclick=async()=>{button.disabled=true;try{if(settings.ledger_scope){const r=await fetch('/api/context',{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf},body:JSON.stringify({...settings.ledger_scope,module_id:'accounting'})});if(!r.ok)throw Error('Módulo contabilístico indisponível.');}location.assign(settings.ledger_cycle_url);}catch(e){document.querySelector('#error').textContent=e.message;button.disabled=false;}};}
document.querySelector('#reload').onclick=()=>location.reload();
for(const selector of document.querySelectorAll('[data-origin]')){const sync=()=>selector.form.elements.source_revision.value=selector.selectedOptions[0]?.dataset.revision??'';selector.onchange=sync;sync();}
for(const form of document.querySelectorAll('[data-action]')){