ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить selected-object follow-up по складу и включить разговорные/UI-варианты в обязательный domain-loop

This commit is contained in:
2026-04-14 14:18:38 +03:00
parent 9048632d3e
commit d41819eabd
67 changed files with 3800 additions and 195 deletions
@@ -165,14 +165,34 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
if (
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
intent === "inventory_purchase_to_sale_chain"
) {
if (intent === "inventory_purchase_to_sale_chain") {
return {
enabled: true,
reason: "inventory_purchase_to_sale_chain_route_enabled"
};
}
return {
enabled: false,
reason: "inventory_provenance_route_not_implemented"
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
? "inventory_trace_route_enabled"
: "inventory_trace_route_disabled_by_flag"
};
}
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
? "inventory_supplier_stock_overlap_route_enabled"
: "inventory_supplier_stock_overlap_route_disabled_by_flag"
};
}
if (intent === "inventory_aging_by_purchase_date") {
return {
enabled: true,
reason: "inventory_aging_route_enabled"
};
}
if (intent === "list_payables_counterparties") {
@@ -1,4 +1,4 @@
import type { AddressFilterExtraction, AddressFilterSet, AddressIntent } from "../types/addressQuery";
import type { AddressFilterExtraction, AddressFilterSet, AddressIntent } from "../types/addressQuery";
import iconv from "iconv-lite";
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
@@ -72,6 +72,10 @@ const COUNTERPARTY_TOKEN_NOISE = new Set([
"могу",
"можем",
"нет",
"был",
"были",
"куплен",
"куплены",
"покажи",
"показать",
"скажи",
@@ -906,6 +910,243 @@ function hasExplicitAccountCue(text: string): boolean {
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
}
function isInventoryTraceIntent(intent: AddressIntent): boolean {
return (
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
);
}
function isInventoryItemAnchoredIntent(intent: AddressIntent): boolean {
return (
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain"
);
}
function usesRecipeDefaultLimit(intent: AddressIntent): boolean {
return (
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
);
}
function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
const value = cleanupAnchorValue(rawValue)
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!value || value.length < 3) {
return true;
}
if (
/^(?:товар(?:ы|а|у|ом)?|номенклатура|позиция|остаток|остатки|склад|складе|складу|поставщик|покупатель|документ|документы)$/iu.test(
value
)
) {
return true;
}
const lowQualityTokens = new Set([
"сейчас",
"лежат",
"лежит",
"лежали",
"куплен",
"куплена",
"куплены",
"продан",
"продана",
"проданы",
"документам",
"документами",
"документы",
"поставщика",
"поставщику",
"покупателю",
"остаток",
"остатки",
"склад",
"складе",
"складу"
]);
const meaningfulTokens = value
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean)
.filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0;
}
function cleanupInventoryItemAnchorValue(value: string): string {
return String(value ?? "")
.replace(/^['"«»`]+|['"«»`]+$/gu, "")
.replace(/\s+/g, " ")
.trim();
}
function trimInventoryItemAnchorTail(rawValue: string): string {
let value = cleanupInventoryItemAnchorValue(rawValue);
const tailPatterns = [
/\s+для\s+остатка(?:\s+на\s+складе.*)?$/iu,
/\s+из\s+текущ(?:его|их)\s+остат(?:ка|ков).*$/iu,
/\s+из\s+остат(?:ка|ков).*$/iu,
/\s+в\s+остатке.*$/iu,
/\s+на\s+складе.*$/iu,
/\s*:\s*закупк.*$/iu
];
for (const pattern of tailPatterns) {
value = value.replace(pattern, "");
}
return cleanupInventoryItemAnchorValue(value);
}
function extractSelectedObjectQuotedValue(text: string): string | undefined {
const patterns = [
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu,
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*:\s*[«"]([^»"\r\n]+)[»"]/iu
];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
if (candidate) {
return candidate;
}
}
return undefined;
}
function extractInventoryItemFromSelectedObject(text: string): string | undefined {
const selectedObject = extractSelectedObjectQuotedValue(text);
if (!selectedObject) {
return undefined;
}
const firstLine = selectedObject
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => cleanupInventoryItemAnchorValue(line))
.find(Boolean);
const withoutNumberPrefix = cleanupInventoryItemAnchorValue(String(firstLine ?? "").replace(/^\d+\.\s*/, ""));
const primarySegment = cleanupInventoryItemAnchorValue(withoutNumberPrefix.split("|")[0] ?? withoutNumberPrefix);
const candidate = cleanupInventoryItemAnchorValue(primarySegment);
if (!candidate || isLowQualityInventoryItemAnchorValue(candidate)) {
return undefined;
}
return candidate;
}
function extractInventoryItemAnchor(text: string): string | undefined {
const selectedObjectItem = extractInventoryItemFromSelectedObject(text);
if (selectedObjectItem) {
return selectedObjectItem;
}
const patterns = [
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu,
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu
];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = trimInventoryItemArrowSuffix(trimInventoryItemChainTail(trimInventoryItemAnchorTail(String(match?.[1] ?? ""))));
if (!candidate || isLowQualityInventoryItemAnchorValue(candidate)) {
continue;
}
return candidate;
}
return undefined;
}
function trimInventoryItemChainTail(rawValue: string): string {
return cleanupInventoryItemAnchorValue(
cleanupInventoryItemAnchorValue(rawValue)
.replace(/\s*(?:->|=>|)\s*(?:РїРѕРєСѓРїР°Сел\w*|buyer\b).*$/iu, "")
.replace(/\s*(?:->|=>|)\s*(?:РїРѕСЃСавСРёРє\w*|supplier\b).*$/iu, "")
);
}
function trimInventoryItemArrowSuffix(rawValue: string): string {
return cleanupAnchorValue(cleanupAnchorValue(rawValue).replace(/\s*(?:->|=>|).+$/u, ""));
}
function isTemporalWarehousePhrase(candidate: string): boolean {
const normalized = cleanupAnchorValue(candidate)
.toLowerCase()
.replace(/ё/g, "е")
.trim();
return /^(?:в|на)\s+(?:январ(?:е|ь)|феврал(?:е|ь)|март(?:е)?|апрел(?:е|ь)|ма(?:й|е)|июн(?:е|ь)|июл(?:е|ь)|август(?:е)?|сентябр(?:е|ь)|октябр(?:е|ь)|ноябр(?:е|ь)|декабр(?:е|ь))(?:\s+\d{4}(?:\s+г(?:\.|ода)?)?)?$/iu.test(
normalized
);
}
function extractInventoryWarehouseAnchor(text: string): string | undefined {
const patterns = [
/(?:на|по)\s+склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu,
/склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu
];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = cleanupAnchorValue(
cleanupAnchorValue(String(match?.[1] ?? "")).replace(
/\s+(?:организац\w*|компани\w*|котор(?:ый|ые|ых)|на\s+дату|по\s+состоянию\s+на\s+дату).*$/iu,
""
)
);
const normalizedCandidate = candidate
.toLowerCase()
.replace(/ё/g, "е")
.trim();
if (
!candidate ||
candidate.includes("->") ||
candidate.includes("=>") ||
normalizedCandidate.startsWith("по состоянию") ||
isTemporalWarehousePhrase(candidate) ||
/^(?:сейчас|на|дату|дате|остаток|остатки)$/iu.test(candidate)
) {
continue;
}
return candidate;
}
return undefined;
}
function extractInventorySupplierAnchor(text: string): string | undefined {
const match = String(text ?? "").match(
/(?:от\s+поставщика|у\s+поставщика|поставщика|поставщику)\s+([^\r\n?]+?)(?=$|[?])/iu
);
if (!match?.[1]) {
return undefined;
}
const candidate = cleanupAnchorValue(
cleanupAnchorValue(String(match[1])).replace(
/\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu,
""
)
);
if (
!candidate ||
isLowQualityCounterpartyAnchorValue(candidate) ||
/^(?:были|был|куплен|куплены|которые|который|которых|сейчас|лежат|лежит)\b/iu.test(candidate)
) {
return undefined;
}
return candidate;
}
function asksForInventorySupplierIdentity(text: string): boolean {
return /(?:^|[\s,.;:!?])(?:у|от)\s+какого\s+поставщика\b/iu.test(String(text ?? ""));
}
function extractAccountTokenHeuristic(text: string): string | undefined {
const source = String(text ?? "");
const dotted = source.match(/(?:^|[^\d])(\d{2}[.,]\d{1,2})(?!\d)/u);
@@ -932,6 +1173,14 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "inventory_on_hand_as_of_date") {
return ["as_of_date"];
}
if (
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain"
) {
return ["item"];
}
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}
@@ -963,6 +1212,12 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
return (
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
@@ -988,7 +1243,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
const filters: AddressFilterSet = {
sort: "period_desc"
};
if (!isManagementProfileIntent) {
if (!isManagementProfileIntent && !usesRecipeDefaultLimit(intent)) {
if (intent !== "open_contracts_confirmed_as_of_date") {
filters.limit = 20;
}
@@ -1017,12 +1272,33 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
}
const counterpartyMatch = text.match(COUNTERPARTY_PATTERN);
if (counterpartyMatch) {
if (isInventoryItemAnchoredIntent(intent)) {
const itemAnchor = extractInventoryItemAnchor(text);
if (itemAnchor) {
filters.item = itemAnchor;
}
}
const warehouseAnchor = extractInventoryWarehouseAnchor(text);
if (warehouseAnchor) {
filters.warehouse = warehouseAnchor;
}
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
const supplierAnchor = asksForInventorySupplierIdentity(text) ? undefined : extractInventorySupplierAnchor(text);
if (supplierAnchor) {
filters.counterparty = supplierAnchor;
}
}
const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent);
const counterpartyMatch = allowGenericCounterpartyAnchor ? text.match(COUNTERPARTY_PATTERN) : null;
if (counterpartyMatch && !filters.counterparty) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
}
if (
!filters.counterparty &&
allowGenericCounterpartyAnchor &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
@@ -1035,6 +1311,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
if (
!filters.counterparty &&
allowGenericCounterpartyAnchor &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
@@ -1047,6 +1324,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
if (
!filters.counterparty &&
allowGenericCounterpartyAnchor &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
@@ -1170,6 +1448,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
(intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") &&
@@ -1209,6 +1493,10 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
delete filters.contract;
warnings.push("contract_anchor_dropped_low_quality");
}
if (filters.item && isLowQualityInventoryItemAnchorValue(filters.item)) {
delete filters.item;
warnings.push("item_anchor_dropped_low_quality");
}
const required = requiredFiltersByIntent(intent);
const missingRequiredFilters = required.filter((key) => {
@@ -1222,4 +1510,3 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
warnings
};
}
@@ -1542,16 +1542,32 @@ function hasAccountNumberAnchor(text: string): boolean {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
}
function hasInventoryAccount41Anchor(text: string): boolean {
return /(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)/iu.test(text);
}
function hasInventoryAsOfCue(text: string): boolean {
return /(?:сейчас|текущ|на\s+дату|по\s+состоянию|срез|на\s+конец|date|as\s+of|current|now|today)/iu.test(
text
);
}
function hasInventoryOnHandSignal(text: string): boolean {
const hasColloquialStockSnapshotCue = /(?:что|ч[её])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом)(?=$|[\s,.;:!?])/iu.test(
text
);
const hasAccount41Anchor = hasInventoryAccount41Anchor(text);
const hasStockLexeme =
/(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text);
if (!hasStockLexeme) {
if (!hasStockLexeme && !hasAccount41Anchor) {
return false;
}
if (
hasInventoryProvenanceSignalV2(text) ||
hasInventoryPurchaseDocumentsSignalV2(text) ||
hasInventorySaleTraceSignalV2(text)
hasInventorySaleTraceSignalV2(text) ||
hasInventoryAgingSignal(text) ||
hasInventoryPurchaseToSaleChainSignal(text)
) {
return false;
}
@@ -1562,7 +1578,11 @@ function hasInventoryOnHandSignal(text: string): boolean {
text
);
const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|какой|сколько|show|list|which|what)/iu.test(text);
return (hasGoodsLexeme || hasBalanceLexeme) && (hasRequestCue || hasBalanceLexeme);
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
return true;
}
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue) &&
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue);
}
function hasInventoryProvenanceSignal(text: string): boolean {
@@ -1590,6 +1610,12 @@ function hasInventoryProvenanceSignalV2(text: string): boolean {
return hasItemCue && hasSupplierCue && hasPurchaseCue;
}
function hasInventoryPurchaseDateSignal(text: string): boolean {
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasPurchaseDateCue = /(?:когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text);
return hasItemCue && hasPurchaseDateCue;
}
function hasInventoryPurchaseDocumentsSignalV2(text: string): boolean {
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasPurchaseDocCue = /(?:по\s+каким\s+документам\s+был\s+куплен|по\s+каким\s+документам\s+куплен|какими\s+документами\s+был\s+куплен|документ(?:ам|ы)\s+закупк|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
@@ -1607,25 +1633,48 @@ function hasInventorySaleTraceSignalV2(text: string): boolean {
}
function hasInventorySupplierStockOverlapSignal(text: string): boolean {
const hasDirectSingleItemSupplierQuestion =
/(?:от\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и))|от\s+кого\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и)))/iu.test(
text
);
if (hasDirectSingleItemSupplierQuestion) {
return false;
}
const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+поставщика|у\s+поставщика)/iu.test(text);
const hasStockCue = /(?:товар|номенклатур|склад|остат(?:ок|ки)|лежат|на\s+дату|по\s+состоянию\s+на\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(
const hasStockCue = /(?:склад|остат(?:ок|ке|ков)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|на\s+дату|по\s+состоянию\s+на\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(
text
);
return hasSupplierCue && hasStockCue;
}
function hasInventoryAgingSignal(text: string): boolean {
return /(?:стар(?:ые|ым|ых)\s+закупк|закупал(?:ись|ся)\s+очень\s+давно|очень\s+давно|давно\s+куплен|когда\s+куплен|возраст\s+остатк|aged?\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date|very\s+old\s+stock)/iu.test(
text
);
const hasResidueCue =
/(?:остат(?:ок|ки)|в\s+остатке|среди\s+текущих\s+остатков|на\s+складе|stock\s+residue|stock\s+balance)/iu.test(text);
const hasAgingCue =
/(?:стар(?:ые|ым|ых)\s+закупк|стары(?:м|х)\s+закупк(?:ам|и|ах)|относит(?:ся|ся\s+ли)?\s+.*\s+к\s+старым\s+закупк|закупал(?:ись|ся)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+до(?:\s+даты)?|закуплен(?:ы|а)?\s+давно|приобретен\s+давно|задолго\s+до(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test(
text
);
return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+до)/iu.test(text));
}
function hasInventoryPurchaseToSaleChainSignal(text: string): boolean {
const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+кого\s+куплен)/iu.test(text);
const hasBuyerCue = /(?:покупател|buyer|customer|client|кому\s+был\s+продан)/iu.test(text);
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasPurchaseSaleCue = /(?:куплен(?:ы)?|закупк|позже\s+продан(?:ы)?|продан(?:ы)?|purchase|sale|цепочк[аи]\s+движен)/iu.test(text);
return (hasSupplierCue && hasBuyerCue && hasItemCue && hasPurchaseSaleCue) || /(?:purchase[\s-]?to[\s-]?sale\s+chain|закупка\s*->\s*склад\s*->\s*продажа)/iu.test(text);
const hasChainCue =
/(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test(
text
) || text.includes("->");
return hasItemCue && hasChainCue;
}
function hasInventorySupplierToBuyerChainSignal(text: string): boolean {
const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text);
const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text);
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasChainCue =
/(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+к\s+покупател|поставщик\s+к\s+клиент|поставщик\s+к\s+товару\s+и\s+покупателю)/iu.test(
text
) || text.includes("->");
return hasSupplierCue && hasBuyerCue && hasItemCue && hasChainCue;
}
export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
@@ -1745,27 +1794,35 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasInventoryProvenanceSignalV2(text)) {
if (
/(?:старым\s+закупк(?:ам|и|ах)|относится\s+ли\s+.*\s+к\s+старым\s+закупк(?:ам|и|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test(
text
)
) {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "medium",
reasons: ["inventory_provenance_signal_detected"]
intent: "inventory_aging_by_purchase_date",
confidence: "high",
reasons: ["inventory_aging_signal_detected_strong"]
};
}
if (hasInventoryPurchaseDocumentsSignalV2(text)) {
if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) {
return {
intent: "inventory_purchase_documents_for_item",
confidence: "medium",
reasons: ["inventory_purchase_documents_signal_detected"]
intent: "inventory_on_hand_as_of_date",
confidence: "high",
reasons: ["inventory_account_41_as_of_date_signal_detected"]
};
}
if (hasInventoryPurchaseToSaleChainSignal(text)) {
if (
/(?:без\s+понятн(?:ой|ого)\s+привязк(?:и|а)\s+к\s+поставщик|без\s+привязк(?:и|а)\s+к\s+поставщик|unresolved\s+supplier\s+link)/iu.test(
text
)
) {
return {
intent: "inventory_purchase_to_sale_chain",
intent: "inventory_supplier_stock_overlap_as_of_date",
confidence: "medium",
reasons: ["inventory_purchase_to_sale_chain_signal_detected"]
reasons: ["inventory_unresolved_provenance_signal_detected"]
};
}
@@ -1777,6 +1834,29 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (
/(?:supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|поставщик\s+к\s+покупателю|поставщик\s*->\s*товар\s*->\s*покупател|документально\s+подтвержденн\w+\s+цепочк)/iu.test(
text
) &&
/(?:поставщик|supplier|vendor)/iu.test(text) &&
/(?:покупател|buyer|customer|client)/iu.test(text) &&
/(?:товар|номенклатур|sku|item|product)/iu.test(text)
) {
return {
intent: "inventory_purchase_to_sale_chain",
confidence: "high",
reasons: ["inventory_supplier_to_buyer_chain_signal_detected_strong"]
};
}
if (hasInventoryPurchaseToSaleChainSignal(text)) {
return {
intent: "inventory_purchase_to_sale_chain",
confidence: "medium",
reasons: ["inventory_purchase_to_sale_chain_signal_detected"]
};
}
if (hasInventoryAgingSignal(text)) {
return {
intent: "inventory_aging_by_purchase_date",
@@ -1785,6 +1865,30 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasInventoryProvenanceSignalV2(text)) {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "medium",
reasons: ["inventory_provenance_signal_detected"]
};
}
if (hasInventoryPurchaseDateSignal(text)) {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "medium",
reasons: ["inventory_purchase_date_signal_detected"]
};
}
if (hasInventoryPurchaseDocumentsSignalV2(text)) {
return {
intent: "inventory_purchase_documents_for_item",
confidence: "medium",
reasons: ["inventory_purchase_documents_signal_detected"]
};
}
if (hasInventorySaleTraceSignalV2(text)) {
return {
intent: "inventory_sale_trace_for_item",
@@ -1793,6 +1897,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasInventorySupplierToBuyerChainSignal(text)) {
return {
intent: "inventory_purchase_to_sale_chain",
confidence: "medium",
reasons: ["inventory_supplier_to_buyer_chain_signal_detected"]
};
}
if (hasInventoryOnHandSignal(text)) {
return {
intent: "inventory_on_hand_as_of_date",
@@ -1284,6 +1284,24 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte
}
}
if (filters.item && String(filters.item).trim()) {
const needle = String(filters.item);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "item_anchor_not_matched_in_materialized_rows";
}
}
if (filters.warehouse && String(filters.warehouse).trim()) {
const needle = String(filters.warehouse);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "warehouse_anchor_not_matched_in_materialized_rows";
}
}
if (filters.document_ref && String(filters.document_ref).trim()) {
const needle = String(filters.document_ref);
const before = filtered.length;
@@ -1367,6 +1385,10 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
@@ -1779,7 +1801,24 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
intent === "bank_operations_by_contract" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
);
}
function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean {
return (
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
);
}
@@ -2133,6 +2172,12 @@ function normalizeMissingAnchorLabel(anchor: string): string {
if (anchor === "organization") {
return "организация";
}
if (anchor === "item") {
return "товар";
}
if (anchor === "warehouse") {
return "склад";
}
if (anchor === "period" || anchor === "period_from" || anchor === "period_to" || anchor === "as_of_date") {
return "период/дата";
}
@@ -2220,6 +2265,14 @@ function buildLimitedOffers(input: {
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76");
} else if (input.intent === "inventory_on_hand_as_of_date") {
offers.push("показать подтвержденный срез товаров на складах на дату по остатку счета 41.01");
} else if (input.intent === "inventory_purchase_provenance_for_item") {
offers.push("показать подтвержденные закупочные движения по товару на 41.01 с датами и документами");
} else if (input.intent === "inventory_purchase_documents_for_item") {
offers.push("показать документы поступления по товару на 41.01");
} else if (input.intent === "inventory_sale_trace_for_item") {
offers.push("показать подтвержденные движения выбытия товара со счета 41.01");
} else if (input.intent === "inventory_purchase_to_sale_chain") {
offers.push("показать документальную цепочку по товару: поступление на 41.01 и последующее выбытие");
} else if (input.intent === "open_contracts_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76");
} else if (input.intent === "vat_payable_confirmed_as_of_date") {
@@ -3352,6 +3405,14 @@ export class AddressQueryService {
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
delete autoBroadenedFilters.period_from;
delete autoBroadenedFilters.period_to;
if (shouldBoostAutoBroadenedLimit(intent.intent)) {
autoBroadenedFilters.limit = Math.max(
ADDRESS_ANCHOR_RECOVERY_LIMIT,
typeof autoBroadenedFilters.limit === "number" && Number.isFinite(autoBroadenedFilters.limit)
? Math.max(1, Math.trunc(autoBroadenedFilters.limit))
: 0
);
}
const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters);
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters);
@@ -20,7 +20,27 @@ const MOVEMENTS_QUERY_TEMPLATE = `
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Движения.Период __ORDER_DIRECTION__
`;
const INVENTORY_MOVEMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Движения.Период КАК Период,
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
Движения.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
ИЗ
РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Движения.Период __ORDER_DIRECTION__
@@ -707,6 +727,72 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "strict",
query_template: "inventory_on_hand_as_of_balance_profile"
},
{
recipe_id: "address_inventory_purchase_provenance_for_item_v1",
intent: "inventory_purchase_provenance_for_item",
purpose: "Trace purchase-side 41.01 movements for one inventory item and summarize supplier/date provenance evidence",
required_filters: ["item"],
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
default_limit: 400,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_purchase_provenance_profile"
},
{
recipe_id: "address_inventory_purchase_documents_for_item_v1",
intent: "inventory_purchase_documents_for_item",
purpose: "Trace purchase-side 41.01 movements for one inventory item and list source purchase documents",
required_filters: ["item"],
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
default_limit: 400,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_purchase_documents_profile"
},
{
recipe_id: "address_inventory_supplier_stock_overlap_as_of_date_v1",
intent: "inventory_supplier_stock_overlap_as_of_date",
purpose: "Trace purchase-side 41.01 movements and summarize supplier overlap with current or dated stock slice",
required_filters: [],
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "counterparty", "limit", "sort"],
default_limit: 500,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_supplier_stock_overlap_profile"
},
{
recipe_id: "address_inventory_sale_trace_for_item_v1",
intent: "inventory_sale_trace_for_item",
purpose: "Trace sale-side 41.01 movements for one inventory item and summarize sale evidence",
required_filters: ["item"],
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
default_limit: 400,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_sale_trace_profile"
},
{
recipe_id: "address_inventory_purchase_to_sale_chain_v1",
intent: "inventory_purchase_to_sale_chain",
purpose: "Trace both purchase and sale side 41.01 movements for one inventory item and summarize the document chain",
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_purchase_to_sale_chain_profile"
},
{
recipe_id: "address_inventory_aging_by_purchase_date_v1",
intent: "inventory_aging_by_purchase_date",
purpose: "Trace purchase-side 41.01 movements and summarize age of stock residue by purchase dates",
required_filters: [],
optional_filters: ["item", "as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
default_limit: 500,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_aging_by_purchase_date_profile"
},
{
recipe_id: "address_open_contracts_confirmed_as_of_date_v1",
intent: "open_contracts_confirmed_as_of_date",
@@ -1039,6 +1125,28 @@ function buildAccountPrefixPredicate(fieldPath: string, prefixes: string[]): str
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildInventoryMovementQuery(
filters: AddressFilterSet,
resolvedLimit: number,
side: "dt" | "kt" | "either"
): string {
const debitPredicate = buildAccountPrefixPredicate("Движения.СчетДт", ["41.01"]);
const creditPredicate = buildAccountPrefixPredicate("Движения.СчетКт", ["41.01"]);
const inventoryCondition =
side === "dt"
? debitPredicate
: side === "kt"
? creditPredicate
: `(${debitPredicate} ИЛИ ${creditPredicate})`;
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace(
"__WHERE_CLAUSE__",
buildWhereClause(filters, "Движения.Период", [inventoryCondition])
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
const hasAnchor =
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
@@ -1067,6 +1175,12 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
@@ -1259,6 +1373,18 @@ export function buildAddressRecipePlan(
.replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "inventory_purchase_provenance_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_purchase_documents_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "kt")
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "either")
: 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"
@@ -910,6 +910,165 @@ function buildInventoryOnHandAggregate(rows: ComposeStageRow[], asOfDate: string
});
}
function inventoryTraceDateLabel(value: string | null): string {
return value ? formatDateRu(value) : "дата не указана";
}
function hasInventoryAccountPrefix(value: string | null | undefined, prefix: string): boolean {
const normalized = String(value ?? "")
.trim()
.replace(",", ".");
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
}
function isInventoryPurchaseMovement(row: ComposeStageRow): boolean {
return hasInventoryAccountPrefix(row.account_dt, "41.01");
}
function isInventorySaleMovement(row: ComposeStageRow): boolean {
return hasInventoryAccountPrefix(row.account_kt, "41.01");
}
function looksLikeInventoryTraceDocumentToken(value: string): boolean {
const normalized = String(value ?? "").trim();
if (!normalized) {
return false;
}
return (
/(?:|contract|invoice|payment|order|накладн|акт|счет|сч[её]т|поступлен|реализац|договор)/iu.test(normalized) ||
/(?:[a-zа-яё].*\d|\d.*[a-zа-яё])/iu.test(normalized)
);
}
function looksLikeInventoryPartyToken(value: string): boolean {
const normalized = String(value ?? "").trim();
if (!normalized || normalized.length < 3) {
return false;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
return false;
}
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
return false;
}
if (/(?:склад|warehouse)/iu.test(normalized)) {
return false;
}
if (looksLikeInventoryTraceDocumentToken(normalized)) {
return false;
}
if (
/(?:ооо|ао|пао|зао|ип|llc|ltd|inc|corp|компани|организац|департамент|комитет|министерств|служб|управлен|торговый\s+дом)/iu.test(
normalized
)
) {
return true;
}
const letterChars = (normalized.match(/[A-Za-zА-Яа-яЁё]/g) ?? []).length;
if (letterChars < 3) {
return false;
}
const words = normalized.split(/\s+/u).filter(Boolean);
if (words.length >= 2) {
return true;
}
return normalized === normalized.toUpperCase() && normalized.length >= 4;
}
function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[] {
const itemToken = normalizeEntityToken(extractInventoryItemName(row));
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
const candidates: string[] = [];
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized || !looksLikeInventoryPartyToken(normalized)) {
continue;
}
const comparable = normalizeEntityToken(normalized);
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) {
continue;
}
candidates.push(normalized);
}
return uniqueStrings(candidates);
}
interface InventoryTraceSummary {
item: string | null;
warehouses: string[];
organizations: string[];
counterparties: string[];
documents: string[];
firstPeriod: string | null;
lastPeriod: string | null;
totalAmount: number;
}
function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSummary {
const items = uniqueStrings(
rows
.map((row) => extractInventoryItemName(row))
.filter((item): item is string => Boolean(item))
);
const warehouses = uniqueStrings(
rows
.map((row) => extractInventoryWarehouseName(row))
.filter((item): item is string => Boolean(item))
);
const organizations = uniqueStrings(
rows
.map((row) => extractInventoryOrganizationName(row))
.filter((item): item is string => Boolean(item))
);
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row)));
const documents = uniqueStrings(
rows
.map((row) => String(row.registrator ?? "").trim())
.filter((item) => item.length > 0 && item !== "(без названия)")
);
const periods = rows
.map((row) => String(row.period ?? "").trim())
.filter((item) => item.length > 0)
.sort((left, right) => left.localeCompare(right, "ru"));
const totalAmount = rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
return {
item: items[0] ?? null,
warehouses,
organizations,
counterparties,
documents,
firstPeriod: periods[0] ?? null,
lastPeriod: periods.length > 0 ? periods[periods.length - 1] : null,
totalAmount
};
}
function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[] {
return rows.slice(0, limit).map((row, index) => {
const parties = extractInventoryCounterpartyCandidates(row);
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const amount =
typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
const parts = [
`${index + 1}. ${row.registrator}`,
`дата: ${inventoryTraceDateLabel(row.period)}`,
`сумма: ${amount}`
];
if (warehouse) {
parts.push(`склад: ${warehouse}`);
}
if (organization) {
parts.push(`организация: ${organization}`);
}
if (parties.length > 0) {
parts.push(`контрагент: ${parties[0]}`);
}
return parts.join(" | ");
});
}
interface CounterpartyRiskAggregate {
name: string;
totalAmount: number;
@@ -3719,6 +3878,265 @@ export function composeFactualReply(
};
}
if (intent === "inventory_purchase_documents_for_item") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const lines: string[] = [
`Собран подтвержденный список документов поступления по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденные движения поступления товара на 41.01 по доступным бухгалтерским проводкам.",
"",
"Блок 2. Что учтено",
`- Дата верхней границы: ${formatDateRu(asOfDate)}.`,
"- Контур: движения, где товар поступает на счет 41.01.",
`- Документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (summary.counterparties.length > 0) {
lines.push(`- Найденные контрагенты в закупочных движениях: ${summary.counterparties.slice(0, 3).join("; ")}.`);
}
lines.push("", "Блок 3. Документы");
if (purchaseRows.length > 0) {
lines.push(...formatInventoryTraceRows(purchaseRows, 12));
} else {
lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре.");
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
if (intent === "inventory_purchase_provenance_for_item") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const lines: string[] = [
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: показаны подтвержденные закупочные движения на 41.01 по выбранному товару.",
"- Важно: без партионности этот контур не подменяет собой лот-level доказательство происхождения текущего остатка.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (summary.counterparties.length === 1) {
lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
} else if (purchaseRows.length > 0) {
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
}
if (summary.documents.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength:
purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const unresolvedRows = purchaseRows.filter((row) => extractInventoryCounterpartyCandidates(row).length === 0);
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
const lines: string[] = [
`Собран exact-срез supplier overlap для складского остатка до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
`- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`,
"- Важно: без партионности этот контур показывает документально наблюдаемые supplier candidates, но не подменяет собой лот-level атрибуцию текущего остатка.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (summary.counterparties.length > 0) {
lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`);
} else if (purchaseRows.length > 0) {
lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
} else {
lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза.");
}
if (unresolvedRows.length > 0) {
lines.push(`- Операций без явно материализованного поставщика: ${formatNumberWithDots(unresolvedRows.length)}.`);
}
if (purchaseRows.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 10));
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
if (intent === "inventory_aging_by_purchase_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const firstPeriodTime = summary.firstPeriod ? Date.parse(summary.firstPeriod) : Number.NaN;
const asOfTime = Date.parse(`${asOfDate}T23:59:59.000Z`);
const ageDays =
Number.isFinite(firstPeriodTime) && Number.isFinite(asOfTime) && firstPeriodTime <= asOfTime
? Math.floor((asOfTime - firstPeriodTime) / 86_400_000)
: null;
const itemLabel = summary.item ?? "выбранному складскому остатку";
const lines: string[] = [
`Собран exact-срез возраста закупочного следа по ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Контур: показаны подтвержденные закупочные движения на 41.01 и их временной разброс.",
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый диапазон закупок.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (ageDays !== null) {
lines.push(`- Между самой ранней найденной закупкой и датой среза прошло ${formatNumberWithDots(ageDays)} дн.`);
}
if (summary.counterparties.length > 0) {
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (purchaseRows.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
} else {
lines.push("", "Блок 3. Опорные документы", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
if (intent === "inventory_sale_trace_for_item") {
const asOfDate = resolvePayablesAsOfDate(options);
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const summary = summarizeInventoryTraceRows(saleRows);
const itemLabel = summary.item ?? "товар не определен";
const lines: string[] = [
`Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: показаны подтвержденные движения выбытия товара со счета 41.01.",
"",
"Блок 2. Сводка",
`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`
];
if (summary.counterparties.length === 1) {
lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`);
} else if (saleRows.length > 0) {
lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре.");
}
lines.push("", "Блок 3. Документы выбытия");
if (saleRows.length > 0) {
lines.push(...formatInventoryTraceRows(saleRows, 12));
} else {
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
}
return {
responseType: saleRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: saleRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
balance_confirmed: saleRows.length > 0
}
};
}
if (intent === "inventory_purchase_to_sale_chain") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const purchaseSummary = summarizeInventoryTraceRows(purchaseRows);
const saleSummary = summarizeInventoryTraceRows(saleRows);
const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен";
const lines: string[] = [
`Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`,
`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`
];
if (purchaseRows.length > 0 && saleRows.length > 0) {
lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие.");
} else if (purchaseRows.length > 0) {
lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено.");
} else if (saleRows.length > 0) {
lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена.");
} else {
lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку.");
}
if (purchaseRows.length > 0) {
lines.push(
"",
"Блок 2. Закупка",
`- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`,
`- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`,
...formatInventoryTraceRows(purchaseRows, 6)
);
}
if (saleRows.length > 0) {
lines.push(
"",
"Блок 3. Выбытие",
`- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`,
`- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`,
...formatInventoryTraceRows(saleRows, 6)
);
}
return {
responseType: purchaseRows.length > 0 || saleRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 && saleRows.length > 0 ? "strong" : purchaseRows.length > 0 || saleRows.length > 0 ? "medium" : "weak",
balance_confirmed: purchaseRows.length > 0 || saleRows.length > 0
}
};
}
if (intent === "open_contracts_confirmed_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
@@ -490,6 +490,12 @@ function mergeFollowupFilters(
intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
@@ -524,6 +530,21 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context");
}
}
if (
!sameDateRequested &&
(intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
if (
!sameDateRequested &&
hasFollowupSignalForConfirmed &&
@@ -572,6 +593,12 @@ function mergeFollowupFilters(
intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date";
@@ -3814,12 +3814,27 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_documents_by_counterparty",
"bank_operations_by_counterparty",
"list_contracts_by_counterparty",
"inventory_purchase_provenance_for_item",
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
"contract_usage_overview",
"contract_usage_and_value",
"vat_payable_forecast",
"vat_liability_confirmed_for_tax_period",
"vat_payable_confirmed_as_of_date"
]);
const ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS = new Set([
"inventory_purchase_provenance_for_item",
"inventory_purchase_documents_for_item",
"inventory_sale_trace_for_item",
"inventory_purchase_to_sale_chain"
]);
function shouldBypassStrictDeepInvestigationCueForAddressIntent(intent) {
return Boolean(intent && ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS.has(intent));
}
export function resolveAssistantOrchestrationDecision(input) {
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
@@ -3873,11 +3888,13 @@ export function resolveAssistantOrchestrationDecision(input) {
hasStrictDeepInvestigationCue(repairedRawUserMessage) ||
hasStrictDeepInvestigationCue(effectiveAddressUserMessage) ||
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
const strictDeepInvestigationBypassAllowed = shouldBypassStrictDeepInvestigationCueForAddressIntent(intentResolution.intent) ||
shouldBypassStrictDeepInvestigationCueForAddressIntent(llmContractIntent);
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal) &&
!strictDeepInvestigationCueDetected;
(!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed);
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
hasStrongDataIntentSignal(repairedRawUserMessage) ||
hasStrongDataIntentSignal(effectiveAddressUserMessage) ||
@@ -4023,7 +4040,7 @@ export function resolveAssistantOrchestrationDecision(input) {
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
const supportedAddressIntentDetected = (!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed) &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal);
@@ -4175,6 +4192,7 @@ export function resolveAssistantOrchestrationDecision(input) {
semantic_reason_codes: semanticReasonCodes,
semantic_route_arbitration: {
supported_address_intent_detected: supportedAddressIntentDetected,
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
@@ -111,6 +111,8 @@ export interface AddressFilterSet {
counterparty?: string;
contract?: string;
account?: string;
item?: string;
warehouse?: string;
document_type?: string;
document_ref?: string;
status?: string;
@@ -150,7 +152,9 @@ export interface AddressRecipeDefinition {
| "inventory_purchase_provenance_profile"
| "inventory_purchase_documents_profile"
| "inventory_supplier_stock_overlap_profile"
| "inventory_sale_trace_profile";
| "inventory_sale_trace_profile"
| "inventory_purchase_to_sale_chain_profile"
| "inventory_aging_by_purchase_date_profile";
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;
default_limit: number;