ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить каскад складских follow-up: удержать дату, поднять прямой ответ вверх и усилить analyst-loop
This commit is contained in:
@@ -1069,6 +1069,143 @@ function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[]
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function buildInventoryAgingByItemAggregate(
|
||||
rows: ComposeStageRow[],
|
||||
asOfDate: string
|
||||
): InventoryAgingByItemAggregate[] {
|
||||
const byItem = new Map<
|
||||
string,
|
||||
{
|
||||
item: string;
|
||||
warehouse: string | null;
|
||||
organization: string | null;
|
||||
firstPurchasePeriod: string | null;
|
||||
lastPurchasePeriod: string | null;
|
||||
operations: number;
|
||||
documents: Set<string>;
|
||||
counterparties: Set<string>;
|
||||
}
|
||||
>();
|
||||
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
|
||||
|
||||
for (const row of rows) {
|
||||
const item = extractInventoryItemName(row);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
const rowTimestamp = toUtcDayTimestamp(row.period);
|
||||
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
|
||||
continue;
|
||||
}
|
||||
const warehouse = extractInventoryWarehouseName(row);
|
||||
const organization = extractInventoryOrganizationName(row);
|
||||
const key = [normalizeEntityToken(item), normalizeEntityToken(warehouse), normalizeEntityToken(organization)].join("|");
|
||||
const registrator = String(row.registrator ?? "").trim();
|
||||
const current = byItem.get(key);
|
||||
if (!current) {
|
||||
byItem.set(key, {
|
||||
item,
|
||||
warehouse,
|
||||
organization,
|
||||
firstPurchasePeriod: row.period,
|
||||
lastPurchasePeriod: row.period,
|
||||
operations: 1,
|
||||
documents: new Set(registrator && registrator !== "(без названия)" ? [registrator] : []),
|
||||
counterparties: new Set(extractInventoryCounterpartyCandidates(row))
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.operations += 1;
|
||||
if ((row.period ?? "") < (current.firstPurchasePeriod ?? "")) {
|
||||
current.firstPurchasePeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPurchasePeriod ?? "")) {
|
||||
current.lastPurchasePeriod = row.period;
|
||||
}
|
||||
if (registrator && registrator !== "(без названия)") {
|
||||
current.documents.add(registrator);
|
||||
}
|
||||
for (const counterparty of extractInventoryCounterpartyCandidates(row)) {
|
||||
current.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byItem.values())
|
||||
.map((item) => {
|
||||
const firstTimestamp = toUtcDayTimestamp(item.firstPurchasePeriod);
|
||||
const ageDays =
|
||||
asOfTimestamp !== null &&
|
||||
firstTimestamp !== null &&
|
||||
Number.isFinite(asOfTimestamp) &&
|
||||
Number.isFinite(firstTimestamp) &&
|
||||
firstTimestamp <= asOfTimestamp
|
||||
? Math.floor((asOfTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
return {
|
||||
item: item.item,
|
||||
warehouse: item.warehouse,
|
||||
organization: item.organization,
|
||||
firstPurchasePeriod: item.firstPurchasePeriod,
|
||||
lastPurchasePeriod: item.lastPurchasePeriod,
|
||||
operations: item.operations,
|
||||
documentCount: item.documents.size,
|
||||
counterparties: Array.from(item.counterparties).sort((left, right) => left.localeCompare(right, "ru")),
|
||||
ageDays
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftAge = left.ageDays ?? Number.NEGATIVE_INFINITY;
|
||||
const rightAge = right.ageDays ?? Number.NEGATIVE_INFINITY;
|
||||
if (rightAge !== leftAge) {
|
||||
return rightAge - leftAge;
|
||||
}
|
||||
if ((left.firstPurchasePeriod ?? "") !== (right.firstPurchasePeriod ?? "")) {
|
||||
return String(left.firstPurchasePeriod ?? "").localeCompare(String(right.firstPurchasePeriod ?? ""), "ru");
|
||||
}
|
||||
if (right.operations !== left.operations) {
|
||||
return right.operations - left.operations;
|
||||
}
|
||||
return left.item.localeCompare(right.item, "ru");
|
||||
});
|
||||
}
|
||||
|
||||
function formatInventoryAgingRows(items: InventoryAgingByItemAggregate[], asOfDate: string, limit = 10): string[] {
|
||||
return items.slice(0, limit).map((item, index) => {
|
||||
const parts = [
|
||||
`${index + 1}. ${item.item}`,
|
||||
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
|
||||
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`,
|
||||
`документов: ${formatNumberWithDots(item.documentCount)}`,
|
||||
`операций: ${formatNumberWithDots(item.operations)}`
|
||||
];
|
||||
if (item.ageDays !== null) {
|
||||
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`);
|
||||
}
|
||||
if (item.warehouse) {
|
||||
parts.push(`склад: ${item.warehouse}`);
|
||||
}
|
||||
if (item.organization) {
|
||||
parts.push(`организация: ${item.organization}`);
|
||||
}
|
||||
if (item.counterparties.length > 0) {
|
||||
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`);
|
||||
}
|
||||
return parts.join(" | ");
|
||||
});
|
||||
}
|
||||
|
||||
interface CounterpartyRiskAggregate {
|
||||
name: string;
|
||||
totalAmount: number;
|
||||
@@ -3920,7 +4057,14 @@ export function composeFactualReply(
|
||||
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||
const itemLabel = summary.item ?? "товар не определен";
|
||||
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,
|
||||
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||
"",
|
||||
"Блок 1. Статус результата",
|
||||
@@ -4002,44 +4146,52 @@ export function composeFactualReply(
|
||||
const asOfDate = resolvePayablesAsOfDate(options);
|
||||
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||
const firstPeriodTime = summary.firstPeriod ? Date.parse(summary.firstPeriod) : Number.NaN;
|
||||
const asOfTime = Date.parse(`${asOfDate}T23:59:59.000Z`);
|
||||
const ageDays =
|
||||
Number.isFinite(firstPeriodTime) && Number.isFinite(asOfTime) && firstPeriodTime <= asOfTime
|
||||
? Math.floor((asOfTime - firstPeriodTime) / 86_400_000)
|
||||
: null;
|
||||
const itemLabel = summary.item ?? "выбранному складскому остатку";
|
||||
const 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[] = [
|
||||
`Собран exact-срез возраста закупочного следа по ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||
directAnswerLine,
|
||||
`Собран exact-срез старых закупок для складского остатка на ${formatDateRu(asOfDate)}.`,
|
||||
"",
|
||||
"Блок 1. Статус результата",
|
||||
"- Контур: показаны подтвержденные закупочные движения на 41.01 и их временной разброс.",
|
||||
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый диапазон закупок.",
|
||||
"- Контур: показан item-level список товарных позиций с самым ранним документально наблюдаемым закупочным следом на 41.01.",
|
||||
"- Порядок: позиции отсортированы от самой старой первой закупки к более новым.",
|
||||
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый возраст закупочного следа по товарной позиции.",
|
||||
"",
|
||||
"Блок 2. Сводка",
|
||||
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
|
||||
`- Дата среза: ${formatDateRu(asOfDate)}.`,
|
||||
`- Самая ранняя первая закупка среди позиций: ${inventoryTraceDateLabel(oldestPurchaseDate)}.`,
|
||||
`- Самая поздняя найденная закупка в наблюдаемом следе: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||
`- Позиции в aging-срезе: ${formatNumberWithDots(agingItems.length)}.`,
|
||||
`- Закупочных документов в наблюдаемом следе: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||
`- Закупочных операций в наблюдаемом следе: ${formatNumberWithDots(purchaseRows.length)}.`
|
||||
];
|
||||
if (ageDays !== null) {
|
||||
lines.push(`- Между самой ранней найденной закупкой и датой среза прошло ${formatNumberWithDots(ageDays)} дн.`);
|
||||
if (oldestPurchaseAgeDays !== null) {
|
||||
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
|
||||
}
|
||||
if (summary.counterparties.length > 0) {
|
||||
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
|
||||
}
|
||||
if (purchaseRows.length > 0) {
|
||||
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
|
||||
if (agingItems.length > 0) {
|
||||
lines.push("", "Блок 3. Позиции от самых старых закупок", ...formatInventoryAgingRows(agingItems, asOfDate, 12));
|
||||
} else {
|
||||
lines.push("", "Блок 3. Опорные документы", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
|
||||
lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: joinLines(lines),
|
||||
semantics: {
|
||||
result_mode: "confirmed_balance",
|
||||
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
|
||||
balance_confirmed: purchaseRows.length > 0
|
||||
evidence_strength: agingItems.length > 0 ? "strong" : "medium",
|
||||
balance_confirmed: agingItems.length > 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,6 +311,9 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (hasAllTimeHint(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user