diff --git a/docs/permission-preview.md b/docs/permission-preview.md
index a32a467..aa94bd9 100644
--- a/docs/permission-preview.md
+++ b/docs/permission-preview.md
@@ -1,6 +1,6 @@
# 权限视角预览
-授权账号登录后,直接访问 `/who` 搜索同一组织的有效用户,先核对角色与数据范围,再进入只读预览。常规页面不展示入口,未授权用户直接访问链接也无法使用。预览使用目标账号真实角色:BI-Leader/所有权限/数智中心为全量,BI-Leader-Dep为本部门,其余为本人负责;多角色时全量优先。保持各业务模块现有的范围规则,库存公共汇总等不会因此变为个人数据。
+授权账号登录后,直接访问 `/who` 搜索同一组织的有效用户,先核对角色与数据范围,再进入只读预览。常规页面不展示入口,未授权用户直接访问链接也无法使用。预览使用目标账号真实角色:BI-Leader/所有权限/数智中心为全量,BI-Leader-Dep为本部门,其余为本人负责;多角色时全量优先。资产库存(包括待交车、异动)对三类角色均展示全量,汇总、分组与车辆明细口径一致;所属公司筛选仍然生效。非库存资产及交还车历史维持部门/个人范围,公共库存权限不扩展历史业务访问。
## 授权与身份来源
diff --git a/src/auth/PermissionPreview.tsx b/src/auth/PermissionPreview.tsx
index 9d1144a..a327485 100644
--- a/src/auth/PermissionPreview.tsx
+++ b/src/auth/PermissionPreview.tsx
@@ -6,8 +6,8 @@ import './PermissionPreview.css';
const scopes = [
{ level: 'full', title: '全量数据', role: 'BI-Leader', detail: '查看全量业务数据。' },
- { level: 'department', title: '部门数据', role: 'BI-Leader-Dep', detail: '查看当前用户所在部门的数据。' },
- { level: 'personal', title: '本人数据', role: '其他角色默认', detail: '查看本人负责的车辆。' },
+ { level: 'department', title: '部门数据', role: 'BI-Leader-Dep', detail: '运营数据限本部门,库存查看全量。' },
+ { level: 'personal', title: '本人数据', role: '其他角色默认', detail: '运营数据限本人负责,库存查看全量。' },
];
export function permissionScope(user: AuthUser) {
return scopes.find(s => s.level === user.permissionLevel)?.title || '本人数据';
@@ -69,7 +69,7 @@ function PreviewPicker({ onClose }: { onClose: () => void }) {
{scopes.map(scope =>
{scope.title}{scope.role}{scope.detail}
)}
-
菜单和数据随目标用户权限变化;共享汇总仍按页面注明的公共口径展示。预览期间仅可查看,不能修改业务数据。
+
菜单和运营数据随目标用户权限变化;库存(含待交车、异动)统一展示全量,仍可按所属公司筛选。预览期间仅可查看,不能修改业务数据。
{error &&
{error}
}
{!hasQuery ?
输入中文姓名、用户名或部门关键词,模糊搜索人员。
:
diff --git a/src/modules/assets/analytics-model.ts b/src/modules/assets/analytics-model.ts
index fce49c1..4d1cf1b 100644
--- a/src/modules/assets/analytics-model.ts
+++ b/src/modules/assets/analytics-model.ts
@@ -285,8 +285,8 @@ const BUSINESS_DIMENSIONS: Record
dimension !== 'manager' && dimension !== 'customer')
+ const common = ['inventory', 'pending', 'abnormal'].includes(view)
+ ? COMMON_DIMENSIONS.filter(dimension => dimension !== 'department' && dimension !== 'manager' && dimension !== 'customer')
: COMMON_DIMENSIONS;
return [...common, ...BUSINESS_DIMENSIONS[view]];
}
diff --git a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx
index fd669cc..d8c27de 100644
--- a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx
+++ b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx
@@ -49,7 +49,7 @@ export function InventoryStatisticsSection({
库存统计
- {preview ? '公共库存汇总 · 明细按当前用户权限查看' : '实时更新库存数据'}
+ {preview ? '全量库存 · 汇总与明细口径一致' : '实时更新库存数据'}
diff --git a/src/server/auth/permissions.ts b/src/server/auth/permissions.ts
index e5e671a..695779e 100644
--- a/src/server/auth/permissions.ts
+++ b/src/server/auth/permissions.ts
@@ -1,4 +1,13 @@
import type { AuthUser } from './types.js';
+import type { Vehicle } from '../types.js';
+import { isInventoryVehicle } from '../routes/vehicles/model.js';
+
+/** 当前库存为公共资产;非库存车辆继续遵守本人/部门权限。历史业务使用原权限过滤。 */
+export function filterAssetVehiclesByPermission(items: Vehicle[], user: AuthUser): Vehicle[] {
+ if (user.permissionLevel === 'full') return items;
+ const authorized = new Set(filterByPermission(items, user));
+ return items.filter(vehicle => isInventoryVehicle(vehicle) || authorized.has(vehicle));
+}
/** 客户名称脱敏 */
export function maskCustomerName(name: string | null): string | null {
diff --git a/src/server/routes/asset-assistant/analysis.test.ts b/src/server/routes/asset-assistant/analysis.test.ts
index dc3aa06..ba1d5d6 100644
--- a/src/server/routes/asset-assistant/analysis.test.ts
+++ b/src/server/routes/asset-assistant/analysis.test.ts
@@ -129,7 +129,7 @@ test("multi-level clicks and follow-ups preserve exact scope, branching and reco
const root = await ask(app, {
message: "各区域的库存车辆有多少?按数量排序。",
});
- assert.equal(root.rows.length, 4);
+ assert.equal(root.rows.length, 5);
assert.equal(root.plan.category, "inventory");
const region = await ask(app, {
...based(root),
@@ -137,13 +137,13 @@ test("multi-level clicks and follow-ups preserve exact scope, branching and reco
});
assert.deepEqual(region.plan.filters, { region: ["华东"] });
assert.equal(region.plan.groupBy, "city");
- assert.equal(region.rows.length, 3);
+ assert.equal(region.rows.length, 4);
const city = await ask(app, {
...based(region),
action: { type: "drill", keys: { city: "嘉兴市" } },
});
assert.deepEqual(city.plan.filters, { region: ["华东"], city: ["嘉兴市"] });
- assert.equal(city.rows.length, 2);
+ assert.equal(city.rows.length, 3);
const types = await ask(app, { ...based(city), message: "再按车型拆分" });
assert.deepEqual(types.plan.filters, city.plan.filters);
assert.equal(types.plan.groupBy, "type");
@@ -167,7 +167,7 @@ test("multi-level clicks and follow-ups preserve exact scope, branching and reco
action: { type: "restore" },
});
assert.deepEqual(branch.plan.filters, {});
- assert.equal(branch.rows.length, 4);
+ assert.equal(branch.rows.length, 5);
assert.equal(detail.breadcrumbs?.length, 6);
for (const r of [root, region, city, types, subsets, detail])
assert.equal(
diff --git a/src/server/routes/asset-assistant/composition.test.ts b/src/server/routes/asset-assistant/composition.test.ts
index c288df7..0a10514 100644
--- a/src/server/routes/asset-assistant/composition.test.ts
+++ b/src/server/routes/asset-assistant/composition.test.ts
@@ -15,8 +15,8 @@ const plan = (overrides: Partial
= {}): QueryPlan => ({
const part = (id: string, title: string, queryPlan: QueryPlan, display = "table") => ({ id, title, question: title, plan: queryPlan, display });
let serial = 0;
-function fixture(t: TestContext) {
- const user: AuthUser = { userId: `composition-${++serial}`, userName: "组合验收", loginName: "composition", depCode: "D", depName: "合成部门", permissionLevel: "personal", roles: [] };
+function fixture(t: TestContext, permissionLevel: AuthUser["permissionLevel"] = "personal") {
+ const user: AuthUser = { userId: `composition-${++serial}`, userName: "组合验收", loginName: "composition", depCode: "D", depName: "合成部门", permissionLevel, roles: [] };
const vehicles = [
{ id: "jx-inventory", type: "4.5T", city: "嘉兴市", status: "Inventory", operationStatus: "3" },
{ id: "hz-inventory", type: "18T", city: "杭州市", status: "Inventory", operationStatus: "3" },
@@ -24,7 +24,7 @@ function fixture(t: TestContext) {
{ id: "jx-operating", type: "18T", city: "嘉兴市", status: "Operating", operationStatus: "1" },
{ id: "south-inventory", type: "49T", city: "广州市", province: "广东省", status: "Inventory", operationStatus: "3" },
{ id: "other-company", type: "4.5T", city: "嘉兴市", subjectOrg: "合成公司B", status: "Inventory", operationStatus: "3" },
- { id: "other-owner", type: "4.5T", city: "嘉兴市", managerId: "another-user", status: "Inventory", operationStatus: "3" },
+ { id: "other-owner", type: "4.5T", city: "嘉兴市", managerId: "another-user", departmentName: "其他部门", status: "Inventory", operationStatus: "3" },
].map((v) => ({ province: "浙江省", model: "合成车型", subjectOrg: company, departmentName: "合成部门", managerId: user.userId, plateNumber: `合成-${v.id}`, vin: `SYNTHETIC-${v.id}`, ...v }) as Vehicle);
const getVehicles = t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
let flowFailure = false;
@@ -74,7 +74,7 @@ function fixture(t: TestContext) {
assert.ok(result, "A composed response must produce a result even if one section fails");
return result;
}
- return { ask, proposals, requests, presentationRequests, gaps, getVehicles, getFlow, vehicles, failFlow: () => { flowFailure = true; } };
+ return { app, ask, proposals, requests, presentationRequests, gaps, getVehicles, getFlow, vehicles, failFlow: () => { flowFailure = true; } };
}
const based = (result: AssistantResult) => ({ sessionId: result.sessionId, baseResultId: result.resultId });
const ids = (result: AssistantResult) => result.rows.map((row) => row.id).sort();
@@ -93,7 +93,7 @@ test("one model decision executes four independent analyses in the same authoriz
const result = await ask({ message: "分别展示华东资产概况、库存分布、待交车清单和运营车型统计。" });
assert.equal(result.sections?.length, 4);
assert.ok(result.resultId);
- const counts = [4, 3, 1, 1];
+ const counts = [5, 4, 1, 1];
const childIds: string[] = [];
for (const [index, section] of result.sections!.entries()) {
assert.equal(section.id, sections[index].id);
@@ -106,7 +106,7 @@ test("one model decision executes four independent analyses in the same authoriz
assert.equal(section.result.sessionId, result.sessionId);
assert.ok(section.result.resultId);
childIds.push(section.result.resultId!);
- assert.ok(ids(section.result).every((id) => !["other-company", "other-owner", "south-inventory"].includes(id)));
+ assert.ok(ids(section.result).every((id) => !["other-company", "south-inventory"].includes(id)));
}
assert.equal(new Set([result.resultId, ...childIds]).size, 5, "Root and each analysis need distinct result identities");
assert.equal(result.outcome, "complete");
@@ -125,7 +125,7 @@ for (const failedFirst of [true, false]) {
assert.equal(result.sections?.length, 2);
const asset = result.sections!.find((section) => section.id === "inventory")!;
const failed = result.sections!.find((section) => section.id === "flow")!;
- assert.equal(asset.result?.rows.length, 3);
+ assert.equal(asset.result?.rows.length, 4);
assert.ok(asset.result?.resultId);
assert.ok(failed.error);
assert.equal(failed.result, undefined);
@@ -146,7 +146,7 @@ test("drilling and following up a child preserves that child's category and filt
assert.equal(drill.sections, undefined, "A local block action must return a normal result for in-place replacement");
assert.equal(drill.plan.category, "inventory");
assert.deepEqual(drill.plan.filters, { region: ["华东"], city: ["嘉兴市"] });
- assert.deepEqual(ids(drill), ["jx-inventory", "jx-pending"]);
+ assert.deepEqual(ids(drill), ["jx-inventory", "jx-pending", "other-owner"]);
assert.equal(requests.length, 1, "Click-based drilling should not trigger another planning request");
assert.equal(presentationRequests.length, 2, "The original result and clicked result each get one display generation pass");
proposals.push({ kind: "query", plan: plan({ category: "pending", view: "list", filters: { region: ["华东"], city: ["嘉兴市"] } }) });
@@ -169,7 +169,7 @@ test("the parent conversation includes all successful analyses for cross-section
const analyses = context.analyses || context.resultContext?.analyses;
assert.ok(Array.isArray(analyses), "The model needs every analysis, not only the root's first plan");
assert.equal(analyses.length, 2);
- assert.ok(analyses.some((analysis: { plan: QueryPlan; metrics: { value: number }[] }) => analysis.plan.category === "inventory" && analysis.metrics[0].value === 3));
+ assert.ok(analyses.some((analysis: { plan: QueryPlan; metrics: { value: number }[] }) => analysis.plan.category === "inventory" && analysis.metrics[0].value === 4));
assert.ok(analyses.some((analysis: { id: string; plan: QueryPlan; metrics: { value: number }[] }) => analysis.id === pending.resultId && analysis.plan.category === "pending" && analysis.metrics[0].value === 1));
assert.equal(next.plan.category, "pending");
assert.deepEqual(ids(next), ["jx-pending"]);
@@ -185,7 +185,7 @@ test("an unknown display or invalid child plan cannot discard the healthy siblin
] });
const result = await ask({ message: "库存分布、待交清单和另外一项查询分别显示。" });
const healthy = result.sections?.find((section) => section.id === "inventory")?.result || result;
- assert.equal(healthy.rows.length, 3);
+ assert.equal(healthy.rows.length, 4);
assert.equal(healthy.plan.category, "inventory");
const display = result.sections?.find((section) => section.id === "unknown-display");
if (display) {
@@ -207,8 +207,8 @@ test("a simple query keeps the legacy result shape and uses one planning call",
proposals.push({ kind: "query", plan: plan() });
const result = await ask({ message: "华东库存按城市统计" });
assert.equal(result.sections, undefined);
- assert.equal(result.rows.length, 3);
- assert.equal(result.metrics[0].value, 3);
+ assert.equal(result.rows.length, 4);
+ assert.equal(result.metrics[0].value, 4);
assert.ok(result.resultId);
assert.equal(requests.length, 1);
assert.equal(presentationRequests.length, 1);
@@ -223,11 +223,11 @@ test("inventory subset percentages use the full inventory denominator even when
proposals.push({ kind: "query", plan: stock, queries: [part("stock-share", "华东库存分布与子集占比", stock, "chart"), part("pending", "华东待交车清单", plan({ category: "pending", view: "list" }), "list")] });
const result = await ask({ message: "华东库存最多的城市和待交车清单分别展示,同时给待交车与异动占库存比例。" });
const stockResult = result.sections!.find((section) => section.id === "stock-share")!.result!;
- assert.equal(stockResult.rows.length, 3);
+ assert.equal(stockResult.rows.length, 4);
assert.equal(stockResult.groups.length, 1);
- assert.equal(stockResult.groups[0].count, 2);
- assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.value, 33.3);
- assert.equal(stockResult.metrics.find((metric) => metric.label === "异动占库存")?.value, 33.3);
+ assert.equal(stockResult.groups[0].count, 3);
+ assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.value, 25);
+ assert.equal(stockResult.metrics.find((metric) => metric.label === "异动占库存")?.value, 25);
assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.unit, "%");
});
@@ -249,7 +249,7 @@ test("explicitly changing the parent region for composed analyses does not resto
const { ask, proposals } = fixture(t);
proposals.push({ kind: "query", plan: plan({ filters: { region: ["华东"], city: ["嘉兴市"] } }) });
const previous = await ask({ message: "只看华东嘉兴库存" });
- assert.equal(previous.rows.length, 2);
+ assert.equal(previous.rows.length, 3);
const southernInventory = plan({ filters: { region: ["华南"] } });
const southernOperating = plan({ category: "operating", filters: { region: ["华南"] } });
proposals.push({ kind: "query", plan: southernInventory, queries: [
@@ -262,3 +262,28 @@ test("explicitly changing the parent region for composed analyses does not resto
assert.deepEqual(ids(inventoryResult), ["south-inventory"]);
assert.ok(changed.sections!.every((section) => !section.result?.plan.filters.city));
});
+
+for (const permissionLevel of ["personal", "department"] as const) {
+ test(`${permissionLevel} can analyze public stock but flow, comparisons, actions and exports retain original scope`, async (t) => {
+ const { app, ask, proposals } = fixture(t, permissionLevel);
+ const compared = { ...flow(), plan: { ...flow().plan, comparePrevious: true } };
+ proposals.push({ kind: "query", plan: plan(), queries: [inventory(), compared] });
+ const result = await ask({ message: "库存和交退换趋势分别展示,并对比上期" });
+ const stock = result.sections!.find(section => section.id === "inventory")!.result!;
+ const business = result.sections!.find(section => section.id === "flow")!.result!;
+ assert.ok(ids(stock).includes("other-owner"), "Other departments' stock is public");
+ assert.deepEqual(ids(business), ["event-valid"], "Public stock does not expose other owners' historical events");
+ assert.equal(business.comparison![0].current, 1);
+ assert.equal(business.comparison![0].previous, 1);
+ const day = await ask({ ...based(business), action: { type: "day", date: business.plan.start! } });
+ assert.deepEqual(ids(day), ["event-valid"]);
+ const exported = await app.request("/ai/export", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sessionId: business.sessionId, resultId: business.resultId, subject: company }),
+ });
+ assert.equal(exported.status, 200);
+ const csv = await exported.text();
+ assert.match(csv, /合成-jx-inventory/);
+ assert.doesNotMatch(csv, /合成-other-owner|合成-other-company/);
+ });
+}
diff --git a/src/server/routes/asset-assistant/composition.ts b/src/server/routes/asset-assistant/composition.ts
index 46210a6..e356dc4 100644
--- a/src/server/routes/asset-assistant/composition.ts
+++ b/src/server/routes/asset-assistant/composition.ts
@@ -1,4 +1,5 @@
import type { AnalysisQuery, AnalysisSection, AssistantResult } from "../../../shared/assets/assistant.js";
+import type { AuthUser } from "../../auth/types.js";
import type { Vehicle } from "../../types.js";
import { execute } from "./execute.js";
import { followupSuggestions, resultScope } from "./presentation.js";
@@ -6,12 +7,12 @@ import { followupSuggestions, resultScope } from "./presentation.js";
/** Independent queries share one authorized vehicle snapshot. One failed source must not erase other answers. */
export async function executeComposition(
vehicles: Vehicle[], queries: AnalysisQuery[], subject: string | null,
- signal: AbortSignal,
+ signal: AbortSignal, user?: AuthUser,
): Promise {
const sections: AnalysisSection[] = await Promise.all(queries.map(async query => {
const { plan, ...section } = query;
try {
- const result = await execute(vehicles, plan);
+ const result = await execute(vehicles, plan, user);
result.scope = resultScope(plan, subject);
result.suggestions = followupSuggestions(result);
return { ...section, result };
diff --git a/src/server/routes/asset-assistant/execute.ts b/src/server/routes/asset-assistant/execute.ts
index d4153cd..4ce37c3 100644
--- a/src/server/routes/asset-assistant/execute.ts
+++ b/src/server/routes/asset-assistant/execute.ts
@@ -5,6 +5,8 @@ import type {
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";
@@ -12,7 +14,10 @@ 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);
@@ -94,7 +99,7 @@ export async function execute(
start,
end,
comparePrevious: false,
- });
+ }, user);
result.comparisonPeriod = { start, end };
result.comparison = result.metrics.map((m, i) => ({
label: m.label,
diff --git a/src/server/routes/asset-assistant/experience.test.ts b/src/server/routes/asset-assistant/experience.test.ts
index 7d76771..e7eb5da 100644
--- a/src/server/routes/asset-assistant/experience.test.ts
+++ b/src/server/routes/asset-assistant/experience.test.ts
@@ -64,9 +64,11 @@ function setup(t: TestContext) {
operationStatus: "1",
},
{
+ // Inventory is public; use another owner's operating vehicle to test restricted data.
id: "not-authorized",
city: "嘉兴市",
- status: "Inventory",
+ status: "Operating",
+ operationStatus: "1",
type: "18T",
managerId: "another-user",
},
diff --git a/src/server/routes/asset-assistant/index.ts b/src/server/routes/asset-assistant/index.ts
index 9d7c53e..601a13e 100644
--- a/src/server/routes/asset-assistant/index.ts
+++ b/src/server/routes/asset-assistant/index.ts
@@ -137,7 +137,7 @@ router.post("/export", async (c) => {
c.get("user"),
body.subject,
);
- const result = await execute(vehicles, node.plan);
+ const result = await execute(vehicles, node.plan, c.get("user"));
const records = [
[
"车牌",
@@ -265,7 +265,7 @@ router.post("/ask", async (c) => {
let parent = baseNode;
if (input.action) {
if (!previous) throw new Error("请先完成一次统计。");
- const currentData = await execute(vehicles, previous);
+ const currentData = await execute(vehicles, previous, user);
if (input.action.type === "explore") {
const exploration = applyGeneratedAction(previous, input.action, currentData, baseNode?.presentation);
plan = exploration.plan;
@@ -313,7 +313,7 @@ router.post("/ask", async (c) => {
if (currentSession.nodes.size + 1 + (queries?.length || 0) > 100)
throw new Error("本次对话分析步骤已满,请新建对话。");
let result: AssistantResult = queries?.length
- ? await executeComposition(vehicles, queries, input.subject, controller.signal)
+ ? await executeComposition(vehicles, queries, input.subject, controller.signal, user)
: plan.kind === "clarify"
? {
plan,
@@ -332,7 +332,7 @@ router.post("/ask", async (c) => {
metrics: [],
answerKind: answerKind === "answer" ? "help" : "clarification",
}
- : await execute(vehicles, plan);
+ : await execute(vehicles, plan, user);
if (controller.signal.aborted) return;
plan = result.plan;
if (result.unanswered?.length) {
diff --git a/src/server/routes/asset-assistant/model.test.ts b/src/server/routes/asset-assistant/model.test.ts
index 6f38508..3aa0ffc 100644
--- a/src/server/routes/asset-assistant/model.test.ts
+++ b/src/server/routes/asset-assistant/model.test.ts
@@ -40,14 +40,14 @@ const vehicles = ["Inventory", "Abnormal", "Pending", "Operating"].map(
}) as Vehicle,
);
-test("permissions and company filters apply before aggregation and detail generation", () => {
+test("public inventory and company filters apply before aggregation and detail generation", () => {
const visible = visibleVehicles(vehicles, user, "甲公司");
const result = assetResult(visible, validatePlan(plan));
- assert.equal(result.metrics[0].value, 1);
+ assert.equal(result.metrics[0].value, 2);
assert.equal(result.groups[0].count, result.rows.length);
assert.deepEqual(
result.rows.map((v) => v.id),
- ["0"],
+ ["0", "1"],
);
});
test("Shanghai counts include districts and preserve permission and company scope", () => {
@@ -59,8 +59,8 @@ test("Shanghai counts include districts and preserve permission and company scop
assert.equal(all.groups.length, 1);
assert.equal(all.groups[0].count, 4);
const scoped = assetResult(visibleVehicles(input, user, "甲公司"), query);
- assert.equal(scoped.metrics[0].value, 2);
- assert.deepEqual(scoped.rows.map(v => v.id).sort(), ["0", "3"]);
+ assert.equal(scoped.metrics[0].value, 3);
+ assert.deepEqual(scoped.rows.map(v => v.id).sort(), ["0", "1", "3"]);
assert.ok(scoped.rows.every(v => v.city === "上海市"));
});
@@ -155,12 +155,12 @@ test("presentation receives authorized aggregates while vehicle rows stay in the
const result = events.find((e) => e.type === "result").result;
assert.deepEqual(
result.rows.map((r: { id: string }) => r.id),
- ["0", "2"],
+ ["0", "1", "2"],
);
assert.equal(prompts.length, 2);
const presentationInput = JSON.parse(JSON.parse(prompts[1]).messages[1].content);
assert.equal(presentationInput.stage, "presentation");
- assert.equal(presentationInput.results[0].metrics[0].value, 2);
+ assert.equal(presentationInput.results[0].metrics[0].value, 3);
assert.equal(presentationInput.results[0].rows, undefined);
assert.ok(
prompts.every(
@@ -180,3 +180,18 @@ test("inventory subsets do not inherit parent inventory metric cards", () => {
assert.doesNotMatch(r.summary, /其中异动.*待交车/);
}
});
+
+test("personal and department scopes include all current stock subsets but keep operating vehicles restricted", () => {
+ const stock = vehicles.slice(0, 3).map(v => ({ ...v, subjectOrg: "甲公司", managerId: "other", departmentName: "乙部门" }));
+ const ownOperating = vehicles[3];
+ const foreignOperating = { ...ownOperating, id: "foreign-operating", managerId: "other", departmentName: "乙部门" };
+ const input = [...stock, ownOperating, foreignOperating];
+ for (const permissionLevel of ["personal", "department"] as const) {
+ const visible = visibleVehicles(input, { ...user, permissionLevel }, "甲公司");
+ assert.deepEqual(visible.map(v => v.id), ["0", "1", "2", "3"]);
+ for (const [category, expected] of [["inventory", ["0", "1", "2"]], ["abnormal", ["1"]], ["pending", ["2"]], ["operating", ["3"]]] as const) {
+ const result = assetResult(visible, validatePlan({ ...plan, category }));
+ assert.deepEqual(result.rows.map(v => v.id), expected);
+ }
+ }
+});
diff --git a/src/server/routes/asset-assistant/model.ts b/src/server/routes/asset-assistant/model.ts
index 8d6ffff..51fb6fb 100644
--- a/src/server/routes/asset-assistant/model.ts
+++ b/src/server/routes/asset-assistant/model.ts
@@ -11,7 +11,7 @@ import {
type AssistantRow,
} from "../../../shared/assets/assistant.js";
import { vehicleStatusResult } from "./vehicle-status.js";
-import { filterByPermission } from "../../auth/permissions.js";
+import { filterAssetVehiclesByPermission } from "../../auth/permissions.js";
import type { AuthUser } from "../../auth/types.js";
import type { Vehicle } from "../../types.js";
import {
@@ -159,7 +159,7 @@ export function visibleVehicles(
user: AuthUser,
subject: string | null,
) {
- return filterByPermission(all, user).filter(
+ return filterAssetVehiclesByPermission(all, user).filter(
(v) => !subject || v.subjectOrg === subject,
);
}
diff --git a/src/server/routes/asset-assistant/presentation.ts b/src/server/routes/asset-assistant/presentation.ts
index ef2d797..6f0e0f9 100644
--- a/src/server/routes/asset-assistant/presentation.ts
+++ b/src/server/routes/asset-assistant/presentation.ts
@@ -36,7 +36,7 @@ export function resultScope(plan: QueryPlan, subject: string | null): NonNullabl
];
const definitions = plan.responseMode === "vehicle-status" ? ["状态来自当前资产台账,不代表车辆实时位置、行驶或故障状态。", "运营状态、库存归属、异动及待交车分别展示,不互相替代。"] : plan.kind === "flow" ? [
"按运维签章日期统计业务车次,替换交车已计入交车,不能重复相加。",
- "按当前账号可见车辆及当前筛选条件查询流转记录;车次差额不代表净增车辆,增减本身不能证明经营改善。",
+ "按当前账号本人或部门权限及当前筛选条件查询流转记录(全量权限不受此限);车次差额不代表净增车辆,增减本身不能证明经营改善。",
] : [
"库存含可运营、待运营、异动及待交车;异动、待交车是库存子集,不与总库存相加。",
"待运营是运营状态,待交车是交车流程状态,两者不可替代。",
@@ -46,7 +46,7 @@ export function resultScope(plan: QueryPlan, subject: string | null): NonNullabl
if (plan.topN) definitions.push(`仅展示前 ${plan.topN} 个分组,指标总量和占比仍按完整筛选范围计算。`);
return {
company: subject || "全部公司(当前账号可见)",
- permission: "仅统计当前账号有权限查看的车辆",
+ permission: plan.kind === "flow" ? "交退换记录沿用当前账号原有数据权限" : "库存(含待交车、异动)全量可见,非库存车辆按当前账号数据权限查看",
filters,
grouping: plan.kind === "flow" ? "按日" : plan.groupBy === "none" ? "不分组" : `按${GROUP_LABELS[plan.groupBy]}${plan.secondaryGroupBy ? ` × ${GROUP_LABELS[plan.secondaryGroupBy]}` : ""}`,
period: plan.kind === "flow" ? `${plan.start} 至 ${plan.end}(含首尾日期)` : "当前车辆状态(非历史快照)",
diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts
index eaa5f1a..2d0df88 100644
--- a/src/server/routes/vehicles.ts
+++ b/src/server/routes/vehicles.ts
@@ -9,7 +9,7 @@ import type {
BatchGroup,
InventoryTypeSummary,
} from '../types.js';
-import { filterByPermission, maskCustomerNames, maskCustomerName } from '../auth/permissions.js';
+import { filterAssetVehiclesByPermission, filterByPermission, maskCustomerNames, maskCustomerName } from '../auth/permissions.js';
import type { AuthUser } from '../auth/types.js';
import type { Context } from 'hono';
import {
@@ -46,12 +46,12 @@ import {
export { mapRegion } from './vehicles/model.js';
-const app = new Hono();
+const app = new Hono<{ Variables: { user: AuthUser } }>();
-async function getVehiclesForUser(c: Context): Promise {
+async function getVehiclesForUser(c: Context, includePublicInventory = true): Promise {
const all = await vehicleRepository.getVehicles();
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
- let list = user ? filterByPermission(all, user) : all;
+ let list = user ? (includePublicInventory ? filterAssetVehiclesByPermission(all, user) : filterByPermission(all, user)) : all;
list = applySubjectFilter(c, list);
return maskCustomerNames(list);
}
@@ -71,7 +71,8 @@ function applySubjectFilter(c: Context, vehicles: Vehicle[]): Vehicle[] {
// GET /api/vehicles/summary
app.get('/summary', async (c) => {
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]);
- const vehicleIds = new Set(vehicles.map(v => String(v.id)));
+ const user = c.get('user') as AuthUser | undefined;
+ const vehicleIds = new Set((user ? filterByPermission(vehicles, user) : vehicles).map(v => String(v.id)));
const summary: SummaryData = {
totalAssets: vehicles.length,
operating: {
@@ -98,7 +99,15 @@ app.get('/summary', async (c) => {
// GET /api/vehicles/by-type
app.get('/by-type', async (c) => {
- const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]);
+ const [vehicles, allWeeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]);
+ const user = c.get('user') as AuthUser | undefined;
+ const historyIds = new Set((user ? filterByPermission(vehicles, user) : vehicles).map(v => String(v.id)));
+ const weeklyIds = {
+ ...allWeeklyIds,
+ delivered: new Set([...allWeeklyIds.delivered].filter(id => historyIds.has(id))),
+ returned: new Set([...allWeeklyIds.returned].filter(id => historyIds.has(id))),
+ replaced: new Set([...allWeeklyIds.replaced].filter(id => historyIds.has(id))),
+ };
const result: TypeSummary[] = SUMMARY_TYPE_FILTERS.map((t) => {
const typeVehicles = vehicles.filter(t.filter);
@@ -587,8 +596,9 @@ app.get('/weekly-detail', async (c) => {
const hasModelFilter = model && model !== 'All';
const hasBatchFilter = batch && batch !== 'All';
const hasLocationFilter = location && location !== 'All';
- if (hasModelFilter || hasBatchFilter || hasLocationFilter || getSubjectParam(c)) {
- const vehicles = await getVehiclesForUser(c);
+ {
+ // Public current inventory does not grant access to historical delivery/return events.
+ const vehicles = await getVehiclesForUser(c, type === 'pending');
let pool2 = vehicles;
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
if (hasBatchFilter) pool2 = pool2.filter((v) => (v.contractNo || '未知') === batch);
@@ -605,7 +615,7 @@ app.get('/weekly-detail', async (c) => {
// 资产流转日报:按运维签章时间统计业务车次;交车包含关联替换单的记录,替换作为其中的子项,同车多笔业务分别累计。
app.get('/flow-stats', async (c) => {
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
- const allowedVehicles = await getVehiclesForUser(c);
+ const allowedVehicles = await getVehiclesForUser(c, false);
const allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id)));
const rows = await vehicleRepository.getFlowDetailRows(start, end);
@@ -654,7 +664,7 @@ app.get('/flow-stats', async (c) => {
app.get('/subjects', async (c) => {
const all = await vehicleRepository.getVehicles();
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
- const visible = user ? filterByPermission(all, user) : all;
+ const visible = user ? filterAssetVehiclesByPermission(all, user) : all;
const map = new Map();
for (const v of visible) {
diff --git a/src/server/routes/vehicles/analytics.test.ts b/src/server/routes/vehicles/analytics.test.ts
index 33f70ad..f9f1ebe 100644
--- a/src/server/routes/vehicles/analytics.test.ts
+++ b/src/server/routes/vehicles/analytics.test.ts
@@ -269,3 +269,68 @@ test('analysis inherits legacy list filtering including attendance and empty inv
assert.equal((await app.request('/analysis', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).status, 400);
}
});
+
+test('department and personal views share all current inventory without widening operating or historical access', async t => {
+ const vehicles = [
+ make('mine'),
+ make('colleague', { managerId: 'manager-b' }),
+ make('outside', { managerId: 'manager-b', departmentName: '部门乙' }),
+ ...(['Inventory', 'Abnormal', 'Pending'] as const).map((status, index) => make(`stock-${index}`, {
+ status, operationStatus: index === 2 ? '4' : '3', managerId: 'manager-b', departmentName: '部门乙',
+ })),
+ make('other-company-stock', { status: 'Inventory', operationStatus: '3', subjectOrg: '公司乙', managerId: 'manager-b', departmentName: '部门乙' }),
+ // An abnormal/pending workflow on an operating vehicle is not public inventory.
+ make('outside-moving', { status: 'Abnormal', operationStatus: '1', managerId: 'manager-b', departmentName: '部门乙' }),
+ ];
+ t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
+ t.mock.method(vehicleRepository, 'getWeeklyTruckIds', async () => ({
+ pending: new Set(['stock-2']), delivered: new Set(['mine', 'stock-0']), returned: new Set(), replaced: new Set(),
+ }));
+ t.mock.method(vehicleRepository, 'getWeeklyDetailRows', async () => [
+ { truck_id: 'mine', customer_name: '测试客户公司' }, { truck_id: 'stock-0', customer_name: '测试客户公司' },
+ ]);
+ t.mock.method(vehicleRepository, 'getFlowDetailRows', async () => [
+ { id: 'history-mine', truck_id: 'mine', type: 'delivered', stat_date: '2026-09-28' },
+ { id: 'history-stock', truck_id: 'stock-0', type: 'delivered', stat_date: '2026-09-28' },
+ ]);
+ for (const permissionLevel of ['personal', 'department'] as const) {
+ const secured = new Hono<{ Variables: { user: AuthUser } }>();
+ secured.use('*', async (c, next) => {
+ c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel, roles: [] });
+ await next();
+ });
+ secured.route('/', app);
+ const get = async (path: string) => {
+ const response = await secured.request(path);
+ assert.equal(response.status, 200, path);
+ return response.json();
+ };
+ const post = async (path: string, body: unknown) => {
+ const response = await secured.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ assert.equal(response.status, 200, path);
+ return response.json();
+ };
+ const summary = await get('/summary?subject=公司甲');
+ const operating = permissionLevel === 'personal' ? 1 : 2;
+ assert.equal(summary.operating.total, operating);
+ assert.equal(summary.inventory.total, 3);
+ assert.equal(summary.inventory.abnormal, 1);
+ assert.equal(summary.pendingDelivery, 1);
+ assert.equal(summary.totalAssets, operating + 3);
+ assert.equal(summary.weeklyDelivered, 1);
+ const groups = await get('/by-type?subject=公司甲');
+ assert.equal(groups.reduce((n: number, row: { totalInventory: number }) => n + row.totalInventory, 0), 3);
+ assert.equal(groups.reduce((n: number, row: { weeklyDelivered: number }) => n + row.weeklyDelivered, 0), 1);
+ const analysis = await post('/analysis?subject=公司甲', {});
+ assert.equal(analysis.summary.inventory.total, 3);
+ assert.equal(analysis.summary.operating.total, operating);
+ for (const [metric, expected] of [['inventory', ['stock-0', 'stock-1', 'stock-2']], ['abnormal', ['stock-1']], ['pending', ['stock-2']]] as const) {
+ const rows = await post('/analysis/details?subject=公司甲', { metric, filters: {}, scope: {}, generatedAt: analysis.generatedAt });
+ assert.deepEqual(rows.map((row: { id: string }) => row.id).sort(), [...expected]);
+ }
+ assert.equal((await get('/list?category=Inventory&subject=公司甲')).length, 3);
+ assert.equal((await get('/summary')).inventory.total, 4);
+ assert.deepEqual((await get('/weekly-detail?type=delivered')).map((row: { truck_id: string }) => row.truck_id), ['mine']);
+ assert.equal((await get('/flow-stats?start=2026-09-28&end=2026-09-28')).totals.delivered, 1);
+ }
+});
diff --git a/vite.config.ts b/vite.config.ts
index 1bc5fc1..ae06d0d 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -7,6 +7,8 @@ export default defineConfig({
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
plugins: [react(), tailwindcss()],
server: {
+ // Preview requests append audit logs; watching them creates a reload/request loop.
+ watch: { ignored: ['**/logs/**'] },
proxy: {
'/api': {
target: 'http://localhost:3001',