32 lines
9.2 KiB
PHP
32 lines
9.2 KiB
PHP
<?php
|
|
namespace App\Modules\Services;
|
|
use Illuminate\Support\Facades\{DB,Crypt,Validator};
|
|
use Tempo\Finance\Payables\Payables;
|
|
/** Atomic administrative composition of the existing draft/preparation writers. No invoice writer. */
|
|
final class MultiLineDrafts
|
|
{
|
|
private function hash(array $a):string{return hash('sha256',Payables::canonical($a));}
|
|
private function input(array $v):array{
|
|
$rules=['draft_id'=>'present|nullable|integer|min:1','version'=>'required|integer|min:0','source_version'=>'required|integer|min:0','preparation_version'=>'required|integer|min:0','name'=>'required|string|min:3|max:255','client_id'=>'required|integer|min:1','currency'=>'required|in:MZN','price_mode'=>'required|in:undefined,added,included','service_date'=>'present|nullable|date_format:Y-m-d','period_start'=>'present|nullable|date_format:Y-m-d','period_end'=>'present|nullable|date_format:Y-m-d|after_or_equal:period_start','proposed_due_date'=>'present|nullable|date_format:Y-m-d','payment_terms'=>'present|nullable|string|max:1000','lines'=>'required|array|min:1|max:25','lines.*'=>'array:service_id,description,quantity,unit_price,unit','lines.*.service_id'=>'required|integer|min:1','lines.*.description'=>'nullable|string|max:2000','lines.*.quantity'=>'present|nullable|string|max:30','lines.*.unit_price'=>'present|nullable|string|max:30','lines.*.unit'=>'present|nullable|string|max:80','reason'=>'required|string|min:10|max:2000'];abort_if(array_diff(array_keys($v),array_filter(array_keys($rules),fn($k)=>!str_contains($k,'*'))),422);$d=Validator::make($v,$rules)->validate();foreach(['draft_id','version','source_version','preparation_version','client_id']as$k)if($d[$k]!==null)$d[$k]=(int)$d[$k];abort_unless(array_is_list($d['lines']),422);foreach($d['lines']as$i=>$row){$d['lines'][$i]['service_id']=(int)$row['service_id'];foreach(['quantity','unit_price']as$f)$d['lines'][$i][$f]=DraftValues::decimal($row[$f],'lines.'.$i.'.'.$f,$f==='quantity');}foreach([$d['name'],$d['payment_terms'],$d['reason'],...array_column($d['lines'],'description')]as$value)if(is_string($value))abort_if((bool)preg_match('/(?:password|palavra.passe|token|secret|cvv|cvc|pin)\\s*[:=]/iu',$value),422,'Não introduza credenciais ou segredos.');return$d;
|
|
}
|
|
private function scope(int $actor,array $d):array{
|
|
$s=app(MultiLineInvoiceCalculation::class)->scope($actor,$d['draft_id']===null?'billing_drafts.create':'billing_drafts.edit');$m=DB::table('mi_draft_meta')->where('policy',MultiLineInvoiceCalculation::POLICY)->first();abort_unless($m&&$m->composition===\App\Shared\Installation\CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('multiline_invoices.draft_schema_sha256')),503,'Composição de rascunhos não conferida.');return$s;
|
|
}
|
|
private function basis(int $actor,array $s,array $d):array{
|
|
$w=app(ServiceWorkflow::class);$w->scope('clients',$actor,'view');$w->scope('services',$actor,'view');$client=\App\Shared\Context\ModuleRecords::query('a_clients')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['client_id'])->first();abort_unless($client,403);$ids=array_unique(array_column($d['lines'],'service_id'));$services=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->whereIn('id',$ids)->orderBy('id')->get();abort_unless($services->count()===count($ids),403,'Serviço fora do módulo/contexto.');$before=null;$binding=null;
|
|
if($d['draft_id']!==null){$binding=DB::table('mi_drafts')->where($s)->where('draft_id',$d['draft_id'])->first();abort_unless($binding,403,'Este editor altera apenas os novos rascunhos multilinha.');$bill=$w->show('billing_drafts',$d['draft_id'],$actor);$source=$w->show('service_drafts',(int)$binding->source_id,$actor);$prepared=app(DocumentPreparation::class)->show('billing_drafts',$d['draft_id'],$actor);abort_unless($bill['version']===$d['version']&&$source['version']===$d['source_version']&&$prepared['version']===$d['preparation_version'],409,'Origem/rascunho/preparação alterados.');abort_if(DB::table('si_invoices')->where('draft_id',$d['draft_id'])->orWhere('source_id',$binding->source_id)->exists(),409,'Documento emitido preservado; não editar a origem por este percurso.');$before=['source'=>$source,'billing'=>$bill,'preparation'=>$prepared];}else abort_unless($d['version']===0&&$d['source_version']===0&&$d['preparation_version']===0,409);
|
|
return['client'=>(array)$client,'services'=>$services->map(fn($r)=>(array)$r)->all(),'binding'=>$binding?(array)$binding:null,'before'=>$before];
|
|
}
|
|
private function write(int $actor,array $s,array $d,array $b,string $key):array{
|
|
$w=app(ServiceWorkflow::class);$lines=array_map(function($l){unset($l['unit']);return$l;},$d['lines']);$sourceInput=['name'=>$d['name'],'client_id'=>$d['client_id'],'currency'=>$d['currency'],'lines'=>$lines];if($b['binding'])$sourceInput['version']=$d['source_version'];$source=$w->save('service_drafts',$b['binding']['source_id']??null,$actor,$sourceInput,$key.'-source');$billInput=$b['binding']?['version'=>$d['version'],'reload_source'=>true,'name'=>$d['name']]:['source_id'=>$source['id'],'name'=>$d['name']];$bill=$w->save('billing_drafts',$d['draft_id'],$actor,$billInput,$key.'-billing');$currentPrep=app(DocumentPreparation::class)->show('billing_drafts',$bill['id'],$actor);$prepared=app(DocumentPreparation::class)->save('billing_drafts',$bill['id'],$actor,['version'=>$currentPrep['version'],'draft_version'=>$bill['version'],'currency'=>$d['currency'],'service_date'=>$d['service_date'],'period_start'=>$d['period_start'],'period_end'=>$d['period_end'],'proposed_due_date'=>$d['proposed_due_date'],'payment_terms'=>$d['payment_terms'],'price_mode'=>$d['price_mode'],'units'=>array_column($d['lines'],'unit'),'payment_instruction_ids'=>[]]);
|
|
if(!$b['binding'])DB::table('mi_drafts')->insert($s+['draft_id'=>$bill['id'],'source_id'=>$source['id'],'actor_id'=>$actor,'created_at'=>now()->utc()]);return ['type'=>'draft','id'=>$bill['id'],'source_id'=>$source['id'],'version'=>$bill['version'],'source_version'=>$source['version'],'preparation_version'=>$prepared['version'],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0];
|
|
}
|
|
private function impact(array $d,array $b):array{$lines=$d['lines'];$map=collect($b['services'])->keyBy('id');foreach($lines as&$l)$l['service_name']=$map[$l['service_id']]['name'];unset($l);return['before'=>$b['before']?['source_version'=>$b['before']['source']['version'],'version'=>$b['before']['billing']['version'],'preparation_version'=>$b['before']['preparation']['version']]:null,'after'=>['client'=>$b['client']['name'],'name'=>$d['name'],'currency'=>$d['currency'],'price_mode'=>$d['price_mode'],'lines'=>$lines,'arithmetic_amount'=>DraftValues::base(['lines'=>$lines]),'period_start'=>$d['period_start'],'period_end'=>$d['period_end']],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0];}
|
|
public function preview(int $actor,array $v,string $key):array{
|
|
$d=$this->input($v);abort_unless(preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$d);return DB::transaction(function()use($actor,$s,$d,$key){DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();$b=$this->basis($actor,$s,$d);DB::beginTransaction();try{$this->write($actor,$s,$d,$b,$key);}finally{DB::rollBack();}$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'basis'=>$this->hash($b),'observed'=>time()];return ['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>$this->impact($d,$b)];});
|
|
}
|
|
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->input($c['input']??[]);$s=$this->scope($actor,$d);return DB::transaction(function()use($actor,$s,$c,$d){DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();$this->scope($actor,$d);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409);$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$request=['domain'=>'draft','input'=>$d];$old=DB::table('mi_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($request)),409);return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem ou versão alteradas; confira novamente.');$result=$this->write($actor,$s,$d,$b,$c['key']);DB::table('mi_commands')->insert($q+['request_hash'=>$this->hash($request),'input'=>Payables::canonical($request),'result'=>Payables::canonical($result),'created_at'=>now()->utc()]);\App\Modules\Identity\Services\Audit::record('service_invoice.multiline_draft_prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$result['id'],'version'=>$result['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];});
|
|
}
|
|
}
|