fix(assets): expose full inventory for scoped users and stabilize previews
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 权限视角预览
|
# 权限视角预览
|
||||||
|
|
||||||
授权账号登录后,直接访问 `/who` 搜索同一组织的有效用户,先核对角色与数据范围,再进入只读预览。常规页面不展示入口,未授权用户直接访问链接也无法使用。预览使用目标账号真实角色:BI-Leader/所有权限/数智中心为全量,BI-Leader-Dep为本部门,其余为本人负责;多角色时全量优先。保持各业务模块现有的范围规则,库存公共汇总等不会因此变为个人数据。
|
授权账号登录后,直接访问 `/who` 搜索同一组织的有效用户,先核对角色与数据范围,再进入只读预览。常规页面不展示入口,未授权用户直接访问链接也无法使用。预览使用目标账号真实角色:BI-Leader/所有权限/数智中心为全量,BI-Leader-Dep为本部门,其余为本人负责;多角色时全量优先。资产库存(包括待交车、异动)对三类角色均展示全量,汇总、分组与车辆明细口径一致;所属公司筛选仍然生效。非库存资产及交还车历史维持部门/个人范围,公共库存权限不扩展历史业务访问。
|
||||||
|
|
||||||
## 授权与身份来源
|
## 授权与身份来源
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import './PermissionPreview.css';
|
|||||||
|
|
||||||
const scopes = [
|
const scopes = [
|
||||||
{ level: 'full', title: '全量数据', role: 'BI-Leader', detail: '查看全量业务数据。' },
|
{ level: 'full', title: '全量数据', role: 'BI-Leader', detail: '查看全量业务数据。' },
|
||||||
{ level: 'department', title: '部门数据', role: 'BI-Leader-Dep', detail: '查看当前用户所在部门的数据。' },
|
{ level: 'department', title: '部门数据', role: 'BI-Leader-Dep', detail: '运营数据限本部门,库存查看全量。' },
|
||||||
{ level: 'personal', title: '本人数据', role: '其他角色默认', detail: '查看本人负责的车辆。' },
|
{ level: 'personal', title: '本人数据', role: '其他角色默认', detail: '运营数据限本人负责,库存查看全量。' },
|
||||||
];
|
];
|
||||||
export function permissionScope(user: AuthUser) {
|
export function permissionScope(user: AuthUser) {
|
||||||
return scopes.find(s => s.level === user.permissionLevel)?.title || '本人数据';
|
return scopes.find(s => s.level === user.permissionLevel)?.title || '本人数据';
|
||||||
@@ -69,7 +69,7 @@ function PreviewPicker({ onClose }: { onClose: () => void }) {
|
|||||||
<header><div><h2 id="permission-preview-title"><ShieldCheck size={19} />权限视角</h2><p>选择人员,查看其实际页面权限与数据范围。</p></div><button disabled={busy} className="permission-preview-back" onClick={onClose}><ArrowLeft size={18} aria-hidden="true" />返回 BI</button></header>
|
<header><div><h2 id="permission-preview-title"><ShieldCheck size={19} />权限视角</h2><p>选择人员,查看其实际页面权限与数据范围。</p></div><button disabled={busy} className="permission-preview-back" onClick={onClose}><ArrowLeft size={18} aria-hidden="true" />返回 BI</button></header>
|
||||||
<div className="permission-preview-body">
|
<div className="permission-preview-body">
|
||||||
<div className="permission-preview-guide" aria-label="三类数据权限说明">{scopes.map(scope => <div key={scope.level}><strong>{scope.title}</strong><code>{scope.role}</code><p>{scope.detail}</p></div>)}</div>
|
<div className="permission-preview-guide" aria-label="三类数据权限说明">{scopes.map(scope => <div key={scope.level}><strong>{scope.title}</strong><code>{scope.role}</code><p>{scope.detail}</p></div>)}</div>
|
||||||
<p className="permission-preview-hint">菜单和数据随目标用户权限变化;共享汇总仍按页面注明的公共口径展示。预览期间仅可查看,不能修改业务数据。</p>
|
<p className="permission-preview-hint">菜单和运营数据随目标用户权限变化;库存(含待交车、异动)统一展示全量,仍可按所属公司筛选。预览期间仅可查看,不能修改业务数据。</p>
|
||||||
<div className="permission-preview-search"><label><Search size={16} /><input autoFocus type="search" value={query} disabled={busy} onChange={e => { setQuery(e.target.value); setPage(1); }} placeholder="搜索中文姓名、用户名或部门" aria-label="搜索人员" /></label><select aria-label="数据范围筛选" value={level} disabled={busy} onChange={e => { setLevel(e.target.value); setPage(1); }}><option value="">全部数据范围</option>{scopes.map(s => <option key={s.level} value={s.level}>{s.title}</option>)}</select></div>
|
<div className="permission-preview-search"><label><Search size={16} /><input autoFocus type="search" value={query} disabled={busy} onChange={e => { setQuery(e.target.value); setPage(1); }} placeholder="搜索中文姓名、用户名或部门" aria-label="搜索人员" /></label><select aria-label="数据范围筛选" value={level} disabled={busy} onChange={e => { setLevel(e.target.value); setPage(1); }}><option value="">全部数据范围</option>{scopes.map(s => <option key={s.level} value={s.level}>{s.title}</option>)}</select></div>
|
||||||
{error && <div className="permission-preview-error" role="alert">{error}<button disabled={busy} onClick={() => setRetry(v => v + 1)}>重试</button></div>}
|
{error && <div className="permission-preview-error" role="alert">{error}<button disabled={busy} onClick={() => setRetry(v => v + 1)}>重试</button></div>}
|
||||||
{!hasQuery ? <p className="permission-preview-empty">输入中文姓名、用户名或部门关键词,模糊搜索人员。</p> : <div className="permission-preview-users" role="radiogroup" aria-label="选择预览用户" aria-busy={loading}>
|
{!hasQuery ? <p className="permission-preview-empty">输入中文姓名、用户名或部门关键词,模糊搜索人员。</p> : <div className="permission-preview-users" role="radiogroup" aria-label="选择预览用户" aria-busy={loading}>
|
||||||
|
|||||||
@@ -285,8 +285,8 @@ const BUSINESS_DIMENSIONS: Record<AnalysisView, readonly AssetAnalysisDimension[
|
|||||||
|
|
||||||
/** Dimensions are choices, not a required drill sequence. */
|
/** Dimensions are choices, not a required drill sequence. */
|
||||||
export function getAvailableDimensions(view: AnalysisView): AssetAnalysisDimension[] {
|
export function getAvailableDimensions(view: AnalysisView): AssetAnalysisDimension[] {
|
||||||
const common = view === 'inventory'
|
const common = ['inventory', 'pending', 'abnormal'].includes(view)
|
||||||
? COMMON_DIMENSIONS.filter(dimension => dimension !== 'manager' && dimension !== 'customer')
|
? COMMON_DIMENSIONS.filter(dimension => dimension !== 'department' && dimension !== 'manager' && dimension !== 'customer')
|
||||||
: COMMON_DIMENSIONS;
|
: COMMON_DIMENSIONS;
|
||||||
return [...common, ...BUSINESS_DIMENSIONS[view]];
|
return [...common, ...BUSINESS_DIMENSIONS[view]];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function InventoryStatisticsSection({
|
|||||||
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
||||||
<span className="text-[10px] text-gray-400 font-medium">{preview ? '公共库存汇总 · 明细按当前用户权限查看' : '实时更新库存数据'}</span>
|
<span className="text-[10px] text-gray-400 font-medium">{preview ? '全量库存 · 汇总与明细口径一致' : '实时更新库存数据'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import type { AuthUser } from './types.js';
|
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 {
|
export function maskCustomerName(name: string | null): string | null {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ test("multi-level clicks and follow-ups preserve exact scope, branching and reco
|
|||||||
const root = await ask(app, {
|
const root = await ask(app, {
|
||||||
message: "各区域的库存车辆有多少?按数量排序。",
|
message: "各区域的库存车辆有多少?按数量排序。",
|
||||||
});
|
});
|
||||||
assert.equal(root.rows.length, 4);
|
assert.equal(root.rows.length, 5);
|
||||||
assert.equal(root.plan.category, "inventory");
|
assert.equal(root.plan.category, "inventory");
|
||||||
const region = await ask(app, {
|
const region = await ask(app, {
|
||||||
...based(root),
|
...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.deepEqual(region.plan.filters, { region: ["华东"] });
|
||||||
assert.equal(region.plan.groupBy, "city");
|
assert.equal(region.plan.groupBy, "city");
|
||||||
assert.equal(region.rows.length, 3);
|
assert.equal(region.rows.length, 4);
|
||||||
const city = await ask(app, {
|
const city = await ask(app, {
|
||||||
...based(region),
|
...based(region),
|
||||||
action: { type: "drill", keys: { city: "嘉兴市" } },
|
action: { type: "drill", keys: { city: "嘉兴市" } },
|
||||||
});
|
});
|
||||||
assert.deepEqual(city.plan.filters, { region: ["华东"], 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: "再按车型拆分" });
|
const types = await ask(app, { ...based(city), message: "再按车型拆分" });
|
||||||
assert.deepEqual(types.plan.filters, city.plan.filters);
|
assert.deepEqual(types.plan.filters, city.plan.filters);
|
||||||
assert.equal(types.plan.groupBy, "type");
|
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" },
|
action: { type: "restore" },
|
||||||
});
|
});
|
||||||
assert.deepEqual(branch.plan.filters, {});
|
assert.deepEqual(branch.plan.filters, {});
|
||||||
assert.equal(branch.rows.length, 4);
|
assert.equal(branch.rows.length, 5);
|
||||||
assert.equal(detail.breadcrumbs?.length, 6);
|
assert.equal(detail.breadcrumbs?.length, 6);
|
||||||
for (const r of [root, region, city, types, subsets, detail])
|
for (const r of [root, region, city, types, subsets, detail])
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const plan = (overrides: Partial<QueryPlan> = {}): QueryPlan => ({
|
|||||||
const part = (id: string, title: string, queryPlan: QueryPlan, display = "table") => ({ id, title, question: title, plan: queryPlan, display });
|
const part = (id: string, title: string, queryPlan: QueryPlan, display = "table") => ({ id, title, question: title, plan: queryPlan, display });
|
||||||
let serial = 0;
|
let serial = 0;
|
||||||
|
|
||||||
function fixture(t: TestContext) {
|
function fixture(t: TestContext, permissionLevel: AuthUser["permissionLevel"] = "personal") {
|
||||||
const user: AuthUser = { userId: `composition-${++serial}`, userName: "组合验收", loginName: "composition", depCode: "D", depName: "合成部门", permissionLevel: "personal", roles: [] };
|
const user: AuthUser = { userId: `composition-${++serial}`, userName: "组合验收", loginName: "composition", depCode: "D", depName: "合成部门", permissionLevel, roles: [] };
|
||||||
const vehicles = [
|
const vehicles = [
|
||||||
{ id: "jx-inventory", type: "4.5T", city: "嘉兴市", status: "Inventory", operationStatus: "3" },
|
{ id: "jx-inventory", type: "4.5T", city: "嘉兴市", status: "Inventory", operationStatus: "3" },
|
||||||
{ id: "hz-inventory", type: "18T", 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: "jx-operating", type: "18T", city: "嘉兴市", status: "Operating", operationStatus: "1" },
|
||||||
{ id: "south-inventory", type: "49T", city: "广州市", province: "广东省", status: "Inventory", operationStatus: "3" },
|
{ 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-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);
|
].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);
|
const getVehicles = t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
|
||||||
let flowFailure = false;
|
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");
|
assert.ok(result, "A composed response must produce a result even if one section fails");
|
||||||
return result;
|
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 based = (result: AssistantResult) => ({ sessionId: result.sessionId, baseResultId: result.resultId });
|
||||||
const ids = (result: AssistantResult) => result.rows.map((row) => row.id).sort();
|
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: "分别展示华东资产概况、库存分布、待交车清单和运营车型统计。" });
|
const result = await ask({ message: "分别展示华东资产概况、库存分布、待交车清单和运营车型统计。" });
|
||||||
assert.equal(result.sections?.length, 4);
|
assert.equal(result.sections?.length, 4);
|
||||||
assert.ok(result.resultId);
|
assert.ok(result.resultId);
|
||||||
const counts = [4, 3, 1, 1];
|
const counts = [5, 4, 1, 1];
|
||||||
const childIds: string[] = [];
|
const childIds: string[] = [];
|
||||||
for (const [index, section] of result.sections!.entries()) {
|
for (const [index, section] of result.sections!.entries()) {
|
||||||
assert.equal(section.id, sections[index].id);
|
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.equal(section.result.sessionId, result.sessionId);
|
||||||
assert.ok(section.result.resultId);
|
assert.ok(section.result.resultId);
|
||||||
childIds.push(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(new Set([result.resultId, ...childIds]).size, 5, "Root and each analysis need distinct result identities");
|
||||||
assert.equal(result.outcome, "complete");
|
assert.equal(result.outcome, "complete");
|
||||||
@@ -125,7 +125,7 @@ for (const failedFirst of [true, false]) {
|
|||||||
assert.equal(result.sections?.length, 2);
|
assert.equal(result.sections?.length, 2);
|
||||||
const asset = result.sections!.find((section) => section.id === "inventory")!;
|
const asset = result.sections!.find((section) => section.id === "inventory")!;
|
||||||
const failed = result.sections!.find((section) => section.id === "flow")!;
|
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(asset.result?.resultId);
|
||||||
assert.ok(failed.error);
|
assert.ok(failed.error);
|
||||||
assert.equal(failed.result, undefined);
|
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.sections, undefined, "A local block action must return a normal result for in-place replacement");
|
||||||
assert.equal(drill.plan.category, "inventory");
|
assert.equal(drill.plan.category, "inventory");
|
||||||
assert.deepEqual(drill.plan.filters, { region: ["华东"], city: ["嘉兴市"] });
|
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(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");
|
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: ["嘉兴市"] } }) });
|
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;
|
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.ok(Array.isArray(analyses), "The model needs every analysis, not only the root's first plan");
|
||||||
assert.equal(analyses.length, 2);
|
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.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.equal(next.plan.category, "pending");
|
||||||
assert.deepEqual(ids(next), ["jx-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 result = await ask({ message: "库存分布、待交清单和另外一项查询分别显示。" });
|
||||||
const healthy = result.sections?.find((section) => section.id === "inventory")?.result || result;
|
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");
|
assert.equal(healthy.plan.category, "inventory");
|
||||||
const display = result.sections?.find((section) => section.id === "unknown-display");
|
const display = result.sections?.find((section) => section.id === "unknown-display");
|
||||||
if (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() });
|
proposals.push({ kind: "query", plan: plan() });
|
||||||
const result = await ask({ message: "华东库存按城市统计" });
|
const result = await ask({ message: "华东库存按城市统计" });
|
||||||
assert.equal(result.sections, undefined);
|
assert.equal(result.sections, undefined);
|
||||||
assert.equal(result.rows.length, 3);
|
assert.equal(result.rows.length, 4);
|
||||||
assert.equal(result.metrics[0].value, 3);
|
assert.equal(result.metrics[0].value, 4);
|
||||||
assert.ok(result.resultId);
|
assert.ok(result.resultId);
|
||||||
assert.equal(requests.length, 1);
|
assert.equal(requests.length, 1);
|
||||||
assert.equal(presentationRequests.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")] });
|
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 result = await ask({ message: "华东库存最多的城市和待交车清单分别展示,同时给待交车与异动占库存比例。" });
|
||||||
const stockResult = result.sections!.find((section) => section.id === "stock-share")!.result!;
|
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.length, 1);
|
||||||
assert.equal(stockResult.groups[0].count, 2);
|
assert.equal(stockResult.groups[0].count, 3);
|
||||||
assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.value, 33.3);
|
assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.value, 25);
|
||||||
assert.equal(stockResult.metrics.find((metric) => metric.label === "异动占库存")?.value, 33.3);
|
assert.equal(stockResult.metrics.find((metric) => metric.label === "异动占库存")?.value, 25);
|
||||||
assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.unit, "%");
|
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);
|
const { ask, proposals } = fixture(t);
|
||||||
proposals.push({ kind: "query", plan: plan({ filters: { region: ["华东"], city: ["嘉兴市"] } }) });
|
proposals.push({ kind: "query", plan: plan({ filters: { region: ["华东"], city: ["嘉兴市"] } }) });
|
||||||
const previous = await ask({ message: "只看华东嘉兴库存" });
|
const previous = await ask({ message: "只看华东嘉兴库存" });
|
||||||
assert.equal(previous.rows.length, 2);
|
assert.equal(previous.rows.length, 3);
|
||||||
const southernInventory = plan({ filters: { region: ["华南"] } });
|
const southernInventory = plan({ filters: { region: ["华南"] } });
|
||||||
const southernOperating = plan({ category: "operating", filters: { region: ["华南"] } });
|
const southernOperating = plan({ category: "operating", filters: { region: ["华南"] } });
|
||||||
proposals.push({ kind: "query", plan: southernInventory, queries: [
|
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.deepEqual(ids(inventoryResult), ["south-inventory"]);
|
||||||
assert.ok(changed.sections!.every((section) => !section.result?.plan.filters.city));
|
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/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AnalysisQuery, AnalysisSection, AssistantResult } from "../../../shared/assets/assistant.js";
|
import type { AnalysisQuery, AnalysisSection, AssistantResult } from "../../../shared/assets/assistant.js";
|
||||||
|
import type { AuthUser } from "../../auth/types.js";
|
||||||
import type { Vehicle } from "../../types.js";
|
import type { Vehicle } from "../../types.js";
|
||||||
import { execute } from "./execute.js";
|
import { execute } from "./execute.js";
|
||||||
import { followupSuggestions, resultScope } from "./presentation.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. */
|
/** Independent queries share one authorized vehicle snapshot. One failed source must not erase other answers. */
|
||||||
export async function executeComposition(
|
export async function executeComposition(
|
||||||
vehicles: Vehicle[], queries: AnalysisQuery[], subject: string | null,
|
vehicles: Vehicle[], queries: AnalysisQuery[], subject: string | null,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal, user?: AuthUser,
|
||||||
): Promise<AssistantResult> {
|
): Promise<AssistantResult> {
|
||||||
const sections: AnalysisSection[] = await Promise.all(queries.map(async query => {
|
const sections: AnalysisSection[] = await Promise.all(queries.map(async query => {
|
||||||
const { plan, ...section } = query;
|
const { plan, ...section } = query;
|
||||||
try {
|
try {
|
||||||
const result = await execute(vehicles, plan);
|
const result = await execute(vehicles, plan, user);
|
||||||
result.scope = resultScope(plan, subject);
|
result.scope = resultScope(plan, subject);
|
||||||
result.suggestions = followupSuggestions(result);
|
result.suggestions = followupSuggestions(result);
|
||||||
return { ...section, result };
|
return { ...section, result };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
AssistantResult,
|
AssistantResult,
|
||||||
} from "../../../shared/assets/assistant.js";
|
} from "../../../shared/assets/assistant.js";
|
||||||
import { DIMENSIONS } 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 type { Vehicle } from "../../types.js";
|
||||||
import { vehicleRepository } from "../vehicles/repository.js";
|
import { vehicleRepository } from "../vehicles/repository.js";
|
||||||
import { listDateRange } from "../vehicles/utils.js";
|
import { listDateRange } from "../vehicles/utils.js";
|
||||||
@@ -12,7 +14,10 @@ import { assetResult, filterVehicles, toRow, validatePlan } from "./model.js";
|
|||||||
export async function execute(
|
export async function execute(
|
||||||
vehicles: Vehicle[],
|
vehicles: Vehicle[],
|
||||||
plan: QueryPlan,
|
plan: QueryPlan,
|
||||||
|
user?: AuthUser,
|
||||||
): Promise<AssistantResult> {
|
): Promise<AssistantResult> {
|
||||||
|
// 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);
|
let result = assetResult(vehicles, plan);
|
||||||
if (plan.kind === "flow") {
|
if (plan.kind === "flow") {
|
||||||
const selected = filterVehicles(vehicles, plan);
|
const selected = filterVehicles(vehicles, plan);
|
||||||
@@ -94,7 +99,7 @@ export async function execute(
|
|||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
comparePrevious: false,
|
comparePrevious: false,
|
||||||
});
|
}, user);
|
||||||
result.comparisonPeriod = { start, end };
|
result.comparisonPeriod = { start, end };
|
||||||
result.comparison = result.metrics.map((m, i) => ({
|
result.comparison = result.metrics.map((m, i) => ({
|
||||||
label: m.label,
|
label: m.label,
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ function setup(t: TestContext) {
|
|||||||
operationStatus: "1",
|
operationStatus: "1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Inventory is public; use another owner's operating vehicle to test restricted data.
|
||||||
id: "not-authorized",
|
id: "not-authorized",
|
||||||
city: "嘉兴市",
|
city: "嘉兴市",
|
||||||
status: "Inventory",
|
status: "Operating",
|
||||||
|
operationStatus: "1",
|
||||||
type: "18T",
|
type: "18T",
|
||||||
managerId: "another-user",
|
managerId: "another-user",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ router.post("/export", async (c) => {
|
|||||||
c.get("user"),
|
c.get("user"),
|
||||||
body.subject,
|
body.subject,
|
||||||
);
|
);
|
||||||
const result = await execute(vehicles, node.plan);
|
const result = await execute(vehicles, node.plan, c.get("user"));
|
||||||
const records = [
|
const records = [
|
||||||
[
|
[
|
||||||
"车牌",
|
"车牌",
|
||||||
@@ -265,7 +265,7 @@ router.post("/ask", async (c) => {
|
|||||||
let parent = baseNode;
|
let parent = baseNode;
|
||||||
if (input.action) {
|
if (input.action) {
|
||||||
if (!previous) throw new Error("请先完成一次统计。");
|
if (!previous) throw new Error("请先完成一次统计。");
|
||||||
const currentData = await execute(vehicles, previous);
|
const currentData = await execute(vehicles, previous, user);
|
||||||
if (input.action.type === "explore") {
|
if (input.action.type === "explore") {
|
||||||
const exploration = applyGeneratedAction(previous, input.action, currentData, baseNode?.presentation);
|
const exploration = applyGeneratedAction(previous, input.action, currentData, baseNode?.presentation);
|
||||||
plan = exploration.plan;
|
plan = exploration.plan;
|
||||||
@@ -313,7 +313,7 @@ router.post("/ask", async (c) => {
|
|||||||
if (currentSession.nodes.size + 1 + (queries?.length || 0) > 100)
|
if (currentSession.nodes.size + 1 + (queries?.length || 0) > 100)
|
||||||
throw new Error("本次对话分析步骤已满,请新建对话。");
|
throw new Error("本次对话分析步骤已满,请新建对话。");
|
||||||
let result: AssistantResult = queries?.length
|
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.kind === "clarify"
|
||||||
? {
|
? {
|
||||||
plan,
|
plan,
|
||||||
@@ -332,7 +332,7 @@ router.post("/ask", async (c) => {
|
|||||||
metrics: [],
|
metrics: [],
|
||||||
answerKind: answerKind === "answer" ? "help" : "clarification",
|
answerKind: answerKind === "answer" ? "help" : "clarification",
|
||||||
}
|
}
|
||||||
: await execute(vehicles, plan);
|
: await execute(vehicles, plan, user);
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
plan = result.plan;
|
plan = result.plan;
|
||||||
if (result.unanswered?.length) {
|
if (result.unanswered?.length) {
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ const vehicles = ["Inventory", "Abnormal", "Pending", "Operating"].map(
|
|||||||
}) as Vehicle,
|
}) 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 visible = visibleVehicles(vehicles, user, "甲公司");
|
||||||
const result = assetResult(visible, validatePlan(plan));
|
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.equal(result.groups[0].count, result.rows.length);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
result.rows.map((v) => v.id),
|
result.rows.map((v) => v.id),
|
||||||
["0"],
|
["0", "1"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
test("Shanghai counts include districts and preserve permission and company scope", () => {
|
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.length, 1);
|
||||||
assert.equal(all.groups[0].count, 4);
|
assert.equal(all.groups[0].count, 4);
|
||||||
const scoped = assetResult(visibleVehicles(input, user, "甲公司"), query);
|
const scoped = assetResult(visibleVehicles(input, user, "甲公司"), query);
|
||||||
assert.equal(scoped.metrics[0].value, 2);
|
assert.equal(scoped.metrics[0].value, 3);
|
||||||
assert.deepEqual(scoped.rows.map(v => v.id).sort(), ["0", "3"]);
|
assert.deepEqual(scoped.rows.map(v => v.id).sort(), ["0", "1", "3"]);
|
||||||
assert.ok(scoped.rows.every(v => v.city === "上海市"));
|
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;
|
const result = events.find((e) => e.type === "result").result;
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
result.rows.map((r: { id: string }) => r.id),
|
result.rows.map((r: { id: string }) => r.id),
|
||||||
["0", "2"],
|
["0", "1", "2"],
|
||||||
);
|
);
|
||||||
assert.equal(prompts.length, 2);
|
assert.equal(prompts.length, 2);
|
||||||
const presentationInput = JSON.parse(JSON.parse(prompts[1]).messages[1].content);
|
const presentationInput = JSON.parse(JSON.parse(prompts[1]).messages[1].content);
|
||||||
assert.equal(presentationInput.stage, "presentation");
|
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.equal(presentationInput.results[0].rows, undefined);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
prompts.every(
|
prompts.every(
|
||||||
@@ -180,3 +180,18 @@ test("inventory subsets do not inherit parent inventory metric cards", () => {
|
|||||||
assert.doesNotMatch(r.summary, /其中异动.*待交车/);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type AssistantRow,
|
type AssistantRow,
|
||||||
} from "../../../shared/assets/assistant.js";
|
} from "../../../shared/assets/assistant.js";
|
||||||
import { vehicleStatusResult } from "./vehicle-status.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 { AuthUser } from "../../auth/types.js";
|
||||||
import type { Vehicle } from "../../types.js";
|
import type { Vehicle } from "../../types.js";
|
||||||
import {
|
import {
|
||||||
@@ -159,7 +159,7 @@ export function visibleVehicles(
|
|||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
subject: string | null,
|
subject: string | null,
|
||||||
) {
|
) {
|
||||||
return filterByPermission(all, user).filter(
|
return filterAssetVehiclesByPermission(all, user).filter(
|
||||||
(v) => !subject || v.subjectOrg === subject,
|
(v) => !subject || v.subjectOrg === subject,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function resultScope(plan: QueryPlan, subject: string | null): NonNullabl
|
|||||||
];
|
];
|
||||||
const definitions = plan.responseMode === "vehicle-status" ? ["状态来自当前资产台账,不代表车辆实时位置、行驶或故障状态。", "运营状态、库存归属、异动及待交车分别展示,不互相替代。"] : plan.kind === "flow" ? [
|
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} 个分组,指标总量和占比仍按完整筛选范围计算。`);
|
if (plan.topN) definitions.push(`仅展示前 ${plan.topN} 个分组,指标总量和占比仍按完整筛选范围计算。`);
|
||||||
return {
|
return {
|
||||||
company: subject || "全部公司(当前账号可见)",
|
company: subject || "全部公司(当前账号可见)",
|
||||||
permission: "仅统计当前账号有权限查看的车辆",
|
permission: plan.kind === "flow" ? "交退换记录沿用当前账号原有数据权限" : "库存(含待交车、异动)全量可见,非库存车辆按当前账号数据权限查看",
|
||||||
filters,
|
filters,
|
||||||
grouping: plan.kind === "flow" ? "按日" : plan.groupBy === "none" ? "不分组" : `按${GROUP_LABELS[plan.groupBy]}${plan.secondaryGroupBy ? ` × ${GROUP_LABELS[plan.secondaryGroupBy]}` : ""}`,
|
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}(含首尾日期)` : "当前车辆状态(非历史快照)",
|
period: plan.kind === "flow" ? `${plan.start} 至 ${plan.end}(含首尾日期)` : "当前车辆状态(非历史快照)",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
BatchGroup,
|
BatchGroup,
|
||||||
InventoryTypeSummary,
|
InventoryTypeSummary,
|
||||||
} from '../types.js';
|
} 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 { AuthUser } from '../auth/types.js';
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import {
|
import {
|
||||||
@@ -46,12 +46,12 @@ import {
|
|||||||
|
|
||||||
export { mapRegion } from './vehicles/model.js';
|
export { mapRegion } from './vehicles/model.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||||
|
|
||||||
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
|
async function getVehiclesForUser(c: Context, includePublicInventory = true): Promise<Vehicle[]> {
|
||||||
const all = await vehicleRepository.getVehicles();
|
const all = await vehicleRepository.getVehicles();
|
||||||
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
|
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);
|
list = applySubjectFilter(c, list);
|
||||||
return maskCustomerNames(list);
|
return maskCustomerNames(list);
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,8 @@ function applySubjectFilter(c: Context, vehicles: Vehicle[]): Vehicle[] {
|
|||||||
// GET /api/vehicles/summary
|
// GET /api/vehicles/summary
|
||||||
app.get('/summary', async (c) => {
|
app.get('/summary', async (c) => {
|
||||||
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]);
|
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 = {
|
const summary: SummaryData = {
|
||||||
totalAssets: vehicles.length,
|
totalAssets: vehicles.length,
|
||||||
operating: {
|
operating: {
|
||||||
@@ -98,7 +99,15 @@ app.get('/summary', async (c) => {
|
|||||||
|
|
||||||
// GET /api/vehicles/by-type
|
// GET /api/vehicles/by-type
|
||||||
app.get('/by-type', async (c) => {
|
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 result: TypeSummary[] = SUMMARY_TYPE_FILTERS.map((t) => {
|
||||||
const typeVehicles = vehicles.filter(t.filter);
|
const typeVehicles = vehicles.filter(t.filter);
|
||||||
@@ -587,8 +596,9 @@ app.get('/weekly-detail', async (c) => {
|
|||||||
const hasModelFilter = model && model !== 'All';
|
const hasModelFilter = model && model !== 'All';
|
||||||
const hasBatchFilter = batch && batch !== 'All';
|
const hasBatchFilter = batch && batch !== 'All';
|
||||||
const hasLocationFilter = location && location !== '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;
|
let pool2 = vehicles;
|
||||||
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
|
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
|
||||||
if (hasBatchFilter) pool2 = pool2.filter((v) => (v.contractNo || '未知') === batch);
|
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) => {
|
app.get('/flow-stats', async (c) => {
|
||||||
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
|
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 allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id)));
|
||||||
|
|
||||||
const rows = await vehicleRepository.getFlowDetailRows(start, end);
|
const rows = await vehicleRepository.getFlowDetailRows(start, end);
|
||||||
@@ -654,7 +664,7 @@ app.get('/flow-stats', async (c) => {
|
|||||||
app.get('/subjects', async (c) => {
|
app.get('/subjects', async (c) => {
|
||||||
const all = await vehicleRepository.getVehicles();
|
const all = await vehicleRepository.getVehicles();
|
||||||
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
|
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<string, { total: number; inventory: number; operating: number }>();
|
const map = new Map<string, { total: number; inventory: number; operating: number }>();
|
||||||
for (const v of visible) {
|
for (const v of visible) {
|
||||||
|
|||||||
@@ -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);
|
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<string>(), replaced: new Set<string>(),
|
||||||
|
}));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export default defineConfig({
|
|||||||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
|
// Preview requests append audit logs; watching them creates a reload/request loop.
|
||||||
|
watch: { ignored: ['**/logs/**'] },
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
|
|||||||
Reference in New Issue
Block a user