ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить selected-object follow-up по складу и включить разговорные/UI-варианты в обязательный domain-loop

This commit is contained in:
2026-04-14 14:18:38 +03:00
parent 9048632d3e
commit d41819eabd
67 changed files with 3800 additions and 195 deletions
@@ -910,6 +910,165 @@ function buildInventoryOnHandAggregate(rows: ComposeStageRow[], asOfDate: string
});
}
function inventoryTraceDateLabel(value: string | null): string {
return value ? formatDateRu(value) : "дата не указана";
}
function hasInventoryAccountPrefix(value: string | null | undefined, prefix: string): boolean {
const normalized = String(value ?? "")
.trim()
.replace(",", ".");
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
}
function isInventoryPurchaseMovement(row: ComposeStageRow): boolean {
return hasInventoryAccountPrefix(row.account_dt, "41.01");
}
function isInventorySaleMovement(row: ComposeStageRow): boolean {
return hasInventoryAccountPrefix(row.account_kt, "41.01");
}
function looksLikeInventoryTraceDocumentToken(value: string): boolean {
const normalized = String(value ?? "").trim();
if (!normalized) {
return false;
}
return (
/(?:№|contract|invoice|payment|order|накладн|акт|счет|сч[её]т|поступлен|реализац|договор)/iu.test(normalized) ||
/(?:[a-zа-яё].*\d|\d.*[a-zа-яё])/iu.test(normalized)
);
}
function looksLikeInventoryPartyToken(value: string): boolean {
const normalized = String(value ?? "").trim();
if (!normalized || normalized.length < 3) {
return false;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
return false;
}
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
return false;
}
if (/(?:склад|warehouse)/iu.test(normalized)) {
return false;
}
if (looksLikeInventoryTraceDocumentToken(normalized)) {
return false;
}
if (
/(?:ооо|ао|пао|зао|ип|llc|ltd|inc|corp|компани|организац|департамент|комитет|министерств|служб|управлен|торговый\s+дом)/iu.test(
normalized
)
) {
return true;
}
const letterChars = (normalized.match(/[A-Za-zА-Яа-яЁё]/g) ?? []).length;
if (letterChars < 3) {
return false;
}
const words = normalized.split(/\s+/u).filter(Boolean);
if (words.length >= 2) {
return true;
}
return normalized === normalized.toUpperCase() && normalized.length >= 4;
}
function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[] {
const itemToken = normalizeEntityToken(extractInventoryItemName(row));
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
const candidates: string[] = [];
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized || !looksLikeInventoryPartyToken(normalized)) {
continue;
}
const comparable = normalizeEntityToken(normalized);
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) {
continue;
}
candidates.push(normalized);
}
return uniqueStrings(candidates);
}
interface InventoryTraceSummary {
item: string | null;
warehouses: string[];
organizations: string[];
counterparties: string[];
documents: string[];
firstPeriod: string | null;
lastPeriod: string | null;
totalAmount: number;
}
function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSummary {
const items = uniqueStrings(
rows
.map((row) => extractInventoryItemName(row))
.filter((item): item is string => Boolean(item))
);
const warehouses = uniqueStrings(
rows
.map((row) => extractInventoryWarehouseName(row))
.filter((item): item is string => Boolean(item))
);
const organizations = uniqueStrings(
rows
.map((row) => extractInventoryOrganizationName(row))
.filter((item): item is string => Boolean(item))
);
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row)));
const documents = uniqueStrings(
rows
.map((row) => String(row.registrator ?? "").trim())
.filter((item) => item.length > 0 && item !== "(без названия)")
);
const periods = rows
.map((row) => String(row.period ?? "").trim())
.filter((item) => item.length > 0)
.sort((left, right) => left.localeCompare(right, "ru"));
const totalAmount = rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
return {
item: items[0] ?? null,
warehouses,
organizations,
counterparties,
documents,
firstPeriod: periods[0] ?? null,
lastPeriod: periods.length > 0 ? periods[periods.length - 1] : null,
totalAmount
};
}
function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[] {
return rows.slice(0, limit).map((row, index) => {
const parties = extractInventoryCounterpartyCandidates(row);
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const amount =
typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
const parts = [
`${index + 1}. ${row.registrator}`,
`дата: ${inventoryTraceDateLabel(row.period)}`,
`сумма: ${amount}`
];
if (warehouse) {
parts.push(`склад: ${warehouse}`);
}
if (organization) {
parts.push(`организация: ${organization}`);
}
if (parties.length > 0) {
parts.push(`контрагент: ${parties[0]}`);
}
return parts.join(" | ");
});
}
interface CounterpartyRiskAggregate {
name: string;
totalAmount: number;
@@ -3719,6 +3878,265 @@ export function composeFactualReply(
};
}
if (intent === "inventory_purchase_documents_for_item") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const lines: string[] = [
`Собран подтвержденный список документов поступления по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденные движения поступления товара на 41.01 по доступным бухгалтерским проводкам.",
"",
"Блок 2. Что учтено",
`- Дата верхней границы: ${formatDateRu(asOfDate)}.`,
"- Контур: движения, где товар поступает на счет 41.01.",
`- Документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (summary.counterparties.length > 0) {
lines.push(`- Найденные контрагенты в закупочных движениях: ${summary.counterparties.slice(0, 3).join("; ")}.`);
}
lines.push("", "Блок 3. Документы");
if (purchaseRows.length > 0) {
lines.push(...formatInventoryTraceRows(purchaseRows, 12));
} else {
lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре.");
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
if (intent === "inventory_purchase_provenance_for_item") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const lines: string[] = [
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: показаны подтвержденные закупочные движения на 41.01 по выбранному товару.",
"- Важно: без партионности этот контур не подменяет собой лот-level доказательство происхождения текущего остатка.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (summary.counterparties.length === 1) {
lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
} else if (purchaseRows.length > 0) {
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
}
if (summary.documents.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength:
purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const unresolvedRows = purchaseRows.filter((row) => extractInventoryCounterpartyCandidates(row).length === 0);
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
const lines: string[] = [
`Собран exact-срез supplier overlap для складского остатка до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
`- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`,
"- Важно: без партионности этот контур показывает документально наблюдаемые supplier candidates, но не подменяет собой лот-level атрибуцию текущего остатка.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (summary.counterparties.length > 0) {
lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`);
} else if (purchaseRows.length > 0) {
lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
} else {
lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза.");
}
if (unresolvedRows.length > 0) {
lines.push(`- Операций без явно материализованного поставщика: ${formatNumberWithDots(unresolvedRows.length)}.`);
}
if (purchaseRows.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 10));
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
if (intent === "inventory_aging_by_purchase_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const firstPeriodTime = summary.firstPeriod ? Date.parse(summary.firstPeriod) : Number.NaN;
const asOfTime = Date.parse(`${asOfDate}T23:59:59.000Z`);
const ageDays =
Number.isFinite(firstPeriodTime) && Number.isFinite(asOfTime) && firstPeriodTime <= asOfTime
? Math.floor((asOfTime - firstPeriodTime) / 86_400_000)
: null;
const itemLabel = summary.item ?? "выбранному складскому остатку";
const lines: string[] = [
`Собран exact-срез возраста закупочного следа по ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Контур: показаны подтвержденные закупочные движения на 41.01 и их временной разброс.",
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый диапазон закупок.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (ageDays !== null) {
lines.push(`- Между самой ранней найденной закупкой и датой среза прошло ${formatNumberWithDots(ageDays)} дн.`);
}
if (summary.counterparties.length > 0) {
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (purchaseRows.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
} else {
lines.push("", "Блок 3. Опорные документы", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
if (intent === "inventory_sale_trace_for_item") {
const asOfDate = resolvePayablesAsOfDate(options);
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const summary = summarizeInventoryTraceRows(saleRows);
const itemLabel = summary.item ?? "товар не определен";
const lines: string[] = [
`Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: показаны подтвержденные движения выбытия товара со счета 41.01.",
"",
"Блок 2. Сводка",
`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`
];
if (summary.counterparties.length === 1) {
lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`);
} else if (saleRows.length > 0) {
lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре.");
}
lines.push("", "Блок 3. Документы выбытия");
if (saleRows.length > 0) {
lines.push(...formatInventoryTraceRows(saleRows, 12));
} else {
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
}
return {
responseType: saleRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: saleRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
balance_confirmed: saleRows.length > 0
}
};
}
if (intent === "inventory_purchase_to_sale_chain") {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const purchaseSummary = summarizeInventoryTraceRows(purchaseRows);
const saleSummary = summarizeInventoryTraceRows(saleRows);
const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен";
const lines: string[] = [
`Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`,
`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`
];
if (purchaseRows.length > 0 && saleRows.length > 0) {
lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие.");
} else if (purchaseRows.length > 0) {
lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено.");
} else if (saleRows.length > 0) {
lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена.");
} else {
lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку.");
}
if (purchaseRows.length > 0) {
lines.push(
"",
"Блок 2. Закупка",
`- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`,
`- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`,
...formatInventoryTraceRows(purchaseRows, 6)
);
}
if (saleRows.length > 0) {
lines.push(
"",
"Блок 3. Выбытие",
`- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`,
`- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`,
...formatInventoryTraceRows(saleRows, 6)
);
}
return {
responseType: purchaseRows.length > 0 || saleRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 && saleRows.length > 0 ? "strong" : purchaseRows.length > 0 || saleRows.length > 0 ? "medium" : "weak",
balance_confirmed: purchaseRows.length > 0 || saleRows.length > 0
}
};
}
if (intent === "open_contracts_confirmed_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
@@ -490,6 +490,12 @@ function mergeFollowupFilters(
intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
@@ -524,6 +530,21 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context");
}
}
if (
!sameDateRequested &&
(intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
if (
!sameDateRequested &&
hasFollowupSignalForConfirmed &&
@@ -572,6 +593,12 @@ function mergeFollowupFilters(
intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date";