ARCH: добавить помесячный MCP discovery для нетто-потока

This commit is contained in:
2026-04-21 08:18:09 +03:00
parent 99a568241d
commit 4baa54fe81
19 changed files with 753 additions and 20 deletions
@@ -40,10 +40,21 @@ export interface AssistantMcpDiscoveryDerivedActivityPeriod {
inference_basis: "first_and_latest_confirmed_1c_activity_rows";
}
export type AssistantMcpDiscoveryAggregationAxis = "month";
export type AssistantMcpDiscoveryNetDirection = "net_incoming" | "net_outgoing" | "balanced";
export interface AssistantMcpDiscoveryValueFlowMonthBucket {
month_bucket: string;
rows_with_amount: number;
total_amount: number;
total_amount_human_ru: string;
}
export interface AssistantMcpDiscoveryDerivedValueFlow {
value_flow_direction: "incoming_customer_revenue" | "outgoing_supplier_payout";
counterparty: string | null;
period_scope: string | null;
aggregation_axis: AssistantMcpDiscoveryAggregationAxis | null;
rows_matched: number;
rows_with_amount: number;
total_amount: number;
@@ -51,6 +62,7 @@ export interface AssistantMcpDiscoveryDerivedValueFlow {
first_movement_date: string | null;
latest_movement_date: string | null;
coverage_limited_by_probe_limit: boolean;
monthly_breakdown: AssistantMcpDiscoveryValueFlowMonthBucket[];
inference_basis: "sum_of_confirmed_1c_value_flow_rows";
}
@@ -64,15 +76,30 @@ export interface AssistantMcpDiscoveryValueFlowSideSummary {
coverage_limited_by_probe_limit: boolean;
}
export interface AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket {
month_bucket: string;
incoming_total_amount: number;
incoming_total_amount_human_ru: string;
incoming_rows_with_amount: number;
outgoing_total_amount: number;
outgoing_total_amount_human_ru: string;
outgoing_rows_with_amount: number;
net_amount: number;
net_amount_human_ru: string;
net_direction: AssistantMcpDiscoveryNetDirection;
}
export interface AssistantMcpDiscoveryDerivedBidirectionalValueFlow {
counterparty: string | null;
period_scope: string | null;
aggregation_axis: AssistantMcpDiscoveryAggregationAxis | null;
incoming_customer_revenue: AssistantMcpDiscoveryValueFlowSideSummary;
outgoing_supplier_payout: AssistantMcpDiscoveryValueFlowSideSummary;
net_amount: number;
net_amount_human_ru: string;
net_direction: "net_incoming" | "net_outgoing" | "balanced";
net_direction: AssistantMcpDiscoveryNetDirection;
coverage_limited_by_probe_limit: boolean;
monthly_breakdown: AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket[];
inference_basis: "incoming_minus_outgoing_confirmed_1c_value_flow_rows";
}
@@ -138,6 +165,13 @@ function pushUnique(target: string[], value: string): void {
}
}
function aggregationAxisForPlanner(
planner: AssistantMcpDiscoveryPlannerContract
): AssistantMcpDiscoveryAggregationAxis | null {
const axis = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_aggregation_axis)?.toLowerCase();
return axis === "month" ? "month" : null;
}
function firstEntityCandidate(planner: AssistantMcpDiscoveryPlannerContract): string | null {
const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? [];
for (const candidate of candidates) {
@@ -367,6 +401,21 @@ function rowAmountValue(row: Record<string, unknown>): number | null {
return null;
}
function monthBucketFromIsoDate(isoDate: string | null): string | null {
const match = isoDate?.match(/^(\d{4})-(\d{2})-\d{2}$/);
return match ? `${match[1]}-${match[2]}` : null;
}
function netDirectionFromAmount(amount: number): AssistantMcpDiscoveryNetDirection {
if (amount > 0) {
return "net_incoming";
}
if (amount < 0) {
return "net_outgoing";
}
return "balanced";
}
function monthDiff(firstIsoDate: string, latestIsoDate: string): number {
const first = new Date(`${firstIsoDate}T00:00:00.000Z`);
const latest = new Date(`${latestIsoDate}T00:00:00.000Z`);
@@ -432,12 +481,88 @@ function formatAmountHumanRu(amount: number): string {
return `${formatted} руб.`;
}
function deriveValueFlowMonthBreakdown(
result: AddressMcpQueryExecutorResult | null,
aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null
): AssistantMcpDiscoveryValueFlowMonthBucket[] {
if (!result || result.error || aggregationAxis !== "month") {
return [];
}
const buckets = new Map<string, { rows_with_amount: number; total_amount: number }>();
for (const row of result.rows) {
const isoDate = rowDateValue(row);
const monthBucket = monthBucketFromIsoDate(isoDate);
const amount = rowAmountValue(row);
if (!monthBucket || amount === null) {
continue;
}
const current = buckets.get(monthBucket) ?? { rows_with_amount: 0, total_amount: 0 };
current.rows_with_amount += 1;
current.total_amount += amount;
buckets.set(monthBucket, current);
}
return Array.from(buckets.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([monthBucket, bucket]) => ({
month_bucket: monthBucket,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount)
}));
}
function deriveBidirectionalValueFlowMonthBreakdown(input: {
incomingResult: AddressMcpQueryExecutorResult | null;
outgoingResult: AddressMcpQueryExecutorResult | null;
aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null;
}): AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket[] {
if (input.aggregationAxis !== "month") {
return [];
}
const incomingBuckets = deriveValueFlowMonthBreakdown(input.incomingResult, "month");
const outgoingBuckets = deriveValueFlowMonthBreakdown(input.outgoingResult, "month");
const allMonthBuckets = new Set<string>();
for (const bucket of incomingBuckets) {
allMonthBuckets.add(bucket.month_bucket);
}
for (const bucket of outgoingBuckets) {
allMonthBuckets.add(bucket.month_bucket);
}
const incomingByMonth = new Map(incomingBuckets.map((bucket) => [bucket.month_bucket, bucket]));
const outgoingByMonth = new Map(outgoingBuckets.map((bucket) => [bucket.month_bucket, bucket]));
return Array.from(allMonthBuckets)
.sort((left, right) => left.localeCompare(right))
.map((monthBucket) => {
const incoming = incomingByMonth.get(monthBucket);
const outgoing = outgoingByMonth.get(monthBucket);
const incomingAmount = incoming?.total_amount ?? 0;
const outgoingAmount = outgoing?.total_amount ?? 0;
const netAmount = incomingAmount - outgoingAmount;
return {
month_bucket: monthBucket,
incoming_total_amount: incomingAmount,
incoming_total_amount_human_ru: formatAmountHumanRu(incomingAmount),
incoming_rows_with_amount: incoming?.rows_with_amount ?? 0,
outgoing_total_amount: outgoingAmount,
outgoing_total_amount_human_ru: formatAmountHumanRu(outgoingAmount),
outgoing_rows_with_amount: outgoing?.rows_with_amount ?? 0,
net_amount: netAmount,
net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)),
net_direction: netDirectionFromAmount(netAmount)
};
});
}
function deriveValueFlow(
result: AddressMcpQueryExecutorResult | null,
counterparty: string | null,
periodScope: string | null,
direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"],
probeLimit: number
probeLimit: number,
aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null
): AssistantMcpDiscoveryDerivedValueFlow | null {
if (!result || result.error || result.matched_rows <= 0) {
return null;
@@ -462,6 +587,7 @@ function deriveValueFlow(
value_flow_direction: direction,
counterparty,
period_scope: periodScope,
aggregation_axis: aggregationAxis,
rows_matched: result.matched_rows,
rows_with_amount: rowsWithAmount,
total_amount: totalAmount,
@@ -469,6 +595,7 @@ function deriveValueFlow(
first_movement_date: dates[0] ?? null,
latest_movement_date: dates[dates.length - 1] ?? null,
coverage_limited_by_probe_limit: result.matched_rows >= probeLimit,
monthly_breakdown: deriveValueFlowMonthBreakdown(result, aggregationAxis),
inference_basis: "sum_of_confirmed_1c_value_flow_rows"
};
}
@@ -519,6 +646,7 @@ function deriveBidirectionalValueFlow(input: {
counterparty: string | null;
periodScope: string | null;
probeLimit: number;
aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null;
}): AssistantMcpDiscoveryDerivedBidirectionalValueFlow | null {
const incoming = deriveValueFlowSideSummary(input.incomingResult, input.probeLimit);
const outgoing = deriveValueFlowSideSummary(input.outgoingResult, input.probeLimit);
@@ -529,13 +657,19 @@ function deriveBidirectionalValueFlow(input: {
return {
counterparty: input.counterparty,
period_scope: input.periodScope,
aggregation_axis: input.aggregationAxis,
incoming_customer_revenue: incoming,
outgoing_supplier_payout: outgoing,
net_amount: netAmount,
net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)),
net_direction: netAmount > 0 ? "net_incoming" : netAmount < 0 ? "net_outgoing" : "balanced",
net_direction: netDirectionFromAmount(netAmount),
coverage_limited_by_probe_limit:
incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit,
monthly_breakdown: deriveBidirectionalValueFlowMonthBreakdown({
incomingResult: input.incomingResult,
outgoingResult: input.outgoingResult,
aggregationAxis: input.aggregationAxis
}),
inference_basis: "incoming_minus_outgoing_confirmed_1c_value_flow_rows"
};
}
@@ -620,10 +754,16 @@ function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueF
if (!derived) {
return [];
}
const facts: string[] = [];
if (derived.value_flow_direction === "outgoing_supplier_payout") {
return ["Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"];
facts.push("Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows");
} else {
facts.push("Counterparty value-flow total was calculated from confirmed 1C movement rows");
}
return ["Counterparty value-flow total was calculated from confirmed 1C movement rows"];
if (derived.aggregation_axis === "month" && derived.monthly_breakdown.length > 0) {
facts.push("Counterparty monthly value-flow breakdown was grouped by month over confirmed 1C movement rows");
}
return facts;
}
function buildBidirectionalValueFlowInferredFacts(
@@ -632,7 +772,11 @@ function buildBidirectionalValueFlowInferredFacts(
if (!derived) {
return [];
}
return ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"];
const facts = ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"];
if (derived.aggregation_axis === "month" && derived.monthly_breakdown.length > 0) {
facts.push("Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows");
}
return facts;
}
function buildLifecycleUnknownFacts(): string[] {
@@ -786,6 +930,7 @@ export async function executeAssistantMcpDiscoveryPilot(
const counterparty = firstEntityCandidate(planner);
const dateScope = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_date_scope);
const aggregationAxis = aggregationAxisForPlanner(planner);
if (valueFlowPilotEligible) {
let queryResult: AddressMcpQueryExecutorResult | null = null;
@@ -864,10 +1009,14 @@ export async function executeAssistantMcpDiscoveryPilot(
outgoingResult,
counterparty,
periodScope: dateScope,
probeLimit: planner.discovery_plan.execution_budget.max_rows_per_probe
probeLimit: planner.discovery_plan.execution_budget.max_rows_per_probe,
aggregationAxis
});
if (derivedBidirectionalValueFlow) {
pushReason(reasonCodes, "pilot_derived_bidirectional_value_flow_from_confirmed_rows");
if (aggregationAxis === "month" && derivedBidirectionalValueFlow.monthly_breakdown.length > 0) {
pushReason(reasonCodes, "pilot_derived_bidirectional_monthly_breakdown_from_confirmed_rows");
}
}
const evidence = resolveAssistantMcpDiscoveryEvidence({
plan: planner.discovery_plan,
@@ -959,10 +1108,14 @@ export async function executeAssistantMcpDiscoveryPilot(
counterparty,
dateScope,
valueFlowProfile.direction,
planner.discovery_plan.execution_budget.max_rows_per_probe
planner.discovery_plan.execution_budget.max_rows_per_probe,
aggregationAxis
);
if (derivedValueFlow) {
pushReason(reasonCodes, "pilot_derived_value_flow_from_confirmed_rows");
if (aggregationAxis === "month" && derivedValueFlow.monthly_breakdown.length > 0) {
pushReason(reasonCodes, "pilot_derived_value_flow_monthly_breakdown_from_confirmed_rows");
}
}
const evidence = resolveAssistantMcpDiscoveryEvidence({
plan: planner.discovery_plan,