Open-World: включить маржинальность выбранной номенклатуры

This commit is contained in:
2026-05-04 08:40:27 +03:00
parent edab736a6d
commit 7294eca381
23 changed files with 472 additions and 56 deletions
@@ -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_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
"customer_revenue_and_payments",
@@ -91,6 +92,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_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
) {
@@ -176,8 +178,15 @@ 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_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain"
) {
if (intent === "inventory_profitability_for_item") {
return {
enabled: true,
reason: "inventory_profitability_route_enabled"
};
}
if (intent === "inventory_purchase_to_sale_chain") {
return {
enabled: true,
@@ -275,6 +284,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_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_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
@@ -2435,6 +2435,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_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
);
@@ -2446,6 +2447,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_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
);
@@ -2454,6 +2456,7 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean {
function shouldClearAsOfDateForHistoryRecovery(intent: AddressIntent): boolean {
return (
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain"
);
}
@@ -2465,6 +2468,7 @@ function shouldDetachLifecycleExecutionFromSnapshotContext(
if (
intent !== "inventory_supplier_stock_overlap_as_of_date" &&
intent !== "inventory_sale_trace_for_item" &&
intent !== "inventory_profitability_for_item" &&
intent !== "inventory_purchase_to_sale_chain"
) {
return false;
@@ -868,6 +868,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "strict",
query_template: "inventory_purchase_to_sale_chain_profile"
},
{
recipe_id: "address_inventory_profitability_for_item_v1",
intent: "inventory_profitability_for_item",
purpose: "Trace purchase and sale document rows for one inventory item and derive bounded revenue, purchase-cost proxy, spread, and margin",
required_filters: ["item"],
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
default_limit: 600,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_profitability_profile"
},
{
recipe_id: "address_inventory_aging_by_purchase_date_v1",
intent: "inventory_aging_by_purchase_date",
@@ -1427,6 +1438,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "open_contracts_confirmed_as_of_date" ||
@@ -1652,6 +1664,8 @@ export function buildAddressRecipePlan(
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventorySaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_profitability_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"
@@ -401,6 +401,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_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain"
);
}
@@ -1272,6 +1273,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_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" ||
@@ -1331,6 +1333,22 @@ function mergeFollowupFilters(
}
}
if (
intent === "inventory_profitability_for_item" &&
previousHasPeriod &&
hasSelectedObjectInventorySignal(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 &&
@@ -132,6 +132,34 @@ function inventoryPartyListOrUnknown(parties: string[]): string {
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
}
function sumInventoryRowAmount(rows: ComposeStageRow[]): number {
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
}
function sumInventoryRowQuantity(rows: ComposeStageRow[]): number {
return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0);
}
function formatInventoryPercent(value: number | null, formatNumberWithDots: (value: number, fractionDigits?: number) => string): string {
return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`;
}
function inventoryProfitabilityPeriodLabel(options: InventoryComposeOptions, deps: InventoryReplyDeps): string {
const from = typeof options.periodFrom === "string" && options.periodFrom.trim().length > 0 ? options.periodFrom : null;
const to = typeof options.periodTo === "string" && options.periodTo.trim().length > 0 ? options.periodTo : null;
if (from && to) {
return `${deps.formatDateRu(from)} - ${deps.formatDateRu(to)}`;
}
if (from) {
return `с ${deps.formatDateRu(from)}`;
}
if (to) {
return `до ${deps.formatDateRu(to)}`;
}
const asOfDate = typeof options.asOfDate === "string" && options.asOfDate.trim().length > 0 ? options.asOfDate : null;
return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке";
}
export function composeInventoryReply(
intent: AddressIntent,
rows: ComposeStageRow[],
@@ -481,6 +509,79 @@ export function composeInventoryReply(
: buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false));
}
if (intent === "inventory_profitability_for_item") {
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row));
const requestedItemHint = String(options.itemHint ?? "").trim();
const excludedCounterpartyTokens = requestedItemHint ? [requestedItemHint] : [];
const purchaseSummary = deps.summarizeInventoryTraceRows(purchaseRows, excludedCounterpartyTokens);
const saleSummary = deps.summarizeInventoryTraceRows(saleRows, excludedCounterpartyTokens);
const itemLabel = requestedItemHint || purchaseSummary.item || saleSummary.item || "товар не определен";
const revenue = sumInventoryRowAmount(saleRows);
const purchaseCostProxy = sumInventoryRowAmount(purchaseRows);
const spread = revenue - purchaseCostProxy;
const marginPct = revenue > 0 ? (spread / revenue) * 100 : null;
const markupPct = purchaseCostProxy > 0 ? (spread / purchaseCostProxy) * 100 : null;
const saleQuantity = sumInventoryRowQuantity(saleRows);
const purchaseQuantity = sumInventoryRowQuantity(purchaseRows);
const periodLabel = inventoryProfitabilityPeriodLabel(options, deps);
const hasSales = saleRows.length > 0;
const hasPurchases = purchaseRows.length > 0;
const directAnswerLine =
hasSales && hasPurchases
? `По товару ${itemLabel} за период ${periodLabel} подтверждена выручка продаж ${deps.formatMoneyRub(revenue)} и закупочный след ${deps.formatMoneyRub(purchaseCostProxy)}; расчетный валовый спред по доступным документам: ${deps.formatMoneyRub(spread)}. Маржинальность к выручке: ${formatInventoryPercent(marginPct, deps.formatNumberWithDots)}.`
: hasSales
? `По товару ${itemLabel} за период ${periodLabel} подтверждена выручка продаж ${deps.formatMoneyRub(revenue)}, но закупочный след в доступных строках не найден; прибыль и маржа не подтверждены.`
: hasPurchases
? `По товару ${itemLabel} за период ${periodLabel} найден закупочный след ${deps.formatMoneyRub(purchaseCostProxy)}, но продажи в доступных строках не найдены; выручка, прибыль и маржа не подтверждены.`
: `По товару ${itemLabel} за период ${periodLabel} не найдено ни продаж, ни закупочного следа в доступных строках 41.01.`;
const lines: string[] = [directAnswerLine];
appendInventoryBulletSection(lines, "Расчет:", [
`Строк продаж со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`,
`Строк закупки на счет 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`,
`Выручка по документам продажи: ${deps.formatMoneyRub(revenue)}.`,
`Закупочная сумма по доступным документам: ${deps.formatMoneyRub(purchaseCostProxy)}.`,
`Расчетный валовый спред: ${deps.formatMoneyRub(spread)}.`,
`Маржинальность к выручке: ${formatInventoryPercent(marginPct, deps.formatNumberWithDots)}.`,
`Наценка к закупочному следу: ${formatInventoryPercent(markupPct, deps.formatNumberWithDots)}.`
]);
if (saleQuantity > 0 || purchaseQuantity > 0) {
lines.push(`- Количество в продажах: ${deps.formatNumberWithDots(saleQuantity, 3)}; количество в закупках: ${deps.formatNumberWithDots(purchaseQuantity, 3)}.`);
}
if (saleSummary.counterparties.length > 0) {
lines.push(`- Покупатели в продаже: ${saleSummary.counterparties.slice(0, 4).join("; ")}.`);
}
if (purchaseSummary.counterparties.length > 0) {
lines.push(`- Поставщики в закупочном следе: ${purchaseSummary.counterparties.slice(0, 4).join("; ")}.`);
}
appendInventoryBulletSection(lines, "Ограничения:", [
"Это не чистая прибыль компании и не бухгалтерский финансовый результат.",
"Закупочная сумма является proxy по найденным документам поступления; без партионного/управленческого учета нельзя доказать точную себестоимость конкретной продажи.",
"Если продажи и закупки попали в разные периоды или разные организации/склады, вывод нужно читать как документальный срез по доступной выборке, а не как полный P&L."
]);
if (saleRows.length > 0) {
appendInventorySection(lines, "Продажи:", [
`- Первая дата продажи: ${deps.inventoryTraceDateLabel(saleSummary.firstPeriod)}.`,
`- Последняя дата продажи: ${deps.inventoryTraceDateLabel(saleSummary.lastPeriod)}.`,
...deps.formatInventoryTraceRows(saleRows, 8, [itemLabel])
]);
}
if (purchaseRows.length > 0) {
appendInventorySection(lines, "Закупки:", [
`- Первая дата закупки: ${deps.inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`,
`- Последняя дата закупки: ${deps.inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`,
...deps.formatInventoryTraceRows(purchaseRows, 8, [itemLabel])
]);
}
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(
hasSales && hasPurchases ? "strong" : hasSales || hasPurchases ? "medium" : "weak",
hasSales || hasPurchases
)
);
}
if (intent === "inventory_purchase_to_sale_chain") {
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row));
@@ -326,6 +326,17 @@ export const INVENTORY_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContrac
answerObjectShape: "inventory_sale_trace_bundle",
bundleReusePolicy: "sale_trace_bundle_preferred"
}),
inventoryExactCapability({
capability_id: "inventory_inventory_profitability_for_item",
intent_ids: ["inventory_profitability_for_item"],
entry_modes: ["selected_object_drilldown", "clarification_resume"],
transitions: ["T3", "T4", "T5", "T7"],
requiresFocusObject: true,
requiredAnchors: ["item"],
resultShape: "item_revenue_purchase_cost_spread_margin_proxy",
answerObjectShape: "inventory_profitability_bundle",
bundleReusePolicy: "sale_trace_bundle_preferred"
}),
inventoryExactCapability({
capability_id: "inventory_inventory_purchase_to_sale_chain",
intent_ids: ["inventory_purchase_to_sale_chain"],
@@ -1,4 +1,4 @@
export type AddressQuestionMode = "address_query" | "deep_analysis" | "unsupported";
export type AddressQuestionMode = "address_query" | "deep_analysis" | "unsupported";
import type {
AssistantCoverageStatus,
@@ -196,6 +196,7 @@ export interface AddressRecipeDefinition {
| "inventory_purchase_documents_profile"
| "inventory_supplier_stock_overlap_profile"
| "inventory_sale_trace_profile"
| "inventory_profitability_profile"
| "inventory_purchase_to_sale_chain_profile"
| "inventory_aging_by_purchase_date_profile";
required_filters: Array<keyof AddressFilterSet>;