Files
SGF/app/Modules/Accounting/GeneralLedger.php
T
2026-11-04 09:37:20 +02:00

144 lines
14 KiB
PHP

<?php
namespace App\Modules\Accounting;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Purchasing\SupplierPayables;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\GeneralLedger\{LedgerSchema,PayableJournal};
use Tempo\Finance\Payables\Payables;
final class GeneralLedger
{
public static function guard():void
{
abort_unless((app()->environment('payable-testing')||(app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true||config('industrial_ledger.synthetic_enabled')===true)))&&config('general_ledger.synthetic_enabled')===true,403,'Lançamentos apenas na composição sintética explicitamente preparada.');
LedgerSchema::assertSynthetic(DB::connection());
foreach(LedgerSchema::TABLES as $table)abort_unless(DB::getSchemaBuilder()->hasTable($table),503,'Núcleo contabilístico não instalado neste ensaio.');
}
private function scope(User $user):array
{
self::guard();$context=app(CompanyContext::class);
abort_unless(auth()->id()===$user->id&&$context->userId()===$user->id&&$context->unitId(),403);
if(request()->hasSession())abort_unless(request()->session()->get('company_context.module_id')==='accounting',409,'Seleccione Contabilidade.');
else abort_unless(app()->runningInConsole(),403);
$scope=$context->identifiers()+['unit_id'=>$context->unitId()];
app(Permissions::class)->require($user->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id']);return $scope;
}
public function can(User $user,string $action):bool
{
$scope=$this->scope($user);return app(Permissions::class)->has($user->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.'.$action,$scope['unit_id']);
}
public function engine(User $user,?\Closure $checkpoint=null):PayableJournal
{
$scope=$this->scope($user);$person=DB::table('pa_access')->where($scope)->where(['user_id'=>$user->id,'action'=>'view'])->value('person');abort_unless(is_string($person)&&$person!=='',403);
$authorize=function(string $action,bool $lock=true)use($user,$scope,$person):void{
self::guard();
if($lock){DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$user->id)->lockForUpdate()->firstOrFail();}
abort_unless($this->scope($user)===$scope,409,'Contexto alterado.');
app(CompanyContext::class)->selectFor($user->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id'=>$user->id,'action'=>'view'])->value('person')===$person,409,'Pessoa alterada; reabra a conferência.');
abort_unless($this->can($user,$action),403);
};
$sales=config('sales_ledger.synthetic_enabled')===true?app(SalesLedgerSource::class):null;
$cost=config('cost_ledger.synthetic_enabled')===true?app(CostLedgerSource::class):null;
$industrial=config('industrial_ledger.synthetic_enabled')===true?app(IndustrialLedgerSource::class):null;
$providers=array_values(array_filter([$sales,$cost,$industrial]));
$extra=$providers?function($kind,$id)use($sales,$cost,$industrial,$user){if(in_array($kind,['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'],true)){if(!$industrial)throw new \DomainException('Origem industrial não composta.');return $kind==='industry.cost.validated'?$industrial->source($user,$id):$industrial->correctionSource($user,$kind,$id);}if(in_array($kind,['workshop.direct_cost.validated','workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true)){if(!$cost)throw new \DomainException('Origem de custo não composta neste host.');return $kind==='workshop.direct_cost.validated'?$cost->source($user,$id):$cost->correctionSource($user,$kind,$id);}if(!$sales)throw new \DomainException('Origem comercial não composta neste host.');return $sales->source($user,$kind,$id);}:null;
$period=$providers?function($period)use($providers,$user){if(count($providers)===1)return $providers[0]->period($user,$period);$facts=0;$pending=0;$hash=hash_init('sha256');foreach($providers as $provider){$v=$provider->period($user,$period);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($hash,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($hash)];}:null;
return new PayableJournal(DB::connection(),$scope,$user->id,$person,fn(string $a)=>$authorize($a),fn()=>$authorize('view',false),fn(int $id)=>$this->source($user,$scope,$id),$checkpoint,$extra,$period);
}
public function industrial(User $user,?int $lot=null,int $page=1):array{
$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$lot,$page,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$this->scope($user);$origin=app(IndustrialLedgerSource::class)->workspace($user,$lot,$page);$data=$this->listing($user,['page'=>$page]+($lot!==null?['industrial_lot_id'=>$lot]:[]),false);if($lot===null){foreach(['journals','lines','proposals','reviews','events'] as $key)$data[$key]=[];$data['totals']=['debit_minor'=>0,'credit_minor'=>0,'net_minor'=>0];$data['pagination']=['page'=>$page,'last_page'=>1,'total'=>0];foreach($data['evidence_pagination'] as &$p)$p=['page'=>$page,'last_page'=>1,'total'=>0];unset($p);}$closed=DB::table('gl_periods')->where($origin['scope'])->where('period_key','QA-2026-10')->orderByDesc('version')->value('status')==='closed';$cycle=$lot!==null&&config('industrial_ledger.correction_enabled')===true?$this->cycle($user,$page,['industrial_lot_id'=>$lot]):['corrections'=>[],'correction_results'=>[],'pagination'=>[],'permissions'=>[]];foreach($cycle['corrections'] as $group){$v=json_decode($group->replacement_snapshot,true)['source'];$group->current=$group->current&&$origin['source']!==null&&($v['replacement_source_revision']??null)===$origin['source']['recognition_revision'];}return ['industrial'=>$origin,'book'=>$data,'closed'=>$closed,'corrections'=>$cycle['corrections'],'correction_results'=>$cycle['correction_results'],'pagination'=>$cycle['pagination'],'permissions'=>$cycle['permissions']];});
}
public function costs(User $user,?int $order=null,int $page=1):array
{
abort_unless(config('cost_ledger.synthetic_enabled')===true,403);$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$order,$page,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$commercial=app(CostLedgerSource::class)->workspace($user,$order,$page);$data=$this->cycle($user,$page,$order!==null?['cost_order_id'=>$order]:[]);foreach($data['corrections'] as $group){$source=json_decode($group->replacement_snapshot,true)['source'];if(isset($source['cost_order_id']))$group->current=$group->current&&$commercial['source']!==null&&($source['replacement_source_revision']??null)===$commercial['source']['recognition_revision'];}return ['cost'=>$commercial]+$data;});
}
private function source(User $user,array $scope,int $id):array
{
$obligation=DB::table('pa_obligations')->where($scope)->where('id',$id)->lockForUpdate()->first()??throw new \DomainException('Obrigação indisponível neste âmbito.');
$origin=DB::table('pa_sources')->where($scope)->where('id',$obligation->source_id)->lockForUpdate()->first()??throw new \DomainException('Referência factual ausente.');
$finance=app(SupplierPayables::class);$document=$finance->document($user,(int)$origin->local_source_id);
$position=$finance->engine($user)->positionForSource((int)$origin->local_source_id);
if(!$position||(int)$position['id']!==$id||$position['source_conflict'])throw new \DomainException('Fonte financeira mudou ou não está conferida.');
$proposal=DB::table('pa_proposals')->where($scope)->where('id',$obligation->proposal_id)->first()??throw new \DomainException('Conferência original ausente.');
return ['effect_type'=>'supplier_payable.recognised','origin_key'=>$origin->source_key,'obligation_id'=>$id,'local_source_id'=>(int)$origin->local_source_id,
'document_revision'=>$obligation->source_revision,'recognition_revision'=>hash('sha256',Payables::canonical(['obligation'=>(array)$obligation,'recognition_snapshot'=>$proposal->snapshot])),
'principal_minor'=>(int)$obligation->principal_minor,'currency'=>$obligation->currency,'due_on'=>$obligation->due_on,'tax_minor'=>$document['tax_minor'],
'compatible'=>true,'history_imported'=>$position['history_imported']];
}
public function report(User $user,string $kind,array $options):array
{
if(!in_array($kind,['trial-balance','account-ledger'],true))throw new \InvalidArgumentException('Relatório desconhecido.');$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$kind,$options,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$engine=$this->engine($user);
return $kind==='trial-balance'?$engine->trialBalance($options):$engine->accountLedger($options);
});
}
public function financialReceipt(User $user,string $domain,string $key):?array
{
if(!in_array($domain,['recognition','settlement'],true))throw new \InvalidArgumentException('Domínio de comprovativo inválido.');$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$domain,$key,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$this->scope($user);$engine=app(SupplierPayables::class)->engine($user);return $domain==='recognition'?$engine->receipt($key):$engine->settlements()->receipt($key);});
}
public function receipt(User $user,string $key):?array
{
$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$key,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $this->engine($user)->receipt($key);});
}
public function listing(User $user,array $options=[],bool $withAccounts=true):array
{
$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$options,$own,$withAccounts){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$data=$this->engine($user)->listing($options,$withAccounts);
$data['permissions']=array_combine(['prepare','validate','post'],array_map(fn($a)=>$this->can($user,$a),['prepare','validate','post']));return $data;
});
}
public function cycle(User $user,int $page=1,array $bookOptions=[]):array
{
abort_unless(config('general_ledger.cycle_enabled')===true,403);
$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$page,$own,$bookOptions){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$data=$this->engine($user)->cycleListing($page,$bookOptions['cost_order_id']??null,$bookOptions['industrial_lot_id']??null);
$data['book']=$this->listing($user,['page'=>$page]+$bookOptions);
$actions=['prepare','validate','post','correct_prepare','correct_validate','correct_post','period_prepare','period_validate','period_close'];
$data['permissions']=array_combine($actions,array_map(fn($a)=>$this->can($user,$a),$actions));return $data;
});
}
public function sales(User $user,?int $invoice=null,int $page=1):array
{
abort_unless(config('sales_ledger.synthetic_enabled')===true,403);$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$invoice,$page,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$commercial=app(SalesLedgerSource::class)->workspace($user,$invoice,$page);$data=$this->cycle($user,$page,$invoice!==null?['sales_invoice_id'=>$invoice]:[]);$data['commercial']=$commercial;
if($invoice!==null){$account=DB::table('gl_accounts')->where($data['commercial']['scope'])->where('code','QA-CLIENTES')->value('id');$data['client_account']=$account?$this->listing($user,['page'=>$page,'sales_invoice_id'=>$invoice,'account_id'=>$account])['totals']:null;}return $data;
});
}
/** Read one factual chain. No command, inferred association or module switch. */
public function flow(User $user,int $sourceId):array
{
$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$sourceId,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$scope=$this->scope($user);
$finance=app(SupplierPayables::class);
abort_unless($finance->can($user,'view'),403);
$page=$finance->listing($user,['source'=>$sourceId,'per_page'=>25]);
$document=$page['documents'][0]??abort(403);
$position=$page['obligations'][0]??null;
$proposal=null;$review=null;$journal=null;
if($position){
$proposal=DB::table('gl_proposals')->where($scope)->where('obligation_id',$position['id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->orderByDesc('capture_version')->first();
if($proposal)$review=DB::table('gl_reviews')->where($scope)->where('proposal_id',$proposal->id)->first();
$journal=DB::table('gl_journals')->where($scope)->where('obligation_id',$position['id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->first();
}
return compact('scope','document','position','proposal','review','journal')+['source_id'=>$sourceId,
'financial_proposals'=>$page['proposals'],'financial_events'=>$page['events'],
'source_conflict'=>$position['source_conflict']??false];
});
}
}