ДОМЕНЫ - ВОПРОСЫ - Усилить НДС 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
@@ -14,12 +14,35 @@ export interface ComposeStageRow {
analytics: string[];
}
export interface VatDirectSourceProbeItem {
fullName: string;
synonym?: string | null;
objectType: "document" | "register";
status: "ok" | "empty" | "error";
rowsFetched: number;
lastPeriod?: string | null;
sampleRegistrator?: string | null;
error?: string | null;
}
export interface VatDirectSourceProbeSummary {
status: "ok" | "error" | "skipped";
objectsTotal: number;
documentsTotal: number;
registersTotal: number;
probedSources: VatDirectSourceProbeItem[];
errors: string[];
}
interface ComposeFactualReplyOptions {
userMessage?: string;
periodFrom?: string;
periodTo?: string;
asOfDate?: string;
requestedResultMode?: AddressResultMode;
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
emphasizeNumbers?: boolean;
useRubCurrency?: boolean;
}
export interface ComposeReplySemantics {
@@ -175,8 +198,36 @@ function formatMoneyRub(value: number): string {
return `${formatNumberWithDots(value, 2)} ₽`;
}
function formatVatProbeStatusRu(status: VatDirectSourceProbeItem["status"]): string {
if (status === "ok") {
return "есть движения";
}
if (status === "empty") {
return "движения не найдены";
}
return "ошибка запроса";
}
function emphasizeNumericTokens(line: string): string {
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: string | null | undefined): { year: number; month: number; day: number } | null {
@@ -219,6 +270,22 @@ function buildIsoDateWithMonthShift(
return date.toISOString().slice(0, 10);
}
function shiftIsoDateToNextBusinessDay(isoDate: string): string {
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: string | null | undefined,
periodTo: string | null | undefined
@@ -243,10 +310,12 @@ function deriveVatDeadlineCalendar(
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}`,
@@ -384,6 +453,14 @@ function needsVatWhyExplanation(userMessage: string | null | undefined): boolean
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
}
function needsVatCalendarDetails(userMessage: string | null | undefined): boolean {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
return /(?:срок|когда|дата\s+уплат|декларац|дол(?:я|ями)|по\s+частям|платежн(?:ый|ого)\s+график)/iu.test(text);
}
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -1464,6 +1541,9 @@ export function composeFactualReply(
rows: ComposeStageRow[],
options: ComposeFactualReplyOptions = {}
): { responseType: AddressResponseType; text: string; semantics?: ComposeReplySemantics } {
const applyNumericEmphasis = (line: string): string => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line);
const joinLines = (lines: string[]): string => lines.map(applyNumericEmphasis).join("\n");
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) {
@@ -2442,32 +2522,72 @@ export function composeFactualReply(
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: number): string => (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."
`В выбранном окне не найдено движений по НДС-субсчетам 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)}` : "расчета"}.`,
`1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${periodWindowLabel ?? "расчета"}.`,
"2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).",
"3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).",
"4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.",
@@ -2475,7 +2595,7 @@ export function composeFactualReply(
);
}
if (vatCalendar) {
if (vatCalendar && shouldShowCalendarDetails) {
const periodWindowLabel =
vatCalendar.windowFrom && vatCalendar.windowTo
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
@@ -2485,18 +2605,20 @@ export function composeFactualReply(
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)}.`,
`Ориентир по долям к уплате: ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentThird)}.`,
"Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."
);
}
if (explainWhyRequested) {
lines.push(
"",
"Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).",
`За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`,
`За период 68 Кт = ${formatForecastMoney(turnover68Credit)}, 68 Дт = ${formatForecastMoney(turnover68Debit)}, разница = ${formatForecastMoney(netVat)}.`,
netVat <= 0
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
: "Разница положительная, поэтому к уплате берется эта положительная величина.",
@@ -2506,7 +2628,7 @@ export function composeFactualReply(
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
text: joinLines(lines)
};
}
@@ -2570,14 +2692,52 @@ export function composeFactualReply(
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счетам НДС к уплате (68*).",
"- Контур: остатки по счетам НДС к уплате (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(
@@ -2592,7 +2752,7 @@ export function composeFactualReply(
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
@@ -2732,7 +2892,7 @@ export function composeFactualReply(
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",
@@ -2812,7 +2972,7 @@ export function composeFactualReply(
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",
@@ -2959,7 +3119,7 @@ export function composeFactualReply(
];
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
@@ -2971,7 +3131,7 @@ export function composeFactualReply(
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",
@@ -2983,7 +3143,7 @@ export function composeFactualReply(
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",