Стабилизировать маржинальность номенклатуры

This commit is contained in:
2026-05-22 16:17:07 +03:00
parent f5d86d4bc1
commit 09c6d1aa0e
45 changed files with 2074 additions and 76 deletions
@@ -12,6 +12,7 @@ const COMPUTE_EXACT_INTENTS = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@@ -68,6 +69,7 @@ function defaultCapabilityId(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") {
@@ -151,12 +153,15 @@ function resolveCapabilityEnabled(intent) {
if (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain") {
if (intent === "inventory_profitability_for_item") {
if (intent === "inventory_profitability_for_item" || intent === "inventory_margin_ranking_for_nomenclature") {
return {
enabled: true,
reason: "inventory_profitability_route_enabled"
reason: intent === "inventory_margin_ranking_for_nomenclature"
? "inventory_margin_ranking_route_enabled"
: "inventory_profitability_route_enabled"
};
}
if (intent === "inventory_purchase_to_sale_chain") {
@@ -249,6 +254,7 @@ function resolveShadowRouteIntent(intent, requestedResultMode) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") {
@@ -101,6 +101,7 @@ function isConfirmedBalanceIntent(intent) {
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "open_contracts_confirmed_as_of_date" ||
@@ -971,6 +971,7 @@ function isInventoryTraceIntent(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -989,6 +990,7 @@ function usesRecipeDefaultLimit(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -1407,6 +1409,9 @@ function requiredFiltersByIntent(intent) {
intent === "inventory_purchase_to_sale_chain") {
return ["item"];
}
if (intent === "inventory_margin_ranking_for_nomenclature") {
return ["period_from", "period_to"];
}
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}
@@ -1664,6 +1664,17 @@ function hasBidirectionalValueFlowComparisonSignal(text) {
const hasNetAmountCue = /(?:сколько|сумм|итог|нетто|сальдо|минус|net|total|sum)/iu.test(normalized);
return hasIncomingCue && hasOutgoingCue && hasComparisonCue && (hasValueFlowCue || hasNetAmountCue);
}
function hasNomenclatureMarginRankingSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasRealizationCue = /(?:реализован|реализац|продан|продаж|отгруж|41(?:[.,]0?1)?|90(?:[.,]\d{1,2})?|sales?|sold)/iu.test(normalized);
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized);
const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые)|больш|меньш|ранж|рейтинг|high|low|top|rank|best|worst)/iu.test(normalized);
return hasNomenclatureCue && hasRealizationCue && hasMarginCue && hasRankingCue;
}
function hasVatPeriodInspectionBridgeSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase();
if (!/(?:ндс|vat)/iu.test(normalized)) {
@@ -1736,6 +1747,9 @@ function resolveUnicodeAddressIntentBridge(text) {
if (hasSelectedObjectProfitabilityCue) {
return unicodeBridgeResolution("inventory_profitability_for_item", "high", "unicode_selected_object_profitability_bridge_signal_detected");
}
if (hasNomenclatureMarginRankingSignal(normalized)) {
return unicodeBridgeResolution("inventory_margin_ranking_for_nomenclature", "high", "unicode_nomenclature_margin_ranking_bridge_signal_detected");
}
const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) &&
/(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(normalized);
if (hasOpenItemsAccountCue) {
@@ -1647,6 +1647,7 @@ function isOrganizationScopedInventoryIntent(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -1680,6 +1681,7 @@ function shouldDeferInventoryOrganizationClarification(intent, filters, semantic
return (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -1981,6 +1983,7 @@ function canAutoBroadenPeriodWindow(intent, filters) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -1990,6 +1993,7 @@ function shouldBoostAutoBroadenedLimit(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -2520,6 +2524,9 @@ async function tryComposeLlmLimitedReply(input) {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
return null;
}
if (input.intent === "inventory_margin_ranking_for_nomenclature" && input.category === "missing_anchor") {
return null;
}
if (!shouldUseLlmLimitedReply(input.category)) {
return null;
}
@@ -2580,6 +2587,13 @@ function composeLimitedReply(input) {
.map((item) => normalizeMissingAnchorLabel(String(item ?? "").trim()))
.filter((item) => item.length > 0)));
const missingAnchorPhrase = missingAnchorLabels.length > 0 ? missingAnchorLabels.join(", ") : "контрагент, договор, счет или период";
if (input.intent === "inventory_margin_ranking_for_nomenclature" && input.category === "missing_anchor") {
return [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период."
].join("\n\n");
}
const heading = input.category === "empty_match"
? pickDeterministicVariant(headingSeed, [
"По текущим условиям в доступном срезе данных совпадений не нашлось.",
+54 -40
View File
@@ -966,6 +966,17 @@ const BASE_RECIPES = [
account_scope_mode: "strict",
query_template: "inventory_trading_margin_proxy_profile"
},
{
recipe_id: "address_inventory_margin_ranking_for_nomenclature_v1",
intent: "inventory_margin_ranking_for_nomenclature",
purpose: "Rank realized nomenclature by bounded gross margin proxy for an explicit period using 41.01 purchase and sale document rows",
required_filters: ["period_from", "period_to"],
optional_filters: ["organization", "warehouse", "limit", "sort"],
default_limit: 800,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_margin_ranking_profile"
},
{
recipe_id: "address_inventory_purchase_to_sale_chain_v1",
intent: "inventory_purchase_to_sale_chain",
@@ -1714,6 +1725,7 @@ function maxLimitForIntent(intent) {
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_trading_margin_proxy_for_organization" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@@ -1905,33 +1917,17 @@ function buildAddressRecipePlan(recipe, filters) {
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_trading_margin_proxy_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
: recipe.query_template === "inventory_margin_ranking_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_quality_events_profile"
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: 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 OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "payables_confirmed_as_of_balance_profile"
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_quality_events_profile"
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
@@ -1946,10 +1942,10 @@ function buildAddressRecipePlan(recipe, filters) {
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "receivables_confirmed_as_of_balance_profile"
: 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)
@@ -1964,20 +1960,38 @@ function buildAddressRecipePlan(recipe, filters) {
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
: recipe.query_template === "receivables_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
return {
recipe,
query,
@@ -8,6 +8,7 @@ exports.hasBareInventoryPurchaseDateFollowupCue = hasBareInventoryPurchaseDateFo
exports.hasInventorySaleFollowupCue = hasInventorySaleFollowupCue;
exports.hasInventoryPurchaseToSaleChainFollowupCue = hasInventoryPurchaseToSaleChainFollowupCue;
exports.hasInventoryPurchaseDateVatBridgeCue = hasInventoryPurchaseDateVatBridgeCue;
exports.hasInventoryMarginRankingFollowupCue = hasInventoryMarginRankingFollowupCue;
exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal;
exports.runAddressDecomposeStage = runAddressDecomposeStage;
const addressQueryClassifier_1 = require("../addressQueryClassifier");
@@ -321,6 +322,7 @@ function isInventoryIntent(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -332,6 +334,7 @@ function isInventoryDrilldownFrameIntent(intent) {
return (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -340,6 +343,7 @@ function isInventoryLifecycleHistoryIntent(intent) {
return (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain");
}
@@ -625,11 +629,29 @@ function hasInventoryPurchaseDateVatBridgeCue(text) {
return (/(?:ндс|vat)/iu.test(normalized) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(normalized));
}
function hasInventoryMarginRankingFollowupCue(text) {
const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized.trim()) {
return false;
}
const wantsFoundRows = /(?:покажи|показать|выведи|дай|раскрой|show|list|покажи|показать|выведи|дай|раскрой)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себестоимостн|реализац|марж|прибыл|номенклат)/iu.test(normalized);
const account41Not01 = /\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вместо|Р°\s+РЅРµ)/iu.test(normalized);
return wantsFoundRows || account41Not01;
}
function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim();
if (!normalized) {
return false;
}
if (hasInventoryMarginRankingFollowupCue(normalized)) {
return true;
}
if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(normalized)) {
return true;
}
@@ -867,6 +889,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@@ -919,6 +942,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
if ((intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date")) {
@@ -1122,6 +1146,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@@ -1134,6 +1159,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const currentContractExplicit = toNonEmptyString(merged.contract);
const currentItemExplicit = toNonEmptyString(merged.item);
const currentAccountExplicit = toNonEmptyString(merged.account);
const currentAccountRefinesMarginDomain = intent === "inventory_margin_ranking_for_nomenclature" &&
hasInventoryMarginRankingFollowupCue(userMessage);
const shouldSuppressGenericPeriodCarryover = (Boolean(currentCounterpartyExplicit) &&
!isLowQualityCounterpartyAnchor(currentCounterpartyExplicit) &&
currentCounterpartyExplicit !== previousCounterparty) ||
@@ -1141,7 +1168,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
!isLowQualityContractAnchor(currentContractExplicit) &&
currentContractExplicit !== previousContract) ||
(Boolean(currentItemExplicit) && currentItemExplicit !== previousItem) ||
(Boolean(currentAccountExplicit) && currentAccountExplicit !== previousAccount);
(Boolean(currentAccountExplicit) && currentAccountExplicit !== previousAccount && !currentAccountRefinesMarginDomain);
const vatRelativeMonthFollowup = relativeMonthFromFollowupYear &&
(intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
@@ -1189,6 +1216,19 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
reasons.push("period_from_followup_context");
}
if (intent === "inventory_margin_ranking_for_nomenclature" &&
previousHasPeriod &&
hasInventoryMarginRankingFollowupCue(userMessage) &&
!hasExplicitPeriodInMessage &&
!hasExplicitCurrentDateInMessage) {
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
if (previousPeriodTo && merged.period_to !== previousPeriodTo) {
merged.period_to = previousPeriodTo;
}
reasons.push("period_from_followup_context");
}
if (!currentHasPeriod &&
previousHasPeriod &&
hasFollowupSignal &&
@@ -1251,6 +1291,7 @@ function resolveMissingRequiredFilters(intent, filters) {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
inventory_on_hand_as_of_date: ["as_of_date"],
inventory_margin_ranking_for_nomenclature: ["period_from", "period_to"],
inventory_profitability_for_item: ["item"],
open_contracts_confirmed_as_of_date: ["as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
@@ -1319,6 +1360,22 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
const hasExplicitInventoryItemReference = /(?:товар|номенклатур|позици|склад|остат|sku|item|product|товар|номенклатур|позици|склад|остат)/iu.test(normalizedMessage) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
const staleInventoryLineageCanYieldToCounterparty = previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
const inventoryPurchaseDateVatBridge = inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
const marginRankingLineageActive = sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
followupContext.root_intent === "inventory_margin_ranking_for_nomenclature";
if (marginRankingLineageActive &&
hasInventoryMarginRankingFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "documents_forming_balance" ||
detectedIntent.intent === "inventory_margin_ranking_for_nomenclature" ||
detectedIntent.intent === sourceIntent)) {
return {
intent: "inventory_margin_ranking_for_nomenclature",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_margin_ranking_followup_context"]
};
}
if (inventoryPurchaseDateVatBridge &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === sourceIntent ||
@@ -81,6 +81,48 @@ function inventoryProfitabilityPeriodLabel(options, deps) {
const asOfDate = typeof options.asOfDate === "string" && options.asOfDate.trim().length > 0 ? options.asOfDate : null;
return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке";
}
function inventoryRowItemLabel(row, deps) {
return deps.summarizeInventoryTraceRows([row]).item;
}
function buildInventoryMarginRankingEntries(rows, deps) {
const byItem = new Map();
for (const row of rows) {
const item = inventoryRowItemLabel(row, deps);
if (!item) {
continue;
}
const key = item.trim().toLocaleLowerCase("ru");
const current = byItem.get(key) ?? { item, saleRows: [], purchaseRows: [] };
if (deps.isInventorySaleMovement(row)) {
current.saleRows.push(row);
}
if (deps.isInventoryPurchaseMovement(row)) {
current.purchaseRows.push(row);
}
byItem.set(key, current);
}
return Array.from(byItem.values())
.map((entry) => {
const revenue = sumInventoryRowAmount(entry.saleRows);
const costProxy = sumInventoryRowAmount(entry.purchaseRows);
const spread = revenue - costProxy;
return {
item: entry.item,
revenue,
costProxy,
spread,
marginPct: revenue > 0 ? (spread / revenue) * 100 : null,
saleQuantity: sumInventoryRowQuantity(entry.saleRows),
purchaseQuantity: sumInventoryRowQuantity(entry.purchaseRows),
saleDocuments: entry.saleRows.length,
purchaseDocuments: entry.purchaseRows.length
};
})
.filter((entry) => entry.revenue > 0 || entry.costProxy > 0);
}
function formatInventoryMarginRankingLine(entry, index, deps) {
return `${index + 1}. ${entry.item} — выручка ${deps.formatMoneyRub(entry.revenue)}, себестоимостная база ${deps.formatMoneyRub(entry.costProxy)}, валовая разница ${deps.formatMoneyRub(entry.spread)}, маржа ${formatInventoryPercent(entry.marginPct, deps.formatNumberWithDots)}.`;
}
function composeInventoryReply(intent, rows, options, deps) {
if (intent === "inventory_on_hand_as_of_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
@@ -401,6 +443,83 @@ function composeInventoryReply(intent, rows, options, deps) {
? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(summary.counterparties.length > 0 ? "strong" : "medium", true))
: (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", false));
}
if (intent === "inventory_margin_ranking_for_nomenclature") {
const entries = buildInventoryMarginRankingEntries(rows, deps);
const confirmedEntries = entries.filter((entry) => entry.revenue > 0 && entry.costProxy > 0);
const highMargin = [...confirmedEntries]
.sort((left, right) => right.spread - left.spread || (right.marginPct ?? -Infinity) - (left.marginPct ?? -Infinity))
.slice(0, 5);
const lowMargin = [...confirmedEntries]
.sort((left, right) => left.spread - right.spread || (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity))
.slice(0, 5);
const salesWithoutCost = entries.filter((entry) => entry.revenue > 0 && entry.costProxy <= 0);
const purchasesWithoutSales = entries.filter((entry) => entry.costProxy > 0 && entry.revenue <= 0);
const periodLabel = inventoryProfitabilityPeriodLabel(options, deps);
const totalRevenue = entries.reduce((sum, entry) => sum + entry.revenue, 0);
const totalCostProxy = entries.reduce((sum, entry) => sum + entry.costProxy, 0);
const totalSpread = totalRevenue - totalCostProxy;
if (confirmedEntries.length === 0) {
const lines = [`За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`];
const findings = [];
if (salesWithoutCost.length > 0) {
const salesCount = deps.formatNumberWithDots(salesWithoutCost.length);
const salesItemPhrase = salesWithoutCost.length === 1 ? "1 номенклатурной позиции" : `${salesCount} номенклатурным позициям`;
findings.push(`Есть реализация по ${salesItemPhrase}.`);
findings.push(salesWithoutCost.length === 1
? "Подтвержденной себестоимости реализации по этой позиции не найдено."
: "Подтвержденной себестоимости реализации по этим позициям не найдено.");
findings.push("Поэтому валовую прибыль и маржинальность честно посчитать нельзя.");
}
if (purchasesWithoutSales.length > 0) {
const purchaseCount = deps.formatNumberWithDots(purchasesWithoutSales.length);
const purchaseItemPhrase = purchasesWithoutSales.length === 1 ? "1 позиции" : `${purchaseCount} позициям`;
findings.push(purchasesWithoutSales.length === 1
? `Есть себестоимостная база по ${purchaseItemPhrase}, но реализации по ней в периоде не найдено.`
: `Есть себестоимостная база по ${purchaseItemPhrase}, но реализации по ним в периоде не найдено.`);
}
if (entries.length === 0) {
findings.push("В доступной выборке нет достаточных строк реализации и себестоимости по номенклатуре.");
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что нашлось:", findings);
lines.push(`Вывод: за период ${periodLabel} нет достаточной базы для рейтинга «высокая / низкая прибыль» по номенклатуре.`);
const nextActions = [];
if (salesWithoutCost.length > 0) {
nextActions.push("показать найденные реализации за этот период;");
}
if (purchasesWithoutSales.length > 0) {
nextActions.push("показать найденные строки себестоимостной базы за этот период;");
}
nextActions.push("расширить период до квартала или года;", "попробовать строгий расчет по проводкам 90.01 / 90.02;", "построить управленческий proxy по закупочным документам, если такой способ допустим для вашей проверки.");
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что можно сделать дальше:", nextActions);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Граница ответа:", [
"Прибыльность номенклатуры считаю только когда есть реализация и подтвержденная себестоимость реализации.",
"Это не чистая прибыль компании и не замена закрытию месяца."
]);
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(entries.length > 0 ? "medium" : "weak", false));
}
const directAnswerLine = confirmedEntries.length > 0
? `За период ${periodLabel} собран рейтинг реализованной номенклатуры по валовой маржинальности: выручка ${deps.formatMoneyRub(totalRevenue)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, расчетная валовая разница ${deps.formatMoneyRub(totalSpread)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
const lines = [directAnswerLine];
if (highMargin.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Высокая валовая маржинальность:", highMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps)));
}
if (lowMargin.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Низкая или отрицательная валовая маржинальность:", lowMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps)));
}
const boundaryLines = [
"Это управленческий расчет валовой маржинальности по реализации и доступной себестоимостной базе, не чистая прибыль компании.",
"Для строгого бухгалтерского расчета нужны проводки 90.01 / 90.02 и закрытие себестоимости; этот ответ не подменяет закрытие месяца."
];
if (salesWithoutCost.length > 0) {
boundaryLines.push(`По ${deps.formatNumberWithDots(salesWithoutCost.length)} позициям есть продажи, но нет подтвержденной себестоимости реализации — их нельзя честно ранжировать по прибыли.`);
}
if (purchasesWithoutSales.length > 0) {
boundaryLines.push(`По ${deps.formatNumberWithDots(purchasesWithoutSales.length)} позициям есть себестоимостная база без реализации в этом периоде.`);
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Граница ответа:", boundaryLines);
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(confirmedEntries.length > 0 ? "strong" : entries.length > 0 ? "medium" : "weak", confirmedEntries.length > 0));
}
if (intent === "inventory_profitability_for_item") {
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row));
@@ -61,6 +61,7 @@ function isInventorySelectedObjectOrRootIntent(intent) {
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@@ -74,6 +75,15 @@ function isGenericCanonicalDriftIntent(intent) {
intent === "bank_operations_by_contract" ||
intent === "documents_forming_balance");
}
function hasInventoryMarginRankingAccountCorrectionCue(text) {
const value = String(text ?? "").toLowerCase();
if (!value.trim()) {
return false;
}
return (/\b41(?:[.,]\d{1,2})?\b/iu.test(value) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(value) &&
/(?:\u0430\s+\u043d\u0435|\u043d\u0435|\u0432\u043c\u0435\u0441\u0442\u043e|not|instead)/iu.test(value));
}
function hasSameDateFollowupSignal(text) {
return /(?:эту\s+же\s+дат(?:у|е|ой)|ту\s+же\s+дат(?:у|е|ой)|same\s+date)/iu.test(String(text ?? ""));
}
@@ -117,6 +127,10 @@ function shouldPreferRawFollowupMessage(userMessage, addressInputMessage, carryo
const hasInventoryItemCarryover = previousAnchorType === "item" && isInventorySelectedObjectOrRootIntent(previousIntent);
const hasInventoryFrameCarryover = isInventorySelectedObjectOrRootIntent(previousIntent) ||
isInventorySelectedObjectOrRootIntent(rootIntent);
const hasInventoryMarginRankingCarryover = previousIntent === "inventory_margin_ranking_for_nomenclature" ||
rootIntent === "inventory_margin_ranking_for_nomenclature";
const hasInventoryMarginRankingAccountCorrection = hasInventoryMarginRankingCarryover &&
[rawMessage, canonicalMessage].some((message) => hasInventoryMarginRankingAccountCorrectionCue(message));
const hasDocumentCarryover = previousIntent === "list_documents_by_counterparty" || previousIntent === "list_documents_by_contract";
if (mode === "unsupported" && intent === "unknown") {
return true;
@@ -132,6 +146,10 @@ function shouldPreferRawFollowupMessage(userMessage, addressInputMessage, carryo
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")) {
return true;
}
if (hasInventoryMarginRankingAccountCorrection &&
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")) {
return true;
}
return ((hasSelectedObjectInventorySignal(rawMessage) || hasInventoryItemCarryover) &&
(hasSelectedObjectInventoryActionCue(rawMessage) || hasShortInventoryPurchaseFollowupCue(rawMessage)) &&
(isGenericCanonicalDriftIntent(intent) || intent === "unknown"));
@@ -8,6 +8,13 @@ function formatMissingAnchors(anchors) {
return anchors.join(", ");
}
function buildClarificationReply(binding) {
if (binding.capability_contract_id === "inventory_inventory_margin_ranking_for_nomenclature") {
return [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период."
].join("\n\n");
}
return [
"Нужно уточнение, чтобы не подставить неподтвержденный объект в расчет.",
`Не хватает: ${formatMissingAnchors(binding.missing_anchors)}.`,
@@ -110,6 +110,13 @@ function anchorSatisfied(requiredAnchor, providedAnchors, debug) {
if (providedAnchors.includes(requiredAnchor)) {
return true;
}
if (requiredAnchor === "period") {
return ((hasValue(filters?.period_from) && hasValue(filters?.period_to)) ||
hasValue(filters?.as_of_date) ||
providedAnchors.includes("period_from") ||
providedAnchors.includes("period_to") ||
providedAnchors.includes("as_of_date"));
}
if (requiredAnchor === "item") {
return (providedAnchors.includes("selected_object") ||
providedAnchors.includes("anchor_item") ||
@@ -141,6 +141,7 @@ function isDetectedIntentAlignedWithTurnMeaning(detectedIntent, turnMeaning) {
if (normalizedIntent === "inventory_purchase_provenance_for_item" ||
normalizedIntent === "inventory_purchase_documents_for_item" ||
normalizedIntent === "inventory_sale_trace_for_item" ||
normalizedIntent === "inventory_margin_ranking_for_nomenclature" ||
normalizedIntent === "inventory_profitability_for_item" ||
normalizedIntent === "inventory_purchase_to_sale_chain") {
return askedDomain === "inventory";
@@ -183,7 +184,7 @@ function isExplicitMetadataDiscoveryTurn(entryPoint) {
reasonCodes.some((reason) => toNonEmptyString(reason) === "mcp_discovery_metadata_signal_detected"));
}
function isInventoryExactAddressIntent(intent) {
return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(String(intent ?? ""));
return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_margin_ranking_for_nomenclature|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(String(intent ?? ""));
}
function hasMetadataDiscoveryPriority(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
@@ -27,6 +27,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@@ -40,6 +41,7 @@ const ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS = new Set([
"inventory_purchase_provenance_for_item",
"inventory_purchase_documents_for_item",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain"
]);
@@ -204,7 +206,7 @@ function createAssistantRoutePolicy(deps) {
: null;
const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false;
const llmSupportedDeepAddressIntentSignal = llmContractMode === "deep_analysis" &&
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_margin_ranking_for_nomenclature|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
semanticCanonicalRecommended;
const llmCanonicalEntitySignal = /(?:заказчик|поставщик|контрагент|компан|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase()));
const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis";
@@ -310,6 +310,18 @@ exports.INVENTORY_CAPABILITY_CONTRACTS = [
answerObjectShape: "inventory_profitability_bundle",
bundleReusePolicy: "sale_trace_bundle_preferred"
}),
inventoryExactCapability({
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
intent_ids: ["inventory_margin_ranking_for_nomenclature"],
entry_modes: ["root_entry", "root_followup", "clarification_resume"],
transitions: ["T1", "T2", "T7"],
requiresFocusObject: false,
requiredAnchors: ["period"],
resultShape: "nomenclature_margin_ranking",
answerObjectShape: "inventory_margin_ranking",
bundleReusePolicy: "none",
scenarioFamilies: ["canonical", "colloquial", "account_41_correction"]
}),
inventoryExactCapability({
capability_id: "inventory_inventory_purchase_to_sale_chain",
intent_ids: ["inventory_purchase_to_sale_chain"],
@@ -7,6 +7,26 @@ function createAssistantTransitionPolicy(deps) {
function normalizeFollowupText(value) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage = null, sourceIntentHint = null) {
if (sourceIntentHint !== "inventory_margin_ranking_for_nomenclature") {
return false;
}
return [userMessage, alternateMessage]
.filter((value) => deps.toNonEmptyString(value))
.map((value) => deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"))
.some((normalized) => {
if (!normalized) {
return false;
}
const wantsFoundRows = /(?:покажи|показать|выведи|дай|раскрой|show|list|покажи|показать|выведи|дай|раскрой)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себестоимостн|реализац|марж|прибыл|номенклат)/iu.test(normalized);
const account41Not01 = /\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вместо|Р°\s+РЅРµ)/iu.test(normalized);
return wantsFoundRows || account41Not01;
});
}
function hasSamePeriodReferenceCue(...values) {
return values
.map((value) => normalizeFollowupText(value))
@@ -545,6 +565,7 @@ function createAssistantTransitionPolicy(deps) {
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint)));
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage, sourceIntentHint);
let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage);
let inventoryShortFollowupAlternate = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
@@ -573,6 +594,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
@@ -582,6 +604,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
@@ -622,6 +645,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@@ -645,6 +669,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@@ -757,6 +782,7 @@ function createAssistantTransitionPolicy(deps) {
!inventoryShortFollowupAlternate &&
!businessOverviewBoundaryFollowupPrimary &&
!businessOverviewBoundaryFollowupAlternate &&
!inventoryMarginRankingFollowup &&
!foreignAccountingPivotOverInventory &&
!deps.hasFollowupMarker(userMessage) &&
!deps.hasReferentialPointer(userMessage) &&
@@ -816,6 +842,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary ||
mcpDiscoveryOrganizationClarificationContinuation;
@@ -827,6 +854,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate ||
mcpDiscoveryOrganizationClarificationContinuation
@@ -849,6 +877,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@@ -24,6 +24,7 @@ const SUPPORTED_ADDRESS_INTENTS = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@@ -31,6 +31,7 @@ const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@@ -92,6 +93,7 @@ function defaultCapabilityId(intent: AddressIntent): string {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -178,13 +180,17 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain"
) {
if (intent === "inventory_profitability_for_item") {
if (intent === "inventory_profitability_for_item" || intent === "inventory_margin_ranking_for_nomenclature") {
return {
enabled: true,
reason: "inventory_profitability_route_enabled"
reason:
intent === "inventory_margin_ranking_for_nomenclature"
? "inventory_margin_ranking_route_enabled"
: "inventory_profitability_route_enabled"
};
}
if (intent === "inventory_purchase_to_sale_chain") {
@@ -284,6 +290,7 @@ export function resolveShadowRouteIntent(
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -149,6 +149,7 @@ export function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "open_contracts_confirmed_as_of_date" ||
@@ -1111,6 +1111,7 @@ function isInventoryTraceIntent(intent: AddressIntent): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -1135,6 +1136,7 @@ function usesRecipeDefaultLimit(intent: AddressIntent): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -1628,6 +1630,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
) {
return ["item"];
}
if (intent === "inventory_margin_ranking_for_nomenclature") {
return ["period_from", "period_to"];
}
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}
@@ -2152,6 +2152,28 @@ function hasBidirectionalValueFlowComparisonSignal(text: string): boolean {
return hasIncomingCue && hasOutgoingCue && hasComparisonCue && (hasValueFlowCue || hasNetAmountCue);
}
function hasNomenclatureMarginRankingSignal(text: string): boolean {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
const hasNomenclatureCue =
/(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasRealizationCue =
/(?:реализован|реализац|продан|продаж|отгруж|41(?:[.,]0?1)?|90(?:[.,]\d{1,2})?|sales?|sold)/iu.test(
normalized
);
const hasMarginCue =
/(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(
normalized
);
const hasRankingCue =
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые)|больш|меньш|ранж|рейтинг|high|low|top|rank|best|worst)/iu.test(
normalized
);
return hasNomenclatureCue && hasRealizationCue && hasMarginCue && hasRankingCue;
}
function hasVatPeriodInspectionBridgeSignal(text: string): boolean {
const normalized = String(text ?? "").trim().toLowerCase();
if (!/(?:ндс|vat)/iu.test(normalized)) {
@@ -2263,6 +2285,14 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio
);
}
if (hasNomenclatureMarginRankingSignal(normalized)) {
return unicodeBridgeResolution(
"inventory_margin_ranking_for_nomenclature",
"high",
"unicode_nomenclature_margin_ranking_bridge_signal_detected"
);
}
const hasOpenItemsAccountCue =
/(?:хвост|долг|незакрыт|вис)/iu.test(normalized) &&
/(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(
@@ -2034,6 +2034,7 @@ function isOrganizationScopedInventoryIntent(intent: AddressIntent): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -2085,6 +2086,7 @@ function shouldDeferInventoryOrganizationClarification(
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -2455,6 +2457,7 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -2467,6 +2470,7 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -3132,6 +3136,9 @@ async function tryComposeLlmLimitedReply(input: {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
return null;
}
if (input.intent === "inventory_margin_ranking_for_nomenclature" && input.category === "missing_anchor") {
return null;
}
if (!shouldUseLlmLimitedReply(input.category)) {
return null;
}
@@ -3206,6 +3213,13 @@ function composeLimitedReply(input: {
)
);
const missingAnchorPhrase = missingAnchorLabels.length > 0 ? missingAnchorLabels.join(", ") : "контрагент, договор, счет или период";
if (input.intent === "inventory_margin_ranking_for_nomenclature" && input.category === "missing_anchor") {
return [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период."
].join("\n\n");
}
const heading =
input.category === "empty_match"
? pickDeterministicVariant(headingSeed, [
@@ -992,6 +992,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "strict",
query_template: "inventory_trading_margin_proxy_profile"
},
{
recipe_id: "address_inventory_margin_ranking_for_nomenclature_v1",
intent: "inventory_margin_ranking_for_nomenclature",
purpose: "Rank realized nomenclature by bounded gross margin proxy for an explicit period using 41.01 purchase and sale document rows",
required_filters: ["period_from", "period_to"],
optional_filters: ["organization", "warehouse", "limit", "sort"],
default_limit: 800,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_margin_ranking_profile"
},
{
recipe_id: "address_inventory_purchase_to_sale_chain_v1",
intent: "inventory_purchase_to_sale_chain",
@@ -1928,6 +1939,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_trading_margin_proxy_for_organization" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@@ -2182,6 +2194,8 @@ export function buildAddressRecipePlan(
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_trading_margin_proxy_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_margin_ranking_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
@@ -423,6 +423,7 @@ function isInventoryIntent(intent: AddressIntent | undefined): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -438,6 +439,7 @@ function isInventoryDrilldownFrameIntent(intent: AddressIntent | undefined): boo
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -449,6 +451,7 @@ function isInventoryLifecycleHistoryIntent(intent: AddressIntent | undefined): b
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain"
);
@@ -798,11 +801,32 @@ export function hasInventoryPurchaseDateVatBridgeCue(text: string): boolean {
);
}
export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized.trim()) {
return false;
}
const wantsFoundRows =
/(?:покажи|показать|выведи|дай|раскрой|show|list|РїРѕРєР°РРё|показаССЊ|РІСведи|РґР°Р|раскроР)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|РЅР°Рденн|СЃССЂРѕРє|реализаС|себесСРѕРёРјРѕСЃСРЅ|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себесСРѕРёРјРѕСЃСРЅ|реализаС|марР|РїСЂРёР±СР»|номенклаС)/iu.test(normalized);
const account41Not01 =
/\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вмесСРѕ|Р°\s+РЅРµ)/iu.test(normalized);
return wantsFoundRows || account41Not01;
}
export function hasAddressFollowupContextSignal(text: string): boolean {
const normalized = String(text ?? "").trim();
if (!normalized) {
return false;
}
if (hasInventoryMarginRankingFollowupCue(normalized)) {
return true;
}
if (
/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
normalized
@@ -1115,6 +1139,7 @@ function mergeFollowupFilters(
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@@ -1175,6 +1200,7 @@ function mergeFollowupFilters(
(intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date")
@@ -1412,6 +1438,7 @@ function mergeFollowupFilters(
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@@ -1424,6 +1451,9 @@ function mergeFollowupFilters(
const currentContractExplicit = toNonEmptyString(merged.contract);
const currentItemExplicit = toNonEmptyString(merged.item);
const currentAccountExplicit = toNonEmptyString(merged.account);
const currentAccountRefinesMarginDomain =
intent === "inventory_margin_ranking_for_nomenclature" &&
hasInventoryMarginRankingFollowupCue(userMessage);
const shouldSuppressGenericPeriodCarryover =
(Boolean(currentCounterpartyExplicit) &&
!isLowQualityCounterpartyAnchor(currentCounterpartyExplicit) &&
@@ -1432,7 +1462,7 @@ function mergeFollowupFilters(
!isLowQualityContractAnchor(currentContractExplicit) &&
currentContractExplicit !== previousContract) ||
(Boolean(currentItemExplicit) && currentItemExplicit !== previousItem) ||
(Boolean(currentAccountExplicit) && currentAccountExplicit !== previousAccount);
(Boolean(currentAccountExplicit) && currentAccountExplicit !== previousAccount && !currentAccountRefinesMarginDomain);
const vatRelativeMonthFollowup =
relativeMonthFromFollowupYear &&
(intent === "vat_payable_confirmed_as_of_date" ||
@@ -1488,6 +1518,22 @@ function mergeFollowupFilters(
reasons.push("period_from_followup_context");
}
if (
intent === "inventory_margin_ranking_for_nomenclature" &&
previousHasPeriod &&
hasInventoryMarginRankingFollowupCue(userMessage) &&
!hasExplicitPeriodInMessage &&
!hasExplicitCurrentDateInMessage
) {
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
if (previousPeriodTo && merged.period_to !== previousPeriodTo) {
merged.period_to = previousPeriodTo;
}
reasons.push("period_from_followup_context");
}
if (
!currentHasPeriod &&
previousHasPeriod &&
@@ -1563,6 +1609,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
inventory_on_hand_as_of_date: ["as_of_date"],
inventory_margin_ranking_for_nomenclature: ["period_from", "period_to"],
inventory_profitability_for_item: ["item"],
open_contracts_confirmed_as_of_date: ["as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
@@ -1648,6 +1695,26 @@ function deriveIntentWithFollowupContext(
previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
const inventoryPurchaseDateVatBridge =
inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
const marginRankingLineageActive =
sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
followupContext.root_intent === "inventory_margin_ranking_for_nomenclature";
if (
marginRankingLineageActive &&
hasInventoryMarginRankingFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "documents_forming_balance" ||
detectedIntent.intent === "inventory_margin_ranking_for_nomenclature" ||
detectedIntent.intent === sourceIntent)
) {
return {
intent: "inventory_margin_ranking_for_nomenclature",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_margin_ranking_followup_context"]
};
}
if (
inventoryPurchaseDateVatBridge &&
@@ -162,6 +162,73 @@ function inventoryProfitabilityPeriodLabel(options: InventoryComposeOptions, dep
return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке";
}
interface InventoryMarginRankingEntry {
item: string;
revenue: number;
costProxy: number;
spread: number;
marginPct: number | null;
saleQuantity: number;
purchaseQuantity: number;
saleDocuments: number;
purchaseDocuments: number;
}
function inventoryRowItemLabel(row: ComposeStageRow, deps: InventoryReplyDeps): string | null {
return deps.summarizeInventoryTraceRows([row]).item;
}
function buildInventoryMarginRankingEntries(rows: ComposeStageRow[], deps: InventoryReplyDeps): InventoryMarginRankingEntry[] {
const byItem = new Map<string, { item: string; saleRows: ComposeStageRow[]; purchaseRows: ComposeStageRow[] }>();
for (const row of rows) {
const item = inventoryRowItemLabel(row, deps);
if (!item) {
continue;
}
const key = item.trim().toLocaleLowerCase("ru");
const current = byItem.get(key) ?? { item, saleRows: [], purchaseRows: [] };
if (deps.isInventorySaleMovement(row)) {
current.saleRows.push(row);
}
if (deps.isInventoryPurchaseMovement(row)) {
current.purchaseRows.push(row);
}
byItem.set(key, current);
}
return Array.from(byItem.values())
.map((entry) => {
const revenue = sumInventoryRowAmount(entry.saleRows);
const costProxy = sumInventoryRowAmount(entry.purchaseRows);
const spread = revenue - costProxy;
return {
item: entry.item,
revenue,
costProxy,
spread,
marginPct: revenue > 0 ? (spread / revenue) * 100 : null,
saleQuantity: sumInventoryRowQuantity(entry.saleRows),
purchaseQuantity: sumInventoryRowQuantity(entry.purchaseRows),
saleDocuments: entry.saleRows.length,
purchaseDocuments: entry.purchaseRows.length
};
})
.filter((entry) => entry.revenue > 0 || entry.costProxy > 0);
}
function formatInventoryMarginRankingLine(
entry: InventoryMarginRankingEntry,
index: number,
deps: InventoryReplyDeps
): string {
return `${index + 1}. ${entry.item} — выручка ${deps.formatMoneyRub(entry.revenue)}, себестоимостная база ${deps.formatMoneyRub(
entry.costProxy
)}, валовая разница ${deps.formatMoneyRub(entry.spread)}, маржа ${formatInventoryPercent(
entry.marginPct,
deps.formatNumberWithDots
)}.`;
}
export function composeInventoryReply(
intent: AddressIntent,
rows: ComposeStageRow[],
@@ -548,6 +615,121 @@ export function composeInventoryReply(
: buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false));
}
if (intent === "inventory_margin_ranking_for_nomenclature") {
const entries = buildInventoryMarginRankingEntries(rows, deps);
const confirmedEntries = entries.filter((entry) => entry.revenue > 0 && entry.costProxy > 0);
const highMargin = [...confirmedEntries]
.sort((left, right) => right.spread - left.spread || (right.marginPct ?? -Infinity) - (left.marginPct ?? -Infinity))
.slice(0, 5);
const lowMargin = [...confirmedEntries]
.sort((left, right) => left.spread - right.spread || (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity))
.slice(0, 5);
const salesWithoutCost = entries.filter((entry) => entry.revenue > 0 && entry.costProxy <= 0);
const purchasesWithoutSales = entries.filter((entry) => entry.costProxy > 0 && entry.revenue <= 0);
const periodLabel = inventoryProfitabilityPeriodLabel(options, deps);
const totalRevenue = entries.reduce((sum, entry) => sum + entry.revenue, 0);
const totalCostProxy = entries.reduce((sum, entry) => sum + entry.costProxy, 0);
const totalSpread = totalRevenue - totalCostProxy;
if (confirmedEntries.length === 0) {
const lines: string[] = [`За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`];
const findings: string[] = [];
if (salesWithoutCost.length > 0) {
const salesCount = deps.formatNumberWithDots(salesWithoutCost.length);
const salesItemPhrase =
salesWithoutCost.length === 1 ? "1 номенклатурной позиции" : `${salesCount} номенклатурным позициям`;
findings.push(
`Есть реализация по ${salesItemPhrase}.`
);
findings.push(
salesWithoutCost.length === 1
? "Подтвержденной себестоимости реализации по этой позиции не найдено."
: "Подтвержденной себестоимости реализации по этим позициям не найдено."
);
findings.push("Поэтому валовую прибыль и маржинальность честно посчитать нельзя.");
}
if (purchasesWithoutSales.length > 0) {
const purchaseCount = deps.formatNumberWithDots(purchasesWithoutSales.length);
const purchaseItemPhrase =
purchasesWithoutSales.length === 1 ? "1 позиции" : `${purchaseCount} позициям`;
findings.push(
purchasesWithoutSales.length === 1
? `Есть себестоимостная база по ${purchaseItemPhrase}, но реализации по ней в периоде не найдено.`
: `Есть себестоимостная база по ${purchaseItemPhrase}, но реализации по ним в периоде не найдено.`
);
}
if (entries.length === 0) {
findings.push("В доступной выборке нет достаточных строк реализации и себестоимости по номенклатуре.");
}
appendInventoryBulletSection(lines, "Что нашлось:", findings);
lines.push(
`Вывод: за период ${periodLabel} нет достаточной базы для рейтинга «высокая / низкая прибыль» по номенклатуре.`
);
const nextActions: string[] = [];
if (salesWithoutCost.length > 0) {
nextActions.push("показать найденные реализации за этот период;");
}
if (purchasesWithoutSales.length > 0) {
nextActions.push("показать найденные строки себестоимостной базы за этот период;");
}
nextActions.push(
"расширить период до квартала или года;",
"попробовать строгий расчет по проводкам 90.01 / 90.02;",
"построить управленческий proxy по закупочным документам, если такой способ допустим для вашей проверки."
);
appendInventoryBulletSection(lines, "Что можно сделать дальше:", nextActions);
appendInventoryBulletSection(lines, "Граница ответа:", [
"Прибыльность номенклатуры считаю только когда есть реализация и подтвержденная себестоимость реализации.",
"Это не чистая прибыль компании и не замена закрытию месяца."
]);
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics(entries.length > 0 ? "medium" : "weak", false));
}
const directAnswerLine =
confirmedEntries.length > 0
? `За период ${periodLabel} собран рейтинг реализованной номенклатуры по валовой маржинальности: выручка ${deps.formatMoneyRub(
totalRevenue
)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, расчетная валовая разница ${deps.formatMoneyRub(
totalSpread
)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
const lines: string[] = [directAnswerLine];
if (highMargin.length > 0) {
appendInventorySection(
lines,
"Высокая валовая маржинальность:",
highMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps))
);
}
if (lowMargin.length > 0) {
appendInventorySection(
lines,
"Низкая или отрицательная валовая маржинальность:",
lowMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps))
);
}
const boundaryLines = [
"Это управленческий расчет валовой маржинальности по реализации и доступной себестоимостной базе, не чистая прибыль компании.",
"Для строгого бухгалтерского расчета нужны проводки 90.01 / 90.02 и закрытие себестоимости; этот ответ не подменяет закрытие месяца."
];
if (salesWithoutCost.length > 0) {
boundaryLines.push(
`По ${deps.formatNumberWithDots(salesWithoutCost.length)} позициям есть продажи, но нет подтвержденной себестоимости реализации — их нельзя честно ранжировать по прибыли.`
);
}
if (purchasesWithoutSales.length > 0) {
boundaryLines.push(
`По ${deps.formatNumberWithDots(purchasesWithoutSales.length)} позициям есть себестоимостная база без реализации в этом периоде.`
);
}
appendInventoryBulletSection(lines, "Граница ответа:", boundaryLines);
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(confirmedEntries.length > 0 ? "strong" : entries.length > 0 ? "medium" : "weak", confirmedEntries.length > 0)
);
}
if (intent === "inventory_profitability_for_item") {
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row));
@@ -157,6 +157,7 @@ function isInventorySelectedObjectOrRootIntent(intent: string | null): boolean {
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@@ -175,6 +176,18 @@ function isGenericCanonicalDriftIntent(intent: string | null): boolean {
);
}
function hasInventoryMarginRankingAccountCorrectionCue(text: string | null): boolean {
const value = String(text ?? "").toLowerCase();
if (!value.trim()) {
return false;
}
return (
/\b41(?:[.,]\d{1,2})?\b/iu.test(value) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(value) &&
/(?:\u0430\s+\u043d\u0435|\u043d\u0435|\u0432\u043c\u0435\u0441\u0442\u043e|not|instead)/iu.test(value)
);
}
function hasSameDateFollowupSignal(text: string | null): boolean {
return /(?:эту\s+же\s+дат(?:у|е|ой)|ту\s+же\s+дат(?:у|е|ой)|same\s+date)/iu.test(String(text ?? ""));
}
@@ -240,6 +253,12 @@ function shouldPreferRawFollowupMessage(
const hasInventoryFrameCarryover =
isInventorySelectedObjectOrRootIntent(previousIntent) ||
isInventorySelectedObjectOrRootIntent(rootIntent);
const hasInventoryMarginRankingCarryover =
previousIntent === "inventory_margin_ranking_for_nomenclature" ||
rootIntent === "inventory_margin_ranking_for_nomenclature";
const hasInventoryMarginRankingAccountCorrection =
hasInventoryMarginRankingCarryover &&
[rawMessage, canonicalMessage].some((message) => hasInventoryMarginRankingAccountCorrectionCue(message));
const hasDocumentCarryover =
previousIntent === "list_documents_by_counterparty" || previousIntent === "list_documents_by_contract";
@@ -263,6 +282,13 @@ function shouldPreferRawFollowupMessage(
return true;
}
if (
hasInventoryMarginRankingAccountCorrection &&
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")
) {
return true;
}
return (
(hasSelectedObjectInventorySignal(rawMessage) || hasInventoryItemCarryover) &&
(hasSelectedObjectInventoryActionCue(rawMessage) || hasShortInventoryPurchaseFollowupCue(rawMessage)) &&
@@ -34,6 +34,13 @@ function formatMissingAnchors(anchors: string[]): string {
}
function buildClarificationReply(binding: AssistantCapabilityRuntimeBindingContract): string {
if (binding.capability_contract_id === "inventory_inventory_margin_ranking_for_nomenclature") {
return [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период."
].join("\n\n");
}
return [
"Нужно уточнение, чтобы не подставить неподтвержденный объект в расчет.",
`Не хватает: ${formatMissingAnchors(binding.missing_anchors)}.`,
@@ -161,6 +161,15 @@ function anchorSatisfied(requiredAnchor: string, providedAnchors: string[], debu
if (providedAnchors.includes(requiredAnchor)) {
return true;
}
if (requiredAnchor === "period") {
return (
(hasValue(filters?.period_from) && hasValue(filters?.period_to)) ||
hasValue(filters?.as_of_date) ||
providedAnchors.includes("period_from") ||
providedAnchors.includes("period_to") ||
providedAnchors.includes("as_of_date")
);
}
if (requiredAnchor === "item") {
return (
providedAnchors.includes("selected_object") ||
@@ -216,6 +216,7 @@ function isDetectedIntentAlignedWithTurnMeaning(
normalizedIntent === "inventory_purchase_provenance_for_item" ||
normalizedIntent === "inventory_purchase_documents_for_item" ||
normalizedIntent === "inventory_sale_trace_for_item" ||
normalizedIntent === "inventory_margin_ranking_for_nomenclature" ||
normalizedIntent === "inventory_profitability_for_item" ||
normalizedIntent === "inventory_purchase_to_sale_chain"
) {
@@ -276,7 +277,7 @@ function isExplicitMetadataDiscoveryTurn(
}
function isInventoryExactAddressIntent(intent: string | null): boolean {
return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(
return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_margin_ranking_for_nomenclature|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(
String(intent ?? "")
);
}
@@ -28,6 +28,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@@ -41,6 +42,7 @@ const ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS = new Set([
"inventory_purchase_provenance_for_item",
"inventory_purchase_documents_for_item",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain"
]);
@@ -286,7 +288,7 @@ export function createAssistantRoutePolicy(deps) {
: null;
const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false;
const llmSupportedDeepAddressIntentSignal = llmContractMode === "deep_analysis" &&
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_margin_ranking_for_nomenclature|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
semanticCanonicalRecommended;
const llmCanonicalEntitySignal = /(?:заказчик|поставщик|контрагент|компан|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase()));
const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis";
@@ -337,6 +337,18 @@ export const INVENTORY_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContrac
answerObjectShape: "inventory_profitability_bundle",
bundleReusePolicy: "sale_trace_bundle_preferred"
}),
inventoryExactCapability({
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
intent_ids: ["inventory_margin_ranking_for_nomenclature"],
entry_modes: ["root_entry", "root_followup", "clarification_resume"],
transitions: ["T1", "T2", "T7"],
requiresFocusObject: false,
requiredAnchors: ["period"],
resultShape: "nomenclature_margin_ranking",
answerObjectShape: "inventory_margin_ranking",
bundleReusePolicy: "none",
scenarioFamilies: ["canonical", "colloquial", "account_41_correction"]
}),
inventoryExactCapability({
capability_id: "inventory_inventory_purchase_to_sale_chain",
intent_ids: ["inventory_purchase_to_sale_chain"],
@@ -47,6 +47,31 @@ export function createAssistantTransitionPolicy(deps) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage = null, sourceIntentHint = null) {
if (sourceIntentHint !== "inventory_margin_ranking_for_nomenclature") {
return false;
}
return [userMessage, alternateMessage]
.filter((value) => deps.toNonEmptyString(value))
.map((value) =>
deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е")
)
.some((normalized) => {
if (!normalized) {
return false;
}
const wantsFoundRows =
/(?:покажи|показать|выведи|дай|раскрой|show|list|РїРѕРєР°РРё|показаССЊ|РІСведи|РґР°Р|раскроР)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|РЅР°Рденн|СЃССЂРѕРє|реализаС|себесСРѕРёРјРѕСЃСРЅ|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себесСРѕРёРјРѕСЃСРЅ|реализаС|марР|РїСЂРёР±СР»|номенклаС)/iu.test(normalized);
const account41Not01 =
/\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вмесСРѕ|Р°\s+РЅРµ)/iu.test(normalized);
return wantsFoundRows || account41Not01;
});
}
function hasSamePeriodReferenceCue(...values) {
return values
.map((value) => normalizeFollowupText(value))
@@ -760,6 +785,11 @@ export function createAssistantTransitionPolicy(deps) {
sourceIntentHint,
hasNavigationInventoryItemFocusHint
);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(
userMessage,
alternateMessage,
sourceIntentHint
);
let inventoryShortFollowupPrimary =
(deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage);
@@ -796,6 +826,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
@@ -805,6 +836,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
@@ -862,6 +894,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@@ -886,6 +919,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@@ -1054,8 +1088,9 @@ export function createAssistantTransitionPolicy(deps) {
!inventoryShortFollowupPrimary &&
!inventoryShortFollowupAlternate &&
!businessOverviewBoundaryFollowupPrimary &&
!businessOverviewBoundaryFollowupAlternate &&
!foreignAccountingPivotOverInventory &&
!businessOverviewBoundaryFollowupAlternate &&
!inventoryMarginRankingFollowup &&
!foreignAccountingPivotOverInventory &&
!deps.hasFollowupMarker(userMessage) &&
!deps.hasReferentialPointer(userMessage) &&
(!deps.toNonEmptyString(alternateMessage)
@@ -1118,6 +1153,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary ||
mcpDiscoveryOrganizationClarificationContinuation;
@@ -1129,6 +1165,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate ||
mcpDiscoveryOrganizationClarificationContinuation
@@ -1151,6 +1188,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@@ -22,6 +22,7 @@ const SUPPORTED_ADDRESS_INTENTS = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@@ -33,6 +33,7 @@ export type AddressIntent =
| "inventory_supplier_stock_overlap_as_of_date"
| "inventory_sale_trace_for_item"
| "inventory_trading_margin_proxy_for_organization"
| "inventory_margin_ranking_for_nomenclature"
| "inventory_profitability_for_item"
| "inventory_purchase_to_sale_chain"
| "inventory_aging_by_purchase_date"
@@ -203,6 +204,7 @@ export interface AddressRecipeDefinition {
| "inventory_supplier_stock_overlap_profile"
| "inventory_sale_trace_profile"
| "inventory_trading_margin_proxy_profile"
| "inventory_margin_ranking_profile"
| "inventory_profitability_profile"
| "inventory_purchase_to_sale_chain_profile"
| "inventory_aging_by_purchase_date_profile"
@@ -104,6 +104,15 @@ describe("addressIntentResolver regression bridges", () => {
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("routes nomenclature margin ranking away from OS, bank, and settlement fallbacks", () => {
const result = resolveAddressIntent(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439"
);
expect(result.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result.reasons).toContain("unicode_nomenclature_margin_ranking_bridge_signal_detected");
});
it("detects bare historical inventory root with explicit month-year", () => {
const result = resolveAddressIntent("остатки на март 2016");
@@ -104,4 +104,292 @@ describe("inventory profitability selected-object regressions", () => {
expect(reply).toContain("не чистая прибыль компании");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
it("asks for a period before ranking nomenclature by margin", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1");
expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature");
expect(result?.debug.missing_required_filters).toEqual(["period_from", "period_to"]);
const reply = String(result?.reply_text ?? "");
expect(reply).toContain("\u043d\u0443\u0436\u0435\u043d \u043f\u0435\u0440\u0438\u043e\u0434");
expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438");
expect(reply).not.toContain("\u041e\u0421");
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
expect(reply).not.toContain("\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u044b\u0439/\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u044b\u0439 \u0441\u043b\u0435\u0434");
expect(reply).not.toContain("settlement");
expect(executeAddressMcpQueryMock).not.toHaveBeenCalled();
});
it("gives a useful accounting limited answer when sales exist but cost is missing", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2020-05-20T00:00:00Z",
Registrator: "Sales document 1",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 1500,
Quantity: 10,
SubcontoKt1: "Item A",
Organization: "OOO Alternative Plus"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 \u043c\u0430\u0439 2020"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.debug.mcp_call_status).toBe("matched_non_empty");
const reply = String(result?.reply_text ?? "");
expect(reply).toContain("\u0440\u0435\u0439\u0442\u0438\u043d\u0433 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440\u044b \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f");
expect(reply).toContain("\u0415\u0441\u0442\u044c \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f");
expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u0438 \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438");
expect(reply).toContain("\u0447\u0435\u0441\u0442\u043d\u043e \u043f\u043e\u0441\u0447\u0438\u0442\u0430\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f");
expect(reply).toContain("\u0427\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435");
expect(reply).toContain("90.01 / 90.02");
expect(reply).not.toContain("\u041e\u0421");
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
expect(reply).not.toContain("\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u044b\u0439/\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u044b\u0439 \u0441\u043b\u0435\u0434");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
it("does not offer to show realizations when only cost base exists in the period", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2017-09-12T00:00:00Z",
Registrator: "Purchase document 1",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 700,
Quantity: 2,
SubcontoDt1: "Item C",
Organization: "OOO Alternative Plus"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
const reply = String(result?.reply_text ?? "");
expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u0430\u044f \u0431\u0430\u0437\u0430");
expect(reply).toContain("\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e \u043d\u0435\u0439 \u0432 \u043f\u0435\u0440\u0438\u043e\u0434\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e");
expect(reply).toContain("\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u043e\u0439 \u0431\u0430\u0437\u044b");
expect(reply).not.toContain("\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438");
expect(reply).not.toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u0430\u044f/\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u0430\u044f");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
it("keeps cost-base line drilldown inside the nomenclature margin route", () => {
const marginFollowupContext = {
previous_intent: "inventory_margin_ranking_for_nomenclature" as const,
target_intent: "inventory_margin_ranking_for_nomenclature" as const,
root_intent: "inventory_margin_ranking_for_nomenclature" as const,
previous_filters: {
organization: "OOO Alternative Plus",
period_from: "2017-09-01",
period_to: "2017-09-30"
},
previous_anchor_type: "unknown" as const,
previous_anchor_value: null
};
const result = runAddressDecomposeStage(
"\u043f\u043e\u043a\u0430\u0436\u0438 \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u043e\u0439 \u0431\u0430\u0437\u044b",
marginFollowupContext
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30");
expect(result?.filters.missing_required_filters).toEqual([]);
expect(result?.intent.reasons).toContain("intent_adjusted_to_inventory_margin_ranking_followup_context");
});
it("does not pivot margin follow-up account-41 correction into a balance snapshot", () => {
const marginFollowupContext = {
previous_intent: "inventory_margin_ranking_for_nomenclature" as const,
target_intent: "inventory_margin_ranking_for_nomenclature" as const,
root_intent: "inventory_margin_ranking_for_nomenclature" as const,
previous_filters: {
organization: "OOO Alternative Plus",
period_from: "2017-09-01",
period_to: "2017-09-30"
},
previous_anchor_type: "unknown" as const,
previous_anchor_value: null
};
for (const message of [
"\u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e 41 \u0441\u0447\u0435\u0442\u0443 \u0430 \u043d\u0435 01",
"\u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e \u0441\u0447\u0435\u0442\u0443 41 \u0432\u043c\u0435\u0441\u0442\u043e \u0441\u0447\u0435\u0442\u0430 01"
]) {
const result = runAddressDecomposeStage(message, marginFollowupContext);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.intent.intent).not.toBe("account_balance_snapshot");
expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30");
expect(result?.filters.missing_required_filters).toEqual([]);
expect(result?.intent.reasons).toContain("intent_adjusted_to_inventory_margin_ranking_followup_context");
}
});
it("keeps carried period when executing margin account-41 correction", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2017-02-10T00:00:00Z",
Registrator: "Purchase document 2017",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 500,
Quantity: 1,
SubcontoDt1: "Item A",
Organization: "OOO Alternative Plus"
},
{
Period: "2017-03-10T00:00:00Z",
Registrator: "Sales document 2017",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 900,
Quantity: 1,
SubcontoKt1: "Item A",
Organization: "OOO Alternative Plus"
}
],
rows: [],
error: null
});
const marginFollowupContext = {
previous_intent: "inventory_margin_ranking_for_nomenclature" as const,
target_intent: "inventory_margin_ranking_for_nomenclature" as const,
root_intent: "inventory_margin_ranking_for_nomenclature" as const,
previous_filters: {
organization: "OOO Alternative Plus",
period_from: "2017-01-01",
period_to: "2017-12-31"
},
previous_anchor_type: "organization" as const,
previous_anchor_value: "OOO Alternative Plus"
};
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e 41 \u0441\u0447\u0435\u0442\u0443 \u0430 \u043d\u0435 01",
{ followupContext: marginFollowupContext }
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1");
expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature");
expect(result?.debug.extracted_filters?.period_from).toBe("2017-01-01");
expect(result?.debug.extracted_filters?.period_to).toBe("2017-12-31");
expect(result?.debug.missing_required_filters).toEqual([]);
expect(result?.debug.mcp_call_status).toBe("matched_non_empty");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
it("answers period-scoped nomenclature margin ranking with high and low gross-margin buckets", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 4,
matched_rows: 4,
raw_rows: [
{
Period: "2020-01-10T00:00:00Z",
Registrator: "Purchase document 1",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 500,
Quantity: 10,
SubcontoDt1: "Item A",
Organization: "OOO Alternative Plus"
},
{
Period: "2020-02-10T00:00:00Z",
Registrator: "Sales document 1",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 1500,
Quantity: 10,
SubcontoKt1: "Item A",
Organization: "OOO Alternative Plus"
},
{
Period: "2020-03-10T00:00:00Z",
Registrator: "Purchase document 2",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 1000,
Quantity: 5,
SubcontoDt1: "Item B",
Organization: "OOO Alternative Plus"
},
{
Period: "2020-04-10T00:00:00Z",
Registrator: "Sales document 2",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 900,
Quantity: 5,
SubcontoKt1: "Item B",
Organization: "OOO Alternative Plus"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 2020 \u0433\u043e\u0434"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1");
expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature");
expect(result?.debug.mcp_call_status).toBe("matched_non_empty");
const reply = String(result?.reply_text ?? "");
expect(reply).toContain("\u0412\u044b\u0441\u043e\u043a\u0430\u044f \u0432\u0430\u043b\u043e\u0432\u0430\u044f \u043c\u0430\u0440\u0436\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c");
expect(reply).toContain("\u041d\u0438\u0437\u043a\u0430\u044f \u0438\u043b\u0438 \u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u0430\u044f");
expect(reply).toContain("Item A");
expect(reply).toContain("Item B");
expect(reply).toContain("\u043d\u0435 \u0447\u0438\u0441\u0442\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438");
expect(reply).not.toContain("\u041e\u0421");
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
});
@@ -499,6 +499,70 @@ describe("assistant address orchestration runtime adapter", () => {
);
});
it("prefers raw margin-ranking account correction over account-balance canonical drift", async () => {
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
followupContext: {
previous_intent: "inventory_margin_ranking_for_nomenclature",
target_intent: "inventory_margin_ranking_for_nomenclature",
root_intent: "inventory_margin_ranking_for_nomenclature",
previous_filters: {
period_from: "2017-01-01",
period_to: "2017-12-31",
organization: "OOO Alternative Plus"
},
previous_anchor_type: "organization",
previous_anchor_value: "OOO Alternative Plus"
}
}));
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
runAddressLane: true,
livingMode: "address_data",
livingReason: "address_lane_triggered",
toolGateDecision: "run_address_lane",
toolGateReason: "followup_context_detected",
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
}));
const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({
schema_version: "address_llm_predecompose_contract_v1",
source_message: sourceMessage,
canonical_message: canonicalMessage,
mode: canonicalMessage === sourceMessage ? "unsupported" : "address_query",
intent: canonicalMessage === sourceMessage ? "unknown" : "account_balance_snapshot"
}));
const rawMessage = "\u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e 41 \u0441\u0447\u0435\u0442\u0443 \u0430 \u043d\u0435 01";
const output = await buildAssistantAddressOrchestrationRuntime(
buildInput({
userMessage: rawMessage,
runAddressLlmPreDecompose: vi.fn(async () => ({
attempted: true,
applied: true,
effectiveMessage:
"\u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e \u0441\u0447\u0435\u0442\u0443 41 \u0432\u043c\u0435\u0441\u0442\u043e \u0441\u0447\u0435\u0442\u0430 01",
reason: "normalized_fragment_applied",
predecomposeContract: {
mode: "address_query",
intent: "account_balance_snapshot"
}
})),
buildAddressLlmPredecomposeContractV1,
resolveAddressFollowupCarryoverContext,
resolveAssistantOrchestrationDecision
})
);
expect(output.addressInputMessage).toBe(rawMessage);
expect(output.addressPreDecompose.applied).toBe(false);
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
expect.objectContaining({
rawUserMessage: rawMessage,
effectiveAddressUserMessage: rawMessage
})
);
});
it("prefers raw selected-object inventory action over generic canonical drift intent", async () => {
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
followupContext: {
@@ -66,6 +66,35 @@ describe("assistant capability binding response guard", () => {
expect(output.audit.reason_codes).toContain("capability_binding_guard_clarification_reply");
});
it("uses business clarification for nomenclature margin ranking period", () => {
const output = applyAssistantCapabilityBindingResponseGuard({
assistantReply: "unsafe answer",
replyType: "partial_coverage",
capabilityBinding: binding({
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
capability_contract_id: "inventory_inventory_margin_ranking_for_nomenclature",
binding_status: "blocked",
binding_action: "clarify",
required_anchors: ["period"],
provided_anchors: [],
missing_anchors: ["period"],
requires_focus_object: false,
focus_object_binding_status: "not_required",
result_shape: "nomenclature_margin_ranking",
answer_object_shape: "inventory_margin_ranking",
violations: ["required_anchor_missing"],
reason_codes: ["required_anchor_missing"]
})
});
expect(output.replyType).toBe("partial_coverage");
expect(output.assistantReply).toContain("Для рейтинга прибыльности номенклатуры нужен период");
expect(output.assistantReply).toContain("выручку");
expect(output.assistantReply).toContain("себестоимость реализации");
expect(output.assistantReply).not.toContain("period");
expect(output.audit.applied).toBe(true);
});
it("turns blocked incompatible transitions into bounded replies", () => {
const output = applyAssistantCapabilityBindingResponseGuard({
assistantReply: "unsafe answer",
@@ -130,6 +130,34 @@ describe("assistant capability runtime binding adapter", () => {
expect(binding.violations).toEqual([]);
});
it("treats period_from and period_to as satisfying period anchor", () => {
const binding = resolveAssistantCapabilityRuntimeBinding({
addressDebug: {
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
detected_intent: "inventory_margin_ranking_for_nomenclature",
detected_mode: "address_query",
capability_layer: "compute",
capability_route_mode: "exact",
extracted_filters: {
period_from: "2020-05-01",
period_to: "2020-05-31",
organization: "ООО Альтернатива Плюс"
},
rows_matched: 1,
route_expectation_status: "matched"
},
groundingStatus: "grounded",
replyType: "factual"
});
expect(binding.binding_status).toBe("bound");
expect(binding.binding_action).toBe("allow");
expect(binding.required_anchors).toEqual(["period"]);
expect(binding.provided_anchors).toEqual(expect.arrayContaining(["period_from", "period_to"]));
expect(binding.missing_anchors).toEqual([]);
expect(binding.violations).toEqual([]);
});
it("blocks selected-object capabilities when required anchors are missing", () => {
const binding = resolveAssistantCapabilityRuntimeBinding({
addressDebug: {