diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 593452e..1aca12c 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.composeInventoryReply = composeInventoryReply; const replyContracts_1 = require("./replyContracts"); +const inventoryReplyPresentation_1 = require("./inventoryReplyPresentation"); function composeInventoryReply(intent, rows, options, deps) { if (intent === "inventory_on_hand_as_of_date") { const asOfDate = deps.resolvePayablesAsOfDate(options); @@ -15,21 +16,26 @@ function composeInventoryReply(intent, rows, options, deps) { : `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; const lines = [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}`; - })); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", positions.slice(0, 20).map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, { + formatDateRu: deps.formatDateRu, + formatNumberWithDots: deps.formatNumberWithDots, + formatMoneyRub: deps.formatMoneyRub + }))); } else { - lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", [ + "- На дату среза товары с ненулевым остатком не найдены." + ]); } - lines.push("", "Подтверждение:", `- Дата среза: ${deps.formatDateRu(asOfDate)}.`, "- Контур: остатки по счету 41.01 «Товары на складах».", `- Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, `- Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, `- Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`); + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`, + `Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, + `Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, + `Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.` + ]); if (rows.length !== positions.length) { - lines.push(`- Строк в подтвержденной выборке: ${deps.formatNumberWithDots(rows.length)}.`); + lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`); } return positions.length > 0 ? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("strong")) @@ -41,12 +47,13 @@ function composeInventoryReply(intent, rows, options, deps) { const summary = deps.summarizeInventoryTraceRows(purchaseRows); const itemLabel = summary.item ?? "товар не определен"; const directAnswerLine = purchaseRows.length <= 0 - ? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.` + ? `По позиции ${itemLabel} подтвержденные документы закупки в доступных данных не найдены.` : `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`; const lines = [directAnswerLine]; - lines.push("", "Подтверждение:"); - lines.push(`- Дата верхней границы: ${deps.formatDateRu(asOfDate)}.`); - lines.push(`- Операций поступления в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ + `Дата верхней границы: ${deps.formatDateRu(asOfDate)}.`, + `Операций поступления в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]); if (summary.counterparties.length === 1) { lines.push(`- Поставщик: ${summary.counterparties[0]}.`); } @@ -58,7 +65,7 @@ function composeInventoryReply(intent, rows, options, deps) { lines.push(...deps.formatInventoryTraceRows(purchaseRows, 12)); } else { - lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре."); + lines.push("- По выбранному товару не найдено проводок поступления в доступных данных."); } return purchaseRows.length > 0 ? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("strong", true)) @@ -75,7 +82,7 @@ function composeInventoryReply(intent, rows, options, deps) { const firstPurchaseDate = deps.inventoryTraceDateLabel(summary.firstPeriod); const lastPurchaseDate = deps.inventoryTraceDateLabel(summary.lastPeriod); const directAnswerLine = purchaseRows.length <= 0 || !summary.firstPeriod - ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` + ? `По позиции ${itemLabel} подтвержденная дата закупки в доступных данных не найдена.` : summary.firstPeriod === summary.lastPeriod ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` : boundedAsOfLabel @@ -147,7 +154,7 @@ function composeInventoryReply(intent, rows, options, deps) { lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); } if (summary.documents.length > 0) { - lines.push("", "Опорные документы:", ...deps.formatInventoryTraceRows(purchaseRows, 8)); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 8)); } return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); } @@ -161,36 +168,32 @@ function composeInventoryReply(intent, rows, options, deps) { ? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` : summary.counterparties.length > 1 ? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` - : `По складскому остатку ${warehouseLabel} поставщик в текущем exact-контуре не материализован.`; - const lines = [ - 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)}.` - ]; + : `По складскому остатку ${warehouseLabel} поставщик в доступных данных не выделен.`; + const lines = [directAnswerLine]; + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`, + `Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, + `Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`, + `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]); + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ + "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии." + ]); if (summary.counterparties.length > 0) { - lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`); + lines.push(`- Найденные поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); } else if (purchaseRows.length > 0) { - lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); + lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных."); } else { - lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза."); + lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу."); } if (unresolvedRows.length > 0) { - lines.push(`- Операций без явно материализованного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`); + lines.push(`- Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`); } if (purchaseRows.length > 0) { - lines.push("", "Блок 3. Опорные документы", ...deps.formatInventoryTraceRows(purchaseRows, 10)); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 10)); } return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); } @@ -208,23 +211,18 @@ function composeInventoryReply(intent, rows, options, deps) { const directAnswerLine = agingItems.length > 0 ? `К старым закупкам на ${deps.formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` : `По доступному закупочному следу на ${deps.formatDateRu(asOfDate)} позиции старых закупок не материализованы.`; - const lines = [ - 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)}.` - ]; + const lines = [directAnswerLine]; + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`, + `Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, + `Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`, + `Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`, + `Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]); + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ + "Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота." + ]); if (oldestPurchaseAgeDays !== null) { lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`); } @@ -232,10 +230,12 @@ function composeInventoryReply(intent, rows, options, deps) { lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`); } if (agingItems.length > 0) { - lines.push("", "Блок 3. Позиции от самых старых закупок", ...deps.formatInventoryAgingRows(agingItems, asOfDate, 12)); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 12)); } else { - lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза."); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", [ + "- В доступных данных не найдено закупочных движений для выбранного среза." + ]); } return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(agingItems.length > 0 ? "strong" : "medium", agingItems.length > 0)); } @@ -251,7 +251,7 @@ function composeInventoryReply(intent, rows, options, deps) { ? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.` : summary.counterparties.length > 1 ? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.` - : `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`; + : `По товару ${itemLabel} покупатель в доступных данных не выделен.`; const lines = [directAnswerLine, "", "Подтверждение:"]; lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); lines.push(`- Последняя найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`); @@ -264,14 +264,14 @@ function composeInventoryReply(intent, rows, options, deps) { lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`); } else if (saleRows.length > 0) { - lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре."); + lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных."); } lines.push("", "Документы выбытия:"); if (saleRows.length > 0) { lines.push(...deps.formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens)); } else { - lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре."); + lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных."); } return saleRows.length > 0 ? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(summary.counterparties.length > 0 ? "strong" : "medium", true)) @@ -290,13 +290,13 @@ function composeInventoryReply(intent, rows, options, deps) { 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("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие."); + lines.push("- В доступных данных найдены обе стороны цепочки: поступление и последующее выбытие."); } else if (purchaseRows.length > 0) { - lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено."); + lines.push("- Найдена только закупочная часть цепочки; выбытие в доступных данных не подтверждено."); } else if (saleRows.length > 0) { - lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена."); + lines.push("- Найдена только часть выбытия; закупочная часть цепочки в доступных данных не подтверждена."); } else { lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку."); diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyPresentation.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyPresentation.js new file mode 100644 index 0000000..6dd9dcc --- /dev/null +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyPresentation.js @@ -0,0 +1,23 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.appendInventorySection = appendInventorySection; +exports.appendInventoryBulletSection = appendInventoryBulletSection; +exports.formatInventorySnapshotPositionLine = formatInventorySnapshotPositionLine; +function appendInventorySection(lines, title, entries) { + const visibleEntries = entries.filter((entry) => String(entry ?? "").trim().length > 0); + if (visibleEntries.length === 0) { + return; + } + lines.push("", title, ...visibleEntries); +} +function appendInventoryBulletSection(lines, title, bullets) { + appendInventorySection(lines, title, bullets + .filter((bullet) => String(bullet ?? "").trim().length > 0) + .map((bullet) => (bullet.startsWith("- ") ? bullet : `- ${bullet}`))); +} +function formatInventorySnapshotPositionLine(item, index, deps) { + const warehouseLabel = item.warehouse ?? "склад не указан"; + const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; + const lastSeenLabel = item.lastPeriod ? ` | последняя дата: ${deps.formatDateRu(item.lastPeriod)}` : ""; + return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${deps.formatNumberWithDots(item.quantity, 3)} | сумма: ${deps.formatMoneyRub(item.amount)}${organizationLabel}${lastSeenLabel}`; +} diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 245ed8c..3813cb7 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -4,6 +4,11 @@ import { buildFactualListReply, buildFactualSummaryReply } from "./replyContracts"; +import { + appendInventoryBulletSection, + appendInventorySection, + formatInventorySnapshotPositionLine +} from "./inventoryReplyPresentation"; import type { ComposeReplyResult } from "./replyPackaging"; import type { ComposeStageRow } from "./composeStage"; @@ -90,31 +95,32 @@ export function composeInventoryReply( 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}`; - }) + appendInventorySection( + lines, + "Позиции:", + positions.slice(0, 20).map((item, index) => + formatInventorySnapshotPositionLine(item, index, { + formatDateRu: deps.formatDateRu, + formatNumberWithDots: deps.formatNumberWithDots, + formatMoneyRub: deps.formatMoneyRub + }) + ) ); } else { - lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + appendInventorySection(lines, "Позиции:", [ + "- На дату среза товары с ненулевым остатком не найдены." + ]); } - lines.push( - "", - "Подтверждение:", - `- Дата среза: ${deps.formatDateRu(asOfDate)}.`, - "- Контур: остатки по счету 41.01 «Товары на складах».", - `- Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, - `- Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, - `- Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.` - ); + appendInventoryBulletSection(lines, "Сводка:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`, + `Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, + `Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, + `Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.` + ]); if (rows.length !== positions.length) { - lines.push(`- Строк в подтвержденной выборке: ${deps.formatNumberWithDots(rows.length)}.`); + lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`); } return positions.length > 0 @@ -129,12 +135,13 @@ export function composeInventoryReply( const itemLabel = summary.item ?? "товар не определен"; const directAnswerLine = purchaseRows.length <= 0 - ? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.` + ? `По позиции ${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)}.`); + appendInventoryBulletSection(lines, "Сводка:", [ + `Дата верхней границы: ${deps.formatDateRu(asOfDate)}.`, + `Операций поступления в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]); if (summary.counterparties.length === 1) { lines.push(`- Поставщик: ${summary.counterparties[0]}.`); } else if (summary.counterparties.length > 1) { @@ -144,7 +151,7 @@ export function composeInventoryReply( if (purchaseRows.length > 0) { lines.push(...deps.formatInventoryTraceRows(purchaseRows, 12)); } else { - lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре."); + lines.push("- По выбранному товару не найдено проводок поступления в доступных данных."); } return purchaseRows.length > 0 ? buildFactualListReply(lines, buildConfirmedBalanceSemantics("strong", true)) @@ -163,7 +170,7 @@ export function composeInventoryReply( const lastPurchaseDate = deps.inventoryTraceDateLabel(summary.lastPeriod); const directAnswerLine = purchaseRows.length <= 0 || !summary.firstPeriod - ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` + ? `По позиции ${itemLabel} подтвержденная дата закупки в доступных данных не найдена.` : summary.firstPeriod === summary.lastPeriod ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` : boundedAsOfLabel @@ -240,7 +247,7 @@ export function composeInventoryReply( lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); } if (summary.documents.length > 0) { - lines.push("", "Опорные документы:", ...deps.formatInventoryTraceRows(purchaseRows, 8)); + appendInventorySection(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 8)); } return buildFactualSummaryReply( lines, @@ -262,34 +269,30 @@ export function composeInventoryReply( ? `По складскому остатку ${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)}.` - ]; + : `По складскому остатку ${warehouseLabel} поставщик в доступных данных не выделен.`; + const lines: string[] = [directAnswerLine]; + appendInventoryBulletSection(lines, "Что проверили:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`, + `Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, + `Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`, + `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]); + appendInventoryBulletSection(lines, "Ограничения:", [ + "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии." + ]); if (summary.counterparties.length > 0) { - lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`); + lines.push(`- Найденные поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); } else if (purchaseRows.length > 0) { - lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); + lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных."); } else { - lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза."); + lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу."); } if (unresolvedRows.length > 0) { - lines.push(`- Операций без явно материализованного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`); + lines.push(`- Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`); } if (purchaseRows.length > 0) { - lines.push("", "Блок 3. Опорные документы", ...deps.formatInventoryTraceRows(purchaseRows, 10)); + appendInventorySection(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 10)); } return buildFactualSummaryReply( lines, @@ -315,23 +318,18 @@ export function composeInventoryReply( 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)}.` - ]; + const lines: string[] = [directAnswerLine]; + appendInventoryBulletSection(lines, "Сводка:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`, + `Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, + `Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`, + `Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`, + `Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]); + appendInventoryBulletSection(lines, "Ограничения:", [ + "Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота." + ]); if (oldestPurchaseAgeDays !== null) { lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`); } @@ -339,9 +337,11 @@ export function composeInventoryReply( lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`); } if (agingItems.length > 0) { - lines.push("", "Блок 3. Позиции от самых старых закупок", ...deps.formatInventoryAgingRows(agingItems, asOfDate, 12)); + appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 12)); } else { - lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза."); + appendInventorySection(lines, "Позиции от самых старых закупок:", [ + "- В доступных данных не найдено закупочных движений для выбранного среза." + ]); } return buildFactualSummaryReply( lines, @@ -362,7 +362,7 @@ export function composeInventoryReply( ? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.` : summary.counterparties.length > 1 ? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.` - : `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`; + : `По товару ${itemLabel} покупатель в доступных данных не выделен.`; const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); lines.push(`- Последняя найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`); @@ -373,13 +373,13 @@ export function composeInventoryReply( } else if (summary.counterparties.length > 1) { lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`); } else if (saleRows.length > 0) { - lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре."); + lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных."); } lines.push("", "Документы выбытия:"); if (saleRows.length > 0) { lines.push(...deps.formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens)); } else { - lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре."); + lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных."); } return saleRows.length > 0 ? buildFactualListReply( @@ -403,11 +403,11 @@ export function composeInventoryReply( 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("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие."); + lines.push("- В доступных данных найдены обе стороны цепочки: поступление и последующее выбытие."); } else if (purchaseRows.length > 0) { - lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено."); + lines.push("- Найдена только закупочная часть цепочки; выбытие в доступных данных не подтверждено."); } else if (saleRows.length > 0) { - lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена."); + lines.push("- Найдена только часть выбытия; закупочная часть цепочки в доступных данных не подтверждена."); } else { lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку."); } diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyPresentation.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyPresentation.ts new file mode 100644 index 0000000..92b78cc --- /dev/null +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyPresentation.ts @@ -0,0 +1,43 @@ +interface InventorySnapshotPositionLike { + item: string; + warehouse: string | null; + organization: string | null; + quantity: number; + amount: number; + lastPeriod: string | null; +} + +interface InventoryPresentationDeps { + formatDateRu: (isoDate: string) => string; + formatNumberWithDots: (value: number, fractionDigits?: number) => string; + formatMoneyRub: (value: number) => string; +} + +export function appendInventorySection(lines: string[], title: string, entries: string[]): void { + const visibleEntries = entries.filter((entry) => String(entry ?? "").trim().length > 0); + if (visibleEntries.length === 0) { + return; + } + lines.push("", title, ...visibleEntries); +} + +export function appendInventoryBulletSection(lines: string[], title: string, bullets: string[]): void { + appendInventorySection( + lines, + title, + bullets + .filter((bullet) => String(bullet ?? "").trim().length > 0) + .map((bullet) => (bullet.startsWith("- ") ? bullet : `- ${bullet}`)) + ); +} + +export function formatInventorySnapshotPositionLine( + item: InventorySnapshotPositionLike, + index: number, + deps: InventoryPresentationDeps +): string { + const warehouseLabel = item.warehouse ?? "склад не указан"; + const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; + const lastSeenLabel = item.lastPeriod ? ` | последняя дата: ${deps.formatDateRu(item.lastPeriod)}` : ""; + return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${deps.formatNumberWithDots(item.quantity, 3)} | сумма: ${deps.formatMoneyRub(item.amount)}${organizationLabel}${lastSeenLabel}`; +} diff --git a/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts b/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts index eb00eb1..93a9694 100644 --- a/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts @@ -64,8 +64,10 @@ describe("inventory aging follow-up", () => { expect(reply.responseType).toBe("FACTUAL_SUMMARY"); expect(reply.text).toContain("К старым закупкам на 30.09.2021"); - expect(reply.text).toContain("Блок 3. Позиции от самых старых закупок"); - expect(reply.text).not.toContain("Блок 3. Опорные документы"); + expect(reply.text).toContain("Позиции от самых старых закупок:"); + expect(reply.text).toContain("Ограничения:"); + expect(reply.text).not.toContain("Блок 1"); + expect(reply.text).not.toContain("exact-контур"); expect(reply.text.indexOf("1. Четки Пост (84*117)")).toBeGreaterThan(-1); expect(reply.text.indexOf("2. Зеркало для инвалидов поворотное травмобезопасное")).toBeGreaterThan( reply.text.indexOf("1. Четки Пост (84*117)") diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 3e8728f..8ada357 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -365,7 +365,7 @@ describe("address query shape classifier", () => { useRubCurrency: true } ); - expect(reply.text.split("\n")[0]).toContain("поставщиком"); + expect(reply.text.split("\n")[0]).toContain("подтвержден поставщик"); expect(reply.text).toContain("Подтверждение"); expect(reply.text).not.toContain("Блок 1"); expect(reply.text).toContain("Гамма-мебель, ООО"); @@ -5149,14 +5149,46 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { expect(reply.responseType).toBe("FACTUAL_LIST"); expect(reply.text.split("\n")[0]).toContain("На 31.03.2020 на складе подтверждено"); - expect(reply.text).toContain("Контур: остатки по счету 41.01"); + expect(reply.text).toContain("Сводка:"); expect(reply.text).not.toContain("Блок 1"); + expect(reply.text).not.toContain("Контур:"); + expect(reply.text).not.toContain("source refs"); expect(reply.text).toContain("Шкаф картотечный"); expect(reply.text).toContain("Основной склад"); expect(reply.semantics?.result_mode).toBe("confirmed_balance"); expect(reply.semantics?.balance_confirmed).toBe(true); }); + it("keeps supplier-overlap reply business-first without exact contour leakage", () => { + const reply = composeFactualReply( + "inventory_supplier_stock_overlap_as_of_date", + [ + { + period: "2020-02-12T00:00:00Z", + registrator: "Поступление товаров и услуг 00000000003 от 12.02.2020 0:00:00", + account_dt: "41.01", + account_kt: "60.01", + amount: 3724.17, + analytics: ["Столешница 600*3050*26 дуб ниагара", "Основной склад", "Торговый дом \\Союз\\"], + item: "Столешница 600*3050*26 дуб ниагара", + warehouse: "Основной склад", + organization: 'ООО "Альтернатива Плюс"' + } + ], + { + asOfDate: "2020-03-31", + useRubCurrency: true + } + ); + + expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text).toContain("Что проверили:"); + expect(reply.text).toContain("Опорные документы:"); + expect(reply.text).not.toContain("exact-контур"); + expect(reply.text).not.toContain("Блок 1"); + expect(reply.text).not.toContain("Контур:"); + }); + it("routes inventory provenance questions to a dedicated intent", () => { const result = resolveAddressIntent("От какого поставщика куплен товар Шкаф картоотечный?"); expect(result.intent).toBe("inventory_purchase_provenance_for_item");