ДОМЕНЫ - ВОПРОСЫ - Усилить НДС forecast: сумма в начале ответа, расширенный MCP probe источников и форматирование чисел
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user