Files
ln-bi/src/server/routes/asset-assistant/execute.ts
T
2026-09-23 18:05:10 +08:00

222 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
QueryPlan,
AssistantAction,
Dimension,
AssistantResult,
} from "../../../shared/assets/assistant.js";
import { DIMENSIONS } from "../../../shared/assets/assistant.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,
): Promise<AssistantResult> {
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,
});
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);
}