ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить selected-object follow-up по складу и включить разговорные/UI-варианты в обязательный domain-loop
This commit is contained in:
@@ -138,13 +138,33 @@ function resolveCapabilityEnabled(intent) {
|
||||
}
|
||||
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: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||
reason: config_1.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: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||
reason: config_1.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") {
|
||||
|
||||
+241
-3
@@ -61,6 +61,10 @@ const COUNTERPARTY_TOKEN_NOISE = new Set([
|
||||
"могу",
|
||||
"можем",
|
||||
"нет",
|
||||
"был",
|
||||
"были",
|
||||
"куплен",
|
||||
"куплены",
|
||||
"покажи",
|
||||
"показать",
|
||||
"скажи",
|
||||
@@ -796,6 +800,198 @@ function extractLeadingCounterpartyTokenHeuristic(text) {
|
||||
function hasExplicitAccountCue(text) {
|
||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
||||
}
|
||||
function isInventoryTraceIntent(intent) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
return String(value ?? "")
|
||||
.replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
function trimInventoryItemAnchorTail(rawValue) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
return cleanupInventoryItemAnchorValue(cleanupInventoryItemAnchorValue(rawValue)
|
||||
.replace(/\s*(?:->|=>|→)\s*(?:покупател\w*|buyer\b).*$/iu, "")
|
||||
.replace(/\s*(?:->|=>|→)\s*(?:поставщик\w*|supplier\b).*$/iu, ""));
|
||||
}
|
||||
function trimInventoryItemArrowSuffix(rawValue) {
|
||||
return cleanupAnchorValue(cleanupAnchorValue(rawValue).replace(/\s*(?:->|=>|→).+$/u, ""));
|
||||
}
|
||||
function isTemporalWarehousePhrase(candidate) {
|
||||
const normalized = cleanupAnchorValue(candidate)
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е")
|
||||
.trim();
|
||||
return /^(?:в|на)\s+(?:январ(?:е|ь)|феврал(?:е|ь)|март(?:е)?|апрел(?:е|ь)|ма(?:й|е)|июн(?:е|ь)|июл(?:е|ь)|август(?:е)?|сентябр(?:е|ь)|октябр(?:е|ь)|ноябр(?:е|ь)|декабр(?:е|ь))(?:\s+\d{4}(?:\s+г(?:\.|ода)?)?)?$/iu.test(normalized);
|
||||
}
|
||||
function extractInventoryWarehouseAnchor(text) {
|
||||
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) {
|
||||
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) {
|
||||
return /(?:^|[\s,.;:!?])(?:у|от)\s+какого\s+поставщика\b/iu.test(String(text ?? ""));
|
||||
}
|
||||
function extractAccountTokenHeuristic(text) {
|
||||
const source = String(text ?? "");
|
||||
const dotted = source.match(/(?:^|[^\d])(\d{2}[.,]\d{1,2})(?!\d)/u);
|
||||
@@ -820,6 +1016,12 @@ function requiredFiltersByIntent(intent) {
|
||||
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"];
|
||||
}
|
||||
@@ -847,6 +1049,12 @@ function requiredFiltersByIntent(intent) {
|
||||
}
|
||||
function usesAsOfPrimaryWindow(intent) {
|
||||
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" ||
|
||||
@@ -869,7 +1077,7 @@ function extractAddressFilters(userMessage, intent) {
|
||||
const filters = {
|
||||
sort: "period_desc"
|
||||
};
|
||||
if (!isManagementProfileIntent) {
|
||||
if (!isManagementProfileIntent && !usesRecipeDefaultLimit(intent)) {
|
||||
if (intent !== "open_contracts_confirmed_as_of_date") {
|
||||
filters.limit = 20;
|
||||
}
|
||||
@@ -895,11 +1103,29 @@ function extractAddressFilters(userMessage, intent) {
|
||||
filters.limit = Math.min(200, Math.trunc(parsed));
|
||||
}
|
||||
}
|
||||
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")) {
|
||||
@@ -910,6 +1136,7 @@ function extractAddressFilters(userMessage, intent) {
|
||||
}
|
||||
}
|
||||
if (!filters.counterparty &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
(intent === "list_documents_by_counterparty" ||
|
||||
intent === "bank_operations_by_counterparty" ||
|
||||
intent === "list_contracts_by_counterparty")) {
|
||||
@@ -920,6 +1147,7 @@ function extractAddressFilters(userMessage, intent) {
|
||||
}
|
||||
}
|
||||
if (!filters.counterparty &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
(intent === "list_documents_by_counterparty" ||
|
||||
intent === "bank_operations_by_counterparty" ||
|
||||
intent === "list_contracts_by_counterparty")) {
|
||||
@@ -1028,6 +1256,12 @@ function extractAddressFilters(userMessage, intent) {
|
||||
if ((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") &&
|
||||
@@ -1062,6 +1296,10 @@ function extractAddressFilters(userMessage, intent) {
|
||||
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) => {
|
||||
const value = filters[key];
|
||||
|
||||
+95
-20
@@ -1302,20 +1302,34 @@ function hasGenericAddressLookupSignal(text) {
|
||||
function hasAccountNumberAnchor(text) {
|
||||
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
||||
}
|
||||
function hasInventoryAccount41Anchor(text) {
|
||||
return /(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)/iu.test(text);
|
||||
}
|
||||
function hasInventoryAsOfCue(text) {
|
||||
return /(?:сейчас|текущ|на\s+дату|по\s+состоянию|срез|на\s+конец|date|as\s+of|current|now|today)/iu.test(text);
|
||||
}
|
||||
function hasInventoryOnHandSignal(text) {
|
||||
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;
|
||||
}
|
||||
const hasGoodsLexeme = /(?:товар(?:ы|ов|ом|а|ные)?|номенклатур|материал(?:ы|ов|а|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text);
|
||||
const hasBalanceLexeme = /(?:леж(?:ит|ат)|есть|числ(?:ит(?:ся|сь)|ятся)|остат(?:ок|ки)|срез|на\s+дат|по\s+состоянию|на\s+конец|today|now|current|as\s+of)/iu.test(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) {
|
||||
return /(?:поставщик|закупк|происхожд|откуда|когда был куплен|активная закупк|purchase provenance|purchase date|supplier provenance|stock overlap)/iu.test(text);
|
||||
@@ -1332,6 +1346,11 @@ function hasInventoryProvenanceSignalV2(text) {
|
||||
const hasPurchaseCue = /(?:куплен(?:ы|а)?|закупк|происхождени|откуда|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+provenance|purchase\s+date)/iu.test(text);
|
||||
return hasItemCue && hasSupplierCue && hasPurchaseCue;
|
||||
}
|
||||
function hasInventoryPurchaseDateSignal(text) {
|
||||
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) {
|
||||
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(text);
|
||||
@@ -1343,19 +1362,30 @@ function hasInventorySaleTraceSignalV2(text) {
|
||||
return hasItemCue && hasTraceCue;
|
||||
}
|
||||
function hasInventorySupplierStockOverlapSignal(text) {
|
||||
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(text);
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
function resolveAddressIntent(userMessage) {
|
||||
const text = String(userMessage ?? "").trim().toLowerCase();
|
||||
@@ -1459,25 +1489,25 @@ function resolveAddressIntent(userMessage) {
|
||||
reasons: ["documents_by_account_drilldown_signal_detected"]
|
||||
};
|
||||
}
|
||||
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"]
|
||||
};
|
||||
}
|
||||
if (hasInventorySupplierStockOverlapSignal(text)) {
|
||||
@@ -1487,6 +1517,23 @@ function resolveAddressIntent(userMessage) {
|
||||
reasons: ["inventory_supplier_stock_overlap_signal_detected"]
|
||||
};
|
||||
}
|
||||
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",
|
||||
@@ -1494,6 +1541,27 @@ function resolveAddressIntent(userMessage) {
|
||||
reasons: ["inventory_aging_signal_detected"]
|
||||
};
|
||||
}
|
||||
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",
|
||||
@@ -1501,6 +1569,13 @@ function resolveAddressIntent(userMessage) {
|
||||
reasons: ["inventory_sale_trace_signal_detected"]
|
||||
};
|
||||
}
|
||||
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",
|
||||
|
||||
+58
-1
@@ -1058,6 +1058,22 @@ function applyAddressFilters(rows, filters) {
|
||||
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -1124,6 +1140,10 @@ function isConfirmedBalanceIntent(intent) {
|
||||
return (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" ||
|
||||
@@ -1453,7 +1473,21 @@ function canAutoBroadenPeriodWindow(intent, filters) {
|
||||
return (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) {
|
||||
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 invertSort(sort) {
|
||||
return sort === "period_asc" ? "period_desc" : "period_asc";
|
||||
@@ -1723,6 +1757,12 @@ function normalizeMissingAnchorLabel(anchor) {
|
||||
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 "период/дата";
|
||||
}
|
||||
@@ -1797,6 +1837,18 @@ function buildLimitedOffers(input) {
|
||||
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");
|
||||
}
|
||||
@@ -2752,6 +2804,11 @@ class AddressQueryService {
|
||||
const autoBroadenedFilters = { ...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 = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, autoBroadenedFilters);
|
||||
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
||||
const broadenedPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
||||
|
||||
+184
-68
@@ -17,7 +17,26 @@ 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__
|
||||
@@ -686,6 +705,72 @@ const BASE_RECIPES = [
|
||||
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",
|
||||
@@ -981,6 +1066,19 @@ function buildAccountPrefixPredicate(fieldPath, prefixes) {
|
||||
const clauses = normalizedPrefixes.map((prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`);
|
||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
||||
}
|
||||
function buildInventoryMovementQuery(filters, resolvedLimit, side) {
|
||||
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) {
|
||||
const hasAnchor = (typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
||||
(typeof filters.contract === "string" && filters.contract.trim().length > 0);
|
||||
@@ -1004,6 +1102,12 @@ function maxLimitForIntent(intent) {
|
||||
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" ||
|
||||
@@ -1150,73 +1254,85 @@ function buildAddressRecipePlan(recipe, filters) {
|
||||
.replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
})()
|
||||
: 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"
|
||||
? (() => {
|
||||
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 PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
|
||||
.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
||||
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
|
||||
.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));
|
||||
: 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"
|
||||
? (() => {
|
||||
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"
|
||||
? (() => {
|
||||
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 PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
|
||||
.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
||||
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
|
||||
.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,
|
||||
|
||||
@@ -689,6 +689,130 @@ function buildInventoryOnHandAggregate(rows, asOfDate) {
|
||||
return left.item.localeCompare(right.item, "ru");
|
||||
});
|
||||
}
|
||||
function inventoryTraceDateLabel(value) {
|
||||
return value ? formatDateRu(value) : "дата не указана";
|
||||
}
|
||||
function hasInventoryAccountPrefix(value, prefix) {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.replace(",", ".");
|
||||
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
|
||||
}
|
||||
function isInventoryPurchaseMovement(row) {
|
||||
return hasInventoryAccountPrefix(row.account_dt, "41.01");
|
||||
}
|
||||
function isInventorySaleMovement(row) {
|
||||
return hasInventoryAccountPrefix(row.account_kt, "41.01");
|
||||
}
|
||||
function looksLikeInventoryTraceDocumentToken(value) {
|
||||
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) {
|
||||
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) {
|
||||
const itemToken = normalizeEntityToken(extractInventoryItemName(row));
|
||||
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
|
||||
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
|
||||
const candidates = [];
|
||||
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);
|
||||
}
|
||||
function summarizeInventoryTraceRows(rows) {
|
||||
const items = uniqueStrings(rows
|
||||
.map((row) => extractInventoryItemName(row))
|
||||
.filter((item) => Boolean(item)));
|
||||
const warehouses = uniqueStrings(rows
|
||||
.map((row) => extractInventoryWarehouseName(row))
|
||||
.filter((item) => Boolean(item)));
|
||||
const organizations = uniqueStrings(rows
|
||||
.map((row) => extractInventoryOrganizationName(row))
|
||||
.filter((item) => 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, limit = 10) {
|
||||
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(" | ");
|
||||
});
|
||||
}
|
||||
function liabilityCategoryLabel(category) {
|
||||
if (category === "supplier_or_contractor") {
|
||||
return "поставщики/подрядчики";
|
||||
@@ -2873,6 +2997,257 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||
}
|
||||
};
|
||||
}
|
||||
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 = [
|
||||
`Собран подтвержденный список документов поступления по товару ${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 = [
|
||||
`Собран подтвержденный закупочный след по товару ${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 = [
|
||||
`Собран 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 = [
|
||||
`Собран 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 = [
|
||||
`Собран подтвержденный след выбытия по товару ${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 = [
|
||||
`Собрана документальная цепочка по товару ${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);
|
||||
|
||||
@@ -390,6 +390,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
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") {
|
||||
@@ -420,6 +426,19 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
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 &&
|
||||
!hasExplicitPeriodLiteral(userMessage) &&
|
||||
@@ -460,6 +479,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
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";
|
||||
|
||||
+20
-2
@@ -3856,12 +3856,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));
|
||||
}
|
||||
function resolveAssistantOrchestrationDecision(input) {
|
||||
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
|
||||
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
|
||||
@@ -3915,11 +3930,13 @@ 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) ||
|
||||
@@ -4065,7 +4082,7 @@ 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);
|
||||
@@ -4217,6 +4234,7 @@ 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,28 +42,73 @@ describe("address capability policy", () => {
|
||||
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||
});
|
||||
|
||||
it("marks inventory provenance intents as blocked exact-capability gaps", () => {
|
||||
it("enables inventory provenance intent as compute exact capability", () => {
|
||||
const decision = resolveAddressCapabilityRouteDecision("inventory_purchase_provenance_for_item");
|
||||
expect(decision.capability_id).toBe("inventory_inventory_purchase_provenance_for_item");
|
||||
expect(decision.capability_layer).toBe("compute");
|
||||
expect(decision.capability_route_mode).toBe("exact");
|
||||
expect(decision.capability_route_enabled).toBe(false);
|
||||
expect(decision.capability_route_reason).toBe("inventory_provenance_route_not_implemented");
|
||||
expect(isCapabilityRouteBlocked(decision)).toBe(true);
|
||||
expect(decision.capability_route_enabled).toBe(true);
|
||||
expect(decision.capability_route_reason).toBe("inventory_trace_route_enabled");
|
||||
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||
});
|
||||
|
||||
it("marks purchase-to-sale trace and aging intents as blocked exact-capability gaps", () => {
|
||||
it("enables inventory supplier overlap intent as compute exact capability", () => {
|
||||
const decision = resolveAddressCapabilityRouteDecision("inventory_supplier_stock_overlap_as_of_date");
|
||||
expect(decision.capability_id).toBe("inventory_inventory_supplier_stock_overlap_as_of_date");
|
||||
expect(decision.capability_layer).toBe("compute");
|
||||
expect(decision.capability_route_mode).toBe("exact");
|
||||
expect(decision.capability_route_enabled).toBe(true);
|
||||
expect(decision.capability_route_reason).toBe("inventory_supplier_stock_overlap_route_enabled");
|
||||
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||
});
|
||||
|
||||
it("enables purchase-document and sale-trace intents", () => {
|
||||
const purchaseDocsDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_documents_for_item");
|
||||
expect(purchaseDocsDecision.capability_route_enabled).toBe(true);
|
||||
expect(purchaseDocsDecision.capability_route_reason).toBe("inventory_trace_route_enabled");
|
||||
|
||||
const saleTraceDecision = resolveAddressCapabilityRouteDecision("inventory_sale_trace_for_item");
|
||||
expect(saleTraceDecision.capability_route_enabled).toBe(true);
|
||||
expect(saleTraceDecision.capability_route_reason).toBe("inventory_trace_route_enabled");
|
||||
});
|
||||
|
||||
|
||||
it("keeps inventory purchase documents and sale chain on exact compute routes", () => {
|
||||
const purchaseDocsDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_documents_for_item");
|
||||
expect(purchaseDocsDecision.capability_id).toBe("inventory_inventory_purchase_documents_for_item");
|
||||
expect(purchaseDocsDecision.capability_layer).toBe("compute");
|
||||
expect(purchaseDocsDecision.capability_route_mode).toBe("exact");
|
||||
|
||||
const chainDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_to_sale_chain");
|
||||
expect(chainDecision.capability_id).toBe("inventory_inventory_purchase_to_sale_chain");
|
||||
expect(chainDecision.capability_layer).toBe("compute");
|
||||
expect(chainDecision.capability_route_mode).toBe("exact");
|
||||
});
|
||||
|
||||
it("enables purchase-to-sale trace 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");
|
||||
expect(chainDecision.capability_route_enabled).toBe(false);
|
||||
expect(isCapabilityRouteBlocked(chainDecision)).toBe(true);
|
||||
expect(chainDecision.capability_route_enabled).toBe(true);
|
||||
expect(chainDecision.capability_route_reason).toBe("inventory_purchase_to_sale_chain_route_enabled");
|
||||
expect(isCapabilityRouteBlocked(chainDecision)).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");
|
||||
expect(agingDecision.capability_route_enabled).toBe(false);
|
||||
expect(isCapabilityRouteBlocked(agingDecision)).toBe(true);
|
||||
expect(agingDecision.capability_route_enabled).toBe(true);
|
||||
expect(agingDecision.capability_route_reason).toBe("inventory_aging_route_enabled");
|
||||
expect(isCapabilityRouteBlocked(agingDecision)).toBe(false);
|
||||
});
|
||||
|
||||
it("enables aging by purchase date as an exact inventory capability", () => {
|
||||
const decision = resolveAddressCapabilityRouteDecision("inventory_aging_by_purchase_date");
|
||||
expect(decision.capability_id).toBe("inventory_inventory_aging_by_purchase_date");
|
||||
expect(decision.capability_layer).toBe("compute");
|
||||
expect(decision.capability_route_mode).toBe("exact");
|
||||
expect(decision.capability_route_enabled).toBe(true);
|
||||
expect(decision.capability_route_reason).toBe("inventory_aging_route_enabled");
|
||||
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||
});
|
||||
|
||||
it("maps document drilldown intent to navigation capability", () => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
|
||||
|
||||
describe("inventory supplier-item-buyer chain filter extraction", () => {
|
||||
it("trims buyer tail from item filter in documentary chain questions", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы",
|
||||
"inventory_purchase_to_sale_chain"
|
||||
).extracted_filters;
|
||||
|
||||
expect(filters.item).toContain("1000*400*2100");
|
||||
expect(filters.item).not.toContain("покупатель");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { executeAddressMcpQueryMock } = vi.hoisted(() => ({
|
||||
executeAddressMcpQueryMock: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../src/services/addressMcpClient", async () => {
|
||||
const actual = await vi.importActual<typeof import("../src/services/addressMcpClient")>(
|
||||
"../src/services/addressMcpClient"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
executeAddressMcpQuery: executeAddressMcpQueryMock
|
||||
};
|
||||
});
|
||||
|
||||
import { AddressQueryService } from "../src/services/addressQueryService";
|
||||
|
||||
afterEach(() => {
|
||||
executeAddressMcpQueryMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("inventory selected-object follow-up", () => {
|
||||
it("auto-broadens dated stock follow-up window for inventory provenance", async () => {
|
||||
executeAddressMcpQueryMock
|
||||
.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2021-03-09T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000001 от 09.03.2021 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 442075,
|
||||
SubcontoDt1: "Рабочая станция универсального специалиста с угловым элементом.",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "АСТРА",
|
||||
SubcontoKt2: "А_03/2021 от 01.03.2021г.",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
fetched_rows: 2,
|
||||
matched_rows: 2,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2020-02-11T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2020 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 165.83,
|
||||
SubcontoDt1: "Кромка с клеем 33 альмандин 137 м",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "Торговый дом \\Союз МСК\\",
|
||||
SubcontoKt2: "Договор поставки № 12 от 01.02.2020",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
{
|
||||
Period: "2020-02-11T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2020 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 165.83,
|
||||
SubcontoDt1: "Кромка с клеем 33 дуб ниагара 137 м",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "Торговый дом \\Союз МСК\\",
|
||||
SubcontoKt2: "Договор поставки № 12 от 01.02.2020",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
'По выбранному объекту "Кромка с клеем 33 альмандин 137 м | склад: Основной склад | количество: 1,000 | стоимость: 165,83 ₽ | организация: ООО \\\\Альтернатива Плюс\\\\ | дата строки: 2021-03-31T23:59:59Z": От какого поставщика куплен товар'
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м");
|
||||
expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data");
|
||||
expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз МСК\\");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
|
||||
|
||||
describe("inventory warehouse anchor extraction", () => {
|
||||
it("does not treat 'по состоянию ...' as warehouse name in stock snapshot questions", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"Какие товары находятся на складе по состоянию на 15 марта 2020 года?",
|
||||
"inventory_on_hand_as_of_date"
|
||||
).extracted_filters;
|
||||
|
||||
expect(filters.as_of_date).toBe("2020-03-15");
|
||||
expect(filters.warehouse).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not treat month phrases as warehouse name in stock snapshot questions", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"Какие товары лежат на складе на март 2019",
|
||||
"inventory_on_hand_as_of_date"
|
||||
).extracted_filters;
|
||||
|
||||
expect(filters.period_from).toBe("2019-03-01");
|
||||
expect(filters.period_to).toBe("2019-03-31");
|
||||
expect(filters.as_of_date).toBe("2019-03-31");
|
||||
expect(filters.warehouse).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,222 @@ describe("address query shape classifier", () => {
|
||||
expect(result.mode).toBe("address_query");
|
||||
});
|
||||
|
||||
it("extracts item anchor for inventory provenance questions", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"От какого поставщика куплен товар Шкаф картотечный?",
|
||||
"inventory_purchase_provenance_for_item"
|
||||
).extracted_filters;
|
||||
expect(filters.item).toBe("Шкаф картотечный");
|
||||
});
|
||||
|
||||
it("cuts inventory item anchor before current-stock tail", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
|
||||
"inventory_purchase_provenance_for_item"
|
||||
).extracted_filters;
|
||||
expect(filters.item).toBe("Диван трехместный");
|
||||
expect(filters.warehouse).toBe("Основной склад");
|
||||
});
|
||||
|
||||
it("cuts inventory item anchor before chain suffix and ignores chain pseudo-warehouse", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
|
||||
"inventory_purchase_to_sale_chain"
|
||||
).extracted_filters;
|
||||
expect(filters.item).toBe("Шкаф картотечный 1000*400*2100");
|
||||
expect(filters.warehouse).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cuts inventory item anchor before purchase-doc residue tail", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад",
|
||||
"inventory_purchase_documents_for_item"
|
||||
).extracted_filters;
|
||||
expect(filters.item).toBe("Диван трехместный");
|
||||
expect(filters.warehouse).toBe("Основной склад");
|
||||
});
|
||||
|
||||
it("extracts item anchor from selected-object inventory row for provenance follow-up", () => {
|
||||
const filters = extractAddressFilters(
|
||||
'По выбранному объекту "Кромка с клеем 33 альмандин 137 м | склад: Основной склад | количество: 1,000 | стоимость: 165,83 ₽ | организация: ООО \\\\Альтернатива Плюс\\\\ | дата строки: 2021-03-31T23:59:59Z": От какого поставщика куплен товар',
|
||||
"inventory_purchase_provenance_for_item"
|
||||
).extracted_filters;
|
||||
expect(filters.item).toBe("Кромка с клеем 33 альмандин 137 м");
|
||||
});
|
||||
|
||||
it("keeps full supplier anchor with comma suffix for stock-overlap questions", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад?",
|
||||
"inventory_supplier_stock_overlap_as_of_date"
|
||||
).extracted_filters;
|
||||
expect(filters.counterparty).toBe("Гамма-мебель, ООО");
|
||||
});
|
||||
|
||||
it("does not capture organization wording as supplier anchor in overlap questions", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад организации ООО \\Альтернатива Плюс\\",
|
||||
"inventory_supplier_stock_overlap_as_of_date"
|
||||
).extracted_filters;
|
||||
expect(filters.counterparty).toBeUndefined();
|
||||
expect(filters.warehouse).toBe("Основной склад");
|
||||
});
|
||||
|
||||
it("extracts item anchor for inventory aging questions", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"Относится ли товар Шкаф картотечный 1000*400*2100 в остатке на дату 2020-03-31 к старым закупкам",
|
||||
"inventory_aging_by_purchase_date"
|
||||
).extracted_filters;
|
||||
expect(filters.item).toBe("Шкаф картотечный 1000*400*2100");
|
||||
});
|
||||
|
||||
it("builds dedicated inventory purchase-documents query plan", () => {
|
||||
const selected = selectAddressRecipe("inventory_purchase_documents_for_item", {
|
||||
item: "Шкаф картотечный",
|
||||
as_of_date: "2020-03-31"
|
||||
});
|
||||
expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_purchase_documents_for_item_v1");
|
||||
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
|
||||
item: "Шкаф картотечный",
|
||||
as_of_date: "2020-03-31"
|
||||
});
|
||||
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
|
||||
expect(plan.query).toContain("Движения.СчетДт");
|
||||
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1");
|
||||
expect(plan.query).toContain("41.01");
|
||||
});
|
||||
|
||||
it("builds overlap recipe for supplier-linked stock slice", () => {
|
||||
const selected = selectAddressRecipe("inventory_supplier_stock_overlap_as_of_date", {
|
||||
counterparty: "Гамма-мебель, ООО",
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2020-03-31"
|
||||
});
|
||||
expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_supplier_stock_overlap_as_of_date_v1");
|
||||
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
|
||||
counterparty: "Гамма-мебель, ООО",
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2020-03-31"
|
||||
});
|
||||
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
|
||||
expect(plan.query).toContain("Движения.СчетДт");
|
||||
expect(plan.query).toContain("41.01");
|
||||
});
|
||||
|
||||
it("renders inventory purchase documents from purchase-side 41.01 movements", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_purchase_documents_for_item",
|
||||
[
|
||||
{
|
||||
period: "2020-03-15T12:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 0001",
|
||||
account_dt: "41.01",
|
||||
account_kt: "60.01",
|
||||
amount: 150000,
|
||||
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
|
||||
item: "Шкаф картотечный",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО Ромашка"
|
||||
}
|
||||
],
|
||||
{
|
||||
asOfDate: "2020-03-31",
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
expect(reply.text).toContain("Шкаф картотечный");
|
||||
expect(reply.text).toContain("Поступление товаров и услуг 0001");
|
||||
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
|
||||
});
|
||||
|
||||
it("renders inventory provenance summary from purchase-side 41.01 movements", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_purchase_provenance_for_item",
|
||||
[
|
||||
{
|
||||
period: "2020-03-15T12:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 0001",
|
||||
account_dt: "41.01",
|
||||
account_kt: "60.01",
|
||||
amount: 150000,
|
||||
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
|
||||
item: "Шкаф картотечный",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО Ромашка"
|
||||
}
|
||||
],
|
||||
{
|
||||
asOfDate: "2020-03-31",
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
expect(reply.text).toContain("закупочный след");
|
||||
expect(reply.text).toContain("Гамма-мебель, ООО");
|
||||
expect(reply.semantics?.balance_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it("renders inventory sale trace from credit-side 41.01 movements", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_sale_trace_for_item",
|
||||
[
|
||||
{
|
||||
period: "2020-04-10T12:00:00Z",
|
||||
registrator: "Реализация товаров и услуг 0007",
|
||||
account_dt: "90.02",
|
||||
account_kt: "41.01",
|
||||
amount: 210000,
|
||||
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Департамент капитального ремонта города Москвы"],
|
||||
item: "Шкаф картотечный",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО Ромашка"
|
||||
}
|
||||
],
|
||||
{
|
||||
asOfDate: "2020-04-30",
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
expect(reply.text).toContain("след выбытия");
|
||||
expect(reply.text).toContain("Реализация товаров и услуг 0007");
|
||||
expect(reply.text).toContain("Департамент капитального ремонта города Москвы");
|
||||
});
|
||||
|
||||
it("renders purchase-to-sale chain from both sides of 41.01", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_purchase_to_sale_chain",
|
||||
[
|
||||
{
|
||||
period: "2020-03-15T12:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 0001",
|
||||
account_dt: "41.01",
|
||||
account_kt: "60.01",
|
||||
amount: 150000,
|
||||
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
|
||||
item: "Шкаф картотечный",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО Ромашка"
|
||||
},
|
||||
{
|
||||
period: "2020-04-10T12:00:00Z",
|
||||
registrator: "Реализация товаров и услуг 0007",
|
||||
account_dt: "90.02",
|
||||
account_kt: "41.01",
|
||||
amount: 210000,
|
||||
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Департамент капитального ремонта города Москвы"],
|
||||
item: "Шкаф картотечный",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО Ромашка"
|
||||
}
|
||||
],
|
||||
{
|
||||
asOfDate: "2020-04-30",
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
expect(reply.text).toContain("документальная цепочка");
|
||||
expect(reply.text).toContain("Поступление товаров и услуг 0001");
|
||||
expect(reply.text).toContain("Реализация товаров и услуг 0007");
|
||||
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
|
||||
});
|
||||
});
|
||||
|
||||
describe("address compose stage utf8 headers", () => {
|
||||
@@ -4090,6 +4306,39 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||
expect(plan.query).toContain("ДоговорКонтрагента");
|
||||
});
|
||||
|
||||
it("keeps recipe-default limits for inventory exact intents", () => {
|
||||
const onHand = extractAddressFilters("Какие товары сейчас лежат на складе?", "inventory_on_hand_as_of_date");
|
||||
const provenance = extractAddressFilters(
|
||||
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад?",
|
||||
"inventory_purchase_provenance_for_item"
|
||||
);
|
||||
const purchaseDocs = extractAddressFilters(
|
||||
"По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад?",
|
||||
"inventory_purchase_documents_for_item"
|
||||
);
|
||||
const overlap = extractAddressFilters(
|
||||
"Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад?",
|
||||
"inventory_supplier_stock_overlap_as_of_date"
|
||||
);
|
||||
const saleTrace = extractAddressFilters("Кому был продан товар Шкаф картотечный 1000*400*2100?", "inventory_sale_trace_for_item");
|
||||
const chain = extractAddressFilters(
|
||||
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа?",
|
||||
"inventory_purchase_to_sale_chain"
|
||||
);
|
||||
const aging = extractAddressFilters(
|
||||
"Есть ли среди текущих остатков на складе Основной склад позиции, закупленные очень давно?",
|
||||
"inventory_aging_by_purchase_date"
|
||||
);
|
||||
|
||||
expect(onHand.extracted_filters.limit).toBeUndefined();
|
||||
expect(provenance.extracted_filters.limit).toBeUndefined();
|
||||
expect(purchaseDocs.extracted_filters.limit).toBeUndefined();
|
||||
expect(overlap.extracted_filters.limit).toBeUndefined();
|
||||
expect(saleTrace.extracted_filters.limit).toBeUndefined();
|
||||
expect(chain.extracted_filters.limit).toBeUndefined();
|
||||
expect(aging.extracted_filters.limit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("selects customer value recipe and keeps top-20 default", () => {
|
||||
const selected = selectAddressRecipe("customer_revenue_and_payments", {});
|
||||
expect(selected.selected_recipe).toBeTruthy();
|
||||
@@ -4339,6 +4588,52 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||
expect(result.confidence).toBe("high");
|
||||
});
|
||||
|
||||
it("detects colloquial warehouse snapshot wording as inventory-on-hand intent", () => {
|
||||
const result = resolveAddressIntent("что у нас на складе на март 2017");
|
||||
expect(result.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result.confidence).toBe("high");
|
||||
});
|
||||
|
||||
it("routes account 41 composition wording into inventory snapshot intent", () => {
|
||||
const result = resolveAddressIntent("Из каких товаров состоит остаток по 41 счету");
|
||||
expect(result.intent).toBe("inventory_on_hand_as_of_date");
|
||||
});
|
||||
|
||||
it("routes account 41 date snapshot wording into inventory snapshot intent", () => {
|
||||
const result = resolveAddressIntent("Какие товары числятся на 41 счете на дату 2020-03-31");
|
||||
expect(result.intent).toBe("inventory_on_hand_as_of_date");
|
||||
});
|
||||
|
||||
it("routes supplier stock overlap wording into overlap intent", () => {
|
||||
const result = resolveAddressIntent("Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад");
|
||||
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
|
||||
});
|
||||
|
||||
it("routes supplier to buyer chain wording into purchase-to-sale chain intent", () => {
|
||||
const result = resolveAddressIntent(
|
||||
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"
|
||||
);
|
||||
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||
});
|
||||
|
||||
it("routes supplier-to-buyer inventory chains as exact trace intents", () => {
|
||||
const result = resolveAddressIntent(
|
||||
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"
|
||||
);
|
||||
|
||||
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||
expect(result.confidence).toBe("medium");
|
||||
});
|
||||
|
||||
it("routes old purchase residue questions to aging-by-purchase-date", () => {
|
||||
const result = resolveAddressIntent(
|
||||
"Относится ли товар Шкаф картотечный 1000*400*2100 в остатке на дату 2020-03-31 к старым закупкам"
|
||||
);
|
||||
|
||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
expect(result.confidence).toBe("high");
|
||||
});
|
||||
|
||||
it("derives as_of_date for inventory-on-hand from explicit month window", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"Какие товары лежат на складе на март 2020",
|
||||
@@ -4400,6 +4695,13 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
});
|
||||
|
||||
it("keeps direct item supplier questions in provenance intent even with current-stock tail", () => {
|
||||
const result = resolveAddressIntent(
|
||||
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад?"
|
||||
);
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
});
|
||||
|
||||
it("keeps inventory supplier overlap questions out of on-hand routing", () => {
|
||||
const result = resolveAddressIntent("Какие товары от поставщика Альфа сейчас лежат на складе?");
|
||||
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
|
||||
@@ -4414,7 +4716,7 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||
const result = resolveAddressIntent(
|
||||
"Через какие документы прошел путь товара Шкаф картоотечный: закупка -> склад -> продажа?"
|
||||
);
|
||||
expect(result.intent).toBe("inventory_sale_trace_for_item");
|
||||
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||
});
|
||||
|
||||
it("keeps inventory provenance wording out of inventory-on-hand routing", () => {
|
||||
@@ -4427,6 +4729,16 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
});
|
||||
|
||||
it("keeps very old purchase wording out of on-hand routing", () => {
|
||||
const result = resolveAddressIntent("Относится ли товар к закупленным задолго до даты остатка на складе?");
|
||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
});
|
||||
|
||||
it("routes old stock wording with residue anchor to aging intent", () => {
|
||||
const result = resolveAddressIntent("Это остаток по очень давно купленному товару?");
|
||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
});
|
||||
|
||||
it("routes purchase-document trace wording to dedicated inventory intent", () => {
|
||||
const result = resolveAddressIntent("Какими документами был куплен товар Шкаф картоотечный?");
|
||||
expect(result.intent).toBe("inventory_purchase_documents_for_item");
|
||||
@@ -4436,9 +4748,33 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
});
|
||||
|
||||
it("routes residue wording with explicit cut-off date into aging intent", () => {
|
||||
const result = resolveAddressIntent(
|
||||
"Есть ли среди текущих остатков на складе Основной склад позиции, закупленные задолго до 2020-03-31?"
|
||||
);
|
||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
});
|
||||
|
||||
it("keeps unresolved stock provenance wording out of open-items routing", () => {
|
||||
const result = resolveAddressIntent("Какие товары сейчас висят в остатке без понятной привязки к поставщику?");
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
|
||||
});
|
||||
|
||||
it("routes documentary supplier-to-buyer chain wording into inventory chain intent", () => {
|
||||
const result = resolveAddressIntent(
|
||||
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы?"
|
||||
);
|
||||
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||
});
|
||||
|
||||
it("routes explicit supplier-item-buyer chain wording into inventory chain intent", () => {
|
||||
const result = resolveAddressIntent("supplier -> item -> buyer");
|
||||
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||
});
|
||||
|
||||
it("routes documented supplier-item-buyer chain wording into inventory chain intent", () => {
|
||||
const result = resolveAddressIntent("Документально подтвержденная цепочка: поставщик -> товар -> покупатель");
|
||||
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -254,6 +254,102 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||||
});
|
||||
|
||||
it("keeps colloquial warehouse snapshot questions in exact inventory contour when llm rewrites them abstractly", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
trace_id: "norm-predecompose-inventory-colloquial",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "что у нас на складе на март 2017",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "что у нас на складе на март 2017",
|
||||
normalized_fragment_text: "информация о наличии товаров на складе за март 2017 года",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: [],
|
||||
account_hints: [],
|
||||
document_hints: [],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "explicit",
|
||||
value: "2017-03",
|
||||
confidence: "high"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: "medium",
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: [],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: `asst-predecompose-inventory-colloquial-${Date.now()}`,
|
||||
user_message: "что у нас на складе на март 2017",
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].message.toLowerCase()).toContain("складе");
|
||||
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
|
||||
expect(response.debug?.llm_predecompose_contract?.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||||
});
|
||||
|
||||
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
|
||||
@@ -264,6 +264,36 @@ describe("assistant orchestration contract", () => {
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps inventory provenance and sale-trace queries in address lane", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
|
||||
effectiveAddressUserMessage: "От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
|
||||
followupContext: {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
as_of_date: "2020-03-31"
|
||||
}
|
||||
},
|
||||
llmPreDecomposeMeta: {
|
||||
applied: true,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract: {
|
||||
mode: "address_query",
|
||||
mode_confidence: "high",
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
intent_confidence: "high"
|
||||
}
|
||||
} as any,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps customer-value ranking question in address lane even when LLM semantic guard rejects canonical rewrite", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "кто больше всего принес денег в 2020",
|
||||
@@ -558,6 +588,47 @@ describe("assistant orchestration contract", () => {
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps documentary inventory chain verification in address lane for supported exact intent", () => {
|
||||
const question =
|
||||
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: question,
|
||||
effectiveAddressUserMessage: question,
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: {
|
||||
applied: true,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract: {
|
||||
mode: "deep_analysis",
|
||||
mode_confidence: "high",
|
||||
intent: "inventory_purchase_to_sale_chain",
|
||||
intent_confidence: "medium"
|
||||
},
|
||||
semanticExtractionContract: {
|
||||
valid: true,
|
||||
apply_canonical_recommended: true,
|
||||
reason_codes: ["deep_investigation_signal_detected"],
|
||||
guard_hints: {
|
||||
deep_investigation_signal_detected: true
|
||||
},
|
||||
extraction: {
|
||||
query_shape: "VERIFY_FACTUAL",
|
||||
aggregation_profile: "unknown"
|
||||
}
|
||||
}
|
||||
} as any,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
|
||||
expect(decision.orchestrationContract?.semantic_route_arbitration?.supported_address_intent_detected).toBe(true);
|
||||
expect(decision.orchestrationContract?.semantic_route_arbitration?.strict_deep_investigation_bypass_allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps 'a na tekushuyu datu' follow-up in address lane when previous VAT context exists", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "а на текущую дату",
|
||||
|
||||
Reference in New Issue
Block a user