import type { QueryPlan, AssistantAction, Dimension, AssistantResult, } from "../../../shared/assets/assistant.js"; import { DIMENSIONS } from "../../../shared/assets/assistant.js"; import type { AuthUser } from "../../auth/types.js"; import { filterByPermission } from "../../auth/permissions.js"; import type { Vehicle } from "../../types.js"; import { vehicleRepository } from "../vehicles/repository.js"; import { listDateRange } from "../vehicles/utils.js"; import { assetResult, filterVehicles, toRow, validatePlan } from "./model.js"; export async function execute( vehicles: Vehicle[], plan: QueryPlan, user?: AuthUser, ): Promise { // Public current stock must not broaden access to historical business events. if (plan.kind === "flow" && user) vehicles = filterByPermission(vehicles, user); let result = assetResult(vehicles, plan); if (plan.kind === "flow") { const selected = filterVehicles(vehicles, plan); const byId = new Map(selected.map((v) => [String(v.id), v])); const events = ( await vehicleRepository.getFlowDetailRows(plan.start!, plan.end!) ).filter((e) => byId.has(String(e.truck_id))); const daily = listDateRange(plan.start!, plan.end!).map((date) => ({ date, delivered: 0, returned: 0, replaced: 0, total: 0, })); const byDate = new Map(daily.map((d) => [d.date, d])); for (const e of events) { const d = byDate.get(e.stat_date); if (!d) continue; d[e.type]++; if (e.type === "replaced") d.delivered++; d.total++; } result = { ...result, title: "交退换车趋势", source: "运维签章时间 · 业务车次 · 替换交车已计入交车", daily, rows: events.map((e) => ({ ...toRow(byId.get(String(e.truck_id))!), id: String(e.id), date: e.stat_date, event: e.type_label, })), groups: daily.map((d) => ({ name: d.date, count: d.total, operating: 0, rate: 0, })), metrics: [ { label: "交车(含替换)", value: daily.reduce((n, d) => n + d.delivered, 0), unit: "车次", }, { label: "还车", value: daily.reduce((n, d) => n + d.returned, 0), unit: "车次", }, { label: "其中替换交车", value: daily.reduce((n, d) => n + d.replaced, 0), unit: "车次", }, ], summary: `${plan.start} 至 ${plan.end} 共 ${events.length} 笔流转业务;交车已包含替换交车,不能将两者相加。`, }; } if (plan.kind === "flow") { result.outcome = result.rows.length ? "complete" : "empty"; result.summary = `${plan.start} 至 ${plan.end},交车 ${result.metrics[0].value} 车次(含替换交车 ${result.metrics[2].value} 车次),还车 ${result.metrics[1].value} 车次。${result.rows.length ? "" : "该范围暂无交退换业务记录。"}替换交车已计入交车,不能重复相加。`; result.insights = [ "交车包含替换交车,不能将二者相加。流转范围按当前账号可见车辆筛选。", ]; result.exceptions = []; } if (plan.kind === "flow" && plan.comparePrevious) { const span = Date.parse(plan.end!) - Date.parse(plan.start!) + 86400000; const start = new Date(Date.parse(plan.start!) - span) .toISOString() .slice(0, 10); const end = new Date(Date.parse(plan.start!) - 86400000) .toISOString() .slice(0, 10); const previous = await execute(vehicles, { ...plan, start, end, comparePrevious: false, }, user); result.comparisonPeriod = { start, end }; result.comparison = result.metrics.map((m, i) => ({ label: m.label, current: m.value, previous: previous.metrics[i].value, delta: m.value - previous.metrics[i].value, rate: previous.metrics[i].value ? Math.round( ((m.value - previous.metrics[i].value) / previous.metrics[i].value) * 1000, ) / 10 : null, })); result.summary += ` 对比 ${start} 至 ${end}:${result.comparison.map(m => `${m.label}${m.delta === 0 ? "持平" : `${m.delta > 0 ? "增加" : "减少"} ${Math.abs(m.delta)} 车次`}`).join(",")}。`; result.insights!.push("上述变化是业务车次对比;缺少经营目标、收入和成本依据,不能仅凭车次判断经营改善或变差,也不能据此推断车辆净增长。"); result.insights!.push( `对比上一等长周期 ${start} 至 ${end};基期为0时不计算增长率。`, ); } return result; } export function applyAction( plan: QueryPlan, action: AssistantAction, result: AssistantResult, ): QueryPlan { const next = structuredClone(plan); if (!["restore", "view"].includes(action.type)) delete next.responseMode; if (next.analysis === "briefing" && ["category", "group", "drill", "exception"].includes(action.type)) next.analysis = "distribution"; switch (action.type) { case "clear-filter": if (!DIMENSIONS.includes(action.dimension)) throw new Error("筛选维度无效。"); delete next.filters[action.dimension]; if (next.excludeFilters) delete next.excludeFilters[action.dimension]; break; case "reset-filters": next.filters = {}; delete next.excludeFilters; delete next.vehicleIds; delete next.exception; delete next.topN; break; case "sort": next.sortBy = action.sortBy; next.sortOrder = action.sortOrder; next.topN = action.topN; break; case "restore": break; case "exception": next.kind = "assets"; next.category = "abnormal"; next.exception = action.exception; next.view = "list"; delete next.start; delete next.end; delete next.comparePrevious; break; case "view": next.view = action.view; break; case "category": next.kind = "assets"; next.category = action.category; delete next.start; delete next.end; delete next.comparePrevious; delete next.exception; break; case "analysis": next.analysis = action.analysis; break; case "group": if (next.kind === "flow") throw new Error("交退换趋势暂按日统计,不能切换车辆分组。"); next.groupBy = action.groupBy; next.secondaryGroupBy = action.secondaryGroupBy; break; case "compare-period": if (next.kind !== "flow") throw new Error("当前库存没有历史快照,不能进行跨期比较。"); next.comparePrevious = true; break; case "day": if ( next.kind !== "flow" || !result.daily?.some((d) => d.date === action.date) ) throw new Error("日期不在当前结果内。"); next.start = action.date; next.end = action.date; next.view = "list"; next.comparePrevious = false; break; case "drill": { if ( next.kind === "flow" || !action.keys || typeof action.keys !== "object" || Array.isArray(action.keys) ) throw new Error("下钻条件无效。"); const entries = Object.entries(action.keys); const group = result.groups.find( (g) => entries.length > 0 && Object.keys(g.keys || {}).length === entries.length && entries.every(([k, v]) => g.keys?.[k as Dimension] === v), ); if (!group) throw new Error("分组已变化,请刷新后重试。"); for (const [k, v] of entries) next.filters[k as Dimension] = [v]; next.groupBy = DIMENSIONS.find((d) => !next.filters[d]) || "none"; delete next.secondaryGroupBy; delete next.topN; next.view = next.groupBy === "none" ? "list" : "chart"; break; } default: throw new Error("分析操作不受支持。"); } return validatePlan(next); }