commit de compras
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
final class CostLedgerController extends Controller
|
||||
{
|
||||
public function index(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
$d=$r->validate(['order_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
try{$data=$ledger->costs($r->user(),isset($d['order_id'])?(int)$d['order_id']:null,(int)($d['page']??1));}catch(\DomainException $e){abort(403,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.cost-ledger',['data'=>$data])->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Workshop\DirectCosts;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Brick\Math\BigRational;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Explicit composition of an actual cost conference; no technical, financial or outbox writes. */
|
||||
final class CostLedgerSource
|
||||
{
|
||||
private function scope(User $u):array
|
||||
{
|
||||
abort_unless(config('cost_ledger.synthetic_enabled')===true,403);DirectCosts::guard();$ctx=app(CompanyContext::class);
|
||||
abort_unless(auth()->id()===$u->id&&$ctx->userId()===$u->id&&$ctx->unitId(),403);$s=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];
|
||||
foreach(['workshop.view','direct_costs.view'] as $p)app(Permissions::class)->require($u->id,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);
|
||||
foreach(['gl_cost_sources','wo_orders','wo_entries','wo_decisions','dc_rates','dc_calculations','dc_decisions'] as $t)abort_unless(DB::getSchemaBuilder()->hasTable($t),503,'Origem de custo não composta neste ensaio.');return $s;
|
||||
}
|
||||
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
|
||||
private function facts(User $u,array $s,object $map):array
|
||||
{
|
||||
$c=app(DirectCosts::class)->conference($map->order_id,$u->id);$lines=$c['snapshot']['lines']??[];$entries=array_column($lines,'entry_id');$rates=array_values(array_filter(array_column($lines,'rate_id'),fn($id)=>$id!==null));
|
||||
return ['mapping'=>(array)$map,'conference'=>$c,'entries'=>DB::table('wo_entries')->where('order_id',$map->order_id)->whereIn('id',$entries)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'technical_decisions'=>DB::table('wo_decisions')->whereIn('entry_id',$entries)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'rates'=>DB::table('dc_rates')->where($s)->where('order_id',$map->order_id)->whereIn('id',$rates)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'rate_decisions'=>DB::table('dc_decisions')->whereIn('rate_id',$rates)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()];
|
||||
}
|
||||
private function eligible(array $facts):array
|
||||
{
|
||||
$m=$facts['mapping'];$c=$facts['conference'];$snap=$c['snapshot'];$decision=$c['decision'];
|
||||
if(!$c['calculation']||!$c['current']||!$decision||$decision['decision']!=='approved'||$decision['actor_id']===$c['calculation']['actor_id']||$snap['currency']!=='MZN'||$snap['incomplete']||$snap['selected_direct_total_exact']===null)throw new \DomainException('Cálculo actual completo e validado distintamente obrigatório; custo conhecido parcial não é total.');
|
||||
$origins=[];foreach($snap['lines'] as $line)$origins[$line['kind']]=$line['origin'];
|
||||
if($origins!==['time'=>$m['time_origin'],'material'=>$m['material_origin']]&&$origins!==['material'=>$m['material_origin'],'time'=>$m['time_origin']])throw new \DomainException('Perímetro seleccionado mudou; não atribuir outra origem automaticamente.');
|
||||
if(config('industrial_ledger.synthetic_enabled')===true){abort_unless(DB::getSchemaBuilder()->hasTable('gl_industrial_sources'),503);if(DB::table('ic_inputs as i')->join('gl_industrial_sources as m','m.lot_id','=','i.lot_id')->where('i.tenant_id',$m['tenant_id'])->where('i.company_id',$m['company_id'])->whereIn('i.source_ref',array_values($origins))->exists())throw new \DomainException('Origem já composta em lote industrial; não reconhecer novamente.');}
|
||||
try{$amount=BigRational::of($snap['selected_direct_total_exact'])->multipliedBy(100)->toBigInteger()->toInt();}catch(\Brick\Math\Exception\MathException $e){throw new \DomainException('Custo exacto não representável em cêntimos; não arredondar.',0,$e);}
|
||||
if($amount<=0)throw new \DomainException('Este ensaio exige custo seleccionado positivo.');
|
||||
return ['effect_type'=>'workshop.direct_cost.validated','source_id'=>$m['order_id'],'cost_order_id'=>$m['order_id'],'obligation_id'=>$m['order_id'],'origin_key'=>$this->hash(['scope'=>array_intersect_key($m,array_flip(['tenant_id','company_id','unit_id'])),'order'=>$m['order_id'],'material_origin'=>$m['material_origin'],'time_origin'=>$m['time_origin']]),'recognition_revision'=>$this->hash($facts),'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'permitted_period_key'=>$m['period_key'],'calculation_id'=>$c['calculation']['id'],'calculation_version'=>$c['calculation']['version'],'document_revision'=>$c['calculation']['fingerprint'],'cost_evidence'=>$facts,'label'=>'Custo directo conhecido do perímetro seleccionado; não custo completo, margem ou valorização de inventário'];
|
||||
}
|
||||
public function source(User $u,int $order):array
|
||||
{
|
||||
$s=$this->scope($u);$m=DB::table('gl_cost_sources')->where($s)->where('order_id',$order)->first()??throw new \DomainException('Ordem/perímetro de custo não composto neste âmbito.');return $this->eligible($this->facts($u,$s,$m));
|
||||
}
|
||||
public function period(User $u,array $period):array
|
||||
{
|
||||
$s=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;
|
||||
foreach(DB::table('gl_cost_sources')->where($s)->where('period_key',$period['period_key'])->orderBy('id')->cursor() as $m){$v=$this->facts($u,$s,$m);hash_update($h,Payables::canonical($v));$facts++;try{$source=$this->eligible($v);$j=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$m->order_id)->first();if(!$j||!$this->resolved($s,$j,$source))$pending++;}catch(\DomainException){$pending++;}}
|
||||
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
|
||||
}
|
||||
public function correctionSource(User $u,string $kind,int $journal):array
|
||||
{
|
||||
abort_unless(config('cost_ledger.correction_enabled')===true,403,'Correcção de custo não composta neste ensaio.');$s=$this->scope($u);abort_unless(DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability'),503,'Schema de correcção de custo não instalado.');
|
||||
if(!in_array($kind,['workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true))throw new \DomainException('Tipo de correcção fora do recorte.');$j=DB::table('gl_journals')->where($s)->where('id',$journal)->first()??throw new \DomainException('Original fora do âmbito.');$original=json_decode($j->snapshot,true,128,JSON_THROW_ON_ERROR);if($original['source']['effect_type']!=='workshop.direct_cost.validated')throw new \DomainException('Só o custo original admite esta correcção.');
|
||||
$base=$this->source($u,(int)$original['source']['cost_order_id']);if($base['origin_key']!==$original['source']['origin_key']||$base['permitted_period_key']!==$original['period']['period_key'])throw new \DomainException('Perímetro/período original incompatível.');if($base['recognition_revision']===$original['source']['recognition_revision'])throw new \DomainException('Não há nova versão validada para substituir o original.');
|
||||
return array_replace($base,['effect_type'=>$kind,'source_id'=>$journal,'amount_minor'=>$kind==='workshop.direct_cost.reversal'?(int)$j->debit_minor:$base['principal_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>$this->hash(['current_source'=>$base,'original'=>(array)$j,'kind'=>$kind]),'original_journal_id'=>$journal,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>$this->hash((array)$j),'original_cost_source'=>$original['source']]);
|
||||
}
|
||||
private function resolved(array $s,object $original,array $source):bool
|
||||
{
|
||||
if(json_decode($original->snapshot,true)['source']['recognition_revision']===$source['recognition_revision'])return true;
|
||||
if(config('cost_ledger.correction_enabled')!==true||!DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability'))return false;
|
||||
$r=DB::table('gl_correction_results')->where($s)->where('original_journal_id',$original->id)->first();if(!$r)return false;$j=DB::table('gl_journals')->where($s)->where('id',$r->replacement_journal_id)->first();if(!$j)return false;$current=json_decode($j->snapshot,true)['source'];return $current['effect_type']==='workshop.direct_cost.replacement'&&($current['cost_order_id']??null)===$source['cost_order_id']&&($current['replacement_source_revision']??null)===$source['recognition_revision'];
|
||||
}
|
||||
public function workspace(User $u,?int $order,int $page):array
|
||||
{
|
||||
$s=$this->scope($u);$q=DB::table('gl_cost_sources')->where($s);$p=$q->orderBy('id')->paginate(25,['*'],'page',$page);$chosen=null;$source=null;$pending=null;
|
||||
if($order!==null){$m=DB::table('gl_cost_sources')->where($s)->where('order_id',$order)->first()??abort(403,'Ordem fora do âmbito composto.');$chosen=$this->facts($u,$s,$m);try{$source=$this->eligible($chosen);}catch(\DomainException $e){$pending=$e->getMessage();}}
|
||||
$original=$order!==null?DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$order)->first():null;$state=$original?($source&&$this->resolved($s,$original,$source)?'current':'requires_correction'):'unposted';
|
||||
return ['scope'=>$s,'orders'=>$p->items(),'pagination'=>['page'=>$page,'total'=>$p->total(),'last_page'=>$p->lastPage()],'order_id'=>$order,'facts'=>$chosen,'source'=>$source,'pending'=>$pending,'original_journal'=>$original,'accounting_state'=>$state];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\GeneralLedger\{LedgerSchema,PayableJournal};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
final class GeneralLedger
|
||||
{
|
||||
public static function guard():void
|
||||
{
|
||||
abort_unless((app()->environment('payable-testing')||(app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true||config('industrial_ledger.synthetic_enabled')===true)))&&config('general_ledger.synthetic_enabled')===true,403,'Lançamentos apenas na composição sintética explicitamente preparada.');
|
||||
LedgerSchema::assertSynthetic(DB::connection());
|
||||
foreach(LedgerSchema::TABLES as $table)abort_unless(DB::getSchemaBuilder()->hasTable($table),503,'Núcleo contabilístico não instalado neste ensaio.');
|
||||
}
|
||||
private function scope(User $user):array
|
||||
{
|
||||
self::guard();$context=app(CompanyContext::class);
|
||||
abort_unless(auth()->id()===$user->id&&$context->userId()===$user->id&&$context->unitId(),403);
|
||||
if(request()->hasSession())abort_unless(request()->session()->get('company_context.module_id')==='accounting',409,'Seleccione Contabilidade.');
|
||||
else abort_unless(app()->runningInConsole(),403);
|
||||
$scope=$context->identifiers()+['unit_id'=>$context->unitId()];
|
||||
app(Permissions::class)->require($user->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id']);return $scope;
|
||||
}
|
||||
public function can(User $user,string $action):bool
|
||||
{
|
||||
$scope=$this->scope($user);return app(Permissions::class)->has($user->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.'.$action,$scope['unit_id']);
|
||||
}
|
||||
public function engine(User $user,?\Closure $checkpoint=null):PayableJournal
|
||||
{
|
||||
$scope=$this->scope($user);$person=DB::table('pa_access')->where($scope)->where(['user_id'=>$user->id,'action'=>'view'])->value('person');abort_unless(is_string($person)&&$person!=='',403);
|
||||
$authorize=function(string $action,bool $lock=true)use($user,$scope,$person):void{
|
||||
self::guard();
|
||||
if($lock){DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$user->id)->lockForUpdate()->firstOrFail();}
|
||||
abort_unless($this->scope($user)===$scope,409,'Contexto alterado.');
|
||||
app(CompanyContext::class)->selectFor($user->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id'=>$user->id,'action'=>'view'])->value('person')===$person,409,'Pessoa alterada; reabra a conferência.');
|
||||
abort_unless($this->can($user,$action),403);
|
||||
};
|
||||
$sales=config('sales_ledger.synthetic_enabled')===true?app(SalesLedgerSource::class):null;
|
||||
$cost=config('cost_ledger.synthetic_enabled')===true?app(CostLedgerSource::class):null;
|
||||
$industrial=config('industrial_ledger.synthetic_enabled')===true?app(IndustrialLedgerSource::class):null;
|
||||
$providers=array_values(array_filter([$sales,$cost,$industrial]));
|
||||
$extra=$providers?function($kind,$id)use($sales,$cost,$industrial,$user){if(in_array($kind,['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'],true)){if(!$industrial)throw new \DomainException('Origem industrial não composta.');return $kind==='industry.cost.validated'?$industrial->source($user,$id):$industrial->correctionSource($user,$kind,$id);}if(in_array($kind,['workshop.direct_cost.validated','workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true)){if(!$cost)throw new \DomainException('Origem de custo não composta neste host.');return $kind==='workshop.direct_cost.validated'?$cost->source($user,$id):$cost->correctionSource($user,$kind,$id);}if(!$sales)throw new \DomainException('Origem comercial não composta neste host.');return $sales->source($user,$kind,$id);}:null;
|
||||
$period=$providers?function($period)use($providers,$user){if(count($providers)===1)return $providers[0]->period($user,$period);$facts=0;$pending=0;$hash=hash_init('sha256');foreach($providers as $provider){$v=$provider->period($user,$period);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($hash,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($hash)];}:null;
|
||||
return new PayableJournal(DB::connection(),$scope,$user->id,$person,fn(string $a)=>$authorize($a),fn()=>$authorize('view',false),fn(int $id)=>$this->source($user,$scope,$id),$checkpoint,$extra,$period);
|
||||
}
|
||||
public function industrial(User $user,?int $lot=null,int $page=1):array{
|
||||
$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$lot,$page,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$this->scope($user);$origin=app(IndustrialLedgerSource::class)->workspace($user,$lot,$page);$data=$this->listing($user,['page'=>$page]+($lot!==null?['industrial_lot_id'=>$lot]:[]),false);if($lot===null){foreach(['journals','lines','proposals','reviews','events'] as $key)$data[$key]=[];$data['totals']=['debit_minor'=>0,'credit_minor'=>0,'net_minor'=>0];$data['pagination']=['page'=>$page,'last_page'=>1,'total'=>0];foreach($data['evidence_pagination'] as &$p)$p=['page'=>$page,'last_page'=>1,'total'=>0];unset($p);}$closed=DB::table('gl_periods')->where($origin['scope'])->where('period_key','QA-2026-10')->orderByDesc('version')->value('status')==='closed';$cycle=$lot!==null&&config('industrial_ledger.correction_enabled')===true?$this->cycle($user,$page,['industrial_lot_id'=>$lot]):['corrections'=>[],'correction_results'=>[],'pagination'=>[],'permissions'=>[]];foreach($cycle['corrections'] as $group){$v=json_decode($group->replacement_snapshot,true)['source'];$group->current=$group->current&&$origin['source']!==null&&($v['replacement_source_revision']??null)===$origin['source']['recognition_revision'];}return ['industrial'=>$origin,'book'=>$data,'closed'=>$closed,'corrections'=>$cycle['corrections'],'correction_results'=>$cycle['correction_results'],'pagination'=>$cycle['pagination'],'permissions'=>$cycle['permissions']];});
|
||||
}
|
||||
public function costs(User $user,?int $order=null,int $page=1):array
|
||||
{
|
||||
abort_unless(config('cost_ledger.synthetic_enabled')===true,403);$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$order,$page,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$commercial=app(CostLedgerSource::class)->workspace($user,$order,$page);$data=$this->cycle($user,$page,$order!==null?['cost_order_id'=>$order]:[]);foreach($data['corrections'] as $group){$source=json_decode($group->replacement_snapshot,true)['source'];if(isset($source['cost_order_id']))$group->current=$group->current&&$commercial['source']!==null&&($source['replacement_source_revision']??null)===$commercial['source']['recognition_revision'];}return ['cost'=>$commercial]+$data;});
|
||||
}
|
||||
private function source(User $user,array $scope,int $id):array
|
||||
{
|
||||
$obligation=DB::table('pa_obligations')->where($scope)->where('id',$id)->lockForUpdate()->first()??throw new \DomainException('Obrigação indisponível neste âmbito.');
|
||||
$origin=DB::table('pa_sources')->where($scope)->where('id',$obligation->source_id)->lockForUpdate()->first()??throw new \DomainException('Referência factual ausente.');
|
||||
$finance=app(SupplierPayables::class);$document=$finance->document($user,(int)$origin->local_source_id);
|
||||
$position=$finance->engine($user)->positionForSource((int)$origin->local_source_id);
|
||||
if(!$position||(int)$position['id']!==$id||$position['source_conflict'])throw new \DomainException('Fonte financeira mudou ou não está conferida.');
|
||||
$proposal=DB::table('pa_proposals')->where($scope)->where('id',$obligation->proposal_id)->first()??throw new \DomainException('Conferência original ausente.');
|
||||
return ['effect_type'=>'supplier_payable.recognised','origin_key'=>$origin->source_key,'obligation_id'=>$id,'local_source_id'=>(int)$origin->local_source_id,
|
||||
'document_revision'=>$obligation->source_revision,'recognition_revision'=>hash('sha256',Payables::canonical(['obligation'=>(array)$obligation,'recognition_snapshot'=>$proposal->snapshot])),
|
||||
'principal_minor'=>(int)$obligation->principal_minor,'currency'=>$obligation->currency,'due_on'=>$obligation->due_on,'tax_minor'=>$document['tax_minor'],
|
||||
'compatible'=>true,'history_imported'=>$position['history_imported']];
|
||||
}
|
||||
public function report(User $user,string $kind,array $options):array
|
||||
{
|
||||
if(!in_array($kind,['trial-balance','account-ledger'],true))throw new \InvalidArgumentException('Relatório desconhecido.');$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$kind,$options,$own){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$engine=$this->engine($user);
|
||||
return $kind==='trial-balance'?$engine->trialBalance($options):$engine->accountLedger($options);
|
||||
});
|
||||
}
|
||||
public function financialReceipt(User $user,string $domain,string $key):?array
|
||||
{
|
||||
if(!in_array($domain,['recognition','settlement'],true))throw new \InvalidArgumentException('Domínio de comprovativo inválido.');$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$domain,$key,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$this->scope($user);$engine=app(SupplierPayables::class)->engine($user);return $domain==='recognition'?$engine->receipt($key):$engine->settlements()->receipt($key);});
|
||||
}
|
||||
public function receipt(User $user,string $key):?array
|
||||
{
|
||||
$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$key,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $this->engine($user)->receipt($key);});
|
||||
}
|
||||
public function listing(User $user,array $options=[],bool $withAccounts=true):array
|
||||
{
|
||||
$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$options,$own,$withAccounts){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$data=$this->engine($user)->listing($options,$withAccounts);
|
||||
$data['permissions']=array_combine(['prepare','validate','post'],array_map(fn($a)=>$this->can($user,$a),['prepare','validate','post']));return $data;
|
||||
});
|
||||
}
|
||||
public function cycle(User $user,int $page=1,array $bookOptions=[]):array
|
||||
{
|
||||
abort_unless(config('general_ledger.cycle_enabled')===true,403);
|
||||
$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$page,$own,$bookOptions){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$data=$this->engine($user)->cycleListing($page,$bookOptions['cost_order_id']??null,$bookOptions['industrial_lot_id']??null);
|
||||
$data['book']=$this->listing($user,['page'=>$page]+$bookOptions);
|
||||
$actions=['prepare','validate','post','correct_prepare','correct_validate','correct_post','period_prepare','period_validate','period_close'];
|
||||
$data['permissions']=array_combine($actions,array_map(fn($a)=>$this->can($user,$a),$actions));return $data;
|
||||
});
|
||||
}
|
||||
public function sales(User $user,?int $invoice=null,int $page=1):array
|
||||
{
|
||||
abort_unless(config('sales_ledger.synthetic_enabled')===true,403);$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$invoice,$page,$own){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$commercial=app(SalesLedgerSource::class)->workspace($user,$invoice,$page);$data=$this->cycle($user,$page,$invoice!==null?['sales_invoice_id'=>$invoice]:[]);$data['commercial']=$commercial;
|
||||
if($invoice!==null){$account=DB::table('gl_accounts')->where($data['commercial']['scope'])->where('code','QA-CLIENTES')->value('id');$data['client_account']=$account?$this->listing($user,['page'=>$page,'sales_invoice_id'=>$invoice,'account_id'=>$account])['totals']:null;}return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Read one factual chain. No command, inferred association or module switch. */
|
||||
public function flow(User $user,int $sourceId):array
|
||||
{
|
||||
$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$sourceId,$own){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$scope=$this->scope($user);
|
||||
$finance=app(SupplierPayables::class);
|
||||
abort_unless($finance->can($user,'view'),403);
|
||||
$page=$finance->listing($user,['source'=>$sourceId,'per_page'=>25]);
|
||||
$document=$page['documents'][0]??abort(403);
|
||||
$position=$page['obligations'][0]??null;
|
||||
$proposal=null;$review=null;$journal=null;
|
||||
if($position){
|
||||
$proposal=DB::table('gl_proposals')->where($scope)->where('obligation_id',$position['id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->orderByDesc('capture_version')->first();
|
||||
if($proposal)$review=DB::table('gl_reviews')->where($scope)->where('proposal_id',$proposal->id)->first();
|
||||
$journal=DB::table('gl_journals')->where($scope)->where('obligation_id',$position['id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->first();
|
||||
}
|
||||
return compact('scope','document','position','proposal','review','journal')+['source_id'=>$sourceId,
|
||||
'financial_proposals'=>$page['proposals'],'financial_events'=>$page['events'],
|
||||
'source_conflict'=>$position['source_conflict']??false];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class GeneralLedgerController extends Controller
|
||||
{
|
||||
public function financialReceipt(Request $r,string $domain,string $key,GeneralLedger $ledger)
|
||||
{
|
||||
abort_if($r->query()!==[],422);try{$data=$ledger->financialReceipt($r->user(),$domain,$key);}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(403,$e->getMessage());}abort_if($data===null,404,'Comprovativo não encontrado neste domínio, contexto e captura.');return response()->json($data)->header('Cache-Control','no-store');
|
||||
}
|
||||
public function receipt(Request $r,string $key,GeneralLedger $ledger)
|
||||
{
|
||||
abort_if($r->query()!==[],422);try{$data=$ledger->receipt($r->user(),$key);}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(403,$e->getMessage());}abort_if($data===null,404,'Comprovativo não encontrado neste contexto e nesta captura.');return response()->json($data)->header('Cache-Control','no-store');
|
||||
}
|
||||
public function report(Request $r,string $kind,GeneralLedger $ledger)
|
||||
{
|
||||
$d=$r->validate(['from_on'=>'required|date_format:Y-m-d','to_on'=>'required|date_format:Y-m-d|after_or_equal:from_on','account_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
|
||||
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
try{$data=$ledger->report($r->user(),$kind,$d);}
|
||||
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
|
||||
return response(\Tempo\Finance\GeneralLedger\ReportsWorkspace::render($data,['host'=>'ERP SGF','return_url'=>'/general-ledger','industrial_links'=>config('industrial_ledger.synthetic_enabled')===true,'cost_links'=>config('cost_ledger.synthetic_enabled')===true,'sales_links'=>config('sales_ledger.synthetic_enabled')===true]))->header('Cache-Control','no-store');
|
||||
}
|
||||
private function options(Request $r):array
|
||||
{
|
||||
$d=$r->validate(['industrial_lot_id'=>'sometimes|required|integer|min:1','cost_order_id'=>'sometimes|required|integer|min:1','sales_invoice_id'=>'sometimes|required|integer|min:1','obligation_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','period_key'=>'sometimes|required|string|max:80','account_id'=>'sometimes|nullable|integer|min:1']);
|
||||
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);if(array_key_exists('account_id',$d)&&$d['account_id']===null)unset($d['account_id']);return $d;
|
||||
}
|
||||
public function index(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
try{return response()->json($ledger->listing($r->user(),$this->options($r)))->header('Cache-Control','no-store');}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
}
|
||||
public function page(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
try{return response()->view('accounting.general-ledger',['data'=>$ledger->listing($r->user(),$this->options($r))])->header('Cache-Control','no-store');}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
}
|
||||
public function command(Request $r,string $action,GeneralLedger $ledger)
|
||||
{
|
||||
if(!in_array($action,['prepare','validate','post'],true))abort_unless(config('general_ledger.cycle_enabled')===true,403);
|
||||
try{return response()->json($ledger->engine($r->user())->command($action,$r->all(),(string)$r->header('Idempotency-Key')))->header('Cache-Control','no-store');}
|
||||
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
catch(\DomainException $e){abort(409,$e->getMessage());}
|
||||
}
|
||||
public function cycle(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page'])!==[],422);
|
||||
$page=(int)($d['page']??1);$data=$ledger->cycle($r->user(),$page);
|
||||
return response(\Tempo\Finance\GeneralLedger\CycleWorkspace::render($data,['host'=>'ERP SGF','csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token'),'scope'=>$r->session()->get('company_context'),'financial_url'=>'/supplier-payments','return_url'=>'/general-ledger','page_url'=>'/general-ledger/cycle']))->header('Cache-Control','no-store');
|
||||
}
|
||||
public function cycleData(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page'])!==[],422);
|
||||
return response()->json($ledger->cycle($r->user(),(int)($d['page']??1)))->header('Cache-Control','no-store');
|
||||
}
|
||||
public function flow(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
$d=$r->validate(['source'=>'required|integer|min:1']);
|
||||
abort_if(array_diff(array_keys($r->query()),['source'])!==[],422);
|
||||
return response()->view('accounting.payable-flow',['flow'=>$ledger->flow($r->user(),(int)$d['source'])])->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
final class IndustrialLedgerController extends Controller{
|
||||
public function index(Request $r,GeneralLedger $ledger){$d=$r->validate(['lot_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);try{$data=$ledger->industrial($r->user(),isset($d['lot_id'])?(int)$d['lot_id']:null,(int)($d['page']??1));}catch(\DomainException $e){abort(403,$e->getMessage());}return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.industrial-ledger',['data'=>$data])->header('Cache-Control','no-store');}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use App\Modules\Industry\IndustrialCosts;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Brick\Math\BigRational;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
final class IndustrialLedgerSource
|
||||
{
|
||||
private function scope(User $u):array{
|
||||
abort_unless(config('industrial_ledger.synthetic_enabled')===true,403);IndustrialCosts::guard();$c=app(CompanyContext::class);abort_unless(auth()->id()===$u->id&&$c->userId()===$u->id&&$c->unitId(),403);$s=$c->identifiers()+['unit_id'=>$c->unitId()];app(Permissions::class)->require($u->id,$s['tenant_id'],$s['company_id'],'industrial_costs.view',$s['unit_id']);foreach(['gl_industrial_sources','ic_lots','ic_results','ic_inputs','ic_decisions'] as $t)abort_unless(DB::getSchemaBuilder()->hasTable($t),503,'Origem industrial indisponível.');return $s;
|
||||
}
|
||||
private function facts(User $u,object $map):array{return ['mapping'=>(array)$map,'conference'=>app(IndustrialCosts::class)->conference($map->lot_id,$u->id)];}
|
||||
private function eligible(array $s,array $facts):array{
|
||||
$m=$facts['mapping'];$c=$facts['conference'];$snap=$c['snapshot'];$r=$c['result'];$d=$c['decision'];
|
||||
if(!$r||!$c['product']||($c['product']['id']??null)!==$c['lot']['product_id']||!$c['current']||!$d||$d['decision']!=='approved'||$d['actor_id']===$r['actor_id']||!$snap||$snap['incomplete']||$snap['selected_total_exact']===null||$snap['currency']!=='MZN'||!$snap['reconciliation']['balanced'])throw new \DomainException('Resultado industrial actual completo e validado distintamente obrigatório.');
|
||||
$origins=[];foreach(['material','labour','pool','machine','production'] as $kind)$origins[$kind]=$snap['origins'][$kind]['source_ref'];if(Payables::canonical($origins)!==Payables::canonical(json_decode($m['origins'],true)))throw new \DomainException('Perímetro industrial alterado; nova conferência necessária.');
|
||||
if(count(array_unique($origins))!==5)throw new \DomainException('Origem industrial ambígua.');
|
||||
if(DB::table('ic_inputs')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('lot_id','<>',$m['lot_id'])->whereIn('source_ref',array_values($origins))->exists())throw new \DomainException('Origem reutilizada entre lotes.');
|
||||
if(DB::getSchemaBuilder()->hasTable('gl_cost_sources')&&DB::table('gl_cost_sources')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where(fn($q)=>$q->whereIn('material_origin',array_values($origins))->orWhereIn('time_origin',array_values($origins)))->exists())throw new \DomainException('Origem já composta como custo directo; não reconhecer novamente.');
|
||||
try{$amount=BigRational::of($snap['selected_total_exact'])->multipliedBy(100)->toBigInteger()->toInt();}catch(\Brick\Math\Exception\MathException $e){throw new \DomainException('Montante industrial não representável em cêntimos; sem arredondamento.',0,$e);}if($amount<=0)throw new \DomainException('Montante seleccionado deve ser positivo.');
|
||||
return ['effect_type'=>'industry.cost.validated','source_id'=>$m['lot_id'],'industrial_lot_id'=>$m['lot_id'],'obligation_id'=>$m['lot_id'],'origin_key'=>hash('sha256',Payables::canonical(['scope'=>$s,'lot'=>$m['lot_id'],'origins'=>$origins])),'recognition_revision'=>hash('sha256',Payables::canonical($facts)),'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'permitted_period_key'=>$m['period_key'],'period_start'=>$c['lot']['period_start'],'period_end'=>$c['lot']['period_end'],'result_id'=>$r['id'],'result_version'=>$r['version'],'document_revision'=>$r['fingerprint'],'good_units'=>$snap['good_units'],'unit_cost_exact'=>$snap['unit_cost_exact'],'residual_exact'=>$snap['residual_exact'],'industrial_evidence'=>$facts,'label'=>'Resultado industrial seleccionado de ensaio; residual separado, sem stock ou salários contabilizados'];
|
||||
}
|
||||
public function source(User $u,int $lot):array{$s=$this->scope($u);$m=DB::table('gl_industrial_sources')->where($s)->where('lot_id',$lot)->first()??throw new \DomainException('Lote fora do âmbito composto.');return $this->eligible($s,$this->facts($u,$m));}
|
||||
public function correctionSource(User $u,string $kind,int $id):array{
|
||||
abort_unless(config('industrial_ledger.correction_enabled')===true,403);$s=$this->scope($u);abort_unless(DB::getSchemaBuilder()->hasTable('gl_industrial_correction_capability'),503);if(!in_array($kind,['industry.cost.reversal','industry.cost.replacement'],true))throw new \DomainException('Tipo de correcção industrial inválido.');$j=DB::table('gl_journals')->where($s)->where('id',$id)->first()??throw new \DomainException('Original fora do âmbito.');$old=json_decode($j->snapshot,true);if($old['source']['effect_type']!=='industry.cost.validated')throw new \DomainException('Apenas o original industrial admite esta correcção.');$base=$this->source($u,(int)$old['source']['industrial_lot_id']);if($base['origin_key']!==$old['source']['origin_key']||$base['permitted_period_key']!==$old['period']['period_key']||$base['recognition_revision']===$old['source']['recognition_revision'])throw new \DomainException('Origem/perímetro/período sem sucessora compatível.');return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$kind==='industry.cost.reversal'?(int)$j->debit_minor:$base['principal_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>hash('sha256',Payables::canonical(['current_source'=>$base,'original'=>(array)$j,'kind'=>$kind])),'original_journal_id'=>$id,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>hash('sha256',Payables::canonical((array)$j)),'original_industrial_source'=>$old['source']]);
|
||||
}
|
||||
private function resolved(array $s,object $j,array $source):bool{
|
||||
if($j->source_revision===$source['recognition_revision'])return true;if(config('industrial_ledger.correction_enabled')!==true||!DB::getSchemaBuilder()->hasTable('gl_industrial_correction_capability'))return false;$r=DB::table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();if(!$r)return false;$replacement=DB::table('gl_journals')->where($s)->where('id',$r->replacement_journal_id)->first();if(!$replacement)return false;$v=json_decode($replacement->snapshot,true)['source'];return $v['effect_type']==='industry.cost.replacement'&&($v['industrial_lot_id']??null)===$source['industrial_lot_id']&&($v['replacement_source_revision']??null)===$source['recognition_revision'];
|
||||
}
|
||||
public function period(User $u,array $period):array{
|
||||
$s=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;foreach(DB::table('gl_industrial_sources')->where($s)->where('period_key',$period['period_key'])->orderBy('id')->cursor() as $m){$v=$this->facts($u,$m);hash_update($h,Payables::canonical($v));$facts++;try{$source=$this->eligible($s,$v);$j=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$m->lot_id)->first();if(!$j||!$this->resolved($s,$j,$source)||$source['period_start']<$period['starts_on']||$source['period_end']>$period['ends_on'])$pending++;}catch(\DomainException){$pending++;}}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
|
||||
}
|
||||
public function workspace(User $u,?int $lot,int $page):array{
|
||||
$s=$this->scope($u);$p=DB::table('gl_industrial_sources')->where($s)->orderBy('lot_id')->paginate(25,['lot_id','period_key'],'page',$page);$facts=null;$source=null;$pending=null;$journal=null;if($lot!==null){$m=DB::table('gl_industrial_sources')->where($s)->where('lot_id',$lot)->first()??abort(403,'Lote fora do âmbito.');$facts=$this->facts($u,$m);try{$source=$this->eligible($s,$facts);}catch(\DomainException $e){$pending=$e->getMessage();}$journal=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$lot)->first();}return ['scope'=>$s,'lots'=>$p->items(),'pagination'=>['page'=>$page,'total'=>$p->total(),'last_page'=>$p->lastPage()],'lot_id'=>$lot,'facts'=>$facts,'source'=>$source,'pending'=>$pending,'journal'=>$journal,'accounting_state'=>$journal?($source&&$this->resolved($s,$journal,$source)?'current':'requires_future_correction'):'unposted'];
|
||||
}
|
||||
public function cards(User $u,int $page):array{
|
||||
$s=$this->scope($u);$p=DB::table('gl_industrial_sources')->where($s)->orderBy('lot_id')->paginate(25,['lot_id','period_key'],'industrial_page',$page);$ids=array_column($p->items(),'lot_id');$results=DB::table('ic_results as r')->where(array_combine(array_map(fn($k)=>'r.'.$k,array_keys($s)),array_values($s)))->whereIn('r.lot_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('ic_results as n')->whereColumn('n.previous_id','r.id'))->get(['r.id','r.lot_id','r.version'])->keyBy('lot_id');$decisions=DB::table('ic_decisions')->whereIn('result_id',$results->pluck('id')->all())->get(['result_id','decision'])->keyBy('result_id');$posted=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->whereIn('snapshot->source->industrial_lot_id',$ids)->select('id')->selectRaw("snapshot->'source'->>'industrial_lot_id' AS lot_id")->get()->keyBy('lot_id');$rows=[];foreach($p->items() as $m){$r=$results->get($m->lot_id);$rows[]=['lot_id'=>$m->lot_id,'period_key'=>$m->period_key,'result_id'=>$r?->id,'result_version'=>$r?->version,'recorded_decision'=>$r?$decisions->get($r->id)?->decision:null,'original_journal_id'=>$posted->get($m->lot_id)?->id,'current_dependency_conference_required'=>true];}return ['allowed'=>true,'rows'=>$rows,'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total(),'per_page'=>25]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Bounded cross-source navigation. Recorded evidence never substitutes a current owner conference. */
|
||||
final class LedgerComposition
|
||||
{
|
||||
private function requireTables(array $tables):void{foreach($tables as $t)abort_unless(DB::getSchemaBuilder()->hasTable($t),503,'Fonte composta indisponível; não interpretar como zero.');}
|
||||
private function postings(array $scope,string $kind,string $key,array $ids):array
|
||||
{
|
||||
if(!$ids)return [];$rows=DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type',$kind)->whereIn('snapshot->source->'.$key,$ids)->select('id')->selectRaw("snapshot->'source'->>'$key' AS source_id")->get();$out=[];foreach($rows as $row)$out[(int)$row->source_id]=$row->id;return $out;
|
||||
}
|
||||
private function page($pager,array $rows):array{return ['allowed'=>true,'rows'=>$rows,'pagination'=>['page'=>$pager->currentPage(),'total'=>$pager->total(),'last_page'=>$pager->lastPage(),'per_page'=>25]];}
|
||||
public function workspace(User $user,array $options=[]):array
|
||||
{
|
||||
abort_unless(config('general_ledger.composition_enabled')===true&&config('sales_ledger.synthetic_enabled')===true&&config('cost_ledger.synthetic_enabled')===true,403,'Composição disponível apenas no ensaio explicitamente preparado.');$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$options,$own){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$ledger=app(GeneralLedger::class);$book=$ledger->listing($user,['page'=>(int)($options['journals_page']??1),'period_key'=>$options['period_key']??'QA-2026-10'],false);$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$permissions=app(Permissions::class);$can=fn($right)=>$permissions->has($user->id,$scope['tenant_id'],$scope['company_id'],$right,$scope['unit_id']);$panels=['sales'=>['allowed'=>false],'costs'=>['allowed'=>false],'suppliers'=>['allowed'=>false]];
|
||||
if($can('service_invoices.view')&&$can('service_receivables.view')){
|
||||
$this->requireTables(['si_invoices','sr_records','sr_effects','gl_sales_capability']);$p=ModuleRecords::query('si_invoices')->where($scope)->orderBy('id')->paginate(25,['id','series_code','number','issued_at'],'invoices_page',(int)($options['invoices_page']??1));$ids=array_column($p->items(),'id');$posted=$this->postings($scope,'sales.invoice','invoice_id',$ids);$records=DB::table('sr_records')->where($scope)->whereIn('invoice_id',$ids)->where('kind','obligation')->get(['id','invoice_id'])->keyBy('invoice_id');$opened=DB::table('sr_effects')->whereIn('record_id',$records->pluck('id')->all())->where('kind','open')->pluck('record_id')->all();$rows=[];foreach($p->items() as $i){$ob=$records->get($i->id);$rows[]=['invoice_id'=>$i->id,'number'=>$i->series_code.'/'.$i->number,'issued_at'=>$i->issued_at,'confirmed_opening'=>$ob&&in_array($ob->id,$opened,true),'original_journal_id'=>$posted[$i->id]??null];}$panels['sales']=$this->page($p,$rows);
|
||||
}
|
||||
if($can('workshop.view')&&$can('direct_costs.view')){
|
||||
$this->requireTables(['gl_cost_sources','wo_orders','dc_calculations','dc_decisions']);$p=DB::table('gl_cost_sources')->where($scope)->orderBy('order_id')->paginate(25,['order_id','period_key'],'costs_page',(int)($options['costs_page']??1));$ids=array_column($p->items(),'order_id');$calculations=DB::table('dc_calculations as c')->where(array_combine(array_map(fn($k)=>'c.'.$k,array_keys($scope)),array_values($scope)))->whereIn('c.order_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('dc_calculations as n')->whereColumn('n.order_id','c.order_id')->whereColumn('n.version','>','c.version'))->get(['c.id','c.order_id','c.version'])->keyBy('order_id');$decisions=DB::table('dc_decisions')->whereIn('calculation_id',$calculations->pluck('id')->all())->get(['calculation_id','decision'])->keyBy('calculation_id');$posted=$this->postings($scope,'workshop.direct_cost.validated','cost_order_id',$ids);$results=DB::table('gl_correction_results')->where($scope)->whereIn('original_journal_id',array_values($posted))->get(['id','original_journal_id'])->keyBy('original_journal_id');$rows=[];foreach($p->items() as $m){$calc=$calculations->get($m->order_id);$j=$posted[$m->order_id]??null;$rows[]=['order_id'=>$m->order_id,'period_key'=>$m->period_key,'calculation_id'=>$calc?->id,'calculation_version'=>$calc?->version,'recorded_decision'=>$calc?$decisions->get($calc->id)?->decision:null,'original_journal_id'=>$j,'recorded_correction_result_id'=>$j?$results->get($j)?->id:null,'current_dependency_conference_required'=>true];}$panels['costs']=$this->page($p,$rows);
|
||||
}
|
||||
if($can('supplier_payables.view')){
|
||||
$this->requireTables(['pa_obligations','pa_sources','pa_imported_sources']);$q=DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->join('pa_imported_sources as r','r.id','=','s.local_source_id');foreach($scope as $k=>$v)$q->where('o.'.$k,$v)->where('s.'.$k,$v)->where('r.'.$k,$v);$p=$q->orderBy('o.id')->paginate(25,['o.id','o.principal_minor','o.currency','s.local_source_id','r.payload'],'suppliers_page',(int)($options['suppliers_page']??1));$posted=$this->postings($scope,'supplier_payable.recognised','obligation_id',array_column($p->items(),'id'));$rows=[];foreach($p->items() as $o)$rows[]=['obligation_id'=>$o->id,'source_id'=>$o->local_source_id,'document_number'=>json_decode($o->payload,true)['number']??'—','principal_minor'=>(int)$o->principal_minor,'currency'=>$o->currency,'original_journal_id'=>$posted[$o->id]??null];$panels['suppliers']=$this->page($p,$rows);
|
||||
}
|
||||
if(config('industrial_ledger.synthetic_enabled')===true)$panels['industrial']=$can('industrial_costs.view')?app(IndustrialLedgerSource::class)->cards($user,(int)($options['industrial_page']??1)):['allowed'=>false];
|
||||
$key=$options['period_key']??'QA-2026-10';$period=DB::table('gl_periods')->where($scope)->where('period_key',$key)->orderByDesc('version')->first();$p=DB::table('gl_period_checks')->where($scope)->where('period_key',$key)->orderByDesc('id')->paginate(25,['*'],'checks_page',(int)($options['checks_page']??1));$ids=array_column($p->items(),'id');$reviews=DB::table('gl_period_reviews')->where($scope)->whereIn('check_id',$ids)->get()->keyBy('check_id');$closures=DB::table('gl_period_closures')->where($scope)->whereIn('check_id',$ids)->get()->keyBy('check_id');$checks=[];foreach($p->items() as $c)$checks[]=['capture'=>(array)$c,'review'=>$reviews->get($c->id),'closure'=>$closures->get($c->id)];$periodActions=[];foreach(['period_prepare','period_validate','period_close'] as $a)$periodActions[$a]=$ledger->can($user,$a);return ['synthetic_only'=>true,'scope'=>$scope,'panels'=>$panels,'book'=>$book,'period'=>$period,'checks'=>$this->page($p,$checks),'period_permissions'=>$periodActions,'all_current_source_views'=>!in_array(false,array_column($panels,'allowed'),true),'query'=>$options,'recorded_state_is_not_current_conference'=>true];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
final class LedgerCompositionController extends Controller
|
||||
{
|
||||
public function index(Request $r,LedgerComposition $workspace)
|
||||
{
|
||||
$rules=['period_key'=>'sometimes|required|string|max:80'];foreach(['industrial_page','invoices_page','costs_page','suppliers_page','journals_page','checks_page'] as $k)$rules[$k]='sometimes|integer|min:1|max:100000';$d=$r->validate($rules);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
try{$data=$workspace->workspace($r->user(),$d);}catch(\DomainException $e){abort(403,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.ledger-composition',['data'=>$data])->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
final class SalesLedgerController extends Controller
|
||||
{
|
||||
private function data(Request $r,GeneralLedger $ledger):array
|
||||
{
|
||||
$d=$r->validate(['invoice_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
try{return $ledger->sales($r->user(),isset($d['invoice_id'])?(int)$d['invoice_id']:null,(int)($d['page']??1));}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
}
|
||||
public function page(Request $r,GeneralLedger $ledger){return response()->view('accounting.sales-ledger',['data'=>$this->data($r,$ledger)])->header('Cache-Control','no-store');}
|
||||
public function index(Request $r,GeneralLedger $ledger){return response()->json($this->data($r,$ledger))->header('Cache-Control','no-store');}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Services\{ServiceInvoicing,ServiceReceivables};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Reads immutable commercial/receivable facts. Never issues, opens, receives or allocates. */
|
||||
final class SalesLedgerSource
|
||||
{
|
||||
private function scope(User $u):array
|
||||
{
|
||||
abort_unless(config('sales_ledger.synthetic_enabled')===true,403);ServiceReceivables::guard();foreach(['si_invoices','sr_records','sr_effects','gl_sales_capability'] as $table)abort_unless(DB::getSchemaBuilder()->hasTable($table),503,'Composição comercial contabilística não instalada.');$ctx=app(CompanyContext::class);
|
||||
abort_unless(auth()->id()===$u->id&&$ctx->userId()===$u->id&&$ctx->unitId(),403);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];
|
||||
foreach(['service_invoices.view','service_receivables.view'] as $p)app(Permissions::class)->require($u->id,$scope['tenant_id'],$scope['company_id'],$p,$scope['unit_id']);return $scope;
|
||||
}
|
||||
private function hash(array $d):string{return hash('sha256',Payables::canonical($d));}
|
||||
private function minor(string $value):int{return BigDecimal::of(ServiceReceivables::money($value,false))->multipliedBy(100)->toInt();}
|
||||
private function effectQuery(array $s){return DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(array_combine(array_map(fn($k)=>'r.'.$k,array_keys($s)),array_values($s)))->select('e.*');}
|
||||
private function invoice(User $u,array $scope,int $id):array
|
||||
{
|
||||
$row=DB::table('si_invoices')->where($scope)->where('id',$id)->first()??abort(403,'Factura fora do âmbito.');
|
||||
$shown=app(ServiceInvoicing::class)->show($id,$u->id);$snapshot=$shown['snapshot'];
|
||||
if(($snapshot['simulation_only']??false)!==true||($snapshot['notice']??'')!==ServiceInvoicing::NOTICE||($snapshot['document']['data']['currency']??'')!=='MZN')throw new \DomainException('Só factura efectivamente emitida pelo circuito de teste.');
|
||||
$ob=DB::table('sr_records')->where($scope)->where(['invoice_id'=>$id,'kind'=>'obligation'])->first();
|
||||
$open=$ob?$this->effectQuery($scope)->where('e.record_id',$ob->id)->where('e.kind','open')->first():null;
|
||||
if(!$ob||!$open)throw new \DomainException('Conferência financeira de abertura pendente; não presumir dívida aprovada.');
|
||||
$origin=json_decode($ob->data,true)['origin']??[];
|
||||
if(($origin['invoice_id']??null)!==$id||!hash_equals($origin['snapshot_sha256']??'',hash('sha256',$row->snapshot))||(int)$open->actor_id===(int)$ob->actor_id)throw new \DomainException('Abertura ou fotografia documental divergente.');
|
||||
$calc=$snapshot['calculation'];$amount=$this->minor($calc['total_document_proposed']);
|
||||
if($this->minor($ob->amount)!==$amount||$this->minor($open->amount)!==$amount)throw new \DomainException('Principal não coincide com o documento.');
|
||||
return ['effect_type'=>'sales.invoice','origin_key'=>$this->hash(['scope'=>$scope,'invoice'=>$id,'series'=>$row->series_code,'number'=>$row->number]),'source_id'=>$id,'invoice_id'=>$id,'obligation_id'=>(int)$ob->id,'recognition_revision'=>$this->hash(['invoice_snapshot'=>$snapshot,'pdf_sha256'=>$row->pdf_sha256,'opening'=>(array)$open,'obligation'=>(array)$ob]),'principal_minor'=>$amount,'amount_minor'=>$amount,'base_minor'=>$this->minor($calc['base_exact']),'tax_minor'=>$this->minor($calc['vat_document_proposed']),'currency'=>'MZN','compatible'=>true,'history_imported'=>false,'due_on'=>json_decode($ob->data,true)['due_date']??null,'document_revision'=>hash('sha256',$row->snapshot),'document_snapshot'=>$snapshot,'opening_effect_id'=>$open->id];
|
||||
}
|
||||
public function source(User $u,string $kind,int $id):array
|
||||
{
|
||||
$scope=$this->scope($u);if($kind==='sales.invoice')return $this->invoice($u,$scope,$id);
|
||||
$expected=match($kind){'receivable.allocated'=>'allocate','receivable.reversed'=>'reverse_allocation',default=>throw new \DomainException('Efeito comercial não suportado.')};
|
||||
$e=$this->effectQuery($scope)->where('e.id',$id)->where('e.kind',$expected)->first()??throw new \DomainException('Afectação/reversão confirmada fora do âmbito.');
|
||||
$ob=DB::table('sr_records')->where($scope)->where('id',$e->obligation_id)->where('kind','obligation')->first()??throw new \DomainException('Obrigação a receber indisponível.');
|
||||
$base=$this->invoice($u,$scope,(int)$ob->invoice_id);
|
||||
$receipt=DB::table('sr_records')->where($scope)->where('id',$e->receipt_id)->where(['kind'=>'receipt','obligation_id'=>$ob->id,'invoice_id'=>$ob->invoice_id])->first();
|
||||
$confirmation=$receipt?$this->effectQuery($scope)->where('e.record_id',$receipt->id)->where('e.kind','receipt_confirm')->first():null;
|
||||
if(!$receipt||!$confirmation||$receipt->actor_id===$confirmation->actor_id)throw new \DomainException('Recebimento não confirmado independentemente.');
|
||||
if(!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$ob->invoice_id)->exists())throw new \DomainException('Contabilize primeiro a factura original.');
|
||||
if($kind==='receivable.reversed'){
|
||||
$target=$this->effectQuery($scope)->where('e.id',$e->target_effect_id)->where('e.kind','allocate')->where('e.obligation_id',$ob->id)->first();
|
||||
if(!$target||$target->amount!==$e->amount||!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','receivable.allocated')->where('snapshot->source->source_id',(int)$target->id)->exists())throw new \DomainException('Reversão exige a afectação original contabilizada integralmente.');
|
||||
}
|
||||
return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$this->minor($e->amount),'tax_minor'=>0,'invoice_tax_minor'=>$base['tax_minor'],'recognition_revision'=>$this->hash(['invoice'=>$base['recognition_revision'],'effect'=>(array)$e,'receipt'=>(array)$receipt,'confirmation'=>(array)$confirmation]),'financial_effect'=>(array)$e,'receipt_confirmation_id'=>$confirmation->id]);
|
||||
}
|
||||
public function period(User $u,array $period):array
|
||||
{
|
||||
$scope=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;
|
||||
foreach(DB::table('si_invoices')->where($scope)->orderBy('id')->cursor() as $i){$facts++;hash_update($h,$i->snapshot);hash_update($h,$i->pdf_sha256);if(!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$i->id)->exists())$pending++;}
|
||||
foreach(DB::table('sr_records')->where($scope)->orderBy('id')->cursor() as $r){hash_update($h,Payables::canonical((array)$r));if(!$this->effectQuery($scope)->where('e.record_id',$r->id)->where('e.kind','<>','allocate')->exists())$pending++;}
|
||||
foreach($this->effectQuery($scope)->orderBy('e.id')->cursor() as $e){$facts++;hash_update($h,Payables::canonical((array)$e));
|
||||
if(in_array($e->kind,['allocate','reverse_allocation'],true)){if(!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type',$e->kind==='allocate'?'receivable.allocated':'receivable.reversed')->where('snapshot->source->source_id',(int)$e->id)->exists())$pending++;}
|
||||
elseif(!in_array($e->kind,['open','receipt_confirm','refused'],true))$pending++; // Unsupported financial changes are visible blockers, never ignored.
|
||||
}
|
||||
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
|
||||
}
|
||||
public function workspace(User $u,?int $invoice,int $page=1):array
|
||||
{
|
||||
$scope=$this->scope($u);$p=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($scope)->orderByDesc('id')->paginate(25,['id','series_code','number','issued_at'],'page',$page);
|
||||
$data=['scope'=>$scope,'invoices'=>$p->items(),'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()],'invoice'=>null];if($invoice===null)return $data;
|
||||
$data['invoice']=app(ServiceInvoicing::class)->show($invoice,$u->id);
|
||||
$row=DB::table('si_invoices')->where($scope)->where('id',$invoice)->first()??abort(403);
|
||||
$records=DB::table('sr_records')->where($scope)->where('invoice_id',$invoice)->orderByDesc('id')->paginate(25,['*'],'records_page',$page);$data['records']=$records->items();$data['records_pagination']=['page'=>$page,'last_page'=>$records->lastPage(),'total'=>$records->total()];
|
||||
$decisions=$this->effectQuery($scope)->whereIn('e.record_id',array_column($records->items(),'id'))->where('e.kind','<>','allocate')->get()->keyBy('record_id');foreach($data['records'] as $r){$r->decision_kind=$decisions->get($r->id)?->kind;$r->decision_id=$decisions->get($r->id)?->id;}
|
||||
$ob=DB::table('sr_records')->where($scope)->where(['invoice_id'=>$invoice,'kind'=>'obligation'])->first();$data['obligation_id']=$ob?->id;
|
||||
$data['position']=$ob?app(ServiceReceivables::class)->position($ob->id,$u->id):null;
|
||||
if($data['position']){unset($data['position']['effects']);$data['position']['receipts']=array_intersect_key($data['position']['receipts'],array_fill_keys(array_column($records->items(),'id'),true));}
|
||||
$effects=$this->effectQuery($scope)->where('r.invoice_id',$invoice)->orderByDesc('e.id')->paginate(25,['e.*'],'effects_page',$page);$data['effects']=$effects->items();$data['effects_pagination']=['page'=>$page,'last_page'=>$effects->lastPage(),'total'=>$effects->total()];
|
||||
$data['finance_permissions']=[];foreach(['prepare','confirm','allocate','correction_request','correction_approve'] as $a)$data['finance_permissions'][$a]=app(Permissions::class)->has($u->id,$scope['tenant_id'],$scope['company_id'],'service_receivables.'.$a,$scope['unit_id']);
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** SGF observes the canonical physical/valuation book with a SELECT-only QA role. */
|
||||
final class StockValuationRead
|
||||
{
|
||||
public function index(Request $r,CompanyContext $context,Permissions $permissions)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
$s=$context->identifiers();$unit=$context->unitId();abort_unless($unit,403);
|
||||
foreach(['stock.view','general_ledger.view']as$p)$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$p,$unit);
|
||||
$binding=config('stock_ledger.read_sources.'.$s['tenant_id'].':'.$s['company_id'].':'.$unit);
|
||||
if(!$binding)return response()->json(['status'=>'unavailable','message'=>'Livro de valorização autorizado ainda não ligado a este contexto.'],503)->header('Cache-Control','no-store');
|
||||
abort_unless(app()->environment('budget-testing'),403);
|
||||
$host=DB::connection();$h=$host->getConfig();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&($h['driver']??null)==='pgsql'&&($h['host']??null)==='127.0.0.1'&&(string)($h['port']??'')==='55439'&&preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$h['database']??'',$hm)&&($h['username']??null)==='stock_ledger_sgf_'.$hm[1]&&empty($h['url'])&&empty($h['read'])&&empty($h['write']),403);
|
||||
$hi=$host->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster");abort_unless($hi->db===$h['database']&&$hi->role===$h['username']&&$hi->cluster==='sgf_modular_regression_'.$run,403);
|
||||
abort_unless(($binding['connection']??null)==='stock_valuation_source'&&is_int($binding['book_id']??null)&&($binding['book_id']??0)>0,503);
|
||||
$c=DB::connection('stock_valuation_source');$cfg=$c->getConfig();
|
||||
abort_unless(($cfg['driver']??null)==='pgsql'&&($cfg['host']??null)==='127.0.0.1'&&(string)($cfg['port']??'')==='55439'&&preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$cfg['database']??'',$m)&&($cfg['username']??null)==='stock_valuation_read_'.$m[1]&&empty($cfg['url'])&&empty($cfg['read'])&&empty($cfg['write'])&&($cfg['search_path']??'public')==='public',403);
|
||||
$native=$binding['native_scope']??[];abort_unless(array_keys($native)===['tenant_id','company_id','unit_id']&&is_int($native['tenant_id'])&&$native['tenant_id']>0&&$native['company_id']===0&&$native['unit_id']===0,503);
|
||||
try{$data=$c->transaction(function()use($c,$cfg,$run,$native,$binding,$d){
|
||||
abort_unless($c->transactionLevel()===1,503);$c->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$i=$c->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,current_setting('default_transaction_read_only') ro,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless($i->db===$cfg['database']&&$i->role===$cfg['username']&&$i->cluster==='sgf_modular_regression_'.$run&&$i->ro==='on'&&!$i->elevated,403);
|
||||
foreach(['stock_movements','gl_journals','gl_lines','gl_stock_rates','gl_stock_books']as$table)abort_if($c->selectOne("SELECT has_table_privilege(current_user,?,'INSERT,UPDATE,DELETE') writes",[$table])->writes,403);
|
||||
$book=$c->table('gl_stock_books')->where($native)->where('id',$binding['book_id'])->first();abort_unless($book,503,'Livro composto incompatível.');
|
||||
abort_unless($c->table('stock_items')->where(['tenant_id'=>$native['tenant_id'],'id'=>$book->stock_item_id,'unit'=>$book->unit_code])->exists()
|
||||
&&$c->table('warehouses')->where(['tenant_id'=>$native['tenant_id'],'id'=>$book->warehouse_id])->exists(),503,'Artigo/armazém/unidade da origem incompatíveis.');
|
||||
$page=(int)($d['page']??1);$size=(int)($d['per_page']??25);
|
||||
$physical=$c->table('stock_movements')->where(['tenant_id'=>$native['tenant_id'],'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id]);
|
||||
$journals=$c->table('gl_journals')->where($native)->where('snapshot->source->stock_book_id',$book->id);$count=(clone$journals)->count();
|
||||
$rows=(clone$journals)->orderBy('id')->offset(($page-1)*$size)->limit($size)->get();$ids=$rows->pluck('id')->all();$lines=$c->table('gl_lines')->where($native)->whereIn('journal_id',$ids)->orderBy('journal_id')->orderBy('line_no')->get();
|
||||
$postedIds=(clone$journals)->selectRaw("(snapshot->'source'->>'stock_movement_id')::bigint");
|
||||
$unposted=(clone$physical)->whereNotIn('id',$postedIds)->count();
|
||||
$rate=$c->table('gl_stock_rates')->where($native)->where('book_id',$book->id)->orderByDesc('version')->first();$review=$rate?$c->table('gl_stock_rate_reviews')->where($native)->where('rate_id',$rate->id)->first():null;
|
||||
$stale=$rate?(clone$journals)->where('snapshot->source->stock_rate_id','<>',$rate->id)->count():$count;
|
||||
$net=$c->table('gl_lines as l')->join('gl_accounts as a','a.id','=','l.account_id')->where(['l.tenant_id'=>$native['tenant_id'],'l.company_id'=>0,'l.unit_id'=>0,'a.code'=>'QA-STOCK'])->whereIn('l.journal_id',(clone$journals)->select('id'))->selectRaw('coalesce(sum(l.debit_minor-l.credit_minor),0) net')->first();
|
||||
return ['synthetic_only'=>true,'single_canonical_book'=>true,'status'=>$rate&&$review&&$review->decision==='approved'&&$unposted===0&&$stale===0?'reconciled':'pending','reconciliation_scope'=>'physical_movement_coverage_and_rate_ids_only','current_source_dependencies_validated'=>false,'book'=>['id'=>$book->id,'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id,'unit'=>$book->unit_code,'period_key'=>$book->period_key],'physical_quantity_milli'=>(int)(clone$physical)->sum('quantity_milli'),'physical_revision'=>(int)(clone$physical)->max('id'),'posted_stock_value_minor'=>(int)$net->net,'currency'=>'MZN','unposted_movements'=>$unposted,'stale_rate_journals'=>$stale,'journals'=>$rows->all(),'lines'=>$lines->all(),'pagination'=>['page'=>$page,'per_page'=>$size,'total'=>$count],'source_identity'=>['database'=>$i->db,'role'=>$i->role],'operation_executed'=>false];
|
||||
});}catch(\PDOException $e){$state=$e->errorInfo[0]??$e->getCode();if(str_starts_with((string)$state,'08')||in_array($state,['28P01','3D000','42P01','42703','57014'],true))return response()->json(['status'=>'unavailable','message'=>'Livro de valorização indisponível ou incompatível.'],503)->header('Cache-Control','no-store');throw$e;}
|
||||
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
|
||||
return response()->view('accounting.stock-valuation-read',['data'=>$data])->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ final class RequireCompanyContext
|
||||
|
||||
app(\App\Shared\Context\RequireModuleContext::class)->check($request, $this->context);
|
||||
$response = $next($request);
|
||||
if ($request->isMethod('GET') && in_array($request->path(), ['supplier-payables','supplier-payments'], true) && $response->isSuccessful()) {
|
||||
if ($request->isMethod('GET') && in_array($request->path(), ['supplier-payables','supplier-payments','purchasing/internal-requisitions'], true) && $response->isSuccessful()) {
|
||||
$bar = '<nav aria-label="Contexto SGF" style="padding:12px;background:#edf4f3;color:#163e38"><strong>Módulo activo: Compras</strong> · <a href="/" data-sgf-module-back>Voltar à navegação SGF</a></nav><script src="/sgf-module-context.js" defer></script>';
|
||||
$response->setContent(preg_replace('/(<body\b[^>]*>)/i', '$1'.$bar, $response->getContent(), 1));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,20 @@ final class IdentityWorkflow
|
||||
$result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id),
|
||||
'token' => $selected['token'], 'grants' => $grants,
|
||||
'modules' => app(\App\Shared\Context\ModuleNavigation::class)->available($this->context),
|
||||
'module_id' => $selected['module_id'] ?? null];
|
||||
'module_id' => $selected['module_id'] ?? null,
|
||||
'geography_enabled'=>app()->environment('budget-testing')&&config('geography.enabled')===true,
|
||||
'stock'=>['period_report'=>app()->environment('budget-testing')&&config('receipt_qa.stock_read_enabled')===true&&config('receipt_qa.stock_period_enabled')===true],
|
||||
'purchasing' => ['internal_requisitions' => app()->environment('budget-testing')
|
||||
&& config('internal_requisitions.qa_enabled') === true,
|
||||
'receipts'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true,
|
||||
'orders'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.orders_enabled')===true,
|
||||
'supplier_obligations'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.obligations_enabled')===true,
|
||||
'supplier_invoices'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.invoices_enabled')===true,
|
||||
'source_requests'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.requests_enabled')===true,
|
||||
'suppliers'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.suppliers_enabled')===true,
|
||||
'sourcing'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.sourcing_enabled')===true],
|
||||
'accounting' => ['stock_valuation_read' => app()->environment('budget-testing')
|
||||
&& config('stock_ledger.read_sources.'.$selected['tenant_id'].':'.$selected['company_id'].':'.($selected['unit_id']??'')) !== null]];
|
||||
if ($result['context']['module_id'] && !in_array($result['context']['module_id'], array_column($result['context']['modules'], 'id'), true)) {
|
||||
$result['context']['module_id'] = null;
|
||||
$result['context']['token'] = bin2hex(random_bytes(24));
|
||||
|
||||
@@ -119,6 +119,12 @@ final class IndustrialCosts {
|
||||
$id=DB::table('ic_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'lot_id'=>$lot,'version'=>$r->version,'kind'=>'decision'];
|
||||
});
|
||||
}
|
||||
/** Current owner conference for one lot; no listing universe or writer lock. */
|
||||
public function conference(int $lot,int $actor):array {
|
||||
self::guard();$s=$this->scope($actor,'view');$l=$this->lot($lot,$s);$r=DB::table('ic_results')->where($s)->where('lot_id',$lot)->orderByDesc('version')->first();$snapshot=$r?json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR):null;
|
||||
$ids=array_column(array_filter($snapshot['refs']??[]),'id');$inputs=DB::table('ic_inputs')->where($s)->where('lot_id',$lot)->whereIn('id',$ids)->orderBy('id')->get();$decisions=DB::table('ic_decisions')->whereIn('input_id',$ids)->orderBy('id')->get();$decision=$r?DB::table('ic_decisions')->where('result_id',$r->id)->first():null;
|
||||
return ['lot'=>(array)$l,'product'=>(array)DB::table('ic_products')->where($s)->where('id',$l->product_id)->first(),'result'=>$r?(array)$r:null,'snapshot'=>$snapshot,'decision'=>$decision?(array)$decision:null,'current'=>$r?$this->resultCurrent($r):false,'inputs'=>$inputs->map(fn($i)=>(array)$i)->all(),'input_decisions'=>$decisions->map(fn($i)=>(array)$i)->all()];
|
||||
}
|
||||
public function listing(int $actor): array {
|
||||
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'lots'=>[],'notice'=>self::NOTICE];self::guard();
|
||||
return DB::transaction(function()use($s){
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use App\Modules\Purchasing\BudgetAdapter;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require_once __DIR__.'/../../../packages/inventory-read/bootstrap.php';
|
||||
|
||||
/** Observe the composed receipt book; never borrow its writer or tenant mutex. */
|
||||
final class CompositionStockRead
|
||||
{
|
||||
public function index(Request $r, CompanyContext $context, Permissions $permissions)
|
||||
{
|
||||
abort_unless(config('receipt_qa.stock_read_enabled') === true && config('receipt_qa.enabled') === true,404);
|
||||
abort_unless(app()->environment('budget-testing'),403);
|
||||
BudgetAdapter::guard();
|
||||
$d=$r->validate(['view'=>'sometimes|in:items,warehouses,balances,movements,period','search'=>'nullable|string|max:100',
|
||||
'warehouse_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000',
|
||||
'per_page'=>'sometimes|integer|in:25,100','snapshot'=>'sometimes|string|max:8192',
|
||||
'from'=>'required_if:view,period|prohibited_unless:view,period|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31',
|
||||
'to'=>'required_if:view,period|prohibited_unless:view,period|date_format:Y-m-d|after_or_equal:from|before_or_equal:9998-12-31']);
|
||||
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
$filters=['view'=>$d['view']??'balances','search'=>$d['search']??'',
|
||||
'warehouse_id'=>isset($d['warehouse_id'])?(int)$d['warehouse_id']:null,'page'=>(int)($d['page']??1),'per_page'=>(int)($d['per_page']??25)];
|
||||
if($filters['view']==='period'){
|
||||
abort_unless(config('receipt_qa.stock_period_enabled')===true,404);
|
||||
$filters+=['from'=>$d['from'],'to'=>$d['to']];
|
||||
}else abort_if(array_key_exists('from',$d)||array_key_exists('to',$d),422);
|
||||
$host=DB::connection();$c=$host->getConfig();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$host->getDatabaseName(),$m)
|
||||
&&($c['username']??null)==='ir_composition_'.$m[1]&&preg_match('/^[a-f0-9]{12}$/D',$run)
|
||||
&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??'public')==='public',403);
|
||||
$identity=$host->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$identity->elevated&&$identity->db===$host->getDatabaseName()&&$identity->role===$c['username']&&$identity->cluster==='sgf_modular_regression_'.$run,403);
|
||||
$scope=$context->identifiers();$unit=$context->unitId();$user=$context->userId();
|
||||
abort_unless($unit&&$r->user()&&(int)$r->user()->id===$user,403);
|
||||
// Hold only shared host authority locks. Unrelated readers remain concurrent.
|
||||
try {
|
||||
return $host->transaction(function()use($host,$scope,$unit,$user,$permissions,$m,$run,$r,$filters,$d){
|
||||
$host->statement("SET LOCAL lock_timeout='2s'");
|
||||
$host->statement("SET LOCAL statement_timeout='3s'");
|
||||
abort_unless($host->table('users')->where('id',$user)->sharedLock()->first(),403);
|
||||
abort_unless($host->table('company_memberships')->where($scope+['user_id'=>$user])->sharedLock()->first(),403);
|
||||
foreach(['company.view'=>null,'unit.view'=>$unit,'stock.view'=>$unit]as$right=>$u){
|
||||
abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$user,'unit_id'=>$u,'permission'=>$right])->sharedLock()->first(),403);
|
||||
$permissions->require($user,$scope['tenant_id'],$scope['company_id'],$right,$u);
|
||||
}
|
||||
$root='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$m[1];
|
||||
$profile=realpath($root.'/stock-binding.json');
|
||||
abort_unless($profile&&!is_link($root.'/stock-binding.json')&&strtolower(str_replace('\\','/',$profile))===strtolower($root.'/stock-binding.json'),503,'Fonte de stock indisponível.');
|
||||
$p=json_decode(file_get_contents($profile),true,32,JSON_THROW_ON_ERROR);
|
||||
$key=implode(':',[$scope['tenant_id'],$scope['company_id'],$unit,$user]);
|
||||
$binding=$p['bindings'][$key]??null;abort_unless(is_array($binding),403);
|
||||
abort_unless(is_int($binding['source_tenant_id']??null)&&$binding['source_tenant_id']>0
|
||||
&&is_array($binding['warehouse_ids']??null)&&count($binding['warehouse_ids'])>0&&count($binding['warehouse_ids'])<=1000
|
||||
&&!array_filter($binding['warehouse_ids'],fn($id)=>!is_int($id)||$id<1),503,'Fonte de stock incompatível.');
|
||||
$c=$p['connection']??[];
|
||||
abort_unless(($c['driver']??null)==='pgsql'&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439
|
||||
&&($c['database']??null)==='compras_receipts_test_'.$m[1]&&($c['username']??null)==='stock_receipt_read_'.$m[1]
|
||||
&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??null)==='public',403);
|
||||
// Purge avoids retaining an earlier profile/credential after a configuration change.
|
||||
DB::purge('composition_stock');config(['database.connections.composition_stock'=>$c]);$source=DB::connection('composition_stock');
|
||||
$i=$source->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,current_setting('default_transaction_read_only') read_only,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$i->elevated&&$i->db===$c['database']&&$i->role===$c['username']&&$i->cluster==='sgf_modular_regression_'.$run&&$i->read_only==='on',403);
|
||||
$rights=$source->selectOne("SELECT EXISTS(SELECT FROM pg_tables WHERE schemaname='public' AND (has_table_privilege(current_user,quote_ident(schemaname)||'.'||quote_ident(tablename),'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') OR (tablename NOT IN ('stock_items','warehouses','stock_movements') AND has_table_privilege(current_user,quote_ident(schemaname)||'.'||quote_ident(tablename),'SELECT')))) forbidden,
|
||||
EXISTS(SELECT FROM pg_trigger WHERE tgrelid='public.stock_movements'::regclass AND tgname='composition_stock_append_only' AND tgenabled='O' AND NOT tgisinternal AND (tgtype::int & 26)=26) append_only");
|
||||
abort_if($rights->forbidden,403);abort_unless($rights->append_only,503,'Fonte de stock incompatível.');
|
||||
abort_unless(($binding['schema']??null)==='public'&&($binding['append_only']??false)===true,503);
|
||||
if($filters['warehouse_id']!==null)abort_unless(in_array($filters['warehouse_id'],$binding['warehouse_ids'],true),403);
|
||||
$binding['physical_source']=[$i->db,$i->role,$i->cluster];
|
||||
$fingerprint=hash('sha256',json_encode([$scope,$unit,$user,$r->session()->get('company_context.token'),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
|
||||
$result=$filters['view']==='period'
|
||||
?(new StockPeriodReport($source))->read($binding,$fingerprint,$filters,$d['snapshot']??null)
|
||||
:(new \Tempo\Inventory\InventoryReadSession($source))->read($binding,$fingerprint,$filters,$d['snapshot']??null);
|
||||
$result['capabilities']['receipt_trace']=config('receipt_qa.stock_trace_enabled')===true
|
||||
&&$permissions->has($user,$scope['tenant_id'],$scope['company_id'],'purchasing_receipts.view',$unit);
|
||||
return response()->json($result)->header('Cache-Control','no-store');
|
||||
});
|
||||
}catch(\PDOException $e){
|
||||
$state=$e->errorInfo[0]??(string)$e->getCode();
|
||||
if(!str_starts_with($state,'08')&&!in_array($state,['28P01','3D000','42P01','42703','42883','55P03','57014'],true))throw $e;
|
||||
return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/** Physical registration-period quantities, projected from the canonical book only. */
|
||||
final class StockPeriodReport
|
||||
{
|
||||
public function __construct(private ConnectionInterface $db){}
|
||||
public function read(array $binding,string $fingerprint,array $filters,?string $token):array
|
||||
{
|
||||
return $this->db->transaction(function()use($binding,$fingerprint,$filters,$token){
|
||||
abort_unless($this->db->transactionLevel()===1,503);
|
||||
$this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
$this->db->statement("SET LOCAL statement_timeout='3s'");
|
||||
$reader=new \Tempo\Inventory\InventoryReader($this->db,$binding['schema'],$binding['source_tenant_id'],$binding['warehouse_ids']);
|
||||
try{$revision=$reader->revision();}catch(\UnexpectedValueException){abort(503,'Fonte de stock incompatível.');}
|
||||
$observed=time();
|
||||
if($token!==null){
|
||||
try{$cut=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Referência de relatório inválida. Actualize a consulta.');}
|
||||
abort_unless(($cut['fingerprint']??null)===$fingerprint&&($cut['revision']??null)===$revision
|
||||
&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A fonte ou o contexto mudou. Actualize o relatório.');
|
||||
$observed=$cut['observed'];
|
||||
}
|
||||
$from=$filters['from'].' 00:00:00';
|
||||
$until=(new \DateTimeImmutable($filters['to'],new \DateTimeZone('UTC')))->modify('+1 day')->format('Y-m-d').' 00:00:00';
|
||||
$q=$this->db->table('public.stock_movements AS m')
|
||||
->join('public.stock_items AS i',fn($j)=>$j->on('i.id','=','m.stock_item_id')->on('i.tenant_id','=','m.tenant_id'))
|
||||
->join('public.warehouses AS w',fn($j)=>$j->on('w.id','=','m.warehouse_id')->on('w.tenant_id','=','m.tenant_id'))
|
||||
->where('m.tenant_id',$binding['source_tenant_id'])->whereIn('m.warehouse_id',$binding['warehouse_ids'])->where('m.moved_at','<',$until);
|
||||
if($filters['warehouse_id']!==null)$q->where('m.warehouse_id',$filters['warehouse_id']);
|
||||
if($filters['search']!==''){
|
||||
$literal='%'.str_replace(['\\','%','_'],['\\\\','\\%','\\_'],$filters['search']).'%';
|
||||
$q->where(fn($s)=>$s->where('i.code','ilike',$literal)->orWhere('i.name','ilike',$literal));
|
||||
}
|
||||
$q->select(['m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code AS warehouse_code','w.name AS warehouse_name'])
|
||||
->selectRaw('SUM(CASE WHEN m.moved_at < ? THEN m.quantity_milli::numeric ELSE 0 END)::text AS opening_milli',[$from])
|
||||
->selectRaw('SUM(CASE WHEN m.moved_at >= ? AND m.quantity_milli > 0 THEN m.quantity_milli::numeric ELSE 0 END)::text AS incoming_milli',[$from])
|
||||
->selectRaw('SUM(CASE WHEN m.moved_at >= ? AND m.quantity_milli < 0 THEN -m.quantity_milli::numeric ELSE 0 END)::text AS outgoing_milli',[$from])
|
||||
->selectRaw('SUM(m.quantity_milli)::text AS closing_milli')
|
||||
->selectRaw('COUNT(*) FILTER (WHERE m.moved_at >= ?)::text AS period_movements',[$from])
|
||||
->groupBy('m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code','w.name')
|
||||
->orderBy('m.stock_item_id')->orderBy('m.warehouse_id');
|
||||
$total=(int)$this->db->query()->fromSub((clone $q)->reorder(),'stock_period_pairs')->count();
|
||||
$rows=$q->limit($filters['per_page'])->offset(($filters['page']-1)*$filters['per_page'])->get()->map(fn($r)=>(array)$r)->all();
|
||||
return ['status'=>'available','synthetic_only'=>true,'report'=>'physical_period','period'=>['from'=>$filters['from'],'to'=>$filters['to'],'timezone'=>'UTC','basis'=>'registration_time'],
|
||||
'rows'=>$rows,'pagination'=>['total'=>$total,'page'=>$filters['page'],'per_page'=>$filters['per_page'],'last_page'=>max(1,(int)ceil($total/$filters['per_page']))],
|
||||
'observed_at'=>gmdate('c',$observed),'snapshot'=>Crypt::encryptString(json_encode(['fingerprint'=>$fingerprint,'revision'=>$revision,'observed'=>$observed],JSON_THROW_ON_ERROR)),
|
||||
'ledger_revision'=>$revision,'capabilities'=>['write'=>false,'valuation'=>false,'reserved'=>false,'blocked'=>false],
|
||||
'notice'=>'Quantidades por data de registo UTC; designações actuais. Não há total somado entre unidades diferentes. Sem valorização, reservas ou disponibilidade. Pares sem história até ao fecho não são saldos zero.'];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ final class StockReadController
|
||||
{
|
||||
public function index(Request $r, CompanyContext $context, Permissions $permissions)
|
||||
{
|
||||
if (config('receipt_qa.stock_read_enabled') === true) {
|
||||
return app(CompositionStockRead::class)->index($r, $context, $permissions);
|
||||
}
|
||||
$d = $r->validate(['view' => 'sometimes|in:items,warehouses,balances,movements','search' => 'nullable|string|max:100',
|
||||
'warehouse_id' => 'sometimes|integer|min:1','page' => 'sometimes|integer|min:1|max:100000','per_page' => 'sometimes|integer|min:1|max:100',
|
||||
'snapshot' => 'sometimes|string|max:8192']);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Requisition,Quotation,PurchaseAward,PurchaseOrder,GoodsReceipt,Invoice,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\SupplierPayables;
|
||||
|
||||
/** Read-only view of existing facts, with independent authority for every stage. */
|
||||
final class DossierController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.dossiers_enabled')===true&&config('receipt_qa.requests_enabled')===true,404); }
|
||||
private function source(int $id,array $binding,object $actor):Requisition {
|
||||
abort_unless($actor->hasFunctions(['requester','procurement','finance','department_head'])&&RequisitionAccess::allowed($id,$binding),403);
|
||||
return Requisition::visibleTo($actor)->find($id)??abort(403,'Requisição fora do contexto autorizado.');
|
||||
}
|
||||
private function page($query,array $columns,string $name,array $input):array {
|
||||
$page=$query->orderBy('id')->paginate(25,$columns,$name,(int)($input[$name]??1));
|
||||
return ['accessible'=>true,'data'=>$page->items(),'pagination'=>['page'=>$page->currentPage(),'last_page'=>$page->lastPage(),'total'=>$page->total(),'per_page'=>25]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$runtime->run('purchasing_requests.view',function($a){abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);});
|
||||
return response()->view('purchasing-dossier',['context'=>$r->session()->get('company_context.token')]);
|
||||
}
|
||||
public function index(Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'sometimes|in:draft,pending,approved,rejected','search'=>'sometimes|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_requests.view',function($a,$b)use($d){abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);$q=Requisition::visibleTo($a)->whereIn('id',RequisitionAccess::ids($b));if(isset($d['status']))$q->where('status',$d['status']);if(isset($d['search']))$q->whereRaw('position(lower(?) in lower(number))>0',[$d['search']]);return response()->json($q->orderBy('id')->paginate(25,['id','number','status','process_version'],'page',(int)($d['page']??1)))->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$rules=[];foreach(['quotes','awards','orders','receipts','invoices','obligations','events']as$key)$rules[$key.'_page']='sometimes|integer|min:1|max:10000';$d=$r->validate($rules);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
$host=DB::connection();$c=app(\App\Shared\Context\CompanyContext::class);$scope=$c->identifiers()+['unit_id'=>$c->unitId(),'user_id'=>$c->userId()];
|
||||
return $runtime->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$d,$host,$scope){
|
||||
$request=$this->source($id,$b,$a);$allowed=[];
|
||||
foreach(['sourcing','orders','receipts','invoices','obligations']as$stage){$flag=$stage==='receipts'?'enabled':$stage.'_enabled';$allowed[$stage]=config('receipt_qa.'.$flag)===true&&$host->table('permission_grants')->where($scope)->where('permission','purchasing_'.$stage.'.view')->lockForUpdate()->first()!==null;}
|
||||
$viewer=$a->hasFunctions(['admin','procurement','finance','department_head']);$finance=$a->hasFunctions(['admin','finance']);$allowed['sourcing']=$allowed['sourcing']&&$viewer;$allowed['orders']=$allowed['orders']&&$viewer;$allowed['receipts']=$allowed['receipts']&&Gate::allows('viewAny',GoodsReceipt::class);$allowed['invoices']=$allowed['invoices']&&$finance;$allowed['obligations']=$allowed['obligations']&&$finance;
|
||||
$orders=fn()=>PurchaseOrder::where('tenant_id',$a->tenant_id)->where('requisition_id',$id)->select('id');
|
||||
$invoices=fn()=>Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$orders());
|
||||
$restricted=['accessible'=>false];$stages=['quotes'=>$restricted,'awards'=>$restricted,'orders'=>$restricted,'receipts'=>$restricted,'invoices'=>$restricted,'obligations'=>$restricted];$patterns=['requisition.%','sgf.request.command'];
|
||||
if($allowed['sourcing']){$stages['quotes']=$this->page(Quotation::where('requisition_id',$id),['id','reference','supplier_id','total_minor','created_by','created_at'],'quotes_page',$d);$stages['awards']=$this->page(PurchaseAward::where('requisition_id',$id),['id','quotation_id','status','total_minor','created_by','created_at'],'awards_page',$d);array_push($patterns,'quotation.%','award.%','sgf.sourcing.command');}
|
||||
if($allowed['orders']){$stages['orders']=$this->page(PurchaseOrder::visibleTo($a)->where('requisition_id',$id),['id','number','status','total_minor','issued_by','issued_at'],'orders_page',$d);array_push($patterns,'order.%','sgf.order.command');}
|
||||
if($allowed['receipts']){$stages['receipts']=$this->page(GoodsReceipt::whereIn('purchase_order_id',$orders()),['id','number','purchase_order_id','status','received_on','received_by','confirmed_at'],'receipts_page',$d);array_push($patterns,'receipt.%','sgf.receipt.command','sgf.receipt.document','sgf.receipt.document_downloaded');}
|
||||
if($allowed['invoices']){$stages['invoices']=$this->page($invoices(),['id','number','purchase_order_id','status','total_minor','created_by','validated_by','validated_at'],'invoices_page',$d);array_push($patterns,'invoice.%','sgf.invoice.command');}
|
||||
if($allowed['obligations']){
|
||||
$service=app(SupplierPayables::class);$service->readContext($a);
|
||||
// The window is on source invoices, not the complete financial universe.
|
||||
$window=$invoices()->orderBy('id')->paginate(25,['id'],'obligations_page',(int)($d['obligations_page']??1));
|
||||
$positions=$service->engine($a)->positionsPage($window->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all());
|
||||
$stages['obligations']=['accessible'=>true,'data'=>$positions['obligations'],'pagination'=>['page'=>$window->currentPage(),'last_page'=>$window->lastPage(),'total_sources'=>$window->total(),'per_page'=>25],'source_window'=>true];$patterns[]='sgf.obligation.command';
|
||||
}
|
||||
$events=AuditLog::where('requisition_id',$id)->where(function($q)use($patterns){foreach($patterns as$pattern)$q->orWhere('action','like',$pattern);});$history=$this->page($events,['id','user_id','action','created_at'],'events_page',$d);
|
||||
$view=['request'=>$request->only(['id','number','status','process_version','requester_id','total_minor']),'stages'=>$stages,'events'=>$history,'read_only'=>true,'revision_scope'=>'visible_pages_only'];
|
||||
$view=app(ProcessNavigation::class)->decorate($view,$allowed,$id);
|
||||
$view['view_revision']=hash('sha256',Payables::canonical([$fingerprint,$view]));
|
||||
return response()->json($view)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use LogicException;
|
||||
|
||||
/** Mounts the existing shared requisition adapter in a fresh, pinned QA composition. */
|
||||
final class InternalRequisitionQa
|
||||
{
|
||||
public const FILES = [
|
||||
'packages/purchasing/src/RequisitionPort.php',
|
||||
'packages/purchasing/src/RequisitionFailure.php',
|
||||
'packages/purchasing/src/InternalRequisitions.php',
|
||||
'integrations/sgf/internal-requisitions/bootstrap.php',
|
||||
'integrations/sgf/internal-requisitions/Schema.php',
|
||||
'integrations/sgf/internal-requisitions/Port.php',
|
||||
'integrations/sgf/internal-requisitions/Controller.php',
|
||||
'integrations/sgf/internal-requisitions/routes.php',
|
||||
'integrations/sgf/internal-requisitions/screen.php',
|
||||
'integrations/sgf/internal-requisitions/screen.mjs',
|
||||
];
|
||||
|
||||
public static function verifiedBundle(string $bundle, string $pin): string
|
||||
{
|
||||
$root = realpath($bundle);
|
||||
if ($root === false || is_link($bundle) || ! preg_match('/^[a-f0-9]{64}$/D', $pin)
|
||||
|| ! is_file($root.'/manifest.json') || is_link($root.'/manifest.json')
|
||||
|| ! hash_equals($pin, hash_file('sha256', $root.'/manifest.json'))) {
|
||||
throw new LogicException('REQUISITION_QA_BUNDLE_REFUSED');
|
||||
}
|
||||
$manifest = json_decode(file_get_contents($root.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
$files = $manifest['files'] ?? [];
|
||||
$expected = self::FILES; $actual = array_keys($files); sort($expected); sort($actual);
|
||||
if (($manifest['format'] ?? null) !== 'sgf.requisitions.qa-bundle.v1' || $actual !== $expected) {
|
||||
throw new LogicException('REQUISITION_QA_MANIFEST_REFUSED');
|
||||
}
|
||||
foreach ($files as $relative => $hash) {
|
||||
$file = realpath($root.'/'.$relative);
|
||||
if ($file === false || ! str_starts_with(strtolower($file), strtolower($root.DIRECTORY_SEPARATOR))
|
||||
|| ! is_file($file) || ! is_string($hash) || ! preg_match('/^[a-f0-9]{64}$/D', $hash)
|
||||
|| ! hash_equals($hash, hash_file('sha256', $file))) {
|
||||
throw new LogicException('REQUISITION_QA_SOURCE_REFUSED');
|
||||
}
|
||||
$part = $root;
|
||||
foreach (explode('/', $relative) as $segment) {
|
||||
$part .= '/'.$segment;
|
||||
if (is_link($part)) throw new LogicException('REQUISITION_QA_LINK_REFUSED');
|
||||
}
|
||||
}
|
||||
return $root;
|
||||
}
|
||||
|
||||
public static function mount(): ?string
|
||||
{
|
||||
if (config('internal_requisitions.qa_enabled') !== true) return null;
|
||||
if (! app()->environment('budget-testing')) throw new LogicException('REQUISITION_QA_ENVIRONMENT_REFUSED');
|
||||
$c = DB::connection(); $config = $c->getConfig();
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
if (! preg_match('/^[a-f0-9]{12}$/D', $run) || ($config['driver'] ?? null) !== 'pgsql'
|
||||
|| ($config['host'] ?? null) !== '127.0.0.1' || (string) ($config['port'] ?? '') !== '55439'
|
||||
|| ! preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D', $config['database'] ?? '', $m)
|
||||
|| ($config['username'] ?? null) !== 'ir_composition_'.$m[1]
|
||||
|| ! empty($config['url']) || ! empty($config['read']) || ! empty($config['write'])
|
||||
|| ($config['search_path'] ?? 'public') !== 'public') throw new LogicException('REQUISITION_QA_DATABASE_REFUSED');
|
||||
$identity = $c->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,
|
||||
rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
if ($identity->db !== $config['database'] || $identity->role !== $config['username'] || $identity->elevated
|
||||
|| $identity->cluster !== 'sgf_modular_regression_'.$run) throw new LogicException('REQUISITION_QA_IDENTITY_REFUSED');
|
||||
$bundle = self::verifiedBundle((string) config('internal_requisitions.qa_bundle'), (string) config('internal_requisitions.qa_manifest_sha256'));
|
||||
$expected = 'C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/requisition-composition-'.$m[1].'/bundle';
|
||||
if (strtolower(str_replace('\\', '/', $bundle)) !== strtolower($expected)) throw new LogicException('REQUISITION_QA_LOCATION_REFUSED');
|
||||
require_once $bundle.'/integrations/sgf/internal-requisitions/bootstrap.php';
|
||||
return $bundle.'/integrations/sgf/internal-requisitions/routes.php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{PurchaseOrder,Invoice,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{InvoiceWorkflow,ReceiptWorkflow,RequisitionWorkflow,InventoryDispositions,InventoryReversals,CurrentActor,OrganizationAccess};
|
||||
|
||||
/** Transport around the original supplier-document matching and budget-consumption workflow. */
|
||||
final class InvoiceController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.invoices_enabled')===true,404);}
|
||||
private function viewer(object $a):void{abort_unless($a->hasFunctions(['admin','finance']),403);}
|
||||
private function hash(mixed $v):string{return hash('sha256',Payables::canonical([$v]));}
|
||||
private function generation():string{return $this->hash(request()->session()->get('company_context'));}
|
||||
private function order(int $id,array $b,object $a):PurchaseOrder{$this->viewer($a);$r=PurchaseOrder::visibleTo($a)->where('id',$id)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)))->first();abort_unless($r,403,'Ordem fora da origem autorizada.');return $r;}
|
||||
private function state(PurchaseOrder $order):array{
|
||||
$lines=$order->items()->orderBy('id')->get();$effects=[];foreach($lines as$line)$effects[]=['line'=>$line->id,'returned'=>app(InventoryDispositions::class)->returnedForOrderLine($line->id),'reversed'=>app(InventoryReversals::class)->voidedReceiptQuantity($line->id,'accepted_milli')];
|
||||
return ['supplier'=>\Tempo\Purchasing\Embedded\Models\Supplier::findOrFail($order->supplier_id)->toArray(),'request'=>\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id)->load('steps')->toArray(),'conflicts'=>\Tempo\Purchasing\Embedded\Models\ConflictDeclaration::where(fn($q)=>$q->where('requisition_id',$order->requisition_id)->orWhere('supplier_id',$order->supplier_id))->orderBy('id')->get()->toArray(),'budgets'=>DB::table('budgets')->whereIn('id',DB::table('budget_entries')->where('requisition_id',$order->requisition_id)->select('budget_id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'order'=>$order->load('items')->toArray(),'receipts'=>DB::table('goods_receipt_items')->whereIn('purchase_order_item_id',$lines->pluck('id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'physical_corrections'=>$effects,'invoices'=>Invoice::where('purchase_order_id',$order->id)->with('items')->orderBy('id')->get()->toArray(),'budget_entries'=>DB::table('budget_entries')->where('requisition_id',$order->requisition_id)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'budget_consumptions'=>DB::table('budget_consumptions')->whereIn('invoice_id',Invoice::where('purchase_order_id',$order->id)->select('id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()];
|
||||
}
|
||||
private function input(Request $http,array $data,object $actor):Request{$r=Request::create('/embedded/supplier-invoice','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);$r->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$r->setLaravelSession($http->session());return $r;}
|
||||
private function dispatch(string $action,array $data,PurchaseOrder $order,object $actor,Request $http):array{
|
||||
$workflow=app(InvoiceWorkflow::class);
|
||||
if($action==='register'){
|
||||
abort_if(array_diff(array_keys($data),['number','issued_on','subtotal','tax','total','items']),422);Validator::make($data,['items'=>'required|array|min:1|max:50','items.*'=>'array:purchase_order_item_id,quantity,unit,unit_price,tax_rate'])->validate();
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\InvoiceController)->store($this->input($http,$data,$actor),$order,$workflow);
|
||||
$invoice=Invoice::where('purchase_order_id',$order->id)->where('created_by',$actor->id)->orderByDesc('id')->firstOrFail();
|
||||
}else{
|
||||
abort_if(array_diff(array_keys($data),['invoice_id']),422);$d=Validator::make($data,['invoice_id'=>'required|integer|min:1'])->validate();$invoice=Invoice::where('purchase_order_id',$order->id)->findOrFail($d['invoice_id']);$workflow->validate($actor,$invoice);$invoice->refresh();
|
||||
}
|
||||
return ['invoice'=>$invoice,'result'=>['invoice_id'=>$invoice->id,'order_id'=>$order->id,'status'=>$invoice->status],'impact'=>['operation'=>$action==='register'?'Registar factura recebida pendente':'Validar conferência da factura','invoice'=>$invoice->load('items')->toArray(),'budget_consumption_minor'=>$action==='validate'?(int)$invoice->total_minor:0,'stock_changed'=>false,'payable_created'=>false,'payment_created'=>false,'ledger_posted'=>false]];
|
||||
}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','validate']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_invoices.'.$r,$c->unitId());return $out;}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_invoices.view',fn($a)=>$this->viewer($a));$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$link=config('receipt_qa.obligations_enabled')===true&&app(\App\Modules\Identity\Services\Permissions::class)->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_obligations.view',$c->unitId());return response()->view('supplier-invoice-workspace',['obligations'=>$link,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_invoices.view',function($a,$b)use($d){$this->viewer($a);return response()->json(PurchaseOrder::visibleTo($a)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)))->orderBy('id')->paginate(25,['id','number','status','currency'],'page',(int)($d['page']??1)));});}
|
||||
public function show(int $id,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();return $rt->run('purchasing_invoices.view',function($a,$b)use($id,$rights){$order=$this->order($id,$b,$a);$state=$this->state($order);$revision=$this->hash($state);$matching=config('receipt_qa.invoice_progress_enabled')===true?app(InvoiceMatchingProgress::class)->forOrder($order,$state):null;$workflow=app(InvoiceWorkflow::class);foreach($state['invoices']as&$i)$i['can_validate']=$rights['validate']&&$workflow->reviewerEligible($a,Invoice::findOrFail($i['id']));unset($i);return response()->json(['order'=>$state['order'],'invoices'=>$state['invoices'],'revision'=>$revision,'matching_progress'=>$matching,'can_prepare'=>$rights['prepare']&&$a->hasFunctions(['finance'])&&!app(OrganizationAccess::class)->maintenanceOnly($a),'receipt_progress'=>app(ReceiptWorkflow::class)->progress($order),'physical_corrections'=>$state['physical_corrections']]);});}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_invoices.view',function($a,$b)use($id,$d){$order=$this->order($id,$b,$a);return response()->json(AuditLog::where('requisition_id',$order->requisition_id)->whereIn('action',['invoice.created','invoice.validated','sgf.invoice.command'])->orderByDesc('id')->paginate(15,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:register,validate','reason'=>'required|string|min:10|max:2000','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>65536,422);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();return $rt->run('purchasing_invoices.'.($d['action']==='register'?'prepare':'validate'),function($a,$b,$f)use($id,$d,$r,$generation){$order=$this->order($id,$b,$a);$a=app(CurrentActor::class)->lock($a,['finance']);abort_unless(hash_equals($this->hash($this->state($order)),$d['revision']),409,'A conferência mudou. Actualize a ordem.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$order,$a,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}if($d['action']==='register'){unset($out['impact']['invoice']['id']);foreach($out['impact']['invoice']['items']as&$line)unset($line['id'],$line['invoice_id']);unset($line);}$cut=$d+['order_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']+['reason'=>$d['reason']]]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['register','validate'],true),409);$generation=$this->generation();return $rt->run('purchasing_invoices.'.($cut['action']==='register'?'prepare':'validate'),function($a,$b,$f,$origin)use($id,$r,$cut,$generation){$order=$this->order($id,$b,$a);$a=app(CurrentActor::class)->lock($a,['finance']);abort_unless(($cut['order_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'O contexto ou a prévia mudou.');$hash=$this->hash([$id,$cut['action'],$cut['data'],$cut['reason']]);$old=AuditLog::where('requisition_id',$order->requisition_id)->where('user_id',$a->id)->where('action','sgf.invoice.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outro conteúdo.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($order)),$cut['revision']),409,'A base mudou desde a conferência.');$before=$this->state($order);$out=$this->dispatch($cut['action'],$cut['data'],$order,$a,$r);app(RequisitionWorkflow::class)->audit($a,'sgf.invoice.command',\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id),['invoices'=>$before['invoices']],$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$f,'reason'=>$cut['reason'],'command'=>$cut['action'],'result'=>$out['result'],'invoice'=>$out['invoice']->load('items')->toArray()]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Embedded\Services\ReceiptWorkflow;
|
||||
|
||||
/** Read projection of the native matching quantities; never a reservation or writer. */
|
||||
final class InvoiceMatchingProgress
|
||||
{
|
||||
public function quantities(int $ordered,int $accepted,int $returned,int $reversed,int $validated,int $pending):array
|
||||
{
|
||||
$net=$accepted-$returned-$reversed;$ceiling=min($ordered,$net);
|
||||
return ['ordered_milli'=>$ordered,'accepted_milli'=>$accepted,'returned_milli'=>$returned,'reversed_milli'=>$reversed,'accepted_net_milli'=>$net,'validated_milli'=>$validated,'pending_milli'=>$pending,'remaining_milli'=>max(0,$ceiling-$validated),'deficit_milli'=>max(0,$validated-$ceiling)];
|
||||
}
|
||||
public function forOrder(object $order,array $state):array
|
||||
{
|
||||
$ids=array_column($state['order']['items'],'id');if($ids===[])return [];
|
||||
$accepted=DB::table('goods_receipt_items')->where('tenant_id',$order->tenant_id)->whereIn('purchase_order_item_id',$ids)->groupBy('purchase_order_item_id')->selectRaw('purchase_order_item_id,SUM(accepted_milli) AS accepted')->get()->keyBy('purchase_order_item_id');
|
||||
$invoiced=DB::table('invoice_items as l')->join('invoices as i','i.id','=','l.invoice_id')->where(['l.tenant_id'=>$order->tenant_id,'i.tenant_id'=>$order->tenant_id,'i.purchase_order_id'=>$order->id])->whereIn('l.purchase_order_item_id',$ids)->groupBy('l.purchase_order_item_id')->selectRaw("l.purchase_order_item_id,SUM(CASE WHEN i.status='validated' THEN l.quantity_milli ELSE 0 END) AS validated,SUM(CASE WHEN i.status='pending' THEN l.quantity_milli ELSE 0 END) AS pending")->get()->keyBy('purchase_order_item_id');
|
||||
$effects=array_column($state['physical_corrections'],null,'line');$rows=[];
|
||||
foreach($state['order']['items']as$line){$id=$line['id'];
|
||||
$rows[]=['line_id'=>$id,'description'=>$line['description'],'unit'=>$line['unit']]+$this->quantities(ReceiptWorkflow::milli($line['quantity']),(int)($accepted[$id]->accepted??0),(int)($effects[$id]['returned']??0),(int)($effects[$id]['reversed']??0),(int)($invoiced[$id]->validated??0),(int)($invoiced[$id]->pending??0));
|
||||
}return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Invoice,PurchaseOrder,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{SupplierPayables,RequisitionWorkflow};
|
||||
|
||||
/** Explicit recognition in the original native book; no imported mirror or second writer. */
|
||||
final class ObligationController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.obligations_enabled')===true,404); }
|
||||
private function hash(array $value):string { return hash('sha256',Payables::canonical($value)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function orders(object $a,array $b) {
|
||||
return PurchaseOrder::visibleTo($a)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)));
|
||||
}
|
||||
private function invoice(int $id,object $a,array $b):Invoice {
|
||||
return Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$this->orders($a,$b)->select('id'))->find($id)??abort(403,'Documento fora da origem autorizada.');
|
||||
}
|
||||
private function adapter(object $a):SupplierPayables {
|
||||
$service=app(SupplierPayables::class);$service->readContext($a);return $service;
|
||||
}
|
||||
private function state(object $a,Invoice $invoice,SupplierPayables $service):array {
|
||||
$scope=['tenant_id'=>$a->tenant_id,'company_id'=>0,'unit_id'=>0];
|
||||
$sources=DB::table('pa_sources')->where($scope)->where('local_source_id',$invoice->id)->select('id');
|
||||
return ['document'=>$service->document($a,$invoice->id),
|
||||
'proposals'=>DB::table('pa_proposals')->where($scope)->whereIn('source_id',$sources)->orderByDesc('id')->limit(1)->get()->all(),
|
||||
'obligations'=>DB::table('pa_obligations')->where($scope)->whereIn('source_id',$sources)->orderBy('id')->get()->all(),
|
||||
'identity'=>DB::table('pa_access')->where($scope)->where('user_id',$a->id)->orderBy('action')->get(['action','person'])->all()];
|
||||
}
|
||||
private function rights():array {
|
||||
$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$rights=[];
|
||||
foreach(['prepare','validate']as$r)$rights[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_obligations.'.$r,$c->unitId());return $rights;
|
||||
}
|
||||
private function commandId(string $action,int $proposal,Invoice $invoice,object $a):int {
|
||||
if($action==='prepare')return $invoice->id;
|
||||
abort_unless(DB::table('pa_proposals as p')->join('pa_sources as s','s.id','=','p.source_id')->where(['p.id'=>$proposal,'p.tenant_id'=>$a->tenant_id,'p.company_id'=>0,'p.unit_id'=>0,'s.tenant_id'=>$a->tenant_id,'s.company_id'=>0,'s.unit_id'=>0,'s.local_source_id'=>$invoice->id])->exists(),403,'Proposta fora do documento autorizado.');return $proposal;
|
||||
}
|
||||
private function execute(Payables $engine,array $cut,Invoice $invoice,object $a):array {
|
||||
try{return $engine->command($cut['action'],$this->commandId($cut['action'],$cut['proposal_id'],$invoice,$a),$cut['reason'],$cut['operation_key']);}
|
||||
catch(\DomainException|\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$rt->run('purchasing_obligations.view',fn($a)=>$this->adapter($a));return response()->view('supplier-obligation-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);
|
||||
}
|
||||
public function index(Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'sometimes|in:pending,validated','search'=>'sometimes|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $rt->run('purchasing_obligations.view',function($a,$b)use($d){$this->adapter($a);$q=Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$this->orders($a,$b)->select('id'));if(isset($d['status']))$q->where('status',$d['status']);if(isset($d['search']))$q->whereRaw('position(? in number)>0',[$d['search']]);return response()->json($q->orderBy('id')->paginate(25,['id','number','status','total_minor','purchase_order_id'],'page',(int)($d['page']??1)));});
|
||||
}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['proposal_page'=>'sometimes|integer|min:1|max:10000','event_page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$rights=$this->rights();
|
||||
return $rt->run('purchasing_obligations.view',function($a,$b)use($id,$d,$rights){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);$out=$service->engine($a)->listingPage([$id],$d['proposal_page']??1,$d['event_page']??1,25);foreach($rights as$right=>$allowed)$out['permissions'][$right]=$allowed&&$service->can($a,$right);return response()->json($out+['revision'=>$this->hash($this->state($a,$invoice,$service))]);});
|
||||
}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['action'=>'required|in:prepare,validate','proposal_id'=>'required|integer|min:0','operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();
|
||||
return $rt->run('purchasing_obligations.'.$d['action'],function($a,$b,$f)use($id,$d,$generation){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);abort_unless(hash_equals($this->hash($this->state($a,$invoice,$service)),$d['revision']),409,'A conferência mudou.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$result=$this->execute($service->engine($a),$d,$invoice,$a);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
unset($result['proposal_id'],$result['obligation_id']);$cut=$d+['person_revision'=>$this->hash($this->state($a,$invoice,$service)['identity']),'invoice_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['result'=>$result,'principal_minor'=>$invoice->total_minor,'due_on'=>null,'due_status'=>'unknown','budget_changed'=>false,'stock_changed'=>false,'payment_created'=>false,'ledger_posted'=>false,'reason'=>$d['reason']]]);});
|
||||
}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['prepare','validate'],true),409);$generation=$this->generation();
|
||||
return $rt->run('purchasing_obligations.'.$cut['action'],function($a,$b,$f,$origin)use($id,$cut,$generation){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);$engine=$service->engine($a);abort_unless(($cut['invoice_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'O contexto ou a prévia mudou.');
|
||||
// A committed retry still calls the kernel: current source/payment eligibility is rechecked.
|
||||
$receipt=DB::table('pa_commands')->where(['tenant_id'=>$a->tenant_id,'company_id'=>0,'unit_id'=>0,'actor_id'=>$a->id,'command_key'=>$cut['operation_key']])->first();if($receipt)abort_unless((json_decode($receipt->result,true)['local_source_id']??null)===$id,409);
|
||||
$before=$this->state($a,$invoice,$service);abort_unless(($cut['person_revision']??null)===$this->hash($before['identity']),409,'Identidade alterada.');if(!$receipt)abort_unless(hash_equals($this->hash($before),$cut['revision']),409,'A base mudou desde a conferência.');
|
||||
$result=$this->execute($engine,$cut,$invoice,$a);
|
||||
if(!($result['replayed']??false))app(RequisitionWorkflow::class)->audit($a,'sgf.obligation.command',Requisition::findOrFail(PurchaseOrder::findOrFail($invoice->purchase_order_id)->requisition_id),$before,$origin+['operation_key'=>$cut['operation_key'],'reason'=>$cut['reason'],'result'=>$result,'after'=>$this->state($a,$invoice,$service)]);
|
||||
return response()->json($result,($result['state']??'')==='conflict'?409:(($result['replayed']??false)?200:201));});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Tempo\Purchasing\Embedded\Models\{PurchaseAward,PurchaseOrder,Requisition,Quotation};
|
||||
use Tempo\Purchasing\Embedded\Services\{PurchaseWorkflow,ProposalComparison,RequisitionWorkflow};
|
||||
|
||||
/** Issuance from an already approved award; no supplier selection or approval by this controller. */
|
||||
final class OrderController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.orders_enabled')===true,404);}
|
||||
private function viewActor(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function award(int $id,array $binding,object $actor):array{
|
||||
$this->viewActor($actor);
|
||||
$award=PurchaseAward::where('id',$id)->where(fn($q)=>$q->whereIn('id',$binding['award_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();abort_unless($award,403);
|
||||
$request=Requisition::visibleTo($actor)->findOrFail($award->requisition_id);
|
||||
return [$award,$request];
|
||||
}
|
||||
private function basis(PurchaseAward $award,Requisition $request):array{
|
||||
$quote=Quotation::findOrFail($award->quotation_id)->load(['items','supplier']);
|
||||
$basis=['award'=>$award->load('approvals')->toArray(),'requisition'=>$request->load(['items','steps'])->toArray(),'quote'=>$quote->toArray(),'comparison'=>app(ProposalComparison::class)->inspect($request)];
|
||||
return ['revision'=>hash('sha256',json_encode($basis,JSON_THROW_ON_ERROR)),'basis'=>$basis];
|
||||
}
|
||||
private function output(PurchaseOrder $order,bool $replayed):array{return ['order'=>$order->load('items')->toArray(),'replayed'=>$replayed,'receipt_url'=>'/purchasing/receipts','stock_created'=>false,'invoice_created'=>false,'payment_created'=>false];}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$this->guard();$runtime->run('purchasing_orders.view',fn($actor)=>$this->viewActor($actor));return response()->view('order-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_orders.view',function($actor,$binding)use($d){$this->viewActor($actor);return response()->json(PurchaseAward::where(fn($q)=>$q->whereIn('id',$binding['award_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->whereIn('requisition_id',Requisition::visibleTo($actor)->select('id'))->orderBy('id')->paginate(25,['id','requisition_id','status','total_minor'],'page',(int)($d['page']??1)));});}
|
||||
public function show(int $id,ReceiptRuntime $runtime){$this->guard();$ctx=app(\App\Shared\Context\CompanyContext::class);$scope=$ctx->identifiers();$can=app(\App\Modules\Identity\Services\Permissions::class)->has($ctx->userId(),$scope['tenant_id'],$scope['company_id'],'purchasing_orders.issue',$ctx->unitId());return $runtime->run('purchasing_orders.view',function($actor,$binding)use($id,$can){[$award,$request]=$this->award($id,$binding,$actor);$basis=$this->basis($award,$request);$order=PurchaseOrder::where('requisition_id',$request->id)->first();return response()->json($basis+['can_issue'=>$can&&app(PurchaseWorkflow::class)->canIssue($actor,$award,$request),'existing'=>$order?$this->output($order,true):null]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['revision'=>'required|string|size:64']);abort_if(array_diff(array_keys($r->all()),['revision']),422);return $runtime->run('purchasing_orders.issue',function($actor,$binding,$fingerprint)use($id,$d){[$award,$request]=$this->award($id,$binding,$actor);abort_unless(app(PurchaseWorkflow::class)->canIssue($actor,$award,$request),403);$basis=$this->basis($award,$request);abort_unless(hash_equals($basis['revision'],$d['revision']),409,'A base aprovada mudou. Confira novamente.');$cut=['award'=>$id,'actor'=>$actor->id,'binding'=>$fingerprint,'revision'=>$basis['revision'],'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['supplier'=>$basis['basis']['quote']['supplier']['name'],'total_minor'=>$award->total_minor,'currency'=>$basis['basis']['quote']['currency'],'delivery_date'=>$basis['basis']['award']['delivery_date'],'delivery_location'=>$award->delivery_location,'items'=>$basis['basis']['quote']['items'],'budget'=>'O workflow original revalida e regista o compromisso, se configurado.','stock'=>false,'invoice'=>false,'payment'=>false]]);});}
|
||||
public function issue(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['preview'=>'required|string|max:32768']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}return $runtime->run('purchasing_orders.issue',function($actor,$binding,$fingerprint,$origin)use($id,$cut){[$award,$request]=$this->award($id,$binding,$actor);abort_unless(($cut['award']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O contexto ou a prévia mudou.');$existing=PurchaseOrder::where('requisition_id',$request->id)->first();abort_unless($existing||hash_equals($this->basis($award,$request)['revision'],$cut['revision']),409,'A base mudou desde a conferência.');$order=app(PurchaseWorkflow::class)->issue($actor,$award);if(!$existing)app(RequisitionWorkflow::class)->audit($actor,'sgf.order.issued',$request,null,$origin+['order_id'=>$order->id,'award_id'=>$award->id,'binding_revision'=>$fingerprint]);return response()->json($this->output($order,(bool)$existing),$existing?200:201);});}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Tempo\Purchasing\Embedded\Models\AuditLog;
|
||||
|
||||
/** A stage's history must not become a back door into downstream financial payloads. */
|
||||
final class ProcessHistory
|
||||
{
|
||||
public function query(int $request,string $stage)
|
||||
{
|
||||
$patterns=match($stage){
|
||||
'requests'=>['requisition.%','sgf.request.command'],
|
||||
'sourcing'=>['requisition.%','sgf.request.command','quotation.%','award.%','sgf.sourcing.command'],
|
||||
default=>throw new \InvalidArgumentException('Unknown history stage'),
|
||||
};
|
||||
return AuditLog::where('requisition_id',$request)->where(function($q)use($patterns){foreach($patterns as$pattern)$q->orWhere('action','like',$pattern);});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
/** Links to already visible records; every destination retains its own authority checks. */
|
||||
final class ProcessNavigation
|
||||
{
|
||||
public function decorate(array $view,array $allowed,int $request):array
|
||||
{
|
||||
if(config('receipt_qa.focus_enabled')!==true)return $view;
|
||||
$link=fn(string $stage,array $ids,string $label)=>['href'=>'/purchasing/'.$stage.'?'.http_build_query($ids),'label'=>$label];
|
||||
$view['_links']=[$link('requests',['request'=>$request],'Consultar esta requisição')];
|
||||
if($allowed['sourcing'])$view['_links'][]=$link('sourcing',['request'=>$request],'Consultar propostas desta requisição');
|
||||
foreach($view['stages']as$key=>&$stage){
|
||||
if(!($stage['accessible']??false))continue;
|
||||
$stage['data']=array_map(function($record)use($key,$allowed,$request,$link){
|
||||
$row=is_array($record)?$record:(method_exists($record,'toArray')?$record->toArray():(array)$record);$links=[];
|
||||
if($key==='quotes'&&$allowed['sourcing'])$links[]=$link('sourcing',['request'=>$request],'Consultar propostas desta requisição');
|
||||
if($key==='awards'&&$allowed['orders'])$links[]=$link('orders',['award'=>$row['id']],'Consultar esta adjudicação e ordem');
|
||||
if($key==='orders'){
|
||||
if($allowed['receipts'])$links[]=$link('receipts',['order'=>$row['id']],'Consultar recepções desta ordem');
|
||||
if($allowed['invoices'])$links[]=$link('invoices',['order'=>$row['id']],'Consultar facturas desta ordem');
|
||||
}
|
||||
if($key==='receipts'&&$allowed['receipts'])$links[]=$link('receipts',['order'=>$row['purchase_order_id'],'receipt'=>$row['id']],'Consultar esta recepção e os seus anexos');
|
||||
if($key==='invoices'){
|
||||
if($allowed['invoices'])$links[]=$link('invoices',['order'=>$row['purchase_order_id'],'invoice'=>$row['id']],'Consultar esta factura');
|
||||
if($allowed['obligations'])$links[]=$link('obligations',['invoice'=>$row['id']],'Consultar obrigação desta factura');
|
||||
}
|
||||
return $row+['_links'=>$links];
|
||||
},$stage['data']);
|
||||
}unset($stage);return $view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Explicit access to existing synthetic identities, only in the selected local installation. */
|
||||
final class PurchasingEntry
|
||||
{
|
||||
public static function profiles(array $fixture):array
|
||||
{
|
||||
$definitions=['requester'=>['Solicitante','Prepara e submete a necessidade.'],'approver'=>['Chefia','Decide requisições e adjudicações por identidade distinta.'],'actor'=>['Compras','Prepara fornecedores, propostas, comparação e ordens.'],'issuer'=>['Finanças','Revê fornecedores, avalia propostas, regista facturas e prepara obrigações.'],'validator'=>['Armazém','Confere e confirma a recepção física.'],'reviewer'=>['Revisor financeiro','Valida facturas e obrigações por identidade distinta.'],'auditor'=>['Consulta financeira','Consulta autorizada dos documentos financeiros.']];
|
||||
$stages=['requests'=>'Requisições','suppliers'=>'Fornecedores','sourcing'=>'Propostas e adjudicação','orders'=>'Ordens','receipts'=>'Recepções','invoices'=>'Facturas','obligations'=>'Obrigações'];$profiles=[];
|
||||
$grants=DB::table('permission_grants')->where($fixture['scope'])->whereIn('user_id',array_intersect_key($fixture,$definitions))->get(['user_id','permission'])->groupBy('user_id');
|
||||
foreach($definitions as$key=>[$label,$description]){
|
||||
if(!isset($fixture[$key]))continue;$available=[];
|
||||
$rights=($grants[$fixture[$key]]??collect())->pluck('permission')->all();foreach($stages as$stage=>$name)if(in_array('purchasing_'.$stage.'.view',$rights,true))$available[$stage]=$name;
|
||||
$profiles[$key]=['user_id'=>$fixture[$key],'label'=>$label,'description'=>$description,'stages'=>$available];
|
||||
}return $profiles;
|
||||
}
|
||||
private static function guard():void
|
||||
{
|
||||
abort_unless(config('purchasing_workspace.single_installation')===true&&app()->environment('budget-testing')&&in_array(request()->ip(),['127.0.0.1','::1'],true),404);
|
||||
BudgetAdapter::guard();
|
||||
$id=(string)config('purchasing_workspace.id');$db=DB::connection();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$id)&&preg_match('/^[a-f0-9]{12}$/D',$run)
|
||||
&&$db->getDatabaseName()==='budget_core_sgf_'.$id&&$db->getConfig('username')==='ir_composition_'.$id&&(int)$db->getConfig('port')===55439,403);
|
||||
$identity=$db->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$identity->elevated&&$identity->role==='ir_composition_'.$id&&$identity->cluster==='sgf_modular_regression_'.$run,403);
|
||||
}
|
||||
public function screen(Request $r)
|
||||
{
|
||||
self::guard();$scope=config('purchasing_workspace.scope');
|
||||
return response()->view('purchasing-entry',['profiles'=>config('purchasing_workspace.profiles'),'company'=>DB::table('companies')->find($scope['company_id']),'unit'=>DB::table('units')->find($scope['unit_id']),'returnTo'=>(string)$r->query('return_to','')])->header('Cache-Control','no-store')->header('X-SGF-Installation',config('purchasing_workspace.id'));
|
||||
}
|
||||
public function enter(Request $r)
|
||||
{
|
||||
self::guard();$d=$r->validate(['role'=>'required|string','stage'=>'required|string','return_to'=>'nullable|string|max:300']);abort_if(array_diff(array_keys($r->all()),['_token','role','stage','return_to']),422);
|
||||
$profile=config('purchasing_workspace.profiles.'.$d['role']);abort_unless(is_array($profile)&&isset($profile['stages'][$d['stage']]),403);
|
||||
$target='/purchasing/'.$d['stage'];
|
||||
if(!empty($d['return_to'])){
|
||||
$parts=parse_url($d['return_to']);abort_unless(is_array($parts)&&!isset($parts['scheme'])&&!isset($parts['host'])&&!isset($parts['fragment'])&&($parts['path']??null)===$target,422);
|
||||
parse_str($parts['query']??'',$query);$allowed=['requests'=>['request'],'suppliers'=>['supplier'],'sourcing'=>['request'],'orders'=>['award'],'receipts'=>['order','receipt','movement'],'invoices'=>['order','invoice'],'obligations'=>['invoice']][$d['stage']];
|
||||
abort_if(array_diff(array_keys($query),$allowed),422);foreach($query as$v)abort_unless(is_string($v)&&preg_match('/^[1-9][0-9]{0,14}$/D',$v),422);
|
||||
$pairs=isset($parts['query'])?explode('&',$parts['query']):[];abort_unless(count($pairs)===count($query),422);
|
||||
if($query){$primary=$d['stage']==='receipts'&&isset($query['movement'])?'movement':$allowed[0];abort_unless(isset($query[$primary]),422);if($primary==='movement')abort_unless(count($query)===1,422);}
|
||||
$target.=isset($parts['query'])?'?'.http_build_query($query):'';
|
||||
}
|
||||
$user=User::findOrFail($profile['user_id']);auth()->login($user);$scope=config('purchasing_workspace.scope');$r->session()->put('auth_version',$user->auth_version);
|
||||
$r->session()->put('company_context',$scope+['module_id'=>'purchasing','token'=>bin2hex(random_bytes(24))]);
|
||||
return redirect($target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{StockItem,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{SupplyPlanning,EquipmentWorkflow};
|
||||
|
||||
/** Bounded catalogue discovery; the existing receipt workflow remains the physical authority. */
|
||||
final class ReceiptCatalogue
|
||||
{
|
||||
public function query(object $order,object $line) {
|
||||
$q=StockItem::query()->whereRaw('lower(btrim(unit))=lower(btrim(?))',[$line->unit]);
|
||||
if($line->purchase_type==='service')return $q->whereRaw('1=0');
|
||||
$fixed=$order->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id']??null;
|
||||
if($fixed!==null)$q->whereKey($fixed);
|
||||
if(Schema::hasTable('supply_records')){
|
||||
$mapping=app(SupplyPlanning::class)->latest('delivery')->where('purchase_order_id',$order->id)->where('data->purchase_order_item_id',$line->id)->first();
|
||||
if($mapping)$q->whereKey($mapping->stock_item_id);
|
||||
}
|
||||
if($line->purchase_type==='spare_part'){
|
||||
abort_unless($line->spare_part_id&&$line->equipment_id,422,'A ordem não contém associação autorizada da peça.');
|
||||
$part=app(EquipmentWorkflow::class)->linkedPart((int)$line->spare_part_id,(int)$line->equipment_id,Requisition::findOrFail($order->requisition_id)->department_id);$q->whereKey($part->stock_item_id);
|
||||
}
|
||||
return $q;
|
||||
}
|
||||
public function revision(StockItem $item):string { return hash('sha256',Payables::canonical($item->toArray())); }
|
||||
public function selected(array $data):array {
|
||||
$ids=array_values(array_unique(array_filter(array_column($data['items'],'stock_item_id'))));sort($ids,SORT_NUMERIC);$revisions=[];
|
||||
foreach($ids as$id){$item=StockItem::whereKey($id)->lockForUpdate()->first();abort_unless($item,422,'Artigo fora do catálogo autorizado.');$revisions[(string)$id]=$this->revision($item);}
|
||||
ksort($revisions);return $revisions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate,Validator};
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder,StockItem,Warehouse};
|
||||
use Tempo\Purchasing\Embedded\Services\ReceiptWorkflow;
|
||||
|
||||
final class ReceiptController
|
||||
{
|
||||
private function canConfirm():bool{$ctx=app(\App\Shared\Context\CompanyContext::class);$s=$ctx->identifiers();return app(\App\Modules\Identity\Services\Permissions::class)->has($ctx->userId(),$s['tenant_id'],$s['company_id'],'purchasing_receipts.confirm',$ctx->unitId());}
|
||||
private function order(int $id,array $binding):PurchaseOrder{$order=PurchaseOrder::where('id',$id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);return $order;}
|
||||
private function revision(PurchaseOrder $order,array $binding):string{return hash('sha256',json_encode([$order->getAttributes(),app(ReceiptWorkflow::class)->progress($order),$binding],JSON_THROW_ON_ERROR));}
|
||||
private function result(GoodsReceipt $receipt,bool $replayed):array{return ['receipt'=>$receipt->load('items')->toArray(),'replayed'=>$replayed,'movements'=>DB::table('stock_movements')->where('tenant_id',$receipt->tenant_id)->whereIn('goods_receipt_item_id',$receipt->items->pluck('id'))->orderBy('id')->get(['id','goods_receipt_item_id','quantity_milli','warehouse_id','stock_item_id','movement_type'])->all()];}
|
||||
public function catalogue(int $id,int $line,Request $r,ReceiptRuntime $runtime) {
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_receipts.confirm',function($actor,$binding)use($id,$line,$d){$order=$this->order($id,$binding);Gate::authorize('create',[GoodsReceipt::class,$order]);$source=$order->items()->find($line);abort_unless($source,403,'Linha fora da ordem autorizada.');$catalogue=app(ReceiptCatalogue::class);$q=$catalogue->query($order,$source);if(isset($d['search'])&&$d['search']!=='')$q->where(fn($w)=>$w->whereRaw('position(lower(?) in lower(code))>0',[$d['search']])->orWhereRaw('position(lower(?) in lower(name))>0',[$d['search']]));$page=$q->orderBy('code')->orderBy('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$page->through(fn($item)=>$item->only(['id','code','name','unit'])+['selection_revision'=>$catalogue->revision($item)]);return response()->json($page)->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$d){$order=$this->order($id,$binding);return response()->json(GoodsReceipt::where('purchase_order_id',$order->id)->orderByDesc('id')->paginate(25,['id','number','status','received_on','received_by'],'page',(int)($d['page']??1)))->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$runtime->run('purchasing_receipts.view',fn()=>true);return response()->view('receipt-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($d){Gate::authorize('viewAny',GoodsReceipt::class);$orders=PurchaseOrder::where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->orderBy('id')->paginate((int)($d['per_page']??25),['id','number','status'], 'page',(int)($d['page']??1));return response()->json($orders);});}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$can=$this->canConfirm();return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$d,$can){$order=$this->order($id,$binding);return response()->json(['order'=>$order->only(['id','number','status']),'progress'=>app(ReceiptWorkflow::class)->progress($order),'revision'=>$this->revision($order,$binding),'can_confirm'=>$can&&Gate::allows('create',[GoodsReceipt::class,$order]),'warehouses'=>Warehouse::whereIn('id',$binding['warehouse_ids'])->orderBy('id')->get(['id','code','name']),'items'=>StockItem::orderBy('id')->limit(25)->get(['id','code','name','unit']),'history'=>GoodsReceipt::where('purchase_order_id',$id)->orderByDesc('id')->paginate(25,['id','number','status','received_on','received_by'],'page',(int)($d['page']??1))]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){return $runtime->run('purchasing_receipts.confirm',function($actor,$binding,$fingerprint)use($id,$r){$order=$this->order($id,$binding);Gate::authorize('create',[GoodsReceipt::class,$order]);$input=$r->all();$revision=$input['revision']??null;$selected=$input['item_revisions']??null;unset($input['revision'],$input['item_revisions']);abort_if(array_diff(array_keys($input),['idempotency_key','delivery_note','received_on','warehouse_id','items']),422);$rules=(new \Tempo\Purchasing\Embedded\Http\Requests\ConfirmReceiptRequest)->setUserResolver(fn()=>$actor)->rules();$data=Validator::make($input,$rules)->validate();abort_unless(in_array((int)($data['warehouse_id']??0),$binding['warehouse_ids'],true),403);abort_unless(is_string($revision)&&hash_equals($this->revision($order,$binding),$revision),409,'A origem mudou. Actualize e confira novamente.');$items=app(ReceiptCatalogue::class)->selected($data);if($selected!==null){Validator::make(['selected'=>$selected],['selected'=>'array|max:50','selected.*'=>'required|string|size:64'])->validate();foreach($selected as$key=>$value)abort_unless(isset($items[(string)$key])&&hash_equals($items[(string)$key],$value),409,'O artigo mudou desde a escolha. Pesquise novamente.');} $cut=['item_revisions'=>$items,'actor'=>$actor->id,'fingerprint'=>$fingerprint,'order'=>$id,'revision'=>$revision,'data'=>$data,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['document'=>'Recepção confirmada, com histórico imutável','stock'=>'Apenas quantidades físicas aceites geram entrada, uma única vez.','payments'=>false,'accounting'=>false],'data'=>$data]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime){$d=$r->validate(['preview'=>'required|string|max:100000']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida. Confira novamente.');}return $runtime->run('purchasing_receipts.confirm',function($actor,$binding,$fingerprint,$origin)use($id,$cut){$order=$this->order($id,$binding);abort_unless(($cut['actor']??null)===$actor->id&&($cut['order']??null)===$id&&($cut['fingerprint']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou o contexto mudou. Confira novamente.');$existing=GoodsReceipt::where('purchase_order_id',$id)->where('idempotency_key',$cut['data']['idempotency_key'])->first();abort_unless($existing||hash_equals($this->revision($order,$binding),$cut['revision']),409,'Recepção alterada por outra operação. Confira novamente.');if(!$existing){$current=app(ReceiptCatalogue::class)->selected($cut['data']);abort_unless(($cut['item_revisions']??null)===$current,409,'O artigo mudou desde a previa. Confira novamente.');}$receipt=app(ReceiptWorkflow::class)->confirm($actor,$order,$cut['data']);if(!$existing)app(\Tempo\Purchasing\Embedded\Services\RequisitionWorkflow::class)->audit($actor,'sgf.receipt.confirmed',\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id),null,$origin+['receipt_id'=>$receipt->id,'binding_revision'=>$fingerprint]);return response()->json($this->result($receipt,(bool)$existing),$existing?200:201);});}
|
||||
public function receipt(int $id,ReceiptRuntime $runtime){$can=$this->canConfirm();return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$can){$receipt=GoodsReceipt::findOrFail($id);$this->order($receipt->purchase_order_id,$binding);Gate::authorize('view',$receipt);return response()->json($this->result($receipt,true)+['documents_enabled'=>config('receipt_qa.receipt_documents_enabled')===true,'document_revision'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical([$receipt->getAttributes()])),'can_attach'=>$can&&config('receipt_qa.receipt_documents_enabled')===true&&app(\Tempo\Purchasing\Embedded\Services\ReceiptDocuments::class)->canAdd($actor,$receipt)]);});}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\{Request,UploadedFile};
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder,ReceiptDocument,AuditLog,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{ReceiptDocuments,RequisitionWorkflow};
|
||||
|
||||
/** Private transport; all attachment decisions and writes belong to the pinned native service. */
|
||||
final class ReceiptDocumentsController
|
||||
{
|
||||
private const FIELDS=['id','goods_receipt_id','document_type','original_name','mime_type','size','sha256','uploaded_by','created_at'];
|
||||
private function guard():void { abort_unless(config('receipt_qa.receipt_documents_enabled')===true,404); }
|
||||
private function hash(array $v):string { return hash('sha256',Payables::canonical($v)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function source(int $id,array $binding):GoodsReceipt {
|
||||
$receipt=GoodsReceipt::findOrFail($id);
|
||||
$order=PurchaseOrder::where('id',$receipt->purchase_order_id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();
|
||||
abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);Gate::authorize('view',$receipt);return $receipt;
|
||||
}
|
||||
private function meta(UploadedFile $file):array { return ['sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize(),'mime'=>$file->getMimeType(),'name'=>$file->getClientOriginalName()]; }
|
||||
private function integrity(ReceiptDocument $doc,string $root):void {
|
||||
$file=app(SupplierDocumentFiles::class)->path($root,$doc->path,(int)$doc->tenant_id,(int)$doc->goods_receipt_id);
|
||||
abort_unless(filesize($file)===(int)$doc->size&&hash_equals($doc->sha256,hash_file('sha256',$file)),409,'O ficheiro difere da evidência registada.');
|
||||
}
|
||||
private function same(GoodsReceipt $receipt,array $cut):?ReceiptDocument { return ReceiptDocument::where('goods_receipt_id',$receipt->id)->where('document_type',$cut['document_type'])->where('sha256',$cut['file']['sha256'])->first(); }
|
||||
public function index(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_receipts.view',function($a,$b)use($id,$d){$receipt=$this->source($id,$b);abort_unless(app(ReceiptDocuments::class)->canRead($a,$receipt),403);return response()->json(ReceiptDocument::where('goods_receipt_id',$id)->orderByDesc('id')->paginate(15,self::FIELDS,'page',(int)($d['page']??1)))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','document_type'=>'required|string|max:30','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);unset($d['file']);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();$meta=$this->meta($r->file('file'));
|
||||
return $runtime->run('purchasing_receipts.confirm',function($a,$b,$f)use($id,$r,$d,$generation,$meta){$receipt=$this->source($id,$b);$service=app(ReceiptDocuments::class);abort_unless($service->canAdd($a,$receipt),403);abort_unless(hash_equals($this->hash([$receipt->getAttributes()]),$d['revision']),409,'A recepção mudou. Confira novamente.');$files=app(SupplierDocumentFiles::class);$stage=$files->root(true,'receipt');$db=DB::connection();$level=$db->transactionLevel();$document=null;$existing=$this->same($receipt,$d+['file'=>$meta]);$db->beginTransaction();
|
||||
try{$document=$files->using($stage,fn()=>$service->add($a,$receipt,$d,$r->file('file')),'receipt_documents');$impact=['document'=>$document->only(self::FIELDS),'reused'=>$existing!==null,'receipt_unchanged'=>true,'stock_unchanged'=>true,'reason'=>$d['reason']];unset($impact['document']['id'],$impact['document']['created_at']);}
|
||||
finally{while($db->transactionLevel()>$level)$db->rollBack();if($document&&!$existing)$files->discard($stage,$document->path,(int)$receipt->tenant_id,$id,'receipt_documents');}
|
||||
// A reused document belongs to permanent storage; never delete or stage it.
|
||||
if($existing)$this->integrity($existing,$files->root(false,'receipt'));
|
||||
$cut=$d+['receipt_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'file'=>$meta,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$impact]);});
|
||||
}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$meta=$this->meta($r->file('file'));$generation=$this->generation();$created=null;
|
||||
try{return $runtime->run('purchasing_receipts.confirm',function($a,$b,$f,$o)use($id,$r,$cut,$meta,$generation,&$created){$receipt=$this->source($id,$b);$service=app(ReceiptDocuments::class);abort_unless($service->canAdd($a,$receipt),403);
|
||||
abort_unless(($cut['receipt_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&($cut['file']??null)===$meta&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O ficheiro, contexto ou prévia mudou.');
|
||||
$hash=$this->hash([$id,$cut['file'],$cut['document_type'],$cut['reason']]);$files=app(SupplierDocumentFiles::class);$root=$files->root(false,'receipt');$old=AuditLog::where('action','sgf.receipt.document')->where('after->operation_key',$cut['operation_key'])->first();
|
||||
if($old){abort_unless($old->user_id===$a->id&&($old->after['request_hash']??null)===$hash&&($old->after['binding']??null)===$f,409,'Chave usada com outro conteúdo.');$doc=ReceiptDocument::where('goods_receipt_id',$id)->findOrFail($old->after['result']['document_id']);$this->integrity($doc,$root);return response()->json($old->after['result']+['replayed'=>true]);}
|
||||
abort_unless(hash_equals($this->hash([$receipt->getAttributes()]),$cut['revision']),409,'A recepção mudou desde a conferência.');$existing=$this->same($receipt,$cut);$document=$files->using($root,fn()=>$service->add($a,$receipt,$cut,$r->file('file')),'receipt_documents');if(!$existing)$created=['root'=>$root,'path'=>$document->path,'tenant'=>(int)$receipt->tenant_id,'receipt'=>$id];$this->integrity($document,$root);
|
||||
$result=['receipt_id'=>$id,'document_id'=>$document->id,'document'=>$document->only(self::FIELDS),'reused'=>$existing!==null,'receipt_unchanged'=>true,'stock_unchanged'=>true];$order=PurchaseOrder::findOrFail($receipt->purchase_order_id);app(RequisitionWorkflow::class)->audit($a,'sgf.receipt.document',Requisition::findOrFail($order->requisition_id),null,$o+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding'=>$f,'reason'=>$cut['reason'],'result'=>$result]);return response()->json($result+['replayed'=>false],201);});}
|
||||
catch(\Throwable $e){if($created){try{$exists=DB::connection('receipt_native')->table('receipt_documents')->where(['tenant_id'=>$created['tenant'],'goods_receipt_id'=>$id,'path'=>$created['path']])->exists();if(!$exists)app(SupplierDocumentFiles::class)->discard($created['root'],$created['path'],$created['tenant'],$id,'receipt_documents');}catch(\Throwable){/* An uncertain commit retains its evidence. */}}throw $e;}
|
||||
}
|
||||
public function download(int $id,int $document,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();abort_if($r->query(),422);return $runtime->run('purchasing_receipts.view',function($a,$b,$f,$o)use($id,$document,$r){$receipt=$this->source($id,$b);$doc=ReceiptDocument::where('goods_receipt_id',$id)->findOrFail($document);$files=app(SupplierDocumentFiles::class);$root=$files->root(false,'receipt');$files->path($root,$doc->path,(int)$doc->tenant_id,$id);$input=Request::create('/embedded/receipt-document','GET');$input->setUserResolver(fn($guard=null)=>$a);
|
||||
$response=$files->using($root,fn()=>(new \Tempo\Purchasing\Embedded\Http\Controllers\ReceiptDocumentController)->download($input,$receipt,$document,app(ReceiptDocuments::class)),'receipt_documents');$order=PurchaseOrder::findOrFail($receipt->purchase_order_id);app(RequisitionWorkflow::class)->audit($a,'sgf.receipt.document_downloaded',Requisition::findOrFail($order->requisition_id),null,$o+['receipt_id'=>$id,'document_id'=>$document,'sha256'=>$doc->sha256]);return $response;});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\{Auth,DB,Gate,Schema};
|
||||
|
||||
/** Local QA composition of the original receipt workflow, never an HTTP proxy or second writer. */
|
||||
final class ReceiptRuntime
|
||||
{
|
||||
private static array $loaded=[];
|
||||
private static function loadClass(string $class,string $bundle,array $files):void
|
||||
{
|
||||
foreach(['Tempo\\Purchasing\\Embedded\\'=>'app/','Laravel\\Fortify\\'=>'fortify/']as$prefix=>$directory){
|
||||
if(!str_starts_with($class,$prefix))continue;
|
||||
$tail=substr($class,strlen($prefix));abort_unless(preg_match('/^[A-Za-z0-9_\\\\]+$/D',$tail),503);
|
||||
$relative=$directory.str_replace('\\','/',$tail).'.php';$pin=$files[$relative]??null;$file=realpath($bundle.'/'.$relative);$base=realpath($bundle);
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&$base&&$file&&str_starts_with(strtolower($file),strtolower($base.DIRECTORY_SEPARATOR))&&!is_link($bundle.'/'.$relative)&&hash_equals($pin,hash_file('sha256',$file)),503,'Dependência do pacote incompatível.');
|
||||
require_once $file;return;
|
||||
}
|
||||
}
|
||||
public function run(string $right,\Closure $work):mixed
|
||||
{
|
||||
abort_unless(config('receipt_qa.enabled')===true,404);
|
||||
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);
|
||||
if(str_starts_with($right,'purchasing_orders.'))abort_unless(config('receipt_qa.orders_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_suppliers.'))abort_unless(config('receipt_qa.suppliers_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_sourcing.'))abort_unless(config('receipt_qa.sourcing_enabled')===true,404);
|
||||
abort_unless(app()->environment('budget-testing'),403);
|
||||
BudgetAdapter::guard();
|
||||
$host=DB::connection();$db=$host->getDatabaseName();$role=$host->getConfig('username');
|
||||
abort_unless(preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$db,$m)&&$role==='ir_composition_'.$m[1],403);
|
||||
$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$hostConfig=$host->getConfig();abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&($hostConfig['host']??null)==='127.0.0.1'&&(int)($hostConfig['port']??0)===55439&&empty($hostConfig['read'])&&empty($hostConfig['write'])&&($hostConfig['search_path']??'public')==='public',403);
|
||||
$identity=$host->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$identity->elevated&&$identity->role===$role&&$identity->cluster==='sgf_modular_regression_'.$run,403);
|
||||
$expected='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$m[1];
|
||||
$profile=realpath((string)config('receipt_qa.profile'));
|
||||
abort_unless($profile&&strtolower(str_replace('\\','/',$profile))===strtolower($expected.'/binding.json'),403);
|
||||
$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.');
|
||||
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';
|
||||
abort_unless($bundle&&hash_equals($p['manifest_sha256'],hash_file('sha256',$manifest)),503);
|
||||
$files=json_decode(file_get_contents($manifest),true,64,JSON_THROW_ON_ERROR)['files'];
|
||||
foreach($files as$file=>$pin)abort_unless(preg_match('~^(app|fortify)/[A-Za-z0-9_/]+\.php$~D',$file)&&!str_contains($file,'..')&&is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin),503);
|
||||
if(config('receipt_qa.orders_enabled')===true||config('receipt_qa.sourcing_enabled')===true){
|
||||
$economic=$expected.'/economic';abort_unless(isset($p['economic_manifest_sha256'])&&is_file($economic.'/manifest.json')&&hash_equals($p['economic_manifest_sha256'],hash_file('sha256',$economic.'/manifest.json')),503);
|
||||
$economicFiles=json_decode(file_get_contents($economic.'/manifest.json'),true,32,JSON_THROW_ON_ERROR)['files'];$names=array_keys($economicFiles);sort($names);abort_unless($names===['ProposalScoring.php','QuotationPricing.php'],503);
|
||||
foreach($economicFiles as$file=>$pin){abort_unless(is_file($economic.'/'.$file)&&!is_link($economic.'/'.$file)&&hash_equals($pin,hash_file('sha256',$economic.'/'.$file)),503);$class='Tempo\\Purchasing\\Economic\\'.basename($file,'.php');if(class_exists($class,false)){abort_unless(hash_equals($pin,hash_file('sha256',(new \ReflectionClass($class))->getFileName())),503);}else require_once $economic.'/'.$file;}
|
||||
}
|
||||
if(config('receipt_qa.requests_enabled')===true){
|
||||
$internal=$expected.'/internal';abort_unless(isset($p['internal_manifest_sha256'])&&hash_equals($p['internal_manifest_sha256'],hash_file('sha256',$internal.'/manifest.json')),503);$pins=json_decode(file_get_contents($internal.'/manifest.json'),true,32,JSON_THROW_ON_ERROR)['files'];$names=array_keys($pins);sort($names);abort_unless($names===['InternalRequisitions.php','RequisitionFailure.php','RequisitionPort.php'],503);foreach(['RequisitionPort.php','RequisitionFailure.php','InternalRequisitions.php']as$file){$pin=$pins[$file];abort_unless(preg_match('/^[a-f0-9]{64}$/D',$pin)&&!is_link($internal.'/'.$file)&&hash_equals($pin,hash_file('sha256',$internal.'/'.$file)),503);$class='Tempo\\Purchasing\\Internal\\'.basename($file,'.php');if(class_exists($class,false)||interface_exists($class,false)){abort_unless(hash_equals($pin,hash_file('sha256',(new \ReflectionClass($class))->getFileName())),503);}else require_once $internal.'/'.$file;}
|
||||
}
|
||||
if(!isset(self::$loaded[$bundle])){
|
||||
abort_unless(self::$loaded===[],503,'Uma só versão do workflow por processo.');
|
||||
spl_autoload_register(fn($class)=>self::loadClass($class,$bundle,$files));
|
||||
Gate::policy(\Tempo\Purchasing\Embedded\Models\GoodsReceipt::class,\Tempo\Purchasing\Embedded\Policies\GoodsReceiptPolicy::class);Gate::policy(\Tempo\Purchasing\Embedded\Models\Supplier::class,\Tempo\Purchasing\Embedded\Policies\SupplierPolicy::class);self::$loaded[$bundle]=true;
|
||||
app()->singleton(\Tempo\Purchasing\Embedded\Services\ReplenishmentRun::class);
|
||||
}
|
||||
$connection=$p['connection'];abort_unless(($connection['driver']??null)==='pgsql'&&($connection['host']??null)==='127.0.0.1'&&(int)($connection['port']??0)===55439
|
||||
&&($connection['database']??null)==='compras_receipts_test_'.$m[1]&&($connection['username']??null)==='payable_history_native_'.$m[1]
|
||||
&&empty($connection['url'])&&empty($connection['read'])&&empty($connection['write'])&&($connection['search_path']??null)==='public',403);
|
||||
config(['database.connections.receipt_native'=>$connection]);$native=DB::connection('receipt_native');
|
||||
$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){
|
||||
// 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';
|
||||
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();
|
||||
config(['auth.guards.receipt_native'=>['driver'=>'session','provider'=>'receipt_native'],'auth.providers.receipt_native'=>['driver'=>'eloquent','model'=>\Tempo\Purchasing\Embedded\Models\User::class]]);
|
||||
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){
|
||||
$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'];}
|
||||
if(isset($p['economic_manifest_sha256'])){$version[]=$p['economic_manifest_sha256'];$origin['economic_manifest']=$p['economic_manifest_sha256'];}
|
||||
return $work($actor,$binding,hash('sha256',json_encode($version,JSON_THROW_ON_ERROR)),$origin);
|
||||
});
|
||||
}finally{Auth::forgetGuards();Auth::shouldUse($guard);DB::setDefaultConnection($default);Schema::clearResolvedInstance('db.schema');app()->instance('env',$environment);}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Requisition,AuditLog,CostCenter};
|
||||
use Tempo\Purchasing\Embedded\Services\{RequisitionWorkflow,OrganizationAccess};
|
||||
|
||||
/** Native requisition lifecycle in SGF, sharing the same reservation and order book. */
|
||||
final class RequestController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.requests_enabled')===true,404);}
|
||||
private function hash(mixed $v):string{return hash('sha256',Payables::canonical([$v]));}
|
||||
private function state(?Requisition $r):?array{return $r?->load(['items','steps'])->toArray();}
|
||||
private function source(int $id,array $b,object $a):?Requisition{abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);if(!$id)return null;abort_unless(RequisitionAccess::allowed($id,$b),403);return Requisition::visibleTo($a)->findOrFail($id);}
|
||||
private function right(string $a):string{return ['save'=>'prepare','submit'=>'submit','decide'=>'approve'][$a];}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$v=[];foreach(['prepare','submit','approve']as$r)$v[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_requests.'.$r,$c->unitId());return $v;}
|
||||
private function historyPage(int $id,object $actor,string $binding,int $size=25,?string $token=null):array {
|
||||
$generation=$this->hash(request()->session()->get('company_context'));$cursor=null;
|
||||
if($token!==null){try{$cut=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Cursor inválido. Actualize o histórico.');}
|
||||
abort_unless(($cut['request']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$binding&&($cut['generation']??null)===$generation&&($cut['size']??null)===$size&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time()&&is_string($cut['cursor']??null),409,'A consulta ou o contexto mudou. Actualize o histórico.');
|
||||
$cursor=\Illuminate\Pagination\Cursor::fromEncoded($cut['cursor']);abort_unless($cursor,409,'Cursor inválido. Actualize o histórico.');
|
||||
}
|
||||
$page=app(ProcessHistory::class)->query($id,'requests')->orderByDesc('id')->cursorPaginate($size,['id','user_id','action','created_at','before','after'],'cursor',$cursor);
|
||||
$encode=fn($value)=>$value?Crypt::encryptString(json_encode(['request'=>$id,'actor'=>$actor->id,'binding'=>$binding,'generation'=>$generation,'size'=>$size,'observed'=>time(),'cursor'=>$value->encode()],JSON_THROW_ON_ERROR)):null;
|
||||
return ['data'=>$page->items(),'navigation'=>['next'=>$encode($page->nextCursor()),'previous'=>$encode($page->previousCursor()),'per_page'=>$size,'order'=>'sequence_desc']];
|
||||
}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $rt){
|
||||
$this->guard();abort_unless(config('receipt_qa.request_history_enabled')===true,404);abort_unless($id>0,404);$d=$r->validate(['per_page'=>'sometimes|integer|in:25,100','cursor'=>'sometimes|required|string|max:16384']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $rt->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$d){$this->source($id,$b,$a);return response()->json($this->historyPage($id,$a,$fingerprint,(int)($d['per_page']??25),$d['cursor']??null))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
private function dispatch(string $action,array $data,?Requisition $record,object $actor,array $binding,string $key):array{
|
||||
$workflow=app(RequisitionWorkflow::class);$before=$this->state($record);
|
||||
if($action==='save'){
|
||||
abort_if(array_diff(array_keys($data),['cost_center_id','priority','needed_by','justification','items']),422);
|
||||
Validator::make($data,['items'=>'required|array|min:1|max:50','items.*'=>'array:description,category,purchase_type,unit,quantity,unit_price,tax_rate','items.*.purchase_type'=>'required|in:consumable,service,other'])->validate();
|
||||
$input=(new \Tempo\Purchasing\Embedded\Http\Requests\StoreRequisitionRequest)->setUserResolver(fn()=>$actor);
|
||||
$data=Validator::make($data,$input->rules())->validate();abort_unless(in_array((int)$data['cost_center_id'],$binding['centre_ids']??[],true),403);
|
||||
$record=$workflow->save($actor,$data+['operation_key'=>$key]+($record?['process_version'=>(int)$record->process_version]:[]),$record);
|
||||
}elseif($action==='submit'){
|
||||
abort_unless($record&&$data===[],422);$workflow->submit($actor,$record,$key,(int)$record->process_version);
|
||||
}else{
|
||||
abort_unless($record,422);abort_if(array_diff(array_keys($data),['decision','comment']),422);$data=Validator::make($data,['decision'=>'required|in:approved,rejected','comment'=>'required|string|min:10|max:2000'])->validate();$workflow->decide($actor,$record,$data['decision'],$data['comment'],$key,(int)$record->process_version);
|
||||
}
|
||||
$record->refresh();return ['result'=>['request_id'=>$record->id,'status'=>$record->status,'version'=>(int)$record->process_version],'impact'=>['operation'=>$action,'before'=>$before,'after'=>$this->state($record),'budget_authority'=>'Compras: mesmo livro da ordem; sem reserva no host SGF','reservation_on_submit'=>$action==='submit','release_on_rejection'=>$action==='decide'&&$data['decision']==='rejected']];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_requests.view',fn($a)=>$this->source(0,[],$a));return response()->view('request-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'nullable|in:draft,pending,approved,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_requests.view',function($a,$b)use($d,$rights){$this->source(0,$b,$a);$q=Requisition::visibleTo($a)->whereIn('id',RequisitionAccess::ids($b));if($d['status']??'')$q->where('status',$d['status']);return response()->json(['requests'=>$q->orderByDesc('id')->paginate(15,['id','number','status','process_version'],'page',(int)($d['page']??1)),'centres'=>CostCenter::whereIn('id',$b['centre_ids']??[])->where('department_id',$a->department_id)->get(['id','code','name']),'new_revision'=>$this->hash(null),'can_prepare'=>$rights['prepare']&&app(OrganizationAccess::class)->canRequest($a)]);});}
|
||||
public function show(int $id,ReceiptRuntime $rt){$this->guard();abort_unless($id>0,404);$rights=$this->rights();return $rt->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$rights){$r=$this->source($id,$b,$a);$state=$this->state($r);$own=$r->requester_id===$a->id&&$r->status==='draft';$paged=config('receipt_qa.request_history_enabled')===true?$this->historyPage($id,$a,$fingerprint):null;return response()->json(['request'=>$state,'revision'=>$this->hash($state),'capabilities'=>['save'=>$rights['prepare']&&$own,'submit'=>$rights['submit']&&$own,'decide'=>$rights['approve']&&$r->canDecide($a)],'history'=>$paged?$paged['data']:app(ProcessHistory::class)->query($id,'requests')->orderByDesc('id')->limit(25)->get(['id','user_id','action','created_at','before','after']),'history_navigation'=>$paged['navigation']??null]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:save,submit,decide','data'=>'present|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>65536||($id===0&&$d['action']!=='save'),422);$d['operation_key']=strtolower($d['operation_key']);return $rt->run('purchasing_requests.'.$this->right($d['action']),function($a,$b,$f)use($r,$id,$d){$record=$this->source($id,$b,$a);abort_unless(hash_equals($this->hash($this->state($record)),$d['revision']),409,'A requisição mudou. Confira novamente.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$record,$a,$b,$d['operation_key']);}finally{while($db->transactionLevel()>$level)$db->rollBack();}if(!$id){unset($out['impact']['after']['id'],$out['impact']['after']['number']);foreach($out['impact']['after']['items']as&$line)unset($line['id'],$line['requisition_id']);unset($line);}$cut=$d+['id'=>$id,'actor'=>$a->id,'binding'=>$f,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'',['save','submit','decide'],true),409);return $rt->run('purchasing_requests.'.$this->right($cut['action']),function($a,$b,$f,$o)use($id,$cut){$record=$this->source($id,$b,$a);abort_unless(($cut['id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'A prévia ou o contexto mudou.');$hash=$this->hash([$id,$cut['action'],$cut['data']]);$old=AuditLog::where('user_id',$a->id)->where('action','sgf.request.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outros dados.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($record)),$cut['revision']),409,'A requisição mudou desde a conferência.');$before=$this->state($record);$out=$this->dispatch($cut['action'],$cut['data'],$record,$a,$b,$cut['operation_key']);$record=Requisition::findOrFail($out['result']['request_id']);app(RequisitionWorkflow::class)->audit($a,'sgf.request.command',$record,$before,$o+['request_scope'=>RequisitionAccess::scope(),'operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$f,'command'=>$cut['action'],'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
use Tempo\Purchasing\Embedded\Models\Requisition;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
|
||||
/** One native record/book; admission is an explicit binding or an audited host origin. */
|
||||
final class RequisitionAccess
|
||||
{
|
||||
public static function scope():string{$c=app(CompanyContext::class);return hash('sha256',json_encode($c->identifiers()+['unit_id'=>$c->unitId()],JSON_THROW_ON_ERROR));}
|
||||
public static function ids(array $binding){return Requisition::query()->select('requisitions.id')->where(function($q)use($binding){$q->whereIn('requisitions.id',$binding['requisition_ids']??[]);if(config('receipt_qa.requests_enabled')===true)$q->orWhereExists(fn($a)=>$a->selectRaw('1')->from('audit_logs as ra')->whereColumn('ra.tenant_id','requisitions.tenant_id')->whereColumn('ra.requisition_id','requisitions.id')->where('ra.action','sgf.request.command')->where('ra.after->request_scope',self::scope()));});}
|
||||
public static function allowed(int $id,array $binding):bool{return self::ids($binding)->where('requisitions.id',$id)->exists();}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Requisition,Quotation,PurchaseAward,Supplier,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,QuotationParticipation,ProposalComparison,PurchaseWorkflow,RequisitionWorkflow};
|
||||
|
||||
/** Transport and receipts around the original quotation/award commands; no pricing or scoring engine. */
|
||||
final class SourcingController
|
||||
{
|
||||
private const ROLES=['quote'=>['procurement'],'evaluate'=>['procurement','finance','department_head'],'select'=>['procurement'],'decide'=>['procurement','finance','department_head']];
|
||||
private const RIGHTS=['quote'=>'prepare','evaluate'=>'evaluate','select'=>'select','decide'=>'approve'];
|
||||
private function guard():void{abort_unless(config('receipt_qa.sourcing_enabled')===true,404);}
|
||||
private function viewer(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function source(int $id,array $binding,object $actor):Requisition{$this->viewer($actor);abort_unless(RequisitionAccess::allowed($id,$binding),403);return Requisition::visibleTo($actor)->findOrFail($id);}
|
||||
private function hash(mixed $value):string{return hash('sha256',Payables::canonical($value));}
|
||||
private function state(Requisition $request):array{
|
||||
return ['request'=>$request->load(['items','steps'])->toArray(),'quotes'=>Quotation::where('requisition_id',$request->id)->with(['items','supplier','evaluations'])->orderBy('id')->get()->toArray(),'award'=>PurchaseAward::where('requisition_id',$request->id)->with('approvals')->first()?->toArray(),'comparison'=>app(ProposalComparison::class)->inspect($request)];
|
||||
}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','evaluate','select','approve']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$r,$c->unitId());return $out;}
|
||||
private function input(Request $r,array $data,object $actor):Request{$input=Request::create('/embedded/sourcing','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);$input->setUserResolver(fn($guard=null)=>$actor);if($r->hasSession())$input->setLaravelSession($r->session());return $input;}
|
||||
private function supplierAccess(string $action,array $data,array $binding,string $context):?string {
|
||||
if($action!=='quote')return null;
|
||||
$d=Validator::make($data,['supplier_id'=>'required|integer|min:1','supplier_revision'=>'sometimes|required|string|size:64'])->validate();
|
||||
$supplier=SupplierAccess::query($binding,$context)->find($d['supplier_id']);abort_unless($supplier,404);
|
||||
$revision=$this->hash($supplier->toArray());
|
||||
if(isset($d['supplier_revision']))abort_unless(hash_equals($revision,$d['supplier_revision']),409,'O fornecedor mudou desde a selecção. Pesquise e confira novamente.');
|
||||
return $revision;
|
||||
}
|
||||
private function eligibleSuppliers(array $binding,string $context) {
|
||||
return SupplierAccess::query($binding,$context)->where('status','active')->where('blocked',false)->whereDate('next_review','>=',today());
|
||||
}
|
||||
public function suppliers(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_sourcing.prepare',function($actor,$binding,$fingerprint,$origin)use($id,$d){
|
||||
$request=$this->source($id,$binding,$actor);abort_unless(app(QuotationParticipation::class)->canPrepare($actor,$request),403);
|
||||
$q=$this->eligibleSuppliers($binding,$origin['sgf_context_key']);
|
||||
if(isset($d['search'])&&$d['search']!=='')$q->where(fn($query)=>$query->whereRaw('position(lower(?) in lower(suppliers.name))>0',[$d['search']])->orWhereRaw('position(? in suppliers.nuit)>0',[$d['search']]));
|
||||
$page=$q->orderBy('suppliers.name')->orderBy('suppliers.id')->paginate((int)($d['per_page']??25),['suppliers.*'],'page',(int)($d['page']??1));
|
||||
$page->through(fn($s)=>['id'=>$s->id,'name'=>$s->name,'nuit'=>$s->nuit,'version'=>$s->version,'selection_revision'=>$this->hash($s->toArray())]);
|
||||
return response()->json($page)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
private function dispatch(string $action,array $data,Requisition $request,object $actor,Request $http):array{
|
||||
$keys=match($action){'quote'=>['supplier_id','supplier_revision','reference','delivery_days','valid_until','payment_terms','no_conflict','items'],'evaluate'=>['quotation_id','quality','history','payment','comment','no_conflict'],'select'=>['quotation_id','justification','delivery_date','delivery_location'],'decide'=>['award_id','decision','comment']};abort_if(array_diff(array_keys($data),$keys),422);
|
||||
$audit=app(RequisitionWorkflow::class);
|
||||
if($action==='quote'){
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\QuotationController)->store($this->input($http,array_diff_key($data,['supplier_revision'=>true]),$actor),$request,$audit);
|
||||
$quote=Quotation::where('requisition_id',$request->id)->where('supplier_id',$data['supplier_id'])->with(['supplier','items'])->firstOrFail();
|
||||
return ['result'=>['quotation_id'=>$quote->id],'impact'=>['operation'=>'Registar proposta','supplier'=>$quote->supplier->name,'total_minor'=>$quote->total_minor,'currency'=>$quote->currency,'lines'=>$quote->items->map(fn($i)=>$i->only(['description','quantity','unit_price_minor','tax_minor','total_minor']))->all()]];
|
||||
}
|
||||
if($action==='evaluate'){
|
||||
$quote=Quotation::where('requisition_id',$request->id)->findOrFail($data['quotation_id']??0);$payload=$data;unset($payload['quotation_id']);
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\QuotationController)->evaluate($this->input($http,$payload,$actor),$quote,$audit);
|
||||
return ['result'=>['quotation_id'=>$quote->id,'evaluator_id'=>$actor->id],'impact'=>['operation'=>'Registar avaliação individual','supplier'=>$quote->supplier->name,'quality'=>$data['quality'],'history'=>$data['history'],'payment'=>$data['payment'],'comment'=>$data['comment']]];
|
||||
}
|
||||
if($action==='select'){
|
||||
$validated=Validator::make($data,['quotation_id'=>'required|integer','justification'=>'required|string|min:10|max:5000','delivery_date'=>'required|date_format:Y-m-d|after_or_equal:today','delivery_location'=>'required|string|min:3|max:500'])->validate();
|
||||
$award=app(PurchaseWorkflow::class)->select($actor,$request,$validated);
|
||||
return ['result'=>['award_id'=>$award->id,'status'=>$award->status],'impact'=>['operation'=>'Submeter adjudicação para aprovação distinta','supplier'=>Quotation::findOrFail($award->quotation_id)->supplier->name,'total_minor'=>$award->total_minor,'delivery_date'=>$validated['delivery_date'],'delivery_location'=>$award->delivery_location,'required_approvals'=>$award->approvals->pluck('role')->all()]];
|
||||
}
|
||||
$validated=Validator::make($data,['award_id'=>'required|integer','decision'=>'required|in:approved,rejected','comment'=>'nullable|required_if:decision,rejected|string|max:2000'])->validate();$award=PurchaseAward::where('requisition_id',$request->id)->findOrFail($validated['award_id']);
|
||||
app(PurchaseWorkflow::class)->decide($actor,$award,$validated['decision'],$validated['comment']??null);$award->refresh();
|
||||
return ['result'=>['award_id'=>$award->id,'status'=>$award->status],'impact'=>['operation'=>'Decidir adjudicação','decision'=>$validated['decision'],'resulting_status'=>$award->status,'total_minor'=>$award->total_minor,'comment'=>$validated['comment']??null]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$this->guard();$runtime->run('purchasing_sourcing.view',fn($actor)=>$this->viewer($actor));return response()->view('sourcing-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_sourcing.view',function($actor,$binding)use($d){$this->viewer($actor);return response()->json(Requisition::visibleTo($actor)->whereIn('id',RequisitionAccess::ids($binding))->where('status','approved')->orderBy('id')->paginate(25,['id','number','status'],'page',(int)($d['page']??1)));});}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_sourcing.view',function($actor,$binding)use($id,$d){$this->source($id,$binding,$actor);return response()->json(app(ProcessHistory::class)->query($id,'sourcing')->orderByDesc('id')->paginate(25,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
|
||||
public function show(int $id,ReceiptRuntime $runtime){$this->guard();$rights=$this->rights();return $runtime->run('purchasing_sourcing.view',function($actor,$binding,$fingerprint,$origin)use($id,$rights){$request=$this->source($id,$binding,$actor);$state=$this->state($request);$part=app(QuotationParticipation::class);$quotes=Quotation::where('requisition_id',$id)->get()->keyBy('id');foreach($state['comparison']['rows']as&$row)$row['can_evaluate']=$rights['evaluate']&&$part->canEvaluate($actor,$request,$quotes[$row['id']]);unset($row);$award=PurchaseAward::where('requisition_id',$id)->first();return response()->json($state+['revision'=>$this->hash($this->state($request)),'capabilities'=>['quote'=>$rights['prepare']&&$part->canPrepare($actor,$request),'select'=>$rights['select']&&$actor->hasFunctions(['procurement'])&&$actor->id!==$request->requester_id&&!$award&&$state['comparison']['ready'],'decide'=>$rights['approve']&&$award&&app(PurchaseWorkflow::class)->canDecide($actor,$award,$request)],'suppliers'=>SupplierAccess::query($binding,$origin['sgf_context_key'])->where('status','active')->where('blocked',false)->whereDate('next_review','>=',today())->orderBy('id')->limit(25)->get(['id','name']),'history'=>app(ProcessHistory::class)->query($id,'sourcing')->orderByDesc('id')->limit(25)->get(['id','user_id','action','created_at'])]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:quote,evaluate,select,decide','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data'],JSON_THROW_ON_ERROR))>65536,422);$d['operation_key']=strtolower($d['operation_key']);return $runtime->run('purchasing_sourcing.'.self::RIGHTS[$d['action']],function($actor,$binding,$fingerprint,$origin)use($id,$d,$r){$request=$this->source($id,$binding,$actor);$actor=app(CurrentActor::class)->lock($actor,self::ROLES[$d['action']]);abort_unless(hash_equals($this->hash($this->state($request)),$d['revision']),409,'O processo mudou. Actualize e confira novamente.');
|
||||
// Exact native validation/pricing in a rollback-only savepoint; no preview record is committed.
|
||||
$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$supplierRevision=$this->supplierAccess($d['action'],$d['data'],$binding,$origin['sgf_context_key']);$out=$this->dispatch($d['action'],$d['data'],$request,$actor,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
$cut=$d+['supplier_revision'=>$supplierRevision,'request_id'=>$id,'actor'=>$actor->id,'binding'=>$fingerprint,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']+['stock'=>false,'order'=>false,'payment'=>false],'operation_key'=>$d['operation_key']]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(isset(self::RIGHTS[$cut['action']??'']),409);return $runtime->run('purchasing_sourcing.'.self::RIGHTS[$cut['action']],function($actor,$binding,$fingerprint,$origin)use($id,$cut,$r){$request=$this->source($id,$binding,$actor);$actor=app(CurrentActor::class)->lock($actor,self::ROLES[$cut['action']]);abort_unless(($cut['request_id']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou o contexto mudou.');$hash=$this->hash([$cut['action'],$cut['data']]);$old=AuditLog::where('requisition_id',$id)->where('user_id',$actor->id)->where('action','sgf.sourcing.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){$receipt=$old->after;abort_unless(($receipt['request_hash']??null)===$hash&&($receipt['binding_revision']??null)===$fingerprint,409,'Chave já usada com outros dados.');return response()->json($receipt['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($request)),$cut['revision']),409,'O processo mudou desde a conferência.');$supplierRevision=$this->supplierAccess($cut['action'],$cut['data'],$binding,$origin['sgf_context_key']);if($cut['action']==='quote')abort_unless(is_string($cut['supplier_revision']??null)&&hash_equals($cut['supplier_revision'],$supplierRevision),409,'Fornecedor alterado; exige nova conferencia.');$out=$this->dispatch($cut['action'],$cut['data'],$request,$actor,$r);app(RequisitionWorkflow::class)->audit($actor,'sgf.sourcing.command',$request,null,$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$fingerprint,'command'=>$cut['action'],'supplier_revision'=>$supplierRevision,'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{DB,Gate};
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder};
|
||||
|
||||
/** Resolve an actual movement to its authorised receipt; URL parent ids are never trusted. */
|
||||
final class StockReceiptTraceController
|
||||
{
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime,CompanyContext $ctx)
|
||||
{
|
||||
abort_unless(config('receipt_qa.stock_trace_enabled')===true&&config('receipt_qa.stock_read_enabled')===true,404);
|
||||
abort_if($r->query()!==[],422);
|
||||
$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$user=$ctx->userId();$host=DB::connection();
|
||||
return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$scope,$user,$host){
|
||||
abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$user,'permission'=>'stock.view'])->sharedLock()->first(),403);
|
||||
$tag=substr($host->getDatabaseName(),strlen('budget_core_sgf_'));
|
||||
$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$tag.'/stock-binding.json';
|
||||
abort_unless(is_file($file)&&!is_link($file),503,'Origem de stock indisponível.');
|
||||
try{$profile=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);}catch(\JsonException){abort(503,'Origem de stock incompatível.');}
|
||||
$stock=$profile['bindings'][implode(':',array_values($scope)).':'.$user]??null;
|
||||
abort_unless($stock&&($stock['source_tenant_id']??null)===$binding['tenant_id']
|
||||
&&($profile['connection']['database']??null)===DB::connection()->getDatabaseName()
|
||||
&&($stock['schema']??null)==='public'&&($stock['append_only']??false)===true,403);
|
||||
$movement=DB::table('stock_movements')->where('tenant_id',$binding['tenant_id'])->where('id',$id)
|
||||
->whereIn('warehouse_id',array_values(array_intersect($stock['warehouse_ids']??[],$binding['warehouse_ids'])))
|
||||
->where('movement_type','receipt_in')->first(['id','goods_receipt_item_id','stock_item_id','warehouse_id','quantity_milli','snapshot']);
|
||||
abort_unless($movement,403,'Movimento fora da origem autorizada.');
|
||||
$line=DB::table('goods_receipt_items')->where('tenant_id',$binding['tenant_id'])->where('id',$movement->goods_receipt_item_id)->first();
|
||||
abort_unless($line,409,'A evidência de origem exige nova conferência.');
|
||||
$receipt=GoodsReceipt::findOrFail($line->goods_receipt_id);
|
||||
$order=PurchaseOrder::where('id',$receipt->purchase_order_id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();
|
||||
abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);Gate::authorize('view',$receipt);
|
||||
try{$snapshot=json_decode($movement->snapshot,true,64,JSON_THROW_ON_ERROR);}catch(\JsonException){abort(409,'A evidência de origem exige nova conferência.');}
|
||||
abort_unless($receipt->tenant_id===$binding['tenant_id']&&(int)$receipt->warehouse_id===(int)$movement->warehouse_id
|
||||
&&(int)$line->stock_item_id===(int)$movement->stock_item_id&&(string)$line->accepted_milli===(string)$movement->quantity_milli
|
||||
&&(int)$movement->quantity_milli>0&&($snapshot['receipt_id']??null)===$receipt->id,409,'O movimento e a recepção exigem nova conferência.');
|
||||
return response()->json(['movement_id'=>(int)$movement->id,'order_id'=>$order->id,'receipt_id'=>$receipt->id,
|
||||
'href'=>'/purchasing/receipts?order='.$order->id.'&receipt='.$receipt->id,'read_only'=>true])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Tempo\Purchasing\Embedded\Models\Supplier;
|
||||
|
||||
/** Explicit fixture bindings and suppliers actually prepared in this company/unit. */
|
||||
final class SupplierAccess
|
||||
{
|
||||
public static function scope(string $context):string{return hash('sha256',substr($context,0,strrpos($context,':')));}
|
||||
public static function query(array $binding,string $context){
|
||||
return Supplier::query()->where(function($q)use($binding,$context){
|
||||
$q->whereIn('suppliers.id',$binding['supplier_ids']??[]);
|
||||
if(config('receipt_qa.suppliers_enabled')===true)$q->orWhereExists(fn($a)=>$a->selectRaw('1')->from('audit_logs as sa')->whereColumn('sa.tenant_id','suppliers.tenant_id')->where('sa.action','sgf.supplier.command')->where('sa.after->supplier_scope',self::scope($context))->whereRaw("sa.after->'result'->>'supplier_id'=suppliers.id::text"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Supplier,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,SupplierParticipation,RequisitionWorkflow};
|
||||
|
||||
/** QA transport around the original supplier controller and FormRequest. */
|
||||
final class SupplierController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.suppliers_enabled')===true,404);}
|
||||
private function viewer(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function hash(mixed $data):string{return hash('sha256',Payables::canonical([$data]));}
|
||||
private function source(int $id,array $b,object $a,string $context):?Supplier{$this->viewer($a);return $id===0?null:SupplierAccess::query($b,$context)->findOrFail($id);}
|
||||
private function revision(?Supplier $s):string{return $this->hash($s?->toArray());}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','review']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_suppliers.'.$r,$c->unitId());return $out;}
|
||||
private function right(string $action):string{return $action==='review'?'review':'prepare';}
|
||||
private function dispatch(string $action,array $data,?Supplier $supplier,object $actor,Request $http):array{
|
||||
$controller=new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierController;$workflow=app(RequisitionWorkflow::class);
|
||||
$class=$action==='review'?Request::class:\Tempo\Purchasing\Embedded\Http\Requests\SaveSupplierRequest::class;
|
||||
$input=$class::create('/embedded/suppliers','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);
|
||||
$input->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$input->setLaravelSession($http->session());
|
||||
if($action!=='review'){
|
||||
$input->setContainer(app())->setRedirector(app('redirect'));
|
||||
$route=new \Illuminate\Routing\Route(['POST'],'embedded/suppliers/{supplier}',fn()=>null);$route->bind($input);if($supplier)$route->setParameter('supplier',$supplier);else $route->forgetParameter('supplier');$input->setRouteResolver(fn()=>$route);
|
||||
abort_if(array_diff(array_keys($data),array_keys($input->rules())),422);$input->validateResolved();
|
||||
}else abort_if(array_diff(array_keys($data),['status','comment','version']),422);
|
||||
if($action==='create'){$controller->store($input,$workflow);$supplier=Supplier::where('nuit',$data['nuit'])->firstOrFail();}
|
||||
elseif($action==='update')$controller->update($input,$supplier,$workflow);
|
||||
else $controller->review($input,$supplier,$workflow);
|
||||
$supplier->refresh();return ['result'=>['supplier_id'=>$supplier->id,'status'=>$supplier->status,'version'=>$supplier->version],'impact'=>['operation'=>$action,'before'=>$action==='create'?null:$supplier->getOriginal(),'after'=>$supplier->toArray()]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_suppliers.view',fn($a)=>$this->viewer($a));return response()->view('supplier-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','search'=>'nullable|string|max:100','status'=>'nullable|in:pending,active,suspended']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($d,$rights){$this->viewer($a);$q=SupplierAccess::query($b,$o['sgf_context_key']);if($d['search']??'')$q->where(fn($w)=>$w->where('name','ilike','%'.$d['search'].'%')->orWhere('nuit','like','%'.$d['search'].'%'));if($d['status']??'')$q->where('status',$d['status']);$part=app(SupplierParticipation::class);return response()->json(['suppliers'=>$q->orderBy('name')->orderBy('id')->paginate(15,['id','name','nuit','status','version'],'page',(int)($d['page']??1)),'new_revision'=>$this->revision(null),'can_prepare'=>$rights['prepare']&&$part->canPrepare($a)]);});}
|
||||
public function show(int $supplier,ReceiptRuntime $rt){$this->guard();abort_unless($supplier>0,404);$rights=$this->rights();return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$rights){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$part=app(SupplierParticipation::class);return response()->json(['supplier'=>$s,'revision'=>$this->revision($s),'documents_enabled'=>config('receipt_qa.supplier_documents_enabled')===true&&\Illuminate\Support\Facades\Gate::allows('viewDocuments',$s),'geography_enabled'=>config('geography.enabled')===true,'capabilities'=>['prepare'=>$rights['prepare']&&$part->canPrepare($a),'review'=>$rights['review']&&$part->canReview($a,$s)]]);});}
|
||||
public function history(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();abort_unless($supplier>0,404);$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$d){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);return response()->json(AuditLog::where('tenant_id',$s->tenant_id)->where(function($q)use($supplier){$q->where(fn($w)=>$w->whereIn('action',['supplier.created','supplier.updated','supplier.reviewed'])->where('after->id',$supplier))->orWhere(fn($w)=>$w->where('action','supplier.document_uploaded')->where('after->document->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','supplier.document_downloaded')->where('after->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','sgf.supplier.document')->where('after->result->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','sgf.supplier.command')->where('after->result->supplier_id',$supplier));})->orderByDesc('id')->paginate(15,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
|
||||
public function preview(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:create,update,review','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>16384,422);abort_unless(($supplier===0)===($d['action']==='create'),422);$d['operation_key']=strtolower($d['operation_key']);return $rt->run('purchasing_suppliers.'.$this->right($d['action']),function($a,$b,$f,$o)use($supplier,$r,$d){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,[$d['action']==='review'?'finance':'procurement']);abort_unless(hash_equals($this->revision($s),$d['revision']),409,'A ficha mudou. Confira novamente.');$before=$s?->toArray();$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$s,$a,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$out['impact']['before']=$before;if($d['action']==='create')unset($out['impact']['after']['id'],$out['impact']['after']['created_at'],$out['impact']['after']['updated_at']);$cut=$d+['supplier_id'=>$supplier,'actor'=>$a->id,'binding'=>$f,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']]);});}
|
||||
public function confirm(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:65536']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['create','update','review'],true),409);return $rt->run('purchasing_suppliers.'.$this->right($cut['action']),function($a,$b,$f,$o)use($supplier,$r,$cut){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,[$cut['action']==='review'?'finance':'procurement']);abort_unless(($cut['supplier_id']??null)===$supplier&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou contexto mudou.');$hash=$this->hash([$supplier,$cut['action'],$cut['data']]);$old=AuditLog::where('user_id',$a->id)->where('action','sgf.supplier.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outros dados.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->revision($s),$cut['revision']),409,'A ficha mudou desde a conferência.');$before=$s?->toArray();$out=$this->dispatch($cut['action'],$cut['data'],$s,$a,$r);app(RequisitionWorkflow::class)->audit($a,'sgf.supplier.command',null,$before,$o+['supplier_scope'=>SupplierAccess::scope($o['sgf_context_key']),'operation_key'=>$cut['operation_key'],'binding_revision'=>$f,'request_hash'=>$hash,'command'=>$cut['action'],'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/** Storage is confined to the already verified composition, never public or client storage. */
|
||||
final class SupplierDocumentFiles
|
||||
{
|
||||
public function root(bool $preview=false,string $resource='supplier'):string {
|
||||
abort_unless(in_array($resource,['supplier','receipt'],true),403);
|
||||
$profile=realpath((string)config('receipt_qa.profile'));abort_unless($profile,403);
|
||||
$base=str_replace('\\','/',dirname($profile));$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&preg_match('~^'.preg_quote('C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-','~').'[a-f0-9]{12}$~iD',$base),403);
|
||||
$container=$base.'/'.$resource.($preview?'-document-previews':'-documents');abort_if(is_link($container),403);
|
||||
if(!is_dir($container))mkdir($container,0700);
|
||||
abort_unless(realpath($container)&&str_starts_with(strtolower(realpath($container)),strtolower(realpath($base).DIRECTORY_SEPARATOR)),403);
|
||||
$root=$preview?$container.'/'.bin2hex(random_bytes(16)):$container;
|
||||
if(!is_dir($root))mkdir($root,0700);abort_unless(realpath($root)&&!is_link($root),403);return $root;
|
||||
}
|
||||
public function using(string $root,\Closure $work,string $disk='supplier_documents'):mixed {
|
||||
abort_unless(in_array($disk,['supplier_documents','receipt_documents'],true),403);
|
||||
$before=config('filesystems.disks.'.$disk);config(['filesystems.disks.'.$disk=>['driver'=>'local','root'=>$root,'throw'=>true,'serve'=>false]]);Storage::forgetDisk($disk);
|
||||
try{return $work(Storage::disk($disk));}finally{Storage::forgetDisk($disk);config(['filesystems.disks.'.$disk=>$before]);}
|
||||
}
|
||||
public function path(string $root,string $path,int $tenant,int $supplier):string {
|
||||
abort_unless(preg_match('~^'.$tenant.'/'.$supplier.'/[A-Za-z0-9]{40}\.(pdf|png|jpe?g)$~D',$path),409,'Referência de anexo inválida.');
|
||||
$file=realpath($root.'/'.$path);$base=realpath($root);abort_unless($file&&$base&&!is_link($root.'/'.$path)&&str_starts_with(strtolower($file),strtolower($base.DIRECTORY_SEPARATOR)),409,'Anexo indisponível.');return $file;
|
||||
}
|
||||
public function discard(string $root,string $path,int $tenant,int $supplier,string $disk='supplier_documents'):void {
|
||||
$this->path($root,$path,$tenant,$supplier);$this->using($root,fn($storage)=>$storage->delete($path),$disk);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\{Request,UploadedFile};
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Supplier,SupplierDocument,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,RequisitionWorkflow};
|
||||
|
||||
/** Transport around the original upload/review invalidation and private audited download. */
|
||||
final class SupplierDocumentsController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.supplier_documents_enabled')===true,404); }
|
||||
private function hash(array $value):string { return hash('sha256',Payables::canonical($value)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function source(int $id,array $binding,string $context):Supplier { return SupplierAccess::query($binding,$context)->find($id)??abort(404); }
|
||||
private function meta(UploadedFile $file):array { return ['sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize(),'mime'=>$file->getMimeType(),'name'=>$file->getClientOriginalName()]; }
|
||||
private function input(Request $http,object $actor,string $type):Request {
|
||||
$files=$http->file('file')?['file'=>$http->file('file')]:[];
|
||||
$r=Request::create('/embedded/supplier-documents','POST',['document_type'=>$type],[],$files,['HTTP_ACCEPT'=>'application/json']);$r->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$r->setLaravelSession($http->session());return $r;
|
||||
}
|
||||
private function store(Request $http,object $actor,Supplier $supplier,string $type):SupplierDocument {
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierDocumentController)->store($this->input($http,$actor,$type),$supplier,app(RequisitionWorkflow::class));return $supplier->documents()->orderByDesc('id')->firstOrFail();
|
||||
}
|
||||
private function document(Supplier $supplier,int $id):SupplierDocument { return $supplier->documents()->find($id)??abort(404); }
|
||||
private function integrity(SupplierDocument $document,string $root):void {
|
||||
$file=app(SupplierDocumentFiles::class)->path($root,$document->path,(int)$document->tenant_id,(int)$document->supplier_id);
|
||||
abort_unless(filesize($file)===(int)$document->size&&hash_equals($document->sha256,hash_file('sha256',$file)),409,'O ficheiro difere da evidência registada.');
|
||||
}
|
||||
public function index(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$d){$s=$this->source($supplier,$b,$o['sgf_context_key']);Gate::authorize('viewDocuments',$s);return response()->json($s->documents()->orderByDesc('id')->paginate(15,['id','supplier_id','document_type','original_name','mime_type','size','sha256','uploaded_by','created_at'],'page',(int)($d['page']??1)))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
public function preview(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','document_type'=>'required|string|max:30','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);unset($d['file']);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();$meta=$this->meta($r->file('file'));
|
||||
return $runtime->run('purchasing_suppliers.prepare',function($a,$b,$f,$o)use($supplier,$r,$d,$generation,$meta){$s=$this->source($supplier,$b,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,['procurement']);Gate::authorize('update',$s);abort_unless(hash_equals($this->hash([$s->toArray()]),$d['revision']),409,'A ficha mudou. Confira novamente.');$files=app(SupplierDocumentFiles::class);$stage=$files->root(true);$db=DB::connection();$level=$db->transactionLevel();$document=null;$db->beginTransaction();
|
||||
try{$files->using($stage,function()use($r,$a,$s,$d,&$document){$document=$this->store($r,$a,$s,$d['document_type']);});$impact=['document'=>$document->toArray(),'supplier_status'=>'pending','supplier_version'=>$s->fresh()->version,'reason'=>$d['reason']];unset($impact['document']['id'],$impact['document']['created_at'],$impact['document']['updated_at']);}
|
||||
finally{while($db->transactionLevel()>$level)$db->rollBack();if($document)$files->discard($stage,$document->path,(int)$s->tenant_id,$s->id);}
|
||||
$cut=$d+['supplier_id'=>$supplier,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'file'=>$meta,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$impact]);});
|
||||
}
|
||||
public function confirm(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$generation=$this->generation();$meta=$this->meta($r->file('file'));$created=null;
|
||||
try{return $runtime->run('purchasing_suppliers.prepare',function($a,$b,$f,$o)use($supplier,$r,$cut,$generation,$meta,&$created){$s=$this->source($supplier,$b,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,['procurement']);Gate::authorize('update',$s);abort_unless(($cut['supplier_id']??null)===$supplier&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&($cut['file']??null)===$meta&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O ficheiro, contexto ou prévia mudou.');
|
||||
$hash=$this->hash([$supplier,$cut['file'],$cut['document_type'],$cut['reason']]);$files=app(SupplierDocumentFiles::class);$root=$files->root();$old=AuditLog::where('user_id',$a->id)->where('action','sgf.supplier.document')->where('after->operation_key',$cut['operation_key'])->first();
|
||||
if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding']??null)===$f,409,'Chave usada com outro conteúdo.');$this->integrity($this->document($s,$old->after['result']['document_id']),$root);return response()->json($old->after['result']+['replayed'=>true]);}
|
||||
abort_unless(hash_equals($this->hash([$s->toArray()]),$cut['revision']),409,'A ficha mudou desde a conferência.');$before=$s->toArray();$document=$files->using($root,fn()=>$this->store($r,$a,$s,$cut['document_type']));$created=['root'=>$root,'path'=>$document->path,'tenant'=>(int)$s->tenant_id,'supplier'=>$s->id];$this->integrity($document,$root);$result=['supplier_id'=>$s->id,'document_id'=>$document->id,'document'=>$document->toArray(),'supplier_version'=>$s->fresh()->version,'supplier_status'=>'pending'];
|
||||
app(RequisitionWorkflow::class)->audit($a,'sgf.supplier.document',null,$before,$o+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding'=>$f,'reason'=>$cut['reason'],'result'=>$result]);return response()->json($result+['replayed'=>false],201);});}
|
||||
catch(\Throwable $e){
|
||||
// Delete only a newly created file after observing that its DB row did not commit.
|
||||
// If rollback/commit is uncertain or verification fails, retain it for recovery.
|
||||
if($created){try{$exists=DB::connection('receipt_native')->table('supplier_documents')->where(['tenant_id'=>$created['tenant'],'supplier_id'=>$created['supplier'],'path'=>$created['path']])->exists();if(!$exists)app(SupplierDocumentFiles::class)->discard($created['root'],$created['path'],$created['tenant'],$created['supplier']);}catch(\Throwable){/* Preserve evidence if outcome cannot be verified. */}}throw $e;
|
||||
}
|
||||
}
|
||||
public function download(int $supplier,int $document,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();abort_if($r->query(),422);return $runtime->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$document,$r){$s=$this->source($supplier,$b,$o['sgf_context_key']);Gate::authorize('viewDocuments',$s);$doc=$this->document($s,$document);$files=app(SupplierDocumentFiles::class);$root=$files->root();$this->integrity($doc,$root);return $files->using($root,fn()=>(new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierDocumentController)->download($this->input($r,$a,$doc->document_type),$s,$doc,app(RequisitionWorkflow::class)));});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Modules\Purchasing;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\HistoricalPayments;
|
||||
use Tempo\Finance\Payables\HistoricalCapture;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Reads explicitly reconciled immutable receipts; never imports or decides payments. */
|
||||
@@ -34,7 +35,7 @@ final class SupplierHistoryReceipts
|
||||
}
|
||||
|
||||
/** Local read snapshots only; no command obtains its source through this batch. */
|
||||
public function page(User $user, array $documents): array
|
||||
public function page(User $user, array $documents, bool $validatedCapture = false): array
|
||||
{
|
||||
abort_unless($this->available(), 503);
|
||||
$adapter = app(SupplierPayables::class); $scope = $adapter->scope($user);
|
||||
@@ -43,14 +44,14 @@ final class SupplierHistoryReceipts
|
||||
->distinct('local_source_id')->orderBy('local_source_id')->orderByDesc('version')->get()->keyBy('local_source_id');
|
||||
$result = [];
|
||||
foreach ($documents as $id => $source) {
|
||||
try { $result[$id] = $this->decode($source, $receipts->get($id)); }
|
||||
try { $result[$id] = $this->decode($source, $receipts->get($id), $validatedCapture ? $scope : null); }
|
||||
catch (\DomainException $e) { $result[$id] = $e; }
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function decode(array $source, ?object $receipt): array
|
||||
private function decode(array $source, ?object $receipt, ?array $captureScope = null): array|HistoricalCapture
|
||||
{
|
||||
if (! $receipt) {
|
||||
throw new \DomainException('Histórico não reconciliado para esta origem e contexto.');
|
||||
@@ -66,11 +67,12 @@ final class SupplierHistoryReceipts
|
||||
&& is_array($payload['payments'] ?? null) && array_is_list($payload['payments']))) {
|
||||
throw new \DomainException('Recibo histórico antigo ou incompatível; exige nova conferência da origem.');
|
||||
}
|
||||
$capture = HistoricalPayments::capture($source, $payload['payments']);
|
||||
$value = $captureScope === null ? null : HistoricalCapture::fromPayments($captureScope, $source, $payload['payments']);
|
||||
$capture = $value ? $value->forSource($captureScope, $source) : HistoricalPayments::capture($source, $payload['payments']);
|
||||
if (($payload['history_hash'] ?? null) !== $capture['history_hash']) {
|
||||
throw new \DomainException('Fotografia histórica incompatível com os pagamentos originais.');
|
||||
}
|
||||
|
||||
return $capture['payments'];
|
||||
return $value ?? $capture['payments'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class SupplierPayableReportController extends Controller
|
||||
{
|
||||
private function criteria(Request $request): array
|
||||
{
|
||||
$data = $request->validate(['q' => 'sometimes|nullable|string|max:100', 'source' => 'sometimes|required|integer|min:1']);
|
||||
abort_if(array_diff(array_keys($request->query()), array_keys($data)) !== [], 422);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function index(Request $request, SupplierPayables $service)
|
||||
{
|
||||
return response()->json($service->report($request->user(), $this->criteria($request)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $request, SupplierPayables $service)
|
||||
{
|
||||
return response()->view('purchasing.payable-report', ['report' => $service->report($request->user(), $this->criteria($request))])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ final class SupplierPayables
|
||||
public static function guard(): void
|
||||
{
|
||||
$c = DB::connection();
|
||||
abort_unless(app()->environment('payable-testing'), 403, 'Obrigações apenas no ensaio isolado.');
|
||||
$composedQa=app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true);
|
||||
abort_unless(app()->environment('payable-testing')||$composedQa, 403, 'Obrigações apenas no ensaio isolado.');
|
||||
if($composedQa)\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^payable_sgf_[a-f0-9]{12}$/D', $c->getDatabaseName()) || $c->getConfig('host') !== '127.0.0.1' || $c->selectOne('select current_database() as d')->d !== $c->getDatabaseName()) {
|
||||
throw new \LogicException('PAYABLE_QA_DATABASE_REFUSED');
|
||||
}
|
||||
@@ -81,7 +83,32 @@ final class SupplierPayables
|
||||
};
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, fn (string $a) => $authorize($a), $source,
|
||||
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false));
|
||||
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false), fn () => $authorize('payments_view', false));
|
||||
}
|
||||
|
||||
/** One predicate for list windows and complete position reports. */
|
||||
private function sourceQuery(array $scope, array $options): array
|
||||
{
|
||||
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
|
||||
abort_unless(mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
|
||||
$query = DB::table('pa_imported_sources AS r')->where($scope);
|
||||
$sourceId = array_key_exists('source', $options) ? filter_var($options['source'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) : null;
|
||||
abort_if($sourceId === false || (isset($options['source']) && is_bool($options['source'])), 422, 'Origem inválida.');
|
||||
if ($sourceId !== null) {
|
||||
abort_unless((clone $query)->where('r.id', $sourceId)->exists(), 403, 'Origem não mapeada neste contexto.');
|
||||
$query->where('r.id', $sourceId);
|
||||
}
|
||||
if ($q !== '') {
|
||||
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
|
||||
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
|
||||
}
|
||||
if ($recognition !== 'all') {
|
||||
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
|
||||
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
|
||||
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
|
||||
}
|
||||
return [$query, $sourceId];
|
||||
}
|
||||
|
||||
public function listing(User $u, array $options = []): array
|
||||
@@ -93,17 +120,7 @@ final class SupplierPayables
|
||||
$page = (int) ($options['page'] ?? 1); $perPage = (int) ($options['per_page'] ?? 25);
|
||||
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
|
||||
abort_unless($page >= 1 && $page <= 100000 && in_array($perPage, [25, 100], true) && mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
|
||||
$query = DB::table('pa_imported_sources AS r')->where($scope);
|
||||
if ($q !== '') {
|
||||
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
|
||||
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
|
||||
}
|
||||
if ($recognition !== 'all') {
|
||||
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
|
||||
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
|
||||
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
|
||||
}
|
||||
[$query, $sourceId] = $this->sourceQuery($scope, $options);
|
||||
$pager = $query->orderByDesc('r.id')->paginate($perPage, ['r.*'], 'page', $page); $documents = [];
|
||||
foreach ($pager->items() as $row) { $documents[(int) $row->id] = $this->receiptDocument($row); }
|
||||
$provider = app(SupplierHistoryReceipts::class);
|
||||
@@ -111,6 +128,7 @@ final class SupplierPayables
|
||||
$d = $this->buildEngine($u, $documents, $histories)->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $perPage);
|
||||
$d['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()];
|
||||
$d['listing_query'] = ['page' => $page, 'per_page' => $perPage, 'q' => $q, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)];
|
||||
if ($sourceId !== null) { $d['listing_query']['source'] = $sourceId; }
|
||||
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
|
||||
$d['history_import_available'] = app(SupplierHistoryReceipts::class)->available();
|
||||
$d['history_decisions_available'] = false;
|
||||
@@ -122,4 +140,79 @@ final class SupplierPayables
|
||||
return $d;
|
||||
});
|
||||
}
|
||||
|
||||
/** Current recognised positions, aggregated through the existing engine in bounded windows. */
|
||||
public function report(User $u, array $criteria = []): array
|
||||
{
|
||||
abort_if(array_diff(array_keys($criteria), ['q', 'source']) !== [], 422);
|
||||
abort_if(isset($criteria['q']) && !is_string($criteria['q']), 422);
|
||||
if (array_key_exists('source', $criteria)) {
|
||||
$id = filter_var($criteria['source'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
abort_if($id === false || is_bool($criteria['source']), 422);
|
||||
$criteria['source'] = $id;
|
||||
}
|
||||
$own = DB::transactionLevel() === 0;
|
||||
return DB::transaction(function () use ($u, $criteria, $own): array {
|
||||
if ($own) DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
else abort_unless(in_array(DB::selectOne('SHOW transaction_isolation')->transaction_isolation, ['repeatable read', 'serializable'], true), 409, 'O relatório exige uma fotografia coerente própria.');
|
||||
$scope = $this->scope($u);
|
||||
abort_unless($this->can($u, 'view'), 403);
|
||||
$criteria['q'] = trim((string) ($criteria['q'] ?? ''));
|
||||
$hash = hash_init('sha256');
|
||||
hash_update($hash, Payables::canonical(['scope' => $scope, 'criteria' => $criteria, 'basis' => 'current_recognised_positions', 'window' => 100])."\n");
|
||||
$total = ['count' => 0, 'principal_minor' => 0, 'unknown_balance_count' => 0, 'unknown_due_count' => 0];
|
||||
$known = ['count' => 0, 'principal_minor' => 0, 'historical_confirmed_minor' => 0, 'net_allocated_minor' => 0, 'balance_minor' => 0];
|
||||
[$query] = $this->sourceQuery($scope, $criteria + ['recognition' => 'recognised']);
|
||||
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
|
||||
abort_unless(is_string($person) && $person !== '', 403);
|
||||
$provider = app(SupplierHistoryReceipts::class); $historical = $provider->available();
|
||||
$read = function () use ($u, $scope, $person): void {
|
||||
$context = app(CompanyContext::class);
|
||||
abort_unless(auth()->id() === $u->id && $context->userId() === $u->id, 403);
|
||||
abort_unless(($context->identifiers() + ['unit_id' => $context->unitId()]) === $scope, 409);
|
||||
abort_unless($this->can($u, 'view'), 403);
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409);
|
||||
};
|
||||
$last = null; $windows = 0;
|
||||
do {
|
||||
$window = clone $query;
|
||||
if ($last !== null) $window->where('r.id', '<', $last);
|
||||
$rows = $window->orderByDesc('r.id')->limit(100)->get(['r.*']);
|
||||
if ($rows->isEmpty()) break;
|
||||
$documents = [];
|
||||
foreach ($rows as $row) $documents[(int) $row->id] = $this->receiptDocument($row);
|
||||
$histories = $historical ? $provider->page($u, $documents, true) : [];
|
||||
$engine = new Payables(DB::connection(), $u->id, $person, $scope,
|
||||
static fn () => throw new \LogicException('Relatórios nunca executam comandos.'),
|
||||
fn (int $id) => $documents[$id] ?? abort(403),
|
||||
$historical ? static function (int $id) use ($histories) {
|
||||
$result = $histories[$id] ?? new \DomainException('Histórico fora da janela.');
|
||||
if ($result instanceof \DomainException) throw $result;
|
||||
return $result;
|
||||
} : null, $read);
|
||||
$data = $engine->positionsPage(array_keys($documents));
|
||||
foreach ($data['documents'] as $doc) hash_update($hash, Payables::canonical(['local_source_id' => $doc['local_id'], 'current_revision' => $doc['revision']])."\n");
|
||||
foreach ($data['obligations'] as $o) {
|
||||
abort_unless($o['currency'] === 'MZN', 409, 'Moeda incompatível no relatório; nenhuma soma entre moedas.');
|
||||
$total['count']++; $total['principal_minor'] += $o['principal_minor'];
|
||||
if ($o['due_on'] === null) $total['unknown_due_count']++;
|
||||
if ($o['source_conflict'] || $o['balance_minor'] === null) $total['unknown_balance_count']++;
|
||||
else {
|
||||
$known['count']++; $known['principal_minor'] += $o['principal_minor'];
|
||||
$known['historical_confirmed_minor'] += $o['confirmed_payments_minor'];
|
||||
$known['net_allocated_minor'] += $o['allocated_payments_minor'];
|
||||
$known['balance_minor'] += $o['balance_minor'];
|
||||
}
|
||||
hash_update($hash, Payables::canonical($o)."\n");
|
||||
}
|
||||
$windows++; $last = (int) $rows->last()->id;
|
||||
} while ($rows->count() === 100);
|
||||
abort_unless($known['principal_minor'] - $known['historical_confirmed_minor'] - $known['net_allocated_minor'] === $known['balance_minor'], 409, 'Componentes financeiros não reconciliados.');
|
||||
$total['balance_minor'] = $total['unknown_balance_count'] === 0 ? $known['balance_minor'] : null;
|
||||
return ['version' => 'SGF-PAYABLE-POSITION-REPORT-SIM-v1', 'synthetic_only' => true,
|
||||
'basis' => 'current_recognised_positions', 'captured_at_utc' => DB::selectOne("SELECT transaction_timestamp() AT TIME ZONE 'UTC' AS captured")->captured,
|
||||
'scope' => $scope, 'criteria' => $criteria, 'currency' => 'MZN', 'totals' => $total, 'known_positions' => $known,
|
||||
'positions_sha256' => hash_final($hash), 'window_size' => 100, 'windows' => $windows];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
private function options(Request $r): array
|
||||
public function listingOptions(Request $r): array
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
|
||||
$d = $r->validate(['source' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'event_page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100',
|
||||
'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
@@ -21,17 +21,21 @@ final class SupplierPayablesController extends Controller
|
||||
|
||||
public function index(Request $r, SupplierPayables $s)
|
||||
{
|
||||
return response()->json($s->listing($r->user(), $this->options($r)))->header('Cache-Control', 'no-store');
|
||||
return response()->json($s->listing($r->user(), $this->listingOptions($r)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r, SupplierPayables $s)
|
||||
{
|
||||
$data = $s->listing($r->user(), $this->options($r));
|
||||
$data = $s->listing($r->user(), $this->listingOptions($r));
|
||||
$scope = $s->scope($r->user());
|
||||
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
|
||||
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
|
||||
$flow=[];
|
||||
if(config('general_ledger.synthetic_enabled')===true&&isset($data['listing_query']['source'])&&app(\App\Modules\Identity\Services\Permissions::class)->has($r->user()->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id'])){
|
||||
$flow=['ledger_flow_url'=>'/general-ledger/flow?source='.$data['listing_query']['source'],'ledger_flow_scope'=>$scope];
|
||||
}
|
||||
|
||||
return response(Workspace::render($data, ['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
|
||||
return response(Workspace::render($data, $flow+['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'document_filter' => 'source', 'report_url' => '/supplier-payables/report', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action, SupplierPayables $s)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Tempo\Finance\Payables\SettlementHttp;
|
||||
|
||||
final class SupplierPaymentsController extends SettlementHttp
|
||||
@@ -9,4 +10,27 @@ final class SupplierPaymentsController extends SettlementHttp
|
||||
protected const MODE = 'sgf';
|
||||
|
||||
protected const SERVICE = SupplierPayables::class;
|
||||
|
||||
private const PAYMENT_FILTERS = ['payments_page', 'allocation_proposals_page', 'allocations_page', 'payment_events_page', 'payment_per_page', 'payment_q', 'payment_state', 'payment'];
|
||||
protected function ledgerSettings(Request $request):array
|
||||
{
|
||||
if(config('general_ledger.cycle_enabled')!==true)return [];
|
||||
$scope=app(SupplierPayables::class)->scope($request->user());
|
||||
if(!app(\App\Modules\Identity\Services\Permissions::class)->has($request->user()->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id']))return [];
|
||||
return ['ledger_cycle_url'=>'/general-ledger/cycle','ledger_scope'=>$scope];
|
||||
}
|
||||
|
||||
protected function obligationOptions(Request $request): array
|
||||
{
|
||||
return app(SupplierPayablesController::class)->listingOptions($request->duplicate(array_diff_key($request->query(), array_flip(self::PAYMENT_FILTERS))));
|
||||
}
|
||||
|
||||
protected function settlementOptions(Request $request): ?array
|
||||
{
|
||||
return $request->validate(['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|nullable|string|max:100', 'payment_state' => 'sometimes|in:all,proposed,confirmed,rejected',
|
||||
'payment' => 'sometimes|required|integer|min:1']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/** Navigation only: every destination retains its own current native authority checks. */
|
||||
final class WorkspaceNavigation
|
||||
{
|
||||
public function links():array
|
||||
{
|
||||
if(config('receipt_qa.enabled')!==true||!app()->environment('budget-testing'))return [];
|
||||
$c=app(CompanyContext::class);$scope=$c->identifiers();if(!$c->unitId())return [];
|
||||
$definitions=[
|
||||
['requests','requests','Requisições','Preparar, submeter e obter aprovação distinta.'],
|
||||
['suppliers','suppliers','Fornecedores','Conferir cadastro e revisão antes das propostas.'],
|
||||
['sourcing','sourcing','Propostas e adjudicação','Comparar propostas e fundamentar a escolha; aprovação distinta.'],
|
||||
['orders','orders','Ordens','Emitir a partir da adjudicação aprovada.'],
|
||||
['enabled','receipts','Recepções','Conferir quantidades e qualidade da ordem emitida.'],
|
||||
['invoices','invoices','Facturas','Registar e validar o documento recebido.'],
|
||||
['obligations','obligations','Obrigações','Preparar fotografia e reconhecer por pessoa distinta.'],
|
||||
];$links=[];$permissions=app(Permissions::class);
|
||||
foreach($definitions as[$flag,$path,$label,$description]){
|
||||
$flag=$flag==='enabled'?$flag:$flag.'_enabled';$right='purchasing_'.$path.'.view';
|
||||
if(config('receipt_qa.'.$flag)===true&&$permissions->has($c->userId(),$scope['tenant_id'],$scope['company_id'],$right,$c->unitId()))$links[]=['url'=>'/purchasing/'.$path,'label'=>$label,'description'=>$description,'right'=>$right];
|
||||
}
|
||||
if(config('receipt_qa.dossiers_enabled')===true&&array_filter($links,fn($link)=>$link['right']==='purchasing_requests.view'))$links[]=['url'=>'/purchasing/dossiers','label'=>'Dossiês e evidência','description'=>'Consultar as etapas e o histórico autorizado da mesma requisição.','right'=>'purchasing_requests.view'];
|
||||
return $links;
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime)
|
||||
{
|
||||
abort_unless(config('receipt_qa.enabled')===true,404);$links=$this->links();abort_unless($links,403);
|
||||
$runtime->run($links[0]['right'],fn()=>null);
|
||||
return response()->view('purchasing-workspace',['links'=>$links]);
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,11 @@ final class ServiceReceivables
|
||||
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
|
||||
});
|
||||
}
|
||||
/** Scoped single-position read reuses the authoritative state calculation without writer locks. */
|
||||
public function position(int $obligation,int $actor):array
|
||||
{
|
||||
self::guard();$scope=$this->scope($actor,'view');$this->row($obligation,$scope,'obligation');return $this->state($obligation);
|
||||
}
|
||||
/** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */
|
||||
public function deliver(int $event): bool
|
||||
{
|
||||
|
||||
@@ -110,6 +110,12 @@ final class DirectCosts
|
||||
$id=DB::table('dc_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version,'kind'=>'decision','target_id'=>$r->id];
|
||||
});
|
||||
}
|
||||
/** Authoritative scoped conference for read-only consumers; no second cost calculator. */
|
||||
public function conference(int $order,int $actor):array {
|
||||
self::guard();$s=$this->scope($actor,'view');$o=$this->order($order,$s);$row=DB::table('dc_calculations')->where($s)->where('order_id',$order)->orderByDesc('version')->first();
|
||||
$decision=$row?DB::table('dc_decisions')->where('calculation_id',$row->id)->first():null;$current=$row?$this->calculationCurrent($row):false;
|
||||
return ['order'=>(array)$o,'calculation'=>$row?(array)$row:null,'snapshot'=>$row?json_decode($row->snapshot,true,64,JSON_THROW_ON_ERROR):null,'decision'=>$decision?(array)$decision:null,'current'=>$current];
|
||||
}
|
||||
public function listing(int $actor): array {
|
||||
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[],'notice'=>self::NOTICE];self::guard();
|
||||
return DB::transaction(function()use($s){
|
||||
|
||||
Reference in New Issue
Block a user