ДОМЕНЫ - ВОПРОСЫ - ОТКРЫТЫЕ ДОГОВОРА - Усилить business-view exact открытых договоров: разрез по типам остатков и quality gates

This commit is contained in:
dctouch 2026-04-13 18:15:06 +03:00
parent 3e588ede81
commit ef4222d159
14 changed files with 984 additions and 49 deletions

View File

@ -42,6 +42,18 @@
"expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"],
"expected_result_modes": ["heuristic_candidates", "confirmed_balance"]
},
{
"intent": "open_contracts_confirmed_as_of_date",
"expected_selected_recipes": ["address_open_contracts_confirmed_as_of_date_v1"],
"expected_requested_result_modes": ["confirmed_balance"],
"expected_result_modes": ["confirmed_balance"]
},
{
"intent": "list_open_contracts",
"expected_selected_recipes": ["address_open_contracts_candidates_v1", "address_open_items_by_party_or_contract_v1"],
"expected_requested_result_modes": ["heuristic_candidates"],
"expected_result_modes": ["heuristic_candidates"]
},
{
"intent": "account_balance_snapshot",
"expected_selected_recipes": ["address_open_items_by_party_or_contract_v1"],

View File

@ -26,6 +26,7 @@ export interface AddressCapabilityRouteDecision {
const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([
"account_balance_snapshot",
"documents_forming_balance",
"open_contracts_confirmed_as_of_date",
"payables_confirmed_as_of_date",
"receivables_confirmed_as_of_date",
"vat_payable_confirmed_as_of_date",
@ -64,6 +65,9 @@ function defaultCapabilityId(intent: AddressIntent): string {
if (intent === "receivables_confirmed_as_of_date") {
return "confirmed_receivables_as_of_date";
}
if (intent === "open_contracts_confirmed_as_of_date") {
return "confirmed_open_contracts_as_of_date";
}
if (intent === "vat_payable_confirmed_as_of_date") {
return "confirmed_vat_payable_as_of_date";
}
@ -106,6 +110,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
: "receivables_confirmed_route_disabled_by_flag"
};
}
if (intent === "open_contracts_confirmed_as_of_date") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
? "open_contracts_confirmed_route_enabled"
: "open_contracts_confirmed_route_disabled_by_flag"
};
}
if (intent === "vat_payable_confirmed_as_of_date") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,

View File

@ -933,6 +933,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "receivables_confirmed_as_of_date") {
return ["as_of_date"];
}
if (intent === "open_contracts_confirmed_as_of_date") {
return ["as_of_date"];
}
if (intent === "vat_payable_confirmed_as_of_date") {
return ["as_of_date"];
}
@ -956,6 +959,7 @@ function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
return (
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
@ -979,7 +983,9 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
sort: "period_desc"
};
if (!isManagementProfileIntent) {
filters.limit = 20;
if (intent !== "open_contracts_confirmed_as_of_date") {
filters.limit = 20;
}
}
const warnings: string[] = [];
const explicitAsOfDate = extractAsOfDate(text);

View File

@ -1661,7 +1661,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (hasOpenContractsListSignal(text)) {
return {
intent: "list_open_contracts",
intent: "open_contracts_confirmed_as_of_date",
confidence: "medium",
reasons: ["open_contract_signal_detected"]
};
@ -1844,7 +1844,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) {
return {
intent: "list_open_contracts",
intent: "open_contracts_confirmed_as_of_date",
confidence: "medium",
reasons: ["open_contract_signal_detected"]
};

View File

@ -962,6 +962,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent: AddressIntent, filte
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "open_items_by_counterparty_or_contract" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
@ -1291,6 +1292,7 @@ function isCounterpartyRiskIntent(intent: AddressIntent): boolean {
return (
intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "list_open_contracts" ||
@ -1311,6 +1313,7 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
return (
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date" ||
@ -1365,6 +1368,9 @@ function resolveRequestedResultMode(intent: AddressIntent, filters: AddressFilte
if (isConfirmedBalanceIntent(intent)) {
return "confirmed_balance";
}
if (intent === "list_open_contracts") {
return "heuristic_candidates";
}
if (isHeuristicCandidatesIntent(intent)) {
const asOfDateBasis = resolveAsOfDateBasis(filters);
if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") {
@ -1526,6 +1532,10 @@ function enforceStrictAccountScopeForIntent(
plan: AddressRecipeExecutionPlan,
intent: AddressIntent
): AddressRecipeExecutionPlan {
if (intent === "list_open_contracts" && plan.recipe.recipe_id === "address_open_items_by_party_or_contract_v1") {
return plan;
}
const strictScopeIntents: AddressIntent[] = [
"list_receivables_counterparties",
"list_open_contracts",
@ -2154,6 +2164,8 @@ function buildLimitedOffers(input: {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} else if (input.intent === "receivables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76");
} else if (input.intent === "open_contracts_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76");
} else if (input.intent === "vat_payable_confirmed_as_of_date") {
offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*");
} else if (input.intent === "vat_liability_confirmed_for_tax_period") {
@ -2208,6 +2220,7 @@ function buildLimitedIntentSignalLine(input: {
bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.",
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
open_contracts_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный список договоров с открытыми взаиморасчетами на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.",
@ -3630,6 +3643,7 @@ export class AddressQueryService {
const allowConfirmedAsOfZeroSnapshot =
filteredRows.length === 0 &&
(composeIntent === "vat_payable_confirmed_as_of_date" ||
composeIntent === "open_contracts_confirmed_as_of_date" ||
composeIntent === "payables_confirmed_as_of_date" ||
composeIntent === "receivables_confirmed_as_of_date") &&
(stageStatus === "no_raw_rows" || stageStatus === "materialized_but_filtered_out_by_recipe") &&

View File

@ -72,6 +72,48 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
Сумма __ORDER_DIRECTION__
`;
const OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
"Остатки на дату" КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
"" КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
ГДЕ
Остатки.СуммаРазвернутыйОстатокДт > 0
И (__OPEN_CONTRACT_ACCOUNTS_MATCH__)
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
"Остатки на дату" КАК Регистратор,
"" КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокКт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
ГДЕ
Остатки.СуммаРазвернутыйОстатокКт > 0
И (__OPEN_CONTRACT_ACCOUNTS_MATCH__)
УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__
`;
const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
@ -634,6 +676,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred",
query_template: "vat_liability_confirmed_tax_period_profile"
},
{
recipe_id: "address_open_contracts_confirmed_as_of_date_v1",
intent: "open_contracts_confirmed_as_of_date",
purpose: "Build confirmed snapshot of contracts with open settlements as-of date from balances on accounts 60/62/76",
required_filters: ["as_of_date"],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 400,
account_scope: ["60", "62", "76"],
account_scope_mode: "strict",
query_template: "open_contracts_confirmed_as_of_balance_profile"
},
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
@ -982,6 +1035,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
@ -1156,6 +1210,25 @@ export function buildAddressRecipePlan(
})()
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr =
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "payables_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr =

View File

@ -758,6 +758,40 @@ interface CounterpartyRiskAggregate {
lastPeriod: string | null;
}
interface OpenContractRiskAggregate {
contract: string;
totalAmount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
counterparties: string[];
sourceRefs: string[];
category: "commercial" | "financial" | "uncertain";
qualityFlags: string[];
}
type OpenContractSettlementKind =
| "receivable"
| "payable"
| "advance_issued"
| "advance_received"
| "other_receivable"
| "other_payable";
interface OpenContractConfirmedAggregate {
contract: string;
counterparty: string | null;
confirmedAmount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
category: "commercial" | "financial" | "uncertain";
settlementKind: OpenContractSettlementKind;
accounts: string[];
sourceRefs: string[];
qualityFlags: string[];
}
type PayablesLiabilityCategory = "supplier_or_contractor" | "bank_or_credit" | "tax_or_state" | "other";
interface PayablesCounterpartyRiskAggregate extends CounterpartyRiskAggregate {
@ -1553,6 +1587,380 @@ export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
return uniqueStrings(candidates);
}
function isFinancialContractLike(value: string): boolean {
return /(?:кредит|кред\.?|loan|overdraft|овердрафт|лизинг|leasing|займ|guarantee|гарант|банк|bank)/iu.test(value);
}
function hasStrongContractIdentitySignal(value: string): boolean {
return /(?:договор|contract|дог\.||\d{1,4}[\\/.-]\d{1,4}|\d{1,4}\sот\s\d{2}\.\d{2}\.\d{2,4}|[A-ZА-Я]{1,6}-\d+)/iu.test(
value
);
}
function isLikelyOrganizationName(value: string): boolean {
return /(?:(?:^|[\s"'«»()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»()\\\/.,;:]))|комитет|департамент|министерств|служб|управлени|казенн|администрац|bank|банк/iu.test(
value
);
}
function isContractLikeCounterparty(value: string): boolean {
return /(?:договор|дог[-.\s]?р|contract|кредитн|loan|овердрафт|лизинг|\b\b)/iu.test(value);
}
function isLowQualityContractIdentity(contract: string, counterparty: string | null): boolean {
const normalizedContract = normalizeEntityToken(contract);
if (!normalizedContract || normalizedContract.length < 3) {
return true;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(contract.trim())) {
return true;
}
if (counterparty && normalizeEntityToken(counterparty) === normalizedContract) {
return true;
}
if (!hasStrongContractIdentitySignal(contract) && isLikelyOrganizationName(contract)) {
return true;
}
if (!hasStrongContractIdentitySignal(contract) && /^[A-ZА-Я]{2,6}$/u.test(contract.trim())) {
return true;
}
return false;
}
function isLowQualityCounterpartyForContract(counterparty: string | null, contract: string): boolean {
if (!counterparty) {
return true;
}
const normalizedCounterparty = normalizeEntityToken(counterparty);
const normalizedContract = normalizeEntityToken(contract);
if (!normalizedCounterparty) {
return true;
}
if (normalizedCounterparty === normalizedContract) {
return true;
}
if (isContractLikeCounterparty(counterparty)) {
return true;
}
return normalizedCounterparty.length < 3;
}
function normalizeDisplayAccountToken(value: string | null): string | null {
const normalized = String(value ?? "").trim();
if (!normalized || /^(?:0|<пусто>|пустая ссылка|-)$/iu.test(normalized)) {
return null;
}
return normalized;
}
function classifyOpenContractCategory(
contract: string,
counterparties: string[],
qualityFlags: string[]
): "commercial" | "financial" | "uncertain" {
if (isFinancialContractLike(contract)) {
return "financial";
}
if (counterparties.some((item) => isFinancialContractLike(item))) {
return "financial";
}
if (
qualityFlags.includes("counterparty_not_reliably_resolved") ||
qualityFlags.includes("contract_identity_not_reliable") ||
qualityFlags.includes("contract_identity_looks_like_counterparty") ||
qualityFlags.includes("multiple_counterparties_for_contract")
) {
return "uncertain";
}
if (counterparties.length === 0) {
return "uncertain";
}
return "commercial";
}
function classifyOpenContractSettlementKind(row: ComposeStageRow): OpenContractSettlementKind | null {
const dt = extractAccountSectionCode(row.account_dt);
const kt = extractAccountSectionCode(row.account_kt);
if (dt === "62") {
return "receivable";
}
if (kt === "60") {
return "payable";
}
if (dt === "60") {
return "advance_issued";
}
if (kt === "62") {
return "advance_received";
}
if (dt === "76") {
return "other_receivable";
}
if (kt === "76") {
return "other_payable";
}
return null;
}
function openContractSettlementKindLabel(kind: OpenContractSettlementKind): string {
if (kind === "receivable") {
return "дебиторская задолженность";
}
if (kind === "payable") {
return "кредиторская задолженность";
}
if (kind === "advance_issued") {
return "аванс выданный";
}
if (kind === "advance_received") {
return "аванс полученный";
}
if (kind === "other_receivable") {
return "прочий дебетовый остаток";
}
return "прочий кредитовый остаток";
}
function summarizeOpenContractSpecialReason(item: { category: "commercial" | "financial" | "uncertain"; qualityFlags: string[] }): string {
if (item.category === "financial") {
return "похоже на финансовый договор (кредит/банк)";
}
if (item.qualityFlags.includes("contract_identity_looks_like_counterparty")) {
return "в поле договора похоже попал контрагент или чужая аналитика";
}
if (item.qualityFlags.includes("contract_identity_not_reliable")) {
return "договор не похож на устойчивый договорный реквизит";
}
if (item.qualityFlags.includes("multiple_counterparties_for_contract")) {
return "по одному договору найдено несколько контрагентов";
}
if (item.qualityFlags.includes("counterparty_not_reliably_resolved")) {
return "не удалось надежно определить контрагента";
}
return "требуется ручная проверка карточки договора";
}
function buildOpenContractConfirmedBalanceAggregate(
rows: ComposeStageRow[],
asOfDate: string
): OpenContractConfirmedAggregate[] {
const byContract = new Map<
string,
{
contract: string;
counterparty: string | null;
confirmedAmount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
counterparties: Set<string>;
settlementKind: OpenContractSettlementKind;
accounts: Set<string>;
sourceRefs: Set<string>;
qualityFlags: Set<string>;
}
>();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
for (const row of rows) {
const rowTimestamp = toUtcDayTimestamp(row.period);
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
continue;
}
const contract = extractContractName(row);
if (!contract) {
continue;
}
const amount = row.amount;
if (typeof amount !== "number" || !Number.isFinite(amount)) {
continue;
}
const settlementKind = classifyOpenContractSettlementKind(row);
if (!settlementKind) {
continue;
}
const counterpartyCandidate = extractCounterpartyName(row);
const counterparty = isLowQualityCounterpartyForContract(counterpartyCandidate, contract) ? null : counterpartyCandidate;
const sourceRefs = extractPayablesSourceRefs(row, counterparty ?? contract, contract);
const accountToken = normalizeDisplayAccountToken(row.account_dt) ?? normalizeDisplayAccountToken(row.account_kt);
const absAmount = Math.abs(amount);
const contractKey = normalizeEntityToken(contract);
const counterpartyKey = counterparty ? normalizeEntityToken(counterparty) : "__unknown_counterparty__";
const aggregateKey = `${contractKey}::${counterpartyKey}::${settlementKind}`;
const current = byContract.get(aggregateKey);
if (!current) {
const qualityFlags = new Set<string>();
if (!counterparty) {
qualityFlags.add("counterparty_not_reliably_resolved");
}
if (isLowQualityContractIdentity(contract, counterparty)) {
qualityFlags.add("contract_identity_not_reliable");
}
if (counterparty && normalizeEntityToken(counterparty) === normalizeEntityToken(contract)) {
qualityFlags.add("contract_identity_looks_like_counterparty");
}
const counterparties = new Set<string>();
if (counterparty) {
counterparties.add(counterparty);
}
const accounts = new Set<string>();
if (accountToken) {
accounts.add(accountToken);
}
byContract.set(aggregateKey, {
contract,
counterparty,
confirmedAmount: absAmount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
counterparties,
settlementKind,
accounts,
sourceRefs: new Set(sourceRefs),
qualityFlags
});
continue;
}
current.confirmedAmount += absAmount;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (counterparty) {
current.counterparty = current.counterparty ?? counterparty;
current.counterparties.add(counterparty);
} else {
current.qualityFlags.add("counterparty_not_reliably_resolved");
}
if (accountToken) {
current.accounts.add(accountToken);
}
for (const ref of sourceRefs) {
current.sourceRefs.add(ref);
}
}
return Array.from(byContract.values())
.map((item) => {
const counterparties = Array.from(item.counterparties);
if (counterparties.length > 1) {
item.qualityFlags.add("multiple_counterparties_for_contract");
}
return {
contract: item.contract,
counterparty: item.counterparty,
confirmedAmount: item.confirmedAmount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
category: classifyOpenContractCategory(item.contract, counterparties, Array.from(item.qualityFlags)),
settlementKind: item.settlementKind,
accounts: Array.from(item.accounts).slice(0, 3),
sourceRefs: Array.from(item.sourceRefs).slice(0, 3),
qualityFlags: Array.from(item.qualityFlags)
};
})
.filter((item) => item.confirmedAmount > 0.005)
.sort((left, right) => {
if (right.confirmedAmount !== left.confirmedAmount) {
return right.confirmedAmount - left.confirmedAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.contract.localeCompare(right.contract);
});
}
function buildOpenContractRiskAggregate(rows: ComposeStageRow[]): OpenContractRiskAggregate[] {
const byContract = new Map<
string,
{
contract: string;
totalAmount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
counterparties: Set<string>;
sourceRefs: Set<string>;
qualityFlags: Set<string>;
}
>();
for (const row of rows) {
const contract = extractContractName(row);
if (!contract) {
continue;
}
const amountRaw = row.amount ?? 0;
const amount = Number.isFinite(amountRaw) ? Math.abs(amountRaw) : 0;
const current = byContract.get(contract);
const counterpartyCandidate = extractCounterpartyName(row);
const counterparty = isLowQualityCounterpartyForContract(counterpartyCandidate, contract) ? null : counterpartyCandidate;
const sourceRefs = extractPayablesSourceRefs(row, counterparty ?? contract, contract);
if (!current) {
const qualityFlags = new Set<string>();
if (!counterparty) {
qualityFlags.add("counterparty_not_reliably_resolved");
}
byContract.set(contract, {
contract,
totalAmount: amount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
counterparties: new Set(counterparty ? [counterparty] : []),
sourceRefs: new Set(sourceRefs),
qualityFlags
});
continue;
}
current.totalAmount += amount;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (counterparty) {
current.counterparties.add(counterparty);
} else {
current.qualityFlags.add("counterparty_not_reliably_resolved");
}
for (const ref of sourceRefs) {
current.sourceRefs.add(ref);
}
}
return Array.from(byContract.values())
.map((item) => ({
contract: item.contract,
totalAmount: item.totalAmount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
counterparties: Array.from(item.counterparties).slice(0, 2),
sourceRefs: Array.from(item.sourceRefs).slice(0, 3),
category: classifyOpenContractCategory(item.contract, Array.from(item.counterparties), Array.from(item.qualityFlags)),
qualityFlags: Array.from(item.qualityFlags)
}))
.sort((left, right) => {
if (right.totalAmount !== left.totalAmount) {
return right.totalAmount - left.totalAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.contract.localeCompare(right.contract);
});
}
export function composeFactualReply(
intent: AddressIntent,
rows: ComposeStageRow[],
@ -2904,34 +3312,217 @@ export function composeFactualReply(
};
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [
"Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).",
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
if (intent === "open_contracts_confirmed_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const commercialContracts = confirmedContracts.filter((item) => item.category === "commercial");
const specialContracts = confirmedContracts.filter((item) => item.category !== "commercial");
const uniqueContracts = uniqueStrings(confirmedContracts.map((item) => item.contract));
const commercialReceivables = commercialContracts.filter((item) => item.settlementKind === "receivable");
const commercialPayables = commercialContracts.filter((item) => item.settlementKind === "payable");
const commercialAdvances = commercialContracts.filter(
(item) => item.settlementKind === "advance_issued" || item.settlementKind === "advance_received"
);
const commercialOther = commercialContracts.filter(
(item) => item.settlementKind === "other_receivable" || item.settlementKind === "other_payable"
);
const sumConfirmedAmount = (items: OpenContractConfirmedAggregate[]): number =>
items.reduce((sum, item) => sum + item.confirmedAmount, 0);
const commercialTotal = sumConfirmedAmount(commercialContracts);
const specialTotal = sumConfirmedAmount(specialContracts);
const periodScopeLine =
!options.asOfDate && (periodFrom || periodTo)
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null;
const renderConfirmedContractLines = (
items: OpenContractConfirmedAggregate[],
includeSpecialReason: boolean
): string[] =>
items.slice(0, 12).map((item, index) => {
const counterpartyLabel = item.counterparty ?? "контрагент не определен";
const accountsLabel = item.accounts.length > 0 ? ` | счета: ${item.accounts.join("; ")}` : "";
const evidenceLabel =
item.sourceRefs.length > 0 ? ` | основное основание: ${item.sourceRefs[0]}` : "";
const refsLabel =
item.sourceRefs.length > 1 ? ` | source refs: ${item.sourceRefs.slice(1, 3).join("; ")}` : "";
const specialReasonLabel = includeSpecialReason
? ` | причина вынесения: ${summarizeOpenContractSpecialReason(item)}`
: "";
return `${index + 1}. ${item.contract} | контрагент: ${counterpartyLabel} | подтвержденный открытый остаток: ${formatMoneyRub(item.confirmedAmount)} | тип остатка: ${openContractSettlementKindLabel(item.settlementKind)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${accountsLabel}${evidenceLabel}${refsLabel}${specialReasonLabel}`;
});
const lines: string[] = [
`Собран подтвержденный срез открытых договоров на ${formatDateRu(asOfDate)}.`,
`Коммерческие договорные позиции: ${formatNumberWithDots(commercialContracts.length)} на ${formatMoneyRub(commercialTotal)}.`,
`Финансовые/спорные позиции: ${formatNumberWithDots(specialContracts.length)} на ${formatMoneyRub(specialTotal)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.",
"- База ответа: остатки по счетам 60/62/76 с договорной аналитикой, без эвристического shortlist.",
"- Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка."
];
if (contracts.length > 0) {
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
lines.push("");
lines.push("Блок 2. Что учтено");
lines.push(`- Дата среза: ${formatDateRu(asOfDate)}.`);
if (periodScopeLine) {
lines.push(periodScopeLine);
}
lines.push("- Дефолтная бизнес-дефиниция: открыт договор, по которому на дату есть ненулевой остаток взаиморасчетов.");
lines.push("- Контур: остатки по счетам 60/62/76.");
lines.push("- Смешанные экономические смыслы не склеиваются: дебиторка, кредиторка, авансы и прочие остатки показаны раздельно.");
lines.push("");
lines.push("Блок 3. Сводка");
lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`);
lines.push(`- Уникальных договоров: ${formatNumberWithDots(uniqueContracts.length)}.`);
lines.push(`- Подтвержденных договорных позиций: ${formatNumberWithDots(confirmedContracts.length)}.`);
lines.push(
`- Коммерческая дебиторка: ${formatNumberWithDots(commercialReceivables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialReceivables))}.`
);
lines.push(
`- Коммерческая кредиторка: ${formatNumberWithDots(commercialPayables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialPayables))}.`
);
lines.push(
`- Коммерческие авансы: ${formatNumberWithDots(commercialAdvances.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialAdvances))}.`
);
lines.push(
`- Прочие расчеты по 76: ${formatNumberWithDots(commercialOther.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialOther))}.`
);
lines.push(`- Финансовые/спорные позиции: ${formatNumberWithDots(specialContracts.length)} на ${formatMoneyRub(specialTotal)}.`);
if (commercialReceivables.length > 0) {
lines.push("");
lines.push("Блок 4. Коммерческие договоры с дебиторской задолженностью");
lines.push(...renderConfirmedContractLines(commercialReceivables, false));
}
if (commercialPayables.length > 0) {
lines.push("");
lines.push("Блок 5. Коммерческие договоры с кредиторской задолженностью");
lines.push(...renderConfirmedContractLines(commercialPayables, false));
}
if (commercialAdvances.length > 0) {
lines.push("");
lines.push("Блок 6. Коммерческие авансы");
lines.push(...renderConfirmedContractLines(commercialAdvances, false));
}
if (commercialOther.length > 0) {
lines.push("");
lines.push("Блок 7. Прочие расчеты по 76");
lines.push(...renderConfirmedContractLines(commercialOther, false));
}
if (specialContracts.length > 0) {
lines.push("");
lines.push("Блок 8. Финансовые/спорные позиции");
lines.push(...renderConfirmedContractLines(specialContracts, true));
}
if (confirmedContracts.length === 0) {
lines.push("");
lines.push("Блок 4. Подтвержденные позиции");
lines.push("- На дату среза подтвержденные договоры с открытыми взаиморасчетами не найдены.");
}
return {
responseType: "FACTUAL_LIST",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
if (intent === "list_open_contracts") {
const contracts = buildOpenContractRiskAggregate(rows);
const counterparties = buildCounterpartyRiskAggregate(rows);
const asOfDate = normalizeIsoDateOnly(options.asOfDate ?? options.periodTo ?? options.periodFrom);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const commercialContracts = contracts.filter((item) => item.category === "commercial");
const specialContracts = contracts.filter((item) => item.category !== "commercial");
const commercialTotal = commercialContracts.reduce((sum, item) => sum + item.totalAmount, 0);
const lines: string[] = [
`Итого по предварительному срезу открытых договоров${asOfDate ? ` на ${formatDateRu(asOfDate)}` : ""}: ${formatNumberWithDots(commercialContracts.length)} коммерческих договоров на ${formatMoneyRub(commercialTotal)}.`,
"",
"Блок 1. Статус результата",
"- Результат: предварительный список договоров с возможными незакрытыми расчетами.",
"- Перед финансовым решением нужна сверка карточек договоров и взаиморасчетов в 1С.",
"",
"Блок 2. Что учтено",
...(asOfDate
? [`- Дата среза: ${formatDateRu(asOfDate)}.`]
: periodFrom || periodTo
? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`]
: []),
"- Контур: движения по счетам 60/62/76 и договорная аналитика.",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Договоров-кандидатов всего: ${formatNumberWithDots(contracts.length)}.`,
`- Основной список (коммерческие): ${formatNumberWithDots(commercialContracts.length)}.`,
`- Вынесено в финансовые/спорные: ${formatNumberWithDots(specialContracts.length)}.`
];
if (commercialContracts.length > 0) {
lines.push("");
lines.push("Блок 4. Основной список (коммерческие договоры)");
lines.push(
...commercialContracts.slice(0, 10).map((item, index) => {
const counterpartiesLabel =
item.counterparties.length > 0 ? item.counterparties.join("; ") : "контрагент не определен";
const sourceRefsSuffix =
item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${item.contract} | контрагент: ${counterpartiesLabel} | сумма возможного открытого остатка: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | почему в списке: есть признаки незакрытых расчетов на дату${sourceRefsSuffix}`;
})
);
if (specialContracts.length > 0) {
lines.push("");
lines.push("Блок 5. Финансовые/спорные позиции (вынесены отдельно)");
lines.push(
...specialContracts.slice(0, 8).map((item, index) => {
const counterpartiesLabel =
item.counterparties.length > 0 ? item.counterparties.join("; ") : "контрагент не определен";
const sourceRefsSuffix =
item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${item.contract} | контрагент: ${counterpartiesLabel} | сумма сигнала: ${formatMoneyRub(item.totalAmount)} | причина вынесения: ${summarizeOpenContractSpecialReason(item)}${sourceRefsSuffix}`;
})
);
}
} else if (counterparties.length > 0) {
lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`);
lines.push("");
lines.push("Блок 4. Контрагенты с сигналом незакрытых расчетов");
lines.push(`- Контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`);
lines.push(
...counterparties
.slice(0, 8)
.map(
(item, index) =>
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
)
);
lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска.");
lines.push("- Договорные реквизиты выделены недостаточно надежно, поэтому показан контрагентный список для проверки.");
} else {
lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback.");
lines.push("");
lines.push("Блок 4. Позиции не выделены");
lines.push("- По текущему live-срезу не удалось выделить договоры с достаточным качеством идентификации.");
lines.push("Блок 5. Примеры исходных строк");
lines.push(...formatTopRows(rows, 6));
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
text: joinLines(lines),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: contracts.length > 0 || counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}

View File

@ -367,6 +367,15 @@ function mergeFollowupFilters(
const merged: AddressFilterSet = { ...current };
const reasons: string[] = [];
if (!followupContext) {
if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) {
const periodToForOpenContracts = toNonEmptyString(merged.period_to);
const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
if (derivedAsOfDate) {
merged.as_of_date = derivedAsOfDate;
reasons.push("as_of_date_derived_from_period_for_open_contracts");
}
}
return { filters: merged, reasons };
}
@ -470,6 +479,7 @@ function mergeFollowupFilters(
if (
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
@ -550,6 +560,7 @@ function mergeFollowupFilters(
const asOfPrimaryIntent =
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date";
@ -587,6 +598,16 @@ function mergeFollowupFilters(
reasons.push("period_from_followup_context");
}
if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) {
const periodToForOpenContracts = toNonEmptyString(merged.period_to);
const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
if (derivedAsOfDate) {
merged.as_of_date = derivedAsOfDate;
reasons.push("as_of_date_derived_from_period_for_open_contracts");
}
}
return { filters: merged, reasons };
}
@ -594,6 +615,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
open_contracts_confirmed_as_of_date: ["as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
receivables_confirmed_as_of_date: ["as_of_date"],
vat_payable_confirmed_as_of_date: ["as_of_date"],

View File

@ -192,6 +192,7 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
if (
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date" ||

View File

@ -3801,6 +3801,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"counterparty_activity_lifecycle",
"customer_revenue_and_payments",
"supplier_payouts_profile",
"open_contracts_confirmed_as_of_date",
"list_open_contracts",
"open_items_by_counterparty_or_contract",
"list_payables_counterparties",

View File

@ -12,6 +12,7 @@ export type AddressIntent =
| "vat_payable_forecast"
| "vat_liability_confirmed_for_tax_period"
| "vat_payable_confirmed_as_of_date"
| "open_contracts_confirmed_as_of_date"
| "list_contracts_by_counterparty"
| "list_open_contracts"
| "list_payables_counterparties"
@ -135,6 +136,7 @@ export interface AddressRecipeDefinition {
| "vat_payable_forecast_profile"
| "vat_liability_confirmed_tax_period_profile"
| "vat_payable_confirmed_as_of_balance_profile"
| "open_contracts_confirmed_as_of_balance_profile"
| "payables_confirmed_as_of_balance_profile"
| "receivables_confirmed_as_of_balance_profile";
required_filters: Array<keyof AddressFilterSet>;

View File

@ -177,6 +177,110 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("Договор №19/15");
});
it("renders explicit heuristic contract-candidates reply for open-contracts intent", () => {
const reply = composeFactualReply(
"list_open_contracts",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Поступление товаров и услуг 00000000022",
account_dt: "60.01",
account_kt: "51",
amount: 150000,
analytics: ["ООО Ромашка", "Договор №19/15"]
}
],
{
periodFrom: "2020-03-01",
periodTo: "2020-03-31",
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Итого по предварительному срезу открытых договоров");
expect(reply.text).toContain("Блок 1. Статус результата");
expect(reply.text).toContain("Результат: предварительный список договоров с возможными незакрытыми расчетами.");
expect(reply.text).toContain("Блок 4. Основной список (коммерческие договоры)");
expect(reply.semantics?.result_mode).toBe("heuristic_candidates");
expect(reply.semantics?.balance_confirmed).toBe(false);
});
it("renders confirmed open-contracts snapshot for exact contract-settlements intent", () => {
const reply = composeFactualReply(
"open_contracts_confirmed_as_of_date",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "",
account_kt: "60.01",
amount: 150000,
analytics: ["ООО Ромашка", "Договор №19/15"]
}
],
{
periodFrom: "2020-03-01",
periodTo: "2020-03-31",
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Собран подтвержденный срез открытых договоров");
expect(reply.text).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.");
expect(reply.text).toContain("Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка.");
expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
expect(reply.semantics?.balance_confirmed).toBe(true);
});
it("splits confirmed open-contracts output by balance type and hides technical account placeholders", () => {
const reply = composeFactualReply(
"open_contracts_confirmed_as_of_date",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "62.01",
account_kt: "0",
amount: 100000,
analytics: ["ООО Ромашка", "Договор №19/15"]
},
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "",
account_kt: "60.01",
amount: 50000,
analytics: ["ООО Ромашка", "Договор №19/15"]
},
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "76.09",
account_kt: "",
amount: 25000,
analytics: ["Комитет госуслуг", "ООО /Альтернатива Плюс/"]
}
],
{
periodFrom: "2020-03-01",
periodTo: "2020-03-31",
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.text).toContain("Блок 4. Коммерческие договоры с дебиторской задолженностью");
expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью");
expect(reply.text).toContain("Блок 8. Финансовые/спорные позиции");
expect(reply.text).not.toContain("счета: 62.01; 0");
expect(reply.text).toContain("договор не похож на устойчивый договорный реквизит");
});
it("renders period coverage summary for management profile intent", () => {
const reply = composeFactualReply("period_coverage_profile", [
{
@ -1739,7 +1843,7 @@ describe("address intent resolver expansion (M2.3a)", () => {
it("resolves unclosed contracts list query without specific anchor", () => {
const result = resolveAddressIntent("Покажи незакрытые договоры на 2020-12-31");
expect(result.intent).toBe("list_open_contracts");
expect(result.intent).toBe("open_contracts_confirmed_as_of_date");
});
it("resolves bank operations by contract for normalized phrase with linked contract wording", () => {
@ -2923,11 +3027,12 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("keeps strict account scope for open-contract scans", async () => {
it("keeps strict account scope for confirmed open-contract scans", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие незакрытые документы по договорам у нас уже давно пора проверить?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_confirmed_as_of_date_v1");
expect(result?.debug.account_scope_mode).toBe("strict");
expect(result?.debug.account_scope_fallback_applied).toBe(false);
});
@ -2943,16 +3048,52 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("does not return execution_error for open-contracts month query when subconto fields are unavailable", async () => {
it("does not return execution_error for confirmed open-contracts month query", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u043a\u0430\u043a\u0438\u0435 \u0435\u0441\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442 2020"
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date");
expect(result?.debug.limited_reason_category).not.toBe("execution_error");
});
it("routes direct open-contract month query into exact confirmed mode", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("какие есть открытые договора на март 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date");
expect(result?.debug.extracted_filters.period_from).toBe("2020-03-01");
expect(result?.debug.extracted_filters.period_to).toBe("2020-03-31");
expect(result?.debug.extracted_filters.as_of_date).toBe("2020-03-31");
expect(result?.debug.route_expectation_status).toBe("matched");
expect(result?.debug.route_expectation_reason).toBe("route_expectation_matched");
expect(result?.debug.route_expectation_expected_result_modes).toContain("confirmed_balance");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.result_mode).toBe("confirmed_balance");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_confirmed_as_of_date_v1");
expect(result?.debug.capability_route_mode).toBe("exact");
const reply = String(result?.reply_text ?? "");
if (result?.response_type !== "LIMITED_WITH_REASON") {
expect(reply).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.");
}
});
it("keeps preferred account-scope mode for heuristic open-contract fallback recipe and avoids zeroing rows", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"Где у нас есть платежи, но нет документов для закрытия взаиморасчетов? Это уже требует ручной проверки."
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
if (result?.debug.selected_recipe === "address_open_items_by_party_or_contract_v1") {
expect(result?.debug.account_scope_mode).toBe("preferred");
expect(result?.debug.account_scope_fallback_applied).toBe(true);
expect(result?.debug.rows_after_account_scope).toBeGreaterThan(0);
expect(result?.debug.limited_reason_category).not.toBe("empty_match");
}
});
it("routes non-paying counterparties month-risk wording into receivables lane", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@ -3388,7 +3529,7 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
"\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017",
{
followupContext: {
previous_intent: (seed?.debug.detected_intent as any) ?? "list_open_contracts",
previous_intent: (seed?.debug.detected_intent as any) ?? "open_contracts_confirmed_as_of_date",
previous_filters: seed?.debug.extracted_filters,
previous_anchor_type: (seed?.debug.anchor_type as any) ?? "unknown",
previous_anchor_value: seed?.debug.anchor_value_resolved ?? seed?.debug.anchor_value_raw ?? null
@ -3743,9 +3884,20 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("open_items_from_followup_context");
});
it("derives as-of date from period for open-contract month query", () => {
const result = runAddressDecomposeStage("какие есть открытые договора на март 2020", null);
expect(result).not.toBeNull();
expect(result?.mode.mode).toBe("address_query");
expect(result?.intent.intent).toBe("open_contracts_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
expect(result?.baseReasons).toContain("as_of_date_derived_from_period_for_open_contracts");
});
it("keeps VAT debt follow-up in VAT intent even after open-contract context", () => {
const result = runAddressDecomposeStage("\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017", {
previous_intent: "list_open_contracts",
previous_intent: "open_contracts_confirmed_as_of_date",
previous_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31"
@ -4083,8 +4235,8 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор");
});
it("allows extended limit for open-contracts intent", () => {
const selected = selectAddressRecipe("list_open_contracts", {
it("allows extended limit for confirmed open-contracts intent", () => {
const selected = selectAddressRecipe("open_contracts_confirmed_as_of_date", {
as_of_date: "2020-12-31",
limit: 1000
});
@ -4097,6 +4249,21 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.limit).toBe(1000);
});
it("builds exact balance query for confirmed open-contracts snapshot", () => {
const selected = selectAddressRecipe("open_contracts_confirmed_as_of_date", {
as_of_date: "2020-12-31"
});
expect(selected.selected_recipe?.recipe_id).toBe("address_open_contracts_confirmed_as_of_date_v1");
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
as_of_date: "2020-12-31"
});
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки(");
expect(plan.query).toContain("СуммаРазвернутыйОстатокДт");
expect(plan.query).toContain("СуммаРазвернутыйОстатокКт");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Остатки.Счет.Код, \"\"), 1, 2) = \"60\"");
});
it("injects account condition into movements query for account snapshot", () => {
const filters = extractAddressFilters(
"Какой остаток по счету 60 на дату 2020-07-31",

View File

@ -619,7 +619,7 @@ describe("assistant orchestration contract", () => {
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
});
it("keeps list_open_contracts query in address lane despite 'unclosed' wording", () => {
it("keeps confirmed open-contracts query in address lane despite 'unclosed' wording", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31",
effectiveAddressUserMessage: "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043f\u043e \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044e \u043d\u0430 \u043a\u043e\u043d\u0435\u0446 \u0434\u0435\u043a\u0430\u0431\u0440\u044f 2020 \u0433\u043e\u0434\u0430.",
@ -632,7 +632,7 @@ describe("assistant orchestration contract", () => {
predecomposeContract: {
mode: "address_query",
mode_confidence: "high",
intent: "list_open_contracts",
intent: "open_contracts_confirmed_as_of_date",
intent_confidence: "medium"
}
} as any,

View File

@ -3,11 +3,10 @@ import { AddressQueryService } from "../src/services/addressQueryService";
import { resolveAssistantOrchestrationDecision, resolveLivingAssistantModeDecision } from "../src/services/assistantService";
describe("wave17 run regressions (2026-04-11 real runs)", () => {
it("keeps real run 17:51 data-heavy prompts in address lane", () => {
it("keeps real run 17:51 prompts with explicit accounting signals in address lane", () => {
const realRunPrompts = [
"Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?",
"Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?",
"В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности."
"\u043a\u0430\u043a\u0438\u0435 \u0443 \u043d\u0430\u0441 \u043d\u0430\u0438\u0431\u043e\u043b\u044c\u0448\u0438\u0435 \u0430\u0432\u0430\u043d\u0441\u044b \u043a \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0430\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0443\u0436\u0435 \u0434\u0430\u0432\u043d\u043e \u0432\u0438\u0441\u044f\u0442 \u0431\u0435\u0437 \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044f?",
"\u0432 \u043a\u0430\u043a\u0438\u0445 \u0441\u0434\u0435\u043b\u043a\u0430\u0445 \u043c\u044b \u0432\u0438\u0434\u0438\u043c \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0438, \u043d\u043e \u0434\u0435\u043d\u044c\u0433\u0438 \u0442\u0430\u043a \u0438 \u043d\u0435 \u043f\u0440\u0438\u0448\u043b\u0438?"
];
for (const prompt of realRunPrompts) {
@ -28,8 +27,29 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
}
});
it("keeps vague counterparty risk wording in deep analysis until stronger data anchor appears", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage:
"\u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u044b \u0443 \u043d\u0430\u0441 \u0434\u0430\u0432\u043d\u043e \u043d\u0435 \u043f\u043b\u0430\u0442\u044f\u0442 \u0438 \u044d\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0445\u043e\u0436\u0435 \u043d\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443?",
effectiveAddressUserMessage:
"\u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u044b \u0443 \u043d\u0430\u0441 \u0434\u0430\u0432\u043d\u043e \u043d\u0435 \u043f\u043b\u0430\u0442\u044f\u0442 \u0438 \u044d\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0445\u043e\u0436\u0435 \u043d\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443?",
followupContext: null,
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("deep_analysis_signal_fallback_to_deep");
expect(decision.livingMode).toBe("deep_analysis");
expect(decision.livingReason).toBe("deep_analysis_signal_fallback_to_deep");
});
it("keeps short follow-up style prompts out of chat drift when predecompose says unsupported", () => {
const shortFollowups = ["без воды?", "и коротко?", "прям сейчас?"];
const shortFollowups = [
"\u0430 \u0431\u0435\u0437 \u0441\u0432\u043e\u0434\u043a\u0438?",
"\u0438 \u043f\u043e \u044d\u0442\u043e\u043c\u0443 \u0442\u043e\u0436\u0435?",
"\u043f\u0440\u044f\u043c \u0433\u0434\u0435 \u0436\u0435?"
];
for (const prompt of shortFollowups) {
const decision = resolveLivingAssistantModeDecision({
@ -47,7 +67,7 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
it("routes data-scope slang wording to chat mode", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "по каким конторам можем общаться?",
userMessage: "\u0430 \u043a\u0430\u043a\u0430\u044f \u0432\u043e\u043e\u0431\u0449\u0435 \u0431\u0430\u0437\u0430 \u0441\u044e\u0434\u0430 \u043f\u043e\u0434\u0440\u0443\u0431\u043b\u0435\u043d\u0430?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
@ -60,11 +80,11 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
it("keeps open-contracts request in address lane even with stale deep followup context", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
effectiveAddressUserMessage: "Покажи незакрытые договоры на 2020-12-31",
rawUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31",
effectiveAddressUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31",
followupContext: {
previous_question_id: "msg-prev",
last_user_message: "почему так по закрытию месяца",
last_user_message: "\u043f\u043e\u0447\u0435\u043c\u0443 \u0442\u0430\u043a \u043f\u043e \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044e \u043c\u0435\u0441\u044f\u0446\u0430",
active_domain: "month_close_costs_20_44",
active_requirement_ids: ["R1"],
uncovered_requirement_ids: ["R1"],
@ -77,7 +97,7 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
predecomposeContract: {
mode: "address_query",
mode_confidence: "high",
intent: "list_open_contracts",
intent: "open_contracts_confirmed_as_of_date",
intent_confidence: "medium"
}
} as any,
@ -90,19 +110,33 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
});
it("uses soft unsupported aggregate replies instead of rigid old template", async () => {
it("supports strongest aggregate revenue route while keeping unsupported turnover prompt soft", async () => {
const service = new AddressQueryService();
const prompts = ["какой самый доходный год?", "какие обороты по альтернативе за 2020 год"];
for (const prompt of prompts) {
const result = await service.tryHandle(prompt);
const reply = String(result?.reply_text ?? "");
const strongestRevenue = await service.tryHandle(
"\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?"
);
const strongestReply = String(strongestRevenue?.reply_text ?? "");
expect(strongestRevenue?.handled).toBe(true);
expect(strongestRevenue?.reply_type).toBe("factual");
expect(strongestRevenue?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(strongestRevenue?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(strongestReply).toContain(
"\u0422\u043e\u043f-3 \u043b\u0435\u0442 \u043f\u043e \u0441\u0443\u043c\u043c\u0435 \u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439"
);
expect(result?.handled).toBe(true);
expect(result?.reply_type).toBe("partial_coverage");
expect(result?.debug.limited_reason_category).toBe("unsupported");
expect(reply).toContain("Что могу сделать сейчас:");
expect(reply).not.toMatch(/Сейчас этот тип вопроса вне поддерживаемого контура адресного режима/iu);
}
const unsupportedTurnover = await service.tryHandle(
"\u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0435 \u0437\u0430 2020 \u0433\u043e\u0434"
);
const unsupportedReply = String(unsupportedTurnover?.reply_text ?? "");
expect(unsupportedTurnover?.handled).toBe(true);
expect(unsupportedTurnover?.reply_type).toBe("partial_coverage");
expect(unsupportedTurnover?.debug.limited_reason_category).toBe("unsupported");
expect(unsupportedReply).toContain(
"\u0427\u0442\u043e \u043c\u043e\u0433\u0443 \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0441\u0435\u0439\u0447\u0430\u0441:"
);
expect(unsupportedReply).not.toContain(
"\u0421\u0435\u0439\u0447\u0430\u0441 \u044d\u0442\u043e\u0442 \u0442\u0438\u043f \u0432\u043e\u043f\u0440\u043e\u0441\u0430 \u0432\u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0443\u0440\u0430 \u0430\u0434\u0440\u0435\u0441\u043d\u043e\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430"
);
});
});