feat(asset-ai): add contextual analysis and unmet-demand feedback
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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.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.insights!.push(
|
||||
`对比上一等长周期 ${start} 至 ${end};基期为0时不计算增长率。`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function applyAction(
|
||||
plan: QueryPlan,
|
||||
action: AssistantAction,
|
||||
result: AssistantResult,
|
||||
): QueryPlan {
|
||||
const next = structuredClone(plan);
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user