ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Протянуть exact capability складских остатков товаров на дату

This commit is contained in:
2026-04-13 20:40:24 +03:00
parent c2ac0c610b
commit 2b48229312
27 changed files with 1781 additions and 95 deletions
@@ -565,6 +565,130 @@ function extractCounterpartyName(row) {
}
return null;
}
function extractInventoryItemName(row) {
const direct = String(row.item ?? "").trim();
if (direct) {
return direct;
}
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
continue;
}
if (/(?:склад|warehouse|ооо|ао|пао|зао|ип|организац)/iu.test(normalized)) {
continue;
}
return normalized;
}
return null;
}
function extractInventoryWarehouseName(row) {
const direct = String(row.warehouse ?? "").trim();
if (direct) {
return direct;
}
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (/(?:склад|warehouse)/iu.test(normalized)) {
return normalized;
}
}
return null;
}
function extractInventoryOrganizationName(row) {
const direct = String(row.organization ?? "").trim();
if (direct) {
return direct;
}
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»„“()\\\/.,;:]))|организац|комитет|департамент|министерств|служб|управлени|казенн|администрац/iu.test(normalized)) {
return normalized;
}
}
return null;
}
function extractInventoryQuantity(row) {
return typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : null;
}
function buildInventoryOnHandAggregate(rows, asOfDate) {
const byPosition = new Map();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
for (const row of rows) {
const item = extractInventoryItemName(row);
if (!item) {
continue;
}
const rowTimestamp = toUtcDayTimestamp(row.period);
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
continue;
}
const quantity = extractInventoryQuantity(row);
if (quantity === null || quantity <= 0) {
continue;
}
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0;
const key = [normalizeEntityToken(item), normalizeEntityToken(warehouse), normalizeEntityToken(organization)].join("|");
const registrator = String(row.registrator ?? "").trim();
const current = byPosition.get(key);
if (!current) {
byPosition.set(key, {
item,
warehouse,
organization,
quantity,
amount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
sourceRefs: new Set(registrator && registrator !== "Остатки на дату" ? [registrator] : [])
});
continue;
}
current.quantity += quantity;
current.amount += amount;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (registrator && registrator !== "Остатки на дату") {
current.sourceRefs.add(registrator);
}
}
return Array.from(byPosition.values())
.map((item) => ({
item: item.item,
warehouse: item.warehouse,
organization: item.organization,
quantity: item.quantity,
amount: item.amount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
sourceRefs: Array.from(item.sourceRefs).slice(0, 3)
}))
.filter((item) => item.quantity > 0)
.sort((left, right) => {
if (right.quantity !== left.quantity) {
return right.quantity - left.quantity;
}
if (right.amount !== left.amount) {
return right.amount - left.amount;
}
return left.item.localeCompare(right.item, "ru");
});
}
function liabilityCategoryLabel(category) {
if (category === "supplier_or_contractor") {
return "поставщики/подрядчики";
@@ -1212,6 +1336,455 @@ function contractCandidatesFromRows(rows) {
}
return uniqueStrings(candidates);
}
function isFinancialContractLike(value) {
return /(?:кредит|кред\.?|loan|overdraft|овердрафт|лизинг|leasing|займ|guarantee|гарант|банк|bank)/iu.test(value);
}
function hasStrongContractIdentitySignal(value) {
return /(?:договор|contract|дог\.|№|\d{1,4}[\\/.-]\d{1,4}|\d{1,4}\sот\s\d{2}\.\d{2}\.\d{2,4}|[A-ZА-Я]{1,6}-\d+)/iu.test(value);
}
function isLikelyOrganizationName(value) {
return /(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»„“()\\\/.,;:]))|комитет|департамент|министерств|служб|управлени|казенн|администрац|bank|банк/iu.test(value);
}
function isContractLikeCounterparty(value) {
return /(?:договор|дог[-.\s]?р|contract|кредитн|loan|овердрафт|лизинг|\b№\b)/iu.test(value);
}
function isLowQualityContractIdentity(contract, counterparty) {
const normalizedContract = normalizeEntityToken(contract);
if (!normalizedContract || normalizedContract.length < 3) {
return true;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(contract.trim())) {
return true;
}
if (counterparty && normalizeEntityToken(counterparty) === normalizedContract) {
return true;
}
if (!hasStrongContractIdentitySignal(contract) && isLikelyOrganizationName(contract)) {
return true;
}
if (!hasStrongContractIdentitySignal(contract) && /^[A-ZА-Я]{2,6}$/u.test(contract.trim())) {
return true;
}
return false;
}
function isLowQualityCounterpartyForContract(counterparty, contract) {
if (!counterparty) {
return true;
}
const normalizedCounterparty = normalizeEntityToken(counterparty);
const normalizedContract = normalizeEntityToken(contract);
if (!normalizedCounterparty) {
return true;
}
if (normalizedCounterparty === normalizedContract) {
return true;
}
if (isContractLikeCounterparty(counterparty)) {
return true;
}
return normalizedCounterparty.length < 3;
}
function normalizeDisplayAccountToken(value) {
const normalized = String(value ?? "").trim();
if (!normalized || /^(?:0|<пусто>|пустая ссылка|-)$/iu.test(normalized)) {
return null;
}
return normalized;
}
function classifyOpenContractCategory(contract, counterparties, qualityFlags) {
if (isFinancialContractLike(contract)) {
return "financial";
}
if (counterparties.some((item) => isFinancialContractLike(item))) {
return "financial";
}
if (qualityFlags.includes("counterparty_not_reliably_resolved") ||
qualityFlags.includes("contract_identity_not_reliable") ||
qualityFlags.includes("contract_identity_looks_like_counterparty") ||
qualityFlags.includes("multiple_counterparties_for_contract")) {
return "uncertain";
}
if (counterparties.length === 0) {
return "uncertain";
}
return "commercial";
}
function classifyOpenContractSettlementKind(row) {
const dt = extractAccountSectionCode(row.account_dt);
const kt = extractAccountSectionCode(row.account_kt);
if (dt === "62") {
return "receivable";
}
if (kt === "60") {
return "payable";
}
if (dt === "60") {
return "advance_issued";
}
if (kt === "62") {
return "advance_received";
}
if (dt === "76") {
return "other_receivable";
}
if (kt === "76") {
return "other_payable";
}
return null;
}
function openContractSettlementKindLabel(kind) {
if (kind === "receivable") {
return "дебиторская задолженность";
}
if (kind === "payable") {
return "кредиторская задолженность";
}
if (kind === "advance_issued") {
return "аванс выданный";
}
if (kind === "advance_received") {
return "аванс полученный";
}
if (kind === "other_receivable") {
return "прочий дебетовый остаток";
}
return "прочий кредитовый остаток";
}
function openContractSettlementKindSign(kind) {
if (kind === "receivable" || kind === "advance_issued" || kind === "other_receivable") {
return 1;
}
return -1;
}
function classifyOpenContractReviewBucket(item) {
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) {
if (amount > 0.005) {
return "к получению";
}
if (amount < -0.005) {
return "к оплате";
}
return "нетто закрыт";
}
function formatOpenContractComponentsSummary(components) {
const kindOrder = [
"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) {
if (item.category === "financial") {
return "похоже на финансовый договор (кредит/банк)";
}
if (item.qualityFlags.includes("contract_identity_looks_like_counterparty")) {
return "в поле договора похоже попал контрагент или чужая аналитика";
}
if (item.qualityFlags.includes("contract_identity_not_reliable")) {
return "договор не похож на устойчивый договорный реквизит";
}
if (item.qualityFlags.includes("multiple_counterparties_for_contract")) {
return "по одному договору найдено несколько контрагентов";
}
if (item.qualityFlags.includes("counterparty_not_reliably_resolved")) {
return "не удалось надежно определить контрагента";
}
return "требуется ручная проверка карточки договора";
}
function buildOpenContractConfirmedBalanceAggregate(rows, asOfDate) {
const byContract = new Map();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
for (const row of rows) {
const rowTimestamp = toUtcDayTimestamp(row.period);
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
continue;
}
const contract = extractContractName(row);
if (!contract) {
continue;
}
const amount = row.amount;
if (typeof amount !== "number" || !Number.isFinite(amount)) {
continue;
}
const settlementKind = classifyOpenContractSettlementKind(row);
if (!settlementKind) {
continue;
}
const counterpartyCandidate = extractCounterpartyName(row);
const counterparty = isLowQualityCounterpartyForContract(counterpartyCandidate, contract) ? null : counterpartyCandidate;
const sourceRefs = extractPayablesSourceRefs(row, counterparty ?? contract, contract);
const accountToken = normalizeDisplayAccountToken(row.account_dt) ?? normalizeDisplayAccountToken(row.account_kt);
const absAmount = Math.abs(amount);
const contractKey = normalizeEntityToken(contract);
const counterpartyKey = counterparty ? normalizeEntityToken(counterparty) : "__unknown_counterparty__";
const aggregateKey = `${contractKey}::${counterpartyKey}::${settlementKind}`;
const current = byContract.get(aggregateKey);
if (!current) {
const qualityFlags = new Set();
if (!counterparty) {
qualityFlags.add("counterparty_not_reliably_resolved");
}
if (isLowQualityContractIdentity(contract, counterparty)) {
qualityFlags.add("contract_identity_not_reliable");
}
if (counterparty && normalizeEntityToken(counterparty) === normalizeEntityToken(contract)) {
qualityFlags.add("contract_identity_looks_like_counterparty");
}
const counterparties = new Set();
if (counterparty) {
counterparties.add(counterparty);
}
const accounts = new Set();
if (accountToken) {
accounts.add(accountToken);
}
byContract.set(aggregateKey, {
contract,
counterparty,
confirmedAmount: absAmount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
counterparties,
settlementKind,
accounts,
sourceRefs: new Set(sourceRefs),
qualityFlags
});
continue;
}
current.confirmedAmount += absAmount;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (counterparty) {
current.counterparty = current.counterparty ?? counterparty;
current.counterparties.add(counterparty);
}
else {
current.qualityFlags.add("counterparty_not_reliably_resolved");
}
if (accountToken) {
current.accounts.add(accountToken);
}
for (const ref of sourceRefs) {
current.sourceRefs.add(ref);
}
}
return Array.from(byContract.values())
.map((item) => {
const counterparties = Array.from(item.counterparties);
if (counterparties.length > 1) {
item.qualityFlags.add("multiple_counterparties_for_contract");
}
return {
contract: item.contract,
counterparty: item.counterparty,
confirmedAmount: item.confirmedAmount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
category: classifyOpenContractCategory(item.contract, counterparties, Array.from(item.qualityFlags)),
settlementKind: item.settlementKind,
accounts: Array.from(item.accounts).slice(0, 3),
sourceRefs: Array.from(item.sourceRefs).slice(0, 3),
qualityFlags: Array.from(item.qualityFlags)
};
})
.filter((item) => item.confirmedAmount > 0.005)
.sort((left, right) => {
if (right.confirmedAmount !== left.confirmedAmount) {
return right.confirmedAmount - left.confirmedAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.contract.localeCompare(right.contract);
});
}
function buildOpenContractNetAggregate(items) {
const byContract = new Map();
const categoryPriority = (value) => {
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)
};
})
.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) {
const byContract = new Map();
for (const row of rows) {
const contract = extractContractName(row);
if (!contract) {
continue;
}
const amountRaw = row.amount ?? 0;
const amount = Number.isFinite(amountRaw) ? Math.abs(amountRaw) : 0;
const current = byContract.get(contract);
const counterpartyCandidate = extractCounterpartyName(row);
const counterparty = isLowQualityCounterpartyForContract(counterpartyCandidate, contract) ? null : counterpartyCandidate;
const sourceRefs = extractPayablesSourceRefs(row, counterparty ?? contract, contract);
if (!current) {
const qualityFlags = new Set();
if (!counterparty) {
qualityFlags.add("counterparty_not_reliably_resolved");
}
byContract.set(contract, {
contract,
totalAmount: amount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
counterparties: new Set(counterparty ? [counterparty] : []),
sourceRefs: new Set(sourceRefs),
qualityFlags
});
continue;
}
current.totalAmount += amount;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (counterparty) {
current.counterparties.add(counterparty);
}
else {
current.qualityFlags.add("counterparty_not_reliably_resolved");
}
for (const ref of sourceRefs) {
current.sourceRefs.add(ref);
}
}
return Array.from(byContract.values())
.map((item) => ({
contract: item.contract,
totalAmount: item.totalAmount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
counterparties: Array.from(item.counterparties).slice(0, 2),
sourceRefs: Array.from(item.sourceRefs).slice(0, 3),
category: classifyOpenContractCategory(item.contract, Array.from(item.counterparties), Array.from(item.qualityFlags)),
qualityFlags: Array.from(item.qualityFlags)
}))
.sort((left, right) => {
if (right.totalAmount !== left.totalAmount) {
return right.totalAmount - left.totalAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.contract.localeCompare(right.contract);
});
}
function composeFactualReply(intent, rows, options = {}) {
const applyNumericEmphasis = (line) => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line);
const joinLines = (lines) => lines.map(applyNumericEmphasis).join("\n");
@@ -2250,31 +2823,259 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n")
};
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const counterparties = buildCounterpartyRiskAggregate(rows);
if (intent === "inventory_on_hand_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const positions = buildInventoryOnHandAggregate(rows, asOfDate);
const uniqueItems = uniqueStrings(positions.map((item) => item.item));
const uniqueWarehouses = uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0));
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const lines = [
"Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).",
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
`Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный список товарных остатков на дату.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счету 41.01 «Товары на складах».",
"- Базовая единица детализации: одна строка = товар, склад и организация на дату.",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`,
`- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`,
`- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`,
`- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`,
`- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`,
"",
"Блок 4. Подтвержденные позиции"
];
if (contracts.length > 0) {
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
}
else if (counterparties.length > 0) {
lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`);
lines.push(...counterparties
.slice(0, 8)
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска.");
if (positions.length > 0) {
lines.push(...positions.slice(0, 20).map((item, index) => {
const warehouseLabel = item.warehouse ?? "склад не определен";
const organizationLabel = item.organization ? ` | организация: ${item.organization}` : "";
const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : "";
const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${formatNumberWithDots(item.quantity, 3)} | стоимость: ${formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`;
}));
}
else {
lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback.");
lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
}
return {
responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: positions.length > 0 ? "strong" : "medium",
balance_confirmed: true
}
};
}
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 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((item) => item.settlementKind === "advance_issued" || item.settlementKind === "advance_received");
const commercialOther = commercialContracts.filter((item) => item.settlementKind === "other_receivable" || item.settlementKind === "other_payable");
const sumConfirmedAmount = (items) => items.reduce((sum, item) => sum + item.confirmedAmount, 0);
const sumNetAmount = (items) => items.reduce((sum, item) => sum + item.netOpenBalance, 0);
const sumGrossAmount = (items) => 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, includeSpecialReason) => 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, includeSpecialReason) => 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} | подтвержденный открытый остаток: ${formatMoneyRub(item.confirmedAmount)} | тип остатка: ${openContractSettlementKindLabel(item.settlementKind)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${accountsLabel}${evidenceLabel}${refsLabel}${specialReasonLabel}`;
});
const lines = [
`Собран подтвержденный срез открытых договоров на ${formatDateRu(asOfDate)}.`,
`Чистый коммерческий остаток: ${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("");
lines.push("Блок 2. Что учтено");
lines.push(`- Дата среза: ${formatDateRu(asOfDate)}.`);
if (periodScopeLine) {
lines.push(periodScopeLine);
}
lines.push("- Дефолтная бизнес-дефиниция: открыт договор, по которому на дату есть ненулевой остаток взаиморасчетов.");
lines.push("- Контур: остатки по счетам 60/62/76.");
lines.push("- Смешанные экономические смыслы не склеиваются: дебиторка, кредиторка, авансы и прочие остатки показаны раздельно.");
lines.push("");
lines.push("Блок 3. Сводка");
lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`);
lines.push(`- Уникальных договоров: ${formatNumberWithDots(uniqueContracts.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))}.`);
lines.push(`- Коммерческая кредиторка: ${formatNumberWithDots(commercialPayables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialPayables))}.`);
lines.push(`- Коммерческие авансы: ${formatNumberWithDots(commercialAdvances.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialAdvances))}.`);
lines.push(`- Прочие расчеты по 76: ${formatNumberWithDots(commercialOther.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialOther))}.`);
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("Блок 5. Коммерческие дебиторские компоненты");
lines.push(...renderConfirmedContractLines(commercialReceivables, false));
}
if (commercialPayables.length > 0) {
lines.push("");
lines.push("Блок 6. Коммерческие кредиторские компоненты");
lines.push(...renderConfirmedContractLines(commercialPayables, false));
}
if (commercialAdvances.length > 0) {
lines.push("");
lines.push("Блок 7. Коммерческие авансовые компоненты");
lines.push(...renderConfirmedContractLines(commercialAdvances, false));
}
if (commercialOther.length > 0) {
lines.push("");
lines.push("Блок 8. Прочие компоненты по 76");
lines.push(...renderConfirmedContractLines(commercialOther, false));
}
if (specialProfiles.length > 0) {
lines.push("");
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) {
lines.push("");
lines.push("Блок 4. Подтвержденные позиции");
lines.push("- На дату среза подтвержденные договоры с открытыми взаиморасчетами не найдены.");
}
return {
responseType: "FACTUAL_LIST",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
if (intent === "list_open_contracts") {
const contracts = buildOpenContractRiskAggregate(rows);
const counterparties = buildCounterpartyRiskAggregate(rows);
const asOfDate = normalizeIsoDateOnly(options.asOfDate ?? options.periodTo ?? options.periodFrom);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const commercialContracts = contracts.filter((item) => item.category === "commercial");
const specialContracts = contracts.filter((item) => item.category !== "commercial");
const commercialTotal = commercialContracts.reduce((sum, item) => sum + item.totalAmount, 0);
const lines = [
`Итого по предварительному срезу открытых договоров${asOfDate ? ` на ${formatDateRu(asOfDate)}` : ""}: ${formatNumberWithDots(commercialContracts.length)} коммерческих договоров на ${formatMoneyRub(commercialTotal)}.`,
"",
"Блок 1. Статус результата",
"- Результат: предварительный список договоров с возможными незакрытыми расчетами.",
"- Перед финансовым решением нужна сверка карточек договоров и взаиморасчетов в 1С.",
"",
"Блок 2. Что учтено",
...(asOfDate
? [`- Дата среза: ${formatDateRu(asOfDate)}.`]
: periodFrom || periodTo
? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`]
: []),
"- Контур: движения по счетам 60/62/76 и договорная аналитика.",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Договоров-кандидатов всего: ${formatNumberWithDots(contracts.length)}.`,
`- Основной список (коммерческие): ${formatNumberWithDots(commercialContracts.length)}.`,
`- Вынесено в финансовые/спорные: ${formatNumberWithDots(specialContracts.length)}.`
];
if (commercialContracts.length > 0) {
lines.push("");
lines.push("Блок 4. Основной список (коммерческие договоры)");
lines.push(...commercialContracts.slice(0, 10).map((item, index) => {
const counterpartiesLabel = item.counterparties.length > 0 ? item.counterparties.join("; ") : "контрагент не определен";
const sourceRefsSuffix = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${item.contract} | контрагент: ${counterpartiesLabel} | сумма возможного открытого остатка: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | почему в списке: есть признаки незакрытых расчетов на дату${sourceRefsSuffix}`;
}));
if (specialContracts.length > 0) {
lines.push("");
lines.push("Блок 5. Финансовые/спорные позиции (вынесены отдельно)");
lines.push(...specialContracts.slice(0, 8).map((item, index) => {
const counterpartiesLabel = item.counterparties.length > 0 ? item.counterparties.join("; ") : "контрагент не определен";
const sourceRefsSuffix = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${item.contract} | контрагент: ${counterpartiesLabel} | сумма сигнала: ${formatMoneyRub(item.totalAmount)} | причина вынесения: ${summarizeOpenContractSpecialReason(item)}${sourceRefsSuffix}`;
}));
}
}
else if (counterparties.length > 0) {
lines.push("");
lines.push("Блок 4. Контрагенты с сигналом незакрытых расчетов");
lines.push(`- Контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`);
lines.push(...counterparties
.slice(0, 8)
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
lines.push("- Договорные реквизиты выделены недостаточно надежно, поэтому показан контрагентный список для проверки.");
}
else {
lines.push("");
lines.push("Блок 4. Позиции не выделены");
lines.push("- По текущему live-срезу не удалось выделить договоры с достаточным качеством идентификации.");
lines.push("Блок 5. Примеры исходных строк");
lines.push(...formatTopRows(rows, 6));
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
text: joinLines(lines),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: contracts.length > 0 || counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}
if (intent === "payables_confirmed_as_of_date") {
@@ -22,7 +22,7 @@ function hasAllTimeHint(text) {
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(normalized);
}
function hasSameDateHint(text) {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? ""));
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? ""));
}
function hasExplicitPeriodLiteral(text) {
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? ""));
@@ -286,6 +286,20 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const merged = { ...current };
const reasons = [];
if (!followupContext) {
if ((intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date") &&
!toNonEmptyString(merged.as_of_date)) {
const periodToForOpenContracts = toNonEmptyString(merged.period_to);
const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
if (derivedAsOfDate) {
merged.as_of_date = derivedAsOfDate;
reasons.push(intent === "inventory_on_hand_as_of_date"
? "as_of_date_derived_from_period_for_inventory"
: "as_of_date_derived_from_period_for_open_contracts");
}
}
return { filters: merged, reasons };
}
const previous = followupContext.previous_filters ?? {};
@@ -374,6 +388,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
if (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") {
@@ -442,6 +458,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage);
const asOfPrimaryIntent = intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date";
@@ -474,12 +492,28 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
reasons.push("period_from_followup_context");
}
if ((intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date") &&
!toNonEmptyString(merged.as_of_date)) {
const periodToForOpenContracts = toNonEmptyString(merged.period_to);
const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
if (derivedAsOfDate) {
merged.as_of_date = derivedAsOfDate;
reasons.push(intent === "inventory_on_hand_as_of_date"
? "as_of_date_derived_from_period_for_inventory"
: "as_of_date_derived_from_period_for_open_contracts");
}
}
return { filters: merged, reasons };
}
function resolveMissingRequiredFilters(intent, filters) {
const requiredByIntent = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
inventory_on_hand_as_of_date: ["as_of_date"],
open_contracts_confirmed_as_of_date: ["as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
receivables_confirmed_as_of_date: ["as_of_date"],
vat_payable_confirmed_as_of_date: ["as_of_date"],
@@ -527,7 +561,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
reasons: [...detectedIntent.reasons, "intent_adjusted_to_vat_followup_context"]
};
}
if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
return {
intent: "open_items_by_counterparty_or_contract",
confidence: "low",
@@ -93,6 +93,7 @@ function inferAggregationProfile(intent, shape) {
}
if (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date" ||