commit melhorias sgf

This commit is contained in:
2026-11-11 09:00:17 +02:00
parent 0b05a1192c
commit 1d11a5e57b
736 changed files with 40746 additions and 210 deletions
@@ -17,9 +17,10 @@ final class CompositionStockRead
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',
$d=$r->validate(['view'=>'sometimes|in:items,warehouses,balances,movements,period,inactive','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',
'as_of'=>'required_if:view,inactive|prohibited_unless:view,inactive|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31','days'=>'required_if:view,inactive|prohibited_unless:view,inactive|integer|min:1|max:3650',
'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);
@@ -28,7 +29,7 @@ final class CompositionStockRead
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);
}elseif($filters['view']==='inactive'){abort_unless(config('receipt_qa.stock_inactive_enabled')===true,404);$filters+=['as_of'=>$d['as_of'],'days'=>(int)$d['days']];}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)
@@ -72,9 +73,9 @@ final class CompositionStockRead
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'
$result=$filters['view']==='inactive' ?(new StockInactiveReport($source))->read($binding,$fingerprint,$filters,$d['snapshot']??null) :($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);
:(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');
@@ -0,0 +1,78 @@
<?php
namespace App\Modules\Inventory;
use App\Modules\Purchasing\ReceiptRuntime;
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Purchasing\Embedded\Services\{CurrentActor,OrganizationAccess,RequisitionWorkflow};
final class StockCatalogueController
{
public const POLICY='SGF-STOCK-CATALOGUE-QA-v1';
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
private function exact(array $v,array $rules):array{abort_if(array_diff(array_keys($v),array_keys($rules)),422);return Validator::make($v,$rules)->validate();}
private function q(array $p){return DB::table('sc_catalogue_versions')->where($p['scope']);}
private function latest(array $p){return $this->q($p)->whereNotExists(function($q){$q->selectRaw('1')->from('sc_catalogue_versions as newer')->whereColumn('newer.record_key','sc_catalogue_versions.record_key')->whereColumn('newer.tenant_id','sc_catalogue_versions.tenant_id')->whereColumn('newer.company_id','sc_catalogue_versions.company_id')->whereColumn('newer.unit_id','sc_catalogue_versions.unit_id')->whereColumn('newer.version','>','sc_catalogue_versions.version');});}
private function run(Request $r,ReceiptRuntime $rt,string $right,\Closure $fn):mixed
{
abort_unless(config('stock_catalogue.enabled')===true,404);CompositionRuntime::assert();$physical=CompositionRuntime::physical();$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];abort_unless(ModuleRecords::active()==='stock',403);
$file='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$physical['composition'].'/stock-catalogue-binding.json';$pin=config('stock_catalogue.binding_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);$p=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($p['composition']===$physical['composition']&&$p['scope']===$scope,403);
$host=(int)$r->user()->id;$generation=$this->hash($r->session()->get('company_context',[]));
try{return $rt->run($right,function($a,$b,$binding)use($p,$host,$generation,$right,$fn){
$a=app(CurrentActor::class)->lock($a);abort_if(app(OrganizationAccess::class)->maintenanceOnly($a),403);abort_unless($a->tenant_id===$p['scope']['tenant_id']&&!empty($b['warehouse_ids']),403);
if($right!=='stock.view'){$prepare=$right==='stock.items.prepare';abort_unless($host===$p[$prepare?'preparer_host_id':'reviewer_host_id']&&$a->id===$p[$prepare?'preparer_native_id':'reviewer_native_id'],403);}
$meta=DB::table('sc_catalogue_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&$meta->composition===$p['composition']&&hash_equals($meta->source_hash,(string)config('stock_catalogue.schema_sha256')),503,'Schema de catálogo não conferido.');
return $fn($a,$p,$host,$generation,$this->hash([$binding,$p]));
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Catálogo ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}
}
private function input(array $v):array
{
if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','record_key'=>'required|uuid','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:2000']);$d['record_key']=strtolower($d['record_key']);$d['version']=(int)$d['version'];
if($d['action']==='prepare'){$data=$d['data'];foreach(['code','name','unit']as$key)if(isset($data[$key])&&is_string($data[$key]))$data[$key]=trim($data[$key]);$d['data']=$this->exact($data,['code'=>['required','string','max:30','regex:/^[A-Z0-9][A-Z0-9_.-]{1,29}$/D'],'name'=>'required|string|min:3|max:150','unit'=>['required','string','max:12','regex:/^[A-Z][A-Z0-9]{0,11}$/D']]);}
else $d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;
}
private function basis(array $p,string $key,bool $lock=false):array
{
$last=$this->q($p)->where('record_key',$key)->orderByDesc('version')->first();$approved=$this->q($p)->where(['record_key'=>$key,'state'=>'approved'])->orderByDesc('version')->first();$item=null;if($approved){$q=DB::table('stock_items')->where(['tenant_id'=>$p['scope']['tenant_id'],'id'=>$approved->stock_item_id]);if($lock)$q->lockForUpdate();$item=$q->first(['id','code','name','unit']);abort_unless($item,409,'Artigo de origem indisponível.');$saved=json_decode($approved->data,true);abort_unless($item->code===$saved['code']&&$item->unit===$saved['unit']&&$item->name===$saved['name'],409,'Catálogo alterado fora desta versão; exige conferência.');}
return ['version'=>(int)($last?->version??0),'last'=>$last?(array)$last:null,'approved'=>$approved?(array)$approved:null,'item'=>$item?(array)$item:null];
}
private function execute(object $a,array $p,int $host,array $d,array $before):array
{
abort_unless($before['version']===$d['version'],409,'A versão mudou. Confira novamente.');$last=$before['last'];$approved=$before['approved'];$item=$before['item'];$data=$d['data'];$itemId=$item['id']??null;$willCreate=false;$willRename=false;
if($d['action']==='prepare'){
abort_unless($host===$p['preparer_host_id'],403);$state='prepared';$prepared=$host;$preparedNative=$a->id;
if($approved){abort_unless($data['code']===$item['code']&&$data['unit']===$item['unit'],422,'Código e unidade são imutáveis.');abort_if($data['name']===$item['name'],422,'Indique uma alteração de designação.');}
else abort_if(DB::table('stock_items')->where(['tenant_id'=>$a->tenant_id,'code'=>$data['code']])->exists(),409,'Código já existente; não associar artigo anterior automaticamente.');
}else{
abort_unless($last&&$last['state']==='prepared'&&$host!==$last['prepared_host_id']&&$a->id!==$last['prepared_native_id'],403,'Revisão exige preparação alheia actual.');$state=$data['decision'];$data=json_decode($last['data'],true);$prepared=$last['prepared_host_id'];$preparedNative=$last['prepared_native_id'];
if($state==='approved'){
if($item){abort_unless($data['code']===$item['code']&&$data['unit']===$item['unit'],409);$changed=DB::table('stock_items')->where(['id'=>$itemId,'tenant_id'=>$a->tenant_id,'code'=>$item['code'],'unit'=>$item['unit'],'name'=>$item['name']])->update(['name'=>$data['name'],'updated_at'=>now()->utc()]);abort_unless($changed===1,409);$willRename=true;}
else{abort_if(DB::table('stock_items')->where(['tenant_id'=>$a->tenant_id,'code'=>$data['code']])->exists(),409,'Código entretanto utilizado; confira novamente.');$itemId=DB::table('stock_items')->insertGetId(['tenant_id'=>$a->tenant_id,'created_by'=>$a->id,'code'=>$data['code'],'name'=>$data['name'],'unit'=>$data['unit'],'created_at'=>now()->utc(),'updated_at'=>now()->utc()]);$willCreate=true;}
}
}
$version=$before['version']+1;$id=DB::table('sc_catalogue_versions')->insertGetId($p['scope']+['record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_host_id'=>$prepared,'prepared_native_id'=>$preparedNative,'host_actor_id'=>$host,'actor_id'=>$a->id,'stock_item_id'=>$itemId,'data'=>json_encode($data,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],'created_at'=>now()->utc()]);
$impact=['before'=>['version'=>$before['version'],'state'=>$last['state']??null,'item'=>$item],'after'=>['version'=>$version,'state'=>$state,'data'=>$data],'creates_article'=>$willCreate,'renames_owned_article'=>$willRename,'stock_movements'=>0,'journal_entries'=>0,'initial_balance'=>null];
return ['id'=>$id,'record_key'=>$d['record_key'],'version'=>$version,'state'=>$state,'stock_item_id'=>$itemId,'impact'=>$impact];
}
public function preview(Request $r,ReceiptRuntime $rt)
{
$d=$this->input($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$rt,'stock.items.'.($d['action']==='prepare'?'prepare':'review'),function($a,$p,$host,$generation,$binding)use($d,$key){$before=$this->basis($p,$d['record_key'],true);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($a,$p,$host,$d,$before);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$host,'generation'=>$generation,'binding'=>$binding,'input'=>$d,'key'=>$key,'observed'=>time(),'basis'=>$this->hash($before),'impact'=>$out['impact']];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']])->header('Cache-Control','no-store');});
}
public function confirm(Request $r,ReceiptRuntime $rt)
{
$body=$this->exact($r->all(),['preview'=>'required|string|max:65536']);try{$cut=json_decode(Crypt::decryptString($body['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($cut)&&is_array($cut['input']??null),409);$d=$this->input($cut['input']);return$this->run($r,$rt,'stock.items.'.($d['action']==='prepare'?'prepare':'review'),function($a,$p,$host,$generation,$binding)use($cut,$d){abort_unless(($cut['actor']??null)===$host&&($cut['generation']??null)===$generation&&($cut['binding']??null)===$binding&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Prévia/contexto alterados.');$scope=$p['scope']+['host_actor_id'=>$host,'command_key'=>$cut['key']];$hash=$this->hash($d);$old=DB::table('sc_catalogue_commands')->where($scope)->first();if($old){abort_unless(hash_equals($old->request_hash,$hash)&&hash_equals($old->binding_hash,$binding),409,'Chave utilizada com outros dados.');return response()->json(json_decode($old->result,true)+['replayed'=>true]);}$before=$this->basis($p,$d['record_key'],true);abort_unless(hash_equals($cut['basis'],$this->hash($before)),409,'Catálogo alterado desde a prévia.');$out=$this->execute($a,$p,$host,$d,$before);abort_unless($out['impact']===$cut['impact'],409,'Impacto alterado.');app(RequisitionWorkflow::class)->audit($a,'sgf.stock.catalogue.command',null,$before,['host_actor_id'=>$host,'record_key'=>$d['record_key'],'operation_key'=>$cut['key'],'reason'=>$d['reason'],'after'=>$out]);DB::table('sc_catalogue_commands')->insert($scope+['actor_id'=>$a->id,'request_hash'=>$hash,'binding_hash'=>$binding,'input'=>json_encode($d,JSON_THROW_ON_ERROR),'result'=>json_encode($out,JSON_THROW_ON_ERROR),'created_at'=>now()->utc()]);return response()->json($out+['replayed'=>false],201)->header('Cache-Control','no-store');});
}
public function receipt(Request $r,ReceiptRuntime $rt,string $key)
{
abort_if($r->query()!==[],422);$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$rt,'stock.view',function($a,$p,$host,$g,$binding)use($key){$old=DB::table('sc_catalogue_commands')->where($p['scope']+['host_actor_id'=>$host,'command_key'=>$key])->first();abort_unless($old,404);abort_unless(hash_equals($old->binding_hash,$binding),409);return response()->json(['result'=>json_decode($old->result,true),'reason'=>json_decode($old->input,true)['reason'],'created_at'=>$old->created_at,'operation_executed'=>false,'historical_receipt_only'=>true])->header('Cache-Control','no-store');});
}
public function screen(Request $r,ReceiptRuntime $rt){abort_if($r->query()!==[],422);return$this->run($r,$rt,'stock.view',fn()=>response()->view('stock-catalogue-workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store'));}
public function index(Request $r,ReceiptRuntime $rt)
{
$d=$this->exact($r->query(),['view'=>'sometimes|in:items,proposals,history','search'=>'sometimes|nullable|string|max:100','state'=>'sometimes|in:prepared,approved,refused','record_key'=>'required_if:view,history|prohibited_unless:view,history|uuid','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$hostDb=DB::connection();return$this->run($r,$rt,'stock.view',function($a,$p,$host)use($d,$hostDb){$view=$d['view']??'proposals';$size=(int)($d['per_page']??25);$page=(int)($d['page']??1);if($view==='items'){$approved=$this->q($p)->where('state','approved')->whereNotExists(fn($q)=>$q->selectRaw('1')->from('sc_catalogue_versions as next')->whereColumn('next.record_key','sc_catalogue_versions.record_key')->whereColumn('next.version','>','sc_catalogue_versions.version')->whereColumn('next.tenant_id','sc_catalogue_versions.tenant_id')->whereColumn('next.company_id','sc_catalogue_versions.company_id')->whereColumn('next.unit_id','sc_catalogue_versions.unit_id')->where('next.state','approved'));$q=DB::table('stock_items as i')->where('i.tenant_id',$a->tenant_id)->leftJoinSub($approved,'v','v.stock_item_id','=','i.id')->select(['i.id','i.code','i.name','i.unit','v.record_key','v.version','v.state']);$code='i.code';$name='i.name';$order='i.id';}
else{$q=$view==='history'?$this->q($p)->where('record_key',$d['record_key']):$this->latest($p);if($view==='history')abort_unless((clone$q)->exists(),403,'Histórico fora deste contexto.');if(isset($d['state']))$q->where('state',$d['state']);$code="data->>'code'";$name="data->>'name'";$order='id';}
if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($v)=>$v->whereRaw("$code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("$name ILIKE ? ESCAPE '!'",[$needle]));}$rows=$q->orderByDesc($order)->paginate($size,['*'],'page',$page);foreach($rows->items()as$row)if(isset($row->data))$row->data=json_decode($row->data,true);return response()->json(['view'=>$view,'records'=>$rows,'capabilities'=>['prepare'=>$host===$p['preparer_host_id']&&$hostDb->table('permission_grants')->where($p['scope']+['user_id'=>$host,'permission'=>'stock.items.prepare'])->exists(),'review'=>$host===$p['reviewer_host_id']&&$hostDb->table('permission_grants')->where($p['scope']+['user_id'=>$host,'permission'=>'stock.items.review'])->exists()],'notice'=>'Artigos existentes sem proveniência deste percurso são apenas consultados. Preparar não cria artigo ou saldo.'])->header('Cache-Control','no-store');});
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Modules\Inventory;
use App\Modules\Purchasing\ReceiptRuntime;
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB};
use Tempo\Purchasing\Embedded\Models\{SupplyRecord,StockItem,Warehouse};
use Tempo\Purchasing\Embedded\Services\{CurrentActor,OrganizationAccess,SupplyPlanning,InventoryDispositions,StockWorkflow,RequisitionWorkflow};
/** Explicit QA policy and supplier return commands over the original native services. */
final class StockCompletionController
{
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
private function run(Request $r,ReceiptRuntime $rt,string $right,\Closure $fn):mixed
{
abort_unless(config('stock_completion.enabled')===true,404);$physical=CompositionRuntime::physical();
$file='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$physical['composition'].'/stock-completion-binding.json';$pin=config('stock_completion.binding_sha256');
abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);
$p=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);$host=$r->user()->id;$generation=$this->hash($r->session()->get('company_context',[]));
abort_unless($p['composition']===$physical['composition']&&$p['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],403);
try{return $rt->run($right,function($a,$b,$binding)use($fn,$p,$host,$generation){
$a=app(CurrentActor::class)->lock($a);abort_if(app(OrganizationAccess::class)->maintenanceOnly($a),403);
$review=$host===$p['reviewer_host_id'];abort_unless(in_array($host,[$p['dispatcher_host_id'],$p['reviewer_host_id']],true)&&$a->id===($review?$p['reviewer_native_id']:$p['dispatcher_native_id'])&&in_array($p['warehouse_id'],$b['warehouse_ids'],true),403);
return $fn($a,$p,$host,$generation,$binding,$review);
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Operação ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}
}
private function family(string $family):string{abort_unless(in_array($family,['policy','supplier_return'],true),422);return $family==='policy'?'stock.planning':'stock.disposition';}
private function query(array $p,string $family){return app(SupplyPlanning::class)->latest($family==='policy'?'policy':'stock_event')->where('tenant_id',1)->where('stock_item_id',$p['stock_item_id'])->where('warehouse_id',$p['warehouse_id']);}
private function basis(array $p):array
{
return ['policy'=>$this->hash($p),'balance'=>app(StockWorkflow::class)->balance($p['stock_item_id'],$p['warehouse_id']),
'revision'=>(int)SupplyRecord::where('tenant_id',1)->where('stock_item_id',$p['stock_item_id'])->where('warehouse_id',$p['warehouse_id'])->max('id'),
'item'=>StockItem::findOrFail($p['stock_item_id'])->only(['id','code','name','unit']),
'warehouse'=>Warehouse::findOrFail($p['warehouse_id'])->only(['id','code','name'])];
}
public function screen(Request $r,ReceiptRuntime $rt){return $this->run($r,$rt,'stock.planning.view',fn()=>response()->view('stock-completion-workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store'));}
public function index(Request $r,ReceiptRuntime $rt)
{
$d=$r->validate(['family'=>'required|in:policy,supplier_return','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','state'=>'nullable|in:draft,returned,approved,suspended,executed','record_key'=>'sometimes|uuid']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $this->run($r,$rt,$this->family($d['family']).'.view',function($a,$p,$host,$g,$binding,$review)use($d){
abort_unless($d['family']==='policy'?app(SupplyPlanning::class)->allows($a,'supply_read'):app(InventoryDispositions::class)->allows($a,'read'),403);
$q=isset($d['record_key'])?SupplyRecord::where('tenant_id',1)->where('kind',$d['family']==='policy'?'policy':'stock_event')->where('stock_item_id',$p['stock_item_id'])->where('warehouse_id',$p['warehouse_id'])->where('record_key',$d['record_key']):$this->query($p,$d['family']);if(!empty($d['state']))$q->where('state',$d['state']);
$page=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));
$basis=$this->basis($p);return response()->json(['basis'=>$basis,'revision'=>$this->hash($basis),'reviewer'=>$review,'records'=>$page,'receipt_item_id'=>$p['receipt_item_id'],'capabilities'=>['prepare'=>!$review,'review'=>$review,'execute'=>!$review]])->header('Cache-Control','no-store');
});
}
private function right(array $d):string{return $d['action']==='execute'?'stock.operations.confirm':$this->family($d['family']).'.'.($d['action']==='save'?'prepare':'review');}
public function preview(Request $r,ReceiptRuntime $rt)
{
$d=$r->validate(['family'=>'required|in:policy,supplier_return','action'=>'required|in:save,approve,return,suspend,execute','operation_key'=>'required|uuid','record_key'=>'nullable|uuid','version'=>'required|integer|min:0','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:4000','data'=>'present|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$d['operation_key']=strtolower($d['operation_key']);
return $this->run($r,$rt,$this->right($d),function($a,$p,$host,$g,$binding,$review)use($d){
abort_unless($d['revision']===$this->hash($this->basis($p)),409,'O estado mudou. Confira novamente.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();
try{$result=$this->execute($a,$p,$d,$review);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
$cut=['input'=>$d,'host'=>$host,'native'=>$a->id,'generation'=>$g,'binding'=>$binding,'observed'=>time(),'impact'=>$result['impact']];
return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$result['impact']])->header('Cache-Control','no-store');
});
}
public function confirm(Request $r,ReceiptRuntime $rt)
{
$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);
try{$cut=json_decode(Crypt::decryptString($r->input('preview')),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}
abort_unless(is_array($cut['input']??null),409);
return $this->run($r,$rt,$this->right($cut['input']),function($a,$p,$host,$g,$binding,$review)use($cut){
abort_unless($cut['host']===$host&&$cut['native']===$a->id&&$cut['generation']===$g&&$cut['binding']===$binding&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Contexto ou prévia expirados.');$d=$cut['input'];$db=DB::connection();
$old=$db->table('audit_logs')->where(['tenant_id'=>1,'user_id'=>$a->id,'action'=>'sgf.stock.completion.command'])->where('after->operation_key',$d['operation_key'])->first();
if($old){$v=json_decode($old->after,true);abort_unless($v['request_hash']===$this->hash($d)&&$v['host_actor_id']===$host&&($v['policy']??null)===$this->hash($p),409);return response()->json($v['result']+['replayed'=>true]);}
$before=$this->basis($p);abort_unless($this->hash($before)===$d['revision'],409,'Estado alterado desde a conferência.');$result=$this->execute($a,$p,$d,$review);abort_unless($result['impact']===$cut['impact'],409,'Impacto alterado.');
app(RequisitionWorkflow::class)->audit($a,'sgf.stock.completion.command',null,$before,['operation_key'=>$d['operation_key'],'request_hash'=>$this->hash($d),'host_actor_id'=>$host,'policy'=>$this->hash($p),'reason'=>$d['reason'],'result'=>$result,'after'=>$this->basis($p)]);
return response()->json($result+['replayed'=>false],201)->header('Cache-Control','no-store');
});
}
public function receipt(Request $r,ReceiptRuntime $rt,string $key)
{
abort_if($r->query()!==[],422);abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);
return $this->run($r,$rt,'stock.planning.view',function($a,$p,$host)use($key){
$row=DB::table('audit_logs')->where(['tenant_id'=>1,'user_id'=>$a->id,'action'=>'sgf.stock.completion.command'])->where('after->operation_key',$key)->first();abort_unless($row,404);$v=json_decode($row->after,true);abort_unless($v['host_actor_id']===$host&&$v['policy']===$this->hash($p),403);
return response()->json(['result'=>$v['result'],'reason'=>$v['reason'],'created_at'=>$row->created_at,'historical_receipt_only'=>true,'operation_executed'=>false])->header('Cache-Control','no-store');
});
}
private function execute(object $a,array $p,array $d,bool $review):array
{
$family=$d['family'];$action=$d['action'];abort_unless($family==='policy'?in_array($action,['save','approve','return','suspend'],true):in_array($action,['save','approve','return','execute'],true),422);
abort_unless($review===in_array($action,['approve','return','suspend'],true),403);
abort_if(SupplyRecord::where('operation_key',$d['operation_key'])->exists(),409,'Chave utilizada. Recupere a confirmação original.');
if(!empty($d['record_key'])){$old=$this->query($p,$family)->where('record_key',$d['record_key'])->firstOrFail();abort_unless($old->version===$d['version'],409,'Versão alterada.');}
else abort_unless($d['version']===0&&$action==='save',409);
if($action==='save'){
abort_unless(($d['data']['stock_item_id']??null)===$p['stock_item_id']&&($d['data']['warehouse_id']??null)===$p['warehouse_id'],403);
if($family==='supplier_return')abort_unless(($d['data']['kind']??null)==='supplier_return'&&($d['data']['receipt_item_id']??null)===$p['receipt_item_id']&&empty($d['data']['lot_id']),403);
else abort_unless(($d['data']['forecast_method']??null)==='manual'&&empty($d['data']['agreement_key'])&&($d['data']['origin']??null)==='local'&&($d['data']['purpose']??null)==='internal',422);
if(!empty($d['data']['predecessor_key']))abort_unless($this->query($p,'policy')->where('record_key',$d['data']['predecessor_key'])->where('version',$d['data']['predecessor_version']??0)->exists(),403);
}
// No automatic purchasing rule may act on this selected QA source.
abort_if(app(SupplyPlanning::class)->latest('auto_rule')->where('stock_item_id',$p['stock_item_id'])->where('warehouse_id',$p['warehouse_id'])->where('state','approved')->exists(),409,'Existe regra automática. É necessária conferência própria.');
$envelope=array_intersect_key($d,array_flip(['operation_key','record_key','version','action','reason','data']));$before=app(StockWorkflow::class)->balance($p['stock_item_id'],$p['warehouse_id']);
$record=$family==='policy'?app(SupplyPlanning::class)->save($a,'policy',$envelope):app(InventoryDispositions::class)->save($a,$envelope);
$after=app(StockWorkflow::class)->balance($p['stock_item_id'],$p['warehouse_id']);
return ['record_id'=>$record->id,'record_key'=>$record->record_key,'version'=>$record->version,'impact'=>['family'=>$family,'action'=>$action,'state'=>$record->state,'before_milli'=>$before['quantity'],'after_milli'=>$after['quantity'],'physical_change_milli'=>$after['quantity']-$before['quantity'],'financial_credit_created'=>false,'financial_posting'=>false]];
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Modules\Inventory;
use App\Modules\Purchasing\ReceiptRuntime;
use App\Shared\Context\CompanyContext;
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB};
use Tempo\Purchasing\Embedded\Models\{StockItem,StockOperation,StockMovement,SupplyRecord,Warehouse};
use Tempo\Purchasing\Embedded\Services\{CurrentActor,OrganizationAccess,InventoryLots,InventoryCounts,StockWorkflow,WarehouseReservations,RequisitionWorkflow};
/** Scoped QA transport for the original lot/count services and one append-only transit protocol. */
final class StockFinalController
{
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
private function policy():array
{
abort_unless(config('stock_operations.final_enabled')===true,404);$p=CompositionRuntime::physical();
$path='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$p['composition'].'/stock-final-binding.json';$pin=config('stock_operations.final_sha256');
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&is_file($path)&&!is_link($path)&&hash_equals($pin,hash_file('sha256',$path)),503);
$v=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);abort_unless($v['composition']===$p['composition']&&$v['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&isset($v['lot_item_id'],$v['transfer_item_id']),403);return $v;
}
private function run(Request $r,ReceiptRuntime $rt,string $right,\Closure $fn):mixed
{
$p=$this->policy();$hostActor=$r->user()->id;$generation=$this->hash($r->session()->get('company_context',[]));
try{return $rt->run($right,function($a,$b,$binding)use($fn,$p,$hostActor,$generation){
$a=app(CurrentActor::class)->lock($a);abort_if(app(OrganizationAccess::class)->maintenanceOnly($a),403);
abort_unless(in_array($hostActor,[$p['dispatcher_host_id'],$p['reviewer_host_id']],true),403);
$review=$hostActor===$p['reviewer_host_id'];abort_unless($a->id===($review?$p['reviewer_native_id']:$p['dispatcher_native_id']),403);
foreach([$p['source_warehouse_id'],$p['destination_warehouse_id']] as $id)abort_unless(in_array($id,$b['warehouse_ids'],true),403);
return $fn($a,$p,$hostActor,$generation,$binding,$review);
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Operação ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}
}
private function basis(array $p):array
{
$scope=$p['scope'];$db=DB::connection();$w=app(StockWorkflow::class);
return ['transfer_source'=>$w->balance($p['transfer_item_id'],$p['source_warehouse_id']),'transfer_destination'=>$w->balance($p['transfer_item_id'],$p['destination_warehouse_id']),
'lot_balance'=>$w->balance($p['lot_item_id'],$p['source_warehouse_id']),
'lot_context'=>app(InventoryLots::class)->context($p['lot_item_id'],$p['source_warehouse_id']),
'lot_revision'=>(int)SupplyRecord::where('tenant_id',1)->where('stock_item_id',$p['lot_item_id'])->where('warehouse_id',$p['source_warehouse_id'])->max('id'),
'transit_revision'=>(int)$db->table('sgf_stock_transit_events')->where($scope)->max('id'),'policy'=>$this->hash($p)];
}
public function screen(Request $r,ReceiptRuntime $rt){return $this->run($r,$rt,'stock.view',fn()=>response()->view('stock-final-workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store'));}
public function history(Request $r,ReceiptRuntime $rt)
{
$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);
return $this->run($r,$rt,'stock.view',function($a,$p)use($d){$q=DB::table('audit_logs')->where(['tenant_id'=>1,'action'=>'sgf.stock.final.command'])->whereIn('user_id',[$p['dispatcher_native_id'],$p['reviewer_native_id']])->whereIn('after->host_actor_id',[$p['dispatcher_host_id'],$p['reviewer_host_id']])->where('before->policy',$this->hash($p));
$page=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['id','user_id','before','after','created_at'],'page',(int)($d['page']??1));
$page->getCollection()->transform(function($row){$v=json_decode($row->after,true);return ['id'=>$row->id,'author_id'=>$row->user_id,'host_author_id'=>$v['host_actor_id'],'created_at'=>$row->created_at,'reason'=>$v['reason'],'operation_key'=>$v['operation_key'],'result'=>$v['result'],'before'=>json_decode($row->before,true),'after'=>$v['after']];});return response()->json($page)->header('Cache-Control','no-store');});
}
public function index(Request $r,ReceiptRuntime $rt)
{
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','search'=>'nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $this->run($r,$rt,'stock.view',function($a,$p,$host,$g,$binding,$review)use($d){
abort_unless(app(InventoryLots::class)->allows($a,'inventory_lot_read'),403);$basis=$this->basis($p);$q=DB::table('sgf_stock_transit_events')->where($p['scope'])->where('action','dispatch');
if(($d['search']??'')!=='')$q->whereRaw("transfer_key::text LIKE ? ESCAPE '!'",['%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%']);
$page=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$ids=$page->pluck('transfer_key')->all();
$settled=DB::table('sgf_stock_transit_events')->where($p['scope'])->whereIn('transfer_key',$ids)->where('action','<>','dispatch')->groupBy('transfer_key')->selectRaw('transfer_key,sum(quantity_milli) quantity')->get()->keyBy('transfer_key');
$page->getCollection()->transform(function($row)use($settled){$row->remaining_milli=(int)$row->quantity_milli-(int)($settled->get($row->transfer_key)->quantity??0);return $row;});
$records=SupplyRecord::where('tenant_id',1)->where('stock_item_id',$p['lot_item_id'])->where('warehouse_id',$p['source_warehouse_id'])->orderByDesc('id')->limit(100)->get();
$counts=StockOperation::where('tenant_id',1)->where('stock_item_id',$p['lot_item_id'])->where('warehouse_id',$p['source_warehouse_id'])->where('kind','count')->whereNotIn('id',StockOperation::where('tenant_id',1)->whereNotNull('count_id')->select('count_id'))->orderByDesc('id')->limit(100)->get(['id','reference','quantity_milli']);
$inTransit=(int)DB::table('sgf_stock_transit_events')->where($p['scope'])->selectRaw("coalesce(sum(CASE WHEN action='dispatch' THEN quantity_milli ELSE -quantity_milli END),0) quantity")->value('quantity');
return response()->json(['basis'=>$basis,'revision'=>$this->hash($basis),'reviewer'=>$review,'policy'=>$p,'items'=>StockItem::whereIn('id',[$p['lot_item_id'],$p['transfer_item_id']])->get(['id','code','name','unit']),'warehouses'=>Warehouse::whereIn('id',[$p['source_warehouse_id'],$p['destination_warehouse_id']])->get(['id','name']),'transfers'=>$page,'lot_records'=>$records,'counts'=>$counts,'lot_count_source'=>app(InventoryCounts::class)->source($p['lot_item_id'],$p['source_warehouse_id']),'in_transit_milli'=>$inTransit])->header('Cache-Control','no-store');
});
}
private function right(string $action):string{return match($action){'receive'=>'stock.transfers.receive','activate','release','adjustment'=>'stock.lots.review',default=>'stock.operations.prepare'};}
public function preview(Request $r,ReceiptRuntime $rt)
{
$d=$r->validate(['action'=>'required|in:dispatch,receive,return,prepare,activate,identify,hold,release,count,adjustment','operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:4000','data'=>'present|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$d['operation_key']=strtolower($d['operation_key']);
return $this->run($r,$rt,$this->right($d['action']),function($a,$p,$host,$g,$binding,$review)use($d){
abort_unless(hash_equals($this->hash($this->basis($p)),$d['revision']),409,'O estado mudou. Confira novamente.');
$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$result=$this->execute($a,$p,$host,$review,$d);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
// Generated IDs are not a promise: preview inserts are rolled back.
$cut=['input'=>$d,'host'=>$host,'native'=>$a->id,'generation'=>$g,'binding'=>$binding,'observed'=>time(),'impact'=>$result['impact']];
return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$result['impact']])->header('Cache-Control','no-store');
});
}
public function confirm(Request $r,ReceiptRuntime $rt)
{
$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);
try{$cut=json_decode(Crypt::decryptString($r->input('preview')),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}
abort_unless(in_array($cut['input']['action']??null,['dispatch','receive','return','prepare','activate','identify','hold','release','count','adjustment'],true),409);
return $this->run($r,$rt,$this->right($cut['input']['action']),function($a,$p,$host,$g,$binding,$review)use($cut){
abort_unless($cut['host']===$host&&$cut['native']===$a->id&&$cut['generation']===$g&&$cut['binding']===$binding&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Contexto ou prévia expirados.');$d=$cut['input'];$db=DB::connection();
$old=$db->table('audit_logs')->where(['tenant_id'=>1,'user_id'=>$a->id,'action'=>'sgf.stock.final.command'])->where('after->operation_key',$d['operation_key'])->first();
if($old){$v=json_decode($old->after,true);abort_unless($v['request_hash']===$this->hash($d)&&$v['host_actor_id']===$host,409);return response()->json($v['result']+['replayed'=>true]);}
$before=$this->basis($p);abort_unless(hash_equals($this->hash($before),$d['revision']),409,'Estado alterado desde a conferência.');
$result=$this->execute($a,$p,$host,$review,$d);abort_unless($result['impact']===$cut['impact'],409,'Impacto alterado.');
app(RequisitionWorkflow::class)->audit($a,'sgf.stock.final.command',null,$before,['operation_key'=>$d['operation_key'],'request_hash'=>$this->hash($d),'host_actor_id'=>$host,'reason'=>$d['reason'],'result'=>$result,'after'=>$this->basis($p)]);
return response()->json($result+['replayed'=>false],201)->header('Cache-Control','no-store');
});
}
public function receipt(Request $r,ReceiptRuntime $rt,string $key)
{
abort_if($r->query()!==[],422);abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);
return $this->run($r,$rt,'stock.view',function($a,$p,$host)use($key){$row=DB::table('audit_logs')->where(['tenant_id'=>1,'user_id'=>$a->id,'action'=>'sgf.stock.final.command'])->where('after->operation_key',$key)->first();abort_unless($row,404);$v=json_decode($row->after,true);abort_unless($v['host_actor_id']===$host,403);return response()->json(['command_key'=>$key,'created_at'=>$row->created_at,'reason'=>$v['reason'],'result'=>$v['result'],'operation_executed'=>false,'historical_receipt_only'=>true])->header('Cache-Control','no-store');});
}
private function execute(object $a,array $p,int $host,bool $review,array $d):array
{
$action=$d['action'];$data=$d['data'];$w=app(StockWorkflow::class);$lots=app(InventoryLots::class);
abort_if(StockOperation::where('idempotency_key',$d['operation_key'])->exists()||SupplyRecord::where('operation_key',$d['operation_key'])->exists(),409,'Chave já utilizada. Consulte o recibo original; não altere o conteúdo.');
if(in_array($action,['dispatch','receive','return'],true))return $this->transfer($a,$p,$host,$review,$d);
abort_unless($review===in_array($action,['activate','release','adjustment'],true),403);
if(in_array($action,['prepare','activate','identify','hold','release'],true)){
$v=validator($data,['record_key'=>'nullable|uuid','version'=>'required|integer|min:0','data'=>'present|array'])->validate();abort_if(array_diff(array_keys($data),['record_key','version','data']),422);
if($action==='prepare'){abort_unless(($v['data']['stock_item_id']??null)===$p['lot_item_id']&&($v['data']['warehouse_id']??null)===$p['source_warehouse_id'],403);}
else{$old=SupplyRecord::where('tenant_id',1)->where('record_key',$v['record_key']??'')->orderByDesc('version')->firstOrFail();abort_unless($old->stock_item_id===$p['lot_item_id']&&$old->warehouse_id===$p['source_warehouse_id'],403);}
$record=$lots->save($a,['action'=>$action,'operation_key'=>$d['operation_key'],'record_key'=>$v['record_key']??null,'version'=>(int)$v['version'],'reason'=>$d['reason'],'data'=>$v['data']]);
return ['record_id'=>$record->id,'record_key'=>$record->record_key,'version'=>$record->version,'impact'=>['action'=>$action,'physical_change_milli'=>0,'state'=>$record->state,'stock_item_id'=>$p['lot_item_id'],'warehouse_id'=>$p['source_warehouse_id']]];
}
abort_unless(in_array($action,['count','adjustment'],true),422);
$v=validator($data,['quantity'=>'required|string|regex:/^\d{1,9}(\.\d{1,3})?$/','ledger_version'=>'required|integer|min:0','lot_version'=>'nullable|string|size:64','lot_counts'=>'sometimes|array|max:100','count_id'=>'nullable|integer|min:1'])->validate();abort_if(array_diff(array_keys($data),array_keys($v)),422);
if($action==='adjustment'){$count=StockOperation::where('tenant_id',1)->findOrFail($v['count_id']??0);abort_unless($count->kind==='count'&&$count->stock_item_id===$p['lot_item_id']&&$count->warehouse_id===$p['source_warehouse_id'],403);}
$before=$w->balance($p['lot_item_id'],$p['source_warehouse_id']);
$op=$w->record($a,$v+['kind'=>$action,'idempotency_key'=>$d['operation_key'],'reference'=>'QA-LOTS-'.$d['operation_key'],'stock_item_id'=>$p['lot_item_id'],'warehouse_id'=>$p['source_warehouse_id'],'responsible_id'=>$a->id,'reason'=>$d['reason']]);
$after=$w->balance($p['lot_item_id'],$p['source_warehouse_id']);
return ['operation_id'=>$op->id,'impact'=>['action'=>$action,'physical_before_milli'=>$before['quantity'],'physical_after_milli'=>$after['quantity'],'physical_change_milli'=>$after['quantity']-$before['quantity'],'counted_milli'=>$op->quantity_milli,'financial_posting'=>false]];
}
private function transfer(object $a,array $p,int $host,bool $review,array $d):array
{
$action=$d['action'];abort_unless($review===($action==='receive'),403);if(!$review)abort_unless($a->hasFunctions(['warehouse']),403);
$v=validator($d['data'],['quantity'=>'required|string|regex:/^\d{1,9}(\.\d{1,3})?$/','transfer_key'=>'required|uuid','stock_item_id'=>'required|integer','source_warehouse_id'=>'required|integer','destination_warehouse_id'=>'required|integer'])->validate();abort_if(array_diff(array_keys($d['data']),array_keys($v)),422);
abort_unless($v['stock_item_id']===$p['transfer_item_id']&&$v['source_warehouse_id']===$p['source_warehouse_id']&&$v['destination_warehouse_id']===$p['destination_warehouse_id'],403);
$db=DB::connection();$q=$db->table('sgf_stock_transit_events')->where($p['scope'])->where('transfer_key',$v['transfer_key']);$dispatch=(clone $q)->where('action','dispatch')->first();$qty=\Tempo\Purchasing\Embedded\Services\ReceiptWorkflow::milli($v['quantity']);abort_unless($qty>0,422);
$w=app(StockWorkflow::class);$warehouse=$action==='receive'?$p['destination_warehouse_id']:$p['source_warehouse_id'];$before=$w->balance($p['transfer_item_id'],$warehouse);$remaining=0;
if($action==='dispatch'){
abort_if($dispatch!==null,409,'Expedição já registada.');abort_if(app(InventoryLots::class)->setup($p['transfer_item_id'],$warehouse)!==null,409,'Este protocolo exige artigo sem lotes.');
$free=app(WarehouseReservations::class)->free($p['transfer_item_id'],$warehouse);abort_unless($before['quantity']>=$qty&&$free>=$qty,409,'Quantidade disponível insuficiente.');$remaining=$qty;
}else{
abort_unless($dispatch&&$dispatch->host_actor_id!==$host||($dispatch&&$action==='return'),403);
$remaining=(int)$dispatch->quantity_milli-(int)(clone $q)->where('action','<>','dispatch')->sum('quantity_milli');abort_unless($qty<=$remaining,409,'A parcela excede o trânsito por receber.');$remaining-=$qty;
}
$sign=$action==='dispatch'?-1:1;$impact=['action'=>$action,'quantity_milli'=>$qty,'physical_before_milli'=>$before['quantity'],'physical_after_milli'=>$before['quantity']+$sign*$qty,'in_transit_after_milli'=>$remaining,'ownership_change_milli'=>0,'financial_posting'=>false];
$snapshot=['sgf_internal_transit'=>['scope'=>$p['scope'],'transfer_key'=>$v['transfer_key'],'action'=>$action,'host_actor_id'=>$host,'source_warehouse_id'=>$p['source_warehouse_id'],'destination_warehouse_id'=>$p['destination_warehouse_id']],'development_only'=>true,'budget_checked'=>false,'impact'=>$impact];
$op=StockOperation::create(['tenant_id'=>1,'created_by'=>$a->id,'responsible_id'=>$a->id,'stock_item_id'=>$p['transfer_item_id'],'warehouse_id'=>$warehouse,'destination_warehouse_id'=>$action==='dispatch'?$p['destination_warehouse_id']:null,'kind'=>'transfer','reference'=>'QA-TRANSIT-'.$d['operation_key'],'idempotency_key'=>$d['operation_key'],'payload_hash'=>$this->hash($d),'reason'=>$d['reason'],'quantity_milli'=>$qty,'theoretical_milli'=>$before['quantity'],'ledger_version'=>$before['version'],'snapshot'=>$snapshot,'recorded_at'=>now()]);
$movement=StockMovement::create(['tenant_id'=>1,'stock_operation_id'=>$op->id,'stock_item_id'=>$p['transfer_item_id'],'warehouse_id'=>$warehouse,'moved_by'=>$a->id,'movement_type'=>$action==='dispatch'?'transfer_out':'transfer_in','quantity_milli'=>$sign*$qty,'snapshot'=>$snapshot,'moved_at'=>now()]);
$id=$db->table('sgf_stock_transit_events')->insertGetId($p['scope']+['transfer_key'=>$v['transfer_key'],'command_key'=>$d['operation_key'],'request_hash'=>$this->hash($d),'action'=>$action,'actor_id'=>$a->id,'host_actor_id'=>$host,'stock_item_id'=>$p['transfer_item_id'],'source_warehouse_id'=>$p['source_warehouse_id'],'destination_warehouse_id'=>$p['destination_warehouse_id'],'quantity_milli'=>$qty,'movement_id'=>$movement->id,'reason'=>$d['reason'],'snapshot'=>json_encode($snapshot),'created_at'=>now()]);
return ['event_id'=>$id,'operation_id'=>$op->id,'movement_id'=>$movement->id,'transfer_key'=>$v['transfer_key'],'impact'=>$impact];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Modules\Inventory;
use Illuminate\Database\ConnectionInterface;use Illuminate\Support\Facades\Crypt;
/** Inactivity of known article/warehouse pairs; never invents placement or availability. */
final class StockInactiveReport
{
public function __construct(private ConnectionInterface $db){}
public function read(array $binding,string $fingerprint,array $f,?string $token):array
{
if($f['warehouse_id']!==null)abort_unless(in_array($f['warehouse_id'],$binding['warehouse_ids'],true),403);
return $this->db->transaction(function()use($binding,$fingerprint,$f,$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{$c=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Referência de inactividade inválida. Actualize a consulta.');}abort_unless(($c['fingerprint']??null)===$fingerprint&&($c['revision']??null)===$revision&&is_int($c['observed']??null)&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'A origem ou o contexto mudou. Actualize o relatório.');$observed=$c['observed'];}
$asOf=new \DateTimeImmutable($f['as_of'],new \DateTimeZone('UTC'));$until=$asOf->modify('+1 day')->format('Y-m-d').' 00:00:00+00';$cut=$asOf->modify('-'.($f['days']-1).' days')->format('Y-m-d').' 00: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($f['warehouse_id']!==null)$q->where('m.warehouse_id',$f['warehouse_id']);if($f['search']!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$f['search']).'%';$q->where(fn($s)=>$s->whereRaw("i.code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("i.name ILIKE ? ESCAPE '!'",[$needle]));}
$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(m.quantity_milli::numeric)::text physical_quantity_milli,MAX(m.moved_at) last_registered_at')->selectRaw("(?::date-(MAX(m.moved_at) AT TIME ZONE 'UTC')::date)::integer days_without_record",[$f['as_of']])->groupBy('m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code','w.name')->havingRaw('MAX(m.moved_at) < ?',[$cut])->orderBy('m.stock_item_id')->orderBy('m.warehouse_id');
$total=(int)$this->db->query()->fromSub((clone$q)->reorder(),'stock_inactive_pairs')->count();$rows=$q->limit($f['per_page'])->offset(($f['page']-1)*$f['per_page'])->get()->map(fn($r)=>(array)$r)->all();
return ['status'=>'available','synthetic_only'=>true,'report'=>'known_pairs_inactivity','as_of'=>$f['as_of'],'minimum_days'=>$f['days'],'timezone'=>'UTC','rows'=>$rows,'pagination'=>['page'=>$f['page'],'per_page'=>$f['per_page'],'total'=>$total,'last_page'=>max(1,(int)ceil($total/$f['per_page']))],'observed_at'=>gmdate('c',$observed),'ledger_revision'=>$revision,'snapshot'=>Crypt::encryptString(json_encode(['fingerprint'=>$fingerprint,'revision'=>$revision,'observed'=>$observed],JSON_THROW_ON_ERROR)),'capabilities'=>['write'=>false,'valuation'=>false,'reserved'=>false,'blocked'=>false],'notice'=>'Último registo físico UTC em pares artigo/armazém com história conhecida até ao corte. Metadados actuais. Quantidade física não é disponível. Sem história não se atribui armazém nem se presume inactividade, saldo zero ou ausência de actividade real. Sem somar unidades; corte futuro não prevê movimentos por registar.'];
});
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Modules\Inventory;
use App\Modules\Purchasing\ReceiptRuntime;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt, DB, Validator};
use Tempo\Purchasing\Embedded\Models\{AuditLog, StockItem, StockMovement, StockOperation, User, Warehouse};
use Tempo\Purchasing\Embedded\Services\{CurrentActor, OrganizationAccess, RequisitionWorkflow, StockWorkflow};
/** SGF transport over the pinned physical workflow; never an independent stock ledger. */
final class StockOperationsController
{
private function run(ReceiptRuntime $rt,string $right,\Closure $work): mixed
{
try { return $rt->run($right,$work); }
catch(\Illuminate\Database\QueryException $e){
if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;
return response()->json(['message'=>'Operação ocupada. Recupere a mesma confirmação; não crie outra chave.'],503)->header('Cache-Control','no-store')->header('Retry-After','5');
}
}
private function guard(): void { abort_unless(config('stock_operations.enabled') === true,404); }
private function hash(array $value): string { return hash('sha256',json_encode($value,JSON_THROW_ON_ERROR)); }
private function generation(): string { return $this->hash(request()->session()->get('company_context',[])); }
private function operator(object $actor): object
{
$actor=app(CurrentActor::class)->lock($actor,['warehouse']);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor),403);
return $actor;
}
private function entities(array $data,array $binding,object $actor): array
{
$warehouses=array_values(array_unique([$data['warehouse_id'],...($data['kind']==='transfer'?[$data['destination_warehouse_id']]:[])]));
foreach($warehouses as $id) abort_unless(in_array($id,$binding['warehouse_ids']??[],true),403,'Armazém fora do contexto autorizado.');
$item=StockItem::where('tenant_id',$actor->tenant_id)->findOrFail($data['stock_item_id']);
$rows=Warehouse::where('tenant_id',$actor->tenant_id)->whereIn('id',$warehouses)->orderBy('id')->get();
abort_unless($rows->count()===count($warehouses),403);
$responsible=User::where('tenant_id',$actor->tenant_id)->findOrFail($data['responsible_id']);
$basis=['item'=>$item->only(['id','code','name','unit']),'warehouses'=>$rows->map->only(['id','code','name'])->all(),
'responsible'=>$responsible->only(['id','name']), 'balances'=>array_map(fn($id)=>['warehouse_id'=>$id]+app(StockWorkflow::class)->balance($item->id,$id),$warehouses)];
if($data['kind']==='adjustment'){
$count=StockOperation::where('tenant_id',$actor->tenant_id)->where('kind','count')->where('stock_item_id',$item->id)->where('warehouse_id',$data['warehouse_id'])->findOrFail($data['count_id']);
$basis['count']=$count->toArray();$basis['count_applied']=StockOperation::where('tenant_id',$actor->tenant_id)->where('count_id',$count->id)->exists();
}
return $basis;
}
private function execute(array $data,Request $http,object $actor): array
{
$input=Request::create('/embedded/stock-operation','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);
$input->setUserResolver(fn($guard=null)=>$actor);
if($http->hasSession()) $input->setLaravelSession($http->session());
$response=(new \Tempo\Purchasing\Embedded\Http\Controllers\StockOperationController)->store($input,app(StockWorkflow::class));
$operation=StockOperation::findOrFail($response->getData(true)['id']);
$difference=match($operation->kind){'count'=>0,'adjustment'=>(int)$operation->quantity_milli-(int)$operation->theoretical_milli,default=>-(int)$operation->quantity_milli};
return ['operation'=>$operation,'impact'=>[
'kind'=>$operation->kind,'reference'=>$operation->reference,'quantity_milli'=>$operation->quantity_milli,
'stock_item_id'=>$operation->stock_item_id,'warehouse_id'=>$operation->warehouse_id,
'destination_warehouse_id'=>$operation->destination_warehouse_id,'destination'=>$operation->destination,
'theoretical_milli'=>$operation->theoretical_milli,'snapshot'=>$operation->snapshot,
'change_milli'=>$difference,'quantity_after_milli'=>(int)$operation->theoretical_milli+$difference,
'destination_quantity_after_milli'=>$operation->kind==='transfer'?app(StockWorkflow::class)->balance($operation->stock_item_id,$operation->destination_warehouse_id)['quantity']:null,
'reason'=>$operation->reason,'financial_posting'=>false,
]];
}
public function screen(Request $r,ReceiptRuntime $rt)
{
$this->guard();$access=$this->run($rt,'stock.view',fn($a)=>$this->operator($a));
if($access instanceof \Symfony\Component\HttpFoundation\Response)return $access;
return response()->view('stock-operations-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()])->header('Cache-Control','no-store');
}
public function index(Request $r,ReceiptRuntime $rt)
{
$this->guard();$d=$r->validate(['view'=>'required|in:items,warehouses,people,equipment,counts,history','search'=>'nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','warehouse_id'=>'required_if:view,counts|prohibited_unless:view,counts|integer|min:1','stock_item_id'=>'required_if:view,counts|prohibited_unless:view,counts|integer|min:1']);
abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $this->run($rt,'stock.view',function($a,$b)use($d){
$a=$this->operator($a);$kind=$d['view'];
if($kind==='counts')abort_unless(in_array((int)$d['warehouse_id'],$b['warehouse_ids']??[],true),403);
$q=match($kind){'items'=>StockItem::where('tenant_id',$a->tenant_id),'warehouses'=>Warehouse::where('tenant_id',$a->tenant_id)->whereIn('id',$b['warehouse_ids']??[]),'people'=>User::where('tenant_id',$a->tenant_id),'equipment'=>\Tempo\Purchasing\Embedded\Models\Equipment::where('tenant_id',$a->tenant_id),'counts'=>StockOperation::where('tenant_id',$a->tenant_id)->where('kind','count')->where('warehouse_id',$d['warehouse_id'])->where('stock_item_id',$d['stock_item_id'])->whereNotIn('id',StockOperation::where('tenant_id',$a->tenant_id)->whereNotNull('count_id')->select('count_id')),default=>StockOperation::where('tenant_id',$a->tenant_id)->whereIn('warehouse_id',$b['warehouse_ids']??[])->where(fn($q)=>$q->whereNull('destination_warehouse_id')->orWhereIn('destination_warehouse_id',$b['warehouse_ids']??[]))};
if(($d['search']??'')!==''){
$search='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';
$q->whereRaw((in_array($kind,['history','counts'],true)?'reference':($kind==='equipment'?'description':'name'))." ILIKE ? ESCAPE '!'",[$search]);
}
$fields=match($kind){'items'=>['id','code','name','unit'],'warehouses'=>['id','code','name'],'people'=>['id','name'],'equipment'=>['id','code','description as name'],'counts'=>['id','reference as name','quantity_milli','ledger_version'],default=>['id','kind','reference','stock_item_id','warehouse_id','destination_warehouse_id','quantity_milli','reason','recorded_at']};
return response()->json($q->orderBy('id',$kind==='history'?'desc':'asc')->paginate((int)($d['per_page']??25),$fields,'page',(int)($d['page']??1)))->header('Cache-Control','no-store');
});
}
public function basis(Request $r,ReceiptRuntime $rt)
{
$this->guard();$d=$this->data($r->query());
return $this->run($rt,'stock.view',function($a,$b)use($d){$a=$this->operator($a);$basis=$this->entities($d,$b,$a);return response()->json(['basis'=>$basis,'revision'=>$this->hash($basis)])->header('Cache-Control','no-store');});
}
private function data(array $data): array
{
// Returns retain their own declaration and independent reception workflow.
$validated=Validator::make($data,[
'kind'=>'required|in:issue,transfer,count,adjustment','warehouse_id'=>'required|integer|min:1','stock_item_id'=>'required|integer|min:1',
'responsible_id'=>'required|integer|min:1','destination_warehouse_id'=>'required_if:kind,transfer|prohibited_unless:kind,transfer|integer|min:1',
'count_id'=>'required_if:kind,adjustment|prohibited_unless:kind,adjustment|integer|min:1',
])->after(function($v)use($data){if(array_diff(array_keys($data),['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id']))$v->errors()->add('data','Campos não reconhecidos.');})->validate();
foreach(['warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id'] as $key) if(isset($validated[$key])) $validated[$key]=(int)$validated[$key];
return $validated;
}
public function preview(Request $r,ReceiptRuntime $rt)
{
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','data'=>'required|array:kind,warehouse_id,stock_item_id,responsible_id,destination_warehouse_id,quantity,reference,reason,destination,equipment_id,count_id,ledger_version']);
abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);
$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();
return $this->run($rt,'stock.operations.prepare',function($a,$b,$binding)use($d,$r,$generation){
$a=$this->operator($a);$data=$d['data'];$keys=['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id'];
$basis=$this->entities($this->data(array_intersect_key($data,array_flip($keys))),$b,$a);
abort_unless(hash_equals($this->hash($basis),$d['revision']),409,'O saldo ou o contexto mudou. Confira novamente.');
abort_if(StockOperation::where('idempotency_key',$d['operation_key'])->exists(),409,'Operação já registada. Consulte o histórico.');
$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();
try{$out=$this->execute($data+['idempotency_key'=>$d['operation_key']],$r,$a);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
$cut=$d+['actor'=>$a->id,'binding'=>$binding,'generation'=>$generation,'observed'=>time(),'impact'=>$this->hash($out['impact'])];
return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'basis'=>$basis,'impact'=>$out['impact']])->header('Cache-Control','no-store');
});
}
public function confirm(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.');}
$generation=$this->generation();
return $this->run($rt,'stock.operations.confirm',function($a,$b,$binding,$origin)use($cut,$generation,$r){
$a=$this->operator($a);
abort_unless(($cut['actor']??null)===$a->id&&($cut['binding']??null)===$binding&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Prévia expirada ou contexto alterado.');
$keys=['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id'];
$basis=$this->entities($this->data(array_intersect_key($cut['data'],array_flip($keys))),$b,$a);
$hash=$this->hash([$cut['operation_key'],$cut['data']]);
$old=AuditLog::where('tenant_id',$a->tenant_id)->where('user_id',$a->id)->where('action','sgf.stock.command')->where('after->operation_key',$cut['operation_key'])->first();
if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$binding,409);return response()->json($old->after['result']+['replayed'=>true])->header('Cache-Control','no-store');}
abort_unless(hash_equals($this->hash($basis),$cut['revision']),409,'Movimentos posteriores à conferência. Actualize o saldo.');
abort_if(StockOperation::where('idempotency_key',$cut['operation_key'])->exists(),409,'Chave utilizada fora desta conferência.');
$out=$this->execute($cut['data']+['idempotency_key'=>$cut['operation_key']],$r,$a);
abort_unless(hash_equals($cut['impact'],$this->hash($out['impact'])),409,'Dependências alteradas. Confira novamente.');
$result=['operation_id'=>$out['operation']->id,'kind'=>$out['operation']->kind,'reference'=>$out['operation']->reference];
app(RequisitionWorkflow::class)->audit($a,'sgf.stock.command',null,$basis,$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$binding,'reason'=>$out['operation']->reason,'result'=>$result,'impact'=>$out['impact']]);
return response()->json($result+['replayed'=>false],201)->header('Cache-Control','no-store');
});
}
}