Files
SGF/app/Modules/Industry/IndustrialBudgets.php

95 lines
12 KiB
PHP

<?php
namespace App\Modules\Industry;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Modules\Services\ServiceCalculation;
use App\Shared\Context\CompanyContext;
use App\Shared\Costs\ExactCostMath as Exact;
use Brick\Math\BigRational as N;
use Illuminate\Support\Facades\{DB,Validator};
final class IndustrialBudgets {
public const POLICY='INDUSTRIAL-BUDGET-ACTUAL-SIM-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions,private IndustrialCosts $costs){}
public static function guard(): void {IndustrialCosts::guard();}
private function json($v): string {return json_encode($v,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
private function canonical($v){if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map(fn($x)=>$this->canonical($x),$v);}
private function fields(array $in,array $rules): array {abort_if(array_diff(array_keys($in),array_keys($rules)),422,'Campo fora do ensaio.');return Validator::make($in,$rules)->validate();}
private function scope(int $actor,string $right): array {
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']!==null,403);
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
foreach(['industrial_budgets.view','industrial_costs.view',$right] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);return $s;
}
private function lot(int $id,array $s): object {return DB::table('ic_lots')->where($s)->where('id',$id)->first()??abort(403,'Lote fora do contexto.');}
private function cmd(int $lot,int $actor,string $action,array $in,string $key,callable $write): array {
self::guard();$right=match($action){'propose'=>'industrial_budgets.prepare','decide'=>'industrial_budgets.validate','compare'=>'industrial_budget_comparisons.prepare'};
$s=$this->scope($actor,$right);if($action==='compare')$this->scope($actor,'industrial_budget_comparisons.view');$this->lot($lot,$s);
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência necessária.');$hash=hash('sha256',$this->json($this->canonical($in)));
return DB::transaction(function()use($lot,$actor,$action,$in,$key,$write,$hash,$s,$right){
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$right);if($action==='compare')$this->scope($actor,'industrial_budget_comparisons.view');$this->lot($lot,$s);
$old=DB::table('ib_commands')->where(['lot_id'=>$lot,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();
if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com conteúdo diferente.');return json_decode($old->result,true)+['replayed'=>true];}
$out=$write($s);$cmd=DB::table('ib_commands')->insertGetId(['lot_id'=>$lot,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($out),'created_at'=>now()]);
Audit::record('industrial_budgets.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']]);
$event=DB::table('ib_events')->insertGetId(['command_id'=>$cmd,'payload'=>$this->json(['policy'=>self::POLICY,'scope'=>$s,'action'=>$action,'result'=>$out]),'created_at'=>now()]);
DB::table('ib_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_stock_or_accounting']);return $out+['replayed'=>false];
},3);
}
private function next(string $table,int $lot,array $d): void {
$last=DB::table($table)->where('lot_id',$lot)->orderByDesc('version')->first();
abort_unless($d['version']===($last?->version??0)+1&&($d['previous_id']??null)===$last?->id,409,'Versão anterior mudou; rever explicitamente.');
}
private function row(string $table,int $lot,int $id,int $version): object {return DB::table($table)->where(['id'=>$id,'lot_id'=>$lot,'version'=>$version])->first()??abort(409,'Referência ou versão incompatível.');}
private function current(string $table,object $r): bool {return !DB::table($table)->where('previous_id',$r->id)->exists();}
public function dimensions(object $lot): array {return ['tenant_id'=>$lot->tenant_id,'company_id'=>$lot->company_id,'unit_id'=>$lot->unit_id,'product_id'=>$lot->product_id,'lot_id'=>$lot->id,'quantity'=>'10','unit'=>'UN','currency'=>$lot->currency,'period_start'=>$lot->period_start,'period_end'=>$lot->period_end,'scope'=>'material_labour_allocated'];}
public function propose(int $lot,int $actor,array $in,string $key): array {
self::guard();$d=$this->fields($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','dimensions'=>'required|array','amounts'=>'required|array','evidence'=>'required|string|min:3|max:1000']);
return $this->cmd($lot,$actor,'propose',$d,$key,function($s)use($lot,$actor,$d){
$this->next('ib_budgets',$lot,$d);$dims=$this->dimensions($this->lot($lot,$s));abort_unless($this->canonical($dims)===$this->canonical($d['dimensions']),409,'Produto, lote, quantidade, unidade, moeda, período ou âmbito incompatível.');
abort_unless(count($d['amounts'])===3&&!array_diff(array_keys($d['amounts']),['material','labour','allocated']),422);
foreach($d['amounts'] as $v)abort_unless($v===null||(is_string($v)&&preg_match('/^(?:0|[1-9][0-9]{0,15})(?:\.[0-9]{1,8})?$/D',$v)),422,'Montante exacto não negativo ou desconhecido necessário.');
$id=DB::table('ib_budgets')->insertGetId(['lot_id'=>$lot,'version'=>$d['version'],'previous_id'=>$d['previous_id'],'dimensions'=>$this->json($dims),'amounts'=>$this->json($d['amounts']),'evidence'=>$d['evidence'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version'],'lot_id'=>$lot];
});
}
public function decide(int $lot,int $actor,array $in,string $key): array {
self::guard();$d=$this->fields($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);
return $this->cmd($lot,$actor,'decide',$d,$key,function()use($lot,$actor,$d){
$b=$this->row('ib_budgets',$lot,$d['id'],$d['version']);abort_if($b->actor_id===$actor,403,'Validador distinto necessário.');abort_unless($this->current('ib_budgets',$b),409,'Orçamento desactualizado.');abort_if(DB::table('ib_decisions')->where('budget_id',$b->id)->exists(),409,'Decisão imutável já registada.');
$id=DB::table('ib_decisions')->insertGetId(['budget_id'=>$b->id,'version'=>$b->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$b->version,'lot_id'=>$lot];
});
}
private function approvedBudget(int $lot,int $id,int $version): object {
$b=$this->row('ib_budgets',$lot,$id,$version);abort_unless($this->current('ib_budgets',$b)&&DB::table('ib_decisions')->where(['budget_id'=>$id,'decision'=>'approved'])->exists(),409,'Orçamento não aprovado ou desactualizado.');return $b;
}
private function result(int $actor,int $lot,int $id,int $version): array {
foreach($this->costs->listing($actor)['lots'] as $l)if($l['id']===$lot)foreach($l['results'] as $r)if($r->id===$id&&$r->version===$version){abort_unless($r->current&&$r->decision?->decision==='approved',409,'Realizado não validado ou desactualizado.');return $r->snapshot;}
abort(409,'Resultado ou versão incompatível.');
}
public static function variance(array $p,array $r): array {
$rows=[];$ps=N::of(0);$rs=N::of(0);$ds=N::of(0);$complete=true;
foreach(['material','labour','allocated'] as $k){$a=$p[$k];$b=$r[$k];$d=$a===null||$b===null?null:N::of($b)->minus($a);$pct=$d===null||N::of($a)->isZero()?null:Exact::text($d->multipliedBy(100)->dividedBy($a));
$rows[$k]=['planned'=>$a,'actual'=>$b,'delta'=>$d===null?null:Exact::text($d),'percent'=>$pct,'percent_state'=>$d===null?'unknown':(N::of($a)->isZero()?'zero_base':'available')];
if($d===null){$complete=false;continue;}$ps=$ps->plus($a);$rs=$rs->plus($b);$ds=$ds->plus($d);
}
return ['rows'=>$rows,'incomplete'=>!$complete,'paired_planned'=>Exact::text($ps),'paired_actual'=>Exact::text($rs),'paired_delta'=>Exact::text($ds),'total_delta'=>$complete?Exact::text($ds):null,'total_percent'=>$complete&&!$ps->isZero()?Exact::text($ds->multipliedBy(100)->dividedBy($ps)):null,'total_percent_state'=>!$complete?'unknown':($ps->isZero()?'zero_base':'available'),'balanced'=>$ps->plus($ds)->isEqualTo($rs)];
}
public function compare(int $lot,int $actor,array $in,string $key): array {
self::guard();$d=$this->fields($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','budget_id'=>'required|integer|min:1','budget_version'=>'required|integer|min:1','result_id'=>'required|integer|min:1','result_version'=>'required|integer|min:1']);
return $this->cmd($lot,$actor,'compare',$d,$key,function($s)use($lot,$actor,$d){
$this->next('ib_comparisons',$lot,$d);$b=$this->approvedBudget($lot,$d['budget_id'],$d['budget_version']);$r=$this->result($actor,$lot,$d['result_id'],$d['result_version']);$dims=$this->dimensions($this->lot($lot,$s));
abort_unless($this->canonical(json_decode($b->dimensions,true))===$this->canonical($dims)&&N::of($r['good_units'])->isEqualTo($dims['quantity'])&&$r['currency']===$dims['currency'],409,'Quantidade, moeda ou âmbito incompatível.');
$snapshot=self::variance(json_decode($b->amounts,true),['material'=>$r['material_exact'],'labour'=>$r['labour_exact'],'allocated'=>$r['allocated_exact']])+['policy'=>self::POLICY,'dimensions'=>$dims,'budget'=>['id'=>$b->id,'version'=>$b->version],'result'=>['id'=>$d['result_id'],'version'=>$d['result_version'],'refs'=>$r['refs']],'actual_known'=>$r['known_cost_exact'],'residual_separate'=>$r['residual_exact'],'actual_reconciliation'=>$r['reconciliation'],'retrospective_hypothesis'=>true];
$id=DB::table('ib_comparisons')->insertGetId(['lot_id'=>$lot,'version'=>$d['version'],'previous_id'=>$d['previous_id'],'budget_id'=>$b->id,'result_id'=>$d['result_id'],'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version'],'lot_id'=>$lot];
});
}
public function listing(int $actor): array {
$s=$this->scope($actor,'industrial_budgets.view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'lots'=>[]];self::guard();
return DB::transaction(function()use($actor,$s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,'industrial_budgets.view');$lots=$this->costs->listing($actor)['lots'];
$view=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'industrial_budget_comparisons.view',$s['unit_id']);
foreach($lots as &$l){$l['dimensions']=$this->dimensions((object)$l);$l['budgets']=DB::table('ib_budgets')->where('lot_id',$l['id'])->orderBy('version')->get()->all();foreach($l['budgets'] as $b){$b->current=$this->current('ib_budgets',$b);$b->amounts=json_decode($b->amounts,true);$b->dimensions=json_decode($b->dimensions,true);$b->decision=DB::table('ib_decisions')->where('budget_id',$b->id)->first();}
$l['comparisons']=$view?DB::table('ib_comparisons')->where('lot_id',$l['id'])->orderBy('version')->get()->all():[];
foreach($l['comparisons'] as $c){$c->snapshot=json_decode($c->snapshot,true);try{$this->approvedBudget($l['id'],$c->budget_id,$c->snapshot['budget']['version']);$this->result($actor,$l['id'],$c->result_id,$c->snapshot['result']['version']);$c->current=$this->current('ib_comparisons',$c);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){$c->current=false;}}
}return ['blocked'=>false,'lots'=>$lots,'policy'=>self::POLICY];
});
}
}