Open-World: добавить годовую динамику в бизнес-обзор

This commit is contained in:
2026-05-04 11:47:31 +03:00
parent 027e9b373e
commit 04244ff3d7
9 changed files with 396 additions and 14 deletions
@@ -393,6 +393,9 @@ function headlineFor(mode, pilot) {
overview.outgoing_supplier_payout.rows_with_amount > 0) {
families.push("денежный поток");
}
if (overview.yearly_breakdown?.length) {
families.push("годовая operating-flow динамика");
}
if (overview.activity_period) {
families.push("активность");
}
@@ -615,6 +618,7 @@ function buildMustNotClaim(pilot) {
}
if (isBusinessOverviewPilot(pilot)) {
claims.push("Do not present business overview cash-flow spread as profit or margin.");
claims.push("Do not present business overview yearly operating-flow breakdown as profit, financial result, or a complete annual P&L.");
claims.push("Do not present business overview trading-margin proxy as clean profit, accounting financial result, or exact cost-of-sales margin.");
claims.push("Do not present business overview supplier concentration as vendor-risk audit, procurement quality, or full expense structure.");
claims.push("Do not claim debt quality, VAT position, inventory health, or company health unless those contours were separately checked.");
@@ -899,6 +903,18 @@ function amountHumanRu(value) {
const rounded = Math.round(Math.abs(value) * 100) / 100;
return `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(rounded)} руб.`;
}
function yearCountHumanRu(count) {
const abs = Math.abs(count) % 100;
const last = abs % 10;
const noun = abs >= 11 && abs <= 14
? "лет"
: last === 1
? "год"
: last >= 2 && last <= 4
? "года"
: "лет";
return `${count} ${noun}`;
}
function percentOfTotal(part, total) {
if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) {
return null;
@@ -955,6 +971,9 @@ function derivedBusinessOverviewConfirmedLines(pilot) {
if (supplierLeader) {
lines.push(`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}.`);
}
if (overview.yearly_breakdown?.length) {
lines.push(`Годовая раскладка операционного денежного потока построена по подтвержденным строкам 1С за ${yearCountHumanRu(overview.yearly_breakdown.length)}.`);
}
if (overview.activity_period) {
lines.push(`Окно подтвержденной активности в 1С: ${overview.activity_period.first_activity_date} — ${overview.activity_period.latest_activity_date}; ориентировочно ${overview.activity_period.duration_human_ru}.`);
}
@@ -1059,6 +1078,32 @@ function businessOverviewSupplierConcentrationLine(overview) {
? `Концентрация исходящего потока: крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru}). Это сигнал procurement concentration по найденным строкам, а не полный vendor-risk аудит или структура всех расходов.`
: `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`;
}
function businessOverviewYearlyOperatingLine(overview) {
const years = overview.yearly_breakdown ?? [];
if (years.length === 0) {
return null;
}
const strongestIncomingYear = [...years]
.filter((bucket) => bucket.incoming_total_amount > 0)
.sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0];
const strongestNetYear = [...years]
.filter((bucket) => bucket.net_amount !== 0)
.sort((left, right) => right.net_amount - left.net_amount || left.year_bucket.localeCompare(right.year_bucket))[0];
if (!strongestIncomingYear && !strongestNetYear) {
return null;
}
const parts = [];
if (strongestIncomingYear) {
parts.push(`самый сильный год по подтвержденным входящим поступлениям ${strongestIncomingYear.year_bucket}: ${strongestIncomingYear.incoming_total_amount_human_ru}`);
}
if (strongestNetYear) {
const netText = strongestNetYear.net_direction === "net_outgoing"
? `нетто исходящее ${strongestNetYear.net_amount_human_ru}`
: `нетто в плюс ${strongestNetYear.net_amount_human_ru}`;
parts.push(`лучший год по расчетному операционному нетто ${strongestNetYear.year_bucket}: ${netText}`);
}
return `Годовая динамика по проверенным строкам: ${parts.join("; ")}. Это operating-flow proxy, не бухгалтерская прибыль и не финрезультат.`;
}
function businessOverviewRiskSynthesisLine(overview) {
const signals = [];
if (overview.tax_position) {
@@ -1144,6 +1189,7 @@ function derivedBusinessOverviewInferenceLines(pilot) {
businessOverviewCashSynthesisLine(overview),
businessOverviewCustomerConcentrationLine(overview),
businessOverviewSupplierConcentrationLine(overview),
businessOverviewYearlyOperatingLine(overview),
businessOverviewRiskSynthesisLine(overview),
businessOverviewExecutiveVerdictLine(overview),
"Это аналитическая интерпретация подтвержденных строк, а не прибыль и не маржа: для финального управленческого вывода нужны отдельные расходы, себестоимость, закрывающие документы, долги, налоги и складская оборачиваемость."
@@ -1202,6 +1248,9 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
if (pilot.derived_business_overview?.top_suppliers?.length) {
pushReason(reasonCodes, "answer_contains_business_overview_supplier_concentration");
}
if (pilot.derived_business_overview?.yearly_breakdown?.length) {
pushReason(reasonCodes, "answer_contains_business_overview_yearly_operating_breakdown");
}
if (pilot.derived_business_overview?.debt_position) {
pushReason(reasonCodes, "answer_contains_business_overview_debt_position");
}
@@ -1630,6 +1630,10 @@ function monthBucketFromIsoDate(isoDate) {
const match = isoDate?.match(/^(\d{4})-(\d{2})-\d{2}$/);
return match ? `${match[1]}-${match[2]}` : null;
}
function yearBucketFromIsoDate(isoDate) {
const match = isoDate?.match(/^(\d{4})-\d{2}-\d{2}$/);
return match ? match[1] : null;
}
function netDirectionFromAmount(amount) {
if (amount > 0) {
return "net_incoming";
@@ -1698,6 +1702,18 @@ function formatAmountHumanRu(amount) {
.replace(/\u00a0/g, " ");
return `${formatted} руб.`;
}
function yearCountHumanRu(count) {
const abs = Math.abs(count) % 100;
const last = abs % 10;
const noun = abs >= 11 && abs <= 14
? "лет"
: last === 1
? "год"
: last >= 2 && last <= 4
? "года"
: "лет";
return `${count} ${noun}`;
}
function deriveValueFlowMonthBreakdown(result, aggregationAxis) {
if (!result || result.error || aggregationAxis !== "month") {
return [];
@@ -1761,6 +1777,65 @@ function deriveBidirectionalValueFlowMonthBreakdown(input) {
};
});
}
function deriveBusinessOverviewSideYearBreakdown(result) {
if (!result || result.error) {
return [];
}
const buckets = new Map();
for (const row of result.rows) {
const yearBucket = yearBucketFromIsoDate(rowDateValue(row));
const amount = rowAmountValue(row);
if (!yearBucket || amount === null) {
continue;
}
const current = buckets.get(yearBucket) ?? { rows_with_amount: 0, total_amount: 0 };
current.rows_with_amount += 1;
current.total_amount += amount;
buckets.set(yearBucket, current);
}
return Array.from(buckets.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([yearBucket, bucket]) => ({
year_bucket: yearBucket,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount)
}));
}
function deriveBusinessOverviewYearlyBreakdown(input) {
const incomingBuckets = deriveBusinessOverviewSideYearBreakdown(input.incomingResult);
const outgoingBuckets = deriveBusinessOverviewSideYearBreakdown(input.outgoingResult);
const allYearBuckets = new Set();
for (const bucket of incomingBuckets) {
allYearBuckets.add(bucket.year_bucket);
}
for (const bucket of outgoingBuckets) {
allYearBuckets.add(bucket.year_bucket);
}
const incomingByYear = new Map(incomingBuckets.map((bucket) => [bucket.year_bucket, bucket]));
const outgoingByYear = new Map(outgoingBuckets.map((bucket) => [bucket.year_bucket, bucket]));
return Array.from(allYearBuckets)
.sort((left, right) => left.localeCompare(right))
.map((yearBucket) => {
const incoming = incomingByYear.get(yearBucket);
const outgoing = outgoingByYear.get(yearBucket);
const incomingAmount = incoming?.total_amount ?? 0;
const outgoingAmount = outgoing?.total_amount ?? 0;
const netAmount = incomingAmount - outgoingAmount;
return {
year_bucket: yearBucket,
incoming_total_amount: incomingAmount,
incoming_total_amount_human_ru: formatAmountHumanRu(incomingAmount),
incoming_rows_with_amount: incoming?.rows_with_amount ?? 0,
outgoing_total_amount: outgoingAmount,
outgoing_total_amount_human_ru: formatAmountHumanRu(outgoingAmount),
outgoing_rows_with_amount: outgoing?.rows_with_amount ?? 0,
net_amount: netAmount,
net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)),
net_direction: netDirectionFromAmount(netAmount)
};
});
}
function deriveValueFlow(result, counterparty, periodScope, direction, aggregationAxis) {
if (!result || result.error || result.matched_rows <= 0) {
return null;
@@ -2542,6 +2617,10 @@ function deriveBusinessOverview(input) {
direction: "outgoing_supplier_payout",
rankingNeed: "top_desc"
});
const yearlyBreakdown = deriveBusinessOverviewYearlyBreakdown({
incomingResult: input.incomingResult,
outgoingResult: input.outgoingResult
});
const activityPeriod = deriveActivityPeriod(input.lifecycleResult);
const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope);
const tradingMarginProxy = deriveBusinessOverviewTradingMarginProxy(input.tradingMarginResult, input.periodScope);
@@ -2595,6 +2674,7 @@ function deriveBusinessOverview(input) {
net_direction: netDirectionFromAmount(netAmount),
top_customers: rankedIncoming?.ranked_values ?? [],
top_suppliers: rankedOutgoing?.ranked_values ?? [],
yearly_breakdown: yearlyBreakdown,
activity_period: activityPeriod,
tax_position: taxPosition,
trading_margin_proxy: tradingMarginProxy,
@@ -2688,6 +2768,9 @@ function buildBusinessOverviewConfirmedFacts(derived) {
const leader = derived.top_suppliers[0];
facts.push(`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`);
}
if (derived.yearly_breakdown.length > 0) {
facts.push(`Годовая раскладка операционного денежного потока построена по подтвержденным строкам 1С за ${yearCountHumanRu(derived.yearly_breakdown.length)}.`);
}
if (derived.activity_period) {
facts.push(`Подтвержденное окно активности в 1С: ${derived.activity_period.first_activity_date} — ${derived.activity_period.latest_activity_date}.`);
}
@@ -2780,6 +2863,12 @@ function buildBusinessOverviewInferredFacts(derived) {
const supplierSharePct = supplierLeader
? percentageOfTotal(supplierLeader.total_amount, derived.outgoing_supplier_payout.total_amount)
: null;
const strongestIncomingYear = [...derived.yearly_breakdown]
.filter((bucket) => bucket.incoming_total_amount > 0)
.sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0];
const strongestNetYear = [...derived.yearly_breakdown]
.filter((bucket) => bucket.net_amount !== 0)
.sort((left, right) => right.net_amount - left.net_amount || left.year_bucket.localeCompare(right.year_bucket))[0];
return [
`Расчетное нетто по найденным строкам: ${derived.net_amount_human_ru}; ${direction}.`,
supplierLeader
@@ -2787,6 +2876,12 @@ function buildBusinessOverviewInferredFacts(derived) {
? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.`
: `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}.`
: null,
strongestIncomingYear
? `Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} (${strongestIncomingYear.incoming_total_amount_human_ru}).`
: null,
strongestNetYear
? `Лучший год по расчетному операционному нетто найденных строк: ${strongestNetYear.year_bucket} (${netDirectionFromAmount(strongestNetYear.net_amount) === "net_outgoing" ? "нетто исходящее" : "нетто в плюс"} ${strongestNetYear.net_amount_human_ru}). Это не бухгалтерская прибыль.`
: null,
"Это операционный денежный сигнал по найденным строкам 1С, а не прибыль, маржа или бухгалтерское заключение о здоровье бизнеса."
].filter((fact) => Boolean(fact));
}
@@ -3772,6 +3867,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
if (derivedBusinessOverview.top_suppliers.length > 0) {
pushReason(reasonCodes, "pilot_derived_business_overview_top_suppliers_from_confirmed_rows");
}
if (derivedBusinessOverview.yearly_breakdown.length > 0) {
pushReason(reasonCodes, "pilot_derived_business_overview_yearly_operating_breakdown_from_confirmed_rows");
}
if (derivedBusinessOverview.activity_period) {
pushReason(reasonCodes, "pilot_derived_business_overview_activity_window_from_confirmed_rows");
}