commit melhorias1

This commit is contained in:
2026-11-11 15:04:01 +02:00
parent 1d11a5e57b
commit cd9897df05
145 changed files with 10950 additions and 84 deletions
+1
View File
@@ -10,6 +10,7 @@ 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/LedgerAssets.php';
require_once __DIR__.'/src/PayableJournal.php';
require_once __DIR__.'/src/StockSchema.php';
require_once __DIR__.'/src/StockValuation.php';
@@ -0,0 +1,14 @@
<?php
namespace Tempo\Finance\GeneralLedger;
/** Typed source capture only; all prepare/review/post transactions remain in PayableJournal. */
trait LedgerAssets
{
private function captureAssets(array $s,string $date,object $period,object $rule,int $amount):array
{
$meta=$this->db->table('gl_asset_books')->where($this->scope)->first();$kind=$s['effect_type'];if(!$meta||$meta->policy!=='QA-ASSET-JOURNAL-v1'||$meta->host_database!==($s['host_database']??null)||$rule->policy!=='QA-ASSET-JOURNAL-v1'||$rule->effect_type!==$kind||$rule->currency!=='MZN'||$date<$rule->valid_from||$date>$rule->valid_to||$s['permitted_period_key']!==$period->period_key)throw new \DomainException('Origem de activos fora da composição ou regra revista.');
$correction=in_array($kind,['assets.depreciation.reversal','assets.depreciation.replacement'],true);if((!$correction&&$date!==$s['effect_on'])||($correction&&$date<$s['original_journal_on']))throw new \DomainException('Data da origem de activos incompatível.');
$parts=match($kind){'assets.admitted'=>[['QA-ACTIVO-CUSTO',$amount,0],['QA-ACTIVO-ORIGEM-QA',0,$amount]],'assets.depreciation','assets.depreciation.replacement'=>[['QA-ACTIVO-GASTO-DEP',$amount,0],['QA-ACTIVO-DEP-ACUM',0,$amount]],'assets.depreciation.reversal'=>[['QA-ACTIVO-DEP-ACUM',$amount,0],['QA-ACTIVO-GASTO-DEP',0,$amount]],'assets.writeoff'=>[['QA-ACTIVO-DEP-ACUM',$s['accumulated_minor'],0],['QA-ACTIVO-GASTO-ABATE',$s['carrying_minor'],0],['QA-ACTIVO-CUSTO',0,$s['cost_minor']]],default=>throw new \DomainException('Tipo de activo não suportado.')};
$parts=array_values(array_filter($parts,fn($p)=>$p[1]+$p[2]>0));$accounts=$this->q('accounts')->whereIn('code',array_column($parts,0))->get()->keyBy('code');$lines=[];foreach($parts as[$code,$debit,$credit]){$a=$accounts->get($code);if(!$a||!$a->is_movement||$a->currency!=='MZN')throw new \DomainException('Conta de activo incompatível.');$lines[]=['account_id'=>$a->id,'account_code'=>$code,'debit_minor'=>$debit,'credit_minor'=>$credit];}
$debit=array_sum(array_column($lines,'debit_minor'));$credit=array_sum(array_column($lines,'credit_minor'));if($debit!==$amount||$credit!==$amount||count($lines)<2||count($lines)>3||$rule->debit_account_id!==$lines[0]['account_id']||$rule->credit_account_id!==$lines[count($lines)-1]['account_id'])throw new \DomainException('Plano do activo não concilia com regra ou montante.');return['contract'=>'SGF-FIXED-ASSETS-INTEGRATED-QA-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]];
}
}
+3 -2
View File
@@ -51,7 +51,7 @@ trait LedgerCycle
$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','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed','workshop.direct_cost.validated','industry.cost.validated','stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_return'],true))throw new \InvalidArgumentException('Seleccione uma origem contabilística suportada.');
if(!in_array($d['effect_type'],['allocation.confirmed','allocation.reversed','sales.invoice','receivable.allocated','receivable.reversed','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed','workshop.direct_cost.validated','industry.cost.validated','stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_return','assets.admitted','assets.depreciation','assets.writeoff'],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);
@@ -65,8 +65,9 @@ trait LedgerCycle
$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';
$isAsset=json_decode($original->snapshot,true)['source']['effect_type']==='assets.depreciation';
$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){
foreach($isAsset?['assets.depreciation.reversal'=>'QA-ASSET-CORRECTION-REVERSE','assets.depreciation.replacement'=>'QA-ASSET-CORRECTION-REPLACE']:($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);
+11 -8
View File
@@ -8,6 +8,7 @@ use Tempo\Finance\Payables\Payables;
final class PayableJournal
{
use LedgerCycle;
use LedgerAssets;
use LedgerSales;
use LedgerReports;
public const POLICY='QA-PAYABLE-JOURNAL-v1';
@@ -37,8 +38,9 @@ final class PayableJournal
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','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed'],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);
$asset=in_array($kind,['assets.admitted','assets.depreciation','assets.writeoff','assets.depreciation.reversal','assets.depreciation.replacement'],true);
$stock=in_array($kind,['stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_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));
$s=$sales||$cost||$industrial||$stock||$asset?($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);
@@ -47,6 +49,7 @@ final class PayableJournal
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($asset)return $this->captureAssets($s,$date,$period,$rule,$amount);
if($rule->policy!==(str_starts_with($kind,'cash.')?'QA-CASH-JOURNAL-v1':($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(str_starts_with($kind,'cash.')&&($s['permitted_period_key']!==$periodKey||$s['cash_effect_on']!==$date))throw new \DomainException('Data ou período de Caixa incompatível.');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');
@@ -83,7 +86,7 @@ final class PayableJournal
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(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','assets.depreciation.reversal','assets.depreciation.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);
@@ -107,7 +110,7 @@ final class PayableJournal
{
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(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','assets.depreciation.reversal','assets.depreciation.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];}
@@ -118,7 +121,7 @@ final class PayableJournal
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(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','assets.depreciation.reversal','assets.depreciation.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];}
@@ -153,12 +156,12 @@ final class PayableJournal
$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();
$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','assets.depreciation.reversal','assets.depreciation.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')));
$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','assets.depreciation.reversal','assets.depreciation.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','assets.depreciation.reversal','assets.depreciation.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','assets.depreciation.reversal','assets.depreciation.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],