Files
SGF/app/Modules/Accounting/FixedAssetLedgerSource.php
T
2026-11-11 15:04:01 +02:00

42 lines
9.6 KiB
PHP

<?php
namespace App\Modules\Accounting;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\Payables;
/** Read-only qualified source adapter. Never creates a journal or debt. */
final class FixedAssetLedgerSource
{
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
public function source(User $actor,string $kind,int $id,object $native,array $scope,bool $historical=false):array{
$service=app(FixedAssets::class);abort_unless($service->scope($actor->id)===$scope,403);
if(in_array($kind,['assets.depreciation.reversal','assets.depreciation.replacement'],true)){
$j=$native->table('gl_journals')->where($scope)->where('id',$id)->first()??abort(403);$original=json_decode($j->snapshot,true);abort_unless($original['source']['effect_type']==='assets.depreciation',409,'Só a quota original admite esta correcção.');
$base=$this->source($actor,'assets.depreciation',(int)$original['source']['source_id'],$native,$scope);abort_unless($base['recognition_revision']!==$original['source']['recognition_revision'],409,'Não existe nova quota revista.');return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$kind==='assets.depreciation.reversal'?(int)$j->debit_minor:$base['amount_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>$this->hash(['current'=>$base,'original'=>(array)$j,'kind'=>$kind]),'original_journal_id'=>$id,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>$this->hash((array)$j)]);
}
abort_unless(in_array($kind,['assets.admitted','assets.depreciation','assets.writeoff'],true),422);$initial=$service->byId($scope,$id);abort_unless($initial&&$initial['state']==='approved',403);$expected=match($kind){'assets.admitted'=>'asset','assets.depreciation'=>'charge','assets.writeoff'=>'disposal'};abort_unless($initial['kind']===$expected,409);$current=$service->approved($scope,$initial['record_key'],$expected);$asset=$expected==='asset'?$current:$service->approved($scope,$current['data']['input']['asset_key'],'asset');$a=$asset['data']['input'];abort_unless($a['cost']!==null&&$a['residual']!==null&&$a['available_on']!==null,409,'Dados financeiros do activo indeterminados.');
$balance=$service->balance($scope,$asset['record_key'],$native);$amount=(int)str_replace('.','',$a['cost']);$date=$a['available_on'];$facts=['asset'=>FixedAssets::financial($asset)];$extra=[];
if($expected==='charge'){
abort_unless($balance['cost_minor']===$amount&&($historical||!$balance['disposed']),409,'Admissão actual contabilizada obrigatória.');$p=$service->approved($scope,$current['data']['input']['plan_key'],'plan');abort_unless(($historical||$p['data']['status']==='active')&&$p['data']['input']['asset_key']===$asset['record_key'],409,'Plano suspenso ou incompatível.');abort_unless($current['data']['dependencies']['plan']['data']['input']['life_months']===$p['data']['input']['life_months']&&FixedAssets::financial($current['data']['dependencies']['asset'])===FixedAssets::financial($asset),409,'Plano/dados alterados; confira a quota.');
$c=$current['data']['calculation'];abort_unless($c['complete'],409);$amount=(int)$c['quota_cents'];$month=$current['data']['input']['month'];$date=(new \DateTimeImmutable($month.'-01'))->modify('last day of this month')->format('Y-m-d');for($n=1;$n<$c['month'];$n++){$previous=(new \DateTimeImmutable(substr($a['available_on'],0,7).'-01'))->modify('+'.($n-1).' months')->format('Y-m');abort_unless(isset($balance['charges'][$previous]),409,'Quota anterior não contabilizada.');}
if(!$historical&&!isset($balance['charges'][$month]))abort_unless($balance['accumulated_minor']===(int)$c['accumulated_cents']-$amount&&$balance['accumulated_minor']+$amount<=(int)$c['cost_cents']-(int)$c['residual_cents'],409,'Acumulado não corresponde às quotas anteriores revistas; exige conferência/correcção.');
$facts+=['charge_version'=>$current['id'],'plan'=>$current['data']['dependencies']['plan']['id'],'calculation'=>$c];$extra=['charge_key'=>$current['record_key'],'charge_month'=>$month];
}
if($expected==='disposal'){
$pending=DB::table('fa_versions as v')->where(array_combine(array_map(fn($k)=>'v.'.$k,array_keys($scope)),array_values($scope)))->whereIn('v.kind',['plan','charge'])->whereRaw("v.data->'input'->>'asset_key'=?",[$asset['record_key']])->where('v.state','prepared')->whereNotExists(function($q){$q->selectRaw('1')->from('fa_versions as n')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->whereColumn('n.record_key','v.record_key')->whereColumn('n.version','>','v.version');})->exists();abort_if($pending,409,'Plano ou quota aguarda decisão; confira antes do abate.');
foreach($native->table('gl_journals')->where($scope)->where('snapshot->source->asset_key',$asset['record_key'])->where('snapshot->source->effect_type','assets.depreciation')->orderBy('id')->get()as$q){$snap=json_decode($q->snapshot,true)['source'];$currentSource=$this->source($actor,'assets.depreciation',(int)$snap['source_id'],$native,$scope);if($snap['recognition_revision']!==$currentSource['recognition_revision']){$pair=$native->table('gl_correction_results')->where($scope)->where('original_journal_id',$q->id)->first();$replacement=$pair?$native->table('gl_journals')->where($scope)->where('id',$pair->replacement_journal_id)->first():null;abort_unless($replacement&&(json_decode($replacement->snapshot,true)['source']['replacement_source_revision']??null)===$currentSource['recognition_revision'],409,'Quota exige correcção aplicada antes do abate.');}}
abort_unless($balance['cost_minor']===$amount&&!$balance['disposed'],409,'Activo sem custo aplicado ou já abatido.');$frozen=$current['data']['calculation'];abort_unless($this->hash($balance)===$this->hash($frozen),409,'Saldo alterado; confira novo abate.');$date=$current['data']['input']['effect_on'];$last=$native->table('gl_journals')->where($scope)->where('snapshot->source->asset_key',$asset['record_key'])->max('journal_on');abort_unless($last===null||$last<=$date,409,'Abate anterior a movimentos.');$facts+=['disposal_version'=>$current['id'],'balance'=>$balance];$extra=['cost_minor'=>$balance['cost_minor'],'accumulated_minor'=>$balance['accumulated_minor'],'carrying_minor'=>$balance['carrying_minor']];
}
abort_unless($amount>0,409,'Efeito nulo não cria lançamento.');$observedKeys=array_values(array_unique(array_filter([$asset['record_key'],$current['record_key'],$current['data']['input']['plan_key']??null])));$observed=DB::table('fa_versions')->where($scope)->whereIn('record_key',$observedKeys)->select('record_key')->selectRaw('max(version) as version')->groupBy('record_key')->orderBy('record_key')->get()->map(fn($r)=>(array)$r)->all();$extra['administrative_revision']=$this->hash($observed);$first=DB::table('fa_versions')->where($scope)->where(['record_key'=>$current['record_key'],'state'=>'approved'])->orderBy('version')->value('id');return $extra+['effect_type'=>$kind,'source_id'=>(int)$first,'obligation_id'=>(int)$first,'asset_key'=>$asset['record_key'],'origin_key'=>$this->hash(['scope'=>$scope,'asset'=>$asset['record_key'],'kind'=>$kind,'month'=>$extra['charge_month']??null]),'host_database'=>DB::selectOne('SELECT current_database() d')->d,'permitted_period_key'=>'QA-2026-INTEGRADO','effect_on'=>$date,'recognition_revision'=>$this->hash($facts),'document_revision'=>$this->hash($facts),'amount_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false];
}
public function period(User $actor,array $period,object $native,array $s):array{
app(FixedAssets::class)->scope($actor->id);$h=hash_init('sha256');$facts=0;$pending=0;$rows=DB::table('fa_versions as v')->where(array_combine(array_map(fn($k)=>'v.'.$k,array_keys($s)),array_values($s)))->where('v.state','approved')->whereIn('v.kind',['asset','charge','disposal'])->whereNotExists(function($q){$q->selectRaw('1')->from('fa_versions as n')->whereColumn('n.record_key','v.record_key')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->where('n.state','approved')->whereColumn('n.version','>','v.version');});
foreach($rows->orderBy('v.id')->cursor()as$r){$d=json_decode($r->data,true);if($d['status']==='cancelled')continue;$on=match($r->kind){'asset'=>$d['input']['available_on'],'charge'=>$d['input']['month'].'-01','disposal'=>$d['input']['effect_on']};if($on!==null&&($on<$period['starts_on']||$on>$period['ends_on']))continue;$facts++;hash_update($h,$r->data);$kind=match($r->kind){'asset'=>'assets.admitted','charge'=>'assets.depreciation','disposal'=>'assets.writeoff'};
$j=$native->table('gl_journals')->where($s)->where('snapshot->source->effect_type',$kind)->where('snapshot->source->asset_key',$r->kind==='asset'?$r->record_key:$d['input']['asset_key']);if($r->kind==='charge')$j->where('snapshot->source->charge_key',$r->record_key);$j=$j->first();
if($r->kind==='disposal'&&$j)continue;
try{$source=$this->source($actor,$kind,(int)$r->id,$native,$s,true);if(!$j){$pending++;continue;}$snap=json_decode($j->snapshot,true)['source'];if($snap['recognition_revision']!==$source['recognition_revision']){$pair=$native->table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();$replacement=$pair?$native->table('gl_journals')->where($s)->where('id',$pair->replacement_journal_id)->first():null;if(!$replacement||(json_decode($replacement->snapshot,true)['source']['replacement_source_revision']??null)!==$source['recognition_revision'])$pending++;}}
catch(\DomainException|\Symfony\Component\HttpKernel\Exception\HttpException $e){$pending++;}
}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
}