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