fix(assets): expose full inventory for scoped users and stabilize previews
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-28 13:16:12 +08:00
parent f1b3f8d3da
commit 48cf429468
17 changed files with 191 additions and 57 deletions
@@ -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 });
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/);
});
}