Стабилизировать маржинальность номенклатуры
This commit is contained in:
@@ -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 ||
|
||||
|
||||
+119
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user