Files
SGF/app/Modules/Workshop/WorkshopBilling.php
2026-11-11 09:00:17 +02:00

98 lines
16 KiB
PHP

<?php
namespace App\Modules\Workshop;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Modules\Services\{ServiceWorkflow,ServiceCalculation,DraftValues};
use App\Shared\Context\CompanyContext;
use App\Shared\Costs\ExactCostMath as Exact;
use Illuminate\Support\Facades\{DB,Validator};
final class WorkshopBilling {
public const POLICY='WORKSHOP-BILLING-DRAFT-SIM-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions,private ServiceWorkflow $workflow){}
public static function guard(): void {WorkshopWorkflow::guard();}
private function json($x): string{return json_encode($x,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
private function canonical($x){if(!is_array($x))return $x;if(!array_is_list($x))ksort($x);return array_map(fn($v)=>$this->canonical($v),$x);}
private function exact(array $in,array $rules): array{
abort_if(array_diff(array_keys($in),array_keys($rules)),422,'Campo fora do recorte.');
return Validator::make($in,$rules,[
'required'=>'Preencha :attribute.','present'=>'Falta :attribute; actualize o formulário.','integer'=>':attribute deve ser um número inteiro.',
'string'=>':attribute deve ser texto.','array'=>':attribute deve conter uma lista.','in'=>':attribute tem um valor não permitido.',
'min'=>':attribute não cumpre o mínimo permitido (:min).','max'=>':attribute excede o máximo permitido (:max).',
'lines.required'=>'Seleccione pelo menos uma origem verificada.','lines.min'=>'Seleccione pelo menos uma origem verificada.',
],['entry_id'=>'a origem','entry_version'=>'a versão da origem','service_id'=>'o serviço comercial','service_version'=>'a versão do serviço','price'=>'o preço comercial','price_source'=>'a fonte comercial e versão','lines'=>'as origens seleccionadas','evidence'=>'o fundamento da proposta','reason'=>'o fundamento da revisão','client_id'=>'o cliente','currency'=>'a moeda','version'=>'a versão','previous_id'=>'a referência anterior','decision'=>'a decisão','id'=>'a proposta'])->validate();
}
private function scope(int $actor,string $action): array {
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($this->context->userId()===$actor&&$s['unit_id']!==null,403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
foreach(['workshop_billing.view','workshop_billing.'.$action,'workshop.view'] as $right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,$s['unit_id']);
foreach(['clients.view','services.view'] as $right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right);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 contexto.');}
private function commercial(\Closure $work):mixed{
if(config('sgf_single_installation.enabled')!==true)return $work();
\App\Shared\Installation\CompositionRuntime::assert();$modules=app(\App\Shared\Context\ModuleNavigation::class)->available($this->context);abort_unless(in_array('commercial',array_column($modules,'id'),true),403,'Comercial não autorizado neste contexto.');
$request=request();$old=$request->attributes->get('sgf_module');$request->attributes->set('sgf_module','commercial');try{return $work();}finally{if($old===null)$request->attributes->remove('sgf_module');else $request->attributes->set('sgf_module',$old);}
}
private function catalogue(string $table){return $this->commercial(fn()=>\App\Shared\Context\ModuleRecords::query($table));}
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(409,'Origem/versão incompatível.');$e->data=json_decode($e->data,true);
abort_unless(!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->exists(),409,'Origem não verificada ou desactualizada.');
$task=DB::table('wo_entries')->where(['id'=>$e->task_id,'order_id'=>$order,'kind'=>'task'])->first();
abort_unless($task&&!DB::table('wo_entries')->where('previous_id',$task->id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$task->id,'entry_version'=>$task->version,'decision'=>'verified'])->exists(),409,'Tarefa não verificada/actual.');
abort_unless(($e->kind==='time'&&$e->data['time_type']==='work')||($e->kind==='material'&&$e->data['event']==='consumed'&&$e->data['owner']==='company'),422,'Só tempo efectivo e material consumido da empresa.');return $e;
}
private function compose(int $order,array $input,array $s,bool $allowClaim=false): array {
$o=$this->order($order,$s);abort_unless($input['client_id']===$o->client_id&&$input['currency']==='MZN',409,'Cliente ou moeda incompatível.');
$client=$this->catalogue('a_clients')->where(['id'=>$o->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409);
abort_unless(count($input['lines'])>0,422);$seen=[];$lines=[];
foreach($input['lines'] as $line){
$d=$this->exact($line,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','price'=>'required|string','price_source'=>'required|string|min:3|max:120']);
$e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_if(isset($seen[$e->origin]),409,'Origem repetida.');$seen[$e->origin]=true;
abort_if(!$allowClaim&&DB::table('wb_claims')->where(['order_id'=>$order,'origin'=>$e->origin])->exists(),409,'Origem já utilizada comercialmente.');
$service=$this->catalogue('a_services')->where(['id'=>$d['service_id'],'version'=>$d['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
$price=DraftValues::decimal($d['price'],'price');abort_if($price===null,422,'Preço desconhecido não permite gerar.');
$quantity=$e->kind==='time'?Exact::text(Exact::hours($e->minutes)):$e->quantity;$quantity=DraftValues::decimal($quantity,'quantity',true);
$lines[]=['entry_id'=>$e->id,'entry_version'=>$e->version,'origin'=>$e->origin,'task_id'=>$e->task_id,'source_kind'=>$e->kind,'source_quantity'=>$e->kind==='time'?(string)$e->minutes:$e->quantity,'unit'=>$e->kind==='time'?'H':$e->data['unit'],'service_id'=>$service->id,'service_version'=>$service->version,'quantity'=>$quantity,'unit_price'=>$price,'price_source'=>$d['price_source'],'description'=>"Oficina #{$order} · origem #{$e->id} v{$e->version} · ".($e->kind==='time'?'Trabalho efectivo':$e->data['resource'])];
}
$data=['currency'=>'MZN','lines'=>$lines];return ['policy'=>self::POLICY,'order_id'=>$order,'client_id'=>$o->client_id,'client_version'=>$client->version,'currency'=>'MZN','lines'=>$lines,'base_exact'=>DraftValues::base($data),'price_policy'=>'explicit_commercial_not_internal_cost','evidence'=>$input['evidence']];
}
private function command(int $order,int $actor,string $action,array $d,string $key,callable $write): array {
self::guard();$s=$this->scope($actor,$action);if($action==='generate'){$this->workflow->scope('service_drafts',$actor,'create');$this->workflow->scope('billing_drafts',$actor,'create');}$this->order($order,$s);abort_unless(preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422);$hash=hash('sha256',$this->json($this->canonical($d)));
return DB::transaction(function()use($order,$actor,$action,$d,$key,$hash,$write,$s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$action);if($action==='generate'){$this->workflow->scope('service_drafts',$actor,'create');$this->workflow->scope('billing_drafts',$actor,'create');}$this->order($order,$s);
$old=DB::table('wb_commands')->where(['order_id'=>$order,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();if($old){abort_unless(hash_equals($old->request_hash,$hash),409);return json_decode($old->result,true)+['replayed'=>true];}
$out=$write($s);$cmd=DB::table('wb_commands')->insertGetId(['order_id'=>$order,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($out),'created_at'=>now()]);Audit::record('workshop_billing.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']]);
$event=DB::table('wb_events')->insertGetId(['command_id'=>$cmd,'payload'=>$this->json(['policy'=>self::POLICY,'scope'=>$s,'action'=>$action,'result'=>$out]),'created_at'=>now()]);DB::table('wb_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_stock_or_accounting']);return $out+['replayed'=>false];},3);
}
public function prepare(int $order,int $actor,array $in,string $key): array {
self::guard();$d=$this->exact($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','client_id'=>'required|integer|min:1','currency'=>'required|in:MZN','lines'=>'required|array|min:1|max:50','evidence'=>'required|string|min:3|max:1000']);
return $this->command($order,$actor,'prepare',$d,$key,function($s)use($order,$actor,$d){abort_if(\Illuminate\Support\Facades\Schema::hasTable('cq_conversions')&&DB::table('cq_conversions')->where('source_order_id',$order)->exists(),409,'Ordem convertida por orçamento; não duplicar a facturação.');abort_if(DB::table('wb_generations')->where('order_id',$order)->exists(),409,'Ordem já convertida; exige correcção própria.');$last=DB::table('wb_proposals')->where('order_id',$order)->orderByDesc('version')->first();abort_unless($d['version']===($last?->version??0)+1&&$d['previous_id']===$last?->id,409,'Rever versão anterior.');$snapshot=$this->compose($order,$d,$s);$id=DB::table('wb_proposals')->insertGetId(['order_id'=>$order,'version'=>$d['version'],'previous_id'=>$last?->id,'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version']];});
}
private function proposal(int $order,array $d): object {$p=DB::table('wb_proposals')->where(['id'=>$d['id'],'order_id'=>$order,'version'=>$d['version']])->first()??abort(409,'Proposta/versão incompatível.');abort_if(DB::table('wb_proposals')->where('previous_id',$p->id)->exists(),409,'Proposta desactualizada.');return $p;}
private function recheck(object $p,array $s,bool $allowClaim=false): array {
$snap=is_array($p->snapshot)?$p->snapshot:json_decode($p->snapshot,true);$lines=array_map(fn($l)=>['entry_id'=>$l['entry_id'],'entry_version'=>$l['entry_version'],'service_id'=>$l['service_id'],'service_version'=>$l['service_version'],'price'=>$l['unit_price'],'price_source'=>$l['price_source']],$snap['lines']);$new=$this->compose($p->order_id,['client_id'=>$snap['client_id'],'currency'=>$snap['currency'],'lines'=>$lines,'evidence'=>$snap['evidence']],$s,$allowClaim);abort_unless($this->canonical($snap)===$this->canonical($new),409,'Dados comerciais/origens mudaram; nova proposta necessária.');return $snap;
}
public function review(int $order,int $actor,array $in,string $key): array {
self::guard();$d=$this->exact($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->command($order,$actor,'review',$d,$key,function($s)use($order,$actor,$d){$p=$this->proposal($order,$d);abort_if($p->actor_id===$actor,403,'Revisor distinto necessário.');$this->recheck($p,$s);abort_if(DB::table('wb_decisions')->where('proposal_id',$p->id)->exists(),409);$id=DB::table('wb_decisions')->insertGetId(['proposal_id'=>$p->id,'version'=>$p->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$p->version];});
}
public function generate(int $order,int $actor,array $in,string $key): array {
self::guard();$d=$this->exact($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1']);
return $this->command($order,$actor,'generate',$d,$key,function($s)use($order,$actor,$d,$key){$p=$this->proposal($order,$d);abort_unless(DB::table('wb_decisions')->where(['proposal_id'=>$p->id,'version'=>$p->version,'decision'=>'approved'])->exists(),409,'Revisão exacta necessária.');abort_if(\Illuminate\Support\Facades\Schema::hasTable('cq_conversions')&&DB::table('cq_conversions')->where('source_order_id',$order)->exists(),409,'Ordem convertida por orçamento; não duplicar a facturação.');abort_if(DB::table('wb_generations')->where('order_id',$order)->exists(),409,'Ordem já convertida.');$snap=$this->recheck($p,$s);
$this->workflow->scope('service_drafts',$actor,'create');$this->workflow->scope('billing_drafts',$actor,'create');
$lines=array_map(fn($l)=>array_intersect_key($l,array_flip(['service_id','quantity','unit_price','description'])),$snap['lines']);$name='Oficina #'.$order.' · proposta #'.$p->id.' v'.$p->version;
[$service,$billing]=$this->commercial(function()use($actor,$snap,$name,$lines,$key){$service=$this->workflow->save('service_drafts',null,$actor,['client_id'=>$snap['client_id'],'name'=>$name,'currency'=>'MZN','lines'=>$lines,'description'=>'Origem rastreável de oficina; sem validade fiscal.'],substr(hash('sha256','wb-service-'.$key),0,40));$billing=$this->workflow->save('billing_drafts',null,$actor,['source_id'=>$service['id'],'name'=>$name],substr(hash('sha256','wb-billing-'.$key),0,40));return [$service,$billing];});
$id=DB::table('wb_generations')->insertGetId(['order_id'=>$order,'proposal_id'=>$p->id,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'actor_id'=>$actor,'created_at'=>now()]);foreach($snap['lines'] as $l)DB::table('wb_claims')->insert(['generation_id'=>$id,'order_id'=>$order,'origin'=>$l['origin'],'entry_id'=>$l['entry_id'],'entry_version'=>$l['entry_version']]);return ['id'=>$id,'version'=>$p->version,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'base_exact'=>$snap['base_exact']];});
}
private function generationState(int $order): ?object {
$g=DB::table('wb_generations')->where('order_id',$order)->first();if(!$g)return null;
$g->commercial_state=WorkshopCommercialState::state('billing_drafts',$g->billing_draft_id);
$g->latest_commercial_change=\Illuminate\Support\Facades\Schema::hasTable('wc_applications')?DB::table('wc_applications')->where('generation_id',$g->id)->orderByDesc('sequence')->first():null;
return $g;
}
public function listing(int $actor): array {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[]];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)->get() as $o){$entries=[];foreach(DB::table('wo_entries')->where('order_id',$o->id)->whereIn('kind',['time','material'])->get() as $e){try{$entries[]=$this->source($e->id,$e->version,$o->id);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){}}
$proposals=DB::table('wb_proposals')->where('order_id',$o->id)->orderBy('version')->get()->all();foreach($proposals as $p){$p->snapshot=json_decode($p->snapshot,true);$p->decision=DB::table('wb_decisions')->where('proposal_id',$p->id)->first();$p->current=!DB::table('wb_proposals')->where('previous_id',$p->id)->exists();if($p->current){try{$this->recheck($p,$s,true);}catch(\Throwable){$p->current=false;}}}
$orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>$this->generationState($o->id)];}return ['blocked'=>false,'orders'=>$orders,'services'=>$this->catalogue('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
}
}