Files
SGF/app/Modules/Accounting/ComposedLedger.php
T
2026-11-11 09:00:17 +02:00

80 lines
9.6 KiB
PHP

<?php
namespace App\Modules\Accounting;
use App\Modules\Purchasing\{ReceiptRuntime,RequisitionAccess};
use App\Shared\Context\CompanyContext;
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{DB,Auth,Schema};
use Tempo\Finance\GeneralLedger\{LedgerSchema,PayableJournal,StockValuation};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{Invoice,PurchaseOrder};
use Tempo\Purchasing\Embedded\Services\{CurrentActor,OrganizationAccess,SupplierPayables};
/** Host authority and immutable commercial/cost sources; one native financial writer/book. */
final class ComposedLedger
{
private function withHost(object $host,object $user,\Closure $work):mixed
{
$default=DB::getDefaultConnection();$guard=Auth::getDefaultDriver();$nativeUser=Auth::user();$environment=app()->environment();
DB::setDefaultConnection($host->getName());Schema::clearResolvedInstance('db.schema');Auth::shouldUse('web');Auth::guard('web')->setUser($user);app()->instance('env','budget-testing');
try{return $work();}finally{DB::setDefaultConnection($default);Schema::clearResolvedInstance('db.schema');Auth::shouldUse($guard);if($nativeUser)Auth::guard($guard)->setUser($nativeUser);app()->instance('env',$environment);}
}
private function source(object $actor,array $binding,array $financialScope,int $id):array
{
$o=DB::table('pa_obligations')->where($financialScope)->where('id',$id)->first()??throw new \DomainException('Obrigação fora da origem financeira autorizada.');
$s=DB::table('pa_sources')->where($financialScope)->where('id',$o->source_id)->first()??throw new \DomainException('Origem financeira ausente.');
$orders=PurchaseOrder::visibleTo($actor)->where(fn($q)=>$q->whereIn('id',$binding['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)));
abort_unless(Invoice::where('tenant_id',$actor->tenant_id)->whereIn('purchase_order_id',$orders->select('id'))->where('id',$s->local_source_id)->exists(),403);
$service=app(SupplierPayables::class);$service->readContext($actor);$document=$service->document($actor,(int)$s->local_source_id);$position=$service->engine($actor)->positionForSource((int)$s->local_source_id);
if(!$position||(int)$position['id']!==$id||$position['source_conflict'])throw new \DomainException('A fonte financeira mudou; exige nova conferência.');
$proposal=DB::table('pa_proposals')->where($financialScope)->where('id',$o->proposal_id)->first()??throw new \DomainException('Reconhecimento sem conferência.');
return ['effect_type'=>'supplier_payable.recognised','origin_key'=>$s->source_key,'obligation_id'=>$id,'local_source_id'=>(int)$s->local_source_id,'document_revision'=>$o->source_revision,'recognition_revision'=>hash('sha256',Payables::canonical(['obligation'=>(array)$o,'recognition_snapshot'=>$proposal->snapshot])),'principal_minor'=>(int)$o->principal_minor,'currency'=>$o->currency,'due_on'=>$o->due_on,'tax_minor'=>$document['tax_minor'],'compatible'=>true,'history_imported'=>$position['history_imported']];
}
public function run(string $action,\Closure $work):mixed
{
CompositionRuntime::assert();abort_unless(config('sgf_single_installation.ledger_enabled')===true,404);
abort_unless(request()->session()->get('company_context.module_id')==='accounting',409,'Seleccione Contabilidade.');
$context=app(CompanyContext::class);$scope=$context->identifiers()+['unit_id'=>$context->unitId()];$hostUser=$context->userId();$host=DB::connection();$hostIdentity=CompositionRuntime::physical($host);
$generation=request()->session()->get('company_context.token');$hostActor=\App\Models\User::findOrFail($hostUser);
try{return $host->transaction(function()use($action,$work,$context,$scope,$hostUser,$hostActor,$host,$hostIdentity,$generation){
$host->statement("SET LOCAL lock_timeout='5s'");$host->table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();
return app(ReceiptRuntime::class)->run('general_ledger.'.$action,function($actor,$binding,$revision,$origin)use($action,$work,$context,$scope,$hostUser,$hostActor,$host,$hostIdentity,$generation){
$native=DB::connection();LedgerSchema::assertSynthetic($native);$r=$native->table('gl_sgf_composition')->where('tenant_id',$actor->tenant_id)->first();
abort_unless($r&&$r->host_database===$hostIdentity['database']&&(int)$r->company_id===$scope['company_id']&&(int)$r->unit_id===$scope['unit_id'],403,'Livro financeiro ligado a outro contexto SGF.');
$ledgerScope=['tenant_id'=>(int)$actor->tenant_id,'company_id'=>$scope['company_id'],'unit_id'=>$scope['unit_id']];$financialScope=['tenant_id'=>(int)$actor->tenant_id,'company_id'=>0,'unit_id'=>0];
// The legacy payable book has no SGF unit column. Never expose its
// listings or settlement facts until every obligation is qualified.
$orders=PurchaseOrder::visibleTo($actor)->where(fn($q)=>$q->whereIn('id',$binding['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)));
$unqualified=$native->table('pa_obligations as o')->where(['o.tenant_id'=>$actor->tenant_id,'o.company_id'=>0,'o.unit_id'=>0])->whereNotExists(function($q)use($orders,$actor){
$q->selectRaw('1')->from('pa_sources as s')->join('invoices as i','i.id','=','s.local_source_id')->whereColumn('s.id','o.source_id')->whereColumn('s.tenant_id','o.tenant_id')->whereColumn('s.company_id','o.company_id')->whereColumn('s.unit_id','o.unit_id')->where('i.tenant_id',$actor->tenant_id)->whereIn('i.purchase_order_id',$orders->select('id'));
})->exists();
abort_if($unqualified,503,'Há origens financeiras sem unidade conferida; a partição exige decisão explícita.');
$identity=app(SupplierPayables::class)->readContext($actor);$person=$identity['person'];
$authorize=function(string $right)use($host,$hostUser,$scope,$context,$actor,$person,$ledgerScope,$generation){
abort_unless($context->userId()===$hostUser&&$context->identifiers()+['unit_id'=>$context->unitId()]===$scope&&request()->session()->get('company_context.token')===$generation,409,'Contexto SGF alterado.');
foreach(['general_ledger.view','general_ledger.'.$right] as $permission)abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$hostUser,'permission'=>$permission])->exists(),403);
$fresh=app(CurrentActor::class)->lock($actor,['finance']);abort_if(app(OrganizationAccess::class)->maintenanceOnly($fresh),403);
abort_unless(DB::table('pa_access')->where($ledgerScope)->where(['user_id'=>$actor->id,'action'=>'general_ledger.'.$right,'person'=>$person])->exists(),403);
};
$authorize('view');
$isolation=fn()=>app(ComposedMaterialIsolation::class)->check($host,$native,$ledgerScope);
$stock=new StockValuation($native,$ledgerScope,$actor->id,$person,$authorize,fn()=>$authorize('view'),$isolation);
$additional=function($kind,$id)use($host,$hostActor,$stock,$isolation){
if(str_starts_with($kind,'stock.'))return $stock->source($kind,$id);
if(str_starts_with($kind,'workshop.')||str_starts_with($kind,'industry.'))$isolation();
return $this->withHost($host,$hostActor,function()use($hostActor,$kind,$id){
if(str_starts_with($kind,'cash.'))return app(CashLedgerSource::class)->source($hostActor,$kind,$id);if(str_starts_with($kind,'workshop.'))return $kind==='workshop.direct_cost.validated'?app(CostLedgerSource::class)->source($hostActor,$id):app(CostLedgerSource::class)->correctionSource($hostActor,$kind,$id);
if(str_starts_with($kind,'industry.'))return $kind==='industry.cost.validated'?app(IndustrialLedgerSource::class)->source($hostActor,$id):app(IndustrialLedgerSource::class)->correctionSource($hostActor,$kind,$id);
return app(SalesLedgerSource::class)->source($hostActor,$kind,$id);
});
};
$period=function($p)use($host,$hostActor,$stock){$extra=$this->withHost($host,$hostActor,function()use($p,$hostActor){$facts=0;$pending=0;$h=hash_init('sha256');foreach([SalesLedgerSource::class,CostLedgerSource::class,IndustrialLedgerSource::class,CashLedgerSource::class] as $class){$v=app($class)->period($hostActor,$p);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];});$physical=$stock->period($p);return ['facts'=>$extra['facts']+$physical['facts'],'pending'=>$extra['pending']+$physical['pending'],'hash'=>hash('sha256',$extra['hash'].$physical['hash'])];};
$engine=new PayableJournal($native,$ledgerScope,$actor->id,$person,$authorize,fn()=>$authorize('view'),fn(int $id)=>$this->source($actor,$binding,$financialScope,$id),null,$additional,$period,$financialScope);
return $work($engine,$actor,$binding,$origin+['sgf_host_user'=>$hostUser,'sgf_host_scope'=>$scope,'binding_revision'=>$revision],$ledgerScope,$stock,fn(\Closure $read)=>$this->withHost($host,$hostActor,$read));
});
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Livro ocupado. Recupere a mesma operação, sem gerar outra chave.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}
}
}