АРЧ АП11 - Оркестрация: дожать агентный прогон по selected-object continuity и сохранить accepted
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user