ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов Stage 3.7 ХВОСТЫ фикс маршрутов по домену задолжностей
This commit is contained in:
@@ -156,6 +156,86 @@ function normalizeQuestionText(value) {
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
function normalizeIsoDateOnly(value) {
|
||||
const parsed = parseIsoDateToken(value);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return toIsoDate(parsed.year, parsed.month, parsed.day);
|
||||
}
|
||||
function toUtcDayTimestamp(isoDate) {
|
||||
const parsed = parseIsoDateToken(isoDate);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return Date.UTC(parsed.year, parsed.month - 1, parsed.day);
|
||||
}
|
||||
function resolveReceivablesAsOfDate(options) {
|
||||
const explicit = normalizeIsoDateOnly(options.asOfDate);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
const periodTo = normalizeIsoDateOnly(options.periodTo);
|
||||
if (periodTo) {
|
||||
return periodTo;
|
||||
}
|
||||
const now = new Date();
|
||||
return toIsoDate(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate());
|
||||
}
|
||||
function hasReceivablesDebtAgingFocus(userMessage) {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const hasDebtSignal = /(?:долг(?:и|ов|а|у)?|задолж|дебитор|не плат|неоплач|просроч|хвост)/iu.test(text);
|
||||
const hasLongevitySignal = /(?:долгожив|долгожител|дольше|длительн|несколько\s+месяц|возраст|по\s+времен|с\s+момент|на\s+этот\s+момент)/iu.test(text);
|
||||
const hasCounterpartySignal = /(?:заказчик|клиент|покупател|контрагент|должник|counterpart|customer|client|buyer)/iu.test(text);
|
||||
return hasDebtSignal && hasLongevitySignal && hasCounterpartySignal;
|
||||
}
|
||||
function formatAgeYearsMonthsDays(daysRaw) {
|
||||
const safeDays = Math.max(0, Math.floor(daysRaw));
|
||||
const years = Math.floor(safeDays / 365);
|
||||
const remAfterYears = safeDays % 365;
|
||||
const months = Math.floor(remAfterYears / 30);
|
||||
const days = remAfterYears % 30;
|
||||
const parts = [];
|
||||
if (years > 0) {
|
||||
parts.push(`${years} г.`);
|
||||
}
|
||||
if (months > 0) {
|
||||
parts.push(`${months} мес.`);
|
||||
}
|
||||
if (parts.length === 0 || days > 0) {
|
||||
parts.push(`${days} дн.`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
function extractContractDateFromToken(token) {
|
||||
const source = String(token ?? "").trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
const lower = source.toLowerCase();
|
||||
if (!/(?:договор|contract|дог\.)/iu.test(lower)) {
|
||||
return null;
|
||||
}
|
||||
const isoMatch = source.match(/\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/);
|
||||
if (isoMatch) {
|
||||
const isoCandidate = isoMatch[0];
|
||||
return normalizeIsoDateOnly(isoCandidate);
|
||||
}
|
||||
const ruMatch = source.match(/\b(0[1-9]|[12]\d|3[01])[./-](0[1-9]|1[0-2])[./-]((?:19|20)\d{2})\b/);
|
||||
if (!ruMatch) {
|
||||
return null;
|
||||
}
|
||||
const day = Number(ruMatch[1]);
|
||||
const month = Number(ruMatch[2]);
|
||||
const year = Number(ruMatch[3]);
|
||||
if (!Number.isFinite(day) || !Number.isFinite(month) || !Number.isFinite(year)) {
|
||||
return null;
|
||||
}
|
||||
return toIsoDate(year, month, day);
|
||||
}
|
||||
function needsVatWhyExplanation(userMessage) {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
@@ -270,6 +350,16 @@ function detectCounterpartyLifecycleFocus(userMessage) {
|
||||
}
|
||||
return "active_customers_period";
|
||||
}
|
||||
function hasCounterpartyLifecycleLongevityQuestion(userMessage) {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test(text);
|
||||
const hasLongevityCue = /(?:долгожив|долгожител|дольше(?:\s+всех)?|сам(?:ые|ый)\s+стар(?:ые|ый)|лет\s+в\s+базе|лет\s+с\s+нами|longest|oldest)/iu.test(text);
|
||||
const hasImplicitCounterpartyQuestion = /(?:кто\s+с\s+нами|кто\s+у\s+нас)/iu.test(text);
|
||||
return (hasCounterpartyLexeme || hasImplicitCounterpartyQuestion) && hasLongevityCue;
|
||||
}
|
||||
function detectMinOpsForAvgCheck(userMessage) {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
@@ -345,6 +435,7 @@ function extractRequestedYearFromQuestion(userMessage) {
|
||||
return 2000 + shortYear;
|
||||
}
|
||||
function extractCounterpartyName(row) {
|
||||
const skipTokenPattern = /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu;
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
if (!normalized) {
|
||||
@@ -353,10 +444,178 @@ function extractCounterpartyName(row) {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (/^\d+(?:[./-]\d+)*$/.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (!/[a-zа-я]/iu.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (skipTokenPattern.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (normalized.length < 3) {
|
||||
continue;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function buildCounterpartyRiskAggregate(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 current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
totalAmount: amount,
|
||||
operations: 1,
|
||||
firstPeriod: row.period,
|
||||
lastPeriod: row.period
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.totalAmount += amount;
|
||||
current.operations += 1;
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
}
|
||||
return Array.from(byCounterparty.values()).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 pickContractStartDateFromRow(row) {
|
||||
for (const token of row.analytics) {
|
||||
const detected = extractContractDateFromToken(token);
|
||||
if (detected) {
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
const byRegistrator = extractContractDateFromToken(row.registrator);
|
||||
if (byRegistrator) {
|
||||
return byRegistrator;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function minIsoDate(left, right) {
|
||||
if (!left) {
|
||||
return right;
|
||||
}
|
||||
if (!right) {
|
||||
return left;
|
||||
}
|
||||
return right < left ? right : left;
|
||||
}
|
||||
function maxIsoDate(left, right) {
|
||||
if (!left) {
|
||||
return right;
|
||||
}
|
||||
if (!right) {
|
||||
return left;
|
||||
}
|
||||
return right > left ? right : left;
|
||||
}
|
||||
function buildCounterpartyDebtAgingAggregate(rows, asOfDate) {
|
||||
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 rowIso = normalizeIsoDateOnly(row.period);
|
||||
const contractDate = pickContractStartDateFromRow(row);
|
||||
const contractName = extractContractName(row);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
base: {
|
||||
name,
|
||||
totalAmount: amount,
|
||||
operations: 1,
|
||||
firstPeriod: rowIso,
|
||||
lastPeriod: rowIso
|
||||
},
|
||||
minContractDate: contractDate,
|
||||
contracts: new Set(contractName ? [contractName] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.base.totalAmount += amount;
|
||||
current.base.operations += 1;
|
||||
current.base.firstPeriod = minIsoDate(current.base.firstPeriod, rowIso);
|
||||
current.base.lastPeriod = maxIsoDate(current.base.lastPeriod, rowIso);
|
||||
current.minContractDate = minIsoDate(current.minContractDate, contractDate);
|
||||
if (contractName) {
|
||||
current.contracts.add(contractName);
|
||||
}
|
||||
}
|
||||
const asOfTs = toUtcDayTimestamp(asOfDate);
|
||||
const finalized = Array.from(byCounterparty.values()).map((entry) => {
|
||||
const debtAgeStartDate = entry.minContractDate ?? entry.base.firstPeriod ?? null;
|
||||
const startTs = toUtcDayTimestamp(debtAgeStartDate);
|
||||
const debtAgeSource = entry.minContractDate
|
||||
? "contract_date"
|
||||
: "first_movement";
|
||||
const debtAgeDays = asOfTs !== null && startTs !== null && asOfTs >= startTs
|
||||
? Math.floor((asOfTs - startTs) / (24 * 60 * 60 * 1000))
|
||||
: null;
|
||||
return {
|
||||
...entry.base,
|
||||
debtAgeStartDate,
|
||||
debtAgeDays,
|
||||
debtAgeSource,
|
||||
contractDateDetected: Boolean(entry.minContractDate),
|
||||
contracts: Array.from(entry.contracts.values()).slice(0, 3)
|
||||
};
|
||||
});
|
||||
return finalized.sort((left, right) => {
|
||||
const leftAge = left.debtAgeDays ?? -1;
|
||||
const rightAge = right.debtAgeDays ?? -1;
|
||||
if (rightAge !== leftAge) {
|
||||
return rightAge - leftAge;
|
||||
}
|
||||
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 extractContractName(row) {
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
@@ -744,7 +1003,37 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||
}
|
||||
if (intent === "counterparty_activity_lifecycle") {
|
||||
const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY");
|
||||
const activityYearRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR");
|
||||
const byCounterparty = new Map();
|
||||
for (const row of activityYearRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.opsCount += opsCount;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
for (const row of activityRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
@@ -753,48 +1042,90 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, { name, opsCount, lastPeriod: row.period });
|
||||
const year = extractYearFromIso(row.period);
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (opsCount > current.opsCount) {
|
||||
if (activityYearRows.length === 0 && opsCount > current.opsCount) {
|
||||
current.opsCount = opsCount;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
const year = extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
const counterparties = Array.from(byCounterparty.values()).sort((left, right) => {
|
||||
const counterpartiesRaw = Array.from(byCounterparty.values());
|
||||
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
|
||||
const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
|
||||
const rankingLimit = detectRankingLimit(options.userMessage, 10);
|
||||
const counterparties = counterpartiesRaw.sort((left, right) => {
|
||||
if (longevityQuestion) {
|
||||
const yearsDiff = right.years.size - left.years.size;
|
||||
if (yearsDiff !== 0) {
|
||||
return yearsDiff;
|
||||
}
|
||||
}
|
||||
if (right.opsCount !== left.opsCount) {
|
||||
return right.opsCount - left.opsCount;
|
||||
}
|
||||
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
|
||||
});
|
||||
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
|
||||
const scopeLabel = focus === "active_customers_all_time"
|
||||
? "за все время"
|
||||
: requestedYear
|
||||
? `в ${requestedYear} году`
|
||||
: "в выбранном периоде";
|
||||
const lines = [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
const lines = longevityQuestion
|
||||
? [
|
||||
`Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`,
|
||||
"Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
]
|
||||
: [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (counterparties.length === 0) {
|
||||
lines.push("По выбранному окну активности заказчики не найдены.");
|
||||
lines.push(longevityQuestion
|
||||
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам."
|
||||
: "По выбранному окну активности заказчики не найдены.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const visible = counterparties.slice(0, 120);
|
||||
const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120);
|
||||
if (longevityQuestion) {
|
||||
lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`);
|
||||
}
|
||||
lines.push(...visible.map((item, index) => {
|
||||
const years = Array.from(item.years).sort((a, b) => a - b);
|
||||
const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : "";
|
||||
const periodSpan = item.firstPeriod && item.lastPeriod ? ` | период: ${item.firstPeriod}..${item.lastPeriod}` : "";
|
||||
if (longevityQuestion) {
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`;
|
||||
}
|
||||
const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : "";
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}`;
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`;
|
||||
}));
|
||||
if (counterparties.length > visible.length) {
|
||||
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
|
||||
lines.push(longevityQuestion
|
||||
? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).`
|
||||
: `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
@@ -1171,6 +1502,7 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||
}
|
||||
if (intent === "list_open_contracts") {
|
||||
const contracts = contractCandidatesFromRows(rows);
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const lines = [
|
||||
"Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).",
|
||||
`Строк движения: ${rows.length}.`,
|
||||
@@ -1179,6 +1511,13 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||
if (contracts.length > 0) {
|
||||
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
||||
}
|
||||
else if (counterparties.length > 0) {
|
||||
lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`);
|
||||
lines.push(...counterparties
|
||||
.slice(0, 8)
|
||||
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
|
||||
lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска.");
|
||||
}
|
||||
else {
|
||||
lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback.");
|
||||
lines.push(...formatTopRows(rows, 6));
|
||||
@@ -1189,39 +1528,102 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||
};
|
||||
}
|
||||
if (intent === "list_payables_counterparties") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const lines = [
|
||||
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
|
||||
`Строк в выборке: ${rows.length}.`,
|
||||
...(rows.length > 0
|
||||
? ["Ниже примеры строк для ручной проверки."]
|
||||
: ["Явных признаков системной задолженности по доступному срезу не найдено."]),
|
||||
...formatTopRows(rows, 6)
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
if (counterparties.length > 0) {
|
||||
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
|
||||
lines.push(...counterparties
|
||||
.slice(0, 8)
|
||||
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
|
||||
lines.push("Примеры исходных строк:");
|
||||
lines.push(...formatTopRows(rows, 4));
|
||||
}
|
||||
else {
|
||||
lines.push("Явных признаков системной задолженности по доступному срезу не найдено.");
|
||||
lines.push(...formatTopRows(rows, 6));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "list_receivables_counterparties") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage);
|
||||
if (debtAgingFocus) {
|
||||
const asOfDate = resolveReceivablesAsOfDate(options);
|
||||
const aging = buildCounterpartyDebtAgingAggregate(rows, asOfDate);
|
||||
const detectedContractDates = aging.filter((item) => item.contractDateDetected).length;
|
||||
const lines = [
|
||||
"Проверил должников по сроку жизни задолженности (контур 62/76).",
|
||||
`Дата среза: ${formatDateRu(asOfDate)}.`,
|
||||
`Строк в выборке: ${rows.length}.`,
|
||||
`Контрагентов с сигналом: ${aging.length}.`
|
||||
];
|
||||
if (aging.length > 0) {
|
||||
lines.push("Приоритет ручной проверки (по возрасту долга, по убыванию):");
|
||||
lines.push(...aging.slice(0, 10).map((item, index) => {
|
||||
const ageLabel = item.debtAgeDays !== null ? formatAgeYearsMonthsDays(item.debtAgeDays) : "н/д";
|
||||
const startDateLabel = item.debtAgeStartDate ? formatDateRu(item.debtAgeStartDate) : "не определена";
|
||||
const startSourceLabel = item.debtAgeSource === "contract_date" ? "дата договора" : "первое движение по договору/контрагенту";
|
||||
const contractsLabel = item.contracts.length > 0 ? item.contracts.join("; ") : "договор не выделен в срезе";
|
||||
return `${index + 1}. ${item.name} | договоры: ${contractsLabel} | возраст долга: ${ageLabel} | старт: ${startDateLabel} (${startSourceLabel}) | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}`;
|
||||
}));
|
||||
lines.push(detectedContractDates > 0
|
||||
? `Дата договора выделена для ${detectedContractDates} из ${aging.length} контрагентов; для остальных использован старт первого движения.`
|
||||
: "Явная дата договора в live-строках не выделена, возраст рассчитан от первого движения.");
|
||||
}
|
||||
else {
|
||||
lines.push("Явных признаков затяжной дебиторки по доступному срезу не найдено.");
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const lines = [
|
||||
"Проверил покупателей с признаками затянутой оплаты (контур 62/76).",
|
||||
`Строк в выборке: ${rows.length}.`,
|
||||
...(rows.length > 0
|
||||
? ["Ниже примеры строк, которые стоит проверить в первую очередь."]
|
||||
: ["Явных признаков затяжной дебиторки по доступному срезу не найдено."]),
|
||||
...formatTopRows(rows, 6)
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
if (counterparties.length > 0) {
|
||||
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
|
||||
lines.push(...counterparties
|
||||
.slice(0, 8)
|
||||
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
|
||||
lines.push("Примеры исходных строк:");
|
||||
lines.push(...formatTopRows(rows, 4));
|
||||
}
|
||||
else {
|
||||
lines.push("Явных признаков затяжной дебиторки по доступному срезу не найдено.");
|
||||
lines.push(...formatTopRows(rows, 6));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const lines = [
|
||||
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
||||
"Собраны открытые позиции по взаиморасчетам.",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 6)
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
if (counterparties.length > 0) {
|
||||
lines.push(...counterparties
|
||||
.slice(0, 8)
|
||||
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
|
||||
lines.push("Примеры исходных строк:");
|
||||
lines.push(...formatTopRows(rows, 4));
|
||||
}
|
||||
else {
|
||||
lines.push(...formatTopRows(rows, 6));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
|
||||
Reference in New Issue
Block a user