diff --git a/docs/TECH/address_route_baseline_v1.json b/docs/TECH/address_route_baseline_v1.json index 0d5bfa9..8fa5321 100644 --- a/docs/TECH/address_route_baseline_v1.json +++ b/docs/TECH/address_route_baseline_v1.json @@ -20,6 +20,12 @@ "capability_layer": "compute", "capability_route_mode": "exact" }, + { + "intent": "inventory_on_hand_as_of_date", + "capability_id": "confirmed_inventory_on_hand_as_of_date", + "capability_layer": "compute", + "capability_route_mode": "exact" + }, { "intent": "list_payables_counterparties", "capability_id": "payables_candidates_list", diff --git a/docs/TECH/address_route_expectations_v1.json b/docs/TECH/address_route_expectations_v1.json index 802bd6b..d28a9f0 100644 --- a/docs/TECH/address_route_expectations_v1.json +++ b/docs/TECH/address_route_expectations_v1.json @@ -20,6 +20,12 @@ "expected_requested_result_modes": ["confirmed_balance"], "expected_result_modes": ["confirmed_balance"] }, + { + "intent": "inventory_on_hand_as_of_date", + "expected_selected_recipes": ["address_inventory_on_hand_as_of_date_v1"], + "expected_requested_result_modes": ["confirmed_balance"], + "expected_result_modes": ["confirmed_balance"] + }, { "intent": "vat_payable_forecast", "expected_selected_recipes": ["address_vat_payable_forecast_v1"] diff --git a/docs/TECH/capabilities_registry.json b/docs/TECH/capabilities_registry.json index e8691b9..aa2b6e8 100644 --- a/docs/TECH/capabilities_registry.json +++ b/docs/TECH/capabilities_registry.json @@ -163,6 +163,47 @@ "Счет 50, 51, 52, 55" ] }, + { + "group_code": "inventory", + "group_title": "Товары и складские остатки", + "description": "Подтвержденные срезы товарных остатков по складам и организациям на дату.", + "risk_level": "medium", + "maturity_status": "production_ready", + "supported_operations": [ + "inventory_on_hand_as_of_date", + "inventory_positions_by_warehouse" + ], + "unsupported_operations": [ + "post_goods_issue", + "revalue_inventory" + ], + "required_entities": [ + "as_of_date" + ], + "optional_entities": [ + "organization", + "warehouse", + "item_scope" + ], + "typical_queries": [ + "Какие товары сейчас лежат на складе?", + "Покажи остатки товаров на дату.", + "Какие позиции с ненулевым остатком были на складе?" + ], + "related_routes": [ + "address_inventory_on_hand_as_of_date_v1" + ], + "safe_alternatives": [ + "Уточнить дату среза", + "Ограничить организацию или склад" + ], + "one_c_hints": [ + "Счет 41.01", + "РегистрБухгалтерии.Хозрасчетный.Остатки", + "Справочник.Склады", + "Справочник.Номенклатура" + ] + }, { "group_code": "capability_boundaries", "group_title": "Ограничения", diff --git a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js index 1bedba7..ea820b9 100644 --- a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js +++ b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js @@ -7,6 +7,8 @@ const config_1 = require("../config"); const COMPUTE_EXACT_INTENTS = new Set([ "account_balance_snapshot", "documents_forming_balance", + "inventory_on_hand_as_of_date", + "open_contracts_confirmed_as_of_date", "payables_confirmed_as_of_date", "receivables_confirmed_as_of_date", "vat_payable_confirmed_as_of_date", @@ -41,12 +43,18 @@ function defaultCapabilityId(intent) { if (intent === "receivables_confirmed_as_of_date") { return "confirmed_receivables_as_of_date"; } + if (intent === "open_contracts_confirmed_as_of_date") { + return "confirmed_open_contracts_as_of_date"; + } 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 === "inventory_on_hand_as_of_date") { + return "confirmed_inventory_on_hand_as_of_date"; + } if (intent === "list_payables_counterparties") { return "payables_candidates_list"; } @@ -82,6 +90,14 @@ function resolveCapabilityEnabled(intent) { : "receivables_confirmed_route_disabled_by_flag" }; } + if (intent === "open_contracts_confirmed_as_of_date") { + return { + enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, + reason: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 + ? "open_contracts_confirmed_route_enabled" + : "open_contracts_confirmed_route_disabled_by_flag" + }; + } if (intent === "vat_payable_confirmed_as_of_date") { return { enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, @@ -98,6 +114,14 @@ function resolveCapabilityEnabled(intent) { : "vat_liability_confirmed_tax_period_route_disabled_by_flag" }; } + if (intent === "inventory_on_hand_as_of_date") { + return { + enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, + reason: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 + ? "inventory_on_hand_route_enabled" + : "inventory_on_hand_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 e5f228e..0e15f68 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -816,12 +816,18 @@ function requiredFiltersByIntent(intent) { if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { return ["account", "as_of_date"]; } + if (intent === "inventory_on_hand_as_of_date") { + return ["as_of_date"]; + } if (intent === "payables_confirmed_as_of_date") { return ["as_of_date"]; } if (intent === "receivables_confirmed_as_of_date") { return ["as_of_date"]; } + if (intent === "open_contracts_confirmed_as_of_date") { + return ["as_of_date"]; + } if (intent === "vat_payable_confirmed_as_of_date") { return ["as_of_date"]; } @@ -839,8 +845,10 @@ function requiredFiltersByIntent(intent) { return []; } function usesAsOfPrimaryWindow(intent) { - return (intent === "open_items_by_counterparty_or_contract" || + return (intent === "inventory_on_hand_as_of_date" || + intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || + intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date"); @@ -861,7 +869,9 @@ function extractAddressFilters(userMessage, intent) { sort: "period_desc" }; if (!isManagementProfileIntent) { - filters.limit = 20; + if (intent !== "open_contracts_confirmed_as_of_date") { + filters.limit = 20; + } } const warnings = []; const explicitAsOfDate = extractAsOfDate(text); @@ -1016,6 +1026,7 @@ function extractAddressFilters(userMessage, intent) { // - else default to today. if ((intent === "account_balance_snapshot" || intent === "documents_forming_balance" || + intent === "inventory_on_hand_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date") && diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 24e3b5d..bc0fc85 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -390,8 +390,10 @@ function hasFlexibleReceivablesDebtSignal(text) { if (!normalized) { return false; } - return (/(?:кто(?:\s+\S+){0,4}\s+нам(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || - /(?:нам(?:\s+\S+){0,4}\s+кто(?:\s+\S+){0,4}\s+долж)/iu.test(normalized)); + const hasFlexibleWhoOwesUs = /(?:\u043a\u0442\u043e(?:\s+\S+){0,4}\s+\u043d\u0430\u043c(?:\s+\S+){0,4}\s+\u0434\u043e\u043b\u0436)/iu.test(normalized) || + /(?:\u043d\u0430\u043c(?:\s+\S+){0,4}\s+\u043a\u0442\u043e(?:\s+\S+){0,4}\s+\u0434\u043e\u043b\u0436)/iu.test(normalized); + const hasTorchatToUsSignal = /(?:\u043d\u0430\u043c(?:\s+\S+){0,3}\s+\u0442\u043e\u0440\u0447(?:\u0430\u0442|\u0438\u0442)|\u0442\u043e\u0440\u0447(?:\u0430\u0442|\u0438\u0442)(?:\s+\S+){0,3}\s+\u043d\u0430\u043c)/iu.test(normalized); + return hasFlexibleWhoOwesUs || hasTorchatToUsSignal; } function hasFlexiblePayablesDebtSignal(text) { const normalized = String(text ?? ""); @@ -1300,6 +1302,16 @@ function hasGenericAddressLookupSignal(text) { function hasAccountNumberAnchor(text) { return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text); } +function hasInventoryOnHandSignal(text) { + const hasStockLexeme = /(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); + if (!hasStockLexeme) { + return false; + } + const hasGoodsLexeme = /(?:товар(?:ы|ов|ом|а|ные)?|номенклатур|материал(?:ы|ов|а|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text); + const hasBalanceLexeme = /(?:леж(?:ит|ат)|есть|числ(?:ит(?:ся|сь)|ятся)|остат(?:ок|ки)|срез|на\s+дат|по\s+состоянию|на\s+конец|today|now|current|as\s+of)/iu.test(text); + const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|какой|сколько|show|list|which|what)/iu.test(text); + return (hasGoodsLexeme || hasBalanceLexeme) && (hasRequestCue || hasBalanceLexeme); +} function resolveAddressIntent(userMessage) { const text = String(userMessage ?? "").trim().toLowerCase(); if (hasVatLiabilityConfirmedTaxPeriodSignal(text)) { @@ -1402,9 +1414,16 @@ function resolveAddressIntent(userMessage) { reasons: ["documents_by_account_drilldown_signal_detected"] }; } + if (hasInventoryOnHandSignal(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "high", + reasons: ["inventory_on_hand_signal_detected"] + }; + } if (hasOpenContractsListSignal(text)) { return { - intent: "list_open_contracts", + intent: "open_contracts_confirmed_as_of_date", confidence: "medium", reasons: ["open_contract_signal_detected"] }; @@ -1546,7 +1565,7 @@ function resolveAddressIntent(userMessage) { } if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) { return { - intent: "list_open_contracts", + intent: "open_contracts_confirmed_as_of_date", confidence: "medium", reasons: ["open_contract_signal_detected"] }; diff --git a/llm_normalizer/backend/dist/services/addressQueryClassifier.js b/llm_normalizer/backend/dist/services/addressQueryClassifier.js index 87707d3..d819f0e 100644 --- a/llm_normalizer/backend/dist/services/addressQueryClassifier.js +++ b/llm_normalizer/backend/dist/services/addressQueryClassifier.js @@ -100,6 +100,14 @@ const ADDRESS_ENTITY_TOKENS = [ "поступлени", "списан", "списани", + "склад", + "складе", + "складу", + "товар", + "товары", + "товарн", + "номенклат", + "материал", "долг", "должен", "должны", diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 8ea9113..5fb6f49 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -220,6 +220,24 @@ function detectVatMetadataObjectType(fullName) { } return null; } +function firstNonEmptyString(...values) { + for (const value of values) { + const normalized = valueAsString(value).trim(); + if (normalized) { + return normalized; + } + } + return null; +} +function firstFiniteNumber(...values) { + for (const value of values) { + const parsed = parseFiniteNumber(value); + if (parsed !== null) { + return parsed; + } + } + return null; +} function extractVatMetadataObjects(rows) { const out = []; const seen = new Set(); @@ -780,6 +798,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent, filters) { intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || intent === "open_items_by_counterparty_or_contract" || + intent === "open_contracts_confirmed_as_of_date" || intent === "list_payables_counterparties" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || @@ -893,6 +912,17 @@ function collectAnalyticsStrings(row) { "Контрагент", "Contract", "Договор", + "Item", + "item", + "Номенклатура", + "НоменклатураПредставление", + "Warehouse", + "warehouse", + "Склад", + "СкладПредставление", + "Quantity", + "quantity", + "Количество", "Organization", "Организация", "ОрганизацияПредставление", @@ -912,6 +942,10 @@ function collectAnalyticsStrings(row) { lowerKey.includes("субконто") || lowerKey.includes("контраг") || lowerKey.includes("договор") || + lowerKey.includes("warehouse") || + lowerKey.includes("склад") || + lowerKey.includes("item") || + lowerKey.includes("номенклат") || lowerKey.includes("organization") || lowerKey.includes("организац")) { const value = valueAsString(rawValue).trim(); @@ -932,6 +966,10 @@ function toNormalizedRows(rows) { const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null; const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); + const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity); + const item = firstNonEmptyString(row.Номенклатура, row.Item, row.item, row.НоменклатураПредставление); + const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление); + const organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление); const analytics = collectAnalyticsStrings(row); return { period, @@ -939,7 +977,11 @@ function toNormalizedRows(rows) { account_dt: accountDt, account_kt: accountKt, amount, - analytics + analytics, + quantity, + item, + warehouse, + organization }; }) .filter((item) => Boolean(item.period || item.registrator)); @@ -1066,6 +1108,7 @@ function parseIsoDateUtcTimestamp(value) { function isCounterpartyRiskIntent(intent) { return (intent === "list_receivables_counterparties" || intent === "list_payables_counterparties" || + intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "list_open_contracts" || @@ -1080,6 +1123,8 @@ function isHeuristicCandidatesIntent(intent) { function isConfirmedBalanceIntent(intent) { return (intent === "account_balance_snapshot" || intent === "documents_forming_balance" || + intent === "inventory_on_hand_as_of_date" || + intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date" || @@ -1125,6 +1170,9 @@ function resolveRequestedResultMode(intent, filters) { if (isConfirmedBalanceIntent(intent)) { return "confirmed_balance"; } + if (intent === "list_open_contracts") { + return "heuristic_candidates"; + } if (isHeuristicCandidatesIntent(intent)) { const asOfDateBasis = resolveAsOfDateBasis(filters); if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") { @@ -1245,7 +1293,16 @@ function buildRouteExpectationAudit(input) { }; } function enforceStrictAccountScopeForIntent(plan, intent) { - if (intent !== "list_receivables_counterparties" || plan.account_scope_mode === "strict") { + if (intent === "list_open_contracts" && plan.recipe.recipe_id === "address_open_items_by_party_or_contract_v1") { + return plan; + } + const strictScopeIntents = [ + "list_receivables_counterparties", + "list_open_contracts", + "open_items_by_counterparty_or_contract" + ]; + const shouldEnforceStrictScope = strictScopeIntents.includes(intent); + if (!shouldEnforceStrictScope || plan.account_scope_mode === "strict") { return plan; } return { @@ -1737,6 +1794,12 @@ function buildLimitedOffers(input) { else if (input.intent === "receivables_confirmed_as_of_date") { offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); } + else if (input.intent === "inventory_on_hand_as_of_date") { + offers.push("показать подтвержденный срез товаров на складах на дату по остатку счета 41.01"); + } + else if (input.intent === "open_contracts_confirmed_as_of_date") { + offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76"); + } else if (input.intent === "vat_payable_confirmed_as_of_date") { offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*"); } @@ -1786,8 +1849,10 @@ function buildLimitedIntentSignalLine(input) { bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.", open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.", list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.", + open_contracts_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный список договоров с открытыми взаиморасчетами на дату.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", + inventory_on_hand_as_of_date: "Сигнал запроса: нужен подтвержденный срез товаров на складе на дату.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.", vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату.", @@ -1917,15 +1982,17 @@ function buildLimitedExecutionResult(input) { }); const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters); const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(input.reasons, requestedResultMode, undefined, resultSemantics.result_mode); - const exactLimitedReason = input.intent.intent === "payables_confirmed_as_of_date" - ? "exact_payables_mode_limited_response" - : input.intent.intent === "receivables_confirmed_as_of_date" - ? "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 exactLimitedReason = input.intent.intent === "inventory_on_hand_as_of_date" + ? "exact_inventory_mode_limited_response" + : input.intent.intent === "payables_confirmed_as_of_date" + ? "exact_payables_mode_limited_response" + : input.intent.intent === "receivables_confirmed_as_of_date" + ? "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) ? [...reasonsWithConfirmedFallback, exactLimitedReason] : reasonsWithConfirmedFallback; @@ -2036,6 +2103,7 @@ class AddressQueryService { requestedResultMode === "confirmed_balance"; const confirmedBalanceReceivablesIntent = intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; const confirmedBalanceVatPayableIntent = intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; + const confirmedBalanceInventoryIntent = intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance"; const payablesConfirmedExecution = confirmedBalancePayablesIntent ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) : null; @@ -2045,7 +2113,11 @@ class AddressQueryService { const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) : null; - const executionFilters = payablesConfirmedExecution?.executionFilters ?? + const inventoryConfirmedExecution = confirmedBalanceInventoryIntent + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + : null; + const executionFilters = inventoryConfirmedExecution?.executionFilters ?? + payablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ?? vatPayableConfirmedExecution?.executionFilters ?? filters.extracted_filters; @@ -2076,6 +2148,15 @@ class AddressQueryService { baseReasons.push("as_of_date_derived_for_confirmed_vat_payable"); } } + if (inventoryConfirmedExecution?.asOfDerived && + !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { + if (!filters.warnings.includes("as_of_date_derived_for_inventory_on_hand")) { + filters.warnings.push("as_of_date_derived_for_inventory_on_hand"); + } + if (!baseReasons.includes("as_of_date_derived_for_inventory_on_hand")) { + baseReasons.push("as_of_date_derived_for_inventory_on_hand"); + } + } const capabilityDecision = (0, addressCapabilityPolicy_1.resolveAddressCapabilityRouteDecision)(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent); const shadowRouteAudit = buildShadowRouteAudit({ @@ -2149,6 +2230,10 @@ class AddressQueryService { !baseReasons.includes("confirmed_balance_exact_receivables_intent")) { baseReasons.push("confirmed_balance_exact_receivables_intent"); } + if (intent.intent === "inventory_on_hand_as_of_date" && + !baseReasons.includes("confirmed_balance_exact_inventory_intent")) { + baseReasons.push("confirmed_balance_exact_inventory_intent"); + } if (intent.intent === "vat_payable_confirmed_as_of_date" && !baseReasons.includes("confirmed_balance_exact_vat_payable_intent")) { baseReasons.push("confirmed_balance_exact_vat_payable_intent"); @@ -2976,6 +3061,7 @@ class AddressQueryService { } const allowConfirmedAsOfZeroSnapshot = filteredRows.length === 0 && (composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "open_contracts_confirmed_as_of_date" || composeIntent === "payables_confirmed_as_of_date" || composeIntent === "receivables_confirmed_as_of_date") && (stageStatus === "no_raw_rows" || stageStatus === "materialized_but_filtered_out_by_recipe") && diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 1faacda..dd63bbc 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -66,6 +66,47 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` УПОРЯДОЧИТЬ ПО Сумма __ORDER_DIRECTION__ `; +const OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт, + "" КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокДт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.СуммаРазвернутыйОстатокДт > 0 + И (__OPEN_CONTRACT_ACCOUNTS_MATCH__) +ОБЪЕДИНИТЬ ВСЕ +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + "" КАК СчетДт, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокКт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.СуммаРазвернутыйОстатокКт > 0 + И (__OPEN_CONTRACT_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ __AS_OF_EXPR__ КАК Период, @@ -88,6 +129,25 @@ const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` УПОРЯДОЧИТЬ ПО Сумма __ORDER_DIRECTION__ `; +const INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт, + "" КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокДт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Номенклатура, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК Склад, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация, + Остатки.КоличествоРазвернутыйОстатокДт КАК Количество +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.КоличествоРазвернутыйОстатокДт > 0 + И (__INVENTORY_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Количество __ORDER_DIRECTION__ +`; const BANK_DOCS_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ БанкСписание.Дата КАК Период, @@ -615,6 +675,28 @@ const BASE_RECIPES = [ account_scope_mode: "preferred", query_template: "vat_liability_confirmed_tax_period_profile" }, + { + recipe_id: "address_inventory_on_hand_as_of_date_v1", + intent: "inventory_on_hand_as_of_date", + purpose: "Build confirmed stock-on-hand snapshot from balances on goods-on-warehouse account 41.01", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "limit", "sort"], + default_limit: 300, + account_scope: ["41.01"], + account_scope_mode: "strict", + query_template: "inventory_on_hand_as_of_balance_profile" + }, + { + recipe_id: "address_open_contracts_confirmed_as_of_date_v1", + intent: "open_contracts_confirmed_as_of_date", + purpose: "Build confirmed snapshot of contracts with open settlements as-of date from balances on accounts 60/62/76", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], + default_limit: 400, + account_scope: ["60", "62", "76"], + account_scope_mode: "strict", + query_template: "open_contracts_confirmed_as_of_balance_profile" + }, { recipe_id: "address_contracts_by_counterparty_v1", intent: "list_contracts_by_counterparty", @@ -921,6 +1003,8 @@ function maxLimitForIntent(intent) { intent === "contract_usage_and_value" || intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period" || + intent === "inventory_on_hand_as_of_date" || + intent === "open_contracts_confirmed_as_of_date" || intent === "list_contracts_by_counterparty" || intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || @@ -1048,27 +1132,27 @@ function buildAddressRecipePlan(recipe, filters) { .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) + : recipe.query_template === "inventory_on_hand_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_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 INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"])) + .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 === "open_contracts_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) @@ -1080,23 +1164,59 @@ function buildAddressRecipePlan(recipe, filters) { ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "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)); + : 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) + : 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" + ? (() => { + 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 370f147..e7c1c6f 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -565,6 +565,130 @@ function extractCounterpartyName(row) { } return null; } +function extractInventoryItemName(row) { + const direct = String(row.item ?? "").trim(); + if (direct) { + return direct; + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (!normalized) { + continue; + } + if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) { + continue; + } + if (/(?:склад|warehouse|ооо|ао|пао|зао|ип|организац)/iu.test(normalized)) { + continue; + } + return normalized; + } + return null; +} +function extractInventoryWarehouseName(row) { + const direct = String(row.warehouse ?? "").trim(); + if (direct) { + return direct; + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (/(?:склад|warehouse)/iu.test(normalized)) { + return normalized; + } + } + return null; +} +function extractInventoryOrganizationName(row) { + const direct = String(row.organization ?? "").trim(); + if (direct) { + return direct; + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (!normalized) { + continue; + } + if (/(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»„“()\\\/.,;:]))|организац|комитет|департамент|министерств|служб|управлени|казенн|администрац/iu.test(normalized)) { + return normalized; + } + } + return null; +} +function extractInventoryQuantity(row) { + return typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : null; +} +function buildInventoryOnHandAggregate(rows, asOfDate) { + const byPosition = new Map(); + const asOfTimestamp = toUtcDayTimestamp(asOfDate); + for (const row of rows) { + const item = extractInventoryItemName(row); + if (!item) { + continue; + } + const rowTimestamp = toUtcDayTimestamp(row.period); + if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) { + continue; + } + const quantity = extractInventoryQuantity(row); + if (quantity === null || quantity <= 0) { + continue; + } + const warehouse = extractInventoryWarehouseName(row); + const organization = extractInventoryOrganizationName(row); + const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0; + const key = [normalizeEntityToken(item), normalizeEntityToken(warehouse), normalizeEntityToken(organization)].join("|"); + const registrator = String(row.registrator ?? "").trim(); + const current = byPosition.get(key); + if (!current) { + byPosition.set(key, { + item, + warehouse, + organization, + quantity, + amount, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + sourceRefs: new Set(registrator && registrator !== "Остатки на дату" ? [registrator] : []) + }); + continue; + } + current.quantity += quantity; + current.amount += amount; + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (registrator && registrator !== "Остатки на дату") { + current.sourceRefs.add(registrator); + } + } + return Array.from(byPosition.values()) + .map((item) => ({ + item: item.item, + warehouse: item.warehouse, + organization: item.organization, + quantity: item.quantity, + amount: item.amount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + sourceRefs: Array.from(item.sourceRefs).slice(0, 3) + })) + .filter((item) => item.quantity > 0) + .sort((left, right) => { + if (right.quantity !== left.quantity) { + return right.quantity - left.quantity; + } + if (right.amount !== left.amount) { + return right.amount - left.amount; + } + return left.item.localeCompare(right.item, "ru"); + }); +} function liabilityCategoryLabel(category) { if (category === "supplier_or_contractor") { return "поставщики/подрядчики"; @@ -1212,6 +1336,455 @@ function contractCandidatesFromRows(rows) { } return uniqueStrings(candidates); } +function isFinancialContractLike(value) { + return /(?:кредит|кред\.?|loan|overdraft|овердрафт|лизинг|leasing|займ|guarantee|гарант|банк|bank)/iu.test(value); +} +function hasStrongContractIdentitySignal(value) { + return /(?:договор|contract|дог\.|№|\d{1,4}[\\/.-]\d{1,4}|\d{1,4}\sот\s\d{2}\.\d{2}\.\d{2,4}|[A-ZА-Я]{1,6}-\d+)/iu.test(value); +} +function isLikelyOrganizationName(value) { + return /(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»„“()\\\/.,;:]))|комитет|департамент|министерств|служб|управлени|казенн|администрац|bank|банк/iu.test(value); +} +function isContractLikeCounterparty(value) { + return /(?:договор|дог[-.\s]?р|contract|кредитн|loan|овердрафт|лизинг|\b№\b)/iu.test(value); +} +function isLowQualityContractIdentity(contract, counterparty) { + const normalizedContract = normalizeEntityToken(contract); + if (!normalizedContract || normalizedContract.length < 3) { + return true; + } + if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(contract.trim())) { + return true; + } + if (counterparty && normalizeEntityToken(counterparty) === normalizedContract) { + return true; + } + if (!hasStrongContractIdentitySignal(contract) && isLikelyOrganizationName(contract)) { + return true; + } + if (!hasStrongContractIdentitySignal(contract) && /^[A-ZА-Я]{2,6}$/u.test(contract.trim())) { + return true; + } + return false; +} +function isLowQualityCounterpartyForContract(counterparty, contract) { + if (!counterparty) { + return true; + } + const normalizedCounterparty = normalizeEntityToken(counterparty); + const normalizedContract = normalizeEntityToken(contract); + if (!normalizedCounterparty) { + return true; + } + if (normalizedCounterparty === normalizedContract) { + return true; + } + if (isContractLikeCounterparty(counterparty)) { + return true; + } + return normalizedCounterparty.length < 3; +} +function normalizeDisplayAccountToken(value) { + const normalized = String(value ?? "").trim(); + if (!normalized || /^(?:0|<пусто>|пустая ссылка|-)$/iu.test(normalized)) { + return null; + } + return normalized; +} +function classifyOpenContractCategory(contract, counterparties, qualityFlags) { + if (isFinancialContractLike(contract)) { + return "financial"; + } + if (counterparties.some((item) => isFinancialContractLike(item))) { + return "financial"; + } + if (qualityFlags.includes("counterparty_not_reliably_resolved") || + qualityFlags.includes("contract_identity_not_reliable") || + qualityFlags.includes("contract_identity_looks_like_counterparty") || + qualityFlags.includes("multiple_counterparties_for_contract")) { + return "uncertain"; + } + if (counterparties.length === 0) { + return "uncertain"; + } + return "commercial"; +} +function classifyOpenContractSettlementKind(row) { + const dt = extractAccountSectionCode(row.account_dt); + const kt = extractAccountSectionCode(row.account_kt); + if (dt === "62") { + return "receivable"; + } + if (kt === "60") { + return "payable"; + } + if (dt === "60") { + return "advance_issued"; + } + if (kt === "62") { + return "advance_received"; + } + if (dt === "76") { + return "other_receivable"; + } + if (kt === "76") { + return "other_payable"; + } + return null; +} +function openContractSettlementKindLabel(kind) { + if (kind === "receivable") { + return "дебиторская задолженность"; + } + if (kind === "payable") { + return "кредиторская задолженность"; + } + if (kind === "advance_issued") { + return "аванс выданный"; + } + if (kind === "advance_received") { + return "аванс полученный"; + } + if (kind === "other_receivable") { + return "прочий дебетовый остаток"; + } + return "прочий кредитовый остаток"; +} +function openContractSettlementKindSign(kind) { + if (kind === "receivable" || kind === "advance_issued" || kind === "other_receivable") { + return 1; + } + return -1; +} +function classifyOpenContractReviewBucket(item) { + if (item.category === "commercial") { + return null; + } + if (item.qualityFlags.includes("counterparty_not_reliably_resolved") || + item.qualityFlags.includes("contract_identity_not_reliable") || + item.qualityFlags.includes("contract_identity_looks_like_counterparty") || + item.qualityFlags.includes("multiple_counterparties_for_contract")) { + return "dirty_unresolved"; + } + return "special_valid"; +} +function openContractNetBalanceDirectionLabel(amount) { + if (amount > 0.005) { + return "к получению"; + } + if (amount < -0.005) { + return "к оплате"; + } + return "нетто закрыт"; +} +function formatOpenContractComponentsSummary(components) { + const kindOrder = [ + "receivable", + "payable", + "advance_issued", + "advance_received", + "other_receivable", + "other_payable" + ]; + const ordered = [...components].sort((left, right) => kindOrder.indexOf(left.kind) - kindOrder.indexOf(right.kind)); + return ordered + .map((component) => `${openContractSettlementKindLabel(component.kind)} ${formatMoneyRub(component.amount)}`) + .join("; "); +} +function summarizeOpenContractSpecialReason(item) { + if (item.category === "financial") { + return "похоже на финансовый договор (кредит/банк)"; + } + if (item.qualityFlags.includes("contract_identity_looks_like_counterparty")) { + return "в поле договора похоже попал контрагент или чужая аналитика"; + } + if (item.qualityFlags.includes("contract_identity_not_reliable")) { + return "договор не похож на устойчивый договорный реквизит"; + } + if (item.qualityFlags.includes("multiple_counterparties_for_contract")) { + return "по одному договору найдено несколько контрагентов"; + } + if (item.qualityFlags.includes("counterparty_not_reliably_resolved")) { + return "не удалось надежно определить контрагента"; + } + return "требуется ручная проверка карточки договора"; +} +function buildOpenContractConfirmedBalanceAggregate(rows, asOfDate) { + const byContract = new Map(); + const asOfTimestamp = toUtcDayTimestamp(asOfDate); + for (const row of rows) { + const rowTimestamp = toUtcDayTimestamp(row.period); + if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) { + continue; + } + const contract = extractContractName(row); + if (!contract) { + continue; + } + const amount = row.amount; + if (typeof amount !== "number" || !Number.isFinite(amount)) { + continue; + } + const settlementKind = classifyOpenContractSettlementKind(row); + if (!settlementKind) { + continue; + } + const counterpartyCandidate = extractCounterpartyName(row); + const counterparty = isLowQualityCounterpartyForContract(counterpartyCandidate, contract) ? null : counterpartyCandidate; + const sourceRefs = extractPayablesSourceRefs(row, counterparty ?? contract, contract); + const accountToken = normalizeDisplayAccountToken(row.account_dt) ?? normalizeDisplayAccountToken(row.account_kt); + const absAmount = Math.abs(amount); + const contractKey = normalizeEntityToken(contract); + const counterpartyKey = counterparty ? normalizeEntityToken(counterparty) : "__unknown_counterparty__"; + const aggregateKey = `${contractKey}::${counterpartyKey}::${settlementKind}`; + const current = byContract.get(aggregateKey); + if (!current) { + const qualityFlags = new Set(); + if (!counterparty) { + qualityFlags.add("counterparty_not_reliably_resolved"); + } + if (isLowQualityContractIdentity(contract, counterparty)) { + qualityFlags.add("contract_identity_not_reliable"); + } + if (counterparty && normalizeEntityToken(counterparty) === normalizeEntityToken(contract)) { + qualityFlags.add("contract_identity_looks_like_counterparty"); + } + const counterparties = new Set(); + if (counterparty) { + counterparties.add(counterparty); + } + const accounts = new Set(); + if (accountToken) { + accounts.add(accountToken); + } + byContract.set(aggregateKey, { + contract, + counterparty, + confirmedAmount: absAmount, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + counterparties, + settlementKind, + accounts, + sourceRefs: new Set(sourceRefs), + qualityFlags + }); + continue; + } + current.confirmedAmount += absAmount; + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (counterparty) { + current.counterparty = current.counterparty ?? counterparty; + current.counterparties.add(counterparty); + } + else { + current.qualityFlags.add("counterparty_not_reliably_resolved"); + } + if (accountToken) { + current.accounts.add(accountToken); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } + } + return Array.from(byContract.values()) + .map((item) => { + const counterparties = Array.from(item.counterparties); + if (counterparties.length > 1) { + item.qualityFlags.add("multiple_counterparties_for_contract"); + } + return { + contract: item.contract, + counterparty: item.counterparty, + confirmedAmount: item.confirmedAmount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + category: classifyOpenContractCategory(item.contract, counterparties, Array.from(item.qualityFlags)), + settlementKind: item.settlementKind, + accounts: Array.from(item.accounts).slice(0, 3), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3), + qualityFlags: Array.from(item.qualityFlags) + }; + }) + .filter((item) => item.confirmedAmount > 0.005) + .sort((left, right) => { + if (right.confirmedAmount !== left.confirmedAmount) { + return right.confirmedAmount - left.confirmedAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.contract.localeCompare(right.contract); + }); +} +function buildOpenContractNetAggregate(items) { + const byContract = new Map(); + const categoryPriority = (value) => { + if (value === "financial") { + return 2; + } + if (value === "uncertain") { + return 1; + } + return 0; + }; + for (const item of items) { + const counterpartyKey = item.counterparty ? normalizeEntityToken(item.counterparty) : "__unknown_counterparty__"; + const aggregateKey = `${normalizeEntityToken(item.contract)}::${counterpartyKey}`; + const current = byContract.get(aggregateKey); + if (!current) { + byContract.set(aggregateKey, { + contract: item.contract, + counterparty: item.counterparty, + category: item.category, + netOpenBalance: openContractSettlementKindSign(item.settlementKind) * item.confirmedAmount, + grossOpenBalance: item.confirmedAmount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + accounts: new Set(item.accounts), + sourceRefs: new Set(item.sourceRefs), + qualityFlags: new Set(item.qualityFlags), + componentAmounts: new Map([[item.settlementKind, item.confirmedAmount]]) + }); + continue; + } + if (categoryPriority(item.category) > categoryPriority(current.category)) { + current.category = item.category; + } + current.netOpenBalance += openContractSettlementKindSign(item.settlementKind) * item.confirmedAmount; + current.grossOpenBalance += item.confirmedAmount; + current.operations += item.operations; + if ((item.firstPeriod ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = item.firstPeriod; + } + if ((item.lastPeriod ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = item.lastPeriod; + } + for (const account of item.accounts) { + current.accounts.add(account); + } + for (const ref of item.sourceRefs) { + current.sourceRefs.add(ref); + } + for (const flag of item.qualityFlags) { + current.qualityFlags.add(flag); + } + current.componentAmounts.set(item.settlementKind, (current.componentAmounts.get(item.settlementKind) ?? 0) + item.confirmedAmount); + } + return Array.from(byContract.values()) + .map((item) => { + const qualityFlags = Array.from(item.qualityFlags); + return { + contract: item.contract, + counterparty: item.counterparty, + category: item.category, + reviewBucket: classifyOpenContractReviewBucket({ + category: item.category, + qualityFlags + }), + netOpenBalance: item.netOpenBalance, + grossOpenBalance: item.grossOpenBalance, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + accounts: Array.from(item.accounts).slice(0, 4), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3), + qualityFlags, + componentAmounts: Array.from(item.componentAmounts.entries()) + .map(([kind, amount]) => ({ kind, amount })) + .filter((component) => component.amount > 0.005) + }; + }) + .filter((item) => item.grossOpenBalance > 0.005) + .sort((left, right) => { + if (right.grossOpenBalance !== left.grossOpenBalance) { + return right.grossOpenBalance - left.grossOpenBalance; + } + if (Math.abs(right.netOpenBalance) !== Math.abs(left.netOpenBalance)) { + return Math.abs(right.netOpenBalance) - Math.abs(left.netOpenBalance); + } + return left.contract.localeCompare(right.contract); + }); +} +function buildOpenContractRiskAggregate(rows) { + const byContract = new Map(); + for (const row of rows) { + const contract = extractContractName(row); + if (!contract) { + continue; + } + const amountRaw = row.amount ?? 0; + const amount = Number.isFinite(amountRaw) ? Math.abs(amountRaw) : 0; + const current = byContract.get(contract); + const counterpartyCandidate = extractCounterpartyName(row); + const counterparty = isLowQualityCounterpartyForContract(counterpartyCandidate, contract) ? null : counterpartyCandidate; + const sourceRefs = extractPayablesSourceRefs(row, counterparty ?? contract, contract); + if (!current) { + const qualityFlags = new Set(); + if (!counterparty) { + qualityFlags.add("counterparty_not_reliably_resolved"); + } + byContract.set(contract, { + contract, + totalAmount: amount, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + counterparties: new Set(counterparty ? [counterparty] : []), + sourceRefs: new Set(sourceRefs), + qualityFlags + }); + continue; + } + current.totalAmount += amount; + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (counterparty) { + current.counterparties.add(counterparty); + } + else { + current.qualityFlags.add("counterparty_not_reliably_resolved"); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } + } + return Array.from(byContract.values()) + .map((item) => ({ + contract: item.contract, + totalAmount: item.totalAmount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + counterparties: Array.from(item.counterparties).slice(0, 2), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3), + category: classifyOpenContractCategory(item.contract, Array.from(item.counterparties), Array.from(item.qualityFlags)), + qualityFlags: Array.from(item.qualityFlags) + })) + .sort((left, right) => { + if (right.totalAmount !== left.totalAmount) { + return right.totalAmount - left.totalAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.contract.localeCompare(right.contract); + }); +} function composeFactualReply(intent, rows, options = {}) { const applyNumericEmphasis = (line) => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line); const joinLines = (lines) => lines.map(applyNumericEmphasis).join("\n"); @@ -2250,31 +2823,259 @@ function composeFactualReply(intent, rows, options = {}) { text: lines.join("\n") }; } - if (intent === "list_open_contracts") { - const contracts = contractCandidatesFromRows(rows); - const counterparties = buildCounterpartyRiskAggregate(rows); + if (intent === "inventory_on_hand_as_of_date") { + const asOfDate = resolvePayablesAsOfDate(options); + const positions = buildInventoryOnHandAggregate(rows, asOfDate); + const uniqueItems = uniqueStrings(positions.map((item) => item.item)); + const uniqueWarehouses = uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)); + const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0); + const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); const lines = [ - "Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).", - `Строк движения: ${rows.length}.`, - `Договорных кандидатов: ${contracts.length}.` + `Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`, + "", + "Блок 1. Статус результата", + "- Результат: подтвержденный список товарных остатков на дату.", + "", + "Блок 2. Что учтено", + `- Дата среза: ${formatDateRu(asOfDate)}.`, + "- Контур: остатки по счету 41.01 «Товары на складах».", + "- Базовая единица детализации: одна строка = товар, склад и организация на дату.", + "", + "Блок 3. Сводка", + `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, + `- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`, + `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, + `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, + `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`, + `- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`, + "", + "Блок 4. Подтвержденные позиции" ]; - if (contracts.length > 0) { - lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)); - } - else if (counterparties.length > 0) { - lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`); - lines.push(...counterparties - .slice(0, 8) - .map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`)); - lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска."); + if (positions.length > 0) { + lines.push(...positions.slice(0, 20).map((item, index) => { + const warehouseLabel = item.warehouse ?? "склад не определен"; + const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; + const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : ""; + const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; + return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${formatNumberWithDots(item.quantity, 3)} | стоимость: ${formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`; + })); } else { - lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback."); + lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + } + return { + responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: joinLines(lines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: positions.length > 0 ? "strong" : "medium", + balance_confirmed: true + } + }; + } + if (intent === "open_contracts_confirmed_as_of_date") { + const asOfDate = resolvePayablesAsOfDate(options); + const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate); + const contractProfiles = buildOpenContractNetAggregate(confirmedContracts); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const commercialContracts = confirmedContracts.filter((item) => item.category === "commercial"); + const commercialProfiles = contractProfiles.filter((item) => item.category === "commercial"); + const specialProfiles = contractProfiles.filter((item) => item.reviewBucket === "special_valid"); + const dirtyProfiles = contractProfiles.filter((item) => item.reviewBucket === "dirty_unresolved"); + const uniqueContracts = uniqueStrings(contractProfiles.map((item) => item.contract)); + const commercialReceivables = commercialContracts.filter((item) => item.settlementKind === "receivable"); + const commercialPayables = commercialContracts.filter((item) => item.settlementKind === "payable"); + const commercialAdvances = commercialContracts.filter((item) => item.settlementKind === "advance_issued" || item.settlementKind === "advance_received"); + const commercialOther = commercialContracts.filter((item) => item.settlementKind === "other_receivable" || item.settlementKind === "other_payable"); + const sumConfirmedAmount = (items) => items.reduce((sum, item) => sum + item.confirmedAmount, 0); + const sumNetAmount = (items) => items.reduce((sum, item) => sum + item.netOpenBalance, 0); + const sumGrossAmount = (items) => items.reduce((sum, item) => sum + item.grossOpenBalance, 0); + const commercialNetTotal = sumNetAmount(commercialProfiles); + const commercialGrossTotal = sumGrossAmount(commercialProfiles); + const specialTotal = sumGrossAmount(specialProfiles); + const dirtyTotal = sumGrossAmount(dirtyProfiles); + const periodScopeLine = !options.asOfDate && (periodFrom || periodTo) + ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` + : null; + const renderContractProfileLines = (items, includeSpecialReason) => items.slice(0, 12).map((item, index) => { + const counterpartyLabel = item.counterparty ?? "контрагент не определен"; + const accountsLabel = item.accounts.length > 0 ? ` | через счета: ${item.accounts.join("; ")}` : ""; + const evidenceLabel = item.sourceRefs.length > 0 ? ` | основное основание: ${item.sourceRefs[0]}` : ""; + const refsLabel = item.sourceRefs.length > 1 ? ` | source refs: ${item.sourceRefs.slice(1, 3).join("; ")}` : ""; + const specialReasonLabel = includeSpecialReason + ? ` | причина вынесения: ${summarizeOpenContractSpecialReason(item)}` + : ""; + return `${index + 1}. ${item.contract} | контрагент: ${counterpartyLabel} | чистый остаток: ${openContractNetBalanceDirectionLabel(item.netOpenBalance)} ${formatMoneyRub(Math.abs(item.netOpenBalance))} | брутто компонентов: ${formatMoneyRub(item.grossOpenBalance)} | состав: ${formatOpenContractComponentsSummary(item.componentAmounts)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${accountsLabel}${evidenceLabel}${refsLabel}${specialReasonLabel}`; + }); + const renderConfirmedContractLines = (items, includeSpecialReason) => items.slice(0, 12).map((item, index) => { + const counterpartyLabel = item.counterparty ?? "контрагент не определен"; + const accountsLabel = item.accounts.length > 0 ? ` | счета: ${item.accounts.join("; ")}` : ""; + const evidenceLabel = item.sourceRefs.length > 0 ? ` | основное основание: ${item.sourceRefs[0]}` : ""; + const refsLabel = item.sourceRefs.length > 1 ? ` | source refs: ${item.sourceRefs.slice(1, 3).join("; ")}` : ""; + const specialReasonLabel = includeSpecialReason + ? ` | причина вынесения: ${summarizeOpenContractSpecialReason(item)}` + : ""; + return `${index + 1}. ${item.contract} | контрагент: ${counterpartyLabel} | подтвержденный открытый остаток: ${formatMoneyRub(item.confirmedAmount)} | тип остатка: ${openContractSettlementKindLabel(item.settlementKind)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${accountsLabel}${evidenceLabel}${refsLabel}${specialReasonLabel}`; + }); + const lines = [ + `Собран подтвержденный срез открытых договоров на ${formatDateRu(asOfDate)}.`, + `Чистый коммерческий остаток: ${openContractNetBalanceDirectionLabel(commercialNetTotal)} ${formatMoneyRub(Math.abs(commercialNetTotal))}.`, + `Брутто коммерческих компонентов: ${formatMoneyRub(commercialGrossTotal)}.`, + `Специальные финансовые позиции: ${formatNumberWithDots(specialProfiles.length)} на ${formatMoneyRub(specialTotal)}.`, + `Спорные/некачественно нормализованные позиции: ${formatNumberWithDots(dirtyProfiles.length)} на ${formatMoneyRub(dirtyTotal)}.`, + "", + "Блок 1. Статус результата", + "- Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.", + "- База ответа: остатки по счетам 60/62/76 с договорной аналитикой, без эвристического shortlist.", + "- Управленческий вид: по каждому договору показаны чистый остаток и состав по типам открытых расчетов.", + "- Базовая единица детализации: одна строка = один договор, один контрагент и один тип открытого остатка." + ]; + lines.push(""); + lines.push("Блок 2. Что учтено"); + lines.push(`- Дата среза: ${formatDateRu(asOfDate)}.`); + if (periodScopeLine) { + lines.push(periodScopeLine); + } + lines.push("- Дефолтная бизнес-дефиниция: открыт договор, по которому на дату есть ненулевой остаток взаиморасчетов."); + lines.push("- Контур: остатки по счетам 60/62/76."); + lines.push("- Смешанные экономические смыслы не склеиваются: дебиторка, кредиторка, авансы и прочие остатки показаны раздельно."); + lines.push(""); + lines.push("Блок 3. Сводка"); + lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); + lines.push(`- Уникальных договоров: ${formatNumberWithDots(uniqueContracts.length)}.`); + lines.push(`- Подтвержденных договор-контрагент профилей: ${formatNumberWithDots(contractProfiles.length)}.`); + lines.push(`- Подтвержденных договорных компонентов: ${formatNumberWithDots(confirmedContracts.length)}.`); + lines.push(`- Чистый коммерческий остаток: ${openContractNetBalanceDirectionLabel(commercialNetTotal)} ${formatMoneyRub(Math.abs(commercialNetTotal))}.`); + lines.push(`- Брутто коммерческих компонентов: ${formatMoneyRub(commercialGrossTotal)}.`); + lines.push(`- Коммерческая дебиторка: ${formatNumberWithDots(commercialReceivables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialReceivables))}.`); + lines.push(`- Коммерческая кредиторка: ${formatNumberWithDots(commercialPayables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialPayables))}.`); + lines.push(`- Коммерческие авансы: ${formatNumberWithDots(commercialAdvances.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialAdvances))}.`); + lines.push(`- Прочие расчеты по 76: ${formatNumberWithDots(commercialOther.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialOther))}.`); + lines.push(`- Специальные финансовые позиции: ${formatNumberWithDots(specialProfiles.length)} на ${formatMoneyRub(specialTotal)}.`); + lines.push(`- Спорные/некачественно нормализованные позиции: ${formatNumberWithDots(dirtyProfiles.length)} на ${formatMoneyRub(dirtyTotal)}.`); + if (commercialProfiles.length > 0) { + lines.push(""); + lines.push("Блок 4. Чистый открытый остаток по договорам"); + lines.push(...renderContractProfileLines(commercialProfiles, false)); + } + if (commercialReceivables.length > 0) { + lines.push(""); + lines.push("Блок 5. Коммерческие дебиторские компоненты"); + lines.push(...renderConfirmedContractLines(commercialReceivables, false)); + } + if (commercialPayables.length > 0) { + lines.push(""); + lines.push("Блок 6. Коммерческие кредиторские компоненты"); + lines.push(...renderConfirmedContractLines(commercialPayables, false)); + } + if (commercialAdvances.length > 0) { + lines.push(""); + lines.push("Блок 7. Коммерческие авансовые компоненты"); + lines.push(...renderConfirmedContractLines(commercialAdvances, false)); + } + if (commercialOther.length > 0) { + lines.push(""); + lines.push("Блок 8. Прочие компоненты по 76"); + lines.push(...renderConfirmedContractLines(commercialOther, false)); + } + if (specialProfiles.length > 0) { + lines.push(""); + lines.push("Блок 9. Финансовые/специальные позиции"); + lines.push(...renderContractProfileLines(specialProfiles, true)); + } + if (dirtyProfiles.length > 0) { + lines.push(""); + lines.push("Блок 10. Спорные/некачественно нормализованные позиции"); + lines.push(...renderContractProfileLines(dirtyProfiles, true)); + } + if (confirmedContracts.length === 0) { + lines.push(""); + lines.push("Блок 4. Подтвержденные позиции"); + lines.push("- На дату среза подтвержденные договоры с открытыми взаиморасчетами не найдены."); + } + return { + responseType: "FACTUAL_LIST", + text: joinLines(lines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: "strong", + balance_confirmed: true + } + }; + } + if (intent === "list_open_contracts") { + const contracts = buildOpenContractRiskAggregate(rows); + const counterparties = buildCounterpartyRiskAggregate(rows); + const asOfDate = normalizeIsoDateOnly(options.asOfDate ?? options.periodTo ?? options.periodFrom); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const commercialContracts = contracts.filter((item) => item.category === "commercial"); + const specialContracts = contracts.filter((item) => item.category !== "commercial"); + const commercialTotal = commercialContracts.reduce((sum, item) => sum + item.totalAmount, 0); + const lines = [ + `Итого по предварительному срезу открытых договоров${asOfDate ? ` на ${formatDateRu(asOfDate)}` : ""}: ${formatNumberWithDots(commercialContracts.length)} коммерческих договоров на ${formatMoneyRub(commercialTotal)}.`, + "", + "Блок 1. Статус результата", + "- Результат: предварительный список договоров с возможными незакрытыми расчетами.", + "- Перед финансовым решением нужна сверка карточек договоров и взаиморасчетов в 1С.", + "", + "Блок 2. Что учтено", + ...(asOfDate + ? [`- Дата среза: ${formatDateRu(asOfDate)}.`] + : periodFrom || periodTo + ? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`] + : []), + "- Контур: движения по счетам 60/62/76 и договорная аналитика.", + "", + "Блок 3. Сводка", + `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, + `- Договоров-кандидатов всего: ${formatNumberWithDots(contracts.length)}.`, + `- Основной список (коммерческие): ${formatNumberWithDots(commercialContracts.length)}.`, + `- Вынесено в финансовые/спорные: ${formatNumberWithDots(specialContracts.length)}.` + ]; + if (commercialContracts.length > 0) { + lines.push(""); + lines.push("Блок 4. Основной список (коммерческие договоры)"); + lines.push(...commercialContracts.slice(0, 10).map((item, index) => { + const counterpartiesLabel = item.counterparties.length > 0 ? item.counterparties.join("; ") : "контрагент не определен"; + const sourceRefsSuffix = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; + return `${index + 1}. ${item.contract} | контрагент: ${counterpartiesLabel} | сумма возможного открытого остатка: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | почему в списке: есть признаки незакрытых расчетов на дату${sourceRefsSuffix}`; + })); + if (specialContracts.length > 0) { + lines.push(""); + lines.push("Блок 5. Финансовые/спорные позиции (вынесены отдельно)"); + lines.push(...specialContracts.slice(0, 8).map((item, index) => { + const counterpartiesLabel = item.counterparties.length > 0 ? item.counterparties.join("; ") : "контрагент не определен"; + const sourceRefsSuffix = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; + return `${index + 1}. ${item.contract} | контрагент: ${counterpartiesLabel} | сумма сигнала: ${formatMoneyRub(item.totalAmount)} | причина вынесения: ${summarizeOpenContractSpecialReason(item)}${sourceRefsSuffix}`; + })); + } + } + else if (counterparties.length > 0) { + lines.push(""); + lines.push("Блок 4. Контрагенты с сигналом незакрытых расчетов"); + lines.push(`- Контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`); + lines.push(...counterparties + .slice(0, 8) + .map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`)); + lines.push("- Договорные реквизиты выделены недостаточно надежно, поэтому показан контрагентный список для проверки."); + } + else { + lines.push(""); + lines.push("Блок 4. Позиции не выделены"); + lines.push("- По текущему live-срезу не удалось выделить договоры с достаточным качеством идентификации."); + lines.push("Блок 5. Примеры исходных строк"); lines.push(...formatTopRows(rows, 6)); } return { responseType: "FACTUAL_LIST", - text: lines.join("\n") + text: joinLines(lines), + semantics: { + result_mode: "heuristic_candidates", + evidence_strength: contracts.length > 0 || counterparties.length > 0 ? "medium" : "weak", + balance_confirmed: false + } }; } if (intent === "payables_confirmed_as_of_date") { diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 3e5164d..8e7e535 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -22,7 +22,7 @@ function hasAllTimeHint(text) { return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(normalized); } function hasSameDateHint(text) { - return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? "")); + return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? "")); } function hasExplicitPeriodLiteral(text) { return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? "")); @@ -286,6 +286,20 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { const merged = { ...current }; const reasons = []; if (!followupContext) { + if ((intent === "list_open_contracts" || + intent === "open_contracts_confirmed_as_of_date" || + intent === "inventory_on_hand_as_of_date") && + !toNonEmptyString(merged.as_of_date)) { + const periodToForOpenContracts = toNonEmptyString(merged.period_to); + const periodFromForOpenContracts = toNonEmptyString(merged.period_from); + const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts; + if (derivedAsOfDate) { + merged.as_of_date = derivedAsOfDate; + reasons.push(intent === "inventory_on_hand_as_of_date" + ? "as_of_date_derived_from_period_for_inventory" + : "as_of_date_derived_from_period_for_open_contracts"); + } + } return { filters: merged, reasons }; } const previous = followupContext.previous_filters ?? {}; @@ -374,6 +388,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { } if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || + intent === "open_contracts_confirmed_as_of_date" || + intent === "inventory_on_hand_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date") { @@ -442,6 +458,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { const hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage); const asOfPrimaryIntent = intent === "account_balance_snapshot" || intent === "documents_forming_balance" || + intent === "open_contracts_confirmed_as_of_date" || + intent === "inventory_on_hand_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date"; @@ -474,12 +492,28 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { } reasons.push("period_from_followup_context"); } + if ((intent === "list_open_contracts" || + intent === "open_contracts_confirmed_as_of_date" || + intent === "inventory_on_hand_as_of_date") && + !toNonEmptyString(merged.as_of_date)) { + const periodToForOpenContracts = toNonEmptyString(merged.period_to); + const periodFromForOpenContracts = toNonEmptyString(merged.period_from); + const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts; + if (derivedAsOfDate) { + merged.as_of_date = derivedAsOfDate; + reasons.push(intent === "inventory_on_hand_as_of_date" + ? "as_of_date_derived_from_period_for_inventory" + : "as_of_date_derived_from_period_for_open_contracts"); + } + } return { filters: merged, reasons }; } function resolveMissingRequiredFilters(intent, filters) { const requiredByIntent = { account_balance_snapshot: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"], + inventory_on_hand_as_of_date: ["as_of_date"], + open_contracts_confirmed_as_of_date: ["as_of_date"], 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"], @@ -527,7 +561,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo reasons: [...detectedIntent.reasons, "intent_adjusted_to_vat_followup_context"] }; } - if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { + const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup; + if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { return { intent: "open_items_by_counterparty_or_contract", 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 429bdde..930e786 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js +++ b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js @@ -93,6 +93,7 @@ function inferAggregationProfile(intent, shape) { } if (intent === "account_balance_snapshot" || intent === "documents_forming_balance" || + intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date" || diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 8c830ba..99a5b80 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -1070,7 +1070,7 @@ function hasStandaloneAddressTopicSignal(text) { if (!hasRequestCue) { return false; } - const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat)/iu.test(normalized); + const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|склад|товар|номенклат|материал|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat|warehouse|inventory|stock|item)/iu.test(normalized); if (!hasBusinessObject) { return false; } @@ -3843,10 +3843,12 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "counterparty_activity_lifecycle", "customer_revenue_and_payments", "supplier_payouts_profile", + "open_contracts_confirmed_as_of_date", "list_open_contracts", "open_items_by_counterparty_or_contract", "list_payables_counterparties", "list_receivables_counterparties", + "inventory_on_hand_as_of_date", "payables_confirmed_as_of_date", "receivables_confirmed_as_of_date", "list_documents_by_contract", @@ -4236,7 +4238,7 @@ function resolveAssistantOrchestrationDecision(input) { } function hasStrongDataIntentSignal(text) { const lower = String(text ?? "").toLowerCase(); - return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм)/i.test(lower); + return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|склад|товар|номенклат|материал|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|warehouse|inventory|stock|item|организац|компан|контор|фирм)/i.test(lower); } function hasDataRetrievalRequestSignal(text) { const lower = compactWhitespace(String(text ?? "").toLowerCase()); @@ -4244,12 +4246,12 @@ function hasDataRetrievalRequestSignal(text) { return false; } const hasBroadInterrogative = /(?:\u0433\u0434\u0435|\u0432\s+\u043a\u0430\u043a\u0438\u0445|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c|\u043f\u043e\s+\u043a\u043e\u043c\u0443|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0442\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|where|which|who|how\s+many)/iu.test(lower); - const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover)/iu.test(lower); + const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|\u0441\u043a\u043b\u0430\u0434|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442|\u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover|warehouse|inventory|stock|item)/iu.test(lower); if (hasBroadInterrogative && hasBroadBusinessObject) { return true; } const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a|\u043f\u0440\u043e\u0432\u0435\u0440\u044c|\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c)(?:$|[\s,.!?;:])/iu.test(lower); - const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433)/iu.test(lower); + const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u043a\u043b\u0430\u0434|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442|\u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b)/iu.test(lower); if (hasRussianRetrievalAction && hasRussianRetrievalObject) { return true; } @@ -4258,7 +4260,7 @@ function hasDataRetrievalRequestSignal(text) { if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) { return false; } - const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower); + const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|склад|товар|номенклат|материал|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|warehouse|inventory|stock|item|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower); if (!hasRetrievalObject) { return false; } @@ -5080,14 +5082,13 @@ async function resolveAssistantDataScopeProbe() { }; } const catalogQueryCandidates = [ - "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН�?Е(Организации.Ссылка) КАК Организация �?З Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация �?З Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация �?З Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН�?Е(Организации.Ссылка) КАК ОрганизацияПредставление �?З Справочник.Организации КАК Организации" + "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации" ]; const movementProbeCandidates = [ - "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН�?Е(Движения.Организация) КАК ОрганизацияПредставление �?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ�?ТЬ ПО Движения.Период УБЫВ", - "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация �?З РегистрБухгалтерии.Хозрасчетный КАК Движения" + "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ", + "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения" ]; let lastError = null; const catalogFacts = { names: [], refs: [], pairs: [] }; @@ -5218,7 +5219,7 @@ function buildAssistantOperationalBoundaryReply() { return [ "Понимаю, что ситуация срочная.", "Я не могу сам настраивать 1С или менять базу/конфигурацию.", - "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/�?Т-админа." + "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа." ].join(" "); } function buildAssistantSafetyRefusalReply() { diff --git a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts index 15059cb..56a7308 100644 --- a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts +++ b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts @@ -26,6 +26,7 @@ export interface AddressCapabilityRouteDecision { const COMPUTE_EXACT_INTENTS = new Set([ "account_balance_snapshot", "documents_forming_balance", + "inventory_on_hand_as_of_date", "open_contracts_confirmed_as_of_date", "payables_confirmed_as_of_date", "receivables_confirmed_as_of_date", @@ -74,6 +75,9 @@ function defaultCapabilityId(intent: AddressIntent): string { if (intent === "vat_liability_confirmed_for_tax_period") { return "confirmed_vat_liability_for_tax_period"; } + if (intent === "inventory_on_hand_as_of_date") { + return "confirmed_inventory_on_hand_as_of_date"; + } if (intent === "list_payables_counterparties") { return "payables_candidates_list"; } @@ -134,6 +138,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re : "vat_liability_confirmed_tax_period_route_disabled_by_flag" }; } + if (intent === "inventory_on_hand_as_of_date") { + return { + enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, + reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 + ? "inventory_on_hand_route_enabled" + : "inventory_on_hand_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 52703d4..b1bdebd 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -927,6 +927,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array>): VatMetadataObject[] { const out: VatMetadataObject[] = []; const seen = new Set(); @@ -1086,6 +1110,17 @@ function collectAnalyticsStrings(row: Record): string[] { "Контрагент", "Contract", "Договор", + "Item", + "item", + "Номенклатура", + "НоменклатураПредставление", + "Warehouse", + "warehouse", + "Склад", + "СкладПредставление", + "Quantity", + "quantity", + "Количество", "Organization", "Организация", "ОрганизацияПредставление", @@ -1108,6 +1143,10 @@ function collectAnalyticsStrings(row: Record): string[] { lowerKey.includes("субконто") || lowerKey.includes("контраг") || lowerKey.includes("договор") || + lowerKey.includes("warehouse") || + lowerKey.includes("склад") || + lowerKey.includes("item") || + lowerKey.includes("номенклат") || lowerKey.includes("organization") || lowerKey.includes("организац") ) { @@ -1132,6 +1171,16 @@ function toNormalizedRows(rows: Array>): NormalizedAddre const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null; const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); + const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity); + const item = firstNonEmptyString(row.Номенклатура, row.Item, row.item, row.НоменклатураПредставление); + const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление); + const organization = firstNonEmptyString( + row.Организация, + row.Organization, + row.organization, + row.organization_name, + row.ОрганизацияПредставление + ); const analytics = collectAnalyticsStrings(row); return { @@ -1140,7 +1189,11 @@ function toNormalizedRows(rows: Array>): NormalizedAddre account_dt: accountDt, account_kt: accountKt, amount, - analytics + analytics, + quantity, + item, + warehouse, + organization }; }) .filter((item) => Boolean(item.period || item.registrator)); @@ -1313,6 +1366,7 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean { return ( intent === "account_balance_snapshot" || intent === "documents_forming_balance" || + intent === "inventory_on_hand_as_of_date" || intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || @@ -2164,6 +2218,8 @@ function buildLimitedOffers(input: { offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); } else if (input.intent === "receivables_confirmed_as_of_date") { offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); + } else if (input.intent === "inventory_on_hand_as_of_date") { + offers.push("показать подтвержденный срез товаров на складах на дату по остатку счета 41.01"); } else if (input.intent === "open_contracts_confirmed_as_of_date") { offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76"); } else if (input.intent === "vat_payable_confirmed_as_of_date") { @@ -2223,6 +2279,7 @@ function buildLimitedIntentSignalLine(input: { open_contracts_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный список договоров с открытыми взаиморасчетами на дату.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", + inventory_on_hand_as_of_date: "Сигнал запроса: нужен подтвержденный срез товаров на складе на дату.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.", vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату.", @@ -2425,7 +2482,9 @@ function buildLimitedExecutionResult(input: { resultSemantics.result_mode ); const exactLimitedReason = - input.intent.intent === "payables_confirmed_as_of_date" + input.intent.intent === "inventory_on_hand_as_of_date" + ? "exact_inventory_mode_limited_response" + : input.intent.intent === "payables_confirmed_as_of_date" ? "exact_payables_mode_limited_response" : input.intent.intent === "receivables_confirmed_as_of_date" ? "exact_receivables_mode_limited_response" @@ -2553,6 +2612,8 @@ export class AddressQueryService { intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; const confirmedBalanceVatPayableIntent = intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; + const confirmedBalanceInventoryIntent = + intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance"; const payablesConfirmedExecution = confirmedBalancePayablesIntent ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) @@ -2563,7 +2624,11 @@ export class AddressQueryService { const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) : null; + const inventoryConfirmedExecution = confirmedBalanceInventoryIntent + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + : null; const executionFilters = + inventoryConfirmedExecution?.executionFilters ?? payablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ?? vatPayableConfirmedExecution?.executionFilters ?? @@ -2601,6 +2666,17 @@ export class AddressQueryService { baseReasons.push("as_of_date_derived_for_confirmed_vat_payable"); } } + if ( + inventoryConfirmedExecution?.asOfDerived && + !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) + ) { + if (!filters.warnings.includes("as_of_date_derived_for_inventory_on_hand")) { + filters.warnings.push("as_of_date_derived_for_inventory_on_hand"); + } + if (!baseReasons.includes("as_of_date_derived_for_inventory_on_hand")) { + baseReasons.push("as_of_date_derived_for_inventory_on_hand"); + } + } const capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent); const shadowRouteAudit = buildShadowRouteAudit({ @@ -2686,6 +2762,12 @@ export class AddressQueryService { ) { baseReasons.push("confirmed_balance_exact_receivables_intent"); } + if ( + intent.intent === "inventory_on_hand_as_of_date" && + !baseReasons.includes("confirmed_balance_exact_inventory_intent") + ) { + baseReasons.push("confirmed_balance_exact_inventory_intent"); + } if ( intent.intent === "vat_payable_confirmed_as_of_date" && !baseReasons.includes("confirmed_balance_exact_vat_payable_intent") diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 70a5860..c9bb696 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -137,6 +137,26 @@ const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` Сумма __ORDER_DIRECTION__ `; +const INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт, + "" КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокДт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Номенклатура, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК Склад, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация, + Остатки.КоличествоРазвернутыйОстатокДт КАК Количество +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.КоличествоРазвернутыйОстатокДт > 0 + И (__INVENTORY_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Количество __ORDER_DIRECTION__ +`; + const BANK_DOCS_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ БанкСписание.Дата КАК Период, @@ -676,6 +696,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "preferred", query_template: "vat_liability_confirmed_tax_period_profile" }, + { + recipe_id: "address_inventory_on_hand_as_of_date_v1", + intent: "inventory_on_hand_as_of_date", + purpose: "Build confirmed stock-on-hand snapshot from balances on goods-on-warehouse account 41.01", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "limit", "sort"], + default_limit: 300, + account_scope: ["41.01"], + account_scope_mode: "strict", + query_template: "inventory_on_hand_as_of_balance_profile" + }, { recipe_id: "address_open_contracts_confirmed_as_of_date_v1", intent: "open_contracts_confirmed_as_of_date", @@ -1035,6 +1066,7 @@ function maxLimitForIntent(intent: AddressIntent): number { intent === "contract_usage_and_value" || intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period" || + intent === "inventory_on_hand_as_of_date" || intent === "open_contracts_confirmed_as_of_date" || intent === "list_contracts_by_counterparty" || intent === "list_documents_by_counterparty" || @@ -1208,6 +1240,25 @@ export function buildAddressRecipePlan( ) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() + : recipe.query_template === "inventory_on_hand_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 INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"])) + .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 === "open_contracts_confirmed_as_of_balance_profile" diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index c36a762..fce5883 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -12,6 +12,10 @@ export interface ComposeStageRow { account_kt: string | null; amount: number | null; analytics: string[]; + quantity?: number | null; + item?: string | null; + warehouse?: string | null; + organization?: string | null; } export interface VatDirectSourceProbeItem { @@ -750,6 +754,162 @@ function extractCounterpartyName(row: ComposeStageRow): string | null { return null; } +function extractInventoryItemName(row: ComposeStageRow): string | null { + const direct = String(row.item ?? "").trim(); + if (direct) { + return direct; + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (!normalized) { + continue; + } + if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) { + continue; + } + if (/(?:склад|warehouse|ооо|ао|пао|зао|ип|организац)/iu.test(normalized)) { + continue; + } + return normalized; + } + return null; +} + +function extractInventoryWarehouseName(row: ComposeStageRow): string | null { + const direct = String(row.warehouse ?? "").trim(); + if (direct) { + return direct; + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (/(?:склад|warehouse)/iu.test(normalized)) { + return normalized; + } + } + return null; +} + +function extractInventoryOrganizationName(row: ComposeStageRow): string | null { + const direct = String(row.organization ?? "").trim(); + if (direct) { + return direct; + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (!normalized) { + continue; + } + if (/(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»„“()\\\/.,;:]))|организац|комитет|департамент|министерств|служб|управлени|казенн|администрац/iu.test(normalized)) { + return normalized; + } + } + return null; +} + +function extractInventoryQuantity(row: ComposeStageRow): number | null { + return typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : null; +} + +interface InventoryOnHandAggregate { + item: string; + warehouse: string | null; + organization: string | null; + quantity: number; + amount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + sourceRefs: string[]; +} + +function buildInventoryOnHandAggregate(rows: ComposeStageRow[], asOfDate: string): InventoryOnHandAggregate[] { + const byPosition = new Map< + string, + { + item: string; + warehouse: string | null; + organization: string | null; + quantity: number; + amount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + sourceRefs: Set; + } + >(); + const asOfTimestamp = toUtcDayTimestamp(asOfDate); + + for (const row of rows) { + const item = extractInventoryItemName(row); + if (!item) { + continue; + } + const rowTimestamp = toUtcDayTimestamp(row.period); + if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) { + continue; + } + const quantity = extractInventoryQuantity(row); + if (quantity === null || quantity <= 0) { + continue; + } + const warehouse = extractInventoryWarehouseName(row); + const organization = extractInventoryOrganizationName(row); + const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0; + const key = [normalizeEntityToken(item), normalizeEntityToken(warehouse), normalizeEntityToken(organization)].join("|"); + const registrator = String(row.registrator ?? "").trim(); + const current = byPosition.get(key); + if (!current) { + byPosition.set(key, { + item, + warehouse, + organization, + quantity, + amount, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + sourceRefs: new Set(registrator && registrator !== "Остатки на дату" ? [registrator] : []) + }); + continue; + } + current.quantity += quantity; + current.amount += amount; + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (registrator && registrator !== "Остатки на дату") { + current.sourceRefs.add(registrator); + } + } + + return Array.from(byPosition.values()) + .map((item) => ({ + item: item.item, + warehouse: item.warehouse, + organization: item.organization, + quantity: item.quantity, + amount: item.amount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + sourceRefs: Array.from(item.sourceRefs).slice(0, 3) + })) + .filter((item) => item.quantity > 0) + .sort((left, right) => { + if (right.quantity !== left.quantity) { + return right.quantity - left.quantity; + } + if (right.amount !== left.amount) { + return right.amount - left.amount; + } + return left.item.localeCompare(right.item, "ru"); + }); +} + interface CounterpartyRiskAggregate { name: string; totalAmount: number; @@ -3502,6 +3662,63 @@ export function composeFactualReply( }; } + if (intent === "inventory_on_hand_as_of_date") { + const asOfDate = resolvePayablesAsOfDate(options); + const positions = buildInventoryOnHandAggregate(rows, asOfDate); + const uniqueItems = uniqueStrings(positions.map((item) => item.item)); + const uniqueWarehouses = uniqueStrings( + positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0) + ); + const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0); + const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); + + const lines: string[] = [ + `Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`, + "", + "Блок 1. Статус результата", + "- Результат: подтвержденный список товарных остатков на дату.", + "", + "Блок 2. Что учтено", + `- Дата среза: ${formatDateRu(asOfDate)}.`, + "- Контур: остатки по счету 41.01 «Товары на складах».", + "- Базовая единица детализации: одна строка = товар, склад и организация на дату.", + "", + "Блок 3. Сводка", + `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, + `- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`, + `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, + `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, + `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`, + `- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`, + "", + "Блок 4. Подтвержденные позиции" + ]; + + if (positions.length > 0) { + lines.push( + ...positions.slice(0, 20).map((item, index) => { + const warehouseLabel = item.warehouse ?? "склад не определен"; + const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; + const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : ""; + const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; + return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${formatNumberWithDots(item.quantity, 3)} | стоимость: ${formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`; + }) + ); + } else { + lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + } + + return { + responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: joinLines(lines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: positions.length > 0 ? "strong" : "medium", + balance_confirmed: true + } + }; + } + if (intent === "open_contracts_confirmed_as_of_date") { const asOfDate = resolvePayablesAsOfDate(options); const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate); diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 1cb5288..00645a4 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -367,13 +367,22 @@ function mergeFollowupFilters( const merged: AddressFilterSet = { ...current }; const reasons: string[] = []; if (!followupContext) { - if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) { + if ( + (intent === "list_open_contracts" || + intent === "open_contracts_confirmed_as_of_date" || + intent === "inventory_on_hand_as_of_date") && + !toNonEmptyString(merged.as_of_date) + ) { const periodToForOpenContracts = toNonEmptyString(merged.period_to); const periodFromForOpenContracts = toNonEmptyString(merged.period_from); const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts; if (derivedAsOfDate) { merged.as_of_date = derivedAsOfDate; - reasons.push("as_of_date_derived_from_period_for_open_contracts"); + reasons.push( + intent === "inventory_on_hand_as_of_date" + ? "as_of_date_derived_from_period_for_inventory" + : "as_of_date_derived_from_period_for_open_contracts" + ); } } return { filters: merged, reasons }; @@ -480,6 +489,7 @@ function mergeFollowupFilters( intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date" || + intent === "inventory_on_hand_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date" @@ -561,6 +571,7 @@ function mergeFollowupFilters( intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "open_contracts_confirmed_as_of_date" || + intent === "inventory_on_hand_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date"; @@ -598,13 +609,22 @@ function mergeFollowupFilters( reasons.push("period_from_followup_context"); } - if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) { + if ( + (intent === "list_open_contracts" || + intent === "open_contracts_confirmed_as_of_date" || + intent === "inventory_on_hand_as_of_date") && + !toNonEmptyString(merged.as_of_date) + ) { const periodToForOpenContracts = toNonEmptyString(merged.period_to); const periodFromForOpenContracts = toNonEmptyString(merged.period_from); const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts; if (derivedAsOfDate) { merged.as_of_date = derivedAsOfDate; - reasons.push("as_of_date_derived_from_period_for_open_contracts"); + reasons.push( + intent === "inventory_on_hand_as_of_date" + ? "as_of_date_derived_from_period_for_inventory" + : "as_of_date_derived_from_period_for_open_contracts" + ); } } @@ -615,6 +635,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi const requiredByIntent: Record> = { account_balance_snapshot: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"], + inventory_on_hand_as_of_date: ["as_of_date"], open_contracts_confirmed_as_of_date: ["as_of_date"], payables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"], diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 52ee44c..186745a 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -1024,7 +1024,7 @@ function hasStandaloneAddressTopicSignal(text) { if (!hasRequestCue) { return false; } - const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat)/iu.test(normalized); + const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|склад|товар|номенклат|материал|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat|warehouse|inventory|stock|item)/iu.test(normalized); if (!hasBusinessObject) { return false; } @@ -3806,6 +3806,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "open_items_by_counterparty_or_contract", "list_payables_counterparties", "list_receivables_counterparties", + "inventory_on_hand_as_of_date", "payables_confirmed_as_of_date", "receivables_confirmed_as_of_date", "list_documents_by_contract", @@ -4195,7 +4196,7 @@ export function resolveAssistantOrchestrationDecision(input) { } function hasStrongDataIntentSignal(text) { const lower = String(text ?? "").toLowerCase(); - return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм)/i.test(lower); + return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|склад|товар|номенклат|материал|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|warehouse|inventory|stock|item|организац|компан|контор|фирм)/i.test(lower); } function hasDataRetrievalRequestSignal(text) { const lower = compactWhitespace(String(text ?? "").toLowerCase()); @@ -4203,12 +4204,12 @@ function hasDataRetrievalRequestSignal(text) { return false; } const hasBroadInterrogative = /(?:\u0433\u0434\u0435|\u0432\s+\u043a\u0430\u043a\u0438\u0445|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c|\u043f\u043e\s+\u043a\u043e\u043c\u0443|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0442\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|where|which|who|how\s+many)/iu.test(lower); - const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover)/iu.test(lower); + const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|\u0441\u043a\u043b\u0430\u0434|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442|\u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover|warehouse|inventory|stock|item)/iu.test(lower); if (hasBroadInterrogative && hasBroadBusinessObject) { return true; } const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a|\u043f\u0440\u043e\u0432\u0435\u0440\u044c|\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c)(?:$|[\s,.!?;:])/iu.test(lower); - const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433)/iu.test(lower); + const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u043a\u043b\u0430\u0434|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442|\u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b)/iu.test(lower); if (hasRussianRetrievalAction && hasRussianRetrievalObject) { return true; } @@ -4217,7 +4218,7 @@ function hasDataRetrievalRequestSignal(text) { if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) { return false; } - const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower); + const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|склад|товар|номенклат|материал|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|warehouse|inventory|stock|item|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower); if (!hasRetrievalObject) { return false; } diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index a06253d..2264338 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -19,6 +19,7 @@ export type AddressIntent = | "payables_confirmed_as_of_date" | "receivables_confirmed_as_of_date" | "list_receivables_counterparties" + | "inventory_on_hand_as_of_date" | "account_balance_snapshot" | "open_items_by_counterparty_or_contract" | "list_documents_by_counterparty" @@ -138,7 +139,8 @@ export interface AddressRecipeDefinition { | "vat_payable_confirmed_as_of_balance_profile" | "open_contracts_confirmed_as_of_balance_profile" | "payables_confirmed_as_of_balance_profile" - | "receivables_confirmed_as_of_balance_profile"; + | "receivables_confirmed_as_of_balance_profile" + | "inventory_on_hand_as_of_balance_profile"; required_filters: Array; optional_filters: Array; default_limit: number; diff --git a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts index 47ab661..e50f340 100644 --- a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts +++ b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts @@ -33,6 +33,15 @@ describe("address capability policy", () => { expect(isCapabilityRouteBlocked(decision)).toBe(false); }); + it("maps inventory-on-hand intent to compute exact capability", () => { + const decision = resolveAddressCapabilityRouteDecision("inventory_on_hand_as_of_date"); + expect(decision.capability_id).toBe("confirmed_inventory_on_hand_as_of_date"); + expect(decision.capability_layer).toBe("compute"); + expect(decision.capability_route_mode).toBe("exact"); + expect(decision.capability_route_enabled).toBe(true); + expect(isCapabilityRouteBlocked(decision)).toBe(false); + }); + it("maps document drilldown intent to navigation capability", () => { const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract"); expect(decision.capability_id).toBe("documents_drilldown"); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 27ab38a..38475d2 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -4327,6 +4327,72 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("VAT_BOOK_SALES"); expect(plan.query).toContain("VAT_BOOK_PURCHASES"); }); + + it("keeps inventory-on-hand phrasing in address lane", () => { + const result = detectAddressQuestionMode("Какие товары сейчас лежат на складе"); + expect(result.mode).toBe("address_query"); + }); + + it("detects exact inventory-on-hand intent", () => { + const result = resolveAddressIntent("Какие товары сейчас лежат на складе"); + expect(result.intent).toBe("inventory_on_hand_as_of_date"); + expect(result.confidence).toBe("high"); + }); + + it("derives as_of_date for inventory-on-hand from explicit month window", () => { + const filters = extractAddressFilters( + "Какие товары лежат на складе на март 2020", + "inventory_on_hand_as_of_date" + ).extracted_filters; + expect(filters.period_from).toBe("2020-03-01"); + expect(filters.period_to).toBe("2020-03-31"); + expect(filters.as_of_date).toBe("2020-03-31"); + }); + + it("builds exact balance query for inventory-on-hand snapshot", () => { + const selected = selectAddressRecipe("inventory_on_hand_as_of_date", { + as_of_date: "2020-03-31" + }); + expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_on_hand_as_of_date_v1"); + const plan = buildAddressRecipePlan(selected.selected_recipe!, { + as_of_date: "2020-03-31" + }); + + expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки("); + expect(plan.query).toContain("КоличествоРазвернутыйОстатокДт"); + expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Остатки.Счет.Код, \"\"), 1, 5) = \"41.01\""); + }); + + it("renders confirmed inventory-on-hand snapshot from normalized rows", () => { + const reply = composeFactualReply( + "inventory_on_hand_as_of_date", + [ + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: "41.01", + account_kt: "", + amount: 712500, + analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка"], + item: "Шкаф картотечный", + warehouse: "Основной склад", + organization: "ООО Ромашка", + quantity: 15 + } + ], + { + asOfDate: "2020-03-31", + useRubCurrency: true + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("Собран подтвержденный срез товаров на складах"); + expect(reply.text).toContain("Контур: остатки по счету 41.01"); + expect(reply.text).toContain("Шкаф картотечный"); + expect(reply.text).toContain("Основной склад"); + expect(reply.semantics?.result_mode).toBe("confirmed_balance"); + expect(reply.semantics?.balance_confirmed).toBe(true); + }); }); - diff --git a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts index 89ec64d..24aa727 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 inventory route", () => { + const audit = evaluateAddressRouteExpectation({ + intent: "inventory_on_hand_as_of_date", + selectedRecipe: "address_inventory_on_hand_as_of_date_v1", + requestedResultMode: "confirmed_balance", + resultMode: "confirmed_balance" + }); + expect(audit.status).toBe("matched"); + 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", diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index f401540..c8a4e22 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -646,6 +646,24 @@ describe("assistant orchestration contract", () => { expect(decision.toolGateReason).toBe("address_mode_classifier_detected"); }); + it("keeps inventory-on-hand query in address lane", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "Какие товары сейчас лежат на складе", + effectiveAddressUserMessage: "Какие товары сейчас лежат на складе", + followupContext: null, + llmPreDecomposeMeta: null as any, + useMock: true + } as any); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected"]).toContain( + String(decision.toolGateReason) + ); + expect(decision.livingMode).toBe("address_data"); + expect(decision.livingReason).toBe("address_lane_triggered"); + }); + it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",