222 lines
7.6 KiB
TypeScript
222 lines
7.6 KiB
TypeScript
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);
|
||
}
|