Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7b70459d |
@@ -19,13 +19,15 @@ class SimulationTaxParameterController extends Controller
|
||||
$input['period_start'],
|
||||
$input['period_end'],
|
||||
$input['context'] ?? [],
|
||||
$input['component'] === 'IVA',
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'component' => $input['component'],
|
||||
'period' => ['start' => $input['period_start'], 'end' => $input['period_end']],
|
||||
'assumptions' => $input['context'] ?? [],
|
||||
'parameters' => $data,
|
||||
'parameters' => $input['component'] === 'IVA' ? $data['parameters'] : $data,
|
||||
'diagnostics' => $input['component'] === 'IVA' ? $data['diagnostics'] : [],
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class ResolveSimulationTaxParametersRequest extends FormRequest
|
||||
'component' => ['required', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
|
||||
'period_start' => ['required', 'date_format:Y-m-d'],
|
||||
'period_end' => ['required', 'date_format:Y-m-d', 'after_or_equal:period_start'],
|
||||
'context' => ['sometimes', 'array:fiscal_periodicity,fiscal_operation,fiscal_residency_assessment,fiscal_residency_country,fiscal_income_category,fiscal_establishments,fiscal_documentation,fiscal_customs_code,fiscal_vat_refund_state'],
|
||||
'context' => ['sometimes', 'array:currency,fiscal_periodicity,fiscal_operation,fiscal_residency_assessment,fiscal_residency_country,fiscal_income_category,fiscal_establishments,fiscal_documentation,fiscal_customs_code,fiscal_vat_refund_state'],
|
||||
'context.*' => ['string', 'max:160'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'environmental_category', 'community_benefits', 'tax_calculation', 'fiscal_scenario_mode', 'base', 'rate', 'reduction',
|
||||
'sales_taxable', 'sales_exempt', 'sales_zero_rate', 'exports', 'deductible_costs', 'non_deductible_costs',
|
||||
'carried_losses', 'payroll', 'outbound_payments', 'imports', 'claimed_benefits', 'reference_date',
|
||||
'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment',
|
||||
'fiscal_currency', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment',
|
||||
'fiscal_operation', 'fiscal_activity', 'fiscal_establishments', 'fiscal_documentation',
|
||||
'fiscal_customs_code', 'fiscal_vat_refund_state',
|
||||
'offer_or_demand', 'opportunity_type', 'opportunity_type_other', 'opportunity_description',
|
||||
@@ -279,7 +279,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'currency', 'funds_origin_countries', 'environmental_category', 'tax_calculation', 'fiscal_scenario_mode', 'offer_or_demand',
|
||||
'opportunity_type', 'confidentiality', 'publish_requested', 'terms_accepted', 'truth_declaration',
|
||||
'reuse_simulation', 'contact_authorisation', 'publication_authorisation', 'public_fields_confirmation', 'validation_acknowledged',
|
||||
'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment', 'fiscal_operation',
|
||||
'fiscal_currency', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment', 'fiscal_operation',
|
||||
'fiscal_documentation', 'fiscal_vat_refund_state'],
|
||||
'personal_snapshot.selection_labels' => ['person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage', 'legal_form', 'preferred_channel'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/** Explains a failed selection; never changes answers, eligibility or calculation. */
|
||||
final class SimulationIvaDiagnostics
|
||||
{
|
||||
public function explain(array $versions, string $start, string $end, array $context): array
|
||||
{
|
||||
$diagnostics = [];
|
||||
foreach ($versions as $version) {
|
||||
$parameter = $version['parameters']['IVA_GENERAL_RATE'] ?? null;
|
||||
if (! is_array($parameter)) {
|
||||
continue;
|
||||
}
|
||||
$issues = [];
|
||||
$add = function (string $category, ?string $field, string $label, mixed $received, string $expected, string $message) use (&$issues): void {
|
||||
$issues[] = compact('category', 'field', 'label', 'received', 'expected', 'message');
|
||||
};
|
||||
if ($start < $version['valid_from'] || ($version['valid_until'] !== null && $end > $version['valid_until'])) {
|
||||
$add('period_unavailable', 'fiscal_period', 'Período', "$start a $end", $version['valid_from'].' a '.($version['valid_until'] ?? 'sem data final indicada'), 'Esta versão da regra não abrange todo o período seleccionado. Não altere a data real da operação para obter um resultado.');
|
||||
}
|
||||
foreach ($parameter['conditions'] ?? [] as $key => $expected) {
|
||||
$received = $context[$key] ?? null;
|
||||
if ($received === $expected) {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'currency') {
|
||||
$category = $received === null || $received === '' ? 'missing_field' : (preg_match('/^[A-Z]{3}$/D', $received) ? 'out_of_scope' : 'invalid_value');
|
||||
$add($category, 'fiscal_currency', 'Moeda', $received, $expected, $category === 'missing_field' ? 'Indique a moeda da operação.' : 'Esta versão aceita apenas '.$expected.'. Reveja a moeda ou utilize um cálculo adequado à operação; a resposta não foi alterada.');
|
||||
} elseif ($key === 'operation' && ! array_key_exists('operation', $context) && $expected === 'internal_general_net' && in_array($context['fiscal_operation'] ?? null, ['import', 'export', 'non_resident', 'refund'], true)) {
|
||||
$add('out_of_scope', 'fiscal_operation', 'Operação', $context['fiscal_operation'], 'Operação interna à taxa geral, sobre base líquida já apurada', 'A operação declarada está excluída desta versão. Utilize um cálculo adequado à operação; não altere a resposta para obter um resultado.');
|
||||
} elseif ($key === 'operation' && ! array_key_exists('operation', $context)) {
|
||||
$add('system_configuration', 'fiscal_operation', 'Operação', $context['fiscal_operation'] ?? null, $expected === 'internal_general_net' ? 'Operação interna à taxa geral, sobre base líquida já apurada' : $expected, 'Limitação de integração do sistema: esta consulta não comunica o enquadramento específico exigido pela versão. A resposta genérica não permite inferi-lo. Utilize o formulário IVA — taxa geral, com declaração explícita do âmbito; não altere respostas para forçar compatibilidade.');
|
||||
} elseif ($key === 'operation') {
|
||||
$add($received === null || $received === '' ? 'missing_field' : 'out_of_scope', 'fiscal_operation', 'Operação', $received, $expected === 'internal_general_net' ? 'Operação interna à taxa geral, sobre base líquida já apurada' : $expected, 'A operação não está abrangida por esta versão; utilize um cálculo adequado à operação declarada.');
|
||||
} else {
|
||||
$add('system_configuration', null, 'Configuração da regra', null, 'Condições publicadas ligadas a campos deste formulário', 'O sistema não consegue relacionar todas as condições desta versão com o formulário. Não é uma falha das suas respostas.');
|
||||
}
|
||||
}
|
||||
if (! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|
||||
|| ($version['source_version_no'] ?? null) !== ($version['source']['version_no'] ?? null)
|
||||
|| ($version['rule_definition']['language'] ?? null) !== 'investmoz.tax.v1') {
|
||||
$add('system_configuration', null, 'Regra', null, 'Versão publicada com fonte e configuração coerentes', 'A configuração ou a fonte da regra não permite resolver este parâmetro. Não altere as respostas; é necessária correcção do sistema.');
|
||||
}
|
||||
if ($issues !== []) {
|
||||
$source = array_intersect_key($version['source'] ?? [], array_flip(['title', 'diploma', 'article', 'url']));
|
||||
$diagnostics[] = ['label' => 'IVA — taxa geral', 'rule_version' => $version['rule_version'], 'validity' => ['start' => $version['valid_from'], 'end' => $version['valid_until']], 'source' => $source, 'issues' => $issues];
|
||||
}
|
||||
}
|
||||
|
||||
return $diagnostics;
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,9 @@ final class SimulationTaxParameterResolver
|
||||
* never executes a tax formula and requires each parameter to declare context.
|
||||
*
|
||||
* @param array<string,string> $context
|
||||
* @return list<array<string,mixed>>
|
||||
* @return list<array<string,mixed>>|array{parameters:list<array<string,mixed>>,diagnostics:list<array<string,mixed>>}
|
||||
*/
|
||||
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context): array
|
||||
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context, bool $diagnostics = false): array
|
||||
{
|
||||
$start = $this->date($periodStart);
|
||||
$end = $this->date($periodEnd);
|
||||
@@ -67,14 +67,14 @@ final class SimulationTaxParameterResolver
|
||||
->join('investment_legal_sources as source', 'source.id', '=', 'rule.legal_source_id')
|
||||
->where('rule.component', $component)
|
||||
->where('rule.status', 'published')
|
||||
->whereDate('rule.valid_from', '<=', $periodEnd)
|
||||
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart))
|
||||
->when(! $diagnostics, fn ($query) => $query->whereDate('rule.valid_from', '<=', $periodEnd)
|
||||
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart)))
|
||||
->where(fn ($query) => $query->where('source.validation_status', 'validated')->orWhere(fn ($fiscal) => $fiscal->where('source.validation_status', 'pending')->where('source.fiscal_acceptance->scope', 'fiscal_parameters')
|
||||
->where('source.fiscal_acceptance->basis', 'Tempo-official-sources-20261008')
|
||||
->whereRaw("source.fiscal_acceptance->>'sha256'=rule.source_sha256")))
|
||||
->whereColumn('source.version_no', 'rule.source_version_no')
|
||||
->whereDate('source.valid_from', '<=', $periodStart)
|
||||
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd))
|
||||
->when(! $diagnostics, fn ($query) => $query->whereDate('source.valid_from', '<=', $periodStart)
|
||||
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd)))
|
||||
->select([
|
||||
'rule.id', 'rule.rule_code', 'rule.rule_version', 'rule.component', 'rule.scope_key',
|
||||
'rule.valid_from', 'rule.valid_until', 'rule.parameters', 'rule.rule_definition',
|
||||
@@ -121,7 +121,11 @@ final class SimulationTaxParameterResolver
|
||||
];
|
||||
})->all());
|
||||
|
||||
return $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
|
||||
$parameters = $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
|
||||
|
||||
$resolvedIva = array_filter($parameters, fn (array $parameter): bool => $parameter['parameter_code'] === 'IVA_GENERAL_RATE' && $parameter['status'] === 'resolved');
|
||||
|
||||
return $diagnostics ? ['parameters' => $parameters, 'diagnostics' => $resolvedIva !== [] ? [] : (new SimulationIvaDiagnostics)->explain($versions, $periodStart, $periodEnd, $context)] : $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1179,5 +1179,57 @@
|
||||
"calculations_exporters_repeated": false
|
||||
},
|
||||
"remaining": []
|
||||
},
|
||||
"iva_query_diagnostic_qa_20261021": {
|
||||
"recorded_utc": "2026-10-21T11:35:00.737064+00:00",
|
||||
"scope": "QA only, parameter-query diagnosis; no calculation/export retest, no destination writes",
|
||||
"reported_surface": "Componentes fiscais e contributivas, IVA_GENERAL_RATE, annual 2026-01-01 to2026-12-31, indeterminate_context",
|
||||
"confirmed_origin": "Implementation integration gap: query sent fiscal_operation and no currency/operation, whereas the published IVA parameter requires operation=internal_general_net and currency=MZN. Resolver collapsed every mismatch to indeterminate_context and UI blamed responses without individual evidence.",
|
||||
"original_answers_known": "Only the period/component/message were provided. No original currency or operation is asserted; USD/domestic are explicit directed QA cases, not inferred original answers.",
|
||||
"diagnostic_categories": [
|
||||
"missing_field",
|
||||
"invalid_value",
|
||||
"out_of_scope",
|
||||
"period_unavailable",
|
||||
"system_configuration"
|
||||
],
|
||||
"changes": [
|
||||
"Server projects each failed criterion, field, received value, expected criterion, source and exact rule version; no expression projection",
|
||||
"Explicit fiscal_currency answer for IVA query, persisted and recovered; no copy/conversion from budget or IVA-base currency",
|
||||
"Generic domestic is never silently equated to internal_general_net; integration limitation is explained and links to explicit IVA form",
|
||||
"Inline notes plus summary links, human IVA — taxa geral label and Ver fundamento disclosure",
|
||||
"Period diagnostic can inspect published candidates outside queried period under existing visibility policy; selection/calculation eligibility unchanged",
|
||||
"No eligible visible candidate remains explicitly uncertain; no invented input fault"
|
||||
],
|
||||
"verification": {
|
||||
"new_unit_tests": 5,
|
||||
"new_unit_assertions": 24,
|
||||
"targeted_api_tests": 1,
|
||||
"targeted_api_assertions": 16,
|
||||
"existing_resolver_regression_tests": 7,
|
||||
"existing_resolver_regression_assertions": 25,
|
||||
"typescript": "passed",
|
||||
"build": "passed, exit0",
|
||||
"pint": "passed",
|
||||
"edge": "Normal simulator query, annual2026; missing currency then explicitly chosen USD; inline/summary linked fields focused; source/version disclosure; USD and generic operation preserved after save/recovery; internal code and expression absent from query text",
|
||||
"no_simulation_created_by_api_diagnostic": true
|
||||
},
|
||||
"qa_fixture": {
|
||||
"rule_version": 3,
|
||||
"state_after_proof": "suspended",
|
||||
"consumer_enabled": false,
|
||||
"database": "simulator_fiscal_delta_20261021_test",
|
||||
"imported_into_homologation": false
|
||||
},
|
||||
"test_database": "simulator_fiscal_checks_20261021_test, existing QA schema correction applied before targeted API test; isolated guard preserved",
|
||||
"proofs": [
|
||||
"outputs/simulator-continuation-20261020/iva-diagnostic-api-test.txt",
|
||||
"outputs/simulator-continuation-20261020/iva-diagnostic-summary.txt",
|
||||
"outputs/simulator-continuation-20261020/iva-diagnostic-summary.png",
|
||||
"outputs/simulator-continuation-20261020/iva-diagnostic-build-final.txt"
|
||||
],
|
||||
"main_integrated": false,
|
||||
"homologation_rule_changed": false,
|
||||
"legal_coverage_expanded": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1961,3 +1961,22 @@ Para suspender, desligar `INVESTMOZ_IVA_CONSUMER_ENABLED` e reiniciar graciosame
|
||||
Activação separada aplicada em21/10/2026 às10:55:58UTC, alterando apenas as três chaves do consumidor. Ligação exacta aoID acima, rule_version1 eenabled=true; restante configuração preservada. `/api/v1/fiscal-consumer` confirmouenabled=true porHTTP200; a leitura do resolvedor, sem executar cálculo, confirmouIVA_GENERAL_RATE=16percent, mesmoID/versão/fonte no contextoMZN/internal_general_net de2026. Cinco eventos de governação; contagens de resultados/relatórios preservadas, sem simulações sintéticas no destino. O restauro de segurança e a configuração anterior estão conservados no arquivo privado identificado no recibo.
|
||||
|
||||
Suspensão concreta: configurar `INVESTMOZ_IVA_CONSUMER_ENABLED=false` e executar o reinício gracioso existente `php8.4 artisan queue:restart` na releaseactual como operador autorizado; ou `POST /api/v2/investment/admin/tax-rules/01a15e1a-5338-700f-8a45-f87707d696a7/suspend`, com `version_no:5` enquanto corrente e evidência verdadeira de pelo menos20caracteres, pela administração autenticada/MFA. Não repetir a suspensão se o estado/versão já tiver mudado. Conservar a ligação e todos os registos; futuras retomas respeitam o estado da regra e o procedimento de versão aplicável, sem escolher automaticamente outra regra.
|
||||
|
||||
|
||||
### Diagnóstico da consulta IVA — correcção em QA,21/10/2026
|
||||
|
||||
O caso reportado identifica «Componentes fiscais e contributivas», IVA_GENERAL_RATE, período01/01/2026–31/12/2026 e «As condições publicadas não correspondem às respostas indicadas». É consulta de parâmetros, não execução do consumidor IVA. O utilizador não forneceu a moeda ou a operação originais; não se afirma que tenha seleccionadoUSD, omitido um campo ou indicado uma operação excluída.
|
||||
|
||||
**Causa confirmada no contrato:** o formulário envia `fiscal_operation`, sem `operation` ou `currency`; a versão publicada exige `operation=internal_general_net` e `currency=MZN`. A validação da consulta nem aceitava esses nomes canónicos. O resolvedor reduzia qualquer condição falhada a `indeterminate_context`; a interface mostrava apenas a frase genérica e o código interno. Esta restrição de ligação é escolha de implementação, não condição organizacional ou jurídica adicional da Tempo. O mesmo contrato reproduziu o estado no período anual reportado, independentemente de se presumir uma moeda no caso original.
|
||||
|
||||
**Correcção delimitada:** cada condição não satisfeita tem categoria, campo, valor recebido e critério esperado, ligados à versão e fonte. Distinguem-se campo em falta, formato inválido, operação/moeda fora do âmbito, versão que não cobre o período e configuração do sistema. Quando não existe versão elegível visível e a evidência não permite separar ausência de cobertura de indisponibilidade de fonte/configuração, a interface mantém essa incerteza; não inventa uma causa nem atribui culpa às respostas.
|
||||
|
||||
A consulta IVA passa a ter moeda explícita (`fiscal_currency`), guardada no rascunho, sem presumirMZN ou copiar moeda do orçamento ou do cálculo IVA. A API recebe esse valor em `context.currency`. A operação genérica não se converte automaticamente no âmbito «operação interna à taxa geral sobre base líquida»: a limitação de integração é explicada com a resposta recebida e o critério esperado, e existe ligação para o formulário IVA com declaração explícita do âmbito. Uma importação/exportação/não residente/reembolso expressamente seleccionado é identificado como exclusão deste lote; não se altera a resposta para a fazer passar.
|
||||
|
||||
As notas aparecem junto dos campos e num resumo com ligações que focam o campo correspondente, mantendo o rascunho. O nome apresentado é «IVA — taxa geral»; «Ver fundamento» contém versão, vigência e fonte, sem expressãoDSL. As falhas de consulta/configuração são identificadas como sistema. O percurso de cálculo e as suas regras de admissão não foram ampliados. A consulta diagnóstica pode observar versões publicadas fora do período seleccionado, sob a mesma política de visibilidade, apenas para explicar a falta de cobertura; não as torna elegíveis.
|
||||
|
||||
**Prova dirigida:**5testes unitários novos/24asserções;1testeAPI/16asserções no clone isolado simulator_fiscal_checks_20261021_test;7testes existentes do resolvedor/25asserções; TypeScript, build(exit0), Pint e diff-check. A primeira execuçãoAPI detectou que esse clone ainda não tinha a coluna fiscal_acceptance; aplicou-se nele a correcção de esquema já aceite, com a guardaQA intacta, e repetiu-se apenas o testeAPI afectado. Não é diagnóstico da base de homologação. A comparação por índice do ensaio foi ajustada à ordem das chavesJSONB; não se alterou o critério fiscal.
|
||||
|
||||
No Edge, simulador normal QA em127.0.0.1:18580: consulta anual2026, operação genérica explicitamente escolhida para o ensaio; moeda inicialmente vazia, depoisUSD escolhido expressamente como caso dirigido. Confirmaram-se notas, resumo, foco em «Rever moeda»/«Rever operação», fundamento da versão sintética3, eUSD/ano/periodicidade/operação conservados após gravação e recuperação do mesmo rascunho. A captura de prova é um ensaioUSD, não evidência deUSD no caso original. Não se executou cálculo nem se exportou relatório. A fixture publicada apenas emQA para observar o diagnóstico foi depois suspensa; o consumidorQA permaneceu desligado. Nenhuma fixture foi importada, regra alterada ou consumidor activado na homologação nesta correcção.
|
||||
|
||||
Provas privadas na pasta `outputs/simulator-continuation-20261020`: iva-diagnostic-api-test.txt, iva-diagnostic-summary.txt, iva-diagnostic-summary.png e iva-diagnostic-build-final.txt. O reciboJSON existente tem o estado detalhado. A candidata permanece na branch `codex/iva-diagnostics-QA`, separada de main; não é uma nova publicação fiscal nem altera a entrega autorizada anteriormente.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fiscalComponentCodes } from '@/lib/simulation-contract';
|
||||
import { ivaIssueText, type IvaDiagnostic, type IvaIssue } from '@/lib/iva-diagnostics';
|
||||
|
||||
type Parameter = {
|
||||
parameter_code: string;
|
||||
@@ -56,6 +57,8 @@ export function SimulationFiscalStatus({
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
onDiagnostics,
|
||||
onCorrect,
|
||||
}: {
|
||||
salaryView?: string;
|
||||
scenarioMode?: string;
|
||||
@@ -63,8 +66,11 @@ export function SimulationFiscalStatus({
|
||||
periodStart?: string;
|
||||
periodEnd?: string;
|
||||
context?: Record<string, string>;
|
||||
onDiagnostics?: (issues: IvaIssue[]) => void;
|
||||
onCorrect?: (field: string) => void;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<Parameter[]>([]);
|
||||
const [diagnostics, setDiagnostics] = useState<IvaDiagnostic[]>([]);
|
||||
const [requestState, setRequestState] = useState('idle');
|
||||
const [retry, setRetry] = useState(0);
|
||||
|
||||
@@ -79,10 +85,14 @@ export function SimulationFiscalStatus({
|
||||
!periodEnd
|
||||
) {
|
||||
setParameters([]);
|
||||
setDiagnostics([]);
|
||||
onDiagnostics?.([]);
|
||||
setRequestState('idle');
|
||||
return;
|
||||
}
|
||||
const abort = new AbortController();
|
||||
setDiagnostics([]);
|
||||
onDiagnostics?.([]);
|
||||
setRequestState('loading');
|
||||
void fetch('/api/v1/tax-parameters/resolve', {
|
||||
method: 'POST',
|
||||
@@ -104,11 +114,15 @@ export function SimulationFiscalStatus({
|
||||
if (!response.ok) throw new Error('lookup_failed');
|
||||
const payload = (await response.json()) as {
|
||||
parameters?: Parameter[];
|
||||
diagnostics?: IvaDiagnostic[];
|
||||
};
|
||||
if (abort.signal.aborted) return;
|
||||
setParameters(
|
||||
Array.isArray(payload.parameters) ? payload.parameters : [],
|
||||
);
|
||||
const details = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
||||
setDiagnostics(details);
|
||||
onDiagnostics?.(details.flatMap((detail) => detail.issues));
|
||||
setRequestState('done');
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -158,7 +172,7 @@ export function SimulationFiscalStatus({
|
||||
{requestState === 'loading'
|
||||
? 'A consultar parâmetros aprovados para o período e âmbito indicados…'
|
||||
: requestState === 'error'
|
||||
? 'Não foi possível consultar os parâmetros; o resultado permanece indeterminado.'
|
||||
? 'Falha na consulta do sistema: não foi possível obter os parâmetros. O problema não é atribuído às respostas; estas foram preservadas. Tente novamente.'
|
||||
: !periodStart || !periodEnd
|
||||
? 'Indique um período exacto. Para periodicidade mensal ou trimestral, seleccione o mês ou trimestre.'
|
||||
: `Período consultado: ${periodStart} a ${periodEnd}.`}
|
||||
@@ -186,6 +200,22 @@ export function SimulationFiscalStatus({
|
||||
{salaryViewMessages[salaryView]}
|
||||
</p>
|
||||
)}
|
||||
{diagnostics.length > 0 && <div role="alert" className="space-y-3 rounded border border-amber-700 p-3">
|
||||
<h4 className="font-semibold">IVA — taxa geral: condições não satisfeitas</h4>
|
||||
<p>Não foi possível resolver este parâmetro. As respostas foram preservadas; esta consulta não executa o cálculo do imposto.</p>
|
||||
{diagnostics.map((detail, index) => <div key={index}>
|
||||
<ul className="list-disc pl-5">{detail.issues.map((issue, issueIndex) => <li key={issueIndex}>
|
||||
{ivaIssueText(issue)}{' '}
|
||||
{issue.field && <a className="underline" href={`#field-${issue.field}`} onClick={(event) => { if (onCorrect) { event.preventDefault(); onCorrect(issue.field!); } }}>Rever {issue.label.toLowerCase()}</a>}
|
||||
{issue.category === 'system_configuration' && issue.field === 'fiscal_operation' && <a className="ml-2 underline" href="#published-iva">Abrir IVA — taxa geral</a>}
|
||||
</li>)}</ul>
|
||||
<details className="mt-2"><summary className="cursor-pointer underline">Ver fundamento</summary>
|
||||
<p>IVA — taxa geral · versão {detail.rule_version}. Vigência: {detail.validity.start} a {detail.validity.end ?? 'sem data final indicada'}.</p>
|
||||
<p>{detail.source.title} — {detail.source.diploma}, {detail.source.article}</p>
|
||||
{detail.source.url?.startsWith('https://') && <a className="underline" href={detail.source.url} target="_blank" rel="noreferrer">Consultar fonte</a>}
|
||||
</details>
|
||||
</div>)}
|
||||
</div>}
|
||||
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{fiscalComponentCodes.map((code) => {
|
||||
const selected =
|
||||
@@ -211,13 +241,13 @@ export function SimulationFiscalStatus({
|
||||
</p>
|
||||
) : parameters.length === 0 ? (
|
||||
<p className="text-sm">
|
||||
Indeterminado — nenhum parâmetro publicado
|
||||
{code === 'IVA' ? 'Indeterminado — não existe uma versão elegível visível nesta consulta. Consulte o diagnóstico do período acima, quando disponível. Sem esse diagnóstico, não é possível distinguir ausência de cobertura de indisponibilidade da regra ou da fonte; não se atribui o problema às suas respostas.' : <>Indeterminado — nenhum parâmetro publicado
|
||||
aplicável. Faltam parâmetros aprovados para{' '}
|
||||
{code}, no período {periodStart} a{' '}
|
||||
{periodEnd} e no âmbito indicado. A consulta
|
||||
não identifica um conjunto obrigatório de
|
||||
parâmetros; não é possível determinar o
|
||||
imposto nem substituir as lacunas por zero.
|
||||
imposto nem substituir as lacunas por zero.</>}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
@@ -227,7 +257,7 @@ export function SimulationFiscalStatus({
|
||||
className="border-t pt-2"
|
||||
>
|
||||
<strong>
|
||||
{parameter.parameter_code}
|
||||
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'IVA — taxa geral' : parameter.parameter_code}
|
||||
</strong>
|
||||
{parameter.status === 'resolved' ? (
|
||||
<>
|
||||
@@ -238,10 +268,10 @@ export function SimulationFiscalStatus({
|
||||
</p>
|
||||
<p>
|
||||
Regra{' '}
|
||||
{parameter.rule_code} v
|
||||
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'IVA — taxa geral' : parameter.rule_code} v
|
||||
{parameter.rule_version}
|
||||
; âmbito{' '}
|
||||
{parameter.scope_key}
|
||||
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'operação interna geral sobre base líquida em MZN' : parameter.scope_key}
|
||||
</p>
|
||||
<p>
|
||||
Vigência:{' '}
|
||||
@@ -279,13 +309,13 @@ export function SimulationFiscalStatus({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Consultar fundamento
|
||||
Ver fundamento
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{labels[parameter.status] ??
|
||||
{(parameter.parameter_code === 'IVA_GENERAL_RATE' && diagnostics.length ? 'Consulte acima cada condição e o fundamento da versão.' : labels[parameter.status]) ??
|
||||
'Indeterminado — não foi possível validar o parâmetro.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -56,6 +56,7 @@ import { SimulationFinancialTools } from '@/components/simulation-financial-tool
|
||||
import { SimulationCashflowPlanner } from '@/components/simulation-cashflow-planner';
|
||||
import { SimulationBusinessPlan } from '@/components/simulation-business-plan';
|
||||
import { SimulationFiscalStatus } from '@/components/simulation-fiscal-status';
|
||||
import { ivaIssueText, type IvaIssue } from '@/lib/iva-diagnostics';
|
||||
import {
|
||||
ProjectMilestonePlanner,
|
||||
ProjectTeamPlanner,
|
||||
@@ -810,6 +811,7 @@ export function SimulationForm() {
|
||||
});
|
||||
const [step, setStep] = useState(0);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [ivaIssues, setIvaIssues] = useState<IvaIssue[]>([]);
|
||||
const [serverFieldErrors, setServerFieldErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
@@ -1936,6 +1938,7 @@ export function SimulationForm() {
|
||||
) {
|
||||
if (!ready) return;
|
||||
setContinueLaterSaved(false);
|
||||
setIvaIssues((previous) => previous.filter((issue) => issue.field !== key));
|
||||
setServerFieldErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[key];
|
||||
@@ -3285,6 +3288,7 @@ export function SimulationForm() {
|
||||
: Boolean(field.required);
|
||||
const fieldError =
|
||||
serverFieldErrors[field.key] ?? errors[field.key];
|
||||
const ivaNotes = ivaIssues.filter((issue) => issue.field === field.key).map((issue, index) => <p key={index} role="note" className="mt-1 text-sm text-amber-950">{ivaIssueText(issue)}</p>);
|
||||
if (field.options) {
|
||||
const parentKey =
|
||||
field.key === 'district_id'
|
||||
@@ -3331,6 +3335,7 @@ export function SimulationForm() {
|
||||
}
|
||||
/>
|
||||
<FieldGuidance fieldKey={field.key} />
|
||||
{ivaNotes}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3405,6 +3410,7 @@ export function SimulationForm() {
|
||||
{fieldError}
|
||||
</p>
|
||||
)}
|
||||
{ivaNotes}
|
||||
{field.key === 'reduction' && (
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Hipótese sua, não representa um
|
||||
@@ -3609,6 +3615,12 @@ export function SimulationForm() {
|
||||
aria-label="Consulta de parâmetros fiscais"
|
||||
>
|
||||
<SimulationFiscalStatus
|
||||
onDiagnostics={setIvaIssues}
|
||||
onCorrect={(key) => {
|
||||
const target = journeySections.findIndex((section) => (simulationFieldGroups[section.id] ?? []).some((field) => field.key === key));
|
||||
if (target >= 0) setStep(target);
|
||||
window.requestAnimationFrame(() => document.getElementById(`field-${key}`)?.focus());
|
||||
}}
|
||||
salaryView={String(draft.irps_salary_view ?? '')}
|
||||
scenarioMode={
|
||||
openedTool === 'fiscal'
|
||||
@@ -3618,7 +3630,7 @@ export function SimulationForm() {
|
||||
component={String(draft.fiscal_component ?? '')}
|
||||
periodStart={getFiscalPeriodRange(draft)?.start}
|
||||
periodEnd={getFiscalPeriodRange(draft)?.end}
|
||||
context={Object.fromEntries(
|
||||
context={{ ...Object.fromEntries(
|
||||
[
|
||||
'fiscal_periodicity',
|
||||
'fiscal_operation',
|
||||
@@ -3635,7 +3647,7 @@ export function SimulationForm() {
|
||||
? [[key, draft[key] as string]]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
), ...(typeof draft.fiscal_currency === 'string' && draft.fiscal_currency !== '' ? {currency: draft.fiscal_currency} : {}) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -51,8 +51,8 @@ export function SimulationPublishedIva({ busy, onCalculate }: {
|
||||
} catch { /* The execution flow already preserves and reports connection errors. */ }
|
||||
}
|
||||
|
||||
return <section aria-label="IVA geral sobre base líquida" className="space-y-3 rounded border border-emerald-700 p-4">
|
||||
<h4 className="font-semibold">IVA geral sobre base líquida</h4>
|
||||
return <section id="published-iva" tabIndex={-1} aria-label="IVA — taxa geral" className="space-y-3 rounded border border-emerald-700 p-4">
|
||||
<h4 className="font-semibold">IVA — taxa geral</h4>
|
||||
<p role="status">{message}</p>
|
||||
<p className="text-sm">Cobertura: exigibilidade em 2026, operação interna à taxa geral. A base tributável é indicada por si; não é copiada do orçamento. Sem isenções, taxas reduzidas, bases especiais, importações, deduções, declaração ou pagamento. O cálculo não valida o enquadramento jurídico.</p>
|
||||
<fieldset disabled={busy} className="space-y-3">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export type IvaIssue = {
|
||||
category: 'missing_field' | 'invalid_value' | 'out_of_scope' | 'period_unavailable' | 'system_configuration';
|
||||
field: string | null;
|
||||
label: string;
|
||||
received: unknown;
|
||||
expected: string;
|
||||
message: string;
|
||||
};
|
||||
export type IvaDiagnostic = {
|
||||
label: string;
|
||||
rule_version: number;
|
||||
validity: { start: string; end: string | null };
|
||||
source: { title?: string; diploma?: string; article?: string; url?: string };
|
||||
issues: IvaIssue[];
|
||||
};
|
||||
export const ivaIssueCategories = {
|
||||
missing_field: 'Campo em falta', invalid_value: 'Valor inválido', out_of_scope: 'Fora do âmbito',
|
||||
period_unavailable: 'Regra indisponível para o período', system_configuration: 'Configuração do sistema',
|
||||
};
|
||||
export function ivaReceived(value: unknown): string {
|
||||
const labels: Record<string, string> = { domestic: 'Operação interna', internal_general_net: 'Operação interna à taxa geral, sobre base líquida', import: 'Importação', export: 'Exportação', refund: 'Reembolso', unknown: 'Não sei', non_resident: 'Operação com não residente', other: 'Outra operação' };
|
||||
return typeof value === 'string' && value !== '' ? (labels[value] ?? value) : 'Não recebido';
|
||||
}
|
||||
export function ivaIssueText(issue: IvaIssue): string {
|
||||
return `${issue.label}: ${ivaIssueCategories[issue.category]}. Recebido: ${ivaReceived(issue.received)}. Esperado: ${issue.expected}. ${issue.message}`;
|
||||
}
|
||||
@@ -965,6 +965,12 @@ export const simulationFieldGroups: Record<
|
||||
},
|
||||
],
|
||||
tax: [
|
||||
{
|
||||
key: 'fiscal_currency',
|
||||
label: 'Moeda da operação fiscal (sem conversão automática)',
|
||||
options: 'currency',
|
||||
showWhen: (d) => d.fiscal_component === 'iva' && d.fiscal_scenario_mode !== 'custom_planning',
|
||||
},
|
||||
{
|
||||
key: 'fiscal_period',
|
||||
label: 'Ano do período fiscal a simular',
|
||||
|
||||
@@ -76,6 +76,33 @@ class SimulationPublishedFiscalTest extends TestCase
|
||||
return Simulation::with('reports')->findOrFail($id);
|
||||
}
|
||||
|
||||
public function test_iva_query_diagnostics_explain_the_reported_contract_gap_without_calculating_or_changing_answers(): void
|
||||
{
|
||||
$count = Simulation::count();
|
||||
$input = ['component' => 'IVA', 'period_start' => '2026-01-01', 'period_end' => '2026-12-31',
|
||||
'context' => ['fiscal_operation' => 'domestic', 'currency' => 'USD']];
|
||||
$this->actingAs($this->consumer)->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
|
||||
->assertJsonPath('assumptions.currency', 'USD')
|
||||
->assertJsonPath('parameters.0.status', 'indeterminate_context')
|
||||
->assertJsonPath('diagnostics.0.label', 'IVA — taxa geral')
|
||||
->assertJsonPath('diagnostics.0.rule_version', 1)
|
||||
->assertJsonPath('diagnostics.0.issues.1.category', 'system_configuration')
|
||||
->assertJsonPath('diagnostics.0.issues.1.received', 'domestic')
|
||||
->assertJsonPath('diagnostics.0.issues.0.category', 'out_of_scope')
|
||||
->assertJsonPath('diagnostics.0.issues.0.received', 'USD')
|
||||
->assertJsonPath('diagnostics.0.issues.0.expected', 'MZN');
|
||||
unset($input['context']['currency']);
|
||||
$this->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
|
||||
->assertJsonPath('diagnostics.0.issues.0.category', 'missing_field');
|
||||
$input['context']['currency'] = 'MZN';
|
||||
$input['period_start'] = '2022-01-01';
|
||||
$input['period_end'] = '2022-12-31';
|
||||
$this->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
|
||||
->assertJsonPath('parameters', [])
|
||||
->assertJsonPath('diagnostics.0.issues.0.category', 'period_unavailable');
|
||||
self::assertSame($count, Simulation::count());
|
||||
}
|
||||
|
||||
public function test_normal_simulator_admits_published_rule_and_reuses_history_and_reports(): void
|
||||
{
|
||||
$key = (string) Str::uuid();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\SimulationIvaDiagnostics;
|
||||
use App\Services\SimulationTaxParameterResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SimulationIvaDiagnosticsTest extends TestCase
|
||||
{
|
||||
private function version(): array
|
||||
{
|
||||
return ['id' => 'synthetic-qa', 'rule_code' => 'IVA-GERAL-BASE-LIQUIDA', 'rule_version' => 1,
|
||||
'component' => 'IVA', 'status' => 'published', 'scope_key' => 'mz.internal-general-net.mzn',
|
||||
'valid_from' => '2023-01-01', 'valid_until' => null, 'source_version_no' => 1,
|
||||
'source_sha256' => str_repeat('a', 64), 'rule_definition' => ['language' => 'investmoz.tax.v1'],
|
||||
'parameters' => ['IVA_GENERAL_RATE' => ['value' => '16', 'unit' => 'percent', 'conditions' => ['operation' => 'internal_general_net', 'currency' => 'MZN']]],
|
||||
'source' => ['validation_status' => 'validated', 'version_no' => 1, 'valid_from' => '2023-01-01', 'valid_until' => null,
|
||||
'title' => 'Synthetic source', 'diploma' => 'QA only', 'article' => 'Fixture', 'url' => 'https://example.invalid/official']];
|
||||
}
|
||||
|
||||
public function test_reported_annual_query_contract_reproduces_the_generic_failure_and_identifies_both_missing_links(): void
|
||||
{
|
||||
$version = $this->version();
|
||||
$context = ['fiscal_operation' => 'domestic'];
|
||||
$original = $context;
|
||||
$before = (new SimulationTaxParameterResolver)->resolveAvailableParameters([$version], 'IVA', '2026-01-01', '2026-12-31', $context);
|
||||
self::assertSame('indeterminate_context', $before[0]['status']);
|
||||
$details = (new SimulationIvaDiagnostics)->explain([$version], '2026-01-01', '2026-12-31', $context);
|
||||
self::assertSame(['system_configuration', 'missing_field'], array_column($details[0]['issues'], 'category'));
|
||||
self::assertSame('domestic', $details[0]['issues'][0]['received']);
|
||||
self::assertSame('MZN', $details[0]['issues'][1]['expected']);
|
||||
self::assertSame($original, $context);
|
||||
self::assertArrayNotHasKey('rule_definition', $details[0]);
|
||||
self::assertArrayNotHasKey('rule_code', $details[0]);
|
||||
}
|
||||
|
||||
public function test_all_failed_conditions_are_separate_and_currency_is_not_assumed(): void
|
||||
{
|
||||
foreach (['' => 'missing_field', 'usd' => 'invalid_value', 'USD' => 'out_of_scope'] as $value => $category) {
|
||||
$details = (new SimulationIvaDiagnostics)->explain([$this->version()], '2026-01-01', '2026-12-31', ['operation' => 'import', 'currency' => $value]);
|
||||
self::assertCount(2, $details[0]['issues']);
|
||||
self::assertSame('out_of_scope', $details[0]['issues'][0]['category']);
|
||||
self::assertSame($category, $details[0]['issues'][1]['category']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_period_and_broken_source_are_not_reported_as_incorrect_answers(): void
|
||||
{
|
||||
$version = $this->version();
|
||||
$version['source_version_no'] = 2;
|
||||
$details = (new SimulationIvaDiagnostics)->explain([$version], '2022-01-01', '2022-12-31', ['operation' => 'internal_general_net', 'currency' => 'MZN']);
|
||||
self::assertSame(['period_unavailable', 'system_configuration'], array_column($details[0]['issues'], 'category'));
|
||||
self::assertSame('2022-01-01 a 2022-12-31', $details[0]['issues'][0]['received']);
|
||||
self::assertSame(1, $details[0]['rule_version']);
|
||||
self::assertSame('Synthetic source', $details[0]['source']['title']);
|
||||
}
|
||||
|
||||
public function test_matching_context_has_no_diagnostic_and_does_not_evaluate_an_expression(): void
|
||||
{
|
||||
self::assertSame([], (new SimulationIvaDiagnostics)->explain([$this->version()], '2026-01-01', '2026-12-31', ['operation' => 'internal_general_net', 'currency' => 'MZN']));
|
||||
}
|
||||
|
||||
public function test_an_explicitly_excluded_generic_operation_is_reported_without_inferring_a_domestic_scope(): void
|
||||
{
|
||||
$details = (new SimulationIvaDiagnostics)->explain([$this->version()], '2026-01-01', '2026-12-31', ['fiscal_operation' => 'import', 'currency' => 'MZN']);
|
||||
self::assertCount(1, $details[0]['issues']);
|
||||
self::assertSame('out_of_scope', $details[0]['issues'][0]['category']);
|
||||
self::assertSame('import', $details[0]['issues'][0]['received']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user