diff --git a/docs/TECH/address_route_expectations_v1.json b/docs/TECH/address_route_expectations_v1.json index fc8a570..7089cf2 100644 --- a/docs/TECH/address_route_expectations_v1.json +++ b/docs/TECH/address_route_expectations_v1.json @@ -1,6 +1,6 @@ { "schema_version": "address_route_expectations_v1", - "updated_at": "2026-04-12T20:50:00.000Z", + "updated_at": "2026-04-13T00:15:00.000Z", "entries": [ { "intent": "payables_confirmed_as_of_date", @@ -20,6 +20,16 @@ "expected_requested_result_modes": ["confirmed_balance"], "expected_result_modes": ["confirmed_balance"] }, + { + "intent": "vat_payable_forecast", + "expected_selected_recipes": ["address_vat_payable_forecast_v1"] + }, + { + "intent": "vat_liability_confirmed_for_tax_period", + "expected_selected_recipes": ["address_vat_liability_confirmed_tax_period_v1"], + "expected_requested_result_modes": ["confirmed_balance"], + "expected_result_modes": ["confirmed_balance"] + }, { "intent": "list_payables_counterparties", "expected_selected_recipes": ["address_movements_payables_v1", "address_open_items_by_party_or_contract_v1"], diff --git a/docs/TECH/capabilities_registry.json b/docs/TECH/capabilities_registry.json index 8784df7..e8691b9 100644 --- a/docs/TECH/capabilities_registry.json +++ b/docs/TECH/capabilities_registry.json @@ -1,6 +1,6 @@ { "schema_version": "capabilities_registry_v1", - "updated_at": "2026-04-12T20:50:00.000Z", + "updated_at": "2026-04-13T00:15:00.000Z", "assistant_mode": "read_only", "groups": [ { @@ -11,6 +11,7 @@ "maturity_status": "partial", "supported_operations": [ "vat_period_snapshot", + "vat_liability_confirmed_for_tax_period", "vat_payable_confirmed_as_of_date", "vat_payable_forecast", "vat_turnover_breakdown" @@ -34,6 +35,7 @@ ], "related_routes": [ "address_vat_payable_confirmed_as_of_date_v1", + "address_vat_liability_confirmed_tax_period_v1", "address_vat_payable_forecast_v1" ], "safe_alternatives": [ diff --git a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js index 56e8aa0..1bedba7 100644 --- a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js +++ b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js @@ -9,7 +9,8 @@ const COMPUTE_EXACT_INTENTS = new Set([ "documents_forming_balance", "payables_confirmed_as_of_date", "receivables_confirmed_as_of_date", - "vat_payable_confirmed_as_of_date" + "vat_payable_confirmed_as_of_date", + "vat_liability_confirmed_for_tax_period" ]); const NAVIGATION_INTENTS = new Set([ "list_documents_by_counterparty", @@ -43,6 +44,9 @@ function defaultCapabilityId(intent) { if (intent === "vat_payable_confirmed_as_of_date") { return "confirmed_vat_payable_as_of_date"; } + if (intent === "vat_liability_confirmed_for_tax_period") { + return "confirmed_vat_liability_for_tax_period"; + } if (intent === "list_payables_counterparties") { return "payables_candidates_list"; } @@ -86,6 +90,14 @@ function resolveCapabilityEnabled(intent) { : "vat_payable_confirmed_route_disabled_by_flag" }; } + if (intent === "vat_liability_confirmed_for_tax_period") { + return { + enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, + reason: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 + ? "vat_liability_confirmed_tax_period_route_enabled" + : "vat_liability_confirmed_tax_period_route_disabled_by_flag" + }; + } if (intent === "list_payables_counterparties") { return { enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index c0ff61c..b86c1cd 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -825,6 +825,9 @@ function requiredFiltersByIntent(intent) { if (intent === "vat_payable_confirmed_as_of_date") { return ["as_of_date"]; } + if (intent === "vat_liability_confirmed_for_tax_period") { + return ["period_from", "period_to"]; + } if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || intent === "list_contracts_by_counterparty") { @@ -972,6 +975,17 @@ function extractAddressFilters(userMessage, intent) { } } } + if (intent === "vat_liability_confirmed_for_tax_period" && !periodRange.period_from && !periodRange.period_to) { + const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null; + if (periodToForQuarter) { + const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter); + if (quarterWindow) { + filters.period_from = quarterWindow.period_from; + filters.period_to = quarterWindow.period_to; + warnings.push("period_derived_from_tax_quarter_for_confirmed_vat_liability"); + } + } + } if (isManagementProfileIntent && !filters.period_to && !filters.as_of_date) { filters.period_to = new Date().toISOString().slice(0, 10); warnings.push("period_to_defaulted_today_for_management_profile"); diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index de28e27..7c021d2 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -554,6 +554,31 @@ function hasForecastTaxSignal(text) { const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text); return hasForecastLexeme && hasTaxLexeme; } +function hasVatLiabilityConfirmedTaxPeriodSignal(text) { + const hasVatLexeme = /(?:ндс|vat)/iu.test(text); + if (!hasVatLexeme) { + return false; + } + const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(text); + if (!hasPaymentCue) { + return false; + } + const hasAsOfCue = /(?:на\s+дат|по\s+состоянию|на\s+конец|as\s+of)/iu.test(text); + if (hasAsOfCue) { + return false; + } + const hasTaxAuthorityCue = /(?:в\s+налогов|в\s+бюджет|декларац|налогов(?:ый|ую)\s+период)/iu.test(text); + const hasQuarterCue = /(?:\b[1-4]\s*(?:квартал|кв\.?)\b|квартал|кв\.?)/iu.test(text); + const hasZaPeriodCue = /(?:за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр|квартал|кв\.?|месяц|год|период))/iu.test(text); + const hasExplicitDayDate = /\b(?:\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})|(?:19|20)\d{2}[./-]\d{1,2}[./-]\d{1,2})\b/u.test(text); + const hasMonthYearNaCue = /(?:на\s+(?:январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*\s+(?:19|20)\d{2})/iu.test(text); + const hasHowMuchCue = /(?:сколько|скока|скок)/iu.test(text); + // "На март 2020" и конкретная дата без налогового контекста чаще означают as-of срез. + if (!hasTaxAuthorityCue && !hasZaPeriodCue && !hasQuarterCue && (hasMonthYearNaCue || hasExplicitDayDate)) { + return false; + } + return hasTaxAuthorityCue || hasZaPeriodCue || hasQuarterCue || (hasHowMuchCue && hasTaxAuthorityCue); +} function hasVatPayableConfirmedSignal(text) { const hasVatLexeme = /(?:ндс|vat)/iu.test(text); if (!hasVatLexeme) { @@ -1277,6 +1302,13 @@ function hasAccountNumberAnchor(text) { } function resolveAddressIntent(userMessage) { const text = String(userMessage ?? "").trim().toLowerCase(); + if (hasVatLiabilityConfirmedTaxPeriodSignal(text)) { + return { + intent: "vat_liability_confirmed_for_tax_period", + confidence: "high", + reasons: ["vat_liability_confirmed_tax_period_signal_detected"] + }; + } if (hasForecastTaxSignal(text)) { return { intent: "vat_payable_forecast", diff --git a/llm_normalizer/backend/dist/services/addressMcpClient.js b/llm_normalizer/backend/dist/services/addressMcpClient.js index 241efff..222ebe8 100644 --- a/llm_normalizer/backend/dist/services/addressMcpClient.js +++ b/llm_normalizer/backend/dist/services/addressMcpClient.js @@ -245,7 +245,10 @@ function filterRowsByAccountScope(rows, accountScope) { async function executeAddressMcpQuery(input) { const endpoint = buildMcpUrl("/api/execute_query"); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), Math.max(300, config_1.ASSISTANT_MCP_TIMEOUT_MS)); + const resolvedTimeoutMs = typeof input.timeout_ms === "number" && Number.isFinite(input.timeout_ms) + ? Math.max(300, Math.trunc(input.timeout_ms)) + : Math.max(300, config_1.ASSISTANT_MCP_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs); try { const response = await fetch(endpoint, { method: "POST", @@ -305,7 +308,10 @@ async function executeAddressMcpQuery(input) { async function executeAddressMcpMetadata(input) { const endpoint = buildMcpUrl("/api/get_metadata"); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), Math.max(300, config_1.ASSISTANT_MCP_TIMEOUT_MS)); + const resolvedTimeoutMs = typeof input.timeout_ms === "number" && Number.isFinite(input.timeout_ms) + ? Math.max(300, Math.trunc(input.timeout_ms)) + : Math.max(300, config_1.ASSISTANT_MCP_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs); try { const body = {}; if (typeof input.filter === "string" && input.filter.trim().length > 0) { diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index 96c0367..05f3f3c 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -30,6 +30,7 @@ const RESULT_SET_TYPE_BY_INTENT = { list_payables_counterparties: "counterparty_list", payables_confirmed_as_of_date: "balance_snapshot", vat_payable_confirmed_as_of_date: "balance_snapshot", + vat_liability_confirmed_for_tax_period: "balance_snapshot", receivables_confirmed_as_of_date: "balance_snapshot", list_receivables_counterparties: "counterparty_list", list_contracts_by_counterparty: "contract_list", diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index f09628c..a07b4ad 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -16,9 +16,15 @@ const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200; const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000; const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000; const VAT_METADATA_PROBE_LIMIT = 100; -const VAT_SOURCE_PROBE_MAX_OBJECTS = 8; -const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "РегистрСведений", "Документ"]; -const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур", "вычет", "восстанов"]; +const VAT_SOURCE_PROBE_MAX_OBJECTS = 6; +const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "Документ"]; +const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур"]; +const VAT_METADATA_PROBE_CONCURRENCY = 4; +const VAT_METADATA_PROBE_TIMEOUT_MS = 800; +const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_200; +const VAT_OBJECT_PROBE_CONCURRENCY = 4; +const VAT_OBJECT_PROBE_TIMEOUT_MS = 800; +const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 800; const PARTY_ANCHOR_STOPWORDS = new Set([ "ооо", "ао", @@ -212,6 +218,62 @@ function extractVatMetadataObjects(rows) { } return out; } +function isVatMetadataObject(item) { + const source = `${item.fullName} ${item.synonym ?? ""}`.toLowerCase().replace(/ё/g, "е"); + if (source.includes("ндфл")) { + return false; + } + return /(?:ндс|книгапокуп|книгапродаж|счет[\s-]?фактур)/iu.test(source); +} +function isAbortErrorMessage(error) { + const normalized = String(error ?? "").toLowerCase(); + if (!normalized) { + return false; + } + return normalized.includes("aborted") || normalized.includes("abort"); +} +async function mapWithConcurrency(items, concurrency, worker) { + if (items.length === 0) { + return []; + } + const boundedConcurrency = Math.max(1, Math.min(Math.trunc(concurrency), items.length)); + const results = new Array(items.length); + let nextIndex = 0; + const runners = Array.from({ length: boundedConcurrency }, async () => { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + if (currentIndex >= items.length) { + break; + } + results[currentIndex] = await worker(items[currentIndex], currentIndex); + } + }); + await Promise.all(runners); + return results; +} +async function executeVatMetadataProbeRequest(request) { + const firstAttempt = await (0, addressMcpClient_1.executeAddressMcpMetadata)({ + ...request, + timeout_ms: VAT_METADATA_PROBE_TIMEOUT_MS + }); + if (!firstAttempt.error || !isAbortErrorMessage(firstAttempt.error)) { + return firstAttempt; + } + const retryLimit = Math.max(20, Math.min(request.limit, Math.trunc(request.limit / 2))); + const retryAttempt = await (0, addressMcpClient_1.executeAddressMcpMetadata)({ + ...request, + limit: retryLimit, + timeout_ms: VAT_METADATA_PROBE_RETRY_TIMEOUT_MS + }); + if (!retryAttempt.error) { + return retryAttempt; + } + return { + ...retryAttempt, + error: `${firstAttempt.error}; retry: ${retryAttempt.error}` + }; +} function scoreVatMetadataObject(item) { const fullName = item.fullName.toLowerCase(); const synonym = String(item.synonym ?? "").toLowerCase(); @@ -239,8 +301,10 @@ function scoreVatMetadataObject(item) { } return score; } -function buildVatObjectProbeQuery(object, asOfExpr) { +function buildVatObjectProbeQuery(object, asOfExpr, mode = "latest") { + const orderClause = mode === "latest" ? "\nУПОРЯДОЧИТЬ ПО\n Движения.Период УБЫВ" : ""; if (object.objectType === "document") { + const documentOrderClause = mode === "latest" ? "\nУПОРЯДОЧИТЬ ПО\n Док.Дата УБЫВ" : ""; return ` ВЫБРАТЬ ПЕРВЫЕ 1 Док.Дата КАК Период, @@ -252,9 +316,8 @@ function buildVatObjectProbeQuery(object, asOfExpr) { ${object.fullName} КАК Док ГДЕ Док.Дата <= ${asOfExpr} -УПОРЯДОЧИТЬ ПО - Док.Дата УБЫВ -`.trim(); +${documentOrderClause} +`.trim().replace(/\n{3,}/g, "\n\n"); } return ` ВЫБРАТЬ ПЕРВЫЕ 1 @@ -267,9 +330,8 @@ function buildVatObjectProbeQuery(object, asOfExpr) { ${object.fullName} КАК Движения ГДЕ Движения.Период <= ${asOfExpr} -УПОРЯДОЧИТЬ ПО - Движения.Период УБЫВ -`.trim(); +${orderClause} +`.trim().replace(/\n{3,}/g, "\n\n"); } async function probeVatDirectSources(filters) { const asOfDate = normalizeIsoDateForQuery(filters.as_of_date) ?? @@ -301,17 +363,30 @@ async function probeVatDirectSources(filters) { name_mask: nameMask, limit: VAT_METADATA_PROBE_LIMIT }))); - const metadataResponses = await Promise.all(metadataRequests.map((request) => (0, addressMcpClient_1.executeAddressMcpMetadata)(request))); + const metadataResponses = await mapWithConcurrency(metadataRequests, VAT_METADATA_PROBE_CONCURRENCY, (request) => executeVatMetadataProbeRequest(request)); + const metadataOutcomes = metadataResponses.map((response, index) => ({ + request: metadataRequests[index], + response + })); + const successfulMetadataByType = new Map(); const metadataErrors = []; const metadataObjectsBuffer = []; - for (const [index, response] of metadataResponses.entries()) { - const request = metadataRequests[index]; + for (const { request, response } of metadataOutcomes) { if (response.error) { - metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`); continue; } + const currentSuccessCount = successfulMetadataByType.get(request.meta_type) ?? 0; + successfulMetadataByType.set(request.meta_type, currentSuccessCount + 1); metadataObjectsBuffer.push(...extractVatMetadataObjects(response.rows)); } + for (const { request, response } of metadataOutcomes) { + if (response.error) { + if (isAbortErrorMessage(response.error) && (successfulMetadataByType.get(request.meta_type) ?? 0) > 0) { + continue; + } + metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`); + } + } const deduplicatedObjects = new Map(); for (const item of metadataObjectsBuffer) { const existing = deduplicatedObjects.get(item.fullName); @@ -326,46 +401,67 @@ async function probeVatDirectSources(filters) { }); } } - const discoveredMetadataObjects = Array.from(deduplicatedObjects.values()).sort((a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru")); + const discoveredMetadataObjects = Array.from(deduplicatedObjects.values()) + .filter((item) => isVatMetadataObject(item)) + .sort((a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru")); const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS); - const probeRows = []; - for (const object of metadataObjects) { - const probeQuery = buildVatObjectProbeQuery(object, asOfExpr); - const probeResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({ - query: probeQuery, - limit: 1 + const probeRows = await mapWithConcurrency(metadataObjects, VAT_OBJECT_PROBE_CONCURRENCY, async (object) => { + let probeResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + query: buildVatObjectProbeQuery(object, asOfExpr, "latest"), + limit: 1, + timeout_ms: VAT_OBJECT_PROBE_TIMEOUT_MS }); + let fallbackUsed = false; if (probeResult.error) { - probeRows.push({ - fullName: object.fullName, - synonym: object.synonym, - objectType: object.objectType, - status: "error", - rowsFetched: probeResult.fetched_rows, - error: probeResult.error + if (isAbortErrorMessage(probeResult.error)) { + return { + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: "error", + rowsFetched: probeResult.fetched_rows, + error: probeResult.error + }; + } + const fallbackResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + query: buildVatObjectProbeQuery(object, asOfExpr, "exists"), + limit: 1, + timeout_ms: VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS }); - continue; + if (!fallbackResult.error) { + probeResult = fallbackResult; + fallbackUsed = true; + } + else { + return { + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: "error", + rowsFetched: probeResult.fetched_rows, + error: `${probeResult.error}; fallback: ${fallbackResult.error}` + }; + } } const firstRow = probeResult.raw_rows[0] ?? null; const lastPeriod = firstRow !== null - ? valueAsString(firstRow.Период ?? firstRow.period).trim() || - null + ? valueAsString(firstRow.Период ?? firstRow.period).trim() || null : null; const sampleRegistrator = firstRow !== null ? valueAsString(firstRow.Регистратор ?? firstRow.registrator ?? firstRow.Registrator).trim() || null : null; - probeRows.push({ + return { fullName: object.fullName, synonym: object.synonym, objectType: object.objectType, status: probeResult.raw_rows.length > 0 ? "ok" : "empty", rowsFetched: probeResult.fetched_rows, - lastPeriod, + lastPeriod: fallbackUsed ? null : lastPeriod, sampleRegistrator - }); - } + }; + }); const status = metadataResponses.every((item) => item.error) ? "error" : "ok"; const allErrors = [ ...metadataErrors, @@ -909,7 +1005,8 @@ function isConfirmedBalanceIntent(intent) { intent === "documents_forming_balance" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || - intent === "vat_payable_confirmed_as_of_date"); + intent === "vat_payable_confirmed_as_of_date" || + intent === "vat_liability_confirmed_for_tax_period"); } function resolveAsOfDateBasis(filters) { const asOfDate = normalizeAnalysisDateHint(filters.as_of_date); @@ -1566,6 +1663,9 @@ function buildLimitedOffers(input) { else if (input.intent === "vat_payable_confirmed_as_of_date") { offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*"); } + else if (input.intent === "vat_liability_confirmed_for_tax_period") { + offers.push("показать подтвержденный расчет НДС к уплате за налоговый период по книгам продаж/покупок"); + } else if (input.intent === "payables_confirmed_as_of_date") { offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76"); } @@ -1613,7 +1713,8 @@ function buildLimitedIntentSignalLine(input) { list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.", - vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату." + vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату.", + vat_liability_confirmed_for_tax_period: "Сигнал запроса: нужен подтвержденный расчет НДС к уплате за налоговый период." }; const byShape = { AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.", @@ -1745,7 +1846,9 @@ function buildLimitedExecutionResult(input) { ? "exact_receivables_mode_limited_response" : input.intent.intent === "vat_payable_confirmed_as_of_date" ? "exact_vat_payable_mode_limited_response" - : null; + : input.intent.intent === "vat_liability_confirmed_for_tax_period" + ? "exact_vat_tax_period_mode_limited_response" + : null; const reasons = exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason) ? [...reasonsWithConfirmedFallback, exactLimitedReason] : reasonsWithConfirmedFallback; @@ -1973,6 +2076,10 @@ class AddressQueryService { !baseReasons.includes("confirmed_balance_exact_vat_payable_intent")) { baseReasons.push("confirmed_balance_exact_vat_payable_intent"); } + if (intent.intent === "vat_liability_confirmed_for_tax_period" && + !baseReasons.includes("confirmed_balance_exact_vat_tax_period_intent")) { + baseReasons.push("confirmed_balance_exact_vat_tax_period_intent"); + } if (requestedResultMode === "confirmed_balance" && recipeIntent === "open_items_by_counterparty_or_contract" && !baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) { @@ -2899,13 +3006,15 @@ class AddressQueryService { }); } const vatProbeRequired = composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "vat_liability_confirmed_for_tax_period" || (composeIntent === "vat_payable_forecast" && shouldProbeVatSourcesForForecast(userMessage)); const vatDirectSourceProbe = vatProbeRequired ? await probeVatDirectSources(executionFilters) : null; const shouldEmphasizeNumbers = composeIntent === "vat_payable_forecast" || composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "vat_liability_confirmed_for_tax_period" || composeIntent === "payables_confirmed_as_of_date" || composeIntent === "receivables_confirmed_as_of_date"; - const shouldUseRubCurrency = composeIntent === "vat_payable_forecast"; + const shouldUseRubCurrency = composeIntent === "vat_payable_forecast" || composeIntent === "vat_liability_confirmed_for_tax_period"; const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters, { vatDirectSourceProbe, emphasizeNumbers: shouldEmphasizeNumbers, @@ -2969,13 +3078,17 @@ class AddressQueryService { } const exactConfirmedIntent = (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date") || - (intent.intent === "vat_payable_confirmed_as_of_date" && composeIntent === "vat_payable_confirmed_as_of_date"); + (intent.intent === "vat_payable_confirmed_as_of_date" && composeIntent === "vat_payable_confirmed_as_of_date") || + (intent.intent === "vat_liability_confirmed_for_tax_period" && + composeIntent === "vat_liability_confirmed_for_tax_period"); if (exactConfirmedIntent && factualResultSemantics.balance_confirmed !== true) { const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : intent.intent === "receivables_confirmed_as_of_date" ? "receivables" - : "vat_payable"; + : intent.intent === "vat_liability_confirmed_for_tax_period" + ? "vat_tax_period" + : "vat_payable"; return buildLimitedExecutionResult({ mode, shape, @@ -3002,7 +3115,9 @@ class AddressQueryService { reasonText: `exact ${exactModeName} mode: confirmed balance was not proven for the requested as-of slice`, nextStep: intent.intent === "vat_payable_confirmed_as_of_date" ? "specify as_of_date/organization or provide VAT settlement registers to prove exact VAT payable balance" - : "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", + : intent.intent === "vat_liability_confirmed_for_tax_period" + ? "specify tax period boundaries and ensure purchase/sales VAT books are available via MCP" + : "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`], reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`], capabilityAudit, diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index ee23896..1faacda 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -480,6 +480,29 @@ __WHERE_CLAUSE__ УПОРЯДОЧИТЬ ПО Регистратор `; +const VAT_LIABILITY_CONFIRMED_TAX_PERIOD_QUERY_TEMPLATE = ` +ВЫБРАТЬ + ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, + "VAT_BOOK_SALES" КАК Регистратор, + "68.02" КАК СчетДт, + "" КАК СчетКт, + СУММА(ЕСТЬNULL(Движения.НДС, 0)) КАК Сумма +ИЗ + РегистрНакопления.НДСЗаписиКнигиПродаж КАК Движения +__WHERE_CLAUSE__ +ОБЪЕДИНИТЬ ВСЕ +ВЫБРАТЬ + ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, + "VAT_BOOK_PURCHASES" КАК Регистратор, + "19" КАК СчетДт, + "" КАК СчетКт, + СУММА(ЕСТЬNULL(Движения.НДС, 0)) КАК Сумма +ИЗ + РегистрНакопления.НДСЗаписиКнигиПокупок КАК Движения +__WHERE_CLAUSE__ +УПОРЯДОЧИТЬ ПО + Регистратор +`; const BASE_RECIPES = [ { recipe_id: "address_period_coverage_profile_v1", @@ -582,6 +605,16 @@ const BASE_RECIPES = [ account_scope_mode: "strict", query_template: "vat_payable_confirmed_as_of_balance_profile" }, + { + recipe_id: "address_vat_liability_confirmed_tax_period_v1", + intent: "vat_liability_confirmed_for_tax_period", + purpose: "Build confirmed VAT liability for tax period from purchase/sales VAT books", + required_filters: ["period_from", "period_to"], + optional_filters: ["organization"], + default_limit: 32, + account_scope_mode: "preferred", + query_template: "vat_liability_confirmed_tax_period_profile" + }, { recipe_id: "address_contracts_by_counterparty_v1", intent: "list_contracts_by_counterparty", @@ -887,6 +920,7 @@ function maxLimitForIntent(intent) { intent === "supplier_payouts_profile" || intent === "contract_usage_and_value" || intent === "vat_payable_forecast" || + intent === "vat_liability_confirmed_for_tax_period" || intent === "list_contracts_by_counterparty" || intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || @@ -926,7 +960,8 @@ function buildAddressRecipePlan(recipe, filters) { recipe.query_template === "document_section_profile" || recipe.query_template === "counterparty_roles_profile" || recipe.query_template === "contract_usage_profile" || - recipe.query_template === "vat_payable_forecast_profile"; + recipe.query_template === "vat_payable_forecast_profile" || + recipe.query_template === "vat_liability_confirmed_tax_period_profile"; const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit))) : recipe.default_limit; @@ -993,45 +1028,29 @@ function buildAddressRecipePlan(recipe, filters) { .replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_68_PREFIXES)) .replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_19_PREFIXES)) .replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", config_1.VAT_PAYABLE_19_PREFIXES)) - : recipe.query_template === "vat_payable_confirmed_as_of_balance_profile" - ? (() => { - const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 - ? toDateTimeExpr(filters.as_of_date, true) - : null) ?? - (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 - ? toDateTimeExpr(filters.period_to, true) + : recipe.query_template === "vat_liability_confirmed_tax_period_profile" + ? VAT_LIABILITY_CONFIRMED_TAX_PERIOD_QUERY_TEMPLATE.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период")) + : recipe.query_template === "vat_payable_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) : null) ?? - (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 - ? toDateTimeExpr(filters.period_from, true) - : null) ?? - "ТЕКУЩАЯДАТА()"; - return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE - .replaceAll("__LIMIT__", String(resolvedLimit)) - .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES)) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); - })() - : recipe.query_template === "contracts_by_counterparty_profile" - ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) - : recipe.query_template === "payables_confirmed_as_of_balance_profile" - ? (() => { - const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 - ? toDateTimeExpr(filters.as_of_date, true) + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) : null) ?? - (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 - ? toDateTimeExpr(filters.period_to, true) - : null) ?? - (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 - ? toDateTimeExpr(filters.period_from, true) - : null) ?? - "ТЕКУЩАЯДАТА()"; - return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE - .replaceAll("__LIMIT__", String(resolvedLimit)) - .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); - })() - : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES)) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : recipe.query_template === "contracts_by_counterparty_profile" + ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) + : recipe.query_template === "payables_confirmed_as_of_balance_profile" ? (() => { const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 ? toDateTimeExpr(filters.as_of_date, true) @@ -1043,23 +1062,41 @@ function buildAddressRecipePlan(recipe, filters) { ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() - : MOVEMENTS_QUERY_TEMPLATE - .replace("__LIMIT__", String(resolvedLimit)) - .replace("__WHERE_CLAUSE__", (() => { - const extraConditions = []; - const accountCondition = buildMovementAccountCondition(filters); - if (accountCondition) { - extraConditions.push(accountCondition); - } - return buildWhereClause(filters, "Движения.Период", extraConditions); - })()) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : MOVEMENTS_QUERY_TEMPLATE + .replace("__LIMIT__", String(resolvedLimit)) + .replace("__WHERE_CLAUSE__", (() => { + const extraConditions = []; + const accountCondition = buildMovementAccountCondition(filters); + if (accountCondition) { + extraConditions.push(accountCondition); + } + return buildWhereClause(filters, "Движения.Период", extraConditions); + })()) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); return { recipe, query, diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index a0b4d59..1a228d4 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -114,6 +114,9 @@ function emphasizeNumericTokens(line) { if (!line) { return line; } + const isDigit = (char) => /\d/.test(char); + const isLetter = (char) => /[A-Za-zА-Яа-яЁё]/.test(char); + const dateLikePunctuation = new Set([".", "-", "/", ":"]); const chunks = line.split(/(`[^`]*`)/g); return chunks .map((chunk, index) => { @@ -123,9 +126,23 @@ function emphasizeNumericTokens(line) { return chunk.replace(/\b-?(?:\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,]\d+)?\b/g, (match, offset, source) => { const before = offset > 0 ? source[offset - 1] : ""; const after = offset + match.length < source.length ? source[offset + match.length] : ""; + const before2 = offset > 1 ? source[offset - 2] : ""; + const after2 = offset + match.length + 1 < source.length ? source[offset + match.length + 1] : ""; if (before === "*" || after === "*") { return match; } + if (isLetter(before) || isLetter(after)) { + return match; + } + if (offset === 0 && (after === "." || after === ")")) { + return match; + } + if (dateLikePunctuation.has(before) && isDigit(before2)) { + return match; + } + if (dateLikePunctuation.has(after) && isDigit(after2)) { + return match; + } return `**${match}**`; }); }) @@ -1997,11 +2014,24 @@ function composeFactualReply(intent, rows, options = {}) { ]; if (vatProbe && vatProbe.status === "ok") { const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; - lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`); - if (vatProbe.probedSources.length > 0) { - lines.push(...vatProbe.probedSources.slice(0, 6).map((item, index) => { + const statusRank = (status) => status === "ok" ? 0 : status === "empty" ? 1 : 2; + const orderedProbeRows = [...vatProbe.probedSources].sort((a, b) => statusRank(a.status) - statusRank(b.status) || + a.fullName.localeCompare(b.fullName, "ru")); + const nonErrorProbeRows = orderedProbeRows.filter((item) => item.status !== "error"); + const visibleProbeRows = (nonErrorProbeRows.length > 0 ? nonErrorProbeRows : orderedProbeRows).slice(0, 6); + const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; + lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`); + if (visibleProbeRows.length > 0) { + lines.push(...visibleProbeRows.map((item, index) => { const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; - return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`; + const extra = item.status === "ok" + ? item.lastPeriod + ? ` | последнее движение: ${item.lastPeriod}` + : "" + : item.status === "error" && item.error + ? ` | ошибка: ${item.error}` + : ""; + return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${extra}`; })); } if (vatProbe.errors.length > 0) { @@ -2044,6 +2074,61 @@ function composeFactualReply(intent, rows, options = {}) { text: joinLines(lines) }; } + if (intent === "vat_liability_confirmed_for_tax_period") { + const rowsByMarker = new Map(); + for (const row of rows) { + const marker = String(row.registrator ?? "").trim().toUpperCase(); + if (!marker) { + continue; + } + const nextValue = (rowsByMarker.get(marker) ?? 0) + (row.amount ?? 0); + rowsByMarker.set(marker, nextValue); + } + const salesVat = rowsByMarker.get("VAT_BOOK_SALES") ?? 0; + const purchaseVat = rowsByMarker.get("VAT_BOOK_PURCHASES") ?? 0; + const netVat = salesVat - purchaseVat; + const vatToPay = Math.max(0, netVat); + const carryoverOrOverpayment = Math.max(0, -netVat); + const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null; + const formatConfirmedMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value)); + const vatProbe = options.vatDirectSourceProbe ?? null; + const lines = [ + `Собран подтвержденный расчет НДС к уплате за налоговый период: ${formatConfirmedMoney(vatToPay)}.`, + `Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`, + `Потенциальный перенос/переплата: ${formatConfirmedMoney(carryoverOrOverpayment)}.`, + "Режим результата: подтвержденный расчет по регистрам книг продаж/покупок (tax-period mode, без surrogate-формулы 68/19).", + "", + "База расчета:", + `- Строк агрегата: ${formatNumberWithDots(rows.length)}.`, + `- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`, + `- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`, + `- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.` + ]; + if (vatProbe && vatProbe.status === "ok") { + const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; + const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; + lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`); + if (vatProbe.errors.length > 0) { + lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); + } + lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников."); + } + else if (vatProbe && vatProbe.status === "error") { + lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, проверьте доступность регистров книг продаж/покупок."); + } + if (rows.length === 0) { + lines.push("", "За выбранный налоговый период не найдены строки книг продаж/покупок, поэтому подтвержденная сумма к уплате равна 0."); + } + return { + responseType: "FACTUAL_SUMMARY", + text: joinLines(lines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: "strong", + balance_confirmed: true + } + }; + } if (intent === "vat_payable_confirmed_as_of_date") { const asOfDate = resolvePayablesAsOfDate(options); const confirmedRows = rows.filter((row) => { diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 8a98d0c..675fe01 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -36,6 +36,9 @@ function hasVatCue(text) { function hasVatForecastCue(text) { return /(?:прогноз|forecast|прикин|оцен|план)/iu.test(String(text ?? "")); } +function hasVatTaxPaymentCue(text) { + return /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|в\s+налогов|в\s+бюджет)/iu.test(String(text ?? "")); +} function hasDocumentSignal(text) { return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? "")); } @@ -432,7 +435,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage); const currentHasPeriod = hasExplicitPeriodWindow(merged); const previousHasPeriod = hasExplicitPeriodWindow(previous); - if (intent === "vat_payable_forecast" && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) { + if ((intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period") && + previousHasPeriod && + hasFollowupSignal && + !hasExplicitPeriodInMessage) { const currentPeriodFrom = toNonEmptyString(merged.period_from); const currentPeriodTo = toNonEmptyString(merged.period_to); const todayIso = new Date().toISOString().slice(0, 10); @@ -465,6 +471,7 @@ function resolveMissingRequiredFilters(intent, filters) { payables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"], vat_payable_confirmed_as_of_date: ["as_of_date"], + vat_liability_confirmed_for_tax_period: ["period_from", "period_to"], list_documents_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"], @@ -497,9 +504,11 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty; const isVatFollowup = hasVatCue(normalizedMessage); if (detectedIntent.intent === "unknown" && isVatFollowup) { - const vatIntent = hasVatForecastCue(normalizedMessage) - ? "vat_payable_forecast" - : "vat_payable_confirmed_as_of_date"; + const vatIntent = hasVatTaxPaymentCue(normalizedMessage) + ? "vat_liability_confirmed_for_tax_period" + : hasVatForecastCue(normalizedMessage) + ? "vat_payable_forecast" + : "vat_payable_confirmed_as_of_date"; return { intent: vatIntent, confidence: "low", diff --git a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js index 1492515..429bdde 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js +++ b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js @@ -95,7 +95,8 @@ function inferAggregationProfile(intent, shape) { intent === "documents_forming_balance" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || - intent === "vat_payable_confirmed_as_of_date") { + intent === "vat_payable_confirmed_as_of_date" || + intent === "vat_liability_confirmed_for_tax_period") { return "balance_snapshot"; } if (intent === "open_items_by_counterparty_or_contract" || diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 0b70ceb..36cfb91 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3835,6 +3835,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "contract_usage_overview", "contract_usage_and_value", "vat_payable_forecast", + "vat_liability_confirmed_for_tax_period", "vat_payable_confirmed_as_of_date" ]); function resolveAssistantOrchestrationDecision(input) { diff --git a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts index 207bf64..9e462b5 100644 --- a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts +++ b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts @@ -28,7 +28,8 @@ const COMPUTE_EXACT_INTENTS = new Set([ "documents_forming_balance", "payables_confirmed_as_of_date", "receivables_confirmed_as_of_date", - "vat_payable_confirmed_as_of_date" + "vat_payable_confirmed_as_of_date", + "vat_liability_confirmed_for_tax_period" ]); const NAVIGATION_INTENTS = new Set([ "list_documents_by_counterparty", @@ -66,6 +67,9 @@ function defaultCapabilityId(intent: AddressIntent): string { if (intent === "vat_payable_confirmed_as_of_date") { return "confirmed_vat_payable_as_of_date"; } + if (intent === "vat_liability_confirmed_for_tax_period") { + return "confirmed_vat_liability_for_tax_period"; + } if (intent === "list_payables_counterparties") { return "payables_candidates_list"; } @@ -110,6 +114,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re : "vat_payable_confirmed_route_disabled_by_flag" }; } + if (intent === "vat_liability_confirmed_for_tax_period") { + return { + enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, + reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 + ? "vat_liability_confirmed_tax_period_route_enabled" + : "vat_liability_confirmed_tax_period_route_disabled_by_flag" + }; + } if (intent === "list_payables_counterparties") { return { enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 4ce2b33..489883e 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -932,6 +932,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array { const endpoint = buildMcpUrl("/api/execute_query"); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), Math.max(300, ASSISTANT_MCP_TIMEOUT_MS)); + const resolvedTimeoutMs = + typeof input.timeout_ms === "number" && Number.isFinite(input.timeout_ms) + ? Math.max(300, Math.trunc(input.timeout_ms)) + : Math.max(300, ASSISTANT_MCP_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs); try { const response = await fetch(endpoint, { method: "POST", @@ -373,10 +378,15 @@ export async function executeAddressMcpMetadata(input: { offset?: number; sections?: string[]; extension_name?: string | null; + timeout_ms?: number; }): Promise { const endpoint = buildMcpUrl("/api/get_metadata"); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), Math.max(300, ASSISTANT_MCP_TIMEOUT_MS)); + const resolvedTimeoutMs = + typeof input.timeout_ms === "number" && Number.isFinite(input.timeout_ms) + ? Math.max(300, Math.trunc(input.timeout_ms)) + : Math.max(300, ASSISTANT_MCP_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs); try { const body: Record = {}; if (typeof input.filter === "string" && input.filter.trim().length > 0) { diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts index 7551b1f..4b5fc0f 100644 --- a/llm_normalizer/backend/src/services/addressNavigationState.ts +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -39,6 +39,7 @@ const RESULT_SET_TYPE_BY_INTENT: Partial>): VatMet return out; } +function isVatMetadataObject(item: VatMetadataObject): boolean { + const source = `${item.fullName} ${item.synonym ?? ""}`.toLowerCase().replace(/ё/g, "е"); + if (source.includes("ндфл")) { + return false; + } + return /(?:ндс|книгапокуп|книгапродаж|счет[\s-]?фактур)/iu.test(source); +} + +function isAbortErrorMessage(error: string | null | undefined): boolean { + const normalized = String(error ?? "").toLowerCase(); + if (!normalized) { + return false; + } + return normalized.includes("aborted") || normalized.includes("abort"); +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise +): Promise { + if (items.length === 0) { + return []; + } + const boundedConcurrency = Math.max(1, Math.min(Math.trunc(concurrency), items.length)); + const results = new Array(items.length); + let nextIndex = 0; + const runners = Array.from({ length: boundedConcurrency }, async () => { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + if (currentIndex >= items.length) { + break; + } + results[currentIndex] = await worker(items[currentIndex], currentIndex); + } + }); + await Promise.all(runners); + return results; +} + +async function executeVatMetadataProbeRequest(request: { + meta_type: string; + name_mask: string; + limit: number; +}): Promise { + const firstAttempt = await executeAddressMcpMetadata({ + ...request, + timeout_ms: VAT_METADATA_PROBE_TIMEOUT_MS + }); + if (!firstAttempt.error || !isAbortErrorMessage(firstAttempt.error)) { + return firstAttempt; + } + + const retryLimit = Math.max(20, Math.min(request.limit, Math.trunc(request.limit / 2))); + const retryAttempt = await executeAddressMcpMetadata({ + ...request, + limit: retryLimit, + timeout_ms: VAT_METADATA_PROBE_RETRY_TIMEOUT_MS + }); + if (!retryAttempt.error) { + return retryAttempt; + } + + return { + ...retryAttempt, + error: `${firstAttempt.error}; retry: ${retryAttempt.error}` + }; +} + function scoreVatMetadataObject(item: VatMetadataObject): number { const fullName = item.fullName.toLowerCase(); const synonym = String(item.synonym ?? "").toLowerCase(); @@ -340,8 +420,12 @@ function scoreVatMetadataObject(item: VatMetadataObject): number { return score; } -function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string): string { +type VatObjectProbeMode = "latest" | "exists"; + +function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string, mode: VatObjectProbeMode = "latest"): string { + const orderClause = mode === "latest" ? "\nУПОРЯДОЧИТЬ ПО\n Движения.Период УБЫВ" : ""; if (object.objectType === "document") { + const documentOrderClause = mode === "latest" ? "\nУПОРЯДОЧИТЬ ПО\n Док.Дата УБЫВ" : ""; return ` ВЫБРАТЬ ПЕРВЫЕ 1 Док.Дата КАК Период, @@ -353,9 +437,8 @@ function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string): ${object.fullName} КАК Док ГДЕ Док.Дата <= ${asOfExpr} -УПОРЯДОЧИТЬ ПО - Док.Дата УБЫВ -`.trim(); +${documentOrderClause} +`.trim().replace(/\n{3,}/g, "\n\n"); } return ` ВЫБРАТЬ ПЕРВЫЕ 1 @@ -368,9 +451,8 @@ function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string): ${object.fullName} КАК Движения ГДЕ Движения.Период <= ${asOfExpr} -УПОРЯДОЧИТЬ ПО - Движения.Период УБЫВ -`.trim(); +${orderClause} +`.trim().replace(/\n{3,}/g, "\n\n"); } async function probeVatDirectSources(filters: AddressFilterSet): Promise { @@ -409,18 +491,35 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise executeAddressMcpMetadata(request))); + const metadataResponses = await mapWithConcurrency( + metadataRequests, + VAT_METADATA_PROBE_CONCURRENCY, + (request) => executeVatMetadataProbeRequest(request) + ); + const metadataOutcomes = metadataResponses.map((response, index) => ({ + request: metadataRequests[index], + response + })); + const successfulMetadataByType = new Map(); const metadataErrors: string[] = []; const metadataObjectsBuffer: VatMetadataObject[] = []; - for (const [index, response] of metadataResponses.entries()) { - const request = metadataRequests[index]; + for (const { request, response } of metadataOutcomes) { if (response.error) { - metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`); continue; } + const currentSuccessCount = successfulMetadataByType.get(request.meta_type) ?? 0; + successfulMetadataByType.set(request.meta_type, currentSuccessCount + 1); metadataObjectsBuffer.push(...extractVatMetadataObjects(response.rows)); } + for (const { request, response } of metadataOutcomes) { + if (response.error) { + if (isAbortErrorMessage(response.error) && (successfulMetadataByType.get(request.meta_type) ?? 0) > 0) { + continue; + } + metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`); + } + } const deduplicatedObjects = new Map(); for (const item of metadataObjectsBuffer) { @@ -437,54 +536,79 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise isVatMetadataObject(item)) + .sort( (a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru") ); const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS); - const probeRows: VatDirectSourceProbeItem[] = []; - for (const object of metadataObjects) { - const probeQuery = buildVatObjectProbeQuery(object, asOfExpr); - const probeResult = await executeAddressMcpQuery({ - query: probeQuery, - limit: 1 - }); - if (probeResult.error) { - probeRows.push({ + const probeRows = await mapWithConcurrency( + metadataObjects, + VAT_OBJECT_PROBE_CONCURRENCY, + async (object): Promise => { + let probeResult = await executeAddressMcpQuery({ + query: buildVatObjectProbeQuery(object, asOfExpr, "latest"), + limit: 1, + timeout_ms: VAT_OBJECT_PROBE_TIMEOUT_MS + }); + let fallbackUsed = false; + if (probeResult.error) { + if (isAbortErrorMessage(probeResult.error)) { + return { + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: "error", + rowsFetched: probeResult.fetched_rows, + error: probeResult.error + }; + } + const fallbackResult = await executeAddressMcpQuery({ + query: buildVatObjectProbeQuery(object, asOfExpr, "exists"), + limit: 1, + timeout_ms: VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS + }); + if (!fallbackResult.error) { + probeResult = fallbackResult; + fallbackUsed = true; + } else { + return { + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: "error", + rowsFetched: probeResult.fetched_rows, + error: `${probeResult.error}; fallback: ${fallbackResult.error}` + }; + } + } + const firstRow = probeResult.raw_rows[0] ?? null; + const lastPeriod = + firstRow !== null + ? valueAsString( + (firstRow as Record).Период ?? (firstRow as Record).period + ).trim() || null + : null; + const sampleRegistrator = + firstRow !== null + ? valueAsString( + (firstRow as Record).Регистратор ?? + (firstRow as Record).registrator ?? + (firstRow as Record).Registrator + ).trim() || null + : null; + return { fullName: object.fullName, synonym: object.synonym, objectType: object.objectType, - status: "error", + status: probeResult.raw_rows.length > 0 ? "ok" : "empty", rowsFetched: probeResult.fetched_rows, - error: probeResult.error - }); - continue; + lastPeriod: fallbackUsed ? null : lastPeriod, + sampleRegistrator + }; } - - const firstRow = probeResult.raw_rows[0] ?? null; - const lastPeriod = - firstRow !== null - ? valueAsString((firstRow as Record).Период ?? (firstRow as Record).period).trim() || - null - : null; - const sampleRegistrator = - firstRow !== null - ? valueAsString( - (firstRow as Record).Регистратор ?? - (firstRow as Record).registrator ?? - (firstRow as Record).Registrator - ).trim() || null - : null; - probeRows.push({ - fullName: object.fullName, - synonym: object.synonym, - objectType: object.objectType, - status: probeResult.raw_rows.length > 0 ? "ok" : "empty", - rowsFetched: probeResult.fetched_rows, - lastPeriod, - sampleRegistrator - }); - } + ); const status: VatDirectSourceProbeSummary["status"] = metadataResponses.every((item) => item.error) ? "error" : "ok"; const allErrors = [ @@ -1106,7 +1230,8 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean { intent === "documents_forming_balance" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || - intent === "vat_payable_confirmed_as_of_date" + intent === "vat_payable_confirmed_as_of_date" || + intent === "vat_liability_confirmed_for_tax_period" ); } @@ -1942,6 +2067,8 @@ function buildLimitedOffers(input: { offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); } else if (input.intent === "vat_payable_confirmed_as_of_date") { offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*"); + } else if (input.intent === "vat_liability_confirmed_for_tax_period") { + offers.push("показать подтвержденный расчет НДС к уплате за налоговый период по книгам продаж/покупок"); } else if (input.intent === "payables_confirmed_as_of_date") { offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76"); } else if (input.intent === "list_payables_counterparties") { @@ -1996,7 +2123,8 @@ function buildLimitedIntentSignalLine(input: { list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.", - vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату." + vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату.", + vat_liability_confirmed_for_tax_period: "Сигнал запроса: нужен подтвержденный расчет НДС к уплате за налоговый период." }; const byShape: Partial> = { @@ -2201,6 +2329,8 @@ function buildLimitedExecutionResult(input: { ? "exact_receivables_mode_limited_response" : input.intent.intent === "vat_payable_confirmed_as_of_date" ? "exact_vat_payable_mode_limited_response" + : input.intent.intent === "vat_liability_confirmed_for_tax_period" + ? "exact_vat_tax_period_mode_limited_response" : null; const reasons = exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason) @@ -2460,6 +2590,12 @@ export class AddressQueryService { ) { baseReasons.push("confirmed_balance_exact_vat_payable_intent"); } + if ( + intent.intent === "vat_liability_confirmed_for_tax_period" && + !baseReasons.includes("confirmed_balance_exact_vat_tax_period_intent") + ) { + baseReasons.push("confirmed_balance_exact_vat_tax_period_intent"); + } if ( requestedResultMode === "confirmed_balance" && recipeIntent === "open_items_by_counterparty_or_contract" && @@ -3519,14 +3655,17 @@ export class AddressQueryService { const vatProbeRequired = composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "vat_liability_confirmed_for_tax_period" || (composeIntent === "vat_payable_forecast" && shouldProbeVatSourcesForForecast(userMessage)); const vatDirectSourceProbe = vatProbeRequired ? await probeVatDirectSources(executionFilters) : null; const shouldEmphasizeNumbers = composeIntent === "vat_payable_forecast" || composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "vat_liability_confirmed_for_tax_period" || composeIntent === "payables_confirmed_as_of_date" || composeIntent === "receivables_confirmed_as_of_date"; - const shouldUseRubCurrency = composeIntent === "vat_payable_forecast"; + const shouldUseRubCurrency = + composeIntent === "vat_payable_forecast" || composeIntent === "vat_liability_confirmed_for_tax_period"; const factual = composeFactualReply( composeIntent, filteredRows, @@ -3599,14 +3738,18 @@ export class AddressQueryService { const exactConfirmedIntent = (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date") || - (intent.intent === "vat_payable_confirmed_as_of_date" && composeIntent === "vat_payable_confirmed_as_of_date"); + (intent.intent === "vat_payable_confirmed_as_of_date" && composeIntent === "vat_payable_confirmed_as_of_date") || + (intent.intent === "vat_liability_confirmed_for_tax_period" && + composeIntent === "vat_liability_confirmed_for_tax_period"); if (exactConfirmedIntent && factualResultSemantics.balance_confirmed !== true) { const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : intent.intent === "receivables_confirmed_as_of_date" ? "receivables" - : "vat_payable"; + : intent.intent === "vat_liability_confirmed_for_tax_period" + ? "vat_tax_period" + : "vat_payable"; return buildLimitedExecutionResult({ mode, shape, @@ -3634,6 +3777,8 @@ export class AddressQueryService { nextStep: intent.intent === "vat_payable_confirmed_as_of_date" ? "specify as_of_date/organization or provide VAT settlement registers to prove exact VAT payable balance" + : intent.intent === "vat_liability_confirmed_for_tax_period" + ? "specify tax period boundaries and ensure purchase/sales VAT books are available via MCP" : "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`], reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`], diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 6c0df1d..9281ce9 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -498,6 +498,30 @@ __WHERE_CLAUSE__ Регистратор `; +const VAT_LIABILITY_CONFIRMED_TAX_PERIOD_QUERY_TEMPLATE = ` +ВЫБРАТЬ + ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, + "VAT_BOOK_SALES" КАК Регистратор, + "68.02" КАК СчетДт, + "" КАК СчетКт, + СУММА(ЕСТЬNULL(Движения.НДС, 0)) КАК Сумма +ИЗ + РегистрНакопления.НДСЗаписиКнигиПродаж КАК Движения +__WHERE_CLAUSE__ +ОБЪЕДИНИТЬ ВСЕ +ВЫБРАТЬ + ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, + "VAT_BOOK_PURCHASES" КАК Регистратор, + "19" КАК СчетДт, + "" КАК СчетКт, + СУММА(ЕСТЬNULL(Движения.НДС, 0)) КАК Сумма +ИЗ + РегистрНакопления.НДСЗаписиКнигиПокупок КАК Движения +__WHERE_CLAUSE__ +УПОРЯДОЧИТЬ ПО + Регистратор +`; + const BASE_RECIPES: AddressRecipeDefinition[] = [ { recipe_id: "address_period_coverage_profile_v1", @@ -600,6 +624,16 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "strict", query_template: "vat_payable_confirmed_as_of_balance_profile" }, + { + recipe_id: "address_vat_liability_confirmed_tax_period_v1", + intent: "vat_liability_confirmed_for_tax_period", + purpose: "Build confirmed VAT liability for tax period from purchase/sales VAT books", + required_filters: ["period_from", "period_to"], + optional_filters: ["organization"], + default_limit: 32, + account_scope_mode: "preferred", + query_template: "vat_liability_confirmed_tax_period_profile" + }, { recipe_id: "address_contracts_by_counterparty_v1", intent: "list_contracts_by_counterparty", @@ -947,6 +981,7 @@ function maxLimitForIntent(intent: AddressIntent): number { intent === "supplier_payouts_profile" || intent === "contract_usage_and_value" || intent === "vat_payable_forecast" || + intent === "vat_liability_confirmed_for_tax_period" || intent === "list_contracts_by_counterparty" || intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || @@ -996,7 +1031,8 @@ export function buildAddressRecipePlan( recipe.query_template === "document_section_profile" || recipe.query_template === "counterparty_roles_profile" || recipe.query_template === "contract_usage_profile" || - recipe.query_template === "vat_payable_forecast_profile"; + recipe.query_template === "vat_payable_forecast_profile" || + recipe.query_template === "vat_liability_confirmed_tax_period_profile"; const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit))) @@ -1091,6 +1127,11 @@ export function buildAddressRecipePlan( .replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES)) .replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_19_PREFIXES)) .replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES)) + : recipe.query_template === "vat_liability_confirmed_tax_period_profile" + ? VAT_LIABILITY_CONFIRMED_TAX_PERIOD_QUERY_TEMPLATE.replaceAll( + "__WHERE_CLAUSE__", + buildManagementWhereClause(filters, "Движения.Период") + ) : recipe.query_template === "vat_payable_confirmed_as_of_balance_profile" ? (() => { const asOfExpr = diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 73f3e68..e5530c4 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -212,6 +212,9 @@ function emphasizeNumericTokens(line: string): string { if (!line) { return line; } + const isDigit = (char: string): boolean => /\d/.test(char); + const isLetter = (char: string): boolean => /[A-Za-zА-Яа-яЁё]/.test(char); + const dateLikePunctuation = new Set([".", "-", "/", ":"]); const chunks = line.split(/(`[^`]*`)/g); return chunks .map((chunk, index) => { @@ -221,9 +224,23 @@ function emphasizeNumericTokens(line: string): string { return chunk.replace(/\b-?(?:\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,]\d+)?\b/g, (match, offset, source) => { const before = offset > 0 ? source[offset - 1] : ""; const after = offset + match.length < source.length ? source[offset + match.length] : ""; + const before2 = offset > 1 ? source[offset - 2] : ""; + const after2 = offset + match.length + 1 < source.length ? source[offset + match.length + 1] : ""; if (before === "*" || after === "*") { return match; } + if (isLetter(before) || isLetter(after)) { + return match; + } + if (offset === 0 && (after === "." || after === ")")) { + return match; + } + if (dateLikePunctuation.has(before) && isDigit(before2)) { + return match; + } + if (dateLikePunctuation.has(after) && isDigit(after2)) { + return match; + } return `**${match}**`; }); }) @@ -2545,18 +2562,37 @@ export function composeFactualReply( if (vatProbe && vatProbe.status === "ok") { const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; + const statusRank = (status: VatDirectSourceProbeItem["status"]): number => + status === "ok" ? 0 : status === "empty" ? 1 : 2; + const orderedProbeRows = [...vatProbe.probedSources].sort( + (a, b) => + statusRank(a.status) - statusRank(b.status) || + a.fullName.localeCompare(b.fullName, "ru") + ); + const nonErrorProbeRows = orderedProbeRows.filter((item) => item.status !== "error"); + const visibleProbeRows = (nonErrorProbeRows.length > 0 ? nonErrorProbeRows : orderedProbeRows).slice(0, 6); + const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; lines.push( "", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, - `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.` + `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, + `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.` ); - if (vatProbe.probedSources.length > 0) { + if (visibleProbeRows.length > 0) { lines.push( - ...vatProbe.probedSources.slice(0, 6).map((item, index) => { + ...visibleProbeRows.map((item, index) => { const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; - return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`; + const extra = + item.status === "ok" + ? item.lastPeriod + ? ` | последнее движение: ${item.lastPeriod}` + : "" + : item.status === "error" && item.error + ? ` | ошибка: ${item.error}` + : ""; + return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${extra}`; }) ); } @@ -2632,6 +2668,77 @@ export function composeFactualReply( }; } + if (intent === "vat_liability_confirmed_for_tax_period") { + const rowsByMarker = new Map(); + for (const row of rows) { + const marker = String(row.registrator ?? "").trim().toUpperCase(); + if (!marker) { + continue; + } + const nextValue = (rowsByMarker.get(marker) ?? 0) + (row.amount ?? 0); + rowsByMarker.set(marker, nextValue); + } + + const salesVat = rowsByMarker.get("VAT_BOOK_SALES") ?? 0; + const purchaseVat = rowsByMarker.get("VAT_BOOK_PURCHASES") ?? 0; + const netVat = salesVat - purchaseVat; + const vatToPay = Math.max(0, netVat); + const carryoverOrOverpayment = Math.max(0, -netVat); + const periodWindowLabel = + options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null; + const formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value)); + const vatProbe = options.vatDirectSourceProbe ?? null; + + const lines = [ + `Собран подтвержденный расчет НДС к уплате за налоговый период: ${formatConfirmedMoney(vatToPay)}.`, + `Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`, + `Потенциальный перенос/переплата: ${formatConfirmedMoney(carryoverOrOverpayment)}.`, + "Режим результата: подтвержденный расчет по регистрам книг продаж/покупок (tax-period mode, без surrogate-формулы 68/19).", + "", + "База расчета:", + `- Строк агрегата: ${formatNumberWithDots(rows.length)}.`, + `- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`, + `- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`, + `- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.` + ]; + + if (vatProbe && vatProbe.status === "ok") { + const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; + const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; + lines.push( + "", + "Покрытие VAT-источников через MCP:", + `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, + `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, + `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, + `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.` + ); + if (vatProbe.errors.length > 0) { + lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); + } + lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников."); + } else if (vatProbe && vatProbe.status === "error") { + lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, проверьте доступность регистров книг продаж/покупок."); + } + + if (rows.length === 0) { + lines.push( + "", + "За выбранный налоговый период не найдены строки книг продаж/покупок, поэтому подтвержденная сумма к уплате равна 0." + ); + } + + return { + responseType: "FACTUAL_SUMMARY", + text: joinLines(lines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: "strong", + balance_confirmed: true + } + }; + } + if (intent === "vat_payable_confirmed_as_of_date") { const asOfDate = resolvePayablesAsOfDate(options); const confirmedRows = rows.filter((row) => { diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 0e94d88..578f074 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -74,6 +74,10 @@ function hasVatForecastCue(text: string): boolean { return /(?:прогноз|forecast|прикин|оцен|план)/iu.test(String(text ?? "")); } +function hasVatTaxPaymentCue(text: string): boolean { + return /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|в\s+налогов|в\s+бюджет)/iu.test(String(text ?? "")); +} + function hasDocumentSignal(text: string): boolean { return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? "")); } @@ -534,7 +538,12 @@ function mergeFollowupFilters( const currentHasPeriod = hasExplicitPeriodWindow(merged); const previousHasPeriod = hasExplicitPeriodWindow(previous); - if (intent === "vat_payable_forecast" && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) { + if ( + (intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period") && + previousHasPeriod && + hasFollowupSignal && + !hasExplicitPeriodInMessage + ) { const currentPeriodFrom = toNonEmptyString(merged.period_from); const currentPeriodTo = toNonEmptyString(merged.period_to); const todayIso = new Date().toISOString().slice(0, 10); @@ -570,6 +579,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi payables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"], vat_payable_confirmed_as_of_date: ["as_of_date"], + vat_liability_confirmed_for_tax_period: ["period_from", "period_to"], list_documents_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"], @@ -611,9 +621,11 @@ function deriveIntentWithFollowupContext( const isVatFollowup = hasVatCue(normalizedMessage); if (detectedIntent.intent === "unknown" && isVatFollowup) { - const vatIntent: AddressIntent = hasVatForecastCue(normalizedMessage) - ? "vat_payable_forecast" - : "vat_payable_confirmed_as_of_date"; + const vatIntent: AddressIntent = hasVatTaxPaymentCue(normalizedMessage) + ? "vat_liability_confirmed_for_tax_period" + : hasVatForecastCue(normalizedMessage) + ? "vat_payable_forecast" + : "vat_payable_confirmed_as_of_date"; return { intent: vatIntent, confidence: "low", diff --git a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts index 2a1d334..6eb9987 100644 --- a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts +++ b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts @@ -194,7 +194,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape intent === "documents_forming_balance" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || - intent === "vat_payable_confirmed_as_of_date" + intent === "vat_payable_confirmed_as_of_date" || + intent === "vat_liability_confirmed_for_tax_period" ) { return "balance_snapshot"; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 906741f..7e425c6 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3793,6 +3793,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "contract_usage_overview", "contract_usage_and_value", "vat_payable_forecast", + "vat_liability_confirmed_for_tax_period", "vat_payable_confirmed_as_of_date" ]); export function resolveAssistantOrchestrationDecision(input) { diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index 9109905..a079615 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -10,6 +10,7 @@ export type AddressIntent = | "supplier_payouts_profile" | "contract_usage_and_value" | "vat_payable_forecast" + | "vat_liability_confirmed_for_tax_period" | "vat_payable_confirmed_as_of_date" | "list_contracts_by_counterparty" | "list_open_contracts" @@ -132,6 +133,7 @@ export interface AddressRecipeDefinition { | "contract_value_profile" | "contracts_by_counterparty_profile" | "vat_payable_forecast_profile" + | "vat_liability_confirmed_tax_period_profile" | "vat_payable_confirmed_as_of_balance_profile" | "payables_confirmed_as_of_balance_profile" | "receivables_confirmed_as_of_balance_profile"; diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index bee20a9..c3ffa7e 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -1031,7 +1031,7 @@ describe("address compose stage utf8 headers", () => { expect(reply.text).toContain("Профиль договорной базы собран"); expect(reply.text).toContain("Всего договоров в базе: 520."); - expect(reply.text).toContain("�?Использованных договоров (есть factual связь с операциями): 148."); + expect(reply.text).toContain("Использованных договоров (есть factual связь с операциями): 148."); expect(reply.text).toContain("Неиспользуемых договоров: 372."); }); @@ -1458,6 +1458,42 @@ describe("address compose stage utf8 headers", () => { expect(reply.text).toContain("РегистрНакопления.НДСПродажи"); }); + it("builds confirmed VAT tax-period reply from sales and purchase book markers", () => { + const reply = composeFactualReply( + "vat_liability_confirmed_for_tax_period", + [ + { + period: "2019-12-31T23:59:59Z", + registrator: "VAT_BOOK_SALES", + account_dt: "68.02", + account_kt: "", + amount: 120000, + analytics: [] + }, + { + period: "2019-12-31T23:59:59Z", + registrator: "VAT_BOOK_PURCHASES", + account_dt: "19", + account_kt: "", + amount: 70000, + analytics: [] + } + ], + { + userMessage: "сколько платить ндс в налоговую за декабрь 2019", + periodFrom: "2019-10-01", + periodTo: "2019-12-31", + useRubCurrency: true + } + ); + + expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text).toContain("Собран подтвержденный расчет НДС к уплате за налоговый период"); + expect(reply.text).toContain("50.000,00 ₽"); + expect(reply.semantics?.result_mode).toBe("confirmed_balance"); + expect(reply.semantics?.balance_confirmed).toBe(true); + }); + it("formats VAT forecast amounts in rubles and emphasizes numbers when requested", () => { const reply = composeFactualReply( "vat_payable_forecast", @@ -1482,6 +1518,80 @@ describe("address compose stage utf8 headers", () => { expect(reply.text).toContain("Собран прогноз НДС к уплате:"); }); + it("does not split dates and list numbering when numeric emphasis is enabled", () => { + const reply = composeFactualReply( + "vat_payable_forecast", + [ + { + period: "2019-12-31T23:59:59Z", + registrator: "VAT_68_CREDIT", + account_dt: "68", + account_kt: "", + amount: 0, + analytics: [] + }, + { + period: "2019-12-31T23:59:59Z", + registrator: "VAT_68_DEBIT", + account_dt: "68", + account_kt: "", + amount: 0, + analytics: [] + } + ], + { + userMessage: "почему по ндс ноль", + periodFrom: "2019-12-01", + periodTo: "2019-12-31", + emphasizeNumbers: true + } + ); + + expect(reply.text).toContain("Период оценки: 01.12.2019..31.12.2019."); + expect(reply.text).not.toContain("**01**.**12**.**2019**"); + expect(reply.text).toContain("1) Проверьте ОСВ/анализ счета"); + expect(reply.text).not.toContain("**1**)"); + }); + + it("keeps VAT probe timestamps intact when numeric emphasis is enabled", () => { + const reply = composeFactualReply( + "vat_payable_forecast", + [ + { + period: "2019-12-31T23:59:59Z", + registrator: "VAT_68_CREDIT", + account_dt: "68", + account_kt: "", + amount: 1000, + analytics: [] + } + ], + { + userMessage: "прикинь ндс", + emphasizeNumbers: true, + vatDirectSourceProbe: { + status: "ok", + objectsTotal: 1, + documentsTotal: 0, + registersTotal: 1, + probedSources: [ + { + fullName: "РегистрНакопления.НДСПредъявленный", + objectType: "register", + status: "ok", + rowsFetched: 1, + lastPeriod: "2019-12-31T23:59:59Z" + } + ], + errors: [] + } + } + ); + + expect(reply.text).toContain("последнее движение: 2019-12-31T23:59:59Z"); + expect(reply.text).not.toContain("2019****-12**-31T23:**59**:59Z"); + }); + it("adds MCP VAT source probe block for confirmed VAT as-of response", () => { const reply = composeFactualReply( "vat_payable_confirmed_as_of_date", @@ -1972,7 +2082,7 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.reasons).toContain("forecast_tax_signal_detected"); }); - it("resolves colloquial VAT payable estimate wording without explicit 'прогноз'", () => { + it("keeps colloquial VAT payment wording in forecast intent when tax-authority cue is absent", () => { const result = resolveAddressIntent("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года"); expect(result.intent).toBe("vat_payable_forecast"); expect(result.reasons).toContain("forecast_tax_signal_detected"); @@ -2215,6 +2325,16 @@ describe("address filter extraction for balance drilldown", () => { expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast"); }); + it("derives full tax quarter window for confirmed VAT tax-period intent from month phrase", () => { + const extracted = extractAddressFilters( + "сколько ндс надо заплатить в налоговую за декабрь 2019", + "vat_liability_confirmed_for_tax_period" + ); + expect(extracted.extracted_filters.period_from).toBe("2019-10-01"); + expect(extracted.extracted_filters.period_to).toBe("2019-12-31"); + expect(extracted.warnings).toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability"); + }); + it("derives VAT forecast quarter-to-date window for explicit day+month+year phrase", () => { const extracted = extractAddressFilters( "сколько НДС нужно заплатить за 5 марта 2017 года", @@ -2503,7 +2623,7 @@ describe("address filter extraction for balance drilldown", () => { it("repairs mojibake phrase before extracting counterparty filters", () => { const result = extractAddressFilters( - "Показать РґРѕРєСѓР�?енты РЎР’Рљ Р·Р° 2020 РіРѕРґ.", + "Показать документы РЎР’Рљ Р·Р° 2020 РіРѕРґ.", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("СВК"); @@ -3050,10 +3170,11 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.selected_recipe).toBe("address_vat_payable_forecast_v1"); expect(result?.debug.limited_reason_category).not.toBe("unsupported"); expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(result?.debug.route_expectation_status).toBe("matched"); expect(result?.debug.extracted_filters.counterparty).toBeUndefined(); }); - it("routes colloquial VAT payable estimate wording into VAT forecast recipe", async () => { + it("routes colloquial VAT payment wording without tax-authority cue into VAT forecast recipe", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года"); expect(result?.handled).toBe(true); @@ -3061,6 +3182,18 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.selected_recipe).toBe("address_vat_payable_forecast_v1"); expect(result?.debug.limited_reason_category).not.toBe("unsupported"); expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(result?.debug.route_expectation_status).toBe("matched"); + }); + + it("routes 'в налоговую за декабрь' VAT wording into confirmed tax-period route", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("прикинь скок надо заплатить ндс в налоговую на декабрь 2019"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("vat_liability_confirmed_for_tax_period"); + expect(result?.debug.selected_recipe).toBe("address_vat_liability_confirmed_tax_period_v1"); + expect(result?.debug.extracted_filters.period_from).toBe("2019-10-01"); + expect(result?.debug.extracted_filters.period_to).toBe("2019-12-31"); + expect(result?.debug.route_expectation_status).toBe("matched"); }); it("routes customer lifecycle question into dedicated aggregate recipe", async () => { @@ -3648,7 +3781,7 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("DOC_TYPE_DOCS"); expect(plan.query).toContain("SECTION_DT_OPS"); expect(plan.query).toContain("SECTION_KT_OPS"); - expect(plan.query).toContain("СГРУПП�?РОВАТЬ ПО\n Движения.СчетДт"); + expect(plan.query).toContain("СГРУППИРОВАТЬ ПО\n Движения.СчетДт"); expect(plan.query).not.toContain("ЛЕВ(Движения.СчетДт.Код, 2)"); }); @@ -3721,7 +3854,7 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.recipe.recipe_id).toBe("address_contracts_by_counterparty_v1"); expect(plan.query).toContain("Справочник.ДоговорыКонтрагентов"); - expect(plan.query).toContain("ПРЕДСТАВЛЕН�?Е(Договоры.Владелец)"); + expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Договоры.Владелец)"); }); it("selects counterparty lifecycle recipe and keeps activity marker", () => { @@ -3756,7 +3889,7 @@ describe("address recipe catalog counterparty filtering", () => { counterparty: "Жуковка 51", sort: "period_asc" }); - expect(plan.query).toContain("УПОРЯДОЧ�?ТЬ ПО"); + expect(plan.query).toContain("УПОРЯДОЧИТЬ ПО"); expect(plan.query).toContain("Период ВОЗР"); }); @@ -3822,7 +3955,7 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("Документ.СписаниеСРасчетногоСчета"); expect(plan.query).toContain("Документ.ПоступлениеНаРасчетныйСчет"); - expect(plan.query).toContain("ПРЕДСТАВЛЕН�?Е(БанкПоступление.ДоговорКонтрагента) КАК Договор"); + expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор"); }); it("allows extended limit for open-contracts intent", () => { @@ -3880,8 +4013,23 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 4) = \"68.2\""); expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 2) = \"19\""); expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 2) = \"19\""); - expect(plan.query).not.toContain("ПРЕДСТАВЛЕН�?Е(Движения.СчетКт) ПОДОБНО"); - expect(plan.query).not.toContain("ПРЕДСТАВЛЕН�?Е(Движения.СчетДт) ПОДОБНО"); + expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) ПОДОБНО"); + expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) ПОДОБНО"); + }); + + it("builds confirmed VAT tax-period query from sales and purchase VAT books", () => { + const filters = extractAddressFilters( + "сколько ндс надо заплатить в налоговую за декабрь 2019", + "vat_liability_confirmed_for_tax_period" + ).extracted_filters; + const selected = selectAddressRecipe("vat_liability_confirmed_for_tax_period", filters); + expect(selected.selected_recipe).toBeTruthy(); + const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); + + expect(plan.query).toContain("РегистрНакопления.НДСЗаписиКнигиПродаж"); + expect(plan.query).toContain("РегистрНакопления.НДСЗаписиКнигиПокупок"); + expect(plan.query).toContain("VAT_BOOK_SALES"); + expect(plan.query).toContain("VAT_BOOK_PURCHASES"); }); }); diff --git a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts index 53469a3..89ec64d 100644 --- a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts +++ b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts @@ -45,6 +45,17 @@ describe("address route expectations contract", () => { expect(audit.reason).toBe("route_expectation_matched"); }); + it("matches expected recipe and result mode for exact VAT tax-period liability route", () => { + const audit = evaluateAddressRouteExpectation({ + intent: "vat_liability_confirmed_for_tax_period", + selectedRecipe: "address_vat_liability_confirmed_tax_period_v1", + requestedResultMode: "confirmed_balance", + resultMode: "confirmed_balance" + }); + expect(audit.status).toBe("matched"); + expect(audit.reason).toBe("route_expectation_matched"); + }); + it("detects selected recipe mismatch", () => { const audit = evaluateAddressRouteExpectation({ intent: "payables_confirmed_as_of_date",