Закрепить margin follow-up по выбранной позиции
This commit is contained in:
@@ -45,6 +45,7 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressFocusO
|
||||
inventory_purchase_documents_for_item: "item",
|
||||
inventory_supplier_stock_overlap_as_of_date: "item",
|
||||
inventory_sale_trace_for_item: "item",
|
||||
inventory_margin_ranking_for_nomenclature: "item",
|
||||
inventory_profitability_for_item: "item",
|
||||
inventory_purchase_to_sale_chain: "item",
|
||||
inventory_aging_by_purchase_date: "item"
|
||||
@@ -72,6 +73,7 @@ const RESULT_SET_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressResultSetT
|
||||
inventory_purchase_documents_for_item: "inventory_trace",
|
||||
inventory_supplier_stock_overlap_as_of_date: "inventory_trace",
|
||||
inventory_sale_trace_for_item: "inventory_trace",
|
||||
inventory_margin_ranking_for_nomenclature: "inventory_trace",
|
||||
inventory_profitability_for_item: "inventory_trace",
|
||||
inventory_purchase_to_sale_chain: "inventory_trace",
|
||||
inventory_aging_by_purchase_date: "inventory_trace",
|
||||
@@ -196,7 +198,8 @@ function parseEntityCandidateFromLine(line: string): { index: number; value: str
|
||||
const afterNumber = String(numberedMatch[2] ?? "");
|
||||
const pieces = afterNumber.split("|").map((item) => item.trim()).filter(Boolean);
|
||||
const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber;
|
||||
const cleaned = valueCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim();
|
||||
const businessLabel = valueCandidate.split(/\s+[—–-]\s+(?=(?:выручка|сумма|количество|стоимость|маржа|себестоим|дата|склад))/iu)[0] ?? valueCandidate;
|
||||
const cleaned = businessLabel.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim();
|
||||
if (!cleaned || cleaned.length < 2) {
|
||||
return null;
|
||||
}
|
||||
@@ -455,6 +458,43 @@ function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId:
|
||||
return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null;
|
||||
}
|
||||
|
||||
function buildFocusObjectFromPrimaryEntityRef(
|
||||
resultSet: AddressResultSet,
|
||||
createdAt: string
|
||||
): AddressFocusObject | null {
|
||||
if (resultSet.intent !== "inventory_margin_ranking_for_nomenclature") {
|
||||
return null;
|
||||
}
|
||||
const primaryEntity = resultSet.entity_refs[0];
|
||||
if (!primaryEntity || primaryEntity.entity_type !== "item") {
|
||||
return null;
|
||||
}
|
||||
return buildFocusObject("item", primaryEntity.value, resultSet.result_set_id, createdAt);
|
||||
}
|
||||
|
||||
function resolveFocusObjectForNavigation(
|
||||
state: AddressNavigationState,
|
||||
intent: AddressIntent,
|
||||
resultSet: AddressResultSet,
|
||||
debugFocusObject: AddressFocusObject | null,
|
||||
primaryEntityFocusObject: AddressFocusObject | null
|
||||
): AddressFocusObject | null {
|
||||
if (intent !== "inventory_margin_ranking_for_nomenclature") {
|
||||
return debugFocusObject ?? primaryEntityFocusObject;
|
||||
}
|
||||
if (primaryEntityFocusObject) {
|
||||
return primaryEntityFocusObject;
|
||||
}
|
||||
const previousFocus = state.session_context.active_focus_object;
|
||||
if (
|
||||
previousFocus?.object_type === "item" &&
|
||||
(!debugFocusObject || debugFocusObject.object_type === "organization")
|
||||
) {
|
||||
return cloneFocusObject(previousFocus);
|
||||
}
|
||||
return debugFocusObject;
|
||||
}
|
||||
|
||||
function capResultSets(resultSets: AddressResultSet[]): AddressResultSet[] {
|
||||
if (resultSets.length <= MAX_RESULT_SETS) {
|
||||
return resultSets;
|
||||
@@ -663,7 +703,15 @@ export function evolveAddressNavigationStateWithAssistantItem(
|
||||
created_at: createdAt
|
||||
};
|
||||
const previousResultSetId = state.session_context.active_result_set_id;
|
||||
const focusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
|
||||
const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
|
||||
const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt);
|
||||
const focusObject = resolveFocusObjectForNavigation(
|
||||
state,
|
||||
intent,
|
||||
resultSet,
|
||||
debugFocusObject,
|
||||
primaryEntityFocusObject
|
||||
);
|
||||
const comparisonCounterparty =
|
||||
selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
|
||||
? readNavigationDiscoveryCounterparty(debug)
|
||||
|
||||
@@ -1474,9 +1474,9 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTo
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(normalized);
|
||||
candidates.push(collapseRepeatedInventoryPartyLabel(normalized));
|
||||
}
|
||||
const explicitCounterparty = normalizeCounterpartyDisplayLabel(row.counterparty);
|
||||
const explicitCounterparty = collapseRepeatedInventoryPartyLabel(normalizeCounterpartyDisplayLabel(row.counterparty));
|
||||
const explicitComparable = normalizeEntityToken(explicitCounterparty);
|
||||
if (
|
||||
explicitCounterparty &&
|
||||
@@ -1491,6 +1491,53 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTo
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
|
||||
function collapseRepeatedInventoryPartyLabel(value: string | null | undefined): string {
|
||||
const compact = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||
if (!compact) {
|
||||
return compact;
|
||||
}
|
||||
const tokens = compact.split(" ").filter(Boolean);
|
||||
for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) {
|
||||
const collapsedTokens: string[] = [];
|
||||
let changed = false;
|
||||
for (let index = 0; index < tokens.length; ) {
|
||||
const leftTokens = tokens.slice(index, index + size);
|
||||
const rightTokens = tokens.slice(index + size, index + size * 2);
|
||||
if (
|
||||
leftTokens.length === size &&
|
||||
rightTokens.length === size &&
|
||||
normalizeEntityToken(leftTokens.join(" ")) === normalizeEntityToken(rightTokens.join(" "))
|
||||
) {
|
||||
collapsedTokens.push(...leftTokens);
|
||||
index += size * 2;
|
||||
changed = true;
|
||||
} else {
|
||||
collapsedTokens.push(tokens[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
return collapseRepeatedInventoryPartyLabel(collapsedTokens.join(" "));
|
||||
}
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
function uniqueCollapsedInventoryPartyLabels(values: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const collapsed = collapseRepeatedInventoryPartyLabel(value);
|
||||
const comparable = normalizeEntityToken(collapsed);
|
||||
if (!collapsed || !comparable || seen.has(comparable)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(comparable);
|
||||
result.push(collapsed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface InventoryTraceSummary {
|
||||
item: string | null;
|
||||
warehouses: string[];
|
||||
@@ -1518,7 +1565,7 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[], excludedCounterpar
|
||||
.map((row) => extractInventoryOrganizationName(row))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
const counterparties = uniqueStrings(
|
||||
const counterparties = uniqueCollapsedInventoryPartyLabels(
|
||||
rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens))
|
||||
);
|
||||
const documents = uniqueStrings(
|
||||
|
||||
@@ -764,7 +764,7 @@ export function hasInventorySupplierFollowupCue(text: string): boolean {
|
||||
export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
|
||||
const value = String(text ?? "");
|
||||
return (
|
||||
/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
|
||||
/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
|
||||
value
|
||||
) ||
|
||||
/(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(
|
||||
|
||||
@@ -145,6 +145,65 @@ function normalizeInventoryReplyEntityToken(value: string | null | undefined): s
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeInventoryReplyLabelComparable(value: string | null | undefined): string | null {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/gu, "е")
|
||||
.replace(/С‘/gu, "Рµ")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function collapseRepeatedInventoryReplyLabel(value: string | null | undefined): string {
|
||||
const compact = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||
if (!compact) {
|
||||
return compact;
|
||||
}
|
||||
const tokens = compact.split(/\s+/u).filter(Boolean);
|
||||
for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) {
|
||||
const collapsedTokens: string[] = [];
|
||||
let changed = false;
|
||||
for (let index = 0; index < tokens.length; ) {
|
||||
const leftTokens = tokens.slice(index, index + size);
|
||||
const rightTokens = tokens.slice(index + size, index + size * 2);
|
||||
if (
|
||||
leftTokens.length === size &&
|
||||
rightTokens.length === size &&
|
||||
normalizeInventoryReplyLabelComparable(leftTokens.join(" ")) === normalizeInventoryReplyLabelComparable(rightTokens.join(" "))
|
||||
) {
|
||||
collapsedTokens.push(...leftTokens);
|
||||
index += size * 2;
|
||||
changed = true;
|
||||
} else {
|
||||
collapsedTokens.push(tokens[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
return collapseRepeatedInventoryReplyLabel(collapsedTokens.join(" "));
|
||||
}
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
function normalizeInventoryTraceSummaryCounterparties(summary: InventoryTraceSummary): void {
|
||||
const normalized: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const counterparty of summary.counterparties) {
|
||||
const collapsed = collapseRepeatedInventoryReplyLabel(counterparty);
|
||||
const comparable = normalizeInventoryReplyLabelComparable(collapsed);
|
||||
if (!collapsed || !comparable || seen.has(comparable)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(comparable);
|
||||
normalized.push(collapsed);
|
||||
}
|
||||
summary.counterparties = normalized;
|
||||
}
|
||||
|
||||
function sumInventoryRowAmount(rows: ComposeStageRow[]): number {
|
||||
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
||||
}
|
||||
@@ -204,6 +263,12 @@ function asksInventoryMarginAccount41Not01(userMessage: string | null | undefine
|
||||
return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text);
|
||||
}
|
||||
|
||||
function asksForInventoryConfirmationDocument(userMessage: string | null | undefined): boolean {
|
||||
return /(?:каким\s+документом|чем\s+подтвержд|документ(?:ом|ами)?\s+подтвержд|покажи\s+закупочн(?:ый|ые)\s+документ|покажи\s+документ)/iu.test(
|
||||
String(userMessage ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
interface InventoryMarginRankingEntry {
|
||||
item: string;
|
||||
revenue: number;
|
||||
@@ -346,10 +411,15 @@ export function composeInventoryReply(
|
||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||
const itemLabel = summary.item ?? "товар не определен";
|
||||
const firstDocument = summary.documents[0] ?? null;
|
||||
const confirmationDocumentFocus = asksForInventoryConfirmationDocument(options.userMessage);
|
||||
const directAnswerLine =
|
||||
purchaseRows.length <= 0
|
||||
? `По позиции ${itemLabel} подтвержденные документы закупки в доступных данных не найдены.`
|
||||
: confirmationDocumentFocus && firstDocument
|
||||
? `Документ подтверждения по позиции ${itemLabel}: ${firstDocument}.`
|
||||
: `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`;
|
||||
const lines: string[] = [directAnswerLine];
|
||||
appendInventoryBulletSection(lines, "Сводка:", [
|
||||
@@ -376,6 +446,7 @@ export function composeInventoryReply(
|
||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||
const itemLabel = summary.item ?? "товар не определен";
|
||||
const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null;
|
||||
const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage);
|
||||
@@ -485,6 +556,7 @@ export function composeInventoryReply(
|
||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0);
|
||||
const unresolvedSupplierQuestion =
|
||||
/(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(
|
||||
@@ -579,6 +651,7 @@ export function composeInventoryReply(
|
||||
return Boolean(itemToken && agingItemTokens.has(itemToken));
|
||||
});
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
|
||||
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
|
||||
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
|
||||
@@ -631,6 +704,7 @@ export function composeInventoryReply(
|
||||
const requestedItemHint = String(options.itemHint ?? "").trim();
|
||||
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
|
||||
const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
|
||||
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
|
||||
const excludedCounterpartyTokens = [itemLabel];
|
||||
const directAnswerLine =
|
||||
@@ -715,7 +789,7 @@ export function composeInventoryReply(
|
||||
"оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;",
|
||||
"строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений."
|
||||
]);
|
||||
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false));
|
||||
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", true));
|
||||
}
|
||||
if (confirmedEntries.length === 0) {
|
||||
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
|
||||
|
||||
Reference in New Issue
Block a user