141 lines
19 KiB
PHP
141 lines
19 KiB
PHP
<?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];
|
|
}
|
|
}
|