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

This commit is contained in:
2026-04-13 18:15:06 +03:00
parent 3e588ede81
commit ef4222d159
14 changed files with 984 additions and 49 deletions
@@ -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,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);
@@ -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",