ДОМЕНЫ - ВОПРОСЫ - Усилить НДС 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
@@ -1,4 +1,4 @@
import {
import {
ASSISTANT_MCP_CHANNEL,
ASSISTANT_MCP_PROXY_URL,
ASSISTANT_MCP_TIMEOUT_MS
@@ -17,6 +17,13 @@ export interface AddressMcpQueryResult {
error: string | null;
}
export interface AddressMcpMetadataRowsResult {
fetched_rows: number;
raw_rows: Array<Record<string, unknown>>;
rows: Array<Record<string, unknown>>;
error: string | null;
}
function toStringValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
@@ -188,7 +195,12 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
return normalizeMojibakeRows(rows);
}
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
function parseRowsPayload(
payload: unknown,
options: {
allowSingleObjectRow?: boolean;
} = {}
): AddressMcpQueryResult {
if (!payload || typeof payload !== "object") {
return {
ok: false,
@@ -240,6 +252,14 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
};
}
if (source.data && typeof source.data === "object" && options.allowSingleObjectRow) {
return {
ok: true,
rows: [normalizeMojibakeValue(source.data) as Record<string, unknown>],
error: null
};
}
return {
ok: true,
rows: [],
@@ -312,7 +332,7 @@ export async function executeAddressMcpQuery(input: {
}
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
const parsed = parseExecutePayload(payload);
const parsed = parseRowsPayload(payload);
if (!parsed.ok) {
return {
fetched_rows: 0,
@@ -344,3 +364,100 @@ export async function executeAddressMcpQuery(input: {
clearTimeout(timeout);
}
}
export async function executeAddressMcpMetadata(input: {
filter?: string;
meta_type?: string | string[];
name_mask?: string;
limit?: number;
offset?: number;
sections?: string[];
extension_name?: string | null;
}): Promise<AddressMcpMetadataRowsResult> {
const endpoint = buildMcpUrl("/api/get_metadata");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(300, ASSISTANT_MCP_TIMEOUT_MS));
try {
const body: Record<string, unknown> = {};
if (typeof input.filter === "string" && input.filter.trim().length > 0) {
body.filter = input.filter.trim();
}
if (typeof input.meta_type === "string" && input.meta_type.trim().length > 0) {
body.meta_type = input.meta_type.trim();
} else if (Array.isArray(input.meta_type) && input.meta_type.length > 0) {
const values = input.meta_type
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0);
if (values.length > 0) {
body.meta_type = values;
}
}
if (typeof input.name_mask === "string" && input.name_mask.trim().length > 0) {
body.name_mask = input.name_mask.trim();
}
if (typeof input.limit === "number" && Number.isFinite(input.limit)) {
body.limit = Math.max(1, Math.min(1000, Math.trunc(input.limit)));
}
if (typeof input.offset === "number" && Number.isFinite(input.offset)) {
body.offset = Math.max(0, Math.min(1_000_000, Math.trunc(input.offset)));
}
if (Array.isArray(input.sections) && input.sections.length > 0) {
const sections = input.sections
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0);
if (sections.length > 0) {
body.sections = sections;
}
}
if (input.extension_name !== undefined) {
body.extension_name = input.extension_name;
}
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8"
},
body: JSON.stringify(body),
signal: controller.signal
});
const responseText = await response.text();
if (!response.ok) {
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}`
};
}
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
const parsed = parseRowsPayload(payload, { allowSingleObjectRow: true });
if (!parsed.ok) {
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: parsed.error
};
}
return {
fetched_rows: parsed.rows.length,
raw_rows: parsed.rows,
rows: parsed.rows,
error: null
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP fetch failed: ${message}`
};
} finally {
clearTimeout(timeout);
}
}
@@ -27,10 +27,16 @@ import {
selectAddressRecipe,
type AddressRecipeExecutionPlan
} from "./addressRecipeCatalog";
import { executeAddressMcpQuery } from "./addressMcpClient";
import { executeAddressMcpMetadata, executeAddressMcpQuery } from "./addressMcpClient";
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
import { composeFactualReply, inferReplyType, type ComposeReplySemantics } from "./address_runtime/composeStage";
import {
composeFactualReply,
inferReplyType,
type ComposeReplySemantics,
type VatDirectSourceProbeItem,
type VatDirectSourceProbeSummary
} from "./address_runtime/composeStage";
import {
isCapabilityRouteBlocked,
resolveAddressCapabilityRouteDecision,
@@ -80,6 +86,10 @@ const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const VAT_METADATA_PROBE_LIMIT = 100;
const VAT_SOURCE_PROBE_MAX_OBJECTS = 8;
const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "РегистрСведений", "Документ"] as const;
const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур", "вычет", "восстанов"] as const;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@@ -130,6 +140,12 @@ const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
interface VatMetadataObject {
fullName: string;
synonym: string | null;
objectType: "document" | "register";
}
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
@@ -201,6 +217,293 @@ function valueAsString(value: unknown): string {
return String(value);
}
function normalizeIsoDateForQuery(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
const candidate = new Date(Date.UTC(year, month - 1, day));
if (
candidate.getUTCFullYear() !== year ||
candidate.getUTCMonth() + 1 !== month ||
candidate.getUTCDate() !== day
) {
return null;
}
return `${match[1]}-${match[2]}-${match[3]}`;
}
function toDateTimeExprForQuery(isoDate: string): string | null {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, 23, 59, 59)`;
}
function shouldProbeVatSourcesForForecast(userMessage: string): boolean {
const text = String(userMessage ?? "")
.toLowerCase()
.replace(/ё/g, "е");
if (!text.trim()) {
return false;
}
return /(?:в\s+налогов|почему|из\s+чего|источн|декларац|книга\s+продаж|книга\s+покупок|вычет|восстанов)/iu.test(text);
}
function detectVatMetadataObjectType(fullName: string): VatMetadataObject["objectType"] | null {
const normalized = String(fullName ?? "").trim();
if (!normalized) {
return null;
}
if (normalized.startsWith("Документ.")) {
return "document";
}
if (normalized.startsWith("РегистрНакопления.") || normalized.startsWith("РегистрСведений.")) {
return "register";
}
return null;
}
function extractVatMetadataObjects(rows: Array<Record<string, unknown>>): VatMetadataObject[] {
const out: VatMetadataObject[] = [];
const seen = new Set<string>();
for (const row of rows) {
const fullName =
valueAsString(row.ПолноеИмя ?? row.full_name ?? row.FullName ?? row.Имя ?? row.name ?? row.Name).trim() || null;
if (!fullName) {
continue;
}
const objectType = detectVatMetadataObjectType(fullName);
if (!objectType) {
continue;
}
if (seen.has(fullName)) {
continue;
}
seen.add(fullName);
const synonym =
valueAsString(row.Синоним ?? row.synonym ?? row.Synonym ?? row.Представление ?? row.presentation).trim() || null;
out.push({
fullName,
synonym,
objectType
});
}
return out;
}
function scoreVatMetadataObject(item: VatMetadataObject): number {
const fullName = item.fullName.toLowerCase();
const synonym = String(item.synonym ?? "").toLowerCase();
let score = item.objectType === "register" ? 120 : 80;
if (fullName.includes("книгипродаж") || synonym.includes("продаж")) {
score += 60;
}
if (fullName.includes("книгипокупок") || synonym.includes("покуп")) {
score += 60;
}
if (fullName.includes("начислен") || synonym.includes("начислен")) {
score += 40;
}
if (fullName.includes("предъявлен") || synonym.includes("предъявлен")) {
score += 40;
}
if (fullName.includes("оплатындс") || synonym.includes("в бюджет")) {
score += 35;
}
if (fullName.includes("декларац")) {
score -= 25;
}
if (fullName.includes("пояснен")) {
score -= 25;
}
return score;
}
function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string): string {
if (object.objectType === "document") {
return `
ВЫБРАТЬ ПЕРВЫЕ 1
Док.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Док.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма
ИЗ
${object.fullName} КАК Док
ГДЕ
Док.Дата <= ${asOfExpr}
УПОРЯДОЧИТЬ ПО
Док.Дата УБЫВ
`.trim();
}
return `
ВЫБРАТЬ ПЕРВЫЕ 1
Движения.Период КАК Период,
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма
ИЗ
${object.fullName} КАК Движения
ГДЕ
Движения.Период <= ${asOfExpr}
УПОРЯДОЧИТЬ ПО
Движения.Период УБЫВ
`.trim();
}
async function probeVatDirectSources(filters: AddressFilterSet): Promise<VatDirectSourceProbeSummary> {
const asOfDate =
normalizeIsoDateForQuery(filters.as_of_date) ??
normalizeIsoDateForQuery(filters.period_to) ??
normalizeIsoDateForQuery(filters.period_from);
if (!asOfDate) {
return {
status: "skipped",
objectsTotal: 0,
documentsTotal: 0,
registersTotal: 0,
probedSources: [],
errors: ["as_of_date_not_resolved_for_vat_probe"]
};
}
const asOfExpr = toDateTimeExprForQuery(asOfDate);
if (!asOfExpr) {
return {
status: "skipped",
objectsTotal: 0,
documentsTotal: 0,
registersTotal: 0,
probedSources: [],
errors: ["as_of_expr_not_resolved_for_vat_probe"]
};
}
const metadataRequests: Array<{ meta_type: string; name_mask: string; limit: number }> = VAT_METADATA_PROBE_TYPES.flatMap(
(metaType) =>
VAT_METADATA_PROBE_MASKS.map((nameMask) => ({
meta_type: metaType,
name_mask: nameMask,
limit: VAT_METADATA_PROBE_LIMIT
}))
);
const metadataResponses = await Promise.all(metadataRequests.map((request) => executeAddressMcpMetadata(request)));
const metadataErrors: string[] = [];
const metadataObjectsBuffer: VatMetadataObject[] = [];
for (const [index, response] of metadataResponses.entries()) {
const request = metadataRequests[index];
if (response.error) {
metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`);
continue;
}
metadataObjectsBuffer.push(...extractVatMetadataObjects(response.rows));
}
const deduplicatedObjects = new Map<string, VatMetadataObject>();
for (const item of metadataObjectsBuffer) {
const existing = deduplicatedObjects.get(item.fullName);
if (!existing) {
deduplicatedObjects.set(item.fullName, item);
continue;
}
if (!existing.synonym && item.synonym) {
deduplicatedObjects.set(item.fullName, {
...existing,
synonym: item.synonym
});
}
}
const discoveredMetadataObjects = Array.from(deduplicatedObjects.values()).sort(
(a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru")
);
const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS);
const probeRows: VatDirectSourceProbeItem[] = [];
for (const object of metadataObjects) {
const probeQuery = buildVatObjectProbeQuery(object, asOfExpr);
const probeResult = await executeAddressMcpQuery({
query: probeQuery,
limit: 1
});
if (probeResult.error) {
probeRows.push({
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: probeResult.error
});
continue;
}
const firstRow = probeResult.raw_rows[0] ?? null;
const lastPeriod =
firstRow !== null
? valueAsString((firstRow as Record<string, unknown>).Период ?? (firstRow as Record<string, unknown>).period).trim() ||
null
: null;
const sampleRegistrator =
firstRow !== null
? valueAsString(
(firstRow as Record<string, unknown>).Регистратор ??
(firstRow as Record<string, unknown>).registrator ??
(firstRow as Record<string, unknown>).Registrator
).trim() || null
: null;
probeRows.push({
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: probeResult.raw_rows.length > 0 ? "ok" : "empty",
rowsFetched: probeResult.fetched_rows,
lastPeriod,
sampleRegistrator
});
}
const status: VatDirectSourceProbeSummary["status"] = metadataResponses.every((item) => item.error) ? "error" : "ok";
const allErrors = [
...metadataErrors,
...probeRows
.filter((item) => item.status === "error")
.map((item) => `${item.fullName}: ${valueAsString(item.error).slice(0, 120)}`)
];
return {
status,
objectsTotal: discoveredMetadataObjects.length,
documentsTotal: discoveredMetadataObjects.filter((item) => item.objectType === "document").length,
registersTotal: discoveredMetadataObjects.filter((item) => item.objectType === "register").length,
probedSources: probeRows,
errors: allErrors
};
}
function transliterateCyrillicToLatin(value: string): string {
const map: Record<string, string> = {
а: "a",
@@ -2096,12 +2399,22 @@ export class AddressQueryService {
shadowRouteAudit
});
}
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
const composeOptionsFromFilters = (
filterSet: AddressFilterSet,
options: {
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
emphasizeNumbers?: boolean;
useRubCurrency?: boolean;
} = {}
) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode
requestedResultMode,
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined,
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? undefined
});
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
@@ -3204,7 +3517,36 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters));
const vatProbeRequired =
composeIntent === "vat_payable_confirmed_as_of_date" ||
(composeIntent === "vat_payable_forecast" && shouldProbeVatSourcesForForecast(userMessage));
const vatDirectSourceProbe = vatProbeRequired ? await probeVatDirectSources(executionFilters) : null;
const shouldEmphasizeNumbers =
composeIntent === "vat_payable_forecast" ||
composeIntent === "vat_payable_confirmed_as_of_date" ||
composeIntent === "payables_confirmed_as_of_date" ||
composeIntent === "receivables_confirmed_as_of_date";
const shouldUseRubCurrency = composeIntent === "vat_payable_forecast";
const factual = composeFactualReply(
composeIntent,
filteredRows,
composeOptionsFromFilters(executionFilters, {
vatDirectSourceProbe,
emphasizeNumbers: shouldEmphasizeNumbers,
useRubCurrency: shouldUseRubCurrency
})
);
const vatProbeLimitations =
vatProbeRequired && vatDirectSourceProbe
? vatDirectSourceProbe.status === "error"
? ["vat_source_probe_error"]
: vatDirectSourceProbe.status === "skipped"
? ["vat_source_probe_skipped"]
: vatDirectSourceProbe.errors.length > 0
? ["vat_source_probe_partial_errors"]
: []
: [];
const factualLimitations = [...filters.warnings, ...vatProbeLimitations];
const factualResultSemantics = mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: composeIntent,
@@ -3360,7 +3702,7 @@ export class AddressQueryService {
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
...factualResultSemantics,
limitations: filters.warnings,
limitations: factualLimitations,
reasons: withConfirmedBalanceFallbackReason(
reasonsWithRouteExpectation,
requestedResultMode,
@@ -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",