ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Протянуть exact capability складских остатков товаров на дату
This commit is contained in:
@@ -26,6 +26,7 @@ export interface AddressCapabilityRouteDecision {
|
||||
const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([
|
||||
"account_balance_snapshot",
|
||||
"documents_forming_balance",
|
||||
"inventory_on_hand_as_of_date",
|
||||
"open_contracts_confirmed_as_of_date",
|
||||
"payables_confirmed_as_of_date",
|
||||
"receivables_confirmed_as_of_date",
|
||||
@@ -74,6 +75,9 @@ function defaultCapabilityId(intent: AddressIntent): string {
|
||||
if (intent === "vat_liability_confirmed_for_tax_period") {
|
||||
return "confirmed_vat_liability_for_tax_period";
|
||||
}
|
||||
if (intent === "inventory_on_hand_as_of_date") {
|
||||
return "confirmed_inventory_on_hand_as_of_date";
|
||||
}
|
||||
if (intent === "list_payables_counterparties") {
|
||||
return "payables_candidates_list";
|
||||
}
|
||||
@@ -134,6 +138,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
|
||||
: "vat_liability_confirmed_tax_period_route_disabled_by_flag"
|
||||
};
|
||||
}
|
||||
if (intent === "inventory_on_hand_as_of_date") {
|
||||
return {
|
||||
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||
reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
|
||||
? "inventory_on_hand_route_enabled"
|
||||
: "inventory_on_hand_route_disabled_by_flag"
|
||||
};
|
||||
}
|
||||
if (intent === "list_payables_counterparties") {
|
||||
return {
|
||||
enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,
|
||||
|
||||
@@ -927,6 +927,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
|
||||
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||
return ["account", "as_of_date"];
|
||||
}
|
||||
if (intent === "inventory_on_hand_as_of_date") {
|
||||
return ["as_of_date"];
|
||||
}
|
||||
if (intent === "payables_confirmed_as_of_date") {
|
||||
return ["as_of_date"];
|
||||
}
|
||||
@@ -957,6 +960,7 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
|
||||
|
||||
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
|
||||
return (
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "open_items_by_counterparty_or_contract" ||
|
||||
intent === "list_open_contracts" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
@@ -1163,6 +1167,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
if (
|
||||
(intent === "account_balance_snapshot" ||
|
||||
intent === "documents_forming_balance" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date") &&
|
||||
|
||||
@@ -1542,6 +1542,22 @@ function hasAccountNumberAnchor(text: string): boolean {
|
||||
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
||||
}
|
||||
|
||||
function hasInventoryOnHandSignal(text: string): boolean {
|
||||
const hasStockLexeme =
|
||||
/(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text);
|
||||
if (!hasStockLexeme) {
|
||||
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);
|
||||
}
|
||||
|
||||
export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
|
||||
const text = String(userMessage ?? "").trim().toLowerCase();
|
||||
|
||||
@@ -1659,6 +1675,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
if (hasInventoryOnHandSignal(text)) {
|
||||
return {
|
||||
intent: "inventory_on_hand_as_of_date",
|
||||
confidence: "high",
|
||||
reasons: ["inventory_on_hand_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasOpenContractsListSignal(text)) {
|
||||
return {
|
||||
intent: "open_contracts_confirmed_as_of_date",
|
||||
|
||||
@@ -100,6 +100,14 @@ const ADDRESS_ENTITY_TOKENS = [
|
||||
"поступлени",
|
||||
"списан",
|
||||
"списани",
|
||||
"склад",
|
||||
"складе",
|
||||
"складу",
|
||||
"товар",
|
||||
"товары",
|
||||
"товарн",
|
||||
"номенклат",
|
||||
"материал",
|
||||
"долг",
|
||||
"должен",
|
||||
"должны",
|
||||
|
||||
@@ -55,6 +55,10 @@ interface NormalizedAddressRow {
|
||||
account_kt: string | null;
|
||||
amount: number | null;
|
||||
analytics: string[];
|
||||
quantity?: number | null;
|
||||
item?: string | null;
|
||||
warehouse?: string | null;
|
||||
organization?: string | null;
|
||||
}
|
||||
|
||||
interface AddressTryHandleOptions {
|
||||
@@ -321,6 +325,26 @@ function detectVatMetadataObjectType(fullName: string): VatMetadataObject["objec
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
const normalized = valueAsString(value).trim();
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstFiniteNumber(...values: unknown[]): number | null {
|
||||
for (const value of values) {
|
||||
const parsed = parseFiniteNumber(value);
|
||||
if (parsed !== null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractVatMetadataObjects(rows: Array<Record<string, unknown>>): VatMetadataObject[] {
|
||||
const out: VatMetadataObject[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -1086,6 +1110,17 @@ function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
|
||||
"Контрагент",
|
||||
"Contract",
|
||||
"Договор",
|
||||
"Item",
|
||||
"item",
|
||||
"Номенклатура",
|
||||
"НоменклатураПредставление",
|
||||
"Warehouse",
|
||||
"warehouse",
|
||||
"Склад",
|
||||
"СкладПредставление",
|
||||
"Quantity",
|
||||
"quantity",
|
||||
"Количество",
|
||||
"Organization",
|
||||
"Организация",
|
||||
"ОрганизацияПредставление",
|
||||
@@ -1108,6 +1143,10 @@ function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
|
||||
lowerKey.includes("субконто") ||
|
||||
lowerKey.includes("контраг") ||
|
||||
lowerKey.includes("договор") ||
|
||||
lowerKey.includes("warehouse") ||
|
||||
lowerKey.includes("склад") ||
|
||||
lowerKey.includes("item") ||
|
||||
lowerKey.includes("номенклат") ||
|
||||
lowerKey.includes("organization") ||
|
||||
lowerKey.includes("организац")
|
||||
) {
|
||||
@@ -1132,6 +1171,16 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
|
||||
const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null;
|
||||
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
|
||||
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
|
||||
const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity);
|
||||
const item = firstNonEmptyString(row.Номенклатура, row.Item, row.item, row.НоменклатураПредставление);
|
||||
const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление);
|
||||
const organization = firstNonEmptyString(
|
||||
row.Организация,
|
||||
row.Organization,
|
||||
row.organization,
|
||||
row.organization_name,
|
||||
row.ОрганизацияПредставление
|
||||
);
|
||||
const analytics = collectAnalyticsStrings(row);
|
||||
|
||||
return {
|
||||
@@ -1140,7 +1189,11 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
|
||||
account_dt: accountDt,
|
||||
account_kt: accountKt,
|
||||
amount,
|
||||
analytics
|
||||
analytics,
|
||||
quantity,
|
||||
item,
|
||||
warehouse,
|
||||
organization
|
||||
};
|
||||
})
|
||||
.filter((item) => Boolean(item.period || item.registrator));
|
||||
@@ -1313,6 +1366,7 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
|
||||
return (
|
||||
intent === "account_balance_snapshot" ||
|
||||
intent === "documents_forming_balance" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
@@ -2164,6 +2218,8 @@ function buildLimitedOffers(input: {
|
||||
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
|
||||
} else if (input.intent === "receivables_confirmed_as_of_date") {
|
||||
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76");
|
||||
} else if (input.intent === "inventory_on_hand_as_of_date") {
|
||||
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") {
|
||||
@@ -2223,6 +2279,7 @@ function buildLimitedIntentSignalLine(input: {
|
||||
open_contracts_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный список договоров с открытыми взаиморасчетами на дату.",
|
||||
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
|
||||
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
|
||||
inventory_on_hand_as_of_date: "Сигнал запроса: нужен подтвержденный срез товаров на складе на дату.",
|
||||
receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.",
|
||||
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.",
|
||||
vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату.",
|
||||
@@ -2425,7 +2482,9 @@ function buildLimitedExecutionResult(input: {
|
||||
resultSemantics.result_mode
|
||||
);
|
||||
const exactLimitedReason =
|
||||
input.intent.intent === "payables_confirmed_as_of_date"
|
||||
input.intent.intent === "inventory_on_hand_as_of_date"
|
||||
? "exact_inventory_mode_limited_response"
|
||||
: input.intent.intent === "payables_confirmed_as_of_date"
|
||||
? "exact_payables_mode_limited_response"
|
||||
: input.intent.intent === "receivables_confirmed_as_of_date"
|
||||
? "exact_receivables_mode_limited_response"
|
||||
@@ -2553,6 +2612,8 @@ export class AddressQueryService {
|
||||
intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
|
||||
const confirmedBalanceVatPayableIntent =
|
||||
intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
|
||||
const confirmedBalanceInventoryIntent =
|
||||
intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance";
|
||||
const payablesConfirmedExecution =
|
||||
confirmedBalancePayablesIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
@@ -2563,7 +2624,11 @@ export class AddressQueryService {
|
||||
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
: null;
|
||||
const inventoryConfirmedExecution = confirmedBalanceInventoryIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
: null;
|
||||
const executionFilters =
|
||||
inventoryConfirmedExecution?.executionFilters ??
|
||||
payablesConfirmedExecution?.executionFilters ??
|
||||
receivablesConfirmedExecution?.executionFilters ??
|
||||
vatPayableConfirmedExecution?.executionFilters ??
|
||||
@@ -2601,6 +2666,17 @@ export class AddressQueryService {
|
||||
baseReasons.push("as_of_date_derived_for_confirmed_vat_payable");
|
||||
}
|
||||
}
|
||||
if (
|
||||
inventoryConfirmedExecution?.asOfDerived &&
|
||||
!(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)
|
||||
) {
|
||||
if (!filters.warnings.includes("as_of_date_derived_for_inventory_on_hand")) {
|
||||
filters.warnings.push("as_of_date_derived_for_inventory_on_hand");
|
||||
}
|
||||
if (!baseReasons.includes("as_of_date_derived_for_inventory_on_hand")) {
|
||||
baseReasons.push("as_of_date_derived_for_inventory_on_hand");
|
||||
}
|
||||
}
|
||||
const capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent);
|
||||
const capabilityAudit = buildCapabilityAudit(intent.intent);
|
||||
const shadowRouteAudit = buildShadowRouteAudit({
|
||||
@@ -2686,6 +2762,12 @@ export class AddressQueryService {
|
||||
) {
|
||||
baseReasons.push("confirmed_balance_exact_receivables_intent");
|
||||
}
|
||||
if (
|
||||
intent.intent === "inventory_on_hand_as_of_date" &&
|
||||
!baseReasons.includes("confirmed_balance_exact_inventory_intent")
|
||||
) {
|
||||
baseReasons.push("confirmed_balance_exact_inventory_intent");
|
||||
}
|
||||
if (
|
||||
intent.intent === "vat_payable_confirmed_as_of_date" &&
|
||||
!baseReasons.includes("confirmed_balance_exact_vat_payable_intent")
|
||||
|
||||
@@ -137,6 +137,26 @@ const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
|
||||
Сумма __ORDER_DIRECTION__
|
||||
`;
|
||||
|
||||
const INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
__AS_OF_EXPR__ КАК Период,
|
||||
"Остатки на дату" КАК Регистратор,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Номенклатура,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК Склад,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
|
||||
Остатки.КоличествоРазвернутыйОстатокДт КАК Количество
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
|
||||
ГДЕ
|
||||
Остатки.КоличествоРазвернутыйОстатокДт > 0
|
||||
И (__INVENTORY_ACCOUNTS_MATCH__)
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Количество __ORDER_DIRECTION__
|
||||
`;
|
||||
|
||||
const BANK_DOCS_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
БанкСписание.Дата КАК Период,
|
||||
@@ -676,6 +696,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||
account_scope_mode: "preferred",
|
||||
query_template: "vat_liability_confirmed_tax_period_profile"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_inventory_on_hand_as_of_date_v1",
|
||||
intent: "inventory_on_hand_as_of_date",
|
||||
purpose: "Build confirmed stock-on-hand snapshot from balances on goods-on-warehouse account 41.01",
|
||||
required_filters: ["as_of_date"],
|
||||
optional_filters: ["period_from", "period_to", "organization", "limit", "sort"],
|
||||
default_limit: 300,
|
||||
account_scope: ["41.01"],
|
||||
account_scope_mode: "strict",
|
||||
query_template: "inventory_on_hand_as_of_balance_profile"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_open_contracts_confirmed_as_of_date_v1",
|
||||
intent: "open_contracts_confirmed_as_of_date",
|
||||
@@ -1035,6 +1066,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
|
||||
intent === "contract_usage_and_value" ||
|
||||
intent === "vat_payable_forecast" ||
|
||||
intent === "vat_liability_confirmed_for_tax_period" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
intent === "list_contracts_by_counterparty" ||
|
||||
intent === "list_documents_by_counterparty" ||
|
||||
@@ -1208,6 +1240,25 @@ export function buildAddressRecipePlan(
|
||||
)
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
})()
|
||||
: recipe.query_template === "inventory_on_hand_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 INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE
|
||||
.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
||||
.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"
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface ComposeStageRow {
|
||||
account_kt: string | null;
|
||||
amount: number | null;
|
||||
analytics: string[];
|
||||
quantity?: number | null;
|
||||
item?: string | null;
|
||||
warehouse?: string | null;
|
||||
organization?: string | null;
|
||||
}
|
||||
|
||||
export interface VatDirectSourceProbeItem {
|
||||
@@ -750,6 +754,162 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractInventoryItemName(row: ComposeStageRow): string | null {
|
||||
const direct = String(row.item ?? "").trim();
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (/(?:склад|warehouse|ооо|ао|пао|зао|ип|организац)/iu.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractInventoryWarehouseName(row: ComposeStageRow): string | null {
|
||||
const direct = String(row.warehouse ?? "").trim();
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
if (/(?:склад|warehouse)/iu.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractInventoryOrganizationName(row: ComposeStageRow): string | null {
|
||||
const direct = String(row.organization ?? "").trim();
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (/(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»„“()\\\/.,;:]))|организац|комитет|департамент|министерств|служб|управлени|казенн|администрац/iu.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractInventoryQuantity(row: ComposeStageRow): number | null {
|
||||
return typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : null;
|
||||
}
|
||||
|
||||
interface InventoryOnHandAggregate {
|
||||
item: string;
|
||||
warehouse: string | null;
|
||||
organization: string | null;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
operations: number;
|
||||
firstPeriod: string | null;
|
||||
lastPeriod: string | null;
|
||||
sourceRefs: string[];
|
||||
}
|
||||
|
||||
function buildInventoryOnHandAggregate(rows: ComposeStageRow[], asOfDate: string): InventoryOnHandAggregate[] {
|
||||
const byPosition = new Map<
|
||||
string,
|
||||
{
|
||||
item: string;
|
||||
warehouse: string | null;
|
||||
organization: string | null;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
operations: number;
|
||||
firstPeriod: string | null;
|
||||
lastPeriod: string | null;
|
||||
sourceRefs: Set<string>;
|
||||
}
|
||||
>();
|
||||
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
|
||||
|
||||
for (const row of rows) {
|
||||
const item = extractInventoryItemName(row);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
const rowTimestamp = toUtcDayTimestamp(row.period);
|
||||
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
|
||||
continue;
|
||||
}
|
||||
const quantity = extractInventoryQuantity(row);
|
||||
if (quantity === null || quantity <= 0) {
|
||||
continue;
|
||||
}
|
||||
const warehouse = extractInventoryWarehouseName(row);
|
||||
const organization = extractInventoryOrganizationName(row);
|
||||
const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0;
|
||||
const key = [normalizeEntityToken(item), normalizeEntityToken(warehouse), normalizeEntityToken(organization)].join("|");
|
||||
const registrator = String(row.registrator ?? "").trim();
|
||||
const current = byPosition.get(key);
|
||||
if (!current) {
|
||||
byPosition.set(key, {
|
||||
item,
|
||||
warehouse,
|
||||
organization,
|
||||
quantity,
|
||||
amount,
|
||||
operations: 1,
|
||||
firstPeriod: row.period,
|
||||
lastPeriod: row.period,
|
||||
sourceRefs: new Set(registrator && registrator !== "Остатки на дату" ? [registrator] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.quantity += quantity;
|
||||
current.amount += amount;
|
||||
current.operations += 1;
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if (registrator && registrator !== "Остатки на дату") {
|
||||
current.sourceRefs.add(registrator);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPosition.values())
|
||||
.map((item) => ({
|
||||
item: item.item,
|
||||
warehouse: item.warehouse,
|
||||
organization: item.organization,
|
||||
quantity: item.quantity,
|
||||
amount: item.amount,
|
||||
operations: item.operations,
|
||||
firstPeriod: item.firstPeriod,
|
||||
lastPeriod: item.lastPeriod,
|
||||
sourceRefs: Array.from(item.sourceRefs).slice(0, 3)
|
||||
}))
|
||||
.filter((item) => item.quantity > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.quantity !== left.quantity) {
|
||||
return right.quantity - left.quantity;
|
||||
}
|
||||
if (right.amount !== left.amount) {
|
||||
return right.amount - left.amount;
|
||||
}
|
||||
return left.item.localeCompare(right.item, "ru");
|
||||
});
|
||||
}
|
||||
|
||||
interface CounterpartyRiskAggregate {
|
||||
name: string;
|
||||
totalAmount: number;
|
||||
@@ -3502,6 +3662,63 @@ export function composeFactualReply(
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "inventory_on_hand_as_of_date") {
|
||||
const asOfDate = resolvePayablesAsOfDate(options);
|
||||
const positions = buildInventoryOnHandAggregate(rows, asOfDate);
|
||||
const uniqueItems = uniqueStrings(positions.map((item) => item.item));
|
||||
const uniqueWarehouses = uniqueStrings(
|
||||
positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)
|
||||
);
|
||||
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
const lines: string[] = [
|
||||
`Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`,
|
||||
"",
|
||||
"Блок 1. Статус результата",
|
||||
"- Результат: подтвержденный список товарных остатков на дату.",
|
||||
"",
|
||||
"Блок 2. Что учтено",
|
||||
`- Дата среза: ${formatDateRu(asOfDate)}.`,
|
||||
"- Контур: остатки по счету 41.01 «Товары на складах».",
|
||||
"- Базовая единица детализации: одна строка = товар, склад и организация на дату.",
|
||||
"",
|
||||
"Блок 3. Сводка",
|
||||
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
|
||||
`- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`,
|
||||
`- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`,
|
||||
`- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`,
|
||||
`- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`,
|
||||
`- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`,
|
||||
"",
|
||||
"Блок 4. Подтвержденные позиции"
|
||||
];
|
||||
|
||||
if (positions.length > 0) {
|
||||
lines.push(
|
||||
...positions.slice(0, 20).map((item, index) => {
|
||||
const warehouseLabel = item.warehouse ?? "склад не определен";
|
||||
const organizationLabel = item.organization ? ` | организация: ${item.organization}` : "";
|
||||
const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : "";
|
||||
const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : "";
|
||||
return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${formatNumberWithDots(item.quantity, 3)} | стоимость: ${formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
|
||||
}
|
||||
|
||||
return {
|
||||
responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||
text: joinLines(lines),
|
||||
semantics: {
|
||||
result_mode: "confirmed_balance",
|
||||
evidence_strength: positions.length > 0 ? "strong" : "medium",
|
||||
balance_confirmed: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "open_contracts_confirmed_as_of_date") {
|
||||
const asOfDate = resolvePayablesAsOfDate(options);
|
||||
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
|
||||
|
||||
@@ -367,13 +367,22 @@ function mergeFollowupFilters(
|
||||
const merged: AddressFilterSet = { ...current };
|
||||
const reasons: string[] = [];
|
||||
if (!followupContext) {
|
||||
if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) {
|
||||
if (
|
||||
(intent === "list_open_contracts" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
intent === "inventory_on_hand_as_of_date") &&
|
||||
!toNonEmptyString(merged.as_of_date)
|
||||
) {
|
||||
const periodToForOpenContracts = toNonEmptyString(merged.period_to);
|
||||
const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
|
||||
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
|
||||
if (derivedAsOfDate) {
|
||||
merged.as_of_date = derivedAsOfDate;
|
||||
reasons.push("as_of_date_derived_from_period_for_open_contracts");
|
||||
reasons.push(
|
||||
intent === "inventory_on_hand_as_of_date"
|
||||
? "as_of_date_derived_from_period_for_inventory"
|
||||
: "as_of_date_derived_from_period_for_open_contracts"
|
||||
);
|
||||
}
|
||||
}
|
||||
return { filters: merged, reasons };
|
||||
@@ -480,6 +489,7 @@ function mergeFollowupFilters(
|
||||
intent === "open_items_by_counterparty_or_contract" ||
|
||||
intent === "list_open_contracts" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date"
|
||||
@@ -561,6 +571,7 @@ function mergeFollowupFilters(
|
||||
intent === "account_balance_snapshot" ||
|
||||
intent === "documents_forming_balance" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date";
|
||||
@@ -598,13 +609,22 @@ function mergeFollowupFilters(
|
||||
reasons.push("period_from_followup_context");
|
||||
}
|
||||
|
||||
if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) {
|
||||
if (
|
||||
(intent === "list_open_contracts" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
intent === "inventory_on_hand_as_of_date") &&
|
||||
!toNonEmptyString(merged.as_of_date)
|
||||
) {
|
||||
const periodToForOpenContracts = toNonEmptyString(merged.period_to);
|
||||
const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
|
||||
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
|
||||
if (derivedAsOfDate) {
|
||||
merged.as_of_date = derivedAsOfDate;
|
||||
reasons.push("as_of_date_derived_from_period_for_open_contracts");
|
||||
reasons.push(
|
||||
intent === "inventory_on_hand_as_of_date"
|
||||
? "as_of_date_derived_from_period_for_inventory"
|
||||
: "as_of_date_derived_from_period_for_open_contracts"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,6 +635,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
|
||||
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
|
||||
account_balance_snapshot: ["account", "as_of_date"],
|
||||
documents_forming_balance: ["account", "as_of_date"],
|
||||
inventory_on_hand_as_of_date: ["as_of_date"],
|
||||
open_contracts_confirmed_as_of_date: ["as_of_date"],
|
||||
payables_confirmed_as_of_date: ["as_of_date"],
|
||||
receivables_confirmed_as_of_date: ["as_of_date"],
|
||||
|
||||
@@ -1024,7 +1024,7 @@ function hasStandaloneAddressTopicSignal(text) {
|
||||
if (!hasRequestCue) {
|
||||
return false;
|
||||
}
|
||||
const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat)/iu.test(normalized);
|
||||
const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|склад|товар|номенклат|материал|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat|warehouse|inventory|stock|item)/iu.test(normalized);
|
||||
if (!hasBusinessObject) {
|
||||
return false;
|
||||
}
|
||||
@@ -3806,6 +3806,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
|
||||
"open_items_by_counterparty_or_contract",
|
||||
"list_payables_counterparties",
|
||||
"list_receivables_counterparties",
|
||||
"inventory_on_hand_as_of_date",
|
||||
"payables_confirmed_as_of_date",
|
||||
"receivables_confirmed_as_of_date",
|
||||
"list_documents_by_contract",
|
||||
@@ -4195,7 +4196,7 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||
}
|
||||
function hasStrongDataIntentSignal(text) {
|
||||
const lower = String(text ?? "").toLowerCase();
|
||||
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм)/i.test(lower);
|
||||
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|склад|товар|номенклат|материал|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|warehouse|inventory|stock|item|организац|компан|контор|фирм)/i.test(lower);
|
||||
}
|
||||
function hasDataRetrievalRequestSignal(text) {
|
||||
const lower = compactWhitespace(String(text ?? "").toLowerCase());
|
||||
@@ -4203,12 +4204,12 @@ function hasDataRetrievalRequestSignal(text) {
|
||||
return false;
|
||||
}
|
||||
const hasBroadInterrogative = /(?:\u0433\u0434\u0435|\u0432\s+\u043a\u0430\u043a\u0438\u0445|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c|\u043f\u043e\s+\u043a\u043e\u043c\u0443|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0442\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|where|which|who|how\s+many)/iu.test(lower);
|
||||
const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover)/iu.test(lower);
|
||||
const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|\u0441\u043a\u043b\u0430\u0434|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442|\u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover|warehouse|inventory|stock|item)/iu.test(lower);
|
||||
if (hasBroadInterrogative && hasBroadBusinessObject) {
|
||||
return true;
|
||||
}
|
||||
const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a|\u043f\u0440\u043e\u0432\u0435\u0440\u044c|\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c)(?:$|[\s,.!?;:])/iu.test(lower);
|
||||
const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433)/iu.test(lower);
|
||||
const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u043a\u043b\u0430\u0434|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442|\u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b)/iu.test(lower);
|
||||
if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
|
||||
return true;
|
||||
}
|
||||
@@ -4217,7 +4218,7 @@ function hasDataRetrievalRequestSignal(text) {
|
||||
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
|
||||
return false;
|
||||
}
|
||||
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
|
||||
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|склад|товар|номенклат|материал|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|warehouse|inventory|stock|item|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
|
||||
if (!hasRetrievalObject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user