Open-World: добавить возрастной сигнал открытых расчетов

This commit is contained in:
2026-05-04 08:52:43 +03:00
parent 7294eca381
commit 9b26bd05ef
9 changed files with 353 additions and 15 deletions
@@ -354,6 +354,9 @@ function headlineFor(mode, pilot) {
}
if (overview.debt_open_settlement_quality) {
families.push("качество открытых расчетов");
if (overview.debt_open_settlement_quality.age_signal) {
families.push("возрастной сигнал открытых расчетов");
}
}
if (overview.inventory_position) {
families.push("складской срез на дату");
@@ -863,6 +866,12 @@ function derivedBusinessOverviewConfirmedLines(pilot) {
? ` Крупнейший открытый договор: ${topContract.contract}${topContract.counterparty ? ` / ${topContract.counterparty}` : ""}${topContract.total_amount_human_ru}${topContract.share_of_gross_open_amount_pct === null ? "" : ` (${topContract.share_of_gross_open_amount_pct}%)`}.`
: "";
lines.push(`Качество открытых расчетов на ${quality.as_of_date}: брутто открытых договорных остатков ${quality.gross_open_amount_human_ru}, договоров ${quality.unique_contracts}, контрагентов ${quality.unique_counterparties}.${topContractText}`);
if (quality.age_signal?.oldest_start_date) {
const ageText = quality.age_signal.max_age_days === null
? ""
: `, максимальный возраст сигнала ${quality.age_signal.max_age_days} дн.`;
lines.push(`Возрастной сигнал открытых расчетов: самая ранняя найденная дата договора ${quality.age_signal.oldest_start_date}${ageText}. Это не просрочка и не due-date анализ.`);
}
}
if (overview.inventory_position) {
const leader = overview.inventory_position.top_items[0];
@@ -940,6 +949,9 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
}
if (pilot.derived_business_overview?.debt_open_settlement_quality) {
pushReason(reasonCodes, "answer_contains_business_overview_open_settlement_quality");
if (pilot.derived_business_overview.debt_open_settlement_quality.age_signal) {
pushReason(reasonCodes, "answer_contains_business_overview_debt_age_signal");
}
}
if (pilot.derived_business_overview?.inventory_position) {
pushReason(reasonCodes, "answer_contains_business_overview_inventory_position");
@@ -1541,6 +1541,66 @@ function rowCounterpartyValue(row) {
}
return rowAnalyticsTextValues(row).find(isLikelyCounterpartyToken) ?? null;
}
function normalizeDateParts(yearText, monthText, dayText) {
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null;
}
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day) {
return null;
}
return [
String(year).padStart(4, "0"),
String(month).padStart(2, "0"),
String(day).padStart(2, "0")
].join("-");
}
function extractContractDateFromText(value) {
const text = toNonEmptyString(value);
if (!text || !/(?:договор|contract|дог\.)/iu.test(text)) {
return null;
}
const isoLikeMatch = text.match(/(\d{4})[-./](\d{1,2})[-./](\d{1,2})/);
if (isoLikeMatch) {
return normalizeDateParts(isoLikeMatch[1], isoLikeMatch[2], isoLikeMatch[3]);
}
const ruDateMatch = text.match(/(\d{1,2})[./-](\d{1,2})[./-](\d{4})/);
if (ruDateMatch) {
return normalizeDateParts(ruDateMatch[3], ruDateMatch[2], ruDateMatch[1]);
}
return null;
}
function earlierIsoDate(left, right) {
if (!left) {
return right;
}
if (!right) {
return left;
}
return right < left ? right : left;
}
function rowOpenSettlementContractStartDateValue(row) {
const candidates = [
rowContractValue(row),
rowDocumentValue(row),
...rowAnalyticsTextValues(row)
];
for (const candidate of candidates) {
const contractDate = extractContractDateFromText(candidate);
if (contractDate) {
return contractDate;
}
}
return null;
}
function monthBucketFromIsoDate(isoDate) {
const match = isoDate?.match(/^(\d{4})-(\d{2})-\d{2}$/);
return match ? `${match[1]}-${match[2]}` : null;
@@ -1982,6 +2042,7 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input) {
if (!input.debtAsOfDate || !input.openContractsResult || input.openContractsResult.error || input.openContractsResult.matched_rows <= 0) {
return null;
}
const debtAsOfDate = input.debtAsOfDate;
const counterpartyBuckets = new Map();
const contractBuckets = new Map();
const counterparties = new Set();
@@ -2014,15 +2075,18 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input) {
rowsWithoutCounterparty += 1;
}
if (contract) {
const contractStartDate = rowOpenSettlementContractStartDateValue(row);
contracts.add(contract);
const current = contractBuckets.get(contract) ?? {
counterparty,
contract_start_date: contractStartDate,
rows_with_amount: 0,
total_amount: 0
};
if (!current.counterparty && counterparty) {
current.counterparty = counterparty;
}
current.contract_start_date = earlierIsoDate(current.contract_start_date, contractStartDate);
current.rows_with_amount += 1;
current.total_amount += absAmount;
contractBuckets.set(contract, current);
@@ -2050,6 +2114,7 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input) {
.map(([contract, bucket]) => ({
contract,
counterparty: bucket.counterparty,
contract_start_date: bucket.contract_start_date,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
@@ -2060,8 +2125,51 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input) {
return amountDelta !== 0 ? amountDelta : left.contract.localeCompare(right.contract, "ru");
})
.slice(0, 5);
const agedContractBuckets = Array.from(contractBuckets.entries())
.map(([contract, bucket]) => {
if (!bucket.contract_start_date) {
return null;
}
return {
contract,
counterparty: bucket.counterparty,
start_date: bucket.contract_start_date,
age_days: daysBetweenIsoDates(bucket.contract_start_date, debtAsOfDate),
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
share_of_gross_open_amount_pct: percentageOfTotal(bucket.total_amount, grossOpenAmount)
};
})
.filter((item) => Boolean(item))
.sort((left, right) => {
const leftAge = left.age_days ?? -1;
const rightAge = right.age_days ?? -1;
const ageDelta = rightAge - leftAge;
if (ageDelta !== 0) {
return ageDelta;
}
const amountDelta = right.total_amount - left.total_amount;
return amountDelta !== 0 ? amountDelta : left.contract.localeCompare(right.contract, "ru");
});
const debtAgeDates = agedContractBuckets.map((bucket) => bucket.start_date).sort();
const maxAgeDays = agedContractBuckets.reduce((max, bucket) => {
if (bucket.age_days === null) {
return max;
}
return max === null ? bucket.age_days : Math.max(max, bucket.age_days);
}, null);
const ageSignal = agedContractBuckets.length > 0
? {
contracts_with_start_date: agedContractBuckets.length,
oldest_start_date: debtAgeDates[0] ?? null,
latest_start_date: debtAgeDates[debtAgeDates.length - 1] ?? null,
max_age_days: maxAgeDays,
top_aged_contracts: agedContractBuckets.slice(0, 5),
inference_basis: "contract_dates_from_open_settlement_rows"
}
: null;
return {
as_of_date: input.debtAsOfDate,
as_of_date: debtAsOfDate,
rows_matched: input.openContractsResult.matched_rows,
rows_with_amount: rowsWithAmount,
gross_open_amount: grossOpenAmount,
@@ -2074,6 +2182,7 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input) {
top_contracts: topContracts,
concentration_top_counterparty_pct: percentageOfTotal(topCounterparties[0]?.total_amount ?? 0, grossOpenAmount),
concentration_top_contract_pct: percentageOfTotal(topContracts[0]?.total_amount ?? 0, grossOpenAmount),
age_signal: ageSignal,
inference_basis: "open_contracts_confirmed_1c_balance_rows"
};
}
@@ -2335,6 +2444,12 @@ function buildBusinessOverviewConfirmedFacts(derived) {
? ` Крупнейший открытый договор: ${leader.contract}${leader.counterparty ? ` / ${leader.counterparty}` : ""}${leader.total_amount_human_ru}${leaderShareText}.`
: "";
facts.push(`Качество открытых расчетов на ${quality.as_of_date} проверено по договорным остаткам 60/62/76: брутто ${quality.gross_open_amount_human_ru}, договоров ${quality.unique_contracts}, контрагентов ${quality.unique_counterparties}.${leaderText}`);
if (quality.age_signal?.oldest_start_date) {
const ageText = quality.age_signal.max_age_days === null
? ""
: `, максимальный возраст сигнала ${quality.age_signal.max_age_days} дн.`;
facts.push(`Возрастной сигнал открытых расчетов подтвержден по датам договоров: самая ранняя дата договора ${quality.age_signal.oldest_start_date}${ageText}. Это не договорная просрочка и не due-date анализ.`);
}
}
if (derived.inventory_position) {
const leader = derived.inventory_position.top_items[0];
@@ -3318,6 +3433,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
}
if (derivedBusinessOverview.debt_open_settlement_quality) {
pushReason(reasonCodes, "pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows");
if (derivedBusinessOverview.debt_open_settlement_quality.age_signal) {
pushReason(reasonCodes, "pilot_derived_business_overview_debt_age_signal_from_contract_dates");
}
}
if (derivedBusinessOverview.inventory_position) {
pushReason(reasonCodes, "pilot_derived_business_overview_inventory_position_from_confirmed_rows");
@@ -455,6 +455,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
}
if (overview.debt_open_settlement_quality) {
families.push("качество открытых расчетов");
if (overview.debt_open_settlement_quality.age_signal) {
families.push("возрастной сигнал открытых расчетов");
}
}
if (overview.inventory_position) {
families.push("складской срез на дату");
@@ -1020,6 +1023,14 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot
lines.push(
`Качество открытых расчетов на ${quality.as_of_date}: брутто открытых договорных остатков ${quality.gross_open_amount_human_ru}, договоров ${quality.unique_contracts}, контрагентов ${quality.unique_counterparties}.${topContractText}`
);
if (quality.age_signal?.oldest_start_date) {
const ageText = quality.age_signal.max_age_days === null
? ""
: `, максимальный возраст сигнала ${quality.age_signal.max_age_days} дн.`;
lines.push(
`Возрастной сигнал открытых расчетов: самая ранняя найденная дата договора ${quality.age_signal.oldest_start_date}${ageText}. Это не просрочка и не due-date анализ.`
);
}
}
if (overview.inventory_position) {
const leader = overview.inventory_position.top_items[0];
@@ -1111,6 +1122,9 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
}
if (pilot.derived_business_overview?.debt_open_settlement_quality) {
pushReason(reasonCodes, "answer_contains_business_overview_open_settlement_quality");
if (pilot.derived_business_overview.debt_open_settlement_quality.age_signal) {
pushReason(reasonCodes, "answer_contains_business_overview_debt_age_signal");
}
}
if (pilot.derived_business_overview?.inventory_position) {
pushReason(reasonCodes, "answer_contains_business_overview_inventory_position");
@@ -201,12 +201,32 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtPosition {
export interface AssistantMcpDiscoveryBusinessOverviewDebtOpenContractBucket {
contract: string;
counterparty: string | null;
contract_start_date: string | null;
rows_with_amount: number;
total_amount: number;
total_amount_human_ru: string;
share_of_gross_open_amount_pct: number | null;
}
export interface AssistantMcpDiscoveryBusinessOverviewDebtAgeContractBucket {
contract: string;
counterparty: string | null;
start_date: string;
age_days: number | null;
total_amount: number;
total_amount_human_ru: string;
share_of_gross_open_amount_pct: number | null;
}
export interface AssistantMcpDiscoveryBusinessOverviewDebtAgeSignal {
contracts_with_start_date: number;
oldest_start_date: string | null;
latest_start_date: string | null;
max_age_days: number | null;
top_aged_contracts: AssistantMcpDiscoveryBusinessOverviewDebtAgeContractBucket[];
inference_basis: "contract_dates_from_open_settlement_rows";
}
export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality {
as_of_date: string;
rows_matched: number;
@@ -221,6 +241,7 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQ
top_contracts: AssistantMcpDiscoveryBusinessOverviewDebtOpenContractBucket[];
concentration_top_counterparty_pct: number | null;
concentration_top_contract_pct: number | null;
age_signal: AssistantMcpDiscoveryBusinessOverviewDebtAgeSignal | null;
inference_basis: "open_contracts_confirmed_1c_balance_rows";
}
@@ -2160,6 +2181,72 @@ function rowCounterpartyValue(row: Record<string, unknown>): string | null {
return rowAnalyticsTextValues(row).find(isLikelyCounterpartyToken) ?? null;
}
function normalizeDateParts(yearText: string, monthText: string, dayText: string): string | null {
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null;
}
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return [
String(year).padStart(4, "0"),
String(month).padStart(2, "0"),
String(day).padStart(2, "0")
].join("-");
}
function extractContractDateFromText(value: string | null): string | null {
const text = toNonEmptyString(value);
if (!text || !/(?:договор|contract|дог\.)/iu.test(text)) {
return null;
}
const isoLikeMatch = text.match(/(\d{4})[-./](\d{1,2})[-./](\d{1,2})/);
if (isoLikeMatch) {
return normalizeDateParts(isoLikeMatch[1], isoLikeMatch[2], isoLikeMatch[3]);
}
const ruDateMatch = text.match(/(\d{1,2})[./-](\d{1,2})[./-](\d{4})/);
if (ruDateMatch) {
return normalizeDateParts(ruDateMatch[3], ruDateMatch[2], ruDateMatch[1]);
}
return null;
}
function earlierIsoDate(left: string | null, right: string | null): string | null {
if (!left) {
return right;
}
if (!right) {
return left;
}
return right < left ? right : left;
}
function rowOpenSettlementContractStartDateValue(row: Record<string, unknown>): string | null {
const candidates = [
rowContractValue(row),
rowDocumentValue(row),
...rowAnalyticsTextValues(row)
];
for (const candidate of candidates) {
const contractDate = extractContractDateFromText(candidate);
if (contractDate) {
return contractDate;
}
}
return null;
}
function monthBucketFromIsoDate(isoDate: string | null): string | null {
const match = isoDate?.match(/^(\d{4})-(\d{2})-\d{2}$/);
return match ? `${match[1]}-${match[2]}` : null;
@@ -2685,10 +2772,11 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input: {
return null;
}
const debtAsOfDate = input.debtAsOfDate;
const counterpartyBuckets = new Map<string, { rows_with_amount: number; total_amount: number }>();
const contractBuckets = new Map<
string,
{ counterparty: string | null; rows_with_amount: number; total_amount: number }
{ counterparty: string | null; contract_start_date: string | null; rows_with_amount: number; total_amount: number }
>();
const counterparties = new Set<string>();
const contracts = new Set<string>();
@@ -2722,15 +2810,18 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input: {
}
if (contract) {
const contractStartDate = rowOpenSettlementContractStartDateValue(row);
contracts.add(contract);
const current = contractBuckets.get(contract) ?? {
counterparty,
contract_start_date: contractStartDate,
rows_with_amount: 0,
total_amount: 0
};
if (!current.counterparty && counterparty) {
current.counterparty = counterparty;
}
current.contract_start_date = earlierIsoDate(current.contract_start_date, contractStartDate);
current.rows_with_amount += 1;
current.total_amount += absAmount;
contractBuckets.set(contract, current);
@@ -2760,6 +2851,7 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input: {
.map(([contract, bucket]) => ({
contract,
counterparty: bucket.counterparty,
contract_start_date: bucket.contract_start_date,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
@@ -2771,8 +2863,52 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input: {
})
.slice(0, 5);
const agedContractBuckets = Array.from(contractBuckets.entries())
.map(([contract, bucket]): AssistantMcpDiscoveryBusinessOverviewDebtAgeContractBucket | null => {
if (!bucket.contract_start_date) {
return null;
}
return {
contract,
counterparty: bucket.counterparty,
start_date: bucket.contract_start_date,
age_days: daysBetweenIsoDates(bucket.contract_start_date, debtAsOfDate),
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
share_of_gross_open_amount_pct: percentageOfTotal(bucket.total_amount, grossOpenAmount)
};
})
.filter((item): item is AssistantMcpDiscoveryBusinessOverviewDebtAgeContractBucket => Boolean(item))
.sort((left, right) => {
const leftAge = left.age_days ?? -1;
const rightAge = right.age_days ?? -1;
const ageDelta = rightAge - leftAge;
if (ageDelta !== 0) {
return ageDelta;
}
const amountDelta = right.total_amount - left.total_amount;
return amountDelta !== 0 ? amountDelta : left.contract.localeCompare(right.contract, "ru");
});
const debtAgeDates = agedContractBuckets.map((bucket) => bucket.start_date).sort();
const maxAgeDays = agedContractBuckets.reduce<number | null>((max, bucket) => {
if (bucket.age_days === null) {
return max;
}
return max === null ? bucket.age_days : Math.max(max, bucket.age_days);
}, null);
const ageSignal: AssistantMcpDiscoveryBusinessOverviewDebtAgeSignal | null = agedContractBuckets.length > 0
? {
contracts_with_start_date: agedContractBuckets.length,
oldest_start_date: debtAgeDates[0] ?? null,
latest_start_date: debtAgeDates[debtAgeDates.length - 1] ?? null,
max_age_days: maxAgeDays,
top_aged_contracts: agedContractBuckets.slice(0, 5),
inference_basis: "contract_dates_from_open_settlement_rows"
}
: null;
return {
as_of_date: input.debtAsOfDate,
as_of_date: debtAsOfDate,
rows_matched: input.openContractsResult.matched_rows,
rows_with_amount: rowsWithAmount,
gross_open_amount: grossOpenAmount,
@@ -2785,6 +2921,7 @@ function deriveBusinessOverviewDebtOpenSettlementQuality(input: {
top_contracts: topContracts,
concentration_top_counterparty_pct: percentageOfTotal(topCounterparties[0]?.total_amount ?? 0, grossOpenAmount),
concentration_top_contract_pct: percentageOfTotal(topContracts[0]?.total_amount ?? 0, grossOpenAmount),
age_signal: ageSignal,
inference_basis: "open_contracts_confirmed_1c_balance_rows"
};
}
@@ -3114,6 +3251,14 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv
facts.push(
`Качество открытых расчетов на ${quality.as_of_date} проверено по договорным остаткам 60/62/76: брутто ${quality.gross_open_amount_human_ru}, договоров ${quality.unique_contracts}, контрагентов ${quality.unique_counterparties}.${leaderText}`
);
if (quality.age_signal?.oldest_start_date) {
const ageText = quality.age_signal.max_age_days === null
? ""
: `, максимальный возраст сигнала ${quality.age_signal.max_age_days} дн.`;
facts.push(
`Возрастной сигнал открытых расчетов подтвержден по датам договоров: самая ранняя дата договора ${quality.age_signal.oldest_start_date}${ageText}. Это не договорная просрочка и не due-date анализ.`
);
}
}
if (derived.inventory_position) {
const leader = derived.inventory_position.top_items[0];
@@ -4225,6 +4370,9 @@ export async function executeAssistantMcpDiscoveryPilot(
}
if (derivedBusinessOverview.debt_open_settlement_quality) {
pushReason(reasonCodes, "pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows");
if (derivedBusinessOverview.debt_open_settlement_quality.age_signal) {
pushReason(reasonCodes, "pilot_derived_business_overview_debt_age_signal_from_contract_dates");
}
}
if (derivedBusinessOverview.inventory_position) {
pushReason(reasonCodes, "pilot_derived_business_overview_inventory_position_from_confirmed_rows");
@@ -361,8 +361,8 @@ describe("assistant MCP discovery answer adapter", () => {
},
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А", Contract: "Договор А" },
{ Period: "2020-12-31T00:00:00", Amount: 50000, Counterparty: "Поставщик А", Contract: "Договор Б" }
{ Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А", Contract: "Договор А от 10.02.2019" },
{ Period: "2020-12-31T00:00:00", Amount: 50000, Counterparty: "Поставщик А", Contract: "Договор Б от 15.03.2020" }
]
},
{ rows: [] },
@@ -380,12 +380,16 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.headline).toContain("долговой срез");
expect(draft.headline).toContain("качество открытых расчетов");
expect(draft.headline).toContain("возрастной сигнал открытых расчетов");
expect(draft.confirmed_lines.join("\n")).toContain("Долговой срез на 2020-12-31");
expect(draft.confirmed_lines.join("\n")).toContain("Качество открытых расчетов на 2020-12-31");
expect(draft.confirmed_lines.join("\n")).toContain("Возрастной сигнал открытых расчетов");
expect(draft.confirmed_lines.join("\n")).toContain("не due-date анализ");
expect(draft.confirmed_lines.join("\n")).toContain("нетто");
expect(draft.unknown_lines.join("\n")).toContain("due-date");
expect(draft.reason_codes).toContain("answer_contains_business_overview_debt_position");
expect(draft.reason_codes).toContain("answer_contains_business_overview_open_settlement_quality");
expect(draft.reason_codes).toContain("answer_contains_business_overview_debt_age_signal");
expect(draft.must_not_claim).toContain("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis.");
expect(draft.must_not_claim).toContain("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt.");
});
@@ -326,8 +326,8 @@ describe("assistant MCP discovery pilot executor", () => {
},
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 90000, Counterparty: "Клиент А", Contract: "Договор А" },
{ Period: "2020-12-31T00:00:00", Amount: 30000, Counterparty: "Клиент Б", Contract: "Договор Б" }
{ Period: "2020-12-31T00:00:00", Amount: 90000, Counterparty: "Клиент А", Contract: "Договор А от 10.02.2019" },
{ Period: "2020-12-31T00:00:00", Amount: 30000, Counterparty: "Клиент Б", Contract: "Договор Б от 15.03.2020" }
]
},
{ rows: [] },
@@ -368,16 +368,31 @@ describe("assistant MCP discovery pilot executor", () => {
unique_contracts: 2,
concentration_top_contract_pct: 75
});
expect(result.derived_business_overview?.debt_open_settlement_quality?.age_signal).toMatchObject({
contracts_with_start_date: 2,
oldest_start_date: "2019-02-10",
latest_start_date: "2020-03-15",
max_age_days: 690
});
expect(result.derived_business_overview?.debt_open_settlement_quality?.age_signal?.top_aged_contracts[0]).toMatchObject({
contract: "Договор А от 10.02.2019",
start_date: "2019-02-10",
age_days: 690,
total_amount: 90000
});
expect(result.derived_business_overview?.missing_signal_families).not.toContain("debt_position");
expect(result.derived_business_overview?.missing_signal_families).not.toContain("debt_open_settlement_quality");
expect(result.derived_business_overview?.missing_signal_families).toContain("debt_due_date_aging_quality");
expect(result.evidence.confirmed_facts.join("\n")).toContain("Долговая позиция на 2020-12-31");
expect(result.evidence.confirmed_facts.join("\n")).toContain("Качество открытых расчетов на 2020-12-31");
expect(result.evidence.confirmed_facts.join("\n")).toContain("Возрастной сигнал открытых расчетов");
expect(result.evidence.confirmed_facts.join("\n")).toContain("не due-date анализ");
expect(result.evidence.unknown_facts.join("\n")).toContain("due-date");
expect(result.reason_codes).toContain("pilot_business_overview_debt_query_mcp_executed");
expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_position_from_confirmed_rows");
expect(result.reason_codes).toContain("pilot_business_overview_open_contracts_query_mcp_executed");
expect(result.reason_codes).toContain("pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows");
expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_age_signal_from_contract_dates");
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(9);
const receivablesCall = deps.executeAddressMcpQuery.mock.calls[3]?.[0];
const payablesCall = deps.executeAddressMcpQuery.mock.calls[4]?.[0];