ДОМЕНЫ - ВОПРОСЫ - НДС: Стабилизировать VAT MCP probe: добавить задержки, увеличить таймауты и retry на aborted

This commit is contained in:
dctouch 2026-04-13 09:29:31 +03:00
parent c4f87222a8
commit 4205c6b3e6
4 changed files with 249 additions and 79 deletions

View File

@ -16,15 +16,20 @@ const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const VAT_METADATA_PROBE_LIMIT = 100;
const VAT_SOURCE_PROBE_MAX_OBJECTS = 6;
const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "Документ"];
const VAT_SOURCE_PROBE_MAX_OBJECTS = 4;
const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления"];
const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур"];
const VAT_METADATA_PROBE_CONCURRENCY = 4;
const VAT_METADATA_PROBE_TIMEOUT_MS = 800;
const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_200;
const VAT_OBJECT_PROBE_CONCURRENCY = 4;
const VAT_OBJECT_PROBE_TIMEOUT_MS = 800;
const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 800;
const VAT_METADATA_PROBE_CONCURRENCY = 2;
const VAT_METADATA_PROBE_STAGGER_MS = 90;
const VAT_METADATA_PROBE_TIMEOUT_MS = 1_200;
const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_800;
const VAT_METADATA_PROBE_RETRY_DELAY_MS = 140;
const VAT_OBJECT_PROBE_CONCURRENCY = 1;
const VAT_OBJECT_PROBE_STAGGER_MS = 120;
const VAT_OBJECT_PROBE_TIMEOUT_MS = 1_500;
const VAT_OBJECT_PROBE_ABORT_RETRY_TIMEOUT_MS = 2_200;
const VAT_OBJECT_PROBE_ABORT_RETRY_DELAY_MS = 180;
const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 1_500;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@ -75,6 +80,28 @@ const ACCOUNT_ALIAS_MAP = {
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
const VAT_FALLBACK_METADATA_OBJECTS = [
{
fullName: "РегистрНакопления.НДСЗаписиКнигиПродаж",
synonym: "НДС Продажи",
objectType: "register"
},
{
fullName: "РегистрНакопления.НДСЗаписиКнигиПокупок",
synonym: "НДС Покупки",
objectType: "register"
},
{
fullName: "РегистрНакопления.НДСПредъявленный",
synonym: "НДС предъявленный",
objectType: "register"
},
{
fullName: "РегистрНакопления.НДСВключенныйВСтоимость",
synonym: "НДС, включенный в стоимость",
objectType: "register"
}
];
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
@ -252,6 +279,13 @@ async function mapWithConcurrency(items, concurrency, worker) {
await Promise.all(runners);
return results;
}
function sleepMs(ms) {
const delayMs = Number.isFinite(ms) ? Math.max(0, Math.trunc(ms)) : 0;
if (delayMs <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => setTimeout(resolve, delayMs));
}
async function executeVatMetadataProbeRequest(request) {
const firstAttempt = await (0, addressMcpClient_1.executeAddressMcpMetadata)({
...request,
@ -260,6 +294,7 @@ async function executeVatMetadataProbeRequest(request) {
if (!firstAttempt.error || !isAbortErrorMessage(firstAttempt.error)) {
return firstAttempt;
}
await sleepMs(VAT_METADATA_PROBE_RETRY_DELAY_MS);
const retryLimit = Math.max(20, Math.min(request.limit, Math.trunc(request.limit / 2)));
const retryAttempt = await (0, addressMcpClient_1.executeAddressMcpMetadata)({
...request,
@ -301,6 +336,10 @@ function scoreVatMetadataObject(item) {
}
return score;
}
function vatMetadataObjectPriority(item) {
const idx = VAT_FALLBACK_METADATA_OBJECTS.findIndex((fallback) => fallback.fullName === item.fullName);
return idx >= 0 ? idx : VAT_FALLBACK_METADATA_OBJECTS.length + 1;
}
function buildVatObjectProbeQuery(object, asOfExpr, mode = "latest") {
const orderClause = mode === "latest" ? "\nУПОРЯДОЧИТЬ ПО\n Движения.Период УБЫВ" : "";
if (object.objectType === "document") {
@ -363,7 +402,12 @@ async function probeVatDirectSources(filters) {
name_mask: nameMask,
limit: VAT_METADATA_PROBE_LIMIT
})));
const metadataResponses = await mapWithConcurrency(metadataRequests, VAT_METADATA_PROBE_CONCURRENCY, (request) => executeVatMetadataProbeRequest(request));
const metadataResponses = await mapWithConcurrency(metadataRequests, VAT_METADATA_PROBE_CONCURRENCY, async (request, index) => {
if (index > 0 && VAT_METADATA_PROBE_STAGGER_MS > 0) {
await sleepMs(index * VAT_METADATA_PROBE_STAGGER_MS);
}
return executeVatMetadataProbeRequest(request);
});
const metadataOutcomes = metadataResponses.map((response, index) => ({
request: metadataRequests[index],
response
@ -403,9 +447,27 @@ async function probeVatDirectSources(filters) {
}
const discoveredMetadataObjects = Array.from(deduplicatedObjects.values())
.filter((item) => isVatMetadataObject(item))
.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 = await mapWithConcurrency(metadataObjects, VAT_OBJECT_PROBE_CONCURRENCY, async (object) => {
.sort((a, b) => vatMetadataObjectPriority(a) - vatMetadataObjectPriority(b) ||
scoreVatMetadataObject(b) - scoreVatMetadataObject(a) ||
a.fullName.localeCompare(b.fullName, "ru"));
const mergedMetadataObjectsMap = new Map();
for (const item of discoveredMetadataObjects) {
mergedMetadataObjectsMap.set(item.fullName, item);
}
for (const fallbackObject of VAT_FALLBACK_METADATA_OBJECTS) {
if (!mergedMetadataObjectsMap.has(fallbackObject.fullName)) {
mergedMetadataObjectsMap.set(fallbackObject.fullName, fallbackObject);
}
}
const metadataObjects = Array.from(mergedMetadataObjectsMap.values())
.sort((a, b) => vatMetadataObjectPriority(a) - vatMetadataObjectPriority(b) ||
scoreVatMetadataObject(b) - scoreVatMetadataObject(a) ||
a.fullName.localeCompare(b.fullName, "ru"))
.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS);
const probeRows = await mapWithConcurrency(metadataObjects, VAT_OBJECT_PROBE_CONCURRENCY, async (object, index) => {
if (index > 0 && VAT_OBJECT_PROBE_STAGGER_MS > 0) {
await sleepMs(index * VAT_OBJECT_PROBE_STAGGER_MS);
}
let probeResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: buildVatObjectProbeQuery(object, asOfExpr, "latest"),
limit: 1,
@ -413,34 +475,43 @@ async function probeVatDirectSources(filters) {
});
let fallbackUsed = false;
if (probeResult.error) {
let latestError = probeResult.error;
if (isAbortErrorMessage(probeResult.error)) {
return {
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: probeResult.error
};
await sleepMs(VAT_OBJECT_PROBE_ABORT_RETRY_DELAY_MS);
const retryLatestResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: buildVatObjectProbeQuery(object, asOfExpr, "latest"),
limit: 1,
timeout_ms: VAT_OBJECT_PROBE_ABORT_RETRY_TIMEOUT_MS
});
if (!retryLatestResult.error) {
probeResult = retryLatestResult;
latestError = null;
}
else {
probeResult = retryLatestResult;
latestError = `${latestError}; retry_latest: ${retryLatestResult.error}`;
}
}
const fallbackResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: buildVatObjectProbeQuery(object, asOfExpr, "exists"),
limit: 1,
timeout_ms: VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS
});
if (!fallbackResult.error) {
probeResult = fallbackResult;
fallbackUsed = true;
}
else {
return {
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: `${probeResult.error}; fallback: ${fallbackResult.error}`
};
if (probeResult.error) {
const fallbackResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: buildVatObjectProbeQuery(object, asOfExpr, "exists"),
limit: 1,
timeout_ms: VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS
});
if (!fallbackResult.error) {
probeResult = fallbackResult;
fallbackUsed = true;
}
else {
return {
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: `${latestError ?? probeResult.error}; fallback: ${fallbackResult.error}`
};
}
}
}
const firstRow = probeResult.raw_rows[0] ?? null;
@ -462,7 +533,13 @@ async function probeVatDirectSources(filters) {
sampleRegistrator
};
});
const status = metadataResponses.every((item) => item.error) ? "error" : "ok";
const hasProbeAttempts = probeRows.length > 0;
const hasNonErrorProbeResult = probeRows.some((item) => item.status !== "error");
const status = hasProbeAttempts && hasNonErrorProbeResult
? "ok"
: metadataResponses.every((item) => item.error) && !hasProbeAttempts
? "error"
: "ok";
const allErrors = [
...metadataErrors,
...probeRows

View File

@ -2114,7 +2114,10 @@ function composeFactualReply(intent, rows, options = {}) {
lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников.");
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, проверьте доступность регистров книг продаж/покупок.");
lines.push("", "Покрытие VAT-источников через MCP: дополнительный probe недоступен (например, timeout metadata).", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия.");
if (vatProbe.errors.length > 0) {
lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
}
if (rows.length === 0) {
lines.push("", "За выбранный налоговый период не найдены строки книг продаж/покупок, поэтому подтвержденная сумма к уплате равна 0.");

View File

@ -91,15 +91,20 @@ const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const VAT_METADATA_PROBE_LIMIT = 100;
const VAT_SOURCE_PROBE_MAX_OBJECTS = 6;
const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "Документ"] as const;
const VAT_SOURCE_PROBE_MAX_OBJECTS = 4;
const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления"] as const;
const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур"] as const;
const VAT_METADATA_PROBE_CONCURRENCY = 4;
const VAT_METADATA_PROBE_TIMEOUT_MS = 800;
const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_200;
const VAT_OBJECT_PROBE_CONCURRENCY = 4;
const VAT_OBJECT_PROBE_TIMEOUT_MS = 800;
const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 800;
const VAT_METADATA_PROBE_CONCURRENCY = 2;
const VAT_METADATA_PROBE_STAGGER_MS = 90;
const VAT_METADATA_PROBE_TIMEOUT_MS = 1_200;
const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_800;
const VAT_METADATA_PROBE_RETRY_DELAY_MS = 140;
const VAT_OBJECT_PROBE_CONCURRENCY = 1;
const VAT_OBJECT_PROBE_STAGGER_MS = 120;
const VAT_OBJECT_PROBE_TIMEOUT_MS = 1_500;
const VAT_OBJECT_PROBE_ABORT_RETRY_TIMEOUT_MS = 2_200;
const VAT_OBJECT_PROBE_ABORT_RETRY_DELAY_MS = 180;
const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 1_500;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@ -156,6 +161,28 @@ interface VatMetadataObject {
synonym: string | null;
objectType: "document" | "register";
}
const VAT_FALLBACK_METADATA_OBJECTS: VatMetadataObject[] = [
{
fullName: "РегистрНакопления.НДСЗаписиКнигиПродаж",
synonym: "НДС Продажи",
objectType: "register"
},
{
fullName: "РегистрНакопления.НДСЗаписиКнигиПокупок",
synonym: "НДС Покупки",
objectType: "register"
},
{
fullName: "РегистрНакопления.НДСПредъявленный",
synonym: "НДС предъявленный",
objectType: "register"
},
{
fullName: "РегистрНакопления.НДСВключенныйВСтоимость",
synonym: "НДС, включенный в стоимость",
objectType: "register"
}
];
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
@ -363,6 +390,14 @@ async function mapWithConcurrency<T, R>(
return results;
}
function sleepMs(ms: number): Promise<void> {
const delayMs = Number.isFinite(ms) ? Math.max(0, Math.trunc(ms)) : 0;
if (delayMs <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => setTimeout(resolve, delayMs));
}
async function executeVatMetadataProbeRequest(request: {
meta_type: string;
name_mask: string;
@ -376,6 +411,7 @@ async function executeVatMetadataProbeRequest(request: {
return firstAttempt;
}
await sleepMs(VAT_METADATA_PROBE_RETRY_DELAY_MS);
const retryLimit = Math.max(20, Math.min(request.limit, Math.trunc(request.limit / 2)));
const retryAttempt = await executeAddressMcpMetadata({
...request,
@ -420,6 +456,11 @@ function scoreVatMetadataObject(item: VatMetadataObject): number {
return score;
}
function vatMetadataObjectPriority(item: VatMetadataObject): number {
const idx = VAT_FALLBACK_METADATA_OBJECTS.findIndex((fallback) => fallback.fullName === item.fullName);
return idx >= 0 ? idx : VAT_FALLBACK_METADATA_OBJECTS.length + 1;
}
type VatObjectProbeMode = "latest" | "exists";
function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string, mode: VatObjectProbeMode = "latest"): string {
@ -494,7 +535,12 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise<VatDire
const metadataResponses = await mapWithConcurrency(
metadataRequests,
VAT_METADATA_PROBE_CONCURRENCY,
(request) => executeVatMetadataProbeRequest(request)
async (request, index) => {
if (index > 0 && VAT_METADATA_PROBE_STAGGER_MS > 0) {
await sleepMs(index * VAT_METADATA_PROBE_STAGGER_MS);
}
return executeVatMetadataProbeRequest(request);
}
);
const metadataOutcomes = metadataResponses.map((response, index) => ({
@ -539,14 +585,36 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise<VatDire
const discoveredMetadataObjects = Array.from(deduplicatedObjects.values())
.filter((item) => isVatMetadataObject(item))
.sort(
(a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru")
(a, b) =>
vatMetadataObjectPriority(a) - vatMetadataObjectPriority(b) ||
scoreVatMetadataObject(b) - scoreVatMetadataObject(a) ||
a.fullName.localeCompare(b.fullName, "ru")
);
const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS);
const mergedMetadataObjectsMap = new Map<string, VatMetadataObject>();
for (const item of discoveredMetadataObjects) {
mergedMetadataObjectsMap.set(item.fullName, item);
}
for (const fallbackObject of VAT_FALLBACK_METADATA_OBJECTS) {
if (!mergedMetadataObjectsMap.has(fallbackObject.fullName)) {
mergedMetadataObjectsMap.set(fallbackObject.fullName, fallbackObject);
}
}
const metadataObjects = Array.from(mergedMetadataObjectsMap.values())
.sort(
(a, b) =>
vatMetadataObjectPriority(a) - vatMetadataObjectPriority(b) ||
scoreVatMetadataObject(b) - scoreVatMetadataObject(a) ||
a.fullName.localeCompare(b.fullName, "ru")
)
.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS);
const probeRows = await mapWithConcurrency(
metadataObjects,
VAT_OBJECT_PROBE_CONCURRENCY,
async (object): Promise<VatDirectSourceProbeItem> => {
async (object, index): Promise<VatDirectSourceProbeItem> => {
if (index > 0 && VAT_OBJECT_PROBE_STAGGER_MS > 0) {
await sleepMs(index * VAT_OBJECT_PROBE_STAGGER_MS);
}
let probeResult = await executeAddressMcpQuery({
query: buildVatObjectProbeQuery(object, asOfExpr, "latest"),
limit: 1,
@ -554,33 +622,41 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise<VatDire
});
let fallbackUsed = false;
if (probeResult.error) {
let latestError: string | null = probeResult.error;
if (isAbortErrorMessage(probeResult.error)) {
return {
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: probeResult.error
};
await sleepMs(VAT_OBJECT_PROBE_ABORT_RETRY_DELAY_MS);
const retryLatestResult = await executeAddressMcpQuery({
query: buildVatObjectProbeQuery(object, asOfExpr, "latest"),
limit: 1,
timeout_ms: VAT_OBJECT_PROBE_ABORT_RETRY_TIMEOUT_MS
});
if (!retryLatestResult.error) {
probeResult = retryLatestResult;
latestError = null;
} else {
probeResult = retryLatestResult;
latestError = `${latestError}; retry_latest: ${retryLatestResult.error}`;
}
}
const fallbackResult = await executeAddressMcpQuery({
query: buildVatObjectProbeQuery(object, asOfExpr, "exists"),
limit: 1,
timeout_ms: VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS
});
if (!fallbackResult.error) {
probeResult = fallbackResult;
fallbackUsed = true;
} else {
return {
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: `${probeResult.error}; fallback: ${fallbackResult.error}`
};
if (probeResult.error) {
const fallbackResult = await executeAddressMcpQuery({
query: buildVatObjectProbeQuery(object, asOfExpr, "exists"),
limit: 1,
timeout_ms: VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS
});
if (!fallbackResult.error) {
probeResult = fallbackResult;
fallbackUsed = true;
} else {
return {
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: `${latestError ?? probeResult.error}; fallback: ${fallbackResult.error}`
};
}
}
}
const firstRow = probeResult.raw_rows[0] ?? null;
@ -610,7 +686,14 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise<VatDire
}
);
const status: VatDirectSourceProbeSummary["status"] = metadataResponses.every((item) => item.error) ? "error" : "ok";
const hasProbeAttempts = probeRows.length > 0;
const hasNonErrorProbeResult = probeRows.some((item) => item.status !== "error");
const status: VatDirectSourceProbeSummary["status"] =
hasProbeAttempts && hasNonErrorProbeResult
? "ok"
: metadataResponses.every((item) => item.error) && !hasProbeAttempts
? "error"
: "ok";
const allErrors = [
...metadataErrors,
...probeRows

View File

@ -2718,7 +2718,14 @@ export function composeFactualReply(
}
lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников.");
} else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, проверьте доступность регистров книг продаж/покупок.");
lines.push(
"",
"Покрытие VAT-источников через MCP: дополнительный probe недоступен (например, timeout metadata).",
"Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия."
);
if (vatProbe.errors.length > 0) {
lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
}
if (rows.length === 0) {