Files
SGF/app/Modules/Services/ServiceCalculation.php
T

129 lines
9.8 KiB
PHP

<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
final class ServiceCalculation
{
public const POLICY = 'SGF-SERVICE-NET-VAT-TRUNCATE2-v1';
public const SOURCES = ['InvesteMoz/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md@v1.2', 'Lei22/2022-republicada:17(1)', 'CIVA:15,7,8,17(2)'];
public function __construct(private ServiceWorkflow $workflow, private DocumentPreparation $preparation, private CompanyContext $context) {}
public static function isolated(): bool { return app()->environment(['testing','browser-testing']); }
public static function evaluateReal(array $data, array $prepared, array $profile): array
{
abort_unless(($profile['kind'] ?? null)==='real',422,'Perfil real identificado obrigatório.');
// Shared decimal arithmetic; authority/applicability is checked by ServiceFiscal before calling.
$result=self::evaluate($data,$prepared,array_replace($profile,['kind'=>'synthetic']));
if ($result['state']!=='pending') $result['state']='real_proposed_conditioned';
return $result;
}
public static function evaluate(array $data, array $prepared, ?array $profile): array
{
$pending = [];
if (count($data['lines'] ?? []) !== 1) { $pending[] = 'Apenas uma linha de serviço é suportada.'; }
if (($prepared['price_mode'] ?? null) !== 'added') { $pending[] = 'É necessário preço sem imposto (acrescido); incluído fica fora deste âmbito.'; }
if (!preg_match('/^[A-Z]{3}$/D', $data['currency'] ?? '')) { $pending[] = 'Moeda explícita em falta.'; }
if (!$profile || ($profile['kind'] ?? null) !== 'synthetic' || ($profile['policy'] ?? null) !== self::POLICY) { $pending[] = 'Perfil de ensaio e política identificados em falta ou incompatíveis.'; }
if (!preg_match('/^\d{1,3}(\.\d{1,8})?$/D', $profile['rate_percent'] ?? '') || BigDecimal::of($profile['rate_percent'] ?? '0')->isLessThanOrEqualTo(0)) { $pending[] = 'Taxa positiva identificada em falta.'; }
if (($profile['currency'] ?? null) !== ($data['currency'] ?? null)) { $pending[] = 'Moeda incompatível com o perfil.'; }
$line = $data['lines'][0] ?? [];
foreach (['quantity','unit_price'] as $field) {
if (!is_string($line[$field] ?? null) || !preg_match('/^\d{1,16}(\.\d{1,8})?$/D', $line[$field])) { $pending[] = 'Quantidade/preço ausente ou fora dos limites exactos de A.'; }
}
if (!$pending && BigDecimal::of($line['quantity'])->isLessThanOrEqualTo(0)) { $pending[] = 'Quantidade deve ser positiva.'; }
if ($pending) { return ['state' => 'pending', 'pending' => array_values(array_unique($pending)), 'policy' => self::POLICY]; }
$base = BigDecimal::of($line['quantity'])->multipliedBy($line['unit_price']);
$exact = $base->multipliedBy($profile['rate_percent'])->multipliedBy('0.01');
$document = $exact->toScale(2, RoundingMode::DOWN);
$representable = $base->stripTrailingZeros()->getScale() <= 2;
return ['state' => $representable ? 'synthetic_proposed' : 'synthetic_base_representation_pending', 'policy' => self::POLICY,
'base_exact' => (string) $base->stripTrailingZeros(), 'vat_exact' => (string) $exact->stripTrailingZeros(),
'vat_document_proposed' => (string) $document, 'discarded_difference' => (string) $exact->minus($document)->stripTrailingZeros(),
'total_document_proposed' => $representable ? (string) $base->plus($document)->toScale(2) : null,
'currency' => $data['currency'], 'pending' => $representable ? [] : ['Política de representação da base/total pendente; base não truncada.'],
'fiscally_validated' => false, 'policy_basis' => 'Decisão operacional do utilizador; não obrigação legal comprovada.'];
}
private function sources(int $draft, int $actor): array
{
$billing = $this->workflow->show('billing_drafts', $draft, $actor);
abort_if($billing['internal_confirmation'], 409, 'Fotografia B1 preservada; cálculo não altera confirmações.');
$prepared = $this->preparation->show('billing_drafts', $draft, $actor);
return [$billing, $prepared];
}
private function input(array $b, array $p, object $profile): array
{
return ['draft_id' => (int) $b['id'], 'draft_version' => $b['version'], 'preparation_version' => $p['version'],
'data' => $b['data'], 'prepared' => $p['data'], 'profile_id' => (int) $profile->id,
'profile_version' => (int) $profile->version, 'profile_identifier' => $profile->identifier,
'profile' => json_decode($profile->definition, true, 32, JSON_THROW_ON_ERROR), 'policy' => self::POLICY,
'source_current_version' => (int) DB::table('a_service_drafts')->where('id',$b['source_id'])->where('tenant_id',$b['tenant_id'])->where('company_id',$b['company_id'])->where('unit_id',$b['unit_id'])->value('version')];
}
private function fingerprint(array $input): string { return hash('sha256', json_encode($input, JSON_THROW_ON_ERROR)); }
public function calculate(int $draft, int $actor, array $command): array
{
// Test-only command is deliberately not a fiscal permission or real profile activation path.
abort_unless(self::isolated(), 403, 'Cálculo de ensaio restrito ao ambiente isolado de testes.');
TestingDatabaseGuard::assertSafe();
$scope = $this->workflow->scope('billing_drafts', $actor, 'edit');
return DB::transaction(function () use ($draft,$actor,$command,$scope) {
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$this->workflow->scope('billing_drafts', $actor, 'edit');
[$b,$p] = $this->sources($draft,$actor);
abort_unless($b['version'] === $command['draft_version'] && $p['version'] === $command['preparation_version'], 409, 'Versões desactualizadas.');
abort_unless(($p['data']['draft_version'] ?? null) === $b['version'], 409, 'Reveja a preparação após alterar o rascunho.');
$profile = DB::table('service_calculation_profiles')->where($scope)->where('id', $command['profile_id'])->first();
abort_unless($profile, 403, 'Perfil fora do âmbito autorizado.');
$latest = DB::table('service_calculation_profiles')->where($scope)->where('identifier', $profile->identifier)->max('version');
abort_unless((int) $latest === (int) $profile->version && (int) $profile->version === $command['profile_version'], 409, 'Perfil desactualizado.');
$input = $this->input($b,$p,$profile);
abort_unless($input['source_current_version'] === $b['data']['source_version'], 409, 'Prestação de origem desactualizada.');
$result = self::evaluate($b['data'],$p['data'],$input['profile']);
abort_if($result['state'] === 'pending', 422, implode(' ', $result['pending']));
$hash = $this->fingerprint($input);
$old = DB::table('service_calculation_results')->where('billing_draft_id',$draft)->where('fingerprint',$hash)->first();
if ($old) { return ['id' => $old->id, 'replayed' => true]; }
$id = DB::table('service_calculation_results')->insertGetId($scope + ['billing_draft_id'=>$draft, 'profile_id'=>$profile->id, 'actor_id'=>$actor,
'fingerprint'=>$hash, 'snapshot'=>json_encode(['input'=>$input,'result'=>$result],JSON_THROW_ON_ERROR), 'created_at'=>now()]);
Audit::record('service_calculation.synthetic_created',$actor,$scope['tenant_id'],$scope['company_id'],$scope['unit_id'],details:['result_id'=>$id,'policy'=>self::POLICY]);
return ['id'=>$id,'replayed'=>false];
});
}
public function read(int $draft, int $actor): array
{
$scope = $this->workflow->scope('billing_drafts',$actor,'view');
if (!self::isolated()) { return ['environment'=>'normal','history'=>[], 'pending'=>['O cálculo proposto requer perfil aprovado, activo e compatível e decisão temporal aprovada. Consulte o resultado e as pendências abaixo, quando disponíveis.']]; }
TestingDatabaseGuard::assertSafe();
[$b,$p] = $this->sources($draft,$actor);
$profiles = DB::table('service_calculation_profiles')->where($scope)->whereRaw("definition->>'kind' = ?",['synthetic'])->orderByDesc('version')->get();
$latest = []; foreach ($profiles as $profile) { $latest[$profile->identifier] ??= $profile; }
$history = [];
foreach (DB::table('service_calculation_results')->where($scope)->where('billing_draft_id',$draft)->orderByDesc('id')->get() as $row) {
$snapshot = json_decode($row->snapshot,true,32,JSON_THROW_ON_ERROR);
if (isset($snapshot['input']['profile_proposal_id'])) continue;
$profile = $latest[$snapshot['input']['profile_identifier']] ?? null;
$current = $profile && hash_equals($row->fingerprint,$this->fingerprint($this->input($b,$p,$profile)));
$history[] = ['id'=>$row->id,'current'=>(bool)$current,'snapshot'=>$snapshot];
}
return ['environment'=>'synthetic_test_only','policy'=>self::POLICY,'history'=>$history,
'can_calculate'=>app(\App\Modules\Identity\Services\Permissions::class)->has($actor,$scope['tenant_id'],$scope['company_id'],'billing_drafts.edit',$scope['unit_id']),
'profiles'=>array_map(fn ($r)=>['id'=>$r->id,'version'=>(int)$r->version,'identifier'=>$r->identifier],array_values($latest)),
'draft_version'=>$b['version'],'preparation_version'=>$p['version'],
'pending'=>['ENSAIO SINTÉTICO — não valida regime, base tributável, data fiscal ou emissão da empresa real.']];
}
}