ДОМЕНЫ - ВОПРОСЫ - ОТКРЫТЫЕ ДОГОВОРА - Усилить бизнес-представление точного среза открытых договоров: нетто/брутто и разрез спорных позиций
This commit is contained in:
parent
ef4222d159
commit
f64980fa13
|
|
@ -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<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[] {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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("договор не похож на устойчивый договорный реквизит");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue