weaver-e9-assistant-cli 1.1.2 → 1.1.4
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 +264 -22
- package/dist/cube.js +31 -0
- package/dist/hrm.js +478 -0
- package/dist/parse.js +15 -1
- package/dist/subordinate.js +1 -1
- package/dist/wfstart.js +34 -2
- package/dist/wfuser.js +8 -9
- package/dist/workflow.js +100 -5
- package/package.json +2 -2
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,
|
|
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, expandHrmOrgTree } 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,11 @@ 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 tree [--id <组织id>] [--type <1分部|2部门>] [--depth <n>] [--dept <部门名|id>] [--subcompany <分部名|id>] [--json] 组织架构树',
|
|
150
|
+
' e9 hrm tabs 列出人事搜索范围(全部人员/同部门/我的下属)',
|
|
151
|
+
' e9 hrm conditions [--tab <全部人员|同部门|我的下属>] 列出该范围可用搜索条件(label→domkey)',
|
|
152
|
+
' e9 hrm search [--tab <全部人员|同部门|我的下属>] [--name <姓名>] [--condition "岗位=经理;性别=男"]',
|
|
153
|
+
' [--dept <部门名|id>] [--subcompany <分部名|id>] [--all-level] [--page <n>] [--json] [--raw] 人事搜索(姓名=详情卡片超链接)',
|
|
148
154
|
' e9 search <名称> 统一搜台账+菜单+流程(只说名字、不知归属时用,避免猜错域)',
|
|
149
155
|
' e9 config 查看当前配置来源与状态',
|
|
150
156
|
' e9 config set <field> <value> 设置配置(field: baseUrl/cookie/username/menuIds/...;首次用 set baseUrl)',
|
|
@@ -340,7 +346,7 @@ function escapeLinkText(text) {
|
|
|
340
346
|
return String(text).replace(/([\[\]()\\])/g, '\\$1');
|
|
341
347
|
}
|
|
342
348
|
/** 统一拼各域 list/search 项的 markdown 超链接;无 id/无可链目标时回退纯文本。
|
|
343
|
-
* domain: 'todo'|'odoc'|'create'|'blog'|'email'|'cube'|'workplan'
|
|
349
|
+
* domain: 'todo'|'odoc'|'create'|'blog'|'email'|'cube'|'workplan'|'hrm'
|
|
344
350
|
* text: 展示文本(调用方已计算好回退值);ctx: cube 需 {modeId,formId,customid} */
|
|
345
351
|
function hyperlinkFor(domain, item, base, text, ctx = {}) {
|
|
346
352
|
const meta = linkMetaFor(domain, item, base, ctx);
|
|
@@ -389,6 +395,12 @@ function linkMetaFor(domain, item, base, ctx = {}) {
|
|
|
389
395
|
url = `${b}/spa/workplan/static/index.html#/main/wp/myWorkPlan`;
|
|
390
396
|
urlType = url ? 'list-generic' : '';
|
|
391
397
|
break;
|
|
398
|
+
case 'hrm':
|
|
399
|
+
// 人事详情卡片(真机验证通过):SPA hash 路由 #/main/hrm/card/cardInfo/<hrmresource.id>
|
|
400
|
+
if (item?.id)
|
|
401
|
+
url = buildHrmCardUrl(b, item.id);
|
|
402
|
+
urlType = url ? 'detail' : '';
|
|
403
|
+
break;
|
|
392
404
|
}
|
|
393
405
|
return {
|
|
394
406
|
url: url || null,
|
|
@@ -1736,15 +1748,16 @@ async function runBlog(args) {
|
|
|
1736
1748
|
return { ...it, matchType, _sort: matchType === 'exact' ? 0 : matchType === 'prefix' ? 1 : 2 };
|
|
1737
1749
|
});
|
|
1738
1750
|
annotated.sort((a, b) => a._sort - b._sort);
|
|
1751
|
+
const atBase = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
1739
1752
|
if (args.flags.json) {
|
|
1740
|
-
console.log(JSON.stringify({ pageIndex: r.pageIndex, pageSize: r.pageSize, noMore: r.noMore, query: q || undefined, items: annotated.map(({ _sort, ...rest }) => rest) }, null, 2));
|
|
1753
|
+
console.log(JSON.stringify({ pageIndex: r.pageIndex, pageSize: r.pageSize, noMore: r.noMore, query: q || undefined, items: annotated.map(({ _sort, ...rest }) => withLink('hrm', { id: rest.uid, name: rest.name }, atBase, rest)) }, null, 2));
|
|
1741
1754
|
return;
|
|
1742
1755
|
}
|
|
1743
1756
|
console.log(`E9 微博 @ 提及用户列表 [第 ${r.pageIndex} 页]${r.noMore ? '' : '(还有更多)'} 共 ${annotated.length} 条${q ? ` · 搜索「${flagStr(args, 'name')}」` : ''}`);
|
|
1744
1757
|
for (let i = 0; i < annotated.length; i++) {
|
|
1745
1758
|
const it = annotated[i];
|
|
1746
1759
|
const tag = it.matchType === 'exact' ? ' ★精确匹配' : it.matchType === 'prefix' ? ' ·前缀' : '';
|
|
1747
|
-
console.log(`${i + 1}. ${it.name} (uid=${it.uid})${tag}`);
|
|
1760
|
+
console.log(`${i + 1}. ${hyperlinkFor('hrm', { id: it.uid }, atBase, it.name)} (uid=${it.uid})${tag}`);
|
|
1748
1761
|
}
|
|
1749
1762
|
return;
|
|
1750
1763
|
}
|
|
@@ -3432,6 +3445,12 @@ async function runCube(args) {
|
|
|
3432
3445
|
catch {
|
|
3433
3446
|
curFields = null;
|
|
3434
3447
|
}
|
|
3448
|
+
// ★字段属性硬规则:只读字段禁止填值,编辑 dry-run 也要拦(真实提交由 submitCubeCard 兜底)。
|
|
3449
|
+
// 判据必须取「新建态」字段定义——curFields 带 billid 是查看态,isedit 恒为 false,会误伤全部字段。
|
|
3450
|
+
const defFields = (await getCardFields(cfg, { modeId, formId })).fields;
|
|
3451
|
+
assertEditableCardValues(defFields, values);
|
|
3452
|
+
if (files.length)
|
|
3453
|
+
assertEditableCardValues(defFields, Object.fromEntries(files.map((f) => [String(f.fieldId), '1'])));
|
|
3435
3454
|
// 查看态不带值时:list 反查该条记录的行值作对比兜底
|
|
3436
3455
|
if (!(curFields && curFields.fields.some((f) => f.value !== undefined && f.value !== null && f.value !== ''))) {
|
|
3437
3456
|
try {
|
|
@@ -3764,8 +3783,9 @@ async function runSubordinate(args) {
|
|
|
3764
3783
|
console.error(JSON.stringify(r.raw.counts, null, 2));
|
|
3765
3784
|
console.error('===== 解析结果 =====');
|
|
3766
3785
|
}
|
|
3786
|
+
const subBase = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
3767
3787
|
if (args.flags.json) {
|
|
3768
|
-
console.log(JSON.stringify({ lastname, allLevel, page, pageSize: r.pageSize, total: r.total, count: r.count, items: r.items.map((it) => ({ id: it.id, name: it.name, fields: it.fields })) }, null, 2));
|
|
3788
|
+
console.log(JSON.stringify({ lastname, allLevel, page, pageSize: r.pageSize, total: r.total, count: r.count, items: r.items.map((it) => withLink('hrm', { id: it.id, name: it.name, fields: it.fields }, subBase)) }, null, 2));
|
|
3769
3789
|
return;
|
|
3770
3790
|
}
|
|
3771
3791
|
console.log(`我的下属${allLevel ? '(全部层级)' : '(直接下属)'}${name ? ` · 姓名过滤「${name}」` : ''} [第 ${page} 页${r.pageSize != null ? ` · 每页 ${r.pageSize} 条` : ''}${r.total != null ? ` / 共 ${r.total} 人` : ''}] 当前 ${r.count} 人`);
|
|
@@ -3773,17 +3793,230 @@ async function runSubordinate(args) {
|
|
|
3773
3793
|
console.log('(无下属)');
|
|
3774
3794
|
return;
|
|
3775
3795
|
}
|
|
3776
|
-
|
|
3796
|
+
// 表头同样排除 id 列(服务端把 id 列的标题误标为「是否在线」,列出来会误导)与 randomFieldId
|
|
3797
|
+
const hdrIdCol = r.items[0]?.idCol;
|
|
3798
|
+
const cols = (r.columns || []).filter((c) => c.dataIndex && c.dataIndex !== 'randomFieldId' && c.dataIndex !== hdrIdCol);
|
|
3777
3799
|
if (cols.length)
|
|
3778
3800
|
console.log(`字段(${cols.length}): ${cols.map((c) => c.title ?? c.dataIndex).join(' | ')}`);
|
|
3779
3801
|
for (let i = 0; i < r.items.length; i++) {
|
|
3780
3802
|
const it = r.items[i];
|
|
3781
|
-
console.log(`${i + 1}. ${it.name || '(无姓名)'} [id=${it.id || ''}]`);
|
|
3782
|
-
|
|
3803
|
+
console.log(`${i + 1}. ${hyperlinkFor('hrm', { id: it.id }, subBase, it.name || '(无姓名)')} [id=${it.id || ''}]`);
|
|
3804
|
+
// 排除内部列:id 列(其标题被服务端误标为「是否在线」)、姓名列(已作可点标题)、randomFieldId(id 的副本)
|
|
3805
|
+
const meta = it.fields.filter((f) => f.value && f.title && f.dataIndex !== it.idCol && f.dataIndex !== it.nameCol && f.dataIndex !== 'randomFieldId').slice(0, 6).map((f) => `${f.title}=${f.value}`).join(' | ');
|
|
3783
3806
|
if (meta)
|
|
3784
3807
|
console.log(` ${meta}`);
|
|
3785
3808
|
}
|
|
3786
3809
|
}
|
|
3810
|
+
|
|
3811
|
+
// ─── 组织人事(HRM)搜索 ─────────────────────────────────
|
|
3812
|
+
|
|
3813
|
+
/**
|
|
3814
|
+
* hrm search 结果里值为「字典编码」的列(dataIndex)。
|
|
3815
|
+
* E9 在人事搜索接口(hrm/search/getHrmSearchResult)里**不解析**这些字段——给的是 id/code,
|
|
3816
|
+
* 而同为人员列表的 subordinateinfo/getSearchResult 会解析(部门/分部/直接上级都给中文名)。
|
|
3817
|
+
* 实测:岗位 jobtitlespan 给的就是 "1830"、性别 sexspan 给 "1"。OA 的「查询人员」页同样只显示编码。
|
|
3818
|
+
* 故渲染时标注「(编码)」,避免用户把 1830 误读成名称。
|
|
3819
|
+
*/
|
|
3820
|
+
const HRM_CODE_FIELDS = new Set([
|
|
3821
|
+
'sex', 'accounttype', 'belongto', 'jobtitle', 'jobGroupId',
|
|
3822
|
+
'jobactivity', 'jobcall', 'joblevel', 'status', 'systemlanguage', 'locationid',
|
|
3823
|
+
]);
|
|
3824
|
+
|
|
3825
|
+
/** 组织节点名称 → id(用于 --dept/--subcompany 名称定位;纯数字原样直传)。 */
|
|
3826
|
+
async function resolveHrmOrgValue(cfg, keyword, { matchType, flag }) {
|
|
3827
|
+
const node = await findHrmOrgNode(cfg, keyword, { matchType });
|
|
3828
|
+
if (!node)
|
|
3829
|
+
throw new Error(`${flag} 无法在组织树中定位「${keyword}」(type=${matchType})。可先用数字 ID 直传,或确认名称在人事组织树中存在。`);
|
|
3830
|
+
return node.id;
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
/** 未识别条件的提示(含相近候选),与 cube 的 printConditionUnresolved 一致——不给静默放空。 */
|
|
3834
|
+
function printHrmConditionUnresolved(unresolved) {
|
|
3835
|
+
for (const u of unresolved) {
|
|
3836
|
+
const cands = (u.candidates ?? []).join('、');
|
|
3837
|
+
console.error(`⚠ 人事条件「${u.label}」未匹配到搜索条件字段${cands ? `。相近字段候选: ${cands}` : ''}(可用 e9 hrm conditions 查看全部可用条件名)。`);
|
|
3838
|
+
}
|
|
3839
|
+
process.exit(1);
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
async function runHrm(args) {
|
|
3843
|
+
const sub = args._[1] ?? 'search';
|
|
3844
|
+
const cfg = loadConfig(flagStr(args, 'config'));
|
|
3845
|
+
if (sub === 'tabs') {
|
|
3846
|
+
const r = await getHrmTabs(cfg);
|
|
3847
|
+
if (args.flags.json) {
|
|
3848
|
+
console.log(JSON.stringify({ tabs: r.tabs }, null, 2));
|
|
3849
|
+
return;
|
|
3850
|
+
}
|
|
3851
|
+
console.log('E9 人事搜索范围:');
|
|
3852
|
+
if (r.tabs.length === 0) {
|
|
3853
|
+
console.log('(无可用范围,可能无组织人事查看权限)');
|
|
3854
|
+
return;
|
|
3855
|
+
}
|
|
3856
|
+
for (const t of r.tabs)
|
|
3857
|
+
console.log(` ${t.title} (${t.typeid})`);
|
|
3858
|
+
return;
|
|
3859
|
+
}
|
|
3860
|
+
if (sub === 'conditions') {
|
|
3861
|
+
const tab = flagStr(args, 'tab') ?? '全部人员';
|
|
3862
|
+
const mouldid = resolveHrmTab(tab);
|
|
3863
|
+
const r = await getHrmConditions(cfg, { mouldid });
|
|
3864
|
+
if (args.flags.json) {
|
|
3865
|
+
console.log(JSON.stringify({ tab, mouldid, groups: r.groups }, null, 2));
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
console.log(`E9 人事搜索条件 [${tab}]:`);
|
|
3869
|
+
if (r.groups.length === 0) {
|
|
3870
|
+
console.log('(无可用条件)');
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
for (const g of r.groups) {
|
|
3874
|
+
console.log(` ${g.title}:`);
|
|
3875
|
+
for (const it of g.items)
|
|
3876
|
+
console.log(` - ${it.label} (${it.domkey}${it.conditionType ? `, ${it.conditionType}` : ''})`);
|
|
3877
|
+
}
|
|
3878
|
+
return;
|
|
3879
|
+
}
|
|
3880
|
+
if (sub === 'tree') {
|
|
3881
|
+
// 组织架构树:无参列顶层分部;--dept/--subcompany 定位到该组织后展开;--id(+可选 --type)直接指定起点;--depth 展示层数
|
|
3882
|
+
const depth = Math.min(Math.max(Number(flagStr(args, 'depth') ?? '1') || 1, 1), 5);
|
|
3883
|
+
const deptSpec = flagStr(args, 'dept');
|
|
3884
|
+
const subSpec = flagStr(args, 'subcompany');
|
|
3885
|
+
let start = { type: '0', id: '0' };
|
|
3886
|
+
let located = null;
|
|
3887
|
+
if (deptSpec || subSpec) {
|
|
3888
|
+
const kw = deptSpec ?? subSpec;
|
|
3889
|
+
const wantType = deptSpec ? '2' : '1';
|
|
3890
|
+
const label = deptSpec ? '部门' : '分部';
|
|
3891
|
+
if (/^\d+$/.test(kw)) {
|
|
3892
|
+
located = { id: kw, type: wantType, name: kw };
|
|
3893
|
+
}
|
|
3894
|
+
else {
|
|
3895
|
+
located = await findHrmOrgNode(cfg, kw, { matchType: wantType });
|
|
3896
|
+
if (!located)
|
|
3897
|
+
throw new Error(`未找到${label}「${kw}」。可先跑 e9 hrm tree 查看顶层分部,或改用数字 id。`);
|
|
3898
|
+
}
|
|
3899
|
+
start = { type: located.type, id: located.id };
|
|
3900
|
+
}
|
|
3901
|
+
else if (flagStr(args, 'id')) {
|
|
3902
|
+
start = { type: flagStr(args, 'type') ?? '1', id: flagStr(args, 'id') };
|
|
3903
|
+
}
|
|
3904
|
+
const r = await expandHrmOrgTree(cfg, { ...start, depth });
|
|
3905
|
+
if (args.flags.json) {
|
|
3906
|
+
console.log(JSON.stringify({ start, located, depth, nodes: r.nodes, total: r.total, truncated: r.truncated }, null, 2));
|
|
3907
|
+
return;
|
|
3908
|
+
}
|
|
3909
|
+
console.log(located
|
|
3910
|
+
? `E9 组织架构 · ${located.name || located.id}(type=${start.type} id=${start.id})`
|
|
3911
|
+
: 'E9 组织架构 · 顶层分部');
|
|
3912
|
+
if (!r.nodes.length) {
|
|
3913
|
+
console.log('(无下级组织)');
|
|
3914
|
+
return;
|
|
3915
|
+
}
|
|
3916
|
+
const render = (nodes, indent) => {
|
|
3917
|
+
for (const n of nodes) {
|
|
3918
|
+
const kind = n.type === '1' ? '分部' : n.type === '2' ? '部门' : `type=${n.type}`;
|
|
3919
|
+
console.log(`${indent}${n.name} [${kind} id=${n.id}]`);
|
|
3920
|
+
if (n.children.length)
|
|
3921
|
+
render(n.children, indent + ' ');
|
|
3922
|
+
}
|
|
3923
|
+
};
|
|
3924
|
+
render(r.nodes, ' ');
|
|
3925
|
+
console.log(`\n共 ${r.total} 个组织节点${r.truncated ? '(已达显示上限,结果被截断)' : ''}。`);
|
|
3926
|
+
return;
|
|
3927
|
+
}
|
|
3928
|
+
if (sub !== 'search') {
|
|
3929
|
+
console.error(`hrm 子命令: tabs/conditions/search/tree,收到: ${sub}`);
|
|
3930
|
+
process.exit(1);
|
|
3931
|
+
}
|
|
3932
|
+
const tab = flagStr(args, 'tab') ?? '全部人员';
|
|
3933
|
+
const tabkey = resolveHrmTab(tab);
|
|
3934
|
+
const page = Number(flagStr(args, 'page') ?? '1') || 1;
|
|
3935
|
+
const allLevel = flagBool(args, 'all-level');
|
|
3936
|
+
const name = flagStr(args, 'name');
|
|
3937
|
+
const dept = flagStr(args, 'dept');
|
|
3938
|
+
const subcompany = flagStr(args, 'subcompany');
|
|
3939
|
+
const spec = flagStr(args, 'condition') ?? '';
|
|
3940
|
+
|
|
3941
|
+
const values = {};
|
|
3942
|
+
// ★越权防护:按名过滤目标必须 ∈ {本人 + 全部层级下属}(与 subordinate/todo/cube 一致:默认停用 no-op)
|
|
3943
|
+
if (name) {
|
|
3944
|
+
await assertNamesInScope(cfg, [name], { domain: 'hrm' });
|
|
3945
|
+
values.resourcename = name;
|
|
3946
|
+
}
|
|
3947
|
+
// --dept/--subcompany:数字直传 domkey;名称走组织树 BFS 定位(部门 type 2 / 分部 type 1)
|
|
3948
|
+
if (dept)
|
|
3949
|
+
values.departmentid = /^\d+$/.test(dept) ? dept : await resolveHrmOrgValue(cfg, dept, { matchType: '2', flag: '--dept' });
|
|
3950
|
+
if (subcompany)
|
|
3951
|
+
values.subcompanyid1 = /^\d+$/.test(subcompany) ? subcompany : await resolveHrmOrgValue(cfg, subcompany, { matchType: '1', flag: '--subcompany' });
|
|
3952
|
+
// --condition 中文条件(label→domkey 自动映射)
|
|
3953
|
+
const condRes = await resolveHrmCondition(cfg, { mouldid: tabkey, condition: spec });
|
|
3954
|
+
if (condRes.unresolved.length)
|
|
3955
|
+
printHrmConditionUnresolved(condRes.unresolved);
|
|
3956
|
+
// ★越权防护(fail-closed):无法确认是否按人过滤的条件 → 硬拒绝;策略未启用(默认)不拦,仅保留代码路径
|
|
3957
|
+
if (isScopeEnforced() && condRes.unverifiablePerson)
|
|
3958
|
+
throw new Error('拒绝越权查询:人事搜索条件无法确认是否按人过滤(条件定义读取失败或含未识别字段)。为防越权,本次查询已拒绝,请稍后重试或改用中文字段名。');
|
|
3959
|
+
// ★越权防护:按人条件(姓名/直接上级/助理)目标必须 ∈ {本人 + 全部层级下属}
|
|
3960
|
+
// assertNamesInScope 同时返回解析后的 uid(blog at-list 硬规则唯一入口)。
|
|
3961
|
+
// ★E9 实测校准(2026-09-20 e9pc):姓名=INPUT 认姓名文本;直接上级/助理=BROWSER 人字段认 **hrmresource id**
|
|
3962
|
+
// (=userid,本人 7111 验证 manager=7111 → 9 个直接下属;manager=杨文元sh → 0)。故 BROWSER 人字段值回填为 uid。
|
|
3963
|
+
if (condRes.personConditions?.length) {
|
|
3964
|
+
const resolvedIds = await assertNamesInScope(cfg, condRes.personConditions.map((p) => p.value), { domain: 'hrm' });
|
|
3965
|
+
condRes.conditionValues = applyResolvedPersonIds(condRes.conditionValues, condRes.personConditions, resolvedIds);
|
|
3966
|
+
}
|
|
3967
|
+
Object.assign(values, condRes.conditionValues);
|
|
3968
|
+
// ★E9 实测校准:--condition 里的 部门/分部 已由 resolveHrmCondition 重映射为 departmentid/subcompanyid1
|
|
3969
|
+
// (条件定义 domkey=department/subcompany 不是搜索接口认的参数);名称值在此经组织树解析为 id(同 --dept/--subcompany 路径)。
|
|
3970
|
+
for (const [key, matchType, label] of [['departmentid', '2', '部门'], ['subcompanyid1', '1', '分部']]) {
|
|
3971
|
+
const raw = values[key];
|
|
3972
|
+
if (raw != null && raw !== '' && !/^\d+$/.test(String(raw)))
|
|
3973
|
+
values[key] = await resolveHrmOrgValue(cfg, String(raw), { matchType, flag: `--condition "${label}=${raw}"` });
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
const r = await hrmList(cfg, { tabkey, values, showAllLevel: allLevel, page });
|
|
3977
|
+
const base = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
3978
|
+
if (args.flags.raw) {
|
|
3979
|
+
console.error('===== RAW getHrmSearchResult =====');
|
|
3980
|
+
console.error(JSON.stringify(r.raw.search, null, 2));
|
|
3981
|
+
console.error('===== RAW datas =====');
|
|
3982
|
+
console.error(JSON.stringify(r.raw.datas, null, 2));
|
|
3983
|
+
console.error('===== RAW counts =====');
|
|
3984
|
+
console.error(JSON.stringify(r.raw.counts, null, 2));
|
|
3985
|
+
console.error('===== 解析结果 =====');
|
|
3986
|
+
}
|
|
3987
|
+
if (args.flags.json) {
|
|
3988
|
+
console.log(JSON.stringify({
|
|
3989
|
+
tab, tabkey, page,
|
|
3990
|
+
pageSize: r.pageSize, total: r.total, count: r.count,
|
|
3991
|
+
sessionkey: r.sessionkey,
|
|
3992
|
+
columns: r.columns.map((c) => ({ dataIndex: c.dataIndex, title: c.title })),
|
|
3993
|
+
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)),
|
|
3994
|
+
}, null, 2));
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
console.log(`E9 人事 [${tab}]${allLevel ? '(含子部门)' : ''} 第 ${page} 页${r.pageSize != null ? ` · 每页 ${r.pageSize} 条` : ''}${r.total != null ? ` / 共 ${r.total} 人` : ''} 当前 ${r.count} 人`);
|
|
3998
|
+
if (r.count === 0) {
|
|
3999
|
+
console.log('(无匹配人员)');
|
|
4000
|
+
return;
|
|
4001
|
+
}
|
|
4002
|
+
const cols = (r.columns || []).filter((c) => c.dataIndex && !['id', 'randomField0', 'randomFieldId'].includes(c.dataIndex));
|
|
4003
|
+
if (cols.length)
|
|
4004
|
+
console.log(`字段(${cols.length}): ${cols.map((c) => c.title ?? c.dataIndex).join(' | ')}`);
|
|
4005
|
+
for (let i = 0; i < r.items.length; i++) {
|
|
4006
|
+
const it = r.items[i];
|
|
4007
|
+
const head = hyperlinkFor('hrm', it, base, it.name || '(无姓名)');
|
|
4008
|
+
console.log(`${i + 1}. ${head} [id=${it.id || ''}]`);
|
|
4009
|
+
// 行 meta 去掉姓名(head 已显示)与 id 类列,避免重复;字典编码字段标注「(编码)」
|
|
4010
|
+
const meta = it.fields
|
|
4011
|
+
.filter((f) => !['lastname', 'id', 'randomField0', 'randomFieldId'].includes(f.dataIndex))
|
|
4012
|
+
.slice(0, 8)
|
|
4013
|
+
.map((f) => `${f.title}=${f.value}${f.value && HRM_CODE_FIELDS.has(f.dataIndex) ? '(编码)' : ''}`)
|
|
4014
|
+
.join(' | ');
|
|
4015
|
+
if (meta)
|
|
4016
|
+
console.log(` ${meta}`);
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
|
|
3787
4020
|
function printTodoItems(items, base, { indent = '', max = 10 } = {}) {
|
|
3788
4021
|
const visible = items.slice(0, max);
|
|
3789
4022
|
for (let i = 0; i < visible.length; i++) {
|
|
@@ -3873,6 +4106,7 @@ async function runReport(args) {
|
|
|
3873
4106
|
console.log(`- 未查询成功的来源:${report.failures.map((item) => item.index).join(', ')}`);
|
|
3874
4107
|
}
|
|
3875
4108
|
|
|
4109
|
+
|
|
3876
4110
|
async function main() {
|
|
3877
4111
|
const args = parseArgs(process.argv.slice(2));
|
|
3878
4112
|
if (args.flags.help || args.flags.h) {
|
|
@@ -3882,7 +4116,7 @@ async function main() {
|
|
|
3882
4116
|
const cmd = args._[0] ?? 'todo';
|
|
3883
4117
|
if (cmd === 'version') {
|
|
3884
4118
|
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.
|
|
4119
|
+
console.log(JSON.stringify({ name: 'weaver-e9-assistant-cli', version: '1.1.4', buildHash }, null, 2));
|
|
3886
4120
|
return;
|
|
3887
4121
|
}
|
|
3888
4122
|
if (cmd === 'whoami') {
|
|
@@ -3941,6 +4175,10 @@ async function main() {
|
|
|
3941
4175
|
await runSubordinate(args);
|
|
3942
4176
|
return;
|
|
3943
4177
|
}
|
|
4178
|
+
if (cmd === 'hrm') {
|
|
4179
|
+
await runHrm(args);
|
|
4180
|
+
return;
|
|
4181
|
+
}
|
|
3944
4182
|
if (cmd !== 'todo') {
|
|
3945
4183
|
console.error(`未知命令: ${cmd}\n\n${usage()}`);
|
|
3946
4184
|
process.exit(1);
|
|
@@ -4086,33 +4324,37 @@ async function main() {
|
|
|
4086
4324
|
debug: queryBool('debug'),
|
|
4087
4325
|
excludeCreatorId,
|
|
4088
4326
|
};
|
|
4089
|
-
//
|
|
4090
|
-
const shouldGroup =
|
|
4327
|
+
// 默认按 OA 二级分类(流程中心页签)分组展示——待办/已办/我发起通用;只列有数据的分类。
|
|
4328
|
+
const shouldGroup = !listOpts.subCategory && !tab && !listOpts.totalOnly && !queryBool('ungrouped') && !queryBool('raw');
|
|
4091
4329
|
if (shouldGroup) {
|
|
4092
|
-
const grouped = await
|
|
4330
|
+
const grouped = await getOaTabGroups(cfg, { ...listOpts, category });
|
|
4331
|
+
const scope = CATEGORY_SCOPE[category] ?? CATEGORY_SCOPE.todo;
|
|
4093
4332
|
const groupedSnapshot = {
|
|
4094
|
-
|
|
4333
|
+
category,
|
|
4095
4334
|
categoryTotals: grouped.categoryTotals,
|
|
4096
4335
|
categoryTotalSum: grouped.categoryTotalSum,
|
|
4097
4336
|
page: grouped.page,
|
|
4098
4337
|
groups: grouped.groups.map((group) => ({
|
|
4099
|
-
|
|
4338
|
+
viewcondition: group.viewcondition,
|
|
4100
4339
|
title: group.title,
|
|
4101
|
-
total: group.
|
|
4340
|
+
total: group.total,
|
|
4102
4341
|
items: group.items,
|
|
4103
4342
|
raw: group.result.raw,
|
|
4104
4343
|
})),
|
|
4105
4344
|
};
|
|
4106
|
-
const savedGroupedResult = persistSearchResult(groupedSnapshot,
|
|
4345
|
+
const savedGroupedResult = persistSearchResult(groupedSnapshot, `E9 ${scope.pageTitle}分类概览`);
|
|
4346
|
+
const base = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
4107
4347
|
if (args.flags.json) {
|
|
4108
|
-
|
|
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));
|
|
4348
|
+
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
4349
|
return;
|
|
4111
4350
|
}
|
|
4112
|
-
|
|
4113
|
-
|
|
4351
|
+
if (!grouped.groups.length) {
|
|
4352
|
+
console.log(`E9 ${scope.pageTitle}:各分类均无数据。`);
|
|
4353
|
+
return;
|
|
4354
|
+
}
|
|
4355
|
+
console.log(`E9 ${scope.pageTitle}(按 OA 分类页签分组,只列有数据的分类)`);
|
|
4114
4356
|
for (const group of grouped.groups) {
|
|
4115
|
-
console.log(`\n### ${group.title}(共 ${group.
|
|
4357
|
+
console.log(`\n### ${group.title}(共 ${group.total ?? '?'} 条,第 ${group.result.page} 页显示前 10 条)`);
|
|
4116
4358
|
if (group.items.length)
|
|
4117
4359
|
printTodoItems(group.items, base);
|
|
4118
4360
|
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,478 @@
|
|
|
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
|
+
* 递归展开组织树(供 `e9 hrm tree` 用)。
|
|
416
|
+
* 起点 (type,id):'0'/'0' = 顶层分部;'1'/<分部id> = 子分部+部门;'2'/<部门id> = 子部门。
|
|
417
|
+
* depth = 展示层数(1 = 只列起点下一层);maxNodes 防失控(超限截断,调用方须提示)。
|
|
418
|
+
*/
|
|
419
|
+
export async function expandHrmOrgTree(cfg, { type = '0', id = '0', depth = 1, maxNodes = 500 } = {}) {
|
|
420
|
+
let count = 0;
|
|
421
|
+
let truncated = false;
|
|
422
|
+
async function walk(t, i, levels) {
|
|
423
|
+
if (levels < 1)
|
|
424
|
+
return [];
|
|
425
|
+
const r = await getHrmSearchTree(cfg, { type: t, id: i });
|
|
426
|
+
const out = [];
|
|
427
|
+
for (const n of r.datas) {
|
|
428
|
+
if (count >= maxNodes) {
|
|
429
|
+
truncated = true;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
count++;
|
|
433
|
+
const node = { id: n.id, name: n.name, type: n.type, isParent: n.isParent, children: [] };
|
|
434
|
+
if (n.isParent && levels > 1)
|
|
435
|
+
node.children = await walk(n.type, n.id, levels - 1);
|
|
436
|
+
out.push(node);
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
const nodes = await walk(type, id, depth);
|
|
441
|
+
return { nodes, total: count, truncated };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── 编排:完整搜索 ──────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 人事搜索编排:tab + 条件 → sessionkey → 分页数据 + 总数。
|
|
448
|
+
* opts: { tabkey, values:{domkey:value}, showAllLevel, page }。
|
|
449
|
+
*/
|
|
450
|
+
export async function hrmList(cfg, opts = {}) {
|
|
451
|
+
const tabkey = opts.tabkey ?? 'default_3';
|
|
452
|
+
const page = Number(opts.page ?? '1') || 1;
|
|
453
|
+
const values = { ...(opts.values ?? {}) };
|
|
454
|
+
const { sessionkey, raw: searchRaw } = await hrmSearch(cfg, { tabkey, values, showAllLevel: !!opts.showAllLevel });
|
|
455
|
+
const data = await fetchHrmSearchTable(cfg, sessionkey, page);
|
|
456
|
+
const count = await fetchHrmSearchCount(cfg, sessionkey);
|
|
457
|
+
const total = count.total ?? null;
|
|
458
|
+
const items = data.rows.map((row) => normalizeHrmRow(row, data.columns));
|
|
459
|
+
return {
|
|
460
|
+
tabkey,
|
|
461
|
+
page,
|
|
462
|
+
pageSize: data.rows.length || null,
|
|
463
|
+
total,
|
|
464
|
+
count: items.length,
|
|
465
|
+
sessionkey,
|
|
466
|
+
columns: data.columns,
|
|
467
|
+
items,
|
|
468
|
+
raw: { search: searchRaw, datas: data.raw, counts: count.raw },
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ─── 工具 ────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
function truncate(s, n = 300) {
|
|
475
|
+
if (s == null)
|
|
476
|
+
return '(空)';
|
|
477
|
+
return String(s).length > n ? String(s).slice(0, n) + '…(已截断)' : String(s);
|
|
478
|
+
}
|
package/dist/parse.js
CHANGED
|
@@ -113,6 +113,20 @@ export function stripHtml(s) {
|
|
|
113
113
|
.replace(/'/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 =
|
|
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/subordinate.js
CHANGED
|
@@ -116,7 +116,7 @@ export function normalizeSubordinateRow(row, columns) {
|
|
|
116
116
|
title: c.title,
|
|
117
117
|
value: stripHtml(get(`${c.dataIndex}span`) ?? get(c.dataIndex)),
|
|
118
118
|
}));
|
|
119
|
-
return { id, name, fields, raw: row };
|
|
119
|
+
return { id, name, fields, idCol: idCol?.dataIndex, nameCol: nameCol?.dataIndex, raw: row };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/**
|
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; //
|
|
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
|
|
13
|
-
const
|
|
14
|
-
const
|
|
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", "
|
|
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", "
|
|
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.
|
|
3
|
+
"version": "1.1.4",
|
|
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",
|