Files
SGF/packages/general-ledger/src/PayableJournal.php
T
2026-11-11 15:04:01 +02:00

173 lines
27 KiB
PHP

<?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 LedgerAssets;
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 ?array $financialScope=null) {
if($financialScope!==null){
if(array_keys($financialScope)!==['tenant_id','company_id','unit_id']||$financialScope['tenant_id']!==$scope['tenant_id']||!is_int($financialScope['company_id'])||!is_int($financialScope['unit_id'])||$financialScope['company_id']<0||$financialScope['unit_id']<0)throw new \InvalidArgumentException('Qualified financial source scope required.');
LedgerSchema::assertSynthetic($db);
if(!$db->getSchemaBuilder()->hasTable('gl_sgf_composition'))throw new \DomainException('Explicit SGF financial binding is absent.');
$binding=$db->table('gl_sgf_composition')->where('tenant_id',$scope['tenant_id'])->first();
if(!$binding||(int)$binding->company_id!==$scope['company_id']||(int)$binding->unit_id!==$scope['unit_id']||(int)$binding->financial_company_id!==$financialScope['company_id']||(int)$binding->financial_unit_id!==$financialScope['unit_id'])throw new \DomainException('Financial/ledger context binding changed.');
}
}
private function financialScope():array{return $this->financialScope??$this->scope;}
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','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||$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);
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($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');
$debit=$accounts->get($rule->debit_account_id);$credit=$accounts->get($rule->credit_account_id);
$codes=match($kind){'stock.supplier_return'=>['QA-DEVOLUCAO-FORNECEDOR','QA-STOCK'],'stock.adjustment'=>['QA-DIFERENCA-INVENTARIO','QA-STOCK'],'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','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);
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','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];}
$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','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];}
$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||(!LedgerSchema::externalHostComposition($this->db)&&!$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->financialScope())->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','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','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],
'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];
});
}
}