Open-World: добавить возрастной сигнал открытых расчетов
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user