Files
SGF/app/Modules/Purchasing/PayableDueController.php
2026-11-11 09:00:17 +02:00

65 lines
12 KiB
PHP

<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use App\Shared\Context\CompanyContext;
use App\Shared\Installation\CompositionRuntime;
use Tempo\Purchasing\Embedded\Services\RequisitionWorkflow;
final class PayableDueController
{
public const POLICY='SGF-PAYABLE-DUE-QA-v1';
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
private function exact(array $v,array $rules):array{abort_if(array_diff(array_keys($v),array_keys($rules)),422);return Validator::make($v,$rules)->validate();}
private function run(Request $r,string $action,\Closure $fn):mixed
{
abort_unless(config('payable_due.enabled')===true,404);$physical=CompositionRuntime::physical();$host=DB::connection();$user=(int)$r->user()->id;$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$generation=$this->hash($r->session()->get('company_context',[]));
return app(ComposedFinance::class)->run('view',function($engine,$settlements,$actor,$origin,$orders)use($action,$fn,$host,$user,$scope,$generation,$physical){
$meta=DB::table('pd_due_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&$meta->composition===$physical['composition']&&hash_equals($meta->source_hash,(string)config('payable_due.schema_sha256')),503,'Schema de vencimentos não conferido.');
if($action!=='view'){$expected=$action==='prepare'?13:14;$profile=$action==='prepare'?'finance_prepare':'finance_review';abort_unless($user===$expected&&$origin['profile']===$profile&&$host->table('permission_grants')->where($scope+['user_id'=>$user,'permission'=>'supplier_payables.due.'.$action])->lockForUpdate()->first(),403);}
$person=DB::table('pa_access')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$actor->id,'action'=>'view'])->lockForUpdate()->value('person');abort_unless(is_string($person)&&$person!=='',403);
return $fn($engine,$actor,$origin,$orders,$scope,$user,$generation,$this->hash([$physical,$origin['sgf_context_key'],$scope]));
});
}
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','obligation_id'=>'required|integer|min:1','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:2000']);$d['obligation_id']=(int)$d['obligation_id'];$d['version']=(int)$d['version'];
if($d['action']==='prepare'){$data=$d['data'];if(isset($data['evidence'])&&is_string($data['evidence']))$data['evidence']=trim($data['evidence']);$d['data']=$this->exact($data,['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:1000']);}
else $d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;
}
private function basis($engine,$actor,$orders,array $scope,int $id):array
{
$source=DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->join('invoices as i','i.id','=','s.local_source_id')->where(['o.id'=>$id,'o.tenant_id'=>$actor->tenant_id,'o.company_id'=>0,'o.unit_id'=>0,'s.tenant_id'=>$actor->tenant_id,'s.company_id'=>0,'s.unit_id'=>0,'i.tenant_id'=>$actor->tenant_id])->whereIn('i.purchase_order_id',$orders->select('id'))->first(['i.id']);abort_unless($source,403,'Obrigação fora da origem autorizada.');
$identities=DB::table('pa_sources')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'local_source_id'=>$source->id])->count();abort_unless($identities===1,409,'Identidade financeira ambígua.');
$book=$engine->positionsPage([(int)$source->id]);$position=collect($book['obligations'])->firstWhere('id',$id);$document=collect($book['documents'])->firstWhere('local_id',(int)$source->id);abort_unless($position&&$document&&!$position['source_conflict']&&$position['balance_minor']!==null,409,'Origem ou saldo exige nova conferência.');
$q=DB::table('pd_due_versions')->where($scope+['obligation_id'=>$id]);$last=(clone$q)->orderByDesc('version')->first();$approved=(clone$q)->where('state','approved')->orderByDesc('version')->first();
return ['current_person'=>DB::table('pa_access')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$actor->id,'action'=>'view'])->value('person'),'version'=>(int)($last?->version??0),'last'=>$last?(array)$last:null,'approved'=>$approved?(array)$approved:null,'position'=>$position,'document'=>$document,'source_basis'=>$this->hash([$position,$document])];
}
private function execute($actor,array $scope,int $host,array $d,array $before):array
{
abort_unless($before['version']===$d['version'],409,'A versão mudou; confira novamente.');$last=$before['last'];$person=DB::table('pa_access')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$actor->id,'action'=>'view'])->value('person');
if($d['action']==='prepare'){$state='prepared';$data=$d['data']+['_prepared_person'=>$person];$prepared=$host;$native=$actor->id;}
else{abort_unless($last&&$last['state']==='prepared'&&(int)$last['prepared_host_id']!==$host&&(int)$last['prepared_native_id']!==$actor->id,403,'Revisão exige preparação alheia actual.');abort_unless(hash_equals($last['source_basis'],$before['source_basis']),409,'Origem ou saldo alterado desde a preparação; prepare novamente.');$state=$d['data']['decision'];$data=json_decode($last['data'],true);$preparedPerson=DB::table('pa_access')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$last['prepared_native_id'],'action'=>'view'])->lockForUpdate()->value('person');abort_unless(isset($data['_prepared_person'])&&$preparedPerson===$data['_prepared_person'],409,'Identidade do preparador alterada; prepare novamente.');abort_unless($person!==$preparedPerson,403,'A mesma pessoa não pode rever a preparação.');$prepared=$last['prepared_host_id'];$native=$last['prepared_native_id'];}
$version=$before['version']+1;$id=DB::table('pd_due_versions')->insertGetId($scope+['obligation_id'=>$d['obligation_id'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'source_basis'=>$before['source_basis'],'prepared_host_id'=>$prepared,'prepared_native_id'=>$native,'host_actor_id'=>$host,'actor_id'=>$actor->id,'data'=>json_encode($data,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],'created_at'=>now()->utc()]);
$prior=$before['approved']?json_decode($before['approved']['data'],true):null;$impact=['before'=>['version'=>$before['version'],'state'=>$last['state']??null,'reviewed_terms'=>$prior],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'reviewed_terms'=>$state==='approved'?$data:$prior],'source_basis'=>$before['source_basis'],'principal_minor'=>(string)$before['position']['principal_minor'],'balance_minor'=>(string)$before['position']['balance_minor'],'currency'=>$before['position']['currency'],'original_due_on'=>$before['position']['due_on'],'payment_created'=>false,'journal_entries'=>0,'original_obligation_changed'=>false];
return ['id'=>$id,'obligation_id'=>$d['obligation_id'],'version'=>$version,'state'=>$state,'impact'=>$impact];
}
public function preview(Request $r)
{
$d=$this->input($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$d['action'],function($engine,$a,$origin,$orders,$scope,$host,$generation,$binding)use($d,$key){$before=$this->basis($engine,$a,$orders,$scope,$d['obligation_id']);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($a,$scope,$host,$d,$before);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$host,'generation'=>$generation,'binding'=>$binding,'input'=>$d,'key'=>$key,'observed'=>time(),'basis'=>$this->hash($before),'impact'=>$out['impact']];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']])->header('Cache-Control','no-store');});
}
public function confirm(Request $r)
{
$v=$this->exact($r->all(),['preview'=>'required|string|max:65536']);try{$cut=json_decode(Crypt::decryptString($v['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($cut)&&is_array($cut['input']??null),409);$d=$this->input($cut['input']);return$this->run($r,$d['action'],function($engine,$a,$origin,$orders,$scope,$host,$generation,$binding)use($cut,$d){abort_unless(($cut['actor']??null)===$host&&($cut['generation']??null)===$generation&&($cut['binding']??null)===$binding&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Prévia/contexto alterados.');$before=$this->basis($engine,$a,$orders,$scope,$d['obligation_id']);$command=$scope+['host_actor_id'=>$host,'command_key'=>$cut['key']];$old=DB::table('pd_due_commands')->where($command)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d))&&hash_equals($old->binding_hash,$binding),409,'Chave utilizada com outros dados.');return response()->json(json_decode($old->result,true)+['replayed'=>true])->header('Cache-Control','no-store');}abort_unless(hash_equals($cut['basis'],$this->hash($before)),409,'Estado alterado desde a prévia. Confira novamente.');$out=$this->execute($a,$scope,$host,$d,$before);abort_unless($out['impact']===$cut['impact'],409,'Impacto alterado.');app(RequisitionWorkflow::class)->audit($a,'sgf.finance.due.command',null,$before,['host_actor_id'=>$host,'operation_key'=>$cut['key'],'reason'=>$d['reason'],'after'=>$out]);DB::table('pd_due_commands')->insert($command+['actor_id'=>$a->id,'request_hash'=>$this->hash($d),'binding_hash'=>$binding,'input'=>json_encode($d,JSON_THROW_ON_ERROR),'result'=>json_encode($out,JSON_THROW_ON_ERROR),'created_at'=>now()->utc()]);return response()->json($out+['replayed'=>false],201)->header('Cache-Control','no-store');});
}
public function receipt(Request $r,string $key)
{
abort_if($r->query()!==[],422);$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,'view',function($engine,$a,$origin,$orders,$scope,$host,$generation,$binding)use($key){$old=DB::table('pd_due_commands')->where($scope+['host_actor_id'=>$host,'command_key'=>$key])->first();abort_unless($old,404);abort_unless(hash_equals($old->binding_hash,$binding),409);$d=json_decode($old->input,true);$this->basis($engine,$a,$orders,$scope,$d['obligation_id']);return response()->json(['result'=>json_decode($old->result,true),'reason'=>$d['reason'],'created_at'=>$old->created_at,'historical_receipt_only'=>true,'operation_executed'=>false])->header('Cache-Control','no-store');});
}
public function index(Request $r)
{
$d=$this->exact($r->query(),['obligation_id'=>'required|integer|min:1','reference_on'=>'required|date_format:Y-m-d','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$hostDb=DB::connection();return$this->run($r,'view',function($engine,$a,$origin,$orders,$scope,$host)use($d,$hostDb){$before=$this->basis($engine,$a,$orders,$scope,(int)$d['obligation_id']);$approved=$before['approved'];$terms=$approved?json_decode($approved['data'],true):null;$date=$terms['due_on']??null;$days=$date===null?null:max(0,(int)(new \DateTimeImmutable($date,new \DateTimeZone('UTC')))->diff(new \DateTimeImmutable($d['reference_on'],new \DateTimeZone('UTC')))->format('%r%a'));$q=DB::table('pd_due_versions')->where($scope+['obligation_id'=>(int)$d['obligation_id']]);$history=$q->orderByDesc('version')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));foreach($history->items()as$row)$row->data=json_decode($row->data,true);$cap=fn($action,$expected)=>$host===$expected&&$hostDb->table('permission_grants')->where($scope+['user_id'=>$host,'permission'=>'supplier_payables.due.'.$action])->exists();return response()->json(['basis'=>$before,'reviewed_terms'=>$terms,'reference_on'=>$d['reference_on'],'days_overdue'=>$days,'due_status'=>$date===null?'unknown':($date<$d['reference_on']?'overdue':'not_overdue'),'history'=>$history,'capabilities'=>['prepare'=>$cap('prepare',13),'review'=>$cap('review',14)],'notice'=>'Condição administrativa QA revista, separada do vencimento original. Saldo actual; referência apenas classifica atraso, sem juros ou pagamento.'])->header('Cache-Control','no-store');});
}
}