json($a));} private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();} public function scope(int $actor,string $action='view'):array{ abort_unless(config('fixed_assets.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='accounting',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']); foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.'.$r,$s['unit_id']); $m=DB::table('fa_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('fixed_assets.schema_sha256')),503,'Schema de activos não conferido.'); $boot=config('database.connections.receipt_native');if($boot===null){$ready=app(ComposedLedger::class)->run('view',fn()=>true);abort_unless($ready===true,503,'Livro ocupado; tente novamente com a mesma operação.');}$native=DB::connection('receipt_native');\Tempo\Finance\GeneralLedger\LedgerSchema::assertSynthetic($native);$m=$native->table('gl_asset_extension_versions')->where('version',1)->first();abort_unless($m&&hash_equals($m->source_hash,(string)config('fixed_assets.native_schema_sha256'))&&$native->table('gl_asset_books')->where($s)->where('host_database',DB::connection()->getDatabaseName())->exists(),503,'Origem contabilística de activos não conferida.');return$s; } private function identity(int $actor,array $s):string{$u=DB::table('users')->where('id',$actor)->first(['id','name','email']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);} private function lock(int $actor,array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();} private function rows(array $s){return DB::table('fa_versions')->where($s);} private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$k)$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return$a;} private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());} public function effective(array $s,string $key):?array{return $this->latest($s,$key,true);} public function byId(array $s,int $id):?array{return $this->row($this->rows($s)->where('id',$id)->first());} public function approved(array $s,string $key,string $kind):array{$a=$this->effective($s,$key);abort_unless($a&&$a['kind']===$kind&&$a['data']['status']!=='cancelled',409,'Origem revista activa obrigatória.');return $a;} private function input(array $v):array{ if(is_string($v['reason']??null))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','kind'=>'required|in:asset,plan,charge,disposal','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['version']=(int)$d['version'];$d['record_key']=strtolower($d['record_key']); if($d['action']==='review'){$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;} $money='present|nullable|string|regex:/^[0-9]{1,12}\\.[0-9]{2}$/D'; $rules=match($d['kind']){ 'asset'=>['code'=>'required|string|regex:/^QA-[A-Z0-9-]{2,60}$/D','description'=>'required|string|min:5|max:300','origin_reference'=>'required|string|regex:/^QA-[A-Z0-9-]{2,80}$/D','cost'=>$money,'residual'=>$money,'available_on'=>'present|nullable|date_format:Y-m-d','location'=>'required|string|max:100','custodian'=>'required|string|max:100'], 'plan'=>['asset_key'=>'required|uuid','life_months'=>'present|nullable|integer|min:1|max:600','status'=>'required|in:active,suspended'], 'charge'=>['asset_key'=>'required|uuid','plan_key'=>'required|uuid','month'=>'required|date_format:Y-m'], 'disposal'=>['asset_key'=>'required|uuid','effect_on'=>'required|date_format:Y-m-d']}; $d['data']=$this->exact($d['data'],['operation'=>'required|in:establish,amend,cancel,correct_plan','evidence'=>'required|string|min:10|max:1000']+$rules);foreach(['asset_key','plan_key']as$k)if(isset($d['data'][$k]))$d['data'][$k]=strtolower($d['data'][$k]);if(isset($d['data']['life_months']))$d['data']['life_months']=(int)$d['data']['life_months'];return$d; } private function basis(int $actor,array $s,array $d):array{ $last=$this->latest($s,$d['record_key']);$data=$d['action']==='prepare'?$d['data']:($last['data']['input']??[]);$deps=[]; if($d['kind']!=='asset'){$deps['asset']=$this->approved($s,$data['asset_key']??'','asset');$deps['ledger']=$this->balance($s,$data['asset_key']);} else $deps['ledger']=$this->balance($s,$d['record_key']); if($d['kind']==='charge')$deps['plan']=$this->approved($s,$data['plan_key']??'','plan'); return['last'=>$last,'effective'=>$this->effective($s,$d['record_key']),'dependencies'=>$deps,'actor_identity'=>$this->identity($actor,$s)]; } public function balance(array $s,string $key,?object $db=null):array{ $db??=DB::connection('receipt_native');$q=$db->table('gl_journals')->where($s)->where('snapshot->source->asset_key',$key);$rows=(clone$q)->orderBy('id')->get(['id','snapshot','debit_minor']);$cost=0;$acc=0;$disposed=false;$charges=[]; foreach($rows as$j){$v=json_decode($j->snapshot,true);$kind=$v['source']['effect_type'];if($kind==='assets.admitted')$cost+=(int)$j->debit_minor;if(in_array($kind,['assets.depreciation','assets.depreciation.replacement'],true))$acc+=(int)$j->debit_minor;if($kind==='assets.depreciation.reversal')$acc-=(int)$j->debit_minor;if($kind==='assets.writeoff')$disposed=true;if($kind==='assets.depreciation')$charges[$v['source']['charge_month']]=$j->id;} return['cost_minor'=>$cost,'accumulated_minor'=>$acc,'carrying_minor'=>$disposed?0:$cost-$acc,'disposed'=>$disposed,'charges'=>$charges,'journal_ids'=>$rows->pluck('id')->all()]; } public static function financial(array $a):array{return array_intersect_key($a['data']['input'],array_flip(['code','origin_reference','cost','residual','available_on']));} private function plan(int $actor,array $s,array $d,array $b):array{ $last=$b['last'];$effective=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$effective['data']??null; if($d['action']==='prepare'){$input=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador alterada.');$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'fixed_assets.prepare',$s['unit_id']);$input=$last['data']['input'];abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Dependências mudaram. Prepare novamente.');} $op=$input['operation'];if($op==='establish')abort_if($effective,409,'Registo já aprovado. Use correcção.');else abort_unless($old&&$old['status']!=='cancelled',409,'Exige registo activo já aprovado.'); $kind=$d['kind'];$dep=$b['dependencies'];$balance=$dep['ledger'];$status=$op==='cancel'?'cancelled':'active';$calculation=null; if($op==='correct_plan')abort_unless($kind==='plan'&&$old,422); if($kind==='asset'){ if($old)foreach(['code','origin_reference']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Código/origem imutáveis.'); abort_if($this->rows($s)->where('kind','asset')->where('record_key','<>',$d['record_key'])->where(fn($q)=>$q->whereRaw("data->'input'->>'code'=?",[$input['code']])->orWhereRaw("data->'input'->>'origin_reference'=?",[$input['origin_reference']]))->exists(),409,'Código ou origem já usados.'); if($balance['journal_ids']){abort_if($op==='cancel',409,'Activo contabilizado não se cancela; use abate.');foreach(['cost','residual','available_on']as$k)abort_unless($old['input'][$k]===$input[$k],409,'Custo/residual/data contabilizados imutáveis.');} if($input['cost']!==null&&$input['residual']!==null)AssetDepreciation::quota($input['cost'],$input['residual'],1,1); if($op==='cancel')abort_if($this->rows($s)->where('kind','<>','asset')->whereRaw("data->'input'->>'asset_key'=?",[$d['record_key']])->exists(),409,'Activo com dependências.'); }else{ abort_if($balance['disposed'],409,'Activo já abatido.');if($old)abort_unless($input['asset_key']===$old['input']['asset_key'],409,'Activo imutável.'); if(in_array($kind,['plan','disposal'],true))abort_if($this->rows($s)->where('kind',$kind)->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'asset_key'=?",[$input['asset_key']])->where('state','approved')->exists(),409,'Reutilize o plano ou abate existente.'); if($kind==='plan'){$status=$input['status'];abort_if($op==='cancel',422,'Suspenda explicitamente o plano.');if($old&&$balance['charges']&&$input['life_months']!==$old['input']['life_months']){abort_unless($op==='correct_plan',409,'Quotas aplicadas: exige plano de correcção explícito.');abort_unless(count($balance['charges'])===1,409,'Várias quotas aplicadas exigem tratamento coordenado fora deste recorte.');}} if($kind==='charge'){ $a=$dep['asset']['data']['input'];$p=$dep['plan']['data'];abort_unless($p['input']['asset_key']===$input['asset_key']&&$p['status']==='active',409,'Plano suspenso ou de outro activo.');abort_unless($a['available_on']!==null,409,'Entrada em uso desconhecida.');$begin=$input['month'].'-01';$available=substr($a['available_on'],0,7).'-01';$delta=(int)substr($begin,0,4)*12+(int)substr($begin,5,2)-((int)substr($available,0,4)*12+(int)substr($available,5,2))+1;abort_unless($delta>=1,409,'Competência anterior à entrada em uso.');$calculation=AssetDepreciation::quota($a['cost'],$a['residual'],$p['input']['life_months'],$delta);if($old)foreach(['month','plan_key']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Competência/plano imutáveis.'); abort_if($this->rows($s)->where('kind','charge')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'asset_key'=? AND data->'input'->>'month'=?",[$input['asset_key'],$input['month']])->exists(),409,'Competência já registada.');abort_if($op==='cancel'&&isset($balance['charges'][$input['month']]),409,'Quota aplicada exige par de correcção.'); if($d['action']==='review'&&$d['data']['decision']==='approved'&&$op!=='cancel')abort_unless($calculation['complete'],409,'Custo/residual/vida desconhecidos.'); } if($kind==='disposal'){abort_unless($balance['cost_minor']>0,409,'Admissão contabilizada obrigatória.');abort_unless($input['effect_on']>=$dep['asset']['data']['input']['available_on'],409);$calculation=$balance;} } $data=['input'=>$input,'status'=>$status,'dependencies'=>$dep,'calculation'=>$calculation,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1; $impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'payments'=>0,'journals'=>0,'accounting_requires_separate_decisions'=>true,'legal_tax_treatment'=>null];return['kind'=>$kind,'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact]; } public function preview(int $actor,array $input,string $key):array{$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['action']);$b=$this->basis($actor,$s,$d);$out=$this->plan($actor,$s,$d,$b);$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($c)),'impact'=>$out['impact']];});} public function confirm(int $actor,string $token):array{ try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('fa_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem/versão alterada. Confira novamente.');$out=$this->plan($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');$row=$out;unset($row['impact'],$row['data']);$id=DB::table('fa_versions')->insertGetId($s+$row+['data'=>$this->json($out['data']),'impact'=>$this->json($out['impact']),'source_basis'=>$this->hash($b['dependencies']),'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'record_key'=>$d['record_key'],'version'=>$out['version'],'state'=>$out['state'],'impact'=>$out['impact']];DB::table('fa_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('fixed_assets.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];}); } public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor);$old=DB::table('fa_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];} public function listing(int $actor,array $input):array{ $s=$this->scope($actor);$d=$this->exact($input,['kind'=>'required|in:asset,plan,charge,disposal','record_key'=>'sometimes|uuid','month'=>'sometimes|date_format:Y-m','state'=>'sometimes|in:prepared,approved,refused','search'=>'sometimes|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']); return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$this->rows($s)->where('kind',$d['kind']);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403);}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('fa_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'fa_versions.'.$k);$n->whereColumn('next.version','>','fa_versions.version');}); if(isset($d['month']))$q->whereRaw("data->'input'->>'month'=?",[$d['month']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("concat_ws(' ',data->'input'->>'code',data->'input'->>'description',data->'input'->>'asset_key',data->'input'->>'month') ILIKE ? ESCAPE '!'",[$needle]);} $page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->unique()->all();$a=$this->rows($s)->whereIn('record_key',$keys)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('fa_versions as e');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('e.'.$k,'fa_versions.'.$k);$n->where('e.state','approved')->whereColumn('e.version','>','fa_versions.version');})->get()->keyBy('record_key'); $items=[];foreach($page->items()as$r){$row=$this->row($r);$row['effective']=$this->row($a->get($r->record_key));$items[]=$row;}$page->setCollection(collect($items));return['policy'=>self::POLICY,'kind'=>$d['kind'],'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.review',$s['unit_id'])],'synthetic_only'=>true,'legal_tax_treatment'=>null];}); } }