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

43 lines
9.8 KiB
PHP

<?php
namespace App\Modules\Accounting;
use App\Models\User;
use App\Modules\Industry\IndustrialCosts;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Brick\Math\BigRational;
use Tempo\Finance\Payables\Payables;
final class IndustrialLedgerSource
{
private function scope(User $u):array{
abort_unless(config('industrial_ledger.synthetic_enabled')===true,403);IndustrialCosts::guard();$c=app(CompanyContext::class);abort_unless(auth()->id()===$u->id&&$c->userId()===$u->id&&$c->unitId(),403);$s=$c->identifiers()+['unit_id'=>$c->unitId()];app(Permissions::class)->require($u->id,$s['tenant_id'],$s['company_id'],'industrial_costs.view',$s['unit_id']);foreach(['gl_industrial_sources','ic_lots','ic_results','ic_inputs','ic_decisions'] as $t)abort_unless(str_starts_with($t,'gl_')?SourceBook::has($t):DB::getSchemaBuilder()->hasTable($t),503,'Origem industrial indisponível.');return $s;
}
private function facts(User $u,object $map):array{return ['mapping'=>(array)$map,'conference'=>app(IndustrialCosts::class)->conference($map->lot_id,$u->id)];}
private function eligible(array $s,array $facts):array{
$m=$facts['mapping'];$c=$facts['conference'];$snap=$c['snapshot'];$r=$c['result'];$d=$c['decision'];
if(!$r||!$c['product']||($c['product']['id']??null)!==$c['lot']['product_id']||!$c['current']||!$d||$d['decision']!=='approved'||$d['actor_id']===$r['actor_id']||!$snap||$snap['incomplete']||$snap['selected_total_exact']===null||$snap['currency']!=='MZN'||!$snap['reconciliation']['balanced'])throw new \DomainException('Resultado industrial actual completo e validado distintamente obrigatório.');
$origins=[];foreach(['material','labour','pool','machine','production'] as $kind)$origins[$kind]=$snap['origins'][$kind]['source_ref'];if(Payables::canonical($origins)!==Payables::canonical(json_decode($m['origins'],true)))throw new \DomainException('Perímetro industrial alterado; nova conferência necessária.');
if(count(array_unique($origins))!==5)throw new \DomainException('Origem industrial ambígua.');
if(DB::table('ic_inputs')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('lot_id','<>',$m['lot_id'])->whereIn('source_ref',array_values($origins))->exists())throw new \DomainException('Origem reutilizada entre lotes.');
if(SourceBook::has('gl_cost_sources')&&SourceBook::table('gl_cost_sources')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where(fn($q)=>$q->whereIn('material_origin',array_values($origins))->orWhereIn('time_origin',array_values($origins)))->exists())throw new \DomainException('Origem já composta como custo directo; não reconhecer novamente.');
try{$amount=BigRational::of($snap['selected_total_exact'])->multipliedBy(100)->toBigInteger()->toInt();}catch(\Brick\Math\Exception\MathException $e){throw new \DomainException('Montante industrial não representável em cêntimos; sem arredondamento.',0,$e);}if($amount<=0)throw new \DomainException('Montante seleccionado deve ser positivo.');
return ['effect_type'=>'industry.cost.validated','source_id'=>$m['lot_id'],'industrial_lot_id'=>$m['lot_id'],'obligation_id'=>$m['lot_id'],'origin_key'=>hash('sha256',Payables::canonical(['scope'=>$s,'lot'=>$m['lot_id'],'origins'=>$origins])),'recognition_revision'=>hash('sha256',Payables::canonical($facts)),'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'permitted_period_key'=>$m['period_key'],'period_start'=>$c['lot']['period_start'],'period_end'=>$c['lot']['period_end'],'result_id'=>$r['id'],'result_version'=>$r['version'],'document_revision'=>$r['fingerprint'],'good_units'=>$snap['good_units'],'unit_cost_exact'=>$snap['unit_cost_exact'],'residual_exact'=>$snap['residual_exact'],'industrial_evidence'=>$facts,'label'=>'Resultado industrial seleccionado de ensaio; residual separado, sem stock ou salários contabilizados'];
}
public function source(User $u,int $lot):array{$s=$this->scope($u);$m=SourceBook::table('gl_industrial_sources')->where($s)->where('lot_id',$lot)->first()??throw new \DomainException('Lote fora do âmbito composto.');return $this->eligible($s,$this->facts($u,$m));}
public function correctionSource(User $u,string $kind,int $id):array{
abort_unless(config('industrial_ledger.correction_enabled')===true,403);$s=$this->scope($u);abort_unless(SourceBook::has('gl_industrial_correction_capability'),503);if(!in_array($kind,['industry.cost.reversal','industry.cost.replacement'],true))throw new \DomainException('Tipo de correcção industrial inválido.');$j=SourceBook::table('gl_journals')->where($s)->where('id',$id)->first()??throw new \DomainException('Original fora do âmbito.');$old=json_decode($j->snapshot,true);if($old['source']['effect_type']!=='industry.cost.validated')throw new \DomainException('Apenas o original industrial admite esta correcção.');$base=$this->source($u,(int)$old['source']['industrial_lot_id']);if($base['origin_key']!==$old['source']['origin_key']||$base['permitted_period_key']!==$old['period']['period_key']||$base['recognition_revision']===$old['source']['recognition_revision'])throw new \DomainException('Origem/perímetro/período sem sucessora compatível.');return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$kind==='industry.cost.reversal'?(int)$j->debit_minor:$base['principal_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>hash('sha256',Payables::canonical(['current_source'=>$base,'original'=>(array)$j,'kind'=>$kind])),'original_journal_id'=>$id,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>hash('sha256',Payables::canonical((array)$j)),'original_industrial_source'=>$old['source']]);
}
private function resolved(array $s,object $j,array $source):bool{
if($j->source_revision===$source['recognition_revision'])return true;if(config('industrial_ledger.correction_enabled')!==true||!SourceBook::has('gl_industrial_correction_capability'))return false;$r=SourceBook::table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();if(!$r)return false;$replacement=SourceBook::table('gl_journals')->where($s)->where('id',$r->replacement_journal_id)->first();if(!$replacement)return false;$v=json_decode($replacement->snapshot,true)['source'];return $v['effect_type']==='industry.cost.replacement'&&($v['industrial_lot_id']??null)===$source['industrial_lot_id']&&($v['replacement_source_revision']??null)===$source['recognition_revision'];
}
public function period(User $u,array $period):array{
$s=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;foreach(SourceBook::table('gl_industrial_sources')->where($s)->where('period_key',$period['period_key'])->orderBy('id')->cursor() as $m){$v=$this->facts($u,$m);hash_update($h,Payables::canonical($v));$facts++;try{$source=$this->eligible($s,$v);$j=SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$m->lot_id)->first();if(!$j||!$this->resolved($s,$j,$source)||$source['period_start']<$period['starts_on']||$source['period_end']>$period['ends_on'])$pending++;}catch(\DomainException){$pending++;}}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
public function workspace(User $u,?int $lot,int $page):array{
$s=$this->scope($u);$p=SourceBook::table('gl_industrial_sources')->where($s)->orderBy('lot_id')->paginate(25,['lot_id','period_key'],'page',$page);$facts=null;$source=null;$pending=null;$journal=null;if($lot!==null){$m=SourceBook::table('gl_industrial_sources')->where($s)->where('lot_id',$lot)->first()??abort(403,'Lote fora do âmbito.');$facts=$this->facts($u,$m);try{$source=$this->eligible($s,$facts);}catch(\DomainException $e){$pending=$e->getMessage();}$journal=SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$lot)->first();}return ['scope'=>$s,'lots'=>$p->items(),'pagination'=>['page'=>$page,'total'=>$p->total(),'last_page'=>$p->lastPage()],'lot_id'=>$lot,'facts'=>$facts,'source'=>$source,'pending'=>$pending,'journal'=>$journal,'accounting_state'=>$journal?($source&&$this->resolved($s,$journal,$source)?'current':'requires_future_correction'):'unposted'];
}
public function cards(User $u,int $page):array{
$s=$this->scope($u);$p=SourceBook::table('gl_industrial_sources')->where($s)->orderBy('lot_id')->paginate(25,['lot_id','period_key'],'industrial_page',$page);$ids=array_column($p->items(),'lot_id');$results=DB::table('ic_results as r')->where(array_combine(array_map(fn($k)=>'r.'.$k,array_keys($s)),array_values($s)))->whereIn('r.lot_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('ic_results as n')->whereColumn('n.previous_id','r.id'))->get(['r.id','r.lot_id','r.version'])->keyBy('lot_id');$decisions=DB::table('ic_decisions')->whereIn('result_id',$results->pluck('id')->all())->get(['result_id','decision'])->keyBy('result_id');$posted=SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->whereIn('snapshot->source->industrial_lot_id',$ids)->select('id')->selectRaw("snapshot->'source'->>'industrial_lot_id' AS lot_id")->get()->keyBy('lot_id');$rows=[];foreach($p->items() as $m){$r=$results->get($m->lot_id);$rows[]=['lot_id'=>$m->lot_id,'period_key'=>$m->period_key,'result_id'=>$r?->id,'result_version'=>$r?->version,'recorded_decision'=>$r?$decisions->get($r->id)?->decision:null,'original_journal_id'=>$posted->get($m->lot_id)?->id,'current_dependency_conference_required'=>true];}return ['allowed'=>true,'rows'=>$rows,'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total(),'per_page'=>25]];
}
}