bdip-office-assistant-cli 0.0.0-stage → 1.0.1

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.
Files changed (63) hide show
  1. package/README.md +37 -2
  2. package/dist/accounts.js +30 -0
  3. package/dist/args.js +36 -0
  4. package/dist/auth-token.js +783 -0
  5. package/dist/auth.js +331 -0
  6. package/dist/blog.js +562 -0
  7. package/dist/browser-links.js +224 -0
  8. package/dist/captcha-display.js +30 -0
  9. package/dist/cli.js +5533 -0
  10. package/dist/client.js +254 -0
  11. package/dist/config.js +170 -0
  12. package/dist/createcontext.js +41 -0
  13. package/dist/crypto.js +139 -0
  14. package/dist/cube.js +1771 -0
  15. package/dist/documents.js +98 -0
  16. package/dist/email.js +841 -0
  17. package/dist/field-preparation.js +237 -0
  18. package/dist/fileutil.js +20 -0
  19. package/dist/formula-runtime.js +55 -0
  20. package/dist/hrm.js +478 -0
  21. package/dist/keepalive.js +99 -0
  22. package/dist/kq.js +280 -0
  23. package/dist/linkage-calculations.js +68 -0
  24. package/dist/links.js +34 -0
  25. package/dist/meetings.js +172 -0
  26. package/dist/nodemap.js +242 -0
  27. package/dist/odoc.js +199 -0
  28. package/dist/office-tools.js +155 -0
  29. package/dist/pageopen.js +29 -0
  30. package/dist/parse.js +177 -0
  31. package/dist/password-input.js +84 -0
  32. package/dist/password-login.js +208 -0
  33. package/dist/person-selection.js +81 -0
  34. package/dist/querystate.js +38 -0
  35. package/dist/render.js +21 -0
  36. package/dist/report.js +50 -0
  37. package/dist/session-worker.js +54 -0
  38. package/dist/session.js +280 -0
  39. package/dist/submissionplan.js +45 -0
  40. package/dist/submissionstate.js +81 -0
  41. package/dist/subordinate.js +458 -0
  42. package/dist/supervise.js +152 -0
  43. package/dist/todoaskidea.js +54 -0
  44. package/dist/todobatch.js +172 -0
  45. package/dist/todocc.js +128 -0
  46. package/dist/todoforward.js +66 -0
  47. package/dist/todoreject.js +215 -0
  48. package/dist/todosubmit.js +325 -0
  49. package/dist/todotransfer.js +54 -0
  50. package/dist/util.js +25 -0
  51. package/dist/wfagent.js +538 -0
  52. package/dist/wfcreate.js +102 -0
  53. package/dist/wfform.js +169 -0
  54. package/dist/wfops.js +40 -0
  55. package/dist/wfreceiver.js +43 -0
  56. package/dist/wfstart.js +558 -0
  57. package/dist/wfsummary.js +89 -0
  58. package/dist/wfupload.js +83 -0
  59. package/dist/wfuser.js +96 -0
  60. package/dist/workflow-runtime.js +177 -0
  61. package/dist/workflow.js +570 -0
  62. package/dist/workplan.js +431 -0
  63. package/package.json +29 -6
package/dist/kq.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * BDIP5 考勤(kq)打卡模块。
3
+ *
4
+ * 抓包+实跑确认的接口(测试环境,2026-08-12):
5
+ * POST /api/hrm/kq/attendanceButton/getButtons 页面加载预发:取 timeline 班次参数(无 body)
6
+ * POST /api/hrm/kq/attendanceButton/punchButton 打卡(带 timeline 班次参数 + signTime + encryptObj)
7
+ *
8
+ * signSecret 签名算法从前端 signFunc.js 逆向(webpack source map 虚拟路径,靠 Sources 复制源码):
9
+ * signSecret(str) = md5("wea" + str) + "ver"(小写;标准 MD5,但输入前缀加 "wea")
10
+ * encryptObj = [timestamp, userid, signSecret(userid + groupid)] = `${ts},${uid},${md5("wea"+uid+gid)+"ver"}`
11
+ * timestamp = Date.now()(现算,服务端不校验时效);signTime = 现算 HH:mm:ss
12
+ * groupid = 考勤组 id(222222600 默认考勤组=1;每用户可能不同,待校准,CLI 可配 --group-id)
13
+ *
14
+ * 走当前 baseUrl(单一环境),aiAgentToken 鉴权(服务端只校验 md5(uid+gid) 防篡改,不校验会话/时效)。
15
+ */
16
+ import * as crypto from 'crypto';
17
+ import { createApiFromProfile } from './client.js';
18
+ import { buildRequestFormUrl } from './wfcreate.js';
19
+ // ───────────────── signSecret(前端 signFunc.js 逆向)─────────────────
20
+ /** md5 小写。 */
21
+ function md5Hex(s) {
22
+ return crypto.createHash('md5').update(s, 'utf8').digest('hex');
23
+ }
24
+ /**
25
+ * 考勤签名:signSecret(str) = md5("wea" + str) + "ver"(与前端 signFunc.js 一致)。
26
+ * 前端输入经 uTF8Encode,但纯 ASCII("wea"+uid+gid)无变化,故直接 md5。
27
+ */
28
+ export function signSecret(str) {
29
+ return md5Hex(`wea${str}`) + 'ver';
30
+ }
31
+ /**
32
+ * 构造 encryptObj = `${timestamp},${userid},${signSecret(userid+groupid)}ver`。
33
+ * 与 DataList.js 的 `signData=[generateTimeStamp(), userid, signSecret(userid+groupid)]` 一致
34
+ * (数组 toString() = "ts,uid,signSecret结果",signSecret 已含 ver 后缀)。
35
+ */
36
+ export function buildEncryptObj(userid, groupId, timestamp) {
37
+ const ts = (timestamp ?? Date.now()) + '';
38
+ return `${ts},${userid},${signSecret(`${userid}${groupId}`)}`;
39
+ }
40
+ /**
41
+ * 取打卡班次 timeline。POST /api/hrm/kq/attendanceButton/getButtons(无 body)。
42
+ * 返回 timeline[](每项=一个打卡时段,含全部班次参数)+ date/now/groupname。
43
+ * 不返回 encryptObj/groupid/signature(前端 JS 现算)。
44
+ */
45
+ export async function getButtons(cfg) {
46
+ const api = await createApiFromProfile(cfg);
47
+ const r = await api.post('/api/hrm/kq/attendanceButton/getButtons', {});
48
+ const j = r.json || {};
49
+ const tlRaw = Array.isArray(j.timeline) ? j.timeline : [];
50
+ const timeline = tlRaw.map((t) => ({
51
+ date: s(t.date),
52
+ type: s(t.type),
53
+ belongtime: s(t.belongtime),
54
+ canSignTime: s(t.canSignTime),
55
+ across: s(t.across),
56
+ belongdate: s(t.belongdate),
57
+ datetime: s(t.datetime),
58
+ min: s(t.min),
59
+ signSectionEndTime: s(t.signSectionEndTime),
60
+ islastsign: s(t.islastsign),
61
+ signSectionTime: s(t.signSectionTime),
62
+ signSectionBeginTime: s(t.signSectionBeginTime),
63
+ isYellow: s(t.isYellow),
64
+ isPunchOpen: s(t.isPunchOpen),
65
+ isacross: s(t.isacross),
66
+ pre: s(t.pre),
67
+ signSection: s(t.signSection),
68
+ signbelongspan: s(t.signbelongspan),
69
+ active: s(t.active),
70
+ needSign: s(t.needSign),
71
+ workmins: s(t.workmins),
72
+ signbelong: s(t.signbelong),
73
+ reSign: s(t.reSign),
74
+ min_next: s(t.min_next),
75
+ signfrom: s(t.signfrom),
76
+ serialid: s(t.serialid),
77
+ signAcross: s(t.signAcross),
78
+ signAcross_next: s(t.signAcross_next),
79
+ time: s(t.time),
80
+ signTime: s(t.signTime),
81
+ position: s(t.position),
82
+ isfirstsign: s(t.isfirstsign),
83
+ }));
84
+ return {
85
+ date: s(j.date),
86
+ now: s(j.now),
87
+ groupname: s(j.groupname),
88
+ lastname: s(j.lastname),
89
+ timeline,
90
+ raw: j,
91
+ };
92
+ }
93
+ /**
94
+ * 解析本次允许操作的打卡时段。
95
+ *
96
+ * getButtons 是当天考勤页的状态,不是历史补签查询接口。历史日期若在
97
+ * timeline 中不存在,绝不能回退到同卡种的当天记录,否则一次失败的补卡会
98
+ * 变成对今天记录的真实写入。
99
+ */
100
+ export function resolvePunchItem(buttons, opts) {
101
+ const targetDate = String(opts?.date ?? buttons?.date ?? '');
102
+ const type = String(opts?.type ?? '');
103
+ const currentDate = String(buttons?.date ?? '');
104
+ if (!currentDate)
105
+ throw new Error('考勤接口未返回当前日期,无法安全打卡');
106
+ if (targetDate !== currentDate) {
107
+ throw new Error(`当前考勤接口只支持当天(${currentDate})打卡;不能安全处理 ${targetDate || '未指定日期'} 的历史补卡,请在 OA 考勤补签流程办理`);
108
+ }
109
+ const item = (buttons?.timeline ?? []).find((t) => String(t.date) === targetDate && String(t.type) === type);
110
+ if (!item)
111
+ throw new Error(`未找到当天打卡时段(date=${targetDate}, type=${type}),已停止执行以避免误写其他记录`);
112
+ return { targetDate, item };
113
+ }
114
+ /**
115
+ * 打卡。链路:getButtons(取 timeline 班次参数)→ 生成 encryptObj → punchButton。
116
+ * - type=on 上班卡 / off 下班卡,按 date(默认今天)匹配 timeline 时段。
117
+ * - encryptObj = [Date.now(), userid, signSecret(userid+groupId)+"ver"](前端 signFunc.js 算法)。
118
+ * - signTime = 当前时刻 HH:mm:ss(或传入)。
119
+ * 服务端只校验 md5("wea"+uid+gid) 防篡改,不校验 timestamp/会话,token 鉴权可行。
120
+ */
121
+ export async function punch(cfg, opts) {
122
+ const api = await createApiFromProfile(cfg);
123
+ const buttons = await getButtons(cfg);
124
+ const { item } = resolvePunchItem(buttons, opts);
125
+ // userid:优先传入;否则从 config.cookie 的 loginuuids 解析(token-login 写回 cookie 含用户 id)
126
+ const userid = opts.userid ?? parseUserIdFromCookie(cfg.cookie) ?? '';
127
+ if (!userid) {
128
+ throw new Error('未取到 userid:punch 需 --userid,或 config.cookie 须含 loginuuids(token-login 会写回)');
129
+ }
130
+ const groupId = opts.groupId ?? '1';
131
+ // punchButton 的 signTime 是本次打卡时刻,不是修改既有打卡时间的参数。
132
+ // 始终现算,避免调用方把历史或任意时间写入当天记录。
133
+ const signTime = nowHHmmss();
134
+ const encryptObj = buildEncryptObj(userid, groupId, opts.timestamp);
135
+ const body = {
136
+ date: item.date,
137
+ belongtime: item.belongtime,
138
+ type: item.type,
139
+ canSignTime: item.canSignTime,
140
+ across: item.across,
141
+ belongdate: item.belongdate,
142
+ datetime: item.datetime,
143
+ min: item.min,
144
+ islastsign: item.islastsign,
145
+ signSectionTime: item.signSectionTime,
146
+ signSectionBeginTime: item.signSectionBeginTime ?? '',
147
+ isYellow: item.isYellow,
148
+ isPunchOpen: item.isPunchOpen,
149
+ isacross: item.isacross,
150
+ pre: item.pre ?? '0',
151
+ signSection: item.signSection,
152
+ signbelongspan: item.signbelongspan ?? '今天',
153
+ active: item.active ?? '0',
154
+ needSign: item.needSign ?? '0',
155
+ workmins: item.workmins,
156
+ signbelong: item.signbelong ?? '今天',
157
+ reSign: item.reSign ?? '1',
158
+ min_next: item.min_next,
159
+ signfrom: item.signfrom ?? 'e9pc',
160
+ serialid: item.serialid,
161
+ signAcross: item.signAcross,
162
+ signAcross_next: item.signAcross_next,
163
+ time: item.time,
164
+ position: '',
165
+ longitude: '',
166
+ latitude: '',
167
+ sid: '',
168
+ mac: '',
169
+ deviceId: '',
170
+ deviceInfo: '',
171
+ signTime,
172
+ encryptObj,
173
+ ...(item.isfirstsign !== undefined ? { isfirstsign: item.isfirstsign } : {}),
174
+ };
175
+ const r = await api.post('/api/hrm/kq/attendanceButton/punchButton', body);
176
+ const j = r.json || {};
177
+ return {
178
+ success: s(j.success),
179
+ message: s(j.message),
180
+ signtime: s(j.signtime),
181
+ signdate: s(j.signdate),
182
+ kqstatus: s(j.kqstatus),
183
+ status: s(j.status),
184
+ raw: j,
185
+ };
186
+ }
187
+ /** 本人考勤按网页“我的考勤”读取,不使用 Cookie 组合推断账号。 */
188
+ export async function getKqCurrentUser(cfg) {
189
+ const api = await createApiFromProfile(cfg);
190
+ const r = await api.get('/api/hrm/login/getAccountList', {}, { forceCookie: true });
191
+ const current = r.json?.data?.accountlist?.filter(a => String(a.iscurrent) === '1');
192
+ if (!Array.isArray(current) || current.length !== 1)
193
+ throw new Error('未能核验当前考勤账号,请重新登录;不能将鉴权失败视为零考勤');
194
+ return String(current[0].userid);
195
+ }
196
+ async function readMyAttendance(cfg, opts, method) {
197
+ const current = await getKqCurrentUser(cfg);
198
+ if (opts.resourceId && String(opts.resourceId) !== current)
199
+ throw new Error('当前仅验证了本人“我的考勤”接口;指定他人查询尚未验证,不返回本人结果代替');
200
+ const type = String(opts.type ?? '2');
201
+ if (type !== '2') throw new Error('本人考勤年视图尚未验证,请使用 --month 查询月视图');
202
+ const typevalue = String(opts.typevalue ?? defaultKqTypeValue('2'));
203
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(typevalue)) throw new Error('月份必须为YYYY-MM');
204
+ const api = await createApiFromProfile(cfg);
205
+ const r = await api.post('/api/kq/myattendance/' + method,
206
+ { loaddata: '1', type, typevalue }, { forceCookie: true });
207
+ if (String(r.json?.status) !== '1' || r.json?.api_status === false)
208
+ throw new Error('我的考勤读取失败或鉴权失效,不能归零:' + String(r.json?.msg ?? r.json?.message ?? '非预期响应'));
209
+ if (await getKqCurrentUser(cfg) !== current) throw new Error('查询期间账号发生变化,请重新查询');
210
+ return { j: r.json, type, typevalue, resourceId: current };
211
+ }
212
+ export function normalizeKqReport(j) {
213
+ const g1 = j.groupitem1;
214
+ if (!g1 || g1.value === undefined || !Array.isArray(g1.items) ||
215
+ !g1.items.some(x => x.id === 'leaveDays') || !g1.items.some(x => x.id === 'realWorkDays') ||
216
+ !Array.isArray(j.groupitem2?.items) || !Array.isArray(j.groupitem3?.items) ||
217
+ !Array.isArray(j.groupitem4?.items) || !Array.isArray(j.groupitem5?.items))
218
+ throw new Error('我的考勤统计结构不匹配,不能按零或全正常输出');
219
+ const flat = g => g.items.map(it => ({ id: s(it.id), title: s(it.title || it.name), value: s(it.value), type: s(it.type), unit: s(it.unit) }));
220
+ const attendance = flat(g1);
221
+ return {
222
+ attendance: { workDays: s(g1.value), unit: '天',
223
+ leaveDays: s(g1.items.find(x => x.id === 'leaveDays').value),
224
+ realWorkDays: s(g1.items.find(x => x.id === 'realWorkDays').value), items: attendance },
225
+ durations: flat(j.groupitem2), exceptions: flat(j.groupitem3),
226
+ leave: j.groupitem4.items.map(g => ({ title: s(g.title), unit: s(g.unit),
227
+ items: (g.item ?? []).filter(x => x && x.title).map(x => ({title: s(x.title), value: s(x.value), unit: s(x.unit)})) })),
228
+ leaveDurations: flat(j.groupitem5),
229
+ notes: j.groupitem5.items.length ? [] : ['服务器未返回请假明细'],
230
+ };
231
+ }
232
+ export async function getKqReport(cfg, opts = {}) {
233
+ const {j, ...meta} = await readMyAttendance(cfg, opts, 'getHrmKQReportInfo');
234
+ return {...meta, ...normalizeKqReport(j), status: j.status, raw: j};
235
+ }
236
+ export function normalizeKqDays(j, baseUrl = '') {
237
+ const res = j.result;
238
+ if (!res || typeof res !== 'object' || !Object.keys(res).length)
239
+ throw new Error('我的考勤月明细为空或结构不匹配,不能宣称无异常');
240
+ return Object.values(res).sort((a,b) => String(a.date).localeCompare(String(b.date))).map(d => {
241
+ if (!d.date || !Array.isArray(d.types) || (d.signInfo !== undefined && !Array.isArray(d.signInfo)) || (d.signInfo === undefined && d.isWorkDay !== false) || !Array.isArray(d.workflow))
242
+ throw new Error('我的考勤每日明细结构不匹配');
243
+ const pick = title => s((d.signInfo ?? []).find(x => x.title === title)?.signTime);
244
+ return {date: s(d.date), types: d.types, signInfo: d.signInfo ?? [],
245
+ signIn: pick('上班打卡'), signOut: pick('下班打卡'), isWorkDay: d.isWorkDay,
246
+ workflow: d.workflow.map(w => ({...w,
247
+ url: w.requestId && baseUrl ? buildRequestFormUrl(baseUrl, w.requestId) : undefined}))};
248
+ });
249
+ }
250
+ export async function getKqMonthReport(cfg, opts = {}) {
251
+ const {j, ...meta} = await readMyAttendance(cfg, {...opts, type: '2'}, 'getHrmKQMonthReportInfo');
252
+ return {...meta, days: normalizeKqDays(j, cfg.baseUrl), status: j.status, raw: j};
253
+ }
254
+
255
+ /** 默认 typevalue:type=1 → "YYYY",type=2 → "YYYY-MM"(本地时区)。 */
256
+ function defaultKqTypeValue(type) {
257
+ const d = new Date();
258
+ const p = (n) => String(n).padStart(2, '0');
259
+ return String(type) === '1' ? `${d.getFullYear()}` : `${d.getFullYear()}-${p(d.getMonth() + 1)}`;
260
+ }
261
+
262
+ // ───────────────── 工具 ─────────────────
263
+ function s(v) {
264
+ if (v === undefined || v === null)
265
+ return '';
266
+ return String(v);
267
+ }
268
+ /** 从 cookie 串解析 loginuuids(当前登录用户 id)。token-login 写回 cookie 含此字段。 */
269
+ function parseUserIdFromCookie(cookie) {
270
+ if (!cookie)
271
+ return '';
272
+ const m = cookie.match(/loginuuids=([^;]+)/);
273
+ return m ? m[1].trim() : '';
274
+ }
275
+ /** 当前时刻 HH:mm:ss(本地时区)。 */
276
+ function nowHHmmss() {
277
+ const d = new Date();
278
+ const p = (n) => String(n).padStart(2, '0');
279
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
280
+ }
@@ -0,0 +1,68 @@
1
+ import {parseFormula,evaluateFormula} from './formula-runtime.js';
2
+
3
+ export async function executeCalculations({api,form,tables,input,defs,getCells,write,seen,evidence,step}) {
4
+ const cfg=form.linkageCfg??{};let changed=false;
5
+ const read=(ref,row)=>{const [table,id]=ref.split('.');const v=getCells(table,table==='main'?-1:row)?.['field'+id]?.value??'';return v;};
6
+ for(const [trigger,rules]of Object.entries(cfg.rowCalCfg??{})) {
7
+ if(trigger==='rowCalAssignInfo')continue;
8
+ if(!Array.isArray(rules))throw Error('行规则格式未知');
9
+ for(const rule of rules){const d=defs.find(x=>x.id===String(rule.assignField));if(!d||d.table==='main')throw Error('行规则目标不是明细字段');
10
+ const expression=String(rule.config).replace(/detailfield_(\d+)/g,(_,id)=>`[${d.table}.${id}]`),tree=parseFormula(expression);
11
+ for(let row=0;row<(tables[d.table]?.rows.length??0);row++){
12
+ const value=await evaluateFormula(tree,ref=>read(ref,row));
13
+ changed=write(d.table,row,d.id,{value},'rowCal')||changed;
14
+ evidence.coverage.push({kind:'rowCal',config:d.id,row,state:'executed'});
15
+ }
16
+ }
17
+ }
18
+ for(const id of new Set([...Object.keys(cfg.mainCalCfg??{}),...Object.keys(cfg.colCalCfg??{})])) {
19
+ const d=defs.find(x=>x.id===id);if(!d||d.table==='main'||!tables[d.table])throw Error('列合计字段未知');
20
+ const rows=tables[d.table].rows;
21
+ const sum=rows.reduce((total,row)=>{const n=Number(row['field'+id]?.value??0);if(!Number.isFinite(n))throw Error('列合计包含非数字');return total+n;},0);
22
+ if(cfg.colCalCfg?.[id]){if(cfg.colCalCfg[id]!=='needsum')throw Error('未知列合计规则');(evidence.columnSums??={})[id]=sum;evidence.coverage.push({kind:'colCal',config:id,state:'executed'});}
23
+ const target=cfg.mainCalCfg?.[id];
24
+ if(target){const dest=defs.find(x=>x.id===String(target));if(!dest||dest.table!=='main')throw Error('明细合计赋值主表目标未知');
25
+ const value=!rows.length&&String(form.propFileCfg?.colRule_noRow_empty)!=='1'?'':sum;
26
+ changed=write('main',-1,dest.id,{value},'mainCal')||changed;evidence.coverage.push({kind:'mainCal',config:id,target:String(target),state:'executed'});
27
+ }
28
+ }
29
+ for(const [trigger,rules]of Object.entries(cfg.fieldDateCfg??{}))for(const rule of rules){
30
+ const d=defs.find(x=>x.id===String(rule.assignField));
31
+ if(!d&&/^\d+$/.test(String(rule.assignField))&&form.cellInfo?.fieldCellInfo&&!Object.hasOwn(form.cellInfo.fieldCellInfo,String(rule.assignField))){evidence.coverage.push({kind:'fieldDate',config:String(rule.config),target:String(rule.assignField),state:'not-in-layout'});continue;}
32
+ if(!d)throw Error('日期规则目标未知');
33
+ for(const row of d.table==='main'?[-1]:(tables[d.table]?.rows??[]).map((_,i)=>i)){
34
+ const values={};for(const id of rule.relateFields??[]){const dep=defs.find(x=>x.id===String(id));if(!dep)throw Error('日期依赖不存在');values['field'+id+(dep.table==='main'?'':'_'+row)]=getCells(dep.table,dep.table==='main'?-1:row)?.['field'+id]?.value??'';}
35
+ const key='date:'+rule.config+':'+row,signature=JSON.stringify(values);if(seen.get(key)===signature)continue;seen.set(key,signature);step();
36
+ const p=form.params;
37
+ const body={...values,requestid:String(input.requestId??'-1'),workflowid:String(input.workflowId),nodeid:String(p.nodeid),formid:String(p.formid),isbill:String(p.isbill??'1'),triSource:'1',showAI:'0',linkageid:String(rule.config),linkageUUID:form.submitParams?.linkageUUID??'',
38
+ ['triFieldid_'+rule.config]:trigger,['rowIndexStr_'+rule.config]:String(row),['triTableMark_'+rule.config]:d.table,f_weaver_belongto_userid:input.accountId,f_weaver_belongto_usertype:String(p.f_weaver_belongto_usertype??'0')};
39
+ const r=await api.post('/api/workflow/linkage/reqDateTimeResult',body,{forceCookie:true}),info=r.json?.['assignInfo_'+rule.config];
40
+ if(!info||info.error||info.resultOutOfLimit||r.json?.status===false)throw Error('日期联动失败:'+rule.config);
41
+ for(const k of Object.keys(info))if(!['changeValue','resultOutOfLimit','linkageSql'].includes(k))throw Error('日期联动响应尚未支持:'+k);
42
+ for(const [key,value]of Object.entries(info.changeValue??{})){
43
+ const m=key.match(/^field(\d+)(?:_(\d+))?$/),dest=m&&defs.find(x=>x.id===m[1]);if(!dest)throw Error('日期联动返回未知字段');
44
+ changed=write(dest.table,m[2]?Number(m[2]):dest.table==='main'?-1:row,dest.id,value,'date')||changed;
45
+ }
46
+ evidence.coverage.push({kind:'fieldDate',config:String(rule.config),row,state:'executed'});
47
+ }
48
+ }
49
+ evidence.viewAttributes={};
50
+ for(const [ref,rules]of Object.entries(cfg.viewAttrCfg??{})){
51
+ const id=ref.split('_')[0],d=defs.find(x=>x.id===id);if(!d||!Array.isArray(rules.relateFields))throw Error('属性联动配置未知');
52
+ for(const row of d.table==='main'?[-1]:(tables[d.table]?.rows??[]).map((_,i)=>i)){
53
+ const value=String(read(d.table+'.'+id,row)),assign={};
54
+ for(const [condition,targets]of Object.entries(rules))if(condition!=='relateFields'){
55
+ const [op,expected]=condition.split('|');if(expected===undefined||!['0','1','2','3','4','5'].includes(op))throw Error('属性联动条件未知');
56
+ const match=op==='0'?expected.split(',').includes(value):op==='1'?!expected.split(',').includes(value):op==='2'?(','+value+',').includes(','+expected+','):op==='3'?!(','+value+',').includes(','+expected+','):op==='4'?value==='1':value!=='1';
57
+ if(match)Object.assign(assign,targets);
58
+ }
59
+ for(const target of rules.relateFields){
60
+ const tid=target.split('_')[0],dest=defs.find(x=>x.id===tid);if(!dest)throw Error('属性联动目标未知');
61
+ const code=assign[target],attr=code===undefined?Number(dest.viewattr):({'1':2,'2':3,'3':1,'4':Number(dest.viewattr),'5':4})[String(code)];
62
+ if(attr===undefined)throw Error('属性联动属性未知');evidence.viewAttributes[dest.table+':'+(dest.table==='main'?-1:row)+':'+tid]=attr;
63
+ }
64
+ evidence.coverage.push({kind:'viewAttr',config:ref,row,state:'executed'});
65
+ }
66
+ }
67
+ return changed;
68
+ }
package/dist/links.js ADDED
@@ -0,0 +1,34 @@
1
+ function baseUrl(base) {
2
+ return String(base ?? '').replace(/\/+$/, '');
3
+ }
4
+
5
+ export function buildBlogMyUrl(base) {
6
+ return `${baseUrl(base)}/wui/index.html#/main/blog/myBlog?menuIds=392,393&menuPathIds=392,393`;
7
+ }
8
+
9
+ export function buildCubeCardUrl(base, { modeId, formId, billid, customid }) {
10
+ if ([modeId, formId, billid, customid].some((value) => value === undefined || value === null || value === ''))
11
+ return undefined;
12
+ return `${baseUrl(base)}/spa/cube/index.html#/main/cube/card?type=0&modeId=${modeId}&formId=${formId}&billid=${billid}&opentype=0&customid=${customid}&viewfrom=fromsearchlist`;
13
+ }
14
+
15
+ export function buildCubeSearchUrl(base, customid) {
16
+ if (customid === undefined || customid === null || customid === '')
17
+ return undefined;
18
+ return `${baseUrl(base)}/spa/cube/index.html#/main/cube/search?customid=${encodeURIComponent(String(customid))}`;
19
+ }
20
+
21
+ /** 把 BDIP5 菜单返回的相对地址补成可点击的绝对地址。 */
22
+ export function normalizeE9Url(base, value) {
23
+ const raw = String(value ?? '').trim();
24
+ if (!raw)
25
+ return undefined;
26
+ if (/^https?:\/\//i.test(raw))
27
+ return raw;
28
+ const b = baseUrl(base);
29
+ if (raw.startsWith('#/'))
30
+ return `${b}/spa/cube/index.html${raw}`;
31
+ if (raw.startsWith('/main/cube/'))
32
+ return `${b}/spa/cube/index.html#${raw}`;
33
+ return `${b}/${raw.replace(/^\/+/, '')}`;
34
+ }
@@ -0,0 +1,172 @@
1
+ import fs from 'node:fs';
2
+
3
+ const clone = v => JSON.parse(JSON.stringify(v));
4
+ const same = (a,b) => JSON.stringify(a) === JSON.stringify(b);
5
+ const text = v => v == null ? '' : String(v);
6
+ const minute = v => text(v).slice(0,16);
7
+ const rowKeys = ['coding','subject','hrmid01','hrmid02','begindate','enddate'];
8
+ const links = ['decisiontskids','decisiondoc','decisionprjids','decisionwfids','decisioncrmids','decisionatchids'];
9
+ async function call(api, route, body={}) {
10
+ const r=await api.post(route.startsWith('/api/')?route:'/api/meeting/'+route,body,{forceCookie:true});
11
+ const j=r?.json;
12
+ if(r.status!==200 || !j || j.status===false || j.api_status===false || j.retstatus===false || j.ret==='false') throw Error('会议接口失败:'+route);
13
+ return j;
14
+ }
15
+ function walk(v, fn) {
16
+ if(!v || typeof v!=='object')return;
17
+ if(Array.isArray(v)){v.forEach(x=>walk(x,fn));return;}
18
+ fn(v); Object.values(v).forEach(x=>{if(x&&typeof x==='object')walk(x,fn);});
19
+ }
20
+ export async function fields(api) {
21
+ const meta=await call(api,'field/getMeetingBaseFieldMobx');
22
+ if(String(meta.onlyFlowCreate)==='1' || String(meta.hideCreateEntry)==='1')throw Error('平台限制直接新建会议,请按平台指定入口办理');
23
+ const defaults={},schema={};
24
+ walk(meta.datas,f=>{if(!Array.isArray(f.domkey))return;f.domkey.forEach((k,i)=>{
25
+ const value=Array.isArray(f.value)?f.value[i]:f.value;
26
+ schema[k]={viewAttr:f.viewAttr};
27
+ if(value!=null && typeof value!=='object')defaults[k]=text(value);
28
+ });});
29
+ if(!schema.name)throw Error('未取得会议表单结构');
30
+ return {meta,defaults,schema};
31
+ }
32
+ export async function view(api,id) {
33
+ if(!/^\d+$/.test(text(id)))throw Error('需提供合法会议ID');
34
+ const body={meetingid:text(id)};
35
+ const base=await call(api,'base/getMeetingBaseInfo',body);
36
+ if(!base.data)throw Error('会议不存在或无查看权限');
37
+ const display=await call(api,'field/getViewMeetingField',body);
38
+ const decision=await call(api,'decision/getDecisionData',body);
39
+ if(!Array.isArray(decision.datas))throw Error('决议数据不完整');
40
+ const key=display.decision?.list?.sessionkey,tasks=[];
41
+ if(key){
42
+ const seen=new Set();
43
+ for(let page=1;;page++){
44
+ const j=await call(api,'/api/ec/dev/table/datas',{dataKey:key,current:text(page),pageSize:'50'});
45
+ if(!Array.isArray(j.datas))throw Error('任务分页数据异常');
46
+ if(!j.datas.length)break;
47
+ const signature=JSON.stringify(j.datas);
48
+ if(seen.has(signature)||page>10000)throw Error('任务分页未完成,不能报告完整数量');
49
+ seen.add(signature);tasks.push(...j.datas);
50
+ }
51
+ }else if(decision.datas.length)throw Error('未取得会议任务查询入口');
52
+ return {meetingid:text(id),base:base.data,display,decision,tasks,taskCount:tasks.length};
53
+ }
54
+ function snapshot(v) {
55
+ return {base:Object.fromEntries(['meetingname','begindate','enddate','begintime','endtime','isdecision'].map(k=>[k,v.base[k]])),decision:{decision:v.decision.decision,datas:v.decision.datas.map(r=>Object.fromEntries(rowKeys.map(k=>[k,text(r[k])]))),...Object.fromEntries(links.map(k=>[k,v.decision[k]??[]]))}};
56
+ }
57
+ function validateRows(rows) {
58
+ if(!Array.isArray(rows))throw Error('tasks必须是完整任务数组');
59
+ for(const r of rows){
60
+ for(const k of rowKeys)if(!text(r[k]).trim())throw Error('决议任务缺少'+k+';执行人、检查人未指定时先询问用户');
61
+ for(const k of ['hrmid01','hrmid02'])if(!/^\d+$/.test(text(r[k])))throw Error('执行人、检查人必须为已核实的账号ID');
62
+ for(const k of ['begindate','enddate'])if(!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(r[k]))throw Error('任务时间格式为YYYY-MM-DD HH:mm');
63
+ if(r.begindate>r.enddate)throw Error('任务开始时间晚于截止时间');
64
+ }
65
+ }
66
+ function decisionBody(id,d) {
67
+ const body={meetingid:text(id),decision:d.decision,decisionrows:text(d.datas.length)};
68
+ for(const k of links){
69
+ const values=d[k]??[];
70
+ if(!Array.isArray(values))throw Error('关联数据格式不支持:'+k);
71
+ body[k]=values.map(x=>{if(typeof x!=='object')return text(x);if(x?.id!=null)return text(x.id);throw Error('无法保留关联项:'+k);}).join(',');
72
+ }
73
+ d.datas.forEach((r,i)=>rowKeys.forEach(k=>{body[k+'_'+(i+1)]=text(r[k]);}));
74
+ return body;
75
+ }
76
+ async function conflicts(api,body) {
77
+ const custom=await call(api,'base/customMeetingChk',body);
78
+ const check=await call(api,'base/chkMeeting',body);
79
+ if(custom.chkstatus!==true || check.attribute?.cansub!==true || ['address','member','wpMember','service'].some(k=>check[k]?.chkstatus!==true))throw Error('会议冲突检查未通过,请核实时间、人员、会议室及服务');
80
+ }
81
+ function verifyCreate(v,body) {
82
+ for(const k of ['begindate','enddate','begintime','endtime'])if(text(v.base[k])!==body[k])throw Error('会议时间回读不一致:'+k);
83
+ if(v.base.meetingname!==body.name)throw Error('会议名称回读不一致');
84
+ const display={};walk(v.display.base,f=>{if(f.fieldname)display[f.fieldname]=f.fieldValue;});
85
+ for(const k of Object.keys(body)){
86
+ if(['name','begindate','enddate','begintime','endtime','method','isInterval','serviceitems','totalmember'].includes(k))continue;
87
+ if(k==='hrmmembers'){
88
+ if(!Array.isArray(display[k]) || !same(display[k].map(p=>text(p.id)).sort(),body[k].split(',').filter(Boolean).sort()))throw Error('参会人回读不一致');
89
+ }else if(k in display && typeof display[k]!=='object' && text(display[k])!==text(body[k]))throw Error('会议字段回读不一致:'+k);
90
+ }
91
+ if(text(v.base.address||'')!==text(body.address||''))throw Error('会议室回读不一致');
92
+ for(const k of ['desc_n','customizeAddress'])if(body[k] && text(display[k])!==body[k])throw Error('会议字段未完整回读:'+k);
93
+ }
94
+ function verifyDecision(v,plan) {
95
+ const expected=plan.expected;
96
+ if(text(v.base.isdecision)!==(plan.action==='decision-submit'?'2':'1') || v.decision.decision!==expected.decision)throw Error('决议正文或状态回读不一致');
97
+ if(v.decision.datas.length!==expected.datas.length || v.tasks.length!==expected.datas.length)throw Error('决议任务数量回读不一致');
98
+ expected.datas.forEach((r,i)=>{
99
+ for(const k of rowKeys)if(text(v.decision.datas[i][k])!==text(r[k]))throw Error('决议任务回读不一致:'+k);
100
+ const t=v.tasks[i];
101
+ for(const k of ['coding','subject','hrmid01','hrmid02'])if(text(t[k])!==text(r[k]))throw Error('会议任务回读不一致:'+k);
102
+ if(minute(t.begint??t.begindatespan)!==r.begindate || minute(t.endt??t.enddatespan)!==r.enddate)throw Error('会议任务时间回读不一致');
103
+ if(plan.action==='decision-submit' && (!t.id || text(t.meetingid)!==plan.meetingid))throw Error('已提交任务缺少会议关联');
104
+ });
105
+ for(const k of links)if(!same(v.decision[k]??[],expected[k]??[]))throw Error('决议关联项回读不一致:'+k);
106
+ }
107
+ export async function meetingMain(api,args,accountId,baseUrl) {
108
+ const [command,...rest]=args;
109
+ if(command==='fields')return fields(api);
110
+ if(command==='view'||command==='tasks'){const v=await view(api,rest[0]);return command==='tasks'?{meetingid:v.meetingid,count:v.taskCount,items:v.tasks}:v;}
111
+ if(command==='preview'){
112
+ const [action,inputFile,planFile]=rest;
113
+ if(!['create','decision-save','decision-submit'].includes(action)||!inputFile||!planFile)throw Error('用法:meeting preview <create|decision-save|decision-submit> <输入JSON> <新计划JSON>');
114
+ const input=JSON.parse(fs.readFileSync(inputFile,'utf8'));
115
+ const plan={action,accountId,baseUrl};
116
+ if(action==='create'){
117
+ const f=await fields(api);const values=input.values;
118
+ if(!values||typeof values!=='object'||Array.isArray(values))throw Error('缺少values');
119
+ for(const k of Object.keys(values)){
120
+ if(!['name','begindate','begintime','enddate','endtime','address','customizeAddress','hrmmembers','desc_n'].includes(k))throw Error('当前已验证的新建输入不支持:'+k+';其他字段保留平台默认值');
121
+ if(!f.schema[k] || typeof values[k]==='object')throw Error('不支持的会议字段:'+k);
122
+ if(String(f.schema[k].viewAttr)==='1' && text(values[k])!==text(f.defaults[k]))throw Error('只读会议字段不能覆盖:'+k);
123
+ }
124
+ const body={...f.defaults,...Object.fromEntries(Object.entries(values).map(([k,v])=>[k,text(v)])),method:'',isInterval:'',serviceitems:''};
125
+ for(const [k,s] of Object.entries(f.schema))if(String(s.viewAttr)==='3'&&!body[k])throw Error('会议必填字段缺少:'+k);
126
+ if(!/^\d+(,\d+)*$/.test(body.hrmmembers??''))throw Error('需核实参会人ID');
127
+ body.totalmember=text(new Set(body.hrmmembers.split(',')).size);
128
+ if(!body.address&&!body.customizeAddress && input.noRoom!==true)throw Error('未选择地点,请明确noRoom:true表示不预约会议室');
129
+ if(!/^\d{4}-\d{2}-\d{2}$/.test(body.begindate)||!/^\d{4}-\d{2}-\d{2}$/.test(body.enddate)||!/^\d{2}:\d{2}$/.test(body.begintime)||!/^\d{2}:\d{2}$/.test(body.endtime)||body.begindate+' '+body.begintime>body.enddate+' '+body.endtime)throw Error('会议日期时间不合法');
130
+ await conflicts(api,body);Object.assign(plan,{body,schema:f.schema});
131
+ }else{
132
+ const v=await view(api,input.meetingid);
133
+ if(text(v.base.isdecision)==='2')throw Error('决议已经提交,停止重复保存或提交');
134
+ const d=clone(v.decision);
135
+ if(Object.keys(input).some(k=>!['meetingid','decision','tasks'].includes(k)))throw Error('决议输入仅支持meetingid、decision、tasks');
136
+ if(input.decision!==undefined){if(typeof input.decision!=='string')throw Error('决议正文需为字符串');d.decision=input.decision;}
137
+ if(input.tasks!==undefined)d.datas=input.tasks;
138
+ validateRows(d.datas);
139
+ if(action==='decision-submit' && (text(v.base.isdecision)!=='1'||!same(d,v.decision)))throw Error('正式提交前必须保存并回读,重新按已保存内容预览');
140
+ Object.assign(plan,{meetingid:v.meetingid,baseline:snapshot(v),expected:d,body:decisionBody(v.meetingid,d)});
141
+ }
142
+ fs.writeFileSync(planFile,JSON.stringify(plan,null,2),{flag:'wx'});
143
+ return {state:'preview-only',...plan};
144
+ }
145
+ if(command!=='run'&&command!=='verify')throw Error('会议命令:fields | view <ID> | tasks <ID> | preview <动作> <输入JSON> <新计划JSON> | run <计划JSON> --confirm | verify <计划JSON>');
146
+ const planFile=rest[0],receiptFile=planFile+'.receipt.json';
147
+ const plan=JSON.parse(fs.readFileSync(planFile,'utf8'));
148
+ if(plan.accountId!==accountId||plan.baseUrl!==baseUrl)throw Error('会议计划环境或账号已变化,请重新预览');
149
+ if(!['create','decision-save','decision-submit'].includes(plan.action))throw Error('未知会议动作');
150
+ let receipt=fs.existsSync(receiptFile)?JSON.parse(fs.readFileSync(receiptFile,'utf8')):null;
151
+ if(command==='run'){
152
+ if(!rest.includes('--confirm'))throw Error('请先展示预览,取得用户确认后使用--confirm');
153
+ if(receipt)throw Error('已有执行回执,禁止重复写入,请verify核验');
154
+ if(plan.action==='create'){
155
+ const f=await fields(api);if(!same(f.schema,plan.schema))throw Error('会议表单结构变化,请重新预览');
156
+ await conflicts(api,plan.body);
157
+ }else if(!same(snapshot(await view(api,plan.meetingid)),plan.baseline))throw Error('决议已变化,请重新预览');
158
+ receipt={state:'uncertain',action:plan.action,meetingid:plan.meetingid};
159
+ fs.writeFileSync(receiptFile,JSON.stringify(receipt,null,2),{flag:'wx'});
160
+ const result=await call(api,plan.action==='create'?'base/newMeeting':plan.action==='decision-save'?'decision/edit':'decision/submit',plan.body);
161
+ if(plan.action==='create')receipt.meetingid=text(result.meetingid);
162
+ fs.writeFileSync(receiptFile,JSON.stringify(receipt,null,2));
163
+ if(plan.action==='create'?![true,'true'].includes(result.retstatus):result.status!==true)throw Error('写入未确认,请核验,禁止自动重试');
164
+ }
165
+ if(!receipt?.meetingid)throw Error('缺少会议ID,结果不明,请人工核查,不自动重建');
166
+ receipt.state='verification-required';
167
+ fs.writeFileSync(receiptFile,JSON.stringify(receipt,null,2));
168
+ const v=await view(api,receipt.meetingid);
169
+ if(plan.action==='create')verifyCreate(v,plan.body);else verifyDecision(v,plan);
170
+ receipt.state='verified';receipt.taskCount=v.taskCount;receipt.isdecision=v.base.isdecision;
171
+ fs.writeFileSync(receiptFile,JSON.stringify(receipt,null,2));return receipt;
172
+ }