commit melhorias sgf
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Embedded\Models\PurchaseOrder;
|
||||
use Tempo\Purchasing\Embedded\Services\SupplierPayables;
|
||||
|
||||
/** Financial effects remain in the original authoritative obligation book. */
|
||||
final class ComposedFinance
|
||||
{
|
||||
private const ACTIONS=['view'=>['finance_prepare','finance_review','read_only'],'payment-prepare'=>['finance_prepare'],'allocation-prepare'=>['finance_prepare'],'reversal-prepare'=>['finance_prepare'],'payment-decide'=>['finance_review'],'allocation-decide'=>['finance_review'],'reversal-confirm'=>['finance_review']];
|
||||
public function run(string $action,\Closure $work):mixed
|
||||
{
|
||||
CompositionRuntime::assert();abort_unless(config('sgf_single_installation.finance_enabled')===true,404);abort_unless(isset(self::ACTIONS[$action]),404);
|
||||
abort_unless(request()->session()->get('company_context.module_id')==='finance',409,'Seleccione Financeiro.');
|
||||
$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$host=DB::connection();$user=$ctx->userId();
|
||||
try{return $host->transaction(function()use($action,$work,$scope,$host,$user){
|
||||
$host->statement("SET LOCAL lock_timeout='5s'");$host->table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$profile=$host->table('sgf_presentation_profiles')->where($scope)->where('user_id',$user)->first();abort_unless($profile&&in_array($profile->profile_key,self::ACTIONS[$action],true),403);
|
||||
return app(ReceiptRuntime::class)->run('supplier_payables.view',function($actor,$binding,$revision,$origin)use($action,$work,$scope,$host,$user,$profile){
|
||||
$r=DB::table('gl_sgf_composition')->where('tenant_id',$actor->tenant_id)->first();abort_unless($r&&$r->host_database===$host->getDatabaseName()&&(int)$r->company_id===$scope['company_id']&&(int)$r->unit_id===$scope['unit_id'],403);
|
||||
$orders=PurchaseOrder::visibleTo($actor)->where(fn($q)=>$q->whereIn('id',$binding['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)));
|
||||
$unknown=DB::table('pa_sources as s')->join('pa_obligations as o','o.source_id','=','s.id')->join('invoices as i','i.id','=','s.local_source_id')->where(['s.tenant_id'=>$actor->tenant_id,'s.company_id'=>0,'s.unit_id'=>0])->whereNotIn('i.purchase_order_id',$orders->select('id'))->exists();
|
||||
abort_if($unknown,503,'Há origens financeiras sem unidade conferida; a partição tem de ser resolvida explicitamente.');
|
||||
$service=app(SupplierPayables::class);$service->readContext($actor);$engine=$service->engine($actor);
|
||||
return $work($engine,$engine->settlements(),$actor,$origin+['sgf_host_user'=>$user,'sgf_host_scope'=>$scope,'profile'=>$profile->profile_key],$orders);
|
||||
});
|
||||
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Livro ocupado. Recupere a mesma operação, conservando a chave e os dados.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Invoice,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\RequisitionWorkflow;
|
||||
|
||||
final class ComposedFinanceController
|
||||
{
|
||||
public function index(Request $r,ComposedFinance $finance)
|
||||
{
|
||||
$d=$r->validate(['document_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','payments_page'=>'sometimes|integer|min:1|max:100000','allocation_proposals_page'=>'sometimes|integer|min:1|max:100000','allocations_page'=>'sometimes|integer|min:1|max:100000','payment_events_page'=>'sometimes|integer|min:1|max:100000','payment_per_page'=>'sometimes|integer|in:25,100','payment_q'=>'sometimes|string|max:100','payment_state'=>'sometimes|in:all,proposed,confirmed,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $finance->run('view',function($engine,$settlements,$actor,$origin,$orders)use($r,$d){
|
||||
$q=Invoice::where('tenant_id',$actor->tenant_id)->whereIn('purchase_order_id',$orders->select('id'));if(isset($d['document_id']))$q->where('id',(int)$d['document_id']);$page=$q->orderBy('id')->paginate(25,['id','number'],'page',(int)($d['page']??1));if(isset($d['document_id']))abort_unless($page->total()===1,403,'Documento fora da origem financeira autorizada.');
|
||||
$base=$engine->positionsPage(array_column($page->items(),'id'));$money=$settlements->listingPage(array_diff_key($d,['page'=>true,'document_id'=>true]));
|
||||
$numbers=DB::table('pa_sources as s')->join('invoices as i','i.id','=','s.local_source_id')->where('s.tenant_id',$actor->tenant_id)->whereIn('s.id',array_column($base['obligations'],'source_id'))->pluck('i.number','s.id');
|
||||
foreach($base['obligations'] as &$o)$o['document_number']=$numbers->get($o['source_id']);unset($o);
|
||||
$data=['obligations'=>$base['obligations'],'money'=>$money,'profile'=>$origin['profile'],'pagination'=>['page'=>$page->currentPage(),'last_page'=>$page->lastPage()]];
|
||||
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
|
||||
return response()->view('finance.composed-payables',['data'=>$data,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
|
||||
public function command(string $action,Request $r,ComposedFinance $finance)
|
||||
{
|
||||
$key=strtolower((string)$r->header('Idempotency-Key'));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);$data=$r->all();abort_if(strlen(json_encode($data))>65536,422);
|
||||
try{return $finance->run($action,function($engine,$settlements,$actor,$origin)use($action,$key,$data){
|
||||
$hash=hash('sha256',Payables::canonical([$action,$data]));$old=AuditLog::where('tenant_id',$actor->tenant_id)->where('action','sgf.finance.command')->where('after->operation_key',$key)->first();
|
||||
if($old)abort_unless(($old->after['sgf_context_key']??null)===$origin['sgf_context_key']&&($old->after['request_hash']??null)===$hash,409,'Chave pertence a outro contexto/conteúdo.');
|
||||
$result=$settlements->command($action,$data,$key);
|
||||
if(!$old)app(RequisitionWorkflow::class)->audit($actor,'sgf.finance.command',null,null,$origin+['operation_key'=>$key,'request_hash'=>$hash,'command'=>$action,'result'=>$result,'reason'=>$data['reason']??'']);
|
||||
return response()->json($result)->header('Cache-Control','no-store');
|
||||
});}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(409,$e->getMessage());}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
/** Classification only; balances/terms remain in their existing authoritative readers. */
|
||||
final class PayableAge
|
||||
{
|
||||
public static function classify(array $row,?string $reference):array
|
||||
{
|
||||
$days=null;$bucket=null;
|
||||
if($reference!==null){
|
||||
if(!$row['obligation_id'])$bucket='unrecognised';
|
||||
elseif($row['source_conflict']||$row['balance_minor']===null)$bucket='indeterminate';
|
||||
elseif($row['balance_minor']==='0')$bucket='settled';
|
||||
elseif(($due=$row['reviewed_terms']['due_on']??null)===null)$bucket='unknown';
|
||||
else{$zone=new \DateTimeZone('UTC');$days=max(0,(int)(new \DateTimeImmutable($due,$zone))->diff(new \DateTimeImmutable($reference,$zone))->format('%r%a'));$bucket=$days===0?'not_due':($days<=30?'days_1_30':($days<=60?'days_31_60':($days<=90?'days_61_90':'days_over_90')));}
|
||||
}
|
||||
return ['reviewed_age_days'=>$days,'reviewed_age_bucket'=>$bucket];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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');});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Embedded\Models\Invoice;
|
||||
|
||||
/** Paged source window; positions come exclusively from the original payable engine. */
|
||||
final class PayablePositionReadController
|
||||
{
|
||||
private function reviewedTerms(array $scope,array $ids):array
|
||||
{
|
||||
if(config('payable_due.enabled')!==true||$ids===[])return [];
|
||||
$meta=DB::table('pd_due_meta')->where('policy',PayableDueController::POLICY)->first();abort_unless($meta&&DB::connection()->getDatabaseName()==='compras_receipts_test_'.$meta->composition&&hash_equals($meta->source_hash,(string)config('payable_due.schema_sha256')),503,'Condições administrativas não conferidas.');
|
||||
$q=DB::table('pd_due_versions')->where($scope)->whereIn('obligation_id',$ids)->where('state','approved')->whereNotExists(function($next){$next->selectRaw('1')->from('pd_due_versions as newer')->where('newer.state','approved');foreach(['tenant_id','company_id','unit_id','obligation_id']as$field)$next->whereColumn('newer.'.$field,'pd_due_versions.'.$field);$next->whereColumn('newer.version','>','pd_due_versions.version');});
|
||||
$out=[];foreach($q->get(['obligation_id','version','data','created_at'])as$row){$d=json_decode($row->data,true);$out[$row->obligation_id]=['version'=>(int)$row->version,'due_on'=>$d['due_on'],'evidence'=>$d['evidence'],'reviewed_at'=>$row->created_at];}return$out;
|
||||
}
|
||||
public function index(Request $r,ComposedFinance $finance)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100','document_state'=>'sometimes|in:pending,validated','reference_on'=>'sometimes|date_format:Y-m-d']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
try{return $finance->run('view',function($engine,$settlements,$actor,$origin,$orders)use($d){
|
||||
$q=Invoice::query()->where('invoices.tenant_id',$actor->tenant_id)->whereIn('invoices.purchase_order_id',$orders->select('id'))
|
||||
->join('purchase_orders as po',fn($j)=>$j->on('po.id','=','invoices.purchase_order_id')->on('po.tenant_id','=','invoices.tenant_id'))
|
||||
->leftJoin('suppliers as supplier',fn($j)=>$j->on('supplier.id','=','po.supplier_id')->on('supplier.tenant_id','=','po.tenant_id'));
|
||||
if(isset($d['document_state']))$q->where('invoices.status',$d['document_state']);
|
||||
if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($x)=>$x->whereRaw("invoices.number ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("supplier.name ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
$p=$q->orderByDesc('invoices.id')->paginate((int)($d['per_page']??25),['invoices.id'],'page',(int)($d['page']??1));$ids=$p->pluck('id')->map(fn($id)=>(int)$id)->all();$book=$engine->positionsPage($ids);$positions=collect($book['obligations'])->keyBy('source_id');$sources=DB::table('pa_sources')->where('tenant_id',$actor->tenant_id)->whereIn('local_source_id',$ids)->where(['company_id'=>0,'unit_id'=>0])->get(['id','local_source_id'])->groupBy('local_source_id');$documents=collect($book['documents'])->keyBy('local_id');$rows=[];
|
||||
foreach($ids as$id){$source=$documents->get($id);abort_unless($source,409,'Documento de origem alterado durante a consulta.');$identities=$sources->get($id,collect());abort_if($identities->count()>1,409,'Há mais de uma identidade financeira para este documento; exige conferência.');$position=$positions->get($identities->first()?->id);$money=[];foreach(['principal_minor','balance_minor','confirmed_payments_minor','allocated_payments_minor']as$field)$money[$field]=$position&&$position[$field]!==null?(string)$position[$field]:null;$rows[]=['document_id'=>$id,'reference'=>$source['number'],'supplier'=>$source['supplier_name'],'supplier_id'=>$source['supplier_id'],'document_state'=>$source['status'],'currency'=>$position['currency']??$source['currency'],'document_currency'=>$source['currency'],'obligation_id'=>$position['id']??null,'recognition_state'=>$position?'recognised':'not_recognised','payment_state'=>$position['payment_state']??'not_recognised','source_conflict'=>$position['source_conflict']??false,'source_revision'=>$source['revision'],'due_on'=>$position['due_on']??null,'due_status'=>$position['due_status']??'not_recognised','days_overdue'=>$position['days_overdue']??null,'pending'=>$position['documentary_pending']??$source['missing']]+$money;}
|
||||
$terms=$this->reviewedTerms($origin['sgf_host_scope'],array_values(array_filter(array_column($rows,'obligation_id'))));foreach($rows as&$row){$row['reviewed_terms']=$terms[$row['obligation_id']]??null;$row+=PayableAge::classify($row,$d['reference_on']??null);}unset($row);
|
||||
return response()->json(['positions'=>$rows,'aging_reference_on'=>$d['reference_on']??null,'aging_basis'=>'reviewed_administrative_terms_current_balance','pagination'=>['page'=>$p->currentPage(),'last_page'=>$p->lastPage(),'per_page'=>$p->perPage(),'total'=>$p->total()],'observed_at'=>now()->utc()->toIso8601String(),'balance_basis'=>'original_current_payable_engine','totals_basis'=>'visible_page_only','synthetic_only'=>true,'notice'=>'Documentos sem obrigação reconhecida e saldos indeterminados não são apresentados como dívida zero. Cada página é uma leitura actual; não é um relatório histórico.'])->header('Cache-Control','no-store, private');
|
||||
});}catch(\InvalidArgumentException$e){abort(422,$e->getMessage());}catch(\DomainException$e){abort(403,$e->getMessage());}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,16 @@ final class ReceiptRuntime
|
||||
public function run(string $right,\Closure $work):mixed
|
||||
{
|
||||
abort_unless(config('receipt_qa.enabled')===true,404);
|
||||
if(str_starts_with($right,'general_ledger.'))abort_unless(config('sgf_single_installation.enabled')===true&&config('sgf_single_installation.ledger_enabled')===true,404);
|
||||
if(str_starts_with($right,'supplier_payables.'))abort_unless(config('sgf_single_installation.enabled')===true&&config('sgf_single_installation.finance_enabled')===true,404);
|
||||
if(str_starts_with($right,'stock.')){
|
||||
abort_unless(config('stock_operations.enabled')===true,404);
|
||||
$allowed=['stock.view','stock.operations.prepare','stock.operations.confirm'];
|
||||
if(config('stock_operations.final_enabled')===true)$allowed=[...$allowed,'stock.transfers.receive','stock.lots.review'];
|
||||
if(config('stock_catalogue.enabled')===true)$allowed=[...$allowed,'stock.items.prepare','stock.items.review'];
|
||||
if(config('stock_completion.enabled')===true)$allowed=[...$allowed,'stock.planning.view','stock.planning.prepare','stock.planning.review','stock.disposition.view','stock.disposition.prepare','stock.disposition.review'];
|
||||
abort_unless(in_array($right,$allowed,true),403);
|
||||
}
|
||||
if(str_starts_with($right,'purchasing_obligations.'))abort_unless(config('receipt_qa.obligations_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_invoices.'))abort_unless(config('receipt_qa.invoices_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_requests.'))abort_unless(config('receipt_qa.requests_enabled')===true,404);
|
||||
@@ -41,7 +51,23 @@ final class ReceiptRuntime
|
||||
$p=json_decode(file_get_contents($profile),true,64,JSON_THROW_ON_ERROR);
|
||||
$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$user=$ctx->userId();
|
||||
abort_unless($scope['unit_id']&&request()->user()->id===$user,403);
|
||||
$key=implode(':',array_values($scope)).':'.$user;$binding=$p['bindings'][$key]??null;abort_unless($binding,403,'Origem de recepção não autorizada neste contexto.');
|
||||
$key=implode(':',array_values($scope)).':'.$user;$binding=$p['bindings'][$key]??null;
|
||||
if(config('sgf_single_installation.enabled')===true&&config('sgf_single_installation.bindings_sha256')!==null){
|
||||
$file=$expected.'/single-bindings.json';$pin=config('sgf_single_installation.bindings_sha256');
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);
|
||||
$additional=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
|
||||
abort_unless(($additional['composition']??null)===$m[1],403);
|
||||
if(isset($additional['bindings'][$key]))$binding=$additional['bindings'][$key];
|
||||
}
|
||||
abort_unless($binding,403,'Origem de recepção não autorizada neste contexto.');
|
||||
// A Stock-specific map is explicit and pinned; it cannot change purchasing bindings.
|
||||
if(str_starts_with($right,'stock.')&&config('stock_operations.profile_sha256')!==null){
|
||||
$mapFile=$expected.'/stock-operations-binding.json';$pin=config('stock_operations.profile_sha256');
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&is_file($mapFile)&&!is_link($mapFile)&&hash_equals($pin,hash_file('sha256',$mapFile)),503);
|
||||
$map=json_decode(file_get_contents($mapFile),true,32,JSON_THROW_ON_ERROR);$warehouses=$map['warehouse_bindings'][$key]??null;
|
||||
abort_unless(($map['composition']??null)===$m[1]&&is_array($warehouses)&&count($warehouses)>0&&count($warehouses)<=1000&&!array_filter($warehouses,fn($id)=>!is_int($id)||$id<1),403);
|
||||
$binding['warehouse_ids']=$warehouses;$binding['stock_profile_sha256']=$pin;
|
||||
}
|
||||
if(config('receipt_qa.obligations_enabled')===true)abort_unless(($binding['payable_instance']??null)==='qa-stock-ledger-'.$m[1]&&config('common-payables.source_instance')===$binding['payable_instance']&&config('common-payables.history_enabled')===false&&config('common-payables.history_decisions_enabled')===false,403);
|
||||
$p['context_key']=$key;
|
||||
$bundle=realpath($expected.'/bundle');$manifest=$bundle.'/manifest.json';
|
||||
@@ -69,11 +95,15 @@ final class ReceiptRuntime
|
||||
$i=$native->selectOne("SELECT current_user role,current_database() db,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$i->elevated&&$i->role===$connection['username']&&$i->db===$connection['database']&&$i->cluster==='sgf_modular_regression_'.$run,403);
|
||||
return $host->transaction(function()use($host,$scope,$user,$right,$binding,$work,$p,$native){
|
||||
if(str_starts_with($right,'stock.')){$host->statement("SET LOCAL lock_timeout='5s'");$host->statement("SET LOCAL statement_timeout='15s'");}
|
||||
// Hold existing authority until the native transaction ends; no host business writes.
|
||||
abort_unless($host->table('users')->where('id',$user)->lockForUpdate()->first(),403);
|
||||
abort_unless($host->table('company_memberships')->where(['user_id'=>$user,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id']])->lockForUpdate()->first(),403);
|
||||
$view=str_starts_with($right,'purchasing_invoices.')?'purchasing_invoices.view':(str_starts_with($right,'purchasing_requests.')?'purchasing_requests.view':(str_starts_with($right,'purchasing_suppliers.')?'purchasing_suppliers.view':(str_starts_with($right,'purchasing_sourcing.')?'purchasing_sourcing.view':(str_starts_with($right,'purchasing_orders.')?'purchasing_orders.view':'purchasing_receipts.view'))));
|
||||
if(str_starts_with($right,'purchasing_obligations.'))$view='purchasing_obligations.view';
|
||||
if(str_starts_with($right,'stock.'))$view='stock.view';
|
||||
if(str_starts_with($right,'general_ledger.'))$view='general_ledger.view';
|
||||
if(str_starts_with($right,'supplier_payables.'))$view='supplier_payables.view';
|
||||
foreach(['company.view'=>null,'unit.view'=>$scope['unit_id'],$view=>$scope['unit_id'],$right=>$scope['unit_id']]as$permission=>$unit)
|
||||
abort_unless($host->table('permission_grants')->where(['user_id'=>$user,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'unit_id'=>$unit,'permission'=>$permission])->lockForUpdate()->first(),403);
|
||||
$default=DB::getDefaultConnection();$guard=Auth::getDefaultDriver();$environment=app()->environment();
|
||||
@@ -81,7 +111,8 @@ final class ReceiptRuntime
|
||||
DB::setDefaultConnection('receipt_native');Schema::clearResolvedInstance('db.schema');Auth::shouldUse('receipt_native');app()->instance('env','testing');
|
||||
try{
|
||||
$actor=\Tempo\Purchasing\Embedded\Models\User::where('tenant_id',$binding['tenant_id'])->findOrFail($binding['actor_id']);Auth::guard('receipt_native')->setUser($actor);
|
||||
return $native->transaction(function()use($native,$actor,$binding,$work,$p){
|
||||
return $native->transaction(function()use($native,$actor,$binding,$work,$p,$right){
|
||||
if(str_starts_with($right,'stock.')){$native->statement("SET LOCAL lock_timeout='5s'");$native->statement("SET LOCAL statement_timeout='15s'");}
|
||||
$native->table('tenants')->where('id',$actor->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$version=[$p['context_key'],$binding,$p['manifest_sha256']];$origin=['sgf_context_key'=>$p['context_key'],'workflow_manifest'=>$p['manifest_sha256']];
|
||||
if(isset($p['internal_manifest_sha256'])){$version[]=$p['internal_manifest_sha256'];$origin['internal_manifest']=$p['internal_manifest_sha256'];}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Requests\Sgc\StoreInvoiceRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt, DB};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Stateless transport to the existing pending-invoice workflow; no second business writer. */
|
||||
final class SgcInvoiceIngress
|
||||
{
|
||||
public function receive(StoreInvoiceRequest $request)
|
||||
{
|
||||
$body = $request->validated(); $id = (int)$body['order_id'];
|
||||
$key = strtolower($request->header('X-Idempotency-Key'));
|
||||
$controller = app(InvoiceController::class); $runtime = app(ReceiptRuntime::class);
|
||||
$host = DB::connection();
|
||||
abort_unless($host->getDriverName() === 'pgsql', 503);
|
||||
return $host->transaction(function () use ($request, $body, $id, $key, $controller, $runtime, $host) {
|
||||
// Serialize the complete retry check + native commit, including different orders using one key.
|
||||
$host->statement("SET LOCAL lock_timeout = '5s'");
|
||||
$host->select('SELECT pg_advisory_xact_lock(hashtextextended(?, 0))', ['sgc.invoice:'.$key]);
|
||||
$controller->show($id, $runtime); // exact origin, current visibility and native policies
|
||||
if ($body['phase'] === 'confirm') {
|
||||
try { $cut = json_decode(Crypt::decryptString($body['preview']), true, 128, JSON_THROW_ON_ERROR); }
|
||||
catch (\Throwable) { abort(409, 'Prévia inválida.'); }
|
||||
abort_unless(($cut['action'] ?? null) === 'register' && ($cut['order_id'] ?? null) === $id
|
||||
&& ($cut['operation_key'] ?? null) === $key, 409, 'Prévia de outra operação.');
|
||||
$data = $cut['data']; $reason = $cut['reason'];
|
||||
} else { $cut = null; $data = $body['data']; $reason = $body['reason']; }
|
||||
$hash = hash('sha256', Payables::canonical([[$id, 'register', $data, $reason]]));
|
||||
$generation = hash('sha256', Payables::canonical([$request->session()->get('company_context')]));
|
||||
$old = $runtime->run('purchasing_invoices.prepare', function ($actor, $binding, $fingerprint) use ($key, $id, $hash, $cut, $generation) {
|
||||
$actor = app(\Tempo\Purchasing\Embedded\Services\CurrentActor::class)->lock($actor, ['finance']);
|
||||
abort_if(app(\Tempo\Purchasing\Embedded\Services\OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
if ($cut) abort_unless(($cut['actor'] ?? null) === $actor->id && ($cut['binding'] ?? null) === $fingerprint
|
||||
&& ($cut['generation'] ?? null) === $generation, 409, 'Contexto da prévia alterado.');
|
||||
$receipt = \Tempo\Purchasing\Embedded\Models\AuditLog::where('user_id', $actor->id)
|
||||
->where('action', 'sgf.invoice.command')->where('after->operation_key', $key)->first();
|
||||
if (!$receipt) return null;
|
||||
abort_unless(($receipt->after['result']['order_id'] ?? null) === $id && ($receipt->after['request_hash'] ?? null) === $hash
|
||||
&& ($receipt->after['binding_revision'] ?? null) === $fingerprint, 409, 'Chave usada com outro conteúdo ou origem.');
|
||||
return $receipt->after['result'] + ['replayed'=>true];
|
||||
});
|
||||
if ($old) return response()->json($old);
|
||||
$input = $body['phase'] === 'confirm' ? ['preview'=>$body['preview']] :
|
||||
['operation_key'=>$key, 'revision'=>$body['revision'], 'action'=>'register', 'reason'=>$reason, 'data'=>$data];
|
||||
$native = Request::create('/api/purchasing-invoices/'.$id, 'POST', $input, [], [], ['HTTP_ACCEPT'=>'application/json']);
|
||||
$native->setLaravelSession($request->session()); $native->setUserResolver($request->getUserResolver());
|
||||
return $body['phase'] === 'confirm' ? $controller->confirm($id, $native, $runtime) : $controller->preview($id, $native, $runtime);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user