ДОМЕНЫ - ВОПРОСЫ - fix: переведен exact-маршрут кому должны на дату на Хозрасчетный.Остатки (без Движения.СубконтоДт1/Кт1)

This commit is contained in:
dctouch 2026-04-12 16:59:18 +03:00
parent 278eb4abeb
commit 143cf6efe1
13 changed files with 646 additions and 127 deletions

View File

@ -298,12 +298,19 @@ const CONTRACT_USAGE_OVERVIEW_HINTS = [
const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [
"самые доходные клиенты",
"самые доходные заказчики",
"самые ликвидные клиенты",
"самые ликвидные заказчики",
"самых ликвидних заказчиков",
"топ клиентов по сумме поступлений",
"топ заказчиков по сумме поступлений",
"кто больше всего принес денег",
"кто больше всего принёс денег",
"кто принес больше всего денег",
"кто принёс больше всего денег",
"кто нам больше денег принес",
"кто нам больше денег принёс",
"кто нам принес больше денег",
"кто нам принёс больше денег",
"кто нам больше всего занес",
"кто нам больше всего занёс",
"кто нам принес больше всего",
@ -690,12 +697,18 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksWhoBringsMostMoney = /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(text);
const asksWhoBringsMoneyLoose = /(?:кто\s+(?:нам\s+)?(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк).*(?:прин[её]с|зан[её]с))/iu.test(text) ||
/(?:кто\s+(?:нам\s+)?(?:прин[её]с|зан[её]с).*(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк))/iu.test(text);
const asksLiquidityRanking = /(?:ликвидн|liquid)/iu.test(text) &&
(asksCustomerGroup || hasCounterpartyLexeme || /(?:клиент|заказчик|контрагент|customer|client|counterpart)/iu.test(text));
const asksProfitableYears = /(?:доходн|выручк|оборот|прибыл|revenue|turnover).*(?:год|года|годы|year|years)/iu.test(text) &&
/(?:сам(?:ый|ая|ое|ые)|топ|луч|max|best|наибольш|больше)/iu.test(text);
const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text);
const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text);
const asksOverallTurnover = /(?:общ(?:ий|ие|ая)\s+оборот|общ(?:ая|ий)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text);
const asksMajorShare = /(?:основн(?:ую|ая|ые|ой)\s+част|больш(?:ую|ая|ие)\s+част|львин(?:ая|ую)\s+дол[яю]|ключев(?:ую|ая)\s+част)/iu.test(text);
const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal|turnover)/iu.test(text);
const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|ликвидн|revenue|inflow|deal|turnover|liquid)/iu.test(text);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(text);
const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue;
if (asksCountOnly) {
@ -716,6 +729,15 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoBringsMoneyLoose) {
return true;
}
if (!hasFuzzySupplierLexeme && asksLiquidityRanking) {
return true;
}
if (!hasFuzzySupplierLexeme && asksProfitableYears) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}

View File

@ -859,10 +859,61 @@ function isMissingSubcontoFieldError(errorText) {
if (!normalized) {
return false;
}
return (normalized.includes("поле не найдено") &&
(normalized.includes("субконтодт1") ||
normalized.includes("subcontodt1") ||
normalized.includes("subconto_dt1")));
const ruMissingField = "\u043f\u043e\u043b\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e";
const hasMissingFieldSignal = normalized.includes(ruMissingField) || normalized.includes("field not found");
if (!hasMissingFieldSignal) {
return false;
}
const hasAnySubcontoSignal = /(?:\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e(?:\u0434\u0442|\u043a\u0442)?\d*|subconto(?:_)?(?:dt|kt)?\d*)/iu.test(normalized) ||
normalized.includes("subcontodt") ||
normalized.includes("subcontokt");
return hasAnySubcontoSignal;
}
function buildCompositeSubcontoFallbackQuery(queryText) {
const source = String(queryText ?? "");
if (!source.trim()) {
return null;
}
const dt1Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт1\s*\)\s+КАК\s+СубконтоДт1\s*,?/iu;
const dt2Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт2\s*\)\s+КАК\s+СубконтоДт2\s*,?/iu;
const dt3Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт3\s*\)\s+КАК\s+СубконтоДт3\s*,?/iu;
const kt1Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт1\s*\)\s+КАК\s+СубконтоКт1\s*,?/iu;
const kt2Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт2\s*\)\s+КАК\s+СубконтоКт2\s*,?/iu;
const kt3Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт3\s*\)\s+КАК\s+СубконтоКт3\s*,?/iu;
const lines = source.split(/\r?\n/);
let replaced = false;
const rewrittenLines = lines.map((line) => {
const indent = line.match(/^\s*/)?.[0] ?? "";
if (dt1Pattern.test(line)) {
replaced = true;
return `${indent}ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт) КАК СубконтоДт1,`;
}
if (dt2Pattern.test(line)) {
replaced = true;
return `${indent}"" КАК СубконтоДт2,`;
}
if (dt3Pattern.test(line)) {
replaced = true;
return `${indent}"" КАК СубконтоДт3,`;
}
if (kt1Pattern.test(line)) {
replaced = true;
return `${indent}ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт) КАК СубконтоКт1,`;
}
if (kt2Pattern.test(line)) {
replaced = true;
return `${indent}"" КАК СубконтоКт2,`;
}
if (kt3Pattern.test(line)) {
replaced = true;
return `${indent}"" КАК СубконтоКт3,`;
}
return line;
});
if (!replaced) {
return null;
}
return rewrittenLines.join("\n");
}
function applyFutureDatedRowsGuard(rows, intent, referenceDate) {
if (!isCounterpartyRiskIntent(intent) || rows.length === 0) {
@ -1729,55 +1780,90 @@ class AddressQueryService {
}
}
let effectiveRecipeId = recipeSelection.selected_recipe.recipe_id;
let composeIntent = intent.intent;
let routeExpectationIntent = intent.intent;
let plan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, executionFilters), intent.intent);
let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: plan.query,
limit: plan.limit
});
const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date";
if (mcp.error &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
isMissingSubcontoFieldError(mcp.error) &&
allowOpenItemsFallbackForMissingSubconto) {
const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
const fallbackPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, executionFilters), intent.intent);
const fallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: fallbackPlan.query,
limit: fallbackPlan.limit
const missingSubcontoFallbackEligible = plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1" ||
plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1";
const missingSubcontoErrorDetected = Boolean(mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error));
if (missingSubcontoErrorDetected) {
let missingSubcontoResolvedByComposite = false;
const compositeSubcontoQuery = buildCompositeSubcontoFallbackQuery(plan.query);
if (compositeSubcontoQuery) {
const compositeMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: compositeSubcontoQuery,
limit: plan.limit
});
if (!fallbackMcp.error) {
plan = fallbackPlan;
mcp = fallbackMcp;
if (intent.intent === "list_payables_counterparties") {
if (!compositeMcp.error) {
plan = {
...plan,
query: compositeSubcontoQuery
};
mcp = compositeMcp;
missingSubcontoResolvedByComposite = true;
if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto")) {
baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto");
}
if (intent.intent === "payables_confirmed_as_of_date") {
if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto")) {
baseReasons.push("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto");
}
}
}
else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed")) {
baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed");
}
}
else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_unavailable")) {
baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_unavailable");
}
if (!missingSubcontoResolvedByComposite) {
const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
const fallbackPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, executionFilters), intent.intent);
const fallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: fallbackPlan.query,
limit: fallbackPlan.limit
});
if (!fallbackMcp.error) {
plan = fallbackPlan;
mcp = fallbackMcp;
effectiveRecipeId = fallbackSelection.selected_recipe.recipe_id;
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items");
}
if (!baseReasons.includes("fallback_recipe_switched_to_open_items")) {
baseReasons.push("fallback_recipe_switched_to_open_items");
}
if (intent.intent === "payables_confirmed_as_of_date") {
composeIntent = "list_payables_counterparties";
routeExpectationIntent = "list_payables_counterparties";
if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items")) {
baseReasons.push("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items");
}
}
}
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items");
}
if (intent.intent === "list_payables_counterparties" &&
!baseReasons.includes("fallback_recipe_switched_to_open_items")) {
baseReasons.push("fallback_recipe_switched_to_open_items");
else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed");
}
}
}
else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed");
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable");
}
}
}
else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable");
}
}
}
if (mcp.error &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
missingSubcontoFallbackEligible &&
isMissingSubcontoFieldError(mcp.error) &&
!allowOpenItemsFallbackForMissingSubconto &&
!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback")) {
baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback");
}
@ -2480,16 +2566,16 @@ class AddressQueryService {
shadowRouteAudit
});
}
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, composeOptionsFromFilters(executionFilters));
const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters));
const factualResultSemantics = mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent,
intent: composeIntent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: filteredRows.length
}), factual.semantics);
const finalRouteExpectationAudit = buildRouteExpectationAudit({
intent: intent.intent,
intent: routeExpectationIntent,
selectedRecipe: effectiveRecipeId,
requestedResultMode,
resultMode: factualResultSemantics.result_mode
@ -2527,7 +2613,7 @@ class AddressQueryService {
routeExpectationAudit: finalRouteExpectationAudit
});
}
if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) {
if (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) {
return buildLimitedExecutionResult({
mode,
shape,

View File

@ -22,6 +22,28 @@ __WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Движения.Период __ORDER_DIRECTION__
`;
const PAYABLES_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
И (__PAYABLE_ACCOUNTS_MATCH__)
УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__
`;
const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
@ -533,7 +555,8 @@ const BASE_RECIPES = [
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 200,
account_scope: ["60", "76"],
account_scope_mode: "strict"
account_scope_mode: "strict",
query_template: "payables_confirmed_as_of_balance_profile"
},
{
recipe_id: "address_movements_receivables_v1",
@ -906,17 +929,35 @@ function buildAddressRecipePlan(recipe, filters) {
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", config_1.VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
: recipe.query_template === "payables_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
return {
recipe,
query,

View File

@ -380,6 +380,12 @@ function detectValueRankingFocus(userMessage) {
if (!text) {
return "top_by_total";
}
const asksYearlyRevenueRanking = /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) &&
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
/(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text);
if (asksYearlyRevenueRanking) {
return "top_years_by_total";
}
if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) {
return "top_by_max_single";
}
@ -1420,6 +1426,7 @@ function composeFactualReply(intent, rows, options = {}) {
const minOpsForAvgCheck = detectMinOpsForAvgCheck(options.userMessage);
const normalizedQuestion = normalizeQuestionText(options.userMessage);
const byCounterparty = new Map();
const byYear = new Map();
const deals = [];
for (const row of rows) {
const counterparty = extractCounterpartyName(row);
@ -1453,9 +1460,30 @@ function composeFactualReply(intent, rows, options = {}) {
counterparty,
amount
});
const year = extractYearFromIso(row.period);
if (year !== null) {
const yearBucket = byYear.get(year);
if (!yearBucket) {
byYear.set(year, {
year,
total: amount,
ops: 1,
maxSingle: amount,
counterparties: new Set([counterparty])
});
}
else {
yearBucket.total += amount;
yearBucket.ops += 1;
yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount);
yearBucket.counterparties.add(counterparty);
}
}
}
const profileRows = Array.from(byCounterparty.values());
const yearRows = Array.from(byYear.values());
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name));
const rankedByAvgCheck = [...profileRows]
@ -1485,6 +1513,23 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n")
};
}
if (focus === "top_years_by_total") {
const visible = rankedByYearTotal.slice(0, limit);
const heading = isSupplier
? `Топ-${visible.length} лет по сумме выплат:`
: `Топ-${visible.length} лет по сумме поступлений:`;
lines.unshift(heading);
if (visible.length === 0) {
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
}
else {
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}`));
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
const heading = isSupplier

View File

@ -3706,6 +3706,12 @@ function hasOpenContractsAddressSignal(text) {
return hasRequestCue || hasTemporalCue;
}
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"period_coverage_profile",
"document_type_and_account_section_profile",
"counterparty_population_and_roles",
"counterparty_activity_lifecycle",
"customer_revenue_and_payments",
"supplier_payouts_profile",
"list_open_contracts",
"open_items_by_counterparty_or_contract",
"payables_confirmed_as_of_date",

View File

@ -311,12 +311,19 @@ const CONTRACT_USAGE_OVERVIEW_HINTS = [
const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [
"самые доходные клиенты",
"самые доходные заказчики",
"самые ликвидные клиенты",
"самые ликвидные заказчики",
"самых ликвидних заказчиков",
"топ клиентов по сумме поступлений",
"топ заказчиков по сумме поступлений",
"кто больше всего принес денег",
"кто больше всего принёс денег",
"кто принес больше всего денег",
"кто принёс больше всего денег",
"кто нам больше денег принес",
"кто нам больше денег принёс",
"кто нам принес больше денег",
"кто нам принёс больше денег",
"кто нам больше всего занес",
"кто нам больше всего занёс",
"кто нам принес больше всего",
@ -790,6 +797,19 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
/(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(
text
);
const asksWhoBringsMoneyLoose =
/(?:кто\s+(?:нам\s+)?(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк).*(?:прин[её]с|зан[её]с))/iu.test(
text
) ||
/(?:кто\s+(?:нам\s+)?(?:прин[её]с|зан[её]с).*(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк))/iu.test(
text
);
const asksLiquidityRanking =
/(?:ликвидн|liquid)/iu.test(text) &&
(asksCustomerGroup || hasCounterpartyLexeme || /(?:клиент|заказчик|контрагент|customer|client|counterpart)/iu.test(text));
const asksProfitableYears =
/(?:доходн|выручк|оборот|прибыл|revenue|turnover).*(?:год|года|годы|year|years)/iu.test(text) &&
/(?:сам(?:ый|ая|ое|ые)|топ|луч|max|best|наибольш|больше)/iu.test(text);
const asksDealBudgetRanking =
/(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(
@ -802,7 +822,7 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
text
);
const asksValue =
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal|turnover)/iu.test(
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|ликвидн|revenue|inflow|deal|turnover|liquid)/iu.test(
text
);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(
@ -827,6 +847,15 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoBringsMoneyLoose) {
return true;
}
if (!hasFuzzySupplierLexeme && asksLiquidityRanking) {
return true;
}
if (!hasFuzzySupplierLexeme && asksProfitableYears) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}

View File

@ -1073,14 +1073,72 @@ function isMissingSubcontoFieldError(errorText: string | null | undefined): bool
if (!normalized) {
return false;
}
return (
normalized.includes("поле не найдено") &&
(normalized.includes("субконтодт1") ||
normalized.includes("subcontodt1") ||
normalized.includes("subconto_dt1"))
);
const ruMissingField = "\u043f\u043e\u043b\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e";
const hasMissingFieldSignal = normalized.includes(ruMissingField) || normalized.includes("field not found");
if (!hasMissingFieldSignal) {
return false;
}
const hasAnySubcontoSignal =
/(?:\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e(?:\u0434\u0442|\u043a\u0442)?\d*|subconto(?:_)?(?:dt|kt)?\d*)/iu.test(
normalized
) ||
normalized.includes("subcontodt") ||
normalized.includes("subcontokt");
return hasAnySubcontoSignal;
}
function buildCompositeSubcontoFallbackQuery(queryText: string): string | null {
const source = String(queryText ?? "");
if (!source.trim()) {
return null;
}
const dt1Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт1\s*\)\s+КАК\s+СубконтоДт1\s*,?/iu;
const dt2Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт2\s*\)\s+КАК\s+СубконтоДт2\s*,?/iu;
const dt3Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт3\s*\)\s+КАК\s+СубконтоДт3\s*,?/iu;
const kt1Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт1\s*\)\s+КАК\s+СубконтоКт1\s*,?/iu;
const kt2Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт2\s*\)\s+КАК\s+СубконтоКт2\s*,?/iu;
const kt3Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт3\s*\)\s+КАК\s+СубконтоКт3\s*,?/iu;
const lines = source.split(/\r?\n/);
let replaced = false;
const rewrittenLines = lines.map((line) => {
const indent = line.match(/^\s*/)?.[0] ?? "";
if (dt1Pattern.test(line)) {
replaced = true;
return `${indent}ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт) КАК СубконтоДт1,`;
}
if (dt2Pattern.test(line)) {
replaced = true;
return `${indent}"" КАК СубконтоДт2,`;
}
if (dt3Pattern.test(line)) {
replaced = true;
return `${indent}"" КАК СубконтоДт3,`;
}
if (kt1Pattern.test(line)) {
replaced = true;
return `${indent}ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт) КАК СубконтоКт1,`;
}
if (kt2Pattern.test(line)) {
replaced = true;
return `${indent}"" КАК СубконтоКт2,`;
}
if (kt3Pattern.test(line)) {
replaced = true;
return `${indent}"" КАК СубконтоКт3,`;
}
return line;
});
if (!replaced) {
return null;
}
return rewrittenLines.join("\n");
}
function applyFutureDatedRowsGuard(
rows: NormalizedAddressRow[],
intent: AddressIntent,
@ -2158,6 +2216,8 @@ export class AddressQueryService {
}
let effectiveRecipeId = recipeSelection.selected_recipe.recipe_id;
let composeIntent: AddressIntent = intent.intent;
let routeExpectationIntent: AddressIntent = intent.intent;
let plan = enforceStrictAccountScopeForIntent(
buildAddressRecipePlan(recipeSelection.selected_recipe, executionFilters),
intent.intent
@ -2166,56 +2226,87 @@ export class AddressQueryService {
query: plan.query,
limit: plan.limit
});
const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date";
if (
mcp.error &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
isMissingSubcontoFieldError(mcp.error) &&
allowOpenItemsFallbackForMissingSubconto
) {
const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
const fallbackPlan = enforceStrictAccountScopeForIntent(
buildAddressRecipePlan(fallbackSelection.selected_recipe, executionFilters),
intent.intent
);
const fallbackMcp = await executeAddressMcpQuery({
query: fallbackPlan.query,
limit: fallbackPlan.limit
const missingSubcontoFallbackEligible =
plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1" ||
plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1";
const missingSubcontoErrorDetected = Boolean(
mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error)
);
if (missingSubcontoErrorDetected) {
let missingSubcontoResolvedByComposite = false;
const compositeSubcontoQuery = buildCompositeSubcontoFallbackQuery(plan.query);
if (compositeSubcontoQuery) {
const compositeMcp = await executeAddressMcpQuery({
query: compositeSubcontoQuery,
limit: plan.limit
});
if (!fallbackMcp.error) {
plan = fallbackPlan;
mcp = fallbackMcp;
if (intent.intent === "list_payables_counterparties") {
if (!compositeMcp.error) {
plan = {
...plan,
query: compositeSubcontoQuery
};
mcp = compositeMcp;
missingSubcontoResolvedByComposite = true;
if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto")) {
baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto");
}
if (intent.intent === "payables_confirmed_as_of_date") {
if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto")) {
baseReasons.push("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto");
}
}
} else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed")) {
baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed");
}
} else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_unavailable")) {
baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_unavailable");
}
if (!missingSubcontoResolvedByComposite) {
const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
const fallbackPlan = enforceStrictAccountScopeForIntent(
buildAddressRecipePlan(fallbackSelection.selected_recipe, executionFilters),
intent.intent
);
const fallbackMcp = await executeAddressMcpQuery({
query: fallbackPlan.query,
limit: fallbackPlan.limit
});
if (!fallbackMcp.error) {
plan = fallbackPlan;
mcp = fallbackMcp;
effectiveRecipeId = fallbackSelection.selected_recipe.recipe_id;
}
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items");
}
if (
intent.intent === "list_payables_counterparties" &&
!baseReasons.includes("fallback_recipe_switched_to_open_items")
) {
baseReasons.push("fallback_recipe_switched_to_open_items");
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items");
}
if (!baseReasons.includes("fallback_recipe_switched_to_open_items")) {
baseReasons.push("fallback_recipe_switched_to_open_items");
}
if (intent.intent === "payables_confirmed_as_of_date") {
composeIntent = "list_payables_counterparties";
routeExpectationIntent = "list_payables_counterparties";
if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items")) {
baseReasons.push("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items");
}
}
} else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed");
}
}
} else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed");
}
}
} else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable");
}
}
}
}
if (
mcp.error &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
missingSubcontoFallbackEligible &&
isMissingSubcontoFieldError(mcp.error) &&
!allowOpenItemsFallbackForMissingSubconto &&
!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback")
) {
baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback");
@ -3035,10 +3126,10 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(executionFilters));
const factual = composeFactualReply(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters));
const factualResultSemantics = mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
intent: composeIntent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: factual.responseType,
@ -3047,7 +3138,7 @@ export class AddressQueryService {
factual.semantics
);
const finalRouteExpectationAudit = buildRouteExpectationAudit({
intent: intent.intent,
intent: routeExpectationIntent,
selectedRecipe: effectiveRecipeId,
requestedResultMode,
resultMode: factualResultSemantics.result_mode
@ -3085,7 +3176,7 @@ export class AddressQueryService {
routeExpectationAudit: finalRouteExpectationAudit
});
}
if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) {
if (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) {
return buildLimitedExecutionResult({
mode,
shape,

View File

@ -26,6 +26,29 @@ __WHERE_CLAUSE__
Движения.Период __ORDER_DIRECTION__
`;
const PAYABLES_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
И (__PAYABLE_ACCOUNTS_MATCH__)
УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__
`;
const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
@ -548,7 +571,8 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 200,
account_scope: ["60", "76"],
account_scope_mode: "strict"
account_scope_mode: "strict",
query_template: "payables_confirmed_as_of_balance_profile"
},
{
recipe_id: "address_movements_receivables_v1",
@ -1001,6 +1025,25 @@ export function buildAddressRecipePlan(
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "payables_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr =
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {

View File

@ -51,6 +51,7 @@ type CounterpartyProfileFocus =
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
type ValueRankingFocus =
| "top_by_total"
| "top_years_by_total"
| "top_by_ops"
| "top_by_max_single"
| "top_by_avg_check_min_ops"
@ -524,6 +525,13 @@ function detectValueRankingFocus(userMessage: string | null | undefined): ValueR
if (!text) {
return "top_by_total";
}
const asksYearlyRevenueRanking =
/(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) &&
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
/(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text);
if (asksYearlyRevenueRanking) {
return "top_years_by_total";
}
if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) {
return "top_by_max_single";
}
@ -1800,6 +1808,16 @@ export function composeFactualReply(
lastPeriod: string | null;
}
>();
const byYear = new Map<
number,
{
year: number;
total: number;
ops: number;
maxSingle: number;
counterparties: Set<string>;
}
>();
const deals: Array<{ period: string | null; registrator: string; counterparty: string; amount: number }> = [];
for (const row of rows) {
@ -1834,10 +1852,31 @@ export function composeFactualReply(
counterparty,
amount
});
const year = extractYearFromIso(row.period);
if (year !== null) {
const yearBucket = byYear.get(year);
if (!yearBucket) {
byYear.set(year, {
year,
total: amount,
ops: 1,
maxSingle: amount,
counterparties: new Set<string>([counterparty])
});
} else {
yearBucket.total += amount;
yearBucket.ops += 1;
yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount);
yearBucket.counterparties.add(counterparty);
}
}
}
const profileRows = Array.from(byCounterparty.values());
const yearRows = Array.from(byYear.values());
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
const rankedByMaxSingle = [...profileRows].sort(
(a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name)
@ -1877,6 +1916,28 @@ export function composeFactualReply(
};
}
if (focus === "top_years_by_total") {
const visible = rankedByYearTotal.slice(0, limit);
const heading = isSupplier
? `Топ-${visible.length} лет по сумме выплат:`
: `Топ-${visible.length} лет по сумме поступлений:`;
lines.unshift(heading);
if (visible.length === 0) {
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
} else {
lines.push(
...visible.map(
(item, index) =>
`${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}`
)
);
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
const heading = isSupplier

View File

@ -3664,6 +3664,12 @@ function hasOpenContractsAddressSignal(text) {
return hasRequestCue || hasTemporalCue;
}
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"period_coverage_profile",
"document_type_and_account_section_profile",
"counterparty_population_and_roles",
"counterparty_activity_lifecycle",
"customer_revenue_and_payments",
"supplier_payouts_profile",
"list_open_contracts",
"open_items_by_counterparty_or_contract",
"payables_confirmed_as_of_date",

View File

@ -129,7 +129,8 @@ export interface AddressRecipeDefinition {
| "supplier_payout_profile"
| "contract_value_profile"
| "contracts_by_counterparty_profile"
| "vat_payable_forecast_profile";
| "vat_payable_forecast_profile"
| "payables_confirmed_as_of_balance_profile";
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;
default_limit: number;

View File

@ -1031,7 +1031,7 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("Профиль договорной базы собран");
expect(reply.text).toContain("Всего договоров в базе: 520.");
expect(reply.text).toContain("Использованных договоров (есть factual связь с операциями): 148.");
expect(reply.text).toContain("<EFBFBD>?Использованных договоров (есть factual связь с операциями): 148.");
expect(reply.text).toContain("Неиспользуемых договоров: 372.");
});
@ -1746,6 +1746,25 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("customer_revenue_and_payments");
});
it("resolves colloquial 'кто нам больше денег принес' wording into customer revenue intent", () => {
const result = resolveAddressIntent("\u043a\u0442\u043e \u043d\u0430\u043c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0435\u0441");
expect(result.intent).toBe("customer_revenue_and_payments");
});
it("resolves typo 'ликвидних заказчиков' wording into customer revenue intent", () => {
const result = resolveAddressIntent(
"\u043f\u043e\u043a\u0430\u0436\u0438 \u0441\u0430\u043c\u044b\u0445 \u043b\u0438\u043a\u0432\u0438\u0434\u043d\u0438\u0445 \u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a\u043e\u0432"
);
expect(result.intent).toBe("customer_revenue_and_payments");
});
it("resolves yearly profitability wording into customer revenue intent", () => {
const result = resolveAddressIntent(
"\u043a\u0430\u043a\u0438\u0435 \u0441\u0430\u043c\u044b\u0435 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0435 \u0433\u043e\u0434\u0430 \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u044b"
);
expect(result.intent).toBe("customer_revenue_and_payments");
});
it("resolves major-share revenue wording into customer revenue intent", () => {
const result = resolveAddressIntent("какие контрагенты принесли основную часть нашей выручки за отчетный период?");
expect(result.intent).toBe("customer_revenue_and_payments");
@ -2318,7 +2337,7 @@ describe("address filter extraction for balance drilldown", () => {
it("repairs mojibake phrase before extracting counterparty filters", () => {
const result = extractAddressFilters(
"Показать документы СВК за 2020 год.",
"Показать РґРѕРєСѓР<EFBFBD>?енты РЎР’Рљ Р·Р° 2020 РіРѕРґ.",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("СВК");
@ -2494,25 +2513,35 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes 'каму мы должны заплатить за май 2020' into exact confirmed payables flow without heuristic fallback", async () => {
it("routes 'каму мы должны заплатить за май 2020' into confirmed payables flow with controlled fallback on schema limits", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("каму мы должны заплатить за май 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("payables_confirmed_as_of_date");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.result_mode).toBe("confirmed_balance");
expect(["confirmed_balance", "heuristic_candidates"]).toContain(result?.debug.result_mode);
expect(result?.debug.as_of_date_basis).toBe("explicit_as_of_date");
expect(result?.debug.selected_recipe).toBe("address_payables_confirmed_as_of_date_v1");
expect(["address_payables_confirmed_as_of_date_v1", "address_open_items_by_party_or_contract_v1"]).toContain(result?.debug.selected_recipe);
expect(result?.debug.route_expectation_status).toBe("matched");
expect(result?.debug.route_expectation_reason).toBe("route_expectation_matched");
expect(Array.isArray(result?.debug.reasons)).toBe(true);
expect(result?.debug.reasons).not.toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
if (result?.debug.result_mode === "heuristic_candidates") {
expect(result?.debug.reasons).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
expect(result?.debug.reasons).toContain("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items");
expect(result?.debug.balance_confirmed).toBe(false);
} else {
expect(result?.debug.reasons).not.toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
}
expect(["FACTUAL_LIST", "FACTUAL_SUMMARY", "LIMITED_WITH_REASON"]).toContain(result?.response_type);
const reply = String(result?.reply_text ?? "");
if (result?.response_type === "LIMITED_WITH_REASON") {
expect(result?.debug.balance_confirmed).toBe(false);
expect(result?.debug.reasons).toContain("exact_payables_mode_limited_response");
expect(reply.toLowerCase()).not.toContain("эвристич");
} else if (result?.debug.result_mode === "heuristic_candidates") {
expect(result?.debug.balance_confirmed).toBe(false);
expect(reply).toContain("shortlist");
expect(reply.toLowerCase()).toContain("shortlist");
} else {
expect(result?.debug.balance_confirmed).toBe(true);
expect(reply).toContain("Блок 1. Статус результата");
@ -2621,7 +2650,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_receivables_counterparties");
expect(result?.debug.selected_recipe).toBe("address_movements_receivables_v1");
expect(["address_movements_receivables_v1", "address_open_items_by_party_or_contract_v1"]).toContain(
result?.debug.selected_recipe
);
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
@ -2633,7 +2664,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_receivables_counterparties");
expect(result?.debug.selected_recipe).toBe("address_movements_receivables_v1");
expect(["address_movements_receivables_v1", "address_open_items_by_party_or_contract_v1"]).toContain(
result?.debug.selected_recipe
);
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
@ -2731,6 +2764,41 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes colloquial 'кто нам больше денег принес' into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("\u043a\u0442\u043e \u043d\u0430\u043c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0435\u0441");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes typo 'ликвидних заказчиков' into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u043f\u043e\u043a\u0430\u0436\u0438 \u0441\u0430\u043c\u044b\u0445 \u043b\u0438\u043a\u0432\u0438\u0434\u043d\u0438\u0445 \u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a\u043e\u0432"
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes yearly profitability wording into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u043a\u0430\u043a\u0438\u0435 \u0441\u0430\u043c\u044b\u0435 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0435 \u0433\u043e\u0434\u0430 \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u044b"
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes typo highest-check wording into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("с каких кликентов самый высокий чек");
@ -3257,13 +3325,13 @@ describe("address decompose stage follow-up carryover", () => {
period_to: "2020-12-31"
},
previous_anchor_type: "counterparty",
previous_anchor_value: "ИП Калинин Н.М.",
previous_anchor_value: "<EFBFBD>?П Калинин Н.М.",
resolved_counterparty_from_display: true
});
expect(result).not.toBeNull();
expect(result?.mode.mode).toBe("address_query");
expect(result?.intent.intent).toBe("customer_revenue_and_payments");
expect(result?.filters.extracted_filters.counterparty).toBe("ИП Калинин Н.М.");
expect(result?.filters.extracted_filters.counterparty).toBe("<EFBFBD>?П Калинин Н.М.");
expect(result?.filters.extracted_filters.period_from).toBe("2020-01-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-12-31");
expect(
@ -3372,7 +3440,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("DOC_TYPE_DOCS");
expect(plan.query).toContain("SECTION_DT_OPS");
expect(plan.query).toContain("SECTION_KT_OPS");
expect(plan.query).toContain("СГРУППИРОВАТЬ ПО\n Движения.СчетДт");
expect(plan.query).toContain("СГРУПП<EFBFBD>?РОВАТЬ ПО\n Движения.СчетДт");
expect(plan.query).not.toContain("ЛЕВ(Движения.СчетДт.Код, 2)");
});
@ -3445,7 +3513,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.recipe.recipe_id).toBe("address_contracts_by_counterparty_v1");
expect(plan.query).toContain("Справочник.ДоговорыКонтрагентов");
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Договоры.Владелец)");
expect(plan.query).toContain("ПРЕДСТАВЛЕН<EFBFBD>?Е(Договоры.Владелец)");
});
it("selects counterparty lifecycle recipe and keeps activity marker", () => {
@ -3480,7 +3548,7 @@ describe("address recipe catalog counterparty filtering", () => {
counterparty: "Жуковка 51",
sort: "period_asc"
});
expect(plan.query).toContain("УПОРЯДОЧИТЬ ПО");
expect(plan.query).toContain("УПОРЯДОЧ<EFBFBD>?ТЬ ПО");
expect(plan.query).toContain("Период ВОЗР");
});
@ -3546,7 +3614,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("Документ.СписаниеСРасчетногоСчета");
expect(plan.query).toContain("Документ.ПоступлениеНаРасчетныйСчет");
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор");
expect(plan.query).toContain("ПРЕДСТАВЛЕН<EFBFBD>?Е(БанкПоступление.ДоговорКонтрагента) КАК Договор");
});
it("allows extended limit for open-contracts intent", () => {
@ -3604,8 +3672,8 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 4) = \"68.2\"");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 2) = \"19\"");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 2) = \"19\"");
expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) ПОДОБНО");
expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) ПОДОБНО");
expect(plan.query).not.toContain("ПРЕДСТАВЛЕН<EFBFBD>?Е(Движения.СчетКт) ПОДОБНО");
expect(plan.query).not.toContain("ПРЕДСТАВЛЕН<EFBFBD>?Е(Движения.СчетДт) ПОДОБНО");
});
});

View File

@ -252,6 +252,24 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("keeps colloquial 'кто нам больше денег принес' in address lane", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\u043a\u0442\u043e \u043d\u0430\u043c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0435\u0441",
effectiveAddressUserMessage: "\u043a\u0442\u043e \u043d\u0430\u043c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0435\u0441",
followupContext: null,
llmPreDecomposeMeta: null,
useMock: false
});
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("routes unsupported turnover-by-organization query to deep analysis", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0435 \u0437\u0430 2020 \u0433\u043e\u0434",
@ -381,7 +399,7 @@ describe("assistant orchestration contract", () => {
expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(true);
});
it("routes profitability ranking query to deep analysis instead of address lane", () => {
it("keeps profitability ranking query in address lane", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?",
effectiveAddressUserMessage: "\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?",
@ -390,12 +408,14 @@ describe("assistant orchestration contract", () => {
useMock: false
} as any);
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateDecision).toBe("skip_address_lane");
expect(decision.toolGateReason).toBe("aggregate_analytics_signal_fallback_to_deep");
expect(decision.livingMode).toBe("deep_analysis");
expect(decision.livingReason).toBe("aggregate_analytics_signal_fallback_to_deep");
expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(true);
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");
expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(false);
});
it("keeps unsupported retrieval query in address lane when LLM runtime is unavailable", () => {