commit melhorias1

This commit is contained in:
2026-11-11 15:04:01 +02:00
parent 1d11a5e57b
commit cd9897df05
145 changed files with 10950 additions and 84 deletions
@@ -0,0 +1,37 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{DB,Crypt,Validator};
use Tempo\Finance\Payables\Payables;
/** New typed QA capture. Existing one-line calculation and invoice writer retain their contracts. */
final class MultiLineInvoiceCalculation
{
public const POLICY='SGF-MULTI-LINE-INVOICE-INTEGRATED-QA-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
private function exact(array $v,array $r):array{abort_if(array_diff(array_keys($v),array_keys($r)),422);return Validator::make($v,$r)->validate();}
public function scope(int $actor,string $right='billing_drafts.view'):array{
abort_unless(config('multiline_invoices.enabled')===true,404);CompositionRuntime::assert();ServiceInvoicing::guard();$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']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,str_starts_with($right,'service_fiscal.')&&$right!=='service_fiscal.calculate'?null:$s['unit_id']);if(str_starts_with($right,'service_fiscal.'))$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_fiscal.view');$m=DB::table('mi_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('multiline_invoices.schema_sha256')),503,'Captura multilinha não conferida.');return$s;
}
private function capture(int $actor,int $draft,int $event,?int $bound=null,?int $preparer=null):array{
$s=$this->scope($actor);$binding=DB::table('mi_drafts')->where($s)->where('draft_id',$draft)->first();abort_unless($binding,403,'A captura nova exige rascunho criado neste percurso multilinha.');$view=app(DocumentPreview::class)->captureDraftForIssuance($draft,$actor);abort_unless((int)$binding->source_id===$view['source_id'],409);abort_unless(count($view['data']['lines'])>=1&&count($view['data']['lines'])<=25&&$view['data']['currency']==='MZN',422,'Seleccione1–25 linhas em MZN.');abort_unless(!empty($view['prepared']['period_start'])&&!empty($view['prepared']['period_end']),422,'Identifique a cobertura antes do cálculo documental.');$mode=match($view['prepared']['price_mode']??null){'added'=>'net_added','included'=>'gross_included',default=>abort(422,'Modalidade de preço explícita obrigatória.')};$rows=[];foreach($view['data']['lines']as$i=>$line){abort_unless(isset($view['prepared']['units'][$i])&&trim($view['prepared']['units'][$i])!==''&&$line['quantity']!==null&&$line['unit_price']!==null,422,'Quantidade/preço/unidade obrigatórios em cada linha.');$rows[]=['reference'=>'LINE-'.($i+1),'quantity'=>$line['quantity'],'unit_price'=>$line['unit_price']];}
unset($view['consulted_at'],$view['revision'],$view['calculation'],$view['fiscal_history']);$date=now(ServiceInvoicing::ZONE)->format('Y-m-d');$common=app(FiscalRules::class)->invoiceDocument($actor,$s,$event,['currency'=>'MZN','date'=>$date,'price_mode'=>$mode,'lines'=>$rows],$bound);abort_unless($common['result']['state']==='determined_qa',409,implode(' ',$common['result']['pending']));$prepared=DB::table('users')->where('id',$preparer??$actor)->first(['id','name','email']);abort_unless($prepared,403);return ['prepared_identity'=>$this->hash((array)$prepared),'prepared_by'=>$preparer??$actor,'policy'=>self::POLICY,'document'=>$view,'common'=>$common,'expected_date'=>$date,'synthetic_only'=>true];
}
private function input(array $v):array{$d=$this->exact($v,['draft_id'=>'required|integer|min:1','rule_event_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','reason'=>'required|string|min:10|max:2000']);foreach(['draft_id','rule_event_id','draft_version','preparation_version']as$k)$d[$k]=(int)$d[$k];return$d;}
private function lock(array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();}
public function preview(int $actor,array $input,string $key):array{
$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,'fiscal_rules.prepare');return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($s);$snapshot=$this->capture($actor,$d['draft_id'],$d['rule_event_id']);$v=$snapshot['document'];abort_unless($v['draft_version']===$d['draft_version']&&$v['preparation_version']===$d['preparation_version'],409,'Versões desactualizadas.');$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'fingerprint'=>$this->hash($snapshot),'observed'=>time()];return ['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>['result'=>$snapshot['common']['result'],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0]];});
}
public function confirm(int $actor,string $preview):array{
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->input($c['input']??[]);$s=$this->scope($actor,'fiscal_rules.prepare');return DB::transaction(function()use($actor,$s,$c,$d){$this->lock($s);$this->scope($actor,'fiscal_rules.prepare');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,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('mi_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409);return json_decode($old->result,true)+['replayed'=>true];}$snapshot=$this->capture($actor,$d['draft_id'],$d['rule_event_id']);$fingerprint=$this->hash($snapshot);abort_unless(hash_equals($c['fingerprint'],$fingerprint),409,'Origem/regra/versão/data alteradas. Confira novamente.');$existing=DB::table('mi_calculations')->where($s+['draft_id'=>$d['draft_id'],'fingerprint'=>$fingerprint])->first();$id=$existing?->id??DB::table('mi_calculations')->insertGetId($s+['draft_id'=>$d['draft_id'],'actor_id'=>$actor,'fingerprint'=>$fingerprint,'snapshot'=>Payables::canonical($snapshot),'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'draft_id'=>$d['draft_id'],'type'=>'calculation','rule_event_id'=>$d['rule_event_id'],'draft_version'=>$d['draft_version'],'preparation_version'=>$d['preparation_version'],'date'=>$snapshot['expected_date'],'result'=>$snapshot['common']['result'],'policy'=>self::POLICY];DB::table('mi_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>Payables::canonical($d),'result'=>Payables::canonical($result),'created_at'=>now()->utc()]);Audit::record('service_invoice.multiline_calculated',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'reason'=>$d['reason'],'review_hash'=>$fingerprint]);return$result+['replayed'=>false];});
}
public function current(int $actor,int $id,?int $draft=null):array{
$s=$this->scope($actor);$r=DB::table('mi_calculations')->where($s)->where('id',$id)->first();abort_unless($r&&($draft===null||(int)$r->draft_id===$draft),403,'Cálculo fora do rascunho/contexto.');$this->permissions->require((int)$r->actor_id,$s['tenant_id'],$s['company_id'],'fiscal_rules.prepare',$s['unit_id']);abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$r->actor_id])->exists(),403);$snapshot=json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR);$now=$this->capture($actor,(int)$r->draft_id,(int)$snapshot['common']['rule']['id'],$id,(int)$r->actor_id);abort_unless(hash_equals($r->fingerprint,$this->hash($now)),409,'Cálculo anterior à origem/regra/data actual.');return ['id'=>$r->id,'fingerprint'=>$r->fingerprint,'snapshot'=>$snapshot];
}
public static function financialAmounts(array $snapshot):array{
abort_unless(($snapshot['calculation_policy']??null)===self::POLICY&&($snapshot['renderer']??null)===MultiLineInvoicePdf::VERSION,409,'Tipo de fotografia multilinha incompatível.');$c=$snapshot['calculation'];$r=$c['common_document_result'];$lines=$snapshot['document']['data']['lines'];abort_unless(count($lines)>=1&&count($lines)<=25&&($snapshot['document']['data']['currency']??null)==='MZN',409);$in=[];foreach($lines as$i=>$line)$in[]=['reference'=>'LINE-'.($i+1),'quantity'=>$line['quantity'],'unit_price'=>$line['unit_price']];$mode=match($snapshot['document']['prepared']['price_mode']){'added'=>'net_added','included'=>'gross_included',default=>abort(409)};$expected=FiscalDocumentMath::calculate($in,$mode,$r['rate']);$frozen=$r;unset($frozen['currency'],$frozen['rule']);abort_unless(Payables::canonical($expected)===Payables::canonical($frozen)&&$expected['state']==='determined_qa'&&$c['base_document_proposed']===$r['base_document']&&$c['vat_document_proposed']===$r['tax_document']&&$c['total_document_proposed']===$r['total'],409,'Valores documentais por linha não conciliam com a fotografia.');return['base'=>$r['base_document'],'tax'=>$r['tax_document'],'total'=>$r['total']];
}
public function receipt(int $actor,string $key):array{$s=$this->scope($actor);$this->exact(['key'=>$key],['key'=>'required|uuid']);$r=DB::table('mi_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'historical_receipt_only'=>true,'operation_executed'=>false];}
}