АРЧ АП11 - Оркестрация: дожать агентный прогон по selected-object continuity и сохранить accepted

This commit is contained in:
2026-04-17 18:24:02 +03:00
parent c2bafcff9b
commit 7f42d8ab50
22 changed files with 1775 additions and 816 deletions
@@ -1071,6 +1071,20 @@ export function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean
.map((token) => token.trim())
.filter(Boolean)
.filter((token) => !lowQualityTokens.has(token));
if (
/^(?:(?:\u043a\u043e\u0433\u0434\u0430|when)\s+)?(?:(?:\u0431\u044b\u043b(?:\u0430|\u0438|\u043e)?|was|were)\s+)?(?:\u0437\u0430\u043a\u0443\u043f[\p{L}\p{N}_-]*|purchase\s+date|purchase)$/iu.test(
value
)
) {
return true;
}
if (
/^(?:(?:\u043a\u0442\u043e|who)\s+)?(?:(?:\u043d\u0430\u043c\s+)?(?:\u043f\u043e\u0441\u0442\u0430\u0432[\p{L}\p{N}_-]*|supplier)|supplier)$/iu.test(
value
)
) {
return true;
}
return meaningfulTokens.length === 0;
}
@@ -861,6 +861,12 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
}
function hasCounterpartyShipmentItemFlowSignal(text: string): boolean {
const hasSelectedObjectInventoryCue = /(?:по\s+этой\s+позици(?:и|я|ю)|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|selected\s+object|по\s+выбранному\s+объекту)/iu.test(
text
);
if (hasSelectedObjectInventoryCue) {
return false;
}
const hasNamedTailAfterShipmentCue =
/(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+[a-zа-яё][a-zа-яё0-9._-]{2,}/iu.test(
text
@@ -1705,10 +1711,13 @@ function hasInventoryProvenanceSignalV2(text: string): boolean {
}
function hasInventoryPurchaseDateSignal(text: string): boolean {
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(
text
);
const hasItemCue =
/(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text);
const hasPurchaseDateCue =
/(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(
text
) ||
/(?:когда\s+был(?:а|и|о)?\s+закупк\w*|когда\s+закупк\w*)/iu.test(text);
return hasItemCue && hasPurchaseDateCue;
}
@@ -13,6 +13,7 @@ import {
type ComposeReplyResult,
type ComposeReplySemantics
} from "./replyPackaging";
import { composeInventoryReply } from "./inventoryReplyBuilders";
export type { ComposeFactualReplyOptions, ComposeReplySemantics } from "./replyPackaging";
@@ -2729,6 +2730,26 @@ function composeFactualReplyBody(
options: ComposeFactualReplyOptions = {}
): ComposeReplyResult {
const joinLines = joinComposeReplyLines;
const inventoryReply = composeInventoryReply(intent, rows, options, {
resolvePayablesAsOfDate,
buildInventoryOnHandAggregate,
uniqueStrings,
formatDateRu,
formatNumberWithDots,
formatMoneyRub,
isInventoryPurchaseMovement,
summarizeInventoryTraceRows,
formatInventoryTraceRows,
hasInventoryPurchaseDateActionFocus,
inventoryTraceDateLabel,
extractInventoryCounterpartyCandidates,
buildInventoryAgingByItemAggregate,
formatInventoryAgingRows,
isInventorySaleMovement
});
if (inventoryReply) {
return inventoryReply;
}
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
@@ -4251,401 +4272,6 @@ function composeFactualReplyBody(
};
}
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 directAnswerLine =
positions.length > 0
? `На ${formatDateRu(asOfDate)} на складе подтверждено ${formatNumberWithDots(positions.length)} позиций с остатком на ${formatMoneyRub(totalAmount)}.`
: `На ${formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines: string[] = [directAnswerLine];
if (positions.length > 0) {
lines.push("", "Позиции:");
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("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
}
lines.push(
"",
"Подтверждение:",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счету 41.01 «Товары на складах».",
`- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`,
`- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`,
`- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`
);
if (rows.length !== positions.length) {
lines.push(`- Строк в подтвержденной выборке: ${formatNumberWithDots(rows.length)}.`);
}
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 === "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 directAnswerLine =
purchaseRows.length <= 0
? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.`
: `По позиции ${itemLabel} найдено ${formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${formatDateRu(asOfDate)}.`;
const lines: string[] = [directAnswerLine];
lines.push("", "Подтверждение:");
lines.push(`- Дата верхней границы: ${formatDateRu(asOfDate)}.`);
lines.push(`- Операций поступления в выборке: ${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("; ")}.`);
}
lines.push("", "Документы:");
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 boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null;
const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) {
const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod);
const lastPurchaseDate = inventoryTraceDateLabel(summary.lastPeriod);
const directAnswerLine =
purchaseRows.length <= 0 || !summary.firstPeriod
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines: string[] = [directAnswerLine];
if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:");
lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`);
if (summary.firstPeriod !== summary.lastPeriod) {
lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`);
}
if (summary.counterparties.length === 1) {
lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
}
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
}
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
const directAnswerLine =
purchaseRows.length <= 0
? boundedAsOfLabel
? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.`
: `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.`
: summary.counterparties.length === 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`
: `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
if (purchaseRows.length > 0) {
lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций поступления: ${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, 6).join("; ")}.`);
} else {
lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем.");
}
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
}
} else if (boundedAsOfLabel) {
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.documents.length > 0) {
lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8));
}
return {
responseType: "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 directAnswerLine =
summary.counterparties.length === 1
? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.`
: `По складскому остатку ${warehouseLabel} поставщик в текущем exact-контуре не материализован.`;
const lines: string[] = [
directAnswerLine,
`Собран 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 agingItems = buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
const oldestAnswerPreview = agingItems
.slice(0, 3)
.map((item) => `${item.item} (${inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
.join("; ");
const directAnswerLine =
agingItems.length > 0
? `К старым закупкам на ${formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
: `По доступному закупочному следу на ${formatDateRu(asOfDate)} позиции старых закупок не материализованы.`;
const lines: string[] = [
directAnswerLine,
`Собран exact-срез старых закупок для складского остатка на ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Контур: показан item-level список товарных позиций с самым ранним документально наблюдаемым закупочным следом на 41.01.",
"- Порядок: позиции отсортированы от самой старой первой закупки к более новым.",
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый возраст закупочного следа по товарной позиции.",
"",
"Блок 2. Сводка",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
`- Самая ранняя первая закупка среди позиций: ${inventoryTraceDateLabel(oldestPurchaseDate)}.`,
`- Самая поздняя найденная закупка в наблюдаемом следе: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Позиции в aging-срезе: ${formatNumberWithDots(agingItems.length)}.`,
`- Закупочных документов в наблюдаемом следе: ${formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в наблюдаемом следе: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (oldestPurchaseAgeDays !== null) {
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
}
if (summary.counterparties.length > 0) {
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (agingItems.length > 0) {
lines.push("", "Блок 3. Позиции от самых старых закупок", ...formatInventoryAgingRows(agingItems, asOfDate, 12));
} else {
lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: agingItems.length > 0 ? "strong" : "medium",
balance_confirmed: agingItems.length > 0
}
};
}
if (intent === "inventory_sale_trace_for_item") {
const asOfDate = resolvePayablesAsOfDate(options);
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const requestedItemHint = String(options.itemHint ?? "").trim();
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
const excludedCounterpartyTokens = [itemLabel];
const directAnswerLine =
summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций выбытия: ${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("", "Документы выбытия:");
if (saleRows.length > 0) {
lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
} 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 directAnswerLine =
purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`);
lines.push(`- Движений выбытия со счета 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(
"",
"Закупка:",
`- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`,
`- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`,
...formatInventoryTraceRows(purchaseRows, 6)
);
}
if (saleRows.length > 0) {
lines.push(
"",
"Продажа:",
`- Первая дата: ${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);
@@ -580,7 +580,9 @@ function shouldRestoreInventoryRootFrame(
}
function hasSelectedObjectInventorySignal(text: string): boolean {
return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
String(text ?? "")
);
}
function hasSelectedObjectInlineSnapshotMetadata(text: string): boolean {
@@ -629,9 +631,13 @@ export function hasInventoryProfitabilityFollowupCue(text: string): boolean {
export function hasInventoryPurchaseDateFollowupCue(text: string): boolean {
const value = String(text ?? "");
return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(
value
) || (/когда/iu.test(value) && hasInventoryPurchaseStem(value));
return (
/(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+была\s+закупк(?:а|и)|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(
value
) ||
/(?:по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)[\s\S]{0,40}(?:когда|дата\s+закупки|закупк))/iu.test(value) ||
(/когда/iu.test(value) && hasInventoryPurchaseStem(value))
);
}
export function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean {
@@ -657,7 +663,11 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
if (!normalized) {
return false;
}
if (/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(normalized)) {
if (
/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
normalized
)
) {
return true;
}
if (hasAllTimeHint(normalized)) {
@@ -1373,6 +1383,10 @@ function deriveIntentWithFollowupContext(
if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
if (
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "list_documents_by_contract" ||
detectedIntent.intent === "bank_operations_by_counterparty" ||
detectedIntent.intent === "bank_operations_by_contract" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === sourceIntent ||
detectedIntent.intent === "inventory_on_hand_as_of_date"
@@ -0,0 +1,446 @@
import type { AddressIntent } from "../../types/addressQuery";
import {
buildConfirmedBalanceSemantics,
buildFactualListReply,
buildFactualSummaryReply
} from "./replyContracts";
import type { ComposeReplyResult } from "./replyPackaging";
import type { ComposeStageRow } from "./composeStage";
interface InventoryComposeOptions {
userMessage?: string;
itemHint?: string;
asOfDate?: string;
periodFrom?: string;
periodTo?: string;
}
interface InventoryOnHandAggregate {
item: string;
warehouse: string | null;
organization: string | null;
quantity: number;
amount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
sourceRefs: string[];
}
interface InventoryTraceSummary {
item: string | null;
warehouses: string[];
organizations: string[];
counterparties: string[];
documents: string[];
firstPeriod: string | null;
lastPeriod: string | null;
totalAmount: number;
}
interface InventoryAgingByItemAggregate {
item: string;
warehouse: string | null;
organization: string | null;
firstPurchasePeriod: string | null;
lastPurchasePeriod: string | null;
operations: number;
documentCount: number;
counterparties: string[];
ageDays: number | null;
}
interface InventoryReplyDeps {
resolvePayablesAsOfDate: (options: InventoryComposeOptions) => string;
buildInventoryOnHandAggregate: (rows: ComposeStageRow[], asOfDate: string) => InventoryOnHandAggregate[];
uniqueStrings: (values: string[]) => string[];
formatDateRu: (isoDate: string) => string;
formatNumberWithDots: (value: number, fractionDigits?: number) => string;
formatMoneyRub: (value: number) => string;
isInventoryPurchaseMovement: (row: ComposeStageRow) => boolean;
summarizeInventoryTraceRows: (rows: ComposeStageRow[], excludedCounterpartyTokens?: string[]) => InventoryTraceSummary;
formatInventoryTraceRows: (rows: ComposeStageRow[], limit?: number, excludedCounterpartyTokens?: string[]) => string[];
hasInventoryPurchaseDateActionFocus: (userMessage: string | null | undefined) => boolean;
inventoryTraceDateLabel: (value: string | null) => string;
extractInventoryCounterpartyCandidates: (row: ComposeStageRow, excludedTokens?: string[]) => string[];
buildInventoryAgingByItemAggregate: (rows: ComposeStageRow[], asOfDate: string) => InventoryAgingByItemAggregate[];
formatInventoryAgingRows: (items: InventoryAgingByItemAggregate[], asOfDate: string, limit?: number) => string[];
isInventorySaleMovement: (row: ComposeStageRow) => boolean;
}
export function composeInventoryReply(
intent: AddressIntent,
rows: ComposeStageRow[],
options: InventoryComposeOptions,
deps: InventoryReplyDeps
): ComposeReplyResult | null {
if (intent === "inventory_on_hand_as_of_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const positions = deps.buildInventoryOnHandAggregate(rows, asOfDate);
const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item));
const uniqueWarehouses = deps.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 directAnswerLine =
positions.length > 0
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.`
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines: string[] = [directAnswerLine];
if (positions.length > 0) {
lines.push("", "Позиции:");
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} | количество: ${deps.formatNumberWithDots(item.quantity, 3)} | стоимость: ${deps.formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`;
})
);
} else {
lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
}
lines.push(
"",
"Подтверждение:",
`- Дата среза: ${deps.formatDateRu(asOfDate)}.`,
"- Контур: остатки по счету 41.01 «Товары на складах».",
`- Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
`- Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
`- Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`
);
if (rows.length !== positions.length) {
lines.push(`- Строк в подтвержденной выборке: ${deps.formatNumberWithDots(rows.length)}.`);
}
return positions.length > 0
? buildFactualListReply(lines, buildConfirmedBalanceSemantics("strong"))
: buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium"));
}
if (intent === "inventory_purchase_documents_for_item") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const directAnswerLine =
purchaseRows.length <= 0
? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.`
: `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`;
const lines: string[] = [directAnswerLine];
lines.push("", "Подтверждение:");
lines.push(`- Дата верхней границы: ${deps.formatDateRu(asOfDate)}.`);
lines.push(`- Операций поступления в выборке: ${deps.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("; ")}.`);
}
lines.push("", "Документы:");
if (purchaseRows.length > 0) {
lines.push(...deps.formatInventoryTraceRows(purchaseRows, 12));
} else {
lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре.");
}
return purchaseRows.length > 0
? buildFactualListReply(lines, buildConfirmedBalanceSemantics("strong", true))
: buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false));
}
if (intent === "inventory_purchase_provenance_for_item") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null;
const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) {
const firstPurchaseDate = deps.inventoryTraceDateLabel(summary.firstPeriod);
const lastPurchaseDate = deps.inventoryTraceDateLabel(summary.lastPeriod);
const directAnswerLine =
purchaseRows.length <= 0 || !summary.firstPeriod
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines: string[] = [directAnswerLine];
if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:");
lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`);
if (summary.firstPeriod !== summary.lastPeriod) {
lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`);
}
if (summary.counterparties.length === 1) {
lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
}
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
}
}
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(purchaseRows.length > 0 ? "strong" : "medium", purchaseRows.length > 0)
);
}
const directAnswerLine =
purchaseRows.length <= 0
? boundedAsOfLabel
? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.`
: `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.`
: summary.counterparties.length === 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`
: `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
if (purchaseRows.length > 0) {
lines.push(`- Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов поступления: ${deps.formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций поступления: ${deps.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, 6).join("; ")}.`);
} else {
lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем.");
}
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
}
} else if (boundedAsOfLabel) {
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.documents.length > 0) {
lines.push("", "Опорные документы:", ...deps.formatInventoryTraceRows(purchaseRows, 8));
}
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(
purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium",
purchaseRows.length > 0
)
);
}
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0);
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
const directAnswerLine =
summary.counterparties.length === 1
? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.`
: `По складскому остатку ${warehouseLabel} поставщик в текущем exact-контуре не материализован.`;
const lines: string[] = [
directAnswerLine,
`Собран exact-срез supplier overlap для складского остатка до ${deps.formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
`- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`,
"- Важно: без партионности этот контур не доказывает конкретного владельца каждой партии, а показывает наблюдаемый закупочный след текущего остатка.",
"",
"Блок 2. Подтверждение",
`- Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`- Первая найденная дата закупочного движения: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в выборке: ${deps.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(`- Операций без явно материализованного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`);
}
if (purchaseRows.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...deps.formatInventoryTraceRows(purchaseRows, 10));
}
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(
purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
purchaseRows.length > 0
)
);
}
if (intent === "inventory_aging_by_purchase_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
const agingItems = deps.buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
const oldestAnswerPreview = agingItems
.slice(0, 3)
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
.join("; ");
const directAnswerLine =
agingItems.length > 0
? `К старым закупкам на ${deps.formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
: `По доступному закупочному следу на ${deps.formatDateRu(asOfDate)} позиции старых закупок не материализованы.`;
const lines: string[] = [
directAnswerLine,
`Собран exact-срез старых закупок для складского остатка на ${deps.formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Контур: показан item-level список товарных позиций с самым ранним документально наблюдаемым закупочным следом на 41.01.",
"- Порядок: позиции отсортированы от самой старой первой закупки к более новым.",
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый возраст закупочного следа по товарной позиции.",
"",
"Блок 2. Сводка",
`- Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`- Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`,
`- Самая поздняя найденная закупка в наблюдаемом следе: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Позиции в aging-срезе: ${deps.formatNumberWithDots(agingItems.length)}.`,
`- Закупочных документов в наблюдаемом следе: ${deps.formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в наблюдаемом следе: ${deps.formatNumberWithDots(purchaseRows.length)}.`
];
if (oldestPurchaseAgeDays !== null) {
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
}
if (summary.counterparties.length > 0) {
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (agingItems.length > 0) {
lines.push("", "Блок 3. Позиции от самых старых закупок", ...deps.formatInventoryAgingRows(agingItems, asOfDate, 12));
} else {
lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
}
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(agingItems.length > 0 ? "strong" : "medium", agingItems.length > 0)
);
}
if (intent === "inventory_sale_trace_for_item") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row));
const requestedItemHint = String(options.itemHint ?? "").trim();
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
const excludedCounterpartyTokens = [itemLabel];
const directAnswerLine =
summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов выбытия: ${deps.formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций выбытия: ${deps.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("", "Документы выбытия:");
if (saleRows.length > 0) {
lines.push(...deps.formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
} else {
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
}
return saleRows.length > 0
? buildFactualListReply(
lines,
buildConfirmedBalanceSemantics(summary.counterparties.length > 0 ? "strong" : "medium", true)
)
: buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false));
}
if (intent === "inventory_purchase_to_sale_chain") {
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row));
const purchaseSummary = deps.summarizeInventoryTraceRows(purchaseRows);
const saleSummary = deps.summarizeInventoryTraceRows(saleRows);
const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен";
const directAnswerLine =
purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Закупочных движений на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`);
lines.push(`- Движений выбытия со счета 41.01: ${deps.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(
"",
"Закупка:",
`- Первая дата: ${deps.inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`,
`- Последняя дата: ${deps.inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`,
...deps.formatInventoryTraceRows(purchaseRows, 6)
);
}
if (saleRows.length > 0) {
lines.push(
"",
"Продажа:",
`- Первая дата: ${deps.inventoryTraceDateLabel(saleSummary.firstPeriod)}.`,
`- Последняя дата: ${deps.inventoryTraceDateLabel(saleSummary.lastPeriod)}.`,
...deps.formatInventoryTraceRows(saleRows, 6)
);
}
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(
purchaseRows.length > 0 && saleRows.length > 0
? "strong"
: purchaseRows.length > 0 || saleRows.length > 0
? "medium"
: "weak",
purchaseRows.length > 0 || saleRows.length > 0
)
);
}
return null;
}
@@ -0,0 +1,53 @@
import type { AddressEvidenceStrength, AddressResponseType } from "../../types/addressQuery";
import { joinComposeReplyLines, type ComposeReplyResult, type ComposeReplySemantics } from "./replyPackaging";
export type ComposeReplyText = string | string[];
export function toComposeReplyText(text: ComposeReplyText): string {
return Array.isArray(text) ? joinComposeReplyLines(text) : String(text ?? "");
}
export function buildComposeReplyResult(
responseType: AddressResponseType,
text: ComposeReplyText,
semantics?: ComposeReplySemantics
): ComposeReplyResult {
return {
responseType,
text: toComposeReplyText(text),
...(semantics ? { semantics } : {})
};
}
export function buildFactualSummaryReply(
text: ComposeReplyText,
semantics?: ComposeReplySemantics
): ComposeReplyResult {
return buildComposeReplyResult("FACTUAL_SUMMARY", text, semantics);
}
export function buildFactualListReply(
text: ComposeReplyText,
semantics?: ComposeReplySemantics
): ComposeReplyResult {
return buildComposeReplyResult("FACTUAL_LIST", text, semantics);
}
export function buildConfirmedBalanceSemantics(
evidenceStrength: AddressEvidenceStrength = "strong",
balanceConfirmed = true
): ComposeReplySemantics {
return {
result_mode: "confirmed_balance",
evidence_strength: evidenceStrength,
balance_confirmed: balanceConfirmed
};
}
export function buildHeuristicCandidatesSemantics(hasCandidates: boolean): ComposeReplySemantics {
return {
result_mode: "heuristic_candidates",
evidence_strength: hasCandidates ? "medium" : "weak",
balance_confirmed: false
};
}