Files
SGF/app/Modules/Services/ReceivablePositionRead.php
2026-11-11 09:00:17 +02:00

33 lines
6.2 KiB
PHP

<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{DB,Validator};
use Brick\Math\BigDecimal;
/** Current recognized positions. Reference date classifies due dates, never rewinds effects. */
final class ReceivablePositionRead
{
public function __construct(private CompanyContext $context,private Permissions $permissions){}
public function listing(int $actor,array $input):array
{
CompositionRuntime::assert();ServiceReceivables::guard();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
abort_unless($s['unit_id']&&$actor===$this->context->userId()&&ModuleRecords::active()==='finance',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_receivables.view',$s['unit_id']);
$rules=['reference_on'=>'required|date_format:Y-m-d','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100','state'=>'sometimes|in:proposed,refused,open,partial,settled','due_state'=>'sometimes|in:not_open,unknown,overdue,not_due,settled'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();
return DB::transaction(function()use($s,$d){
if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$scope=fn($alias)=>array_combine(array_map(fn($k)=>$alias.'.'.$k,array_keys($s)),array_values($s));
$effects=DB::table('sr_effects as e')->join('sr_records as scoped','scoped.id','=','e.obligation_id')->where($scope('scoped'))->where('scoped.kind','obligation')->groupBy('e.obligation_id')->selectRaw("e.obligation_id,count(*) revision,bool_or(e.kind='open') opened,bool_or(e.kind='refused' AND e.record_id=e.obligation_id) refused,coalesce(sum(CASE WHEN e.kind='reduction' THEN e.amount WHEN e.kind='reverse_reduction' THEN -e.amount ELSE 0 END),0) reduced,coalesce(sum(CASE WHEN e.kind='allocate' THEN e.amount WHEN e.kind='reverse_allocation' THEN -e.amount ELSE 0 END),0) allocated");
$base=DB::table('sr_records as o')->leftJoinSub($effects,'f','f.obligation_id','=','o.id')->join('si_invoices as invoice',function($j){$j->on('invoice.id','=','o.invoice_id');foreach(['tenant_id','company_id','unit_id']as$key)$j->on('invoice.'.$key,'=','o.'.$key);})->where($scope('o'))->where('o.kind','obligation')->select(['o.id','o.invoice_id','o.client_id','o.currency','o.created_at'])->selectRaw("invoice.series_code||'/'||invoice.number::text reference,invoice.snapshot->'document'->'client'->>'name' client,o.amount::text original,coalesce(f.revision,0) revision,coalesce(f.opened,false) opened,coalesce(f.refused,false) refused,coalesce(f.reduced,0)::text reduced,coalesce(f.allocated,0)::text allocated,(o.amount-coalesce(f.reduced,0)-coalesce(f.allocated,0))::text balance,CASE WHEN EXISTS(SELECT 1 FROM sr_effects due WHERE due.obligation_id=o.id AND due.kind='due') THEN (SELECT due.data->>'due_date' FROM sr_effects due WHERE due.obligation_id=o.id AND due.kind='due' ORDER BY due.id DESC LIMIT 1) ELSE o.data->>'due_date' END due_date");
$positions=DB::query()->fromSub($base,'p')->select('p.*')->selectRaw("CASE WHEN NOT opened THEN CASE WHEN refused THEN 'refused' ELSE 'proposed' END WHEN balance::numeric=0 THEN 'settled' WHEN allocated::numeric>0 THEN 'partial' ELSE 'open' END state,CASE WHEN NOT opened THEN 'not_open' WHEN balance::numeric=0 THEN 'settled' WHEN due_date IS NULL THEN 'unknown' WHEN ?::date>due_date::date THEN 'overdue' ELSE 'not_due' END due_state,CASE WHEN opened AND balance::numeric>0 AND due_date IS NOT NULL THEN greatest(0,?::date-due_date::date) ELSE NULL END overdue_days",[$d['reference_on'],$d['reference_on']]);
$q=DB::query()->fromSub($positions,'positions');foreach(['state','due_state']as$key)if(isset($d[$key]))$q->where($key,$d[$key]);if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($x)=>$x->whereRaw("reference ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("client ILIKE ? ESCAPE '!'",[$needle]));}
$totals=(clone$q)->selectRaw("count(*) FILTER(WHERE opened) recognized_positions,count(*) FILTER(WHERE NOT opened) unrecognized_proposals,count(*) FILTER(WHERE due_state='unknown') unknown_due_positions,coalesce(sum(balance::numeric) FILTER(WHERE opened),0)::text recognized_balance,count(*) FILTER(WHERE opened AND balance::numeric<0) invalid_negative_positions")->first();abort_if((int)$totals->invalid_negative_positions>0,409,'Há saldo incompatível com o livro; exige conferência da origem.');$totals->recognized_balance=(string)BigDecimal::of($totals->recognized_balance)->toScale(2);unset($totals->invalid_negative_positions);
$p=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));foreach($p->items()as$row){foreach(['original','reduced','allocated','balance']as$key)$row->$key=(string)BigDecimal::of($row->$key)->toScale(2);if(!$row->opened)$row->balance=null;$row->revision=(int)$row->revision;}
$records=DB::table('sr_records')->where($s)->selectRaw('count(*) records,coalesce(max(id),0) last_record_id')->first();$marker=DB::table('sr_effects as e')->join('sr_records as scoped','scoped.id','=','e.obligation_id')->where($scope('scoped'))->where('scoped.kind','obligation')->selectRaw('count(*) effects,coalesce(max(e.id),0) last_effect_id')->first();
return ['positions'=>$p,'totals'=>$totals,'basis'=>['scope'=>$s,'records'=>(int)$records->records,'last_record_id'=>(int)$records->last_record_id,'effects'=>(int)$marker->effects,'last_effect_id'=>(int)$marker->last_effect_id],'reference_on'=>$d['reference_on'],'observed_at'=>now()->utc()->toIso8601String(),'currency'=>'MZN','read_only'=>true,'balance_basis'=>'current_confirmed_effects_not_historical_cut','notice'=>'Saldos actuais. A data de referência classifica apenas o vencimento; não reconstitui saldos históricos. Propostas não reconhecidas não entram no saldo total.'];
});
}
}