commit de compras
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);});
|
||||
}
|
||||
}
|
||||
@@ -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;}}
|
||||
}
|
||||
@@ -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&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>
|
||||
@@ -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>
|
||||
@@ -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&to_on=2026-10-31">Balancete</a> · <a href="/stock-valuation/reports/account-ledger?from_on=2026-10-01&to_on=2026-10-31&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']) ?>&page=<?= $e($p['page']-1) ?>&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']) ?>&page=<?= $e($p['page']+1) ?>&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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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]')){
|
||||
|
||||
Reference in New Issue
Block a user