diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 0b32872..c36a762 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -792,6 +792,27 @@ interface OpenContractConfirmedAggregate { 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"; interface PayablesCounterpartyRiskAggregate extends CounterpartyRiskAggregate { @@ -1721,6 +1742,61 @@ function openContractSettlementKindLabel(kind: OpenContractSettlementKind): stri 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 { if (item.category === "financial") { 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; + sourceRefs: Set; + qualityFlags: Set; + componentAmounts: Map; + } + >(); + + 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[] { const byContract = new Map< string, @@ -3315,11 +3505,14 @@ export function composeFactualReply( if (intent === "open_contracts_confirmed_as_of_date") { const asOfDate = resolvePayablesAsOfDate(options); const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate); + const contractProfiles = buildOpenContractNetAggregate(confirmedContracts); 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 commercialProfiles = contractProfiles.filter((item) => item.category === "commercial"); + 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 commercialPayables = commercialContracts.filter((item) => item.settlementKind === "payable"); const commercialAdvances = commercialContracts.filter( @@ -3330,12 +3523,34 @@ export function composeFactualReply( ); const sumConfirmedAmount = (items: OpenContractConfirmedAggregate[]): number => items.reduce((sum, item) => sum + item.confirmedAmount, 0); - const commercialTotal = sumConfirmedAmount(commercialContracts); - const specialTotal = sumConfirmedAmount(specialContracts); + const sumNetAmount = (items: OpenContractNetAggregate[]): number => + 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 = !options.asOfDate && (periodFrom || periodTo) ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` : 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 = ( items: OpenContractConfirmedAggregate[], includeSpecialReason: boolean @@ -3355,13 +3570,16 @@ export function composeFactualReply( const lines: string[] = [ `Собран подтвержденный срез открытых договоров на ${formatDateRu(asOfDate)}.`, - `Коммерческие договорные позиции: ${formatNumberWithDots(commercialContracts.length)} на ${formatMoneyRub(commercialTotal)}.`, - `Финансовые/спорные позиции: ${formatNumberWithDots(specialContracts.length)} на ${formatMoneyRub(specialTotal)}.`, + `Чистый коммерческий остаток: ${openContractNetBalanceDirectionLabel(commercialNetTotal)} ${formatMoneyRub(Math.abs(commercialNetTotal))}.`, + `Брутто коммерческих компонентов: ${formatMoneyRub(commercialGrossTotal)}.`, + `Специальные финансовые позиции: ${formatNumberWithDots(specialProfiles.length)} на ${formatMoneyRub(specialTotal)}.`, + `Спорные/некачественно нормализованные позиции: ${formatNumberWithDots(dirtyProfiles.length)} на ${formatMoneyRub(dirtyTotal)}.`, "", "Блок 1. Статус результата", "- Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.", "- База ответа: остатки по счетам 60/62/76 с договорной аналитикой, без эвристического shortlist.", - "- Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка." + "- Управленческий вид: по каждому договору показаны чистый остаток и состав по типам открытых расчетов.", + "- Базовая единица детализации: одна строка = один договор, один контрагент и один тип открытого остатка." ]; lines.push(""); @@ -3378,7 +3596,10 @@ export function composeFactualReply( lines.push("Блок 3. Сводка"); lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.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( `- Коммерческая дебиторка: ${formatNumberWithDots(commercialReceivables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialReceivables))}.` ); @@ -3391,36 +3612,51 @@ export function composeFactualReply( lines.push( `- Прочие расчеты по 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) { lines.push(""); - lines.push("Блок 4. Коммерческие договоры с дебиторской задолженностью"); + lines.push("Блок 5. Коммерческие дебиторские компоненты"); lines.push(...renderConfirmedContractLines(commercialReceivables, false)); } if (commercialPayables.length > 0) { lines.push(""); - lines.push("Блок 5. Коммерческие договоры с кредиторской задолженностью"); + lines.push("Блок 6. Коммерческие кредиторские компоненты"); lines.push(...renderConfirmedContractLines(commercialPayables, false)); } if (commercialAdvances.length > 0) { lines.push(""); - lines.push("Блок 6. Коммерческие авансы"); + lines.push("Блок 7. Коммерческие авансовые компоненты"); lines.push(...renderConfirmedContractLines(commercialAdvances, false)); } if (commercialOther.length > 0) { lines.push(""); - lines.push("Блок 7. Прочие расчеты по 76"); + lines.push("Блок 8. Прочие компоненты по 76"); lines.push(...renderConfirmedContractLines(commercialOther, false)); } - if (specialContracts.length > 0) { + if (specialProfiles.length > 0) { lines.push(""); - lines.push("Блок 8. Финансовые/спорные позиции"); - lines.push(...renderConfirmedContractLines(specialContracts, true)); + lines.push("Блок 9. Финансовые/специальные позиции"); + lines.push(...renderContractProfileLines(specialProfiles, true)); + } + + if (dirtyProfiles.length > 0) { + lines.push(""); + lines.push("Блок 10. Спорные/некачественно нормализованные позиции"); + lines.push(...renderContractProfileLines(dirtyProfiles, true)); } if (confirmedContracts.length === 0) { diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index f7dbd12..27ab38a 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -231,8 +231,10 @@ describe("address compose stage utf8 headers", () => { expect(reply.responseType).toBe("FACTUAL_LIST"); expect(reply.text).toContain("Собран подтвержденный срез открытых договоров"); expect(reply.text).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату."); - expect(reply.text).toContain("Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка."); - expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью"); + expect(reply.text).toContain("Управленческий вид: по каждому договору показаны чистый остаток и состав по типам открытых расчетов."); + 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?.balance_confirmed).toBe(true); }); @@ -274,9 +276,11 @@ describe("address compose stage utf8 headers", () => { } ); - expect(reply.text).toContain("Блок 4. Коммерческие договоры с дебиторской задолженностью"); - expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью"); - expect(reply.text).toContain("Блок 8. Финансовые/спорные позиции"); + expect(reply.text).toContain("Блок 4. Чистый открытый остаток по договорам"); + expect(reply.text).toContain("Блок 5. Коммерческие дебиторские компоненты"); + 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).toContain("договор не похож на устойчивый договорный реквизит"); });