ДОМЕНЫ - ВОПРОСЫ - ОТКРЫТЫЕ ДОГОВОРА - Усилить бизнес-представление точного среза открытых договоров: нетто/брутто и разрез спорных позиций

This commit is contained in:
dctouch 2026-04-13 18:47:02 +03:00
parent ef4222d159
commit f64980fa13
2 changed files with 261 additions and 21 deletions

View File

@ -792,6 +792,27 @@ interface OpenContractConfirmedAggregate {
qualityFlags: string[]; qualityFlags: string[];
} }
type OpenContractReviewBucket = "special_valid" | "dirty_unresolved";
interface OpenContractNetAggregate {
contract: string;
counterparty: string | null;
category: "commercial" | "financial" | "uncertain";
reviewBucket: OpenContractReviewBucket | null;
netOpenBalance: number;
grossOpenBalance: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
accounts: string[];
sourceRefs: string[];
qualityFlags: string[];
componentAmounts: Array<{
kind: OpenContractSettlementKind;
amount: number;
}>;
}
type PayablesLiabilityCategory = "supplier_or_contractor" | "bank_or_credit" | "tax_or_state" | "other"; type PayablesLiabilityCategory = "supplier_or_contractor" | "bank_or_credit" | "tax_or_state" | "other";
interface PayablesCounterpartyRiskAggregate extends CounterpartyRiskAggregate { interface PayablesCounterpartyRiskAggregate extends CounterpartyRiskAggregate {
@ -1721,6 +1742,61 @@ function openContractSettlementKindLabel(kind: OpenContractSettlementKind): stri
return "прочий кредитовый остаток"; return "прочий кредитовый остаток";
} }
function openContractSettlementKindSign(kind: OpenContractSettlementKind): 1 | -1 {
if (kind === "receivable" || kind === "advance_issued" || kind === "other_receivable") {
return 1;
}
return -1;
}
function classifyOpenContractReviewBucket(item: {
category: "commercial" | "financial" | "uncertain";
qualityFlags: string[];
}): OpenContractReviewBucket | null {
if (item.category === "commercial") {
return null;
}
if (
item.qualityFlags.includes("counterparty_not_reliably_resolved") ||
item.qualityFlags.includes("contract_identity_not_reliable") ||
item.qualityFlags.includes("contract_identity_looks_like_counterparty") ||
item.qualityFlags.includes("multiple_counterparties_for_contract")
) {
return "dirty_unresolved";
}
return "special_valid";
}
function openContractNetBalanceDirectionLabel(amount: number): string {
if (amount > 0.005) {
return "к получению";
}
if (amount < -0.005) {
return "к оплате";
}
return "нетто закрыт";
}
function formatOpenContractComponentsSummary(
components: Array<{
kind: OpenContractSettlementKind;
amount: number;
}>
): string {
const kindOrder: OpenContractSettlementKind[] = [
"receivable",
"payable",
"advance_issued",
"advance_received",
"other_receivable",
"other_payable"
];
const ordered = [...components].sort((left, right) => kindOrder.indexOf(left.kind) - kindOrder.indexOf(right.kind));
return ordered
.map((component) => `${openContractSettlementKindLabel(component.kind)} ${formatMoneyRub(component.amount)}`)
.join("; ");
}
function summarizeOpenContractSpecialReason(item: { category: "commercial" | "financial" | "uncertain"; qualityFlags: string[] }): string { function summarizeOpenContractSpecialReason(item: { category: "commercial" | "financial" | "uncertain"; qualityFlags: string[] }): string {
if (item.category === "financial") { if (item.category === "financial") {
return "похоже на финансовый договор (кредит/банк)"; return "похоже на финансовый договор (кредит/банк)";
@ -1877,6 +1953,120 @@ function buildOpenContractConfirmedBalanceAggregate(
}); });
} }
function buildOpenContractNetAggregate(items: OpenContractConfirmedAggregate[]): OpenContractNetAggregate[] {
const byContract = new Map<
string,
{
contract: string;
counterparty: string | null;
category: "commercial" | "financial" | "uncertain";
netOpenBalance: number;
grossOpenBalance: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
accounts: Set<string>;
sourceRefs: Set<string>;
qualityFlags: Set<string>;
componentAmounts: Map<OpenContractSettlementKind, number>;
}
>();
const categoryPriority = (value: "commercial" | "financial" | "uncertain"): number => {
if (value === "financial") {
return 2;
}
if (value === "uncertain") {
return 1;
}
return 0;
};
for (const item of items) {
const counterpartyKey = item.counterparty ? normalizeEntityToken(item.counterparty) : "__unknown_counterparty__";
const aggregateKey = `${normalizeEntityToken(item.contract)}::${counterpartyKey}`;
const current = byContract.get(aggregateKey);
if (!current) {
byContract.set(aggregateKey, {
contract: item.contract,
counterparty: item.counterparty,
category: item.category,
netOpenBalance: openContractSettlementKindSign(item.settlementKind) * item.confirmedAmount,
grossOpenBalance: item.confirmedAmount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
accounts: new Set(item.accounts),
sourceRefs: new Set(item.sourceRefs),
qualityFlags: new Set(item.qualityFlags),
componentAmounts: new Map([[item.settlementKind, item.confirmedAmount]])
});
continue;
}
if (categoryPriority(item.category) > categoryPriority(current.category)) {
current.category = item.category;
}
current.netOpenBalance += openContractSettlementKindSign(item.settlementKind) * item.confirmedAmount;
current.grossOpenBalance += item.confirmedAmount;
current.operations += item.operations;
if ((item.firstPeriod ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = item.firstPeriod;
}
if ((item.lastPeriod ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = item.lastPeriod;
}
for (const account of item.accounts) {
current.accounts.add(account);
}
for (const ref of item.sourceRefs) {
current.sourceRefs.add(ref);
}
for (const flag of item.qualityFlags) {
current.qualityFlags.add(flag);
}
current.componentAmounts.set(
item.settlementKind,
(current.componentAmounts.get(item.settlementKind) ?? 0) + item.confirmedAmount
);
}
return Array.from(byContract.values())
.map((item) => {
const qualityFlags = Array.from(item.qualityFlags);
return {
contract: item.contract,
counterparty: item.counterparty,
category: item.category,
reviewBucket: classifyOpenContractReviewBucket({
category: item.category,
qualityFlags
}),
netOpenBalance: item.netOpenBalance,
grossOpenBalance: item.grossOpenBalance,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
accounts: Array.from(item.accounts).slice(0, 4),
sourceRefs: Array.from(item.sourceRefs).slice(0, 3),
qualityFlags,
componentAmounts: Array.from(item.componentAmounts.entries())
.map(([kind, amount]) => ({ kind, amount }))
.filter((component) => component.amount > 0.005)
} satisfies OpenContractNetAggregate;
})
.filter((item) => item.grossOpenBalance > 0.005)
.sort((left, right) => {
if (right.grossOpenBalance !== left.grossOpenBalance) {
return right.grossOpenBalance - left.grossOpenBalance;
}
if (Math.abs(right.netOpenBalance) !== Math.abs(left.netOpenBalance)) {
return Math.abs(right.netOpenBalance) - Math.abs(left.netOpenBalance);
}
return left.contract.localeCompare(right.contract);
});
}
function buildOpenContractRiskAggregate(rows: ComposeStageRow[]): OpenContractRiskAggregate[] { function buildOpenContractRiskAggregate(rows: ComposeStageRow[]): OpenContractRiskAggregate[] {
const byContract = new Map< const byContract = new Map<
string, string,
@ -3315,11 +3505,14 @@ export function composeFactualReply(
if (intent === "open_contracts_confirmed_as_of_date") { if (intent === "open_contracts_confirmed_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options); const asOfDate = resolvePayablesAsOfDate(options);
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate); const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
const contractProfiles = buildOpenContractNetAggregate(confirmedContracts);
const periodFrom = normalizeIsoDateOnly(options.periodFrom); const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo); const periodTo = normalizeIsoDateOnly(options.periodTo);
const commercialContracts = confirmedContracts.filter((item) => item.category === "commercial"); const commercialContracts = confirmedContracts.filter((item) => item.category === "commercial");
const specialContracts = confirmedContracts.filter((item) => item.category !== "commercial"); const commercialProfiles = contractProfiles.filter((item) => item.category === "commercial");
const uniqueContracts = uniqueStrings(confirmedContracts.map((item) => item.contract)); const specialProfiles = contractProfiles.filter((item) => item.reviewBucket === "special_valid");
const dirtyProfiles = contractProfiles.filter((item) => item.reviewBucket === "dirty_unresolved");
const uniqueContracts = uniqueStrings(contractProfiles.map((item) => item.contract));
const commercialReceivables = commercialContracts.filter((item) => item.settlementKind === "receivable"); const commercialReceivables = commercialContracts.filter((item) => item.settlementKind === "receivable");
const commercialPayables = commercialContracts.filter((item) => item.settlementKind === "payable"); const commercialPayables = commercialContracts.filter((item) => item.settlementKind === "payable");
const commercialAdvances = commercialContracts.filter( const commercialAdvances = commercialContracts.filter(
@ -3330,12 +3523,34 @@ export function composeFactualReply(
); );
const sumConfirmedAmount = (items: OpenContractConfirmedAggregate[]): number => const sumConfirmedAmount = (items: OpenContractConfirmedAggregate[]): number =>
items.reduce((sum, item) => sum + item.confirmedAmount, 0); items.reduce((sum, item) => sum + item.confirmedAmount, 0);
const commercialTotal = sumConfirmedAmount(commercialContracts); const sumNetAmount = (items: OpenContractNetAggregate[]): number =>
const specialTotal = sumConfirmedAmount(specialContracts); items.reduce((sum, item) => sum + item.netOpenBalance, 0);
const sumGrossAmount = (items: OpenContractNetAggregate[]): number =>
items.reduce((sum, item) => sum + item.grossOpenBalance, 0);
const commercialNetTotal = sumNetAmount(commercialProfiles);
const commercialGrossTotal = sumGrossAmount(commercialProfiles);
const specialTotal = sumGrossAmount(specialProfiles);
const dirtyTotal = sumGrossAmount(dirtyProfiles);
const periodScopeLine = const periodScopeLine =
!options.asOfDate && (periodFrom || periodTo) !options.asOfDate && (periodFrom || periodTo)
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null; : null;
const renderContractProfileLines = (
items: OpenContractNetAggregate[],
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} | чистый остаток: ${openContractNetBalanceDirectionLabel(item.netOpenBalance)} ${formatMoneyRub(Math.abs(item.netOpenBalance))} | брутто компонентов: ${formatMoneyRub(item.grossOpenBalance)} | состав: ${formatOpenContractComponentsSummary(item.componentAmounts)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${accountsLabel}${evidenceLabel}${refsLabel}${specialReasonLabel}`;
});
const renderConfirmedContractLines = ( const renderConfirmedContractLines = (
items: OpenContractConfirmedAggregate[], items: OpenContractConfirmedAggregate[],
includeSpecialReason: boolean includeSpecialReason: boolean
@ -3355,13 +3570,16 @@ export function composeFactualReply(
const lines: string[] = [ const lines: string[] = [
`Собран подтвержденный срез открытых договоров на ${formatDateRu(asOfDate)}.`, `Собран подтвержденный срез открытых договоров на ${formatDateRu(asOfDate)}.`,
`Коммерческие договорные позиции: ${formatNumberWithDots(commercialContracts.length)} на ${formatMoneyRub(commercialTotal)}.`, `Чистый коммерческий остаток: ${openContractNetBalanceDirectionLabel(commercialNetTotal)} ${formatMoneyRub(Math.abs(commercialNetTotal))}.`,
`Финансовые/спорные позиции: ${formatNumberWithDots(specialContracts.length)} на ${formatMoneyRub(specialTotal)}.`, `Брутто коммерческих компонентов: ${formatMoneyRub(commercialGrossTotal)}.`,
`Специальные финансовые позиции: ${formatNumberWithDots(specialProfiles.length)} на ${formatMoneyRub(specialTotal)}.`,
`Спорные/некачественно нормализованные позиции: ${formatNumberWithDots(dirtyProfiles.length)} на ${formatMoneyRub(dirtyTotal)}.`,
"", "",
"Блок 1. Статус результата", "Блок 1. Статус результата",
"- Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.", "- Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.",
"- База ответа: остатки по счетам 60/62/76 с договорной аналитикой, без эвристического shortlist.", "- База ответа: остатки по счетам 60/62/76 с договорной аналитикой, без эвристического shortlist.",
"- Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка." "- Управленческий вид: по каждому договору показаны чистый остаток и состав по типам открытых расчетов.",
"- Базовая единица детализации: одна строка = один договор, один контрагент и один тип открытого остатка."
]; ];
lines.push(""); lines.push("");
@ -3378,7 +3596,10 @@ export function composeFactualReply(
lines.push("Блок 3. Сводка"); lines.push("Блок 3. Сводка");
lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`);
lines.push(`- Уникальных договоров: ${formatNumberWithDots(uniqueContracts.length)}.`); lines.push(`- Уникальных договоров: ${formatNumberWithDots(uniqueContracts.length)}.`);
lines.push(`- Подтвержденных договорных позиций: ${formatNumberWithDots(confirmedContracts.length)}.`); lines.push(`- Подтвержденных договор-контрагент профилей: ${formatNumberWithDots(contractProfiles.length)}.`);
lines.push(`- Подтвержденных договорных компонентов: ${formatNumberWithDots(confirmedContracts.length)}.`);
lines.push(`- Чистый коммерческий остаток: ${openContractNetBalanceDirectionLabel(commercialNetTotal)} ${formatMoneyRub(Math.abs(commercialNetTotal))}.`);
lines.push(`- Брутто коммерческих компонентов: ${formatMoneyRub(commercialGrossTotal)}.`);
lines.push( lines.push(
`- Коммерческая дебиторка: ${formatNumberWithDots(commercialReceivables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialReceivables))}.` `- Коммерческая дебиторка: ${formatNumberWithDots(commercialReceivables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialReceivables))}.`
); );
@ -3391,36 +3612,51 @@ export function composeFactualReply(
lines.push( lines.push(
`- Прочие расчеты по 76: ${formatNumberWithDots(commercialOther.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialOther))}.` `- Прочие расчеты по 76: ${formatNumberWithDots(commercialOther.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialOther))}.`
); );
lines.push(`- Финансовые/спорные позиции: ${formatNumberWithDots(specialContracts.length)} на ${formatMoneyRub(specialTotal)}.`); lines.push(`- Специальные финансовые позиции: ${formatNumberWithDots(specialProfiles.length)} на ${formatMoneyRub(specialTotal)}.`);
lines.push(
`- Спорные/некачественно нормализованные позиции: ${formatNumberWithDots(dirtyProfiles.length)} на ${formatMoneyRub(dirtyTotal)}.`
);
if (commercialProfiles.length > 0) {
lines.push("");
lines.push("Блок 4. Чистый открытый остаток по договорам");
lines.push(...renderContractProfileLines(commercialProfiles, false));
}
if (commercialReceivables.length > 0) { if (commercialReceivables.length > 0) {
lines.push(""); lines.push("");
lines.push("Блок 4. Коммерческие договоры с дебиторской задолженностью"); lines.push("Блок 5. Коммерческие дебиторские компоненты");
lines.push(...renderConfirmedContractLines(commercialReceivables, false)); lines.push(...renderConfirmedContractLines(commercialReceivables, false));
} }
if (commercialPayables.length > 0) { if (commercialPayables.length > 0) {
lines.push(""); lines.push("");
lines.push("Блок 5. Коммерческие договоры с кредиторской задолженностью"); lines.push("Блок 6. Коммерческие кредиторские компоненты");
lines.push(...renderConfirmedContractLines(commercialPayables, false)); lines.push(...renderConfirmedContractLines(commercialPayables, false));
} }
if (commercialAdvances.length > 0) { if (commercialAdvances.length > 0) {
lines.push(""); lines.push("");
lines.push("Блок 6. Коммерческие авансы"); lines.push("Блок 7. Коммерческие авансовые компоненты");
lines.push(...renderConfirmedContractLines(commercialAdvances, false)); lines.push(...renderConfirmedContractLines(commercialAdvances, false));
} }
if (commercialOther.length > 0) { if (commercialOther.length > 0) {
lines.push(""); lines.push("");
lines.push("Блок 7. Прочие расчеты по 76"); lines.push("Блок 8. Прочие компоненты по 76");
lines.push(...renderConfirmedContractLines(commercialOther, false)); lines.push(...renderConfirmedContractLines(commercialOther, false));
} }
if (specialContracts.length > 0) { if (specialProfiles.length > 0) {
lines.push(""); lines.push("");
lines.push("Блок 8. Финансовые/спорные позиции"); lines.push("Блок 9. Финансовые/специальные позиции");
lines.push(...renderConfirmedContractLines(specialContracts, true)); lines.push(...renderContractProfileLines(specialProfiles, true));
}
if (dirtyProfiles.length > 0) {
lines.push("");
lines.push("Блок 10. Спорные/некачественно нормализованные позиции");
lines.push(...renderContractProfileLines(dirtyProfiles, true));
} }
if (confirmedContracts.length === 0) { if (confirmedContracts.length === 0) {

View File

@ -231,8 +231,10 @@ describe("address compose stage utf8 headers", () => {
expect(reply.responseType).toBe("FACTUAL_LIST"); expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Собран подтвержденный срез открытых договоров"); expect(reply.text).toContain("Собран подтвержденный срез открытых договоров");
expect(reply.text).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату."); expect(reply.text).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.");
expect(reply.text).toContain("Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка."); expect(reply.text).toContain("Управленческий вид: по каждому договору показаны чистый остаток и состав по типам открытых расчетов.");
expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью"); expect(reply.text).toContain("Базовая единица детализации: одна строка = один договор, один контрагент и один тип открытого остатка.");
expect(reply.text).toContain("Блок 4. Чистый открытый остаток по договорам");
expect(reply.text).toContain("Блок 6. Коммерческие кредиторские компоненты");
expect(reply.semantics?.result_mode).toBe("confirmed_balance"); expect(reply.semantics?.result_mode).toBe("confirmed_balance");
expect(reply.semantics?.balance_confirmed).toBe(true); expect(reply.semantics?.balance_confirmed).toBe(true);
}); });
@ -274,9 +276,11 @@ describe("address compose stage utf8 headers", () => {
} }
); );
expect(reply.text).toContain("Блок 4. Коммерческие договоры с дебиторской задолженностью"); expect(reply.text).toContain("Блок 4. Чистый открытый остаток по договорам");
expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью"); expect(reply.text).toContain("Блок 5. Коммерческие дебиторские компоненты");
expect(reply.text).toContain("Блок 8. Финансовые/спорные позиции"); expect(reply.text).toContain("Блок 6. Коммерческие кредиторские компоненты");
expect(reply.text).toContain("Блок 10. Спорные/некачественно нормализованные позиции");
expect(reply.text).toContain("брутто компонентов");
expect(reply.text).not.toContain("счета: 62.01; 0"); expect(reply.text).not.toContain("счета: 62.01; 0");
expect(reply.text).toContain("договор не похож на устойчивый договорный реквизит"); expect(reply.text).toContain("договор не похож на устойчивый договорный реквизит");
}); });