ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Протянуть exact capability складских остатков товаров на дату

This commit is contained in:
dctouch 2026-04-13 20:40:24 +03:00
parent c2ac0c610b
commit 2b48229312
27 changed files with 1781 additions and 95 deletions

View File

@ -20,6 +20,12 @@
"capability_layer": "compute", "capability_layer": "compute",
"capability_route_mode": "exact" "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", "intent": "list_payables_counterparties",
"capability_id": "payables_candidates_list", "capability_id": "payables_candidates_list",

View File

@ -20,6 +20,12 @@
"expected_requested_result_modes": ["confirmed_balance"], "expected_requested_result_modes": ["confirmed_balance"],
"expected_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", "intent": "vat_payable_forecast",
"expected_selected_recipes": ["address_vat_payable_forecast_v1"] "expected_selected_recipes": ["address_vat_payable_forecast_v1"]

View File

@ -163,6 +163,47 @@
"Счет 50, 51, 52, 55" "Счет 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_code": "capability_boundaries",
"group_title": "Ограничения", "group_title": "Ограничения",

View File

@ -7,6 +7,8 @@ const config_1 = require("../config");
const COMPUTE_EXACT_INTENTS = new Set([ const COMPUTE_EXACT_INTENTS = new Set([
"account_balance_snapshot", "account_balance_snapshot",
"documents_forming_balance", "documents_forming_balance",
"inventory_on_hand_as_of_date",
"open_contracts_confirmed_as_of_date",
"payables_confirmed_as_of_date", "payables_confirmed_as_of_date",
"receivables_confirmed_as_of_date", "receivables_confirmed_as_of_date",
"vat_payable_confirmed_as_of_date", "vat_payable_confirmed_as_of_date",
@ -41,12 +43,18 @@ function defaultCapabilityId(intent) {
if (intent === "receivables_confirmed_as_of_date") { if (intent === "receivables_confirmed_as_of_date") {
return "confirmed_receivables_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") { if (intent === "vat_payable_confirmed_as_of_date") {
return "confirmed_vat_payable_as_of_date"; return "confirmed_vat_payable_as_of_date";
} }
if (intent === "vat_liability_confirmed_for_tax_period") { if (intent === "vat_liability_confirmed_for_tax_period") {
return "confirmed_vat_liability_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") { if (intent === "list_payables_counterparties") {
return "payables_candidates_list"; return "payables_candidates_list";
} }
@ -82,6 +90,14 @@ function resolveCapabilityEnabled(intent) {
: "receivables_confirmed_route_disabled_by_flag" : "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") { if (intent === "vat_payable_confirmed_as_of_date") {
return { return {
enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, 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" : "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") { if (intent === "list_payables_counterparties") {
return { return {
enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,

View File

@ -816,12 +816,18 @@ function requiredFiltersByIntent(intent) {
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"]; return ["account", "as_of_date"];
} }
if (intent === "inventory_on_hand_as_of_date") {
return ["as_of_date"];
}
if (intent === "payables_confirmed_as_of_date") { if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"]; return ["as_of_date"];
} }
if (intent === "receivables_confirmed_as_of_date") { if (intent === "receivables_confirmed_as_of_date") {
return ["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") { if (intent === "vat_payable_confirmed_as_of_date") {
return ["as_of_date"]; return ["as_of_date"];
} }
@ -839,8 +845,10 @@ function requiredFiltersByIntent(intent) {
return []; return [];
} }
function usesAsOfPrimaryWindow(intent) { 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 === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"); intent === "vat_payable_confirmed_as_of_date");
@ -861,7 +869,9 @@ function extractAddressFilters(userMessage, intent) {
sort: "period_desc" sort: "period_desc"
}; };
if (!isManagementProfileIntent) { if (!isManagementProfileIntent) {
filters.limit = 20; if (intent !== "open_contracts_confirmed_as_of_date") {
filters.limit = 20;
}
} }
const warnings = []; const warnings = [];
const explicitAsOfDate = extractAsOfDate(text); const explicitAsOfDate = extractAsOfDate(text);
@ -1016,6 +1026,7 @@ function extractAddressFilters(userMessage, intent) {
// - else default to today. // - else default to today.
if ((intent === "account_balance_snapshot" || if ((intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") && intent === "vat_payable_confirmed_as_of_date") &&

View File

@ -390,8 +390,10 @@ function hasFlexibleReceivablesDebtSignal(text) {
if (!normalized) { if (!normalized) {
return false; return false;
} }
return (/(?:кто(?:\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) ||
/(?:нам(?:\s+\S+){0,4}\s+кто(?:\s+\S+){0,4}\s+долж)/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) { function hasFlexiblePayablesDebtSignal(text) {
const normalized = String(text ?? ""); const normalized = String(text ?? "");
@ -1300,6 +1302,16 @@ function hasGenericAddressLookupSignal(text) {
function hasAccountNumberAnchor(text) { function hasAccountNumberAnchor(text) {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(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) { function resolveAddressIntent(userMessage) {
const text = String(userMessage ?? "").trim().toLowerCase(); const text = String(userMessage ?? "").trim().toLowerCase();
if (hasVatLiabilityConfirmedTaxPeriodSignal(text)) { if (hasVatLiabilityConfirmedTaxPeriodSignal(text)) {
@ -1402,9 +1414,16 @@ function resolveAddressIntent(userMessage) {
reasons: ["documents_by_account_drilldown_signal_detected"] 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)) { if (hasOpenContractsListSignal(text)) {
return { return {
intent: "list_open_contracts", intent: "open_contracts_confirmed_as_of_date",
confidence: "medium", confidence: "medium",
reasons: ["open_contract_signal_detected"] 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"))) { if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) {
return { return {
intent: "list_open_contracts", intent: "open_contracts_confirmed_as_of_date",
confidence: "medium", confidence: "medium",
reasons: ["open_contract_signal_detected"] reasons: ["open_contract_signal_detected"]
}; };

View File

@ -100,6 +100,14 @@ const ADDRESS_ENTITY_TOKENS = [
"поступлени", "поступлени",
"списан", "списан",
"списани", "списани",
"склад",
"складе",
"складу",
"товар",
"товары",
"товарн",
"номенклат",
"материал",
"долг", "долг",
"должен", "должен",
"должны", "должны",

View File

@ -220,6 +220,24 @@ function detectVatMetadataObjectType(fullName) {
} }
return null; 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) { function extractVatMetadataObjects(rows) {
const out = []; const out = [];
const seen = new Set(); const seen = new Set();
@ -780,6 +798,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent, filters) {
intent === "list_documents_by_counterparty" || intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_counterparty" ||
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "list_payables_counterparties" || intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
@ -893,6 +912,17 @@ function collectAnalyticsStrings(row) {
"Контрагент", "Контрагент",
"Contract", "Contract",
"Договор", "Договор",
"Item",
"item",
"Номенклатура",
"НоменклатураПредставление",
"Warehouse",
"warehouse",
"Склад",
"СкладПредставление",
"Quantity",
"quantity",
"Количество",
"Organization", "Organization",
"Организация", "Организация",
"ОрганизацияПредставление", "ОрганизацияПредставление",
@ -912,6 +942,10 @@ function collectAnalyticsStrings(row) {
lowerKey.includes("субконто") || lowerKey.includes("субконто") ||
lowerKey.includes("контраг") || lowerKey.includes("контраг") ||
lowerKey.includes("договор") || lowerKey.includes("договор") ||
lowerKey.includes("warehouse") ||
lowerKey.includes("склад") ||
lowerKey.includes("item") ||
lowerKey.includes("номенклат") ||
lowerKey.includes("organization") || lowerKey.includes("organization") ||
lowerKey.includes("организац")) { lowerKey.includes("организац")) {
const value = valueAsString(rawValue).trim(); const value = valueAsString(rawValue).trim();
@ -932,6 +966,10 @@ function toNormalizedRows(rows) {
const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null; const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null;
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); 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); const analytics = collectAnalyticsStrings(row);
return { return {
period, period,
@ -939,7 +977,11 @@ function toNormalizedRows(rows) {
account_dt: accountDt, account_dt: accountDt,
account_kt: accountKt, account_kt: accountKt,
amount, amount,
analytics analytics,
quantity,
item,
warehouse,
organization
}; };
}) })
.filter((item) => Boolean(item.period || item.registrator)); .filter((item) => Boolean(item.period || item.registrator));
@ -1066,6 +1108,7 @@ function parseIsoDateUtcTimestamp(value) {
function isCounterpartyRiskIntent(intent) { function isCounterpartyRiskIntent(intent) {
return (intent === "list_receivables_counterparties" || return (intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" || intent === "list_payables_counterparties" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
@ -1080,6 +1123,8 @@ function isHeuristicCandidatesIntent(intent) {
function isConfirmedBalanceIntent(intent) { function isConfirmedBalanceIntent(intent) {
return (intent === "account_balance_snapshot" || return (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || 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 === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date" ||
@ -1125,6 +1170,9 @@ function resolveRequestedResultMode(intent, filters) {
if (isConfirmedBalanceIntent(intent)) { if (isConfirmedBalanceIntent(intent)) {
return "confirmed_balance"; return "confirmed_balance";
} }
if (intent === "list_open_contracts") {
return "heuristic_candidates";
}
if (isHeuristicCandidatesIntent(intent)) { if (isHeuristicCandidatesIntent(intent)) {
const asOfDateBasis = resolveAsOfDateBasis(filters); const asOfDateBasis = resolveAsOfDateBasis(filters);
if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") { if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") {
@ -1245,7 +1293,16 @@ function buildRouteExpectationAudit(input) {
}; };
} }
function enforceStrictAccountScopeForIntent(plan, intent) { 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 plan;
} }
return { return {
@ -1737,6 +1794,12 @@ function buildLimitedOffers(input) {
else if (input.intent === "receivables_confirmed_as_of_date") { else if (input.intent === "receivables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); 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") { else if (input.intent === "vat_payable_confirmed_as_of_date") {
offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*"); offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*");
} }
@ -1786,8 +1849,10 @@ function buildLimitedIntentSignalLine(input) {
bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.", bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.",
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.", open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.", list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
open_contracts_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный список договоров с открытыми взаиморасчетами на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
inventory_on_hand_as_of_date: "Сигнал запроса: нужен подтвержденный срез товаров на складе на дату.",
receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.", payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.",
vat_payable_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 requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(input.reasons, requestedResultMode, undefined, resultSemantics.result_mode); const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(input.reasons, requestedResultMode, undefined, resultSemantics.result_mode);
const exactLimitedReason = input.intent.intent === "payables_confirmed_as_of_date" const exactLimitedReason = input.intent.intent === "inventory_on_hand_as_of_date"
? "exact_payables_mode_limited_response" ? "exact_inventory_mode_limited_response"
: input.intent.intent === "receivables_confirmed_as_of_date" : input.intent.intent === "payables_confirmed_as_of_date"
? "exact_receivables_mode_limited_response" ? "exact_payables_mode_limited_response"
: input.intent.intent === "vat_payable_confirmed_as_of_date" : input.intent.intent === "receivables_confirmed_as_of_date"
? "exact_vat_payable_mode_limited_response" ? "exact_receivables_mode_limited_response"
: input.intent.intent === "vat_liability_confirmed_for_tax_period" : input.intent.intent === "vat_payable_confirmed_as_of_date"
? "exact_vat_tax_period_mode_limited_response" ? "exact_vat_payable_mode_limited_response"
: null; : input.intent.intent === "vat_liability_confirmed_for_tax_period"
? "exact_vat_tax_period_mode_limited_response"
: null;
const reasons = exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason) const reasons = exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason)
? [...reasonsWithConfirmedFallback, exactLimitedReason] ? [...reasonsWithConfirmedFallback, exactLimitedReason]
: reasonsWithConfirmedFallback; : reasonsWithConfirmedFallback;
@ -2036,6 +2103,7 @@ class AddressQueryService {
requestedResultMode === "confirmed_balance"; requestedResultMode === "confirmed_balance";
const confirmedBalanceReceivablesIntent = intent.intent === "receivables_confirmed_as_of_date" && 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 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 const payablesConfirmedExecution = confirmedBalancePayablesIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null; : null;
@ -2045,7 +2113,11 @@ class AddressQueryService {
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null; : null;
const executionFilters = payablesConfirmedExecution?.executionFilters ?? const inventoryConfirmedExecution = confirmedBalanceInventoryIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const executionFilters = inventoryConfirmedExecution?.executionFilters ??
payablesConfirmedExecution?.executionFilters ??
receivablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ??
vatPayableConfirmedExecution?.executionFilters ?? vatPayableConfirmedExecution?.executionFilters ??
filters.extracted_filters; filters.extracted_filters;
@ -2076,6 +2148,15 @@ class AddressQueryService {
baseReasons.push("as_of_date_derived_for_confirmed_vat_payable"); 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 capabilityDecision = (0, addressCapabilityPolicy_1.resolveAddressCapabilityRouteDecision)(intent.intent);
const capabilityAudit = buildCapabilityAudit(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent);
const shadowRouteAudit = buildShadowRouteAudit({ const shadowRouteAudit = buildShadowRouteAudit({
@ -2149,6 +2230,10 @@ class AddressQueryService {
!baseReasons.includes("confirmed_balance_exact_receivables_intent")) { !baseReasons.includes("confirmed_balance_exact_receivables_intent")) {
baseReasons.push("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" && if (intent.intent === "vat_payable_confirmed_as_of_date" &&
!baseReasons.includes("confirmed_balance_exact_vat_payable_intent")) { !baseReasons.includes("confirmed_balance_exact_vat_payable_intent")) {
baseReasons.push("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 && const allowConfirmedAsOfZeroSnapshot = filteredRows.length === 0 &&
(composeIntent === "vat_payable_confirmed_as_of_date" || (composeIntent === "vat_payable_confirmed_as_of_date" ||
composeIntent === "open_contracts_confirmed_as_of_date" ||
composeIntent === "payables_confirmed_as_of_date" || composeIntent === "payables_confirmed_as_of_date" ||
composeIntent === "receivables_confirmed_as_of_date") && composeIntent === "receivables_confirmed_as_of_date") &&
(stageStatus === "no_raw_rows" || stageStatus === "materialized_but_filtered_out_by_recipe") && (stageStatus === "no_raw_rows" || stageStatus === "materialized_but_filtered_out_by_recipe") &&

View File

@ -66,6 +66,47 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
УПОРЯДОЧИТЬ ПО УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__ Сумма __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 = ` const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период, __AS_OF_EXPR__ КАК Период,
@ -88,6 +129,25 @@ const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
УПОРЯДОЧИТЬ ПО УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__ Сумма __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 = ` const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период, БанкСписание.Дата КАК Период,
@ -615,6 +675,28 @@ const BASE_RECIPES = [
account_scope_mode: "preferred", account_scope_mode: "preferred",
query_template: "vat_liability_confirmed_tax_period_profile" 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", recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty", intent: "list_contracts_by_counterparty",
@ -921,6 +1003,8 @@ function maxLimitForIntent(intent) {
intent === "contract_usage_and_value" || intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast" || intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" || 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_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" || intent === "list_documents_by_counterparty" ||
intent === "bank_operations_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("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})() })()
: recipe.query_template === "contracts_by_counterparty_profile" : recipe.query_template === "inventory_on_hand_as_of_balance_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)
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 : null) ??
? toDateTimeExpr(filters.as_of_date, true) (typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ?? : null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0 (typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_to, true) ? toDateTimeExpr(filters.period_from, true)
: null) ?? : null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0 "ТЕКУЩАЯДАТА()";
? toDateTimeExpr(filters.period_from, true) return INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE
: null) ?? .replaceAll("__LIMIT__", String(resolvedLimit))
"ТЕКУЩАЯДАТА()"; .replaceAll("__AS_OF_EXPR__", asOfExpr)
return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
.replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
.replaceAll("__AS_OF_EXPR__", asOfExpr) })()
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) : recipe.query_template === "contracts_by_counterparty_profile"
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
})() : recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
: recipe.query_template === "receivables_confirmed_as_of_balance_profile"
? (() => { ? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true) ? toDateTimeExpr(filters.as_of_date, true)
@ -1080,23 +1164,59 @@ function buildAddressRecipePlan(recipe, filters) {
? toDateTimeExpr(filters.period_from, true) ? toDateTimeExpr(filters.period_from, true)
: null) ?? : null) ??
"ТЕКУЩАЯДАТА()"; "ТЕКУЩАЯДАТА()";
return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr) .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)); .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})() })()
: MOVEMENTS_QUERY_TEMPLATE : recipe.query_template === "payables_confirmed_as_of_balance_profile"
.replace("__LIMIT__", String(resolvedLimit)) ? (() => {
.replace("__WHERE_CLAUSE__", (() => { const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
const extraConditions = []; ? toDateTimeExpr(filters.as_of_date, true)
const accountCondition = buildMovementAccountCondition(filters); : null) ??
if (accountCondition) { (typeof filters.period_to === "string" && filters.period_to.trim().length > 0
extraConditions.push(accountCondition); ? toDateTimeExpr(filters.period_to, true)
} : null) ??
return buildWhereClause(filters, "Движения.Период", extraConditions); (typeof filters.period_from === "string" && filters.period_from.trim().length > 0
})()) ? toDateTimeExpr(filters.period_from, true)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); : 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 { return {
recipe, recipe,
query, query,

View File

@ -565,6 +565,130 @@ function extractCounterpartyName(row) {
} }
return null; 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) { function liabilityCategoryLabel(category) {
if (category === "supplier_or_contractor") { if (category === "supplier_or_contractor") {
return "поставщики/подрядчики"; return "поставщики/подрядчики";
@ -1212,6 +1336,455 @@ function contractCandidatesFromRows(rows) {
} }
return uniqueStrings(candidates); 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 = {}) { function composeFactualReply(intent, rows, options = {}) {
const applyNumericEmphasis = (line) => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line); const applyNumericEmphasis = (line) => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line);
const joinLines = (lines) => lines.map(applyNumericEmphasis).join("\n"); const joinLines = (lines) => lines.map(applyNumericEmphasis).join("\n");
@ -2250,31 +2823,259 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n") text: lines.join("\n")
}; };
} }
if (intent === "list_open_contracts") { if (intent === "inventory_on_hand_as_of_date") {
const contracts = contractCandidatesFromRows(rows); const asOfDate = resolvePayablesAsOfDate(options);
const counterparties = buildCounterpartyRiskAggregate(rows); 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 = [ const lines = [
"Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).", `Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`,
`Строк движения: ${rows.length}.`, "",
`Договорных кандидатов: ${contracts.length}.` "Блок 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) { if (positions.length > 0) {
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)); lines.push(...positions.slice(0, 20).map((item, index) => {
} const warehouseLabel = item.warehouse ?? "склад не определен";
else if (counterparties.length > 0) { const organizationLabel = item.organization ? ` | организация: ${item.organization}` : "";
lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`); const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : "";
lines.push(...counterparties const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : "";
.slice(0, 8) return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${formatNumberWithDots(item.quantity, 3)} | стоимость: ${formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`;
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`)); }));
lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска.");
} }
else { 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)); lines.push(...formatTopRows(rows, 6));
} }
return { return {
responseType: "FACTUAL_LIST", 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") { if (intent === "payables_confirmed_as_of_date") {

View File

@ -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); 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) { 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) { function hasExplicitPeriodLiteral(text) {
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(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 merged = { ...current };
const reasons = []; const reasons = [];
if (!followupContext) { 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 }; return { filters: merged, reasons };
} }
const previous = followupContext.previous_filters ?? {}; const previous = followupContext.previous_filters ?? {};
@ -374,6 +388,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
} }
if (intent === "open_items_by_counterparty_or_contract" || if (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" || 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 === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_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 hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage);
const asOfPrimaryIntent = intent === "account_balance_snapshot" || const asOfPrimaryIntent = intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || 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 === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_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"); 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 }; return { filters: merged, reasons };
} }
function resolveMissingRequiredFilters(intent, filters) { function resolveMissingRequiredFilters(intent, filters) {
const requiredByIntent = { const requiredByIntent = {
account_balance_snapshot: ["account", "as_of_date"], account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["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"], payables_confirmed_as_of_date: ["as_of_date"],
receivables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"],
vat_payable_confirmed_as_of_date: ["as_of_date"], vat_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"] 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 { return {
intent: "open_items_by_counterparty_or_contract", intent: "open_items_by_counterparty_or_contract",
confidence: "low", confidence: "low",

View File

@ -93,6 +93,7 @@ function inferAggregationProfile(intent, shape) {
} }
if (intent === "account_balance_snapshot" || if (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date" ||

View File

@ -1070,7 +1070,7 @@ function hasStandaloneAddressTopicSignal(text) {
if (!hasRequestCue) { if (!hasRequestCue) {
return false; 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) { if (!hasBusinessObject) {
return false; return false;
} }
@ -3843,10 +3843,12 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"counterparty_activity_lifecycle", "counterparty_activity_lifecycle",
"customer_revenue_and_payments", "customer_revenue_and_payments",
"supplier_payouts_profile", "supplier_payouts_profile",
"open_contracts_confirmed_as_of_date",
"list_open_contracts", "list_open_contracts",
"open_items_by_counterparty_or_contract", "open_items_by_counterparty_or_contract",
"list_payables_counterparties", "list_payables_counterparties",
"list_receivables_counterparties", "list_receivables_counterparties",
"inventory_on_hand_as_of_date",
"payables_confirmed_as_of_date", "payables_confirmed_as_of_date",
"receivables_confirmed_as_of_date", "receivables_confirmed_as_of_date",
"list_documents_by_contract", "list_documents_by_contract",
@ -4236,7 +4238,7 @@ function resolveAssistantOrchestrationDecision(input) {
} }
function hasStrongDataIntentSignal(text) { function hasStrongDataIntentSignal(text) {
const lower = String(text ?? "").toLowerCase(); 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) { function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase()); const lower = compactWhitespace(String(text ?? "").toLowerCase());
@ -4244,12 +4246,12 @@ function hasDataRetrievalRequestSignal(text) {
return false; 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 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) { if (hasBroadInterrogative && hasBroadBusinessObject) {
return true; 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 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) { if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
return true; return true;
} }
@ -4258,7 +4260,7 @@ function hasDataRetrievalRequestSignal(text) {
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) { if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
return false; 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) { if (!hasRetrievalObject) {
return false; return false;
} }
@ -5080,14 +5082,13 @@ async function resolveAssistantDataScopeProbe() {
}; };
} }
const catalogQueryCandidates = [ const catalogQueryCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН<D095>?Е(Организации.Ссылка) КАК Организация <20>?З Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация <20>?З Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация <20>?З Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации"
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН<D095>?Е(Организации.Ссылка) КАК ОрганизацияПредставление <20>?З Справочник.Организации КАК Организации"
]; ];
const movementProbeCandidates = [ const movementProbeCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН<D095>?Е(Движения.Организация) КАК ОрганизацияПредставление <20>?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ<EFBFBD>?ТЬ ПО Движения.Период УБЫВ", "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения" "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения"
]; ];
let lastError = null; let lastError = null;
const catalogFacts = { names: [], refs: [], pairs: [] }; const catalogFacts = { names: [], refs: [], pairs: [] };
@ -5218,7 +5219,7 @@ function buildAssistantOperationalBoundaryReply() {
return [ return [
"Понимаю, что ситуация срочная.", "Понимаю, что ситуация срочная.",
"Я не могу сам настраивать 1С или менять базу/конфигурацию.", "Я не могу сам настраивать 1С или менять базу/конфигурацию.",
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/<EFBFBD>?Т-админа." "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа."
].join(" "); ].join(" ");
} }
function buildAssistantSafetyRefusalReply() { function buildAssistantSafetyRefusalReply() {

View File

@ -26,6 +26,7 @@ export interface AddressCapabilityRouteDecision {
const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([ const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([
"account_balance_snapshot", "account_balance_snapshot",
"documents_forming_balance", "documents_forming_balance",
"inventory_on_hand_as_of_date",
"open_contracts_confirmed_as_of_date", "open_contracts_confirmed_as_of_date",
"payables_confirmed_as_of_date", "payables_confirmed_as_of_date",
"receivables_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") { if (intent === "vat_liability_confirmed_for_tax_period") {
return "confirmed_vat_liability_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") { if (intent === "list_payables_counterparties") {
return "payables_candidates_list"; return "payables_candidates_list";
} }
@ -134,6 +138,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
: "vat_liability_confirmed_tax_period_route_disabled_by_flag" : "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") { if (intent === "list_payables_counterparties") {
return { return {
enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,

View File

@ -927,6 +927,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"]; return ["account", "as_of_date"];
} }
if (intent === "inventory_on_hand_as_of_date") {
return ["as_of_date"];
}
if (intent === "payables_confirmed_as_of_date") { if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"]; return ["as_of_date"];
} }
@ -957,6 +960,7 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean { function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
return ( return (
intent === "inventory_on_hand_as_of_date" ||
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" || intent === "open_contracts_confirmed_as_of_date" ||
@ -1163,6 +1167,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
if ( if (
(intent === "account_balance_snapshot" || (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") && intent === "vat_payable_confirmed_as_of_date") &&

View File

@ -1542,6 +1542,22 @@ function hasAccountNumberAnchor(text: string): boolean {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text); return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
} }
function hasInventoryOnHandSignal(text: string): boolean {
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);
}
export function resolveAddressIntent(userMessage: string): AddressIntentResolution { export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
const text = String(userMessage ?? "").trim().toLowerCase(); const text = String(userMessage ?? "").trim().toLowerCase();
@ -1659,6 +1675,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
}; };
} }
if (hasInventoryOnHandSignal(text)) {
return {
intent: "inventory_on_hand_as_of_date",
confidence: "high",
reasons: ["inventory_on_hand_signal_detected"]
};
}
if (hasOpenContractsListSignal(text)) { if (hasOpenContractsListSignal(text)) {
return { return {
intent: "open_contracts_confirmed_as_of_date", intent: "open_contracts_confirmed_as_of_date",

View File

@ -100,6 +100,14 @@ const ADDRESS_ENTITY_TOKENS = [
"поступлени", "поступлени",
"списан", "списан",
"списани", "списани",
"склад",
"складе",
"складу",
"товар",
"товары",
"товарн",
"номенклат",
"материал",
"долг", "долг",
"должен", "должен",
"должны", "должны",

View File

@ -55,6 +55,10 @@ interface NormalizedAddressRow {
account_kt: string | null; account_kt: string | null;
amount: number | null; amount: number | null;
analytics: string[]; analytics: string[];
quantity?: number | null;
item?: string | null;
warehouse?: string | null;
organization?: string | null;
} }
interface AddressTryHandleOptions { interface AddressTryHandleOptions {
@ -321,6 +325,26 @@ function detectVatMetadataObjectType(fullName: string): VatMetadataObject["objec
return null; return null;
} }
function firstNonEmptyString(...values: unknown[]): string | null {
for (const value of values) {
const normalized = valueAsString(value).trim();
if (normalized) {
return normalized;
}
}
return null;
}
function firstFiniteNumber(...values: unknown[]): number | null {
for (const value of values) {
const parsed = parseFiniteNumber(value);
if (parsed !== null) {
return parsed;
}
}
return null;
}
function extractVatMetadataObjects(rows: Array<Record<string, unknown>>): VatMetadataObject[] { function extractVatMetadataObjects(rows: Array<Record<string, unknown>>): VatMetadataObject[] {
const out: VatMetadataObject[] = []; const out: VatMetadataObject[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -1086,6 +1110,17 @@ function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
"Контрагент", "Контрагент",
"Contract", "Contract",
"Договор", "Договор",
"Item",
"item",
"Номенклатура",
"НоменклатураПредставление",
"Warehouse",
"warehouse",
"Склад",
"СкладПредставление",
"Quantity",
"quantity",
"Количество",
"Organization", "Organization",
"Организация", "Организация",
"ОрганизацияПредставление", "ОрганизацияПредставление",
@ -1108,6 +1143,10 @@ function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
lowerKey.includes("субконто") || lowerKey.includes("субконто") ||
lowerKey.includes("контраг") || lowerKey.includes("контраг") ||
lowerKey.includes("договор") || lowerKey.includes("договор") ||
lowerKey.includes("warehouse") ||
lowerKey.includes("склад") ||
lowerKey.includes("item") ||
lowerKey.includes("номенклат") ||
lowerKey.includes("organization") || lowerKey.includes("organization") ||
lowerKey.includes("организац") lowerKey.includes("организац")
) { ) {
@ -1132,6 +1171,16 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null; const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null;
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); 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); const analytics = collectAnalyticsStrings(row);
return { return {
@ -1140,7 +1189,11 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
account_dt: accountDt, account_dt: accountDt,
account_kt: accountKt, account_kt: accountKt,
amount, amount,
analytics analytics,
quantity,
item,
warehouse,
organization
}; };
}) })
.filter((item) => Boolean(item.period || item.registrator)); .filter((item) => Boolean(item.period || item.registrator));
@ -1313,6 +1366,7 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
return ( return (
intent === "account_balance_snapshot" || intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "open_contracts_confirmed_as_of_date" || intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
@ -2164,6 +2218,8 @@ function buildLimitedOffers(input: {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} else if (input.intent === "receivables_confirmed_as_of_date") { } else if (input.intent === "receivables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); 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") { } else if (input.intent === "open_contracts_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76"); offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76");
} else if (input.intent === "vat_payable_confirmed_as_of_date") { } else if (input.intent === "vat_payable_confirmed_as_of_date") {
@ -2223,6 +2279,7 @@ function buildLimitedIntentSignalLine(input: {
open_contracts_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный список договоров с открытыми взаиморасчетами на дату.", open_contracts_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный список договоров с открытыми взаиморасчетами на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
inventory_on_hand_as_of_date: "Сигнал запроса: нужен подтвержденный срез товаров на складе на дату.",
receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.", payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.",
vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату.", vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату.",
@ -2425,7 +2482,9 @@ function buildLimitedExecutionResult(input: {
resultSemantics.result_mode resultSemantics.result_mode
); );
const exactLimitedReason = 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" ? "exact_payables_mode_limited_response"
: input.intent.intent === "receivables_confirmed_as_of_date" : input.intent.intent === "receivables_confirmed_as_of_date"
? "exact_receivables_mode_limited_response" ? "exact_receivables_mode_limited_response"
@ -2553,6 +2612,8 @@ export class AddressQueryService {
intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
const confirmedBalanceVatPayableIntent = const confirmedBalanceVatPayableIntent =
intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; 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 = const payablesConfirmedExecution =
confirmedBalancePayablesIntent confirmedBalancePayablesIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
@ -2563,7 +2624,11 @@ export class AddressQueryService {
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null; : null;
const inventoryConfirmedExecution = confirmedBalanceInventoryIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const executionFilters = const executionFilters =
inventoryConfirmedExecution?.executionFilters ??
payablesConfirmedExecution?.executionFilters ?? payablesConfirmedExecution?.executionFilters ??
receivablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ??
vatPayableConfirmedExecution?.executionFilters ?? vatPayableConfirmedExecution?.executionFilters ??
@ -2601,6 +2666,17 @@ export class AddressQueryService {
baseReasons.push("as_of_date_derived_for_confirmed_vat_payable"); 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 capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent);
const capabilityAudit = buildCapabilityAudit(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent);
const shadowRouteAudit = buildShadowRouteAudit({ const shadowRouteAudit = buildShadowRouteAudit({
@ -2686,6 +2762,12 @@ export class AddressQueryService {
) { ) {
baseReasons.push("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 ( if (
intent.intent === "vat_payable_confirmed_as_of_date" && intent.intent === "vat_payable_confirmed_as_of_date" &&
!baseReasons.includes("confirmed_balance_exact_vat_payable_intent") !baseReasons.includes("confirmed_balance_exact_vat_payable_intent")

View File

@ -137,6 +137,26 @@ const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
Сумма __ORDER_DIRECTION__ Сумма __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 = ` const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период, БанкСписание.Дата КАК Период,
@ -676,6 +696,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred", account_scope_mode: "preferred",
query_template: "vat_liability_confirmed_tax_period_profile" 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", recipe_id: "address_open_contracts_confirmed_as_of_date_v1",
intent: "open_contracts_confirmed_as_of_date", intent: "open_contracts_confirmed_as_of_date",
@ -1035,6 +1066,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "contract_usage_and_value" || intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast" || intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" || intent === "vat_liability_confirmed_for_tax_period" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "open_contracts_confirmed_as_of_date" || intent === "open_contracts_confirmed_as_of_date" ||
intent === "list_contracts_by_counterparty" || intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" || intent === "list_documents_by_counterparty" ||
@ -1208,6 +1240,25 @@ export function buildAddressRecipePlan(
) )
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); .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" : recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile" : recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"

View File

@ -12,6 +12,10 @@ export interface ComposeStageRow {
account_kt: string | null; account_kt: string | null;
amount: number | null; amount: number | null;
analytics: string[]; analytics: string[];
quantity?: number | null;
item?: string | null;
warehouse?: string | null;
organization?: string | null;
} }
export interface VatDirectSourceProbeItem { export interface VatDirectSourceProbeItem {
@ -750,6 +754,162 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
return 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<string>;
}
>();
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 { interface CounterpartyRiskAggregate {
name: string; name: string;
totalAmount: number; 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") { if (intent === "open_contracts_confirmed_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options); const asOfDate = resolvePayablesAsOfDate(options);
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate); const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);

View File

@ -367,13 +367,22 @@ function mergeFollowupFilters(
const merged: AddressFilterSet = { ...current }; const merged: AddressFilterSet = { ...current };
const reasons: string[] = []; const reasons: string[] = [];
if (!followupContext) { 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 periodToForOpenContracts = toNonEmptyString(merged.period_to);
const periodFromForOpenContracts = toNonEmptyString(merged.period_from); const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts; const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
if (derivedAsOfDate) { if (derivedAsOfDate) {
merged.as_of_date = 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 }; return { filters: merged, reasons };
@ -480,6 +489,7 @@ function mergeFollowupFilters(
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" || intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date" intent === "vat_payable_confirmed_as_of_date"
@ -561,6 +571,7 @@ function mergeFollowupFilters(
intent === "account_balance_snapshot" || intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" || intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"; intent === "vat_payable_confirmed_as_of_date";
@ -598,13 +609,22 @@ function mergeFollowupFilters(
reasons.push("period_from_followup_context"); 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 periodToForOpenContracts = toNonEmptyString(merged.period_to);
const periodFromForOpenContracts = toNonEmptyString(merged.period_from); const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts; const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
if (derivedAsOfDate) { if (derivedAsOfDate) {
merged.as_of_date = 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<string, Array<keyof AddressFilterSet>> = { const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
account_balance_snapshot: ["account", "as_of_date"], account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["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"], open_contracts_confirmed_as_of_date: ["as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"], payables_confirmed_as_of_date: ["as_of_date"],
receivables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"],

View File

@ -1024,7 +1024,7 @@ function hasStandaloneAddressTopicSignal(text) {
if (!hasRequestCue) { if (!hasRequestCue) {
return false; 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) { if (!hasBusinessObject) {
return false; return false;
} }
@ -3806,6 +3806,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"open_items_by_counterparty_or_contract", "open_items_by_counterparty_or_contract",
"list_payables_counterparties", "list_payables_counterparties",
"list_receivables_counterparties", "list_receivables_counterparties",
"inventory_on_hand_as_of_date",
"payables_confirmed_as_of_date", "payables_confirmed_as_of_date",
"receivables_confirmed_as_of_date", "receivables_confirmed_as_of_date",
"list_documents_by_contract", "list_documents_by_contract",
@ -4195,7 +4196,7 @@ export function resolveAssistantOrchestrationDecision(input) {
} }
function hasStrongDataIntentSignal(text) { function hasStrongDataIntentSignal(text) {
const lower = String(text ?? "").toLowerCase(); 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) { function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase()); const lower = compactWhitespace(String(text ?? "").toLowerCase());
@ -4203,12 +4204,12 @@ function hasDataRetrievalRequestSignal(text) {
return false; 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 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) { if (hasBroadInterrogative && hasBroadBusinessObject) {
return true; 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 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) { if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
return true; return true;
} }
@ -4217,7 +4218,7 @@ function hasDataRetrievalRequestSignal(text) {
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) { if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
return false; 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) { if (!hasRetrievalObject) {
return false; return false;
} }

View File

@ -19,6 +19,7 @@ export type AddressIntent =
| "payables_confirmed_as_of_date" | "payables_confirmed_as_of_date"
| "receivables_confirmed_as_of_date" | "receivables_confirmed_as_of_date"
| "list_receivables_counterparties" | "list_receivables_counterparties"
| "inventory_on_hand_as_of_date"
| "account_balance_snapshot" | "account_balance_snapshot"
| "open_items_by_counterparty_or_contract" | "open_items_by_counterparty_or_contract"
| "list_documents_by_counterparty" | "list_documents_by_counterparty"
@ -138,7 +139,8 @@ export interface AddressRecipeDefinition {
| "vat_payable_confirmed_as_of_balance_profile" | "vat_payable_confirmed_as_of_balance_profile"
| "open_contracts_confirmed_as_of_balance_profile" | "open_contracts_confirmed_as_of_balance_profile"
| "payables_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<keyof AddressFilterSet>; required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>; optional_filters: Array<keyof AddressFilterSet>;
default_limit: number; default_limit: number;

View File

@ -33,6 +33,15 @@ describe("address capability policy", () => {
expect(isCapabilityRouteBlocked(decision)).toBe(false); 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", () => { it("maps document drilldown intent to navigation capability", () => {
const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract"); const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract");
expect(decision.capability_id).toBe("documents_drilldown"); expect(decision.capability_id).toBe("documents_drilldown");

View File

@ -4327,6 +4327,72 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("VAT_BOOK_SALES"); expect(plan.query).toContain("VAT_BOOK_SALES");
expect(plan.query).toContain("VAT_BOOK_PURCHASES"); 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);
});
}); });

View File

@ -45,6 +45,17 @@ describe("address route expectations contract", () => {
expect(audit.reason).toBe("route_expectation_matched"); 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", () => { it("matches expected recipe and result mode for exact VAT tax-period liability route", () => {
const audit = evaluateAddressRouteExpectation({ const audit = evaluateAddressRouteExpectation({
intent: "vat_liability_confirmed_for_tax_period", intent: "vat_liability_confirmed_for_tax_period",

View File

@ -646,6 +646,24 @@ describe("assistant orchestration contract", () => {
expect(decision.toolGateReason).toBe("address_mode_classifier_detected"); 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", () => { it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => {
const decision = resolveAssistantOrchestrationDecision({ const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31", rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",