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

69 lines
9.3 KiB
PHP

<?php
namespace App\Modules\Accounting;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Workshop\DirectCosts;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Brick\Math\BigRational;
use Tempo\Finance\Payables\Payables;
/** Explicit composition of an actual cost conference; no technical, financial or outbox writes. */
final class CostLedgerSource
{
private function scope(User $u):array
{
abort_unless(config('cost_ledger.synthetic_enabled')===true,403);DirectCosts::guard();$ctx=app(CompanyContext::class);
abort_unless(auth()->id()===$u->id&&$ctx->userId()===$u->id&&$ctx->unitId(),403);$s=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];
foreach(['workshop.view','direct_costs.view'] as $p)app(Permissions::class)->require($u->id,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);
foreach(['gl_cost_sources','wo_orders','wo_entries','wo_decisions','dc_rates','dc_calculations','dc_decisions'] as $t)abort_unless(DB::getSchemaBuilder()->hasTable($t),503,'Origem de custo não composta neste ensaio.');return $s;
}
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
private function facts(User $u,array $s,object $map):array
{
$c=app(DirectCosts::class)->conference($map->order_id,$u->id);$lines=$c['snapshot']['lines']??[];$entries=array_column($lines,'entry_id');$rates=array_values(array_filter(array_column($lines,'rate_id'),fn($id)=>$id!==null));
return ['mapping'=>(array)$map,'conference'=>$c,'entries'=>DB::table('wo_entries')->where('order_id',$map->order_id)->whereIn('id',$entries)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'technical_decisions'=>DB::table('wo_decisions')->whereIn('entry_id',$entries)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'rates'=>DB::table('dc_rates')->where($s)->where('order_id',$map->order_id)->whereIn('id',$rates)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'rate_decisions'=>DB::table('dc_decisions')->whereIn('rate_id',$rates)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()];
}
private function eligible(array $facts):array
{
$m=$facts['mapping'];$c=$facts['conference'];$snap=$c['snapshot'];$decision=$c['decision'];
if(!$c['calculation']||!$c['current']||!$decision||$decision['decision']!=='approved'||$decision['actor_id']===$c['calculation']['actor_id']||$snap['currency']!=='MZN'||$snap['incomplete']||$snap['selected_direct_total_exact']===null)throw new \DomainException('Cálculo actual completo e validado distintamente obrigatório; custo conhecido parcial não é total.');
$origins=[];foreach($snap['lines'] as $line)$origins[$line['kind']]=$line['origin'];
if($origins!==['time'=>$m['time_origin'],'material'=>$m['material_origin']]&&$origins!==['material'=>$m['material_origin'],'time'=>$m['time_origin']])throw new \DomainException('Perímetro seleccionado mudou; não atribuir outra origem automaticamente.');
if(config('industrial_ledger.synthetic_enabled')===true){abort_unless(DB::getSchemaBuilder()->hasTable('gl_industrial_sources'),503);if(DB::table('ic_inputs as i')->join('gl_industrial_sources as m','m.lot_id','=','i.lot_id')->where('i.tenant_id',$m['tenant_id'])->where('i.company_id',$m['company_id'])->whereIn('i.source_ref',array_values($origins))->exists())throw new \DomainException('Origem já composta em lote industrial; não reconhecer novamente.');}
try{$amount=BigRational::of($snap['selected_direct_total_exact'])->multipliedBy(100)->toBigInteger()->toInt();}catch(\Brick\Math\Exception\MathException $e){throw new \DomainException('Custo exacto não representável em cêntimos; não arredondar.',0,$e);}
if($amount<=0)throw new \DomainException('Este ensaio exige custo seleccionado positivo.');
return ['effect_type'=>'workshop.direct_cost.validated','source_id'=>$m['order_id'],'cost_order_id'=>$m['order_id'],'obligation_id'=>$m['order_id'],'origin_key'=>$this->hash(['scope'=>array_intersect_key($m,array_flip(['tenant_id','company_id','unit_id'])),'order'=>$m['order_id'],'material_origin'=>$m['material_origin'],'time_origin'=>$m['time_origin']]),'recognition_revision'=>$this->hash($facts),'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'permitted_period_key'=>$m['period_key'],'calculation_id'=>$c['calculation']['id'],'calculation_version'=>$c['calculation']['version'],'document_revision'=>$c['calculation']['fingerprint'],'cost_evidence'=>$facts,'label'=>'Custo directo conhecido do perímetro seleccionado; não custo completo, margem ou valorização de inventário'];
}
public function source(User $u,int $order):array
{
$s=$this->scope($u);$m=DB::table('gl_cost_sources')->where($s)->where('order_id',$order)->first()??throw new \DomainException('Ordem/perímetro de custo não composto neste âmbito.');return $this->eligible($this->facts($u,$s,$m));
}
public function period(User $u,array $period):array
{
$s=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;
foreach(DB::table('gl_cost_sources')->where($s)->where('period_key',$period['period_key'])->orderBy('id')->cursor() as $m){$v=$this->facts($u,$s,$m);hash_update($h,Payables::canonical($v));$facts++;try{$source=$this->eligible($v);$j=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$m->order_id)->first();if(!$j||!$this->resolved($s,$j,$source))$pending++;}catch(\DomainException){$pending++;}}
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
public function correctionSource(User $u,string $kind,int $journal):array
{
abort_unless(config('cost_ledger.correction_enabled')===true,403,'Correcção de custo não composta neste ensaio.');$s=$this->scope($u);abort_unless(DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability'),503,'Schema de correcção de custo não instalado.');
if(!in_array($kind,['workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true))throw new \DomainException('Tipo de correcção fora do recorte.');$j=DB::table('gl_journals')->where($s)->where('id',$journal)->first()??throw new \DomainException('Original fora do âmbito.');$original=json_decode($j->snapshot,true,128,JSON_THROW_ON_ERROR);if($original['source']['effect_type']!=='workshop.direct_cost.validated')throw new \DomainException('Só o custo original admite esta correcção.');
$base=$this->source($u,(int)$original['source']['cost_order_id']);if($base['origin_key']!==$original['source']['origin_key']||$base['permitted_period_key']!==$original['period']['period_key'])throw new \DomainException('Perímetro/período original incompatível.');if($base['recognition_revision']===$original['source']['recognition_revision'])throw new \DomainException('Não há nova versão validada para substituir o original.');
return array_replace($base,['effect_type'=>$kind,'source_id'=>$journal,'amount_minor'=>$kind==='workshop.direct_cost.reversal'?(int)$j->debit_minor:$base['principal_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>$this->hash(['current_source'=>$base,'original'=>(array)$j,'kind'=>$kind]),'original_journal_id'=>$journal,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>$this->hash((array)$j),'original_cost_source'=>$original['source']]);
}
private function resolved(array $s,object $original,array $source):bool
{
if(json_decode($original->snapshot,true)['source']['recognition_revision']===$source['recognition_revision'])return true;
if(config('cost_ledger.correction_enabled')!==true||!DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability'))return false;
$r=DB::table('gl_correction_results')->where($s)->where('original_journal_id',$original->id)->first();if(!$r)return false;$j=DB::table('gl_journals')->where($s)->where('id',$r->replacement_journal_id)->first();if(!$j)return false;$current=json_decode($j->snapshot,true)['source'];return $current['effect_type']==='workshop.direct_cost.replacement'&&($current['cost_order_id']??null)===$source['cost_order_id']&&($current['replacement_source_revision']??null)===$source['recognition_revision'];
}
public function workspace(User $u,?int $order,int $page):array
{
$s=$this->scope($u);$q=DB::table('gl_cost_sources')->where($s);$p=$q->orderBy('id')->paginate(25,['*'],'page',$page);$chosen=null;$source=null;$pending=null;
if($order!==null){$m=DB::table('gl_cost_sources')->where($s)->where('order_id',$order)->first()??abort(403,'Ordem fora do âmbito composto.');$chosen=$this->facts($u,$s,$m);try{$source=$this->eligible($chosen);}catch(\DomainException $e){$pending=$e->getMessage();}}
$original=$order!==null?DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$order)->first():null;$state=$original?($source&&$this->resolved($s,$original,$source)?'current':'requires_correction'):'unposted';
return ['scope'=>$s,'orders'=>$p->items(),'pagination'=>['page'=>$page,'total'=>$p->total(),'last_page'=>$p->lastPage()],'order_id'=>$order,'facts'=>$chosen,'source'=>$source,'pending'=>$pending,'original_journal'=>$original,'accounting_state'=>$state];
}
}