АРЧ АП11 - Архитектура после ге :
This commit is contained in:
@@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.contractCandidatesFromRows = contractCandidatesFromRows;
|
||||
exports.composeFactualReply = composeFactualReply;
|
||||
exports.inferReplyType = inferReplyType;
|
||||
const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher");
|
||||
const replyPackaging_1 = require("./replyPackaging");
|
||||
const counterpartyAnalyticsReplyBuilders_1 = require("./counterpartyAnalyticsReplyBuilders");
|
||||
const inventoryReplyBuilders_1 = require("./inventoryReplyBuilders");
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values
|
||||
@@ -2135,6 +2135,30 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
||||
if (inventoryReply) {
|
||||
return inventoryReply;
|
||||
}
|
||||
const counterpartyAnalyticsReply = (0, counterpartyAnalyticsReplyBuilders_1.composeCounterpartyAnalyticsReply)(intent, rows, options, {
|
||||
formatPercent,
|
||||
formatDateRu,
|
||||
formatMoneyRub,
|
||||
extractYearFromIso,
|
||||
detectCounterpartyProfileFocus,
|
||||
detectCounterpartyLifecycleFocus,
|
||||
hasCounterpartyLifecycleLongevityQuestion,
|
||||
hasCounterpartyActivityAgeQuestion,
|
||||
detectRankingLimit,
|
||||
detectValueRankingFocus,
|
||||
detectContractValueFocus,
|
||||
detectMinOpsForAvgCheck,
|
||||
extractRequestedYearFromQuestion,
|
||||
extractCounterpartyName,
|
||||
extractContractName,
|
||||
counterpartyLookupMatches,
|
||||
toUtcDayTimestamp,
|
||||
formatAgeYearsMonthsDays,
|
||||
normalizeQuestionText
|
||||
});
|
||||
if (counterpartyAnalyticsReply) {
|
||||
return counterpartyAnalyticsReply;
|
||||
}
|
||||
if (intent === "document_type_and_account_section_profile") {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
@@ -2366,692 +2390,6 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "counterparty_population_and_roles") {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker).push(row);
|
||||
}
|
||||
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const totalCounterparties = sumMarker("CP_TOTAL");
|
||||
const customerActive = sumMarker("CP_CUSTOMER_ACTIVE");
|
||||
const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE");
|
||||
const mixedActive = sumMarker("CP_MIXED_ACTIVE");
|
||||
const activeUnion = sumMarker("CP_ACTIVE_UNION");
|
||||
const customerOnly = Math.max(0, customerActive - mixedActive);
|
||||
const supplierOnly = Math.max(0, supplierActive - mixedActive);
|
||||
const resolvedActive = customerOnly + supplierOnly + mixedActive;
|
||||
const activeCounterparties = Math.max(activeUnion, resolvedActive);
|
||||
const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null;
|
||||
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 = [
|
||||
directLead,
|
||||
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (includeTotal) {
|
||||
if (totalCounterparties > 0) {
|
||||
lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`);
|
||||
}
|
||||
else if (activeCounterparties > 0) {
|
||||
lines.push(`Total из справочника не получен, оценка по активности в документах: ${activeCounterparties} контрагентов.`);
|
||||
}
|
||||
else {
|
||||
lines.push("По количеству контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
if (includeRoles) {
|
||||
if (resolvedActive > 0 || activeCounterparties > 0) {
|
||||
lines.push("Роли контрагентов по активности:");
|
||||
lines.push(`1. Заказчики (только customer-роль): ${customerOnly}.`);
|
||||
lines.push(`2. Поставщики (только supplier-роль): ${supplierOnly}.`);
|
||||
lines.push(`3. Смешанные (и покупатель, и поставщик): ${mixedActive}.`);
|
||||
lines.push(`4. Активные контрагенты (union ролей): ${activeCounterparties}.`);
|
||||
if (otherCounterparties !== null) {
|
||||
lines.push(`5. Прочие/неактивные в выбранном окне: ${otherCounterparties}.`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
lines.push("По role-split контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
if (focus === "suppliers_only") {
|
||||
lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`);
|
||||
}
|
||||
if (focus === "customers_only") {
|
||||
lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`);
|
||||
}
|
||||
if (focus === "mixed_only") {
|
||||
lines.push(`Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "counterparty_activity_lifecycle") {
|
||||
const activityFirstRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST");
|
||||
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 activityFirstRows) {
|
||||
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,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) {
|
||||
current.firstObservedActivity = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
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,
|
||||
firstObservedActivity: null,
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
const year = extractYearFromIso(row.period);
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
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 counterpartiesRaw = Array.from(byCounterparty.values());
|
||||
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
|
||||
const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
|
||||
const activityAgeQuestion = hasCounterpartyActivityAgeQuestion(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 scopeLabel = focus === "active_customers_all_time"
|
||||
? "за все время"
|
||||
: requestedYear
|
||||
? `в ${requestedYear} году`
|
||||
: "в выбранном периоде";
|
||||
if (activityAgeQuestion) {
|
||||
const focusedCounterparty = counterparties.find((item) => counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null;
|
||||
if (focusedCounterparty) {
|
||||
const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod;
|
||||
const lastObservedActivity = focusedCounterparty.lastPeriod;
|
||||
const firstTimestamp = toUtcDayTimestamp(firstObservedActivity);
|
||||
const lastTimestamp = toUtcDayTimestamp(lastObservedActivity);
|
||||
const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel = observedDays !== null
|
||||
? formatAgeYearsMonthsDays(observedDays)
|
||||
: focusedCounterparty.years.size > 0
|
||||
? `${focusedCounterparty.years.size} г.`
|
||||
: null;
|
||||
const directLine = observedAgeLabel && firstObservedActivity && lastObservedActivity
|
||||
? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`;
|
||||
const lines = [directLine];
|
||||
if (firstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${formatDateRu(firstObservedActivity)}.`);
|
||||
}
|
||||
if (lastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${formatDateRu(lastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`);
|
||||
if (focusedCounterparty.years.size > 0) {
|
||||
const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const organizationHint = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint ?? null);
|
||||
if (organizationHint && counterparties.length > 0) {
|
||||
const organizationFirstObservedActivity = counterparties.reduce((earliest, item) => {
|
||||
const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null;
|
||||
if (!candidate) {
|
||||
return earliest;
|
||||
}
|
||||
if (!earliest || candidate < earliest) {
|
||||
return candidate;
|
||||
}
|
||||
return earliest;
|
||||
}, null);
|
||||
const organizationLastObservedActivity = counterparties.reduce((latest, item) => {
|
||||
const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null;
|
||||
if (!candidate) {
|
||||
return latest;
|
||||
}
|
||||
if (!latest || candidate > latest) {
|
||||
return candidate;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
const organizationYears = new Set();
|
||||
let organizationOpsCount = 0;
|
||||
for (const item of counterparties) {
|
||||
organizationOpsCount += item.opsCount;
|
||||
for (const year of item.years) {
|
||||
organizationYears.add(year);
|
||||
}
|
||||
}
|
||||
const firstTimestamp = toUtcDayTimestamp(organizationFirstObservedActivity);
|
||||
const lastTimestamp = toUtcDayTimestamp(organizationLastObservedActivity);
|
||||
const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel = observedDays !== null
|
||||
? formatAgeYearsMonthsDays(observedDays)
|
||||
: organizationYears.size > 0
|
||||
? `${organizationYears.size} г.`
|
||||
: null;
|
||||
const lines = [
|
||||
observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity
|
||||
? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.`
|
||||
];
|
||||
if (organizationFirstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${formatDateRu(organizationFirstObservedActivity)}.`);
|
||||
}
|
||||
if (organizationLastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${formatDateRu(organizationLastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`);
|
||||
if (organizationYears.size > 0) {
|
||||
const years = Array.from(organizationYears).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
}
|
||||
const lines = longevityQuestion
|
||||
? [
|
||||
`Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`,
|
||||
"Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
]
|
||||
: [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (counterparties.length === 0) {
|
||||
lines.push(longevityQuestion
|
||||
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам."
|
||||
: "По выбранному окну активности заказчики не найдены.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
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}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`;
|
||||
}));
|
||||
if (counterparties.length > visible.length) {
|
||||
lines.push(longevityQuestion
|
||||
? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).`
|
||||
: `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "contract_usage_overview") {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker).push(row);
|
||||
}
|
||||
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const totalContracts = sumMarker("CT_TOTAL");
|
||||
const usedContracts = sumMarker("CT_USED");
|
||||
const unusedContracts = 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 = [
|
||||
usageLead,
|
||||
"Профиль договорной базы собран (catalog + usage aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (totalContracts > 0) {
|
||||
lines.push(`Всего договоров в базе: ${totalContracts}.`);
|
||||
}
|
||||
else {
|
||||
lines.push("Общее количество договоров не получено (пустой/недоступный срез справочника).");
|
||||
}
|
||||
lines.push(`Использованных договоров (есть factual связь с операциями): ${usedContracts}.`);
|
||||
if (unusedContracts !== null) {
|
||||
lines.push(`Неиспользуемых договоров: ${unusedContracts}.`);
|
||||
}
|
||||
if (usedShare) {
|
||||
lines.push(`Доля используемых договоров: ${usedShare}.`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
|
||||
const isSupplier = intent === "supplier_payouts_profile";
|
||||
const focus = detectValueRankingFocus(options.userMessage);
|
||||
const limit = detectRankingLimit(options.userMessage, 20);
|
||||
const minOpsForAvgCheck = detectMinOpsForAvgCheck(options.userMessage);
|
||||
const normalizedQuestion = normalizeQuestionText(options.userMessage);
|
||||
const byCounterparty = new Map();
|
||||
const byYear = new Map();
|
||||
const deals = [];
|
||||
for (const row of rows) {
|
||||
const counterparty = extractCounterpartyName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!counterparty || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const current = byCounterparty.get(counterparty);
|
||||
if (!current) {
|
||||
byCounterparty.set(counterparty, {
|
||||
name: counterparty,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
minSingle: amount,
|
||||
lastPeriod: row.period
|
||||
});
|
||||
}
|
||||
else {
|
||||
current.total += amount;
|
||||
current.ops += 1;
|
||||
current.maxSingle = Math.max(current.maxSingle, amount);
|
||||
current.minSingle = Math.min(current.minSingle, amount);
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
}
|
||||
deals.push({
|
||||
period: row.period,
|
||||
registrator: row.registrator,
|
||||
counterparty,
|
||||
amount
|
||||
});
|
||||
const year = extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
const yearBucket = byYear.get(year);
|
||||
if (!yearBucket) {
|
||||
byYear.set(year, {
|
||||
year,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
counterparties: new Set([counterparty])
|
||||
});
|
||||
}
|
||||
else {
|
||||
yearBucket.total += amount;
|
||||
yearBucket.ops += 1;
|
||||
yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount);
|
||||
yearBucket.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByAvgCheck = [...profileRows]
|
||||
.filter((item) => item.ops >= minOpsForAvgCheck)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
avgCheck: item.total / item.ops
|
||||
}))
|
||||
.sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedDealsTop = [...deals].sort((a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? ""));
|
||||
const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion);
|
||||
const activeCounterpartiesForBottom = new Set(profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name));
|
||||
const rankedDealsBottom = [...deals]
|
||||
.filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty))
|
||||
.sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? ""));
|
||||
const lines = [
|
||||
isSupplier
|
||||
? "Собран профиль выплат поставщикам (bank-doc value aggregate)."
|
||||
: "Собран профиль поступлений от заказчиков (bank-doc value aggregate).",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Уникальных контрагентов: ${profileRows.length}.`
|
||||
];
|
||||
if (profileRows.length === 0) {
|
||||
lines.push("По выбранному окну данных платежные строки не найдены.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "total_flow") {
|
||||
const periodLine = options.periodFrom && options.periodTo
|
||||
? `За период ${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)} подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это сумма денег, полученных от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
`- Контрагентов в выборке: ${profileRows.length}.`
|
||||
];
|
||||
if (rankedByYearTotal.length > 0) {
|
||||
summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${formatMoneyRub(rankedByYearTotal[0].total)}).`);
|
||||
}
|
||||
if (rankedByTotal.length > 0) {
|
||||
summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${formatMoneyRub(rankedByTotal[0].total)}).`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: summaryLines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_years_by_total") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} лет по сумме выплат:`
|
||||
: `Топ-${visible.length} лет по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
|
||||
}
|
||||
else {
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}`));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_by_ops") {
|
||||
const visible = rankedByOps.slice(0, limit);
|
||||
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}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_by_max_single") {
|
||||
const visible = rankedByMaxSingle.slice(0, limit);
|
||||
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}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_by_avg_check_min_ops") {
|
||||
const visible = rankedByAvgCheck.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
|
||||
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
|
||||
}
|
||||
else {
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | средний чек: ${item.avgCheck.toFixed(2)} | операций: ${item.ops} | сумма: ${item.total}`));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_deals") {
|
||||
const visible = rankedDealsTop.slice(0, limit);
|
||||
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}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "bottom_deals") {
|
||||
const visible = rankedDealsBottom.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых маленьких разовых выплат:`
|
||||
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
|
||||
lines.unshift(heading);
|
||||
if (activeOnlyForBottomDeals) {
|
||||
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
|
||||
}
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const visible = rankedByTotal.slice(0, limit);
|
||||
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";
|
||||
return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`;
|
||||
}));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "contract_usage_and_value") {
|
||||
const focus = detectContractValueFocus(options.userMessage);
|
||||
const limit = detectRankingLimit(options.userMessage, 20);
|
||||
const byContract = new Map();
|
||||
for (const row of rows) {
|
||||
const contract = extractContractName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!contract || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const counterparty = extractCounterpartyName(row);
|
||||
const current = byContract.get(contract);
|
||||
if (!current) {
|
||||
byContract.set(contract, {
|
||||
contract,
|
||||
turnover: amount,
|
||||
docs: 1,
|
||||
lastPeriod: row.period,
|
||||
counterparties: new Set(counterparty ? [counterparty] : [])
|
||||
});
|
||||
}
|
||||
else {
|
||||
current.turnover += amount;
|
||||
current.docs += 1;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if (counterparty) {
|
||||
current.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
const contractRows = Array.from(byContract.values());
|
||||
const rankedByTurnover = [...contractRows].sort((a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
const rankedByDocs = [...contractRows].sort((a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract));
|
||||
const rankedBottomActive = [...contractRows]
|
||||
.filter((item) => item.docs > 0 && item.turnover > 0)
|
||||
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
const lines = [
|
||||
`Активных договоров: ${contractRows.length}.`,
|
||||
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Договорных агрегатов: ${contractRows.length}.`
|
||||
];
|
||||
if (contractRows.length === 0) {
|
||||
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_by_docs") {
|
||||
const visible = rankedByDocs.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} договоров по количеству операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "bottom_by_turnover_active") {
|
||||
const visible = rankedBottomActive.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const visible = rankedByTurnover.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} договоров по сумме оборота:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "vat_payable_forecast") {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
|
||||
+632
@@ -0,0 +1,632 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.composeCounterpartyAnalyticsReply = composeCounterpartyAnalyticsReply;
|
||||
const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher");
|
||||
const replyContracts_1 = require("./replyContracts");
|
||||
function groupRowsByMarker(rows) {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker).push(row);
|
||||
}
|
||||
return rowsByMarker;
|
||||
}
|
||||
function formatOptionalDate(value, formatDateRu) {
|
||||
return value ? formatDateRu(value) : "дата не указана";
|
||||
}
|
||||
function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
|
||||
if (intent === "counterparty_population_and_roles") {
|
||||
const rowsByMarker = groupRowsByMarker(rows);
|
||||
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const totalCounterparties = sumMarker("CP_TOTAL");
|
||||
const customerActive = sumMarker("CP_CUSTOMER_ACTIVE");
|
||||
const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE");
|
||||
const mixedActive = sumMarker("CP_MIXED_ACTIVE");
|
||||
const activeUnion = sumMarker("CP_ACTIVE_UNION");
|
||||
const customerOnly = Math.max(0, customerActive - mixedActive);
|
||||
const supplierOnly = Math.max(0, supplierActive - mixedActive);
|
||||
const resolvedActive = customerOnly + supplierOnly + mixedActive;
|
||||
const activeCounterparties = Math.max(activeUnion, resolvedActive);
|
||||
const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null;
|
||||
const focus = deps.detectCounterpartyProfileFocus(options.userMessage);
|
||||
const includeTotal = focus === "full_profile" || focus === "total_only";
|
||||
const includeRoles = focus === "full_profile" || focus === "roles_only";
|
||||
const directLead = focus === "suppliers_only"
|
||||
? `Контрагентов только в роли поставщика: ${supplierOnly}.`
|
||||
: focus === "customers_only"
|
||||
? `Контрагентов только в роли заказчика: ${customerOnly}.`
|
||||
: focus === "mixed_only"
|
||||
? `Контрагентов со смешанной ролью: ${mixedActive}.`
|
||||
: includeTotal && totalCounterparties > 0
|
||||
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
|
||||
: `Активных контрагентов по документальной активности: ${activeCounterparties}.`;
|
||||
const lines = [
|
||||
directLead,
|
||||
"Профиль контрагентов собран по справочнику и документальной активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (includeTotal) {
|
||||
if (totalCounterparties > 0) {
|
||||
lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`);
|
||||
}
|
||||
else if (activeCounterparties > 0) {
|
||||
lines.push(`Полный итог по справочнику не получен, поэтому даю оценку по документальной активности: ${activeCounterparties}.`);
|
||||
}
|
||||
else {
|
||||
lines.push("По количеству контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
if (includeRoles) {
|
||||
if (resolvedActive > 0 || activeCounterparties > 0) {
|
||||
lines.push("Распределение ролей по активности:");
|
||||
lines.push(`1. Только заказчики: ${customerOnly}.`);
|
||||
lines.push(`2. Только поставщики: ${supplierOnly}.`);
|
||||
lines.push(`3. И заказчики, и поставщики: ${mixedActive}.`);
|
||||
lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`);
|
||||
if (otherCounterparties !== null) {
|
||||
lines.push(`5. Прочие или неактивные в выбранном окне: ${otherCounterparties}.`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
lines.push("По распределению ролей агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
if (focus === "suppliers_only") {
|
||||
lines.push(`Контрагентов только в роли поставщика: ${supplierOnly}.`);
|
||||
}
|
||||
if (focus === "customers_only") {
|
||||
lines.push(`Контрагентов только в роли заказчика: ${customerOnly}.`);
|
||||
}
|
||||
if (focus === "mixed_only") {
|
||||
lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
if (intent === "counterparty_activity_lifecycle") {
|
||||
const activityFirstRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST");
|
||||
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 activityFirstRows) {
|
||||
const name = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) {
|
||||
current.firstObservedActivity = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
for (const row of activityYearRows) {
|
||||
const name = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: null,
|
||||
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 = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
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 = deps.extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
const counterpartiesRaw = Array.from(byCounterparty.values());
|
||||
const focus = deps.detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = deps.extractRequestedYearFromQuestion(options.userMessage);
|
||||
const longevityQuestion = deps.hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
|
||||
const activityAgeQuestion = deps.hasCounterpartyActivityAgeQuestion(options.userMessage);
|
||||
const rankingLimit = deps.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 scopeLabel = focus === "active_customers_all_time"
|
||||
? "за все время"
|
||||
: requestedYear
|
||||
? `в ${requestedYear} году`
|
||||
: "в выбранном периоде";
|
||||
if (activityAgeQuestion) {
|
||||
const focusedCounterparty = counterparties.find((item) => deps.counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null;
|
||||
if (focusedCounterparty) {
|
||||
const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod;
|
||||
const lastObservedActivity = focusedCounterparty.lastPeriod;
|
||||
const firstTimestamp = deps.toUtcDayTimestamp(firstObservedActivity);
|
||||
const lastTimestamp = deps.toUtcDayTimestamp(lastObservedActivity);
|
||||
const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel = observedDays !== null
|
||||
? deps.formatAgeYearsMonthsDays(observedDays)
|
||||
: focusedCounterparty.years.size > 0
|
||||
? `${focusedCounterparty.years.size} г.`
|
||||
: null;
|
||||
const lines = [
|
||||
observedAgeLabel && firstObservedActivity && lastObservedActivity
|
||||
? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`
|
||||
];
|
||||
if (firstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(firstObservedActivity)}.`);
|
||||
}
|
||||
if (lastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(lastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`);
|
||||
if (focusedCounterparty.years.size > 0) {
|
||||
const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
const organizationHint = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint ?? null);
|
||||
if (organizationHint && counterparties.length > 0) {
|
||||
const organizationFirstObservedActivity = counterparties.reduce((earliest, item) => {
|
||||
const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null;
|
||||
if (!candidate) {
|
||||
return earliest;
|
||||
}
|
||||
if (!earliest || candidate < earliest) {
|
||||
return candidate;
|
||||
}
|
||||
return earliest;
|
||||
}, null);
|
||||
const organizationLastObservedActivity = counterparties.reduce((latest, item) => {
|
||||
const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null;
|
||||
if (!candidate) {
|
||||
return latest;
|
||||
}
|
||||
if (!latest || candidate > latest) {
|
||||
return candidate;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
const organizationYears = new Set();
|
||||
let organizationOpsCount = 0;
|
||||
for (const item of counterparties) {
|
||||
organizationOpsCount += item.opsCount;
|
||||
for (const year of item.years) {
|
||||
organizationYears.add(year);
|
||||
}
|
||||
}
|
||||
const firstTimestamp = deps.toUtcDayTimestamp(organizationFirstObservedActivity);
|
||||
const lastTimestamp = deps.toUtcDayTimestamp(organizationLastObservedActivity);
|
||||
const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel = observedDays !== null
|
||||
? deps.formatAgeYearsMonthsDays(observedDays)
|
||||
: organizationYears.size > 0
|
||||
? `${organizationYears.size} г.`
|
||||
: null;
|
||||
const lines = [
|
||||
observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity
|
||||
? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.`
|
||||
];
|
||||
if (organizationFirstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(organizationFirstObservedActivity)}.`);
|
||||
}
|
||||
if (organizationLastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(organizationLastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`);
|
||||
if (organizationYears.size > 0) {
|
||||
const years = Array.from(organizationYears).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
}
|
||||
const lines = longevityQuestion
|
||||
? [
|
||||
`Заказчиков с самым длинным горизонтом сотрудничества: ${counterparties.length}.`,
|
||||
"Собран профиль длительности сотрудничества по годам и частоте активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
]
|
||||
: [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков по платежным документам.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (counterparties.length === 0) {
|
||||
lines.push(longevityQuestion
|
||||
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества."
|
||||
: "По выбранному окну активные заказчики не найдены.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
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
|
||||
? ` | период: ${formatOptionalDate(item.firstPeriod, deps.formatDateRu)}..${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`
|
||||
: "";
|
||||
if (longevityQuestion) {
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`;
|
||||
}
|
||||
const suffix = item.lastPeriod ? ` | последняя активность: ${deps.formatDateRu(item.lastPeriod)}` : "";
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`;
|
||||
}));
|
||||
if (counterparties.length > visible.length) {
|
||||
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (intent === "contract_usage_overview") {
|
||||
const rowsByMarker = groupRowsByMarker(rows);
|
||||
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const totalContracts = sumMarker("CT_TOTAL");
|
||||
const usedContracts = sumMarker("CT_USED");
|
||||
const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
|
||||
const usedShare = totalContracts > 0 ? deps.formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
|
||||
const usageLead = totalContracts > 0
|
||||
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
|
||||
: `Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`;
|
||||
const lines = [
|
||||
usageLead,
|
||||
"Профиль договорной базы собран по справочнику и подтвержденным операциям.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (totalContracts > 0) {
|
||||
lines.push(`Всего договоров в базе: ${totalContracts}.`);
|
||||
}
|
||||
else {
|
||||
lines.push("Общее количество договоров не получено из доступного среза справочника.");
|
||||
}
|
||||
lines.push(`Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`);
|
||||
if (unusedContracts !== null) {
|
||||
lines.push(`Неиспользуемых договоров: ${unusedContracts}.`);
|
||||
}
|
||||
if (usedShare) {
|
||||
lines.push(`Доля используемых договоров: ${usedShare}.`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
|
||||
const isSupplier = intent === "supplier_payouts_profile";
|
||||
const focus = deps.detectValueRankingFocus(options.userMessage);
|
||||
const limit = deps.detectRankingLimit(options.userMessage, 20);
|
||||
const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage);
|
||||
const normalizedQuestion = deps.normalizeQuestionText(options.userMessage);
|
||||
const byCounterparty = new Map();
|
||||
const byYear = new Map();
|
||||
const deals = [];
|
||||
for (const row of rows) {
|
||||
const counterparty = deps.extractCounterpartyName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!counterparty || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const current = byCounterparty.get(counterparty);
|
||||
if (!current) {
|
||||
byCounterparty.set(counterparty, {
|
||||
name: counterparty,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
minSingle: amount,
|
||||
lastPeriod: row.period
|
||||
});
|
||||
}
|
||||
else {
|
||||
current.total += amount;
|
||||
current.ops += 1;
|
||||
current.maxSingle = Math.max(current.maxSingle, amount);
|
||||
current.minSingle = Math.min(current.minSingle, amount);
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
}
|
||||
deals.push({
|
||||
period: row.period,
|
||||
registrator: row.registrator,
|
||||
counterparty,
|
||||
amount
|
||||
});
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
const yearBucket = byYear.get(year);
|
||||
if (!yearBucket) {
|
||||
byYear.set(year, {
|
||||
year,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
counterparties: new Set([counterparty])
|
||||
});
|
||||
}
|
||||
else {
|
||||
yearBucket.total += amount;
|
||||
yearBucket.ops += 1;
|
||||
yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount);
|
||||
yearBucket.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByAvgCheck = [...profileRows]
|
||||
.filter((item) => item.ops >= minOpsForAvgCheck)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
avgCheck: item.total / item.ops
|
||||
}))
|
||||
.sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedDealsTop = [...deals].sort((a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? ""));
|
||||
const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion);
|
||||
const activeCounterpartiesForBottom = new Set(profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name));
|
||||
const rankedDealsBottom = [...deals]
|
||||
.filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty))
|
||||
.sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? ""));
|
||||
const lines = [
|
||||
isSupplier
|
||||
? "Собран профиль выплат поставщикам по платежным документам."
|
||||
: "Собран профиль поступлений от заказчиков по платежным документам.",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Уникальных контрагентов: ${profileRows.length}.`
|
||||
];
|
||||
if (profileRows.length === 0) {
|
||||
lines.push("По выбранному окну данных платежные строки не найдены.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
if (focus === "total_flow") {
|
||||
const periodLine = options.periodFrom && options.periodTo
|
||||
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
`- Контрагентов в выборке: ${profileRows.length}.`
|
||||
];
|
||||
if (rankedByYearTotal.length > 0) {
|
||||
summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${deps.formatMoneyRub(rankedByYearTotal[0].total)}).`);
|
||||
}
|
||||
if (rankedByTotal.length > 0) {
|
||||
summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${deps.formatMoneyRub(rankedByTotal[0].total)}).`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(summaryLines);
|
||||
}
|
||||
if (focus === "top_years_by_total") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} лет по сумме выплат:`
|
||||
: `Топ-${visible.length} лет по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
|
||||
}
|
||||
else {
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`));
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "top_by_ops") {
|
||||
const visible = rankedByOps.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
|
||||
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "top_by_max_single") {
|
||||
const visible = rankedByMaxSingle.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
|
||||
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "top_by_avg_check_min_ops") {
|
||||
const visible = rankedByAvgCheck.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
|
||||
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
|
||||
}
|
||||
else {
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | средний чек: ${deps.formatMoneyRub(item.avgCheck)} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)}`));
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "top_deals") {
|
||||
const visible = rankedDealsTop.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
|
||||
: `Топ-${visible.length} самых крупных разовых поступлений:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "bottom_deals") {
|
||||
const visible = rankedDealsBottom.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых маленьких разовых выплат:`
|
||||
: `Топ-${visible.length} самых маленьких разовых поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (activeOnlyForBottomDeals) {
|
||||
lines.push("Фильтр: только активные контрагенты с минимум 3 операциями.");
|
||||
}
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
const visible = rankedByTotal.slice(0, limit);
|
||||
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 : 0;
|
||||
return `${index + 1}. ${item.name} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | средний чек: ${deps.formatMoneyRub(avgCheck)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`;
|
||||
}));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (intent === "contract_usage_and_value") {
|
||||
const focus = deps.detectContractValueFocus(options.userMessage);
|
||||
const limit = deps.detectRankingLimit(options.userMessage, 20);
|
||||
const byContract = new Map();
|
||||
for (const row of rows) {
|
||||
const contract = deps.extractContractName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!contract || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const counterparty = deps.extractCounterpartyName(row);
|
||||
const current = byContract.get(contract);
|
||||
if (!current) {
|
||||
byContract.set(contract, {
|
||||
contract,
|
||||
turnover: amount,
|
||||
docs: 1,
|
||||
lastPeriod: row.period,
|
||||
counterparties: new Set(counterparty ? [counterparty] : [])
|
||||
});
|
||||
}
|
||||
else {
|
||||
current.turnover += amount;
|
||||
current.docs += 1;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if (counterparty) {
|
||||
current.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
const contractRows = Array.from(byContract.values());
|
||||
const rankedByTurnover = [...contractRows].sort((a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
const rankedByDocs = [...contractRows].sort((a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract));
|
||||
const rankedBottomActive = [...contractRows]
|
||||
.filter((item) => item.docs > 0 && item.turnover > 0)
|
||||
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
const lines = [
|
||||
`Активных договоров: ${contractRows.length}.`,
|
||||
"Собран профиль договоров по обороту и подтвержденным операциям.",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Договорных агрегатов: ${contractRows.length}.`
|
||||
];
|
||||
if (contractRows.length === 0) {
|
||||
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
if (focus === "top_by_docs") {
|
||||
const visible = rankedByDocs.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} договоров по количеству операций:`);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${deps.formatMoneyRub(item.turnover)} | контрагентов: ${item.counterparties.size}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "bottom_by_turnover_active") {
|
||||
const visible = rankedBottomActive.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} активных договоров с минимальным оборотом:`);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
const visible = rankedByTurnover.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} договоров по сумме оборота:`);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -421,12 +421,17 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
|
||||
const rootContextOnly = followupContext.root_context_only === true;
|
||||
const comingFromInventoryDrilldown = currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
||||
const normalized = String(userMessage ?? "");
|
||||
const hasExplicitInventoryRootSnapshotCue = /(?:склад|остат(?:ок|ки)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized);
|
||||
const hasInventoryRootRestatementCue = /(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) &&
|
||||
/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(normalized);
|
||||
(/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(normalized) ||
|
||||
hasSameDateHint(normalized) ||
|
||||
hasSamePeriodHint(normalized));
|
||||
const canReenterInventoryRoot = comingFromInventoryDrilldown ||
|
||||
rootContextOnly ||
|
||||
(currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) ||
|
||||
(currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized));
|
||||
(currentFrameKind === "generic" &&
|
||||
hasExplicitInventoryRootSnapshotCue &&
|
||||
(hasSameDateHint(normalized) || hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue));
|
||||
if (!canReenterInventoryRoot) {
|
||||
return false;
|
||||
}
|
||||
@@ -791,15 +796,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
||||
!hasExplicitPeriodLiteral(userMessage) &&
|
||||
!hasExplicitCurrentDateHint(userMessage)) {
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||
merged.as_of_date = inheritedAsOfDate;
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||
merged.as_of_date = inheritedAsOfDate;
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
}
|
||||
if ((Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) &&
|
||||
@@ -1025,6 +1028,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
|
||||
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
|
||||
const isVatFollowup = hasVatCue(normalizedMessage);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (detectedIntent.intent === "unknown" && isVatFollowup) {
|
||||
const vatIntent = hasVatTaxPaymentCue(normalizedMessage)
|
||||
? "vat_liability_confirmed_for_tax_period"
|
||||
@@ -1038,7 +1043,10 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||
};
|
||||
}
|
||||
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
|
||||
if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
|
||||
if (allowOpenItemsFollowupFallback &&
|
||||
!inventorySelectedObjectFollowup &&
|
||||
hasOpenItemsHint(normalizedMessage) &&
|
||||
hasAnyPartyAnchor) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "low",
|
||||
@@ -1060,8 +1068,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"]
|
||||
};
|
||||
}
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
|
||||
Reference in New Issue
Block a user