weaver-e9-assistant-cli 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { loadConfig, resolveViewcondition, endpointFor, setConfigField } from './config.js';
6
- import { getWfList, getSmartTodoGroups, resolveWorkflowType } from './workflow.js';
6
+ import { getWfList, getOaTabGroups, resolveWorkflowType, CATEGORY_SCOPE } from './workflow.js';
7
7
  import { parseIdList, batchSubmit, batchForward, batchAttention, getAttentionTypes } from './todobatch.js';
8
8
  import { pageUrl, availablePages } from './pageopen.js';
9
9
  import { buildBlogMyUrl, buildCubeCardUrl, buildCubeSearchUrl, normalizeE9Url } from './links.js';
@@ -28,9 +28,10 @@ import { searchWorkplan, createWorkplan, getMyCalendar, getAttentionData, getBas
28
28
  import { listOdocTodo, getOdocTodoCount, listOdocCreate } from './odoc.js';
29
29
  import { punch, getButtons, resolvePunchItem, signSecret, buildEncryptObj } from './kq.js';
30
30
  import { collectQueryableBusiness } from './report.js';
31
- import { listCube, getCubeCondition, getCubeInit, exportCubeExcel, getSearchColumnInfo, listCubeExcel, getCubeCountData, getCubeGroupData, getExpCardConfig, getModeBrowserOptions, getCardFields, submitCubeCard, deleteCubeData, printCubePdf, listCubeMenus, findCubeByName, fetchMenuMounts, previewCubeCard, fetchCubeRowsByIds, resolveCubeCondition, downloadCubeImportTemplate, importCubeExcel } from './cube.js';
31
+ import { listCube, getCubeCondition, getCubeInit, exportCubeExcel, getSearchColumnInfo, listCubeExcel, getCubeCountData, getCubeGroupData, getExpCardConfig, getModeBrowserOptions, getCardFields, submitCubeCard, deleteCubeData, printCubePdf, listCubeMenus, findCubeByName, fetchMenuMounts, previewCubeCard, fetchCubeRowsByIds, resolveCubeCondition, downloadCubeImportTemplate, importCubeExcel, assertEditableCardValues } from './cube.js';
32
32
  import { getCurrentUser, getCurrentUserIsAdmin } from './wfuser.js';
33
33
  import { getSubordinates, assertUserIdsInScope, assertNamesInScope, isScopeEnforced } from './subordinate.js';
34
+ import { resolveHrmTab, getHrmTabs, getHrmConditions, resolveHrmCondition, hrmList, buildHrmCardUrl, findHrmOrgNode, applyResolvedPersonIds } from './hrm.js';
34
35
  import { parseArgs, flagStr, flagBool } from './args.js';
35
36
  import { todayStr } from './util.js';
36
37
  import { BOX_TOP, DIVIDER } from './render.js';
@@ -145,6 +146,10 @@ function usage() {
145
146
  ' e9 cube find <名称> 按名模糊匹配台账(列候选消歧,返回 customid+mode+app,供 list/create 等用)',
146
147
  ' e9 cube find <名称> --tree-detail 展开 tree 测试类台账的全部树节点路径(默认只显首条菜单路径)',
147
148
  ' e9 subordinate list [--name <姓名>] [--all-level] [--page <n>] [--json] [--raw] [--refresh] 查询我的下属(默认直接下属;--all-level 全部层级)',
149
+ ' e9 hrm tabs 列出人事搜索范围(全部人员/同部门/我的下属)',
150
+ ' e9 hrm conditions [--tab <全部人员|同部门|我的下属>] 列出该范围可用搜索条件(label→domkey)',
151
+ ' e9 hrm search [--tab <全部人员|同部门|我的下属>] [--name <姓名>] [--condition "岗位=经理;性别=男"]',
152
+ ' [--dept <部门名|id>] [--subcompany <分部名|id>] [--all-level] [--page <n>] [--json] [--raw] 人事搜索(姓名=详情卡片超链接)',
148
153
  ' e9 search <名称> 统一搜台账+菜单+流程(只说名字、不知归属时用,避免猜错域)',
149
154
  ' e9 config 查看当前配置来源与状态',
150
155
  ' e9 config set <field> <value> 设置配置(field: baseUrl/cookie/username/menuIds/...;首次用 set baseUrl)',
@@ -340,7 +345,7 @@ function escapeLinkText(text) {
340
345
  return String(text).replace(/([\[\]()\\])/g, '\\$1');
341
346
  }
342
347
  /** 统一拼各域 list/search 项的 markdown 超链接;无 id/无可链目标时回退纯文本。
343
- * domain: 'todo'|'odoc'|'create'|'blog'|'email'|'cube'|'workplan'
348
+ * domain: 'todo'|'odoc'|'create'|'blog'|'email'|'cube'|'workplan'|'hrm'
344
349
  * text: 展示文本(调用方已计算好回退值);ctx: cube 需 {modeId,formId,customid} */
345
350
  function hyperlinkFor(domain, item, base, text, ctx = {}) {
346
351
  const meta = linkMetaFor(domain, item, base, ctx);
@@ -389,6 +394,12 @@ function linkMetaFor(domain, item, base, ctx = {}) {
389
394
  url = `${b}/spa/workplan/static/index.html#/main/wp/myWorkPlan`;
390
395
  urlType = url ? 'list-generic' : '';
391
396
  break;
397
+ case 'hrm':
398
+ // 人事详情卡片(真机验证通过):SPA hash 路由 #/main/hrm/card/cardInfo/<hrmresource.id>
399
+ if (item?.id)
400
+ url = buildHrmCardUrl(b, item.id);
401
+ urlType = url ? 'detail' : '';
402
+ break;
392
403
  }
393
404
  return {
394
405
  url: url || null,
@@ -3432,6 +3443,12 @@ async function runCube(args) {
3432
3443
  catch {
3433
3444
  curFields = null;
3434
3445
  }
3446
+ // ★字段属性硬规则:只读字段禁止填值,编辑 dry-run 也要拦(真实提交由 submitCubeCard 兜底)。
3447
+ // 判据必须取「新建态」字段定义——curFields 带 billid 是查看态,isedit 恒为 false,会误伤全部字段。
3448
+ const defFields = (await getCardFields(cfg, { modeId, formId })).fields;
3449
+ assertEditableCardValues(defFields, values);
3450
+ if (files.length)
3451
+ assertEditableCardValues(defFields, Object.fromEntries(files.map((f) => [String(f.fieldId), '1'])));
3435
3452
  // 查看态不带值时:list 反查该条记录的行值作对比兜底
3436
3453
  if (!(curFields && curFields.fields.some((f) => f.value !== undefined && f.value !== null && f.value !== ''))) {
3437
3454
  try {
@@ -3784,6 +3801,152 @@ async function runSubordinate(args) {
3784
3801
  console.log(` ${meta}`);
3785
3802
  }
3786
3803
  }
3804
+
3805
+ // ─── 组织人事(HRM)搜索 ─────────────────────────────────
3806
+
3807
+ /** 组织节点名称 → id(用于 --dept/--subcompany 名称定位;纯数字原样直传)。 */
3808
+ async function resolveHrmOrgValue(cfg, keyword, { matchType, flag }) {
3809
+ const node = await findHrmOrgNode(cfg, keyword, { matchType });
3810
+ if (!node)
3811
+ throw new Error(`${flag} 无法在组织树中定位「${keyword}」(type=${matchType})。可先用数字 ID 直传,或确认名称在人事组织树中存在。`);
3812
+ return node.id;
3813
+ }
3814
+
3815
+ /** 未识别条件的提示(含相近候选),与 cube 的 printConditionUnresolved 一致——不给静默放空。 */
3816
+ function printHrmConditionUnresolved(unresolved) {
3817
+ for (const u of unresolved) {
3818
+ const cands = (u.candidates ?? []).join('、');
3819
+ console.error(`⚠ 人事条件「${u.label}」未匹配到搜索条件字段${cands ? `。相近字段候选: ${cands}` : ''}(可用 e9 hrm conditions 查看全部可用条件名)。`);
3820
+ }
3821
+ process.exit(1);
3822
+ }
3823
+
3824
+ async function runHrm(args) {
3825
+ const sub = args._[1] ?? 'search';
3826
+ const cfg = loadConfig(flagStr(args, 'config'));
3827
+ if (sub === 'tabs') {
3828
+ const r = await getHrmTabs(cfg);
3829
+ if (args.flags.json) {
3830
+ console.log(JSON.stringify({ tabs: r.tabs }, null, 2));
3831
+ return;
3832
+ }
3833
+ console.log('E9 人事搜索范围:');
3834
+ if (r.tabs.length === 0) {
3835
+ console.log('(无可用范围,可能无组织人事查看权限)');
3836
+ return;
3837
+ }
3838
+ for (const t of r.tabs)
3839
+ console.log(` ${t.title} (${t.typeid})`);
3840
+ return;
3841
+ }
3842
+ if (sub === 'conditions') {
3843
+ const tab = flagStr(args, 'tab') ?? '全部人员';
3844
+ const mouldid = resolveHrmTab(tab);
3845
+ const r = await getHrmConditions(cfg, { mouldid });
3846
+ if (args.flags.json) {
3847
+ console.log(JSON.stringify({ tab, mouldid, groups: r.groups }, null, 2));
3848
+ return;
3849
+ }
3850
+ console.log(`E9 人事搜索条件 [${tab}]:`);
3851
+ if (r.groups.length === 0) {
3852
+ console.log('(无可用条件)');
3853
+ return;
3854
+ }
3855
+ for (const g of r.groups) {
3856
+ console.log(` ${g.title}:`);
3857
+ for (const it of g.items)
3858
+ console.log(` - ${it.label} (${it.domkey}${it.conditionType ? `, ${it.conditionType}` : ''})`);
3859
+ }
3860
+ return;
3861
+ }
3862
+ if (sub !== 'search') {
3863
+ console.error(`hrm 子命令: tabs/conditions/search,收到: ${sub}`);
3864
+ process.exit(1);
3865
+ }
3866
+ const tab = flagStr(args, 'tab') ?? '全部人员';
3867
+ const tabkey = resolveHrmTab(tab);
3868
+ const page = Number(flagStr(args, 'page') ?? '1') || 1;
3869
+ const allLevel = flagBool(args, 'all-level');
3870
+ const name = flagStr(args, 'name');
3871
+ const dept = flagStr(args, 'dept');
3872
+ const subcompany = flagStr(args, 'subcompany');
3873
+ const spec = flagStr(args, 'condition') ?? '';
3874
+
3875
+ const values = {};
3876
+ // ★越权防护:按名过滤目标必须 ∈ {本人 + 全部层级下属}(与 subordinate/todo/cube 一致:默认停用 no-op)
3877
+ if (name) {
3878
+ await assertNamesInScope(cfg, [name], { domain: 'hrm' });
3879
+ values.resourcename = name;
3880
+ }
3881
+ // --dept/--subcompany:数字直传 domkey;名称走组织树 BFS 定位(部门 type 2 / 分部 type 1)
3882
+ if (dept)
3883
+ values.departmentid = /^\d+$/.test(dept) ? dept : await resolveHrmOrgValue(cfg, dept, { matchType: '2', flag: '--dept' });
3884
+ if (subcompany)
3885
+ values.subcompanyid1 = /^\d+$/.test(subcompany) ? subcompany : await resolveHrmOrgValue(cfg, subcompany, { matchType: '1', flag: '--subcompany' });
3886
+ // --condition 中文条件(label→domkey 自动映射)
3887
+ const condRes = await resolveHrmCondition(cfg, { mouldid: tabkey, condition: spec });
3888
+ if (condRes.unresolved.length)
3889
+ printHrmConditionUnresolved(condRes.unresolved);
3890
+ // ★越权防护(fail-closed):无法确认是否按人过滤的条件 → 硬拒绝;策略未启用(默认)不拦,仅保留代码路径
3891
+ if (isScopeEnforced() && condRes.unverifiablePerson)
3892
+ throw new Error('拒绝越权查询:人事搜索条件无法确认是否按人过滤(条件定义读取失败或含未识别字段)。为防越权,本次查询已拒绝,请稍后重试或改用中文字段名。');
3893
+ // ★越权防护:按人条件(姓名/直接上级/助理)目标必须 ∈ {本人 + 全部层级下属}
3894
+ // assertNamesInScope 同时返回解析后的 uid(blog at-list 硬规则唯一入口)。
3895
+ // ★E9 实测校准(2026-09-20 e9pc):姓名=INPUT 认姓名文本;直接上级/助理=BROWSER 人字段认 **hrmresource id**
3896
+ // (=userid,本人 7111 验证 manager=7111 → 9 个直接下属;manager=杨文元sh → 0)。故 BROWSER 人字段值回填为 uid。
3897
+ if (condRes.personConditions?.length) {
3898
+ const resolvedIds = await assertNamesInScope(cfg, condRes.personConditions.map((p) => p.value), { domain: 'hrm' });
3899
+ condRes.conditionValues = applyResolvedPersonIds(condRes.conditionValues, condRes.personConditions, resolvedIds);
3900
+ }
3901
+ Object.assign(values, condRes.conditionValues);
3902
+ // ★E9 实测校准:--condition 里的 部门/分部 已由 resolveHrmCondition 重映射为 departmentid/subcompanyid1
3903
+ // (条件定义 domkey=department/subcompany 不是搜索接口认的参数);名称值在此经组织树解析为 id(同 --dept/--subcompany 路径)。
3904
+ for (const [key, matchType, label] of [['departmentid', '2', '部门'], ['subcompanyid1', '1', '分部']]) {
3905
+ const raw = values[key];
3906
+ if (raw != null && raw !== '' && !/^\d+$/.test(String(raw)))
3907
+ values[key] = await resolveHrmOrgValue(cfg, String(raw), { matchType, flag: `--condition "${label}=${raw}"` });
3908
+ }
3909
+
3910
+ const r = await hrmList(cfg, { tabkey, values, showAllLevel: allLevel, page });
3911
+ const base = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
3912
+ if (args.flags.raw) {
3913
+ console.error('===== RAW getHrmSearchResult =====');
3914
+ console.error(JSON.stringify(r.raw.search, null, 2));
3915
+ console.error('===== RAW datas =====');
3916
+ console.error(JSON.stringify(r.raw.datas, null, 2));
3917
+ console.error('===== RAW counts =====');
3918
+ console.error(JSON.stringify(r.raw.counts, null, 2));
3919
+ console.error('===== 解析结果 =====');
3920
+ }
3921
+ if (args.flags.json) {
3922
+ console.log(JSON.stringify({
3923
+ tab, tabkey, page,
3924
+ pageSize: r.pageSize, total: r.total, count: r.count,
3925
+ sessionkey: r.sessionkey,
3926
+ columns: r.columns.map((c) => ({ dataIndex: c.dataIndex, title: c.title })),
3927
+ items: r.items.map((it) => withLink('hrm', { id: it.id, name: it.name, fields: it.fields.reduce((acc, f) => { acc[f.dataIndex] = f.value; return acc; }, {}) }, base)),
3928
+ }, null, 2));
3929
+ return;
3930
+ }
3931
+ console.log(`E9 人事 [${tab}]${allLevel ? '(含子部门)' : ''} 第 ${page} 页${r.pageSize != null ? ` · 每页 ${r.pageSize} 条` : ''}${r.total != null ? ` / 共 ${r.total} 人` : ''} 当前 ${r.count} 人`);
3932
+ if (r.count === 0) {
3933
+ console.log('(无匹配人员)');
3934
+ return;
3935
+ }
3936
+ const cols = (r.columns || []).filter((c) => c.dataIndex && !['id', 'randomField0', 'randomFieldId'].includes(c.dataIndex));
3937
+ if (cols.length)
3938
+ console.log(`字段(${cols.length}): ${cols.map((c) => c.title ?? c.dataIndex).join(' | ')}`);
3939
+ for (let i = 0; i < r.items.length; i++) {
3940
+ const it = r.items[i];
3941
+ const head = hyperlinkFor('hrm', it, base, it.name || '(无姓名)');
3942
+ console.log(`${i + 1}. ${head} [id=${it.id || ''}]`);
3943
+ // 行 meta 去掉姓名(head 已显示)与 id 类列,避免重复
3944
+ const meta = it.fields.filter((f) => !['lastname', 'id', 'randomField0', 'randomFieldId'].includes(f.dataIndex)).slice(0, 8).map((f) => `${f.title}=${f.value}`).join(' | ');
3945
+ if (meta)
3946
+ console.log(` ${meta}`);
3947
+ }
3948
+ }
3949
+
3787
3950
  function printTodoItems(items, base, { indent = '', max = 10 } = {}) {
3788
3951
  const visible = items.slice(0, max);
3789
3952
  for (let i = 0; i < visible.length; i++) {
@@ -3873,6 +4036,7 @@ async function runReport(args) {
3873
4036
  console.log(`- 未查询成功的来源:${report.failures.map((item) => item.index).join(', ')}`);
3874
4037
  }
3875
4038
 
4039
+
3876
4040
  async function main() {
3877
4041
  const args = parseArgs(process.argv.slice(2));
3878
4042
  if (args.flags.help || args.flags.h) {
@@ -3882,7 +4046,7 @@ async function main() {
3882
4046
  const cmd = args._[0] ?? 'todo';
3883
4047
  if (cmd === 'version') {
3884
4048
  const buildHash = createHash('sha256').update(fs.readFileSync(new URL(import.meta.url))).digest('hex').slice(0, 12);
3885
- console.log(JSON.stringify({ name: 'weaver-e9-assistant-cli', version: '1.1.2', buildHash }, null, 2));
4049
+ console.log(JSON.stringify({ name: 'weaver-e9-assistant-cli', version: '1.1.3', buildHash }, null, 2));
3886
4050
  return;
3887
4051
  }
3888
4052
  if (cmd === 'whoami') {
@@ -3941,6 +4105,10 @@ async function main() {
3941
4105
  await runSubordinate(args);
3942
4106
  return;
3943
4107
  }
4108
+ if (cmd === 'hrm') {
4109
+ await runHrm(args);
4110
+ return;
4111
+ }
3944
4112
  if (cmd !== 'todo') {
3945
4113
  console.error(`未知命令: ${cmd}\n\n${usage()}`);
3946
4114
  process.exit(1);
@@ -4086,33 +4254,37 @@ async function main() {
4086
4254
  debug: queryBool('debug'),
4087
4255
  excludeCreatorId,
4088
4256
  };
4089
- // 默认概览优先展示“我需要做什么”,而不是把转发和关注与待处理混在数千条列表里。
4090
- const shouldGroup = category === 'todo' && !listOpts.subCategory && !tab && !listOpts.totalOnly && !queryBool('ungrouped') && !queryBool('raw');
4257
+ // 默认按 OA 二级分类(流程中心页签)分组展示——待办/已办/我发起通用;只列有数据的分类。
4258
+ const shouldGroup = !listOpts.subCategory && !tab && !listOpts.totalOnly && !queryBool('ungrouped') && !queryBool('raw');
4091
4259
  if (shouldGroup) {
4092
- const grouped = await getSmartTodoGroups(cfg, listOpts);
4260
+ const grouped = await getOaTabGroups(cfg, { ...listOpts, category });
4261
+ const scope = CATEGORY_SCOPE[category] ?? CATEGORY_SCOPE.todo;
4093
4262
  const groupedSnapshot = {
4094
- actionTotal: grouped.actionTotal,
4263
+ category,
4095
4264
  categoryTotals: grouped.categoryTotals,
4096
4265
  categoryTotalSum: grouped.categoryTotalSum,
4097
4266
  page: grouped.page,
4098
4267
  groups: grouped.groups.map((group) => ({
4099
- key: group.key,
4268
+ viewcondition: group.viewcondition,
4100
4269
  title: group.title,
4101
- total: group.result.total,
4270
+ total: group.total,
4102
4271
  items: group.items,
4103
4272
  raw: group.result.raw,
4104
4273
  })),
4105
4274
  };
4106
- const savedGroupedResult = persistSearchResult(groupedSnapshot, 'E9 待办智能概览');
4275
+ const savedGroupedResult = persistSearchResult(groupedSnapshot, `E9 ${scope.pageTitle}分类概览`);
4276
+ const base = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
4107
4277
  if (args.flags.json) {
4108
- const base = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
4109
- console.log(JSON.stringify({ page: grouped.page, actionTotal: grouped.actionTotal, categoryTotals: grouped.categoryTotals, groups: grouped.groups.map((g) => ({ key: g.key, title: g.title, total: g.result.total, items: g.items.map((item) => withLink('todo', item, base)) })) }, null, 2));
4278
+ console.log(JSON.stringify({ category, page: grouped.page, categoryTotals: grouped.categoryTotals, groups: grouped.groups.map((g) => ({ viewcondition: g.viewcondition, title: g.title, total: g.total, items: g.items.map((item) => withLink('todo', item, base)) })) }, null, 2));
4110
4279
  return;
4111
4280
  }
4112
- const base = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
4113
- console.log('E9 待办智能概览(按处理方式分组,避免重复提醒)');
4281
+ if (!grouped.groups.length) {
4282
+ console.log(`E9 ${scope.pageTitle}:各分类均无数据。`);
4283
+ return;
4284
+ }
4285
+ console.log(`E9 ${scope.pageTitle}(按 OA 分类页签分组,只列有数据的分类)`);
4114
4286
  for (const group of grouped.groups) {
4115
- console.log(`\n### ${group.title}(共 ${group.result.total ?? '?'} 条,第 ${group.result.page} 页显示前 10 条)`);
4287
+ console.log(`\n### ${group.title}(共 ${group.total ?? '?'} 条,第 ${group.result.page} 页显示前 10 条)`);
4116
4288
  if (group.items.length)
4117
4289
  printTodoItems(group.items, base);
4118
4290
  else
package/dist/cube.js CHANGED
@@ -1161,6 +1161,25 @@ function scanChangeValue(obj) {
1161
1161
  * opts: { modeId, formId, billid?, values:{id:value}, files:[{fieldId,filePath}], trigger?, pageexpandid?, cf? }
1162
1162
  * ⚠ 必须全程 cookie 鉴权(layoutBase + doSubmit 同 session),否则 doSubmit 返 repeat。
1163
1163
  */
1164
+ /**
1165
+ * ★字段属性硬规则:只读字段(isedit=false)禁止由用户/助手填值——不得绕过卡片字段属性自行填值。
1166
+ * 返回被违规填值的只读字段清单(空数组 = 无违规)。
1167
+ */
1168
+ export function findNonEditableCardFields(fields, values) {
1169
+ return (fields ?? []).filter((f) => {
1170
+ if (f.isedit)
1171
+ return false; // 可编辑字段允许填
1172
+ const id = String(f.id);
1173
+ return values?.[id] !== undefined || values?.[`field${id}`] !== undefined;
1174
+ });
1175
+ }
1176
+ /** 只读字段被填值 → 硬拒绝。 */
1177
+ export function assertEditableCardValues(fields, values) {
1178
+ const bad = findNonEditableCardFields(fields, values);
1179
+ if (bad.length)
1180
+ throw new Error(`拒绝填写只读字段:${bad.map((f) => `${f.label ?? f.name ?? f.id}`).join('、')}。台账卡片只允许填写「可编辑」字段,不得绕过字段属性自行填值。`);
1181
+ }
1182
+
1164
1183
  export async function submitCubeCard(cfg, opts = {}) {
1165
1184
  const ep = endpointFor(cfg);
1166
1185
  const cookie = ep.cookie;
@@ -1170,6 +1189,12 @@ export async function submitCubeCard(cfg, opts = {}) {
1170
1189
  const iscreate = billid ? '0' : '1';
1171
1190
  const cf = opts.cf ?? await getCardFields(cfg, { modeId, formId, billid });
1172
1191
  const { layoutid, cardToken, _key, uuid, fields } = cf;
1192
+ // ★字段属性硬规则:只读字段禁止填值。判据取「新建态」字段定义——带 billid 时 cf 是查看态,
1193
+ // 查看态字段 isedit 恒为 false(全部只读),不能作为可编辑性判据。
1194
+ const defFields = billid ? (await getCardFields(cfg, { modeId, formId })).fields : fields;
1195
+ assertEditableCardValues(defFields, opts.values);
1196
+ if (opts.files?.length)
1197
+ assertEditableCardValues(defFields, Object.fromEntries(opts.files.map((f) => [String(f.fieldId), '1'])));
1173
1198
  const t = String(Date.now());
1174
1199
  const cq = (o = {}) => ({ type: '1', modeId, formId, guid: 'card', t, _key, uuid, billid, ...o });
1175
1200
  // ① 卡片打开链路(cookie,同 session)
@@ -1242,6 +1267,12 @@ export async function previewCubeCard(cfg, opts = {}) {
1242
1267
  const values = { ...(opts.values ?? {}) };
1243
1268
  // 附件:只记录清单(dry-run 不上传),预览提示"--yes 时上传"
1244
1269
  const files = (opts.files ?? []).map((f) => ({ fieldId: String(f.fieldId), filePath: f.filePath }));
1270
+ // ★字段属性硬规则:只读字段禁止填值——dry-run 也要拦,避免「预览通过、提交才报错」。
1271
+ // 判据取「新建态」字段定义(billid 时 fields 是查看态,全为只读,不能作判据)。
1272
+ const defFields = billid ? (await getCardFields(cfg, { modeId, formId })).fields : fields;
1273
+ assertEditableCardValues(defFields, opts.values);
1274
+ if (files.length)
1275
+ assertEditableCardValues(defFields, Object.fromEntries(files.map((f) => [f.fieldId, '1'])));
1245
1276
  // 联动解析(真实接口、无写副作用;失败不阻断——resolveCubeLinkage 内部 catch 返回原 values)
1246
1277
  let linked = [];
1247
1278
  if (opts.trigger === undefined) {
package/dist/hrm.js ADDED
@@ -0,0 +1,448 @@
1
+ /**
2
+ * 组织人事(HRM)—— 人事搜索:按搜索条件查员工,返回人事档案 + 详情卡片超链接。
3
+ *
4
+ * 链路(用户抓包实证,HAR:capture/人事抓包.har):
5
+ * 1) GET /api/hrm/search/getHrmSearchMoudleList → tabs(全部人员 default_3 / 同部门 default_1 / 我的下属 default_2)
6
+ * 2) GET /api/hrm/search/getHrmSearchCondition?mouldid=X → 搜索条件 label→domkey(default_3=23 个 / default_1=21 / default_2=22)
7
+ * 3) POST /api/hrm/search/getHrmSearchResult body{tabkey,showAllLevel,virtualtype,...} → sessionkey(恒顶层)
8
+ * 4) POST /api/ec/dev/table/datas body{dataKey=sessionkey,current,sortParams} → columns(32 列) + datas
9
+ * 5) POST /api/ec/dev/table/counts body{dataKey} → {count:N, status:true}
10
+ * 6) GET /api/hrm/base/getHrmSearchTree 组织树(type=0 顶层分部 / type=1 分部→子分部+部门 / type=2 部门→子部门)
11
+ *
12
+ * 详情卡片链接(真机验证通过):
13
+ * {base}/spa/hrm/index_mobx.html#/main/hrm/card/cardInfo/<hrmresource.id>
14
+ * resourceId 取自 randomField0/randomFieldId(与 id 列同值,但 id 列标题是「是否在线」,不作为主键)。
15
+ *
16
+ * 越权防护(与全局一致,见 cube/subordinate):
17
+ * 按人条件(姓名/直接上级/助理)由调用方 assertNamesInScope 校验——目标必须 ∈ {本人 + 全部层级下属};
18
+ * E9_SCOPE_ENFORCE=1 时才硬拦截,默认停用(不拦截,代码路径保留)。
19
+ * 部门/分部是组织结构过滤,不涉及具体个人,不做按人校验。
20
+ */
21
+ import { createApiFromProfile } from './client.js';
22
+ import { extractDataKey, extractRows, extractColumns, extractTotal, pickByPaths, stripHtml } from './parse.js';
23
+
24
+ // ─── Tab 定义 ────────────────────────────────────────────
25
+
26
+ /** 人事搜索 Tab:中文名/别名 → tabkey(抓包实证 getHrmSearchMoudleList)。 */
27
+ export const HRM_TABS = {
28
+ '全部人员': 'default_3',
29
+ '全部': 'default_3',
30
+ 'all': 'default_3',
31
+ '同部门': 'default_1',
32
+ 'dept': 'default_1',
33
+ '我的下属': 'default_2',
34
+ '下属': 'default_2',
35
+ 'subordinate': 'default_2',
36
+ };
37
+
38
+ /** Tab 名/别名/tabkey → tabkey;未知原样透传(交服务端判定),空则默认全部人员。 */
39
+ export function resolveHrmTab(tab) {
40
+ const t = String(tab ?? '').trim();
41
+ if (HRM_TABS[t])
42
+ return HRM_TABS[t];
43
+ return t || 'default_3';
44
+ }
45
+
46
+ // ─── 按人条件判定(越权校验用) ──────────────────────────
47
+
48
+ /** 明确排除的非人字段:联系方式/日期时间/计数单位(与 cube.js 一致)。 */
49
+ const HRM_NON_PERSON_LABEL = /电话|手机|邮箱|号码|传真|日期|时间|区间|人数|人次|人天/;
50
+ /** 明确的人员白名单(人事域:姓名/直接上级/助理 + 通用称谓)。 */
51
+ const HRM_PERSON_LABEL = /^(姓名|直接上级|直属上级|助理|上级|下属|人员|联系人|领导)$/u;
52
+ /** 人员后缀判据:X人/X人员/X员/X者(报销人/值班人员/采购员…)。 */
53
+ const HRM_PERSON_SUFFIX = /[人者员]$/u;
54
+
55
+ /** 该条件 label 是否「按人过滤」(fail-closed:宁可多拦)。 */
56
+ export function isHrmPersonCondition(label) {
57
+ if (typeof label !== 'string' || !label.trim())
58
+ return false;
59
+ const l = label.trim();
60
+ if (HRM_NON_PERSON_LABEL.test(l))
61
+ return false;
62
+ if (HRM_PERSON_SUFFIX.test(l))
63
+ return true;
64
+ return HRM_PERSON_LABEL.test(l);
65
+ }
66
+
67
+ // ─── 链路 1/2:Tab 与条件定义 ────────────────────────────
68
+
69
+ /** 人事搜索 Tab 列表。GET /api/hrm/search/getHrmSearchMoudleList。 */
70
+ export async function getHrmTabs(cfg, opts = {}) {
71
+ const api = await createApiFromProfile(cfg);
72
+ const r = await api.get('/api/hrm/search/getHrmSearchMoudleList', { virtualtype: opts.virtualtype ?? '' });
73
+ const j = r?.json ?? {};
74
+ const tabs = (j.tabs || []).map((t) => ({
75
+ typeid: t.typeid,
76
+ title: t.title,
77
+ editable: !!t.editable,
78
+ }));
79
+ return { tabs, raw: j };
80
+ }
81
+
82
+ /** 指定 Tab 的搜索条件定义。GET /api/hrm/search/getHrmSearchCondition?mouldid=<tabkey>。 */
83
+ export async function getHrmConditions(cfg, { mouldid = 'default_3' } = {}) {
84
+ const api = await createApiFromProfile(cfg);
85
+ const r = await api.get('/api/hrm/search/getHrmSearchCondition', { mouldid: String(mouldid) });
86
+ const j = r?.json ?? {};
87
+ const groups = (j.condition || []).map((g) => ({
88
+ title: g.title,
89
+ items: (g.items || []).map((it) => ({
90
+ label: it.label,
91
+ domkey: Array.isArray(it.domkey) ? it.domkey[0] : (typeof it.domkey === 'string' ? it.domkey : null),
92
+ conditionType: it.conditionType,
93
+ })).filter((it) => it.label),
94
+ })).filter((g) => g.items.length);
95
+ // 扁平 items(供 label→domkey 映射 / 按人分类)
96
+ const items = groups.flatMap((g) => g.items);
97
+ return { groups, items, raw: j };
98
+ }
99
+
100
+ // ─── 条件解析(--condition "岗位=经理;性别=男") ──────────
101
+
102
+ /** 条件定义 domkey → 真实搜索参数:部门/分部是 BROWSER 占位 domkey(department/subcompany),
103
+ * 搜索接口实际认 departmentid/subcompanyid1(2026-09-20 e9pc 抓包实证 subcompanyid1=80、departmentid=850)。 */
104
+ export const HRM_ORG_DOMKEY_REMAP = { department: 'departmentid', subcompany: 'subcompanyid1' };
105
+
106
+ /** BROWSER 人字段 domkey:值须为 hrmresource id(=userid),不认姓名文本(2026-09-20 e9pc 实测)。
107
+ * 姓名(INPUT)认姓名文本,不在此列。 */
108
+ export const HRM_BROWSER_PERSON_DOMKEYS = new Set(['manager', 'assistant']);
109
+
110
+ /**
111
+ * ★E9 实测校准(2026-09-20 e9pc):直接把 manager/助理 的姓名文本传给搜索接口会 0 结果,
112
+ * 必须先经 assertNamesInScope 解析为 uid(=hrmresource id)再回填。本函数按位把解析结果
113
+ * 替换到 BROWSER 人字段值上(姓名 INPUT 字段如 resourcename 保持姓名文本不动)。
114
+ *
115
+ * @param conditionValues resolveHrmCondition 产出的 {domkey:value}(domkey 已归一为搜索接口认的键)
116
+ * @param personConditions resolveHrmCondition 产出的按人条件 [{label, domkey, value}](与 resolvedIds 按位对应)
117
+ * @param resolvedIds assertNamesInScope 的返回值:单个 uid 或 uid 数组
118
+ * @param browserDomkeys 需替换为 id 的 BROWSER 人字段 domkey 集合(默认 manager/assistant)
119
+ * @returns 新 conditionValues(不修改入参)
120
+ */
121
+ export function applyResolvedPersonIds(conditionValues, personConditions, resolvedIds, browserDomkeys = HRM_BROWSER_PERSON_DOMKEYS) {
122
+ const values = { ...(conditionValues ?? {}) };
123
+ const idArr = Array.isArray(resolvedIds) ? resolvedIds : [resolvedIds];
124
+ const idOf = new Map();
125
+ (personConditions ?? []).forEach((p, i) => {
126
+ if (p && idArr[i] != null && String(idArr[i]).trim())
127
+ idOf.set(p.value, String(idArr[i]));
128
+ });
129
+ for (const [k, v] of Object.entries(values))
130
+ if (browserDomkeys.has(k) && idOf.has(v))
131
+ values[k] = idOf.get(v);
132
+ return values;
133
+ }
134
+
135
+ /** 解析条件串:分号分段、每段取首个 = 为键值。 */
136
+ export function parseHrmConditionPairs(spec) {
137
+ const pairs = [];
138
+ for (const seg of String(spec ?? '').split(';')) {
139
+ const kv = seg.trim();
140
+ if (!kv)
141
+ continue;
142
+ const i = kv.indexOf('=');
143
+ if (i > 0)
144
+ pairs.push([kv.slice(0, i).trim(), kv.slice(i + 1).trim()]);
145
+ }
146
+ return pairs;
147
+ }
148
+
149
+ /**
150
+ * ★越权防护 人员条件分类(纯函数,便于单测)。
151
+ * condItems:getHrmConditions 返回的扁平条件项 [{label, domkey, conditionType}]。
152
+ * rawPairs:[[键,值],...](键可能是 domkey 或中文 label)。
153
+ * 返回 [{label, domkey, value}]——命中按人白名单的键值对。
154
+ */
155
+ export function classifyHrmPersonConditions(condItems, rawPairs) {
156
+ if (!Array.isArray(rawPairs) || rawPairs.length === 0)
157
+ return [];
158
+ const domToLabel = {};
159
+ for (const it of condItems || [])
160
+ if (it.label && it.domkey && !(it.domkey in domToLabel))
161
+ domToLabel[it.domkey] = it.label;
162
+ const labels = new Set((condItems || []).map((it) => it.label).filter(Boolean));
163
+ const personConditions = [];
164
+ for (const [k, v] of rawPairs) {
165
+ const key = String(k ?? '').trim();
166
+ const val = String(v ?? '').trim();
167
+ if (!key || !val)
168
+ continue;
169
+ // con_/dom_/field/col 前缀键剥前缀反查(dom_manager → manager → 直接上级),堵前缀直传绕过按人校验。
170
+ const prefix = /^(con|dom|field|col)[-_]?/i.exec(key);
171
+ const plain = prefix ? key.slice(prefix[0].length) : key;
172
+ const label = domToLabel[plain] ?? domToLabel[key] ?? (labels.has(key) ? key : (labels.has(plain) ? plain : null));
173
+ // 后缀启发式直接判按键名(即使条件定义缺失也能拦「报销人=王五」);
174
+ // domkey 依赖 domToLabel 反查;两者皆无则落到 unverifiablePerson 由调用方 fail-closed 拒绝。
175
+ if (isHrmPersonCondition(label ?? key))
176
+ personConditions.push({ label: label ?? key, domkey: key, value: val });
177
+ }
178
+ return personConditions;
179
+ }
180
+
181
+ /**
182
+ * ★新 条件字符串 → conditionValues 映射。
183
+ *
184
+ * 输入 `--condition` 原始串,自动识别(E9 hrm 搜索接口原生认 plain domkey,如 manager=李四):
185
+ * - 键以 con_/dom_/field/col 开头 → 剥前缀后是已知 domkey 则用 plain domkey(dom_manager→manager),
186
+ * 否则原样透传(向后兼容,交 unverifiablePerson fail-closed 兜底);
187
+ * - plain domkey(在条件定义中)→ 原样直传(E9 原生形式);
188
+ * - 其它 → 中文 label,用 getHrmConditions 的 label→domkey 自动转换;
189
+ * 未匹配时返回 unresolved(含候选 label),由调用方报错提示,不静默放空。
190
+ * ★越权防护(1.1.x):只要传了条件就取一次条件定义——含 domkey 直传也要反查 label 分类。
191
+ * 返回 { conditionValues, unresolved:[{label,candidates}], unverifiablePerson, personConditions }。
192
+ */
193
+ export async function resolveHrmCondition(cfg, { mouldid = 'default_3', condition = '' } = {}) {
194
+ const rawStr = String(condition ?? '');
195
+ const pairs = parseHrmConditionPairs(rawStr);
196
+ const conditionValues = {};
197
+ if (pairs.length === 0)
198
+ return { conditionValues, unresolved: [], unverifiablePerson: false, personConditions: [] };
199
+ let condItems = [];
200
+ let condFetched = false;
201
+ try {
202
+ const cond = await getHrmConditions(cfg, { mouldid });
203
+ condItems = cond.items;
204
+ condFetched = true;
205
+ }
206
+ catch {
207
+ // fail-closed:定义读不到时 domkey 无法反查 → 下方 unverifiablePerson 拒绝;中文 label 按名称判定不受影响
208
+ if (pairs.some(([k]) => /^(con|dom|field|col)[-_]?/i.test(String(k))))
209
+ process.stderr.write('⚠ 无法读取人事搜索条件定义:domkey 类条件将被拒绝(无法确认是否按人过滤),中文字段名条件按名称判定。\n');
210
+ }
211
+ const labelToDom = {};
212
+ for (const it of condItems)
213
+ if (it.label && it.domkey && !(it.label in labelToDom))
214
+ labelToDom[it.label] = it.domkey;
215
+ const knownDom = new Set(condItems.map((it) => it.domkey).filter(Boolean));
216
+ const unresolved = [];
217
+ for (const [k, v] of pairs) {
218
+ const key = String(k).trim();
219
+ if (!key)
220
+ continue;
221
+ const prefix = /^(con|dom|field|col)[-_]?/i.exec(key);
222
+ if (prefix) {
223
+ // 剥前缀后用 E9 认的 plain domkey(如 dom_manager→manager);未知前缀键原样透传
224
+ const plain = key.slice(prefix[0].length);
225
+ conditionValues[knownDom.has(plain) ? plain : key] = v;
226
+ continue;
227
+ }
228
+ if (labelToDom[key] !== undefined) {
229
+ conditionValues[labelToDom[key]] = v;
230
+ continue;
231
+ }
232
+ if (knownDom.has(key)) {
233
+ conditionValues[key] = v; // plain domkey 直传(E9 hrm 搜索接口原生认的形式)
234
+ continue;
235
+ }
236
+ if (!condFetched) {
237
+ // 条件定义接口失败(网络抖动/无权限):按原样透传,不阻断(用户可能直接给了 domkey)——但已打越权警告
238
+ conditionValues[key] = v;
239
+ continue;
240
+ }
241
+ const candidates = Object.keys(labelToDom).filter((l) => l.includes(key) || key.includes(l)).slice(0, 8);
242
+ unresolved.push({ label: key, candidates });
243
+ }
244
+ // ★越权防护(fail-closed):无法确认是否按人过滤的条件 → 调用方硬拒绝。
245
+ // 前缀键剥前缀后仍不在条件定义中 / 定义读取失败 → 无法确认;中文 label 与已知 plain domkey 可经分类判定。
246
+ let unverifiablePerson = false;
247
+ for (const [k, v] of pairs) {
248
+ const key = String(k).trim();
249
+ if (!key || !String(v ?? '').trim())
250
+ continue;
251
+ const prefix = /^(con|dom|field|col)[-_]?/i.exec(key);
252
+ if (prefix) {
253
+ const plain = key.slice(prefix[0].length);
254
+ if (!knownDom.has(plain))
255
+ unverifiablePerson = true;
256
+ }
257
+ }
258
+ const personConditions = classifyHrmPersonConditions(condItems, pairs);
259
+ // ★E9 实测校准(2026-09-20,e9pc 抓包):条件定义里 部门→department / 分部→subcompany 只是 UI 占位 domkey,
260
+ // 真实搜索接口认 departmentid / subcompanyid1(HAR 实证 subcompanyid1=80、departmentid=850)。
261
+ // 重映射这两个条件键为真实搜索参数;名称值由调用方 resolveHrmOrgValue 解析为 id。
262
+ for (const [src, dst] of Object.entries(HRM_ORG_DOMKEY_REMAP))
263
+ if (src in conditionValues && !(dst in conditionValues)) {
264
+ conditionValues[dst] = conditionValues[src];
265
+ delete conditionValues[src];
266
+ }
267
+ return { conditionValues, unresolved, unverifiablePerson, personConditions };
268
+ }
269
+
270
+ // ─── 链路 3/4/5:搜索 → sessionkey → 表数据 → 总数 ───────
271
+
272
+ /** 发起人事搜索。POST /api/hrm/search/getHrmSearchResult,body 表单编码,返回 sessionkey(恒顶层)。 */
273
+ export async function hrmSearch(cfg, { tabkey = 'default_3', values = {}, showAllLevel = false } = {}) {
274
+ const api = await createApiFromProfile(cfg);
275
+ const body = {
276
+ tabkey: String(tabkey),
277
+ showAllLevel: showAllLevel ? '1' : '0',
278
+ virtualtype: '',
279
+ ...(values ?? {}),
280
+ };
281
+ let r = await api.post('/api/hrm/search/getHrmSearchResult', body);
282
+ let sessionkey = extractDataKey(r?.json);
283
+ if (!sessionkey && cfg.cookie) {
284
+ // token no-op 逃生:200 但无 sessionkey 且配了 cookie → 强制 cookie 重试一次
285
+ r = await api.post('/api/hrm/search/getHrmSearchResult', body, { forceCookie: true });
286
+ sessionkey = extractDataKey(r?.json);
287
+ }
288
+ if (!sessionkey) {
289
+ throw new Error('未能从 getHrmSearchResult 响应中提取 sessionkey。\n' + `响应(前300字): ${truncate(r?.text)}`);
290
+ }
291
+ return { sessionkey, raw: r?.json ?? null };
292
+ }
293
+
294
+ /** 拉一页人事搜索结果。POST /api/ec/dev/table/datas。 */
295
+ export async function fetchHrmSearchTable(cfg, sessionkey, page) {
296
+ const api = await createApiFromProfile(cfg);
297
+ const r = await api.post('/api/ec/dev/table/datas', {
298
+ dataKey: sessionkey,
299
+ current: String(page),
300
+ sortParams: '[]',
301
+ });
302
+ const j = r?.json ?? {};
303
+ return { rows: extractRows(j), columns: extractColumns(j), raw: j };
304
+ }
305
+
306
+ /** 人事搜索总数。POST /api/ec/dev/table/counts。 */
307
+ export async function fetchHrmSearchCount(cfg, sessionkey) {
308
+ const api = await createApiFromProfile(cfg);
309
+ const r = await api.post('/api/ec/dev/table/counts', { dataKey: sessionkey });
310
+ return { total: extractTotal(r?.json), raw: r?.json ?? null };
311
+ }
312
+
313
+ // ─── 行归一化 + 详情卡片链接 ─────────────────────────────
314
+
315
+ /**
316
+ * 找人事行 resourceId:randomField0/randomFieldId(抓包三值与 id 列同值;
317
+ * 但 id 列标题是「是否在线」,不作为主键,避免语义混用)。
318
+ */
319
+ function pickHrmRowId(row) {
320
+ for (const key of ['randomField0', 'randomFieldId']) {
321
+ const raw = pickByPaths(row, [key]);
322
+ if (raw != null && String(raw).trim())
323
+ return stripHtml(raw);
324
+ }
325
+ return '';
326
+ }
327
+
328
+ /** 归一化一行人事档案:id(resourceId) + name + 全部字段(<field>span 优先去 HTML)。 */
329
+ export function normalizeHrmRow(row, columns) {
330
+ if (!row || typeof row !== 'object')
331
+ return {};
332
+ const get = (k) => pickByPaths(row, [k]);
333
+ const id = pickHrmRowId(row);
334
+ const name = stripHtml(get('lastnamespan') ?? get('lastname'));
335
+ const fields = (columns || []).map((c) => ({
336
+ dataIndex: c.dataIndex,
337
+ title: c.title ?? c.dataIndex,
338
+ value: stripHtml(get(`${c.dataIndex}span`) ?? get(c.dataIndex)),
339
+ })).filter((f) => f.value !== '');
340
+ return { id, name, fields, raw: row };
341
+ }
342
+
343
+ /** 详情卡片 URL(真机验证通过):{base}/spa/hrm/index_mobx.html#/main/hrm/card/cardInfo/<resourceId> */
344
+ export function buildHrmCardUrl(baseUrl, resourceId) {
345
+ if (resourceId == null || String(resourceId).trim() === '')
346
+ return '';
347
+ const b = String(baseUrl ?? '').replace(/\/+$/, '');
348
+ return `${b}/spa/hrm/index_mobx.html#/main/hrm/card/cardInfo/${String(resourceId).trim()}`;
349
+ }
350
+
351
+ // ─── 组织树(--dept/--subcompany 按名定位) ───────────────
352
+
353
+ /** 组织树节点拉取。GET /api/hrm/base/getHrmSearchTree。
354
+ * param 说明(抓包实证):type=0&id=0 → 顶层分部(type 1);type=1&id=<分部> → 子分部+部门;type=2&id=<部门> → 子部门。 */
355
+ export async function getHrmSearchTree(cfg, opts = {}) {
356
+ const api = await createApiFromProfile(cfg);
357
+ const params = {
358
+ keyword: opts.keyword ?? '',
359
+ virtualCompanyid: opts.virtualCompanyid ?? '1',
360
+ isLoadSubDepartment: opts.isLoadSubDepartment ?? '1',
361
+ };
362
+ if (opts.type != null)
363
+ params.type = String(opts.type);
364
+ if (opts.id != null)
365
+ params.id = String(opts.id);
366
+ if (opts.isVirtual != null)
367
+ params.isVirtual = String(opts.isVirtual);
368
+ const r = await api.get('/api/hrm/base/getHrmSearchTree', params);
369
+ const j = r?.json ?? {};
370
+ const companys = (j.companys || []).map((c) => ({
371
+ id: String(c.companyid ?? ''),
372
+ name: c.name,
373
+ isVirtual: String(c.isVirtual ?? ''),
374
+ isParent: !!c.isParent,
375
+ }));
376
+ const datas = (j.datas || []).map((d) => ({
377
+ id: String(d.id ?? ''),
378
+ pid: String(d.pid ?? ''),
379
+ name: d.name,
380
+ isParent: !!d.isParent,
381
+ type: String(d.type ?? ''),
382
+ isVirtual: String(d.isVirtual ?? ''),
383
+ }));
384
+ return { companys, datas, raw: j };
385
+ }
386
+
387
+ /**
388
+ * 在组织树中按名定位节点(BFS,逐层拉取)。
389
+ * matchType:限定节点类型('1'=分部 / '2'=部门;null=任意)。
390
+ * 返回匹配节点 {id, name, type, pid} 或 null;maxDepth 防失控。
391
+ */
392
+ export async function findHrmOrgNode(cfg, keyword, { matchType = null, maxDepth = 6 } = {}) {
393
+ const kw = String(keyword ?? '').trim();
394
+ if (!kw)
395
+ return null;
396
+ const top = await getHrmSearchTree(cfg, { type: '0', id: '0', isVirtual: '0' });
397
+ const queue = top.datas.map((d) => ({ ...d, depth: 0 }));
398
+ while (queue.length) {
399
+ const node = queue.shift();
400
+ if (node.name === kw && (!matchType || node.type === matchType))
401
+ return node;
402
+ if (!node.isParent || node.depth >= maxDepth)
403
+ continue;
404
+ try {
405
+ const children = await getHrmSearchTree(cfg, { type: node.type, id: node.id, isVirtual: node.isVirtual });
406
+ for (const c of children.datas)
407
+ queue.push({ ...c, depth: node.depth + 1 });
408
+ }
409
+ catch { /* 子树拉取失败跳过该分支(同名节点仍可能在后继队列命中) */ }
410
+ }
411
+ return null;
412
+ }
413
+
414
+ // ─── 编排:完整搜索 ──────────────────────────────────────
415
+
416
+ /**
417
+ * 人事搜索编排:tab + 条件 → sessionkey → 分页数据 + 总数。
418
+ * opts: { tabkey, values:{domkey:value}, showAllLevel, page }。
419
+ */
420
+ export async function hrmList(cfg, opts = {}) {
421
+ const tabkey = opts.tabkey ?? 'default_3';
422
+ const page = Number(opts.page ?? '1') || 1;
423
+ const values = { ...(opts.values ?? {}) };
424
+ const { sessionkey, raw: searchRaw } = await hrmSearch(cfg, { tabkey, values, showAllLevel: !!opts.showAllLevel });
425
+ const data = await fetchHrmSearchTable(cfg, sessionkey, page);
426
+ const count = await fetchHrmSearchCount(cfg, sessionkey);
427
+ const total = count.total ?? null;
428
+ const items = data.rows.map((row) => normalizeHrmRow(row, data.columns));
429
+ return {
430
+ tabkey,
431
+ page,
432
+ pageSize: data.rows.length || null,
433
+ total,
434
+ count: items.length,
435
+ sessionkey,
436
+ columns: data.columns,
437
+ items,
438
+ raw: { search: searchRaw, datas: data.raw, counts: count.raw },
439
+ };
440
+ }
441
+
442
+ // ─── 工具 ────────────────────────────────────────────────
443
+
444
+ function truncate(s, n = 300) {
445
+ if (s == null)
446
+ return '(空)';
447
+ return String(s).length > n ? String(s).slice(0, n) + '…(已截断)' : String(s);
448
+ }
package/dist/parse.js CHANGED
@@ -113,6 +113,20 @@ export function stripHtml(s) {
113
113
  .replace(/&#39;/g, "'");
114
114
  return str.trim();
115
115
  }
116
+ /**
117
+ * 从 requestnamespan 提取可见标题。
118
+ * OA 把标题放进第一个 `<a>`(模板前缀如「[退回]」也在 `<a>` 内),把「我的关注」等状态标签
119
+ * 以独立 `<div class='wf_attention_div_btn'>` 追加在 `<a>` 之后——整体去标签会把标签文字并进标题
120
+ * (实测「我的关注」页签的标题被渲染成「采购审批4-... 我的关注」)。
121
+ * 故优先只取第一个 `<a>` 的文本;无 `<a>` 时回退整体去标签。
122
+ */
123
+ function extractTitleFromNamespan(html) {
124
+ const s = String(html ?? '');
125
+ if (!s)
126
+ return '';
127
+ const m = s.match(/<a\b[^>]*>([\s\S]*?)<\/a>/i);
128
+ return stripHtml(m ? m[1] : s);
129
+ }
116
130
  const LEVEL_LABEL = {
117
131
  '0': '一般',
118
132
  '1': '重要',
@@ -131,7 +145,7 @@ export function normalizeRow(row, columns) {
131
145
  const spanOr = (dataIndex, extraKeys = []) => stripHtml(get([`${dataIndex}span`, dataIndex, ...extraKeys]));
132
146
  // requestnamespan 是 OA 已按管理员模板渲染的可见标题;原始 requestname 仅作回退。
133
147
  const rawTitle = stringify(get(['requestname', 'requestName']));
134
- const title = stripHtml(get(['requestnamespan'])) || rawTitle;
148
+ const title = extractTitleFromNamespan(get(['requestnamespan'])) || rawTitle;
135
149
  const levelRaw = stringify(get(['requestlevel', 'level'])) ?? '';
136
150
  const level = levelRaw ? (LEVEL_LABEL[levelRaw] ?? levelRaw) : undefined;
137
151
  const creatorIdRaw = get(['createrid', 'creatorid', 'creatorId', 'creater']);
package/dist/wfstart.js CHANGED
@@ -431,10 +431,42 @@ async function resolveFieldLinkages(cfg, workflowId, f, merged) {
431
431
  * 明细浏览框字段解析并提交 id 与 name;确认后由提交计划冻结,避免二次解析漂移。
432
432
  * 返回 E9Response(成功通常含 requestid)。
433
433
  */
434
+ /**
435
+ * ★字段属性硬规则:只允许填写「可编辑」(viewattr=1) 的字段。
436
+ * 只读(2)/隐藏(3) 字段**禁止**由用户/助手填值——不得绕过表单字段属性自行填值。
437
+ * 明细表字段同样按 viewattr 判定。无属性信息(viewattr 缺失)不拦,交给后端判定,避免误伤。
438
+ */
439
+ export function findNonEditableFields(form, values, details = []) {
440
+ const byId = new Map();
441
+ for (const table of form?.tables ?? []) {
442
+ for (const fld of table.fields ?? [])
443
+ byId.set(String(fld.id), fld);
444
+ }
445
+ const bad = [];
446
+ const checkId = (rawId) => {
447
+ const id = String(rawId ?? '').replace(/^field/, '');
448
+ const fld = byId.get(id);
449
+ if (!fld || fld.viewattr == null || fld.viewattr === 1)
450
+ return;
451
+ bad.push({ id, label: fld.label, viewattr: fld.viewattr });
452
+ };
453
+ for (const k of Object.keys(values ?? {}))
454
+ checkId(k);
455
+ for (const d of details ?? [])
456
+ for (const row of d?.rows ?? [])
457
+ for (const k of Object.keys(row ?? {}))
458
+ checkId(k);
459
+ return bad;
460
+ }
461
+
434
462
  export async function prepareCreateSubmission(cfg, workflowId, values, requestname, details = []) {
435
463
  const f = await describeFormFields(cfg, workflowId);
436
464
  if (f.verifyRight === false)
437
465
  throw new Error('无权限(verifyRight:false),跑 e9 auth token-login');
466
+ // ★字段属性硬规则:只读/隐藏字段禁止填值(不得绕过字段属性自行填值)
467
+ const badFields = findNonEditableFields(f, values, details);
468
+ if (badFields.length)
469
+ throw new Error(`拒绝填写只读/隐藏字段:${badFields.map((b) => `${b.label ?? b.id}(${VIEWATTR_LABEL[b.viewattr] ?? 'va=' + b.viewattr})`).join('、')}。表单只允许填写「可编辑」字段,不得绕过字段属性自行填值。`);
438
470
  const name = requestname || `${f.workflowName ?? '流程'}-系统管理员-${todayStr()}`;
439
471
  const merged = { ...values };
440
472
  // 用户未填的人员/部门/日期字段,用当前用户信息默认补上
@@ -449,8 +481,8 @@ export async function prepareCreateSubmission(cfg, workflowId, values, requestna
449
481
  const id = String(fld.id);
450
482
  if (merged[id] !== undefined)
451
483
  continue; // 用户已填
452
- if (fld.viewattr === 2)
453
- continue; // 只读字段由系统/联动控制,不补默认值(避免覆盖联动计算,如外出时间 WCSJ)
484
+ if (fld.viewattr === 2 || fld.viewattr === 3)
485
+ continue; // 只读/隐藏字段由系统/联动控制,不补默认值(不得绕过字段属性自行填值)
454
486
  const dv = defaultForField(fld.label, fld.htmltype, user);
455
487
  if (dv)
456
488
  merged[id] = dv;
package/dist/wfuser.js CHANGED
@@ -4,18 +4,17 @@
4
4
  * 接口(用户抓包确认):GET /api/hrm/login/getAccountList?__random__=<ts>
5
5
  * aiAgentToken 鉴权。返回 data.{userid, username, deptid, deptname, subcompanyname, jobs}。
6
6
  */
7
- import { AuthTokenSession } from './auth-token.js';
8
7
  import { createApiFromProfile } from './client.js';
9
8
  import { todayStr } from './util.js';
10
- /** 取当前登录用户信息。 */
9
+ /**
10
+ * 取当前登录用户信息。GET /api/hrm/login/getAccountList?__random__=<ts>
11
+ * 走 E9Api(ensureValid + 双头 + 重试 + 失效逃生,见 client.js)——2026-09-20 e9pc 实测:
12
+ * 裸 fetch(aiAgentToken) 会 404(缺头/token 状态不同步),E9Api 通道正常返回 accountlist。
13
+ */
11
14
  export async function getCurrentUser(cfg) {
12
- const sess = AuthTokenSession.fromProfile(cfg);
13
- const base = cfg.baseUrl.replace(/\/+$/, '');
14
- const r = await fetch(`${base}/api/hrm/login/getAccountList?__random__=${Date.now()}`, {
15
- headers: { aiAgentToken: sess.token, 'X-Requested-With': 'XMLHttpRequest' },
16
- signal: AbortSignal.timeout(12000),
17
- });
18
- const j = await r.json();
15
+ const api = await createApiFromProfile(cfg);
16
+ const r = await api.get('/api/hrm/login/getAccountList', { __random__: Date.now() });
17
+ const j = r?.json ?? {};
19
18
  const d = j?.data ?? {};
20
19
  // accountlist 里 iscurrent=1 的是当前账号;data 顶层也有当前用户字段
21
20
  const list = Array.isArray(d.accountlist) ? d.accountlist : [];
package/dist/workflow.js CHANGED
@@ -48,9 +48,9 @@ export const DEFAULT_FILTERS = {
48
48
  /** 各分类的 viewScope/complete/menuIds/默认viewcondition(来自 HAR 抓包实跑确认)。
49
49
  * menuIds 可由 cfg 覆盖:cfg.doneMenuIds/mineMenuIds;viewcondition 可由 opts 覆盖(子 tab)。 */
50
50
  export const CATEGORY_SCOPE = {
51
- todo: { viewScope: 'doing', complete: '0', menuIdsKey: 'menuIds', defaultViewcondition: '5' },
52
- done: { viewScope: 'done', complete: '2', menuIdsKey: 'doneMenuIds', defaultMenuIds: '1,90', defaultViewcondition: '10' },
53
- mine: { viewScope: 'mine', complete: '2', menuIdsKey: 'mineMenuIds', defaultMenuIds: '1,14', defaultViewcondition: '16' },
51
+ todo: { viewScope: 'doing', complete: '0', menuIdsKey: 'menuIds', defaultViewcondition: '5', fullTab: '0', pageTitle: '待办事宜', countEndpoint: 'doingCountInfo' },
52
+ done: { viewScope: 'done', complete: '2', menuIdsKey: 'doneMenuIds', defaultMenuIds: '1,90', defaultViewcondition: '10', fullTab: '10', pageTitle: '已办事宜', countEndpoint: 'doneCountInfo' },
53
+ mine: { viewScope: 'mine', complete: '2', menuIdsKey: 'mineMenuIds', defaultMenuIds: '1,14', defaultViewcondition: '16', fullTab: '16', pageTitle: '我的请求', countEndpoint: 'mineCountInfo' },
54
54
  };
55
55
  /** 排序字段映射表 */
56
56
  const SORT_FIELD_MAP = {
@@ -113,12 +113,16 @@ export const TODO_TABS = {
113
113
  "被督办": "4", "未读": "1", "反馈": "2", "超时": "3", "我的关注": "37",
114
114
  };
115
115
 
116
+ /** 已办页签(实测 .18 doneBaseInfo.groupinfo;旧表把 17/18 误记成「审批中/已结束」)。 */
116
117
  export const DONE_TABS = {
117
- "全部": "10", "关注": "38",
118
+ "全部": "10", "未归档": "11", "已归档": "12", "待回复": "13",
119
+ "未读": "14", "反馈": "15", "我的关注": "38",
118
120
  };
119
121
 
122
+ /** 我发起页签(实测 .18 mineBaseInfo.groupinfo)。 */
120
123
  export const MINE_TABS = {
121
- "全部": "16", "审批中": "17", "已结束": "18", "关注": "39",
124
+ "全部": "16", "未归档": "17", "已归档": "18",
125
+ "未读": "19", "反馈": "20", "我的关注": "39",
122
126
  };
123
127
 
124
128
  export function resolveSubCategory(spec, category) {
@@ -317,6 +321,97 @@ export function mergeSmartTodoGroups(results, page = 1) {
317
321
  };
318
322
  }
319
323
 
324
+ // ─── 按 OA 二级分类页签分组(待办/已办/我发起通用)────────────
325
+
326
+ /** 内置页签表(CountInfo 不返回 topTab 时回退;实测仅待办返回 topTab,已办/我发起均不返回)。 */
327
+ function builtinTabs(category) {
328
+ const map = category === 'done' ? DONE_TABS : category === 'mine' ? MINE_TABS : TODO_TABS;
329
+ return Object.entries(map).map(([title, viewcondition]) => ({ title, viewcondition: String(viewcondition), groupid: '' }));
330
+ }
331
+
332
+ /**
333
+ * 取 OA 二级分类页签清单(三级数据源,逐级回退):
334
+ * ① {scope}CountInfo.topTab——**只有待办返回**(实测 .18);附带各页签计数 totalcount[groupid]。
335
+ * ② {scope}BaseInfo.groupinfo——**已办/我发起的唯一来源**(实测 .18:done 7 个页签、mine 6 个);无计数。
336
+ * ③ 内置页签名表(网络异常时的兜底);无计数。
337
+ * 已剔除「全部」页签——它是其它所有页签的并集,单列会吞掉全部分类。
338
+ */
339
+ export async function getOaTabs(cfg, category) {
340
+ const scope = CATEGORY_SCOPE[category] ?? CATEGORY_SCOPE.todo;
341
+ const menuIds = String(cfg[scope.menuIdsKey] ?? scope.defaultMenuIds ?? cfg.menuIds ?? '1,13');
342
+ const menuPathIds = String(cfg[scope.menuIdsKey] ?? cfg.menuPathIds ?? menuIds);
343
+ const mapTab = (t) => ({ title: String(t?.title ?? ''), viewcondition: String(t?.viewcondition ?? ''), groupid: String(t?.groupid ?? '') });
344
+ let tabs = [];
345
+ let counts = {};
346
+ let source = 'builtin';
347
+ const api = await createApiFromProfile(cfg);
348
+ // ① CountInfo.topTab(待办有;已办/我发起实测不返回)
349
+ try {
350
+ const r = await api.post(`/api/workflow/reqlist/${scope.countEndpoint}`, buildScopeBody(scope.defaultViewcondition, scope, menuIds, menuPathIds));
351
+ const j = r.json ?? {};
352
+ counts = j.totalcount ?? {};
353
+ tabs = (j.topTab ?? []).map(mapTab).filter((t) => t.title && t.viewcondition !== '');
354
+ if (tabs.length)
355
+ source = 'countinfo';
356
+ }
357
+ catch { /* 落到下一级 */ }
358
+ // ② BaseInfo.groupinfo(已办/我发起的权威来源)
359
+ if (!tabs.length) {
360
+ try {
361
+ const r = await api.post(`/api/workflow/reqlist/${scope.viewScope}BaseInfo`, buildScopeBody(scope.defaultViewcondition, scope, menuIds, menuPathIds));
362
+ tabs = (r.json?.groupinfo ?? []).map(mapTab).filter((t) => t.title && t.viewcondition !== '');
363
+ if (tabs.length)
364
+ source = 'baseinfo';
365
+ }
366
+ catch { /* 落到下一级 */ }
367
+ }
368
+ // ③ 内置表兜底
369
+ if (!tabs.length)
370
+ tabs = builtinTabs(category);
371
+ return { tabs: tabs.filter((t) => t.viewcondition !== String(scope.fullTab)), counts, scope, source };
372
+ }
373
+
374
+ /**
375
+ * 按 OA 页签分组取数:每个页签独立查询、并发执行,同一 requestId 只保留在靠前的页签里(避免重复推荐)。
376
+ * 只列有数据的分类:计数已知且为 0 的页签直接跳过(不查询);查完 total=0 的也不展示。
377
+ */
378
+ export async function getOaTabGroups(cfg, opts = {}) {
379
+ const category = opts.category ?? 'todo';
380
+ const { tabs, counts } = await getOaTabs(cfg, category);
381
+ const results = await Promise.all(tabs.map(async (tab) => {
382
+ const known = tab.groupid && counts[tab.groupid] !== undefined ? Number(counts[tab.groupid]) : NaN;
383
+ if (Number.isFinite(known) && known === 0)
384
+ return null; // 计数已知为 0 → 不查询
385
+ const result = await getWfList(cfg, { ...opts, category, subCategory: tab.viewcondition, _singleWorkflow: false });
386
+ return { tab, result };
387
+ }));
388
+ const seen = new Set();
389
+ const groups = [];
390
+ for (const entry of results) {
391
+ if (!entry)
392
+ continue;
393
+ const { tab, result } = entry;
394
+ if (!result.total)
395
+ continue; // 无数据不展示
396
+ const items = result.items.filter((item) => {
397
+ const id = String(item.requestId ?? '');
398
+ if (!id || seen.has(id))
399
+ return !id;
400
+ seen.add(id);
401
+ return true;
402
+ });
403
+ groups.push({ key: tab.viewcondition, title: tab.title, viewcondition: tab.viewcondition, total: result.total, items, result: { ...result, items } });
404
+ }
405
+ return {
406
+ category,
407
+ page: opts.page ?? 1,
408
+ groups,
409
+ categoryTotals: Object.fromEntries(groups.map((g) => [g.viewcondition, Number(g.total) || 0])),
410
+ // 仅用于诊断:各分类相加(页签间有交集,绝不能称为“总数”)
411
+ categoryTotalSum: groups.reduce((sum, g) => sum + (Number(g.total) || 0), 0),
412
+ };
413
+ }
414
+
320
415
  /** 解析 doingBaseInfo.treedata 中的流程类型节点。 */
321
416
  export function parseWorkflowTypes(tree) {
322
417
  const out = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weaver-e9-assistant-cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "泛微 e-cology E9 HTTP 能力 CLI 封装(待办、微博、新建流程、邮件、公文、日程、考勤、建模台账)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "start": "node dist/cli.js",
20
20
  "e9": "node dist/cli.js",
21
- "build": "node --check dist/cli.js && node --check dist/cube.js && node --check dist/workplan.js && node --check dist/fileutil.js && node --check dist/kq.js && node --check dist/links.js && node --check dist/report.js && node --check dist/workflow.js && node --check dist/parse.js && node --check dist/todosubmit.js && node --check dist/wfsummary.js && node --check dist/wfstart.js && node --check dist/submissionplan.js && node --check dist/submissionstate.js && node --check dist/auth-token.js && node --check dist/todobatch.js && node --check dist/pageopen.js && node --check dist/createcontext.js && node --check dist/querystate.js && node --check dist/wfops.js && node --check dist/subordinate.js",
21
+ "build": "node --check dist/cli.js && node --check dist/cube.js && node --check dist/workplan.js && node --check dist/fileutil.js && node --check dist/kq.js && node --check dist/links.js && node --check dist/report.js && node --check dist/workflow.js && node --check dist/parse.js && node --check dist/todosubmit.js && node --check dist/wfsummary.js && node --check dist/wfstart.js && node --check dist/submissionplan.js && node --check dist/submissionstate.js && node --check dist/auth-token.js && node --check dist/todobatch.js && node --check dist/pageopen.js && node --check dist/createcontext.js && node --check dist/querystate.js && node --check dist/wfops.js && node --check dist/subordinate.js && node --check dist/hrm.js",
22
22
  "test": "node --test \"tests/**/*.test.js\"",
23
23
  "prepack": "npm test",
24
24
  "release": "node scripts/release.js",