Files
SGF/app/Modules/Services/CommercialQuotes.php
T
2026-11-11 09:00:17 +02:00

56 lines
17 KiB
PHP

<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\{Permissions,Audit};use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use Illuminate\Support\Facades\{DB,Crypt,Validator};use Carbon\CarbonImmutable;
final class CommercialQuotes
{
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function scope(int $actor,string $action='view'):array{abort_unless(config('commercial_quotes.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='commercial',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(['view',$action] as $right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'commercial_quotes.'.$right,$s['unit_id']);return $s;}
private function canonical(mixed $v):mixed{if(is_object($v))$v=(array)$v;if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map(fn($x)=>$this->canonical($x),$v);}
private function json(mixed $v):string{return json_encode($this->canonical($v),JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);}
private function hash(mixed $v):string{return hash('sha256',$this->json($v));}
private function current(array $s){return DB::table('cq_versions as q')->where(['q.tenant_id'=>$s['tenant_id'],'q.company_id'=>$s['company_id'],'q.unit_id'=>$s['unit_id']])->whereNotExists(fn($x)=>$x->selectRaw('1')->from('cq_versions as n')->whereColumn('n.previous_id','q.id'));}
private function right(string $action):string{return match($action){'prepare','customer'=>'prepare','review'=>'review','convert'=>'convert',default=>abort(422)};}
private function data(array $input):array
{
foreach(['reason','representative','evidence'] as $k)if(isset($input[$k])&&is_string($input[$k]))$input[$k]=trim($input[$k]);$rules=['action'=>'required|in:prepare,review,customer,convert','quote_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();$d['version']=(int)$d['version'];$d['quote_key']=strtolower($d['quote_key']);return $d;
}
private function basis(int $actor,array $s,array $d):array
{
$old=$this->current($s)->where('quote_key',$d['quote_key'])->first();abort_unless(($old?(int)$old->version:0)===$d['version'],409,'Orçamento alterado. Actualize a conferência.');abort_if($old&&$old->state==='converted',409,'Orçamento já convertido; o original permanece preservado.');$fields=$d['data'];
if($d['action']==='prepare'){
$rules=['client_id'=>'required|integer|min:1','currency'=>'required|in:MZN','valid_until'=>'required|date_format:Y-m-d','source_order_id'=>'nullable|integer|min:1','description'=>'required|string|min:3|max:1000','lines'=>'required|array|min:1|max:100','lines.*.service_id'=>'required|integer|min:1','lines.*.quantity'=>'required|string','lines.*.unit_price'=>'required|string','lines.*.description'=>'nullable|string|max:500'];abort_if(array_diff(array_keys($fields),['client_id','currency','valid_until','source_order_id','description','lines']),422);$v=Validator::make($fields,$rules)->validate();$v['client_id']=(int)$v['client_id'];$v['source_order_id']=isset($v['source_order_id'])?(int)$v['source_order_id']:null;abort_if($v['valid_until']<now('Africa/Maputo')->toDateString(),422,'Validade anterior à data actual.');$v['lines']=DraftValues::lines($v['lines'],$s);foreach($v['lines'] as $line)abort_if($line['quantity']===null||$line['unit_price']===null,422,'Quantidade e preço explícitos obrigatórios.');$v['base']=ServiceReceivables::money(DraftValues::base($v));$v['tax_status']='pending_separate_review';$state='draft';
}else{abort_unless($old,403,'Orçamento fora do âmbito.');$v=json_decode($old->data,true);$state=$old->state;}
$client=ModuleRecords::query('a_clients')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'id'=>$v['client_id']])->first();abort_unless($client,403,'Cliente sem associação explícita ao Comercial.');
$services=ModuleRecords::query('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->whereIn('id',array_column($v['lines'],'service_id'))->orderBy('id')->get(['id','version','name'])->all();abort_unless(count($services)===count(array_unique(array_column($v['lines'],'service_id'))),403,'Serviço fora do módulo autorizado.');
$origin=null;if($v['source_order_id']!==null){$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop_billing.view',$s['unit_id']);$o=DB::table('wo_orders')->where($s)->where('id',$v['source_order_id'])->first();abort_unless($o&&$o->client_id===$v['client_id'],403,'Ordem de origem incompatível.');abort_if(DB::table('wb_generations')->where('order_id',$o->id)->exists()||DB::table('cq_conversions')->where('source_order_id',$o->id)->exists(),409,'Ordem já convertida por outro percurso.');$origin=['order'=>['id'=>$o->id,'reference'=>$o->reference,'client_id'=>$o->client_id,'equipment_id'=>$o->equipment_id],'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) v',[$o->id])->v];}
$dependencies=['client'=>['id'=>$client->id,'version'=>$client->version,'name'=>$client->name],'services'=>$services,'origin'=>$origin];if($d['action']!=='prepare')abort_unless($this->hash(json_decode($old->dependencies,true))===$this->hash($dependencies),409,'Cliente, serviço ou ordem mudou desde a preparação. Prepare nova versão.');
if($d['action']==='review'){abort_unless($state==='draft',409,'Revisão exige a versão preparada actual.');abort_if($old->prepared_by===$actor,403,'Revisão distinta da preparação.');$v['review']=Validator::make($fields,['decision'=>'required|in:approved,rejected'])->validate();abort_if(array_diff(array_keys($fields),['decision']),422);$v['review']+=['actor_id'=>$actor,'reason'=>$d['reason']];$state=$fields['decision']==='approved'?'approved':'rejected';}
if($d['action']==='customer'){abort_unless($state==='approved',409,'Conferência comercial aprovada necessária.');abort_if($v['valid_until']<now('Africa/Maputo')->toDateString(),409,'Orçamento caducado.');$rules=['decision'=>'required|in:accepted,declined','representative'=>'required|string|min:3|max:150','capacity'=>'required|string|min:3|max:150','evidence'=>'required|string|min:10|max:2000','decided_on'=>'required|date_format:Y-m-d|before_or_equal:today'];abort_if(array_diff(array_keys($fields),array_keys($rules)),422);$v['customer']=Validator::make($fields,$rules)->validate();$v['customer']['recorded_by']=$actor;$state=$fields['decision']==='accepted'?'customer_accepted':'customer_declined';}
if($d['action']==='convert'){abort_unless($fields===[]&&$state==='customer_accepted',409,'Aceitação documentada necessária; conversão não é automática.');abort_if($v['valid_until']<now('Africa/Maputo')->toDateString(),409,'Orçamento caducado.');abort_if(DB::table('cq_conversions')->where($s)->where('quote_key',$d['quote_key'])->exists(),409);}
$grants=DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('permission')->orderBy('unit_id')->get(['permission','unit_id'])->all();
return ['previous'=>$old?(array)$old:null,'data'=>$v,'state'=>$state,'prepared_by'=>$d['action']==='prepare'?$actor:$old->prepared_by,'dependencies'=>['client'=>['id'=>$client->id,'version'=>$client->version,'name'=>$client->name],'services'=>$services,'origin'=>$origin],'authority'=>$grants];
}
private function locked(int $actor,string $action,\Closure $fn):mixed{$s=$this->scope($actor,$this->right($action));return DB::transaction(function()use($actor,$action,$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,$this->right($action));DB::table('permission_grants')->where(['user_id'=>$actor,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->lockForUpdate()->get();return $fn($s);});}
private function execute(int $actor,array $s,array $d,array $b):array
{
$state=$d['action']==='convert'?'converted':$b['state'];$drafts=null;
if($d['action']==='convert'){$writer=app(ServiceWorkflow::class);$data=$b['data'];$source=$writer->save('service_drafts',null,$actor,['name'=>'Orçamento '.substr($d['quote_key'],0,8),'client_id'=>$data['client_id'],'currency'=>$data['currency'],'description'=>$data['description'],'lines'=>$data['lines']],(string)\Illuminate\Support\Str::uuid());$billing=$writer->save('billing_drafts',null,$actor,['name'=>'Orçamento '.substr($d['quote_key'],0,8),'source_id'=>$source['id']],(string)\Illuminate\Support\Str::uuid());$drafts=['service_draft_id'=>$source['id'],'billing_draft_id'=>$billing['id']];}
$id=DB::table('cq_versions')->insertGetId($s+['quote_key'=>$d['quote_key'],'version'=>$d['version']+1,'previous_id'=>$b['previous']['id']??null,'action'=>$d['action'],'state'=>$state,'client_id'=>$b['data']['client_id'],'source_order_id'=>$b['data']['source_order_id'],'prepared_by'=>$b['prepared_by'],'actor_id'=>$actor,'data'=>$this->json($b['data']),'dependencies'=>$this->json($b['dependencies']),'reason'=>$d['reason'],'created_at'=>now()]);
if($drafts)DB::table('cq_conversions')->insert($s+['quote_key'=>$d['quote_key'],'quote_version_id'=>$id,'source_order_id'=>$b['data']['source_order_id'],'actor_id'=>$actor,'created_at'=>now()]+$drafts);
$impact=['action'=>$d['action'],'reference'=>'ORC-QA-'.strtoupper(substr($d['quote_key'],0,8)),'version'=>$d['version']+1,'state'=>$state,'client'=>$b['dependencies']['client']['name'],'base'=>$b['data']['base'],'currency'=>'MZN','valid_until'=>$b['data']['valid_until'],'tax_status'=>'pending_separate_review','lines'=>$b['data']['lines'],'source_reference'=>$b['dependencies']['origin']['order']['reference']??null,'creates_draft'=>$drafts!==null,'invoice_issued'=>false,'stock_effect'=>false,'financial_effect'=>false];return ['id'=>$id,'quote_key'=>$d['quote_key'],'version'=>$d['version']+1,'state'=>$state,'drafts'=>$drafts,'impact'=>$impact];
}
public function preview(int $actor,array $input,string $key):array{$d=$this->data($input);Validator::make(['key'=>$key],['key'=>'required|uuid'])->validate();return $this->locked($actor,$d['action'],function($s)use($actor,$d,$key){abort_if(DB::table('cq_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->exists(),409,'Consulte o recibo da chave já usada.');$b=$this->basis($actor,$s,$d);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($actor,$s,$d,$b);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$actor,'scope'=>$s,'data'=>$d,'key'=>$key,'revision'=>$this->hash($b),'impact'=>$out['impact'],'generation'=>$this->hash(request()->session()->get('company_context',[])),'observed'=>time()];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});}
public function confirm(int $actor,string $token):array{try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->data($c['data']??[]);return $this->locked($actor,$d['action'],function($s)use($actor,$c,$d){abort_unless(($c['actor']??null)===$actor&&$this->hash($c['scope']??null)===$this->hash($s)&&($c['generation']??null)===$this->hash(request()->session()->get('company_context',[]))&&($c['observed']??0)>=time()-600&&$c['observed']<=time(),409,'Prévia/contexto expirados ou alterados.');$old=DB::table('cq_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409);$result=json_decode($old->result,true);$source=DB::table('cq_versions')->where($s)->where('id',$result['id'])->first();abort_unless($source&&ModuleRecords::query('a_clients')->where(['id'=>$source->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Origem já não autorizada no módulo.');if($source->source_order_id!==null)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop_billing.view',$s['unit_id']);return $result+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($this->hash($b),$c['revision']),409,'Cliente, serviços, origem, autoridade ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409);DB::table('cq_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('commercial.quote.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'reason'=>$d['reason']]);return $out+['replayed'=>false];});}
public function listing(int $actor,array $input):array{$s=$this->scope($actor);$rules=['state'=>'sometimes|in:draft,approved,rejected,customer_accepted,customer_declined,converted','search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();$q=$this->current($s)->join('a_clients as c','c.id','=','q.client_id')->whereIn('q.client_id',ModuleRecords::query('a_clients')->select('id'));if(isset($d['state']))$q->where('q.state',$d['state']);if(($d['search']??'')!=='')$q->whereRaw("c.name ILIKE ? ESCAPE '!'",['%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%']);$p=$q->orderByDesc('q.id')->paginate((int)($d['per_page']??25),['q.*','c.name as client_name'],'page',(int)($d['page']??1));foreach($p->items() as $row){$row->data=json_decode($row->data,true);$row->dependencies=json_decode($row->dependencies,true);$row->expired=$row->data['valid_until']<now('Africa/Maputo')->toDateString();$row->conversion=DB::table('cq_conversions')->where($s)->where('quote_key',$row->quote_key)->first();}return ['quotes'=>$p,'notice'=>'Orçamento não é factura. Impostos exigem conferência separada.'];}
public function history(int $actor,string $key,int $page=1):array{$s=$this->scope($actor);Validator::make(['key'=>$key,'page'=>$page],['key'=>'required|uuid','page'=>'required|integer|min:1|max:100000'])->validate();$q=DB::table('cq_versions')->where($s)->where('quote_key',$key)->whereIn('client_id',ModuleRecords::query('a_clients')->select('id'));abort_unless((clone$q)->exists(),403);return ['events'=>$q->orderByDesc('version')->paginate(25,['*'],'page',$page)];}
public function options(int $actor,array $input):array
{
$s=$this->scope($actor);$rules=['view'=>'required|in:clients,services,orders','search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();
if($d['view']==='orders'){$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop_billing.view',$s['unit_id']);$q=DB::table('wo_orders')->where($s)->whereIn('client_id',ModuleRecords::query('a_clients')->select('id'))->whereNotIn('id',DB::table('wb_generations')->select('order_id'))->whereNotIn('id',DB::table('cq_conversions')->whereNotNull('source_order_id')->select('source_order_id'));$fields=['id','reference as name','client_id'];$column='reference';}
else{$q=ModuleRecords::query('a_'.$d['view'])->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']]);$fields=['id','name','version'];$column='name';}
if(($d['search']??'')!=='')$q->whereRaw($column." ILIKE ? ESCAPE '!'",['%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%']);return ['records'=>$q->orderBy('id')->paginate((int)($d['per_page']??25),$fields,'page',(int)($d['page']??1))];
}
public function document(int $actor,string $key,int $version):object{$s=$this->scope($actor);Validator::make(['key'=>$key,'version'=>$version],['key'=>'required|uuid','version'=>'required|integer|min:1'])->validate();$row=DB::table('cq_versions')->where($s)->where(['quote_key'=>$key,'version'=>$version])->whereIn('client_id',ModuleRecords::query('a_clients')->select('id'))->first();abort_unless($row,403);$row->data=json_decode($row->data,true);$row->dependencies=json_decode($row->dependencies,true);return $row;}
}