Добавить financial flow hints для банковских контрагентов

This commit is contained in:
2026-05-13 00:57:59 +03:00
parent 9f62d3653f
commit e997449785
12 changed files with 475 additions and 45 deletions
@@ -346,7 +346,10 @@ const BANK_DOCS_QUERY_TEMPLATE = `
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ВидОперации) КАК ВидОперации,
БанкСписание.НазначениеПлатежа КАК НазначениеПлатежа,
БанкСписание.Комментарий КАК Комментарий
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
@@ -358,7 +361,10 @@ __WHERE_OUT__
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ВидОперации) КАК ВидОперации,
"" КАК НазначениеПлатежа,
БанкПоступление.Комментарий КАК Комментарий
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
@@ -624,7 +630,9 @@ const CUSTOMER_REVENUE_PROFILE_QUERY_TEMPLATE = `
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ВидОперации) КАК ВидОперации,
БанкПоступление.Комментарий КАК Комментарий
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
@@ -639,7 +647,10 @@ const SUPPLIER_PAYOUT_PROFILE_QUERY_TEMPLATE = `
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ВидОперации) КАК ВидОперации,
БанкСписание.НазначениеПлатежа КАК НазначениеПлатежа,
БанкСписание.Комментарий КАК Комментарий
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
@@ -595,6 +595,26 @@ function businessOverviewDebtDueDateAgingText(overview) {
}
return `Due-date aging на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`;
}
function financialFlowHintTextRuFromBucket(bucket) {
const rows = bucket?.financial_flow_hint_rows ?? 0;
const rowsText = rows > 0 ? ` (${rows} строк)` : "";
if (bucket?.financial_flow_hint === "loan_or_credit") {
return ` По полям банковского документа доминирует кредитный/заемный признак${rowsText}; это не обычный поставщик и не клиентская выручка без отдельной проверки назначения.`;
}
if (bucket?.financial_flow_hint === "bank_fee_or_service") {
return ` По полям банковского документа доминирует признак банковской комиссии/услуг банка${rowsText}; это не обычный поставщик товаров/услуг без отдельной проверки договора.`;
}
if (bucket?.financial_flow_hint === "tax_or_budget") {
return ` По полям банковского документа доминирует налоговый/бюджетный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "payroll_or_social") {
return ` По полям банковского документа доминирует зарплатный/социальный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "supplier_payment") {
return ` По полям банковского документа доминирует признак оплаты поставщику${rowsText}; если получатель по названию является банком, это все равно требует осторожной трактовки.`;
}
return "";
}
function businessOverviewVendorProcurementQualityText(overview) {
const quality = overview.vendor_procurement_quality;
if (!quality) {
@@ -617,7 +637,7 @@ function businessOverviewVendorProcurementQualityText(overview) {
? ` Договорный профиль: используется ${quality.used_contracts} договоров.`
: ` Договорный профиль: используется ${quality.used_contracts}/${quality.total_contracts} договоров${quality.used_contract_share_pct === null ? "" : ` (${quality.used_contract_share_pct}%)`}.`;
if (quality.evidence_status === "financial_institution_leads_outgoing_cash") {
return `Проверенный procurement-concentration route за ${period}: крупнейший получатель исходящих денег ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}. По названию это банк/финансовая организация, поэтому зависимость от обычного поставщика этим не подтверждается.${nonFinancialText}${contractText} Надежность поставщиков, качество поставок, назначение каждого платежа и полная структура расходов этим маршрутом не доказаны.`;
return `Проверенный procurement-concentration route за ${period}: крупнейший получатель исходящих денег ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}. По названию это банк/финансовая организация, поэтому зависимость от обычного поставщика этим не подтверждается.${financialFlowHintTextRuFromBucket(top)}${nonFinancialText}${contractText} Надежность поставщиков, качество поставок, назначение каждого платежа и полная структура расходов этим маршрутом не доказаны.`;
}
if (quality.evidence_status === "reviewed_procurement_concentration") {
return `Проверенный procurement-concentration route за ${period}: крупнейший поставщик/получатель исходящих платежей ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}.${contractText} Это проверенный сигнал концентрации закупок/исходящих платежей, но не аудит надежности поставщика, качества поставок и полной структуры расходов.`;
@@ -1611,6 +1611,80 @@ function rowCounterpartyValue(row) {
}
return rowAnalyticsTextValues(row).find(isLikelyCounterpartyToken) ?? null;
}
const FINANCIAL_FLOW_HINTS = [
"loan_or_credit",
"bank_fee_or_service",
"tax_or_budget",
"payroll_or_social",
"supplier_payment",
"unclear"
];
function normalizeFinancialFlowText(value) {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/\s+/g, " ")
.trim();
}
function rowFinancialFlowCorpus(row) {
return [
rowTextValue(row, ["ВидОперации", "OperationType", "operation_type"]),
rowTextValue(row, ["НазначениеПлатежа", "PaymentPurpose", "payment_purpose"]),
rowTextValue(row, ["Комментарий", "Comment", "comment"]),
rowContractValue(row),
rowDocumentValue(row)
]
.map(normalizeFinancialFlowText)
.filter(Boolean)
.join(" ");
}
function classifyRowFinancialFlowHint(row) {
const corpus = rowFinancialFlowCorpus(row);
if (!corpus) {
return "unclear";
}
if (/(?:кредит|кредитн|заем|займ|овердрафт|процент|ссуд|депозит|loan|credit|overdraft|deposit)/iu.test(corpus)) {
return "loan_or_credit";
}
if (/(?:комисс|эквайр|расчетн[оа -]*кассов|ведение\s+счет|банк[а-я\s-]*услуг|банковск[а-я\s-]*услуг|рко|bank\s*fee|acquiring|bank\s*service)/iu.test(corpus)) {
return "bank_fee_or_service";
}
if (/(?:налог|взнос|фсс|фомс|пфр|бюджет|казнач|пен[ия]|штраф|tax|budget|treasury)/iu.test(corpus)) {
return "tax_or_budget";
}
if (/(?:зарплат|заработн|перечислениезаработнойплаты|salary|payroll|wage)/iu.test(corpus)) {
return "payroll_or_social";
}
if (/(?:оплатапоставщику|оплата\s+поставщ|поставщ|supplier|vendor)/iu.test(corpus)) {
return "supplier_payment";
}
return "unclear";
}
function createFinancialFlowHintCounts() {
return {
loan_or_credit: 0,
bank_fee_or_service: 0,
tax_or_budget: 0,
payroll_or_social: 0,
supplier_payment: 0,
unclear: 0
};
}
function dominantFinancialFlowHint(counts) {
let bestHint = null;
let bestRows = 0;
for (const hint of FINANCIAL_FLOW_HINTS) {
const rows = counts[hint] ?? 0;
if (rows > bestRows) {
bestHint = hint;
bestRows = rows;
}
}
if (!bestHint || bestRows <= 0 || bestHint === "unclear") {
return { hint: null, rows: 0 };
}
return { hint: bestHint, rows: bestRows };
}
function normalizeDateParts(yearText, monthText, dayText) {
const year = Number(yearText);
const month = Number(monthText);
@@ -2119,22 +2193,32 @@ function deriveRankedValueFlow(result, input) {
continue;
}
rowsWithAmount += 1;
const current = buckets.get(axisValue) ?? { rows_with_amount: 0, total_amount: 0 };
const current = buckets.get(axisValue) ?? {
rows_with_amount: 0,
total_amount: 0,
financial_flow_hint_counts: createFinancialFlowHintCounts()
};
current.rows_with_amount += 1;
current.total_amount += amount;
current.financial_flow_hint_counts[classifyRowFinancialFlowHint(row)] += 1;
buckets.set(axisValue, current);
}
if (rowsWithAmount <= 0 || buckets.size <= 0) {
return null;
}
const rankedValues = Array.from(buckets.entries())
.map(([axisValue, bucket]) => ({
axis_value: axisValue,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
counterparty_role_hint: (0, counterpartyRoleHeuristics_1.counterpartyRoleHintForName)(axisValue)
}))
.map(([axisValue, bucket]) => {
const dominantHint = dominantFinancialFlowHint(bucket.financial_flow_hint_counts);
return {
axis_value: axisValue,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
counterparty_role_hint: (0, counterpartyRoleHeuristics_1.counterpartyRoleHintForName)(axisValue),
financial_flow_hint: dominantHint.hint ?? undefined,
financial_flow_hint_rows: dominantHint.hint ? dominantHint.rows : undefined
};
})
.sort((left, right) => {
const amountDelta = right.total_amount - left.total_amount;
if (input.rankingNeed === "bottom_asc") {
@@ -3430,6 +3514,26 @@ function summarizeBusinessOverviewRows(input) {
}
return parts.length > 0 ? parts.join("; ") : null;
}
function financialFlowHintTextRu(bucket) {
const rows = bucket?.financial_flow_hint_rows ?? 0;
const rowsText = rows > 0 ? ` (${rows} строк)` : "";
if (bucket?.financial_flow_hint === "loan_or_credit") {
return ` По полям банковского документа доминирует кредитный/заемный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "bank_fee_or_service") {
return ` По полям банковского документа доминирует признак банковской комиссии/услуг банка${rowsText}.`;
}
if (bucket?.financial_flow_hint === "tax_or_budget") {
return ` По полям банковского документа доминирует налоговый/бюджетный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "payroll_or_social") {
return ` По полям банковского документа доминирует зарплатный/социальный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "supplier_payment") {
return ` По полям банковского документа доминирует признак оплаты поставщику${rowsText}; все равно нужна осторожность, если контрагент по названию является банком.`;
}
return "";
}
function buildBusinessOverviewConfirmedFacts(derived) {
if (!derived) {
return [];
@@ -3447,6 +3551,10 @@ function buildBusinessOverviewConfirmedFacts(derived) {
const leader = derived.top_customers[0];
if ((0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(leader.axis_value)) {
facts.push(`Крупнейший входящий денежный источник в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа это не доказанный клиент или бизнес-выручка.`);
const financialHintText = financialFlowHintTextRu(leader);
if (financialHintText) {
facts.push(financialHintText.trim());
}
const nonFinancialLeader = derived.top_customers.slice(1).find((item) => !(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(item.axis_value));
if (nonFinancialLeader) {
facts.push(`Крупнейший небанковский входящий контрагент в проверенном срезе: ${nonFinancialLeader.axis_value}${nonFinancialLeader.total_amount_human_ru}.`);
@@ -3460,6 +3568,10 @@ function buildBusinessOverviewConfirmedFacts(derived) {
const leader = derived.top_suppliers[0];
if ((0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(leader.axis_value)) {
facts.push(`Крупнейший получатель исходящих денег в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора это не доказанный обычный поставщик.`);
const financialHintText = financialFlowHintTextRu(leader);
if (financialHintText) {
facts.push(financialHintText.trim());
}
const nonFinancialLeader = derived.top_suppliers.slice(1).find((item) => !(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(item.axis_value));
if (nonFinancialLeader) {
facts.push(`Крупнейший небанковский получатель исходящих денег в проверенном срезе: ${nonFinancialLeader.axis_value}${nonFinancialLeader.total_amount_human_ru}.`);
@@ -415,6 +415,28 @@ function overviewAxisLooksFinancial(row) {
return (row.counterparty_role_hint === "bank_or_financial_institution" ||
(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(row.axis_value));
}
function financialFlowHintTextRuFromRecord(row) {
const hint = toNonEmptyString(row?.financial_flow_hint);
const rows = typeof row?.financial_flow_hint_rows === "number" && Number.isFinite(row.financial_flow_hint_rows)
? ` (${row.financial_flow_hint_rows} строк)`
: "";
if (hint === "loan_or_credit") {
return `По полям банковского документа доминирует кредитный/заемный признак${rows}; это не обычная поставка и не клиентская выручка без отдельной проверки назначения.`;
}
if (hint === "bank_fee_or_service") {
return `По полям банковского документа доминирует признак банковской комиссии/услуг банка${rows}; это не обычный поставщик товаров/услуг без отдельной проверки договора.`;
}
if (hint === "tax_or_budget") {
return `По полям банковского документа доминирует налоговый/бюджетный признак${rows}; это не поставщик и не клиентская выручка.`;
}
if (hint === "payroll_or_social") {
return `По полям банковского документа доминирует зарплатный/социальный признак${rows}; это не поставщик и не клиентская выручка.`;
}
if (hint === "supplier_payment") {
return `По полям банковского документа доминирует признак оплаты поставщику${rows}; если получатель по названию является банком, это все равно требует осторожной трактовки.`;
}
return null;
}
function businessOverviewTaxLine(overview) {
const tax = toRecordObject(overview.tax_position);
if (!tax) {
@@ -795,6 +817,10 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
const totalText = totalOutgoing ? `; всего исходящих платежей в проверенном срезе ${totalOutgoing}` : "";
if (status === "financial_institution_leads_outgoing_cash") {
lines.push(`Коротко: проверенный procurement-concentration route за ${periodScope} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`);
const financialHintText = financialFlowHintTextRuFromRecord(topOutgoingRecord);
if (financialHintText) {
lines.push(financialHintText);
}
if (nonFinancialName) {
lines.push(`Крупнейший небанковский получатель исходящих денег: ${nonFinancialName}${nonFinancialShare ? `, около ${nonFinancialShare}` : ""}${nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}. Это уже сигнал закупочной/исходящей концентрации, но не аудит надежности поставщика.`);
}
@@ -360,7 +360,10 @@ const BANK_DOCS_QUERY_TEMPLATE = `
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ВидОперации) КАК ВидОперации,
БанкСписание.НазначениеПлатежа КАК НазначениеПлатежа,
БанкСписание.Комментарий КАК Комментарий
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
@@ -372,7 +375,10 @@ __WHERE_OUT__
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ВидОперации) КАК ВидОперации,
"" КАК НазначениеПлатежа,
БанкПоступление.Комментарий КАК Комментарий
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
@@ -644,7 +650,9 @@ const CUSTOMER_REVENUE_PROFILE_QUERY_TEMPLATE = `
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ВидОперации) КАК ВидОперации,
БанкПоступление.Комментарий КАК Комментарий
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
@@ -660,7 +668,10 @@ const SUPPLIER_PAYOUT_PROFILE_QUERY_TEMPLATE = `
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ВидОперации) КАК ВидОперации,
БанкСписание.НазначениеПлатежа КАК НазначениеПлатежа,
БанкСписание.Комментарий КАК Комментарий
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
@@ -736,6 +736,27 @@ function businessOverviewDebtDueDateAgingText(overview: BusinessOverview): strin
return `Due-date aging на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`;
}
function financialFlowHintTextRuFromBucket(bucket: BusinessOverviewRankedBucket | null | undefined): string {
const rows = bucket?.financial_flow_hint_rows ?? 0;
const rowsText = rows > 0 ? ` (${rows} строк)` : "";
if (bucket?.financial_flow_hint === "loan_or_credit") {
return ` По полям банковского документа доминирует кредитный/заемный признак${rowsText}; это не обычный поставщик и не клиентская выручка без отдельной проверки назначения.`;
}
if (bucket?.financial_flow_hint === "bank_fee_or_service") {
return ` По полям банковского документа доминирует признак банковской комиссии/услуг банка${rowsText}; это не обычный поставщик товаров/услуг без отдельной проверки договора.`;
}
if (bucket?.financial_flow_hint === "tax_or_budget") {
return ` По полям банковского документа доминирует налоговый/бюджетный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "payroll_or_social") {
return ` По полям банковского документа доминирует зарплатный/социальный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "supplier_payment") {
return ` По полям банковского документа доминирует признак оплаты поставщику${rowsText}; если получатель по названию является банком, это все равно требует осторожной трактовки.`;
}
return "";
}
function businessOverviewVendorProcurementQualityText(overview: BusinessOverview): string | null {
const quality = overview.vendor_procurement_quality;
if (!quality) {
@@ -759,7 +780,7 @@ function businessOverviewVendorProcurementQualityText(overview: BusinessOverview
? ` Договорный профиль: используется ${quality.used_contracts} договоров.`
: ` Договорный профиль: используется ${quality.used_contracts}/${quality.total_contracts} договоров${quality.used_contract_share_pct === null ? "" : ` (${quality.used_contract_share_pct}%)`}.`;
if (quality.evidence_status === "financial_institution_leads_outgoing_cash") {
return `Проверенный procurement-concentration route за ${period}: крупнейший получатель исходящих денег ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}. По названию это банк/финансовая организация, поэтому зависимость от обычного поставщика этим не подтверждается.${nonFinancialText}${contractText} Надежность поставщиков, качество поставок, назначение каждого платежа и полная структура расходов этим маршрутом не доказаны.`;
return `Проверенный procurement-concentration route за ${period}: крупнейший получатель исходящих денег ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}. По названию это банк/финансовая организация, поэтому зависимость от обычного поставщика этим не подтверждается.${financialFlowHintTextRuFromBucket(top)}${nonFinancialText}${contractText} Надежность поставщиков, качество поставок, назначение каждого платежа и полная структура расходов этим маршрутом не доказаны.`;
}
if (quality.evidence_status === "reviewed_procurement_concentration") {
return `Проверенный procurement-concentration route за ${period}: крупнейший поставщик/получатель исходящих платежей ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}.${contractText} Это проверенный сигнал концентрации закупок/исходящих платежей, но не аудит надежности поставщика, качества поставок и полной структуры расходов.`;
@@ -65,6 +65,14 @@ export interface AssistantMcpDiscoveryValueFlowMonthBucket {
total_amount_human_ru: string;
}
export type AssistantMcpDiscoveryFinancialFlowHint =
| "loan_or_credit"
| "bank_fee_or_service"
| "tax_or_budget"
| "payroll_or_social"
| "supplier_payment"
| "unclear";
export interface AssistantMcpDiscoveryDerivedValueFlow {
value_flow_direction: "incoming_customer_revenue" | "outgoing_supplier_payout";
counterparty: string | null;
@@ -89,6 +97,8 @@ export interface AssistantMcpDiscoveryRankedValueFlowBucket {
total_amount: number;
total_amount_human_ru: string;
counterparty_role_hint?: CounterpartyRoleHint;
financial_flow_hint?: AssistantMcpDiscoveryFinancialFlowHint;
financial_flow_hint_rows?: number;
}
export interface AssistantMcpDiscoveryDerivedRankedValueFlow {
@@ -2526,6 +2536,88 @@ function rowCounterpartyValue(row: Record<string, unknown>): string | null {
return rowAnalyticsTextValues(row).find(isLikelyCounterpartyToken) ?? null;
}
const FINANCIAL_FLOW_HINTS: AssistantMcpDiscoveryFinancialFlowHint[] = [
"loan_or_credit",
"bank_fee_or_service",
"tax_or_budget",
"payroll_or_social",
"supplier_payment",
"unclear"
];
function normalizeFinancialFlowText(value: unknown): string {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/\s+/g, " ")
.trim();
}
function rowFinancialFlowCorpus(row: Record<string, unknown>): string {
return [
rowTextValue(row, ["ВидОперации", "OperationType", "operation_type"]),
rowTextValue(row, ["НазначениеПлатежа", "PaymentPurpose", "payment_purpose"]),
rowTextValue(row, ["Комментарий", "Comment", "comment"]),
rowContractValue(row),
rowDocumentValue(row)
]
.map(normalizeFinancialFlowText)
.filter(Boolean)
.join(" ");
}
function classifyRowFinancialFlowHint(row: Record<string, unknown>): AssistantMcpDiscoveryFinancialFlowHint {
const corpus = rowFinancialFlowCorpus(row);
if (!corpus) {
return "unclear";
}
if (/(?:кредит|кредитн|заем|займ|овердрафт|процент|ссуд|депозит|loan|credit|overdraft|deposit)/iu.test(corpus)) {
return "loan_or_credit";
}
if (/(?:комисс|эквайр|расчетн[оа -]*кассов|ведение\s+счет|банк[а-я\s-]*услуг|банковск[а-я\s-]*услуг|рко|bank\s*fee|acquiring|bank\s*service)/iu.test(corpus)) {
return "bank_fee_or_service";
}
if (/(?:налог|взнос|фсс|фомс|пфр|бюджет|казнач|пен[ия]|штраф|tax|budget|treasury)/iu.test(corpus)) {
return "tax_or_budget";
}
if (/(?:зарплат|заработн|перечислениезаработнойплаты|salary|payroll|wage)/iu.test(corpus)) {
return "payroll_or_social";
}
if (/(?:оплатапоставщику|оплата\s+поставщ|поставщ|supplier|vendor)/iu.test(corpus)) {
return "supplier_payment";
}
return "unclear";
}
function createFinancialFlowHintCounts(): Record<AssistantMcpDiscoveryFinancialFlowHint, number> {
return {
loan_or_credit: 0,
bank_fee_or_service: 0,
tax_or_budget: 0,
payroll_or_social: 0,
supplier_payment: 0,
unclear: 0
};
}
function dominantFinancialFlowHint(
counts: Record<AssistantMcpDiscoveryFinancialFlowHint, number>
): { hint: AssistantMcpDiscoveryFinancialFlowHint | null; rows: number } {
let bestHint: AssistantMcpDiscoveryFinancialFlowHint | null = null;
let bestRows = 0;
for (const hint of FINANCIAL_FLOW_HINTS) {
const rows = counts[hint] ?? 0;
if (rows > bestRows) {
bestHint = hint;
bestRows = rows;
}
}
if (!bestHint || bestRows <= 0 || bestHint === "unclear") {
return { hint: null, rows: 0 };
}
return { hint: bestHint, rows: bestRows };
}
function normalizeDateParts(yearText: string, monthText: string, dayText: string): string | null {
const year = Number(yearText);
const month = Number(monthText);
@@ -3104,7 +3196,14 @@ function deriveRankedValueFlow(
return null;
}
const buckets = new Map<string, { rows_with_amount: number; total_amount: number }>();
const buckets = new Map<
string,
{
rows_with_amount: number;
total_amount: number;
financial_flow_hint_counts: Record<AssistantMcpDiscoveryFinancialFlowHint, number>;
}
>();
let rowsWithAmount = 0;
for (const row of result.rows) {
const axisValue = rowCounterpartyValue(row);
@@ -3113,9 +3212,14 @@ function deriveRankedValueFlow(
continue;
}
rowsWithAmount += 1;
const current = buckets.get(axisValue) ?? { rows_with_amount: 0, total_amount: 0 };
const current = buckets.get(axisValue) ?? {
rows_with_amount: 0,
total_amount: 0,
financial_flow_hint_counts: createFinancialFlowHintCounts()
};
current.rows_with_amount += 1;
current.total_amount += amount;
current.financial_flow_hint_counts[classifyRowFinancialFlowHint(row)] += 1;
buckets.set(axisValue, current);
}
@@ -3124,13 +3228,18 @@ function deriveRankedValueFlow(
}
const rankedValues = Array.from(buckets.entries())
.map(([axisValue, bucket]) => ({
axis_value: axisValue,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
counterparty_role_hint: counterpartyRoleHintForName(axisValue)
}))
.map(([axisValue, bucket]) => {
const dominantHint = dominantFinancialFlowHint(bucket.financial_flow_hint_counts);
return {
axis_value: axisValue,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
counterparty_role_hint: counterpartyRoleHintForName(axisValue),
financial_flow_hint: dominantHint.hint ?? undefined,
financial_flow_hint_rows: dominantHint.hint ? dominantHint.rows : undefined
};
})
.sort((left, right) => {
const amountDelta = right.total_amount - left.total_amount;
if (input.rankingNeed === "bottom_asc") {
@@ -4683,6 +4792,27 @@ function summarizeBusinessOverviewRows(input: {
return parts.length > 0 ? parts.join("; ") : null;
}
function financialFlowHintTextRu(bucket: AssistantMcpDiscoveryRankedValueFlowBucket | null | undefined): string {
const rows = bucket?.financial_flow_hint_rows ?? 0;
const rowsText = rows > 0 ? ` (${rows} строк)` : "";
if (bucket?.financial_flow_hint === "loan_or_credit") {
return ` По полям банковского документа доминирует кредитный/заемный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "bank_fee_or_service") {
return ` По полям банковского документа доминирует признак банковской комиссии/услуг банка${rowsText}.`;
}
if (bucket?.financial_flow_hint === "tax_or_budget") {
return ` По полям банковского документа доминирует налоговый/бюджетный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "payroll_or_social") {
return ` По полям банковского документа доминирует зарплатный/социальный признак${rowsText}.`;
}
if (bucket?.financial_flow_hint === "supplier_payment") {
return ` По полям банковского документа доминирует признак оплаты поставщику${rowsText}; все равно нужна осторожность, если контрагент по названию является банком.`;
}
return "";
}
function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDerivedBusinessOverview | null): string[] {
if (!derived) {
return [];
@@ -4706,6 +4836,10 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv
facts.push(
`Крупнейший входящий денежный источник в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа это не доказанный клиент или бизнес-выручка.`
);
const financialHintText = financialFlowHintTextRu(leader);
if (financialHintText) {
facts.push(financialHintText.trim());
}
const nonFinancialLeader = derived.top_customers.slice(1).find((item) => !isLikelyFinancialInstitutionCounterparty(item.axis_value));
if (nonFinancialLeader) {
facts.push(
@@ -4724,6 +4858,10 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv
facts.push(
`Крупнейший получатель исходящих денег в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора это не доказанный обычный поставщик.`
);
const financialHintText = financialFlowHintTextRu(leader);
if (financialHintText) {
facts.push(financialHintText.trim());
}
const nonFinancialLeader = derived.top_suppliers.slice(1).find((item) => !isLikelyFinancialInstitutionCounterparty(item.axis_value));
if (nonFinancialLeader) {
facts.push(
@@ -486,6 +486,29 @@ function overviewAxisLooksFinancial(row: Record<string, unknown> | null): boolea
);
}
function financialFlowHintTextRuFromRecord(row: Record<string, unknown> | null): string | null {
const hint = toNonEmptyString(row?.financial_flow_hint);
const rows = typeof row?.financial_flow_hint_rows === "number" && Number.isFinite(row.financial_flow_hint_rows)
? ` (${row.financial_flow_hint_rows} строк)`
: "";
if (hint === "loan_or_credit") {
return `По полям банковского документа доминирует кредитный/заемный признак${rows}; это не обычная поставка и не клиентская выручка без отдельной проверки назначения.`;
}
if (hint === "bank_fee_or_service") {
return `По полям банковского документа доминирует признак банковской комиссии/услуг банка${rows}; это не обычный поставщик товаров/услуг без отдельной проверки договора.`;
}
if (hint === "tax_or_budget") {
return `По полям банковского документа доминирует налоговый/бюджетный признак${rows}; это не поставщик и не клиентская выручка.`;
}
if (hint === "payroll_or_social") {
return `По полям банковского документа доминирует зарплатный/социальный признак${rows}; это не поставщик и не клиентская выручка.`;
}
if (hint === "supplier_payment") {
return `По полям банковского документа доминирует признак оплаты поставщику${rows}; если получатель по названию является банком, это все равно требует осторожной трактовки.`;
}
return null;
}
function businessOverviewTaxLine(overview: Record<string, unknown>): string | null {
const tax = toRecordObject(overview.tax_position);
if (!tax) {
@@ -948,6 +971,10 @@ function buildCompactBusinessOverviewReply(
lines.push(
`Коротко: проверенный procurement-concentration route за ${periodScope} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`
);
const financialHintText = financialFlowHintTextRuFromRecord(topOutgoingRecord);
if (financialHintText) {
lines.push(financialHintText);
}
if (nonFinancialName) {
lines.push(
`Крупнейший небанковский получатель исходящих денег: ${nonFinancialName}${nonFinancialShare ? `, около ${nonFinancialShare}` : ""}${nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}. Это уже сигнал закупочной/исходящей концентрации, но не аудит надежности поставщика.`
@@ -345,13 +345,13 @@ describe("assistant MCP discovery pilot executor", () => {
const deps = buildSequentialDeps([
{
rows: [
{ Period: "2020-01-15T00:00:00", Amount: 1200000, Counterparty: "СБЕРБАНК, ПАО" },
{ Period: "2020-01-15T00:00:00", Amount: 1200000, Counterparty: "СБЕРБАНК, ПАО", ВидОперации: "Возврат от поставщика", Договор: "Депозитный договор" },
{ Period: "2020-02-15T00:00:00", Amount: 800000, Counterparty: "Группа СВК" }
]
},
{
rows: [
{ Period: "2020-01-20T00:00:00", Amount: 650000, Counterparty: "СБЕРБАНК, ПАО" },
{ Period: "2020-01-20T00:00:00", Amount: 650000, Counterparty: "СБЕРБАНК, ПАО", ВидОперации: "ПрочееСписание", НазначениеПлатежа: "Комиссия банка за ведение счета" },
{ Period: "2020-03-20T00:00:00", Amount: 50000, Counterparty: "ООО Поставщик" }
]
},
@@ -365,7 +365,8 @@ describe("assistant MCP discovery pilot executor", () => {
expect(result.derived_business_overview?.top_customers[0]).toMatchObject({
axis_value: "СБЕРБАНК, ПАО",
counterparty_role_hint: "bank_or_financial_institution"
counterparty_role_hint: "bank_or_financial_institution",
financial_flow_hint: "loan_or_credit"
});
expect(result.derived_business_overview?.top_customers[1]).toMatchObject({
axis_value: "Группа СВК",
@@ -373,13 +374,16 @@ describe("assistant MCP discovery pilot executor", () => {
});
expect(result.derived_business_overview?.top_suppliers[0]).toMatchObject({
axis_value: "СБЕРБАНК, ПАО",
counterparty_role_hint: "bank_or_financial_institution"
counterparty_role_hint: "bank_or_financial_institution",
financial_flow_hint: "bank_fee_or_service"
});
const confirmedFacts = result.evidence.confirmed_facts.join("\n");
const inferredFacts = result.evidence.inferred_facts.join("\n");
expect(confirmedFacts).toContain("Крупнейший входящий денежный источник");
expect(confirmedFacts).toContain("Крупнейший небанковский входящий контрагент");
expect(confirmedFacts).toContain("Крупнейший получатель исходящих денег");
expect(confirmedFacts).toContain("кредитный/заемный признак");
expect(confirmedFacts).toContain("банковской комиссии/услуг банка");
expect(confirmedFacts).not.toContain("Самый крупный подтвержденный клиент в проверенном срезе: СБЕРБАНК");
expect(confirmedFacts).not.toContain("Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: СБЕРБАНК");
expect(inferredFacts).toContain("outgoing cash concentration proxy");