commit melhorias sgf
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
use App\Modules\Identity\Services\Permissions;use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use Illuminate\Support\Facades\{DB,Validator};
|
||||
|
||||
/** Read-only register. SQL filters/pages precede bounded financial enrichment. */
|
||||
final class BillingRegister
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
abort_unless(config('service_documents.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']);
|
||||
$rules=['kind'=>'sometimes|in:invoice,invoice_receipt,receipt,credit_note,quote,draft','search'=>'sometimes|nullable|string|max:100','from'=>'sometimes|date_format:Y-m-d','to'=>'sometimes|date_format:Y-m-d','currency'=>'sometimes|regex:/^[A-Z]{3}$/','series'=>'sometimes|string|max:100','author'=>'sometimes|integer|min:1','state'=>'sometimes|in:issued,cancelled,draft,confirmed_internal,superseded,approved,rejected,refused,customer_accepted,customer_declined,converted','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();abort_if(isset($d['from'],$d['to'])&&$d['from']>$d['to'],422,'Intervalo de emissão inválido.');
|
||||
$has=fn($right)=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],$right,$s['unit_id']);$rights=['invoice'=>$has('service_invoices.view'),'additional'=>$has('service_documents.view'),'quote'=>$has('commercial_quotes.view'),'draft'=>$has('billing_drafts.view'),'finance'=>$has('service_receivables.view')];abort_unless($rights['invoice']||$rights['additional']||$rights['quote']||$rights['draft'],403);
|
||||
return DB::transaction(function()use($actor,$s,$d,$rights){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$families=[];
|
||||
$invoiceQuery=fn()=>ModuleRecords::query('si_invoices')->where($s);
|
||||
if($rights['invoice']){$q=$invoiceQuery();if($rights['additional'])$q->whereNotExists(fn($v)=>$v->selectRaw('1')->from('sd_outputs as fr')->whereColumn('fr.invoice_id','si_invoices.id')->where(['fr.tenant_id'=>$s['tenant_id'],'fr.company_id'=>$s['company_id'],'fr.unit_id'=>$s['unit_id'],'fr.kind'=>'invoice_receipt'])->whereIn('fr.invoice_id',app(ServiceDocuments::class)->sourceInvoices($actor)->select('id')));
|
||||
$families[]=$q->selectRaw("'invoice'::text kind,id source_id,series_code||'/'||number::text reference,snapshot->'document'->'client'->>'name' client,snapshot->'document'->'client'->'data'->>'nuit' nuit,(issued_at AT TIME ZONE 'Africa/Maputo')::date document_on,snapshot->'calculation'->>'total_document_proposed' amount,NULL::text base,'MZN'::text currency,'issued'::text document_state,actor_id,id invoice_id,NULL::text source_key,1::integer version,issued_at sort_at");}
|
||||
if($rights['additional']){
|
||||
$q=DB::table('sd_outputs as o')->join('si_invoices as i','i.id','=','o.invoice_id')->where(['o.tenant_id'=>$s['tenant_id'],'o.company_id'=>$s['company_id'],'o.unit_id'=>$s['unit_id']])->whereIn('i.id',app(ServiceDocuments::class)->sourceInvoices($actor)->select('id'));
|
||||
$families[]=$q->selectRaw("o.kind,o.id source_id,'TEST-SD-'||upper(o.kind)||'/'||o.number::text reference,i.snapshot->'document'->'client'->>'name' client,i.snapshot->'document'->'client'->'data'->>'nuit' nuit,(o.created_at AT TIME ZONE 'Africa/Maputo')::date document_on,o.snapshot->>'amount' amount,NULL::text base,'MZN'::text currency,CASE WHEN EXISTS(SELECT 1 FROM sd_cancellations c WHERE c.output_id=o.id AND c.state='cancelled') THEN 'cancelled' ELSE 'issued' END document_state,o.actor_id,o.invoice_id,o.document_key::text source_key,3::integer version,o.created_at sort_at");
|
||||
}
|
||||
if($rights['draft']){
|
||||
$draftState="CASE WHEN EXISTS(SELECT 1 FROM b_internal_confirmations b WHERE b.billing_draft_id=a_billing_drafts.id AND b.tenant_id=a_billing_drafts.tenant_id AND b.company_id=a_billing_drafts.company_id AND b.unit_id=a_billing_drafts.unit_id) THEN 'confirmed_internal' ELSE 'draft' END";
|
||||
if(DB::getSchemaBuilder()->hasTable('wc_applications'))$draftState="CASE WHEN EXISTS(SELECT 1 FROM wc_applications w WHERE w.old_billing_id=a_billing_drafts.id AND w.kind='replace') THEN 'superseded' WHEN EXISTS(SELECT 1 FROM wc_applications w WHERE w.old_billing_id=a_billing_drafts.id) THEN 'cancelled' ELSE ".$draftState.' END';
|
||||
$families[]=ModuleRecords::query('a_billing_drafts')->where($s)->selectRaw("'draft'::text kind,id source_id,'Interno #'||id::text reference,data->>'client_name' client,NULL::text nuit,NULL::date document_on,NULL::text amount,NULL::text base,data->>'currency' currency,".$draftState." document_state,(SELECT h.actor_id FROM a_history h WHERE h.area='billing_drafts' AND h.record_id=a_billing_drafts.id AND h.version=a_billing_drafts.version AND h.tenant_id=a_billing_drafts.tenant_id AND h.company_id=a_billing_drafts.company_id AND h.unit_id=a_billing_drafts.unit_id) actor_id,NULL::bigint invoice_id,NULL::text source_key,version,updated_at sort_at");
|
||||
}
|
||||
if($rights['quote']){
|
||||
$q=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']])->whereIn('q.client_id',ModuleRecords::query('a_clients')->select('id'))->whereNotExists(fn($v)=>$v->selectRaw('1')->from('cq_versions as n')->whereColumn('n.quote_key','q.quote_key')->whereColumn('n.tenant_id','q.tenant_id')->whereColumn('n.company_id','q.company_id')->whereColumn('n.unit_id','q.unit_id')->whereColumn('n.version','>','q.version'));
|
||||
$families[]=$q->selectRaw("'quote'::text kind,q.id source_id,'ORC-QA-'||upper(substr(q.quote_key::text,1,8)) reference,q.dependencies->'client'->>'name' client,NULL::text nuit,NULL::date document_on,NULL::text amount,q.data->>'base' base,q.data->>'currency' currency,q.state document_state,q.actor_id,NULL::bigint invoice_id,q.quote_key::text source_key,q.version,q.created_at sort_at");
|
||||
}
|
||||
$union=array_shift($families);foreach($families as $q)$union->unionAll($q);$q=DB::query()->fromSub($union,'document_register');if(isset($d['kind']))$q->where('kind',$d['kind']);if(isset($d['state']))$q->where('document_state',$d['state']);if(isset($d['from']))$q->where('document_on','>=',$d['from']);if(isset($d['to']))$q->where('document_on','<=',$d['to']);
|
||||
if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($v)=>$v->whereRaw("reference ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("client ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("nuit ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
if(isset($d['currency']))$q->where('currency',$d['currency']);if(isset($d['author']))$q->where('actor_id',$d['author']);if(isset($d['series']))$q->whereRaw("CASE WHEN kind IN('invoice','invoice_receipt','receipt','credit_note') THEN split_part(reference,'/',1) ELSE NULL END = ?",[$d['series']]);
|
||||
$p=$q->orderByDesc('sort_at')->orderBy('kind')->orderByDesc('source_id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$ids=$p->pluck('invoice_id')->filter()->unique()->values()->all();$positions=collect();$draftIds=collect($p->items())->where('kind','draft')->pluck('source_id')->all();$bases=[];
|
||||
if($draftIds!==[])foreach(ModuleRecords::query('a_billing_drafts')->where($s)->whereIn('id',$draftIds)->get(['id','data']) as $draft)$bases[$draft->id]=DraftValues::base(json_decode($draft->data,true,32,JSON_THROW_ON_ERROR));
|
||||
if($rights['finance']&&$ids!==[]){$positions=DB::table('sr_records as register_ob')->leftJoin('sr_effects as e','e.obligation_id','=','register_ob.id')->where(['register_ob.tenant_id'=>$s['tenant_id'],'register_ob.company_id'=>$s['company_id'],'register_ob.unit_id'=>$s['unit_id'],'register_ob.kind'=>'obligation'])->whereIn('register_ob.invoice_id',$ids)->groupBy('register_ob.id','register_ob.invoice_id','register_ob.amount')->selectRaw("register_ob.invoice_id,CASE WHEN count(*) FILTER(WHERE e.kind='open')>0 THEN (register_ob.amount-coalesce(sum(CASE WHEN e.kind IN('allocate','reduction') THEN e.amount WHEN e.kind IN('reverse_allocation','reverse_reduction') THEN -e.amount ELSE 0 END),0))::text ELSE NULL END balance")->get()->keyBy('invoice_id');}
|
||||
foreach($p->items() as $row){if($row->kind==='draft')$row->base=$bases[$row->source_id]??null;$row->series=in_array($row->kind,['invoice','invoice_receipt','receipt','credit_note'],true)?explode('/',$row->reference,2)[0]:null;$row->origin_invoice_balance=$rights['finance']?$positions->get($row->invoice_id)?->balance:null;$row->balance_available=$rights['finance']&&$row->origin_invoice_balance!==null;$row->pdf_url=match($row->kind){'invoice'=>'/api/service-invoices/'.$row->source_id.'/pdf','invoice_receipt','receipt','credit_note'=>'/api/service-documents/'.$row->source_id.'/pdf',default=>null};$row->print_url=$row->kind==='quote'?'/commercial-quotes/'.$row->source_key.'/print?version='.$row->version:null;}
|
||||
return ['documents'=>$p,'financial_access'=>$rights['finance'],'notice'=>ServiceInvoicing::NOTICE,'has_mixed_document_types'=>true,'totals_not_summed'=>true];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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;}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
use App\Shared\Installation\CompositionRuntime;use App\Modules\Accounting\SourceBook;use Illuminate\Support\Facades\DB;
|
||||
final class DocumentPeriod
|
||||
{
|
||||
public static function open(array $scope):array
|
||||
{
|
||||
$physical=CompositionRuntime::physical();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$physical['composition'].'/binding.json';
|
||||
abort_unless(realpath((string)config('receipt_qa.profile'))===realpath($file),403);$p=json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);$c=$p['connection'];abort_unless($c['database']==='compras_receipts_test_'.$physical['composition']&&$c['username']==='payable_history_native_'.$physical['composition']&&$c['host']==='127.0.0.1'&&(int)$c['port']===55439,403);
|
||||
config(['database.connections.receipt_native'=>$c]);$period=SourceBook::table('gl_periods')->where($scope)->where('period_key','QA-2026-INTEGRADO')->orderByDesc('version')->first();abort_unless($period&&$period->status==='open',409,'A anulação exige período QA aberto; não há reabertura.');return (array)$period;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?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']];});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
|
||||
/** Product capability metadata only: not tax rates, legal rules or activation authority. */
|
||||
final class FiscalCoverage
|
||||
{
|
||||
public static function describe(): array
|
||||
{
|
||||
return [
|
||||
'version'=>'SGF-FISCAL-COVERAGE-v2',
|
||||
'automatic_knowledge_import'=>false,
|
||||
'legal_validation'=>false,
|
||||
'notice'=>'Conhecimento em documentos ou conversas não passa automaticamente para a aplicação. Cada regra exige fonte, versão, âmbito, provas e revisão antes de utilização.',
|
||||
'taxes'=>[
|
||||
['id'=>'iva','label'=>'IVA','state'=>'partial','available'=>'Cálculo delimitado de serviço, MZN, uma linha e imposto acrescido; perfis versionados, revisão distinta e decisão temporal. Documentos QA e diário integrado; livro/mapa interno da origem seleccionada revistos distintamente. Núcleo comum com versões/vigência e cálculo interno QA de1–25linhas, preço acrescido/incluído, exactos e diferenças preservados.','pending'=>'Emissão documental por múltiplas linhas/tratamentos, livros gerais de apuramento/dedução, regularizações e modelos oficiais.','legal_use'=>false],
|
||||
['id'=>'irps','label'=>'IRPS','state'=>'pending','available'=>null,'pending'=>'Motor operacional de rendimentos/retenções, remunerações, apuramento e modelos ligados às operações do SGF.','legal_use'=>false],
|
||||
['id'=>'irpc','label'=>'IRPC','state'=>'pending','available'=>null,'pending'=>'Ponte do resultado contabilístico para ajustamentos/base/colecta, retenções e modelos no SGF.','legal_use'=>false],
|
||||
['id'=>'ispc','label'=>'ISPC','state'=>'pending','available'=>null,'pending'=>'Enquadramento, cálculo, obrigações e modelos operacionais no SGF.','legal_use'=>false],
|
||||
['id'=>'ice','label'=>'ICE','state'=>'pending','available'=>null,'pending'=>'Classificação dos bens, parâmetros com vigência, liquidação e obrigações operacionais no SGF.','legal_use'=>false],
|
||||
['id'=>'inss','label'=>'INSS','state'=>'pending','available'=>null,'pending'=>'Cadastro/processamento de remunerações, contribuições e modelos no SGF.','legal_use'=>false],
|
||||
['id'=>'other','label'=>'Outros impostos e obrigações','state'=>'pending','available'=>null,'pending'=>'Identificação dos tributos aplicáveis, calendário legal, multas/juros e modelos; sem taxas ou prazos presumidos.','legal_use'=>false],
|
||||
],
|
||||
'management'=>[
|
||||
['id'=>'finance','label'=>'Gestão financeira','state'=>'partial','available'=>'Percursos QA de orçamento de Compras, obrigações/recebíveis, afectações/reversões e Caixa com fecho; conferência individual de extracto artificial contra duas linhas existentes.','pending'=>'Operação geral real, bancos e reconciliação bancária geral.'],
|
||||
['id'=>'accounting','label'=>'Contabilidade','state'=>'partial','available'=>'Diário, plano, razão, balancete e conferência/fecho QA com origens distintas.','pending'=>'Demonstrações financeiras legais, encerramento anual real e integração dos apuramentos fiscais.'],
|
||||
['id'=>'analysis','label'=>'Análise financeira','state'=>'partial','available'=>'Custos directos/industriais, orçamento versus realizado, diário/saldos e dois critérios/indicadores QA revistos com fontes explícitas e relatórios.','pending'=>'Rácios de liquidez/rentabilidade/endividamento e tendências conciliadas, com plano de contas e critérios aprovados.'],
|
||||
],
|
||||
'rule_lifecycle'=>['Fonte identificada e versão preservada','Aplicabilidade e vigência explícitas','Cálculo exacto com pressupostos/pendências','Provas e revisão por pessoa distinta','Activação específica e histórico sem reescrever operações anteriores'],
|
||||
'official_submission_enabled'=>false,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
use Brick\Math\{BigRational,RoundingMode};
|
||||
/** Approved QA representation policy. No classification, issuance or financial effects. */
|
||||
final class FiscalDocumentMath
|
||||
{
|
||||
public const POLICY='SGF-QA-LINE-TRUNCATE2-INCLUDED-RESIDUAL-v1';
|
||||
public static function calculate(array $lines,string $mode,string $rate):array
|
||||
{
|
||||
if(!in_array($mode,['net_added','gross_included'],true)||BigRational::of($rate)->isLessThanOrEqualTo(0)||BigRational::of($rate)->isGreaterThanOrEqualTo(1))throw new \InvalidArgumentException('Modalidade/taxa artificial inválida.');$baseSum=BigRational::of(0);$taxSum=BigRational::of(0);$totalSum=BigRational::of(0);$exactBaseSum=BigRational::of(0);$exactTaxSum=BigRational::of(0);$rows=[];$pending=[];$r=BigRational::of($rate);
|
||||
foreach($lines as$i=>$line){$amount=BigRational::of($line['quantity'])->multipliedBy($line['unit_price']);$base=$mode==='net_added'?$amount:$amount->dividedBy(BigRational::of(1)->plus($r));$tax=$mode==='net_added'?$base->multipliedBy($r):$amount->minus($base);$taxDoc=$tax->toScale(2,RoundingMode::Down);$taxDocR=BigRational::of((string)$taxDoc);$baseDoc=null;$total=null;
|
||||
try{if($mode==='net_added'){$baseDoc=$base->toScale(2,RoundingMode::Unnecessary);$total=$base->plus($taxDocR)->toScale(2,RoundingMode::Unnecessary);}else{$gross=$amount->toScale(2,RoundingMode::Unnecessary);$baseDoc=BigRational::of((string)$gross)->minus($taxDocR)->toScale(2);$total=$gross;}}catch(\Brick\Math\Exception\RoundingNecessaryException){$pending[]='Linha '.($i+1).': '.($mode==='net_added'?'base':'bruto').' não representável em duas casas; não truncado.';}
|
||||
$rows[]=['line'=>($i+1),'reference'=>$line['reference'],'quantity'=>$line['quantity'],'unit_price'=>$line['unit_price'],'amount_exact_fraction'=>(string)$amount->simplified(),'base_exact_fraction'=>(string)$base->simplified(),'tax_exact_fraction'=>(string)$tax->simplified(),'base_document'=>$baseDoc===null?null:(string)$baseDoc,'tax_document'=>(string)$taxDoc,'total_document'=>$total===null?null:(string)$total,'base_difference_fraction'=>$baseDoc===null?null:(string)BigRational::of((string)$baseDoc)->minus($base)->simplified(),'tax_difference_fraction'=>(string)$tax->minus($taxDocR)->simplified()];
|
||||
$exactBaseSum=$exactBaseSum->plus($base);$exactTaxSum=$exactTaxSum->plus($tax);if($baseDoc!==null){$baseSum=$baseSum->plus(BigRational::of((string)$baseDoc));$taxSum=$taxSum->plus($taxDocR);$totalSum=$totalSum->plus(BigRational::of((string)$total));}
|
||||
}
|
||||
return ['state'=>$pending?'pending_representation':'determined_qa','policy'=>self::POLICY,'price_mode'=>$mode,'rate'=>$rate,'lines'=>$rows,'base_exact_fraction'=>(string)$exactBaseSum->simplified(),'tax_exact_fraction'=>(string)$exactTaxSum->simplified(),'base_document'=>$pending?null:(string)$baseSum->toScale(2),'tax_document'=>$pending?null:(string)$taxSum->toScale(2),'total'=>$pending?null:(string)$totalSum->toScale(2),'base_difference_fraction'=>$pending?null:(string)$baseSum->minus($exactBaseSum)->simplified(),'tax_difference_fraction'=>$pending?null:(string)$exactTaxSum->minus($taxSum)->simplified(),'aggregation'=>'sum_document_lines','pending'=>$pending,'synthetic_only'=>true,'legal_use'=>false,'financial_effects'=>0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?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;
|
||||
|
||||
/** Administrative QA deadlines/evidence only. No legal-calendar or financial writer. */
|
||||
final class FiscalObligations
|
||||
{
|
||||
public const POLICY='SGF-FISCAL-OBLIGATIONS-QA-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions,private FiscalAssessment $forms){}
|
||||
private function json(array $a):string{return Payables::canonical($a);}
|
||||
private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
||||
private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();}
|
||||
public function scope(int $actor,string $action='view'):array
|
||||
{
|
||||
abort_unless(config('fiscal_obligations.enabled')===true,404);CompositionRuntime::assert();$p=CompositionRuntime::physical();$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$right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.'.$right,$s['unit_id']);
|
||||
if($action!=='view')abort_unless($actor===($action==='prepare'?13:14),403);$meta=DB::table('fo_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&$meta->composition===$p['composition']&&hash_equals($meta->source_hash,(string)config('fiscal_obligations.schema_sha256')),503,'Schema de obrigações não conferido.');return$s;
|
||||
}
|
||||
private function identity(int $actor,array $s):string
|
||||
{
|
||||
$u=DB::table('users')->where('id',$actor)->first(['id','email','name']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);
|
||||
}
|
||||
private function responsible(int $actor,array $s):void
|
||||
{
|
||||
abort_unless(in_array($actor,[13,14,15,16,17],true),403,'Responsável fora dos perfis QA aprovados.');$this->identity($actor,$s);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.view',$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.view',$s['unit_id']);
|
||||
}
|
||||
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()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
private function source(int $actor,array $s):array
|
||||
{
|
||||
$state=$this->forms->listing($actor,['kind'=>'form','per_page'=>25]);abort_unless($state['form_current_review']===true&&$state['form'],409,'Mapa interno revisto deixou de ser actual.');$form=$state['form'];$row=DB::table('fx_versions')->where($s+['id'=>$form['id'],'kind'=>'form','state'=>'reviewed'])->first();abort_unless($row,409);$data=json_decode($row->data,true,64,JSON_THROW_ON_ERROR);$bytes=$data['artifact_csv']??null;$sha=$data['artifact_sha256']??null;abort_unless(is_string($bytes)&&is_string($sha)&&hash_equals($sha,hash('sha256',$bytes)),409,'Exportação interna alterada.');
|
||||
return ['form_id'=>$row->id,'form_version'=>$row->version,'period'=>$state['period'],'export_sha256'=>$sha,'document_reference'=>$state['source']['document_reference'],'document_sha256'=>$state['source']['document_sha256'],'dependencies'=>json_decode($row->dependencies,true),'source_basis'=>$this->hash(['row'=>(array)$row,'source'=>$state['source'],'binding'=>$this->forms->binding()]),'synthetic_only'=>true,'tax_payable'=>null,'official_submission'=>false];
|
||||
}
|
||||
private function rows(array $s){return DB::table('fo_versions')->where($s);}
|
||||
private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$key)$a[$key]=json_decode($a[$key],true,64,JSON_THROW_ON_ERROR);return$a;}
|
||||
private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
|
||||
private function basis(int $actor,array $s,string $key):array{return ['source'=>$this->source($actor,$s),'last'=>$this->latest($s,$key),'effective'=>$this->latest($s,$key,true),'actor_identity'=>$this->identity($actor,$s)];}
|
||||
private function input(array $v):array
|
||||
{
|
||||
if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['record_key']=strtolower($d['record_key']);$d['version']=(int)$d['version'];
|
||||
if($d['action']==='prepare'){$d['data']=$this->exact($d['data'],['operation'=>'required|in:establish,amend,cancel,delivery','form_id'=>'required|integer|min:1','period'=>'required|date_format:Y-m','responsible_id'=>'required|integer|min:1','due_on'=>'present|nullable|date_format:Y-m-d|after_or_equal:2000-01-01|before_or_equal:2100-12-31','evidence'=>'required|string|min:10|max:2000','export_sha256'=>'present|nullable|regex:/^[a-f0-9]{64}$/D']);foreach(['form_id','responsible_id']as$field)$d['data'][$field]=(int)$d['data'][$field];}else$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;
|
||||
}
|
||||
private function duplicate(array $s,string $key,int $form):bool
|
||||
{
|
||||
// Effective approved record, or a first unresolved preparation; no universe in PHP.
|
||||
$q=$this->rows($s)->where('record_key','<>',$key)->whereRaw("(data->>'form_id')::bigint=?",[$form]);
|
||||
return$q->where(function($q){$q->where(function($q){$q->where('state','approved')->whereRaw("data->>'administrative_status'<>'cancelled'")->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as newer')->whereColumn('newer.record_key','fo_versions.record_key')->whereColumn('newer.tenant_id','fo_versions.tenant_id')->whereColumn('newer.company_id','fo_versions.company_id')->whereColumn('newer.unit_id','fo_versions.unit_id')->where('newer.state','approved')->whereColumn('newer.version','>','fo_versions.version');});})->orWhere(function($q){$q->where('state','prepared')->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as anyapproved')->whereColumn('anyapproved.record_key','fo_versions.record_key')->whereColumn('anyapproved.tenant_id','fo_versions.tenant_id')->whereColumn('anyapproved.company_id','fo_versions.company_id')->whereColumn('anyapproved.unit_id','fo_versions.unit_id')->where('anyapproved.state','approved');})->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as next')->whereColumn('next.record_key','fo_versions.record_key')->whereColumn('next.tenant_id','fo_versions.tenant_id')->whereColumn('next.company_id','fo_versions.company_id')->whereColumn('next.unit_id','fo_versions.unit_id')->whereColumn('next.version','>','fo_versions.version');});});})->exists();
|
||||
}
|
||||
private function execute(int $actor,array $s,array $d,array $b):array
|
||||
{
|
||||
$last=$b['last'];$effective=$b['effective'];abort_unless(($last['version']??0)===$d['version'],409,'Versão alterada; confira novamente.');$source=$b['source'];
|
||||
if($d['action']==='prepare'){$data=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}
|
||||
else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador alterada.');$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'fiscal_obligations.prepare',$s['unit_id']);abort_unless(hash_equals($last['source_basis'],$source['source_basis']),409,'Origem mudou desde a preparação.');$data=$last['data'];}
|
||||
abort_unless($data['form_id']===$source['form_id']&&$data['period']===$source['period'],409,'Escolha o mapa/período revistos actuais.');$this->responsible($data['responsible_id'],$s);$op=$data['operation'];$old=$effective['data']??null;
|
||||
if($op==='establish'){abort_unless(!$effective,409,'Já existe condição aprovada neste registo.');abort_if($this->duplicate($s,$d['record_key'],$data['form_id']),409,'Já existe obrigação ou preparação deste mapa. Não duplicar.');}
|
||||
else{abort_unless($old&&$old['administrative_status']!=='cancelled'&&$old['form_id']===$data['form_id']&&$old['period']===$data['period'],409,'Operação exige obrigação activa da mesma origem.');if(in_array($op,['cancel','delivery'],true))foreach(['due_on','responsible_id']as$field)abort_unless($data[$field]===$old[$field],409,'Esta operação conserva as condições aprovadas.');if($op==='delivery')abort_unless($old['administrative_status']!=='delivery_simulated',409,'Entrega simulada já registada.');}
|
||||
if($op==='delivery')abort_unless(is_string($data['export_sha256'])&&hash_equals($source['export_sha256'],$data['export_sha256']),409,'SHA da exportação interna não corresponde.');else abort_unless($data['export_sha256']===null,422,'SHA de entrega só na declaração simulada.');
|
||||
$data['administrative_status']=$op==='cancel'?'cancelled':($op==='delivery'?'delivery_simulated':($old['administrative_status']??'active'));$data['delivery_evidence']=$op==='delivery'?['export_sha256'=>$data['export_sha256'],'reference'=>$data['evidence'],'simulated_only'=>true]:($old['delivery_evidence']??null);$data['source']=$source;
|
||||
$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=($last['version']??0)+1;$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'tax_payable'=>null,'new_debt'=>false,'payments'=>0,'journals'=>0,'official_submission'=>false];
|
||||
$id=DB::table('fo_versions')->insertGetId($s+['record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'preparer_identity'=>$identity,'data'=>$this->json($data),'source_basis'=>$source['source_basis'],'impact'=>$this->json($impact),'reason'=>$d['reason'],'created_at'=>now()->utc()]);return['id'=>$id,'record_key'=>$d['record_key'],'version'=>$version,'state'=>$state,'impact'=>$impact];
|
||||
}
|
||||
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,$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['action']);$b=$this->basis($actor,$s,$d['record_key']);$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,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];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.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['action']);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,'Contexto/prévia alterados.');$b=$this->basis($actor,$s,$d['record_key']);$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('fo_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem, pessoa ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');DB::table('fo_commands')->insert($q+['request_hash'=>$this->hash($d),'result'=>$this->json($out),'input'=>$this->json($d),'created_at'=>now()->utc()]);Audit::record('fiscal_obligations.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'record_key'=>$d['record_key'],'version'=>$out['version'],'reason'=>$d['reason'],'impact'=>$out['impact']]);return$out+['replayed'=>false];});
|
||||
}
|
||||
public function receipt(int $actor,string $key):array
|
||||
{
|
||||
$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor);$this->forms->scope($actor,'form');$old=DB::table('fo_commands')->where($s+['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$this->forms->scope($actor,'form');$d=$this->exact($input,['reference_on'=>'required|date_format:Y-m-d','period'=>'sometimes|date_format:Y-m','status'=>'sometimes|in:active,delivery_simulated,cancelled,unapproved','record_key'=>'sometimes|uuid','due_from'=>'required_with:due_to|date_format:Y-m-d','due_to'=>'required_with:due_from|date_format:Y-m-d|after_or_equal:due_from','due_state'=>'sometimes|in:unknown,overdue,not_overdue','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
|
||||
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$source=null;$blocked=null;try{$source=$this->source($actor,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){if($e->getStatusCode()!==409)throw$e;$blocked=$e->getMessage();}$q=$this->rows($s);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403,'Histórico fora do âmbito.');}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$key)$n->whereColumn('next.'.$key,'fo_versions.'.$key);$n->whereColumn('next.version','>','fo_versions.version');});
|
||||
$effective="(SELECT e.data->>'administrative_status' FROM fo_versions e WHERE e.tenant_id=fo_versions.tenant_id AND e.company_id=fo_versions.company_id AND e.unit_id=fo_versions.unit_id AND e.record_key=fo_versions.record_key AND e.state='approved' ORDER BY e.version DESC LIMIT 1)";$effectiveDue="(SELECT e.data->>'due_on' FROM fo_versions e WHERE e.tenant_id=fo_versions.tenant_id AND e.company_id=fo_versions.company_id AND e.unit_id=fo_versions.unit_id AND e.record_key=fo_versions.record_key AND e.state='approved' ORDER BY e.version DESC LIMIT 1)";if(isset($d['due_from']))$q->whereRaw("($effectiveDue)>=? AND ($effectiveDue)<=?",[$d['due_from'],$d['due_to']]);if(isset($d['due_state'])){if($d['due_state']==='unknown')$q->whereRaw("($effectiveDue) IS NULL");else$q->whereRaw("($effectiveDue)".($d['due_state']==='overdue'?'<':'>=').'?',[$d['reference_on']]);}if(isset($d['status']))$q->whereRaw("coalesce($effective,'unapproved')=?",[$d['status']]);if(isset($d['period']))$q->whereRaw("data->>'period'=?",[$d['period']]);$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->unique()->all();$approved=$this->rows($s)->whereIn('record_key',$keys)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as later');foreach(['tenant_id','company_id','unit_id','record_key']as$key)$n->whereColumn('later.'.$key,'fo_versions.'.$key);$n->where('later.state','approved')->whereColumn('later.version','>','fo_versions.version');})->get()->keyBy('record_key');
|
||||
$items=[];foreach($page->items()as$r){$row=$this->row($r);$a=$approved->get($r->record_key);$terms=$a?json_decode($a->data,true):null;$due=$terms['due_on']??null;$days=$due===null?null:max(0,(int)(new \DateTimeImmutable($due))->diff(new \DateTimeImmutable($d['reference_on']))->format('%r%a'));$row['effective']=$terms;$row['days_overdue']=$days;$row['due_status']=$due===null?'unknown':($due<$d['reference_on']?'overdue':'not_overdue');$row['current_source_matches']=$source!==null&&hash_equals($r->source_basis,$source['source_basis']);$items[]=$row;}$page->setCollection(collect($items));$can=fn($right,$expected)=>$actor===$expected&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.'.$right,$s['unit_id']);return['policy'=>self::POLICY,'synthetic_only'=>true,'reference_on'=>$d['reference_on'],'source'=>$source,'blocked'=>$blocked,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$can('prepare',13),'review'=>$can('review',14)],'official_submission'=>false,'tax_payable'=>null,'notice'=>'Prazo administrativo artificial; entrega apenas simulada, sem transmissão à AT ou pagamento.'];});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use Brick\Math\BigRational;
|
||||
use Brick\Math\RoundingMode;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** A deliberately small, data-only expression language for reviewed tax-rule drafts. */
|
||||
final class FiscalRuleExpression
|
||||
{
|
||||
private const LANGUAGE = 'investmoz.tax.v1';
|
||||
|
||||
private const ROUNDING = [
|
||||
'UNNECESSARY' => RoundingMode::Unnecessary,
|
||||
'UP' => RoundingMode::Up,
|
||||
'DOWN' => RoundingMode::Down,
|
||||
'CEILING' => RoundingMode::Ceiling,
|
||||
'FLOOR' => RoundingMode::Floor,
|
||||
'HALF_UP' => RoundingMode::HalfUp,
|
||||
'HALF_DOWN' => RoundingMode::HalfDown,
|
||||
'HALF_CEILING' => RoundingMode::HalfCeiling,
|
||||
'HALF_FLOOR' => RoundingMode::HalfFloor,
|
||||
'HALF_EVEN' => RoundingMode::HalfEven,
|
||||
];
|
||||
|
||||
/** @param array<string,mixed> $definition */
|
||||
public function validate(array $definition): void
|
||||
{
|
||||
$this->topologicalOrder($definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a validated expression. Missing values are reported as indeterminate;
|
||||
* callers must supply versioned parameters and provenance explicitly.
|
||||
*
|
||||
* @param array<string,mixed> $definition
|
||||
* @param array<string,string|int> $inputs
|
||||
* @param array<string,string|int> $parameters
|
||||
* @return array{status:string,result:?string,missing:list<string>,steps:list<array<string,mixed>>}
|
||||
*/
|
||||
public function evaluate(array $definition, array $inputs, array $parameters): array
|
||||
{
|
||||
$order = $this->topologicalOrder($definition);
|
||||
$nodes = $definition['nodes'];
|
||||
$values = [];
|
||||
$missing = [];
|
||||
$steps = [];
|
||||
|
||||
foreach ($order as $id) {
|
||||
$node = $nodes[$id];
|
||||
$op = $node['op'];
|
||||
if ($op === 'input' || $op === 'parameter') {
|
||||
$key = $node['key'];
|
||||
$source = $op === 'input' ? $inputs : $parameters;
|
||||
if (! array_key_exists($key, $source)) {
|
||||
$missing[] = $op.':'.$key;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'key' => $key, 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $this->number($source[$key]);
|
||||
} elseif ($op === 'literal') {
|
||||
$value = $this->number($node['value']);
|
||||
} else {
|
||||
$args = array_values(array_map(static fn (string $ref): ?BigRational => $values[$ref] ?? null, $node['args'] ?? []));
|
||||
if (in_array(null, $args, true)) {
|
||||
$values[$id] = null;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'args' => $node['args'] ?? [], 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $this->operate($op, $args, $node);
|
||||
}
|
||||
$values[$id] = $value;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'value' => (string) $value];
|
||||
}
|
||||
|
||||
$result = $values[$definition['result']] ?? null;
|
||||
|
||||
return [
|
||||
'status' => $missing === [] && $result !== null ? 'determinate' : 'indeterminate',
|
||||
'result' => $result === null ? null : (string) $result,
|
||||
'missing' => array_values(array_unique($missing)),
|
||||
'steps' => $steps,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $definition
|
||||
* @return list<string>
|
||||
*/
|
||||
private function topologicalOrder(array $definition): array
|
||||
{
|
||||
if (($definition['language'] ?? null) !== self::LANGUAGE || ! is_array($definition['nodes'] ?? null) || ! is_string($definition['result'] ?? null)) {
|
||||
throw new InvalidArgumentException('O formato da expressão não é suportado.');
|
||||
}
|
||||
$nodes = $definition['nodes'];
|
||||
if ($nodes === [] || count($nodes) > 100) {
|
||||
throw new InvalidArgumentException('A expressão deve conter entre 1 e 100 elementos.');
|
||||
}
|
||||
|
||||
foreach ($nodes as $id => $node) {
|
||||
if (! is_string($id) || ! preg_match('/^[a-z][a-z0-9_]{0,47}$/', $id) || ! is_array($node) || ! is_string($node['op'] ?? null)) {
|
||||
throw new InvalidArgumentException('Reveja o elemento indicado na expressão.');
|
||||
}
|
||||
$op = $node['op'];
|
||||
$allowedFields = match ($op) {
|
||||
'input', 'parameter' => ['op', 'key'],
|
||||
'literal' => ['op', 'value'],
|
||||
'add', 'subtract', 'multiply', 'divide', 'min', 'max' => ['op', 'args'],
|
||||
'round' => ['op', 'args', 'scale', 'mode'],
|
||||
default => throw new InvalidArgumentException('Esta operação não é suportada na expressão.'),
|
||||
};
|
||||
if (array_diff(array_keys($node), $allowedFields) !== [] || array_diff($allowedFields, array_keys($node)) !== []) {
|
||||
throw new InvalidArgumentException('Há campos em falta ou não suportados num elemento da expressão.');
|
||||
}
|
||||
if (in_array($op, ['input', 'parameter'], true) && (! is_string($node['key']) || ! preg_match('/^[a-z][a-z0-9_.-]{0,79}$/', $node['key']))) {
|
||||
throw new InvalidArgumentException('Reveja o identificador da entrada ou do parâmetro.');
|
||||
}
|
||||
if ($op === 'literal') {
|
||||
$this->number($node['value']);
|
||||
}
|
||||
if (isset($node['args'])) {
|
||||
if (! is_array($node['args'])) {
|
||||
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
|
||||
}
|
||||
$count = count($node['args']);
|
||||
$validCount = $op === 'round' ? $count === 1 : ($op === 'subtract' || $op === 'divide' ? $count === 2 : $count >= 2 && $count <= 20);
|
||||
if (! $validCount || count(array_filter($node['args'], 'is_string')) !== $count) {
|
||||
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
|
||||
}
|
||||
}
|
||||
if ($op === 'round' && (! is_int($node['scale']) || $node['scale'] < 0 || $node['scale'] > 12 || ! isset(self::ROUNDING[$node['mode']]))) {
|
||||
throw new InvalidArgumentException('Reveja a configuração de arredondamento.');
|
||||
}
|
||||
}
|
||||
|
||||
if (! array_key_exists($definition['result'], $nodes)) {
|
||||
throw new InvalidArgumentException('O resultado da expressão refere um elemento inexistente.');
|
||||
}
|
||||
$state = [];
|
||||
$order = [];
|
||||
$visit = function (string $id) use (&$visit, &$state, &$order, $nodes): void {
|
||||
if (($state[$id] ?? 0) === 1) {
|
||||
throw new InvalidArgumentException('A expressão contém uma dependência circular.');
|
||||
}
|
||||
if (($state[$id] ?? 0) === 2) {
|
||||
return;
|
||||
}
|
||||
$state[$id] = 1;
|
||||
foreach ($nodes[$id]['args'] ?? [] as $dependency) {
|
||||
if (! is_string($dependency) || ! array_key_exists($dependency, $nodes)) {
|
||||
throw new InvalidArgumentException('A expressão refere um elemento inexistente.');
|
||||
}
|
||||
$visit($dependency);
|
||||
}
|
||||
$state[$id] = 2;
|
||||
$order[] = $id;
|
||||
};
|
||||
foreach (array_keys($nodes) as $id) {
|
||||
$visit($id);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<BigRational> $args
|
||||
* @param array<string,mixed> $node
|
||||
*/
|
||||
private function operate(string $op, array $args, array $node): BigRational
|
||||
{
|
||||
if ($op === 'round') {
|
||||
return BigRational::of((string) $args[0]->toScale($node['scale'], self::ROUNDING[$node['mode']]));
|
||||
}
|
||||
if ($op === 'add') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->plus($value), $args[0]);
|
||||
}
|
||||
if ($op === 'subtract') {
|
||||
return $args[0]->minus($args[1]);
|
||||
}
|
||||
if ($op === 'multiply') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->multipliedBy($value), $args[0]);
|
||||
}
|
||||
if ($op === 'divide') {
|
||||
return $args[0]->dividedBy($args[1]);
|
||||
}
|
||||
if ($op === 'min' || $op === 'max') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => ($op === 'min' ? $carry->compareTo($value) <= 0 : $carry->compareTo($value) >= 0) ? $carry : $value, $args[0]);
|
||||
}
|
||||
throw new InvalidArgumentException('Esta operação não é suportada na expressão.');
|
||||
}
|
||||
|
||||
private function number(mixed $value): BigRational
|
||||
{
|
||||
if ((! is_string($value) && ! is_int($value)) || ! preg_match('/^-?\d{1,24}(?:\.\d{1,8})?$/D', (string) $value)) {
|
||||
throw new InvalidArgumentException('Introduza valores numéricos decimais ou inteiros.');
|
||||
}
|
||||
|
||||
return BigRational::of((string) $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use App\Modules\Identity\Services\{Permissions,Audit};
|
||||
use Illuminate\Support\Facades\{DB,Crypt,Validator};
|
||||
use Brick\Math\{BigDecimal,BigRational,RoundingMode};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Versioned data-only rules. Only pinned artificial cases may execute in this QA contract. */
|
||||
final class FiscalRules
|
||||
{
|
||||
public const CONTRACT='SGF-FISCAL-RULES-COMMON-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));}
|
||||
public function scope(int $actor,string $action='view'):array
|
||||
{
|
||||
abort_unless(config('fiscal_rules_qa.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&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'],'fiscal_rules.'.$r,$s['unit_id']);return $s;
|
||||
}
|
||||
public function fixture():array
|
||||
{
|
||||
$file=base_path('resources/data/fiscal-rule-qa.json');$pin=config('fiscal_rules_qa.source_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503,'Fonte artificial do contrato não conferida.');$f=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($f['contract']===self::CONTRACT&&$f['not_tax_law']===true,503);$f['sha256']=$pin;return $f;
|
||||
}
|
||||
private function events(array $s){return DB::table('fr_events')->where($s);}
|
||||
private function row(object $r):array{$a=(array)$r;foreach(['definition','input','result','dependencies']as$k)if(isset($a[$k]))$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return $a;}
|
||||
private function currentVersions(array $s)
|
||||
{
|
||||
return DB::table('fr_events as f')->where(array_combine(array_map(fn($k)=>'f.'.$k,array_keys($s)),array_values($s)))->whereNotExists(function($q){$q->selectRaw('1')->from('fr_events as newer');foreach(['tenant_id','company_id','unit_id','rule_key','rule_version']as$k)$q->whereColumn('newer.'.$k,'f.'.$k);$q->whereColumn('newer.revision','>','f.revision');});
|
||||
}
|
||||
private function exact(array $data,array $rules):array{abort_if(array_diff(array_keys($data),array_keys($rules)),422,'Campo não suportado neste contrato.');return Validator::make($data,$rules)->validate();}
|
||||
public function definition(array $d):array
|
||||
{
|
||||
$d=$this->exact($d,['label'=>'required|string|min:3|max:160','family'=>'required|in:qa,iva,irps,irpc,ispc,ice,inss,other','operation'=>'required|in:percentage_add,metadata_only','conditions'=>'required|string|min:10|max:2000','currency'=>'nullable|in:MZN','unit'=>'required|in:money,none','precision'=>'required|integer|in:2','rounding'=>'required|in:DOWN','priority'=>'required|integer|min:0|max:100','source_reference'=>'required|string|min:3|max:200','source_sha256'=>'nullable|string|regex:/^[a-f0-9]{64}$/D','source_status'=>'required|in:pending,qa_checked','valid_from'=>'nullable|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from|before_or_equal:9998-12-31','parameters'=>'present|array','expression'=>'present|array']);
|
||||
$this->exact($d['parameters'],['rate'=>'nullable|string|regex:/^0\.[0-9]{1,8}$/D']);
|
||||
if($d['expression']){try{(new FiscalRuleExpression)->validate($d['expression']);}catch(\InvalidArgumentException){abort(422,'Expressão declarativa inválida; não é executado código.');}abort_if(array_diff(array_keys($d['expression']),['language','nodes','result']),422);}
|
||||
if($d['source_status']==='qa_checked')abort_unless($this->verified($d),422,'Somente os casos artificiais exactos deste contrato podem ser conferidos para execução QA. Fontes legais não são activadas.');return $d;
|
||||
}
|
||||
private function verified(array $d):bool
|
||||
{
|
||||
$f=$this->fixture();if(($d['source_sha256']??null)!==$f['sha256']||($d['family']??null)!=='qa'||($d['source_status']??null)!=='qa_checked')return false;
|
||||
foreach($f['cases']as$case){$expected=$case['definition'];$actual=$d;unset($actual['source_sha256'],$actual['label']);unset($expected['label']);if($this->hash($actual)===$this->hash($expected))return true;}return false;
|
||||
}
|
||||
private function basis(array $s,array $d):array
|
||||
{
|
||||
$q=$this->events($s);$last=isset($d['rule_key'])?(clone$q)->where('rule_key',$d['rule_key'])->orderByDesc('revision')->first():null;$target=isset($d['target_id'])?(clone$q)->where('id',$d['target_id'])->first():null;$selected=isset($d['data']['rule_event_id'])?(clone$q)->where('id',$d['data']['rule_event_id'])->first():null;
|
||||
return ['source_sha256'=>$this->fixture()['sha256'],'count'=>(int)(clone$q)->count(),'last_id'=>(int)(clone$q)->max('id'),'last'=>$last?$this->row($last):null,'target'=>$target?$this->row($target):null,'selected'=>$selected?$this->row($selected):null];
|
||||
}
|
||||
private function right(string $action):string{return in_array($action,['review','withdraw'],true)?'review':'prepare';}
|
||||
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();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first(),403);DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
abort_if(strlen(json_encode($input))>32768,422);$d=$this->exact($input,['action'=>'required|in:prepare,review,withdraw,calculate,calculate_document','rule_key'=>'nullable|required_unless:action,calculate,calculate_document|string|regex:/^[A-Z0-9_-]{3,80}$/D','revision'=>'required|integer|min:0','target_id'=>'nullable|required_if:action,review,withdraw|integer|min:1','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);if($d['action']==='calculate_document')abort_unless(config('fiscal_rules_qa.document_enabled')===true,404);$d['revision']=(int)$d['revision'];if(isset($d['target_id']))$d['target_id']=(int)$d['target_id'];$s=$this->scope($actor,$this->right($d['action']));
|
||||
return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));$b=$this->basis($s,$d);$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,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'observed'=>time(),'basis'=>$this->hash($b),'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,$this->right($d['action']));return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$this->right($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('fr_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409,'A mesma chave não aceita outra entrada.');return json_decode($old->result,true)+['replayed'=>true,'historical_receipt_only'=>true];}$b=$this->basis($s,$d);abort_unless($this->hash($b)===$c['basis'],409,'Regras, fonte ou versão mudaram; exige nova conferência.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409);DB::table('fr_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_rule.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['revision'],'before_revision'=>$d['revision'],'reason'=>$d['reason'],'review_hash'=>$b['source_sha256']]);return $out+['replayed'=>false];});
|
||||
}
|
||||
private function execute(int $actor,array $s,array $d,array $basis):array
|
||||
{
|
||||
if(in_array($d['action'],['calculate','calculate_document'],true)){
|
||||
abort_if(!empty($d['rule_key'])||isset($d['target_id'])||$d['revision']!==0,422);$in=$d['action']==='calculate_document'?$this->documentInput($d['data']):$this->exact($d['data'],['rule_event_id'=>'nullable|integer|min:1','base'=>'required|string|regex:/^\d{1,16}(\.\d{1,8})?$/D','currency'=>'required|string|regex:/^[A-Z]{3}$/D','date'=>'required|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31']);$selected=$basis['selected'];abort_if(isset($in['rule_event_id'])&&!$selected,403,'Regra fora do contexto.');$result=$d['action']==='calculate_document'?$this->evaluateDocument($s,$in,$selected):$this->evaluate($s,$in,$selected);$deps=['source_sha256'=>$basis['source_sha256'],'rules_count'=>$basis['count'],'rules_last_id'=>$basis['last_id'],'selected_id'=>$selected['id']??null,'selected_fingerprint'=>$selected?$this->hash($selected):null];$id=DB::table('fr_calculations')->insertGetId($s+['actor_id'=>$actor,'rule_event_id'=>$selected['id']??null,'input'=>$this->json($in),'result'=>$this->json($result),'dependencies'=>$this->json($deps),'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$id,'type'=>'calculation','revision'=>0,'state'=>$result['state'],'impact'=>['action'=>'calculate','result'=>$result,'new_journals'=>0,'legal_activation'=>false]];
|
||||
}
|
||||
$last=$basis['last'];abort_unless(($last['revision']??0)===$d['revision'],409,'Revisão do catálogo alterada.');$prepared=$actor;$target=null;
|
||||
if($d['action']==='prepare'){abort_if(isset($d['target_id']),422);$definition=$this->definition($d['data']);$version=(int)$this->events($s)->where('rule_key',$d['rule_key'])->max('rule_version')+1;$state='prepared';}
|
||||
else{
|
||||
$decision=$d['action']==='review'?$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']):$this->exact($d['data'],[]);$target=$basis['target'];abort_unless($target&&$target['rule_key']===$d['rule_key'],403,'Escolha a versão exacta desta regra.');$current=$this->events($s)->where(['rule_key'=>$target['rule_key'],'rule_version'=>$target['rule_version']])->orderByDesc('revision')->value('id');abort_unless($current===$target['id'],409,'A versão escolhida já mudou.');abort_unless($target['prepared_by']!==$actor,403,'Preparação e revisão por pessoas distintas.');abort_unless($target['state']===($d['action']==='withdraw'?'reviewed':'prepared'),409,'Estado incompatível com a decisão.');$prepared=$target['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);abort_unless(DB::table('permission_grants')->where($s+['user_id'=>$prepared,'permission'=>'fiscal_rules.prepare'])->lockForUpdate()->first(),403,'Autor da preparação sem autoridade actual.');$definition=$this->definition($target['definition']);$version=$target['rule_version'];$state=$d['action']==='withdraw'?'withdrawn':$decision['decision'];
|
||||
}
|
||||
$revision=$d['revision']+1;$id=DB::table('fr_events')->insertGetId($s+['rule_key'=>$d['rule_key'],'revision'=>$revision,'rule_version'=>$version,'previous_id'=>$last['id']??null,'target_id'=>$target['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'definition'=>$this->json($definition),'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$id,'type'=>'rule','revision'=>$revision,'rule_version'=>$version,'state'=>$state,'impact'=>['action'=>$d['action'],'before'=>['revision'=>$d['revision'],'state'=>$last['state']??null],'after'=>['revision'=>$revision,'rule_version'=>$version,'state'=>$state],'source_checked'=>$this->verified($definition),'definition'=>$definition,'new_journals'=>0,'legal_activation'=>false]];
|
||||
}
|
||||
private function evaluate(array $s,array $in,?array $selected):array
|
||||
{
|
||||
$pending=[];$def=$selected['definition']??null;
|
||||
if(!$selected)$pending[]='Escolha explícita de regra em falta.';
|
||||
else{$current=$this->events($s)->where(['rule_key'=>$selected['rule_key'],'rule_version'=>$selected['rule_version']])->orderByDesc('revision')->first();if(!$current||$current->id!==$selected['id']||$current->state!=='reviewed')$pending[]='Regra exacta não revista, revogada ou alterada.';if(!$this->verified($def))$pending[]='Fonte/aplicabilidade não conferida para execução neste ensaio.';if(($def['currency']??null)!==$in['currency'])$pending[]='Moeda incompatível.';if(empty($def['valid_from'])||$in['date']<$def['valid_from']||(!empty($def['valid_until'])&&$in['date']>$def['valid_until']))$pending[]='Data fora de vigência ou vigência desconhecida.';
|
||||
$eligible=$this->currentVersions($s)->where('f.state','reviewed')->where('f.definition->family',$def['family'])->where('f.definition->operation',$def['operation'])->where('f.definition->conditions',$def['conditions'])->where('f.definition->currency',$in['currency'])->where('f.definition->source_status','qa_checked')->where('f.definition->valid_from','<=',$in['date'])->where(fn($q)=>$q->whereNull('f.definition->valid_until')->orWhere('f.definition->valid_until','>=',$in['date']));$max=(clone$eligible)->max(DB::raw("(f.definition->>'priority')::int"));if($max!==null){if((int)$max>(int)$def['priority'])$pending[]='Existe prioridade superior; escolha a regra exacta aplicável.';if((clone$eligible)->whereRaw("(f.definition->>'priority')::int=?",[$max])->count()>1)$pending[]='Conflito de regras com a mesma prioridade e âmbito.';}}
|
||||
if($pending)return ['state'=>'pending','pending'=>$pending,'base'=>$in['base'],'tax_exact'=>null,'tax_document'=>null,'total'=>null,'synthetic_only'=>true,'legal_use'=>false];
|
||||
$result=(new FiscalRuleExpression)->evaluate($def['expression'],['base'=>$in['base']],$def['parameters']);if($result['status']!=='determinate')return ['state'=>'pending','pending'=>$result['missing'],'tax_exact'=>null,'tax_document'=>null,'total'=>null,'synthetic_only'=>true,'legal_use'=>false];
|
||||
$tax=BigRational::of($result['result'])->toBigDecimal();$document=$tax->toScale(2,RoundingMode::Down);$base=BigDecimal::of($in['base']);$representable=$base->stripTrailingZeros()->getScale()<=2;
|
||||
return ['state'=>$representable?'determined_qa':'pending_representation','base'=>(string)$base->stripTrailingZeros(),'tax_exact'=>(string)$tax->stripTrailingZeros(),'tax_document'=>(string)$document,'difference'=>(string)$tax->minus($document)->stripTrailingZeros(),'total'=>$representable?(string)$base->plus($document)->toScale(2):null,'currency'=>$in['currency'],'rule'=>['id'=>$selected['id'],'key'=>$selected['rule_key'],'version'=>$selected['rule_version'],'revision'=>$selected['revision'],'source_reference'=>$def['source_reference'],'source_sha256'=>$def['source_sha256']],'steps'=>$result['steps'],'pending'=>$representable?[]:['Base não representável em duas casas; não truncada.'],'synthetic_only'=>true,'legal_use'=>false];
|
||||
}
|
||||
private function documentInput(array $d):array
|
||||
{
|
||||
$in=$this->exact($d,['rule_event_id'=>'nullable|integer|min:1','currency'=>'required|string|regex:/^[A-Z]{3}$/D','date'=>'required|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31','price_mode'=>'required|in:net_added,gross_included','lines'=>'required|array|min:1|max:25']);
|
||||
foreach($in['lines']as$i=>$line){abort_unless(is_array($line),422);$in['lines'][$i]=$this->exact($line,['reference'=>'required|string|max:80','quantity'=>'required|string|regex:/^\d{1,16}(\.\d{1,8})?$/D','unit_price'=>'required|string|regex:/^\d{1,16}(\.\d{1,8})?$/D']);abort_unless(BigDecimal::of($line['quantity'])->isGreaterThan(0),422,'Quantidade positiva obrigatória.');}abort_unless(array_is_list($in['lines'])&&count(array_unique(array_column($in['lines'],'reference')))===count($in['lines']),422,'Linhas sequenciais e referências distintas obrigatórias.');return $in;
|
||||
}
|
||||
private function evaluateDocument(array $s,array $in,?array $selected):array
|
||||
{
|
||||
$gate=$this->evaluate($s,['base'=>'0','currency'=>$in['currency'],'date'=>$in['date']],$selected);if($gate['state']!=='determined_qa'){unset($gate['base']);return $gate+['policy'=>FiscalDocumentMath::POLICY,'price_mode'=>$in['price_mode'],'base_document'=>null,'lines'=>[]];}
|
||||
return FiscalDocumentMath::calculate($in['lines'],$in['price_mode'],$selected['definition']['parameters']['rate'])+['currency'=>$in['currency'],'rule'=>$gate['rule']];
|
||||
}
|
||||
public function documentReport(int $actor,int $id):array
|
||||
{
|
||||
$detail=$this->detail($actor,$id,'calculation');$r=$detail['result'];abort_unless(isset($r['price_mode'],$r['lines']),422,'Este registo não é cálculo por linhas.');$fp=fopen('php://temp','w+');$rule=$r['rule']??[];$rows=[['MAPA INTERNO POR LINHAS QA — SEM VALIDADE FISCAL'],['Politica',$r['policy']],['Modo',$r['price_mode']],['Data',$detail['input']['date']],['Moeda',$detail['input']['currency']],['Regra escolhida',(string)($detail['input']['rule_event_id']??'INDETERMINADA')],['Versao regra',(string)($rule['version']??'INDETERMINADA')],['Fonte',$rule['source_reference']??'INDETERMINADA'],['SHA fonte',$rule['source_sha256']??'INDETERMINADO'],['Estado historico',$r['state']],['Estado actual',$detail['current_evaluation']['state']],['Linha','Referencia','Base exacta fraccao','Imposto exacto fraccao','Base documental','Imposto documental','Total','Diferenca base fraccao','Diferenca imposto fraccao']];foreach($r['lines']as$line){$ref="'".$line['reference'];$rows[]=[(string)$line['line'],$ref,$line['base_exact_fraction'],$line['tax_exact_fraction'],$line['base_document']??'INDETERMINADO',$line['tax_document'],$line['total_document']??'INDETERMINADO',$line['base_difference_fraction']??'INDETERMINADO',$line['tax_difference_fraction']];}$rows[]=['Totais documentais','','','',$r['base_document']??'INDETERMINADO',$r['tax_document']??'INDETERMINADO',$r['total']??'INDETERMINADO'];foreach($rows as$row)fputcsv($fp,$row,';','"','',"\r\n");rewind($fp);$bytes=stream_get_contents($fp);$s=$this->scope($actor);Audit::record('fiscal_rule.document_report',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>0,'review_hash'=>hash('sha256',$bytes)]);return ['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes)];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=$this->exact($input,['view'=>'sometimes|in:rules,history,calculations','rule_key'=>'sometimes|string|regex:/^[A-Z0-9_-]{3,80}$/D','family'=>'sometimes|in:qa,iva,irps,irpc,ispc,ice,inss,other','state'=>'sometimes|in:prepared,reviewed,refused,withdrawn','search'=>'nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$view=$d['view']??'rules';abort_if($view==='history'&&!isset($d['rule_key']),422);abort_if($view==='calculations'&&array_intersect(array_keys($d),['rule_key','family','state','search']),422);
|
||||
return DB::transaction(function()use($actor,$s,$d,$view){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$view==='calculations'?DB::table('fr_calculations')->where($s):($view==='history'?$this->events($s):$this->currentVersions($s));$alias=$view==='rules'?'f.':'';if(isset($d['rule_key']))$q->where($alias.'rule_key',$d['rule_key']);foreach(['family'=>'definition->family','state'=>'state']as$filter=>$column)if(isset($d[$filter]))$q->where($alias.$column,$d[$filter]);if(!empty($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($w)=>$w->whereRaw($alias."rule_key ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw($alias."definition->>'label' ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw($alias."definition->>'source_reference' ILIKE ? ESCAPE '!'",[$needle]));}$page=$q->orderByDesc($alias.'id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$rows=$page->getCollection()->map(fn($r)=>$this->row($r));$ids=$rows->pluck('id')->all();$refs=DB::table('fr_commands')->where($s)->where('actor_id',$actor)->where('result->type',$view==='calculations'?'calculation':'rule')->whereIn(DB::raw("(result->>'id')::bigint"),$ids)->get();$page->setCollection($rows->map(function($r)use($refs){$r['command_reference']=null;foreach($refs as$ref)if(json_decode($ref->result,true)['id']===$r['id'])$r['command_reference']=$ref->command_key;return $r;}));return ['document_enabled'=>config('fiscal_rules_qa.document_enabled')===true,'stream_revision'=>isset($d['rule_key'])?(int)$this->events($s)->where('rule_key',$d['rule_key'])->max('revision'):null,'contract'=>self::CONTRACT,'view'=>$view,'records'=>$page->toArray(),'fixtures'=>$this->fixture(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_rules.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_rules.review',$s['unit_id'])],'synthetic_only'=>true,'legal_activation'=>false];});
|
||||
}
|
||||
public function detail(int $actor,int $id,string $type='rule'):array
|
||||
{
|
||||
$s=$this->scope($actor);$row=DB::table($type==='calculation'?'fr_calculations':'fr_events')->where($s)->where('id',$id)->first();abort_unless($row,404);$a=$this->row($row);if($type==='rule'){$a['current_revision']=(int)$this->events($s)->where('rule_key',$row->rule_key)->max('revision');$a['current_version_event_id']=(int)$this->events($s)->where(['rule_key'=>$row->rule_key,'rule_version'=>$row->rule_version])->orderByDesc('revision')->value('id');}else{$selected=isset($a['input']['rule_event_id'])?$this->events($s)->where('id',$a['input']['rule_event_id'])->first():null;$a['current_evaluation']=isset($a['input']['lines'])?$this->evaluateDocument($s,$a['input'],$selected?$this->row($selected):null):$this->evaluate($s,$a['input'],$selected?$this->row($selected):null);$a['historical_snapshot_unchanged']=true;}return $a;
|
||||
}
|
||||
public function receipt(int $actor,string $key):array
|
||||
{
|
||||
$s=$this->scope($actor);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);$r=DB::table('fr_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'created_at'=>$r->created_at,'command_key'=>$key,'historical_receipt_only'=>true,'operation_executed'=>false];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\CommercialQuotes;use Illuminate\Http\Request;
|
||||
final class CommercialQuotesController
|
||||
{
|
||||
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Orçamento ocupado. Recupere a mesma decisão.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,CommercialQuotes $s){return $this->response(fn()=>$s->listing($r->user()->id,$r->query()));}
|
||||
public function options(Request $r,CommercialQuotes $s){return $this->response(fn()=>$s->options($r->user()->id,$r->query()));}
|
||||
public function history(Request $r,CommercialQuotes $s,string $key){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page']),422);return $this->response(fn()=>$s->history($r->user()->id,$key,(int)($d['page']??1)));}
|
||||
public function preview(Request $r,CommercialQuotes $s){return $this->response(fn()=>$s->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,CommercialQuotes $s){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$s->confirm($r->user()->id,$d['preview']));}
|
||||
public function print(Request $r,CommercialQuotes $s,string $key){$d=$r->validate(['version'=>'required|integer|min:1']);abort_if(array_diff(array_keys($r->query()),['version']),422);return response()->view('commercial.quote-print',['quote'=>$s->document($r->user()->id,$key,(int)$d['version'])])->header('Cache-Control','no-store, private');}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\FiscalAssessment;use Illuminate\Http\Request;
|
||||
final class FiscalAssessmentController
|
||||
{
|
||||
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Livro fiscal ocupado. Recupere a mesma confirmação; se a versão mudou, confira novamente.'],503)->header('Retry-After','5');}}
|
||||
public function screen(Request $r,FiscalAssessment $f){abort_if($r->query()!==[],422);$f->scope($r->user()->id);return response()->view('fiscal.workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
|
||||
public function index(Request $r,FiscalAssessment $f){return $this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,FiscalAssessment $f){return $this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,FiscalAssessment $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
|
||||
public function receipt(Request $r,FiscalAssessment $f,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
public function export(Request $r,FiscalAssessment $f,int $id){abort_if($r->query()!==[],422);$r=$f->export($r->user()->id,$id);return response($r['bytes'])->header('Content-Type','text/csv; charset=UTF-8')->header('Content-Disposition','attachment; filename="mapa-interno-IVA-QA-v'.$r['version'].'.csv"')->header('X-Content-SHA256',$r['sha256'])->header('Cache-Control','no-store, private');}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\FiscalObligations;use Illuminate\Http\Request;
|
||||
final class FiscalObligationsController
|
||||
{
|
||||
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Controlo fiscal ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
|
||||
public function index(Request $r,FiscalObligations $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,FiscalObligations $f){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,FiscalObligations $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
|
||||
public function receipt(Request $r,FiscalObligations $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\FiscalRules;use Illuminate\Http\Request;
|
||||
final class FiscalRulesController
|
||||
{
|
||||
private function response(\Closure $fn){try{$out=$fn();return $out instanceof \Symfony\Component\HttpFoundation\Response?$out:response()->json($out)->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Catálogo fiscal ocupado. Recupere o mesmo pedido; uma versão alterada exige nova conferência.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,FiscalRules $f){return $this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function detail(Request $r,FiscalRules $f,int $id){$d=$r->validate(['type'=>'sometimes|in:rule,calculation']);abort_if(array_diff(array_keys($r->query()),['type']),422);return $this->response(fn()=>$f->detail($r->user()->id,$id,$d['type']??'rule'));}
|
||||
public function preview(Request $r,FiscalRules $f){return $this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,FiscalRules $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
|
||||
public function receipt(Request $r,FiscalRules $f,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
public function screen(Request $r,FiscalRules $f){abort_if($r->query()!==[],422);$f->scope($r->user()->id);return response()->view('fiscal.rules',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
|
||||
public function documentReport(Request $r,FiscalRules $f,int $id){abort_if($r->query()!==[],422);return $this->response(function()use($r,$f,$id){$d=$f->documentReport($r->user()->id,$id);return response($d['bytes'])->header('Content-Type','text/csv; charset=UTF-8')->header('Content-Disposition','attachment; filename="mapa-linhas-QA-'.$id.'.csv"')->header('X-Content-SHA256',$d['sha256'])->header('Cache-Control','no-store, private');});}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\ReceivablePositionRead;
|
||||
use Illuminate\Http\Request;
|
||||
final class ReceivablePositionReadController
|
||||
{
|
||||
public function index(Request $request,ReceivablePositionRead $reader){return response()->json($reader->listing($request->user()->id,$request->query()))->header('Cache-Control','private, no-store');}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use Illuminate\Http\Request;use App\Modules\Services\ServiceDocuments;
|
||||
final class ServiceDocumentsController
|
||||
{
|
||||
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Documento ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,ServiceDocuments $s){return $this->response(fn()=>$s->listing($r->user()->id,$r->query()));}
|
||||
public function register(Request $r,\App\Modules\Services\BillingRegister $s){return $this->response(fn()=>$s->listing($r->user()->id,$r->query()));}
|
||||
public function screen(Request $r,ServiceDocuments $s){$d=$r->validate(['kind'=>'sometimes|in:receipt,credit_note,invoice_receipt']);abort_if(array_diff(array_keys($r->query()),['kind']),422);$s->listing($r->user()->id,['per_page'=>25]);return response()->view('commercial.documents',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token'),'kind'=>$d['kind']??'receipt'])->header('Cache-Control','no-store');}
|
||||
public function options(Request $r,ServiceDocuments $s){return $this->response(fn()=>$s->options($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,ServiceDocuments $s){return $this->response(fn()=>$s->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,ServiceDocuments $s){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$s->confirm($r->user()->id,$d['preview']));}
|
||||
public function pdf(Request $r,ServiceDocuments $s,int $id){abort_if($r->query()!==[],422);$row=$s->pdf($r->user()->id,$id);return response(base64_decode($row->pdf_base64,true))->header('Content-Type','application/pdf')->header('Content-Disposition','inline; filename="TEST-SD-'.$row->id.'.pdf"')->header('Cache-Control','no-store, private');}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\{DB,Validator};
|
||||
use Brick\Math\BigDecimal;
|
||||
|
||||
/** Current recognized positions. Reference date classifies due dates, never rewinds effects. */
|
||||
final class ReceivablePositionRead
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
CompositionRuntime::assert();ServiceReceivables::guard();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
|
||||
abort_unless($s['unit_id']&&$actor===$this->context->userId()&&ModuleRecords::active()==='finance',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_receivables.view',$s['unit_id']);
|
||||
$rules=['reference_on'=>'required|date_format:Y-m-d','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100','state'=>'sometimes|in:proposed,refused,open,partial,settled','due_state'=>'sometimes|in:not_open,unknown,overdue,not_due,settled'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();
|
||||
return DB::transaction(function()use($s,$d){
|
||||
if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$scope=fn($alias)=>array_combine(array_map(fn($k)=>$alias.'.'.$k,array_keys($s)),array_values($s));
|
||||
$effects=DB::table('sr_effects as e')->join('sr_records as scoped','scoped.id','=','e.obligation_id')->where($scope('scoped'))->where('scoped.kind','obligation')->groupBy('e.obligation_id')->selectRaw("e.obligation_id,count(*) revision,bool_or(e.kind='open') opened,bool_or(e.kind='refused' AND e.record_id=e.obligation_id) refused,coalesce(sum(CASE WHEN e.kind='reduction' THEN e.amount WHEN e.kind='reverse_reduction' THEN -e.amount ELSE 0 END),0) reduced,coalesce(sum(CASE WHEN e.kind='allocate' THEN e.amount WHEN e.kind='reverse_allocation' THEN -e.amount ELSE 0 END),0) allocated");
|
||||
$base=DB::table('sr_records as o')->leftJoinSub($effects,'f','f.obligation_id','=','o.id')->join('si_invoices as invoice',function($j){$j->on('invoice.id','=','o.invoice_id');foreach(['tenant_id','company_id','unit_id']as$key)$j->on('invoice.'.$key,'=','o.'.$key);})->where($scope('o'))->where('o.kind','obligation')->select(['o.id','o.invoice_id','o.client_id','o.currency','o.created_at'])->selectRaw("invoice.series_code||'/'||invoice.number::text reference,invoice.snapshot->'document'->'client'->>'name' client,o.amount::text original,coalesce(f.revision,0) revision,coalesce(f.opened,false) opened,coalesce(f.refused,false) refused,coalesce(f.reduced,0)::text reduced,coalesce(f.allocated,0)::text allocated,(o.amount-coalesce(f.reduced,0)-coalesce(f.allocated,0))::text balance,CASE WHEN EXISTS(SELECT 1 FROM sr_effects due WHERE due.obligation_id=o.id AND due.kind='due') THEN (SELECT due.data->>'due_date' FROM sr_effects due WHERE due.obligation_id=o.id AND due.kind='due' ORDER BY due.id DESC LIMIT 1) ELSE o.data->>'due_date' END due_date");
|
||||
$positions=DB::query()->fromSub($base,'p')->select('p.*')->selectRaw("CASE WHEN NOT opened THEN CASE WHEN refused THEN 'refused' ELSE 'proposed' END WHEN balance::numeric=0 THEN 'settled' WHEN allocated::numeric>0 THEN 'partial' ELSE 'open' END state,CASE WHEN NOT opened THEN 'not_open' WHEN balance::numeric=0 THEN 'settled' WHEN due_date IS NULL THEN 'unknown' WHEN ?::date>due_date::date THEN 'overdue' ELSE 'not_due' END due_state,CASE WHEN opened AND balance::numeric>0 AND due_date IS NOT NULL THEN greatest(0,?::date-due_date::date) ELSE NULL END overdue_days",[$d['reference_on'],$d['reference_on']]);
|
||||
$q=DB::query()->fromSub($positions,'positions');foreach(['state','due_state']as$key)if(isset($d[$key]))$q->where($key,$d[$key]);if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($x)=>$x->whereRaw("reference ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("client ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
$totals=(clone$q)->selectRaw("count(*) FILTER(WHERE opened) recognized_positions,count(*) FILTER(WHERE NOT opened) unrecognized_proposals,count(*) FILTER(WHERE due_state='unknown') unknown_due_positions,coalesce(sum(balance::numeric) FILTER(WHERE opened),0)::text recognized_balance,count(*) FILTER(WHERE opened AND balance::numeric<0) invalid_negative_positions")->first();abort_if((int)$totals->invalid_negative_positions>0,409,'Há saldo incompatível com o livro; exige conferência da origem.');$totals->recognized_balance=(string)BigDecimal::of($totals->recognized_balance)->toScale(2);unset($totals->invalid_negative_positions);
|
||||
$p=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));foreach($p->items()as$row){foreach(['original','reduced','allocated','balance']as$key)$row->$key=(string)BigDecimal::of($row->$key)->toScale(2);if(!$row->opened)$row->balance=null;$row->revision=(int)$row->revision;}
|
||||
$records=DB::table('sr_records')->where($s)->selectRaw('count(*) records,coalesce(max(id),0) last_record_id')->first();$marker=DB::table('sr_effects as e')->join('sr_records as scoped','scoped.id','=','e.obligation_id')->where($scope('scoped'))->where('scoped.kind','obligation')->selectRaw('count(*) effects,coalesce(max(e.id),0) last_effect_id')->first();
|
||||
return ['positions'=>$p,'totals'=>$totals,'basis'=>['scope'=>$s,'records'=>(int)$records->records,'last_record_id'=>(int)$records->last_record_id,'effects'=>(int)$marker->effects,'last_effect_id'=>(int)$marker->last_effect_id],'reference_on'=>$d['reference_on'],'observed_at'=>now()->utc()->toIso8601String(),'currency'=>'MZN','read_only'=>true,'balance_basis'=>'current_confirmed_effects_not_historical_cut','notice'=>'Saldos actuais. A data de referência classifica apenas o vencimento; não reconstitui saldos históricos. Propostas não reconhecidas não entram no saldo total.'];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ final class ServiceCalculation
|
||||
|
||||
public function __construct(private ServiceWorkflow $workflow, private DocumentPreparation $preparation, private CompanyContext $context) {}
|
||||
|
||||
public static function isolated(): bool { return app()->environment(['testing','browser-testing']); }
|
||||
public static function isolated(): bool {
|
||||
if(app()->environment('budget-testing')&&config('sgf_single_installation.enabled')===true){\App\Shared\Installation\CompositionRuntime::assert();return true;}
|
||||
return app()->environment(['testing','browser-testing']);
|
||||
}
|
||||
|
||||
public static function evaluateReal(array $data, array $prepared, array $profile): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?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};
|
||||
|
||||
/** Reviewed, versioned synthetic documents. Original invoice and receivable writers own effects. */
|
||||
final class ServiceDocuments
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function json(array $v):string{return \Tempo\Finance\Payables\Payables::canonical($v);}
|
||||
private function hash(mixed $v):string{return hash('sha256',$this->json($v));}
|
||||
private function scope(int $actor,string $right='view'):array
|
||||
{
|
||||
abort_unless(config('service_documents.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']);
|
||||
foreach(['view',$right] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.'.$r,$s['unit_id']);return $s;
|
||||
}
|
||||
private function inputs():array
|
||||
{
|
||||
$p=CompositionRuntime::physical();$file='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$p['composition'].'/single-document-inputs.json';$pin=config('service_documents.inputs_sha256');
|
||||
abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);return json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
|
||||
}
|
||||
private function current(array $s,string $key):?object{return DB::table('sd_versions')->where($s)->where('document_key',$key)->orderByDesc('version')->first();}
|
||||
private function invoice(array $s,int $id):object
|
||||
{
|
||||
$allowed=$this->inputs();$i=ModuleRecords::query('si_invoices')->where($s)->where('id',$id)->first();abort_unless($i&&in_array($i->draft_id,array_column($allowed,'draft_id'),true),403,'Documento fora das novas origens QA autorizadas.');return $i;
|
||||
}
|
||||
private function right(array $d):string{return match($d['action']){'review','cancel_review'=>'review','issue'=>'issue',default=>'prepare'};}
|
||||
private function basis(int $actor,array $s,array $d):array
|
||||
{
|
||||
$old=$this->current($s,$d['document_key']);abort_unless(($old?->version??0)===$d['version'],409,'Versão alterada.');
|
||||
if($old&&$d['action']!=='prepare'){$data=json_decode($old->data,true);$kind=$old->kind;}else{$data=$d['data'];$kind=$d['kind'];}
|
||||
if(str_starts_with($d['action'],'cancel_')){
|
||||
abort_unless($old&&$old->state==='issued'&&$old->kind===$d['kind']&&$d['data']===[],409);$out=DB::table('sd_outputs')->where($s)->where('document_key',$old->document_key)->firstOrFail();$this->invoice($s,$out->invoice_id);$effect=DB::table('sr_effects')->find($out->effect_id);$last=DB::table('sd_cancellations')->where($s)->where('output_id',$out->id)->orderByDesc('version')->first();$period=DocumentPeriod::open($s);return ['old'=>(array)$old,'data'=>$data,'kind'=>$kind,'origin'=>['output'=>(array)$out,'effect'=>(array)$effect,'last'=>$last?(array)$last:null,'revision'=>DB::table('sr_effects')->where('obligation_id',$effect->obligation_id)->count(),'position'=>app(ServiceReceivables::class)->position($effect->obligation_id,$actor),'period'=>$period]];
|
||||
}
|
||||
abort_unless(!$old||$kind===$d['kind'],409);$origin=[];
|
||||
if($kind==='invoice_receipt'){
|
||||
abort_unless(config('service_documents.invoice_receipt_enabled')===true,403,'Facturação-recibo pendente dos acessos de consulta documental apresentados.');
|
||||
$allowed=$this->inputs()['invoice_receipt'];abort_unless(($data['draft_id']??null)===$allowed['draft_id']&&($data['series_id']??null)===$allowed['series_id']&&($data['rule_id']??null)===$allowed['rule_id'],403);
|
||||
// Readiness uses the proposed document authority; final issue retains the old SI right.
|
||||
$review=app(ServiceInvoicing::class)->reviewDocumentIntent($data['draft_id'],$data['series_id'],$data['rule_id'],$actor);
|
||||
abort_unless($review['ready'],422,'Origem documental ainda não pronta.');$origin=['invoice_review_hash'=>$review['review_hash'],'invoice_review'=>$review['payload']];
|
||||
}else{
|
||||
$invoice=$this->invoice($s,(int)($data['invoice_id']??0));$origin=['invoice_id'=>$invoice->id,'invoice_sha256'=>hash('sha256',$invoice->snapshot)];
|
||||
$ob=DB::table('sr_records')->where($s)->where(['invoice_id'=>$invoice->id,'kind'=>'obligation'])->first();abort_unless($ob,409,'Reconhecimento explícito da obrigação necessário.');
|
||||
$origin['obligation_id']=$ob->id;$origin['revision']=DB::table('sr_effects')->where('obligation_id',$ob->id)->count();
|
||||
if($kind==='receipt'){
|
||||
$effect=DB::table('sr_effects')->where(['obligation_id'=>$ob->id,'id'=>$data['allocation_id']??0,'kind'=>'allocate'])->first();abort_unless($effect,403,'Escolha a afectação efectiva da nova origem.');
|
||||
abort_if(DB::table('sr_effects')->where('target_effect_id',$effect->id)->exists(),409,'Afectação revertida; o recibo não pode ser emitido.');$origin['allocation']=(array)$effect;
|
||||
abort_if(DB::table('sd_outputs')->where($s)->where(['kind'=>'receipt','effect_id'=>$effect->id])->where('document_key','<>',$d['document_key'])->exists(),409,'A afectação já tem recibo. Consulte o documento original.');
|
||||
}
|
||||
}
|
||||
return ['old'=>$old?(array)$old:null,'data'=>$data,'kind'=>$kind,'origin'=>$origin];
|
||||
}
|
||||
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'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
|
||||
DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();
|
||||
abort_unless(DB::table('company_memberships')->where(['user_id'=>$actor,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->lockForUpdate()->first(),403);
|
||||
DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->where(fn($q)=>$q->whereNull('unit_id')->orWhere('unit_id',$s['unit_id']))->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
$rules=['action'=>'required|in:prepare,review,issue,cancel_prepare,cancel_review','kind'=>'required|in:receipt,credit_note,invoice_receipt','document_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:4000','data'=>'present|array'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);$s=$this->scope($actor,$this->right($d));
|
||||
return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$this->right($d));$b=$this->basis($actor,$s,$d);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$d,$b,$key);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}
|
||||
$cut=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];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,$this->right($d));
|
||||
return DB::transaction(function()use($actor,$s,$c,$d){$this->lock($actor,$s);$this->scope($actor,$this->right($d));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('sd_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];}
|
||||
$b=$this->basis($actor,$s,$d);abort_unless($this->hash($b)===$c['basis'],409,'A origem ou dependências mudaram. Confira novamente.');$out=$this->execute($actor,$s,$d,$b,$c['key']);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');
|
||||
DB::table('sd_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('service_document.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'reason'=>$d['reason'],'before'=>$b['old'],'after'=>$out]);return $out+['replayed'=>false];});
|
||||
}
|
||||
private function execute(int $actor,array $s,array $d,array $b,string $key):array
|
||||
{
|
||||
$old=$b['old'];$kind=$b['kind'];$data=$b['data'];$action=$d['action'];$prepared=$old['prepared_by']??$actor;$effect=null;$output=null;
|
||||
if(str_starts_with($action,'cancel_'))return $this->cancel($actor,$s,$d,$b,$key);
|
||||
if($action==='prepare'){
|
||||
abort_if($old&&$kind==='credit_note',409,'A preparação financeira é imutável; conserve-a e prepare outro processo após a recusa.');
|
||||
abort_unless(!$old||in_array($old['state'],['draft','refused'],true),409);abort_unless(!$old||$prepared===$actor,403);
|
||||
$rules=match($kind){'receipt'=>['invoice_id'=>'required|integer','allocation_id'=>'required|integer'], 'credit_note'=>['invoice_id'=>'required|integer','amount'=>'required|string','explanation'=>'required|string|min:10|max:1000'], 'invoice_receipt'=>['draft_id'=>'required|integer','series_id'=>'required|integer','rule_id'=>'required|integer','payment_date'=>'required|date_format:Y-m-d|before_or_equal:today','evidence'=>'required|string|min:10|max:1000']};
|
||||
abort_if(array_diff(array_keys($d['data']),array_keys($rules)),422);$data=Validator::make($d['data'],$rules)->validate();
|
||||
if($kind==='credit_note'){
|
||||
$data['amount']=ServiceReceivables::money($data['amount']);abort_unless($data['amount']==='100.00',422,'Este recorte QA limita o crédito a100MZN.');
|
||||
$record=app(ServiceReceivables::class)->propose($actor,['kind'=>'correction','invoice_id'=>$data['invoice_id'],'obligation_id'=>$b['origin']['obligation_id'],'currency'=>'MZN','amount'=>$data['amount'],'source'=>'SIM-DOCUMENTOS','external_id'=>'SD-'.$d['document_key'],'revision'=>$b['origin']['revision'],'action'=>'reduction','document_ref'=>'TEST-REDUCTION-'.$d['document_key'],'line'=>'1','reason'=>$data['explanation']],$key);
|
||||
$data['correction_id']=$record['id'];
|
||||
}
|
||||
if($kind==='invoice_receipt')foreach(['prepare','allocate'] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_receivables.'.$r,$s['unit_id']);
|
||||
$state='draft';
|
||||
}elseif($action==='review'){
|
||||
abort_unless($old&&$old['state']==='draft'&&$prepared!==$actor,403);$review=Validator::make($d['data'],['decision'=>'required|in:approved,refused'])->validate();abort_if(array_diff(array_keys($d['data']),['decision']),422);$state=$review['decision']==='approved'?'approved':'refused';
|
||||
}else{
|
||||
abort_unless($old&&$old['state']==='approved'&&$d['data']===[],409);$state='issued';
|
||||
if($kind==='credit_note'){
|
||||
$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.credit_apply',$s['unit_id']);
|
||||
$decided=app(ServiceReceivables::class)->decide($data['correction_id'],$actor,['version'=>1,'revision'=>$b['origin']['revision'],'decision'=>'approved','reason'=>$d['reason']],$key);$effect=$decided['id'];$invoice=$data['invoice_id'];$amount=$data['amount'];
|
||||
}elseif($kind==='receipt'){$effect=$data['allocation_id'];$invoice=$data['invoice_id'];$amount=$b['origin']['allocation']['amount'];}
|
||||
else{
|
||||
$review=app(ServiceInvoicing::class)->review($data['draft_id'],$data['series_id'],$data['rule_id'],$actor);$issued=app(ServiceInvoicing::class)->issue($data['draft_id'],$actor,['series_id'=>$data['series_id'],'rule_id'=>$data['rule_id'],'review_hash'=>$review['review_hash']],$key);$invoice=$issued['id'];$amount='1000.00';
|
||||
$aggregate=app(ServiceReceivables::class)->completeDocument($old['id'],$invoice,$actor,$key);$effect=$aggregate['allocation_id'];
|
||||
}
|
||||
}
|
||||
$id=DB::table('sd_versions')->insertGetId($s+['document_key'=>$d['document_key'],'version'=>$d['version']+1,'previous_id'=>$old['id']??null,'kind'=>$kind,'action'=>$action,'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'data'=>$this->json($data),'dependencies'=>$this->json($b['origin']),'reason'=>$d['reason'],'created_at'=>now()]);
|
||||
if($state==='issued'){
|
||||
$number=(int)DB::table('sd_outputs')->where($s)->where('kind',$kind)->max('number')+1;$inv=$this->invoice($s,$invoice);$origin=json_decode($inv->snapshot,true);$label=match($kind){'receipt'=>'Recibo','credit_note'=>'Nota de credito',default=>'Factura-recibo'};
|
||||
$lines=[['title',$label.' de TESTE'],['text','TEST-SD-'.strtoupper($kind).'/'.$number],['text',ServiceInvoicing::NOTICE],['text','Origem: '.$inv->series_code.'/'.$inv->number],['text','Cliente: '.($origin['document']['client']['name']??'Cliente QA')],['text','Montante: '.$amount.' MZN'],['text','Autor da preparacao: '.$prepared],['text','Autor da emissao: '.$actor],['text','Fundamento: '.$d['reason']],['text','Sem dinheiro real. Sem efeito automatico em stock.']];
|
||||
if($kind==='credit_note')$lines[]=['text','Reducao nominal de teste; sem reparticao fiscal certificada.'];if($kind==='invoice_receipt')$lines[]=['text','Base862,07 + imposto documental137,93 =1000,00 MZN.'];
|
||||
$snapshot=['document_lines'=>$lines,'kind'=>$kind,'invoice_id'=>$invoice,'effect_id'=>$effect,'amount'=>$amount,'simulation_only'=>true,'notice'=>ServiceInvoicing::NOTICE,'source_sha256'=>hash('sha256',$inv->snapshot),'prepared_by'=>$prepared,'issued_by'=>$actor,'issued_at'=>now()->toIso8601String()];$pdf=app(InvoicePdf::class)->render($snapshot);
|
||||
$output=DB::table('sd_outputs')->insertGetId($s+['document_key'=>$d['document_key'],'version_id'=>$id,'kind'=>$kind,'number'=>$number,'invoice_id'=>$invoice,'effect_id'=>$effect,'snapshot'=>$this->json($snapshot),'pdf_base64'=>base64_encode($pdf),'pdf_sha256'=>hash('sha256',$pdf),'actor_id'=>$actor,'created_at'=>now()]);
|
||||
}
|
||||
$impact=['kind'=>$kind,'action'=>$action,'state'=>$state,'amount'=>$kind==='credit_note'?($data['amount']??null):($kind==='receipt'?($b['origin']['allocation']['amount']??null):'1000.00'),'physical_change'=>false,'financial_reduction'=>$kind==='credit_note'&&$action==='issue','atomic_invoice_receipt'=>$kind==='invoice_receipt'&&$action==='issue'];
|
||||
return ['id'=>$id,'document_key'=>$d['document_key'],'version'=>$d['version']+1,'state'=>$state,'output_id'=>$output,'effect_id'=>$effect,'impact'=>$impact];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=Validator::make($input,['kind'=>'sometimes|in:receipt,credit_note,invoice_receipt','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','document_key'=>'sometimes|uuid'])->validate();abort_if(array_diff(array_keys($input),array_keys($d)),422);
|
||||
$q=DB::table('sd_versions as v')->where(['v.tenant_id'=>$s['tenant_id'],'v.company_id'=>$s['company_id'],'v.unit_id'=>$s['unit_id']]);if(isset($d['document_key']))$q->where('v.document_key',$d['document_key']);else $q->whereNotExists(fn($x)=>$x->selectRaw('1')->from('sd_versions as n')->whereColumn('n.document_key','v.document_key')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->whereColumn('n.version','>','v.version'));
|
||||
if(isset($d['kind']))$q->where('v.kind',$d['kind']);$p=$q->orderByDesc('v.id')->paginate((int)($d['per_page']??25),['v.*'],'page',(int)($d['page']??1));$outputs=DB::table('sd_outputs')->where($s)->whereIn('document_key',$p->pluck('document_key')->all())->get(['id','document_key','number','pdf_sha256'])->keyBy('document_key');foreach($p->items() as $row){$row->data=json_decode($row->data,true);$row->output=$outputs->get($row->document_key);}if(DB::getSchemaBuilder()->hasTable('sd_cancellations')){$events=DB::table('sd_cancellations')->where($s)->whereIn('output_id',$outputs->pluck('id')->all())->orderBy('version')->get()->groupBy('output_id');foreach($p->items() as $row)if($row->output)$row->output->cancellations=($events->get($row->output->id)??collect())->all();}$caps=[];foreach(['prepare','review','issue','credit_apply'] as $right)$caps[$right]=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_documents.'.$right,$s['unit_id']);if(($d['kind']??null)==='invoice_receipt')foreach(['prepare','allocate'] as $right)$caps['prepare']=$caps['prepare']&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_receivables.'.$right,$s['unit_id']);if(($d['kind']??null)==='credit_note')$caps['prepare']=$caps['prepare']&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_receivables.correction_request',$s['unit_id']);$caps['cancel_prepare']=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_documents.prepare',$s['unit_id'])&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_receivables.correction_request',$s['unit_id']);$caps['cancel_review']=$caps['review']&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_receivables.correction_approve',$s['unit_id']);return ['documents'=>$p,'notice'=>ServiceInvoicing::NOTICE,'inputs'=>$this->inputs(),'capabilities'=>$caps,'actor_id'=>$actor];
|
||||
}
|
||||
public function pdf(int $actor,int $id):object{$s=$this->scope($actor);$row=DB::table('sd_outputs')->where($s)->where('id',$id)->first();abort_unless($row,403);$this->invoice($s,$row->invoice_id);abort_unless(hash_equals($row->pdf_sha256,hash('sha256',base64_decode($row->pdf_base64,true))),409,'Integridade documental incompatível.');return $row;}
|
||||
private function cancel(int $actor,array $s,array $d,array $b,string $key):array
|
||||
{
|
||||
$o=$b['origin'];$last=$o['last'];abort_if(($last['state']??null)==='cancelled',409,'Documento já anulado tecnicamente.');$target=$o['effect'];abort_if(DB::table('sr_effects')->where('target_effect_id',$target['id'])->exists(),409,'O efeito já foi revertido; exige reconciliação própria.');$sr=app(ServiceReceivables::class);
|
||||
if($d['action']==='cancel_prepare'){
|
||||
$record=$sr->propose($actor,['kind'=>'correction','invoice_id'=>$o['output']['invoice_id'],'obligation_id'=>$target['obligation_id'],'currency'=>'MZN','source'=>'SIM-SD-ANULACAO','external_id'=>'SD-CANCEL-'.$key,'revision'=>$o['revision'],'action'=>$b['kind']==='credit_note'?'reverse_reduction':'reverse_allocation','target_effect_id'=>$target['id'],'reason'=>$d['reason']],$key);$correction=$record['id'];$effect=null;$state='prepared';
|
||||
}else{
|
||||
abort_unless($last&&$last['state']==='prepared'&&$last['actor_id']!==$actor,403,'Revisão distinta da preparação da anulação necessária.');$correction=$last['correction_id'];$r=DB::table('sr_records')->find($correction);abort_unless((json_decode($r->data,true)['revision']??null)===$o['revision'],409,'O recebível mudou. O preparador deve renovar a conferência da anulação.');
|
||||
if($b['kind']==='credit_note')$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.credit_apply',$s['unit_id']);$result=$sr->decide($correction,$actor,['version'=>1,'revision'=>$o['revision'],'decision'=>'approved','reason'=>$d['reason']],$key);$effect=$result['id'];$state='cancelled';
|
||||
}
|
||||
$id=DB::table('sd_cancellations')->insertGetId($s+['cancellation_key'=>$d['document_key'],'version'=>($last['version']??0)+1,'output_id'=>$o['output']['id'],'state'=>$state,'actor_id'=>$actor,'correction_id'=>$correction,'effect_id'=>$effect,'snapshot'=>$this->json(['target_effect_id'=>$target['id'],'output_sha256'=>$o['output']['pdf_sha256'],'revision'=>$o['revision'],'period'=>$o['period'],'invoice_preserved'=>true,'money_refunded'=>false]),'reason'=>$d['reason'],'created_at'=>now()]);
|
||||
return ['id'=>$id,'document_key'=>$d['document_key'],'version'=>$d['version'],'cancellation_version'=>($last['version']??0)+1,'state'=>$state,'effect_id'=>$effect,'impact'=>['kind'=>$b['kind'],'action'=>$d['action'],'state'=>$state,'amount'=>$target['amount'],'balance_increase_on_confirmation'=>$target['amount'],'balance_before'=>$o['position']['balance'],'balance_after_confirmation'=>(string)\Brick\Math\BigDecimal::of($o['position']['balance'])->plus($target['amount'])->toScale(2),'invoice_preserved'=>true,'physical_change'=>false,'money_refunded'=>false,'financial_reduction'=>false,'atomic_invoice_receipt'=>false]];
|
||||
}
|
||||
public function options(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=Validator::make($input,['view'=>'required|in:invoices,allocations','invoice_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'])->validate();abort_if(array_diff(array_keys($input),array_keys($d)),422);
|
||||
$invoices=ModuleRecords::query('si_invoices')->where($s)->whereIn('draft_id',array_column($this->inputs(),'draft_id'));
|
||||
if($d['view']==='invoices')$q=$invoices->select('id','series_code','number','draft_id');else{
|
||||
$invoice=$this->invoice($s,$d['invoice_id']??0);$q=DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(['r.tenant_id'=>$s['tenant_id'],'r.company_id'=>$s['company_id'],'r.unit_id'=>$s['unit_id'],'r.invoice_id'=>$invoice->id,'e.kind'=>'allocate'])->whereNotExists(fn($v)=>$v->selectRaw('1')->from('sr_effects as rev')->whereColumn('rev.target_effect_id','e.id'))->select('e.id','e.amount','r.invoice_id','e.created_at');
|
||||
}return ['records'=>$q->orderBy('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1))];
|
||||
}
|
||||
public function sourceInvoices(int $actor){$s=$this->scope($actor);return ModuleRecords::query('si_invoices')->where($s)->whereIn('draft_id',array_column($this->inputs(),'draft_id'));}
|
||||
}
|
||||
@@ -55,9 +55,9 @@ final class ServiceFiscal
|
||||
public function listing(int $actor): array
|
||||
{
|
||||
$s=$this->scope($actor);
|
||||
if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return ['migration_pending'=>true,'proposals'=>[]];
|
||||
if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return ['migration_pending'=>true,'proposals'=>[],'coverage'=>FiscalCoverage::describe()];
|
||||
$rows=DB::table('sf_proposals')->where($s)->orderByDesc('id')->get()->map(function($r) { return (array)$r+['definition'=>$this->data($r),'decision'=>$this->decision($r->id),'active'=>$r->kind==='profile' && $this->activated($r)]; })->all();
|
||||
return ['proposals'=>$rows,'migration_pending'=>false,'notice'=>'RASCUNHO — SEM VALIDADE FISCAL; aprovação não activa o perfil.'];
|
||||
return ['coverage'=>FiscalCoverage::describe(),'proposals'=>$rows,'migration_pending'=>false,'notice'=>'RASCUNHO — SEM VALIDADE FISCAL; aprovação não activa o perfil.'];
|
||||
}
|
||||
public function propose(int $actor,array $input): array
|
||||
{
|
||||
|
||||
@@ -107,6 +107,12 @@ final class ServiceInvoicing
|
||||
{
|
||||
return $this->command($actor,'service_invoices.issue',fn($s)=>$this->reviewInside($draft,$series,$rule,$actor,$s));
|
||||
}
|
||||
/** Readiness for a staged document; issuance still requires the original issue right. */
|
||||
public function reviewDocumentIntent(int $draft,int $series,int $rule,int $actor):array
|
||||
{
|
||||
abort_unless(config('service_documents.enabled')===true,404);
|
||||
return $this->command($actor,'service_documents.view',fn($s)=>$this->reviewInside($draft,$series,$rule,$actor,$s));
|
||||
}
|
||||
private function reviewInside(int $draft,int $series,int $rule,int $actor,array $s): array
|
||||
{
|
||||
$view=$this->preview->captureDraftForIssuance($draft,$actor); $date=now(self::ZONE)->format('Y-m-d'); $blocks=[];
|
||||
|
||||
@@ -85,7 +85,7 @@ final class ServiceReceivables
|
||||
public function propose(int $actor,array $input,string $key): array
|
||||
{
|
||||
self::guard();
|
||||
$d=$this->validate($input,['kind'=>'required|in:obligation,receipt,observation,correction','invoice_id'=>'required|integer|min:1','obligation_id'=>'nullable|integer|min:1','receipt_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','amount'=>'nullable|string','source'=>'required|string|min:3|max:80','external_id'=>'required|string|min:3|max:100','date'=>'nullable|date_format:Y-m-d','evidence'=>'nullable|string|min:3|max:1000','due_date'=>'nullable|date_format:Y-m-d','reason'=>'nullable|string|min:3|max:1000','action'=>'nullable|in:reverse_allocation,reverse_reconcile,reverse_receipt,reduction,due','target_effect_id'=>'nullable|integer|min:1','document_ref'=>'nullable|string|max:100','line'=>'nullable|in:1','revision'=>'required|integer|min:0']);
|
||||
$d=$this->validate($input,['kind'=>'required|in:obligation,receipt,observation,correction','invoice_id'=>'required|integer|min:1','obligation_id'=>'nullable|integer|min:1','receipt_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','amount'=>'nullable|string','source'=>'required|string|min:3|max:80','external_id'=>'required|string|min:3|max:100','date'=>'nullable|date_format:Y-m-d','evidence'=>'nullable|string|min:3|max:1000','due_date'=>'nullable|date_format:Y-m-d','reason'=>'nullable|string|min:3|max:1000','action'=>'nullable|in:reverse_allocation,reverse_reconcile,reverse_receipt,reduction,reverse_reduction,due','target_effect_id'=>'nullable|integer|min:1','document_ref'=>'nullable|string|max:100','line'=>'nullable|in:1','revision'=>'required|integer|min:0']);
|
||||
$kind=$d['kind'];
|
||||
$right=$kind==='correction'?'correction_request':'prepare';
|
||||
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation');
|
||||
@@ -121,10 +121,10 @@ final class ServiceReceivables
|
||||
abort_unless(isset($d['amount'],$d['document_ref'],$d['line']) && str_starts_with($d['document_ref'],'TEST-REDUCTION-'),422,'Documento redutor sintético e linha necessários.');
|
||||
$amount=$d['amount'];
|
||||
}
|
||||
if(in_array($d['action'],['reverse_allocation','reverse_reconcile'])) {
|
||||
if(in_array($d['action'],['reverse_allocation','reverse_reconcile','reverse_reduction'])) {
|
||||
$effect=DB::table('sr_effects')->where('obligation_id',$ob->id)->where('id',$d['target_effect_id']??0)->first();
|
||||
abort_unless($effect && $effect->kind===($d['action']==='reverse_allocation'?'allocate':'reconcile'),422,'Alvo de reversão incompatível.');
|
||||
$receipt=$this->row($effect->receipt_id,$s,'receipt'); $amount=$effect->amount;
|
||||
abort_unless($effect && $effect->kind===(match($d['action']){'reverse_allocation'=>'allocate','reverse_reduction'=>'reduction',default=>'reconcile'}),422,'Alvo de reversão incompatível.');
|
||||
if($d['action']==='reverse_reduction'){abort_unless(config('service_documents.enabled')===true&&DB::table('sd_outputs')->where($s)->where(['kind'=>'credit_note','effect_id'=>$effect->id])->exists(),403);DocumentPeriod::open($s);}else $receipt=$this->row($effect->receipt_id,$s,'receipt'); $amount=$effect->amount;
|
||||
}
|
||||
if($d['action']==='reverse_receipt') { abort_unless($receipt,422); $amount=$receipt->amount; }
|
||||
}
|
||||
@@ -153,7 +153,7 @@ final class ServiceReceivables
|
||||
if($r->kind==='correction') {
|
||||
abort_unless(($rd['revision']??-1)===$d['revision'],409,'Proposta ligada a revisão anterior; prepare nova proposta.');
|
||||
$kind=$rd['action']; $amount=$r->amount; $receipt=$r->receipt_id; $target=$rd['target_effect_id']??null;
|
||||
if($kind==='reduction' || $kind==='due') $receipt=null;
|
||||
if($kind==='reduction' || $kind==='due' || $kind==='reverse_reduction') $receipt=null;if($kind==='reverse_reduction'){DocumentPeriod::open($s);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.credit_apply',$s['unit_id']);}
|
||||
}
|
||||
} elseif($r->kind==='observation') abort(422,'A conferência regista correspondência ou divergência; não é aprovação de recibo.');
|
||||
return $this->effect($id,$ob,$receipt,$kind,$amount,$target,$d['revision'],$actor,$d+['due_date'=>$rd['due_date']??null]);
|
||||
@@ -188,6 +188,36 @@ final class ServiceReceivables
|
||||
return $this->effect($id,$r->obligation_id,$id,'allocate',$d['amount'],null,$d['revision'],$actor,[]);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* One sealed QA invoice-receipt, using the original record/effect writers.
|
||||
* prepared_by is the actual prior declarant; actor_id on effects is the real executor.
|
||||
* No guard/context impersonation and no general delegation of prepare/allocate rights.
|
||||
*/
|
||||
public function completeDocument(int $versionId,int $invoiceId,int $actor,string $key):array
|
||||
{
|
||||
self::guard();abort_unless(config('service_documents.enabled')===true&&config('service_documents.invoice_receipt_enabled')===true&&DB::transactionLevel()>0,403);
|
||||
$s=$this->scope($actor,'confirm');foreach(['view','issue'] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.'.$r,$s['unit_id']);
|
||||
$v=DB::table('sd_versions')->where($s)->where(['id'=>$versionId,'kind'=>'invoice_receipt','state'=>'approved'])->first();abort_unless($v&&$v->prepared_by!==$actor,403,'Intenção financeira distinta e revista necessária.');
|
||||
$latest=DB::table('sd_versions')->where($s)->where('document_key',$v->document_key)->max('version');abort_unless($latest===$v->version,409);
|
||||
$intent=json_decode($v->data,true,32,JSON_THROW_ON_ERROR);$preparer=(int)$v->prepared_by;
|
||||
abort_unless(DB::table('users')->where('id',$preparer)->lockForUpdate()->exists()&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$preparer])->lockForUpdate()->exists(),403);
|
||||
DB::table('permission_grants')->where($s)->where('user_id',$preparer)->orderBy('id')->lockForUpdate()->get();
|
||||
foreach(['service_documents.prepare','service_receivables.view','service_receivables.prepare','service_receivables.allocate'] as $right)$this->permissions->require($preparer,$s['tenant_id'],$s['company_id'],$right,$s['unit_id']);
|
||||
$inv=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where(['id'=>$invoiceId,'draft_id'=>$intent['draft_id'],'series_id'=>$intent['series_id'],'rule_id'=>$intent['rule_id'],'actor_id'=>$actor])->first();abort_unless($inv,403,'A emissão deve pertencer à origem selada e ao executor actual.');
|
||||
$snap=json_decode($inv->snapshot,true,64,JSON_THROW_ON_ERROR);abort_unless(($snap['simulation_only']??null)===true&&($snap['notice']??null)===self::NOTICE&&($snap['calculation']['total_document_proposed']??null)==='1000.00'&&($snap['calculation']['currency']??null)==='MZN',422);
|
||||
abort_if(DB::table('sr_records')->where('invoice_id',$invoiceId)->exists()||DB::table('sd_outputs')->where('invoice_id',$invoiceId)->exists(),409,'Origem já utilizada.');
|
||||
$history=DB::table('a_history')->where($s)->where(['area'=>'billing_drafts','record_id'=>$inv->draft_id,'version'=>$snap['document']['draft_version']])->value('snapshot');abort_unless($history,409);$client=(int)json_decode($history,true)['client_id'];
|
||||
$source='SIM-FACTURA-RECIBO';$external='SD-'.$v->document_key;$reason='Conclusão atómica da intenção financeira QA revista: '.$v->document_key;
|
||||
$ob=$this->record($s,$preparer,'obligation',$inv,$client,null,null,'1000.00',$source,$external.'-OB',['due_date'=>null,'document_intent_id'=>$v->id]);$obId=$ob['id'];
|
||||
$this->effect($obId,$obId,null,'open','1000.00',null,0,$actor,['reason'=>$reason,'document_intent_id'=>$v->id]);
|
||||
$rc=$this->record($s,$preparer,'receipt',$inv,$client,$obId,null,'1000.00',$source,$external.'-RC',['date'=>$intent['payment_date'],'evidence'=>$intent['evidence'],'document_intent_id'=>$v->id]);
|
||||
$this->effect($rc['id'],$obId,null,'receipt_confirm','1000.00',null,1,$actor,['reason'=>$reason,'document_intent_id'=>$v->id]);
|
||||
$allocation=$this->effect($rc['id'],$obId,$rc['id'],'allocate','1000.00',null,2,$actor,['reason'=>$reason,'document_intent_id'=>$v->id,'aggregate_authority'=>'service_documents.issue']);
|
||||
$result=['id'=>$obId,'obligation_id'=>$obId,'receipt_id'=>$rc['id'],'allocation_id'=>$allocation['id'],'event'=>'invoice_receipt.simulation','balance'=>'0.00'];
|
||||
$command=DB::table('sr_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>'document_issue','command_key'=>$key,'request_hash'=>hash('sha256',$this->json([$v->id,$invoiceId,$intent])),'result'=>$this->json($result),'created_at'=>now()]);
|
||||
$event=DB::table('sr_events')->insertGetId(['command_id'=>$command,'type'=>'invoice_receipt.simulation','schema_version'=>1,'payload'=>$this->json($s+['result'=>$result,'prepared_by'=>$preparer,'executed_by'=>$actor,'document_intent_id'=>$v->id]),'created_at'=>now()]);DB::table('sr_outbox')->insert(['event_id'=>$event,'state'=>'captured_simulation_only']);
|
||||
Audit::record('service_receivables.document_issue',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['command_id'=>$command,'prepared_by'=>$preparer,'document_intent_id'=>$v->id,'simulation_only'=>true]);return $result;
|
||||
}
|
||||
private function state(int $ob,?string $cut=null): array
|
||||
{
|
||||
$r=DB::table('sr_records')->find($ob); $rd=$this->data($r);
|
||||
@@ -204,7 +234,7 @@ final class ServiceReceivables
|
||||
$x=BigDecimal::of($e->amount)->multipliedBy($e->kind==='allocate'?1:-1); $allocated=$allocated->plus($x);
|
||||
$rc=&$receipts[$e->receipt_id]; $rc['allocated']=(string)BigDecimal::of($rc['allocated'])->plus($x)->toScale(2); $rc['available']=(string)BigDecimal::of($rc['available'])->minus($x)->toScale(2); unset($rc);
|
||||
}
|
||||
if($e->kind==='reduction') $reduced=$reduced->plus($e->amount);
|
||||
if($e->kind==='reduction') $reduced=$reduced->plus($e->amount);if($e->kind==='reverse_reduction') $reduced=$reduced->minus($e->amount);
|
||||
if($e->kind==='reconcile') $receipts[$e->receipt_id]['reconciled']=true;
|
||||
if($e->kind==='reverse_reconcile') $receipts[$e->receipt_id]['reconciled']=false;
|
||||
if($e->kind==='reverse_receipt') { $receipts[$e->receipt_id]['state']='reversed'; $receipts[$e->receipt_id]['available']='0.00'; }
|
||||
|
||||
@@ -89,6 +89,7 @@ final class ServiceWorkflow
|
||||
$old = $id ? $this->query($area, $scope)->where('id', $id)->first() : null;
|
||||
if ($id) {
|
||||
abort_unless($old, 403, 'Registo fora do âmbito autorizado.');
|
||||
if(in_array($area,['service_drafts','billing_drafts'],true)&&\Illuminate\Support\Facades\Schema::hasTable('cq_conversions')){$column=$area==='service_drafts'?'service_draft_id':'billing_draft_id';abort_if(DB::table('cq_conversions')->where($column,$id)->exists(),409,'Rascunho de orçamento convertido: preserve a proposta aceite; alterações exigem correcção própria.');}
|
||||
if (in_array($area, ['service_drafts','billing_drafts'], true) && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
|
||||
$column = $area === 'service_drafts' ? 'service_draft_id' : 'billing_draft_id';
|
||||
abort_if(\App\Modules\Workshop\WorkshopCommercialState::linked($area, $id),409,'Rascunho ligado a oficina: correcção comercial própria necessária; a proposta revista não pode ser alterada por edição directa.');
|
||||
|
||||
Reference in New Issue
Block a user