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',