100 lines
18 KiB
PHP
100 lines
18 KiB
PHP
<?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\{Crypt,DB,Validator};
|
|
use Tempo\Finance\Payables\Payables;
|
|
|
|
/** One explicitly bound QA output book and internal map. No tax settlement or ledger writer. */
|
|
final class FiscalAssessment
|
|
{
|
|
public const CONTRACT='SGF-IVA-ASSESSMENT-FORM-QA-v1';
|
|
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
|
private function json(array $a):string{return Payables::canonical($a);}
|
|
private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
|
private function prefix(string $kind):string{return $kind==='assessment'?'fiscal_assessments.':'fiscal_forms.';}
|
|
public function scope(int $actor,string $kind='assessment',string $action='view'):array
|
|
{
|
|
abort_unless(config('fiscal_qa.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()==='accounting',403);
|
|
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$this->prefix($kind).$r,$s['unit_id']);abort_unless($this->binding()['scope']===$s,403);return $s;
|
|
}
|
|
public function binding():array
|
|
{
|
|
$p=CompositionRuntime::physical();$path='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$p['composition'].'/fiscal-binding.json';$pin=config('fiscal_qa.binding_sha256');abort_unless(is_string($pin)&&is_file($path)&&!is_link($path)&&hash_equals($pin,hash_file('sha256',$path)),503,'Origem fiscal QA não conferida.');$b=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);abort_unless($b['composition']===$p['composition']&&$b['contract']===self::CONTRACT&&$b['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],403);return $b;
|
|
}
|
|
private function rows(array $s,array $b,string $kind){return DB::table('fx_versions')->where($s)->where(['process_key'=>$b['process_key'],'kind'=>$kind]);}
|
|
private function latest(array $s,array $b,string $kind):?array{$r=$this->rows($s,$b,$kind)->orderByDesc('version')->first();return $r?$this->row($r):null;}
|
|
private function row(object $r):array{$r=(array)$r;foreach(['data','dependencies']as$k)$r[$k]=json_decode($r[$k],true,64,JSON_THROW_ON_ERROR);return $r;}
|
|
public function source(array $s,array $b):array
|
|
{
|
|
$doc=DB::table('sd_outputs')->where($s)->where(['id'=>$b['source_output_id'],'invoice_id'=>$b['invoice_id'],'kind'=>'invoice_receipt'])->first();
|
|
$invoice=DB::table('si_invoices')->where($s)->where('id',$b['invoice_id'])->first();abort_unless($doc&&$invoice,409,'Origem QA emitida não encontrada.');
|
|
$draft=DB::table('a_billing_drafts')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'id'=>$invoice->draft_id]);ModuleRecords::constrain($draft,'a_billing_drafts','billing_drafts','commercial');abort_unless($draft->exists(),403,'Origem sem associação Comercial actual.');
|
|
$latest=DB::table('sd_versions')->where($s)->where('document_key',$doc->document_key)->orderByDesc('version')->first();$pending=DB::table('sd_cancellations')->where($s)->where('output_id',$doc->id)->exists();
|
|
$allocation=DB::table('sr_effects')->where(['id'=>$doc->effect_id,'receipt_id'=>$b['receipt_id'],'kind'=>'allocate'])->first();$receipt=DB::table('sr_records')->where($s)->where(['id'=>$b['receipt_id'],'invoice_id'=>$invoice->id,'kind'=>'receipt'])->first();$confirmation=$receipt?DB::table('sr_effects')->where(['record_id'=>$receipt->id,'kind'=>'receipt_confirm'])->first():null;
|
|
abort_unless($latest&&$latest->state==='issued'&&$latest->id===$doc->version_id&&!$pending&&$allocation&&$receipt&&$confirmation&&$receipt->actor_id!==$confirmation->actor_id&&!DB::table('sr_effects')->where('target_effect_id',$allocation->id)->exists()&&!DB::table('sr_effects')->where(['receipt_id'=>$receipt->id,'kind'=>'reverse_receipt'])->exists(),409,'Documento/recebimento alterado, revertido ou em correcção. Exige nova conferência.');
|
|
foreach([$doc,$invoice]as$file){$bytes=base64_decode($file->pdf_base64,true);abort_unless(is_string($bytes)&&hash_equals($file->pdf_sha256,hash('sha256',$bytes)),409,'Documento congelado incompatível.');}
|
|
$snap=json_decode($invoice->snapshot,true,64,JSON_THROW_ON_ERROR);$cal=$snap['calculation']??[];
|
|
abort_unless(($snap['simulation_only']??false)===true&&($cal['policy']??null)===ServiceCalculation::POLICY&&($cal['currency']??null)==='MZN'&&($snap['assessment_period']??null)===$b['period']&&ServiceReceivables::money($cal['base_exact']??'0')==='862.07'&&($cal['vat_document_proposed']??null)==='137.93'&&($cal['total_document_proposed']??null)==='1000.00'&&ServiceReceivables::money($allocation->amount)==='1000.00',409,'Montantes, política ou período não correspondem ao caso QA autorizado.');
|
|
$summary=['output_id'=>$doc->id,'kind'=>'invoice_receipt','document_reference'=>'FR-QA-'.$doc->number,'technical_invoice_id'=>$invoice->id,'document_sha256'=>$doc->pdf_sha256,'currency'=>'MZN','base'=>'862.07','vat'=>'137.93','total'=>'1000.00','vat_exact'=>$cal['vat_exact'],'representation_difference'=>$cal['discarded_difference'],'policy'=>$cal['policy'],'profile_identifier'=>$snap['profile_identifier'],'profile_version'=>$snap['profile_version'],'exigibility_date'=>$snap['exigibility_date'],'period'=>$snap['assessment_period'],'synthetic_only'=>true];
|
|
$state=['invoice_snapshot'=>$snap,'document_snapshot'=>json_decode($doc->snapshot,true),'latest'=>(array)$latest,'allocation'=>(array)$allocation,'receipt'=>(array)$receipt,'confirmation'=>(array)$confirmation];return ['summary'=>$summary,'hash'=>$this->hash($state)];
|
|
}
|
|
private function basis(array $s,array $b,string $kind):array
|
|
{
|
|
$source=$this->source($s,$b);$period=DocumentPeriod::open($s);$assessment=$this->latest($s,$b,'assessment');$old=$kind==='assessment'?$assessment:$this->latest($s,$b,'form');
|
|
return ['source'=>$source,'period'=>$period,'binding'=>$this->hash($b),'old'=>$old,'assessment'=>$assessment];
|
|
}
|
|
private function dependencies(array $basis,string $kind):array{return ['source'=>$basis['source']['hash'],'period'=>$this->hash($basis['period']),'binding'=>$basis['binding'],'assessment_id'=>$kind==='form'?($basis['assessment']['id']??null):null];}
|
|
private function lock(int $actor,array $s):void
|
|
{
|
|
DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->firstOrFail();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('id')->lockForUpdate()->get();
|
|
}
|
|
private function exact(array $data,array $rules):array{abort_if(array_diff(array_keys($data),array_keys($rules)),422,'Campo fora do âmbito.');return Validator::make($data,$rules)->validate();}
|
|
public function preview(int $actor,array $input,string $key):array
|
|
{
|
|
abort_if(strlen(json_encode($input))>16384,422);$d=$this->exact($input,['kind'=>'required|in:assessment,form','action'=>'required|in:prepare,review','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:4000']);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$d['kind'],$d['action']);$b=$this->binding();
|
|
return DB::transaction(function()use($actor,$s,$d,$b,$key){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);$basis=$this->basis($s,$b,$d['kind']);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$b,$d,$basis);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}$cut=['input'=>$d,'key'=>$key,'actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'observed'=>time(),'basis'=>$this->hash($basis),'impact'=>$out['impact']];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
|
|
}
|
|
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=$c['input'];$s=$this->scope($actor,$d['kind'],$d['action']);$b=$this->binding();
|
|
return DB::transaction(function()use($actor,$s,$d,$c,$b){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);abort_unless($c['actor']===$actor&&$c['scope']==$s&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto ou prévia expirados.');$old=DB::table('fx_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409);return json_decode($old->result,true)+['replayed'=>true,'historical_receipt_only'=>true];}$basis=$this->basis($s,$b,$d['kind']);abort_unless($this->hash($basis)===$c['basis'],409,'Origem, versão ou dependências mudaram. Confira novamente.');$out=$this->execute($actor,$s,$b,$d,$basis);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');DB::table('fx_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('fiscal_qa.'.$d['kind'].'.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'before_revision'=>$d['version'],'reason'=>$d['reason'],'review_hash'=>$basis['source']['hash']]);return $out+['replayed'=>false];});
|
|
}
|
|
private function validAssessment(array $basis):bool{$a=$basis['assessment'];return $a&&$a['state']==='reviewed'&&$this->hash($a['dependencies'])===$this->hash($this->dependencies($basis,'assessment'));}
|
|
private function execute(int $actor,array $s,array $b,array $d,array $basis):array
|
|
{
|
|
$kind=$d['kind'];$old=$basis['old'];abort_unless(($old['version']??0)===$d['version'],409,'Versão alterada.');$dependencies=$this->dependencies($basis,$kind);$prepared=$actor;
|
|
if($kind==='form')abort_unless($this->validAssessment($basis),409,'O livro exige revisão actual antes de preparar/rever o mapa.');
|
|
if($d['action']==='prepare'){
|
|
$data=$this->exact($d['data'],['source_output_id'=>'required|integer|min:1','period'=>'required|date_format:Y-m','classification'=>'required|in:qa_output_only','evidence'=>'required|string|min:10|max:2000']);abort_unless($data['source_output_id']===$b['source_output_id']&&$data['period']===$b['period'],403,'Escolha individual da origem e período QA autorizados.');$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_fiscal.view');
|
|
$data+=['source'=>$basis['source']['summary'],'deductible_vat'=>null,'tax_payable'=>null,'complete_company_assessment'=>false,'pending'=>['Aquisições/deduções e outras origens não classificadas; não são zero.','Mapa interno QA — não é declaração oficial nem apuramento integral da empresa.']];
|
|
if($kind==='form'){$data['assessment_id']=$basis['assessment']['id'];$data['artifact_csv']=$this->csv($data);$data['artifact_sha256']=hash('sha256',$data['artifact_csv']);}$state='prepared';
|
|
}else{
|
|
$review=$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']);abort_unless($old&&$old['state']==='prepared'&&$old['prepared_by']!==$actor,403,'Preparação actual e revisor distinto obrigatórios.');$prepared=$old['prepared_by'];DB::table('users')->where('id',$prepared)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$prepared])->lockForUpdate()->first(),403);$grant=DB::table('permission_grants')->where($s+['user_id'=>$prepared,'permission'=>$this->prefix($kind).'prepare'])->lockForUpdate()->first();abort_unless($grant,403,'Autor da preparação já não autorizado.');abort_unless($this->hash($old['dependencies'])===$this->hash($dependencies),409,'A origem/revisão mudou desde a preparação.');$data=$old['data'];$state=$review['decision'];
|
|
}
|
|
$version=($old['version']??0)+1;$id=DB::table('fx_versions')->insertGetId($s+['process_key'=>$b['process_key'],'kind'=>$kind,'version'=>$version,'previous_id'=>$old['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'data'=>$this->json($data),'dependencies'=>$this->json($dependencies),'reason'=>$d['reason'],'created_at'=>now()]);
|
|
return ['id'=>$id,'kind'=>$kind,'version'=>$version,'state'=>$state,'impact'=>['before'=>['version'=>$old['version']??0,'state'=>$old['state']??null],'after'=>['version'=>$version,'state'=>$state],'source'=>$basis['source']['summary'],'deductible_vat'=>null,'tax_payable'=>null,'company_assessment_complete'=>false,'new_journals'=>0,'official_submission'=>false,'artifact_sha256'=>$data['artifact_sha256']??null]];
|
|
}
|
|
private function csv(array $data):string
|
|
{
|
|
$s=$data['source'];$rows=[['MAPA INTERNO IVA QA — SEM VALIDADE FISCAL'],['Ambito','Apenas a origem seleccionada; apuramento da empresa incompleto'],['Periodo','Documento','Base MZN','IVA documental MZN','Total MZN','Regra','Versao perfil','SHA documento'],[$s['period'],$s['document_reference'],$s['base'],$s['vat'],$s['total'],$s['policy'],(string)$s['profile_version'],$s['document_sha256']],['IVA dedutivel','INDETERMINADO'],['Imposto a pagar','INDETERMINADO'],['Origem do livro revisto',(string)$data['assessment_id']]];$fp=fopen('php://temp','w+');foreach($rows as$row)fputcsv($fp,$row,';', '"','',"\r\n");rewind($fp);return stream_get_contents($fp);
|
|
}
|
|
public function listing(int $actor,array $input):array
|
|
{
|
|
$s=$this->scope($actor);$d=$this->exact($input,['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','kind'=>'sometimes|in:assessment,form']);if(($d['kind']??null)==='form')$this->scope($actor,'form');$b=$this->binding();
|
|
return DB::transaction(function()use($actor,$s,$d,$b){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$blocked=null;$basis=null;try{$basis=$this->basis($s,$b,'assessment');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==409)throw $e;$blocked=$e->getMessage();}$a=$this->latest($s,$b,'assessment');$canForm=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.view',$s['unit_id']);$form=$canForm?$this->latest($s,$b,'form'):null;$q=DB::table('fx_versions')->where($s)->where('process_key',$b['process_key']);if(!$canForm)$q->where('kind','assessment');if(isset($d['kind']))$q->where('kind',$d['kind']);$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$rows=$page->getCollection()->map(fn($r)=>$this->row($r))->all();$refs=DB::table('fx_commands')->where($s)->where('actor_id',$actor)->whereIn(DB::raw("(result->>'id')::bigint"),array_column($rows,'id'))->get();foreach($rows as&$row){$row['command_reference']=null;foreach($refs as$ref)if(json_decode($ref->result,true)['id']===$row['id'])$row['command_reference']=$ref->command_key;unset($row['data']['artifact_csv']);}$page->setCollection(collect($rows));
|
|
$aCurrent=$basis&&$this->validAssessment($basis);$formCurrent=$basis&&$aCurrent&&$form&&$form['state']==='reviewed'&&$this->hash($form['dependencies'])===$this->hash($this->dependencies($basis,'form'));if($form)unset($form['data']['artifact_csv']);return ['synthetic_only'=>true,'contract'=>self::CONTRACT,'blocked'=>$blocked,'source'=>$basis['source']['summary']??null,'period'=>$b['period'],'assessment'=>$a,'form'=>$form,'assessment_current_review'=>$aCurrent,'form_current_review'=>$formCurrent,'events'=>$page->toArray(),'capabilities'=>['assessment_prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_assessments.prepare',$s['unit_id']),'assessment_review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_assessments.review',$s['unit_id']),'form_prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.prepare',$s['unit_id']),'form_review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.review',$s['unit_id'])],'official_submission'=>false,'complete_company_assessment'=>false];});
|
|
}
|
|
public function receipt(int $actor,string $key):array
|
|
{
|
|
$s=$this->scope($actor);abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);$r=DB::table('fx_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($r,404);$result=json_decode($r->result,true);$this->scope($actor,$result['kind']);return ['result'=>$result,'command_key'=>$key,'created_at'=>$r->created_at,'historical_receipt_only'=>true,'operation_executed'=>false];
|
|
}
|
|
public function export(int $actor,int $id):array
|
|
{
|
|
$s=$this->scope($actor,'form');$b=$this->binding();return DB::transaction(function()use($actor,$s,$b,$id){$this->lock($actor,$s);$basis=$this->basis($s,$b,'form');$f=$basis['old'];abort_unless($f&&$f['id']===$id&&$f['state']==='reviewed'&&$this->validAssessment($basis)&&$this->hash($f['dependencies'])===$this->hash($this->dependencies($basis,'form')),409,'Mapa ainda não revisto ou desactualizado.');$csv=$f['data']['artifact_csv'];abort_unless(hash_equals($f['data']['artifact_sha256'],hash('sha256',$csv)),409);Audit::record('fiscal_qa.form.downloaded',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$f['version'],'review_hash'=>$f['data']['artifact_sha256']]);return ['bytes'=>$csv,'sha256'=>$f['data']['artifact_sha256'],'version'=>$f['version']];});
|
|
}
|
|
}
|