АДРЕСНЫЙ РЕЖИМ -Step-5 - feat(assistant): стабилизация свободного LLM-роутинга, прическа маршрутов chat/address, прототип прогноза НДС
This commit is contained in:
@@ -19,6 +19,18 @@ function toNumberFlag(value: string | undefined, defaultValue: number): number {
|
||||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
function toStringListFlag(value: string | undefined, defaultValue: string[]): string[] {
|
||||
const source = String(value ?? "").trim();
|
||||
if (!source) {
|
||||
return [...defaultValue];
|
||||
}
|
||||
const tokens = source
|
||||
.split(/[,\s;]+/g)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return tokens.length > 0 ? Array.from(new Set(tokens)) : [...defaultValue];
|
||||
}
|
||||
|
||||
export const PORT = Number(process.env.PORT ?? 8787);
|
||||
export const TIMEZONE = process.env.TZ_FALLBACK ?? "Europe/Moscow";
|
||||
export const DEFAULT_OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
|
||||
@@ -103,6 +115,10 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1,
|
||||
true
|
||||
);
|
||||
export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(
|
||||
/\/+$/,
|
||||
""
|
||||
@@ -110,6 +126,8 @@ export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "
|
||||
export const ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
|
||||
export const ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
|
||||
export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
|
||||
export const VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]);
|
||||
export const VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]);
|
||||
|
||||
export const DATA_DIR = process.env.DATA_DIR ?? path.resolve(MODULE_ROOT, "data");
|
||||
export const TRACES_DIR = path.resolve(DATA_DIR, "traces");
|
||||
|
||||
@@ -5,9 +5,12 @@ const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[
|
||||
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-–—_:№#]*?(\d{1,3})/iu;
|
||||
const COUNTERPARTY_PATTERN =
|
||||
/(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
|
||||
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
|
||||
const CONTRACT_PATTERN =
|
||||
/(?:по\s+(?:договору|контракту)|(?:договор|контракт)(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
|
||||
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
|
||||
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
|
||||
const DATE_DMY_MONTH_NAME_PATTERN =
|
||||
/(?:^|[\s,.;:!?()\-])(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2}|\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
|
||||
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
||||
const PERIOD_RANGE_PATTERN_2 =
|
||||
/(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
||||
@@ -116,6 +119,45 @@ function extractAsOfDate(text: string): string | undefined {
|
||||
return toIsoDate(year, month, day) ?? undefined;
|
||||
}
|
||||
|
||||
const dmyByMonthName = text.match(DATE_DMY_MONTH_NAME_PATTERN);
|
||||
if (dmyByMonthName) {
|
||||
const day = Number(dmyByMonthName[1]);
|
||||
const month = resolveMonthByName(String(dmyByMonthName[2] ?? ""));
|
||||
const yearRaw = Number(dmyByMonthName[3]);
|
||||
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
|
||||
if (month) {
|
||||
return toIsoDate(year, month, day) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractAsOfDateWithCue(text: string): string | undefined {
|
||||
const source = String(text ?? "");
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numericCue = source.match(
|
||||
/(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})(?=$|[\s,.;:!?()\-])/iu
|
||||
);
|
||||
if (numericCue) {
|
||||
return parseDateToken(String(numericCue[1] ?? ""));
|
||||
}
|
||||
|
||||
const monthNameCue = source.match(
|
||||
/(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu
|
||||
);
|
||||
if (monthNameCue) {
|
||||
const day = Number(monthNameCue[1]);
|
||||
const month = resolveMonthByName(String(monthNameCue[2] ?? ""));
|
||||
const year = Number(monthNameCue[3]);
|
||||
if (month && Number.isFinite(year) && Number.isFinite(day)) {
|
||||
return toIsoDate(year, month, day) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -163,6 +205,26 @@ function resolveMonthByName(rawMonthName: string): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function deriveQuarterWindowForDate(asOfIso: string): { period_from: string; period_to: string } | null {
|
||||
const token = String(asOfIso ?? "").trim();
|
||||
const match = token.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
|
||||
return null;
|
||||
}
|
||||
const quarterStartMonth = Math.floor((month - 1) / 3) * 3 + 1;
|
||||
const quarterEndMonth = quarterStartMonth + 2;
|
||||
const quarterEndDay = new Date(Date.UTC(year, quarterEndMonth, 0)).getUTCDate();
|
||||
return {
|
||||
period_from: `${year}-${String(quarterStartMonth).padStart(2, "0")}-01`,
|
||||
period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}`
|
||||
};
|
||||
}
|
||||
|
||||
function extractMonthPeriod(text: string): { period_from?: string; period_to?: string } {
|
||||
const numericMonthYearMatch = text.match(MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN);
|
||||
if (numericMonthYearMatch) {
|
||||
@@ -321,8 +383,13 @@ function extractYearRangePeriod(text: string): { period_from?: string; period_to
|
||||
}
|
||||
|
||||
function cleanupAnchorValue(value: string): string {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized) {
|
||||
const stripOuterQuotes = (text: string): string =>
|
||||
String(text ?? "")
|
||||
.replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
|
||||
.trim();
|
||||
|
||||
let cleaned = stripOuterQuotes(String(value ?? "").trim());
|
||||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -330,49 +397,47 @@ function cleanupAnchorValue(value: string): string {
|
||||
// "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31".
|
||||
const asOfTailPattern =
|
||||
/\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu;
|
||||
if (asOfTailPattern.test(normalized)) {
|
||||
return normalized.replace(asOfTailPattern, "").trim();
|
||||
}
|
||||
const asOfTruncatedTailPattern = /\s+на\s+дат[ауеы]\s+\d{1,2}(?:\s+|$)[\s\S]*$/iu;
|
||||
if (asOfTruncatedTailPattern.test(normalized)) {
|
||||
return normalized.replace(asOfTruncatedTailPattern, "").trim();
|
||||
}
|
||||
const asOfReportDateTailPattern =
|
||||
/\s+на\s+дат[ауеы]\s+(?:отчетност[ьи]|отч[её]тн(?:ую|ой)?\s+дат[ауеы]|конец(?:\s+период[а-яё]*)?)\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?(?:\s+|$)[\s\S]*$/iu;
|
||||
const periodEndTailPattern =
|
||||
/\s+на\s+конец(?:\s+период[а-яё]*)?\s+(?:\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|\d{4}|[a-zа-яё]+\s+\d{4})(?:\s+|$)[\s\S]*$/iu;
|
||||
if (periodEndTailPattern.test(normalized)) {
|
||||
return normalized.replace(periodEndTailPattern, "").trim();
|
||||
}
|
||||
|
||||
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
|
||||
// "<counterparty> с 2020-07-01 по 2020-07-31", "<counterparty> from 2020-07-01 to 2020-07-31"
|
||||
const periodTailPattern =
|
||||
/\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu;
|
||||
if (periodTailPattern.test(normalized)) {
|
||||
return normalized.replace(periodTailPattern, "").trim();
|
||||
}
|
||||
|
||||
const allTimeTailPattern =
|
||||
/\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
|
||||
if (allTimeTailPattern.test(normalized)) {
|
||||
return normalized.replace(allTimeTailPattern, "").trim();
|
||||
}
|
||||
const allTimeTailPatternEn =
|
||||
/\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
|
||||
if (allTimeTailPatternEn.test(normalized)) {
|
||||
return normalized.replace(allTimeTailPatternEn, "").trim();
|
||||
|
||||
for (const tailPattern of [
|
||||
asOfTailPattern,
|
||||
asOfTruncatedTailPattern,
|
||||
asOfReportDateTailPattern,
|
||||
periodEndTailPattern,
|
||||
periodTailPattern,
|
||||
allTimeTailPattern,
|
||||
allTimeTailPatternEn
|
||||
]) {
|
||||
if (tailPattern.test(cleaned)) {
|
||||
cleaned = stripOuterQuotes(cleaned.replace(tailPattern, "").trim());
|
||||
}
|
||||
}
|
||||
|
||||
const trailingYearTailPattern =
|
||||
/\s+(?:year\s+)?(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?:\s+|$)[\s\S]*$/iu;
|
||||
let cleaned = normalized;
|
||||
if (trailingYearTailPattern.test(normalized)) {
|
||||
cleaned = normalized.replace(trailingYearTailPattern, "").trim();
|
||||
if (trailingYearTailPattern.test(cleaned)) {
|
||||
cleaned = stripOuterQuotes(cleaned.replace(trailingYearTailPattern, "").trim());
|
||||
}
|
||||
|
||||
return cleaned
|
||||
cleaned = cleaned
|
||||
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
|
||||
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
|
||||
.trim();
|
||||
|
||||
return stripOuterQuotes(cleaned);
|
||||
}
|
||||
|
||||
function cleanupContractAnchorValue(value: string): string {
|
||||
@@ -442,6 +507,8 @@ function extractLooseByAnchorValue(text: string): string | undefined {
|
||||
"партнера",
|
||||
"договору",
|
||||
"договора",
|
||||
"контракту",
|
||||
"контракта",
|
||||
"счету",
|
||||
"счёту",
|
||||
"дате",
|
||||
@@ -489,6 +556,7 @@ function extractLooseByAnchorValue(text: string): string | undefined {
|
||||
"linked",
|
||||
"нему",
|
||||
"ней",
|
||||
"нее",
|
||||
"ним",
|
||||
"этому",
|
||||
"тому",
|
||||
@@ -552,10 +620,51 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
"каких",
|
||||
"какому",
|
||||
"какую",
|
||||
"кто",
|
||||
"что",
|
||||
"чего",
|
||||
"где",
|
||||
"когда",
|
||||
"почему",
|
||||
"зачем",
|
||||
"сколько",
|
||||
"чьи",
|
||||
"чья",
|
||||
"чей",
|
||||
"чью",
|
||||
"самый",
|
||||
"самая",
|
||||
"самое",
|
||||
"самые",
|
||||
"крупный",
|
||||
"крупная",
|
||||
"крупное",
|
||||
"крупные",
|
||||
"жирный",
|
||||
"жирная",
|
||||
"жирное",
|
||||
"жирные",
|
||||
"больше",
|
||||
"меньше",
|
||||
"платит",
|
||||
"платят",
|
||||
"прогноз",
|
||||
"forecast",
|
||||
"план",
|
||||
"плана",
|
||||
"ндс",
|
||||
"vat",
|
||||
"налог",
|
||||
"оплата",
|
||||
"оплаты",
|
||||
"платеж",
|
||||
"платёж",
|
||||
"платежа",
|
||||
"платежи",
|
||||
"денег",
|
||||
"деньги",
|
||||
"объем",
|
||||
"объём",
|
||||
"док",
|
||||
"доки",
|
||||
"документ",
|
||||
@@ -685,6 +794,14 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
|
||||
if (tokens.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const questionCue =
|
||||
/(?:кто|что|какой|какая|какие|какого|сколько|где|когда|почему|зачем|which|who|what|how\s+many)/iu.test(value) ||
|
||||
/[?]/u.test(String(rawValue ?? ""));
|
||||
const rankingCue = /(?:больше|меньше|сам(?:ый|ая|ое|ые)|крупн|жирн|максим|миним)/iu.test(value);
|
||||
const paymentCue = /(?:плат(?:ит|ят|еж|ёж|ежн|ежей|ежа)|денег|деньг|money|payment)/iu.test(value);
|
||||
if (questionCue && (rankingCue || paymentCue)) {
|
||||
return true;
|
||||
}
|
||||
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
|
||||
return meaningfulTokens.length === 0;
|
||||
}
|
||||
@@ -723,7 +840,9 @@ function isLowQualityContractAnchorValue(rawValue: string): boolean {
|
||||
"период",
|
||||
"периоду",
|
||||
"договор",
|
||||
"договору"
|
||||
"договору",
|
||||
"контракт",
|
||||
"контракту"
|
||||
]);
|
||||
const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token));
|
||||
return meaningfulTokens.length === 0;
|
||||
@@ -941,7 +1060,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
intent === "contract_usage_overview" ||
|
||||
intent === "customer_revenue_and_payments" ||
|
||||
intent === "supplier_payouts_profile" ||
|
||||
intent === "contract_usage_and_value";
|
||||
intent === "contract_usage_and_value" ||
|
||||
intent === "vat_payable_forecast";
|
||||
const filters: AddressFilterSet = {
|
||||
sort: "period_desc"
|
||||
};
|
||||
@@ -949,6 +1069,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
filters.limit = 20;
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const explicitAsOfDate = extractAsOfDate(text);
|
||||
const explicitAsOfDateWithCue = extractAsOfDateWithCue(text);
|
||||
|
||||
const accountMatch = text.match(ACCOUNT_PATTERN);
|
||||
if (accountMatch) {
|
||||
@@ -1071,12 +1193,27 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
}
|
||||
}
|
||||
|
||||
const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
|
||||
if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
|
||||
const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
|
||||
if (quarterWindow) {
|
||||
filters.period_from = quarterWindow.period_from;
|
||||
warnings.push("period_from_derived_from_quarter_for_vat_forecast");
|
||||
filters.period_to = vatAsOfDate;
|
||||
warnings.push("period_to_derived_from_as_of_date_for_vat_forecast");
|
||||
|
||||
if (filters.period_from && filters.period_to && filters.period_from > filters.period_to) {
|
||||
filters.period_from = quarterWindow.period_from;
|
||||
warnings.push("period_from_adjusted_for_vat_as_of_window");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isManagementProfileIntent && !filters.period_to && !filters.as_of_date) {
|
||||
filters.period_to = new Date().toISOString().slice(0, 10);
|
||||
warnings.push("period_to_defaulted_today_for_management_profile");
|
||||
}
|
||||
|
||||
const explicitAsOfDate = extractAsOfDate(text);
|
||||
if (usesAsOfPrimaryWindow(intent) && explicitAsOfDate) {
|
||||
filters.as_of_date = explicitAsOfDate;
|
||||
const periodWasDerivedHeuristically =
|
||||
|
||||
@@ -57,7 +57,8 @@ const OPEN_CONTRACTS_HINTS = [
|
||||
"незакрыт",
|
||||
"не закрыт",
|
||||
"открыт",
|
||||
"договор"
|
||||
"договор",
|
||||
"контракт"
|
||||
];
|
||||
|
||||
const OPEN_ITEMS_HINTS = [
|
||||
@@ -130,7 +131,10 @@ const DOCUMENTS_BY_CONTRACT_HINTS = [
|
||||
"доки по договору",
|
||||
"док по договору",
|
||||
"документы договор",
|
||||
"договор"
|
||||
"договор",
|
||||
"документы по контракту",
|
||||
"доки по контракту",
|
||||
"контракт"
|
||||
];
|
||||
const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
|
||||
"bank operations by contract",
|
||||
@@ -140,7 +144,10 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
|
||||
"bank ops by contract",
|
||||
"банковские операции по договору",
|
||||
"платежи по договору",
|
||||
"выписка по договору"
|
||||
"выписка по договору",
|
||||
"банковские операции по контракту",
|
||||
"платежи по контракту",
|
||||
"выписка по контракту"
|
||||
];
|
||||
|
||||
const BANK_OPERATION_CORE_HINTS = [
|
||||
@@ -337,14 +344,22 @@ const CONTRACT_USAGE_AND_VALUE_HINTS = [
|
||||
"договоры по обороту",
|
||||
"договоры по сумме оборота",
|
||||
"топ договоров по обороту",
|
||||
"контракты по обороту",
|
||||
"контракты по сумме оборота",
|
||||
"топ контрактов по обороту",
|
||||
"договоры с минимальным бюджетом",
|
||||
"договоры с самым маленьким бюджетом",
|
||||
"контракты с минимальным бюджетом",
|
||||
"контракты с самым маленьким бюджетом",
|
||||
"активные договоры по бюджету",
|
||||
"активные контракты по бюджету",
|
||||
"контрагенты с несколькими договорами",
|
||||
"несколько договоров у контрагента",
|
||||
"мультидоговорные контрагенты",
|
||||
"какие договоры активны",
|
||||
"какие контракты активны",
|
||||
"рабочие договоры",
|
||||
"рабочие контракты",
|
||||
"contracts by turnover",
|
||||
"contracts by budget"
|
||||
];
|
||||
@@ -355,6 +370,10 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
|
||||
"список договоров по",
|
||||
"покажи договоры по",
|
||||
"выведи договоры по",
|
||||
"контракты по",
|
||||
"список контрактов по",
|
||||
"покажи контракты по",
|
||||
"выведи контракты по",
|
||||
"contracts by counterparty",
|
||||
"list contracts by counterparty",
|
||||
"show contracts by counterparty"
|
||||
@@ -453,8 +472,25 @@ function hasFuzzyLexeme(text: string, lexemeRoots: string[]): boolean {
|
||||
}
|
||||
|
||||
function hasCompactAccountCodeToken(text: string): boolean {
|
||||
// Match compact account tokens like 60.01 / 62, while avoiding date fragments.
|
||||
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})?(?![\d-])/u.test(text);
|
||||
// Match compact account tokens while reducing false positives on short-year literals like "22 год".
|
||||
const source = String(text ?? "");
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
// Safe compact form: 60.01 / 62.1
|
||||
if (/(?<![\d-])\d{2}[.,]\d{1,2}(?![\d-])/u.test(source)) {
|
||||
return true;
|
||||
}
|
||||
// Plain two-digit code is accepted only in explicit account context.
|
||||
if (/(?:сч[её]т|account)\D{0,12}\d{2}(?![\d-])/iu.test(source)) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:^|\s)по\s+\d{2}(?=$|[\s,.;:!?])/iu.test(source)) {
|
||||
if (!/(?:^|\s)(?:за|в)\s+\d{2}\s*(?:г(?:од|ода)?|year)\b/iu.test(source)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDocumentsFormingBalanceSignal(text: string): boolean {
|
||||
@@ -517,6 +553,18 @@ function hasAccountBalanceSignal(text: string): boolean {
|
||||
return hasAccountLexeme && hasAsOfStyleDate && hasFollowupBalanceVerb;
|
||||
}
|
||||
|
||||
function hasForecastTaxSignal(text: string): boolean {
|
||||
const hasForecastLexeme =
|
||||
/(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text);
|
||||
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
|
||||
const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text);
|
||||
const hasVatPayableEstimatePattern =
|
||||
/(?:(?:сколько|скока|скок).{0,48}(?:ндс|vat).{0,48}(?:надо|нужно|к\s+уплате|заплатить|уплатить|платеж|платежа|платежей|платежку)|(?:ндс|vat).{0,48}(?:к\s+уплате|надо|нужно|заплатить|уплатить)|(?:сколько|скока|скок).{0,32}(?:надо|нужно).{0,32}(?:заплатить|уплатить).{0,32}(?:ндс|vat))/iu.test(
|
||||
text
|
||||
);
|
||||
return (hasForecastLexeme && hasTaxLexeme) || (hasVatLexeme && hasVatPayableEstimatePattern);
|
||||
}
|
||||
|
||||
function hasPeriodCoverageProfileSignal(text: string): boolean {
|
||||
if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) {
|
||||
return true;
|
||||
@@ -670,21 +718,21 @@ function hasContractUsageOverviewSignal(text: string): boolean {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/(?:сколько\s+(?:всего\s+)?договор(?:ов|а)?(?:\s+заведен[оы])?|договорн(?:ая|ой)\s+баз[аы]).*(?:сколько|used|использ)/iu.test(
|
||||
/(?:сколько\s+(?:всего\s+)?(?:договор|контракт)(?:ов|а)?(?:\s+заведен[оы])?|(?:договорн(?:ая|ой)|контрактн(?:ая|ой))\s+баз[аы]).*(?:сколько|used|использ)/iu.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/(?:сколько\s+из\s+договор(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)
|
||||
/(?:сколько\s+из\s+(?:договор|контракт)(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|contract)?/iu.test(text)) {
|
||||
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|контракт|contract)?/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:какие\s+договор(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
|
||||
if (/(?:какие\s+(?:договор|контракт)(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -699,14 +747,18 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
|
||||
}
|
||||
const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]);
|
||||
const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]);
|
||||
const hasCounterpartyLexeme = /(?:контрагент(?:ов|а|ы)?|counterpart(?:y|ies)|компан(?:и|ия|ии|ию)|организац(?:и|ия|ии|ию)|partner(?:s)?)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasSpecificCounterpartyAnchor =
|
||||
hasLooseByAnchorMention(text) ||
|
||||
hasHeuristicCounterpartyAnchor(text) ||
|
||||
/(?:по\s+(?:клиент(?:у|а)?|заказчик(?:у|а)?|покупател(?:ю|я)|customer|client)\s+[a-zа-яё0-9])/iu.test(text);
|
||||
const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:ит|ят)?)/iu.test(text);
|
||||
const asksCustomerGroup =
|
||||
/(?:клиент(?:ов|а|ы)?|заказчик(?:ов|а|и)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text) ||
|
||||
hasFuzzyCustomerLexeme ||
|
||||
/(?:кто\s+нам\s+(?:больше|чаще)|кто\s+платит)/iu.test(text);
|
||||
asksWhoPays;
|
||||
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
|
||||
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
|
||||
const asksDealBudgetRanking =
|
||||
@@ -714,8 +766,10 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
|
||||
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(
|
||||
text
|
||||
);
|
||||
const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text);
|
||||
const asksOverallTurnover = /(?:общ(?:ий|ие|ая)\s+оборот|общ(?:ая|ий)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text);
|
||||
const asksValue =
|
||||
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(
|
||||
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal|turnover)/iu.test(
|
||||
text
|
||||
);
|
||||
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(
|
||||
@@ -731,6 +785,15 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
|
||||
if (asksCustomerGroup && (asksValue || asksRankOrTop)) {
|
||||
return true;
|
||||
}
|
||||
if (!hasFuzzySupplierLexeme && hasCounterpartyLexeme && asksRankOrTop && (asksValue || asksWhoPays)) {
|
||||
return true;
|
||||
}
|
||||
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
|
||||
return true;
|
||||
}
|
||||
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
|
||||
return true;
|
||||
}
|
||||
if (asksCounterpartySource && asksValue) {
|
||||
return true;
|
||||
}
|
||||
@@ -780,13 +843,13 @@ function hasContractUsageAndValueSignal(text: string): boolean {
|
||||
if (hasAny(text, CONTRACT_USAGE_AND_VALUE_HINTS)) {
|
||||
return true;
|
||||
}
|
||||
if (!/(?:договор(?:ов|а|ы)?|contract(?:s)?)/iu.test(text)) {
|
||||
if (!/(?:договор(?:ов|а|ы)?|контракт(?:ов|а|ы|у|ом|е)?|contract(?:s)?)/iu.test(text)) {
|
||||
return false;
|
||||
}
|
||||
if (hasContractUsageOverviewSignal(text)) {
|
||||
return false;
|
||||
}
|
||||
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+договор|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+договор|какие\s+договор(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+договор)/iu.test(
|
||||
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+(?:договор|контракт)|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+(?:договор|контракт)|какие\s+(?:договор|контракт)(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+(?:договор|контракт))/iu.test(
|
||||
text
|
||||
);
|
||||
const asksValue =
|
||||
@@ -796,7 +859,7 @@ function hasContractUsageAndValueSignal(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasContractListByCounterpartySignal(text: string): boolean {
|
||||
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
|
||||
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|контракт(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
|
||||
if (!hasContractLexeme) {
|
||||
return false;
|
||||
}
|
||||
@@ -841,7 +904,7 @@ function hasDocumentsByAccountDrilldownSignal(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasOpenContractsListSignal(text: string): boolean {
|
||||
const hasContractLexeme = text.includes("договор") || text.includes("contract") || text.includes("dogovor");
|
||||
const hasContractLexeme = text.includes("договор") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor");
|
||||
const hasOpenLexeme = /(?:незакрыт|не\s+закрыт|открыт|open|unclosed)/iu.test(text);
|
||||
if (!hasContractLexeme || !hasOpenLexeme) {
|
||||
return false;
|
||||
@@ -1186,6 +1249,14 @@ function hasAccountNumberAnchor(text: string): boolean {
|
||||
export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
|
||||
const text = String(userMessage ?? "").trim().toLowerCase();
|
||||
|
||||
if (hasForecastTaxSignal(text)) {
|
||||
return {
|
||||
intent: "vat_payable_forecast",
|
||||
confidence: "high",
|
||||
reasons: ["forecast_tax_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAny(text, RECEIVABLES_STRONG)) {
|
||||
return {
|
||||
intent: "list_receivables_counterparties",
|
||||
@@ -1228,7 +1299,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
|
||||
if (
|
||||
hasAny(text, OPEN_ITEMS_HINTS) &&
|
||||
(text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))
|
||||
(text.includes("контраг") || text.includes("договор") || text.includes("контракт") || text.includes("counterparty") || text.includes("contract"))
|
||||
) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
@@ -1398,7 +1469,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("contract"))) {
|
||||
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) {
|
||||
return {
|
||||
intent: "list_open_contracts",
|
||||
confidence: "medium",
|
||||
|
||||
@@ -20,6 +20,10 @@ const ADDRESS_ACTION_TOKENS = [
|
||||
"вывед",
|
||||
"кто",
|
||||
"кому",
|
||||
"какой",
|
||||
"какая",
|
||||
"какое",
|
||||
"какую",
|
||||
"какие",
|
||||
"каких",
|
||||
"что по",
|
||||
@@ -67,6 +71,7 @@ const ADDRESS_ENTITY_TOKENS = [
|
||||
"клиент",
|
||||
"покупател",
|
||||
"партнер",
|
||||
"контракт",
|
||||
"банк",
|
||||
"выписк",
|
||||
"операц",
|
||||
@@ -236,7 +241,17 @@ function hasLooseByAnchorMention(text: string): boolean {
|
||||
"активности",
|
||||
"пассивности",
|
||||
"наименее",
|
||||
"минимум"
|
||||
"минимум",
|
||||
"запрос",
|
||||
"запросу",
|
||||
"запроса",
|
||||
"запросом",
|
||||
"запросе",
|
||||
"вопрос",
|
||||
"вопросу",
|
||||
"вопроса",
|
||||
"вопросом",
|
||||
"вопросе"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
@@ -319,7 +334,17 @@ function hasLikelyCounterpartyToken(text: string): boolean {
|
||||
"пассивный",
|
||||
"наименее",
|
||||
"минимум",
|
||||
"реже"
|
||||
"реже",
|
||||
"запрос",
|
||||
"запросу",
|
||||
"запроса",
|
||||
"запросом",
|
||||
"запросе",
|
||||
"вопрос",
|
||||
"вопросу",
|
||||
"вопроса",
|
||||
"вопросом",
|
||||
"вопросе"
|
||||
]);
|
||||
const tokens = String(text ?? "")
|
||||
.split(/[^a-zа-яё0-9._-]+/iu)
|
||||
@@ -386,11 +411,11 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||
};
|
||||
}
|
||||
|
||||
if ((hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
|
||||
if (hasAccountCode && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
confidence: "medium",
|
||||
reasons: ["address_entity_detected"]
|
||||
reasons: ["account_code_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1037,6 +1037,11 @@ export class AddressQueryService {
|
||||
return null;
|
||||
}
|
||||
const { mode, shape, intent, filters, baseReasons } = decompose;
|
||||
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
|
||||
userMessage,
|
||||
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined
|
||||
});
|
||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
|
||||
|
||||
@@ -1273,7 +1278,7 @@ export class AddressQueryService {
|
||||
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
|
||||
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
|
||||
if (recoveredRows.length > 0) {
|
||||
const factual = composeFactualReply(intent.intent, recoveredRows, { userMessage });
|
||||
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters));
|
||||
const recoveryReason =
|
||||
recoveredBankRows.length > 0
|
||||
? "contract_docs_recovered_via_bank_fallback"
|
||||
@@ -1392,7 +1397,11 @@ export class AddressQueryService {
|
||||
rowsAnchorMatched: expandedRowsByAnchor.length,
|
||||
rowsMatched: expandedFilteredRows.length
|
||||
});
|
||||
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows, { userMessage });
|
||||
const expandedFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
expandedFilteredRows,
|
||||
composeOptionsFromFilters(expandedLimitFilters)
|
||||
);
|
||||
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
|
||||
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
|
||||
const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"];
|
||||
@@ -1501,7 +1510,11 @@ export class AddressQueryService {
|
||||
});
|
||||
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
|
||||
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
|
||||
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows, { userMessage });
|
||||
const broadenedFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
broadenedFilteredRows,
|
||||
composeOptionsFromFilters(autoBroadenedFilters)
|
||||
);
|
||||
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
|
||||
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
|
||||
return {
|
||||
@@ -1616,14 +1629,21 @@ export class AddressQueryService {
|
||||
rowsAnchorMatched: historicalRowsByAnchor.length,
|
||||
rowsMatched: historicalFilteredRows.length
|
||||
});
|
||||
const historicalFactual = composeFactualReply(intent.intent, historicalFilteredRows, { userMessage });
|
||||
const historicalPrefix =
|
||||
"В последних доступных записях якорь не подтвердился; показаны найденные строки по историческому окну.";
|
||||
const historicalFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
historicalFilteredRows,
|
||||
composeOptionsFromFilters(historicalFilters)
|
||||
);
|
||||
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
|
||||
const historicalSuggestion =
|
||||
intent.intent === "list_documents_by_counterparty"
|
||||
? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
|
||||
: "";
|
||||
const historicalLimitations = [...filters.warnings, "historical_window_sort_recovery_applied"];
|
||||
const historicalReasons = [...baseReasons, "historical_window_sort_recovery_applied"];
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: `${historicalPrefix}\n${historicalFactual.text}`,
|
||||
reply_text: `${historicalPrefix}\n${historicalFactual.text}${historicalSuggestion}`,
|
||||
reply_type: inferReplyType(historicalFactual.responseType),
|
||||
response_type: historicalFactual.responseType,
|
||||
debug: {
|
||||
@@ -1681,14 +1701,21 @@ export class AddressQueryService {
|
||||
) {
|
||||
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
|
||||
if (documentBankFallbackRows.length > 0) {
|
||||
const fallbackFactual = composeFactualReply(intent.intent, documentBankFallbackRows, { userMessage });
|
||||
const fallbackFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
documentBankFallbackRows,
|
||||
composeOptionsFromFilters(filters.extracted_filters)
|
||||
);
|
||||
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
|
||||
const fallbackSuggestion =
|
||||
intent.intent === "list_documents_by_counterparty"
|
||||
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
|
||||
: "";
|
||||
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
|
||||
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
|
||||
return {
|
||||
handled: true,
|
||||
reply_text:
|
||||
"Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные документы/операции по выбранному типу.\n" +
|
||||
fallbackFactual.text,
|
||||
reply_text: `${fallbackPrefix}\n${fallbackFactual.text}${fallbackSuggestion}`,
|
||||
reply_type: inferReplyType(fallbackFactual.responseType),
|
||||
response_type: fallbackFactual.responseType,
|
||||
debug: {
|
||||
@@ -1751,11 +1778,24 @@ export class AddressQueryService {
|
||||
Array.isArray(filters.warnings) &&
|
||||
(filters.warnings.includes("counterparty_from_followup_context") ||
|
||||
filters.warnings.includes("contract_from_followup_context"));
|
||||
const anchorMismatchByCounterparty =
|
||||
isAnchorMismatch && String(matchFailureReason ?? "").includes("counterparty_anchor_not_matched");
|
||||
const anchorMismatchByContract = isAnchorMismatch && String(matchFailureReason ?? "").includes("contract_anchor_not_matched");
|
||||
const isLowQualityPartyAnchor =
|
||||
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") &&
|
||||
isLikelyLowQualityPartyAnchor(anchor.anchor_value_raw);
|
||||
const anchorMismatchCategory: AddressLimitedReasonCategory =
|
||||
isFollowupAnchorCarryover || !isLowQualityPartyAnchor ? "empty_match" : "missing_anchor";
|
||||
const requestedPeriodFrom =
|
||||
typeof filters.extracted_filters.period_from === "string" ? filters.extracted_filters.period_from : null;
|
||||
const requestedPeriodTo = typeof filters.extracted_filters.period_to === "string" ? filters.extracted_filters.period_to : null;
|
||||
const requestedPeriodHint =
|
||||
requestedPeriodFrom && requestedPeriodTo ? ` (период ${requestedPeriodFrom}..${requestedPeriodTo} сохранен)` : "";
|
||||
const anchorMismatchCategory: AddressLimitedReasonCategory = isFollowupAnchorCarryover
|
||||
? "empty_match"
|
||||
: anchorMismatchByCounterparty || anchorMismatchByContract
|
||||
? "missing_anchor"
|
||||
: !isLowQualityPartyAnchor
|
||||
? "empty_match"
|
||||
: "missing_anchor";
|
||||
const category: AddressLimitedReasonCategory = isAnchorMismatch
|
||||
? anchorMismatchCategory
|
||||
: isRecipeFilteredOut
|
||||
@@ -1764,18 +1804,26 @@ export class AddressQueryService {
|
||||
? "recipe_visibility_gap"
|
||||
: "empty_match";
|
||||
const reasonText = isAnchorMismatch
|
||||
? anchorMismatchCategory === "missing_anchor"
|
||||
? "якорь контрагента/договора не найден в материализованных live-строках"
|
||||
: "по указанному якорю и фильтрам в live-выборке нет строк"
|
||||
? anchorMismatchByCounterparty
|
||||
? "контрагент по указанному имени/алиасу не найден в materialized live-строках"
|
||||
: anchorMismatchByContract
|
||||
? "договор по указанному номеру/названию не найден в materialized live-строках"
|
||||
: anchorMismatchCategory === "missing_anchor"
|
||||
? "якорь контрагента/договора не найден в materialized live-строках"
|
||||
: "по указанному якорю и фильтрам в live-выборке нет строк"
|
||||
: isRecipeFilteredOut
|
||||
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
|
||||
: isVisibilityGapCandidate
|
||||
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
|
||||
: "по выбранным фильтрам в live-выборке нет строк";
|
||||
const nextStep = isAnchorMismatch
|
||||
? anchorMismatchCategory === "missing_anchor"
|
||||
? "уточните контрагента точным именем или добавьте ИНН/договор"
|
||||
: "уточните период или снимите часть фильтров"
|
||||
? anchorMismatchByCounterparty
|
||||
? `уточните точное имя контрагента или добавьте ИНН${requestedPeriodHint}`
|
||||
: anchorMismatchByContract
|
||||
? `уточните номер/наименование договора${requestedPeriodHint}`
|
||||
: anchorMismatchCategory === "missing_anchor"
|
||||
? "уточните контрагента точным именем или добавьте ИНН/договор"
|
||||
: "уточните период или снимите часть фильтров"
|
||||
: isRecipeFilteredOut
|
||||
? "сузьте период, уточните контрагента или документный тип"
|
||||
: isVisibilityGapCandidate
|
||||
@@ -1783,9 +1831,13 @@ export class AddressQueryService {
|
||||
: "уточните период, контрагента, договор или снимите часть фильтров";
|
||||
const limitations = isAnchorMismatch
|
||||
? [
|
||||
anchorMismatchCategory === "missing_anchor"
|
||||
? "anchor_not_matched_after_materialization"
|
||||
: "no_rows_for_anchor_after_materialization"
|
||||
anchorMismatchByCounterparty
|
||||
? "counterparty_anchor_not_matched_after_materialization"
|
||||
: anchorMismatchByContract
|
||||
? "contract_anchor_not_matched_after_materialization"
|
||||
: anchorMismatchCategory === "missing_anchor"
|
||||
? "anchor_not_matched_after_materialization"
|
||||
: "no_rows_for_anchor_after_materialization"
|
||||
]
|
||||
: isRecipeFilteredOut
|
||||
? ["rows_filtered_out_by_recipe_after_anchor_match"]
|
||||
@@ -1824,7 +1876,7 @@ export class AddressQueryService {
|
||||
});
|
||||
}
|
||||
|
||||
const factual = composeFactualReply(intent.intent, filteredRows, { userMessage });
|
||||
const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters));
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: factual.text,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
AddressRecipeDefinition,
|
||||
AddressRecipeSelection
|
||||
} from "../types/addressQuery";
|
||||
import { VAT_PAYABLE_19_PREFIXES, VAT_PAYABLE_68_PREFIXES } from "../config";
|
||||
|
||||
const MOVEMENTS_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
@@ -347,6 +348,66 @@ const CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE = `
|
||||
Справочник.ДоговорыКонтрагентов КАК Договоры
|
||||
`;
|
||||
|
||||
const VAT_PAYABLE_FORECAST_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ
|
||||
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
|
||||
"VAT_68_CREDIT" КАК Регистратор,
|
||||
"68" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
СУММА(ВЫБОР
|
||||
КОГДА __VAT68_KT_MATCH__
|
||||
ТОГДА Движения.Сумма
|
||||
ИНАЧЕ 0
|
||||
КОНЕЦ) КАК Сумма
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
__WHERE_CLAUSE__
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
ВЫБРАТЬ
|
||||
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
|
||||
"VAT_68_DEBIT" КАК Регистратор,
|
||||
"68" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
СУММА(ВЫБОР
|
||||
КОГДА __VAT68_DT_MATCH__
|
||||
ТОГДА Движения.Сумма
|
||||
ИНАЧЕ 0
|
||||
КОНЕЦ) КАК Сумма
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
__WHERE_CLAUSE__
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
ВЫБРАТЬ
|
||||
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
|
||||
"VAT_19_DEBIT" КАК Регистратор,
|
||||
"19" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
СУММА(ВЫБОР
|
||||
КОГДА __VAT19_DT_MATCH__
|
||||
ТОГДА Движения.Сумма
|
||||
ИНАЧЕ 0
|
||||
КОНЕЦ) КАК Сумма
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
__WHERE_CLAUSE__
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
ВЫБРАТЬ
|
||||
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
|
||||
"VAT_19_CREDIT" КАК Регистратор,
|
||||
"19" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
СУММА(ВЫБОР
|
||||
КОГДА __VAT19_KT_MATCH__
|
||||
ТОГДА Движения.Сумма
|
||||
ИНАЧЕ 0
|
||||
КОНЕЦ) КАК Сумма
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
__WHERE_CLAUSE__
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Регистратор
|
||||
`;
|
||||
|
||||
const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||
{
|
||||
recipe_id: "address_period_coverage_profile_v1",
|
||||
@@ -428,6 +489,16 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||
account_scope_mode: "preferred",
|
||||
query_template: "contract_value_profile"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_vat_payable_forecast_v1",
|
||||
intent: "vat_payable_forecast",
|
||||
purpose: "Estimate VAT payable from factual turnovers on accounts 68 and 19 for selected period",
|
||||
required_filters: [],
|
||||
optional_filters: ["period_from", "period_to", "as_of_date", "organization"],
|
||||
default_limit: 32,
|
||||
account_scope_mode: "preferred",
|
||||
query_template: "vat_payable_forecast_profile"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_contracts_by_counterparty_v1",
|
||||
intent: "list_contracts_by_counterparty",
|
||||
@@ -669,6 +740,64 @@ function buildMovementAccountCondition(filters: AddressFilterSet): string | null
|
||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
||||
}
|
||||
|
||||
function normalizeAccountPrefixForQuery(value: string): string | null {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.replace(",", ".")
|
||||
.replace(/[^0-9.]+/g, "");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d{2}(?:\.\d{1,3})*$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function accountPrefixVariants(prefix: string): string[] {
|
||||
const value = normalizeAccountPrefixForQuery(prefix);
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const variants = new Set<string>([value]);
|
||||
const segments = value.split(".");
|
||||
if (segments.length <= 1) {
|
||||
return Array.from(variants);
|
||||
}
|
||||
|
||||
const base = segments[0];
|
||||
const normalizedTail = segments.slice(1).map((segment) => {
|
||||
const trimmed = segment.replace(/^0+(?=\d)/, "");
|
||||
return trimmed.length > 0 ? trimmed : "0";
|
||||
});
|
||||
const compact = [base, ...normalizedTail].join(".");
|
||||
if (compact !== value) {
|
||||
variants.add(compact);
|
||||
}
|
||||
|
||||
return Array.from(variants);
|
||||
}
|
||||
|
||||
function buildAccountPrefixPredicate(fieldPath: string, prefixes: string[]): string {
|
||||
const normalizedPrefixes = Array.from(
|
||||
new Set(
|
||||
(prefixes ?? [])
|
||||
.flatMap((item) => accountPrefixVariants(item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
)
|
||||
);
|
||||
|
||||
if (normalizedPrefixes.length === 0) {
|
||||
return "ЛОЖЬ";
|
||||
}
|
||||
|
||||
const clauses = normalizedPrefixes.map(
|
||||
(prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`
|
||||
);
|
||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
||||
}
|
||||
|
||||
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
|
||||
const hasAnchor =
|
||||
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
||||
@@ -694,6 +823,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
|
||||
intent === "customer_revenue_and_payments" ||
|
||||
intent === "supplier_payouts_profile" ||
|
||||
intent === "contract_usage_and_value" ||
|
||||
intent === "vat_payable_forecast" ||
|
||||
intent === "list_contracts_by_counterparty" ||
|
||||
intent === "list_documents_by_counterparty" ||
|
||||
intent === "bank_operations_by_counterparty" ||
|
||||
@@ -742,7 +872,8 @@ export function buildAddressRecipePlan(
|
||||
recipe.query_template === "period_profile" ||
|
||||
recipe.query_template === "document_section_profile" ||
|
||||
recipe.query_template === "counterparty_roles_profile" ||
|
||||
recipe.query_template === "contract_usage_profile";
|
||||
recipe.query_template === "contract_usage_profile" ||
|
||||
recipe.query_template === "vat_payable_forecast_profile";
|
||||
const baseLimit =
|
||||
typeof filters.limit === "number" && Number.isFinite(filters.limit)
|
||||
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
|
||||
@@ -830,6 +961,13 @@ export function buildAddressRecipePlan(
|
||||
buildContractValueWhereClause(filters, "БанкСписание.Дата", "БанкСписание.ДоговорКонтрагента")
|
||||
)
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
|
||||
: recipe.query_template === "vat_payable_forecast_profile"
|
||||
? VAT_PAYABLE_FORECAST_QUERY_TEMPLATE
|
||||
.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
|
||||
.replaceAll("__VAT68_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_68_PREFIXES))
|
||||
.replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES))
|
||||
.replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_19_PREFIXES))
|
||||
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES))
|
||||
: recipe.query_template === "contracts_by_counterparty_profile"
|
||||
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||
: MOVEMENTS_QUERY_TEMPLATE
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ComposeStageRow {
|
||||
|
||||
interface ComposeFactualReplyOptions {
|
||||
userMessage?: string;
|
||||
periodFrom?: string;
|
||||
periodTo?: string;
|
||||
}
|
||||
|
||||
type PeriodProfileFocus =
|
||||
@@ -130,6 +132,93 @@ function formatPercent(value: number, total: number): string | null {
|
||||
return `${((value / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "0.00";
|
||||
}
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function parseIsoDateToken(value: string | null | undefined): { year: number; month: number; day: number } | null {
|
||||
const source = String(value ?? "").trim();
|
||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
return null;
|
||||
}
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return null;
|
||||
}
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
function toIsoDate(year: number, month: number, day: number): string {
|
||||
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDateRu(isoDate: string): string {
|
||||
const parsed = parseIsoDateToken(isoDate);
|
||||
if (!parsed) {
|
||||
return isoDate;
|
||||
}
|
||||
return `${String(parsed.day).padStart(2, "0")}.${String(parsed.month).padStart(2, "0")}.${String(parsed.year).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
function buildIsoDateWithMonthShift(
|
||||
year: number,
|
||||
monthOneBased: number,
|
||||
day: number,
|
||||
monthShift = 0
|
||||
): string {
|
||||
const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function deriveVatDeadlineCalendar(
|
||||
periodFrom: string | null | undefined,
|
||||
periodTo: string | null | undefined
|
||||
): {
|
||||
periodLabel: string;
|
||||
quarterStart: string;
|
||||
quarterEnd: string;
|
||||
declarationDueDate: string;
|
||||
paymentDueDates: [string, string, string];
|
||||
windowFrom: string | null;
|
||||
windowTo: string | null;
|
||||
} | null {
|
||||
const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
|
||||
if (!reference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quarterIndex = Math.floor((reference.month - 1) / 3);
|
||||
const quarterNumber = quarterIndex + 1;
|
||||
const quarterStartMonth = quarterIndex * 3 + 1;
|
||||
const quarterEndMonth = quarterStartMonth + 2;
|
||||
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
|
||||
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
|
||||
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
|
||||
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
|
||||
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
|
||||
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
|
||||
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
|
||||
|
||||
return {
|
||||
periodLabel: `${quarterNumber} кв. ${reference.year}`,
|
||||
quarterStart,
|
||||
quarterEnd,
|
||||
declarationDueDate,
|
||||
paymentDueDates: [payment1, payment2, payment3],
|
||||
windowFrom: periodFrom ?? null,
|
||||
windowTo: periodTo ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function extractAccountSectionCode(value: string | null): string | null {
|
||||
const source = String(value ?? "").trim();
|
||||
if (!source) {
|
||||
@@ -150,6 +239,18 @@ function normalizeQuestionText(value: string | null | undefined): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function needsVatWhyExplanation(userMessage: string | null | undefined): boolean {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const asksReason = /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text);
|
||||
if (!asksReason) {
|
||||
return false;
|
||||
}
|
||||
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
|
||||
}
|
||||
|
||||
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
@@ -249,13 +350,13 @@ function detectCounterpartyProfileFocus(userMessage: string | null | undefined):
|
||||
text
|
||||
);
|
||||
|
||||
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken && !asksTotal) {
|
||||
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken) {
|
||||
return "suppliers_only";
|
||||
}
|
||||
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken && !asksTotal) {
|
||||
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken) {
|
||||
return "customers_only";
|
||||
}
|
||||
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken && !asksTotal) {
|
||||
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken) {
|
||||
return "mixed_only";
|
||||
}
|
||||
|
||||
@@ -781,8 +882,19 @@ export function composeFactualReply(
|
||||
const focus = detectCounterpartyProfileFocus(options.userMessage);
|
||||
const includeTotal = focus === "full_profile" || focus === "total_only";
|
||||
const includeRoles = focus === "full_profile" || focus === "roles_only";
|
||||
const directLead =
|
||||
focus === "suppliers_only"
|
||||
? `Поставщиков (только supplier-роль): ${supplierOnly}.`
|
||||
: focus === "customers_only"
|
||||
? `Заказчиков (только customer-роль): ${customerOnly}.`
|
||||
: focus === "mixed_only"
|
||||
? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`
|
||||
: includeTotal && totalCounterparties > 0
|
||||
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
|
||||
: `Активных контрагентов по операциям: ${activeCounterparties}.`;
|
||||
|
||||
const lines: string[] = [
|
||||
directLead,
|
||||
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
@@ -871,19 +983,19 @@ export function composeFactualReply(
|
||||
: "в выбранном периоде";
|
||||
|
||||
const lines: string[] = [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
|
||||
if (counterparties.length === 0) {
|
||||
lines.push("Активных заказчиков по выбранному окну не найдено.");
|
||||
lines.push("По выбранному окну активности заказчики не найдены.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
lines.push(`Активные заказчики ${scopeLabel}: ${counterparties.length}.`);
|
||||
const visible = counterparties.slice(0, 120);
|
||||
lines.push(
|
||||
...visible.map((item, index) => {
|
||||
@@ -923,7 +1035,13 @@ export function composeFactualReply(
|
||||
totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
|
||||
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
|
||||
|
||||
const usageLead =
|
||||
totalContracts > 0
|
||||
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
|
||||
: `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`;
|
||||
|
||||
const lines: string[] = [
|
||||
usageLead,
|
||||
"Профиль договорной базы собран (catalog + usage aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
@@ -1046,11 +1164,10 @@ export function composeFactualReply(
|
||||
|
||||
if (focus === "top_by_ops") {
|
||||
const visible = rankedByOps.slice(0, limit);
|
||||
lines.push(
|
||||
isSupplier
|
||||
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
|
||||
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`
|
||||
);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
|
||||
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`
|
||||
@@ -1064,11 +1181,10 @@ export function composeFactualReply(
|
||||
|
||||
if (focus === "top_by_max_single") {
|
||||
const visible = rankedByMaxSingle.slice(0, limit);
|
||||
lines.push(
|
||||
isSupplier
|
||||
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
|
||||
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`
|
||||
);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
|
||||
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`)
|
||||
);
|
||||
@@ -1080,11 +1196,10 @@ export function composeFactualReply(
|
||||
|
||||
if (focus === "top_by_avg_check_min_ops") {
|
||||
const visible = rankedByAvgCheck.slice(0, limit);
|
||||
lines.push(
|
||||
isSupplier
|
||||
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
|
||||
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`
|
||||
);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
|
||||
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
|
||||
} else {
|
||||
@@ -1103,11 +1218,10 @@ export function composeFactualReply(
|
||||
|
||||
if (focus === "top_deals") {
|
||||
const visible = rankedDealsTop.slice(0, limit);
|
||||
lines.push(
|
||||
isSupplier
|
||||
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
|
||||
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`
|
||||
);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
|
||||
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`
|
||||
@@ -1121,11 +1235,10 @@ export function composeFactualReply(
|
||||
|
||||
if (focus === "bottom_deals") {
|
||||
const visible = rankedDealsBottom.slice(0, limit);
|
||||
lines.push(
|
||||
isSupplier
|
||||
? `Топ-${visible.length} самых маленьких разовых выплат:`
|
||||
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`
|
||||
);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых маленьких разовых выплат:`
|
||||
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
|
||||
lines.unshift(heading);
|
||||
if (activeOnlyForBottomDeals) {
|
||||
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
|
||||
}
|
||||
@@ -1141,11 +1254,10 @@ export function composeFactualReply(
|
||||
}
|
||||
|
||||
const visible = rankedByTotal.slice(0, limit);
|
||||
lines.push(
|
||||
isSupplier
|
||||
? `Топ-${visible.length} поставщиков по сумме выплат:`
|
||||
: `Топ-${visible.length} заказчиков по сумме поступлений:`
|
||||
);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по сумме выплат:`
|
||||
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map((item, index) => {
|
||||
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
|
||||
@@ -1212,9 +1324,10 @@ export function composeFactualReply(
|
||||
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
|
||||
const lines: string[] = [
|
||||
`Активных договоров: ${contractRows.length}.`,
|
||||
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Активных договоров: ${contractRows.length}.`
|
||||
`Договорных агрегатов: ${contractRows.length}.`
|
||||
];
|
||||
|
||||
if (contractRows.length === 0) {
|
||||
@@ -1227,7 +1340,8 @@ export function composeFactualReply(
|
||||
|
||||
if (focus === "top_by_docs") {
|
||||
const visible = rankedByDocs.slice(0, limit);
|
||||
lines.push(`Топ-${visible.length} договоров по количеству операций:`);
|
||||
const heading = `Топ-${visible.length} договоров по количеству операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
@@ -1242,7 +1356,8 @@ export function composeFactualReply(
|
||||
|
||||
if (focus === "bottom_by_turnover_active") {
|
||||
const visible = rankedBottomActive.slice(0, limit);
|
||||
lines.push(`Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`);
|
||||
const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
@@ -1256,7 +1371,8 @@ export function composeFactualReply(
|
||||
}
|
||||
|
||||
const visible = rankedByTurnover.slice(0, limit);
|
||||
lines.push(`Топ-${visible.length} договоров по сумме оборота:`);
|
||||
const heading = `Топ-${visible.length} договоров по сумме оборота:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
@@ -1269,6 +1385,98 @@ export function composeFactualReply(
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "vat_payable_forecast") {
|
||||
const rowsByMarker = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
const nextValue = (rowsByMarker.get(marker) ?? 0) + (row.amount ?? 0);
|
||||
rowsByMarker.set(marker, nextValue);
|
||||
}
|
||||
|
||||
const turnover68Credit = rowsByMarker.get("VAT_68_CREDIT") ?? 0;
|
||||
const turnover68Debit = rowsByMarker.get("VAT_68_DEBIT") ?? 0;
|
||||
const turnover19Debit = rowsByMarker.get("VAT_19_DEBIT") ?? 0;
|
||||
const turnover19Credit = rowsByMarker.get("VAT_19_CREDIT") ?? 0;
|
||||
|
||||
const netVat = turnover68Credit - turnover68Debit;
|
||||
const vatToPay = Math.max(0, netVat);
|
||||
const carryoverOrOverpayment = Math.max(0, -netVat);
|
||||
const totalVatTurnoverAbs =
|
||||
Math.abs(turnover68Credit) + Math.abs(turnover68Debit) + Math.abs(turnover19Debit) + Math.abs(turnover19Credit);
|
||||
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
|
||||
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
|
||||
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
|
||||
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
|
||||
|
||||
const lines = [
|
||||
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
|
||||
`Строк агрегата: ${rows.length}.`,
|
||||
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
|
||||
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
|
||||
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
|
||||
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
|
||||
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
|
||||
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
|
||||
];
|
||||
|
||||
if (!vatActivityDetected) {
|
||||
lines.push(
|
||||
"В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00."
|
||||
);
|
||||
} else if (vatToPay === 0 && netVatIsEffectivelyZero) {
|
||||
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
|
||||
} else if (vatToPay === 0 && netVat < 0) {
|
||||
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
|
||||
}
|
||||
if (vatToPay === 0) {
|
||||
lines.push(
|
||||
"Чеклист проверки в 1С (почему к уплате 0):",
|
||||
`1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`,
|
||||
"2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).",
|
||||
"3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).",
|
||||
"4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.",
|
||||
"5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов."
|
||||
);
|
||||
}
|
||||
|
||||
if (vatCalendar) {
|
||||
const periodWindowLabel =
|
||||
vatCalendar.windowFrom && vatCalendar.windowTo
|
||||
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
|
||||
: `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
|
||||
const [payment1, payment2, payment3] = vatCalendar.paymentDueDates;
|
||||
const installmentRaw = vatToPay / 3;
|
||||
const installmentRounded = Number(installmentRaw.toFixed(2));
|
||||
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
|
||||
lines.push(
|
||||
`Период расчета (срез обязательств): ${periodWindowLabel}.`,
|
||||
`Налоговый период: ${vatCalendar.periodLabel}.`,
|
||||
`Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`,
|
||||
`Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`,
|
||||
`Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`,
|
||||
"Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."
|
||||
);
|
||||
}
|
||||
if (explainWhyRequested) {
|
||||
lines.push(
|
||||
"Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).",
|
||||
`За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`,
|
||||
netVat <= 0
|
||||
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
|
||||
: "Разница положительная, поэтому к уплате берется эта положительная величина.",
|
||||
"Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "account_balance_snapshot") {
|
||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const lines = [
|
||||
@@ -1372,8 +1580,7 @@ export function composeFactualReply(
|
||||
|
||||
if (intent === "list_documents_by_counterparty") {
|
||||
const lines = [
|
||||
"Собран список документов по контрагенту (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Найдено документов по контрагенту: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
];
|
||||
return {
|
||||
@@ -1384,6 +1591,7 @@ export function composeFactualReply(
|
||||
|
||||
if (intent === "list_documents_by_contract") {
|
||||
const lines = [
|
||||
`Найдено документов по договору: ${rows.length}.`,
|
||||
"Собран список документов по договору (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
@@ -1396,6 +1604,7 @@ export function composeFactualReply(
|
||||
|
||||
if (intent === "bank_operations_by_counterparty") {
|
||||
const lines = [
|
||||
`Найдено банковских операций по контрагенту: ${rows.length}.`,
|
||||
"Собран список банковских операций по контрагенту (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
@@ -1408,6 +1617,7 @@ export function composeFactualReply(
|
||||
|
||||
if (intent === "bank_operations_by_contract") {
|
||||
const lines = [
|
||||
`Найдено банковских операций по договору: ${rows.length}.`,
|
||||
"Собран список банковских операций по договору (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
|
||||
@@ -144,6 +144,29 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
|
||||
"что",
|
||||
"все",
|
||||
"всё",
|
||||
"кроме",
|
||||
"помимо",
|
||||
"этого",
|
||||
"этот",
|
||||
"эта",
|
||||
"эту",
|
||||
"этом",
|
||||
"это",
|
||||
"эти",
|
||||
"этих",
|
||||
"документ",
|
||||
"документа",
|
||||
"документы",
|
||||
"документов",
|
||||
"договор",
|
||||
"договора",
|
||||
"контрагент",
|
||||
"контрагента",
|
||||
"еще",
|
||||
"ещё",
|
||||
"другие",
|
||||
"другое",
|
||||
"остальное",
|
||||
"год",
|
||||
"года",
|
||||
"году",
|
||||
@@ -284,6 +307,13 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
|
||||
}
|
||||
|
||||
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
|
||||
if (
|
||||
tokenCount <= 12 &&
|
||||
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(normalized) &&
|
||||
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(normalized)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized);
|
||||
if (tokenCount <= 8 && hasPeriodLiteral) {
|
||||
return true;
|
||||
@@ -411,9 +441,28 @@ function mergeFollowupFilters(
|
||||
}
|
||||
}
|
||||
|
||||
const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage);
|
||||
const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage);
|
||||
const currentHasPeriod = hasExplicitPeriodWindow(merged);
|
||||
const previousHasPeriod = hasExplicitPeriodWindow(previous);
|
||||
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
|
||||
|
||||
if (intent === "vat_payable_forecast" && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) {
|
||||
const currentPeriodFrom = toNonEmptyString(merged.period_from);
|
||||
const currentPeriodTo = toNonEmptyString(merged.period_to);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const currentLooksDefaultedToToday = !currentPeriodFrom && currentPeriodTo === todayIso;
|
||||
if (!currentPeriodFrom || currentLooksDefaultedToToday) {
|
||||
if (previousPeriodFrom) {
|
||||
merged.period_from = previousPeriodFrom;
|
||||
}
|
||||
if (previousPeriodTo) {
|
||||
merged.period_to = previousPeriodTo;
|
||||
}
|
||||
reasons.push("period_from_followup_context");
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) {
|
||||
if (previousPeriodFrom) {
|
||||
merged.period_from = previousPeriodFrom;
|
||||
}
|
||||
@@ -562,7 +611,11 @@ export function runAddressDecomposeStage(
|
||||
): AddressDecomposeStageResult | null {
|
||||
const detectedMode = detectAddressQuestionMode(userMessage);
|
||||
const shape = classifyAddressQueryShape(userMessage);
|
||||
if (shape.shape === "EXPLAIN_OR_REASON") {
|
||||
const allowExplainAsFollowup =
|
||||
shape.shape === "EXPLAIN_OR_REASON" &&
|
||||
Boolean(followupContext?.previous_intent) &&
|
||||
hasAddressFollowupContextSignal(userMessage);
|
||||
if (shape.shape === "EXPLAIN_OR_REASON" && !allowExplainAsFollowup) {
|
||||
return null;
|
||||
}
|
||||
const detectedIntent = resolveAddressIntent(userMessage);
|
||||
|
||||
@@ -85,7 +85,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
|
||||
intent === "contract_usage_overview" ||
|
||||
intent === "customer_revenue_and_payments" ||
|
||||
intent === "supplier_payouts_profile" ||
|
||||
intent === "contract_usage_and_value"
|
||||
intent === "contract_usage_and_value" ||
|
||||
intent === "vat_payable_forecast"
|
||||
) {
|
||||
return "management_profile";
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -203,6 +203,103 @@ function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] {
|
||||
}
|
||||
|
||||
export class OpenAIResponsesClient {
|
||||
public async chat(
|
||||
config: OpenAIRequestConfig,
|
||||
prompt: {
|
||||
systemPrompt?: string;
|
||||
developerPrompt?: string;
|
||||
userMessage: string;
|
||||
maxOutputTokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
): Promise<OpenAIResponseEnvelope> {
|
||||
const responsesPayload = {
|
||||
model: config.model,
|
||||
temperature: prompt.temperature ?? config.temperature ?? 0.2,
|
||||
max_output_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
|
||||
input: [
|
||||
...(String(prompt.systemPrompt ?? "").trim().length > 0
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: String(prompt.systemPrompt ?? "").trim() }]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(String(prompt.developerPrompt ?? "").trim().length > 0
|
||||
? [
|
||||
{
|
||||
role: "developer",
|
||||
content: [{ type: "input_text", text: String(prompt.developerPrompt ?? "").trim() }]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: String(prompt.userMessage ?? "") }]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const provider = resolveProvider(config);
|
||||
if (provider === "openai") {
|
||||
const raw = await this.postResponses(config, responsesPayload);
|
||||
return {
|
||||
raw,
|
||||
outputText: extractOutputTextFromResponses(raw),
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await this.postResponses(config, responsesPayload);
|
||||
return {
|
||||
raw,
|
||||
outputText: extractOutputTextFromResponses(raw),
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToChatCompletions(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const chatPayload = {
|
||||
model: config.model,
|
||||
temperature: prompt.temperature ?? config.temperature ?? 0.2,
|
||||
max_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
|
||||
messages: [
|
||||
...(String(prompt.systemPrompt ?? "").trim().length > 0
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: String(prompt.systemPrompt ?? "").trim()
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(String(prompt.developerPrompt ?? "").trim().length > 0
|
||||
? [
|
||||
{
|
||||
role: "developer",
|
||||
content: String(prompt.developerPrompt ?? "").trim()
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
role: "user",
|
||||
content: String(prompt.userMessage ?? "")
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const raw = await this.postChatCompletions(config, chatPayload);
|
||||
return {
|
||||
raw,
|
||||
outputText: extractOutputTextFromChatCompletions(raw),
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
}
|
||||
|
||||
public async listModels(config: OpenAIRequestConfig): Promise<string[]> {
|
||||
const payload = await this.getModels(config);
|
||||
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AddressIntent =
|
||||
| "customer_revenue_and_payments"
|
||||
| "supplier_payouts_profile"
|
||||
| "contract_usage_and_value"
|
||||
| "vat_payable_forecast"
|
||||
| "list_contracts_by_counterparty"
|
||||
| "list_open_contracts"
|
||||
| "list_payables_counterparties"
|
||||
@@ -119,7 +120,8 @@ export interface AddressRecipeDefinition {
|
||||
| "customer_revenue_profile"
|
||||
| "supplier_payout_profile"
|
||||
| "contract_value_profile"
|
||||
| "contracts_by_counterparty_profile";
|
||||
| "contracts_by_counterparty_profile"
|
||||
| "vat_payable_forecast_profile";
|
||||
required_filters: Array<keyof AddressFilterSet>;
|
||||
optional_filters: Array<keyof AddressFilterSet>;
|
||||
default_limit: number;
|
||||
|
||||
Reference in New Issue
Block a user