commit melhorias1

This commit is contained in:
2026-11-11 15:04:01 +02:00
parent 1d11a5e57b
commit cd9897df05
145 changed files with 10950 additions and 84 deletions
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Accounting;
use Brick\Math\{BigInteger,BigRational};
/** Pure proposed QA straight-line policy. No fiscal rate, asset, journal or clock source. */
final class AssetDepreciation
{
public static function quota(?string $cost,?string $residual,?int $months,int $month):array
{
abort_unless($month>=1&&$month<=600,422);foreach([$cost,$residual]as$value)if($value!==null)abort_unless(preg_match('/^[0-9]{1,12}\.[0-9]{2}$/D',$value)===1,422,'Valor documental deve ter duas casas.');if($months!==null)abort_unless($months>=1&&$months<=600&&$month<=$months,422);
if($cost===null||$residual===null||$months===null)return['complete'=>false,'quota_cents'=>null,'accumulated_cents'=>null,'carrying_cents'=>null,'exact_quota_cents'=>null,'difference_cents'=>null,'legal_tax_treatment'=>null];
$c=BigInteger::of(str_replace('.','',$cost));$r=BigInteger::of(str_replace('.','',$residual));abort_if($r->isGreaterThan($c),422,'Residual excede o custo.');$base=$c->minus($r);$standard=$base->quotient($months);$quota=$month===$months?$base->minus($standard->multipliedBy($months-1)):$standard;$accumulated=$month===$months?$base:$standard->multipliedBy($month);$exact=BigRational::of($base)->dividedBy($months)->simplified();return['complete'=>true,'cost_cents'=>(string)$c,'residual_cents'=>(string)$r,'life_months'=>$months,'month'=>$month,'quota_cents'=>(string)$quota,'accumulated_cents'=>(string)$accumulated,'carrying_cents'=>(string)$c->minus($accumulated),'exact_quota_cents'=>(string)$exact,'difference_cents'=>(string)$exact->minus($quota)->simplified(),'policy'=>'QA-straight-line-floor-months-final-residual-adjustment-v1','legal_tax_treatment'=>null];
}
}
+4 -3
View File
@@ -61,16 +61,17 @@ final class ComposedLedger
$authorize('view');
$isolation=fn()=>app(ComposedMaterialIsolation::class)->check($host,$native,$ledgerScope);
$stock=new StockValuation($native,$ledgerScope,$actor->id,$person,$authorize,fn()=>$authorize('view'),$isolation);
$additional=function($kind,$id)use($host,$hostActor,$stock,$isolation){
$additional=function($kind,$id)use($host,$hostActor,$stock,$isolation,$native,$ledgerScope){
if(str_starts_with($kind,'stock.'))return $stock->source($kind,$id);
if(str_starts_with($kind,'workshop.')||str_starts_with($kind,'industry.'))$isolation();
return $this->withHost($host,$hostActor,function()use($hostActor,$kind,$id){
return $this->withHost($host,$hostActor,function()use($hostActor,$kind,$id,$native,$ledgerScope){
if(str_starts_with($kind,'assets.'))return app(FixedAssetLedgerSource::class)->source($hostActor,$kind,$id,$native,$ledgerScope);
if(str_starts_with($kind,'cash.'))return app(CashLedgerSource::class)->source($hostActor,$kind,$id);if(str_starts_with($kind,'workshop.'))return $kind==='workshop.direct_cost.validated'?app(CostLedgerSource::class)->source($hostActor,$id):app(CostLedgerSource::class)->correctionSource($hostActor,$kind,$id);
if(str_starts_with($kind,'industry.'))return $kind==='industry.cost.validated'?app(IndustrialLedgerSource::class)->source($hostActor,$id):app(IndustrialLedgerSource::class)->correctionSource($hostActor,$kind,$id);
return app(SalesLedgerSource::class)->source($hostActor,$kind,$id);
});
};
$period=function($p)use($host,$hostActor,$stock){$extra=$this->withHost($host,$hostActor,function()use($p,$hostActor){$facts=0;$pending=0;$h=hash_init('sha256');foreach([SalesLedgerSource::class,CostLedgerSource::class,IndustrialLedgerSource::class,CashLedgerSource::class] as $class){$v=app($class)->period($hostActor,$p);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];});$physical=$stock->period($p);return ['facts'=>$extra['facts']+$physical['facts'],'pending'=>$extra['pending']+$physical['pending'],'hash'=>hash('sha256',$extra['hash'].$physical['hash'])];};
$period=function($p)use($host,$hostActor,$stock,$native,$ledgerScope){$extra=$this->withHost($host,$hostActor,function()use($p,$hostActor,$native,$ledgerScope){$facts=0;$pending=0;$h=hash_init('sha256');foreach([SalesLedgerSource::class,CostLedgerSource::class,IndustrialLedgerSource::class,CashLedgerSource::class] as $class){$v=app($class)->period($hostActor,$p);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}if(config('fixed_assets.enabled')===true){$v=app(FixedAssetLedgerSource::class)->period($hostActor,$p,$native,$ledgerScope);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];});$physical=$stock->period($p);return ['facts'=>$extra['facts']+$physical['facts'],'pending'=>$extra['pending']+$physical['pending'],'hash'=>hash('sha256',$extra['hash'].$physical['hash'])];};
$engine=new PayableJournal($native,$ledgerScope,$actor->id,$person,$authorize,fn()=>$authorize('view'),fn(int $id)=>$this->source($actor,$binding,$financialScope,$id),null,$additional,$period,$financialScope);
return $work($engine,$actor,$binding,$origin+['sgf_host_user'=>$hostUser,'sgf_host_scope'=>$scope,'binding_revision'=>$revision],$ledgerScope,$stock,fn(\Closure $read)=>$this->withHost($host,$hostActor,$read));
});
@@ -16,8 +16,8 @@ final class ComposedLedgerCandidates
$append(DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->join('invoices as i','i.id','=','s.local_source_id')->where(['o.tenant_id'=>$actor->tenant_id,'o.company_id'=>0,'o.unit_id'=>0])->orderBy('o.id'),'supplier_payable.recognised',['o.id','o.principal_minor','i.number'],fn($o)=>['id'=>$o->id,'label'=>'Compra '.$o->number,'amount'=>$o->principal_minor]);
foreach(['ps_allocations'=>'allocation.confirmed','ps_reversals'=>'allocation.reversed'] as $table=>$type)$append(DB::table($table)->where($financial)->orderBy('id'),$type,['id','amount_minor'],fn($o)=>['id'=>$o->id,'label'=>($type==='allocation.confirmed'?'Afectação':'Reversão').' fornecedor #'.$o->id,'amount'=>$o->amount_minor]);
$hostRead(function()use($scope,$append){
$append(ModuleRecords::query('si_invoices')->where($scope)->orderBy('id'),'sales.invoice',['id','series_code','number'],fn($i)=>['id'=>$i->id,'label'=>'Venda '.$i->series_code.'/'.$i->number,'amount'=>null]);
foreach(['allocate'=>'receivable.allocated','reverse_allocation'=>'receivable.reversed','reduction'=>'receivable.reduced','reverse_reduction'=>'receivable.reduction_reversed'] as $event=>$type)$append(DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(['r.tenant_id'=>$scope['tenant_id'],'r.company_id'=>$scope['company_id'],'r.unit_id'=>$scope['unit_id']])->whereIn('r.invoice_id',ModuleRecords::query('si_invoices')->select('id'))->where('e.kind',$event)->orderBy('e.id'),$type,['e.id'],fn($e)=>['id'=>$e->id,'label'=>'Afectação/reversão cliente #'.$e->id,'amount'=>null]);
$append(\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->orderBy('id'),'sales.invoice',['id','series_code','number'],fn($i)=>['id'=>$i->id,'label'=>'Venda '.$i->series_code.'/'.$i->number,'amount'=>null]);
foreach(['allocate'=>'receivable.allocated','reverse_allocation'=>'receivable.reversed','reduction'=>'receivable.reduced','reverse_reduction'=>'receivable.reduction_reversed'] as $event=>$type)$append(DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(['r.tenant_id'=>$scope['tenant_id'],'r.company_id'=>$scope['company_id'],'r.unit_id'=>$scope['unit_id']])->whereIn('r.invoice_id',\App\Modules\Services\QualifiedInvoiceSource::query()->select('id'))->where('e.kind',$event)->orderBy('e.id'),$type,['e.id'],fn($e)=>['id'=>$e->id,'label'=>'Afectação/reversão cliente #'.$e->id,'amount'=>null]);
if(config('cash_qa.enabled')===true)foreach(['receipt'=>'cash.receipt_classified','expense'=>'cash.expense_confirmed'] as $kind=>$type)$append(DB::table('cash_events')->where($scope)->where(['action'=>'review','state'=>'approved','kind'=>$kind])->orderBy('id'),$type,['id'],fn($r)=>['id'=>$r->id,'label'=>'Caixa QA #'.$r->id,'amount'=>null]);
});
foreach(['gl_cost_sources'=>['order_id','workshop.direct_cost.validated','Custo de oficina'],'gl_industrial_sources'=>['lot_id','industry.cost.validated','Custo industrial']] as $table=>[$column,$type,$label])$append(DB::table($table)->where($scope)->orderBy('id'),$type,[$column],fn($o)=>['id'=>$o->$column,'label'=>$label.' #'.$o->$column,'amount'=>null]);
@@ -36,7 +36,7 @@ final class ComposedLedgerController
{
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','accounts_page'=>'sometimes|integer|min:1|max:100000','accounts_per_page'=>'sometimes|integer|in:25,100','account_q'=>'sometimes|nullable|string|max:80','source_page'=>'sometimes|integer|min:1|max:100000','source_per_page'=>'sometimes|integer|in:25,100','source_kind'=>'sometimes|in:'.implode(',',ComposedLedgerCandidates::KINDS),'period_key'=>'sometimes|string|max:80','account_id'=>'sometimes|nullable|integer|min:1','obligation_id'=>'sometimes|integer|min:1','sales_invoice_id'=>'sometimes|integer|min:1','cost_order_id'=>'sometimes|integer|min:1','industrial_lot_id'=>'sometimes|integer|min:1']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);if(($d['account_id']??null)===null)unset($d['account_id']);$rights=$this->rights();
return $ledger->run('view',function($engine,$actor,$binding,$origin,$scope,$stock,$hostRead)use($r,$d,$rights){
if(isset($d['sales_invoice_id']))abort_unless($hostRead(fn()=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($scope)->where('id',$d['sales_invoice_id'])->exists()),403,'Documento fora do contexto e módulo.');
if(isset($d['sales_invoice_id']))abort_unless($hostRead(fn()=>\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->where('id',$d['sales_invoice_id'])->exists()),403,'Documento fora do contexto e módulo.');
$data=$engine->listing(array_diff_key($d,['source_page'=>true,'source_per_page'=>true,'source_kind'=>true,'accounts_page'=>true,'accounts_per_page'=>true,'account_q'=>true]));$data['cycle']=$engine->cycleListing((int)($d['page']??1));$data['permissions']=$rights;$data['current_actor']=$actor->id;$data['sgf_scope']=$origin['sgf_host_scope'];
$data=array_merge($data,app(ComposedLedgerCandidates::class)->read($d,$actor,$scope,$stock,$hostRead));
$accounts=DB::table('gl_accounts')->where($scope);if(($d['account_q']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['account_q']).'%';$accounts->where(fn($q)=>$q->whereRaw("code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("label ILIKE ? ESCAPE '!'",[$needle]));}
@@ -0,0 +1,28 @@
<?php
namespace App\Modules\Accounting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
/** Preview via rollback, confirm through the original writer, receipt through its original store. */
final class FixedAssetJournalController
{
private function hash(array $a):string{return hash('sha256',Payables::canonical($a));}
private function validate(array $v):array{abort_if(array_diff(array_keys($v),['action','data']),422);return Validator::make($v,['action'=>'required|in:effect-prepare,validate,post,correct-prepare,correct-validate,correct-post','data'=>'required|array'])->validate();}
private function run(Request $r,string $right,\Closure $fn){$actor=$r->user();app(FixedAssets::class)->scope($actor->id);try{return app(ComposedLedger::class)->run($right,fn($e,$a,$b,$o,$s,$stock,$hostRead)=>$fn($e,DB::connection(),$s,$hostRead));}catch(\DomainException $e){abort(409,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}}
private function right(string $action):string{return $action==='effect-prepare'?'prepare':str_replace('-','_',$action);}
private function qualified(object $db,array $s,array $d):void{
$v=$d['data'];if($d['action']==='effect-prepare'){abort_unless(in_array($v['effect_type']??null,['assets.admitted','assets.depreciation','assets.writeoff'],true),422);return;}
if(in_array($d['action'],['validate','post'],true)){$p=$db->table('gl_proposals')->where($s)->where('id',$v['proposal_id']??0)->first();abort_unless($p&&str_starts_with(json_decode($p->snapshot,true)['source']['effect_type'],'assets.'),403);return;}
if($d['action']==='correct-prepare')$id=$v['journal_id']??0;else{$g=$db->table('gl_corrections')->where($s)->where('id',$v['correction_id']??0)->first();abort_unless($g,403);$id=$g->original_journal_id;}$j=$db->table('gl_journals')->where($s)->where('id',$id)->first();abort_unless($j&&json_decode($j->snapshot,true)['source']['effect_type']==='assets.depreciation',403);
}
private function impact(object $db,array $s,array $result):array{
$ids=[];if(isset($result['proposal_id']))$ids[]=$result['proposal_id'];if(isset($result['correction_id'])){$g=$db->table('gl_corrections')->where($s)->where('id',$result['correction_id'])->first();$ids=[$g->reverse_proposal_id,$g->replacement_proposal_id];}
$snap=[];foreach($ids as$id)$snap[]=json_decode($db->table('gl_proposals')->where($s)->where('id',$id)->value('snapshot'),true);return ['state'=>$result['state'],'snapshots'=>$snap,'journal_count'=>isset($result['journal_id'])?1:(isset($result['reverse_journal_id'])?2:0),'payment_created'=>false];
}
private function simulate($e,object $db,array $s,array $d,string $key):array{
$this->qualified($db,$s,$d);$db->beginTransaction();try{$result=$e->command($d['action'],$d['data'],$key);return$this->impact($db,$s,$result);}finally{$db->rollBack();}
}
public function preview(Request $r){abort_if(strlen(json_encode($r->all()))>16384,422);$d=$this->validate($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);return$this->run($r,$this->right($d['action']),function($e,$db,$s)use($r,$d,$key){$impact=$this->simulate($e,$db,$s,$d,$key);$c=['actor'=>$r->user()->id,'scope'=>$s,'generation'=>$r->session()->get('company_context.token'),'observed'=>time(),'input'=>$d,'key'=>$key,'impact'=>$impact];return response()->json(['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>$impact])->header('Cache-Control','no-store');});}
public function confirm(Request $r){abort_if(array_diff(array_keys($r->all()),['preview']),422);$r->validate(['preview'=>'required|string|max:131072']);try{$c=json_decode(Crypt::decryptString($r->input('preview')),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->validate($c['input']??[]);return$this->run($r,$this->right($d['action']),function($e,$db,$s)use($r,$c,$d){abort_unless($c['actor']===$r->user()->id&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===$r->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$this->qualified($db,$s,$d);$old=$e->receipt($c['key']);if($old){abort_unless(hash_equals($old['request_hash'],$this->hash([$d['action'],$d['data']])),409);return response()->json($old['result']+['replayed'=>true])->header('Cache-Control','no-store');}$impact=$this->simulate($e,$db,$s,$d,$c['key']);abort_unless($this->hash($impact)===$this->hash($c['impact']),409,'Impacto ou origem mudou; confira novamente.');$out=$e->command($d['action'],$d['data'],$c['key']);return response()->json($out)->header('Cache-Control','no-store');});}
public function index(Request $r){$v=$r->validate(['asset_key'=>'required|uuid','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($v)),422);$hostActor=$r->user();return$this->run($r,'view',function($e,$db,$s,$hostRead)use($r,$v,$hostActor){$key=$v['asset_key'];$hostRead(fn()=>app(FixedAssets::class)->approved($s,$key,'asset'));$p=$db->table('gl_proposals')->where($s)->where('snapshot->source->asset_key',$key)->orderByDesc('id')->paginate($v['per_page']??25,['*'],'page',$v['page']??1);$ids=$p->pluck('id');$reviews=$db->table('gl_reviews')->where($s)->whereIn('proposal_id',$ids)->get()->keyBy('proposal_id');$journals=$db->table('gl_journals')->where($s)->whereIn('proposal_id',$ids)->get()->keyBy('proposal_id');$items=[];foreach($p as$row)$items[]=['id'=>$row->id,'revision'=>$row->revision,'prepared_by'=>$row->prepared_by,'snapshot'=>json_decode($row->snapshot,true),'review'=>$reviews->get($row->id),'journal'=>$journals->get($row->id)];$p->setCollection(collect($items));$groups=$db->table('gl_corrections')->where($s)->where(fn($q)=>$q->whereIn('reverse_proposal_id',$ids)->orWhereIn('replacement_proposal_id',$ids))->orderByDesc('id')->get()->map(function($g)use($db,$s){$g->applied=$db->table('gl_correction_results')->where($s)->where('correction_id',$g->id)->exists();$g->approved=$db->table('gl_reviews')->where($s)->whereIn('proposal_id',[$g->reverse_proposal_id,$g->replacement_proposal_id])->where('decision','approved')->count()===2;return$g;});$balance=$hostRead(fn()=>app(FixedAssets::class)->balance($s,$key,$db));return response()->json(['records'=>$p->toArray(),'balance'=>$balance,'corrections'=>$groups])->header('Cache-Control','no-store');});}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Accounting;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\Payables;
/** Read-only qualified source adapter. Never creates a journal or debt. */
final class FixedAssetLedgerSource
{
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
public function source(User $actor,string $kind,int $id,object $native,array $scope,bool $historical=false):array{
$service=app(FixedAssets::class);abort_unless($service->scope($actor->id)===$scope,403);
if(in_array($kind,['assets.depreciation.reversal','assets.depreciation.replacement'],true)){
$j=$native->table('gl_journals')->where($scope)->where('id',$id)->first()??abort(403);$original=json_decode($j->snapshot,true);abort_unless($original['source']['effect_type']==='assets.depreciation',409,'Só a quota original admite esta correcção.');
$base=$this->source($actor,'assets.depreciation',(int)$original['source']['source_id'],$native,$scope);abort_unless($base['recognition_revision']!==$original['source']['recognition_revision'],409,'Não existe nova quota revista.');return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$kind==='assets.depreciation.reversal'?(int)$j->debit_minor:$base['amount_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>$this->hash(['current'=>$base,'original'=>(array)$j,'kind'=>$kind]),'original_journal_id'=>$id,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>$this->hash((array)$j)]);
}
abort_unless(in_array($kind,['assets.admitted','assets.depreciation','assets.writeoff'],true),422);$initial=$service->byId($scope,$id);abort_unless($initial&&$initial['state']==='approved',403);$expected=match($kind){'assets.admitted'=>'asset','assets.depreciation'=>'charge','assets.writeoff'=>'disposal'};abort_unless($initial['kind']===$expected,409);$current=$service->approved($scope,$initial['record_key'],$expected);$asset=$expected==='asset'?$current:$service->approved($scope,$current['data']['input']['asset_key'],'asset');$a=$asset['data']['input'];abort_unless($a['cost']!==null&&$a['residual']!==null&&$a['available_on']!==null,409,'Dados financeiros do activo indeterminados.');
$balance=$service->balance($scope,$asset['record_key'],$native);$amount=(int)str_replace('.','',$a['cost']);$date=$a['available_on'];$facts=['asset'=>FixedAssets::financial($asset)];$extra=[];
if($expected==='charge'){
abort_unless($balance['cost_minor']===$amount&&($historical||!$balance['disposed']),409,'Admissão actual contabilizada obrigatória.');$p=$service->approved($scope,$current['data']['input']['plan_key'],'plan');abort_unless(($historical||$p['data']['status']==='active')&&$p['data']['input']['asset_key']===$asset['record_key'],409,'Plano suspenso ou incompatível.');abort_unless($current['data']['dependencies']['plan']['data']['input']['life_months']===$p['data']['input']['life_months']&&FixedAssets::financial($current['data']['dependencies']['asset'])===FixedAssets::financial($asset),409,'Plano/dados alterados; confira a quota.');
$c=$current['data']['calculation'];abort_unless($c['complete'],409);$amount=(int)$c['quota_cents'];$month=$current['data']['input']['month'];$date=(new \DateTimeImmutable($month.'-01'))->modify('last day of this month')->format('Y-m-d');for($n=1;$n<$c['month'];$n++){$previous=(new \DateTimeImmutable(substr($a['available_on'],0,7).'-01'))->modify('+'.($n-1).' months')->format('Y-m');abort_unless(isset($balance['charges'][$previous]),409,'Quota anterior não contabilizada.');}
if(!$historical&&!isset($balance['charges'][$month]))abort_unless($balance['accumulated_minor']===(int)$c['accumulated_cents']-$amount&&$balance['accumulated_minor']+$amount<=(int)$c['cost_cents']-(int)$c['residual_cents'],409,'Acumulado não corresponde às quotas anteriores revistas; exige conferência/correcção.');
$facts+=['charge_version'=>$current['id'],'plan'=>$current['data']['dependencies']['plan']['id'],'calculation'=>$c];$extra=['charge_key'=>$current['record_key'],'charge_month'=>$month];
}
if($expected==='disposal'){
$pending=DB::table('fa_versions as v')->where(array_combine(array_map(fn($k)=>'v.'.$k,array_keys($scope)),array_values($scope)))->whereIn('v.kind',['plan','charge'])->whereRaw("v.data->'input'->>'asset_key'=?",[$asset['record_key']])->where('v.state','prepared')->whereNotExists(function($q){$q->selectRaw('1')->from('fa_versions as n')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->whereColumn('n.record_key','v.record_key')->whereColumn('n.version','>','v.version');})->exists();abort_if($pending,409,'Plano ou quota aguarda decisão; confira antes do abate.');
foreach($native->table('gl_journals')->where($scope)->where('snapshot->source->asset_key',$asset['record_key'])->where('snapshot->source->effect_type','assets.depreciation')->orderBy('id')->get()as$q){$snap=json_decode($q->snapshot,true)['source'];$currentSource=$this->source($actor,'assets.depreciation',(int)$snap['source_id'],$native,$scope);if($snap['recognition_revision']!==$currentSource['recognition_revision']){$pair=$native->table('gl_correction_results')->where($scope)->where('original_journal_id',$q->id)->first();$replacement=$pair?$native->table('gl_journals')->where($scope)->where('id',$pair->replacement_journal_id)->first():null;abort_unless($replacement&&(json_decode($replacement->snapshot,true)['source']['replacement_source_revision']??null)===$currentSource['recognition_revision'],409,'Quota exige correcção aplicada antes do abate.');}}
abort_unless($balance['cost_minor']===$amount&&!$balance['disposed'],409,'Activo sem custo aplicado ou já abatido.');$frozen=$current['data']['calculation'];abort_unless($this->hash($balance)===$this->hash($frozen),409,'Saldo alterado; confira novo abate.');$date=$current['data']['input']['effect_on'];$last=$native->table('gl_journals')->where($scope)->where('snapshot->source->asset_key',$asset['record_key'])->max('journal_on');abort_unless($last===null||$last<=$date,409,'Abate anterior a movimentos.');$facts+=['disposal_version'=>$current['id'],'balance'=>$balance];$extra=['cost_minor'=>$balance['cost_minor'],'accumulated_minor'=>$balance['accumulated_minor'],'carrying_minor'=>$balance['carrying_minor']];
}
abort_unless($amount>0,409,'Efeito nulo não cria lançamento.');$observedKeys=array_values(array_unique(array_filter([$asset['record_key'],$current['record_key'],$current['data']['input']['plan_key']??null])));$observed=DB::table('fa_versions')->where($scope)->whereIn('record_key',$observedKeys)->select('record_key')->selectRaw('max(version) as version')->groupBy('record_key')->orderBy('record_key')->get()->map(fn($r)=>(array)$r)->all();$extra['administrative_revision']=$this->hash($observed);$first=DB::table('fa_versions')->where($scope)->where(['record_key'=>$current['record_key'],'state'=>'approved'])->orderBy('version')->value('id');return $extra+['effect_type'=>$kind,'source_id'=>(int)$first,'obligation_id'=>(int)$first,'asset_key'=>$asset['record_key'],'origin_key'=>$this->hash(['scope'=>$scope,'asset'=>$asset['record_key'],'kind'=>$kind,'month'=>$extra['charge_month']??null]),'host_database'=>DB::selectOne('SELECT current_database() d')->d,'permitted_period_key'=>'QA-2026-INTEGRADO','effect_on'=>$date,'recognition_revision'=>$this->hash($facts),'document_revision'=>$this->hash($facts),'amount_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false];
}
public function period(User $actor,array $period,object $native,array $s):array{
app(FixedAssets::class)->scope($actor->id);$h=hash_init('sha256');$facts=0;$pending=0;$rows=DB::table('fa_versions as v')->where(array_combine(array_map(fn($k)=>'v.'.$k,array_keys($s)),array_values($s)))->where('v.state','approved')->whereIn('v.kind',['asset','charge','disposal'])->whereNotExists(function($q){$q->selectRaw('1')->from('fa_versions as n')->whereColumn('n.record_key','v.record_key')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->where('n.state','approved')->whereColumn('n.version','>','v.version');});
foreach($rows->orderBy('v.id')->cursor()as$r){$d=json_decode($r->data,true);if($d['status']==='cancelled')continue;$on=match($r->kind){'asset'=>$d['input']['available_on'],'charge'=>$d['input']['month'].'-01','disposal'=>$d['input']['effect_on']};if($on!==null&&($on<$period['starts_on']||$on>$period['ends_on']))continue;$facts++;hash_update($h,$r->data);$kind=match($r->kind){'asset'=>'assets.admitted','charge'=>'assets.depreciation','disposal'=>'assets.writeoff'};
$j=$native->table('gl_journals')->where($s)->where('snapshot->source->effect_type',$kind)->where('snapshot->source->asset_key',$r->kind==='asset'?$r->record_key:$d['input']['asset_key']);if($r->kind==='charge')$j->where('snapshot->source->charge_key',$r->record_key);$j=$j->first();
if($r->kind==='disposal'&&$j)continue;
try{$source=$this->source($actor,$kind,(int)$r->id,$native,$s,true);if(!$j){$pending++;continue;}$snap=json_decode($j->snapshot,true)['source'];if($snap['recognition_revision']!==$source['recognition_revision']){$pair=$native->table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();$replacement=$pair?$native->table('gl_journals')->where($s)->where('id',$pair->replacement_journal_id)->first():null;if(!$replacement||(json_decode($replacement->snapshot,true)['source']['replacement_source_revision']??null)!==$source['recognition_revision'])$pending++;}}
catch(\DomainException|\Symfony\Component\HttpKernel\Exception\HttpException $e){$pending++;}
}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Modules\Accounting;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
/** Reviewed administrative assets. Accounting effects exclusively through the common journal. */
final class FixedAssets
{
public const POLICY='SGF-FIXED-ASSETS-INTEGRATED-QA-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function json(array $a):string{return Payables::canonical($a);}
private function hash(array $a):string{return hash('sha256',$this->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];});
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Modules\Accounting;
use Illuminate\Http\Request;
final class FixedAssetsController
{
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Processamento de activos ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
public function index(Request $r,FixedAssets $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
public function preview(Request $r,FixedAssets $f){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function confirm(Request $r,FixedAssets $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
public function receipt(Request $r,FixedAssets $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
}
+1 -1
View File
@@ -20,7 +20,7 @@ final class LedgerComposition
return DB::transaction(function()use($user,$options,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$ledger=app(GeneralLedger::class);$book=$ledger->listing($user,['page'=>(int)($options['journals_page']??1),'period_key'=>$options['period_key']??'QA-2026-10'],false);$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$permissions=app(Permissions::class);$can=fn($right)=>$permissions->has($user->id,$scope['tenant_id'],$scope['company_id'],$right,$scope['unit_id']);$panels=['sales'=>['allowed'=>false],'costs'=>['allowed'=>false],'suppliers'=>['allowed'=>false]];
if($can('service_invoices.view')&&$can('service_receivables.view')){
$this->requireTables(['si_invoices','sr_records','sr_effects','gl_sales_capability']);$p=ModuleRecords::query('si_invoices')->where($scope)->orderBy('id')->paginate(25,['id','series_code','number','issued_at'],'invoices_page',(int)($options['invoices_page']??1));$ids=array_column($p->items(),'id');$posted=$this->postings($scope,'sales.invoice','invoice_id',$ids);$records=DB::table('sr_records')->where($scope)->whereIn('invoice_id',$ids)->where('kind','obligation')->get(['id','invoice_id'])->keyBy('invoice_id');$opened=DB::table('sr_effects')->whereIn('record_id',$records->pluck('id')->all())->where('kind','open')->pluck('record_id')->all();$rows=[];foreach($p->items() as $i){$ob=$records->get($i->id);$rows[]=['invoice_id'=>$i->id,'number'=>$i->series_code.'/'.$i->number,'issued_at'=>$i->issued_at,'confirmed_opening'=>$ob&&in_array($ob->id,$opened,true),'original_journal_id'=>$posted[$i->id]??null];}$panels['sales']=$this->page($p,$rows);
$this->requireTables(['si_invoices','sr_records','sr_effects','gl_sales_capability']);$p=\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->orderBy('id')->paginate(25,['id','series_code','number','issued_at'],'invoices_page',(int)($options['invoices_page']??1));$ids=array_column($p->items(),'id');$posted=$this->postings($scope,'sales.invoice','invoice_id',$ids);$records=DB::table('sr_records')->where($scope)->whereIn('invoice_id',$ids)->where('kind','obligation')->get(['id','invoice_id'])->keyBy('invoice_id');$opened=DB::table('sr_effects')->whereIn('record_id',$records->pluck('id')->all())->where('kind','open')->pluck('record_id')->all();$rows=[];foreach($p->items() as $i){$ob=$records->get($i->id);$rows[]=['invoice_id'=>$i->id,'number'=>$i->series_code.'/'.$i->number,'issued_at'=>$i->issued_at,'confirmed_opening'=>$ob&&in_array($ob->id,$opened,true),'original_journal_id'=>$posted[$i->id]??null];}$panels['sales']=$this->page($p,$rows);
}
if($can('workshop.view')&&$can('direct_costs.view')){
$this->requireTables(['gl_cost_sources','wo_orders','dc_calculations','dc_decisions']);$p=DB::table('gl_cost_sources')->where($scope)->orderBy('order_id')->paginate(25,['order_id','period_key'],'costs_page',(int)($options['costs_page']??1));$ids=array_column($p->items(),'order_id');$calculations=DB::table('dc_calculations as c')->where(array_combine(array_map(fn($k)=>'c.'.$k,array_keys($scope)),array_values($scope)))->whereIn('c.order_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('dc_calculations as n')->whereColumn('n.order_id','c.order_id')->whereColumn('n.version','>','c.version'))->get(['c.id','c.order_id','c.version'])->keyBy('order_id');$decisions=DB::table('dc_decisions')->whereIn('calculation_id',$calculations->pluck('id')->all())->get(['calculation_id','decision'])->keyBy('calculation_id');$posted=$this->postings($scope,'workshop.direct_cost.validated','cost_order_id',$ids);$results=DB::table('gl_correction_results')->where($scope)->whereIn('original_journal_id',array_values($posted))->get(['id','original_journal_id'])->keyBy('original_journal_id');$rows=[];foreach($p->items() as $m){$calc=$calculations->get($m->order_id);$j=$posted[$m->order_id]??null;$rows[]=['order_id'=>$m->order_id,'period_key'=>$m->period_key,'calculation_id'=>$calc?->id,'calculation_version'=>$calc?->version,'recorded_decision'=>$calc?$decisions->get($calc->id)?->decision:null,'original_journal_id'=>$j,'recorded_correction_result_id'=>$j?$results->get($j)?->id:null,'current_dependency_conference_required'=>true];}$panels['costs']=$this->page($p,$rows);
+3 -3
View File
@@ -30,9 +30,9 @@ final class SalesLedgerSource
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']);
$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($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];
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
{
@@ -75,7 +75,7 @@ final class SalesLedgerSource
}
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);
$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);
@@ -9,4 +9,5 @@ final class PersonnelController
public function confirm(Request $r,Personnel $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
public function receipt(Request $r,Personnel $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
public function export(Request $r,Personnel $f,int $id){abort_if($r->query()!==[],422);$out=$f->export($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-rh-pessoal-interno-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('Cache-Control','no-store, private');}
public function readable(Request $r,Personnel $f,int $id){abort_if($r->query()!==[],422);$out=$f->readable($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-rh-resumo-legivel-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('X-Source-SHA256',$out['source_sha256'])->header('X-SGF-Read-Format',$out['format'])->header('Cache-Control','no-store, private');}
}
+11 -4
View File
@@ -54,7 +54,7 @@ final class Personnel
private function plan(int $actor,array $s,array $d,array $b):array{
$last=$b['last'];$eff=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$eff['data']??null;
if($d['action']==='prepare'){$in=$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'];HumanResourcesProfiles::assertActor($prepared,$s,'prepare');abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador mudou.');foreach(['view','prepare']as$a)$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'human_resources.'.$this->area($d['kind']).'.'.$a,$s['unit_id']);$in=$last['data']['input'];abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Fontes mudaram desde a preparação. Prepare novamente.');}
if($d['action']==='prepare'){$in=$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'];HumanResourcesProfiles::assertActor($prepared,$s,'prepare');abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador mudou.');foreach(['view','prepare']as$a)$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'human_resources.'.$this->area($d['kind']).'.'.$a,$s['unit_id']);$in=$last['data']['input'];if($d['data']['decision']==='refused')return$this->refusal($s,$d,$b,$prepared,$identity);abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Fontes mudaram desde a preparação. Prepare novamente.');}
$op=$in['operation'];$kind=$d['kind'];$worker=$in['worker_key'];if($op==='establish')abort_if($eff,409,'Já revisto. Use correcção neste registo.');else abort_unless($old&&$old['status']!=='cancelled',409,'Operação exige origem efectiva não cancelada.');if($last)abort_unless($worker===$last['data']['input']['worker_key'],409,'Pessoa imutável neste registo.');if($op!=='cancel')abort_unless($b['dependencies']['worker']['status']==='active',409,'Cadastro suspenso.');
if(in_array($op,['cancel','take'],true)){foreach($old['input']as$k=>$v)if(!in_array($k,['operation','evidence','reference_on'],true))abort_unless($this->hash(['v'=>$in[$k]??null])===$this->hash(['v'=>$v]),409,'Esta decisão conserva a origem e os valores revistos.');}
$status=$op==='cancel'?'cancelled':'active';$result=[];
@@ -86,6 +86,10 @@ final class Personnel
$data=['input'=>$in,'status'=>$status,'dependencies'=>$b['dependencies'],'result'=>$result,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1;if($state==='approved'){$csv=$this->csv($kind,$d['record_key'],$version,$data);$data['artifact_csv']=$csv;$data['artifact_sha256']=hash('sha256',$csv);}
$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,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];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];
}
private function refusal(array $s,array $d,array $b,int $prepared,string $identity):array{
$last=$b['last'];$old=$b['effective']['data']??null;$data=$last['data'];unset($data['artifact_csv'],$data['artifact_sha256']);$currentBalance=null;if($d['kind']==='leave'){try{$currentBalance=$this->balance($s,$data['input']['entitlement_key']);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){if($e->getStatusCode()!==409)throw$e;}}$data['refusal_conference']=['current_balance'=>$currentBalance,'observed_dependencies'=>$b['dependencies'],'original_source_changed'=>$this->hash($last['data']['dependencies'])!==$this->hash($b['dependencies']),'proposal_applied'=>false];$version=$d['version']+1;$impact=['before'=>['version'=>$last['version'],'state'=>$last['state'],'effective'=>$old],'after'=>['version'=>$version,'state'=>'refused','proposal'=>$data,'effective'=>$old],'payments'=>0,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];return['kind'=>$d['kind'],'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id'],'action'=>'review','state'=>'refused','prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
}
public function period(int $actor,array $s,string $worker,string $period):array{
$this->scope($actor,'report');$from=$period.'-01';$to=(new \DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');$q=$this->personQuery($this->effective($s,'day'),$worker)->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'date'>=? AND data->'input'->>'date'<=?",[$from,$to])->orderByRaw("data->'input'->>'date'");$days=[];$known=0;$unknown=0;$unaccounted=0;$contracts=[];$entitlements=[];
foreach($q->cursor()as$r){abort_if(count($days)>=31,409,'Mais de um registo efectivo por dia exige conferência.');$row=$this->row($r);$i=$row['data']['input'];$leaves=$this->leaveForDay($s,$worker,$i['date']);$calc=PersonnelTime::day($i['date'],$i['planned'],$i['actual'],$i['leave_conferred']?$leaves:null);$days[]=['record_key'=>$r->record_key,'version'=>$r->version,'contract_key'=>$i['contract_key'],'calculation'=>$calc];if($calc['unaccounted_minutes']===null)$unknown++;else{$known++;$unaccounted+=$calc['unaccounted_minutes'];}$contracts[$i['contract_key']]=true;}
@@ -95,17 +99,20 @@ final class Personnel
return['period'=>$period,'worker'=>$this->worker($s,$worker),'days'=>$days,'contracts'=>$contractSources,'leave_balances'=>$entitlements,'payroll'=>$payroll,'known_days'=>count($days),'known_conferred_days'=>$known,'days_with_unknown_conference'=>$unknown,'other_calendar_days_unknown'=>(int)substr($to,-2)-count($days),'unaccounted_known_minutes'=>$unaccounted,'complete_month_proven'=>false,'basis'=>'explicit_days_only_not_legal_calendar','salary_effect'=>false];
}
private function csv(string $kind,string $key,int $version,array $data):string{$rows=[['SGF QA - RH INTERNO - NAO E DOCUMENTO LEGAL'],['Tipo',$kind,'Registo',$key,'Versao',$version],['Pessoa ficticia',$data['dependencies']['worker']['code']],['Estado',$data['status']],['Sem salario/pagamento/lancamento automaticos'],['Input',$this->json($data['input'])],['Resultado',$this->json($data['result'])],['Fontes',$this->json($data['dependencies'])]];return implode("\r\n",array_map(fn($r)=>implode(';',array_map(fn($v)=>'"'.str_replace('"','""',"'".(string)$v).'"',$r)),$rows))."\r\n";}
public function export(int $actor,int $id):array{$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];$r=$this->row($this->rows($s)->where(['id'=>$id,'state'=>'approved'])->first());abort_unless($r,403);$this->scope($actor,$r['kind']);$d=$r['data'];abort_unless(hash_equals($d['artifact_sha256'],hash('sha256',$d['artifact_csv'])),409,'Exportação conservada não confere.');return['bytes'=>$d['artifact_csv'],'sha256'=>$d['artifact_sha256']];}
private function approvedExportRecord(int $actor,int $id):array{$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];$r=$this->row($this->rows($s)->where(['id'=>$id,'state'=>'approved'])->first());abort_unless($r,403);$this->scope($actor,$r['kind']);$d=$r['data'];abort_unless(hash_equals($d['artifact_sha256'],hash('sha256',$d['artifact_csv'])),409,'Exportação conservada não confere.');return$r;}
public function export(int $actor,int $id):array{$r=$this->approvedExportRecord($actor,$id);return['bytes'=>$r['data']['artifact_csv'],'sha256'=>$r['data']['artifact_sha256']];}
public function readable(int $actor,int $id):array{$r=$this->approvedExportRecord($actor,$id);$bytes=PersonnelReadableCsv::render($r);return['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes),'source_sha256'=>$r['data']['artifact_sha256'],'format'=>PersonnelReadableCsv::VERSION];}
public function payrollContract(int $actor,string $key,string $worker,string $period,string $gross):array{$s=$this->scope($actor,'contract');$r=$this->source($s,$key,'contract',$worker);$from=$period.'-01';$to=(new \DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');abort_unless($this->covers($r,$from,$to)&&$r['data']['input']['gross']===$gross,409,'Contrato revisto não cobre o mês/bruto escolhidos.');return['record_key'=>$key,'version'=>$r['version'],'id'=>$r['id'],'latest_id'=>$this->latest($s,$key)['id'],'reference'=>$r['data']['input']['reference'],'gross'=>$gross,'valid_from'=>$r['data']['input']['valid_from'],'valid_to'=>$r['data']['input']['valid_to']];}
public function listing(int $actor,array $input):array{
$d=$this->exact($input,['kind'=>'required|in:contract,day,entitlement,leave,report','worker_key'=>'sometimes|uuid','record_key'=>'sometimes|uuid','period'=>'sometimes|date_format:Y-m','date'=>'sometimes|date_format:Y-m-d','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']);$s=$this->scope($actor,$d['kind']);
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$worker=isset($d['worker_key'])?$this->worker($s,strtolower($d['worker_key'])):null;$q=$this->rows($s)->where('kind',$d['kind']);if($worker)$q=$this->personQuery($q,$worker['record_key']);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('hp_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'hp_versions.'.$k);$n->whereColumn('next.version','>','hp_versions.version');});
if(isset($d['period']))$q->whereRaw("coalesce(data->'input'->>'period',substring(data->'input'->>'date' from 1 for 7))=?",[$d['period']]);if(isset($d['date']))$q->whereRaw("data->'input'->>'date'=?",[$d['date']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("(coalesce(data->'input'->>'reference','')||' '||coalesce(data->'dependencies'->'worker'->>'code','')) ILIKE ? ESCAPE '!'",[$needle]);}
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->all();$effective=$this->effective($s,$d['kind'])->whereIn('record_key',$keys)->get()->keyBy('record_key');$items=[];foreach($page->items()as$r){$a=$this->row($r);$a['effective']=$this->row($effective->get($r->record_key));$items[]=$a;}$page->setCollection(collect($items));$area=$this->area($d['kind']);return['policy'=>self::POLICY,'kind'=>$d['kind'],'worker'=>$worker,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.review',$s['unit_id'])],'synthetic_only'=>true,'salary_effect'=>false,'legal_framework_validated'=>false];});
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->all();$effective=$this->effective($s,$d['kind'])->whereIn('record_key',$keys)->get()->keyBy('record_key');$balances=[];if($d['kind']==='entitlement'&&$keys!==[]){$totals=$this->effective($s,'leave')->whereIn(DB::raw("data->'input'->>'entitlement_key'"),$keys)->selectRaw("data->'input'->>'entitlement_key' balance_key,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='reserved'),0) reserved,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='taken'),0) taken")->groupByRaw("data->'input'->>'entitlement_key'")->get()->keyBy('balance_key');foreach($effective as$key=>$row){$e=$this->row($row);if($e['data']['status']==='cancelled')continue;$sum=$totals->get($key);$initial=$e['data']['input']['minutes'];$reserved=(int)($sum?->reserved??0);$taken=(int)($sum?->taken??0);$balances[$key]=['effective_entitlement_version'=>$e['version'],'entitlement_minutes'=>$initial,'reserved_minutes'=>$reserved,'taken_minutes'=>$taken,'available_minutes'=>$initial===null?null:$initial-$reserved-$taken,'basis'=>'current_effective_requests_at_read'];}}
$items=[];foreach($page->items()as$r){$a=$this->row($r);$a['effective']=$this->row($effective->get($r->record_key));$a['current_balance']=$balances[$r->record_key]??null;$items[]=$a;}$page->setCollection(collect($items));$area=$this->area($d['kind']);return['policy'=>self::POLICY,'kind'=>$d['kind'],'worker'=>$worker,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.review',$s['unit_id'])],'synthetic_only'=>true,'salary_effect'=>false,'legal_framework_validated'=>false];});
}
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['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$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['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$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('hp_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('hp_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('hp_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('human_resources.remunerations.'.$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];});
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['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$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('hp_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('hp_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('hp_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('human_resources.'.$this->area($d['kind']).'.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason'],'area'=>$this->area($d['kind'])]);return$result+['replayed'=>false];});
}
public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,'report');$old=DB::table('hp_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];}
@@ -0,0 +1,20 @@
<?php
namespace App\Modules\HumanResources;
/** A human-readable derivation of one immutable approved version, never a new decision. */
final class PersonnelReadableCsv
{
public const VERSION='SGF-HR-READABLE-CSV-v1';
private static function time(mixed $value):string{return$value===null?'Desconhecido':sprintf('%dh %02dmin (%d minutos)',intdiv((int)$value,60),(int)$value%60,(int)$value);}
public static function render(array $row):string
{
$d=$row['data'];$i=$d['input'];$r=$d['result'];$states=['active'=>'Activo','terminated'=>'Terminado','cancelled'=>'Cancelado','reserved'=>'Reservado','taken'=>'Gozado'];$titles=['contract'=>'Contrato interno QA','day'=>'Plano e assiduidade QA','entitlement'=>'Condição de férias QA','leave'=>'Pedido e gozo de férias QA','report'=>'Conferência do período QA'];
$lines=[['SGF — RECURSOS HUMANOS — RESUMO INTERNO QA'],['Documento',$titles[$row['kind']]],['Natureza','Vista legível derivada da versão conservada; não é documento legal nem nova decisão.'],['Colaborador fictício',$d['dependencies']['worker']['code']],['Registo',$row['record_key']],['Versão revista',$row['version']],['Estado nesta versão',$states[$d['status']]??$d['status']],['Preparação','QA'.$row['prepared_by']],['Autor desta revisão','QA'.$row['actor_id']],['Data da revisão (origem UTC)',$row['created_at']],['Fundamento',$row['reason']],['Referência da evidência',$i['evidence']],['Formato de leitura',self::VERSION],['SHA do CSV original conservado',$d['artifact_sha256']],[]];
if($row['kind']==='contract'){$lines=[...$lines,['CONDIÇÕES DO CONTRATO'],['Referência',$i['reference']],['Modalidade interna',['fixed'=>'Prazo explícito','open'=>'Sem termo declarado','unknown_end'=>'Fim desconhecido'][$i['mode']]],['Início',$i['valid_from']],['Fim',$i['valid_to']??'Não confirmado'],['Bruto mensal declarado',$i['gross'].' MZN'],['Registos dependentes observados nesta decisão',$r['dependent_records']],['Validação jurídica','Não demonstrada']];}
if($row['kind']==='day'){$lines=[...$lines,['ASSIDUIDADE DO DIA'],['Dia local',$i['date']],['Zona',$r['zone']],['Referência da declaração',$i['reference_on']],['Tempo previsto',self::time($r['planned_minutes'])],['Tempo declarado',self::time($r['declared_minutes'])],['Férias conferidas',self::time($r['leave_minutes'])],['Tempo por conferir',self::time($r['unaccounted_minutes'])],['Fora do plano (sem autorização de horas extra)',self::time($r['outside_plan_minutes'])]];foreach(['planned'=>'Intervalos previstos','actual'=>'Intervalos declarados']as$key=>$title){$lines[]=[$title];if($i[$key]===null)$lines[]=['Informação','Desconhecida'];elseif($i[$key]===[])$lines[]=['Informação','Conjunto explicitamente vazio'];else foreach($i[$key]as$range)$lines[]=['Desde / até (hora local)',sprintf('%02d:%02d',intdiv($range['from'],60),$range['from']%60),sprintf('%02d:%02d',intdiv($range['to'],60),$range['to']%60)];}}
if(in_array($row['kind'],['entitlement','leave'],true)){$b=$r['balance_after']??$r;$lines[]=['FÉRIAS — VALORES CONSERVADOS NESTA VERSÃO'];if($row['kind']==='entitlement')$lines[]=['Vigência',$i['valid_from'].' a '.$i['valid_to']];else{$lines[]=['Dia',$i['date']];$lines[]=['Pedido',self::time($r['minutes'])];}$lines=[...$lines,['Orçamento explícito',self::time($b['entitlement_minutes']??null)],['Reservado',self::time($b['reserved_minutes'])],['Gozado',self::time($b['taken_minutes'])],['Disponível',self::time($b['available_minutes'])],['Limite','Orçamento fictício; não comprova direito legal anual. Não é saldo actual se decisões posteriores existirem.']];}
if($row['kind']==='report'){$lines=[...$lines,['CONFERÊNCIA DO PERÍODO'],['Período',$r['period']],['Dias com registos',$r['known_days']],['Dias conferidos',$r['known_conferred_days']],['Dias com conferência desconhecida',$r['days_with_unknown_conference']],['Outros dias sem cobertura comprovada',$r['other_calendar_days_unknown']],['Mês completo','Não demonstrado; dias sem registos não são faltas presumidas.'],[],['Dia local','Versão do dia','Previsto','Declarado','Férias conferidas','Por conferir']];foreach($r['days']as$day){$c=$day['calculation'];$lines[]=[$c['date'],$day['version'],self::time($c['planned_minutes']),self::time($c['declared_minutes']),self::time($c['leave_minutes']),self::time($c['unaccounted_minutes'])];}$lines[]=[];$lines[]=['SALDOS DE FÉRIAS NESTA CONFERÊNCIA','Versão da condição','Orçamento','Reservado','Gozado','Disponível'];foreach($r['leave_balances']as$b)$lines[]=['Condição QA',$b['entitlement_version'],self::time($b['entitlement_minutes']),self::time($b['reserved_minutes']),self::time($b['taken_minutes']),self::time($b['available_minutes'])];$lines[]=[];$lines[]=['PROCESSAMENTOS OBSERVADOS','Versão','Líquido interno (centavos)','Ligação contratual comprovada'];foreach($r['payroll']as$p)$lines[]=['Processamento QA',$p['version'],$p['net_cents']??'Indeterminado',$p['contract_link_proven']?'Sim, escolhida na origem':'Não; sem atribuição retroactiva'];}
$lines=[...$lines,[],['Limites','Sem salário, dívida, pagamento, lançamento ou submissão automáticos. Sem conformidade legal presumida.'],['Fonte','Apenas esta versão revista. Não consulta nem recalcula o estado actual.']];
// Apostrophe protects all free text, including formula prefixes after whitespace. BOM supports local Excel.
return "\xEF\xBB\xBF".implode("\r\n",array_map(fn($line)=>implode(';',array_map(fn($cell)=>'"'.str_replace('"','""',"'".(string)$cell).'"',$line)),$lines))."\r\n";
}
}
@@ -51,6 +51,8 @@ final class IdentityWorkflow
'analysis_qa_enabled'=>app()->environment('budget-testing')&&config('analysis_qa.enabled')===true,
'bank_qa_enabled'=>app()->environment('budget-testing')&&config('bank_qa.enabled')===true,
'fiscal_rules_qa_enabled'=>app()->environment('budget-testing')&&config('fiscal_rules_qa.enabled')===true,
'multiline_invoices_enabled'=>app()->environment('budget-testing')&&config('multiline_invoices.enabled')===true,
'fixed_assets_enabled'=>app()->environment('budget-testing')&&config('fixed_assets.enabled')===true,
'internal_statements_enabled'=>app()->environment('budget-testing')&&config('internal_statements.enabled')===true,
'hr_personnel_enabled'=>app()->environment('budget-testing')&&config('hr_personnel.enabled')===true,
'remunerations_enabled'=>app()->environment('budget-testing')&&config('remunerations.enabled')===true,
+5 -4
View File
@@ -7,22 +7,23 @@ final class FiscalCoverage
public static function describe(): array
{
return [
'version'=>'SGF-FISCAL-COVERAGE-v2',
'version'=>'SGF-FISCAL-COVERAGE-v4',
'automatic_knowledge_import'=>false,
'legal_validation'=>false,
'notice'=>'Conhecimento em documentos ou conversas não passa automaticamente para a aplicação. Cada regra exige fonte, versão, âmbito, provas e revisão antes de utilização.',
'taxes'=>[
['id'=>'iva','label'=>'IVA','state'=>'partial','available'=>'Cálculo delimitado de serviço, MZN, uma linha e imposto acrescido; perfis versionados, revisão distinta e decisão temporal. Documentos QA e diário integrado; livro/mapa interno da origem seleccionada revistos distintamente. Núcleo comum com versões/vigência e cálculo interno QA de1–25linhas, preço acrescido/incluído, exactos e diferenças preservados.','pending'=>'Emissão documental por múltiplas linhas/tratamentos, livros gerais de apuramento/dedução, regularizações e modelos oficiais.','legal_use'=>false],
['id'=>'irps','label'=>'IRPS','state'=>'pending','available'=>null,'pending'=>'Motor operacional de rendimentos/retenções, remunerações, apuramento e modelos ligados às operações do SGF.','legal_use'=>false],
['id'=>'irps','label'=>'IRPS','state'=>'partial','available'=>'RH autónomo com cadastro, contratos/tempos/férias e processamento interno QA revisto; retenção artificial explicada e mapa conservado.','pending'=>'Cálculo legal de IRPS por regime/rendimento, retenções/apuramento e modelos oficiais; o ensaio interno não valida o imposto.','legal_use'=>false],
['id'=>'irpc','label'=>'IRPC','state'=>'pending','available'=>null,'pending'=>'Ponte do resultado contabilístico para ajustamentos/base/colecta, retenções e modelos no SGF.','legal_use'=>false],
['id'=>'ispc','label'=>'ISPC','state'=>'pending','available'=>null,'pending'=>'Enquadramento, cálculo, obrigações e modelos operacionais no SGF.','legal_use'=>false],
['id'=>'ice','label'=>'ICE','state'=>'pending','available'=>null,'pending'=>'Classificação dos bens, parâmetros com vigência, liquidação e obrigações operacionais no SGF.','legal_use'=>false],
['id'=>'inss','label'=>'INSS','state'=>'pending','available'=>null,'pending'=>'Cadastro/processamento de remunerações, contribuições e modelos no SGF.','legal_use'=>false],
['id'=>'inss','label'=>'INSS','state'=>'partial','available'=>'Cadastro e processamento interno QA em RH, com desconto do trabalhador e encargo patronal artificiais separados e mapa revisto.','pending'=>'Regimes/bases/contribuições legais INSS, reconciliação e modelos oficiais; percentagens do exercício não são parâmetros legais activados.','legal_use'=>false],
['id'=>'other','label'=>'Outros impostos e obrigações','state'=>'pending','available'=>null,'pending'=>'Identificação dos tributos aplicáveis, calendário legal, multas/juros e modelos; sem taxas ou prazos presumidos.','legal_use'=>false],
],
'management'=>[
['id'=>'human_resources','label'=>'Recursos Humanos','state'=>'partial','available'=>'Módulo autónomo e perfis próprios: cadastro, contratos internos, plano/assiduidade, condições/pedidos/gozo de férias, conferência de período e remunerações internas QA; histórico e resumos legíveis.','pending'=>'Aplicabilidade laboral real, calendário/turnos gerais, benefícios, cálculo legal IRPS/INSS e efeitos salariais financeiros/contabilísticos.'],
['id'=>'finance','label'=>'Gestão financeira','state'=>'partial','available'=>'Percursos QA de orçamento de Compras, obrigações/recebíveis, afectações/reversões e Caixa com fecho; conferência individual de extracto artificial contra duas linhas existentes.','pending'=>'Operação geral real, bancos e reconciliação bancária geral.'],
['id'=>'accounting','label'=>'Contabilidade','state'=>'partial','available'=>'Diário, plano, razão, balancete e conferência/fecho QA com origens distintas.','pending'=>'Demonstrações financeiras legais, encerramento anual real e integração dos apuramentos fiscais.'],
['id'=>'accounting','label'=>'Contabilidade','state'=>'partial','available'=>'Diário, plano, razão, balancete e conferência/fecho QA; demonstrações internas parciais revistas/conservadas, com contas não classificadas explícitas. Cadastro/custódia, plano mensal, quota e diário de activos QA integrados, com correcção e abate no recorte sintético.','pending'=>'Operação geral de activos e tratamento fiscal de depreciações, demonstrações financeiras legais, encerramento anual real e integração dos apuramentos fiscais.'],
['id'=>'analysis','label'=>'Análise financeira','state'=>'partial','available'=>'Custos directos/industriais, orçamento versus realizado, diário/saldos e dois critérios/indicadores QA revistos com fontes explícitas e relatórios.','pending'=>'Rácios de liquidez/rentabilidade/endividamento e tendências conciliadas, com plano de contas e critérios aprovados.'],
],
'rule_lifecycle'=>['Fonte identificada e versão preservada','Aplicabilidade e vigência explícitas','Cálculo exacto com pressupostos/pendências','Provas e revisão por pessoa distinta','Activação específica e histórico sem reescrever operações anteriores'],
+9
View File
@@ -94,6 +94,15 @@ final class FiscalRules
$gate=$this->evaluate($s,['base'=>'0','currency'=>$in['currency'],'date'=>$in['date']],$selected);if($gate['state']!=='determined_qa'){unset($gate['base']);return $gate+['policy'=>FiscalDocumentMath::POLICY,'price_mode'=>$in['price_mode'],'base_document'=>null,'lines'=>[]];}
return FiscalDocumentMath::calculate($in['lines'],$in['price_mode'],$selected['definition']['parameters']['rate'])+['currency'=>$in['currency'],'rule'=>$gate['rule']];
}
/** Explicit read-only QA bridge; does not change the module required by rule administration. */
public function invoiceDocument(int $actor,array $scope,int $event,array $input,?int $boundCalculation=null):array
{
abort_unless(config('multiline_invoices.enabled')===true&&config('fiscal_rules_qa.enabled')===true,404);CompositionRuntime::assert();$actual=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$actual===$scope&&ModuleRecords::active()==='commercial',403);if(!$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'fiscal_rules.view',$scope['unit_id'])){
// Invoice readers cannot enumerate/administer the rule catalogue. Only revalidate
// a calculation already captured for this exact draft/input by a fiscal preparer.
$this->permissions->require($actor,$scope['tenant_id'],$scope['company_id'],'billing_drafts.view',$scope['unit_id']);$row=$boundCalculation?DB::table('mi_calculations')->where($scope)->where('id',$boundCalculation)->first():null;abort_unless($row,403,'Regra exige captura documental previamente autorizada.');$frozen=json_decode($row->snapshot,true,64,JSON_THROW_ON_ERROR);$expected=$frozen['common']['input'];unset($expected['rule_event_id']);abort_unless((int)$frozen['common']['rule']['id']===$event&&$this->hash($expected)===$this->hash($input),409,'Entrada/data da captura fiscal mudou.');
}$input['rule_event_id']=$event;$in=$this->documentInput($input);$row=$this->events($scope)->where('id',$event)->first();abort_unless($row,403,'Regra fora do contexto.');$selected=$this->row($row);$this->fixture();return ['rule'=>$selected,'result'=>$this->evaluateDocument($scope,$in,$selected),'input'=>$in,'rule_stream'=>['count'=>$this->events($scope)->count(),'last_id'=>$this->events($scope)->max('id')]];
}
public function documentReport(int $actor,int $id):array
{
$detail=$this->detail($actor,$id,'calculation');$r=$detail['result'];abort_unless(isset($r['price_mode'],$r['lines']),422,'Este registo não é cálculo por linhas.');$fp=fopen('php://temp','w+');$rule=$r['rule']??[];$rows=[['MAPA INTERNO POR LINHAS QA — SEM VALIDADE FISCAL'],['Politica',$r['policy']],['Modo',$r['price_mode']],['Data',$detail['input']['date']],['Moeda',$detail['input']['currency']],['Regra escolhida',(string)($detail['input']['rule_event_id']??'INDETERMINADA')],['Versao regra',(string)($rule['version']??'INDETERMINADA')],['Fonte',$rule['source_reference']??'INDETERMINADA'],['SHA fonte',$rule['source_sha256']??'INDETERMINADO'],['Estado historico',$r['state']],['Estado actual',$detail['current_evaluation']['state']],['Linha','Referencia','Base exacta fraccao','Imposto exacto fraccao','Base documental','Imposto documental','Total','Diferenca base fraccao','Diferenca imposto fraccao']];foreach($r['lines']as$line){$ref="'".$line['reference'];$rows[]=[(string)$line['line'],$ref,$line['base_exact_fraction'],$line['tax_exact_fraction'],$line['base_document']??'INDETERMINADO',$line['tax_document'],$line['total_document']??'INDETERMINADO',$line['base_difference_fraction']??'INDETERMINADO',$line['tax_difference_fraction']];}$rows[]=['Totais documentais','','','',$r['base_document']??'INDETERMINADO',$r['tax_document']??'INDETERMINADO',$r['total']??'INDETERMINADO'];foreach($rows as$row)fputcsv($fp,$row,';','"','',"\r\n");rewind($fp);$bytes=stream_get_contents($fp);$s=$this->scope($actor);Audit::record('fiscal_rule.document_report',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>0,'review_hash'=>hash('sha256',$bytes)]);return ['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes)];
@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Services\Http;
use App\Modules\Services\{MultiLineInvoiceCalculation,MultiLineDrafts,ServiceWorkflow,ServiceInvoicing,DocumentPreparation,FiscalRules};
use App\Shared\Context\ModuleRecords;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{DB,Validator};
final class MultiLineInvoiceController
{
private function response(\Closure $fn){try{$value=$fn();return $value instanceof \Symfony\Component\HttpFoundation\Response?$value:response()->json($value)->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Documento ocupado. Recupere a mesma operação, sem gerar outra chave.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
private function exactQuery(Request $r,array $rules):array{abort_if(array_diff(array_keys($r->query()),array_keys($rules)),422);return Validator::make($r->query(),$rules)->validate();}
public function catalogue(Request $r,MultiLineInvoiceCalculation $mi){return$this->response(function()use($r,$mi){$s=$mi->scope($r->user()->id,'unit.view');$d=$this->exactQuery($r,['kind'=>'required|in:clients,services,drafts,rules,series,temporals,calculations','draft_id'=>'sometimes|integer|min:1','search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$kind=$d['kind'];$q=null;
if(in_array($kind,['clients','services','drafts'],true)){$area=$kind==='drafts'?'billing_drafts':$kind;app(ServiceWorkflow::class)->scope($area,$r->user()->id,'view');$q=ModuleRecords::query('a_'.$area)->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('unit_id',$kind==='drafts'?$s['unit_id']:null);if($kind==='drafts')$q->whereIn('id',DB::table('mi_drafts')->where($s)->select('draft_id'));if(!empty($d['search'])){$v='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("name ILIKE ? ESCAPE '!'",[$v]);}$columns=['id','name','version'];}
elseif($kind==='rules'){$s=$mi->scope($r->user()->id,'fiscal_rules.view');$q=DB::table('fr_events as f')->where(['f.tenant_id'=>$s['tenant_id'],'f.company_id'=>$s['company_id'],'f.unit_id'=>$s['unit_id'],'f.state'=>'reviewed','f.definition->family'=>'qa','f.definition->source_status'=>'qa_checked'])->whereNotExists(function($n){$n->selectRaw('1')->from('fr_events as n')->whereColumn('n.tenant_id','f.tenant_id')->whereColumn('n.company_id','f.company_id')->whereColumn('n.unit_id','f.unit_id')->whereColumn('n.rule_key','f.rule_key')->whereColumn('n.rule_version','f.rule_version')->whereColumn('n.revision','>','f.revision');});$columns=['f.id','f.rule_key','f.rule_version','f.definition'];}
elseif($kind==='series'||$kind==='temporals'){$mi->scope($r->user()->id,'service_invoices.view');$q=DB::table('si_proposals')->where($s)->where('kind',$kind==='series'?'series':'temporal');if($kind==='series')$q->where('identifier','like','TEST-MULTI-%');else{abort_unless(isset($d['draft_id']),422);app(ServiceWorkflow::class)->show('billing_drafts',(int)$d['draft_id'],$r->user()->id);$q->where('data->draft_id',(int)$d['draft_id'])->where('data->calculation_policy',MultiLineInvoiceCalculation::POLICY);}$columns=['id','identifier','version','kind','actor_id','data','created_at'];}
else{abort_unless(isset($d['draft_id']),422);app(ServiceWorkflow::class)->show('billing_drafts',(int)$d['draft_id'],$r->user()->id);$q=DB::table('mi_calculations')->where($s)->where('draft_id',(int)$d['draft_id']);$columns=['id','actor_id','snapshot','created_at'];}
if(!empty($d['search'])&&in_array($kind,['rules','series','temporals','calculations'],true)){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$column=match($kind){'rules'=>"f.definition->>'label'",'calculations'=>'id::text',default=>'identifier'};$q->whereRaw($column." ILIKE ? ESCAPE '!'",[$needle]);}
$page=$q->orderByDesc($kind==='rules'?'f.id':'id')->paginate((int)($d['per_page']??25),$columns,'page',(int)($d['page']??1));$decisions=in_array($kind,['series','temporals'],true)?DB::table('si_decisions')->whereIn('proposal_id',$page->pluck('id')->all())->get(['proposal_id','actor_id','decision','reason'])->keyBy('proposal_id'):collect();$items=[];foreach($page as$row){$v=(array)$row;if($kind==='rules'){$definition=json_decode($v['definition'],true);unset($v['definition']);$v+=['name'=>$definition['label'],'valid_from'=>$definition['valid_from'],'valid_until'=>$definition['valid_until'],'rate'=>$definition['parameters']['rate']];}elseif(in_array($kind,['series','temporals'],true)){$v['data']=json_decode($v['data'],true);$v['decision']=$decisions->get($v['id']);}elseif($kind==='calculations'){$snapshot=json_decode($v['snapshot'],true,64);unset($v['snapshot']);$v+=['name'=>'Cálculo #'.$v['id'],'result'=>$snapshot['common']['result'],'rule_event_id'=>$snapshot['common']['rule']['id'],'draft_version'=>$snapshot['document']['draft_version'],'preparation_version'=>$snapshot['document']['preparation_version'],'date'=>$snapshot['expected_date']];}$items[]=$v;}$page->setCollection(collect($items));return ['kind'=>$kind,'records'=>$page->toArray()];});}
public function editor(Request $r,int $id,MultiLineInvoiceCalculation $mi){abort_if($r->query()!==[],422);return$this->response(function()use($r,$id,$mi){$s=$mi->scope($r->user()->id);$binding=DB::table('mi_drafts')->where($s)->where('draft_id',$id)->first();abort_unless($binding,403);$w=app(ServiceWorkflow::class);$billing=$w->show('billing_drafts',$id,$r->user()->id);$source=$w->show('service_drafts',(int)$binding->source_id,$r->user()->id);$p=app(\App\Modules\Identity\Services\Permissions::class);$prep=$p->has($r->user()->id,$s['tenant_id'],$s['company_id'],'draft_documents.view',$s['unit_id'])?app(DocumentPreparation::class)->show('billing_drafts',$id,$r->user()->id):null;$parties=[];foreach(['issuer'=>$s['company_id'],'client'=>$billing['client_id']]as$kind=>$target){if($p->has($r->user()->id,$s['tenant_id'],$s['company_id'],$kind==='issuer'?'issuer_documents.view':'client_documents.view'))$parties[$kind]=app(DocumentPreparation::class)->show($kind,(int)$target,$r->user()->id);}$invoice=ModuleRecords::query('si_invoices')->where($s)->where('draft_id',$id)->first(['id','series_code','number']);if($invoice&&$p->has($r->user()->id,$s['tenant_id'],$s['company_id'],'service_invoices.view',$s['unit_id']))$invoice->totals=app(ServiceInvoicing::class)->show((int)$invoice->id,$r->user()->id)['snapshot']['calculation']['common_document_result']??null;return['billing'=>$billing,'source'=>$source,'preparation'=>$prep,'parties'=>$parties,'invoice'=>$invoice,'policy'=>MultiLineInvoiceCalculation::POLICY,'server_date'=>now(ServiceInvoicing::ZONE)->format('Y-m-d')];});}
public function draftPreview(Request $r,MultiLineDrafts $d){abort_if(strlen(json_encode($r->all()))>65536,422);return$this->response(fn()=>$d->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function draftConfirm(Request $r,MultiLineDrafts $d){abort_if(array_diff(array_keys($r->all()),['preview']),422);$r->validate(['preview'=>'required|string|max:262144']);return$this->response(fn()=>$d->confirm($r->user()->id,$r->input('preview')));}
public function calculationPreview(Request $r,MultiLineInvoiceCalculation $c){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$c->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function calculationConfirm(Request $r,MultiLineInvoiceCalculation $c){abort_if(array_diff(array_keys($r->all()),['preview']),422);$r->validate(['preview'=>'required|string|max:262144']);return$this->response(fn()=>$c->confirm($r->user()->id,$r->input('preview')));}
public function receipt(Request $r,string $key,MultiLineInvoiceCalculation $c){abort_if($r->query()!==[],422);return$this->response(fn()=>$c->receipt($r->user()->id,strtolower($key)));}
private function command(Request $r,\Closure $work){return$this->response(fn()=>DB::transaction(function()use($r,$work){DB::statement("SET LOCAL lock_timeout='5s'");app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);return$work();}));}
private function administrative(Request $r,string $domain,string $right,?int $target,\Closure $work){return$this->command($r,function()use($r,$domain,$right,$target,$work){$s=app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);app(\App\Modules\Identity\Services\Permissions::class)->require($r->user()->id,$s['tenant_id'],$s['company_id'],$right);$key=strtolower((string)$r->header('Idempotency-Key'));abort_unless(preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/D',$key),422);$input=['domain'=>$domain,'target'=>$target,'data'=>$r->all()];$hash=hash('sha256',\Tempo\Finance\Payables\Payables::canonical($input));DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();$s=app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);app(\App\Modules\Identity\Services\Permissions::class)->require($r->user()->id,$s['tenant_id'],$s['company_id'],$right);$q=$s+['actor_id'=>$r->user()->id,'command_key'=>$key];$old=DB::table('mi_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave usada noutra operação.');return json_decode($old->result,true)+['replayed'=>true];}$out=$work();$out['type']=$domain;DB::table('mi_commands')->insert($q+['request_hash'=>$hash,'input'=>\Tempo\Finance\Payables\Payables::canonical($input),'result'=>\Tempo\Finance\Payables\Payables::canonical($out),'created_at'=>now()->utc()]);return$out+['replayed'=>false];});}
public function temporal(Request $r){abort_if(($r->input('kind')!=='temporal')||$r->input('data.calculation_policy')!==MultiLineInvoiceCalculation::POLICY,422);return$this->administrative($r,'temporal','service_fiscal.propose',null,fn()=>app(ServiceInvoicing::class)->propose($r->user()->id,$r->all()));}
public function temporalReview(Request $r,int $id){return$this->administrative($r,'temporal_review','service_fiscal.approve',$id,function()use($r,$id){$s=app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);abort_unless(DB::table('si_proposals')->where($s)->where('id',$id)->where('data->calculation_policy',MultiLineInvoiceCalculation::POLICY)->exists(),403);return app(ServiceInvoicing::class)->decide($id,$r->user()->id,$r->all());});}
public function readiness(Request $r,int $id){abort_if(array_diff(array_keys($r->all()),['series_id','rule_id']),422);$d=$r->validate(['series_id'=>'required|integer|min:1','rule_id'=>'required|integer|min:1']);return$this->command($r,fn()=>app(ServiceInvoicing::class)->review($id,(int)$d['series_id'],(int)$d['rule_id'],$r->user()->id));}
public function issue(Request $r,int $id){return$this->command($r,function()use($r,$id){$s=app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);abort_unless(DB::table('mi_drafts')->where($s)->where('draft_id',$id)->exists()&&DB::table('si_proposals')->where($s)->where('id',$r->input('rule_id'))->where('data->calculation_policy',MultiLineInvoiceCalculation::POLICY)->exists(),403);return app(ServiceInvoicing::class)->issue($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'));});}
public function issueReceipt(Request $r,string $key,MultiLineInvoiceCalculation $c){abort_if($r->query()!==[],422);return$this->response(function()use($r,$key,$c){$s=$c->scope($r->user()->id,'service_invoices.view');abort_unless(preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/D',$key),422);$row=DB::table('si_commands')->where($s+['actor_id'=>$r->user()->id,'command_key'=>$key])->first();abort_unless($row,404);$result=json_decode($row->result,true);$invoice=app(ServiceInvoicing::class)->show((int)$result['id'],$r->user()->id);abort_unless(($invoice['snapshot']['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY,404);return ['result'=>$result,'historical_receipt_only'=>true,'operation_executed'=>false];});}
}
+1
View File
@@ -7,6 +7,7 @@ final class InvoicePdf
public const VERSION='sgf-test-invoice-text-v2';
public static function lines(array $s): array
{
if(($s['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY)return MultiLineInvoicePdf::lines($s);
$lines=[['title','Factura de serviço - demonstração isolada'],['text',$s['series_code'].' / '.$s['number']],['text','Emissão: '.$s['issued_local'].' ('.$s['timezone'].')'],['section','Emitente']];
foreach(['issuer'=>'Emitente','client'=>'Cliente'] as $key=>$title) {
if($key==='client') $lines[]=['section',$title];
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Services;
use Illuminate\Support\Facades\{DB,Crypt,Validator};
use Tempo\Finance\Payables\Payables;
/** Atomic administrative composition of the existing draft/preparation writers. No invoice writer. */
final class MultiLineDrafts
{
private function hash(array $a):string{return hash('sha256',Payables::canonical($a));}
private function input(array $v):array{
$rules=['draft_id'=>'present|nullable|integer|min:1','version'=>'required|integer|min:0','source_version'=>'required|integer|min:0','preparation_version'=>'required|integer|min:0','name'=>'required|string|min:3|max:255','client_id'=>'required|integer|min:1','currency'=>'required|in:MZN','price_mode'=>'required|in:undefined,added,included','service_date'=>'present|nullable|date_format:Y-m-d','period_start'=>'present|nullable|date_format:Y-m-d','period_end'=>'present|nullable|date_format:Y-m-d|after_or_equal:period_start','proposed_due_date'=>'present|nullable|date_format:Y-m-d','payment_terms'=>'present|nullable|string|max:1000','lines'=>'required|array|min:1|max:25','lines.*'=>'array:service_id,description,quantity,unit_price,unit','lines.*.service_id'=>'required|integer|min:1','lines.*.description'=>'nullable|string|max:2000','lines.*.quantity'=>'present|nullable|string|max:30','lines.*.unit_price'=>'present|nullable|string|max:30','lines.*.unit'=>'present|nullable|string|max:80','reason'=>'required|string|min:10|max:2000'];abort_if(array_diff(array_keys($v),array_filter(array_keys($rules),fn($k)=>!str_contains($k,'*'))),422);$d=Validator::make($v,$rules)->validate();foreach(['draft_id','version','source_version','preparation_version','client_id']as$k)if($d[$k]!==null)$d[$k]=(int)$d[$k];abort_unless(array_is_list($d['lines']),422);foreach($d['lines']as$i=>$row){$d['lines'][$i]['service_id']=(int)$row['service_id'];foreach(['quantity','unit_price']as$f)$d['lines'][$i][$f]=DraftValues::decimal($row[$f],'lines.'.$i.'.'.$f,$f==='quantity');}foreach([$d['name'],$d['payment_terms'],$d['reason'],...array_column($d['lines'],'description')]as$value)if(is_string($value))abort_if((bool)preg_match('/(?:password|palavra.passe|token|secret|cvv|cvc|pin)\\s*[:=]/iu',$value),422,'Não introduza credenciais ou segredos.');return$d;
}
private function scope(int $actor,array $d):array{
$s=app(MultiLineInvoiceCalculation::class)->scope($actor,$d['draft_id']===null?'billing_drafts.create':'billing_drafts.edit');$m=DB::table('mi_draft_meta')->where('policy',MultiLineInvoiceCalculation::POLICY)->first();abort_unless($m&&$m->composition===\App\Shared\Installation\CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('multiline_invoices.draft_schema_sha256')),503,'Composição de rascunhos não conferida.');return$s;
}
private function basis(int $actor,array $s,array $d):array{
$w=app(ServiceWorkflow::class);$w->scope('clients',$actor,'view');$w->scope('services',$actor,'view');$client=\App\Shared\Context\ModuleRecords::query('a_clients')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['client_id'])->first();abort_unless($client,403);$ids=array_unique(array_column($d['lines'],'service_id'));$services=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->whereIn('id',$ids)->orderBy('id')->get();abort_unless($services->count()===count($ids),403,'Serviço fora do módulo/contexto.');$before=null;$binding=null;
if($d['draft_id']!==null){$binding=DB::table('mi_drafts')->where($s)->where('draft_id',$d['draft_id'])->first();abort_unless($binding,403,'Este editor altera apenas os novos rascunhos multilinha.');$bill=$w->show('billing_drafts',$d['draft_id'],$actor);$source=$w->show('service_drafts',(int)$binding->source_id,$actor);$prepared=app(DocumentPreparation::class)->show('billing_drafts',$d['draft_id'],$actor);abort_unless($bill['version']===$d['version']&&$source['version']===$d['source_version']&&$prepared['version']===$d['preparation_version'],409,'Origem/rascunho/preparação alterados.');abort_if(DB::table('si_invoices')->where('draft_id',$d['draft_id'])->orWhere('source_id',$binding->source_id)->exists(),409,'Documento emitido preservado; não editar a origem por este percurso.');$before=['source'=>$source,'billing'=>$bill,'preparation'=>$prepared];}else abort_unless($d['version']===0&&$d['source_version']===0&&$d['preparation_version']===0,409);
return['client'=>(array)$client,'services'=>$services->map(fn($r)=>(array)$r)->all(),'binding'=>$binding?(array)$binding:null,'before'=>$before];
}
private function write(int $actor,array $s,array $d,array $b,string $key):array{
$w=app(ServiceWorkflow::class);$lines=array_map(function($l){unset($l['unit']);return$l;},$d['lines']);$sourceInput=['name'=>$d['name'],'client_id'=>$d['client_id'],'currency'=>$d['currency'],'lines'=>$lines];if($b['binding'])$sourceInput['version']=$d['source_version'];$source=$w->save('service_drafts',$b['binding']['source_id']??null,$actor,$sourceInput,$key.'-source');$billInput=$b['binding']?['version'=>$d['version'],'reload_source'=>true,'name'=>$d['name']]:['source_id'=>$source['id'],'name'=>$d['name']];$bill=$w->save('billing_drafts',$d['draft_id'],$actor,$billInput,$key.'-billing');$currentPrep=app(DocumentPreparation::class)->show('billing_drafts',$bill['id'],$actor);$prepared=app(DocumentPreparation::class)->save('billing_drafts',$bill['id'],$actor,['version'=>$currentPrep['version'],'draft_version'=>$bill['version'],'currency'=>$d['currency'],'service_date'=>$d['service_date'],'period_start'=>$d['period_start'],'period_end'=>$d['period_end'],'proposed_due_date'=>$d['proposed_due_date'],'payment_terms'=>$d['payment_terms'],'price_mode'=>$d['price_mode'],'units'=>array_column($d['lines'],'unit'),'payment_instruction_ids'=>[]]);
if(!$b['binding'])DB::table('mi_drafts')->insert($s+['draft_id'=>$bill['id'],'source_id'=>$source['id'],'actor_id'=>$actor,'created_at'=>now()->utc()]);return ['type'=>'draft','id'=>$bill['id'],'source_id'=>$source['id'],'version'=>$bill['version'],'source_version'=>$source['version'],'preparation_version'=>$prepared['version'],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0];
}
private function impact(array $d,array $b):array{$lines=$d['lines'];$map=collect($b['services'])->keyBy('id');foreach($lines as&$l)$l['service_name']=$map[$l['service_id']]['name'];unset($l);return['before'=>$b['before']?['source_version'=>$b['before']['source']['version'],'version'=>$b['before']['billing']['version'],'preparation_version'=>$b['before']['preparation']['version']]:null,'after'=>['client'=>$b['client']['name'],'name'=>$d['name'],'currency'=>$d['currency'],'price_mode'=>$d['price_mode'],'lines'=>$lines,'arithmetic_amount'=>DraftValues::base(['lines'=>$lines]),'period_start'=>$d['period_start'],'period_end'=>$d['period_end']],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0];}
public function preview(int $actor,array $v,string $key):array{
$d=$this->input($v);abort_unless(preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$d);return DB::transaction(function()use($actor,$s,$d,$key){DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();$b=$this->basis($actor,$s,$d);DB::beginTransaction();try{$this->write($actor,$s,$d,$b,$key);}finally{DB::rollBack();}$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'basis'=>$this->hash($b),'observed'=>time()];return ['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>$this->impact($d,$b)];});
}
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.');}$d=$this->input($c['input']??[]);$s=$this->scope($actor,$d);return DB::transaction(function()use($actor,$s,$c,$d){DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();$this->scope($actor,$d);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);$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$request=['domain'=>'draft','input'=>$d];$old=DB::table('mi_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($request)),409);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 ou versão alteradas; confira novamente.');$result=$this->write($actor,$s,$d,$b,$c['key']);DB::table('mi_commands')->insert($q+['request_hash'=>$this->hash($request),'input'=>Payables::canonical($request),'result'=>Payables::canonical($result),'created_at'=>now()->utc()]);\App\Modules\Identity\Services\Audit::record('service_invoice.multiline_draft_prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$result['id'],'version'=>$result['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];});
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{DB,Crypt,Validator};
use Tempo\Finance\Payables\Payables;
/** New typed QA capture. Existing one-line calculation and invoice writer retain their contracts. */
final class MultiLineInvoiceCalculation
{
public const POLICY='SGF-MULTI-LINE-INVOICE-INTEGRATED-QA-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
private function exact(array $v,array $r):array{abort_if(array_diff(array_keys($v),array_keys($r)),422);return Validator::make($v,$r)->validate();}
public function scope(int $actor,string $right='billing_drafts.view'):array{
abort_unless(config('multiline_invoices.enabled')===true,404);CompositionRuntime::assert();ServiceInvoicing::guard();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='commercial',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,str_starts_with($right,'service_fiscal.')&&$right!=='service_fiscal.calculate'?null:$s['unit_id']);if(str_starts_with($right,'service_fiscal.'))$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_fiscal.view');$m=DB::table('mi_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('multiline_invoices.schema_sha256')),503,'Captura multilinha não conferida.');return$s;
}
private function capture(int $actor,int $draft,int $event,?int $bound=null,?int $preparer=null):array{
$s=$this->scope($actor);$binding=DB::table('mi_drafts')->where($s)->where('draft_id',$draft)->first();abort_unless($binding,403,'A captura nova exige rascunho criado neste percurso multilinha.');$view=app(DocumentPreview::class)->captureDraftForIssuance($draft,$actor);abort_unless((int)$binding->source_id===$view['source_id'],409);abort_unless(count($view['data']['lines'])>=1&&count($view['data']['lines'])<=25&&$view['data']['currency']==='MZN',422,'Seleccione1–25 linhas em MZN.');abort_unless(!empty($view['prepared']['period_start'])&&!empty($view['prepared']['period_end']),422,'Identifique a cobertura antes do cálculo documental.');$mode=match($view['prepared']['price_mode']??null){'added'=>'net_added','included'=>'gross_included',default=>abort(422,'Modalidade de preço explícita obrigatória.')};$rows=[];foreach($view['data']['lines']as$i=>$line){abort_unless(isset($view['prepared']['units'][$i])&&trim($view['prepared']['units'][$i])!==''&&$line['quantity']!==null&&$line['unit_price']!==null,422,'Quantidade/preço/unidade obrigatórios em cada linha.');$rows[]=['reference'=>'LINE-'.($i+1),'quantity'=>$line['quantity'],'unit_price'=>$line['unit_price']];}
unset($view['consulted_at'],$view['revision'],$view['calculation'],$view['fiscal_history']);$date=now(ServiceInvoicing::ZONE)->format('Y-m-d');$common=app(FiscalRules::class)->invoiceDocument($actor,$s,$event,['currency'=>'MZN','date'=>$date,'price_mode'=>$mode,'lines'=>$rows],$bound);abort_unless($common['result']['state']==='determined_qa',409,implode(' ',$common['result']['pending']));$prepared=DB::table('users')->where('id',$preparer??$actor)->first(['id','name','email']);abort_unless($prepared,403);return ['prepared_identity'=>$this->hash((array)$prepared),'prepared_by'=>$preparer??$actor,'policy'=>self::POLICY,'document'=>$view,'common'=>$common,'expected_date'=>$date,'synthetic_only'=>true];
}
private function input(array $v):array{$d=$this->exact($v,['draft_id'=>'required|integer|min:1','rule_event_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','reason'=>'required|string|min:10|max:2000']);foreach(['draft_id','rule_event_id','draft_version','preparation_version']as$k)$d[$k]=(int)$d[$k];return$d;}
private function lock(array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();}
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,'fiscal_rules.prepare');return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($s);$snapshot=$this->capture($actor,$d['draft_id'],$d['rule_event_id']);$v=$snapshot['document'];abort_unless($v['draft_version']===$d['draft_version']&&$v['preparation_version']===$d['preparation_version'],409,'Versões desactualizadas.');$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'fingerprint'=>$this->hash($snapshot),'observed'=>time()];return ['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>['result'=>$snapshot['common']['result'],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0]];});
}
public function confirm(int $actor,string $preview):array{
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->input($c['input']??[]);$s=$this->scope($actor,'fiscal_rules.prepare');return DB::transaction(function()use($actor,$s,$c,$d){$this->lock($s);$this->scope($actor,'fiscal_rules.prepare');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('mi_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409);return json_decode($old->result,true)+['replayed'=>true];}$snapshot=$this->capture($actor,$d['draft_id'],$d['rule_event_id']);$fingerprint=$this->hash($snapshot);abort_unless(hash_equals($c['fingerprint'],$fingerprint),409,'Origem/regra/versão/data alteradas. Confira novamente.');$existing=DB::table('mi_calculations')->where($s+['draft_id'=>$d['draft_id'],'fingerprint'=>$fingerprint])->first();$id=$existing?->id??DB::table('mi_calculations')->insertGetId($s+['draft_id'=>$d['draft_id'],'actor_id'=>$actor,'fingerprint'=>$fingerprint,'snapshot'=>Payables::canonical($snapshot),'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'draft_id'=>$d['draft_id'],'type'=>'calculation','rule_event_id'=>$d['rule_event_id'],'draft_version'=>$d['draft_version'],'preparation_version'=>$d['preparation_version'],'date'=>$snapshot['expected_date'],'result'=>$snapshot['common']['result'],'policy'=>self::POLICY];DB::table('mi_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>Payables::canonical($d),'result'=>Payables::canonical($result),'created_at'=>now()->utc()]);Audit::record('service_invoice.multiline_calculated',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'reason'=>$d['reason'],'review_hash'=>$fingerprint]);return$result+['replayed'=>false];});
}
public function current(int $actor,int $id,?int $draft=null):array{
$s=$this->scope($actor);$r=DB::table('mi_calculations')->where($s)->where('id',$id)->first();abort_unless($r&&($draft===null||(int)$r->draft_id===$draft),403,'Cálculo fora do rascunho/contexto.');$this->permissions->require((int)$r->actor_id,$s['tenant_id'],$s['company_id'],'fiscal_rules.prepare',$s['unit_id']);abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$r->actor_id])->exists(),403);$snapshot=json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR);$now=$this->capture($actor,(int)$r->draft_id,(int)$snapshot['common']['rule']['id'],$id,(int)$r->actor_id);abort_unless(hash_equals($r->fingerprint,$this->hash($now)),409,'Cálculo anterior à origem/regra/data actual.');return ['id'=>$r->id,'fingerprint'=>$r->fingerprint,'snapshot'=>$snapshot];
}
public static function financialAmounts(array $snapshot):array{
abort_unless(($snapshot['calculation_policy']??null)===self::POLICY&&($snapshot['renderer']??null)===MultiLineInvoicePdf::VERSION,409,'Tipo de fotografia multilinha incompatível.');$c=$snapshot['calculation'];$r=$c['common_document_result'];$lines=$snapshot['document']['data']['lines'];abort_unless(count($lines)>=1&&count($lines)<=25&&($snapshot['document']['data']['currency']??null)==='MZN',409);$in=[];foreach($lines as$i=>$line)$in[]=['reference'=>'LINE-'.($i+1),'quantity'=>$line['quantity'],'unit_price'=>$line['unit_price']];$mode=match($snapshot['document']['prepared']['price_mode']){'added'=>'net_added','included'=>'gross_included',default=>abort(409)};$expected=FiscalDocumentMath::calculate($in,$mode,$r['rate']);$frozen=$r;unset($frozen['currency'],$frozen['rule']);abort_unless(Payables::canonical($expected)===Payables::canonical($frozen)&&$expected['state']==='determined_qa'&&$c['base_document_proposed']===$r['base_document']&&$c['vat_document_proposed']===$r['tax_document']&&$c['total_document_proposed']===$r['total'],409,'Valores documentais por linha não conciliam com a fotografia.');return['base'=>$r['base_document'],'tax'=>$r['tax_document'],'total'=>$r['total']];
}
public function receipt(int $actor,string $key):array{$s=$this->scope($actor);$this->exact(['key'=>$key],['key'=>'required|uuid']);$r=DB::table('mi_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'historical_receipt_only'=>true,'operation_executed'=>false];}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Modules\Services;
use Illuminate\Support\Facades\{DB,Validator};
use Brick\Math\BigDecimal;
/** Typed readiness and photograph only. ServiceInvoicing retains numbering, invoice and receipt writes. */
final class MultiLineInvoiceIssuance
{
public function temporal(int $actor,array $input,array $scope,?array $previous=null):array{
abort_if(array_diff(array_keys($input),['calculation_policy','common_calculation_id','common_rule_event_id','draft_id','draft_version','preparation_version','valid_from','valid_until','period_start','period_end','issue_window_start','issue_window_end','event','readiness_basis','evidence']),422);$d=Validator::make($input,['calculation_policy'=>'required|in:'.MultiLineInvoiceCalculation::POLICY,'common_calculation_id'=>'required|integer|min:1','common_rule_event_id'=>'required|integer|min:1','draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','period_start'=>'required|date_format:Y-m-d','period_end'=>'required|date_format:Y-m-d|after_or_equal:period_start','issue_window_start'=>'required|date_format:Y-m-d','issue_window_end'=>'required|date_format:Y-m-d|after_or_equal:issue_window_start','event'=>'required|in:on_issue_within_deadline','readiness_basis'=>'required|string|min:10|max:4000','evidence'=>'required|string|min:10|max:4000'])->validate();foreach(['common_calculation_id','common_rule_event_id','draft_id','draft_version','preparation_version']as$k)$d[$k]=(int)$d[$k];$calc=app(MultiLineInvoiceCalculation::class)->current($actor,$d['common_calculation_id'],$d['draft_id']);$snapshot=$calc['snapshot'];$view=$snapshot['document'];abort_unless($d['draft_version']===$view['draft_version']&&$d['preparation_version']===$view['preparation_version']&&$d['period_start']===($view['prepared']['period_start']??null)&&$d['period_end']===($view['prepared']['period_end']??null)&&$d['common_rule_event_id']===$snapshot['common']['rule']['id'],409,'A regra temporal não corresponde à captura revista.');if($previous)abort_unless((int)$previous['draft_id']===$d['draft_id']&&($previous['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY,409,'A regra temporal não pode trocar de origem/política.');$d['calculation_fingerprint']=$calc['fingerprint'];return$d;
}
private function proposal(int $actor,int $id,array $s,string $date):array{
abort_unless(app(MultiLineInvoiceCalculation::class)->scope($actor)===$s,403);$r=DB::table('si_proposals')->where($s)->where('id',$id)->first();abort_unless($r,403);$d=json_decode($r->data,true);$approved=DB::table('si_decisions')->where(['proposal_id'=>$r->id,'decision'=>'approved'])->exists()&&$d['valid_from']<=$date&&(!isset($d['valid_until'])||$d['valid_until']===''||$date<=$d['valid_until'])&&!DB::table('si_proposals as n')->where($s)->where(['kind'=>$r->kind,'identifier'=>$r->identifier])->where('version','>',$r->version)->whereRaw("data->>'valid_from'<=?",[$date])->whereExists(fn($q)=>$q->selectRaw('1')->from('si_decisions as d')->whereColumn('d.proposal_id','n.id')->where('d.decision','approved'))->exists();return ['row'=>$r,'data'=>$d,'approved'=>$approved];
}
public function review(int $draft,int $series,int $rule,int $actor,array $s):array{
$date=now(ServiceInvoicing::ZONE)->format('Y-m-d');$p=$this->proposal($actor,$series,$s,$date);$t=$this->proposal($actor,$rule,$s,$date);$d=$t['data'];$blocks=[];abort_unless(($d['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY,422);$calc=app(MultiLineInvoiceCalculation::class)->current($actor,(int)$d['common_calculation_id'],$draft);$snap=$calc['snapshot'];$view=$snap['document'];$common=$snap['common'];
if($p['row']->kind!=='series'||!$p['approved']||($p['data']['state']??null)!=='open'||!str_starts_with($p['data']['code']??'','TEST-MULTI-'))$blocks[]='Série de teste exacta não revista/vigente/aberta.';if($t['row']->kind!=='temporal'||!$t['approved']||(int)$d['draft_id']!==$draft)$blocks[]='Regra temporal exacta não revista/vigente.';
if(($d['calculation_fingerprint']??null)!==$calc['fingerprint']||$d['draft_version']!==$view['draft_version']||$d['preparation_version']!==$view['preparation_version']||$d['period_start']!==($view['prepared']['period_start']??null)||$d['period_end']!==($view['prepared']['period_end']??null))$blocks[]='Origem/versão/período alterados; exige nova conferência.';
if($date<$d['issue_window_start']||$date>$d['issue_window_end'])$blocks[]='Data actual fora da janela de emissão fundamentada.';
foreach(['issuer'=>'Emitente','client'=>'Cliente']as$party=>$label)foreach(['name','nuit','address','locality','country']as$f){$v=$f==='name'?($view[$party]['name']??''):($view[$party]['data'][$f]??'');if(!is_string($v)||trim($v)==='')$blocks[]=$label.': '.$f.' por completar.';}
if(DB::table('si_invoices')->where('draft_id',$draft)->orWhere('source_id',$view['source_id'])->exists())$blocks[]='Já existe documento desta origem.';
$r=$common['result'];try{$minor=BigDecimal::of($r['total'])->multipliedBy(100)->toInt();if($minor<=0)$blocks[]='Documento financeiro exige total positivo.';if(BigDecimal::of($r['base_document'])->isLessThanOrEqualTo(0)||BigDecimal::of($r['tax_document'])->isLessThanOrEqualTo(0))$blocks[]='O plano contabilístico QA de três linhas exige base e imposto positivos.';}catch(\Brick\Math\Exception\MathException){$blocks[]='Montante fora da representação contabilística inteira.';}$def=$common['rule'];$rate=(string)BigDecimal::of($def['definition']['parameters']['rate'])->multipliedBy(100)->stripTrailingZeros();$result=['policy'=>MultiLineInvoiceCalculation::POLICY,'currency'=>'MZN','state'=>'synthetic_proposed','price_mode'=>$view['prepared']['price_mode'],'base_exact'=>$r['base_exact_fraction'],'base_document_proposed'=>$r['base_document'],'vat_exact'=>$r['tax_exact_fraction'],'vat_document_proposed'=>$r['tax_document'],'discarded_difference'=>$r['tax_difference_fraction'],'total_document_proposed'=>$r['total'],'common_document_result'=>$r,'common_rule'=>$def,'pending'=>[]];$profile=['kind'=>'synthetic_common_rule','rate_percent'=>$rate,'valid_from'=>$def['definition']['valid_from'],'valid_until'=>$def['definition']['valid_until'],'source_sha256'=>$def['definition']['source_sha256']];
$entry=['id'=>$calc['id'],'snapshot'=>['input'=>['policy'=>MultiLineInvoiceCalculation::POLICY,'profile_identifier'=>$def['rule_key'],'profile_version'=>$def['rule_version'],'profile'=>$profile,'common_capture'=>$snap],'result'=>$result]];
$payload=['calculation_policy'=>MultiLineInvoiceCalculation::POLICY,'draft_id'=>$draft,'series_id'=>$series,'series_version'=>(int)$p['row']->version,'series_code'=>$p['data']['code'],'series_definition'=>$p['data'],'rule_id'=>$rule,'rule_version'=>(int)$t['row']->version,'rule'=>$d,'expected_date'=>$date,'document'=>$view,'calculation_entry'=>$entry,'profile'=>$profile,'blocks'=>$blocks];
if(!$blocks){$sample=app(ServiceInvoicing::class)->multiLineSnapshot($payload,0,now()->toIso8601String());if(!InvoicePdf::supports(MultiLineInvoicePdf::lines($sample)))$payload['blocks'][]='Texto não representável no PDF sem perda; confira os dados.';}
return ['ready'=>$payload['blocks']===[],'notice'=>ServiceInvoicing::NOTICE,'payload'=>$payload,'review_hash'=>hash_hmac('sha256',json_encode($payload,JSON_THROW_ON_ERROR),(string)config('app.key'))];
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Services;
final class MultiLineInvoicePdf
{
public const VERSION='sgf-test-invoice-multiline-text-v1';
public static function lines(array $s):array{
$c=$s['calculation'];$r=$c['common_document_result'];$p=$s['document']['prepared'];$rows=[['title','Factura de servicos - TESTE QA'],['text',$s['series_code'].' / '.$s['number']],['text','Emissao: '.$s['issued_local'].' ('.$s['timezone'].')'],['text',ServiceInvoicing::NOTICE]];
foreach(['issuer'=>'Emitente','client'=>'Cliente']as$k=>$label){$party=$s['document'][$k];$rows[]=['section',$label];$rows[]=['text',$party['name']];foreach(['nuit'=>'NUIT','address'=>'Morada','locality'=>'Localidade','country'=>'Pais']as$f=>$n)$rows[]=['text',$n.': '.($party['data'][$f]??'Nao indicado')];}
$rows[]=['section','Linhas do documento'];$rows[]=['text','Modalidade: '.($r['price_mode']==='gross_included'?'preco com imposto incluido':'preco sem imposto, acrescido')];
foreach($s['document']['data']['lines']as$i=>$line){$v=$r['lines'][$i];$rows[]=['section','Linha '.($i+1).' - '.$line['service_name']];if(!empty($line['description']))$rows[]=['text',$line['description']];$rows[]=['text','Quantidade '.$line['quantity'].' | Unidade '.$p['units'][$i].' | Preco '.$line['unit_price']];$rows[]=['text','Base documental '.$v['base_document'].' | Imposto '.$v['tax_document'].' | Total '.$v['total_document']];$rows[]=['text','Base exacta '.$v['base_exact_fraction'].' | Imposto exacto '.$v['tax_exact_fraction']];$rows[]=['text','Diferenca base '.$v['base_difference_fraction'].' | Diferenca imposto '.$v['tax_difference_fraction']];}
$rows[]=['section','Totais MZN - soma dos valores documentais por linha'];$rows[]=['text','Base '.$r['base_document'].' | Imposto '.$r['tax_document'].' | Total '.$r['total']];$rows[]=['text','Regra QA '.$s['profile_identifier'].' v'.$s['profile_version'].' | Taxa artificial '.$s['rate_percent'].'%'];$rows[]=['text','Politica '.$r['policy']];$rows[]=['text','Periodo '.$p['period_start'].' a '.$p['period_end']];$rows[]=['text','Condicoes: '.($p['payment_terms']??'Nao indicadas')];$rows[]=['text','Vencimento proposto: '.($p['proposed_due_date']??'Nao indicado')];$rows[]=['text','Origem: rascunho #'.$s['draft_id'].' v'.$s['document']['draft_version'].' | Regra temporal #'.$s['rule_id'].' v'.$s['rule_version']];$rows[]=['text','Emissao nao comprova recebimento ou aplicacao contabilistica. Sem comunicacao a AT.'];return$rows;
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Modules\Services;
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use App\Modules\Identity\Services\Permissions;
use Illuminate\Support\Facades\DB;
/** Qualified new dependencies keep Commercial ownership and require existing target-module rights. */
final class QualifiedInvoiceSource
{
public static function query(){
$legacy=ModuleRecords::query('si_invoices');$module=ModuleRecords::active();if(config('multiline_invoices.enabled')!==true||!in_array($module,['finance','accounting'],true))return$legacy;CompositionRuntime::assert();$c=app(CompanyContext::class);$s=$c->identifiers()+['unit_id'=>$c->unitId()];$actor=$c->userId();if(!$s['unit_id']||!app(Permissions::class)->has($actor,$s['tenant_id'],$s['company_id'],$module==='finance'?'service_receivables.view':'general_ledger.view',$s['unit_id']))return$legacy;
$owned=DB::table('a_billing_drafts');ModuleRecords::constrain($owned,'a_billing_drafts','billing_drafts','commercial');$owned->where($s);
return DB::table('si_invoices')->where($s)->where(function($q)use($legacy,$s,$owned){$q->whereIn('id',$legacy->select('id'))->orWhere(function($q)use($s,$owned){$q->whereRaw("si_invoices.snapshot::jsonb->>'calculation_policy'=?",[MultiLineInvoiceCalculation::POLICY])->whereExists(function($m)use($s,$owned){$m->selectRaw('1')->from('mi_drafts as md')->whereColumn('md.draft_id','si_invoices.draft_id')->whereColumn('md.source_id','si_invoices.source_id')->where(['md.tenant_id'=>$s['tenant_id'],'md.company_id'=>$s['company_id'],'md.unit_id'=>$s['unit_id']])->whereIn('md.draft_id',$owned->select('id'));});});});
}
}
+11 -5
View File
@@ -68,6 +68,9 @@ final class ServiceInvoicing
abort_unless($d['code']===$in['identifier'],422,'Identidade da série deve coincidir com o código sintético.');
if($previous) { $old=$this->data($previous); abort_unless($old['code']===$d['code'] && (int)$old['first_number']===(int)$d['first_number'],422,'Não reiniciar ou transferir contador existente.'); }
$d['first_number']=(int)$d['first_number'];
} elseif(($in['data']['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY){
$d=app(MultiLineInvoiceIssuance::class)->temporal($actor,$in['data'],$s,$previous?$this->data($previous):null);
abort_if(DB::table('si_proposals')->where($s)->where('kind','temporal')->whereRaw("data->>'draft_id' = ?",[(string)$d['draft_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade temporal existente.');
} else {
$d=$this->exact($in['data'],$common+['draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','profile_proposal_id'=>'required|integer|min:1','period_start'=>'required|date_format:Y-m-d','period_end'=>'required|date_format:Y-m-d|after_or_equal:period_start','issue_window_start'=>'required|date_format:Y-m-d','issue_window_end'=>'required|date_format:Y-m-d|after_or_equal:issue_window_start','event'=>'required|in:on_issue_within_deadline','readiness_basis'=>'required|string|max:4000']);
$view=$this->preview->captureDraftForIssuance((int)$d['draft_id'],$actor);
@@ -90,6 +93,7 @@ final class ServiceInvoicing
$d=$this->exact($input,['version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|max:4000']);
$p=$this->row($id,$s); abort_unless((int)$p->version===(int)$d['version'] && (int)$p->actor_id!==$actor,403,'Validador distinto e versão exacta obrigatórios.');
abort_if($this->decision($id),409,'Decisão imutável; correcção exige nova versão.');
$mi=$this->data($p);if(($mi['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY){$fingerprint=$mi['calculation_fingerprint'];unset($mi['calculation_fingerprint']);$fresh=app(MultiLineInvoiceIssuance::class)->temporal($actor,$mi,$s);abort_unless(hash_equals($fingerprint,$fresh['calculation_fingerprint']),409,'A captura mudou; confira nova proposta.');}
DB::table('si_decisions')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$d['decision'],'reason'=>$d['reason'],'created_at'=>now()]);
Audit::record('service_invoice.decided',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$p->version,'reason'=>$d['reason']]);
return ['id'=>$id,'decision'=>$d['decision']];
@@ -101,7 +105,7 @@ final class ServiceInvoicing
if(!ServiceCalculation::isolated()) return ['real_blocked'=>true,'test_enabled'=>false,'notice'=>'Emissão real bloqueada: conformidade ainda não demonstrada.','proposals'=>[],'invoices'=>[]];
TestingDatabaseGuard::assertSafe();
if(!Schema::hasTable('si_invoices')) return ['real_blocked'=>true,'notice'=>'Migração documental ainda não aplicada.','proposals'=>[],'invoices'=>[]];
return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>QualifiedInvoiceSource::query()->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
}
public function review(int $draft,int $series,int $rule,int $actor): array
{
@@ -115,6 +119,7 @@ final class ServiceInvoicing
}
private function reviewInside(int $draft,int $series,int $rule,int $actor,array $s): array
{
$mi=$this->data($this->row($rule,$s));if(($mi['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY)return app(MultiLineInvoiceIssuance::class)->review($draft,$series,$rule,$actor,$s);
$view=$this->preview->captureDraftForIssuance($draft,$actor); $date=now(self::ZONE)->format('Y-m-d'); $blocks=[];
$p=$this->row($series,$s); $t=$this->row($rule,$s); $sd=$this->data($p); $td=$this->data($t);
if($p->kind!=='series' || !$this->approved($p,$s,$date) || ($sd['state'] ?? null)!=='open') $blocks[]='Série sintética sem configuração aprovada/vigente e aberta.';
@@ -147,11 +152,12 @@ final class ServiceInvoicing
}
private function snapshot(array $p,int $number,string $instant): array
{
return ['notice'=>self::NOTICE,'renderer'=>InvoicePdf::VERSION,'draft_id'=>$p['draft_id'],'series_code'=>$p['series_code'],'number'=>$number,'issued_at'=>$instant,'issued_local'=>\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d H:i:s'),'timezone'=>self::ZONE,
return ['notice'=>self::NOTICE,'renderer'=>isset($p['calculation_policy'])?MultiLineInvoicePdf::VERSION:InvoicePdf::VERSION,'draft_id'=>$p['draft_id'],'series_code'=>$p['series_code'],'number'=>$number,'issued_at'=>$instant,'issued_local'=>\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d H:i:s'),'timezone'=>self::ZONE,
'series_id'=>$p['series_id'],'series_version'=>$p['series_version'],'series_definition'=>$p['series_definition'],'rule_id'=>$p['rule_id'],'rule_version'=>$p['rule_version'],'rule'=>$p['rule'],'document'=>$p['document'],
'calculation'=>array_replace($p['calculation_entry']['snapshot']['result'],['temporal_state'=>'issue_simulated','exigibility_date'=>$p['expected_date'],'assessment_period'=>substr($p['expected_date'],0,7),'pending'=>[]]),'prior_calculation_snapshot'=>$p['calculation_entry']['snapshot'],'calculation_id'=>$p['calculation_entry']['id'],'profile_identifier'=>$p['calculation_entry']['snapshot']['input']['profile_identifier'],'profile_version'=>$p['calculation_entry']['snapshot']['input']['profile_version'],'profile'=>$p['profile'],'rate_percent'=>$p['profile']['rate_percent'],
'exigibility_date'=>$p['expected_date'],'assessment_period'=>substr($p['expected_date'],0,7),'simulation_only'=>true];
}
public function multiLineSnapshot(array $payload,int $number,string $instant):array{abort_unless(($payload['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY,422);return ['calculation_policy'=>MultiLineInvoiceCalculation::POLICY]+$this->snapshot($payload,$number,$instant);}
public function issue(int $draft,int $actor,array $input,string $key): array
{
return $this->command($actor,'service_invoices.issue',function($s)use($draft,$actor,$input,$key) {
@@ -170,7 +176,7 @@ final class ServiceInvoicing
DB::table('si_counters')->where('id',$counter->id)->update(['last_number'=>$number]);
$instant=now()->toIso8601String();
abort_unless(\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d')===$p['expected_date'],409,'Data mudou durante a confirmação; reveja antes de emitir.');
$snapshot=$this->snapshot($p,$number,$instant);
$snapshot=isset($p['calculation_policy'])?$this->multiLineSnapshot($p,$number,$instant):$this->snapshot($p,$number,$instant);
$snapshot['document_lines']=InvoicePdf::lines($snapshot); // Freeze browser representation as well as PDF bytes.
$bytes=$this->pdf->render($snapshot);
$id=\App\Shared\Context\ModuleRecords::query('si_invoices')->insertGetId($s+['draft_id'=>$draft,'source_id'=>$p['document']['source_id'],'series_id'=>$p['series_id'],'rule_id'=>$p['rule_id'],'series_code'=>$sd['code'],'number'=>$number,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'pdf_base64'=>base64_encode($bytes),'pdf_sha256'=>hash('sha256',$bytes),'actor_id'=>$actor,'issued_at'=>$instant]);
@@ -185,13 +191,13 @@ final class ServiceInvoicing
public function show(int $id,int $actor): array
{
self::guard(); $s=$this->scope($actor,'service_invoices.view');
$r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
$r=QualifiedInvoiceSource::query()->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
$snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR);
return ['id'=>$r->id,'snapshot'=>$snapshot,'document_lines'=>$snapshot['document_lines'] ?? InvoicePdf::lines($snapshot),'pdf_sha256'=>$r->pdf_sha256,'corrections'=>DB::table('si_correction_requests')->where('invoice_id',$id)->get(['id','reason','actor_id','created_at'])->all()];
}
public function pdf(int $id,int $actor): string
{
$this->show($id,$actor); $r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where('id',$id)->first(['pdf_base64','pdf_sha256']);
$this->show($id,$actor); $r=QualifiedInvoiceSource::query()->where('id',$id)->first(['pdf_base64','pdf_sha256']);
$bytes=base64_decode($r->pdf_base64,true); abort_unless($bytes!==false && hash_equals($r->pdf_sha256,hash('sha256',$bytes)),409,'Integridade do PDF não confirmada.'); return $bytes;
}
public function requestCorrection(int $id,int $actor,array $input): array
+2 -2
View File
@@ -90,7 +90,7 @@ final class ServiceReceivables
$right=$kind==='correction'?'correction_request':'prepare';
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation');
return $this->command($actor,$right,$key,$d,function($s)use($actor,$d,$kind) {
$inv=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$d['invoice_id'])->first();
$inv=QualifiedInvoiceSource::query()->where($s)->where('id',$d['invoice_id'])->first();
abort_unless($inv,403,'Documento de origem não autorizado.');
$snapshot=json_decode($inv->snapshot,true,32,JSON_THROW_ON_ERROR);
abort_unless(($snapshot['simulation_only']??false)===true && ($snapshot['notice']??'')===self::NOTICE && ($snapshot['document']['data']['currency']??'')==='MZN',422,'Origem sintética compatível necessária.');
@@ -252,7 +252,7 @@ final class ServiceReceivables
return DB::transaction(function()use($s) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$records=DB::table('sr_records')->where($s)->orderBy('id')->get();
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>QualifiedInvoiceSource::query()->where($s)->get(['id','series_code','number'])->all()];
});
}
/** Scoped single-position read reuses the authoritative state calculation without writer locks. */