|
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
<?php
|
|
|
|
|
namespace App\Modules\Workshop;
|
|
|
|
|
use App\Modules\Identity\Services\{Permissions,Audit};
|
|
|
|
|
use App\Modules\Services\{ServiceWorkflow,DraftValues,ServiceCalculation};
|
|
|
|
|
use App\Shared\Context\CompanyContext;
|
|
|
|
|
use Illuminate\Support\Facades\{DB,Validator};
|
|
|
|
|
final class WorkshopCorrections {
|
|
|
|
|
public const POLICY='WORKSHOP-BILLING-CORRECTION-SIM-v1';
|
|
|
|
|
public function __construct(private CompanyContext $context,private Permissions $permissions,private ServiceWorkflow $workflow){}
|
|
|
|
|
public static function guard(): void {WorkshopBilling::guard();}
|
|
|
|
|
private function json($v): string{return json_encode($v,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
|
|
|
|
|
private function canonical($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 exact(array $d,array $rules): array{abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo fora da correcção comercial.');return Validator::make($d,$rules,['required'=>'Preencha :attribute.','present'=>'Indique :attribute.'])->validate();}
|
|
|
|
|
private function scope(int $actor,string $right): 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_corrections.view','workshop_corrections.'.$right,'workshop_billing.view','workshop.view'] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);
|
|
|
|
|
foreach(['clients.view','services.view'] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p);return $s;
|
|
|
|
|
}
|
|
|
|
|
private function root(int $id,array $s): object {
|
|
|
|
|
$g=DB::table('wb_generations as g')->join('wo_orders as o','o.id','=','g.order_id')->where('g.id',$id)->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->select('g.*')->first();return $g??abort(403,'Geração fora do contexto.');
|
|
|
|
|
}
|
|
|
|
|
private function current(object $g): array {
|
|
|
|
|
$a=DB::table('wc_applications')->where('generation_id',$g->id)->orderByDesc('sequence')->first();abort_if($a?->kind==='cancel',409,'Preparação cancelada; origens não reabertas.');
|
|
|
|
|
return ['sequence'=>$a?->sequence??0,'service_id'=>$a?->new_service_id??$g->service_draft_id,'billing_id'=>$a?->new_billing_id??$g->billing_draft_id];
|
|
|
|
|
}
|
|
|
|
|
private function baseline(object $g,array $s): array {
|
|
|
|
|
$c=$this->current($g);$service=DB::table('a_service_drafts')->find($c['service_id']);$bill=DB::table('a_billing_drafts')->find($c['billing_id']);$order=DB::table('wo_orders')->find($g->order_id);
|
|
|
|
|
abort_unless($service&&$bill&&$service->state==='draft'&&$bill->state==='draft'&&$bill->source_id===$service->id&&$bill->client_id===$order->client_id&&$service->client_id===$order->client_id,409,'Destino ou cliente incompatível.');
|
|
|
|
|
WorkshopCommercialState::assertCurrent('billing_drafts',$bill->id);
|
|
|
|
|
abort_if(DB::table('b_internal_confirmations')->where('billing_draft_id',$bill->id)->exists()||DB::table('a_billing_drafts')->where('source_id',$service->id)->where('id','<>',$bill->id)->exists(),409,'Destino confirmado ou com dependências.');
|
|
|
|
|
// Conservative pilot: prepared document payloads require a separately reviewed inheritance path.
|
|
|
|
|
abort_if(DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->where(fn($q)=>$q->where(fn($x)=>$x->where('kind','billing_drafts')->where('record_id',$bill->id))->orWhere(fn($x)=>$x->where('kind','service_drafts')->where('record_id',$service->id)))->exists(),409,'Preparação documental existente: herança exige revisão própria.');
|
|
|
|
|
$origin=json_decode(DB::table('wb_proposals')->find($g->proposal_id)->snapshot,true,64,JSON_THROW_ON_ERROR);
|
|
|
|
|
foreach($origin['lines'] as $line){$e=DB::table('wo_entries')->where(['id'=>$line['entry_id'],'version'=>$line['entry_version'],'order_id'=>$g->order_id])->first();abort_unless($e&&!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$e->id)->exists()&&!DB::table('wo_entries')->where('previous_id',$e->task_id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$e->id,'entry_version'=>$e->version,'decision'=>'verified'])->exists(),409,'Origem técnica desactualizada ou não verificada.');}
|
|
|
|
|
$bd=json_decode($bill->data,true);abort_unless($bd['currency']==='MZN',409,'Moeda incompatível.');
|
|
|
|
|
return $c+['service_version'=>$service->version,'billing_version'=>$bill->version,'client_id'=>$bill->client_id,'client_version'=>DB::table('a_clients')->find($bill->client_id)->version,'currency'=>'MZN','current_base_exact'=>DraftValues::base($bd),'original_origins'=>$origin['lines'],'current_lines'=>$bd['lines'],'doc_versions'=>DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->get(['id','version'])->map(fn($r)=>(array)$r)->all()];
|
|
|
|
|
}
|
|
|
|
|
private function commercial(array $base,array $input,array $s): array {
|
|
|
|
|
abort_unless(count($input)===count($base['original_origins']),422,'Manter todas as origens integrais.');$out=[];
|
|
|
|
|
foreach($base['original_origins'] as $i=>$origin){$l=$this->exact($input[$i],['entry_id'=>'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','description'=>'required|string|min:3|max:500']);
|
|
|
|
|
abort_unless($l['entry_id']===$origin['entry_id'],409,'Não pode substituir ou reordenar origens.');
|
|
|
|
|
$service=DB::table('a_services')->where(['id'=>$l['service_id'],'version'=>$l['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
|
|
|
|
|
$price=DraftValues::decimal($l['price'],'preço comercial');abort_if($price===null,422,'Preço desconhecido.');
|
|
|
|
|
$out[]=array_replace($origin,['service_id'=>$service->id,'service_version'=>$service->version,'unit_price'=>$price,'price_source'=>$l['price_source'],'description'=>$l['description']]);
|
|
|
|
|
}return $out;
|
|
|
|
|
}
|
|
|
|
|
private function cmd(int $generation,int $actor,string $action,array $in,string $key,callable $write): array {
|
|
|
|
|
self::guard();$s=$this->scope($actor,$action);$this->root($generation,$s);abort_unless(preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422);$hash=hash('sha256',$this->json($this->canonical($in)));
|
|
|
|
|
return DB::transaction(function()use($generation,$actor,$action,$in,$key,$write,$s,$hash){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$action);$g=$this->root($generation,$s);
|
|
|
|
|
if($action==='apply'){foreach(['service_drafts','billing_drafts'] as $area)$this->workflow->scope($area,$actor,'create');}
|
|
|
|
|
$old=DB::table('wc_commands')->where(['generation_id'=>$generation,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave com conteúdo diferente.');return json_decode($old->result,true)+['replayed'=>true];}
|
|
|
|
|
$result=$write($g,$s);$command=DB::table('wc_commands')->insertGetId(['generation_id'=>$generation,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($result),'created_at'=>now()]);
|
|
|
|
|
Audit::record('workshop_corrections.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$result['id'],'version'=>$result['version']]);$event=DB::table('wc_events')->insertGetId(['command_id'=>$command,'payload'=>$this->json(['policy'=>self::POLICY,'scope'=>$s,'action'=>$action,'result'=>$result]),'created_at'=>now()]);DB::table('wc_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_stock_or_accounting']);return $result+['replayed'=>false];},3);
|
|
|
|
|
}
|
|
|
|
|
public function request(int $generation,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','kind'=>'required|in:replace,cancel,withdraw','billing_id'=>'required|integer|min:1','billing_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','lines'=>'present|array','reason'=>'required|string|min:3|max:1000']);
|
|
|
|
|
return $this->cmd($generation,$actor,'request',$d,$key,function($g,$s)use($d,$actor){$base=$this->baseline($g,$s);foreach(['billing_id','billing_version','service_id','service_version'] as $field)abort_unless($d[$field]===$base[$field],409,'Destino mudou; rever versões.');$last=DB::table('wc_requests')->where('generation_id',$g->id)->orderByDesc('version')->first();abort_unless($d['version']===($last?->version??0)+1&&$d['previous_id']===$last?->id,409,'Pedido anterior mudou.');
|
|
|
|
|
if($d['kind']==='withdraw')abort_unless($last&&!DB::table('wc_applications')->where('request_id',$last->id)->exists(),409,'Só desistir de pedido não aplicado.');
|
|
|
|
|
if($d['kind']!=='replace')abort_unless($d['lines']===[],422,'Cancelamento/desistência não têm novas linhas.');$lines=$d['kind']==='replace'?$this->commercial($base,$d['lines'],$s):[];
|
|
|
|
|
$snapshot=['policy'=>self::POLICY,'baseline'=>$base,'lines'=>$lines,'base_exact'=>$lines?DraftValues::base(['lines'=>$lines]):null,'reason'=>$d['reason']];$id=DB::table('wc_requests')->insertGetId(['generation_id'=>$g->id,'version'=>$d['version'],'previous_id'=>$d['previous_id'],'kind'=>$d['kind'],'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version']];});
|
|
|
|
|
}
|
|
|
|
|
private function selected(int $generation,array $d): object {$r=DB::table('wc_requests')->where(['id'=>$d['id'],'version'=>$d['version'],'generation_id'=>$generation])->first()??abort(409,'Pedido/versão incompatível.');abort_if($r->kind==='withdraw'||DB::table('wc_requests')->where('previous_id',$r->id)->exists(),409,'Pedido desistido ou desactualizado.');return $r;}
|
|
|
|
|
private function check(object $r,object $g,array $s): array {$snap=json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR);$base=$this->baseline($g,$s);abort_unless($this->canonical($snap['baseline'])===$this->canonical($base),409,'Origem, destino ou dependência mudou; nova revisão necessária.');if($r->kind==='replace'){$input=array_map(fn($l)=>['entry_id'=>$l['entry_id'],'service_id'=>$l['service_id'],'service_version'=>$l['service_version'],'price'=>$l['unit_price'],'price_source'=>$l['price_source'],'description'=>$l['description']],$snap['lines']);$this->commercial($base,$input,$s);}return $snap;}
|
|
|
|
|
public function review(int $generation,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->cmd($generation,$actor,'review',$d,$key,function($g,$s)use($d,$actor){$r=$this->selected($g->id,$d);abort_if($r->actor_id===$actor,403,'Revisor distinto necessário.');$this->check($r,$g,$s);abort_if(DB::table('wc_reviews')->where('request_id',$r->id)->exists(),409,'Decisão imutável já registada.');$id=DB::table('wc_reviews')->insertGetId(['request_id'=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version];});
|
|
|
|
|
}
|
|
|
|
|
public function apply(int $generation,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->cmd($generation,$actor,'apply',$d,$key,function($g,$s)use($d,$actor,$key){$r=$this->selected($g->id,$d);abort_unless(DB::table('wc_reviews')->where(['request_id'=>$r->id,'version'=>$r->version,'decision'=>'approved'])->exists(),409,'Revisão exacta necessária.');$snap=$this->check($r,$g,$s);$base=$snap['baseline'];$service=null;$bill=null;
|
|
|
|
|
if($r->kind==='replace'){$lines=array_map(fn($l)=>array_intersect_key($l,array_flip(['service_id','quantity','unit_price','description'])),$snap['lines']);$name='Correcção oficina #'.$g->order_id.' · pedido #'.$r->id.' v'.$r->version;
|
|
|
|
|
$service=$this->workflow->save('service_drafts',null,$actor,['name'=>$name,'client_id'=>$base['client_id'],'currency'=>'MZN','lines'=>$lines,'description'=>'Substituição rastreável; original preservado.'],substr(hash('sha256','wc-service-'.$g->id.'-'.$r->id.'-'.$key),0,40))['id'];
|
|
|
|
|
$bill=$this->workflow->save('billing_drafts',null,$actor,['name'=>$name,'source_id'=>$service],substr(hash('sha256','wc-billing-'.$g->id.'-'.$r->id.'-'.$key),0,40))['id'];
|
|
|
|
|
}
|
|
|
|
|
$id=DB::table('wc_applications')->insertGetId(['generation_id'=>$g->id,'request_id'=>$r->id,'sequence'=>$base['sequence']+1,'kind'=>$r->kind,'old_service_id'=>$base['service_id'],'old_billing_id'=>$base['billing_id'],'new_service_id'=>$service,'new_billing_id'=>$bill,'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version,'kind'=>$r->kind,'service_draft_id'=>$service,'billing_draft_id'=>$bill,'base_exact'=>$snap['base_exact']];});
|
|
|
|
|
}
|
|
|
|
|
public function listing(int $actor): array {
|
|
|
|
|
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'generations'=>[]];self::guard();return DB::transaction(function()use($s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$generations=[];
|
|
|
|
|
foreach(DB::table('wb_generations as g')->join('wo_orders as o','o.id','=','g.order_id')->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->select('g.*')->get() as $g){$current=null;$issue=null;try{$current=$this->baseline($g,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$issue=$e->getMessage();}$requests=DB::table('wc_requests')->where('generation_id',$g->id)->orderBy('version')->get()->all();foreach($requests as $r){$r->snapshot=json_decode($r->snapshot,true);$r->review=DB::table('wc_reviews')->where('request_id',$r->id)->first();$r->applied=DB::table('wc_applications')->where('request_id',$r->id)->first();$r->current=!DB::table('wc_requests')->where('previous_id',$r->id)->exists()&&$current!==null&&$this->canonical($r->snapshot['baseline'])===$this->canonical($current);}
|
|
|
|
|
$generations[]=(array)$g+['current'=>$current,'issue'=>$issue,'requests'=>$requests,'applications'=>DB::table('wc_applications')->where('generation_id',$g->id)->orderBy('sequence')->get()->all()];}
|
|
|
|
|
return ['blocked'=>false,'generations'=>$generations,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
|
|
|
|
|
}
|
|
|
|
|
}
|