ДОМЕНЫ - ВОПРОСЫ - Усилить НДС forecast: сумма в начале ответа, расширенный MCP probe источников и форматирование чисел

This commit is contained in:
2026-04-12 23:39:05 +03:00
parent 98872c2f11
commit f1ef5f9d3c
11 changed files with 1483 additions and 101 deletions
@@ -101,8 +101,35 @@ function formatNumberWithDots(value, fractionDigits = 0) {
function formatMoneyRub(value) {
return `${formatNumberWithDots(value, 2)}`;
}
function formatVatProbeStatusRu(status) {
if (status === "ok") {
return "есть движения";
}
if (status === "empty") {
return "движения не найдены";
}
return "ошибка запроса";
}
function emphasizeNumericTokens(line) {
return line;
if (!line) {
return line;
}
const chunks = line.split(/(`[^`]*`)/g);
return chunks
.map((chunk, index) => {
if (index % 2 === 1) {
return chunk;
}
return chunk.replace(/\b-?(?:\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,]\d+)?\b/g, (match, offset, source) => {
const before = offset > 0 ? source[offset - 1] : "";
const after = offset + match.length < source.length ? source[offset + match.length] : "";
if (before === "*" || after === "*") {
return match;
}
return `**${match}**`;
});
})
.join("");
}
function parseIsoDateToken(value) {
const source = String(value ?? "").trim();
@@ -135,6 +162,21 @@ function buildIsoDateWithMonthShift(year, monthOneBased, day, monthShift = 0) {
const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day));
return date.toISOString().slice(0, 10);
}
function shiftIsoDateToNextBusinessDay(isoDate) {
const parsed = parseIsoDateToken(isoDate);
if (!parsed) {
return isoDate;
}
const date = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day));
for (let guard = 0; guard < 10; guard += 1) {
const dayOfWeek = date.getUTCDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
return date.toISOString().slice(0, 10);
}
date.setUTCDate(date.getUTCDate() + 1);
}
return isoDate;
}
function deriveVatDeadlineCalendar(periodFrom, periodTo) {
const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
if (!reference) {
@@ -147,10 +189,10 @@ function deriveVatDeadlineCalendar(periodFrom, periodTo) {
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
const declarationDueDate = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1));
const payment1 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1));
const payment2 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2));
const payment3 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3));
return {
periodLabel: `${quarterNumber} кв. ${reference.year}`,
quarterStart,
@@ -270,6 +312,13 @@ function needsVatWhyExplanation(userMessage) {
}
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
}
function needsVatCalendarDetails(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
return /(?:срок|когда|дата\s+уплат|декларац|дол(?:я|ями)|по\s+частям|платежн(?:ый|ого)\s+график)/iu.test(text);
}
function detectRankingLimit(userMessage, fallback = 20) {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -1147,6 +1196,8 @@ function contractCandidatesFromRows(rows) {
return uniqueStrings(candidates);
}
function composeFactualReply(intent, rows, options = {}) {
const applyNumericEmphasis = (line) => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line);
const joinLines = (lines) => lines.map(applyNumericEmphasis).join("\n");
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map();
for (const row of rows) {
@@ -1926,30 +1977,54 @@ function composeFactualReply(intent, rows, options = {}) {
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage);
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
const formatForecastMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const lines = [
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
`Строк агрегата: ${rows.length}.`,
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
`Собран прогноз НДС к уплате: ${formatForecastMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatForecastMoney(carryoverOrOverpayment)}.`,
`Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`,
"Режим результата: предварительная оценка по проводкам 68.02*/19* (не подтвержденная сумма налога по декларации).",
"",
"База расчета:",
`- Строк агрегата: ${formatNumberWithDots(rows.length)}.`,
`- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`,
`- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`,
`- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`,
`- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.`
];
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`);
if (vatProbe.probedSources.length > 0) {
lines.push(...vatProbe.probedSources.slice(0, 6).map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`;
}));
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия.");
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*.");
}
if (!vatActivityDetected) {
lines.push("В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00.");
lines.push(`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(0)}.`);
}
else if (vatToPay === 0 && netVatIsEffectivelyZero) {
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
lines.push(`В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате ${formatForecastMoney(0)}.`);
}
else if (vatToPay === 0 && netVat < 0) {
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
lines.push(`В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате ${formatForecastMoney(0)}.`);
}
if (vatToPay === 0) {
lines.push("Чеклист проверки в 1С (почему к уплате 0):", `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`, "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).", "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).", "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.", "5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов.");
lines.push("", "Чеклист проверки в 1С (почему к уплате 0):", `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${periodWindowLabel ?? "расчета"}.`, "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).", "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).", "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.", "5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов.");
}
if (vatCalendar) {
if (vatCalendar && shouldShowCalendarDetails) {
const periodWindowLabel = vatCalendar.windowFrom && vatCalendar.windowTo
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
: `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
@@ -1957,16 +2032,16 @@ function composeFactualReply(intent, rows, options = {}) {
const installmentRaw = vatToPay / 3;
const installmentRounded = Number(installmentRaw.toFixed(2));
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
lines.push(`Период расчета (срез обязательств): ${periodWindowLabel}.`, `Налоговый период: ${vatCalendar.periodLabel}.`, `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`, `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`, `Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`, "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.");
lines.push("", `Период расчета (срез обязательств): ${periodWindowLabel}.`, `Налоговый период: ${vatCalendar.periodLabel}.`, `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`, `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`, `Ориентир по долям к уплате: ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentThird)}.`, "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.");
}
if (explainWhyRequested) {
lines.push("Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).", `За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`, netVat <= 0
lines.push("", "Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).", `За период 68 Кт = ${formatForecastMoney(turnover68Credit)}, 68 Дт = ${formatForecastMoney(turnover68Debit)}, разница = ${formatForecastMoney(netVat)}.`, netVat <= 0
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
: "Разница положительная, поэтому к уплате берется эта положительная величина.", "Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация.");
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
text: joinLines(lines)
};
}
if (intent === "vat_payable_confirmed_as_of_date") {
@@ -2016,14 +2091,31 @@ function composeFactualReply(intent, rows, options = {}) {
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счетам НДС к уплате (68*).",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`,
"",
"Блок 4. Подтвержденные позиции"
"- Контур: остатки по счетам НДС к уплате (68*)."
];
const vatProbe = options.vatDirectSourceProbe ?? null;
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push("", "Блок 2.1. MCP-проверка VAT-источников", `- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`);
if (vatProbe.probedSources.length > 0) {
lines.push(...vatProbe.probedSources.slice(0, 4).map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
const suffix = item.status === "ok"
? `${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.sampleRegistrator ? ` | пример: ${item.sampleRegistrator}` : ""}`
: item.status === "error" && item.error
? ` | ошибка: ${item.error}`
: "";
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${suffix}`;
}));
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Блок 2.1. MCP-проверка VAT-источников", "- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*).");
}
lines.push("", "Блок 3. Сводка", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Блок 4. Подтвержденные позиции");
if (accountRows.length > 0) {
lines.push(...accountRows.slice(0, 12).map((item, index) => {
const refs = Array.from(item.refs).slice(0, 2).join("; ");
@@ -2035,7 +2127,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
@@ -2156,7 +2248,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
@@ -2223,7 +2315,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
@@ -2345,7 +2437,7 @@ function composeFactualReply(intent, rows, options = {}) {
];
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
@@ -2356,7 +2448,7 @@ function composeFactualReply(intent, rows, options = {}) {
const fallbackLines = buildHeuristicLines(true);
return {
responseType: "FACTUAL_LIST",
text: fallbackLines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(fallbackLines),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
@@ -2367,7 +2459,7 @@ function composeFactualReply(intent, rows, options = {}) {
const lines = buildHeuristicLines(false);
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",