diff --git a/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md b/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md index febbd18..fd9bb32 100644 --- a/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md +++ b/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md @@ -30,8 +30,9 @@ If another document says `78%`, `87%`, `92%`, or `85%` for a module that is now - Completed active slice: `Business Overview Gap-Specific Headline And Next-Step Precision`: broad company-analysis answers now name the remaining unchecked families from `missing_signal_families` instead of using stale generic profit/debt/VAT/warehouse wording after partial proxies are proven. - Completed active slice: `Business Overview Debt Staleness Risk Proxy Bridge`: when current-turn open-settlement concentration and contract-date age are both present, business overview can include a bounded debt staleness-risk proxy while contractual delinquency, credit risk, and due-date aging remain unclaimed. - Completed active slice: `Business Overview Supplier Concentration Proxy Bridge`: business overview now derives top suppliers/recipients from confirmed outgoing payment rows and surfaces procurement concentration without claiming vendor risk, procurement quality, or full expense structure. +- Completed active slice: `Business Overview Yearly Operating-Flow Proxy Bridge`: business overview now derives annual incoming/outgoing/net buckets from confirmed money-flow rows and can name the strongest incoming year and best operating-net year without claiming profit or P&L. - Next active slice: continue breadth into exact company-wide accounting profit/margin, real due-date debt aging, confirmed inventory reserve/write-off/liquidation evidence, and broader unfamiliar 1C route families only where reviewed evidence routes exist. -- Active module progress: `~88% (Open-World Bounded Autonomy Breadth)`. +- Active module progress: `~90% (Open-World Bounded Autonomy Breadth)`. ## Reporting Rule @@ -68,7 +69,7 @@ The project is not yet a universal arbitrary-1C agent. Remaining work belongs to the next breadth module: -- extend `business_overview` beyond money-flow/activity, customer and supplier concentration, explicit-period VAT/tax, as-of-date debt position, open-settlement concentration, contract-date debt age, debt staleness-risk proxy, as-of-date inventory position, trading-margin proxy, sales-to-stock inventory proxy, and warehouse staleness-risk proxy into separately proven exact accounting profit/margin, due-date debt aging/overdue, and confirmed reserve/write-off/liquidation inventory evidence families; +- extend `business_overview` beyond money-flow/activity, customer and supplier concentration, yearly operating-flow dynamics, explicit-period VAT/tax, as-of-date debt position, open-settlement concentration, contract-date debt age, debt staleness-risk proxy, as-of-date inventory position, trading-margin proxy, sales-to-stock inventory proxy, and warehouse staleness-risk proxy into separately proven exact accounting profit/margin, due-date debt aging/overdue, and confirmed reserve/write-off/liquidation inventory evidence families; - broader dynamic schema traversal for unfamiliar 1C asks; - more primitive descriptors where live evidence proves a real gap; - more replay-backed domain packs that start from user business meaning, not from route convenience; diff --git a/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md b/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md index f8c71d6..725588f 100644 --- a/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md +++ b/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md @@ -504,3 +504,27 @@ Local validation is accepted for this slice: - `npm.cmd run build`: passed. Graphify rebuild after Slice 16 code/doc sync: `6041 nodes`, `13162 edges`, `136 communities`. + +## Slice 17 - Business Overview Yearly Operating-Flow Proxy Bridge + +This slice answers a common broad-analysis expectation without crossing into unsupported accounting profit. + +User wording such as "какой самый доходный год" can mean profit, revenue, cash inflow, or operating scale. Until a reviewed P&L/profit route exists, the safe answer is to compute only what the current business-overview runtime actually proves: annual incoming payment flow, annual outgoing payment flow, and annual net over confirmed rows. + +Implemented now: + +- the pilot derives `yearly_breakdown` from the same confirmed incoming/outgoing money-flow rows already fetched by `business_overview`; +- each bucket contains year, incoming total, outgoing total, row counts, calculated net, human-readable amounts, and net direction; +- evidence and answer drafting can name the strongest year by confirmed incoming receipts and the best year by calculated operating net; +- headline/reason-code surfaces expose this as yearly operating-flow dynamics for semantic replay; +- answer boundaries explicitly say this is `operating-flow proxy`, not profit, финрезультат, or a complete annual P&L. + +This improves management usefulness for broad company analysis while preserving the hard boundary that exact company-wide accounting profit still needs separately reviewed closing, cost, expense, and P&L evidence. + +Local validation is accepted for this slice: + +- `npm.cmd test -- assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts`: passed `66` with `1` skipped. +- `npm.cmd test -- addressQueryRuntimeM23.test.ts`: passed `412`. +- `npm.cmd run build`: passed. + +Graphify rebuild after Slice 17 code/doc sync: `6047 nodes`, `13177 edges`, `139 communities`. diff --git a/docs/ARCH/11 - architecture_turnaround/README.md b/docs/ARCH/11 - architecture_turnaround/README.md index 4150c91..f35e424 100644 --- a/docs/ARCH/11 - architecture_turnaround/README.md +++ b/docs/ARCH/11 - architecture_turnaround/README.md @@ -65,6 +65,7 @@ Status canon for planning: - The current completed breadth slice is `Business Overview Gap-Specific Headline And Next-Step Precision`: business-overview answers now name remaining unchecked families from `missing_signal_families` instead of falling back to stale generic gap wording. - The current completed breadth slice is `Business Overview Debt Staleness Risk Proxy Bridge`: when current-turn open-settlement concentration and contract-date age are both present, company analysis can include a bounded debt staleness-risk proxy while confirmed overdue debt, contractual delinquency, credit risk, and due-date aging remain unclaimed. - The current completed breadth slice is `Business Overview Supplier Concentration Proxy Bridge`: company analysis now ranks confirmed outgoing payment counterparties and surfaces supplier/procurement concentration as a bounded proxy, not as vendor risk or full expense structure. +- The current completed breadth slice is `Business Overview Yearly Operating-Flow Proxy Bridge`: company analysis now builds annual incoming/outgoing/net buckets from confirmed money-flow rows and names strongest years as operating-flow proxy, not profit or full P&L. - The next active breadth slice continues breadth into exact company-wide accounting profit/margin, real due-date debt aging, confirmed reserve/write-off/liquidation inventory evidence, and broader unfamiliar 1C route families without relaxing truth boundaries. - The short source of truth for status wording is [21 - current_status_canon_2026-05-01.md](./21%20-%20current_status_canon_2026-05-01.md). @@ -130,11 +131,11 @@ Current honest status: - pre-multidomain readiness: `~90%` - bounded-autonomy foundation readiness: `~89%` - open-world bounded-autonomy readiness: `~87%` -- active Open-World Bounded Autonomy Breadth progress: `~88%`, with business-overview evidence fusion, the reviewed `business_overview` catalog/data-need/planner route-fabric slice, the fresh multi-probe runtime bridge, the explicit-period VAT/tax fact-family bridge, the explicit-period debt-position bridge, the explicit-date inventory-position bridge, the open-settlement quality bridge accepted by live semantic replay, selected-item profitability bridged by local semantic/runtime regression tests, contract-date debt age bridged locally, debt staleness-risk proxy bridged locally, supplier concentration proxy bridged locally, analyst synthesis added to business-overview answer drafting, company-period trading margin proxy bridged locally, inventory sales-to-stock proxy bridged locally, inventory staleness-risk proxy bridged locally, and gap-specific answer shaping bridged locally; exact accounting profit/margin, true due-date debt aging/overdue, confirmed vendor-risk/procurement-quality analysis, and confirmed reserve/write-off/liquidation inventory evidence are still pending +- active Open-World Bounded Autonomy Breadth progress: `~90%`, with business-overview evidence fusion, the reviewed `business_overview` catalog/data-need/planner route-fabric slice, the fresh multi-probe runtime bridge, the explicit-period VAT/tax fact-family bridge, the explicit-period debt-position bridge, the explicit-date inventory-position bridge, the open-settlement quality bridge accepted by live semantic replay, selected-item profitability bridged by local semantic/runtime regression tests, contract-date debt age bridged locally, debt staleness-risk proxy bridged locally, supplier concentration proxy bridged locally, yearly operating-flow proxy bridged locally, analyst synthesis added to business-overview answer drafting, company-period trading margin proxy bridged locally, inventory sales-to-stock proxy bridged locally, inventory staleness-risk proxy bridged locally, and gap-specific answer shaping bridged locally; exact accounting profit/margin, true due-date debt aging/overdue, confirmed vendor-risk/procurement-quality analysis, and confirmed reserve/write-off/liquidation inventory evidence are still pending - Post-F semantic integrity module progress: `~99%` operationally closed, with remaining risk now treated as next-slice discovery rather than an open blocker inside the closed slice - active inventory-stock breadth slice progress: `100%` for the declared scenario pack, not for arbitrary inventory questions - Planner Autonomy Consolidation progress: `100%` for the declared module, with catalog-fabric, value-flow arbitration, lifecycle bounded inference, broad-evaluation bridge, inventory catalog templates, inventory runtime-boundary honesty, exact inventory recipe bridging, unambiguous metadata-surface lane inference, catalog chain-template scoring, structured chain-match contract exposure, runtime/debug propagation, subject-aware bidirectional comparison arbitration, structured catalog-alignment verdicts, representative alignment regression guard, catalog-alignment reason-code telemetry, explicit `alignment_status` propagation, truth-harness/acceptance-matrix surfacing, soft divergence warning, `catalog_alignment_ok` acceptance invariant, step-level expected catalog-alignment assertions, phase66 and phase32 spec alignment expectations, AGENT source-catalog surfacing, generated phase83 mixed planner-brain replay spec, checked-source user-facing error sanitation, surface-grounded catalog promotion, and guarded live phase83 acceptance validated. Broader unfamiliar 1C asks are now next-module breadth work rather than an open blocker inside this declared slice -- graph snapshot after latest rebuild: `6041 nodes`, `13162 edges`, `136 communities` +- graph snapshot after latest rebuild: `6047 nodes`, `13177 edges`, `139 communities` - current regression-gate breakpoint: - the validated hot paths are no longer structurally broken; - flagship continuity collapse is no longer the primary risk; @@ -187,6 +188,7 @@ Latest live proof now includes: - business-overview gap-specific answer shaping accepted locally: answer-adapter slice passed `34/34` with `1` skipped; build passed; graphify rebuilt to `6036 nodes`, `13149 edges`, `134 communities`; headline and next-step wording now follow `missing_signal_families` instead of stale generic gap labels - business-overview debt staleness-risk proxy accepted locally: targeted executor/answer-adapter slice passed `66/66` with `1` skipped; M23 route/runtime regression passed `412/412`; build passed; graphify rebuilt to `6040 nodes`, `13158 edges`, `135 communities`; the proxy combines contract-date age and open-balance concentration while confirmed overdue debt, contractual delinquency, credit risk, and due-date aging remain unclaimed - business-overview supplier concentration proxy accepted locally: targeted executor/answer-adapter slice passed `66/66` with `1` skipped; M23 route/runtime regression passed `412/412`; build passed; graphify rebuilt to `6041 nodes`, `13162 edges`, `136 communities`; the proxy ranks confirmed outgoing payment counterparties while vendor risk, procurement quality, and full expense structure remain unclaimed +- business-overview yearly operating-flow proxy accepted locally: targeted executor/answer-adapter slice passed `66/66` with `1` skipped; M23 route/runtime regression passed `412/412`; build passed; graphify rebuilt to `6047 nodes`, `13177 edges`, `139 communities`; the proxy builds annual incoming/outgoing/net buckets from confirmed money-flow rows while profit, финрезультат, and full P&L remain unclaimed - inventory template lift accepted locally: catalog/data-need/planner/turn-input slice passed `139/139` with `6` skipped; full MCP-discovery slice passed `276/276` with `9` skipped; build passed; graphify stayed at `5912 nodes`, `12833 edges`, `138 communities` - inventory runtime-boundary hardening accepted locally: runtime-bridge/answer-adapter/pilot-executor slice passed `68/68` with `1` skipped; full MCP-discovery slice passed `277/277` with `9` skipped; build passed; graphify rebuilt to `5913 nodes`, `12837 edges`, `138 communities` - inventory exact-runtime bridge accepted locally: runtime-bridge/answer-adapter/pilot-executor slice passed `70/70` with `1` skipped; full MCP-discovery slice passed `279/279` with `9` skipped; build passed; graphify rebuilt to `5930 nodes`, `12884 edges`, `135 communities` diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 38f302d..2cd9751 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -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"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index 13410c1..fec2fb1 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -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"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 789119b..f236de9 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -492,6 +492,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD ) { families.push("денежный поток"); } + if (overview.yearly_breakdown?.length) { + families.push("годовая operating-flow динамика"); + } if (overview.activity_period) { families.push("активность"); } @@ -724,6 +727,7 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): } 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."); @@ -1043,6 +1047,20 @@ function amountHumanRu(value: number): string { return `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(rounded)} руб.`; } +function yearCountHumanRu(count: number): string { + 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: number, total: number): number | null { if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) { return null; @@ -1113,6 +1131,11 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${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}.` @@ -1245,6 +1268,33 @@ function businessOverviewSupplierConcentrationLine(overview: BusinessOverview): : `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`; } +function businessOverviewYearlyOperatingLine(overview: BusinessOverview): string | null { + 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: string[] = []; + 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: BusinessOverview): string | null { const signals: string[] = []; if (overview.tax_position) { @@ -1341,6 +1391,7 @@ function derivedBusinessOverviewInferenceLines(pilot: AssistantMcpDiscoveryPilot businessOverviewCashSynthesisLine(overview), businessOverviewCustomerConcentrationLine(overview), businessOverviewSupplierConcentrationLine(overview), + businessOverviewYearlyOperatingLine(overview), businessOverviewRiskSynthesisLine(overview), businessOverviewExecutiveVerdictLine(overview), "Это аналитическая интерпретация подтвержденных строк, а не прибыль и не маржа: для финального управленческого вывода нужны отдельные расходы, себестоимость, закрывающие документы, долги, налоги и складская оборачиваемость." @@ -1406,6 +1457,9 @@ export function buildAssistantMcpDiscoveryAnswerDraft( 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"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index cd77779..999f9b4 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -125,6 +125,19 @@ export interface AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket { net_direction: AssistantMcpDiscoveryNetDirection; } +export interface AssistantMcpDiscoveryBusinessOverviewYearBucket { + year_bucket: string; + incoming_total_amount: number; + incoming_total_amount_human_ru: string; + incoming_rows_with_amount: number; + outgoing_total_amount: number; + outgoing_total_amount_human_ru: string; + outgoing_rows_with_amount: number; + net_amount: number; + net_amount_human_ru: string; + net_direction: AssistantMcpDiscoveryNetDirection; +} + export interface AssistantMcpDiscoveryDerivedBidirectionalValueFlow { counterparty: string | null; period_scope: string | null; @@ -151,6 +164,7 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverview { net_direction: AssistantMcpDiscoveryNetDirection; top_customers: AssistantMcpDiscoveryRankedValueFlowBucket[]; top_suppliers: AssistantMcpDiscoveryRankedValueFlowBucket[]; + yearly_breakdown: AssistantMcpDiscoveryBusinessOverviewYearBucket[]; activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null; tax_position: AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition | null; trading_margin_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewTradingMarginProxy | null; @@ -2352,6 +2366,11 @@ function monthBucketFromIsoDate(isoDate: string | null): string | null { return match ? `${match[1]}-${match[2]}` : null; } +function yearBucketFromIsoDate(isoDate: string | null): string | null { + const match = isoDate?.match(/^(\d{4})-\d{2}-\d{2}$/); + return match ? match[1] : null; +} + function netDirectionFromAmount(amount: number): AssistantMcpDiscoveryNetDirection { if (amount > 0) { return "net_incoming"; @@ -2427,6 +2446,20 @@ function formatAmountHumanRu(amount: number): string { return `${formatted} руб.`; } +function yearCountHumanRu(count: number): string { + 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: AssistantMcpDiscoveryCoverageAwareQueryResult | null, aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null @@ -2502,6 +2535,74 @@ function deriveBidirectionalValueFlowMonthBreakdown(input: { }); } +function deriveBusinessOverviewSideYearBreakdown( + result: AssistantMcpDiscoveryCoverageAwareQueryResult | null +): Array<{ year_bucket: string; rows_with_amount: number; total_amount: number; total_amount_human_ru: string }> { + 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: { + incomingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; +}): AssistantMcpDiscoveryBusinessOverviewYearBucket[] { + 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: AssistantMcpDiscoveryCoverageAwareQueryResult | null, counterparty: string | null, @@ -3441,6 +3542,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); @@ -3495,6 +3600,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, @@ -3611,6 +3717,11 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${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}.` @@ -3731,6 +3842,12 @@ function buildBusinessOverviewInferredFacts(derived: AssistantMcpDiscoveryDerive 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 @@ -3738,6 +3855,12 @@ function buildBusinessOverviewInferredFacts(derived: AssistantMcpDiscoveryDerive ? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${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): fact is string => Boolean(fact)); } @@ -4839,6 +4962,9 @@ export async function executeAssistantMcpDiscoveryPilot( 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"); } diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 5998dd6..12af240 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -218,11 +218,15 @@ describe("assistant MCP discovery answer adapter", () => { { rows: [ { Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }, - { Period: "2020-02-15T00:00:00", Amount: 80000, Counterparty: "Клиент Б" } + { Period: "2020-02-15T00:00:00", Amount: 80000, Counterparty: "Клиент Б" }, + { Period: "2021-03-15T00:00:00", Amount: 220000, Counterparty: "Клиент А" } ] }, { - rows: [{ Period: "2020-01-20T00:00:00", Amount: 150000, Counterparty: "Поставщик А" }] + rows: [ + { Period: "2020-01-20T00:00:00", Amount: 150000, Counterparty: "Поставщик А" }, + { Period: "2021-03-20T00:00:00", Amount: 50000, Counterparty: "Поставщик Б" } + ] }, { rows: [ @@ -240,17 +244,22 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.confirmed_lines.join("\n")).toContain("Входящие поступления"); expect(draft.confirmed_lines.join("\n")).toContain("Самый крупный подтвержденный клиент"); expect(draft.confirmed_lines.join("\n")).toContain("Самый крупный подтвержденный поставщик"); + expect(draft.confirmed_lines.join("\n")).toContain("Годовая раскладка операционного денежного потока"); expect(draft.inference_lines.join("\n")).toContain("Аналитический вывод по оборотам"); expect(draft.inference_lines.join("\n")).toContain("Концентрация входящего потока"); expect(draft.inference_lines.join("\n")).toContain("Концентрация исходящего потока"); + expect(draft.inference_lines.join("\n")).toContain("Годовая динамика по проверенным строкам"); + expect(draft.inference_lines.join("\n")).toContain("2021"); expect(draft.inference_lines.join("\n")).toContain("Сводный LLM-аудит"); expect(draft.inference_lines.join("\n")).toContain("не прибыль и не маржа"); expect(draft.unknown_lines.join("\n")).toContain("Прибыль и маржа"); expect(draft.unknown_lines.join("\n")).toContain("Налоговая/VAT-позиция"); expect(draft.must_not_claim).toContain("Do not present business overview cash-flow spread as profit or margin."); + expect(draft.must_not_claim).toContain("Do not present business overview yearly operating-flow breakdown as profit, financial result, or a complete annual P&L."); expect(draft.must_not_claim).toContain("Do not present business overview supplier concentration as vendor-risk audit, procurement quality, or full expense structure."); expect(draft.reason_codes).toContain("answer_contains_business_overview"); expect(draft.reason_codes).toContain("answer_contains_business_overview_supplier_concentration"); + expect(draft.reason_codes).toContain("answer_contains_business_overview_yearly_operating_breakdown"); expect(draft.reason_codes).toContain("answer_contains_business_overview_analyst_synthesis"); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index 3fb6f45..e80b771 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -138,12 +138,14 @@ describe("assistant MCP discovery pilot executor", () => { { rows: [ { Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }, - { Period: "2020-02-15T00:00:00", Amount: 80000, Counterparty: "Клиент Б" } + { Period: "2020-02-15T00:00:00", Amount: 80000, Counterparty: "Клиент Б" }, + { Period: "2021-03-15T00:00:00", Amount: 220000, Counterparty: "Клиент А" } ] }, { rows: [ - { Period: "2020-01-20T00:00:00", Amount: 150000, Counterparty: "Поставщик А" } + { Period: "2020-01-20T00:00:00", Amount: 150000, Counterparty: "Поставщик А" }, + { Period: "2021-03-20T00:00:00", Amount: 50000, Counterparty: "Поставщик Б" } ] }, { @@ -175,33 +177,50 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.derived_business_overview).toMatchObject({ organization_scope: "ООО Альтернатива Плюс", incoming_customer_revenue: { + total_amount: 420000, + rows_with_amount: 3 + }, + outgoing_supplier_payout: { total_amount: 200000, rows_with_amount: 2 }, - outgoing_supplier_payout: { - total_amount: 150000, - rows_with_amount: 1 - }, - net_amount: 50000, + net_amount: 220000, net_direction: "net_incoming" }); expect(result.derived_business_overview?.top_customers[0]).toMatchObject({ axis_value: "Клиент А", - total_amount: 120000 + total_amount: 340000 }); expect(result.derived_business_overview?.top_suppliers[0]).toMatchObject({ axis_value: "Поставщик А", total_amount: 150000 }); + expect(result.derived_business_overview?.yearly_breakdown).toMatchObject([ + { + year_bucket: "2020", + incoming_total_amount: 200000, + outgoing_total_amount: 150000, + net_amount: 50000 + }, + { + year_bucket: "2021", + incoming_total_amount: 220000, + outgoing_total_amount: 50000, + net_amount: 170000 + } + ]); expect(result.derived_business_overview?.activity_period?.duration_total_months).toBe(11); expect(result.evidence.confirmed_facts.join("\n")).toContain("В 1С подтверждены входящие поступления"); expect(result.evidence.confirmed_facts.join("\n")).toContain("Самый крупный подтвержденный поставщик"); + expect(result.evidence.confirmed_facts.join("\n")).toContain("Годовая раскладка операционного денежного потока"); expect(result.evidence.inferred_facts.join("\n")).toContain("procurement concentration proxy"); + expect(result.evidence.inferred_facts.join("\n")).toContain("Самый сильный год по подтвержденным входящим поступлениям: 2021"); expect(result.evidence.unknown_facts).toContain( "Прибыль и маржа этим бизнес-обзором не подтверждены: нужны себестоимость, расходы и закрывающие документы." ); expect(result.reason_codes).toContain("pilot_derived_business_overview_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_derived_business_overview_top_suppliers_from_confirmed_rows"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_yearly_operating_breakdown_from_confirmed_rows"); expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(3); });