Files
SGF/app/Modules/Inventory/StockOperationsController.php
T
2026-11-11 09:00:17 +02:00

151 lines
13 KiB
PHP

<?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');
});
}
}