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 97e9bae..b2be151 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 @@ -21,8 +21,9 @@ If another document says `78%`, `87%`, `92%`, or `85%` for a module that is now - Completed active slice: `Business Overview Debt-Position Fact-Family Bridge`: explicit-period business overview can include confirmed receivables/payables as-of-date debt position, while all-time follow-ups do not reuse stale debt snapshots and debt quality/aging remains unclaimed. - Completed active slice: `Business Overview Inventory-Position Fact-Family Bridge`: explicit-date business overview can include confirmed stock-on-hand inventory position, while all-time follow-ups do not reuse stale inventory snapshots and inventory liquidity/turnover remains unclaimed. - Completed active slice: `Business Overview Open-Settlement Quality Bridge`: explicit-period business overview can check open-contract settlement concentration on 60/62/76, while due-date aging/overdue debt remains unclaimed until a reviewed due-date route exists. -- Next active slice: continue `Business Overview Fact-Family Expansion` into profit/margin and due-date debt aging where reviewed routes exist. -- Active module progress: `~60% (Open-World Bounded Autonomy Breadth)`. +- 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. +- Next active slice: continue breadth into company-wide profit/margin and due-date debt aging only where reviewed evidence routes exist. +- Active module progress: `~66% (Open-World Bounded Autonomy Breadth)`. ## Reporting Rule @@ -59,7 +60,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, and as-of-date inventory position into separately proven 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, and as-of-date inventory position into separately proven company-wide 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 63448fa..4ef09d7 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 @@ -190,11 +190,32 @@ Live semantic replay is accepted for this slice: - step 2 proves an all-time follow-up does not reuse the `2020-12-31` open-contract/debt snapshot as current overdue debt or all-time debt quality; - the accepted user-facing answer keeps due-date aging and overdue debt as unconfirmed because open contracts prove concentration of balances, not payment-term delinquency. +## Slice 8 - Selected-Item Profitability Route Bridge + +This slice opens a bounded profitability lane where the evidence is concrete enough: not profit for the whole company, but revenue, purchase-cost proxy, spread, and margin proxy for one selected inventory item. + +Implemented now: + +- `inventory_profitability_for_item` has a dedicated exact recipe, route expectation, capability policy entry, and runtime contract; +- the recipe reuses the reviewed purchase/sale document union over `41.01`, so the answer is grounded in purchase and sale document rows rather than generic LLM inference; +- selected-object wording such as `по выбранному объекту ... сколько заработали` now keeps the selected item and carries the prior inventory period when the user did not give a new date; +- the reply builder computes confirmed sales revenue, purchase-document cost proxy, gross spread, margin-to-revenue, and markup-to-purchase-cost; +- the user-facing answer explicitly says that this is not company net profit or a бухгалтерский финрезультат, and that exact cost of sale still requires stronger lot/management-accounting evidence. + +This does not close broad company-wide profit/margin analysis. It only converts one previously visible recipe gap into a bounded, truthful exact route for selected-item unit economics. + +Local validation is accepted for this slice: + +- `npm.cmd test -- addressInventoryProfitabilitySelectedObjectRegression.test.ts`: passed `2/2`; +- `npm.cmd test -- addressCapabilityPolicy.test.ts assistantRuntimeContractRegistry.test.ts`: passed `31/31`; +- `npm.cmd test -- addressQueryRuntimeM23.test.ts`: passed `412/412`; +- `npm.cmd run build`: passed. + ### Still Pending Breadth Slices Grow this bridge beyond the first confirmed signal bundle: -- add separate evidence families for profit/margin and due-date debt aging/overdue quality where reviewed routes exist; +- add separate evidence families for company-wide profit/margin and due-date debt aging/overdue quality where reviewed 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; - extend debt evidence from as-of-date position/open-settlement concentration into overdue aging only when reviewed due-date or aging evidence exists; - extend VAT/tax beyond explicit-period tax position only when the requested tax fact is provable and the period is explicit; @@ -257,3 +278,12 @@ Business-overview inventory-position fact-family validation: Graphify rebuild after Slice 6 code/doc sync: `6001 nodes`, `13058 edges`, `140 communities`. Graphify rebuild after Slice 7 code/doc sync: `6008 nodes`, `13078 edges`, `138 communities`. + +Selected-item profitability route validation: + +- `npm.cmd test -- addressInventoryProfitabilitySelectedObjectRegression.test.ts`: passed `2/2`. +- `npm.cmd test -- addressCapabilityPolicy.test.ts assistantRuntimeContractRegistry.test.ts`: passed `31/31`. +- `npm.cmd test -- addressQueryRuntimeM23.test.ts`: passed `412/412`. +- `npm.cmd run build`: passed. + +Graphify rebuild after Slice 8 code/doc sync: `6012 nodes`, `13086 edges`, `138 communities`. diff --git a/docs/ARCH/11 - architecture_turnaround/README.md b/docs/ARCH/11 - architecture_turnaround/README.md index 3fcb4b6..0695a55 100644 --- a/docs/ARCH/11 - architecture_turnaround/README.md +++ b/docs/ARCH/11 - architecture_turnaround/README.md @@ -56,7 +56,8 @@ Status canon for planning: - The current completed breadth slice is `Business Overview Debt-Position Fact-Family Bridge`: explicit-period business overview can include confirmed receivables/payables as-of-date debt position, while all-time follow-ups do not reuse stale debt snapshots and debt quality/aging remains unclaimed. - The current completed breadth slice is `Business Overview Inventory-Position Fact-Family Bridge`: explicit-date business overview can include confirmed stock-on-hand inventory position, while all-time follow-ups do not reuse stale inventory snapshots and inventory liquidity/turnover remains unclaimed. - The current completed breadth slice is `Business Overview Open-Settlement Quality Bridge`: explicit-period business overview can check open-contract settlement concentration, while due-date aging and confirmed overdue debt remain outside the answer until a reviewed due-date route exists. -- The next active breadth slice continues `Business Overview Fact-Family Expansion` into profit/margin and due-date debt aging, then broader unfamiliar 1C route breadth without relaxing truth boundaries. +- 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 next active breadth slice continues breadth into company-wide profit/margin and due-date debt aging, then broader unfamiliar 1C route breadth 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: @@ -121,11 +122,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: `~60%`, 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, and the open-settlement quality bridge accepted by live semantic replay; profit/margin, due-date debt aging/overdue, and real inventory-liquidity expansion are still pending +- active Open-World Bounded Autonomy Breadth progress: `~66%`, 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, and selected-item profitability bridged by local semantic/runtime regression tests; company-wide profit/margin, 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: `6008 nodes`, `13078 edges`, `138 communities` +- graph snapshot after latest rebuild: `6012 nodes`, `13086 edges`, `138 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/docs/TECH/address_route_expectations_v1.json b/docs/TECH/address_route_expectations_v1.json index cb2191d..f295d39 100644 --- a/docs/TECH/address_route_expectations_v1.json +++ b/docs/TECH/address_route_expectations_v1.json @@ -1,6 +1,6 @@ { "schema_version": "address_route_expectations_v1", - "updated_at": "2026-04-14T09:30:00.000Z", + "updated_at": "2026-05-04T00:00:00.000Z", "entries": [ { "intent": "payables_confirmed_as_of_date", @@ -44,6 +44,12 @@ "expected_requested_result_modes": ["confirmed_balance"], "expected_result_modes": ["confirmed_balance"] }, + { + "intent": "inventory_profitability_for_item", + "expected_selected_recipes": ["address_inventory_profitability_for_item_v1"], + "expected_requested_result_modes": ["confirmed_balance"], + "expected_result_modes": ["confirmed_balance"] + }, { "intent": "inventory_purchase_to_sale_chain", "expected_selected_recipes": ["address_inventory_purchase_to_sale_chain_v1"], diff --git a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js index c868c06..c580c58 100644 --- a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js +++ b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js @@ -12,6 +12,7 @@ const COMPUTE_EXACT_INTENTS = new Set([ "inventory_purchase_documents_for_item", "inventory_supplier_stock_overlap_as_of_date", "inventory_sale_trace_for_item", + "inventory_profitability_for_item", "inventory_purchase_to_sale_chain", "inventory_aging_by_purchase_date", "customer_revenue_and_payments", @@ -67,6 +68,7 @@ function defaultCapabilityId(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date") { return `inventory_${intent}`; @@ -149,7 +151,14 @@ function resolveCapabilityEnabled(intent) { if (intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain") { + if (intent === "inventory_profitability_for_item") { + return { + enabled: true, + reason: "inventory_profitability_route_enabled" + }; + } if (intent === "inventory_purchase_to_sale_chain") { return { enabled: true, @@ -240,6 +249,7 @@ function resolveShadowRouteIntent(intent, requestedResultMode) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date") { return null; diff --git a/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js b/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js index 2e8452c..8fe63f7 100644 --- a/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js +++ b/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js @@ -101,6 +101,7 @@ function isConfirmedBalanceIntent(intent) { intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 5784850..4cb9c1f 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1970,6 +1970,7 @@ function canAutoBroadenPeriodWindow(intent, filters) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } @@ -1978,16 +1979,19 @@ function shouldBoostAutoBroadenedLimit(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } function shouldClearAsOfDateForHistoryRecovery(intent) { return (intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain"); } function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) { if (intent !== "inventory_supplier_stock_overlap_as_of_date" && intent !== "inventory_sale_trace_for_item" && + intent !== "inventory_profitability_for_item" && intent !== "inventory_purchase_to_sale_chain") { return false; } diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index fbe3274..6c24cc0 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -844,6 +844,17 @@ const BASE_RECIPES = [ account_scope_mode: "strict", query_template: "inventory_purchase_to_sale_chain_profile" }, + { + recipe_id: "address_inventory_profitability_for_item_v1", + intent: "inventory_profitability_for_item", + purpose: "Trace purchase and sale document rows for one inventory item and derive bounded revenue, purchase-cost proxy, spread, and margin", + required_filters: ["item"], + optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"], + default_limit: 600, + account_scope: ["41.01"], + account_scope_mode: "strict", + query_template: "inventory_profitability_profile" + }, { recipe_id: "address_inventory_aging_by_purchase_date_v1", intent: "inventory_aging_by_purchase_date", @@ -1304,6 +1315,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_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" || intent === "open_contracts_confirmed_as_of_date" || @@ -1484,31 +1496,15 @@ function buildAddressRecipePlan(recipe, filters) { ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") : recipe.query_template === "inventory_sale_trace_profile" ? buildInventorySaleDocumentQuery(filters, resolvedLimit) - : recipe.query_template === "inventory_purchase_to_sale_chain_profile" + : recipe.query_template === "inventory_profitability_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) @@ -1520,13 +1516,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) @@ -1538,23 +1534,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/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 5fdba5d..93d7e9a 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -292,6 +292,7 @@ function isInventoryLifecycleHistoryIntent(intent) { return (intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain"); } function shouldSuppressInventoryCounterpartyAlias(intent, counterparty, organization) { @@ -1008,6 +1009,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" || intent === "payables_confirmed_as_of_date" || @@ -1061,6 +1063,19 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("period_from_followup_context"); } } + if (intent === "inventory_profitability_for_item" && + previousHasPeriod && + hasSelectedObjectInventorySignal(userMessage) && + !hasExplicitPeriodInMessage && + !hasExplicitCurrentDateInMessage) { + if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) { + merged.period_from = previousPeriodFrom; + } + if (previousPeriodTo && merged.period_to !== previousPeriodTo) { + merged.period_to = previousPeriodTo; + } + reasons.push("period_from_followup_context"); + } if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 43e4a32..de477e4 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -56,6 +56,30 @@ function inventoryRequestedPartyMatches(requested, actualParties) { function inventoryPartyListOrUnknown(parties) { return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; } +function sumInventoryRowAmount(rows) { + return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0); +} +function sumInventoryRowQuantity(rows) { + return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0); +} +function formatInventoryPercent(value, formatNumberWithDots) { + return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`; +} +function inventoryProfitabilityPeriodLabel(options, deps) { + const from = typeof options.periodFrom === "string" && options.periodFrom.trim().length > 0 ? options.periodFrom : null; + const to = typeof options.periodTo === "string" && options.periodTo.trim().length > 0 ? options.periodTo : null; + if (from && to) { + return `${deps.formatDateRu(from)} - ${deps.formatDateRu(to)}`; + } + if (from) { + return `с ${deps.formatDateRu(from)}`; + } + if (to) { + return `до ${deps.formatDateRu(to)}`; + } + const asOfDate = typeof options.asOfDate === "string" && options.asOfDate.trim().length > 0 ? options.asOfDate : null; + return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке"; +} function composeInventoryReply(intent, rows, options, deps) { if (intent === "inventory_on_hand_as_of_date") { const asOfDate = deps.resolvePayablesAsOfDate(options); @@ -353,6 +377,71 @@ function composeInventoryReply(intent, rows, options, deps) { ? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(summary.counterparties.length > 0 ? "strong" : "medium", true)) : (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", false)); } + if (intent === "inventory_profitability_for_item") { + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row)); + const requestedItemHint = String(options.itemHint ?? "").trim(); + const excludedCounterpartyTokens = requestedItemHint ? [requestedItemHint] : []; + const purchaseSummary = deps.summarizeInventoryTraceRows(purchaseRows, excludedCounterpartyTokens); + const saleSummary = deps.summarizeInventoryTraceRows(saleRows, excludedCounterpartyTokens); + const itemLabel = requestedItemHint || purchaseSummary.item || saleSummary.item || "товар не определен"; + const revenue = sumInventoryRowAmount(saleRows); + const purchaseCostProxy = sumInventoryRowAmount(purchaseRows); + const spread = revenue - purchaseCostProxy; + const marginPct = revenue > 0 ? (spread / revenue) * 100 : null; + const markupPct = purchaseCostProxy > 0 ? (spread / purchaseCostProxy) * 100 : null; + const saleQuantity = sumInventoryRowQuantity(saleRows); + const purchaseQuantity = sumInventoryRowQuantity(purchaseRows); + const periodLabel = inventoryProfitabilityPeriodLabel(options, deps); + const hasSales = saleRows.length > 0; + const hasPurchases = purchaseRows.length > 0; + const directAnswerLine = hasSales && hasPurchases + ? `По товару ${itemLabel} за период ${periodLabel} подтверждена выручка продаж ${deps.formatMoneyRub(revenue)} и закупочный след ${deps.formatMoneyRub(purchaseCostProxy)}; расчетный валовый спред по доступным документам: ${deps.formatMoneyRub(spread)}. Маржинальность к выручке: ${formatInventoryPercent(marginPct, deps.formatNumberWithDots)}.` + : hasSales + ? `По товару ${itemLabel} за период ${periodLabel} подтверждена выручка продаж ${deps.formatMoneyRub(revenue)}, но закупочный след в доступных строках не найден; прибыль и маржа не подтверждены.` + : hasPurchases + ? `По товару ${itemLabel} за период ${periodLabel} найден закупочный след ${deps.formatMoneyRub(purchaseCostProxy)}, но продажи в доступных строках не найдены; выручка, прибыль и маржа не подтверждены.` + : `По товару ${itemLabel} за период ${periodLabel} не найдено ни продаж, ни закупочного следа в доступных строках 41.01.`; + const lines = [directAnswerLine]; + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Расчет:", [ + `Строк продаж со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`, + `Строк закупки на счет 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`, + `Выручка по документам продажи: ${deps.formatMoneyRub(revenue)}.`, + `Закупочная сумма по доступным документам: ${deps.formatMoneyRub(purchaseCostProxy)}.`, + `Расчетный валовый спред: ${deps.formatMoneyRub(spread)}.`, + `Маржинальность к выручке: ${formatInventoryPercent(marginPct, deps.formatNumberWithDots)}.`, + `Наценка к закупочному следу: ${formatInventoryPercent(markupPct, deps.formatNumberWithDots)}.` + ]); + if (saleQuantity > 0 || purchaseQuantity > 0) { + lines.push(`- Количество в продажах: ${deps.formatNumberWithDots(saleQuantity, 3)}; количество в закупках: ${deps.formatNumberWithDots(purchaseQuantity, 3)}.`); + } + if (saleSummary.counterparties.length > 0) { + lines.push(`- Покупатели в продаже: ${saleSummary.counterparties.slice(0, 4).join("; ")}.`); + } + if (purchaseSummary.counterparties.length > 0) { + lines.push(`- Поставщики в закупочном следе: ${purchaseSummary.counterparties.slice(0, 4).join("; ")}.`); + } + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ + "Это не чистая прибыль компании и не бухгалтерский финансовый результат.", + "Закупочная сумма является proxy по найденным документам поступления; без партионного/управленческого учета нельзя доказать точную себестоимость конкретной продажи.", + "Если продажи и закупки попали в разные периоды или разные организации/склады, вывод нужно читать как документальный срез по доступной выборке, а не как полный P&L." + ]); + if (saleRows.length > 0) { + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Продажи:", [ + `- Первая дата продажи: ${deps.inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, + `- Последняя дата продажи: ${deps.inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, + ...deps.formatInventoryTraceRows(saleRows, 8, [itemLabel]) + ]); + } + if (purchaseRows.length > 0) { + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Закупки:", [ + `- Первая дата закупки: ${deps.inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, + `- Последняя дата закупки: ${deps.inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, + ...deps.formatInventoryTraceRows(purchaseRows, 8, [itemLabel]) + ]); + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(hasSales && hasPurchases ? "strong" : hasSales || hasPurchases ? "medium" : "weak", hasSales || hasPurchases)); + } if (intent === "inventory_purchase_to_sale_chain") { const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row)); diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js index df94e6b..b98ab8b 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js @@ -299,6 +299,17 @@ exports.INVENTORY_CAPABILITY_CONTRACTS = [ answerObjectShape: "inventory_sale_trace_bundle", bundleReusePolicy: "sale_trace_bundle_preferred" }), + inventoryExactCapability({ + capability_id: "inventory_inventory_profitability_for_item", + intent_ids: ["inventory_profitability_for_item"], + entry_modes: ["selected_object_drilldown", "clarification_resume"], + transitions: ["T3", "T4", "T5", "T7"], + requiresFocusObject: true, + requiredAnchors: ["item"], + resultShape: "item_revenue_purchase_cost_spread_margin_proxy", + answerObjectShape: "inventory_profitability_bundle", + bundleReusePolicy: "sale_trace_bundle_preferred" + }), inventoryExactCapability({ capability_id: "inventory_inventory_purchase_to_sale_chain", intent_ids: ["inventory_purchase_to_sale_chain"], diff --git a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts index 16cdc13..aa940b1 100644 --- a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts +++ b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts @@ -31,6 +31,7 @@ const COMPUTE_EXACT_INTENTS = new Set([ "inventory_purchase_documents_for_item", "inventory_supplier_stock_overlap_as_of_date", "inventory_sale_trace_for_item", + "inventory_profitability_for_item", "inventory_purchase_to_sale_chain", "inventory_aging_by_purchase_date", "customer_revenue_and_payments", @@ -91,6 +92,7 @@ function defaultCapabilityId(intent: AddressIntent): string { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" ) { @@ -176,8 +178,15 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" ) { + if (intent === "inventory_profitability_for_item") { + return { + enabled: true, + reason: "inventory_profitability_route_enabled" + }; + } if (intent === "inventory_purchase_to_sale_chain") { return { enabled: true, @@ -275,6 +284,7 @@ export function resolveShadowRouteIntent( intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" ) { diff --git a/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts b/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts index 06bdb77..ceb58ed 100644 --- a/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts +++ b/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts @@ -149,6 +149,7 @@ export function isConfirmedBalanceIntent(intent: AddressIntent): boolean { intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index e36f3e2..bd1972c 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -2435,6 +2435,7 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" ); @@ -2446,6 +2447,7 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" ); @@ -2454,6 +2456,7 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean { function shouldClearAsOfDateForHistoryRecovery(intent: AddressIntent): boolean { return ( intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" ); } @@ -2465,6 +2468,7 @@ function shouldDetachLifecycleExecutionFromSnapshotContext( if ( intent !== "inventory_supplier_stock_overlap_as_of_date" && intent !== "inventory_sale_trace_for_item" && + intent !== "inventory_profitability_for_item" && intent !== "inventory_purchase_to_sale_chain" ) { return false; diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index c9f4c1d..b9d1bbd 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -868,6 +868,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "strict", query_template: "inventory_purchase_to_sale_chain_profile" }, + { + recipe_id: "address_inventory_profitability_for_item_v1", + intent: "inventory_profitability_for_item", + purpose: "Trace purchase and sale document rows for one inventory item and derive bounded revenue, purchase-cost proxy, spread, and margin", + required_filters: ["item"], + optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"], + default_limit: 600, + account_scope: ["41.01"], + account_scope_mode: "strict", + query_template: "inventory_profitability_profile" + }, { recipe_id: "address_inventory_aging_by_purchase_date_v1", intent: "inventory_aging_by_purchase_date", @@ -1427,6 +1438,7 @@ function maxLimitForIntent(intent: AddressIntent): number { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" || intent === "open_contracts_confirmed_as_of_date" || @@ -1652,6 +1664,8 @@ export function buildAddressRecipePlan( ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") : recipe.query_template === "inventory_sale_trace_profile" ? buildInventorySaleDocumentQuery(filters, resolvedLimit) + : recipe.query_template === "inventory_profitability_profile" + ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_purchase_to_sale_chain_profile" ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_aging_by_purchase_date_profile" diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 1c67ae5..8549ead 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -401,6 +401,7 @@ function isInventoryLifecycleHistoryIntent(intent: AddressIntent | undefined): b intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" ); } @@ -1272,6 +1273,7 @@ function mergeFollowupFilters( intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" || intent === "payables_confirmed_as_of_date" || @@ -1331,6 +1333,22 @@ function mergeFollowupFilters( } } + if ( + intent === "inventory_profitability_for_item" && + previousHasPeriod && + hasSelectedObjectInventorySignal(userMessage) && + !hasExplicitPeriodInMessage && + !hasExplicitCurrentDateInMessage + ) { + if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) { + merged.period_from = previousPeriodFrom; + } + if (previousPeriodTo && merged.period_to !== previousPeriodTo) { + merged.period_to = previousPeriodTo; + } + reasons.push("period_from_followup_context"); + } + if ( !currentHasPeriod && previousHasPeriod && diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index b68c5f4..6f18ea3 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -132,6 +132,34 @@ function inventoryPartyListOrUnknown(parties: string[]): string { return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; } +function sumInventoryRowAmount(rows: ComposeStageRow[]): number { + return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0); +} + +function sumInventoryRowQuantity(rows: ComposeStageRow[]): number { + return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0); +} + +function formatInventoryPercent(value: number | null, formatNumberWithDots: (value: number, fractionDigits?: number) => string): string { + return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`; +} + +function inventoryProfitabilityPeriodLabel(options: InventoryComposeOptions, deps: InventoryReplyDeps): string { + const from = typeof options.periodFrom === "string" && options.periodFrom.trim().length > 0 ? options.periodFrom : null; + const to = typeof options.periodTo === "string" && options.periodTo.trim().length > 0 ? options.periodTo : null; + if (from && to) { + return `${deps.formatDateRu(from)} - ${deps.formatDateRu(to)}`; + } + if (from) { + return `с ${deps.formatDateRu(from)}`; + } + if (to) { + return `до ${deps.formatDateRu(to)}`; + } + const asOfDate = typeof options.asOfDate === "string" && options.asOfDate.trim().length > 0 ? options.asOfDate : null; + return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке"; +} + export function composeInventoryReply( intent: AddressIntent, rows: ComposeStageRow[], @@ -481,6 +509,79 @@ export function composeInventoryReply( : buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false)); } + if (intent === "inventory_profitability_for_item") { + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row)); + const requestedItemHint = String(options.itemHint ?? "").trim(); + const excludedCounterpartyTokens = requestedItemHint ? [requestedItemHint] : []; + const purchaseSummary = deps.summarizeInventoryTraceRows(purchaseRows, excludedCounterpartyTokens); + const saleSummary = deps.summarizeInventoryTraceRows(saleRows, excludedCounterpartyTokens); + const itemLabel = requestedItemHint || purchaseSummary.item || saleSummary.item || "товар не определен"; + const revenue = sumInventoryRowAmount(saleRows); + const purchaseCostProxy = sumInventoryRowAmount(purchaseRows); + const spread = revenue - purchaseCostProxy; + const marginPct = revenue > 0 ? (spread / revenue) * 100 : null; + const markupPct = purchaseCostProxy > 0 ? (spread / purchaseCostProxy) * 100 : null; + const saleQuantity = sumInventoryRowQuantity(saleRows); + const purchaseQuantity = sumInventoryRowQuantity(purchaseRows); + const periodLabel = inventoryProfitabilityPeriodLabel(options, deps); + const hasSales = saleRows.length > 0; + const hasPurchases = purchaseRows.length > 0; + const directAnswerLine = + hasSales && hasPurchases + ? `По товару ${itemLabel} за период ${periodLabel} подтверждена выручка продаж ${deps.formatMoneyRub(revenue)} и закупочный след ${deps.formatMoneyRub(purchaseCostProxy)}; расчетный валовый спред по доступным документам: ${deps.formatMoneyRub(spread)}. Маржинальность к выручке: ${formatInventoryPercent(marginPct, deps.formatNumberWithDots)}.` + : hasSales + ? `По товару ${itemLabel} за период ${periodLabel} подтверждена выручка продаж ${deps.formatMoneyRub(revenue)}, но закупочный след в доступных строках не найден; прибыль и маржа не подтверждены.` + : hasPurchases + ? `По товару ${itemLabel} за период ${periodLabel} найден закупочный след ${deps.formatMoneyRub(purchaseCostProxy)}, но продажи в доступных строках не найдены; выручка, прибыль и маржа не подтверждены.` + : `По товару ${itemLabel} за период ${periodLabel} не найдено ни продаж, ни закупочного следа в доступных строках 41.01.`; + const lines: string[] = [directAnswerLine]; + appendInventoryBulletSection(lines, "Расчет:", [ + `Строк продаж со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`, + `Строк закупки на счет 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`, + `Выручка по документам продажи: ${deps.formatMoneyRub(revenue)}.`, + `Закупочная сумма по доступным документам: ${deps.formatMoneyRub(purchaseCostProxy)}.`, + `Расчетный валовый спред: ${deps.formatMoneyRub(spread)}.`, + `Маржинальность к выручке: ${formatInventoryPercent(marginPct, deps.formatNumberWithDots)}.`, + `Наценка к закупочному следу: ${formatInventoryPercent(markupPct, deps.formatNumberWithDots)}.` + ]); + if (saleQuantity > 0 || purchaseQuantity > 0) { + lines.push(`- Количество в продажах: ${deps.formatNumberWithDots(saleQuantity, 3)}; количество в закупках: ${deps.formatNumberWithDots(purchaseQuantity, 3)}.`); + } + if (saleSummary.counterparties.length > 0) { + lines.push(`- Покупатели в продаже: ${saleSummary.counterparties.slice(0, 4).join("; ")}.`); + } + if (purchaseSummary.counterparties.length > 0) { + lines.push(`- Поставщики в закупочном следе: ${purchaseSummary.counterparties.slice(0, 4).join("; ")}.`); + } + appendInventoryBulletSection(lines, "Ограничения:", [ + "Это не чистая прибыль компании и не бухгалтерский финансовый результат.", + "Закупочная сумма является proxy по найденным документам поступления; без партионного/управленческого учета нельзя доказать точную себестоимость конкретной продажи.", + "Если продажи и закупки попали в разные периоды или разные организации/склады, вывод нужно читать как документальный срез по доступной выборке, а не как полный P&L." + ]); + if (saleRows.length > 0) { + appendInventorySection(lines, "Продажи:", [ + `- Первая дата продажи: ${deps.inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, + `- Последняя дата продажи: ${deps.inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, + ...deps.formatInventoryTraceRows(saleRows, 8, [itemLabel]) + ]); + } + if (purchaseRows.length > 0) { + appendInventorySection(lines, "Закупки:", [ + `- Первая дата закупки: ${deps.inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, + `- Последняя дата закупки: ${deps.inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, + ...deps.formatInventoryTraceRows(purchaseRows, 8, [itemLabel]) + ]); + } + return buildFactualSummaryReply( + lines, + buildConfirmedBalanceSemantics( + hasSales && hasPurchases ? "strong" : hasSales || hasPurchases ? "medium" : "weak", + hasSales || hasPurchases + ) + ); + } + if (intent === "inventory_purchase_to_sale_chain") { const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row)); diff --git a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts index 04cb195..7eb7922 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts @@ -326,6 +326,17 @@ export const INVENTORY_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContrac answerObjectShape: "inventory_sale_trace_bundle", bundleReusePolicy: "sale_trace_bundle_preferred" }), + inventoryExactCapability({ + capability_id: "inventory_inventory_profitability_for_item", + intent_ids: ["inventory_profitability_for_item"], + entry_modes: ["selected_object_drilldown", "clarification_resume"], + transitions: ["T3", "T4", "T5", "T7"], + requiresFocusObject: true, + requiredAnchors: ["item"], + resultShape: "item_revenue_purchase_cost_spread_margin_proxy", + answerObjectShape: "inventory_profitability_bundle", + bundleReusePolicy: "sale_trace_bundle_preferred" + }), inventoryExactCapability({ capability_id: "inventory_inventory_purchase_to_sale_chain", intent_ids: ["inventory_purchase_to_sale_chain"], diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index 1890d94..7b55684 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -1,4 +1,4 @@ -export type AddressQuestionMode = "address_query" | "deep_analysis" | "unsupported"; +export type AddressQuestionMode = "address_query" | "deep_analysis" | "unsupported"; import type { AssistantCoverageStatus, @@ -196,6 +196,7 @@ export interface AddressRecipeDefinition { | "inventory_purchase_documents_profile" | "inventory_supplier_stock_overlap_profile" | "inventory_sale_trace_profile" + | "inventory_profitability_profile" | "inventory_purchase_to_sale_chain_profile" | "inventory_aging_by_purchase_date_profile"; required_filters: Array; diff --git a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts index e4a5c0e..8127c72 100644 --- a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts +++ b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts @@ -85,7 +85,7 @@ describe("address capability policy", () => { expect(chainDecision.capability_route_mode).toBe("exact"); }); - it("enables purchase-to-sale trace and aging by purchase date", () => { + it("enables purchase-to-sale trace, item profitability, and aging by purchase date", () => { const chainDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_to_sale_chain"); expect(chainDecision.capability_id).toBe("inventory_inventory_purchase_to_sale_chain"); expect(chainDecision.capability_route_mode).toBe("exact"); @@ -93,6 +93,14 @@ describe("address capability policy", () => { expect(chainDecision.capability_route_reason).toBe("inventory_purchase_to_sale_chain_route_enabled"); expect(isCapabilityRouteBlocked(chainDecision)).toBe(false); + const profitabilityDecision = resolveAddressCapabilityRouteDecision("inventory_profitability_for_item"); + expect(profitabilityDecision.capability_id).toBe("inventory_inventory_profitability_for_item"); + expect(profitabilityDecision.capability_layer).toBe("compute"); + expect(profitabilityDecision.capability_route_mode).toBe("exact"); + expect(profitabilityDecision.capability_route_enabled).toBe(true); + expect(profitabilityDecision.capability_route_reason).toBe("inventory_profitability_route_enabled"); + expect(isCapabilityRouteBlocked(profitabilityDecision)).toBe(false); + const agingDecision = resolveAddressCapabilityRouteDecision("inventory_aging_by_purchase_date"); expect(agingDecision.capability_id).toBe("inventory_inventory_aging_by_purchase_date"); expect(agingDecision.capability_route_mode).toBe("exact"); diff --git a/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts b/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts index 0c9a3df..ef9e27a 100644 --- a/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts @@ -49,19 +49,59 @@ describe("inventory profitability selected-object regressions", () => { expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31"); }); - it("returns a truthful recipe visibility gap until item profitability gets a dedicated recipe", async () => { + it("answers selected-object profitability with a bounded document spread instead of a recipe gap", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 2, + matched_rows: 2, + raw_rows: [ + { + Period: "2020-05-10T00:00:00Z", + Registrator: "Поступление товаров и услуг 000000001 от 10.05.2020", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 500, + Quantity: 10, + SubcontoDt1: "Четки Пост (84*117)", + SubcontoDt3: "Основной склад", + Counterparty: "ООО \\Поставщик\\", + Organization: "ООО \\Альтернатива Плюс\\" + }, + { + Period: "2020-05-20T00:00:00Z", + Registrator: "Реализация товаров и услуг 000000017 от 20.05.2020", + AccountDt: "62.01", + AccountKt: "41.01", + Amount: 900, + Quantity: 10, + SubcontoKt1: "Четки Пост (84*117)", + SubcontoKt3: "Основной склад", + Counterparty: "ИП Покупатель", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + const service = new AddressQueryService(); const result = await service.tryHandle(selectedObjectProfitabilityMessage, { followupContext }); expect(result?.handled).toBe(true); - expect(result?.response_type).toBe("LIMITED_WITH_REASON"); + expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_profitability_for_item"); - expect(result?.debug.selected_recipe).toBeNull(); - expect(result?.debug.limited_reason_category).toBe("recipe_visibility_gap"); + expect(result?.debug.selected_recipe).toBe("address_inventory_profitability_for_item_v1"); + expect(result?.debug.capability_id).toBe("inventory_inventory_profitability_for_item"); + expect(result?.debug.mcp_call_status).toBe("matched_non_empty"); expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)"); - expect(String(result?.reply_text ?? "")).toContain("Четки Пост (84*117)"); - expect(executeAddressMcpQueryMock).not.toHaveBeenCalled(); + const reply = String(result?.reply_text ?? ""); + expect(reply).toContain("Четки Пост (84*117)"); + expect(reply).toContain("900"); + expect(reply).toContain("500"); + expect(reply).toContain("400"); + expect(reply).toContain("Маржинальность"); + expect(reply).toContain("не чистая прибыль компании"); + expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); }); }); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 6c497c4..893e623 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -355,6 +355,20 @@ describe("address query shape classifier", () => { expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент"); }); + it("builds item profitability query from purchase and sale document rows", () => { + const selected = selectAddressRecipe("inventory_profitability_for_item", { + item: "Шкаф картотечный" + }); + expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_profitability_for_item_v1"); + const plan = buildAddressRecipePlan(selected.selected_recipe!, { + item: "Шкаф картотечный" + }); + expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары"); + expect(plan.query).toContain("ОБЪЕДИНИТЬ ВСЕ"); + expect(plan.query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары"); + expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент"); + }); + it("renders inventory purchase documents from purchase-side 41.01 movements", () => { const reply = composeFactualReply( "inventory_purchase_documents_for_item", diff --git a/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts b/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts index fc93bb0..63deaab 100644 --- a/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts +++ b/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts @@ -71,6 +71,18 @@ describe("assistant runtime contract registry", () => { expect(contract?.required_scenario_families).toContain("pronoun_followup"); }); + it("declares selected-item profitability as a bounded focus-object contract", () => { + const contract = getAssistantCapabilityContractByIntent("inventory_profitability_for_item"); + expect(contract?.capability_id).toBe("inventory_inventory_profitability_for_item"); + expect(contract?.requires_focus_object).toBe(true); + expect(contract?.accepted_focus_object_kinds).toEqual(["inventory_item", "item"]); + expect(contract?.supported_transition_classes).toEqual(["T3", "T4", "T5", "T7"]); + expect(contract?.required_anchors).toEqual(["item"]); + expect(contract?.result_shape).toBe("item_revenue_purchase_cost_spread_margin_proxy"); + expect(contract?.answer_object_shape).toBe("inventory_profitability_bundle"); + expect(contract?.bundle_reuse_policy).toBe("sale_trace_bundle_preferred"); + }); + it("declares root financial exact capabilities for debt and vat snapshots", () => { const receivables = getAssistantCapabilityContract("confirmed_receivables_as_of_date"); const payables = getAssistantCapabilityContract("confirmed_payables_as_of_date");