Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import * as reports from './src/reports/index.js';
import * as pages from './src/pages/index.js';
import { KpiApp } from './src/apps/index.js';
import { allHooks } from './src/hooks/index.js';
import { scopeMaterializeHooks } from './src/services/scope-materialize.js';
import { allPermissionSets, allPositions } from './src/security/index.js';
import { registerKpiPositionBindings } from './src/security/bind-position-sets.js';
import { registerKpiAdminSetBinding } from './src/security/bind-admin-set.js';
import { KpiTranslationBundle } from './src/translations/index.js';
import { KpiSeedData } from './src/data/index.js';
import { registerDemoUnitAlignment } from './src/data/align-demo-units.js';
Expand Down Expand Up @@ -45,7 +47,9 @@ export default defineStack({
dashboards: Object.values(dashboards),
reports: Object.values(reports),

hooks: allHooks,
// 数据范围补齐 hook 单独注册(见 services/scope-materialize.ts):它补的是平台对系统上下文
// 写入跳过的共享物化,与 hooks/ 下的业务规则不是一类东西。
hooks: [...allHooks, ...scopeMaterializeHooks],

positions: allPositions,
permissions: allPermissionSets,
Expand All @@ -56,6 +60,9 @@ export default defineStack({
/** 岗位↔权限集绑定在安全引导完成后幂等补齐(不能用种子,见 bind-position-sets.ts)。 */
export const onEnable = async (ctx: unknown): Promise<void> => {
registerKpiPositionBindings(ctx as Parameters<typeof registerKpiPositionBindings>[0]);
// 平台管理员即考核系统管理员(CLAUDE.md D4):菜单闸门按能力精确匹配、不认平台超级权限,
// 这条隐含安排必须写成绑定行,否则管理员在 KPI 应用里看不到配置类菜单。
registerKpiAdminSetBinding(ctx as Parameters<typeof registerKpiAdminSetBinding>[0]);
// 演示夹具的租户对齐 —— 临时,随 objectstack-ai/objectstack#14547 的平台修复一起删除。
registerDemoUnitAlignment(ctx as Parameters<typeof registerDemoUnitAlignment>[0]);
// 历史行的方案回填:升级前建的核对任务 / 数据调整 plan 为空,落不进按方案的共享规则条件。
Expand Down
78 changes: 71 additions & 7 deletions scripts/e2e-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -228,18 +228,82 @@ const market3 = sheets3.find((x) => x.name.includes('市场部'));
const east3 = sheets3.find((x) => x.name.includes('华东分公司'));
log('T38', '发布第三版方案(市场部已配分管领导)生成 5 张填报单', sheets3.length === 5 && !!market3 && !!east3, `${msg(r).slice(0, 80)} sheets=${sheets3.length}`);

const rulesOf = async (planId) => (await list('sys_sharing_rule', '?limit=500')).filter((x) => String(x.criteria_json ?? '').includes(planId) && x.active !== false);
// 一个方案名下的全部共享规则,按**规则名前缀**回收。
//
// 不按「条件里含方案 id」筛:审核记录对象上没有方案列(它只认填报单),而平台的共享条件
// 只认对象自己的列、不支持跨对象遍历,所以那批规则的条件是 `{sheet}` —— 含方案 id 的过滤
// 会把它们整批漏掉,数出来的条数偏少,「每条都带方案 id」的断言也跟着空过。方案归属由规则
// 名前缀保证(services/sharing-service.ts 的 planRulePrefix)。
//
// 前缀里的方案片段是稳定散列,脚本**不复刻**那套算法(复刻就会和实现两地各飘一份):改从
// 一条必然存在的锚点规则上反推 —— 每个参与主体都有一条 `<前缀>sheet_<单元>` 规则。锚不到
// 就返回空数组,让调用方的精确相等断言当场变红,而不是悄悄退化成「零条也算过」。
// 共享规则要**翻页**取全,不能一把 `?limit=500` 了事:一个方案现在就写近百条规则,几个
// 方案下来轻松过 500 —— 截断的症状是某条规则「凭空消失」,断言红在一个与截断毫无关系的
// 地方(本次就是 T70 数出 3 条岗位规则而不是 4 条),排查成本远高于这几行。
const allSharingRules = async () => {
const out = [];
for (let skip = 0; ; skip += 500) {
const r = await call('GET', `/data/sys_sharing_rule?limit=500&skip=${skip}`);
const page = rows(r);
out.push(...page);
if (page.length < 500 || r.json?.hasMore === false) break;
}
return out;
};

const rulesOf = async (planId) => {
const all = await allSharingRules();
const subs = await list('kpi_plan_subject', `?plan=${planId}&limit=50`);
let prefix = null;
for (const sub of subs) {
const suffix = `sheet_${sub.subject}`;
const anchor = all.find((x) => x.object_name === 'kpi_entry_sheet' && x.recipient_type === 'unit_and_subordinates'
&& x.recipient_id === sub.subject && String(x.name ?? '').endsWith(suffix)
&& String(x.criteria_json ?? '').includes(planId));
if (anchor) { prefix = String(anchor.name).slice(0, -suffix.length); break; }
}
if (!prefix) return [];
return all.filter((x) => String(x.name ?? '').startsWith(prefix) && x.active !== false);
};
const kpiRules = await rulesOf(plan3.id);
// 推导条数(不是下限):5 个参与主体 × 4 类(填报单 / 核对任务 / 调整 / 结果)= 20,
// 每个配了分管领导的主体 → +2(该主体填报单、该主体结果),每位分管领导 → +1(本人结果),
// 2 条到人分工 → +2,人力岗位 2 个 × 2 类(填报单 / 调整)→ +4。
// 推导条数(不是下限):
// · 每个参与主体 8 类 —— 填报单 / 核对任务 / 调整 / 结果,加上按主体保密的
// 参与主体 / 指标下达 / 到人分工 / 归档快照;
// · 每个配了分管领导的主体 → +6(该主体填报单、该主体结果,加上后四类给领导);
// · 每位分管领导 → +1(本人结果);2 条到人分工 → +2;人力岗位 2 个 × 2 类 → +4;
// · 每张填报单 → +1(该单的审核记录),其主体配了分管领导再 +1;
// · 主体类型为「总公司部门」的那张单 → +1(本单核对任务给被核对的部门),配了分管领导再 +1。
// 分公司主体不建这条:它自己那张单上挂着全部分公司的核对任务,给了就等于让华东看见华南。
// 分管领导按方案实配推导:全部主体都要配分管领导(发布前完整性检查),市场部配的是 C,
// 其余主体沿用复制自上一版的管理员账号。
const subs3After = await list('kpi_plan_subject', `?plan=${plan3.id}&limit=50`);
const leaderSubs3 = subs3After.filter((x) => x.leader);
const EXPECTED_RULES = 5 * 4 + leaderSubs3.length * 2 + new Set(leaderSubs3.map((x) => String(x.leader))).size + 2 + 2 * 2;
const leaderUnits3 = new Set(leaderSubs3.map((x) => String(x.subject)));
const deptSubs3 = subs3After.filter((x) => x.subject_type === 'department');
const leaderSheets3 = sheets3.filter((x) => leaderUnits3.has(String(x.subject))).length;
const deptLeaderSubs3 = deptSubs3.filter((x) => leaderUnits3.has(String(x.subject))).length;
const EXPECTED_RULES = subs3After.length * 8 + leaderSubs3.length * 6 + new Set(leaderSubs3.map((x) => String(x.leader))).size + 2 + 2 * 2
+ sheets3.length + leaderSheets3 + deptSubs3.length + deptLeaderSubs3;
// 条件落在填报单上(而不是方案上)的那批 = 审核记录规则:每张单一条,主体配了领导再一条。
// 本单核对任务的条件同时带方案与填报单,算在「带方案 id」那边。
const EXPECTED_SHEET_SCOPED = sheets3.length + leaderSheets3;
log('T39', '发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动', kpiRules.length === EXPECTED_RULES && kpiRules.some((x) => x.object_name === 'kpi_entry_sheet' && x.recipient_type === 'unit_and_subordinates' && x.recipient_id === 'bu_market') && kpiRules.some((x) => x.recipient_type === 'user' && x.recipient_id === U.c.id && x.object_name === 'kpi_entry_sheet'), `rules=${kpiRules.length} expected=${EXPECTED_RULES}`);
log('T39b', '规则条件按方案隔离:每条规则的条件里都带本方案 id', kpiRules.length > 0 && kpiRules.every((x) => { try { return JSON.parse(x.criteria_json).plan === plan3.id; } catch { return false; } }), `${kpiRules.length} 条`);
// 条件按方案隔离:要么直接带本方案 id,要么带**本方案某张填报单**的 id(审核记录对象上
// 没有方案列;一张填报单只属于一个方案的一个主体,粒度比方案更细)。两类条数都精确相等,
// 任何一类落空或出现第三类(条件既不带方案也不带本方案的单)都判红。
const sheetIds3 = new Set(sheets3.map((x) => String(x.id)));
const scopeOf3 = (x) => {
let c;
try { c = JSON.parse(x.criteria_json); } catch { return 'other'; }
if (c && c.plan === plan3.id) return 'plan';
if (c && typeof c.sheet === 'string' && sheetIds3.has(c.sheet)) return 'sheet';
return 'other';
};
const scoped3 = kpiRules.map(scopeOf3);
const byPlan3 = scoped3.filter((k) => k === 'plan').length;
const bySheet3 = scoped3.filter((k) => k === 'sheet').length;
log('T39b', '规则条件按方案隔离:要么带本方案 id,要么带本方案填报单的 id(审核记录对象没有方案列)', kpiRules.length > 0 && byPlan3 + bySheet3 === kpiRules.length && bySheet3 === EXPECTED_SHEET_SCOPED && byPlan3 > 0, `按方案 ${byPlan3} 条 / 按填报单 ${bySheet3} 条(期望 ${EXPECTED_SHEET_SCOPED})/ 共 ${kpiRules.length} 条`);
const sheetShared = await waitUntil('市场部填报单共享给 A', async () => {
const rowsNow = await list('sys_record_share', '?limit=500');
return rowsNow.some((x) => x.object_name === 'kpi_entry_sheet' && x.record_id === market3.id && x.recipient_type === 'user' && x.recipient_id === U.a.id && x.access_level === 'edit');
Expand Down Expand Up @@ -313,7 +377,7 @@ sheet3 = await get('kpi_entry_sheet', market3.id);
log('T49', '否决加减分后立即重算,被否决的分不计入部门结果', r.status < 300 && !!afterReject && Number(afterReject.score) === Number(after3.score) && Number(afterReject.score) === Number(sheet3.total_score), `after_reject=${afterReject?.score} expected=${after3?.score} sheet_total=${sheet3.total_score} ${msg(r).slice(0, 80)}`);

// 验收 7:静态共享规则文件已删除(元数据零改动的前提)
const staticRules = await list('sys_sharing_rule', '?limit=500');
const staticRules = await allSharingRules();
const kpiOwned = staticRules.filter((x) => String(x.name).startsWith('kpi_p') || String(x.name).startsWith('kpi_share_'));
log('T50', '不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则)', kpiOwned.length > 0 && kpiOwned.every((x) => x.managed_by !== 'package') && !kpiOwned.some((x) => String(x.name).startsWith('kpi_share_')), `${kpiOwned.length} 条,示例 ${kpiOwned.slice(0, 2).map((x) => `${x.name}:${x.managed_by}`).join(' | ')}`);

Expand Down
37 changes: 28 additions & 9 deletions src/apps/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
import { App } from '@objectstack/spec/ui';
import { PLAN_CONFIG_CAPABILITY } from '../security/index.js';

/**
* 配置类菜单按岗位裁剪(维护者 2026-09-03 拍板:各部门目标值互相保密)。
*
* 用 `requiredPermissions` 而不是 `visible`:平台把 `visible` 定为浏览器里求值的 CEL
* 谓词,而 `requiredPermissions` 是**服务端**闸门 —— 不满足的导航项在 `/meta` 里就被剥掉,
* 根本不下发到浏览器(spec `app.zod.ts`:「anything that must never reach the browser goes
* in `requiredPermissions`, never in `visible`」)。本次实测还证实了这一点的另一半:本版本
* 的控制台对导航项的 `visible` **根本不求值**(分组与叶子项都不生效,谓词原样下发到浏览器
* 后被忽略),已按「只上报不修复」报到平台:objectstack-ai/objectstack#15135。
*
* ⚠️ `requiredPermissions` 是**逐条 AND、字符串精确匹配、无通配、无超级用户豁免**,平台
* 管理员也不例外 —— 所以平台管理员必须真正持有本能力,见 `security/bind-admin-set.ts`。
*
* 菜单裁剪只是入口层面的整洁,真正的边界仍是数据层 —— 对象 OWD 收成 `private` + 按方案的
* 动态共享规则(services/sharing-service.ts)+ 权限集 `readScope: 'own'`。
*/
const PLAN_CONFIG_MENU = [PLAN_CONFIG_CAPABILITY];

export const KpiApp = App.create({
name: 'kpi_app',
Expand All @@ -18,13 +37,13 @@ export const KpiApp = App.create({
],
},
{
id: 'group_plan', type: 'group', label: '考核方案', icon: 'clipboard-list',
id: 'group_plan', type: 'group', label: '考核方案', icon: 'clipboard-list', requiredPermissions: PLAN_CONFIG_MENU,
children: [
{ id: 'nav_plans', type: 'object', objectName: 'kpi_plan', label: '方案版本', icon: 'clipboard-list' },
{ id: 'nav_plan_indicators', type: 'object', objectName: 'kpi_plan_indicator', label: '指标下达', icon: 'list-checks' },
{ id: 'nav_plan_subjects', type: 'object', objectName: 'kpi_plan_subject', label: '参与主体', icon: 'building' },
{ id: 'nav_assignments', type: 'object', objectName: 'kpi_staff_assignment', label: '到人分工', icon: 'users' },
{ id: 'nav_disputes', type: 'object', objectName: 'kpi_dispute', label: '指标争议', icon: 'message-square-warning' },
{ id: 'nav_plans', type: 'object', objectName: 'kpi_plan', label: '方案版本', icon: 'clipboard-list', requiredPermissions: PLAN_CONFIG_MENU },
{ id: 'nav_plan_indicators', type: 'object', objectName: 'kpi_plan_indicator', label: '指标下达', icon: 'list-checks', requiredPermissions: PLAN_CONFIG_MENU },
{ id: 'nav_plan_subjects', type: 'object', objectName: 'kpi_plan_subject', label: '参与主体', icon: 'building', requiredPermissions: PLAN_CONFIG_MENU },
{ id: 'nav_assignments', type: 'object', objectName: 'kpi_staff_assignment', label: '到人分工', icon: 'users', requiredPermissions: PLAN_CONFIG_MENU },
{ id: 'nav_disputes', type: 'object', objectName: 'kpi_dispute', label: '指标争议', icon: 'message-square-warning', requiredPermissions: PLAN_CONFIG_MENU },
],
},
{
Expand All @@ -39,10 +58,10 @@ export const KpiApp = App.create({
],
},
{
id: 'group_setup', type: 'group', label: '基础设置', icon: 'settings',
id: 'group_setup', type: 'group', label: '基础设置', icon: 'settings', requiredPermissions: PLAN_CONFIG_MENU,
children: [
{ id: 'nav_indicators', type: 'object', objectName: 'kpi_indicator', label: '指标库', icon: 'target' },
{ id: 'nav_ops_dashboard', type: 'dashboard', dashboardName: 'kpi_progress_dashboard', label: '流程进度', icon: 'activity' },
{ id: 'nav_indicators', type: 'object', objectName: 'kpi_indicator', label: '指标库', icon: 'target', requiredPermissions: PLAN_CONFIG_MENU },
{ id: 'nav_ops_dashboard', type: 'dashboard', dashboardName: 'kpi_progress_dashboard', label: '流程进度', icon: 'activity', requiredPermissions: PLAN_CONFIG_MENU },
],
},
{
Expand Down
4 changes: 3 additions & 1 deletion src/objects/entry.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ export const ReviewRecord = ObjectSchema.create({
pluralLabel: '审核记录',
icon: 'history',
description: '填报单每一次流程动作的留痕:谁、何时、从哪到哪、原因。',
sharingModel: 'public_read',
// 审计留痕同样按主体保密:审核记录上没有主体列(它只认填报单),平台共享条件不支持跨对象
// 遍历,所以按**填报单**建规则 —— 一张填报单只属于一个主体,粒度比按主体更细。
sharingModel: 'private',
nameField: 'name',

fields: {
Expand Down
14 changes: 11 additions & 3 deletions src/objects/plan.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ export const PlanSubject = ObjectSchema.create({
pluralLabel: '参与主体',
icon: 'building',
description: '方案的考核主体、考核部门、主体权重与分管领导。',
sharingModel: 'controlled_by_parent',
// 各部门目标值互相保密(维护者 2026-09-03 拍板):OWD 必须是 private —— 平台共享层把
// `controlled_by_parent` 归入 public(plugin-sharing `effectiveSharingModel`),读侧过滤
// 会在第一道闸门直接放行,权限集里的 readScope 也随之失效。本部门 / 本分公司 / 分管范围
// 的可见性由方案发布时写入的动态共享规则(services/sharing-service.ts)按主体放宽。
sharingModel: 'private',
nameField: 'name',

fields: {
Expand Down Expand Up @@ -174,7 +178,9 @@ export const PlanIndicator = ObjectSchema.create({
pluralLabel: '指标下达',
icon: 'list-checks',
description: '把指标分配到考核主体,并给定权重与目标值。',
sharingModel: 'controlled_by_parent',
// 目标值与权重是本次保密口径的核心对象,理由同「参与主体」:OWD 收成 private,
// 按主体的可见范围交给动态共享规则。
sharingModel: 'private',
nameField: 'name',

fields: {
Expand Down Expand Up @@ -274,7 +280,9 @@ export const StaffAssignment = ObjectSchema.create({
pluralLabel: '到人分工',
icon: 'users',
description: '员工与部门板块的对应关系及其权重、个人系数。',
sharingModel: 'controlled_by_parent',
// 理由同「参与主体」:OWD 收成 private,按部门板块的可见范围交给动态共享规则。
// 个人承接项(kpi_personal_item)仍是本对象的主从子记录,随主记录一起收窄。
sharingModel: 'private',
nameField: 'name',

fields: {
Expand Down
3 changes: 2 additions & 1 deletion src/objects/result.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export const Snapshot = ObjectSchema.create({
pluralLabel: '归档快照',
icon: 'archive',
description: '某周期填报单归档后的不可变留存,历史查询以此为准。',
sharingModel: 'public_read',
// 快照里是某主体的最终得分与全量明细,按主体保密;可见范围由动态共享规则按主体放宽。
sharingModel: 'private',
nameField: 'name',

fields: {
Expand Down
Loading
Loading