ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить selected-object follow-up по складу и включить разговорные/UI-варианты в обязательный domain-loop
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user