ДОМЕНЫ - ВОПРОСЫ - Этап 4: точный маршрут confirmed payables на дату без эвристического фолбэка

This commit is contained in:
2026-04-12 14:14:03 +03:00
parent 1b2ee93176
commit fbd156e58e
19 changed files with 1303 additions and 162 deletions
@@ -508,11 +508,11 @@ function classifyPayablesLiabilityCategory(row, counterparty) {
scores.supplier_or_contractor += 1;
reasons.add("участие счета 76");
}
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|loan|overdraft)/iu.test(text)) {
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit)/iu.test(text)) {
scores.bank_or_credit += 3;
reasons.add("банк/кредит в аналитике");
}
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос)/iu.test(text)) {
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал)/iu.test(text)) {
scores.tax_or_state += 3;
reasons.add("налог/госорган в аналитике");
}
@@ -525,6 +525,42 @@ function classifyPayablesLiabilityCategory(row, counterparty) {
reasons: Array.from(reasons)
};
}
const PAYABLES_CATEGORY_KEYS = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"];
function resolvePayablesLiabilityCategory(scores) {
let winner = "other";
let best = Number.NEGATIVE_INFINITY;
for (const key of PAYABLES_CATEGORY_KEYS) {
const score = scores[key];
if (score > best) {
best = score;
winner = key;
}
}
if (best <= 0) {
return "other";
}
return winner;
}
function hasPayablesSectionPrefix(account) {
const section = extractAccountSectionCode(account);
return section === "60" || section === "76";
}
function resolvePayablesAsOfDate(options) {
const explicit = normalizeIsoDateOnly(options.asOfDate);
if (explicit) {
return explicit;
}
const periodTo = normalizeIsoDateOnly(options.periodTo);
if (periodTo) {
return periodTo;
}
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
if (periodFrom) {
return periodFrom;
}
const now = new Date();
return toIsoDate(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate());
}
function buildPayablesCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
@@ -574,26 +610,10 @@ function buildPayablesCounterpartyRiskAggregate(rows) {
current.reasons.add(reason);
}
}
const scoreKeys = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"];
const toCategory = (scores) => {
let winner = "other";
let best = Number.NEGATIVE_INFINITY;
for (const key of scoreKeys) {
const score = scores[key];
if (score > best) {
best = score;
winner = key;
}
}
if (best <= 0) {
return "other";
}
return winner;
};
return Array.from(byCounterparty.values())
.map((item) => ({
...item.base,
category: toCategory(item.categoryScores),
category: resolvePayablesLiabilityCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.sort((left, right) => {
@@ -606,6 +626,88 @@ function buildPayablesCounterpartyRiskAggregate(rows) {
return left.name.localeCompare(right.name);
});
}
function buildPayablesConfirmedBalanceAggregate(rows, asOfDate) {
const byCounterparty = new Map();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
for (const row of rows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const rowTimestamp = toUtcDayTimestamp(row.period);
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
continue;
}
const amount = row.amount;
if (typeof amount !== "number" || !Number.isFinite(amount)) {
continue;
}
const absAmount = Math.abs(amount);
let delta = 0;
if (hasPayablesSectionPrefix(row.account_kt)) {
delta += absAmount;
}
if (hasPayablesSectionPrefix(row.account_dt)) {
delta -= absAmount;
}
if (Math.abs(delta) <= 0.0000001) {
continue;
}
const classified = classifyPayablesLiabilityCategory(row, name);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
outstandingAmount: delta,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
categoryScores: {
supplier_or_contractor: classified.scores.supplier_or_contractor,
bank_or_credit: classified.scores.bank_or_credit,
tax_or_state: classified.scores.tax_or_state,
other: classified.scores.other
},
reasons: new Set(classified.reasons)
});
continue;
}
current.outstandingAmount += delta;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor;
current.categoryScores.bank_or_credit += classified.scores.bank_or_credit;
current.categoryScores.tax_or_state += classified.scores.tax_or_state;
current.categoryScores.other += classified.scores.other;
for (const reason of classified.reasons) {
current.reasons.add(reason);
}
}
return Array.from(byCounterparty.entries())
.map(([name, item]) => ({
name,
outstandingAmount: item.outstandingAmount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
category: resolvePayablesLiabilityCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.filter((item) => item.outstandingAmount > 0.005)
.sort((left, right) => {
if (right.outstandingAmount !== left.outstandingAmount) {
return right.outstandingAmount - left.outstandingAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.name.localeCompare(right.name);
});
}
function buildCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
@@ -1663,61 +1765,206 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n")
};
}
if (intent === "list_payables_counterparties") {
const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const scopeLine = (() => {
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
if (asOfDate) {
return `Дата среза: ${formatDateRu(asOfDate)}.`;
}
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
if (periodFrom || periodTo) {
return `Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`;
}
return null;
})();
if (intent === "payables_confirmed_as_of_date") {
const payablesAsOfDate = resolvePayablesAsOfDate(options);
const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate);
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const scopeLine = asOfDate
? `- Дата среза: ${formatDateRu(asOfDate)}.`
: periodFrom || periodTo
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null;
const carryoverLine = asOfDate || periodFrom || periodTo
? "- В срез могут входить обязательства, возникшие до периода, если они оставались открытыми на дату среза."
: null;
const categoryCounts = confirmedBalances.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
const lines = [
"Коротко: собран shortlist кандидатов на ручную проверку по потенциально незакрытым обязательствам (контур 60/76).",
"",
"Что это значит:",
"- Режим результата: эвристический скоринг по движениям.",
"- Это не финальный подтвержденный остаток к оплате.",
...(scopeLine ? ["", scopeLine] : []),
"",
`Строк в выборке: ${rows.length}.`,
`Контрагентов-кандидатов: ${counterparties.length}.`
"Блок 1. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате (exact route).",
"- Эвристический shortlist в этом режиме не используется."
];
if (counterparties.length > 0) {
const categoryCounts = counterparties.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
lines.push("");
lines.push("Категории обязательств:");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Приоритет ручной проверки (по сумме/частоте сигналов):");
lines.push(...counterparties
.slice(0, 8)
.map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | статус: требует ручной проверки${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`));
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 4));
lines.push("");
lines.push("Блок 2. Что учтено");
lines.push(`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`);
if (scopeLine) {
lines.push(scopeLine);
}
lines.push("- Контур: обязательства по счетам 60/76.");
if (carryoverLine) {
lines.push(carryoverLine);
}
lines.push("");
lines.push("Блок 3. Сводка");
lines.push(`- Строк в выборке: ${rows.length}.`);
lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${confirmedBalances.length}.`);
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Блок 5. Подтвержденные позиции к оплате");
if (confirmedBalances.length > 0) {
lines.push(...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`));
}
else {
lines.push("");
lines.push("Явных кандидатов на незакрытые обязательства по текущему срезу не найдено.");
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 6));
lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено.");
}
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
balance_confirmed: true
}
};
}
if (intent === "list_payables_counterparties") {
const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const payablesAsOfDate = resolvePayablesAsOfDate(options);
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const scopeLine = asOfDate
? `- Дата среза: ${formatDateRu(asOfDate)}.`
: periodFrom || periodTo
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null;
const carryoverLine = asOfDate || periodFrom || periodTo
? "- В список могут попадать обязательства, возникшие раньше выбранного периода, если они потенциально оставались открытыми на дату среза."
: null;
const formatHeuristicItem = (item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const pushCategorySlice = (lines, title, items, limit) => {
if (items.length === 0) {
return;
}
lines.push("");
lines.push(title);
lines.push(...items.slice(0, limit).map(formatHeuristicItem));
};
const buildHeuristicLines = (forcedFallbackFromConfirmed) => {
const lines = [
"Блок 1. Статус результата",
forcedFallbackFromConfirmed
? "- Режим результата: эвристический скоринг в рамках fallback, потому что подтвержденный срез обязательств к оплате недоступен."
: "- Режим результата: эвристический скоринг (shortlist кандидатов по признакам незакрытых обязательств в контуре 60/76).",
"- Тип результата: кандидаты для ручной проверки, а не финальный платежный реестр.",
"",
"Блок 2. Как читать результат",
"- Это shortlist кандидатов: нужна ручная проверка бухгалтером.",
"- Это не подтвержденный остаток к оплате и не готовое платежное поручение.",
...(scopeLine ? [scopeLine] : []),
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов-кандидатов: ${counterparties.length}.`
];
if (counterparties.length > 0) {
const categoryCounts = counterparties.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
const suppliers = counterparties.filter((item) => item.category === "supplier_or_contractor");
const banks = counterparties.filter((item) => item.category === "bank_or_credit");
const taxOrState = counterparties.filter((item) => item.category === "tax_or_state");
const other = counterparties.filter((item) => item.category === "other");
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Блок 5. Кандидаты на проверку в первую очередь");
pushCategorySlice(lines, "5.1 Поставщики/подрядчики:", suppliers, 6);
pushCategorySlice(lines, "5.2 Банки/кредиты:", banks, 4);
pushCategorySlice(lines, "5.3 Налоги/госорганы:", taxOrState, 4);
pushCategorySlice(lines, "5.4 Прочие:", other, 4);
lines.push("");
lines.push("Блок 6. Примеры исходных строк");
lines.push(...formatTopRows(rows, 4));
}
else {
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push("- Явных кандидатов на незакрытые обязательства по доступному срезу не найдено.");
lines.push("");
lines.push("Блок 5. Примеры исходных строк");
lines.push(...formatTopRows(rows, 6));
}
return lines;
};
if (options.requestedResultMode === "confirmed_balance") {
const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate);
if (confirmedBalances.length > 0) {
const categoryCounts = confirmedBalances.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
const lines = [
"Блок 1. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате по состоянию на дату среза в контуре 60/76.",
"- Тип результата: подтвержденные остатки к оплате.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`,
...(periodFrom || periodTo
? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`]
: []),
"- Основание: движения обязательств и оплат в пределах доступного live-среза.",
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов с подтвержденным остатком: ${confirmedBalances.length}.`,
"",
"Блок 4. Категории обязательств",
`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`,
`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`,
`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`,
`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`,
"",
"Блок 5. Кому нужно заплатить в первую очередь (по сумме остатка):",
...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
const fallbackLines = buildHeuristicLines(true);
return {
responseType: "FACTUAL_LIST",
text: fallbackLines.join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}
const lines = buildHeuristicLines(false);
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
text: lines.join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}
if (intent === "list_receivables_counterparties") {
@@ -350,7 +350,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
}
}
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
if (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date") {
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = !currentContract ||
@@ -370,6 +372,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
if (sameDateRequested) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
}
if (allTimeRequested) {
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
@@ -424,6 +433,7 @@ function resolveMissingRequiredFilters(intent, filters) {
const requiredByIntent = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"],
@@ -91,7 +91,9 @@ function inferAggregationProfile(intent, shape) {
intent === "vat_payable_forecast") {
return "management_profile";
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date") {
return "balance_snapshot";
}
if (intent === "open_items_by_counterparty_or_contract" ||