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.']]; } }