Усилить reliability gate и принять margin semantic replay

This commit is contained in:
2026-05-24 10:34:20 +03:00
parent 7f797e5346
commit 9957f82c21
35 changed files with 2963 additions and 67 deletions
@@ -1670,10 +1670,9 @@ function hasNomenclatureMarginRankingSignal(text) {
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;
const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
}
function hasVatPeriodInspectionBridgeSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase();
@@ -21,6 +21,19 @@ function hasPlainRussianInventoryOnHandSignal(text) {
const hasSnapshotCue = /(?:на\s+(?:дату|сегодня|сейчас|март|апрел|май|мая|июн|июл|август|сентябр|октябр|ноябр|декабр|январ|феврал)|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return hasRequestCue && (hasSnapshotCue || /остатк/iu.test(normalized));
}
function hasInventoryMarginRankingSignal(text) {
const normalized = String(text ?? "")
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized) {
return false;
}
const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized);
const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
}
function hasInventoryOnHandSignal(text) {
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(text);
const hasStockStateCue = /(?:(?:что|ч[еёо])\s+там\s+на\s+склад(?:е|у|ом|ах)|(?:что|ч[еёо]).*происход(?:ит|ило|ящее).*(?:на\s+)?склад(?:е|у|ом|ах)|происход(?:ит|ило|ящее)\s+на\s+склад(?:е|у|ом|ах)|ситуац(?:ия|ии)\s+на\s+склад(?:е|у|ом|ах)|обстановк(?:а|и)\s+на\s+склад(?:е|у|ом|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test(text);
@@ -34,6 +47,7 @@ function hasInventoryOnHandSignal(text) {
hasInventoryPurchaseDocumentsSignalV2(text) ||
hasInventorySaleTraceSignalV2(text) ||
hasInventoryAgingSignal(text) ||
hasInventoryMarginRankingSignal(text) ||
hasInventoryPurchaseToSaleChainSignal(text)) {
return false;
}
@@ -185,6 +199,13 @@ function resolveInventoryAddressIntent(text) {
reasons: ["inventory_aging_signal_detected_strong"]
};
}
if (hasInventoryMarginRankingSignal(text)) {
return {
intent: "inventory_margin_ranking_for_nomenclature",
confidence: "high",
reasons: ["inventory_margin_ranking_signal_detected"]
};
}
if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) {
return {
intent: "inventory_on_hand_as_of_date",
@@ -639,10 +639,12 @@ function hasInventoryMarginRankingFollowupCue(text) {
const wantsFoundRows = /(?:покажи|показать|выведи|дай|раскрой|show|list|покажи|показать|выведи|дай|раскрой)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себестоимостн|реализац|марж|прибыл|номенклат)/iu.test(normalized);
const asksMarginBasis = /(?:из\s+чего|как\s+(?:ты\s+)?(?:это\s+)?посчитал|почему|какие\s+поля|чего\s+не\s+хватает|не\s+хватает|точн(?:ой|ая|ую)?\s+марж|basis|source|fields|calculated|missing)/iu.test(normalized) &&
/(?:марж|прибыл|рентаб|себестоимост|выручк|номенклатур|рейтинг|top|margin|profit|cogs|revenue)/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;
return wantsFoundRows || asksMarginBasis || account41Not01;
}
function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim();
@@ -1346,8 +1348,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
followupContext.root_anchor_type === "item" ||
followupContext.current_frame_kind === "inventory_root" ||
followupContext.current_frame_kind === "inventory_drilldown";
const inventorySelectedObjectFollowup = inventoryLineageActive &&
(hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal));
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
const inventorySelectedObjectFollowup = inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal));
const previousCounterpartyLaneActive = hasPreviousCounterparty &&
(followupContext.previous_anchor_type === "counterparty" ||
sourceIntent === "list_documents_by_counterparty" ||
@@ -1369,6 +1371,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "documents_forming_balance" ||
detectedIntent.intent === "inventory_margin_ranking_for_nomenclature" ||
(detectedIntent.intent === "inventory_profitability_for_item" && !hasSelectedObjectReference) ||
detectedIntent.intent === sourceIntent)) {
return {
intent: "inventory_margin_ranking_for_nomenclature",
@@ -88,6 +88,10 @@ function asksForInventoryCostBaseRows(userMessage) {
}
return /(?:себестоимостн|себестоимост|себестоим|закупочн|закупк|90\.02|\b41\b|баз)/iu.test(text);
}
function asksForInventoryMarginBasis(userMessage) {
const text = String(userMessage ?? "").toLowerCase();
return (/(?:из\s+чего|как\s+(?:ты\s+)?(?:это\s+)?посчитал|какие\s+поля|чего\s+не\s+хватает|не\s+хватает|точн(?:ой|ая|ую)?\s+марж|basis|source|fields|calculated|missing)/iu.test(text) && /(?:марж|прибыл|себестоимост|выручк|margin|profit|cogs|revenue)/iu.test(text));
}
function inventoryRowItemLabel(row, deps) {
return deps.summarizeInventoryTraceRows([row]).item;
}
@@ -454,17 +458,19 @@ function composeInventoryReply(intent, rows, options, deps) {
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);
.sort((left, right) => (right.marginPct ?? -Infinity) - (left.marginPct ?? -Infinity) || right.spread - left.spread)
.slice(0, 3);
const lowMargin = [...confirmedEntries]
.sort((left, right) => left.spread - right.spread || (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity))
.slice(0, 5);
.sort((left, right) => (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity) || left.spread - right.spread)
.slice(0, 3);
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;
const topMarginEntry = highMargin[0] ?? null;
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage);
if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines = [
@@ -512,12 +518,34 @@ function composeInventoryReply(intent, rows, options, deps) {
]);
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 directAnswerLine = topMarginEntry && marginBasisRequested
? `Считал маржу за период ${periodLabel} как выручку реализации минус доступную себестоимостную базу: выручка ${deps.formatMoneyRub(totalRevenue)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, валовая разница ${deps.formatMoneyRub(totalSpread)}.`
: topMarginEntry
? `Самая маржинальная позиция за период ${periodLabel}: ${topMarginEntry.item} — маржа ${formatInventoryPercent(topMarginEntry.marginPct, deps.formatNumberWithDots)}, выручка ${deps.formatMoneyRub(topMarginEntry.revenue)}, себестоимостная база ${deps.formatMoneyRub(topMarginEntry.costProxy)}, валовая разница ${deps.formatMoneyRub(topMarginEntry.spread)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
const lines = [directAnswerLine];
if (marginBasisRequested) {
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "База расчета:", [
"выручка: подтвержденные строки реализации по номенклатуре;",
"себестоимостная база: доступные строки закупочного/себестоимостного следа по той же номенклатуре;",
"валовая маржа: (выручка - себестоимостная база) / выручка."
]);
const basisLimitations = [
"это управленческий расчет валовой маржи, не показатель чистой прибыли;"
];
if (salesWithoutCost.length > 0) {
basisLimitations.push(`по ${deps.formatNumberWithDots(salesWithoutCost.length)} позициям есть продажи без подтвержденной себестоимости реализации;`);
}
if (purchasesWithoutSales.length > 0) {
basisLimitations.push(`по ${deps.formatNumberWithDots(purchasesWithoutSales.length)} позициям есть себестоимостная база без реализации в периоде;`);
}
basisLimitations.push("для строгого бухгалтерского расчета нужны проводки 90.01 / 90.02 и проверка закрытия себестоимости.");
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Чего не хватает для точной маржи:", basisLimitations);
lines.push("", "Следующий шаг: могу раскрыть строки выручки и себестоимостной базы по любой позиции из рейтинга.");
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(confirmedEntries.length > 0 ? "strong" : entries.length > 0 ? "medium" : "weak", confirmedEntries.length > 0));
}
if (highMargin.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Высокая валовая маржинальность:", highMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps)));
(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)));
@@ -533,6 +561,7 @@ function composeInventoryReply(intent, rows, options, deps) {
boundaryLines.push(`По ${deps.formatNumberWithDots(purchasesWithoutSales.length)} позициям есть себестоимостная база без реализации в этом периоде.`);
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Граница ответа:", boundaryLines);
lines.push("", "Следующий шаг: могу раскрыть строки выручки и себестоимостной базы по выбранной позиции из рейтинга.");
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") {
@@ -2159,19 +2159,15 @@ function hasNomenclatureMarginRankingSignal(text: string): boolean {
}
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(
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(
normalized
);
return hasNomenclatureCue && hasRealizationCue && hasMarginCue && hasRankingCue;
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
}
function hasVatPeriodInspectionBridgeSignal(text: string): boolean {
@@ -35,6 +35,27 @@ function hasPlainRussianInventoryOnHandSignal(text: string): boolean {
return hasRequestCue && (hasSnapshotCue || /остатк/iu.test(normalized));
}
function hasInventoryMarginRankingSignal(text: string): boolean {
const normalized = String(text ?? "")
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized) {
return false;
}
const hasNomenclatureCue =
/(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasMarginCue =
/(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(
normalized
);
const hasRankingCue =
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
}
function hasInventoryOnHandSignal(text: string): boolean {
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(
text
@@ -54,6 +75,7 @@ function hasInventoryOnHandSignal(text: string): boolean {
hasInventoryPurchaseDocumentsSignalV2(text) ||
hasInventorySaleTraceSignalV2(text) ||
hasInventoryAgingSignal(text) ||
hasInventoryMarginRankingSignal(text) ||
hasInventoryPurchaseToSaleChainSignal(text)
) {
return false;
@@ -304,6 +326,14 @@ export function resolveInventoryAddressIntent(text: string): AddressIntentResolu
};
}
if (hasInventoryMarginRankingSignal(text)) {
return {
intent: "inventory_margin_ranking_for_nomenclature",
confidence: "high",
reasons: ["inventory_margin_ranking_signal_detected"]
};
}
if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) {
return {
intent: "inventory_on_hand_as_of_date",
@@ -812,11 +812,18 @@ export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
/(?:покажи|показать|выведи|дай|раскрой|show|list|РїРѕРєР°РРё|показать|выведи|дай|раскрой)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себестоимостн|реализац|марР|прибыл|номенклат)/iu.test(normalized);
const asksMarginBasis =
/(?:из\s+чего|как\s+(?:ты\s+)?(?:это\s+)?посчитал|почему|какие\s+поля|чего\s+не\s+хватает|не\s+хватает|точн(?:ой|ая|ую)?\s+марж|basis|source|fields|calculated|missing)/iu.test(
normalized
) &&
/(?:марж|прибыл|рентаб|себестоимост|выручк|номенклатур|рейтинг|top|margin|profit|cogs|revenue)/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;
return wantsFoundRows || asksMarginBasis || account41Not01;
}
export function hasAddressFollowupContextSignal(text: string): boolean {
@@ -1674,9 +1681,9 @@ function deriveIntentWithFollowupContext(
followupContext.root_anchor_type === "item" ||
followupContext.current_frame_kind === "inventory_root" ||
followupContext.current_frame_kind === "inventory_drilldown";
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
const inventorySelectedObjectFollowup =
inventoryLineageActive &&
(hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal));
inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal));
const previousCounterpartyLaneActive =
hasPreviousCounterparty &&
(followupContext.previous_anchor_type === "counterparty" ||
@@ -1707,6 +1714,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "documents_forming_balance" ||
detectedIntent.intent === "inventory_margin_ranking_for_nomenclature" ||
(detectedIntent.intent === "inventory_profitability_for_item" && !hasSelectedObjectReference) ||
detectedIntent.intent === sourceIntent)
) {
return {
@@ -170,6 +170,15 @@ function asksForInventoryCostBaseRows(userMessage: string | null | undefined): b
return /(?:себестоимостн|себестоимост|себестоим|закупочн|закупк|90\.02|\b41\b|баз)/iu.test(text);
}
function asksForInventoryMarginBasis(userMessage: string | null | undefined): boolean {
const text = String(userMessage ?? "").toLowerCase();
return (
/(?:из\s+чего|как\s+(?:ты\s+)?(?:это\s+)?посчитал|какие\s+поля|чего\s+не\s+хватает|не\s+хватает|точн(?:ой|ая|ую)?\s+марж|basis|source|fields|calculated|missing)/iu.test(
text
) && /(?:марж|прибыл|себестоимост|выручк|margin|profit|cogs|revenue)/iu.test(text)
);
}
interface InventoryMarginRankingEntry {
item: string;
revenue: number;
@@ -627,17 +636,19 @@ export function composeInventoryReply(
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);
.sort((left, right) => (right.marginPct ?? -Infinity) - (left.marginPct ?? -Infinity) || right.spread - left.spread)
.slice(0, 3);
const lowMargin = [...confirmedEntries]
.sort((left, right) => left.spread - right.spread || (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity))
.slice(0, 5);
.sort((left, right) => (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity) || left.spread - right.spread)
.slice(0, 3);
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;
const topMarginEntry = highMargin[0] ?? null;
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage);
if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines: string[] = [
@@ -700,19 +711,54 @@ export function composeInventoryReply(
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics(entries.length > 0 ? "medium" : "weak", false));
}
const directAnswerLine =
confirmedEntries.length > 0
? `За период ${periodLabel} собран рейтинг реализованной номенклатуры по валовой маржинальности: выручка ${deps.formatMoneyRub(
topMarginEntry && marginBasisRequested
? `Считал маржу за период ${periodLabel} как выручку реализации минус доступную себестоимостную базу: выручка ${deps.formatMoneyRub(
totalRevenue
)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, расчетная валовая разница ${deps.formatMoneyRub(
)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, валовая разница ${deps.formatMoneyRub(
totalSpread
)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
: topMarginEntry
? `Самая маржинальная позиция за период ${periodLabel}: ${topMarginEntry.item} — маржа ${formatInventoryPercent(
topMarginEntry.marginPct,
deps.formatNumberWithDots
)}, выручка ${deps.formatMoneyRub(topMarginEntry.revenue)}, себестоимостная база ${deps.formatMoneyRub(
topMarginEntry.costProxy
)}, валовая разница ${deps.formatMoneyRub(topMarginEntry.spread)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
const lines: string[] = [directAnswerLine];
if (marginBasisRequested) {
appendInventoryBulletSection(lines, "База расчета:", [
"выручка: подтвержденные строки реализации по номенклатуре;",
"себестоимостная база: доступные строки закупочного/себестоимостного следа по той же номенклатуре;",
"валовая маржа: (выручка - себестоимостная база) / выручка."
]);
const basisLimitations = [
"это управленческий расчет валовой маржи, не показатель чистой прибыли;"
];
if (salesWithoutCost.length > 0) {
basisLimitations.push(
`по ${deps.formatNumberWithDots(salesWithoutCost.length)} позициям есть продажи без подтвержденной себестоимости реализации;`
);
}
if (purchasesWithoutSales.length > 0) {
basisLimitations.push(
`по ${deps.formatNumberWithDots(purchasesWithoutSales.length)} позициям есть себестоимостная база без реализации в периоде;`
);
}
basisLimitations.push("для строгого бухгалтерского расчета нужны проводки 90.01 / 90.02 и проверка закрытия себестоимости.");
appendInventoryBulletSection(lines, "Чего не хватает для точной маржи:", basisLimitations);
lines.push("", "Следующий шаг: могу раскрыть строки выручки и себестоимостной базы по любой позиции из рейтинга.");
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(confirmedEntries.length > 0 ? "strong" : entries.length > 0 ? "medium" : "weak", confirmedEntries.length > 0)
);
}
if (highMargin.length > 0) {
appendInventorySection(
lines,
"Высокая валовая маржинальность:",
"Высокая валовая маржинальность (топ по проценту маржи):",
highMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps))
);
}
@@ -739,6 +785,7 @@ export function composeInventoryReply(
);
}
appendInventoryBulletSection(lines, "Граница ответа:", boundaryLines);
lines.push("", "Следующий шаг: могу раскрыть строки выручки и себестоимостной базы по выбранной позиции из рейтинга.");
return buildFactualSummaryReply(
lines,
@@ -113,6 +113,16 @@ describe("addressIntentResolver regression bridges", () => {
expect(result.reasons).toContain("unicode_nomenclature_margin_ranking_bridge_signal_detected");
});
it("routes bare highest-margin nomenclature wording away from inventory snapshot", () => {
const result = resolveAddressIntent(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0431\u044b\u043b\u0430 \u0441\u0430\u043c\u043e\u0439 \u043c\u0430\u0440\u0436\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0439 \u0437\u0430 2020 \u0433\u043e\u0434?"
);
expect(result.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result.intent).not.toBe("inventory_on_hand_as_of_date");
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");
@@ -18,6 +18,15 @@ describe("addressInventoryIntentSignals", () => {
expect(result?.reasons).toContain("inventory_on_hand_signal_detected");
});
it("keeps bare highest-margin nomenclature wording out of the stock snapshot route", () => {
const result = resolveInventoryAddressIntent(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0431\u044b\u043b\u0430 \u0441\u0430\u043c\u043e\u0439 \u043c\u0430\u0440\u0436\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0439 \u0437\u0430 2020 \u0433\u043e\u0434?"
);
expect(result?.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.reasons).toContain("inventory_margin_ranking_signal_detected");
});
it("classifies selected-object purchase provenance wording through the extracted inventory owner", () => {
const result = resolveInventoryAddressIntent("selected object supplier provenance");
@@ -232,6 +232,33 @@ describe("inventory profitability selected-object regressions", () => {
expect(result?.intent.reasons).toContain("intent_adjusted_to_inventory_margin_ranking_followup_context");
});
it("keeps margin basis follow-up inside ranking context instead of asking for an item", () => {
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: "2020-01-01",
period_to: "2020-12-31"
},
previous_anchor_type: "unknown" as const,
previous_anchor_value: null
};
const result = runAddressDecomposeStage(
"\u0410 \u0438\u0437 \u0447\u0435\u0433\u043e \u0442\u044b \u044d\u0442\u043e \u043f\u043e\u0441\u0447\u0438\u0442\u0430\u043b \u0438 \u0447\u0435\u0433\u043e \u043d\u0435 \u0445\u0432\u0430\u0442\u0430\u0435\u0442 \u0434\u043b\u044f \u0442\u043e\u0447\u043d\u043e\u0439 \u043c\u0430\u0440\u0436\u0438?",
marginFollowupContext
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.filters.extracted_filters.period_from).toBe("2020-01-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-12-31");
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,