ДОМЕНЫ - ВОПРОСЫ - ОТКРЫТЫЕ ДОГОВОРА - Усилить business-view exact открытых договоров: разрез по типам остатков и quality gates
This commit is contained in:
parent
3e588ede81
commit
ef4222d159
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,8 +983,10 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
|||
sort: "period_desc"
|
||||
};
|
||||
if (!isManagementProfileIntent) {
|
||||
if (intent !== "open_contracts_confirmed_as_of_date") {
|
||||
filters.limit = 20;
|
||||
}
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const explicitAsOfDate = extractAsOfDate(text);
|
||||
const explicitAsOfDateWithCue = extractAsOfDateWithCue(text);
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") &&
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue