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

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
@@ -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));