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 ed8d1a1..f678e92 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 @@ -24,8 +24,9 @@ If another document says `78%`, `87%`, `92%`, or `85%` for a module that is now - Completed active slice: `Selected-Item Profitability Route Bridge`: selected-object inventory profitability now has a bounded exact recipe over purchase/sale document rows, with explicit boundaries that this is a gross spread/margin proxy rather than company net profit. - Completed active slice: `Business Overview Contract-Date Debt Age Signal Bridge`: explicit-period open-settlement quality can now include contract-date age as a bounded signal, while due-date aging/overdue debt remains unclaimed until a reviewed payment-term route exists. - Completed active slice: `Business Overview Analyst Synthesis Layer`: business-overview answers now turn checked fact families into a bounded analyst note with operating scale, customer concentration, risk contours, and explicit profit/margin boundaries. -- Next active slice: continue breadth into company-wide profit/margin, real due-date debt aging, inventory-liquidity/turnover, and broader unfamiliar 1C route families only where reviewed evidence routes exist. -- Active module progress: `~73% (Open-World Bounded Autonomy Breadth)`. +- Completed active slice: `Business Overview Trading Margin Proxy Bridge`: explicit-period business overview can include a bounded товарный sales-vs-purchase document proxy for revenue, purchase-cost trace, gross spread, and margin proxy, while clean profit/accounting финрезультат remains unclaimed. +- Next active slice: continue breadth into exact company-wide accounting profit/margin, real due-date debt aging, inventory-liquidity/turnover, and broader unfamiliar 1C route families only where reviewed evidence routes exist. +- Active module progress: `~77% (Open-World Bounded Autonomy Breadth)`. ## Reporting Rule @@ -62,7 +63,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, explicit-period VAT/tax, as-of-date debt position, open-settlement concentration, contract-date debt age, and as-of-date inventory position into separately proven company-wide profit/margin, due-date debt aging/overdue, and real inventory-liquidity evidence families; +- extend `business_overview` beyond money-flow/activity, explicit-period VAT/tax, as-of-date debt position, open-settlement concentration, contract-date debt age, as-of-date inventory position, and trading-margin proxy into separately proven exact accounting profit/margin, due-date debt aging/overdue, and real inventory-liquidity 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 9e5d27b..1c24ae5 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 @@ -247,11 +247,33 @@ Local validation is accepted for this slice: - `npm.cmd test -- assistantMcp`: passed `305/305` with `9` skipped. - `npm.cmd run build`: passed. +## Slice 11 - Business Overview Trading Margin Proxy Bridge + +This slice opens a company-period trading-margin proxy lane where the evidence is concrete enough, while keeping the accounting boundary explicit. + +It does not claim clean profit, бухгалтерский финрезультат, or exact cost-of-sales margin. + +Implemented now: + +- `inventory_trading_margin_proxy_for_organization` exists as a bounded recipe over the reviewed purchase/sale document union on `41.01`; +- explicit-period business overview can execute this document probe beside money-flow/activity, VAT/tax, debt-position, open-settlement quality, and inventory probes; +- the pilot derives sales revenue, purchase-document cost proxy, gross spread proxy, margin-to-revenue, markup-to-purchase-cost, and top items only from confirmed товарные document rows; +- the answer adapter surfaces this as `торговый margin proxy`, not as чистая прибыль or accounting margin; +- the unknown family narrows from generic `profit_margin` to `accounting_profit_margin` when the proxy exists, so the user sees both what is now supported and what remains unproved; +- all-time business overview does not silently reuse the prior period's trading proxy, because the probe requires a fresh explicit period window. + +This is a material breadth step for broad company analysis, but it is still not full company-wide profit analysis. Exact profit/margin still needs reviewed evidence for себестоимость продаж, expenses, period closing, and accounting result. + +Local validation is accepted for this slice: + +- `npm.cmd test -- assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts addressQueryRuntimeM23.test.ts`: passed `478` with `1` skipped. +- `npm.cmd run build`: passed. + ### Still Pending Breadth Slices Grow this bridge beyond the first confirmed signal bundle: -- add separate evidence families for company-wide profit/margin and due-date debt aging/overdue quality where reviewed due-date/payment-term routes exist; +- add separate evidence families for exact company-wide accounting profit/margin and due-date debt aging/overdue quality where reviewed closing-cost and due-date/payment-term routes exist; - extend inventory evidence from as-of-date stock position into real turnover/liquidity only when reviewed sales velocity, aging, or obsolescence evidence exists; - upgrade debt evidence from as-of-date position/open-settlement concentration/contract-date age into overdue aging only when reviewed due-date or payment-term aging evidence exists; - extend VAT/tax beyond explicit-period tax position only when the requested tax fact is provable and the period is explicit; @@ -264,7 +286,7 @@ The slice is healthy when: - broad analysis wording lands on the business-overview contour; - the answer is materially more informative than a generic recap; - confirmed metrics are visibly separated from LLM-style interpretation; -- profit/margin are not claimed without supporting evidence; +- profit/margin are not claimed without supporting evidence, and trading-margin proxy is visibly bounded away from clean profit/accounting финрезультат; - Post-F stale-scope and phase83 catalog-alignment canaries remain green. ## Validation @@ -339,3 +361,10 @@ Business-overview analyst synthesis validation: - `npm.cmd run build`: passed. Graphify rebuild after Slice 10 code/doc sync: `6023 nodes`, `13112 edges`, `136 communities`. + +Business-overview trading-margin proxy validation: + +- `npm.cmd test -- assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts addressQueryRuntimeM23.test.ts`: passed `478` with `1` skipped. +- `npm.cmd run build`: passed. + +Graphify rebuild after Slice 11 code/doc sync: `6028 nodes`, `13131 edges`, `137 communities`. diff --git a/docs/ARCH/11 - architecture_turnaround/README.md b/docs/ARCH/11 - architecture_turnaround/README.md index 45bdbe0..296b4bc 100644 --- a/docs/ARCH/11 - architecture_turnaround/README.md +++ b/docs/ARCH/11 - architecture_turnaround/README.md @@ -59,7 +59,8 @@ Status canon for planning: - The current completed breadth slice is `Selected-Item Profitability Route Bridge`: selected-object inventory profitability now has a bounded exact route over purchase/sale document rows and reports gross spread/margin proxy without claiming company net profit. - The current completed breadth slice is `Business Overview Contract-Date Debt Age Signal Bridge`: explicit-period open-settlement quality can include contract-date age as a bounded signal, while due-date aging/overdue debt still waits for reviewed payment-term evidence. - The current completed breadth slice is `Business Overview Analyst Synthesis Layer`: broad company-analysis answers now synthesize checked fact families into operating scale, customer concentration, risk contours, and a concise bounded LLM-audit. -- The next active breadth slice continues breadth into company-wide profit/margin, real due-date debt aging, inventory-liquidity/turnover, and broader unfamiliar 1C route families without relaxing truth boundaries. +- The current completed breadth slice is `Business Overview Trading Margin Proxy Bridge`: explicit-period company analysis can now include товарный sales-vs-purchase document proxy for revenue, purchase-cost trace, gross spread, and margin proxy, while clean profit/accounting финрезультат remains unclaimed. +- The next active breadth slice continues breadth into exact company-wide accounting profit/margin, real due-date debt aging, inventory-liquidity/turnover, 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). It now documents a turnaround that is already operational in code, already materially past the acute regression breakpoint, and already moved through bounded MCP autonomy, Post-F hardening, inventory breadth proof, and the declared Planner Autonomy slice: @@ -124,11 +125,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: `~73%`, 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, and analyst synthesis added to business-overview answer drafting; company-wide profit/margin, true due-date debt aging/overdue, and real inventory-liquidity expansion are still pending +- active Open-World Bounded Autonomy Breadth progress: `~77%`, 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, analyst synthesis added to business-overview answer drafting, and company-period trading margin proxy bridged locally; exact accounting profit/margin, true due-date debt aging/overdue, and real inventory-liquidity expansion 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: `6023 nodes`, `13112 edges`, `136 communities` +- graph snapshot after latest rebuild: `6028 nodes`, `13131 edges`, `137 communities` - current regression-gate breakpoint: - the validated hot paths are no longer structurally broken; - flagship continuity collapse is no longer the primary risk; diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 6c24cc0..5e06546 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -833,6 +833,17 @@ const BASE_RECIPES = [ account_scope_mode: "strict", query_template: "inventory_sale_trace_profile" }, + { + recipe_id: "address_inventory_trading_margin_proxy_for_organization_v1", + intent: "inventory_trading_margin_proxy_for_organization", + purpose: "Trace organization-period purchase and sale document rows and derive bounded trading revenue, purchase-cost proxy, spread, and margin proxy", + required_filters: [], + optional_filters: ["period_from", "period_to", "organization", "warehouse", "limit", "sort"], + default_limit: 600, + account_scope: ["41.01"], + account_scope_mode: "strict", + query_template: "inventory_trading_margin_proxy_profile" + }, { recipe_id: "address_inventory_purchase_to_sale_chain_v1", intent: "inventory_purchase_to_sale_chain", @@ -1315,6 +1326,7 @@ function maxLimitForIntent(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_trading_margin_proxy_for_organization" || intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" || @@ -1498,31 +1510,15 @@ function buildAddressRecipePlan(recipe, filters) { ? buildInventorySaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_profitability_profile" ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) - : recipe.query_template === "inventory_purchase_to_sale_chain_profile" + : recipe.query_template === "inventory_trading_margin_proxy_profile" ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) - : recipe.query_template === "inventory_aging_by_purchase_date_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") - : recipe.query_template === "contracts_by_counterparty_profile" - ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) - : recipe.query_template === "open_contracts_confirmed_as_of_balance_profile" - ? (() => { - const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 - ? toDateTimeExpr(filters.as_of_date, true) - : null) ?? - (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 - ? toDateTimeExpr(filters.period_to, true) - : null) ?? - (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 - ? toDateTimeExpr(filters.period_from, true) - : null) ?? - "ТЕКУЩАЯДАТА()"; - return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE - .replaceAll("__LIMIT__", String(resolvedLimit)) - .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"])) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); - })() - : recipe.query_template === "payables_confirmed_as_of_balance_profile" + : recipe.query_template === "inventory_purchase_to_sale_chain_profile" + ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) + : recipe.query_template === "inventory_aging_by_purchase_date_profile" + ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") + : recipe.query_template === "contracts_by_counterparty_profile" + ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) + : recipe.query_template === "open_contracts_confirmed_as_of_balance_profile" ? (() => { const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 ? toDateTimeExpr(filters.as_of_date, true) @@ -1534,13 +1530,13 @@ function buildAddressRecipePlan(recipe, filters) { ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) + .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() - : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + : recipe.query_template === "payables_confirmed_as_of_balance_profile" ? (() => { const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 ? toDateTimeExpr(filters.as_of_date, true) @@ -1552,23 +1548,41 @@ function buildAddressRecipePlan(recipe, filters) { ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() - : MOVEMENTS_QUERY_TEMPLATE - .replace("__LIMIT__", String(resolvedLimit)) - .replace("__WHERE_CLAUSE__", (() => { - const extraConditions = []; - const accountCondition = buildMovementAccountCondition(filters); - if (accountCondition) { - extraConditions.push(accountCondition); - } - return buildWhereClause(filters, "Движения.Период", extraConditions); - })()) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : MOVEMENTS_QUERY_TEMPLATE + .replace("__LIMIT__", String(resolvedLimit)) + .replace("__WHERE_CLAUSE__", (() => { + const extraConditions = []; + const accountCondition = buildMovementAccountCondition(filters); + if (accountCondition) { + extraConditions.push(accountCondition); + } + return buildWhereClause(filters, "Движения.Период", extraConditions); + })()) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); return { recipe, query, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 97729ac..99b0b0b 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -349,6 +349,9 @@ function headlineFor(mode, pilot) { if (overview.tax_position) { families.push("НДС-позиция"); } + if (overview.trading_margin_proxy) { + families.push("торговый margin proxy"); + } if (overview.debt_position) { families.push("долговой срез на дату"); } @@ -361,7 +364,7 @@ function headlineFor(mode, pilot) { if (overview.inventory_position) { families.push("складской срез на дату"); } - const unknownFamilies = ["прибыль/маржа"]; + const unknownFamilies = [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"]; if (!overview.tax_position) { unknownFamilies.push("НДС"); } @@ -547,6 +550,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 trading-margin proxy as clean profit, accounting financial result, or exact cost-of-sales margin."); claims.push("Do not claim debt quality, VAT position, inventory health, or company health unless those contours were separately checked."); claims.push("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis."); claims.push("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt."); @@ -865,6 +869,11 @@ function derivedBusinessOverviewConfirmedLines(pilot) { : "сбалансирован"; lines.push(`НДС-позиция за ${overview.tax_position.period_scope}: книга продаж ${overview.tax_position.sales_vat_amount_human_ru}, книга покупок/вычеты ${overview.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${overview.tax_position.net_vat_amount_human_ru}.`); } + if (overview.trading_margin_proxy) { + const proxy = overview.trading_margin_proxy; + const marginText = proxy.margin_to_revenue_pct === null ? "не рассчитана" : `${proxy.margin_to_revenue_pct}%`; + lines.push(`Торговый margin proxy за ${proxy.period_scope}: выручка продаж ${proxy.sales_revenue_human_ru}, закупочный документный след ${proxy.purchase_cost_proxy_human_ru}, валовый спред proxy ${proxy.gross_spread_proxy_human_ru}, маржинальность к выручке ${marginText}. Это не чистая прибыль и не бухгалтерский финрезультат.`); + } if (overview.debt_position) { const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable" ? "в пользу дебиторки" @@ -934,6 +943,12 @@ function businessOverviewRiskSynthesisLine(overview) { : "НДС-позиция сбалансирована"; signals.push(taxDirection); } + if (overview.trading_margin_proxy) { + const marginText = overview.trading_margin_proxy.margin_to_revenue_pct === null + ? "маржинальность не рассчитана" + : `маржинальность proxy ${overview.trading_margin_proxy.margin_to_revenue_pct}%`; + signals.push(`торговый спред proxy ${overview.trading_margin_proxy.gross_spread_proxy_human_ru}, ${marginText}`); + } if (overview.debt_position) { const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable" ? `дебиторка больше кредиторки на ${overview.debt_position.net_debt_position_amount_human_ru}` @@ -962,6 +977,7 @@ function businessOverviewRiskSynthesisLine(overview) { function businessOverviewExecutiveVerdictLine(overview) { const hasCash = overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0; const hasExtraSignals = Boolean(overview.tax_position || + overview.trading_margin_proxy || overview.debt_position || overview.debt_open_settlement_quality || overview.inventory_position); @@ -1038,6 +1054,9 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { if (pilot.derived_business_overview?.tax_position) { pushReason(reasonCodes, "answer_contains_business_overview_tax_position"); } + if (pilot.derived_business_overview?.trading_margin_proxy) { + pushReason(reasonCodes, "answer_contains_business_overview_trading_margin_proxy"); + } 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 8bd4446..c5fcc20 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -205,6 +205,21 @@ function buildBusinessOverviewInventoryFilters(planner) { sort: "period_asc" }; } +function buildBusinessOverviewTradingMarginFilters(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + const periodFilters = dateScopeToFilters(dateScope); + if (!periodFilters.period_from || !periodFilters.period_to) { + return null; + } + return { + ...periodFilters, + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} function buildInventoryExactFilters(planner) { const meaning = planner.discovery_plan.turn_meaning_ref; const subject = firstEntityCandidate(planner); @@ -1454,6 +1469,16 @@ function rowDocumentValue(row) { function rowAccountValue(row) { return rowTextValue(row, ["СчетДт", "AccountDt", "account_dt", "Счет", "Account", "account"]); } +function rowDebitAccountValue(row) { + return rowTextValue(row, ["СчетДт", "AccountDt", "account_dt", "DebitAccount", "debit_account"]); +} +function rowCreditAccountValue(row) { + return rowTextValue(row, ["СчетКт", "AccountKt", "account_kt", "CreditAccount", "credit_account"]); +} +function accountTextMatchesPrefix(account, prefixes) { + const normalized = String(account ?? "").replace(/\s+/g, ""); + return prefixes.some((prefix) => normalized.startsWith(prefix)); +} function rowQuantityValue(row) { return rowNumberValue(row, ["Количество", "Quantity", "quantity", "Qty", "qty", "Остаток", "Balance", "balance"]); } @@ -1961,6 +1986,87 @@ function deriveBusinessOverviewTaxPosition(result, periodScope) { inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows" }; } +function deriveBusinessOverviewTradingMarginProxy(result, periodScope) { + if (!result || result.error || result.matched_rows <= 0 || !periodScope) { + return null; + } + const itemBuckets = new Map(); + let salesRowsWithAmount = 0; + let purchaseRowsWithAmount = 0; + let salesRevenue = 0; + let purchaseCostProxy = 0; + for (const row of result.rows) { + const amount = rowAmountValue(row); + if (amount === null || amount <= 0) { + continue; + } + const isSale = accountTextMatchesPrefix(rowCreditAccountValue(row), ["41.01"]); + const isPurchase = accountTextMatchesPrefix(rowDebitAccountValue(row), ["41.01"]); + if (!isSale && !isPurchase) { + continue; + } + const item = rowInventoryItemValue(row) ?? "unknown_item"; + const quantity = rowQuantityValue(row) ?? 0; + const bucket = itemBuckets.get(item) ?? { + sales_revenue: 0, + purchase_cost_proxy: 0, + sales_quantity: 0, + purchase_quantity: 0 + }; + if (isSale) { + salesRowsWithAmount += 1; + salesRevenue += amount; + bucket.sales_revenue += amount; + bucket.sales_quantity += quantity; + } + if (isPurchase) { + purchaseRowsWithAmount += 1; + purchaseCostProxy += amount; + bucket.purchase_cost_proxy += amount; + bucket.purchase_quantity += quantity; + } + itemBuckets.set(item, bucket); + } + if (salesRowsWithAmount <= 0 && purchaseRowsWithAmount <= 0) { + return null; + } + const grossSpreadProxy = salesRevenue - purchaseCostProxy; + const marginToRevenuePct = salesRevenue > 0 ? percentageOfTotal(grossSpreadProxy, salesRevenue) : null; + const markupToPurchasePct = purchaseCostProxy > 0 ? percentageOfTotal(grossSpreadProxy, purchaseCostProxy) : null; + const topItemsBySales = Array.from(itemBuckets.entries()) + .map(([item, bucket]) => ({ + item, + sales_revenue: bucket.sales_revenue, + sales_revenue_human_ru: formatAmountHumanRu(bucket.sales_revenue), + purchase_cost_proxy: bucket.purchase_cost_proxy, + purchase_cost_proxy_human_ru: formatAmountHumanRu(bucket.purchase_cost_proxy), + gross_spread_proxy: bucket.sales_revenue - bucket.purchase_cost_proxy, + gross_spread_proxy_human_ru: formatAmountHumanRu(bucket.sales_revenue - bucket.purchase_cost_proxy), + sales_quantity: bucket.sales_quantity, + purchase_quantity: bucket.purchase_quantity + })) + .sort((left, right) => { + const salesDelta = right.sales_revenue - left.sales_revenue; + return salesDelta !== 0 ? salesDelta : left.item.localeCompare(right.item, "ru"); + }) + .slice(0, 5); + return { + period_scope: periodScope, + rows_matched: result.matched_rows, + sales_rows_with_amount: salesRowsWithAmount, + purchase_rows_with_amount: purchaseRowsWithAmount, + sales_revenue: salesRevenue, + sales_revenue_human_ru: formatAmountHumanRu(salesRevenue), + purchase_cost_proxy: purchaseCostProxy, + purchase_cost_proxy_human_ru: formatAmountHumanRu(purchaseCostProxy), + gross_spread_proxy: grossSpreadProxy, + gross_spread_proxy_human_ru: formatAmountHumanRu(grossSpreadProxy), + margin_to_revenue_pct: marginToRevenuePct, + markup_to_purchase_pct: markupToPurchasePct, + top_items_by_sales: topItemsBySales, + inference_basis: "sales_documents_minus_purchase_documents_confirmed_1c_rows" + }; +} function deriveBusinessOverviewDebtSide(result) { if (!result || result.error || result.matched_rows <= 0) { return { @@ -2303,6 +2409,7 @@ function deriveBusinessOverview(input) { }); const activityPeriod = deriveActivityPeriod(input.lifecycleResult); const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope); + const tradingMarginProxy = deriveBusinessOverviewTradingMarginProxy(input.tradingMarginResult, input.periodScope); const debtPosition = deriveBusinessOverviewDebtPosition({ receivablesResult: input.receivablesResult, payablesResult: input.payablesResult, @@ -2322,6 +2429,7 @@ function deriveBusinessOverview(input) { outgoing.rows_with_amount > 0, Boolean(activityPeriod), Boolean(taxPosition), + Boolean(tradingMarginProxy), Boolean(debtPosition), Boolean(debtOpenSettlementQuality), Boolean(inventoryPosition) @@ -2341,13 +2449,14 @@ function deriveBusinessOverview(input) { top_customers: rankedIncoming?.ranked_values ?? [], activity_period: activityPeriod, tax_position: taxPosition, + trading_margin_proxy: tradingMarginProxy, debt_position: debtPosition, debt_open_settlement_quality: debtOpenSettlementQuality, inventory_position: inventoryPosition, coverage_limited_by_probe_limit: incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, checked_signal_count: checkedSignalCount, missing_signal_families: [ - "profit_margin", + tradingMarginProxy ? "accounting_profit_margin" : "profit_margin", debtPosition ? null : "debt_position", debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality", taxPosition ? null : "tax_position", @@ -2381,6 +2490,9 @@ function summarizeBusinessOverviewRows(input) { if (input.taxResult && !input.taxResult.error) { parts.push(`${input.taxResult.fetched_rows} VAT/tax rows fetched, ${input.taxResult.matched_rows} matched`); } + if (input.tradingMarginResult && !input.tradingMarginResult.error) { + parts.push(`${input.tradingMarginResult.fetched_rows} trading-margin document rows fetched, ${input.tradingMarginResult.matched_rows} matched`); + } if (input.receivablesResult && !input.receivablesResult.error) { parts.push(`${input.receivablesResult.fetched_rows} receivables rows fetched, ${input.receivablesResult.matched_rows} matched`); } @@ -2426,6 +2538,11 @@ function buildBusinessOverviewConfirmedFacts(derived) { : "сбалансирован"; facts.push(`НДС-позиция за ${derived.tax_position.period_scope} подтверждена по книгам продаж/покупок: продажи ${derived.tax_position.sales_vat_amount_human_ru}, покупки/вычеты ${derived.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${derived.tax_position.net_vat_amount_human_ru}.`); } + if (derived.trading_margin_proxy) { + const proxy = derived.trading_margin_proxy; + const marginText = proxy.margin_to_revenue_pct === null ? "не рассчитана" : `${proxy.margin_to_revenue_pct}%`; + facts.push(`Торговый margin proxy за ${proxy.period_scope} подтвержден по товарным документам продаж/поступлений: выручка продаж ${proxy.sales_revenue_human_ru}, закупочный документный след ${proxy.purchase_cost_proxy_human_ru}, валовый спред proxy ${proxy.gross_spread_proxy_human_ru}, маржинальность к выручке ${marginText}. Это не чистая прибыль и не бухгалтерский финрезультат.`); + } if (derived.debt_position) { const debtDirection = derived.debt_position.net_debt_position_direction === "net_receivable" ? "в пользу дебиторки" @@ -2495,6 +2612,9 @@ function buildBusinessOverviewUnknownFacts(derived) { missing.has("profit_margin") ? "Прибыль и маржа этим бизнес-обзором не подтверждены: нужны себестоимость, расходы и закрывающие документы." : null, + missing.has("accounting_profit_margin") + ? "Чистая прибыль, бухгалтерская маржа и финрезультат этим бизнес-обзором не подтверждены: торговый proxy показывает только документную разницу продаж и поступлений без расходов, закрытия периода и точной себестоимости продаж." + : null, missing.has("debt_quality") ? "Качество дебиторки/кредиторки этим бизнес-обзором не подтверждено: нужен отдельный долговой срез." : null, @@ -3123,6 +3243,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { let outgoingResult = null; let lifecycleResult = null; let taxResult = null; + let tradingMarginResult = null; let receivablesResult = null; let payablesResult = null; let openContractsResult = null; @@ -3131,6 +3252,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const valueFilters = buildValueFlowFilters(planner); const lifecycleFilters = buildLifecycleFilters(planner); const taxFilters = buildBusinessOverviewTaxFilters(planner); + const tradingMarginFilters = buildBusinessOverviewTradingMarginFilters(planner); const debtFilters = buildBusinessOverviewDebtFilters(planner); const inventoryFilters = buildBusinessOverviewInventoryFilters(planner); const debtAsOfDate = toNonEmptyString(debtFilters?.as_of_date); @@ -3141,6 +3263,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const taxSelection = taxFilters ? (0, addressRecipeCatalog_1.selectAddressRecipe)("vat_liability_confirmed_for_tax_period", taxFilters) : null; + const tradingMarginSelection = tradingMarginFilters + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("inventory_trading_margin_proxy_for_organization", tradingMarginFilters) + : null; const receivablesSelection = debtFilters ? (0, addressRecipeCatalog_1.selectAddressRecipe)("receivables_confirmed_as_of_date", debtFilters) : null; @@ -3196,6 +3321,16 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { pushReason(reasonCodes, "pilot_business_overview_tax_recipe_not_available"); pushUnique(queryLimitations, "Business overview VAT/tax probe requires an executable tax-period recipe"); } + if (tradingMarginSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_trading_margin_recipe_selected"); + } + else if (!tradingMarginFilters) { + pushReason(reasonCodes, "pilot_business_overview_trading_margin_probe_skipped_without_explicit_period"); + } + else { + pushReason(reasonCodes, "pilot_business_overview_trading_margin_recipe_not_available"); + pushUnique(queryLimitations, "Business overview trading-margin proxy requires an executable explicit-period purchase/sale document recipe"); + } if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) { pushReason(reasonCodes, "pilot_business_overview_debt_recipes_selected"); } @@ -3390,6 +3525,15 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { }); pushUnique(executedPrimitives, step.primitive_id); probeResults.push(queryResultToProbeResult(step.primitive_id, lifecycleResult)); + if (tradingMarginSelection?.selected_recipe) { + const tradingMarginPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(tradingMarginSelection.selected_recipe, tradingMarginFilters); + tradingMarginResult = await runtimeDeps.executeAddressMcpQuery({ + query: tradingMarginPlan.query, + limit: tradingMarginPlan.limit, + account_scope: tradingMarginPlan.account_scope + }); + probeResults.push(queryResultToProbeResult(step.primitive_id, tradingMarginResult)); + } if (lifecycleResult.error) { pushUnique(queryLimitations, lifecycleResult.error); pushReason(reasonCodes, "pilot_business_overview_query_documents_mcp_error"); @@ -3397,6 +3541,13 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { else { pushReason(reasonCodes, "pilot_business_overview_query_documents_mcp_executed"); } + if (tradingMarginResult?.error) { + pushUnique(queryLimitations, tradingMarginResult.error); + pushReason(reasonCodes, "pilot_business_overview_trading_margin_query_mcp_error"); + } + else if (tradingMarginResult) { + pushReason(reasonCodes, "pilot_business_overview_trading_margin_query_mcp_executed"); + } continue; } skippedPrimitives.push(step.primitive_id); @@ -3407,6 +3558,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { outgoingResult, lifecycleResult, taxResult, + tradingMarginResult, receivablesResult, payablesResult, openContractsResult, @@ -3428,6 +3580,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { if (derivedBusinessOverview.tax_position) { pushReason(reasonCodes, "pilot_derived_business_overview_tax_position_from_confirmed_rows"); } + if (derivedBusinessOverview.trading_margin_proxy) { + pushReason(reasonCodes, "pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows"); + } if (derivedBusinessOverview.debt_position) { pushReason(reasonCodes, "pilot_derived_business_overview_debt_position_from_confirmed_rows"); } @@ -3446,6 +3601,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { outgoingResult, lifecycleResult, taxResult, + tradingMarginResult, receivablesResult, payablesResult, openContractsResult, diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index b9d1bbd..b4655c5 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -857,6 +857,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "strict", query_template: "inventory_sale_trace_profile" }, + { + recipe_id: "address_inventory_trading_margin_proxy_for_organization_v1", + intent: "inventory_trading_margin_proxy_for_organization", + purpose: "Trace organization-period purchase and sale document rows and derive bounded trading revenue, purchase-cost proxy, spread, and margin proxy", + required_filters: [], + optional_filters: ["period_from", "period_to", "organization", "warehouse", "limit", "sort"], + default_limit: 600, + account_scope: ["41.01"], + account_scope_mode: "strict", + query_template: "inventory_trading_margin_proxy_profile" + }, { recipe_id: "address_inventory_purchase_to_sale_chain_v1", intent: "inventory_purchase_to_sale_chain", @@ -1438,6 +1449,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_trading_margin_proxy_for_organization" || intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" || @@ -1666,6 +1678,8 @@ export function buildAddressRecipePlan( ? buildInventorySaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_profitability_profile" ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) + : recipe.query_template === "inventory_trading_margin_proxy_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" diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 0ff8aec..7c0b68e 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -452,6 +452,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (overview.tax_position) { families.push("НДС-позиция"); } + if (overview.trading_margin_proxy) { + families.push("торговый margin proxy"); + } if (overview.debt_position) { families.push("долговой срез на дату"); } @@ -464,7 +467,7 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (overview.inventory_position) { families.push("складской срез на дату"); } - const unknownFamilies = ["прибыль/маржа"]; + const unknownFamilies = [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"]; if (!overview.tax_position) { unknownFamilies.push("НДС"); } @@ -658,6 +661,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 trading-margin proxy as clean profit, accounting financial result, or exact cost-of-sales margin."); claims.push("Do not claim debt quality, VAT position, inventory health, or company health unless those contours were separately checked."); claims.push("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis."); claims.push("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt."); @@ -1022,6 +1026,13 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot `НДС-позиция за ${overview.tax_position.period_scope}: книга продаж ${overview.tax_position.sales_vat_amount_human_ru}, книга покупок/вычеты ${overview.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${overview.tax_position.net_vat_amount_human_ru}.` ); } + if (overview.trading_margin_proxy) { + const proxy = overview.trading_margin_proxy; + const marginText = proxy.margin_to_revenue_pct === null ? "не рассчитана" : `${proxy.margin_to_revenue_pct}%`; + lines.push( + `Торговый margin proxy за ${proxy.period_scope}: выручка продаж ${proxy.sales_revenue_human_ru}, закупочный документный след ${proxy.purchase_cost_proxy_human_ru}, валовый спред proxy ${proxy.gross_spread_proxy_human_ru}, маржинальность к выручке ${marginText}. Это не чистая прибыль и не бухгалтерский финрезультат.` + ); + } if (overview.debt_position) { const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable" @@ -1106,6 +1117,12 @@ function businessOverviewRiskSynthesisLine(overview: BusinessOverview): string | : "НДС-позиция сбалансирована"; signals.push(taxDirection); } + if (overview.trading_margin_proxy) { + const marginText = overview.trading_margin_proxy.margin_to_revenue_pct === null + ? "маржинальность не рассчитана" + : `маржинальность proxy ${overview.trading_margin_proxy.margin_to_revenue_pct}%`; + signals.push(`торговый спред proxy ${overview.trading_margin_proxy.gross_spread_proxy_human_ru}, ${marginText}`); + } if (overview.debt_position) { const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable" @@ -1137,6 +1154,7 @@ function businessOverviewExecutiveVerdictLine(overview: BusinessOverview): strin const hasCash = overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0; const hasExtraSignals = Boolean( overview.tax_position || + overview.trading_margin_proxy || overview.debt_position || overview.debt_open_settlement_quality || overview.inventory_position @@ -1223,6 +1241,9 @@ export function buildAssistantMcpDiscoveryAnswerDraft( if (pilot.derived_business_overview?.tax_position) { pushReason(reasonCodes, "answer_contains_business_overview_tax_position"); } + if (pilot.derived_business_overview?.trading_margin_proxy) { + pushReason(reasonCodes, "answer_contains_business_overview_trading_margin_proxy"); + } 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 1768d6d..44d947f 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -152,6 +152,7 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverview { top_customers: AssistantMcpDiscoveryRankedValueFlowBucket[]; activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null; tax_position: AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition | null; + trading_margin_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewTradingMarginProxy | null; debt_position: AssistantMcpDiscoveryDerivedBusinessOverviewDebtPosition | null; debt_open_settlement_quality: AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality | null; inventory_position: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition | null; @@ -180,6 +181,35 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition { inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows"; } +export interface AssistantMcpDiscoveryBusinessOverviewTradingItemBucket { + item: string; + sales_revenue: number; + sales_revenue_human_ru: string; + purchase_cost_proxy: number; + purchase_cost_proxy_human_ru: string; + gross_spread_proxy: number; + gross_spread_proxy_human_ru: string; + sales_quantity: number; + purchase_quantity: number; +} + +export interface AssistantMcpDiscoveryDerivedBusinessOverviewTradingMarginProxy { + period_scope: string; + rows_matched: number; + sales_rows_with_amount: number; + purchase_rows_with_amount: number; + sales_revenue: number; + sales_revenue_human_ru: string; + purchase_cost_proxy: number; + purchase_cost_proxy_human_ru: string; + gross_spread_proxy: number; + gross_spread_proxy_human_ru: string; + margin_to_revenue_pct: number | null; + markup_to_purchase_pct: number | null; + top_items_by_sales: AssistantMcpDiscoveryBusinessOverviewTradingItemBucket[]; + inference_basis: "sales_documents_minus_purchase_documents_confirmed_1c_rows"; +} + export interface AssistantMcpDiscoveryBusinessOverviewDebtSideSummary { rows_matched: number; rows_with_amount: number; @@ -578,6 +608,22 @@ function buildBusinessOverviewInventoryFilters(planner: AssistantMcpDiscoveryPla }; } +function buildBusinessOverviewTradingMarginFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet | null { + const meaning = planner.discovery_plan.turn_meaning_ref; + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + const periodFilters = dateScopeToFilters(dateScope); + if (!periodFilters.period_from || !periodFilters.period_to) { + return null; + } + return { + ...periodFilters, + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} + function buildInventoryExactFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet { const meaning = planner.discovery_plan.turn_meaning_ref; const subject = firstEntityCandidate(planner); @@ -2087,6 +2133,19 @@ function rowAccountValue(row: Record): string | null { return rowTextValue(row, ["СчетДт", "AccountDt", "account_dt", "Счет", "Account", "account"]); } +function rowDebitAccountValue(row: Record): string | null { + return rowTextValue(row, ["СчетДт", "AccountDt", "account_dt", "DebitAccount", "debit_account"]); +} + +function rowCreditAccountValue(row: Record): string | null { + return rowTextValue(row, ["СчетКт", "AccountKt", "account_kt", "CreditAccount", "credit_account"]); +} + +function accountTextMatchesPrefix(account: string | null, prefixes: string[]): boolean { + const normalized = String(account ?? "").replace(/\s+/g, ""); + return prefixes.some((prefix) => normalized.startsWith(prefix)); +} + function rowQuantityValue(row: Record): number | null { return rowNumberValue(row, ["Количество", "Quantity", "quantity", "Qty", "qty", "Остаток", "Balance", "balance"]); } @@ -2673,6 +2732,101 @@ function deriveBusinessOverviewTaxPosition( }; } +function deriveBusinessOverviewTradingMarginProxy( + result: AddressMcpQueryExecutorResult | null, + periodScope: string | null +): AssistantMcpDiscoveryDerivedBusinessOverviewTradingMarginProxy | null { + if (!result || result.error || result.matched_rows <= 0 || !periodScope) { + return null; + } + + const itemBuckets = new Map(); + let salesRowsWithAmount = 0; + let purchaseRowsWithAmount = 0; + let salesRevenue = 0; + let purchaseCostProxy = 0; + + for (const row of result.rows) { + const amount = rowAmountValue(row); + if (amount === null || amount <= 0) { + continue; + } + const isSale = accountTextMatchesPrefix(rowCreditAccountValue(row), ["41.01"]); + const isPurchase = accountTextMatchesPrefix(rowDebitAccountValue(row), ["41.01"]); + if (!isSale && !isPurchase) { + continue; + } + const item = rowInventoryItemValue(row) ?? "unknown_item"; + const quantity = rowQuantityValue(row) ?? 0; + const bucket = itemBuckets.get(item) ?? { + sales_revenue: 0, + purchase_cost_proxy: 0, + sales_quantity: 0, + purchase_quantity: 0 + }; + if (isSale) { + salesRowsWithAmount += 1; + salesRevenue += amount; + bucket.sales_revenue += amount; + bucket.sales_quantity += quantity; + } + if (isPurchase) { + purchaseRowsWithAmount += 1; + purchaseCostProxy += amount; + bucket.purchase_cost_proxy += amount; + bucket.purchase_quantity += quantity; + } + itemBuckets.set(item, bucket); + } + + if (salesRowsWithAmount <= 0 && purchaseRowsWithAmount <= 0) { + return null; + } + + const grossSpreadProxy = salesRevenue - purchaseCostProxy; + const marginToRevenuePct = salesRevenue > 0 ? percentageOfTotal(grossSpreadProxy, salesRevenue) : null; + const markupToPurchasePct = purchaseCostProxy > 0 ? percentageOfTotal(grossSpreadProxy, purchaseCostProxy) : null; + const topItemsBySales = Array.from(itemBuckets.entries()) + .map(([item, bucket]) => ({ + item, + sales_revenue: bucket.sales_revenue, + sales_revenue_human_ru: formatAmountHumanRu(bucket.sales_revenue), + purchase_cost_proxy: bucket.purchase_cost_proxy, + purchase_cost_proxy_human_ru: formatAmountHumanRu(bucket.purchase_cost_proxy), + gross_spread_proxy: bucket.sales_revenue - bucket.purchase_cost_proxy, + gross_spread_proxy_human_ru: formatAmountHumanRu(bucket.sales_revenue - bucket.purchase_cost_proxy), + sales_quantity: bucket.sales_quantity, + purchase_quantity: bucket.purchase_quantity + })) + .sort((left, right) => { + const salesDelta = right.sales_revenue - left.sales_revenue; + return salesDelta !== 0 ? salesDelta : left.item.localeCompare(right.item, "ru"); + }) + .slice(0, 5); + + return { + period_scope: periodScope, + rows_matched: result.matched_rows, + sales_rows_with_amount: salesRowsWithAmount, + purchase_rows_with_amount: purchaseRowsWithAmount, + sales_revenue: salesRevenue, + sales_revenue_human_ru: formatAmountHumanRu(salesRevenue), + purchase_cost_proxy: purchaseCostProxy, + purchase_cost_proxy_human_ru: formatAmountHumanRu(purchaseCostProxy), + gross_spread_proxy: grossSpreadProxy, + gross_spread_proxy_human_ru: formatAmountHumanRu(grossSpreadProxy), + margin_to_revenue_pct: marginToRevenuePct, + markup_to_purchase_pct: markupToPurchasePct, + top_items_by_sales: topItemsBySales, + inference_basis: "sales_documents_minus_purchase_documents_confirmed_1c_rows" + }; +} + function deriveBusinessOverviewDebtSide( result: AddressMcpQueryExecutorResult | null ): AssistantMcpDiscoveryBusinessOverviewDebtSideSummary { @@ -3059,6 +3213,7 @@ function deriveBusinessOverview(input: { outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; lifecycleResult: AddressMcpQueryExecutorResult | null; taxResult: AddressMcpQueryExecutorResult | null; + tradingMarginResult: AddressMcpQueryExecutorResult | null; receivablesResult: AddressMcpQueryExecutorResult | null; payablesResult: AddressMcpQueryExecutorResult | null; openContractsResult: AddressMcpQueryExecutorResult | null; @@ -3079,6 +3234,7 @@ function deriveBusinessOverview(input: { }); const activityPeriod = deriveActivityPeriod(input.lifecycleResult); const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope); + const tradingMarginProxy = deriveBusinessOverviewTradingMarginProxy(input.tradingMarginResult, input.periodScope); const debtPosition = deriveBusinessOverviewDebtPosition({ receivablesResult: input.receivablesResult, payablesResult: input.payablesResult, @@ -3098,6 +3254,7 @@ function deriveBusinessOverview(input: { outgoing.rows_with_amount > 0, Boolean(activityPeriod), Boolean(taxPosition), + Boolean(tradingMarginProxy), Boolean(debtPosition), Boolean(debtOpenSettlementQuality), Boolean(inventoryPosition) @@ -3118,6 +3275,7 @@ function deriveBusinessOverview(input: { top_customers: rankedIncoming?.ranked_values ?? [], activity_period: activityPeriod, tax_position: taxPosition, + trading_margin_proxy: tradingMarginProxy, debt_position: debtPosition, debt_open_settlement_quality: debtOpenSettlementQuality, inventory_position: inventoryPosition, @@ -3125,7 +3283,7 @@ function deriveBusinessOverview(input: { incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, checked_signal_count: checkedSignalCount, missing_signal_families: [ - "profit_margin", + tradingMarginProxy ? "accounting_profit_margin" : "profit_margin", debtPosition ? null : "debt_position", debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality", taxPosition ? null : "tax_position", @@ -3152,6 +3310,7 @@ function summarizeBusinessOverviewRows(input: { outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; lifecycleResult: AddressMcpQueryExecutorResult | null; taxResult: AddressMcpQueryExecutorResult | null; + tradingMarginResult: AddressMcpQueryExecutorResult | null; receivablesResult: AddressMcpQueryExecutorResult | null; payablesResult: AddressMcpQueryExecutorResult | null; openContractsResult: AddressMcpQueryExecutorResult | null; @@ -3171,6 +3330,9 @@ function summarizeBusinessOverviewRows(input: { if (input.taxResult && !input.taxResult.error) { parts.push(`${input.taxResult.fetched_rows} VAT/tax rows fetched, ${input.taxResult.matched_rows} matched`); } + if (input.tradingMarginResult && !input.tradingMarginResult.error) { + parts.push(`${input.tradingMarginResult.fetched_rows} trading-margin document rows fetched, ${input.tradingMarginResult.matched_rows} matched`); + } if (input.receivablesResult && !input.receivablesResult.error) { parts.push(`${input.receivablesResult.fetched_rows} receivables rows fetched, ${input.receivablesResult.matched_rows} matched`); } @@ -3228,6 +3390,13 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv `НДС-позиция за ${derived.tax_position.period_scope} подтверждена по книгам продаж/покупок: продажи ${derived.tax_position.sales_vat_amount_human_ru}, покупки/вычеты ${derived.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${derived.tax_position.net_vat_amount_human_ru}.` ); } + if (derived.trading_margin_proxy) { + const proxy = derived.trading_margin_proxy; + const marginText = proxy.margin_to_revenue_pct === null ? "не рассчитана" : `${proxy.margin_to_revenue_pct}%`; + facts.push( + `Торговый margin proxy за ${proxy.period_scope} подтвержден по товарным документам продаж/поступлений: выручка продаж ${proxy.sales_revenue_human_ru}, закупочный документный след ${proxy.purchase_cost_proxy_human_ru}, валовый спред proxy ${proxy.gross_spread_proxy_human_ru}, маржинальность к выручке ${marginText}. Это не чистая прибыль и не бухгалтерский финрезультат.` + ); + } if (derived.debt_position) { const debtDirection = derived.debt_position.net_debt_position_direction === "net_receivable" @@ -3313,6 +3482,9 @@ function buildBusinessOverviewUnknownFacts(derived: AssistantMcpDiscoveryDerived missing.has("profit_margin") ? "Прибыль и маржа этим бизнес-обзором не подтверждены: нужны себестоимость, расходы и закрывающие документы." : null, + missing.has("accounting_profit_margin") + ? "Чистая прибыль, бухгалтерская маржа и финрезультат этим бизнес-обзором не подтверждены: торговый proxy показывает только документную разницу продаж и поступлений без расходов, закрытия периода и точной себестоимости продаж." + : null, missing.has("debt_quality") ? "Качество дебиторки/кредиторки этим бизнес-обзором не подтверждено: нужен отдельный долговой срез." : null, @@ -4066,6 +4238,7 @@ export async function executeAssistantMcpDiscoveryPilot( let outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null = null; let lifecycleResult: AddressMcpQueryExecutorResult | null = null; let taxResult: AddressMcpQueryExecutorResult | null = null; + let tradingMarginResult: AddressMcpQueryExecutorResult | null = null; let receivablesResult: AddressMcpQueryExecutorResult | null = null; let payablesResult: AddressMcpQueryExecutorResult | null = null; let openContractsResult: AddressMcpQueryExecutorResult | null = null; @@ -4074,6 +4247,7 @@ export async function executeAssistantMcpDiscoveryPilot( const valueFilters = buildValueFlowFilters(planner); const lifecycleFilters = buildLifecycleFilters(planner); const taxFilters = buildBusinessOverviewTaxFilters(planner); + const tradingMarginFilters = buildBusinessOverviewTradingMarginFilters(planner); const debtFilters = buildBusinessOverviewDebtFilters(planner); const inventoryFilters = buildBusinessOverviewInventoryFilters(planner); const debtAsOfDate = toNonEmptyString(debtFilters?.as_of_date); @@ -4084,6 +4258,9 @@ export async function executeAssistantMcpDiscoveryPilot( const taxSelection = taxFilters ? selectAddressRecipe("vat_liability_confirmed_for_tax_period", taxFilters) : null; + const tradingMarginSelection = tradingMarginFilters + ? selectAddressRecipe("inventory_trading_margin_proxy_for_organization", tradingMarginFilters) + : null; const receivablesSelection = debtFilters ? selectAddressRecipe("receivables_confirmed_as_of_date", debtFilters) : null; @@ -4139,6 +4316,14 @@ export async function executeAssistantMcpDiscoveryPilot( pushReason(reasonCodes, "pilot_business_overview_tax_recipe_not_available"); pushUnique(queryLimitations, "Business overview VAT/tax probe requires an executable tax-period recipe"); } + if (tradingMarginSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_trading_margin_recipe_selected"); + } else if (!tradingMarginFilters) { + pushReason(reasonCodes, "pilot_business_overview_trading_margin_probe_skipped_without_explicit_period"); + } else { + pushReason(reasonCodes, "pilot_business_overview_trading_margin_recipe_not_available"); + pushUnique(queryLimitations, "Business overview trading-margin proxy requires an executable explicit-period purchase/sale document recipe"); + } if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) { pushReason(reasonCodes, "pilot_business_overview_debt_recipes_selected"); } else if (!debtFilters) { @@ -4326,12 +4511,27 @@ export async function executeAssistantMcpDiscoveryPilot( }); pushUnique(executedPrimitives, step.primitive_id); probeResults.push(queryResultToProbeResult(step.primitive_id, lifecycleResult)); + if (tradingMarginSelection?.selected_recipe) { + const tradingMarginPlan = buildAddressRecipePlan(tradingMarginSelection.selected_recipe, tradingMarginFilters!); + tradingMarginResult = await runtimeDeps.executeAddressMcpQuery({ + query: tradingMarginPlan.query, + limit: tradingMarginPlan.limit, + account_scope: tradingMarginPlan.account_scope + }); + probeResults.push(queryResultToProbeResult(step.primitive_id, tradingMarginResult)); + } if (lifecycleResult.error) { pushUnique(queryLimitations, lifecycleResult.error); pushReason(reasonCodes, "pilot_business_overview_query_documents_mcp_error"); } else { pushReason(reasonCodes, "pilot_business_overview_query_documents_mcp_executed"); } + if (tradingMarginResult?.error) { + pushUnique(queryLimitations, tradingMarginResult.error); + pushReason(reasonCodes, "pilot_business_overview_trading_margin_query_mcp_error"); + } else if (tradingMarginResult) { + pushReason(reasonCodes, "pilot_business_overview_trading_margin_query_mcp_executed"); + } continue; } @@ -4344,6 +4544,7 @@ export async function executeAssistantMcpDiscoveryPilot( outgoingResult, lifecycleResult, taxResult, + tradingMarginResult, receivablesResult, payablesResult, openContractsResult, @@ -4365,6 +4566,9 @@ export async function executeAssistantMcpDiscoveryPilot( if (derivedBusinessOverview.tax_position) { pushReason(reasonCodes, "pilot_derived_business_overview_tax_position_from_confirmed_rows"); } + if (derivedBusinessOverview.trading_margin_proxy) { + pushReason(reasonCodes, "pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows"); + } if (derivedBusinessOverview.debt_position) { pushReason(reasonCodes, "pilot_derived_business_overview_debt_position_from_confirmed_rows"); } @@ -4383,6 +4587,7 @@ export async function executeAssistantMcpDiscoveryPilot( outgoingResult, lifecycleResult, taxResult, + tradingMarginResult, receivablesResult, payablesResult, openContractsResult, diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index 7b55684..a9eb9bf 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -30,6 +30,7 @@ export type AddressIntent = | "inventory_purchase_documents_for_item" | "inventory_supplier_stock_overlap_as_of_date" | "inventory_sale_trace_for_item" + | "inventory_trading_margin_proxy_for_organization" | "inventory_profitability_for_item" | "inventory_purchase_to_sale_chain" | "inventory_aging_by_purchase_date" @@ -196,6 +197,7 @@ export interface AddressRecipeDefinition { | "inventory_purchase_documents_profile" | "inventory_supplier_stock_overlap_profile" | "inventory_sale_trace_profile" + | "inventory_trading_margin_proxy_profile" | "inventory_profitability_profile" | "inventory_purchase_to_sale_chain_profile" | "inventory_aging_by_purchase_date_profile"; diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 024d944..57ea632 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -294,11 +294,22 @@ describe("assistant MCP discovery answer adapter", () => { { Регистратор: "VAT_BOOK_PURCHASES", СчетДт: "19", Сумма: 12000 } ] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, { rows: [ { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, { Период: "2020-12-15T00:00:00", Регистратор: "Поступление 2" } ] + }, + { + rows: [ + { Period: "2020-03-01T00:00:00", Amount: 200000, Item: "Товар А", Counterparty: "Клиент А", СчетКт: "41.01" }, + { Period: "2020-02-01T00:00:00", Amount: 120000, Item: "Товар А", Counterparty: "Поставщик А", СчетДт: "41.01" } + ] } ]) ); @@ -306,12 +317,20 @@ describe("assistant MCP discovery answer adapter", () => { const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); expect(draft.headline).toContain("НДС-позиция"); + expect(draft.headline).toContain("торговый margin proxy"); expect(draft.confirmed_lines.join("\n")).toContain("НДС-позиция за 2020"); expect(draft.confirmed_lines.join("\n")).toContain("нетто к уплате 28 000 руб."); + expect(draft.confirmed_lines.join("\n")).toContain("Торговый margin proxy за 2020"); + expect(draft.confirmed_lines.join("\n")).toContain("валовый спред proxy 80 000 руб."); + expect(draft.confirmed_lines.join("\n")).toContain("не чистая прибыль"); + expect(draft.inference_lines.join("\n")).toContain("торговый спред proxy 80 000 руб."); expect(draft.inference_lines.join("\n")).toContain("не прибыль и не маржа"); + expect(draft.unknown_lines.join("\n")).toContain("Чистая прибыль"); expect(draft.unknown_lines.join("\n")).not.toContain("Налоговая/VAT-позиция"); expect(draft.reason_codes).toContain("answer_contains_business_overview_tax_position"); + expect(draft.reason_codes).toContain("answer_contains_business_overview_trading_margin_proxy"); 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 trading-margin proxy as clean profit, accounting financial result, or exact cost-of-sales margin."); }); it("surfaces checked debt-position and open-settlement quality without treating them as overdue debt", async () => { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index f91ac37..ecd836f 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -151,6 +151,12 @@ describe("assistant MCP discovery pilot executor", () => { { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, { Период: "2020-12-15T00:00:00", Регистратор: "Поступление 2" } ] + }, + { + rows: [ + { Period: "2020-03-01T00:00:00", Amount: 200000, Item: "Товар А", Counterparty: "Клиент А", СчетКт: "41.01" }, + { Period: "2020-02-01T00:00:00", Amount: 120000, Item: "Товар А", Counterparty: "Поставщик А", СчетДт: "41.01" } + ] } ]); @@ -251,6 +257,12 @@ describe("assistant MCP discovery pilot executor", () => { { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, { Период: "2020-12-15T00:00:00", Регистратор: "Поступление 2" } ] + }, + { + rows: [ + { Period: "2020-03-01T00:00:00", Amount: 200000, Item: "Товар А", Counterparty: "Клиент А", AccountKt: "41.01" }, + { Period: "2020-02-01T00:00:00", Amount: 120000, Item: "Товар А", Counterparty: "Поставщик А", AccountDt: "41.01" } + ] } ]); @@ -266,15 +278,96 @@ describe("assistant MCP discovery pilot executor", () => { net_vat_amount: 28000, net_vat_direction: "vat_to_pay" }); + expect(result.derived_business_overview?.trading_margin_proxy).toMatchObject({ + period_scope: "2020", + sales_rows_with_amount: 1, + purchase_rows_with_amount: 1, + sales_revenue: 200000, + purchase_cost_proxy: 120000, + gross_spread_proxy: 80000, + margin_to_revenue_pct: 40 + }); expect(result.derived_business_overview?.missing_signal_families).not.toContain("tax_position"); + expect(result.derived_business_overview?.missing_signal_families).not.toContain("profit_margin"); + expect(result.derived_business_overview?.missing_signal_families).toContain("accounting_profit_margin"); expect(result.evidence.confirmed_facts.join("\n")).toContain("НДС-позиция за 2020 подтверждена"); + expect(result.evidence.confirmed_facts.join("\n")).toContain("Торговый margin proxy за 2020"); + expect(result.evidence.unknown_facts.join("\n")).toContain("Чистая прибыль"); expect(result.evidence.unknown_facts.join("\n")).not.toContain("Налоговая/VAT-позиция этим бизнес-обзором не подтверждена"); expect(result.reason_codes).toContain("pilot_business_overview_tax_query_mcp_executed"); expect(result.reason_codes).toContain("pilot_derived_business_overview_tax_position_from_confirmed_rows"); - expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(9); + expect(result.reason_codes).toContain("pilot_business_overview_trading_margin_query_mcp_executed"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(10); const taxCall = deps.executeAddressMcpQuery.mock.calls[2]?.[0]; + const tradingMarginCall = deps.executeAddressMcpQuery.mock.calls[9]?.[0]; expect(String(taxCall?.query ?? "")).toContain("НДСЗаписиКнигиПродаж"); expect(String(taxCall?.query ?? "")).toContain("НДСЗаписиКнигиПокупок"); + expect(String(tradingMarginCall?.query ?? "")).toContain("Документ.РеализацияТоваровУслуг.Товары"); + expect(String(tradingMarginCall?.query ?? "")).toContain("Документ.ПоступлениеТоваровУслуг.Товары"); + }); + + it("keeps negative trading-margin proxy signed instead of reporting an absolute spread", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }); + const deps = buildSequentialDeps([ + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-03-01T00:00:00", Amount: 100000, Item: "Товар А", Counterparty: "Клиент А", AccountKt: "41.01" }, + { Period: "2020-02-01T00:00:00", Amount: 150000, Item: "Товар А", Counterparty: "Поставщик А", AccountDt: "41.01" } + ] + } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.derived_business_overview?.trading_margin_proxy).toMatchObject({ + sales_revenue: 100000, + purchase_cost_proxy: 150000, + gross_spread_proxy: -50000, + gross_spread_proxy_human_ru: "-50 000 руб.", + margin_to_revenue_pct: -50 + }); + expect(result.evidence.confirmed_facts.join("\n")).toContain("валовый спред proxy -50 000 руб."); + expect(result.evidence.confirmed_facts.join("\n")).toContain("не чистая прибыль"); }); it("adds a checked debt-position family to business overview only as an as-of-date snapshot", async () => { @@ -337,7 +430,8 @@ describe("assistant MCP discovery pilot executor", () => { { Period: "2020-01-15T00:00:00", Registrator: "Поступление 1" }, { Period: "2020-12-15T00:00:00", Registrator: "Поступление 2" } ] - } + }, + { rows: [] } ]); const result = await executeAssistantMcpDiscoveryPilot(planner, deps); @@ -393,7 +487,7 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.reason_codes).toContain("pilot_business_overview_open_contracts_query_mcp_executed"); expect(result.reason_codes).toContain("pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_age_signal_from_contract_dates"); - expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(9); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(10); const receivablesCall = deps.executeAddressMcpQuery.mock.calls[3]?.[0]; const payablesCall = deps.executeAddressMcpQuery.mock.calls[4]?.[0]; const openContractsCall = deps.executeAddressMcpQuery.mock.calls[5]?.[0]; @@ -458,7 +552,8 @@ describe("assistant MCP discovery pilot executor", () => { { Period: "2020-01-15T00:00:00", Registrator: "Поступление 1" }, { Period: "2020-12-15T00:00:00", Registrator: "Поступление 2" } ] - } + }, + { rows: [] } ]); const result = await executeAssistantMcpDiscoveryPilot(planner, deps); @@ -490,7 +585,7 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.evidence.unknown_facts.join("\n")).toContain("оборачиваемость"); expect(result.reason_codes).toContain("pilot_business_overview_inventory_query_mcp_executed"); expect(result.reason_codes).toContain("pilot_derived_business_overview_inventory_position_from_confirmed_rows"); - expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(9); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(10); const inventoryCall = deps.executeAddressMcpQuery.mock.calls[6]?.[0]; expect(inventoryCall?.account_scope).toContain("41.01"); });