102 lines
12 KiB
PHP
102 lines
12 KiB
PHP
<?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]];
|
|
}
|
|
}
|