context->identifiers()+['unit_id'=>$this->context->unitId()]; abort_unless($s['unit_id']!==null && $actor===$this->context->userId(),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'],'workshop.'.$r,$s['unit_id']); return $s; } private function has(int $actor,string $right,array $s): bool { return $this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$right,$s['unit_id']); } private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); } private function json(mixed $value): string { return json_encode($value,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE); } private function exact(array $data,array $rules): array { abort_if(array_diff(array_keys($data),array_keys($rules)),422,'Campo protegido ou fora do recorte.'); return Validator::make($data,$rules)->validate(); } 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 revision(int $id): int { return (int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$id])->v; } private function order(int $id,int $actor,array $s): object { $o=DB::table('wo_orders')->where($s)->where('id',$id)->first(); abort_unless($o,403,'Ordem fora do âmbito autorizado.'); if(!$this->has($actor,'open',$s) && !$this->has($actor,'assign',$s) && !$this->has($actor,'authorize',$s) && !$this->has($actor,'verify',$s)) { abort_unless((int)DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'assignment'])->orderByDesc('id')->value('technician_id')===$actor,403,'Ordem não atribuída ao técnico.'); } return $o; } private function entry(int $id,int $order,?string $kind=null): object { $q=DB::table('wo_entries')->where(['order_id'=>$order,'id'=>$id]); if($kind) $q->where('kind',$kind); return $q->first()??abort(403,'Apontamento/origem fora da ordem.'); } private function current(object $e): bool { return !DB::table('wo_entries')->where('previous_id',$e->id)->exists(); } private function command(int $actor,string $right,string $key,array $input,?int $order,callable $write): array { self::guard(); $s=$this->scope($actor,$right); abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência obrigatória.'); $hash=hash('sha256',$this->json($this->canonical($input))); return DB::transaction(function()use($actor,$right,$key,$hash,$s,$write,$order) { DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail(); $this->scope($actor,$right); if($order) $this->order($order,$actor,$s); $old=DB::table('wo_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$right,'command_key'=>$key])->first(); if($old) { $this->order($old->order_id,$actor,$s); abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com conteúdo diferente.'); return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true]; } $out=$write($s); $cmd=DB::table('wo_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>$right,'command_key'=>$key,'request_hash'=>$hash,'order_id'=>$out['order_id'],'result'=>$this->json($out),'created_at'=>now()]); Audit::record('workshop.'.$out['event'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']??1]); $event=DB::table('wo_events')->insertGetId(['command_id'=>$cmd,'type'=>$out['event'],'schema_version'=>1,'payload'=>$this->json($s+['policy'=>self::POLICY,'actor_id'=>$actor,'command_id'=>$cmd,'result'=>$out]),'created_at'=>now()]); DB::table('wo_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_cost_stock_or_billing']); return $out+['replayed'=>false]; },3); } public function open(int $actor,array $input,string $key): array { self::guard(); $d=$this->exact($input,['reference'=>'required|string|min:3|max:100','client_id'=>'required|integer|min:1','equipment_id'=>'nullable|integer|min:1','equipment_identifier'=>'required_without:equipment_id|string|max:100','equipment_description'=>'required_without:equipment_id|string|max:300','serial'=>'nullable|string|max:100','request'=>'required|string|min:3|max:2000','location'=>'required|string|max:300','evidence'=>'required|string|min:3|max:500']); return $this->command($actor,'open',$key,$d,null,function($s)use($d,$actor) { abort_unless(DB::table('a_clients')->where(['id'=>$d['client_id'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente de outra empresa.'); abort_if(DB::table('wo_orders')->where(['company_id'=>$s['company_id'],'reference'=>$d['reference']])->exists(),409,'Referência de ordem já existente.'); $equipment=$d['equipment_id']??null; if($equipment) abort_unless(DB::table('wo_equipment')->where(['id'=>$equipment,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id']])->exists(),403,'Equipamento incompatível com o cliente.'); else { abort_if(DB::table('wo_equipment')->where(['company_id'=>$s['company_id'],'identifier'=>$d['equipment_identifier']])->exists(),409,'Equipamento já identificado; reutilize o ID existente.'); $equipment=DB::table('wo_equipment')->insertGetId(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id'],'identifier'=>$d['equipment_identifier'],'description'=>$d['equipment_description'],'serial'=>$d['serial']??null,'actor_id'=>$actor,'created_at'=>now()]); } $id=DB::table('wo_orders')->insertGetId($s+['client_id'=>$d['client_id'],'equipment_id'=>$equipment,'reference'=>$d['reference'],'request'=>$d['request'],'location'=>$d['location'],'evidence'=>$d['evidence'],'actor_id'=>$actor,'created_at'=>now()]); return ['id'=>$id,'order_id'=>$id,'equipment_id'=>$equipment,'event'=>'order.opened','version'=>1]; }); } private function timestamp(string $value): string { abort_unless(preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(Z|[+-]\d{2}:\d{2})$/D',$value),422,'Use data/hora completa com zona explícita.'); try { return CarbonImmutable::parse($value)->utc()->format('Y-m-d\TH:i:sP'); } catch(\Throwable) { abort(422,'Data/hora inválida.'); } } public function record(int $id,int $actor,array $input,string $key): array { self::guard(); $d=$this->exact($input,['kind'=>'required|in:assignment,task,authorization,start,time,material','origin'=>'required|string|min:3|max:100','revision'=>'required|integer|min:0','previous_id'=>'nullable|integer|min:1','correction_reason'=>'nullable|string|min:3|max:1000','version'=>'required|integer|min:1', 'technician_id'=>'nullable|integer|min:1','task_id'=>'nullable|integer|min:1','authorization_id'=>'nullable|integer|min:1','start_id'=>'nullable|integer|min:1', 'description'=>'nullable|string|min:3|max:2000','service_id'=>'nullable|integer|min:1','evidence'=>'nullable|string|min:3|max:1000', 'authorized_by'=>'nullable|string|min:3|max:255','capacity'=>'nullable|string|min:3|max:255','authorized_at'=>'nullable|date','channel'=>'nullable|string|min:3|max:100','scope'=>'nullable|string|min:3|max:2000', 'starts_at'=>'nullable|date','ends_at'=>'nullable|date','pauses'=>'nullable|array|max:100','pauses.*'=>'array:start,end','pauses.*.start'=>'required|date','pauses.*.end'=>'required|date','mode'=>'nullable|in:reported,started','time_type'=>'nullable|in:work,travel', 'quantity'=>'nullable|string','resource'=>'nullable|string|min:2|max:100','unit'=>'nullable|in:UN,KG,M,L','owner'=>'nullable|in:company,client','event'=>'nullable|in:required,delivered,consumed,returned']); foreach(['starts_at','ends_at','authorized_at'] as $field) if(isset($d[$field])) $d[$field]=$this->timestamp($d[$field]); if(isset($d['quantity'])) $d['quantity']=DraftValues::decimal($d['quantity'],'quantity',true); $d['pauses']=array_map(fn($p)=>['start'=>$this->timestamp($p['start']),'end'=>$this->timestamp($p['end'])],$d['pauses']??[]); $right=match($d['kind']) {'assignment'=>'assign','authorization'=>'authorize',default=>'record'}; return $this->command($actor,$right,$key,['order_id'=>$id]+$d,$id,function($s)use($id,$actor,$d) { abort_unless((int)$d['revision']===$this->revision($id),409,'Revisão da ordem desactualizada.'); $kind=$d['kind']; $prev=null; if(!empty($d['previous_id'])) { $prev=$this->entry($d['previous_id'],$id,$kind); abort_unless($this->current($prev) && $prev->origin===$d['origin'] && (int)$d['version']===$prev->version+1 && !empty($d['correction_reason']),409,'Nova versão exige origem, motivo e versão anterior exactos.'); if(in_array($kind,['time','material','task'])) abort_unless($prev->actor_id===$actor,403,'Não pode corrigir o apontamento de outro autor.'); } else { abort_unless((int)$d['version']===1,422); abort_if(DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>$kind,'origin'=>$d['origin']])->exists(),409,'Origem já registada; corrija por versão explícita.'); } $assigned=(int)DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'assignment'])->orderByDesc('id')->value('technician_id'); $tech=null; $task=null; $auth=null; $start=null; $minutes=null; $quantity=null; $outside=false; $starts=$d['starts_at']??null; $ends=$d['ends_at']??null; if($kind==='assignment') { $tech=$d['technician_id']??0; foreach(['view','record'] as $r) $this->permissions->require($tech,$s['tenant_id'],$s['company_id'],'workshop.'.$r,$s['unit_id']); } if(in_array($kind,['task','start','time','material'])) { abort_unless($assigned===$actor,403,'Apenas o técnico atribuído regista.'); $tech=$actor; } if($kind==='task') abort_unless(!empty($d['description']),422,'Descrição da tarefa necessária.'); if(in_array($kind,['authorization','start','time','material'])) { $task=$this->entry((int)($d['task_id']??0),$id,'task'); if(in_array($kind,['authorization','start'])) abort_unless($this->current($task),409,'Tarefa tem nova versão; reveja âmbito.'); } if($kind==='authorization') { foreach(['authorized_by','capacity','authorized_at','channel','scope','evidence'] as $field) abort_unless(!empty($d[$field]),422,'Autorização: pessoa, qualidade, data, canal, âmbito e comprovativo necessários.'); abort_if(CarbonImmutable::parse($d['authorized_at'])->isAfter(now()),422,'Autorização futura não é facto.'); // Revisions of one authorization cannot silently move to another task or authorize it retroactively. if($prev) abort_unless($prev->task_id===$task->id || !$this->current($this->entry($prev->task_id,$id,'task')),409,'Alteração de tarefa exige revisão explícita da origem.'); } if(in_array($kind,['start','time','material'])) { abort_unless(!empty($d['evidence']),422,'Evidência da ocorrência necessária.'); if(!empty($d['authorization_id'])) $auth=$this->entry($d['authorization_id'],$id,'authorization'); if($auth) abort_unless($auth->task_id===$task->id,409,'Autorização não cobre esta versão da tarefa.'); if($kind==='start') { $starts=now()->toIso8601String(); $ends=null; } $outside=!$auth || !$this->current($auth) || !$this->current($task) || CarbonImmutable::parse($auth->created_at)->isAfter(CarbonImmutable::parse($starts??now())) || CarbonImmutable::parse($this->data($auth)['authorized_at'])->isAfter(CarbonImmutable::parse($starts??now())); if($kind==='start') { abort_if($outside,409,'Início bloqueado: autorização da versão da tarefa necessária. Pode relatar uma ocorrência já acontecida para revisão.'); abort_if(DB::table('wo_entries as e')->join('wo_orders as o','o.id','=','e.order_id')->where('o.company_id',$s['company_id'])->where(['e.kind'=>'start','e.technician_id'=>$actor])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_entries as done')->whereColumn('done.start_id','e.id'))->exists(),409,'Já existe trabalho iniciado sem apontamento final.'); } if($kind==='time') { abort_unless($starts && $ends && !empty($d['time_type']) && !empty($d['mode']),422,'Intervalo, tipo e modo de relato necessários.'); $from=CarbonImmutable::parse($starts); $to=CarbonImmutable::parse($ends); abort_unless($to->isAfter($from) && !$to->isAfter(now()),422,'Intervalo inválido ou ocorrência futura.'); $seconds=$to->getTimestamp()-$from->getTimestamp(); $last=$from; foreach($d['pauses'] as $pause) { $a=CarbonImmutable::parse($pause['start']); $b=CarbonImmutable::parse($pause['end']); abort_unless(!$a->isBefore($last) && $b->isAfter($a) && !$b->isAfter($to),422,'Pausas inválidas/sobrepostas.'); $seconds-=$b->getTimestamp()-$a->getTimestamp(); $last=$b; } abort_unless($seconds>0 && $seconds%60===0,422,'Tempo efectivo deve ser exactamente representável em minutos; sem truncagem.'); $minutes=intdiv($seconds,60); if(!empty($d['start_id'])) { $start=$this->entry($d['start_id'],$id,'start'); abort_unless($start->technician_id===$actor && $start->task_id===$task->id && $start->authorization_id===($auth?->id) && CarbonImmutable::parse($start->starts_at)->eq($from),409,'Início incompatível.'); abort_if(DB::table('wo_entries')->where('start_id',$start->id)->exists(),409,'Início já concluído.'); } else abort_unless($d['mode']==='reported',422,'Seleccione relato de ocorrência ou indique início existente.'); // A correction cannot silently turn an already reported exception into authorized work. if($prev && $prev->outside_authority) $outside=true; $overlap=DB::table('wo_entries as e')->join('wo_orders as o','o.id','=','e.order_id')->where('o.company_id',$s['company_id'])->where(['e.kind'=>'time','e.technician_id'=>$actor])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_entries as n')->whereColumn('n.previous_id','e.id'))->when($prev,fn($q)=>$q->where('e.id','<>',$prev->id))->where('e.starts_at','<',$ends)->where('e.ends_at','>',$starts)->exists(); abort_if($overlap,409,'Intervalo sobreposto do mesmo técnico; não será duplicado.'); } if($kind==='material') { foreach(['quantity','resource','unit','owner','event','starts_at'] as $f) abort_unless(isset($d[$f]),422,'Material: recurso, unidade, propriedade, tipo, quantidade e data necessários.'); abort_if(CarbonImmutable::parse($starts)->isAfter(now()),422,'Ocorrência futura não é material observado.'); $quantity=$d['quantity']; foreach(DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'material'])->get() as $other) { $od=$this->data($other); if($od['resource']===$d['resource']) abort_unless($od['unit']===$d['unit'] && $od['owner']===$d['owner'],409,'A referência material conserva unidade e propriedade.'); } if($prev) { $pd=$this->data($prev); foreach(['resource','unit','owner','event'] as $field) abort_unless($pd[$field]===$d[$field],409,'A correcção não pode transferir a origem material para outro acontecimento.'); } if($d['event']!=='consumed') $outside=false; if($prev && $prev->outside_authority) $outside=true; } } $data=$d+['policy'=>self::POLICY]; $entry=DB::table('wo_entries')->insertGetId(['order_id'=>$id,'kind'=>$kind,'origin'=>$d['origin'],'version'=>$d['version'],'previous_id'=>$prev?->id,'task_id'=>$task?->id,'authorization_id'=>$auth?->id,'start_id'=>$start?->id,'technician_id'=>$tech,'starts_at'=>$starts,'ends_at'=>$ends,'minutes'=>$minutes,'quantity'=>$quantity,'outside_authority'=>$outside,'revision'=>$d['revision'],'data'=>$this->json($data),'actor_id'=>$actor,'created_at'=>now()]); $actual=DB::table('wo_entries')->find($entry); return ['id'=>$entry,'order_id'=>$id,'version'=>(int)$d['version'],'event'=>$kind.'.recorded','outside_authority'=>(bool)$actual->outside_authority,'revision'=>$this->revision($id)]; }); } public function verify(int $id,int $actor,array $input,string $key): array { self::guard(); $s=$this->scope($actor,'verify'); $entry=DB::table('wo_entries')->find($id); abort_unless($entry,403); $this->order($entry->order_id,$actor,$s); $d=$this->exact($input,['version'=>'required|integer|min:1','revision'=>'required|integer|min:0','decision'=>'required|in:verified,refused,acknowledged','reason'=>'required|string|min:3|max:1000']); return $this->command($actor,'verify',$key,['entry_id'=>$id]+$d,$entry->order_id,function($s)use($entry,$actor,$d) { $e=$this->entry($entry->id,$entry->order_id); abort_unless(in_array($e->kind,['task','time','material']),422,'Este registo não é apontamento verificável.'); abort_if($e->actor_id===$actor,403,'Verificador deve ser distinto do autor.'); abort_unless($this->current($e) && (int)$d['version']===$e->version && (int)$d['revision']===$this->revision($e->order_id),409,'Versão/revisão desactualizada.'); abort_if(DB::table('wo_decisions')->where('entry_id',$e->id)->exists(),409,'Versão já decidida.'); abort_if($e->outside_authority && $d['decision']==='verified',409,'Fora do âmbito: pode reconhecer a ocorrência para auditoria ou recusar, sem autorizar retroactivamente.'); abort_if(!$e->outside_authority && $d['decision']==='acknowledged',422,'Reconhecimento de excepção apenas para ocorrência fora do âmbito.'); $decision=DB::table('wo_decisions')->insertGetId(['entry_id'=>$e->id,'entry_version'=>$d['version'],'revision'=>$d['revision'],'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]); return ['id'=>$decision,'entry_id'=>$e->id,'order_id'=>$e->order_id,'version'=>$e->version,'event'=>'entry.'.$d['decision'],'revision'=>$this->revision($e->order_id)]; }); } public function listing(int $actor): array { $s=$this->scope($actor,'view'); if(!ServiceCalculation::isolated()) return ['blocked'=>true,'notice'=>self::NOTICE,'orders'=>[]]; self::guard(); return DB::transaction(function()use($s,$actor) { DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail(); $orders=[]; foreach(DB::table('wo_orders')->where($s)->orderBy('id')->get() as $o) { try {$this->order($o->id,$actor,$s);} catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface) {continue;} $entries=DB::table('wo_entries')->where('order_id',$o->id)->orderBy('id')->get(); foreach($entries as $e) { $e->data=$this->data($e); $e->current=$this->current($e); $e->decision=DB::table('wo_decisions')->where('entry_id',$e->id)->first(); } $current=$entries->where('current',true); $points=$current->whereIn('kind',['task','time','material']); $exception=$points->contains(fn($e)=>$e->outside_authority); $pending=$points->contains(fn($e)=>!$e->decision || $e->decision->decision!=='verified'); $state=$points->isEmpty()?'open':($exception?'exception_review':($pending?'in_review':'records_verified')); $materials=[]; foreach($current->where('kind','material') as $e) { $k=$e->data['resource'].'|'.$e->data['owner'].'|'.$e->data['unit']; $materials[$k]??=['resource'=>$e->data['resource'],'unit'=>$e->data['unit'],'owner'=>$e->data['owner'],'required'=>'0','delivered'=>'0','consumed'=>'0','returned'=>'0','verified_consumed'=>'0']; $event=$e->data['event']; $materials[$k][$event]=(string)\Brick\Math\BigDecimal::of($materials[$k][$event])->plus($e->quantity); if($event==='consumed' && $e->decision?->decision==='verified') $materials[$k]['verified_consumed']=(string)\Brick\Math\BigDecimal::of($materials[$k]['verified_consumed'])->plus($e->quantity); } foreach($materials as &$m) { $m['unexplained']=(string)\Brick\Math\BigDecimal::of($m['delivered'])->minus($m['consumed'])->minus($m['returned']); $m['status']=$m['unexplained']==='0'?'balanced_observations':'pending_physical_explanation'; } unset($m); $orders[]=(array)$o+['equipment'=>DB::table('wo_equipment')->find($o->equipment_id),'revision'=>$this->revision($o->id),'assigned_to'=>$entries->where('kind','assignment')->last()?->technician_id,'state'=>$state,'entries'=>$entries->all(),'minutes_reported'=>$current->where('kind','time')->sum('minutes'),'minutes_verified'=>$current->where('kind','time')->filter(fn($e)=>$e->decision?->decision==='verified')->sum('minutes'),'materials'=>array_values($materials)]; } $techs=$this->has($actor,'assign',$s)?DB::table('permission_grants as g')->join('users as u','u.id','=','g.user_id')->where(['g.company_id'=>$s['company_id'],'g.unit_id'=>$s['unit_id'],'g.permission'=>'workshop.record'])->get(['u.id','u.name'])->all():[]; $clients=DB::table('a_clients')->where(['company_id'=>$s['company_id'],'tenant_id'=>$s['tenant_id']]); if(!$this->has($actor,'open',$s)) $clients->whereIn('id',array_column($orders,'client_id')); return ['blocked'=>false,'notice'=>self::NOTICE,'policy'=>self::POLICY,'orders'=>$orders,'clients'=>$clients->get(['id','name'])->all(),'technicians'=>$techs]; }); } }