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]; }); } 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]; }); } }