ДОМЕНЫ - ВОПРОСЫ - Усилить НДС forecast: сумма в начале ответа, расширенный MCP probe источников и форматирование чисел
This commit is contained in:
parent
98872c2f11
commit
f1ef5f9d3c
|
|
@ -574,6 +574,11 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
|
||||||
if (questionCue && (rankingCue || paymentCue)) {
|
if (questionCue && (rankingCue || paymentCue)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const moneyAsOfPhraseCue = /(?:денег|деньг|money|cash)/iu.test(value) &&
|
||||||
|
/(?:на\s+(?:данн(?:ую|ой|ая|ое)|эту|ту)\s+дат|on\s+(?:this|that)\s+date|as\s+of\s+(?:this|that)\s+date)/iu.test(value);
|
||||||
|
if (moneyAsOfPhraseCue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const hasTemporalCue = /(?:по\s+состоянию|на\s+дат|на\s+конец|за\s+(?:период|месяц|год|квартал)|\b(?:19|20)\d{2}\b|\bянвар|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(value);
|
const hasTemporalCue = /(?:по\s+состоянию|на\s+дат|на\s+конец|за\s+(?:период|месяц|год|квартал)|\b(?:19|20)\d{2}\b|\bянвар|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(value);
|
||||||
const hasGenericEntityCue = /(?:компан|организац|контрагент|поставщик|клиент|покупател|дебитор|кредитор|counterparty|company|supplier|customer)/iu.test(value);
|
const hasGenericEntityCue = /(?:компан|организац|контрагент|поставщик|клиент|покупател|дебитор|кредитор|counterparty|company|supplier|customer)/iu.test(value);
|
||||||
if (hasTemporalCue && hasGenericEntityCue) {
|
if (hasTemporalCue && hasGenericEntityCue) {
|
||||||
|
|
@ -605,12 +610,41 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
|
||||||
"ноябрь",
|
"ноябрь",
|
||||||
"декабрь"
|
"декабрь"
|
||||||
]);
|
]);
|
||||||
|
const lowQualityGenericTokens = new Set([
|
||||||
|
"деньги",
|
||||||
|
"денег",
|
||||||
|
"деньгам",
|
||||||
|
"деньгами",
|
||||||
|
"денежный",
|
||||||
|
"денежные",
|
||||||
|
"данную",
|
||||||
|
"данной",
|
||||||
|
"данный",
|
||||||
|
"данное",
|
||||||
|
"эту",
|
||||||
|
"этой",
|
||||||
|
"этот",
|
||||||
|
"этом",
|
||||||
|
"ту",
|
||||||
|
"той",
|
||||||
|
"тот",
|
||||||
|
"том",
|
||||||
|
"вцелом",
|
||||||
|
"целом"
|
||||||
|
]);
|
||||||
const meaningfulNonTemporalTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) &&
|
const meaningfulNonTemporalTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) &&
|
||||||
!lowQualityTimeTokens.has(token) &&
|
!lowQualityTimeTokens.has(token) &&
|
||||||
!/^(?:19|20)\d{2}$/.test(token));
|
!/^(?:19|20)\d{2}$/.test(token));
|
||||||
if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) {
|
if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const meaningfulNonGenericTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) &&
|
||||||
|
!lowQualityTimeTokens.has(token) &&
|
||||||
|
!lowQualityGenericTokens.has(token) &&
|
||||||
|
!/^(?:19|20)\d{2}$/.test(token));
|
||||||
|
if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
|
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
|
||||||
return meaningfulTokens.length === 0;
|
return meaningfulTokens.length === 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,22 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
|
||||||
function hasAny(text, patterns) {
|
function hasAny(text, patterns) {
|
||||||
return patterns.some((item) => text.includes(item));
|
return patterns.some((item) => text.includes(item));
|
||||||
}
|
}
|
||||||
|
function hasFlexibleReceivablesDebtSignal(text) {
|
||||||
|
const normalized = String(text ?? "");
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (/(?:кто(?:\s+\S+){0,4}\s+нам(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) ||
|
||||||
|
/(?:нам(?:\s+\S+){0,4}\s+кто(?:\s+\S+){0,4}\s+долж)/iu.test(normalized));
|
||||||
|
}
|
||||||
|
function hasFlexiblePayablesDebtSignal(text) {
|
||||||
|
const normalized = String(text ?? "");
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (/(?:кому(?:\s+\S+){0,4}\s+мы(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) ||
|
||||||
|
/(?:мы(?:\s+\S+){0,4}\s+кому(?:\s+\S+){0,4}\s+долж)/iu.test(normalized));
|
||||||
|
}
|
||||||
function tokenizeText(text) {
|
function tokenizeText(text) {
|
||||||
return String(text ?? "")
|
return String(text ?? "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
@ -1275,11 +1291,14 @@ function resolveAddressIntent(userMessage) {
|
||||||
reasons: ["vat_payable_confirmed_signal_detected"]
|
reasons: ["vat_payable_confirmed_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasAny(text, RECEIVABLES_STRONG)) {
|
if (hasAny(text, RECEIVABLES_STRONG) || hasFlexibleReceivablesDebtSignal(text)) {
|
||||||
const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text);
|
const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text) || hasFlexibleReceivablesDebtSignal(text);
|
||||||
const reasons = ["receivables_signal_detected"];
|
const reasons = ["receivables_signal_detected"];
|
||||||
if (receivablesDebtLifecycleSignal) {
|
if (receivablesDebtLifecycleSignal) {
|
||||||
reasons.push("receivables_debt_lifecycle_signal_detected");
|
reasons.push("receivables_debt_lifecycle_signal_detected");
|
||||||
|
if (hasFlexibleReceivablesDebtSignal(text)) {
|
||||||
|
reasons.push("receivables_signal_detected_flexible_phrase");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
intent: receivablesDebtLifecycleSignal ? "receivables_confirmed_as_of_date" : "list_receivables_counterparties",
|
intent: receivablesDebtLifecycleSignal ? "receivables_confirmed_as_of_date" : "list_receivables_counterparties",
|
||||||
|
|
@ -1287,11 +1306,14 @@ function resolveAddressIntent(userMessage) {
|
||||||
reasons
|
reasons
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasAny(text, PAYABLES_STRONG)) {
|
if (hasAny(text, PAYABLES_STRONG) || hasFlexiblePayablesDebtSignal(text)) {
|
||||||
const reasons = ["payables_signal_detected"];
|
const reasons = ["payables_signal_detected"];
|
||||||
const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text);
|
const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text) || hasFlexiblePayablesDebtSignal(text);
|
||||||
if (payablesDebtLifecycleSignal) {
|
if (payablesDebtLifecycleSignal) {
|
||||||
reasons.push("payables_debt_lifecycle_signal_detected");
|
reasons.push("payables_debt_lifecycle_signal_detected");
|
||||||
|
if (hasFlexiblePayablesDebtSignal(text)) {
|
||||||
|
reasons.push("payables_signal_detected_flexible_phrase");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",
|
intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.executeAddressMcpQuery = executeAddressMcpQuery;
|
exports.executeAddressMcpQuery = executeAddressMcpQuery;
|
||||||
|
exports.executeAddressMcpMetadata = executeAddressMcpMetadata;
|
||||||
const config_1 = require("../config");
|
const config_1 = require("../config");
|
||||||
const iconv_lite_1 = __importDefault(require("iconv-lite"));
|
const iconv_lite_1 = __importDefault(require("iconv-lite"));
|
||||||
function toStringValue(value) {
|
function toStringValue(value) {
|
||||||
|
|
@ -165,7 +166,7 @@ function parseRowsFromTextTable(source) {
|
||||||
}
|
}
|
||||||
return normalizeMojibakeRows(rows);
|
return normalizeMojibakeRows(rows);
|
||||||
}
|
}
|
||||||
function parseExecutePayload(payload) {
|
function parseRowsPayload(payload, options = {}) {
|
||||||
if (!payload || typeof payload !== "object") {
|
if (!payload || typeof payload !== "object") {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -208,6 +209,13 @@ function parseExecutePayload(payload) {
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (source.data && typeof source.data === "object" && options.allowSingleObjectRow) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
rows: [normalizeMojibakeValue(source.data)],
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
rows: [],
|
rows: [],
|
||||||
|
|
@ -261,7 +269,7 @@ async function executeAddressMcpQuery(input) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const payload = responseText.trim() ? JSON.parse(responseText) : {};
|
const payload = responseText.trim() ? JSON.parse(responseText) : {};
|
||||||
const parsed = parseExecutePayload(payload);
|
const parsed = parseRowsPayload(payload);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
return {
|
return {
|
||||||
fetched_rows: 0,
|
fetched_rows: 0,
|
||||||
|
|
@ -294,3 +302,90 @@ async function executeAddressMcpQuery(input) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function executeAddressMcpMetadata(input) {
|
||||||
|
const endpoint = buildMcpUrl("/api/get_metadata");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), Math.max(300, config_1.ASSISTANT_MCP_TIMEOUT_MS));
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
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) : {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
|
||||||
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
|
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
|
||||||
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
|
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
|
||||||
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
|
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 = ["РегистрНакопления", "РегистрСведений", "Документ"];
|
||||||
|
const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур", "вычет", "восстанов"];
|
||||||
const PARTY_ANCHOR_STOPWORDS = new Set([
|
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||||
"ооо",
|
"ооо",
|
||||||
"ао",
|
"ао",
|
||||||
|
|
@ -122,6 +126,262 @@ function valueAsString(value) {
|
||||||
}
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
function normalizeIsoDateForQuery(value) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
const text = String(userMessage ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е");
|
||||||
|
if (!text.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(?:в\s+налогов|почему|из\s+чего|источн|декларац|книга\s+продаж|книга\s+покупок|вычет|восстанов)/iu.test(text);
|
||||||
|
}
|
||||||
|
function detectVatMetadataObjectType(fullName) {
|
||||||
|
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) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
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) {
|
||||||
|
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, asOfExpr) {
|
||||||
|
if (object.objectType === "document") {
|
||||||
|
return `
|
||||||
|
ВЫБРАТЬ ПЕРВЫЕ 1
|
||||||
|
Док.Дата КАК Период,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Док.Ссылка) КАК Регистратор,
|
||||||
|
"" КАК СчетДт,
|
||||||
|
"" КАК СчетКт,
|
||||||
|
0 КАК Сумма
|
||||||
|
ИЗ
|
||||||
|
${object.fullName} КАК Док
|
||||||
|
ГДЕ
|
||||||
|
Док.Дата <= ${asOfExpr}
|
||||||
|
УПОРЯДОЧИТЬ ПО
|
||||||
|
Док.Дата УБЫВ
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
ВЫБРАТЬ ПЕРВЫЕ 1
|
||||||
|
Движения.Период КАК Период,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
|
||||||
|
"" КАК СчетДт,
|
||||||
|
"" КАК СчетКт,
|
||||||
|
0 КАК Сумма
|
||||||
|
ИЗ
|
||||||
|
${object.fullName} КАК Движения
|
||||||
|
ГДЕ
|
||||||
|
Движения.Период <= ${asOfExpr}
|
||||||
|
УПОРЯДОЧИТЬ ПО
|
||||||
|
Движения.Период УБЫВ
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
async function probeVatDirectSources(filters) {
|
||||||
|
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 = 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) => (0, addressMcpClient_1.executeAddressMcpMetadata)(request)));
|
||||||
|
const metadataErrors = [];
|
||||||
|
const metadataObjectsBuffer = [];
|
||||||
|
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();
|
||||||
|
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 = [];
|
||||||
|
for (const object of metadataObjects) {
|
||||||
|
const probeQuery = buildVatObjectProbeQuery(object, asOfExpr);
|
||||||
|
const probeResult = await (0, addressMcpClient_1.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.Период ?? firstRow.period).trim() ||
|
||||||
|
null
|
||||||
|
: null;
|
||||||
|
const sampleRegistrator = firstRow !== null
|
||||||
|
? valueAsString(firstRow.Регистратор ??
|
||||||
|
firstRow.registrator ??
|
||||||
|
firstRow.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 = 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) {
|
function transliterateCyrillicToLatin(value) {
|
||||||
const map = {
|
const map = {
|
||||||
а: "a",
|
а: "a",
|
||||||
|
|
@ -1666,12 +1926,15 @@ class AddressQueryService {
|
||||||
shadowRouteAudit
|
shadowRouteAudit
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const composeOptionsFromFilters = (filterSet) => ({
|
const composeOptionsFromFilters = (filterSet, options = {}) => ({
|
||||||
userMessage,
|
userMessage,
|
||||||
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
||||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
||||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : 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);
|
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
|
||||||
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
|
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
|
||||||
|
|
@ -2635,7 +2898,29 @@ class AddressQueryService {
|
||||||
shadowRouteAudit
|
shadowRouteAudit
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const factual = (0, composeStage_1.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 = (0, composeStage_1.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({
|
const factualResultSemantics = mergeAddressResultSemantics(deriveAddressResultSemantics({
|
||||||
intent: composeIntent,
|
intent: composeIntent,
|
||||||
selectedRecipe: effectiveRecipeId,
|
selectedRecipe: effectiveRecipeId,
|
||||||
|
|
@ -2784,7 +3069,7 @@ class AddressQueryService {
|
||||||
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
|
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
|
||||||
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
|
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
|
||||||
...factualResultSemantics,
|
...factualResultSemantics,
|
||||||
limitations: filters.warnings,
|
limitations: factualLimitations,
|
||||||
reasons: withConfirmedBalanceFallbackReason(reasonsWithRouteExpectation, requestedResultMode, factual.semantics, factualResultSemantics.result_mode)
|
reasons: withConfirmedBalanceFallbackReason(reasonsWithRouteExpectation, requestedResultMode, factual.semantics, factualResultSemantics.result_mode)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -101,8 +101,35 @@ function formatNumberWithDots(value, fractionDigits = 0) {
|
||||||
function formatMoneyRub(value) {
|
function formatMoneyRub(value) {
|
||||||
return `${formatNumberWithDots(value, 2)} ₽`;
|
return `${formatNumberWithDots(value, 2)} ₽`;
|
||||||
}
|
}
|
||||||
|
function formatVatProbeStatusRu(status) {
|
||||||
|
if (status === "ok") {
|
||||||
|
return "есть движения";
|
||||||
|
}
|
||||||
|
if (status === "empty") {
|
||||||
|
return "движения не найдены";
|
||||||
|
}
|
||||||
|
return "ошибка запроса";
|
||||||
|
}
|
||||||
function emphasizeNumericTokens(line) {
|
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) {
|
function parseIsoDateToken(value) {
|
||||||
const source = String(value ?? "").trim();
|
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));
|
const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day));
|
||||||
return date.toISOString().slice(0, 10);
|
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) {
|
function deriveVatDeadlineCalendar(periodFrom, periodTo) {
|
||||||
const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
|
const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
|
||||||
if (!reference) {
|
if (!reference) {
|
||||||
|
|
@ -147,10 +189,10 @@ function deriveVatDeadlineCalendar(periodFrom, periodTo) {
|
||||||
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
|
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
|
||||||
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
|
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
|
||||||
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
|
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
|
||||||
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
|
const declarationDueDate = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1));
|
||||||
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
|
const payment1 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1));
|
||||||
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
|
const payment2 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2));
|
||||||
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
|
const payment3 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3));
|
||||||
return {
|
return {
|
||||||
periodLabel: `${quarterNumber} кв. ${reference.year}`,
|
periodLabel: `${quarterNumber} кв. ${reference.year}`,
|
||||||
quarterStart,
|
quarterStart,
|
||||||
|
|
@ -270,6 +312,13 @@ function needsVatWhyExplanation(userMessage) {
|
||||||
}
|
}
|
||||||
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
|
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) {
|
function detectRankingLimit(userMessage, fallback = 20) {
|
||||||
const text = normalizeQuestionText(userMessage);
|
const text = normalizeQuestionText(userMessage);
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|
@ -1147,6 +1196,8 @@ function contractCandidatesFromRows(rows) {
|
||||||
return uniqueStrings(candidates);
|
return uniqueStrings(candidates);
|
||||||
}
|
}
|
||||||
function composeFactualReply(intent, rows, options = {}) {
|
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") {
|
if (intent === "document_type_and_account_section_profile") {
|
||||||
const rowsByMarker = new Map();
|
const rowsByMarker = new Map();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
@ -1926,30 +1977,54 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
|
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
|
||||||
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
|
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
|
||||||
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
|
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
|
||||||
|
const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage);
|
||||||
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
|
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 = [
|
const lines = [
|
||||||
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
|
`Собран прогноз НДС к уплате: ${formatForecastMoney(vatToPay)}.`,
|
||||||
`Строк агрегата: ${rows.length}.`,
|
`Потенциальный перенос/переплата: ${formatForecastMoney(carryoverOrOverpayment)}.`,
|
||||||
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
|
`Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`,
|
||||||
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
|
"Режим результата: предварительная оценка по проводкам 68.02*/19* (не подтвержденная сумма налога по декларации).",
|
||||||
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
|
"",
|
||||||
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
|
"База расчета:",
|
||||||
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
|
`- Строк агрегата: ${formatNumberWithDots(rows.length)}.`,
|
||||||
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
|
`- Оборот по кредиту 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) {
|
if (!vatActivityDetected) {
|
||||||
lines.push("В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00.");
|
lines.push(`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(0)}.`);
|
||||||
}
|
}
|
||||||
else if (vatToPay === 0 && netVatIsEffectivelyZero) {
|
else if (vatToPay === 0 && netVatIsEffectivelyZero) {
|
||||||
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
|
lines.push(`В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате ${formatForecastMoney(0)}.`);
|
||||||
}
|
}
|
||||||
else if (vatToPay === 0 && netVat < 0) {
|
else if (vatToPay === 0 && netVat < 0) {
|
||||||
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
|
lines.push(`В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате ${formatForecastMoney(0)}.`);
|
||||||
}
|
}
|
||||||
if (vatToPay === 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
|
const periodWindowLabel = vatCalendar.windowFrom && vatCalendar.windowTo
|
||||||
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
|
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
|
||||||
: `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
|
: `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
|
||||||
|
|
@ -1957,16 +2032,16 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
const installmentRaw = vatToPay / 3;
|
const installmentRaw = vatToPay / 3;
|
||||||
const installmentRounded = Number(installmentRaw.toFixed(2));
|
const installmentRounded = Number(installmentRaw.toFixed(2));
|
||||||
const installmentThird = Number((vatToPay - installmentRounded * 2).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) {
|
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, а отрицательная часть показана как перенос/переплата."
|
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
|
||||||
: "Разница положительная, поэтому к уплате берется эта положительная величина.", "Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация.");
|
: "Разница положительная, поэтому к уплате берется эта положительная величина.", "Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_SUMMARY",
|
responseType: "FACTUAL_SUMMARY",
|
||||||
text: lines.join("\n")
|
text: joinLines(lines)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (intent === "vat_payable_confirmed_as_of_date") {
|
if (intent === "vat_payable_confirmed_as_of_date") {
|
||||||
|
|
@ -2016,14 +2091,31 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
"",
|
"",
|
||||||
"Блок 2. Что учтено",
|
"Блок 2. Что учтено",
|
||||||
`- Дата среза: ${formatDateRu(asOfDate)}.`,
|
`- Дата среза: ${formatDateRu(asOfDate)}.`,
|
||||||
"- Контур: остатки по счетам НДС к уплате (68*).",
|
"- Контур: остатки по счетам НДС к уплате (68*)."
|
||||||
"",
|
|
||||||
"Блок 3. Сводка",
|
|
||||||
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
|
|
||||||
`- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`,
|
|
||||||
"",
|
|
||||||
"Блок 4. Подтвержденные позиции"
|
|
||||||
];
|
];
|
||||||
|
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) {
|
if (accountRows.length > 0) {
|
||||||
lines.push(...accountRows.slice(0, 12).map((item, index) => {
|
lines.push(...accountRows.slice(0, 12).map((item, index) => {
|
||||||
const refs = Array.from(item.refs).slice(0, 2).join("; ");
|
const refs = Array.from(item.refs).slice(0, 2).join("; ");
|
||||||
|
|
@ -2035,7 +2127,7 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "confirmed_balance",
|
result_mode: "confirmed_balance",
|
||||||
evidence_strength: "strong",
|
evidence_strength: "strong",
|
||||||
|
|
@ -2156,7 +2248,7 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "confirmed_balance",
|
result_mode: "confirmed_balance",
|
||||||
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
|
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
|
||||||
|
|
@ -2223,7 +2315,7 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "confirmed_balance",
|
result_mode: "confirmed_balance",
|
||||||
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
|
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
|
||||||
|
|
@ -2345,7 +2437,7 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "confirmed_balance",
|
result_mode: "confirmed_balance",
|
||||||
evidence_strength: "strong",
|
evidence_strength: "strong",
|
||||||
|
|
@ -2356,7 +2448,7 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
const fallbackLines = buildHeuristicLines(true);
|
const fallbackLines = buildHeuristicLines(true);
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: fallbackLines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(fallbackLines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "heuristic_candidates",
|
result_mode: "heuristic_candidates",
|
||||||
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
||||||
|
|
@ -2367,7 +2459,7 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
const lines = buildHeuristicLines(false);
|
const lines = buildHeuristicLines(false);
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "heuristic_candidates",
|
result_mode: "heuristic_candidates",
|
||||||
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
||||||
|
|
|
||||||
|
|
@ -2572,17 +2572,20 @@ function hasAddressFollowupContextSignal(userMessage) {
|
||||||
const shortFollowup = minTokens <= 8;
|
const shortFollowup = minTokens <= 8;
|
||||||
const ultraShortFollowup = minTokens <= 3;
|
const ultraShortFollowup = minTokens <= 3;
|
||||||
const debtRoleSwapToReceivables = shortFollowup &&
|
const debtRoleSwapToReceivables = shortFollowup &&
|
||||||
/^(?:\u0430|\u0438)\s+(?:\u043d\u0430\u043c\s+)?\u043a\u0442\u043e\b/iu.test(rawText);
|
(/^(?:\u0430|a|\u0438|i)\s+(?:\u043d\u0430\u043c\s+)?\u043a\u0442\u043e(?=$|[\s,.;:!?])/iu.test(rawText) ||
|
||||||
|
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(rawText));
|
||||||
if (debtRoleSwapToReceivables) {
|
if (debtRoleSwapToReceivables) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const debtRoleSwapToPayables = shortFollowup &&
|
const debtRoleSwapToPayables = shortFollowup &&
|
||||||
/^(?:\u0430|\u0438)\s+(?:\u043c\u044b\s+)?\u043a\u043e\u043c\u0443\b/iu.test(rawText);
|
(/^(?:\u0430|a|\u0438|i)\s+(?:\u043c\u044b\s+)?\u043a\u043e\u043c\u0443(?=$|[\s,.;:!?])/iu.test(rawText) ||
|
||||||
|
/^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(rawText));
|
||||||
if (debtRoleSwapToPayables) {
|
if (debtRoleSwapToPayables) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const shortContinuationCue = ultraShortFollowup &&
|
const shortContinuationCue = ultraShortFollowup &&
|
||||||
/^(?:\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u044b\u0430\u0439|\u0435\u0449[\u0435\u0451]|also|again|go|ok|okay)\b/iu.test(rawText);
|
(/^(?:\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u044b\u0430\u0439|\u0435\u0449[\u0435\u0451]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu.test(rawText) ||
|
||||||
|
/^(?:рґр°рір°р№|рїрѕрєр°р·с‹рір°р№|рїрѕрєр°р·с‹ріс‹р°р№|рµс‰[рµс‘]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu.test(rawText));
|
||||||
if (shortContinuationCue) {
|
if (shortContinuationCue) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -2591,13 +2594,13 @@ function hasAddressFollowupContextSignal(userMessage) {
|
||||||
if (shortVatCue) {
|
if (shortVatCue) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (shortFollowup && hasAny(/^(?:а|и)\s+(?:нам\s+)?кто\b/iu)) {
|
if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (shortFollowup && hasAny(/^(?:а|и)\s+(?:мы\s+)?кому\b/iu)) {
|
if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)\b/iu)) {
|
if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (hasStandaloneAddressTopicSignal(rawText || repairedText)) {
|
if (hasStandaloneAddressTopicSignal(rawText || repairedText)) {
|
||||||
|
|
@ -2645,13 +2648,33 @@ function hasAddressFollowupContextSignal(userMessage) {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
function hasShortDebtMirrorFollowupSignal(userMessage) {
|
||||||
|
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
|
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||||
|
const samples = [rawText, repairedText].filter((item) => item.length > 0);
|
||||||
|
if (samples.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY);
|
||||||
|
if (minTokens > 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) ||
|
||||||
|
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) ||
|
||||||
|
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) ||
|
||||||
|
/^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
|
||||||
|
}
|
||||||
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
|
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
|
||||||
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
if (!normalized || countTokens(normalized) > 10) {
|
if (!normalized || countTokens(normalized) > 10) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized);
|
const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) ||
|
||||||
const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized);
|
/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||||
|
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(normalized);
|
||||||
|
const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) ||
|
||||||
|
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||||
|
/^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(normalized);
|
||||||
if ((previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties") &&
|
if ((previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties") &&
|
||||||
hasReceivablesCue) {
|
hasReceivablesCue) {
|
||||||
return "receivables_confirmed_as_of_date";
|
return "receivables_confirmed_as_of_date";
|
||||||
|
|
@ -3211,6 +3234,13 @@ function hasSameDateAccountFollowupSignalForPredecompose(text) {
|
||||||
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) ||
|
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) ||
|
||||||
/\b\d{2}(?:[.,]\d{1,2})\b/u.test(source));
|
/\b\d{2}(?:[.,]\d{1,2})\b/u.test(source));
|
||||||
}
|
}
|
||||||
|
function hasPredecomposeDiagnosticUncertaintyLead(text) {
|
||||||
|
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /^(?:неясно|не\s+ясно|непонятно|не\s+понятно|unclear|not\s+clear|ambiguous|unknown)(?=$|[\s,.;:!?])/iu.test(normalized);
|
||||||
|
}
|
||||||
function attachAddressPredecomposeContract(meta, sourceMessage) {
|
function attachAddressPredecomposeContract(meta, sourceMessage) {
|
||||||
const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? "");
|
const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? "");
|
||||||
const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
|
const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
|
||||||
|
|
@ -3312,6 +3342,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
|
||||||
const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate);
|
const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate);
|
||||||
const sourceIntentKnown = sourceIntentResolution.intent !== "unknown";
|
const sourceIntentKnown = sourceIntentResolution.intent !== "unknown";
|
||||||
const candidateIntentKnown = candidateIntentResolution.intent !== "unknown";
|
const candidateIntentKnown = candidateIntentResolution.intent !== "unknown";
|
||||||
|
const candidateStartsWithDiagnosticUncertainty = hasPredecomposeDiagnosticUncertaintyLead(candidate);
|
||||||
|
if (candidateStartsWithDiagnosticUncertainty && sourceIntentKnown) {
|
||||||
|
return attachAddressPredecomposeContract({
|
||||||
|
...baseMeta,
|
||||||
|
attempted: true,
|
||||||
|
applied: false,
|
||||||
|
traceId: normalized?.trace_id ?? null,
|
||||||
|
llmCanonicalCandidateDetected: true,
|
||||||
|
effectiveMessage: userMessage,
|
||||||
|
reason: "normalized_fragment_rejected_diagnostic_rewrite",
|
||||||
|
fallbackRuleHit: null,
|
||||||
|
sanitizedUserMessage
|
||||||
|
}, userMessage);
|
||||||
|
}
|
||||||
const intentConflict = sourceIntentKnown &&
|
const intentConflict = sourceIntentKnown &&
|
||||||
candidateIntentKnown &&
|
candidateIntentKnown &&
|
||||||
sourceIntentResolution.intent !== candidateIntentResolution.intent;
|
sourceIntentResolution.intent !== candidateIntentResolution.intent;
|
||||||
|
|
@ -3578,6 +3622,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
|
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
|
||||||
hasAccountingSignal(addressInputMessage) ||
|
hasAccountingSignal(addressInputMessage) ||
|
||||||
hasAccountingSignal(repairedInputMessage) ||
|
hasAccountingSignal(repairedInputMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
|
||||||
sameDateAccountFollowupSignal;
|
sameDateAccountFollowupSignal;
|
||||||
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
||||||
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
||||||
|
|
@ -3595,6 +3641,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
!followupContext &&
|
!followupContext &&
|
||||||
!hasClassifierSignal &&
|
!hasClassifierSignal &&
|
||||||
!hasIntentSignal &&
|
!hasIntentSignal &&
|
||||||
|
!hasLexicalAddressSignal &&
|
||||||
!strongDataSignalFromRawMessage &&
|
!strongDataSignalFromRawMessage &&
|
||||||
!strongDataSignalFromEffectiveMessage) {
|
!strongDataSignalFromEffectiveMessage) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -3869,7 +3916,11 @@ function resolveAssistantOrchestrationDecision(input) {
|
||||||
const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) ||
|
const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) ||
|
||||||
hasAddressFollowupContextSignal(repairedRawUserMessage) ||
|
hasAddressFollowupContextSignal(repairedRawUserMessage) ||
|
||||||
hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
|
hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
|
||||||
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage);
|
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(rawUserMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
|
||||||
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
|
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
|
||||||
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
|
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
|
||||||
!capabilityMetaQuery &&
|
!capabilityMetaQuery &&
|
||||||
|
|
@ -3984,7 +4035,11 @@ function resolveAssistantOrchestrationDecision(input) {
|
||||||
hasAddressFollowupContextSignal(rawUserMessage) ||
|
hasAddressFollowupContextSignal(rawUserMessage) ||
|
||||||
hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
|
hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
|
||||||
hasAddressFollowupContextSignal(repairedRawUserMessage) ||
|
hasAddressFollowupContextSignal(repairedRawUserMessage) ||
|
||||||
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage));
|
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(rawUserMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
|
||||||
|
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
|
||||||
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
|
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
|
||||||
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
||||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {
|
import {
|
||||||
ASSISTANT_MCP_CHANNEL,
|
ASSISTANT_MCP_CHANNEL,
|
||||||
ASSISTANT_MCP_PROXY_URL,
|
ASSISTANT_MCP_PROXY_URL,
|
||||||
ASSISTANT_MCP_TIMEOUT_MS
|
ASSISTANT_MCP_TIMEOUT_MS
|
||||||
|
|
@ -17,6 +17,13 @@ export interface AddressMcpQueryResult {
|
||||||
error: string | null;
|
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 {
|
function toStringValue(value: unknown): string {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -188,7 +195,12 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
|
||||||
return normalizeMojibakeRows(rows);
|
return normalizeMojibakeRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
|
function parseRowsPayload(
|
||||||
|
payload: unknown,
|
||||||
|
options: {
|
||||||
|
allowSingleObjectRow?: boolean;
|
||||||
|
} = {}
|
||||||
|
): AddressMcpQueryResult {
|
||||||
if (!payload || typeof payload !== "object") {
|
if (!payload || typeof payload !== "object") {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
rows: [],
|
rows: [],
|
||||||
|
|
@ -312,7 +332,7 @@ export async function executeAddressMcpQuery(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
|
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
|
||||||
const parsed = parseExecutePayload(payload);
|
const parsed = parseRowsPayload(payload);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
return {
|
return {
|
||||||
fetched_rows: 0,
|
fetched_rows: 0,
|
||||||
|
|
@ -344,3 +364,100 @@ export async function executeAddressMcpQuery(input: {
|
||||||
clearTimeout(timeout);
|
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,
|
selectAddressRecipe,
|
||||||
type AddressRecipeExecutionPlan
|
type AddressRecipeExecutionPlan
|
||||||
} from "./addressRecipeCatalog";
|
} from "./addressRecipeCatalog";
|
||||||
import { executeAddressMcpQuery } from "./addressMcpClient";
|
import { executeAddressMcpMetadata, executeAddressMcpQuery } from "./addressMcpClient";
|
||||||
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
|
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
|
||||||
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
|
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 {
|
import {
|
||||||
isCapabilityRouteBlocked,
|
isCapabilityRouteBlocked,
|
||||||
resolveAddressCapabilityRouteDecision,
|
resolveAddressCapabilityRouteDecision,
|
||||||
|
|
@ -80,6 +86,10 @@ const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
|
||||||
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
|
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
|
||||||
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
|
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
|
||||||
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
|
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([
|
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||||
"ооо",
|
"ооо",
|
||||||
"ао",
|
"ао",
|
||||||
|
|
@ -130,6 +140,12 @@ const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
|
||||||
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
|
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
|
||||||
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
|
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface VatMetadataObject {
|
||||||
|
fullName: string;
|
||||||
|
synonym: string | null;
|
||||||
|
objectType: "document" | "register";
|
||||||
|
}
|
||||||
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
|
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
|
||||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||||
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
|
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
|
||||||
|
|
@ -201,6 +217,293 @@ function valueAsString(value: unknown): string {
|
||||||
return String(value);
|
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 {
|
function transliterateCyrillicToLatin(value: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
а: "a",
|
а: "a",
|
||||||
|
|
@ -2096,12 +2399,22 @@ export class AddressQueryService {
|
||||||
shadowRouteAudit
|
shadowRouteAudit
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
|
const composeOptionsFromFilters = (
|
||||||
|
filterSet: AddressFilterSet,
|
||||||
|
options: {
|
||||||
|
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
|
||||||
|
emphasizeNumbers?: boolean;
|
||||||
|
useRubCurrency?: boolean;
|
||||||
|
} = {}
|
||||||
|
) => ({
|
||||||
userMessage,
|
userMessage,
|
||||||
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
||||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
||||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : 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);
|
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
|
||||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
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(
|
const factualResultSemantics = mergeAddressResultSemantics(
|
||||||
deriveAddressResultSemantics({
|
deriveAddressResultSemantics({
|
||||||
intent: composeIntent,
|
intent: composeIntent,
|
||||||
|
|
@ -3360,7 +3702,7 @@ export class AddressQueryService {
|
||||||
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
|
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
|
||||||
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
|
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
|
||||||
...factualResultSemantics,
|
...factualResultSemantics,
|
||||||
limitations: filters.warnings,
|
limitations: factualLimitations,
|
||||||
reasons: withConfirmedBalanceFallbackReason(
|
reasons: withConfirmedBalanceFallbackReason(
|
||||||
reasonsWithRouteExpectation,
|
reasonsWithRouteExpectation,
|
||||||
requestedResultMode,
|
requestedResultMode,
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,35 @@ export interface ComposeStageRow {
|
||||||
analytics: string[];
|
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 {
|
interface ComposeFactualReplyOptions {
|
||||||
userMessage?: string;
|
userMessage?: string;
|
||||||
periodFrom?: string;
|
periodFrom?: string;
|
||||||
periodTo?: string;
|
periodTo?: string;
|
||||||
asOfDate?: string;
|
asOfDate?: string;
|
||||||
requestedResultMode?: AddressResultMode;
|
requestedResultMode?: AddressResultMode;
|
||||||
|
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
|
||||||
|
emphasizeNumbers?: boolean;
|
||||||
|
useRubCurrency?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComposeReplySemantics {
|
export interface ComposeReplySemantics {
|
||||||
|
|
@ -175,8 +198,36 @@ function formatMoneyRub(value: number): string {
|
||||||
return `${formatNumberWithDots(value, 2)} ₽`;
|
return `${formatNumberWithDots(value, 2)} ₽`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatVatProbeStatusRu(status: VatDirectSourceProbeItem["status"]): string {
|
||||||
|
if (status === "ok") {
|
||||||
|
return "есть движения";
|
||||||
|
}
|
||||||
|
if (status === "empty") {
|
||||||
|
return "движения не найдены";
|
||||||
|
}
|
||||||
|
return "ошибка запроса";
|
||||||
|
}
|
||||||
|
|
||||||
function emphasizeNumericTokens(line: string): string {
|
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 {
|
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);
|
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(
|
function deriveVatDeadlineCalendar(
|
||||||
periodFrom: string | null | undefined,
|
periodFrom: string | null | undefined,
|
||||||
periodTo: 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 quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
|
||||||
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
|
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
|
||||||
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
|
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
|
||||||
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
|
const declarationDueDate = shiftIsoDateToNextBusinessDay(
|
||||||
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
|
buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1)
|
||||||
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
|
);
|
||||||
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
|
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 {
|
return {
|
||||||
periodLabel: `${quarterNumber} кв. ${reference.year}`,
|
periodLabel: `${quarterNumber} кв. ${reference.year}`,
|
||||||
|
|
@ -384,6 +453,14 @@ function needsVatWhyExplanation(userMessage: string | null | undefined): boolean
|
||||||
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
|
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 {
|
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
|
||||||
const text = normalizeQuestionText(userMessage);
|
const text = normalizeQuestionText(userMessage);
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|
@ -1464,6 +1541,9 @@ export function composeFactualReply(
|
||||||
rows: ComposeStageRow[],
|
rows: ComposeStageRow[],
|
||||||
options: ComposeFactualReplyOptions = {}
|
options: ComposeFactualReplyOptions = {}
|
||||||
): { responseType: AddressResponseType; text: string; semantics?: ComposeReplySemantics } {
|
): { 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") {
|
if (intent === "document_type_and_account_section_profile") {
|
||||||
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
@ -2442,32 +2522,72 @@ export function composeFactualReply(
|
||||||
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
|
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
|
||||||
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
|
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
|
||||||
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
|
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
|
||||||
|
const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage);
|
||||||
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
|
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 = [
|
const lines = [
|
||||||
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
|
`Собран прогноз НДС к уплате: ${formatForecastMoney(vatToPay)}.`,
|
||||||
`Строк агрегата: ${rows.length}.`,
|
`Потенциальный перенос/переплата: ${formatForecastMoney(carryoverOrOverpayment)}.`,
|
||||||
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
|
`Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`,
|
||||||
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
|
"Режим результата: предварительная оценка по проводкам 68.02*/19* (не подтвержденная сумма налога по декларации).",
|
||||||
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
|
"",
|
||||||
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
|
"База расчета:",
|
||||||
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
|
`- Строк агрегата: ${formatNumberWithDots(rows.length)}.`,
|
||||||
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
|
`- Оборот по кредиту 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) {
|
if (!vatActivityDetected) {
|
||||||
lines.push(
|
lines.push(
|
||||||
"В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00."
|
`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(
|
||||||
|
0
|
||||||
|
)}.`
|
||||||
);
|
);
|
||||||
} else if (vatToPay === 0 && netVatIsEffectivelyZero) {
|
} else if (vatToPay === 0 && netVatIsEffectivelyZero) {
|
||||||
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
|
lines.push(
|
||||||
|
`В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате ${formatForecastMoney(0)}.`
|
||||||
|
);
|
||||||
} else if (vatToPay === 0 && netVat < 0) {
|
} else if (vatToPay === 0 && netVat < 0) {
|
||||||
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
|
lines.push(
|
||||||
|
`В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате ${formatForecastMoney(0)}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (vatToPay === 0) {
|
if (vatToPay === 0) {
|
||||||
lines.push(
|
lines.push(
|
||||||
|
"",
|
||||||
"Чеклист проверки в 1С (почему к уплате 0):",
|
"Чеклист проверки в 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* (включая субсчета).",
|
"2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).",
|
||||||
"3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).",
|
"3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).",
|
||||||
"4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.",
|
"4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.",
|
||||||
|
|
@ -2475,7 +2595,7 @@ export function composeFactualReply(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vatCalendar) {
|
if (vatCalendar && shouldShowCalendarDetails) {
|
||||||
const periodWindowLabel =
|
const periodWindowLabel =
|
||||||
vatCalendar.windowFrom && vatCalendar.windowTo
|
vatCalendar.windowFrom && vatCalendar.windowTo
|
||||||
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
|
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
|
||||||
|
|
@ -2485,18 +2605,20 @@ export function composeFactualReply(
|
||||||
const installmentRounded = Number(installmentRaw.toFixed(2));
|
const installmentRounded = Number(installmentRaw.toFixed(2));
|
||||||
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
|
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
|
||||||
lines.push(
|
lines.push(
|
||||||
|
"",
|
||||||
`Период расчета (срез обязательств): ${periodWindowLabel}.`,
|
`Период расчета (срез обязательств): ${periodWindowLabel}.`,
|
||||||
`Налоговый период: ${vatCalendar.periodLabel}.`,
|
`Налоговый период: ${vatCalendar.periodLabel}.`,
|
||||||
`Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`,
|
`Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`,
|
||||||
`Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`,
|
`Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`,
|
||||||
`Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`,
|
`Ориентир по долям к уплате: ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentThird)}.`,
|
||||||
"Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."
|
"Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (explainWhyRequested) {
|
if (explainWhyRequested) {
|
||||||
lines.push(
|
lines.push(
|
||||||
|
"",
|
||||||
"Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).",
|
"Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).",
|
||||||
`За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`,
|
`За период 68 Кт = ${formatForecastMoney(turnover68Credit)}, 68 Дт = ${formatForecastMoney(turnover68Debit)}, разница = ${formatForecastMoney(netVat)}.`,
|
||||||
netVat <= 0
|
netVat <= 0
|
||||||
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
|
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
|
||||||
: "Разница положительная, поэтому к уплате берется эта положительная величина.",
|
: "Разница положительная, поэтому к уплате берется эта положительная величина.",
|
||||||
|
|
@ -2506,7 +2628,7 @@ export function composeFactualReply(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_SUMMARY",
|
responseType: "FACTUAL_SUMMARY",
|
||||||
text: lines.join("\n")
|
text: joinLines(lines)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2570,14 +2692,52 @@ export function composeFactualReply(
|
||||||
"",
|
"",
|
||||||
"Блок 2. Что учтено",
|
"Блок 2. Что учтено",
|
||||||
`- Дата среза: ${formatDateRu(asOfDate)}.`,
|
`- Дата среза: ${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. Сводка",
|
"Блок 3. Сводка",
|
||||||
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
|
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
|
||||||
`- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`,
|
`- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`,
|
||||||
"",
|
"",
|
||||||
"Блок 4. Подтвержденные позиции"
|
"Блок 4. Подтвержденные позиции"
|
||||||
];
|
);
|
||||||
|
|
||||||
if (accountRows.length > 0) {
|
if (accountRows.length > 0) {
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|
@ -2592,7 +2752,7 @@ export function composeFactualReply(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "confirmed_balance",
|
result_mode: "confirmed_balance",
|
||||||
evidence_strength: "strong",
|
evidence_strength: "strong",
|
||||||
|
|
@ -2732,7 +2892,7 @@ export function composeFactualReply(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "confirmed_balance",
|
result_mode: "confirmed_balance",
|
||||||
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
|
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
|
||||||
|
|
@ -2812,7 +2972,7 @@ export function composeFactualReply(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "confirmed_balance",
|
result_mode: "confirmed_balance",
|
||||||
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
|
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
|
||||||
|
|
@ -2959,7 +3119,7 @@ export function composeFactualReply(
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "confirmed_balance",
|
result_mode: "confirmed_balance",
|
||||||
evidence_strength: "strong",
|
evidence_strength: "strong",
|
||||||
|
|
@ -2971,7 +3131,7 @@ export function composeFactualReply(
|
||||||
const fallbackLines = buildHeuristicLines(true);
|
const fallbackLines = buildHeuristicLines(true);
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: fallbackLines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(fallbackLines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "heuristic_candidates",
|
result_mode: "heuristic_candidates",
|
||||||
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
||||||
|
|
@ -2983,7 +3143,7 @@ export function composeFactualReply(
|
||||||
const lines = buildHeuristicLines(false);
|
const lines = buildHeuristicLines(false);
|
||||||
return {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: lines.map(emphasizeNumericTokens).join("\n"),
|
text: joinLines(lines),
|
||||||
semantics: {
|
semantics: {
|
||||||
result_mode: "heuristic_candidates",
|
result_mode: "heuristic_candidates",
|
||||||
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { executeAddressMcpQuery } from "../src/services/addressMcpClient";
|
import { executeAddressMcpMetadata, executeAddressMcpQuery } from "../src/services/addressMcpClient";
|
||||||
|
|
||||||
const ORIGINAL_FETCH = globalThis.fetch;
|
const ORIGINAL_FETCH = globalThis.fetch;
|
||||||
|
|
||||||
|
|
@ -44,4 +44,33 @@ describe("address MCP encoding repair", () => {
|
||||||
expect(result.rows[0]?.["Контрагент"]).toBe("Группа СВК");
|
expect(result.rows[0]?.["Контрагент"]).toBe("Группа СВК");
|
||||||
expect(result.rows[0]?.["Регистратор"]).toContain("Поступление");
|
expect(result.rows[0]?.["Регистратор"]).toContain("Поступление");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses get_metadata text-table payload", async () => {
|
||||||
|
const payload = {
|
||||||
|
success: true,
|
||||||
|
data: `[2]{"FullName","Synonym"}:
|
||||||
|
Document.VATDoc,VAT document
|
||||||
|
Register.VATRegister,VAT register`
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await executeAddressMcpMetadata({
|
||||||
|
meta_type: "Документ",
|
||||||
|
name_mask: "ндс",
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.fetched_rows).toBe(2);
|
||||||
|
expect(result.rows[0]?.["FullName"]).toBe("Document.VATDoc");
|
||||||
|
expect(result.rows[0]?.["Synonym"]).toBe("VAT document");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1256,6 +1256,7 @@ describe("address compose stage utf8 headers", () => {
|
||||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||||
expect(reply.text).toContain("Почему прогноз к уплате 0");
|
expect(reply.text).toContain("Почему прогноз к уплате 0");
|
||||||
expect(reply.text).toContain("max(0, 68 Кт - 68 Дт)");
|
expect(reply.text).toContain("max(0, 68 Кт - 68 Дт)");
|
||||||
|
expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00.");
|
||||||
expect(reply.text).toContain("За период 68 Кт = 9126.00, 68 Дт = 115342.00, разница = -106216.00.");
|
expect(reply.text).toContain("За период 68 Кт = 9126.00, 68 Дт = 115342.00, разница = -106216.00.");
|
||||||
expect(reply.text).toContain("Разница неположительная");
|
expect(reply.text).toContain("Разница неположительная");
|
||||||
expect(reply.text).toContain("оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*");
|
expect(reply.text).toContain("оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*");
|
||||||
|
|
@ -1283,7 +1284,7 @@ describe("address compose stage utf8 headers", () => {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
userMessage: "сколько НДС нужно заплатить по состоянию на 15 марта 2020 года",
|
userMessage: "какие сроки уплаты и сдачи декларации по НДС по состоянию на 15 марта 2020 года",
|
||||||
periodFrom: "2020-01-01",
|
periodFrom: "2020-01-01",
|
||||||
periodTo: "2020-03-15"
|
periodTo: "2020-03-15"
|
||||||
}
|
}
|
||||||
|
|
@ -1292,8 +1293,8 @@ describe("address compose stage utf8 headers", () => {
|
||||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||||
expect(reply.text).toContain("Период расчета (срез обязательств): 01.01.2020..15.03.2020.");
|
expect(reply.text).toContain("Период расчета (срез обязательств): 01.01.2020..15.03.2020.");
|
||||||
expect(reply.text).toContain("Налоговый период: 1 кв. 2020.");
|
expect(reply.text).toContain("Налоговый период: 1 кв. 2020.");
|
||||||
expect(reply.text).toContain("Срок сдачи декларации: до 25.04.2020.");
|
expect(reply.text).toContain("Срок сдачи декларации: до 27.04.2020.");
|
||||||
expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 28.06.2020.");
|
expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 29.06.2020.");
|
||||||
expect(reply.text).toContain("Ориентир по долям к уплате: 100.00 / 100.00 / 100.00.");
|
expect(reply.text).toContain("Ориентир по долям к уплате: 100.00 / 100.00 / 100.00.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1311,7 +1312,7 @@ describe("address compose stage utf8 headers", () => {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
userMessage: "прогноз НДС на 31 декабря 2020",
|
userMessage: "когда платить НДС за 4 квартал 2020",
|
||||||
periodFrom: "2020-10-01",
|
periodFrom: "2020-10-01",
|
||||||
periodTo: "2020-12-31"
|
periodTo: "2020-12-31"
|
||||||
}
|
}
|
||||||
|
|
@ -1320,7 +1321,7 @@ describe("address compose stage utf8 headers", () => {
|
||||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||||
expect(reply.text).toContain("Налоговый период: 4 кв. 2020.");
|
expect(reply.text).toContain("Налоговый период: 4 кв. 2020.");
|
||||||
expect(reply.text).toContain("Срок сдачи декларации: до 25.01.2021.");
|
expect(reply.text).toContain("Срок сдачи декларации: до 25.01.2021.");
|
||||||
expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 28.02.2021, 28.03.2021.");
|
expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 01.03.2021, 29.03.2021.");
|
||||||
expect(reply.text).toContain("Ориентир по долям к уплате: 30.00 / 30.00 / 30.00.");
|
expect(reply.text).toContain("Ориентир по долям к уплате: 30.00 / 30.00 / 30.00.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1369,7 +1370,7 @@ describe("address compose stage utf8 headers", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||||
expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
|
expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00.");
|
||||||
expect(reply.text).toContain("не найдено движений по НДС-субсчетам 68.02*/19*");
|
expect(reply.text).toContain("не найдено движений по НДС-субсчетам 68.02*/19*");
|
||||||
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
|
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
|
||||||
expect(reply.text).toContain("Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный");
|
expect(reply.text).toContain("Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный");
|
||||||
|
|
@ -1404,10 +1405,160 @@ describe("address compose stage utf8 headers", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||||
expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
|
expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00.");
|
||||||
expect(reply.text).toContain("обороты по 68* взаимно перекрылись");
|
expect(reply.text).toContain("обороты по 68* взаимно перекрылись");
|
||||||
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
|
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds MCP VAT source coverage block for VAT forecast response", () => {
|
||||||
|
const reply = composeFactualReply(
|
||||||
|
"vat_payable_forecast",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
period: "2019-12-31T23:59:59Z",
|
||||||
|
registrator: "VAT_68_CREDIT",
|
||||||
|
account_dt: "68",
|
||||||
|
account_kt: "",
|
||||||
|
amount: 1000,
|
||||||
|
analytics: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
userMessage: "прикинь ндс за декабрь 2019",
|
||||||
|
periodFrom: "2019-12-01",
|
||||||
|
periodTo: "2019-12-31",
|
||||||
|
vatDirectSourceProbe: {
|
||||||
|
status: "ok",
|
||||||
|
objectsTotal: 5,
|
||||||
|
documentsTotal: 2,
|
||||||
|
registersTotal: 3,
|
||||||
|
probedSources: [
|
||||||
|
{
|
||||||
|
fullName: "РегистрНакопления.НДСПродажи",
|
||||||
|
objectType: "register",
|
||||||
|
status: "ok",
|
||||||
|
rowsFetched: 1,
|
||||||
|
lastPeriod: "2019-12-31T23:59:59Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: "РегистрНакопления.НДСПокупки",
|
||||||
|
objectType: "register",
|
||||||
|
status: "empty",
|
||||||
|
rowsFetched: 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
errors: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||||
|
expect(reply.text).toContain("Покрытие VAT-источников через MCP");
|
||||||
|
expect(reply.text).toContain("Найдено VAT-объектов: 5");
|
||||||
|
expect(reply.text).toContain("РегистрНакопления.НДСПродажи");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats VAT forecast amounts in rubles and emphasizes numbers when requested", () => {
|
||||||
|
const reply = composeFactualReply(
|
||||||
|
"vat_payable_forecast",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
period: "2019-12-31T23:59:59Z",
|
||||||
|
registrator: "VAT_68_CREDIT",
|
||||||
|
account_dt: "68",
|
||||||
|
account_kt: "",
|
||||||
|
amount: 1234567.89,
|
||||||
|
analytics: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
userMessage: "прикинь ндс",
|
||||||
|
useRubCurrency: true,
|
||||||
|
emphasizeNumbers: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.text).toContain("**1.234.567,89** ₽");
|
||||||
|
expect(reply.text).toContain("Собран прогноз НДС к уплате:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds MCP VAT source probe block for confirmed VAT as-of response", () => {
|
||||||
|
const reply = composeFactualReply(
|
||||||
|
"vat_payable_confirmed_as_of_date",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
period: "2020-03-31T23:59:59Z",
|
||||||
|
registrator: "Остатки на дату",
|
||||||
|
account_dt: null,
|
||||||
|
account_kt: "68.02",
|
||||||
|
amount: 123456.78,
|
||||||
|
analytics: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
asOfDate: "2020-03-31",
|
||||||
|
vatDirectSourceProbe: {
|
||||||
|
status: "ok",
|
||||||
|
objectsTotal: 3,
|
||||||
|
documentsTotal: 1,
|
||||||
|
registersTotal: 2,
|
||||||
|
probedSources: [
|
||||||
|
{
|
||||||
|
fullName: "РегистрНакопления.НДСНачисленный",
|
||||||
|
objectType: "register",
|
||||||
|
status: "ok",
|
||||||
|
rowsFetched: 1,
|
||||||
|
lastPeriod: "2020-03-31T23:59:59Z",
|
||||||
|
sampleRegistrator: "Отражение начисления НДС 0001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: "Документ.РегистрацияОплатыНДСВБюджет",
|
||||||
|
objectType: "document",
|
||||||
|
status: "empty",
|
||||||
|
rowsFetched: 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
errors: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.responseType).toBe("FACTUAL_LIST");
|
||||||
|
expect(reply.text).toContain("Блок 2.1. MCP-проверка VAT-источников");
|
||||||
|
expect(reply.text).toContain("VAT-объектов в метаданных 1С: 3");
|
||||||
|
expect(reply.text).toContain("Источников с движениями до даты среза: 1");
|
||||||
|
expect(reply.text).toContain("РегистрНакопления.НДСНачисленный");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds VAT probe error note for confirmed VAT as-of response", () => {
|
||||||
|
const reply = composeFactualReply(
|
||||||
|
"vat_payable_confirmed_as_of_date",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
period: "2020-03-31T23:59:59Z",
|
||||||
|
registrator: "Остатки на дату",
|
||||||
|
account_dt: null,
|
||||||
|
account_kt: "68.02",
|
||||||
|
amount: 1000,
|
||||||
|
analytics: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
asOfDate: "2020-03-31",
|
||||||
|
vatDirectSourceProbe: {
|
||||||
|
status: "error",
|
||||||
|
objectsTotal: 0,
|
||||||
|
documentsTotal: 0,
|
||||||
|
registersTotal: 0,
|
||||||
|
probedSources: [],
|
||||||
|
errors: ["metadata timeout"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply.responseType).toBe("FACTUAL_LIST");
|
||||||
|
expect(reply.text).toContain("Probe VAT-источников завершился ошибкой");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("address intent resolver expansion (M2.3a)", () => {
|
describe("address intent resolver expansion (M2.3a)", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue