context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']!==null&&ModuleRecords::active()==='workshop',403); $this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(['company.view'=>null,'unit.view'=>$s['unit_id'],'workshop.view'=>$s['unit_id']]+($write?['workshop.assign'=>$s['unit_id']]:[]) as $right=>$unit)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,$unit);return $s; } private function manager(int $actor,array $s):bool{foreach(['open','assign','authorize','verify'] as $right)if($this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$right,$s['unit_id']))return true;return false;} private function orders(int $actor,array $s) { $q=DB::table('wo_orders as o')->where(['o.tenant_id'=>$s['tenant_id'],'o.company_id'=>$s['company_id'],'o.unit_id'=>$s['unit_id']])->whereIn('o.client_id',ModuleRecords::query('a_clients')->select('id')); if(!$this->manager($actor,$s))$q->whereRaw("(SELECT e.technician_id FROM wo_entries e WHERE e.order_id=o.id AND e.kind='assignment' ORDER BY e.id DESC LIMIT 1)=?",[$actor]);return $q; } private function current(array $s){return DB::table('wo_schedule_events as b')->where(['b.tenant_id'=>$s['tenant_id'],'b.company_id'=>$s['company_id'],'b.unit_id'=>$s['unit_id']])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_schedule_events as n')->whereColumn('n.previous_id','b.id'));} private function json(mixed $v):string{if(is_array($v)&&!array_is_list($v)){ksort($v);foreach($v as &$x)if(is_array($x))$x=json_decode($this->json($x),true);}return json_encode($v,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);} private function hash(mixed $v):string{return hash('sha256',$this->json($v));} private function time(string $value):CarbonImmutable { 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,'Indique data/hora com zona explícita.');try{$d=new \DateTimeImmutable($value);$errors=\DateTimeImmutable::getLastErrors();abort_if($errors&&($errors['warning_count']||$errors['error_count']),422);return CarbonImmutable::instance($d)->utc();}catch(\Exception){abort(422,'Data/hora inválida.');} } private function normalize(array $input):array { if(isset($input['reason'])&&is_string($input['reason']))$input['reason']=trim($input['reason']);$rules=['action'=>'required|in:book,reschedule,cancel','booking_key'=>'required|uuid','version'=>'required|integer|min:0','order_id'=>'required|integer|min:1','technician_id'=>'required|integer|min:1','starts_at'=>'required|string|max:35','ends_at'=>'required|string|max:35','reason'=>'required|string|min:10|max:2000'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();foreach(['version','order_id','technician_id'] as $key)$d[$key]=(int)$d[$key];$d['booking_key']=strtolower($d['booking_key']);$start=$this->time($d['starts_at']);$end=$this->time($d['ends_at']);abort_unless($end>$start&&$end<=$start->addDays(7),422,'Fim deve ser posterior e o intervalo não pode exceder sete dias.');$d['starts_at']=$start->toIso8601String();$d['ends_at']=$end->toIso8601String();return $d; } private function basis(int $actor,array $s,array $d):array { $order=$this->orders($actor,$s)->where('o.id',$d['order_id'])->first();abort_unless($order,403,'Ordem fora do âmbito autorizado.'); abort_unless(ModuleRecords::query('a_clients')->where(['id'=>$order->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente sem associação explícita a Oficinas.'); $assignment=DB::table('wo_entries')->where(['order_id'=>$order->id,'kind'=>'assignment'])->orderByDesc('id')->first(); if($d['action']!=='cancel'){abort_unless($assignment&&(int)$assignment->technician_id===$d['technician_id'],409,'Atribua primeiro o técnico na ordem; agendar não altera essa decisão.');$this->permissions->require($d['technician_id'],$s['tenant_id'],$s['company_id'],'workshop.record',$s['unit_id']);} $old=$this->current($s)->where('booking_key',$d['booking_key'])->first(); abort_unless(($old?(int)$old->version:0)===$d['version'],409,'Agendamento alterado. Confira novamente.'); abort_unless($d['action']==='book'?!$old:($old&&$old->state==='scheduled'&&$old->order_id===$order->id),409,'Agendamento incompatível ou cancelado.'); if($d['action']==='cancel')abort_unless($d['starts_at']===CarbonImmutable::parse($old->starts_at)->utc()->toIso8601String()&&$d['ends_at']===CarbonImmutable::parse($old->ends_at)->utc()->toIso8601String()&&$d['technician_id']===$old->technician_id,409,'Cancelar conserva o intervalo e técnico originais.'); $conflicts=$d['action']==='cancel'?[]:$this->current($s)->where('booking_key','<>',$d['booking_key'])->where('state','scheduled')->where(fn($q)=>$q->where('technician_id',$d['technician_id'])->orWhere('equipment_id',$order->equipment_id))->where('starts_at','<',$d['ends_at'])->where('ends_at','>',$d['starts_at'])->orderBy('id')->limit(10)->get(['id','booking_key','technician_id','equipment_id','starts_at','ends_at'])->all(); $revision=(int)DB::selectOne('SELECT sgf_wo_revision(?) v',[$order->id])->v; return ['order'=>(array)$order,'assignment'=>$assignment?->id,'order_revision'=>$revision,'previous'=>$old?(array)$old:null,'conflicts'=>$conflicts]; } private function locked(int $actor,\Closure $fn):mixed { $s=$this->scope($actor,true);return DB::transaction(function()use($actor,$s,$fn){DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();$this->scope($actor,true);DB::table('permission_grants')->where($s+['user_id'=>$actor])->lockForUpdate()->get();return $fn($s);}); } private function impact(array $d,array $basis):array{return ['action'=>$d['action'],'order_reference'=>$basis['order']['reference'],'technician_id'=>$d['technician_id'],'equipment_id'=>$basis['order']['equipment_id'],'starts_at'=>$d['starts_at'],'ends_at'=>$d['ends_at'],'state'=>$d['action']==='cancel'?'cancelled':'scheduled','conflicts'=>$basis['conflicts'],'stock_effect'=>false,'financial_effect'=>false,'external_messages'=>false];} public function preview(int $actor,array $input,string $key):array { abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422,'Use uma chave UUID.');$d=$this->normalize($input); return $this->locked($actor,function($s)use($actor,$d,$key){abort_if(DB::table('wo_schedule_events')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->exists(),409,'Consulte o recibo da chave já usada.');$basis=$this->basis($actor,$s,$d);abort_if($basis['conflicts']!==[],409,'Técnico ou equipamento ocupado neste intervalo.');$impact=$this->impact($d,$basis);$cut=['actor'=>$actor,'scope'=>$s,'input'=>$d,'key'=>$key,'revision'=>$this->hash($basis),'generation'=>$this->hash(request()->session()->get('company_context',[])),'observed'=>time(),'impact'=>$impact];return ['preview'=>Crypt::encryptString($this->json($cut)),'revision'=>$cut['revision'],'impact'=>$impact];}); } public function confirm(int $actor,string $preview):array { try{$cut=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');} return $this->locked($actor,function($s)use($actor,$cut){abort_unless(($cut['actor']??null)===$actor&&$this->hash($cut['scope']??null)===$this->hash($s)&&($cut['generation']??null)===$this->hash(request()->session()->get('company_context',[]))&&($cut['observed']??0)>=time()-600&&$cut['observed']<=time(),409,'Contexto/prévia alterados ou expirados.');$d=$this->normalize($cut['input']);$hash=$this->hash($d);$old=DB::table('wo_schedule_events')->where($s)->where(['actor_id'=>$actor,'command_key'=>$cut['key']])->first(); if($old){abort_unless(hash_equals($old->request_hash,$hash)&&$this->orders($actor,$s)->where('o.id',$old->order_id)->exists(),409);return $this->result($old)+['replayed'=>true];} $basis=$this->basis($actor,$s,$d);abort_unless(hash_equals($this->hash($basis),$cut['revision'])&&$basis['conflicts']===[],409,'Disponibilidade, ordem ou versão mudou. Faça nova conferência.');abort_unless($this->hash($this->impact($d,$basis))===$this->hash($cut['impact']),409); $id=DB::table('wo_schedule_events')->insertGetId($s+['booking_key'=>$d['booking_key'],'version'=>$d['version']+1,'previous_id'=>$basis['previous']['id']??null,'order_id'=>$d['order_id'],'equipment_id'=>$basis['order']['equipment_id'],'technician_id'=>$d['technician_id'],'starts_at'=>$d['starts_at'],'ends_at'=>$d['ends_at'],'state'=>$d['action']==='cancel'?'cancelled':'scheduled','reason'=>$d['reason'],'actor_id'=>$actor,'command_key'=>$cut['key'],'request_hash'=>$hash,'source_revision'=>$cut['revision'],'snapshot'=>$this->json(['before'=>$basis,'after'=>$cut['impact']]),'created_at'=>now()]);Audit::record('workshop.schedule.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['booking_key'=>$d['booking_key'],'record_id'=>$id,'version'=>$d['version']+1,'reason'=>$d['reason']]);return $this->result(DB::table('wo_schedule_events')->where('id',$id)->first())+['replayed'=>false]; }); } private function result(object $r):array{return ['id'=>$r->id,'booking_key'=>$r->booking_key,'version'=>$r->version,'state'=>$r->state,'order_id'=>$r->order_id,'starts_at'=>$r->starts_at,'ends_at'=>$r->ends_at];} public function listing(int $actor,array $input):array { $s=$this->scope($actor);$rules=['from'=>'required|date_format:Y-m-d','to'=>'required|date_format:Y-m-d|after_or_equal:from','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','state'=>'sometimes|in:scheduled,cancelled','technician_id'=>'sometimes|integer|min:1','search'=>'sometimes|nullable|string|max:100','order_id'=>'sometimes|integer|min:1'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();$from=CarbonImmutable::parse($d['from'],WorkshopWorkflow::ZONE)->startOfDay();$to=CarbonImmutable::parse($d['to'],WorkshopWorkflow::ZONE)->addDay()->startOfDay();abort_if($from->diffInDays($to)>93,422,'Escolha no máximo93dias.'); $query=$this->current($s)->whereIn('b.order_id',$this->orders($actor,$s)->select('o.id'))->join('wo_orders as o','o.id','=','b.order_id')->join('users as u','u.id','=','b.technician_id')->join('wo_equipment as e','e.id','=','b.equipment_id')->where('b.starts_at','<',$to->utc()->toIso8601String())->where('b.ends_at','>',$from->utc()->toIso8601String());foreach(['state','technician_id','order_id'] as $key)if(isset($d[$key]))$query->where('b.'.$key,$d[$key]);if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$query->where(fn($q)=>$q->whereRaw("o.reference ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("e.identifier ILIKE ? ESCAPE '!'",[$needle]));} $page=$query->orderBy('b.starts_at')->orderBy('b.id')->paginate((int)($d['per_page']??25),['b.*','o.reference','e.identifier as equipment','u.name as technician'],'page',(int)($d['page']??1)); $now=CarbonImmutable::now('UTC');foreach($page->items() as $row)$row->reminder=$row->state!=='scheduled'?'cancelled':(CarbonImmutable::parse($row->ends_at)<$now?'elapsed':(CarbonImmutable::parse($row->starts_at)<=$now?'in_progress':(CarbonImmutable::parse($row->starts_at)<=$now->addDay()?'within24h':'planned'))); return ['bookings'=>$page,'can_manage'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.assign',$s['unit_id']),'zone'=>WorkshopWorkflow::ZONE,'reminders'=>'local_only_no_messages','notice'=>'Agendar não autoriza trabalho nem altera a ordem.']; } public function options(int $actor,array $input):array { $s=$this->scope($actor);$d=Validator::make($input,['search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'])->validate();abort_if(array_diff(array_keys($input),array_keys($d)),422);$q=$this->orders($actor,$s)->join('wo_equipment as e','e.id','=','o.equipment_id');if(($d['search']??'')!=='')$q->whereRaw("o.reference ILIKE ? ESCAPE '!'",['%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%']); $p=$q->orderByDesc('o.id')->paginate((int)($d['per_page']??25),['o.id','o.reference','e.identifier as equipment'],'page',(int)($d['page']??1));$ids=$p->pluck('id')->all();$assigned=DB::table('wo_entries as a')->join('users as u','u.id','=','a.technician_id')->whereIn('a.order_id',$ids)->where('a.kind','assignment')->whereNotExists(fn($x)=>$x->selectRaw('1')->from('wo_entries as n')->whereColumn('n.order_id','a.order_id')->where('n.kind','assignment')->whereColumn('n.id','>','a.id'))->get(['a.order_id','a.technician_id','u.name'])->keyBy('order_id');foreach($p->items() as $row)$row->assignment=$assigned->get($row->id);return ['orders'=>$p]; } public function history(int $actor,string $key,int $page=1):array { $s=$this->scope($actor);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key)&&$page>0&&$page<=100000,422);$q=DB::table('wo_schedule_events')->where($s)->where('booking_key',$key)->whereIn('order_id',$this->orders($actor,$s)->select('o.id'));abort_unless((clone$q)->exists(),403);return ['events'=>$q->orderByDesc('version')->paginate(25,['*'],'page',$page)]; } }