ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Спека exact-маршрута payables на дату: confirmed_balance без эвристического финала

This commit is contained in:
2026-04-12 13:46:14 +03:00
parent ca2feab893
commit 1b2ee93176
19 changed files with 2453 additions and 252 deletions
@@ -470,6 +470,142 @@ function extractCounterpartyName(row) {
}
return null;
}
function liabilityCategoryLabel(category) {
if (category === "supplier_or_contractor") {
return "поставщики/подрядчики";
}
if (category === "bank_or_credit") {
return "банки/кредиты";
}
if (category === "tax_or_state") {
return "налоги/госорганы";
}
return "прочие";
}
function classifyPayablesLiabilityCategory(row, counterparty) {
const scores = {
supplier_or_contractor: 0,
bank_or_credit: 0,
tax_or_state: 0,
other: 0
};
const reasons = new Set();
const text = `${counterparty} ${row.registrator} ${row.analytics.join(" ")}`.toLowerCase();
const accountPrefixes = [extractAccountSectionCode(row.account_dt), extractAccountSectionCode(row.account_kt)].filter((item) => Boolean(item));
if (accountPrefixes.includes("60")) {
scores.supplier_or_contractor += 3;
reasons.add("участие счета 60");
}
if (accountPrefixes.includes("66") || accountPrefixes.includes("67")) {
scores.bank_or_credit += 4;
reasons.add("участие счета 66/67");
}
if (accountPrefixes.includes("68") || accountPrefixes.includes("69")) {
scores.tax_or_state += 4;
reasons.add("участие счета 68/69");
}
if (accountPrefixes.includes("76")) {
scores.supplier_or_contractor += 1;
reasons.add("участие счета 76");
}
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|loan|overdraft)/iu.test(text)) {
scores.bank_or_credit += 3;
reasons.add("банк/кредит в аналитике");
}
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос)/iu.test(text)) {
scores.tax_or_state += 3;
reasons.add("налог/госорган в аналитике");
}
if (/(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor)/iu.test(text)) {
scores.supplier_or_contractor += 2;
reasons.add("коммерческий контрагент в аналитике");
}
return {
scores,
reasons: Array.from(reasons)
};
}
function buildPayablesCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const amountRaw = row.amount ?? 0;
if (!Number.isFinite(amountRaw)) {
continue;
}
const amount = Math.abs(amountRaw);
const classified = classifyPayablesLiabilityCategory(row, name);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
base: {
name,
totalAmount: amount,
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.base.totalAmount += amount;
current.base.operations += 1;
if ((row.period ?? "") < (current.base.firstPeriod ?? "")) {
current.base.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.base.lastPeriod ?? "")) {
current.base.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);
}
}
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),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.sort((left, right) => {
if (right.totalAmount !== left.totalAmount) {
return right.totalAmount - left.totalAmount;
}
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) {
@@ -1528,22 +1664,55 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
if (intent === "list_payables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows);
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;
})();
const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
"Коротко: собран shortlist кандидатов на ручную проверку по потенциально незакрытым обязательствам (контур 60/76).",
"",
"Что это значит:",
"- Режим результата: эвристический скоринг по движениям.",
"- Это не финальный подтвержденный остаток к оплате.",
...(scopeLine ? ["", scopeLine] : []),
"",
`Строк в выборке: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.`
`Контрагентов-кандидатов: ${counterparties.length}.`
];
if (counterparties.length > 0) {
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
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} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
.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));
}
else {
lines.push("Явных признаков системной задолженности по доступному срезу не найдено.");
lines.push("");
lines.push("Явных кандидатов на незакрытые обязательства по текущему срезу не найдено.");
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 6));
}
return {