92 lines
13 KiB
PHP
92 lines
13 KiB
PHP
<?php
|
|
namespace App\Modules\Accounting;
|
|
use App\Models\User;
|
|
use App\Modules\Identity\Services\Permissions;
|
|
use App\Modules\Services\{ServiceInvoicing,ServiceReceivables};
|
|
use App\Shared\Context\CompanyContext;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Brick\Math\BigDecimal;
|
|
use Tempo\Finance\Payables\Payables;
|
|
|
|
/** Reads immutable commercial/receivable facts. Never issues, opens, receives or allocates. */
|
|
final class SalesLedgerSource
|
|
{
|
|
private function scope(User $u):array
|
|
{
|
|
abort_unless(config('sales_ledger.synthetic_enabled')===true,403);ServiceReceivables::guard();foreach(['si_invoices','sr_records','sr_effects','gl_sales_capability'] as $table)abort_unless(str_starts_with($table,'gl_')?SourceBook::has($table):DB::getSchemaBuilder()->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']);$documentBase=$calc['base_exact'];if(($snapshot['calculation_policy']??null)===\App\Modules\Services\MultiLineInvoiceCalculation::POLICY){$values=\App\Modules\Services\MultiLineInvoiceCalculation::financialAmounts($snapshot);$documentBase=$values['base'];}
|
|
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($documentBase),'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\Modules\Services\QualifiedInvoiceSource::query()->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;
|
|
}
|
|
}
|