134 lines
15 KiB
PHP
134 lines
15 KiB
PHP
<?php
|
|
namespace App\Modules\Workshop;
|
|
use App\Modules\Identity\Services\Audit;
|
|
use App\Modules\Identity\Services\Permissions;
|
|
use App\Modules\Services\ServiceCalculation;
|
|
use App\Shared\Context\CompanyContext;
|
|
use App\Shared\Testing\TestingDatabaseGuard;
|
|
use Brick\Math\BigDecimal;
|
|
use Brick\Math\BigRational;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
final class DirectCosts
|
|
{
|
|
public const POLICY='WORKSHOP-DIRECT-COST-SIM-v1';
|
|
public const NOTICE='ENSAIO — SEM UTILIZAÇÃO OPERACIONAL';
|
|
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
|
public static function guard(): void {abort_unless(ServiceCalculation::isolated(),403,'Custeio disponível apenas em testes isolados.');TestingDatabaseGuard::assertSafe();}
|
|
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(['view',$right] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'direct_costs.'.$r,$s['unit_id']);
|
|
return $s;
|
|
}
|
|
private function order(int $id,array $s): object {return DB::table('wo_orders')->where($s)->where('id',$id)->first()??abort(403,'Ordem fora do âmbito de custos.');}
|
|
private function exact(array $in,array $rules): array {abort_if(array_diff(array_keys($in),array_keys($rules)),422,'Campo fora do âmbito de custos.');return Validator::make($in,$rules)->validate();}
|
|
private function json(mixed $v): string {return json_encode($v,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
|
|
private function canonical(mixed $v): mixed {if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map(fn($x)=>$this->canonical($x),$v);}
|
|
private function command(int $order,int $actor,string $action,string $right,array $in,string $key,callable $write): array {
|
|
self::guard();$s=$this->scope($actor,$right);$this->order($order,$s);
|
|
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de repetição obrigatória.');$hash=hash('sha256',$this->json($this->canonical(['order_id'=>$order]+$in)));
|
|
return DB::transaction(function()use($order,$actor,$action,$right,$in,$key,$hash,$write,$s){
|
|
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$right);$this->order($order,$s);
|
|
$old=DB::table('dc_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();
|
|
if($old){abort_unless($old->order_id===$order&&hash_equals($old->request_hash,$hash),409,'Repetição com conteúdo diferente.');return json_decode($old->result,true,64,JSON_THROW_ON_ERROR)+['replayed'=>true];}
|
|
$out=$write($s);
|
|
$cmd=DB::table('dc_commands')->insertGetId($s+['order_id'=>$order,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($out),'created_at'=>now()]);
|
|
Audit::record('direct_costs.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']]);
|
|
$event=DB::table('dc_events')->insertGetId(['command_id'=>$cmd,'schema_version'=>1,'payload'=>$this->json(['policy'=>self::POLICY,'action'=>$action,'result'=>$out,'scope'=>$s,'actor_id'=>$actor]),'created_at'=>now()]);
|
|
DB::table('dc_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_accounting']);return $out+['replayed'=>false];
|
|
},3);
|
|
}
|
|
private function source(int $id,int $version,int $order): object {
|
|
$e=DB::table('wo_entries')->where(['id'=>$id,'version'=>$version,'order_id'=>$order])->first();abort_unless($e,403,'Origem fora da ordem/versão.');
|
|
$e->data=json_decode($e->data,true,32,JSON_THROW_ON_ERROR);
|
|
abort_if(DB::table('wo_entries')->where('previous_id',$e->id)->exists()||DB::table('wo_entries')->where('previous_id',$e->task_id)->exists(),409,'Origem ou tarefa desactualizada.');
|
|
abort_unless(!$e->outside_authority&&DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->exists(),409,'Origem não verificada/elegível.');
|
|
abort_unless(($e->kind==='time'&&$e->data['time_type']==='work')||($e->kind==='material'&&$e->data['event']==='consumed'&&$e->data['owner']==='company'),422,'Só trabalho efectivo e consumo da empresa; entrega/devolução não são consumo.');
|
|
return $e;
|
|
}
|
|
private function row(string $kind,int $id,int $order): object {return DB::table($kind==='rate'?'dc_rates':'dc_calculations')->where(['id'=>$id,'order_id'=>$order])->first()??abort(403,'Versão de custo fora da ordem.');}
|
|
private function current(string $kind,object $row): bool {return !DB::table($kind==='rate'?'dc_rates':'dc_calculations')->where('previous_id',$row->id)->exists();}
|
|
private function sourceSet(int $order): array {
|
|
return DB::table('wo_entries as e')->where('e.order_id',$order)->whereIn('e.kind',['time','material'])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_entries as n')->whereColumn('n.previous_id','e.id'))->orderBy('e.id')->pluck('e.id')->all();
|
|
}
|
|
public function rate(int $order,int $actor,array $input,string $key): array {
|
|
self::guard();
|
|
$d=$this->exact($input,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','identifier'=>'required|string|min:3|max:80','version'=>'required|integer|min:1','previous_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','unit'=>'required|in:UN,KG,M,L,H','amount'=>['required','string','regex:/^(?:0|[1-9][0-9]{0,15})(?:\.[0-9]{1,8})?$/D'],'evidence'=>'required|string|min:3|max:1000']);
|
|
return $this->command($order,$actor,'rate','prepare',$d,$key,function($s)use($order,$actor,$d){
|
|
$e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_unless($d['unit']===($e->kind==='time'?'H':$e->data['unit']),422,'Unidade da taxa incompatível com a origem.');
|
|
$last=DB::table('dc_rates')->where(['order_id'=>$order,'identifier'=>$d['identifier']])->orderByDesc('version')->first();
|
|
abort_unless($d['version']===($last?->version??0)+1&&($d['previous_id']??null)===$last?->id,409,'Versão anterior exacta necessária.');
|
|
$id=DB::table('dc_rates')->insertGetId($s+array_merge($d,['order_id'=>$order,'amount'=>(string)BigDecimal::of($d['amount'])->stripTrailingZeros(),'actor_id'=>$actor,'created_at'=>now()]));return ['id'=>$id,'version'=>$d['version'],'kind'=>'rate'];
|
|
});
|
|
}
|
|
private function value(BigRational $n): string {return \App\Shared\Costs\ExactCostMath::text($n);}
|
|
private function compose(int $order,array $lines): array {
|
|
abort_unless(count($lines)===2,422,'Este ensaio exige um consumo e um tempo efectivos.');$out=[];$seen=[];$kinds=[];$pending=[];$sum=BigRational::of(0);
|
|
foreach($lines as $line){
|
|
$d=$this->exact($line,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','rate_id'=>'present|nullable|integer|min:1','rate_version'=>'present|nullable|integer|min:1']);
|
|
$e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_if(isset($seen[$e->id]),422,'Origem duplicada.');$seen[$e->id]=true;$kinds[]=$e->kind;
|
|
$quantity=$e->kind==='time'?\App\Shared\Costs\ExactCostMath::hours($e->minutes):BigRational::of($e->quantity);
|
|
$rate=null;$cost=null;
|
|
if($d['rate_id']!==null){
|
|
$r=$this->row('rate',$d['rate_id'],$order);
|
|
abort_unless($d['rate_version']===$r->version&&$r->entry_id===$e->id&&$r->entry_version===$e->version&&$r->currency==='MZN'&&$r->unit===($e->kind==='time'?'H':$e->data['unit']),409,'Taxa/versão/unidade/moeda incompatível.');
|
|
abort_unless($this->current('rate',$r)&&DB::table('dc_decisions')->where(['rate_id'=>$r->id,'decision'=>'approved'])->exists(),409,'Taxa exige versão actual aprovada; selecção explícita.');
|
|
$rate=(string)BigDecimal::of($r->amount)->stripTrailingZeros();$cost=$quantity->multipliedBy($rate);$sum=$sum->plus($cost);
|
|
}else{abort_unless($d['rate_version']===null,422,'Taxa ausente não tem versão.');$pending[]='Taxa em falta para origem #'.$e->id.' v'.$e->version;}
|
|
$out[]=$d+['kind'=>$e->kind,'origin'=>$e->origin,'quantity'=>$e->kind==='time'?(string)$e->minutes:(string)BigDecimal::of($e->quantity)->stripTrailingZeros(),'source_unit'=>$e->kind==='time'?'MIN':$e->data['unit'],'cost_quantity_exact'=>$this->value($quantity),'rate_unit'=>$e->kind==='time'?'H':$e->data['unit'],'rate_amount'=>$rate,'cost_exact'=>$cost===null?null:$this->value($cost)];
|
|
}
|
|
sort($kinds);abort_unless($kinds===['material','time'],422,'Um consumo e um tempo, sem duplicação.');usort($out,fn($a,$b)=>$a['entry_id']<=>$b['entry_id']);
|
|
return ['policy'=>self::POLICY,'currency'=>'MZN','label'=>'Custo directo conhecido — não é custo completo ou margem','lines'=>$out,'known_subtotal_exact'=>$this->value($sum),'selected_direct_total_exact'=>$pending?null:$this->value($sum),'incomplete'=>$pending!==[],'pending'=>$pending,'source_set'=>$this->sourceSet($order),'scope'=>'Duas origens seleccionadas; sem custos indirectos ou valorização de inventário'];
|
|
}
|
|
private function calculationCurrent(object $row): bool {
|
|
if(!$this->current('calculation',$row))return false;
|
|
try{$snapshot=json_decode($row->snapshot,true,64,JSON_THROW_ON_ERROR);$lines=array_map(fn($l)=>array_intersect_key($l,array_flip(['entry_id','entry_version','rate_id','rate_version'])),$snapshot['lines']);return hash_equals($row->fingerprint,hash('sha256',$this->json($this->compose($row->order_id,$lines))));}
|
|
catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){return false;}
|
|
}
|
|
public function calculate(int $order,int $actor,array $input,string $key): array {
|
|
self::guard();
|
|
$d=$this->exact($input,['version'=>'required|integer|min:1','previous_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','lines'=>'required|array|size:2']);
|
|
return $this->command($order,$actor,'calculate','prepare',$d,$key,function($s)use($order,$actor,$d){
|
|
$last=DB::table('dc_calculations')->where('order_id',$order)->orderByDesc('version')->first();
|
|
abort_unless($d['version']===($last?->version??0)+1&&($d['previous_id']??null)===$last?->id,409,'Cálculo mudou; rever versão anterior.');
|
|
$snapshot=$this->compose($order,$d['lines']);$hash=hash('sha256',$this->json($snapshot));
|
|
if($last&&hash_equals($last->fingerprint,$hash))return ['id'=>$last->id,'version'=>$last->version,'kind'=>'calculation','unchanged'=>true];
|
|
$id=DB::table('dc_calculations')->insertGetId($s+['order_id'=>$order,'version'=>$d['version'],'previous_id'=>$last?->id,'fingerprint'=>$hash,'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version'],'kind'=>'calculation'];
|
|
});
|
|
}
|
|
public function decide(int $order,int $actor,array $input,string $key): array {
|
|
self::guard();
|
|
$d=$this->exact($input,['kind'=>'required|in:rate,calculation','id'=>'required|integer|min:1','version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);
|
|
return $this->command($order,$actor,'decide','validate',$d,$key,function($s)use($order,$actor,$d){
|
|
$r=$this->row($d['kind'],$d['id'],$order);abort_if($r->actor_id===$actor,403,'Validador deve ser distinto do preparador.');
|
|
abort_unless($r->version===$d['version']&&$this->current($d['kind'],$r),409,'Versão antiga.');
|
|
if($d['kind']==='rate')$this->source($r->entry_id,$r->entry_version,$order);else abort_unless($this->calculationCurrent($r),409,'Dependências alteradas; preparar nova versão.');
|
|
$field=$d['kind']==='rate'?'rate_id':'calculation_id';abort_if(DB::table('dc_decisions')->where($field,$r->id)->exists(),409,'Versão já decidida.');
|
|
$id=DB::table('dc_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version,'kind'=>'decision','target_id'=>$r->id];
|
|
});
|
|
}
|
|
/** Authoritative scoped conference for read-only consumers; no second cost calculator. */
|
|
public function conference(int $order,int $actor):array {
|
|
self::guard();$s=$this->scope($actor,'view');$o=$this->order($order,$s);$row=DB::table('dc_calculations')->where($s)->where('order_id',$order)->orderByDesc('version')->first();
|
|
$decision=$row?DB::table('dc_decisions')->where('calculation_id',$row->id)->first():null;$current=$row?$this->calculationCurrent($row):false;
|
|
return ['order'=>(array)$o,'calculation'=>$row?(array)$row:null,'snapshot'=>$row?json_decode($row->snapshot,true,64,JSON_THROW_ON_ERROR):null,'decision'=>$decision?(array)$decision:null,'current'=>$current];
|
|
}
|
|
public function listing(int $actor): array {
|
|
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[],'notice'=>self::NOTICE];self::guard();
|
|
return DB::transaction(function()use($s){
|
|
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$orders=[];
|
|
foreach(DB::table('wo_orders')->where($s)->orderBy('id')->get(['id','reference']) as $o){
|
|
$sources=[];foreach(DB::table('wo_entries')->where('order_id',$o->id)->whereIn('kind',['time','material'])->orderBy('id')->get(['id','version']) as $entry){
|
|
try{$e=$this->source($entry->id,$entry->version,$o->id);$sources[]=['id'=>$e->id,'version'=>$e->version,'kind'=>$e->kind,'origin'=>$e->origin,'quantity'=>$e->kind==='time'?(string)$e->minutes:(string)$e->quantity,'unit'=>$e->kind==='time'?'MIN':$e->data['unit']];}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){}
|
|
}
|
|
$rates=DB::table('dc_rates')->where('order_id',$o->id)->orderBy('id')->get();foreach($rates as $r){$r->current=$this->current('rate',$r);$r->decision=DB::table('dc_decisions')->where('rate_id',$r->id)->first();}
|
|
$calculations=DB::table('dc_calculations')->where('order_id',$o->id)->orderBy('id')->get();foreach($calculations as $c){$c->snapshot=json_decode($c->snapshot,true,64,JSON_THROW_ON_ERROR);$copy=clone $c;$copy->snapshot=$this->json($c->snapshot);$c->current=$this->calculationCurrent($copy);$c->decision=DB::table('dc_decisions')->where('calculation_id',$c->id)->first();}
|
|
$orders[]=(array)$o+['sources'=>$sources,'rates'=>$rates->all(),'calculations'=>$calculations->all()];
|
|
}return ['blocked'=>false,'notice'=>self::NOTICE,'orders'=>$orders,'policy'=>self::POLICY];
|
|
});
|
|
}
|
|
}
|