Стабилизировать маржинальность номенклатуры 1С

This commit is contained in:
2026-05-23 14:39:37 +03:00
parent 473cdc3a9b
commit a15f24f21d
14 changed files with 760 additions and 18 deletions
@@ -162,6 +162,14 @@ function inventoryProfitabilityPeriodLabel(options: InventoryComposeOptions, dep
return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке";
}
function asksForInventoryCostBaseRows(userMessage: string | null | undefined): boolean {
const text = String(userMessage ?? "").toLowerCase();
if (!/(?:покажи|показать|выведи|вывести|дай|дать|раскрой|раскрыть|строк|строки|строку|баз)/iu.test(text)) {
return false;
}
return /(?:себестоимостн|себестоимост|себестоим|закупочн|закупк|90\.02|\b41\b|баз)/iu.test(text);
}
interface InventoryMarginRankingEntry {
item: string;
revenue: number;
@@ -631,7 +639,12 @@ export function composeInventoryReply(
const totalCostProxy = entries.reduce((sum, entry) => sum + entry.costProxy, 0);
const totalSpread = totalRevenue - totalCostProxy;
if (confirmedEntries.length === 0) {
const lines: string[] = [`За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`];
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines: string[] = [
costBaseRowsRequested && purchasesWithoutSales.length === 0
? `За период ${periodLabel} подтвержденных строк себестоимостной базы по реализованной номенклатуре не найдено.`
: `За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`
];
const findings: string[] = [];
if (salesWithoutCost.length > 0) {
const salesCount = deps.formatNumberWithDots(salesWithoutCost.length);
@@ -647,6 +660,9 @@ export function composeInventoryReply(
);
findings.push("Поэтому валовую прибыль и маржинальность честно посчитать нельзя.");
}
if (costBaseRowsRequested && purchasesWithoutSales.length === 0) {
findings.push("Строк себестоимости реализации / себестоимостной базы для показа нет.");
}
if (purchasesWithoutSales.length > 0) {
const purchaseCount = deps.formatNumberWithDots(purchasesWithoutSales.length);
const purchaseItemPhrase =
@@ -679,7 +695,7 @@ export function composeInventoryReply(
appendInventoryBulletSection(lines, "Что можно сделать дальше:", nextActions);
appendInventoryBulletSection(lines, "Граница ответа:", [
"Прибыльность номенклатуры считаю только когда есть реализация и подтвержденная себестоимость реализации.",
"Это не чистая прибыль компании и не замена закрытию месяца."
"Это не показатель чистой прибыли и не замена закрытию месяца."
]);
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics(entries.length > 0 ? "medium" : "weak", false));
}
@@ -709,7 +725,7 @@ export function composeInventoryReply(
}
const boundaryLines = [
"Это управленческий расчет валовой маржинальности по реализации и доступной себестоимостной базе, не чистая прибыль компании.",
"Это управленческий расчет валовой маржинальности по реализации и доступной себестоимостной базе, не показатель чистой прибыли.",
"Для строгого бухгалтерского расчета нужны проводки 90.01 / 90.02 и закрытие себестоимости; этот ответ не подменяет закрытие месяца."
];
if (salesWithoutCost.length > 0) {
@@ -496,6 +496,31 @@ function hasExactBankOperationsAddressReply(
);
}
function hasInventoryMarginRankingAddressReply(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
): boolean {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
if (!toNonEmptyString(input.currentReply)) {
return false;
}
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const capabilityId =
toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id);
return Boolean(
detectedIntent === "inventory_margin_ranking_for_nomenclature" ||
selectedRecipe === "address_inventory_margin_ranking_for_nomenclature_v1" ||
capabilityId === "inventory_inventory_margin_ranking_for_nomenclature"
);
}
function hasValueFlowActionConflictWithDiscoveryTurnMeaning(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
@@ -834,6 +859,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
entryPoint
);
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(
@@ -917,6 +943,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
if (exactBankOperationsAddressReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_bank_operations_address_reply");
}
if (inventoryMarginRankingAddressReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply");
}
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
pushReason(
reasonCodes,
@@ -952,6 +981,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
!staleMetadataDiscoveryFallbackAgainstExactAddressReply &&
!exactValueFlowReplyForBusinessOverviewDirectMoneyNeed &&
!exactBankOperationsAddressReply &&
!inventoryMarginRankingAddressReply &&
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
candidate.eligible_for_future_hot_runtime &&
@@ -397,6 +397,24 @@ export function createAssistantRoutePolicy(deps) {
const hasTemporalCue = /(?:на\s+эту\s+же\s+дат[ауеы]|на\s+тот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return hasRequestCue && hasTemporalCue;
}
function hasInventoryMarginRankingContinuationSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
const wantsFoundRows =
/(?:покажи|показать|выведи|дай|раскрой|show|list)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур)/iu.test(normalized);
const account41Not01 =
/\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead)/iu.test(normalized);
const periodExpansion =
/(?:расширь|расширить|возьми|давай|покажи|за|на|до|весь|год|квартал|месяц|expand|period)/iu.test(normalized) &&
/(?:январ|феврал|март|апрел|ма[йяе]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return wantsFoundRows || account41Not01 || periodExpansion;
}
function hasOrganizationClarificationTextCue(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
@@ -565,6 +583,7 @@ export function createAssistantRoutePolicy(deps) {
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const followupPreviousIntent = toNonEmptyString(followupContext?.previous_intent);
const followupRootIntent = toNonEmptyString(followupContext?.root_intent);
const followupPreviousFilters = followupContext?.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: null;
@@ -599,6 +618,16 @@ export function createAssistantRoutePolicy(deps) {
hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) ||
hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage)));
const protectedInventoryMarginRankingFollowup = Boolean(followupContext &&
(followupPreviousIntent === "inventory_margin_ranking_for_nomenclature" ||
followupRootIntent === "inventory_margin_ranking_for_nomenclature") &&
!dataScopeMetaQuery &&
!capabilityMetaQuery &&
!dangerOrCoercionSignal &&
(hasInventoryMarginRankingContinuationSignal(rawUserMessage) ||
hasInventoryMarginRankingContinuationSignal(repairedRawUserMessage) ||
hasInventoryMarginRankingContinuationSignal(effectiveAddressUserMessage) ||
hasInventoryMarginRankingContinuationSignal(repairedEffectiveAddressUserMessage)));
const organizationClarificationContinuationDetected = Boolean((followupContext || continuitySnapshot.hasGroundedAddressContext) &&
lastOrganizationClarificationDebug &&
explicitOrganizationClarificationSelection &&
@@ -656,6 +685,7 @@ export function createAssistantRoutePolicy(deps) {
!baseToolGatePreservesAddressLane &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!protectedInventoryShortFollowup &&
!protectedInventoryMarginRankingFollowup &&
!organizationClarificationContinuationDetected &&
!routeCandidateOrganizationClarificationDetected);
const lastAddressAssistantDebug = sessionItems
@@ -1167,6 +1197,7 @@ export function createAssistantRoutePolicy(deps) {
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage) ||
protectedInventoryMarginRankingFollowup ||
inventoryRootRestatementFollowupDetected);
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
@@ -1204,7 +1235,9 @@ export function createAssistantRoutePolicy(deps) {
(!llmContractIntent || llmContractIntent === "unknown"));
const exactAddressIntentProtectedFromSemanticDeepHint = laneProtectionArbitration.exactAddressIntentProtectedFromSemanticDeepHint;
const protectAddressLaneFromFallback = Boolean(
laneProtectionArbitration.protectAddressLaneFromFallback || customerValueRankingAddressSignal
laneProtectionArbitration.protectAddressLaneFromFallback ||
customerValueRankingAddressSignal ||
protectedInventoryMarginRankingFollowup
);
const vatExplainFollowupSignal = Boolean(followupContext &&
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
@@ -1285,7 +1318,7 @@ export function createAssistantRoutePolicy(deps) {
let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane");
let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0");
const semanticAddressLaneRecovery = Boolean(!runAddressLane &&
supportedAddressRouteCandidateDetected &&
(supportedAddressRouteCandidateDetected || protectedInventoryMarginRankingFollowup) &&
!deepAnalysisPreferenceDetected &&
!unsupportedAddressIntentFallbackToDeep &&
!deepAnalysisSignalFallbackToDeep &&
@@ -1294,7 +1327,9 @@ export function createAssistantRoutePolicy(deps) {
if (semanticAddressLaneRecovery) {
runAddressLane = true;
toolGateDecision = "run_address_lane";
toolGateReason = resolvedIntentResolution.intent !== "unknown" || llmContractIntent
toolGateReason = protectedInventoryMarginRankingFollowup
? "followup_context_detected"
: resolvedIntentResolution.intent !== "unknown" || llmContractIntent
? "address_intent_resolver_detected"
: "address_signal_detected";
}