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

This commit is contained in:
2026-04-12 16:59:18 +03:00
parent 278eb4abeb
commit 143cf6efe1
13 changed files with 646 additions and 127 deletions
@@ -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;
}
@@ -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,
@@ -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__", (() => {
@@ -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
@@ -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",