hasTable($table),503,'Composição comercial contabilística não instalada.');$ctx=app(CompanyContext::class); abort_unless(auth()->id()===$u->id&&$ctx->userId()===$u->id&&$ctx->unitId(),403);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()]; foreach(['service_invoices.view','service_receivables.view'] as $p)app(Permissions::class)->require($u->id,$scope['tenant_id'],$scope['company_id'],$p,$scope['unit_id']);return $scope; } private function hash(array $d):string{return hash('sha256',Payables::canonical($d));} private function minor(string $value):int{return BigDecimal::of(ServiceReceivables::money($value,false))->multipliedBy(100)->toInt();} private function effectQuery(array $s){return DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(array_combine(array_map(fn($k)=>'r.'.$k,array_keys($s)),array_values($s)))->select('e.*');} private function invoice(User $u,array $scope,int $id):array { $row=DB::table('si_invoices')->where($scope)->where('id',$id)->first()??abort(403,'Factura fora do âmbito.'); $shown=app(ServiceInvoicing::class)->show($id,$u->id);$snapshot=$shown['snapshot']; if(($snapshot['simulation_only']??false)!==true||($snapshot['notice']??'')!==ServiceInvoicing::NOTICE||($snapshot['document']['data']['currency']??'')!=='MZN')throw new \DomainException('Só factura efectivamente emitida pelo circuito de teste.'); $ob=DB::table('sr_records')->where($scope)->where(['invoice_id'=>$id,'kind'=>'obligation'])->first(); $open=$ob?$this->effectQuery($scope)->where('e.record_id',$ob->id)->where('e.kind','open')->first():null; if(!$ob||!$open)throw new \DomainException('Conferência financeira de abertura pendente; não presumir dívida aprovada.'); $origin=json_decode($ob->data,true)['origin']??[]; if(($origin['invoice_id']??null)!==$id||!hash_equals($origin['snapshot_sha256']??'',hash('sha256',$row->snapshot))||(int)$open->actor_id===(int)$ob->actor_id)throw new \DomainException('Abertura ou fotografia documental divergente.'); $calc=$snapshot['calculation'];$amount=$this->minor($calc['total_document_proposed']); if($this->minor($ob->amount)!==$amount||$this->minor($open->amount)!==$amount)throw new \DomainException('Principal não coincide com o documento.'); return ['effect_type'=>'sales.invoice','origin_key'=>$this->hash(['scope'=>$scope,'invoice'=>$id,'series'=>$row->series_code,'number'=>$row->number]),'source_id'=>$id,'invoice_id'=>$id,'obligation_id'=>(int)$ob->id,'recognition_revision'=>$this->hash(['invoice_snapshot'=>$snapshot,'pdf_sha256'=>$row->pdf_sha256,'opening'=>(array)$open,'obligation'=>(array)$ob]),'principal_minor'=>$amount,'amount_minor'=>$amount,'base_minor'=>$this->minor($calc['base_exact']),'tax_minor'=>$this->minor($calc['vat_document_proposed']),'currency'=>'MZN','compatible'=>true,'history_imported'=>false,'due_on'=>json_decode($ob->data,true)['due_date']??null,'document_revision'=>hash('sha256',$row->snapshot),'document_snapshot'=>$snapshot,'opening_effect_id'=>$open->id]; } public function source(User $u,string $kind,int $id):array { $scope=$this->scope($u);if($kind==='sales.invoice')return $this->invoice($u,$scope,$id); $expected=match($kind){'receivable.allocated'=>'allocate','receivable.reversed'=>'reverse_allocation','receivable.reduced'=>'reduction','receivable.reduction_reversed'=>'reverse_reduction',default=>throw new \DomainException('Efeito comercial não suportado.')}; $e=$this->effectQuery($scope)->where('e.id',$id)->where('e.kind',$expected)->first()??throw new \DomainException('Afectação/reversão confirmada fora do âmbito.'); $ob=DB::table('sr_records')->where($scope)->where('id',$e->obligation_id)->where('kind','obligation')->first()??throw new \DomainException('Obrigação a receber indisponível.'); $base=$this->invoice($u,$scope,(int)$ob->invoice_id); if($kind==='receivable.reduction_reversed'){ $target=$this->effectQuery($scope)->where(['e.id'=>$e->target_effect_id,'e.kind'=>'reduction','e.obligation_id'=>$ob->id])->first();$r=DB::table('sr_records')->where($scope)->where(['id'=>$e->record_id,'kind'=>'correction'])->first();$cancel=DB::table('sd_cancellations')->where($scope)->where(['effect_id'=>$e->id,'state'=>'cancelled'])->first(); if(!$target||!$r||!$cancel||$r->actor_id===$e->actor_id||$target->amount!==$e->amount||!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','receivable.reduced')->where('snapshot->source->source_id',(int)$target->id)->exists())throw new \DomainException('Reversão sem crédito original contabilizado e anulação distinta.'); return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$this->minor($e->amount),'tax_minor'=>0,'recognition_revision'=>$this->hash(['invoice'=>$base['recognition_revision'],'target'=>(array)$target,'effect'=>(array)$e,'record'=>(array)$r,'cancellation'=>(array)$cancel]),'financial_effect'=>(array)$e]); } if($kind==='receivable.reduced'){ $r=DB::table('sr_records')->where($scope)->where(['id'=>$e->record_id,'kind'=>'correction','invoice_id'=>$ob->invoice_id,'obligation_id'=>$ob->id])->first(); $doc=DB::getSchemaBuilder()->hasTable('sd_outputs')?DB::table('sd_outputs')->where($scope)->where(['effect_id'=>$e->id,'invoice_id'=>$ob->invoice_id,'kind'=>'credit_note'])->first():null; if(!$r||!$doc||$r->actor_id===$e->actor_id||$r->amount!==$e->amount||!hash_equals($doc->pdf_sha256,hash('sha256',base64_decode($doc->pdf_base64,true)))||!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$ob->invoice_id)->exists())throw new \DomainException('Crédito sem documento, decisão distinta ou factura original contabilizada.'); return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$this->minor($e->amount),'tax_minor'=>0,'invoice_tax_minor'=>$base['tax_minor'],'recognition_revision'=>$this->hash(['invoice'=>$base['recognition_revision'],'effect'=>(array)$e,'record'=>(array)$r,'credit_document'=>(array)$doc]),'financial_effect'=>(array)$e,'credit_document_id'=>$doc->id,'operational_nominal_reduction'=>true]); } $receipt=DB::table('sr_records')->where($scope)->where('id',$e->receipt_id)->where(['kind'=>'receipt','obligation_id'=>$ob->id,'invoice_id'=>$ob->invoice_id])->first(); $confirmation=$receipt?$this->effectQuery($scope)->where('e.record_id',$receipt->id)->where('e.kind','receipt_confirm')->first():null; if(!$receipt||!$confirmation||$receipt->actor_id===$confirmation->actor_id)throw new \DomainException('Recebimento não confirmado independentemente.'); if(!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$ob->invoice_id)->exists())throw new \DomainException('Contabilize primeiro a factura original.'); if($kind==='receivable.reversed'){ $target=$this->effectQuery($scope)->where('e.id',$e->target_effect_id)->where('e.kind','allocate')->where('e.obligation_id',$ob->id)->first(); if(!$target||$target->amount!==$e->amount||!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','receivable.allocated')->where('snapshot->source->source_id',(int)$target->id)->exists())throw new \DomainException('Reversão exige a afectação original contabilizada integralmente.'); } return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$this->minor($e->amount),'tax_minor'=>0,'invoice_tax_minor'=>$base['tax_minor'],'recognition_revision'=>$this->hash(['invoice'=>$base['recognition_revision'],'effect'=>(array)$e,'receipt'=>(array)$receipt,'confirmation'=>(array)$confirmation]),'financial_effect'=>(array)$e,'receipt_confirmation_id'=>$confirmation->id]); } public function period(User $u,array $period):array { $scope=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0; foreach(DB::table('si_invoices')->where($scope)->orderBy('id')->cursor() as $i){$facts++;hash_update($h,$i->snapshot);hash_update($h,$i->pdf_sha256);if(!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$i->id)->exists())$pending++;} foreach(DB::table('sr_records')->where($scope)->orderBy('id')->cursor() as $r){hash_update($h,Payables::canonical((array)$r));if(!$this->effectQuery($scope)->where('e.record_id',$r->id)->where('e.kind','<>','allocate')->exists())$pending++;} foreach($this->effectQuery($scope)->orderBy('e.id')->cursor() as $e){$facts++;hash_update($h,Payables::canonical((array)$e)); if(in_array($e->kind,['allocate','reverse_allocation','reduction','reverse_reduction'],true)){if(!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type',match($e->kind){'allocate'=>'receivable.allocated','reduction'=>'receivable.reduced','reverse_reduction'=>'receivable.reduction_reversed',default=>'receivable.reversed'})->where('snapshot->source->source_id',(int)$e->id)->exists())$pending++;} elseif(!in_array($e->kind,['open','receipt_confirm','refused'],true))$pending++; // Unsupported financial changes are visible blockers, never ignored. } return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)]; } public function workspace(User $u,?int $invoice,int $page=1):array { $scope=$this->scope($u);$p=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($scope)->orderByDesc('id')->paginate(25,['id','series_code','number','issued_at'],'page',$page); $data=['scope'=>$scope,'invoices'=>$p->items(),'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()],'invoice'=>null];if($invoice===null)return $data; $data['invoice']=app(ServiceInvoicing::class)->show($invoice,$u->id); $row=DB::table('si_invoices')->where($scope)->where('id',$invoice)->first()??abort(403); $records=DB::table('sr_records')->where($scope)->where('invoice_id',$invoice)->orderByDesc('id')->paginate(25,['*'],'records_page',$page);$data['records']=$records->items();$data['records_pagination']=['page'=>$page,'last_page'=>$records->lastPage(),'total'=>$records->total()]; $decisions=$this->effectQuery($scope)->whereIn('e.record_id',array_column($records->items(),'id'))->where('e.kind','<>','allocate')->get()->keyBy('record_id');foreach($data['records'] as $r){$r->decision_kind=$decisions->get($r->id)?->kind;$r->decision_id=$decisions->get($r->id)?->id;} $ob=DB::table('sr_records')->where($scope)->where(['invoice_id'=>$invoice,'kind'=>'obligation'])->first();$data['obligation_id']=$ob?->id; $data['position']=$ob?app(ServiceReceivables::class)->position($ob->id,$u->id):null; if($data['position']){unset($data['position']['effects']);$data['position']['receipts']=array_intersect_key($data['position']['receipts'],array_fill_keys(array_column($records->items(),'id'),true));} $effects=$this->effectQuery($scope)->where('r.invoice_id',$invoice)->orderByDesc('e.id')->paginate(25,['e.*'],'effects_page',$page);$data['effects']=$effects->items();$data['effects_pagination']=['page'=>$page,'last_page'=>$effects->lastPage(),'total'=>$effects->total()]; $data['finance_permissions']=[];foreach(['prepare','confirm','allocate','correction_request','correction_approve'] as $a)$data['finance_permissions'][$a]=app(Permissions::class)->has($u->id,$scope['tenant_id'],$scope['company_id'],'service_receivables.'.$a,$scope['unit_id']); return $data; } }