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

90 lines
8.8 KiB
PHP

<?php
namespace App\Modules\Inventory;
use App\Modules\Purchasing\BudgetAdapter;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
require_once __DIR__.'/../../../packages/inventory-read/bootstrap.php';
/** Observe the composed receipt book; never borrow its writer or tenant mutex. */
final class CompositionStockRead
{
public function index(Request $r, CompanyContext $context, Permissions $permissions)
{
abort_unless(config('receipt_qa.stock_read_enabled') === true && config('receipt_qa.enabled') === true,404);
abort_unless(app()->environment('budget-testing'),403);
BudgetAdapter::guard();
$d=$r->validate(['view'=>'sometimes|in:items,warehouses,balances,movements,period,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);
$filters=['view'=>$d['view']??'balances','search'=>$d['search']??'',
'warehouse_id'=>isset($d['warehouse_id'])?(int)$d['warehouse_id']:null,'page'=>(int)($d['page']??1),'per_page'=>(int)($d['per_page']??25)];
if($filters['view']==='period'){
abort_unless(config('receipt_qa.stock_period_enabled')===true,404);
$filters+=['from'=>$d['from'],'to'=>$d['to']];
}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)
&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??'public')==='public',403);
$identity=$host->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless(!$identity->elevated&&$identity->db===$host->getDatabaseName()&&$identity->role===$c['username']&&$identity->cluster==='sgf_modular_regression_'.$run,403);
$scope=$context->identifiers();$unit=$context->unitId();$user=$context->userId();
abort_unless($unit&&$r->user()&&(int)$r->user()->id===$user,403);
// Hold only shared host authority locks. Unrelated readers remain concurrent.
try {
return $host->transaction(function()use($host,$scope,$unit,$user,$permissions,$m,$run,$r,$filters,$d){
$host->statement("SET LOCAL lock_timeout='2s'");
$host->statement("SET LOCAL statement_timeout='3s'");
abort_unless($host->table('users')->where('id',$user)->sharedLock()->first(),403);
abort_unless($host->table('company_memberships')->where($scope+['user_id'=>$user])->sharedLock()->first(),403);
foreach(['company.view'=>null,'unit.view'=>$unit,'stock.view'=>$unit]as$right=>$u){
abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$user,'unit_id'=>$u,'permission'=>$right])->sharedLock()->first(),403);
$permissions->require($user,$scope['tenant_id'],$scope['company_id'],$right,$u);
}
$root='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$m[1];
$profile=realpath($root.'/stock-binding.json');
abort_unless($profile&&!is_link($root.'/stock-binding.json')&&strtolower(str_replace('\\','/',$profile))===strtolower($root.'/stock-binding.json'),503,'Fonte de stock indisponível.');
$p=json_decode(file_get_contents($profile),true,32,JSON_THROW_ON_ERROR);
$key=implode(':',[$scope['tenant_id'],$scope['company_id'],$unit,$user]);
$binding=$p['bindings'][$key]??null;abort_unless(is_array($binding),403);
abort_unless(is_int($binding['source_tenant_id']??null)&&$binding['source_tenant_id']>0
&&is_array($binding['warehouse_ids']??null)&&count($binding['warehouse_ids'])>0&&count($binding['warehouse_ids'])<=1000
&&!array_filter($binding['warehouse_ids'],fn($id)=>!is_int($id)||$id<1),503,'Fonte de stock incompatível.');
$c=$p['connection']??[];
abort_unless(($c['driver']??null)==='pgsql'&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439
&&($c['database']??null)==='compras_receipts_test_'.$m[1]&&($c['username']??null)==='stock_receipt_read_'.$m[1]
&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??null)==='public',403);
// Purge avoids retaining an earlier profile/credential after a configuration change.
DB::purge('composition_stock');config(['database.connections.composition_stock'=>$c]);$source=DB::connection('composition_stock');
$i=$source->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,current_setting('default_transaction_read_only') read_only,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless(!$i->elevated&&$i->db===$c['database']&&$i->role===$c['username']&&$i->cluster==='sgf_modular_regression_'.$run&&$i->read_only==='on',403);
$rights=$source->selectOne("SELECT EXISTS(SELECT FROM pg_tables WHERE schemaname='public' AND (has_table_privilege(current_user,quote_ident(schemaname)||'.'||quote_ident(tablename),'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') OR (tablename NOT IN ('stock_items','warehouses','stock_movements') AND has_table_privilege(current_user,quote_ident(schemaname)||'.'||quote_ident(tablename),'SELECT')))) forbidden,
EXISTS(SELECT FROM pg_trigger WHERE tgrelid='public.stock_movements'::regclass AND tgname='composition_stock_append_only' AND tgenabled='O' AND NOT tgisinternal AND (tgtype::int & 26)=26) append_only");
abort_if($rights->forbidden,403);abort_unless($rights->append_only,503,'Fonte de stock incompatível.');
abort_unless(($binding['schema']??null)==='public'&&($binding['append_only']??false)===true,503);
if($filters['warehouse_id']!==null)abort_unless(in_array($filters['warehouse_id'],$binding['warehouse_ids'],true),403);
$binding['physical_source']=[$i->db,$i->role,$i->cluster];
$fingerprint=hash('sha256',json_encode([$scope,$unit,$user,$r->session()->get('company_context.token'),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
$result=$filters['view']==='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));
$result['capabilities']['receipt_trace']=config('receipt_qa.stock_trace_enabled')===true
&&$permissions->has($user,$scope['tenant_id'],$scope['company_id'],'purchasing_receipts.view',$unit);
return response()->json($result)->header('Cache-Control','no-store');
});
}catch(\PDOException $e){
$state=$e->errorInfo[0]??(string)$e->getCode();
if(!str_starts_with($state,'08')&&!in_array($state,['28P01','3D000','42P01','42703','42883','55P03','57014'],true))throw $e;
return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
}
}
}