weaver-work-cli 0.1.33 → 0.1.36
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/chunks/{archive-FVFY6GZN.js → archive-XBVH7JU7.js} +2 -2
- package/dist/chunks/{archive-oa-NW3OD4UV.js → archive-oa-MZM3EKFQ.js} +2 -2
- package/dist/chunks/{asset-7QJKMIDV.js → asset-4XO7UOUW.js} +2 -2
- package/dist/chunks/{attachment-content-OSAVA6K3.js → attachment-content-VXSWANAC.js} +3 -3
- package/dist/chunks/{blog-I7SDPNW4.js → blog-PGZKOPSC.js} +2 -2
- package/dist/chunks/{caizhilian-O37ZIQNY.js → caizhilian-BH4ZVGUN.js} +2 -2
- package/dist/chunks/{calendar-7KXE4ED3.js → calendar-MXEMTIO4.js} +2 -2
- package/dist/chunks/{chunk-VBCFFFGT.js → chunk-5DQEEGCC.js} +4 -1
- package/dist/chunks/{chunk-KAS45KNR.js → chunk-6LLJFKJE.js} +1 -1
- package/dist/chunks/{chunk-HEL5XTYI.js → chunk-BPE37FPB.js} +26 -4
- package/dist/chunks/{chunk-IHUYP6LO.js → chunk-F746NKNF.js} +1 -1
- package/dist/chunks/{chunk-MQ2I5AA3.js → chunk-HXJQI5NM.js} +2 -2
- package/dist/chunks/{chunk-ILMBNVSG.js → chunk-I6QGIBDM.js} +2 -2
- package/dist/chunks/{chunk-EEWPQE3O.js → chunk-JCSN3JGM.js} +2 -2
- package/dist/chunks/{chunk-D223PTXU.js → chunk-JNG42JKG.js} +29 -1
- package/dist/chunks/{chunk-6ATGWRZM.js → chunk-JUXVYKII.js} +4 -1
- package/dist/chunks/{chunk-GFPXQIY2.js → chunk-JYKABYCV.js} +2 -2
- package/dist/chunks/{chunk-Q6LWB5HX.js → chunk-NCOVUAZK.js} +2 -2
- package/dist/chunks/{chunk-WY74UBJ6.js → chunk-QHFANTX7.js} +900 -204
- package/dist/chunks/{chunk-WRRJ4GG2.js → chunk-QYGO5EE2.js} +2 -2
- package/dist/chunks/{chunk-RBGOMK4Y.js → chunk-T72N74ZT.js} +2 -2
- package/dist/chunks/{chunk-MEEFGWTM.js → chunk-TC6MZAO3.js} +84 -9
- package/dist/chunks/{chunk-OCU4RLHC.js → chunk-VPUTVEMC.js} +98 -34
- package/dist/chunks/{chunk-PRHJGPMA.js → chunk-VPXOPQJI.js} +2 -2
- package/dist/chunks/{chunk-NHA62QD7.js → chunk-WXFPWCXG.js} +3 -3
- package/dist/chunks/{chunk-2FLWXFVS.js → chunk-XMGSYD6S.js} +4 -4
- package/dist/chunks/{conflict-SFNEVJY5.js → conflict-JAB2LJEZ.js} +2 -2
- package/dist/chunks/contract-XI2EEIZU.js +9110 -0
- package/dist/chunks/{cowork-CKWYHXYB.js → cowork-SCPGLBA4.js} +44 -25
- package/dist/chunks/{ebuilder-form-KOLJRVZD.js → ebuilder-form-EYLHNOTA.js} +3 -3
- package/dist/chunks/{ehr-WFOC3B4D.js → ehr-EAP77IMT.js} +172 -9
- package/dist/chunks/{esb-LJZTN663.js → esb-2TGGXEE7.js} +4 -4
- package/dist/chunks/{fna-F7QRIIZC.js → fna-2X7ZE7BU.js} +13 -8
- package/dist/chunks/{host-VQ6JJXGQ.js → host-C6EL2RKL.js} +3 -3
- package/dist/chunks/{host-BCGUVEWX.js → host-I4NRK4MX.js} +3 -3
- package/dist/chunks/{host-DGE2TZD4.js → host-Q4LZGCRU.js} +3 -3
- package/dist/chunks/{host-BQBRHVR3.js → host-UEI5FGDM.js} +3 -3
- package/dist/chunks/{host-KXPTEWFS.js → host-XCLDDXKQ.js} +3 -3
- package/dist/chunks/{hrm-NI6XAKW5.js → hrm-YXERJM76.js} +3 -3
- package/dist/chunks/{im-7TRTF3KT.js → im-A4VZN2TQ.js} +3 -3
- package/dist/chunks/{invoice-AOXWJKYT.js → invoice-2Z6YPMIS.js} +3 -3
- package/dist/chunks/{jiuchuanhui-7NQWEZAW.js → jiuchuanhui-AR5GMKZB.js} +2 -2
- package/dist/chunks/{lazy-dispatch-RNS4EE4S.js → lazy-dispatch-QXW7P4X3.js} +4 -4
- package/dist/chunks/{mail-VDHJAB4O.js → mail-7GZ6CPBJ.js} +44 -23
- package/dist/chunks/{meeting-GA7XCNNP.js → meeting-3NMGA3MB.js} +74 -10
- package/dist/chunks/{okr-TQIRERPQ.js → okr-BZ6XUOAG.js} +2 -2
- package/dist/chunks/{plan-4RY4LN2P.js → plan-DR5CMUYP.js} +2 -2
- package/dist/chunks/{project-D6XT2OC5.js → project-6J63MK33.js} +2 -2
- package/dist/chunks/{registry-JHRIDQ3A.js → registry-KDD7DHNK.js} +4 -4
- package/dist/chunks/{registry-VOGKFFTR.js → registry-TKX2E7C7.js} +2 -2
- package/dist/chunks/{registry-QKMI43Q6.js → registry-VRTNC6UR.js} +8 -8
- package/dist/chunks/{room-recommend-D4EUOOWY.js → room-recommend-XCWRMB5P.js} +4 -4
- package/dist/chunks/{schedule-XPFBHPCV.js → schedule-IVBICP32.js} +4 -4
- package/dist/chunks/{workflow-HLBZRWIY.js → workflow-D74NFK3C.js} +11 -4
- package/dist/index.js +31 -26
- package/dist/internal/e10/fetch-timing.js +31 -0
- package/dist/internal/e10/request-runtime.js +5 -0
- package/dist/shortcuts/cowork/host.js +8 -0
- package/dist/shortcuts/cowork/operations/types.js +17 -2
- package/dist/shortcuts/ehr/operations/recruit-write.js +35 -0
- package/dist/shortcuts/ehr/operations/recruit.js +147 -0
- package/dist/shortcuts/fna/operations/expense-chart.js +19 -7
- package/dist/shortcuts/index.js +5 -0
- package/dist/shortcuts/mail/host.js +8 -0
- package/dist/shortcuts/mail/operations/types.js +17 -2
- package/dist/shortcuts/meeting/index.js +79 -1
- package/dist/shortcuts/meeting/manifest.js +10 -0
- package/dist/shortcuts/meeting/operations/eb/catalog.js +5 -0
- package/dist/shortcuts/meeting/operations/eb/conflict.js +6 -1
- package/dist/shortcuts/meeting/operations/eb/create-flow.js +1047 -134
- package/dist/shortcuts/meeting/operations/eb/factory.js +101 -7
- package/dist/shortcuts/meeting/operations/eb/room-recommend.js +36 -2
- package/dist/shortcuts/meeting/operations/eb-fallback.js +4 -0
- package/dist/shortcuts/meeting/operations/env.js +6 -0
- package/dist/shortcuts/meeting/operations/invitation.js +79 -20
- package/dist/shortcuts/workflow/endpoints.js +8 -3
- package/dist/shortcuts/workflow/operations/form.js +181 -34
- package/dist/shortcuts/workflow/operations.js +23 -1
- package/docs/ehr.md +1 -1
- package/docs/fna.md +2 -1
- package/package.json +1 -1
- package/skill-template/business-info.json +18 -0
- package/skill-template/domains/cowork.md +14 -0
- package/skills/weaver-e10-cowork/SKILL.md +101 -0
- package/skills/weaver-e10-cowork/references/cowork-read.md +90 -0
- package/skills/weaver-e10-cowork/references/cowork-write.md +131 -0
- package/skills/weaver-e10-cowork/references/safety-boundaries.md +45 -0
- package/skills/weaver-e10-jucailin/SKILL.md +21 -3
- package/skills/weaver-e10-jucailin/references/recruit-read.md +25 -0
- package/skills/weaver-e10-jucailin/references/recruit-write.md +13 -0
- package/skills/weaver-e10-jucailin/references/salary.md +15 -11
- package/skills/weaver-e10-mail/SKILL.md +12 -9
- package/skills/weaver-e10-meeting/SKILL.md +284 -34
- package/skills/weaver-e10-meeting/references/meeting-eb.md +131 -0
- package/skills/weaver-e10-meeting/references/meeting-invitation.md +28 -2
- package/skills/weaver-e10-meeting.zip +0 -0
- package/skills/weaver-e10-qiyecheng/SKILL.md +5 -2
- package/skills/weaver-e10-qiyecheng/references/catalog.md +2 -2
- package/skills/weaver-e10-qiyecheng/references/expense-chart.md +15 -1
- package/skills/weaver-e10-qiyecheng/references/source-manifest.json +22 -21
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
1
5
|
import { meetingSuccess, schemaOf, SCHEMA_DATETIME_MINUTE } from '../types.js';
|
|
2
6
|
import { meetingValidationError } from '../../errors.js';
|
|
3
7
|
import { fetchEbAppSettings, findCommonFreeSlots } from './conflict.js';
|
|
4
|
-
import { recommendRooms } from './room-recommend.js';
|
|
8
|
+
import { loadRooms, recommendRooms } from './room-recommend.js';
|
|
5
9
|
/**
|
|
6
10
|
* 通过**流程**新建 EB 会议(`meeting.eb.meeting.create.flow`)。
|
|
7
11
|
*
|
|
@@ -132,12 +136,17 @@ async function resolveRoomValue(env, input) {
|
|
|
132
136
|
async function shapeBrowserValues(env, values) {
|
|
133
137
|
const notes = [];
|
|
134
138
|
const shaped = { ...values };
|
|
139
|
+
const readable = {};
|
|
135
140
|
if (shaped.address !== undefined && shaped.address !== '') {
|
|
136
141
|
const room = await resolveRoomValue(env, String(shaped.address));
|
|
137
142
|
if (room) {
|
|
138
143
|
shaped.address = room;
|
|
144
|
+
readable.address = room.name;
|
|
139
145
|
notes.push(`会议地点按浏览按钮格式提交:${room.name}(${room.id})`);
|
|
140
146
|
}
|
|
147
|
+
else {
|
|
148
|
+
readable.address = String(shaped.address);
|
|
149
|
+
}
|
|
141
150
|
}
|
|
142
151
|
const personFields = ['hrm_members', 'caller', 'contacter'];
|
|
143
152
|
const idPool = [];
|
|
@@ -151,43 +160,150 @@ async function shapeBrowserValues(env, values) {
|
|
|
151
160
|
if (idPool.length > 0) {
|
|
152
161
|
const names = await resolveEmployeeNames(env, idPool);
|
|
153
162
|
const toObject = (id) => ({ id, name: names.get(id) ?? id });
|
|
163
|
+
const toName = (id) => names.get(id) ?? id;
|
|
154
164
|
if (shaped.hrm_members !== undefined && shaped.hrm_members !== '') {
|
|
155
165
|
const ids = String(shaped.hrm_members).split(/[,;,;\s]+/).map((item) => item.trim()).filter((item) => item !== '');
|
|
156
166
|
shaped.hrm_members = ids.map(toObject);
|
|
167
|
+
readable.hrm_members = ids.map(toName).join('、');
|
|
157
168
|
notes.push(`参会人员按浏览按钮格式提交:${ids.length} 人`);
|
|
158
169
|
}
|
|
159
170
|
if (shaped.caller !== undefined && shaped.caller !== '') {
|
|
160
171
|
const id = String(shaped.caller).trim();
|
|
161
172
|
shaped.caller = toObject(id);
|
|
173
|
+
readable.caller = toName(id);
|
|
162
174
|
}
|
|
163
175
|
if (shaped.contacter !== undefined && shaped.contacter !== '') {
|
|
164
176
|
const id = String(shaped.contacter).trim();
|
|
165
177
|
shaped.contacter = toObject(id);
|
|
178
|
+
readable.contacter = toName(id);
|
|
166
179
|
}
|
|
167
180
|
}
|
|
168
|
-
|
|
181
|
+
// 其余标量字段(会议名称/开始时间/结束时间/会议内容/人数…)原样给邀请函用
|
|
182
|
+
for (const [key, raw] of Object.entries(values)) {
|
|
183
|
+
if (readable[key] !== undefined)
|
|
184
|
+
continue;
|
|
185
|
+
if (raw === undefined || raw === null || raw === '')
|
|
186
|
+
continue;
|
|
187
|
+
if (typeof raw === 'object')
|
|
188
|
+
continue;
|
|
189
|
+
readable[key] = String(raw);
|
|
190
|
+
}
|
|
191
|
+
return { values: shaped, notes, readable };
|
|
192
|
+
}
|
|
193
|
+
/** 去掉字段名里的括号提示语并压掉空白:「开始时间(具体开始时间)」→「开始时间」。 */
|
|
194
|
+
function normalizeFieldName(name) {
|
|
195
|
+
return String(name ?? '')
|
|
196
|
+
.replace(/[((][^(())]*[))]/g, '')
|
|
197
|
+
.replace(/[\s\u3000]+/g, '')
|
|
198
|
+
.trim();
|
|
169
199
|
}
|
|
170
|
-
/**
|
|
171
|
-
|
|
200
|
+
/**
|
|
201
|
+
* 按别名把会议字段映射到表单字段 **datakey**(fieldId)。
|
|
202
|
+
*
|
|
203
|
+
* @param preferred 上次解析出的「语义键 → datakey」。命中即直接用,**不再看中文名** ——
|
|
204
|
+
* 表单字段改名、或别名表没覆盖到(如「会议名称(请填写会议的具体名称)」这类带提示语的)也能填对。
|
|
205
|
+
*/
|
|
206
|
+
export function mapFields(fields, values, preferred = {}) {
|
|
172
207
|
const byName = new Map(fields.map((field) => [field.name.trim(), field]));
|
|
208
|
+
const byId = new Map(fields.map((field) => [field.id, field]));
|
|
209
|
+
// 归一化索引:去掉字段名里的括号提示语(「开始时间(具体开始时间)」→「开始时间」)。
|
|
210
|
+
// ⛔ 2026-10-30 真机踩到:NEW 建会流程的字段名全带提示语,按原名精确匹配会**全部落空** ——
|
|
211
|
+
// 表现为「会议名称/开始时间/结束时间都没填进去」,而 prepare 仍然显示成功。
|
|
212
|
+
const byNormalized = new Map();
|
|
213
|
+
for (const field of fields) {
|
|
214
|
+
const key = normalizeFieldName(field.name);
|
|
215
|
+
if (key && !byNormalized.has(key))
|
|
216
|
+
byNormalized.set(key, field);
|
|
217
|
+
}
|
|
173
218
|
const main = {};
|
|
174
219
|
const mapped = {};
|
|
175
220
|
const unmapped = [];
|
|
221
|
+
const resolved = {};
|
|
222
|
+
const findField = (aliases) => {
|
|
223
|
+
// ① 原名精确
|
|
224
|
+
for (const name of aliases) {
|
|
225
|
+
const field = byName.get(name);
|
|
226
|
+
if (field)
|
|
227
|
+
return field;
|
|
228
|
+
}
|
|
229
|
+
// ② 去掉括号提示语后精确
|
|
230
|
+
for (const name of aliases) {
|
|
231
|
+
const field = byNormalized.get(normalizeFieldName(name));
|
|
232
|
+
if (field)
|
|
233
|
+
return field;
|
|
234
|
+
}
|
|
235
|
+
// ③ 归一化后包含(兜住「会议名称(请填写…)」之外的变体,如「会议主题名称」)
|
|
236
|
+
for (const name of aliases) {
|
|
237
|
+
const needle = normalizeFieldName(name);
|
|
238
|
+
if (needle.length < 2)
|
|
239
|
+
continue;
|
|
240
|
+
for (const [key, field] of byNormalized) {
|
|
241
|
+
if (key.includes(needle))
|
|
242
|
+
return field;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
};
|
|
176
247
|
for (const [key, raw] of Object.entries(values)) {
|
|
177
248
|
if (raw === undefined || raw === null || raw === '')
|
|
178
249
|
continue;
|
|
250
|
+
// ① 先用固化的 datakey(字段改名也不受影响);② 没有再按中文名别名匹配。
|
|
179
251
|
// 2026-10-28:租户自建字段不在别名表里 —— 此时按调用方给的**字段名原文**精确匹配,
|
|
180
252
|
// 这样 extraFields 能填流程表单上的任何字段(参会客户/联系方式之类),而不是只能填内置的十来个。
|
|
181
253
|
const aliases = FIELD_ALIASES[key] ?? [key];
|
|
182
|
-
const
|
|
254
|
+
const prefId = preferred[key];
|
|
255
|
+
const hit = (prefId ? byId.get(prefId) : undefined) ?? findField(aliases);
|
|
183
256
|
if (!hit) {
|
|
184
257
|
unmapped.push(key);
|
|
185
258
|
continue;
|
|
186
259
|
}
|
|
187
260
|
main[hit.id] = raw;
|
|
188
261
|
mapped[key] = `${hit.name}(${hit.id})`;
|
|
262
|
+
resolved[key] = hit.id;
|
|
263
|
+
}
|
|
264
|
+
return { main, mapped, unmapped, resolved };
|
|
265
|
+
}
|
|
266
|
+
function flowFieldCacheDir(env) {
|
|
267
|
+
const configured = env?.ctx?.config?.path?.();
|
|
268
|
+
const root = configured
|
|
269
|
+
? resolve(String(configured), '..')
|
|
270
|
+
: (process.env.WEAVER_WORK_CLI_HOME || process.env.WORK_CLI_HOME || resolve(homedir(), '.weaver-work-cli'));
|
|
271
|
+
return resolve(root, 'meeting', 'cache');
|
|
272
|
+
}
|
|
273
|
+
function flowFieldCacheFile(env, workflowId) {
|
|
274
|
+
return resolve(flowFieldCacheDir(env), `flow-fields-${workflowId}.json`);
|
|
275
|
+
}
|
|
276
|
+
/** 读固化映射;读不到/损坏都返回空对象(不阻断建会)。 */
|
|
277
|
+
export async function readFlowFieldMap(env, workflowId) {
|
|
278
|
+
try {
|
|
279
|
+
const file = flowFieldCacheFile(env, workflowId);
|
|
280
|
+
if (!existsSync(file))
|
|
281
|
+
return {};
|
|
282
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
283
|
+
return parsed && typeof parsed.mapping === 'object' && parsed.mapping ? parsed.mapping : {};
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return {};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** 写固化映射;失败只影响"下次还要按名字猜",不影响本次结果,故吞掉异常。 */
|
|
290
|
+
async function writeFlowFieldMap(env, workflowId, workflowName, mapping) {
|
|
291
|
+
if (Object.keys(mapping).length === 0)
|
|
292
|
+
return;
|
|
293
|
+
try {
|
|
294
|
+
const file = flowFieldCacheFile(env, workflowId);
|
|
295
|
+
await mkdir(resolve(file, '..'), { recursive: true });
|
|
296
|
+
const cache = {
|
|
297
|
+
workflowId,
|
|
298
|
+
...(workflowName ? { workflowName } : {}),
|
|
299
|
+
savedAt: new Date().toISOString(),
|
|
300
|
+
mapping,
|
|
301
|
+
};
|
|
302
|
+
await writeFile(file, JSON.stringify(cache, null, 2), 'utf8');
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* 缓存写失败不阻断 */
|
|
189
306
|
}
|
|
190
|
-
return { main, mapped, unmapped };
|
|
191
307
|
}
|
|
192
308
|
/**
|
|
193
309
|
* 把 `workflowId` 入参规整成 id 列表。
|
|
@@ -215,7 +331,85 @@ export function normalizeWorkflowIds(raw) {
|
|
|
215
331
|
}
|
|
216
332
|
return ids;
|
|
217
333
|
}
|
|
218
|
-
/**
|
|
334
|
+
/**
|
|
335
|
+
* 自用固化:建会流程 id 列表,**顺序即优先级**(第一个当前账号能发起的直接就用)。
|
|
336
|
+
*
|
|
337
|
+
* 为什么固化(2026-10-30 用户明确要求):自用场景就固定这两条建会流程,不必每次都把
|
|
338
|
+
* "选流程"这道选择题抛给用户 —— 每次打断一轮对话,且选错流程会导致字段填不进另一条
|
|
339
|
+
* (2026-10-29 真实事故)。流程 id 写在这里而不是读应用设置,顺带省掉一次设置查询。
|
|
340
|
+
*
|
|
341
|
+
* 关闭/覆盖:
|
|
342
|
+
* - `WEAVER_MEETING_FIXED_FLOW=0` → 关掉固化,退回"读应用设置 mt_new_wf + 让用户选"的通用行为;
|
|
343
|
+
* - `WEAVER_MEETING_FIXED_FLOW_IDS=<id1,id2>` → 换一组默认流程(切租户/环境时用)。
|
|
344
|
+
*
|
|
345
|
+
* ⛔ 固化不等于"不校验权限":仍然逐个探测"当前账号能不能发起",全部发起不了才回退应用设置。
|
|
346
|
+
*/
|
|
347
|
+
const FIXED_FLOW_IDS = ['1325677244111462606', '1326190634427154474'];
|
|
348
|
+
const FIXED_FLOW_PROFILES = [
|
|
349
|
+
{
|
|
350
|
+
host: 'www.e-cology.com.cn',
|
|
351
|
+
tenantKey: 't7akvdnf84',
|
|
352
|
+
label: '泛微生产(泛微网络)',
|
|
353
|
+
ids: [...FIXED_FLOW_IDS],
|
|
354
|
+
},
|
|
355
|
+
];
|
|
356
|
+
function fixedFlowFlagOn() {
|
|
357
|
+
const flag = String(process.env.WEAVER_MEETING_FIXED_FLOW ?? '1').trim().toLowerCase();
|
|
358
|
+
return !(flag === '0' || flag === 'false' || flag === 'off');
|
|
359
|
+
}
|
|
360
|
+
function fixedFlowOverride() {
|
|
361
|
+
const override = String(process.env.WEAVER_MEETING_FIXED_FLOW_IDS ?? '').trim();
|
|
362
|
+
if (!override)
|
|
363
|
+
return [];
|
|
364
|
+
return override.split(/[,;,;\s]+/).map((item) => item.trim()).filter((item) => item !== '');
|
|
365
|
+
}
|
|
366
|
+
/** 无环境的固化列表(也供单测):环境变量覆盖 > 内置默认。 */
|
|
367
|
+
export function fixedFlowIds() {
|
|
368
|
+
if (!fixedFlowFlagOn())
|
|
369
|
+
return [];
|
|
370
|
+
const override = fixedFlowOverride();
|
|
371
|
+
return override.length > 0 ? override : [...FIXED_FLOW_IDS];
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* 按当前登录环境挑固化流程:host 必须命中配置(租户可取到才对租户,取不到只比 host)。
|
|
375
|
+
* 命中不了 → 返回空,表示"这个环境不启用固化",交回应用设置/入参决定。
|
|
376
|
+
*/
|
|
377
|
+
export async function fixedFlowIdsForEnv(env) {
|
|
378
|
+
if (!fixedFlowFlagOn())
|
|
379
|
+
return { ids: [] };
|
|
380
|
+
const override = fixedFlowOverride();
|
|
381
|
+
if (override.length > 0)
|
|
382
|
+
return { ids: override, label: '环境变量指定' };
|
|
383
|
+
let baseUrl = '';
|
|
384
|
+
let tenantKey = '';
|
|
385
|
+
try {
|
|
386
|
+
const session = await env?.host?.getSession?.();
|
|
387
|
+
baseUrl = String(session?.baseUrl ?? '');
|
|
388
|
+
tenantKey = String(session?.tenantKey ?? session?.tenantId ?? '');
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
/* 取不到登录环境(离线/单测)→ 按默认配置走 */
|
|
392
|
+
}
|
|
393
|
+
if (!baseUrl)
|
|
394
|
+
return { ids: [...FIXED_FLOW_IDS], label: '默认固化配置(未取到登录环境)' };
|
|
395
|
+
const host = baseUrl.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
|
|
396
|
+
const hit = FIXED_FLOW_PROFILES.find((profile) => profile.host === host
|
|
397
|
+
&& (!profile.tenantKey || !tenantKey || profile.tenantKey === tenantKey));
|
|
398
|
+
return hit ? { ids: [...hit.ids], label: hit.label } : { ids: [] };
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 多流程都能发起时,是否自动取第一个。
|
|
402
|
+
*
|
|
403
|
+
* ⛔ **默认不自动**(2026-10-30 用户明确纠正):候选流程**都有发起权限 → 弹窗让用户选**;
|
|
404
|
+
* 只有一个有权限 → 直接发起(不弹窗);都没有权限 → 报"没有发起权限"。
|
|
405
|
+
* 只有显式 `flowChoice: 'auto'`(或 `WEAVER_MEETING_FLOW_CHOICE=auto`)才自动取第一个。
|
|
406
|
+
*/
|
|
407
|
+
export function shouldAutoPickFlow(args) {
|
|
408
|
+
if (args.flowChoice === 'auto')
|
|
409
|
+
return true;
|
|
410
|
+
return String(process.env.WEAVER_MEETING_FLOW_CHOICE ?? '').trim().toLowerCase() === 'auto';
|
|
411
|
+
}
|
|
412
|
+
/** 解析要用的建会流程 id:入参优先(可多个),其次固化列表,最后才是应用设置里的 mt_new_wf。 */
|
|
219
413
|
export async function resolveWorkflowId(env, args) {
|
|
220
414
|
const explicitList = normalizeWorkflowIds(args.workflowId);
|
|
221
415
|
if (explicitList.length > 0) {
|
|
@@ -226,30 +420,102 @@ export async function resolveWorkflowId(env, args) {
|
|
|
226
420
|
source: 'argument',
|
|
227
421
|
};
|
|
228
422
|
}
|
|
423
|
+
// 固化优先(2026-10-30):自用固定流程,省一次设置查询,也不受 mt_new_wf 配置影响。
|
|
424
|
+
// 只有登录环境命中固化配置才启用(避免拿生产的流程 id 去撞测试环境)。
|
|
425
|
+
const fixed = await fixedFlowIdsForEnv(env);
|
|
426
|
+
if (fixed.ids.length > 0) {
|
|
427
|
+
return {
|
|
428
|
+
workflowId: fixed.ids.length === 1 ? fixed.ids[0] : '',
|
|
429
|
+
candidates: fixed.ids,
|
|
430
|
+
source: 'fixed',
|
|
431
|
+
...(fixed.label ? { label: fixed.label } : {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
229
434
|
const settings = await fetchEbAppSettings(env, 'meeting.eb.meeting.create.flow.prepare');
|
|
230
435
|
const candidates = String(settings.mtNewWf ?? '')
|
|
231
436
|
.split(/[,;,;\s]+/)
|
|
232
437
|
.map((item) => item.trim())
|
|
233
438
|
.filter((item) => item !== '');
|
|
234
439
|
if (candidates.length === 0) {
|
|
235
|
-
throw meetingValidationError('flow_not_configured', '应用设置里没有配置「新建会议工作流」(mt_new_wf
|
|
440
|
+
throw meetingValidationError('flow_not_configured', '应用设置里没有配置「新建会议工作流」(mt_new_wf 为空),也没有启用固化流程,无法走流程建会;'
|
|
441
|
+
+ '请管理员在会议应用设置里配置,或用 WEAVER_MEETING_FIXED_FLOW_IDS 指定默认流程');
|
|
236
442
|
}
|
|
237
443
|
// 多个流程时先不拍板:由调用方探测"当前人员对每个流程有没有发起权限",再决定是否让用户选。
|
|
238
444
|
return { workflowId: candidates.length === 1 ? candidates[0] : '', candidates, source: 'settings' };
|
|
239
445
|
}
|
|
446
|
+
function pad2(value) {
|
|
447
|
+
return String(value).padStart(2, '0');
|
|
448
|
+
}
|
|
449
|
+
/** 明天的 yyyy-MM-dd:用户只说"开个会"没给日期时的兜底查找日。 */
|
|
450
|
+
function tomorrowIso() {
|
|
451
|
+
const date = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
452
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
453
|
+
}
|
|
454
|
+
/** Date → "yyyy-MM-dd HH:mm"。 */
|
|
455
|
+
function formatMinute(date) {
|
|
456
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} `
|
|
457
|
+
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* 并行探测一组流程 id 的可发起性(`workflow.createForm` 能载入 = 当前账号有发起权限)。
|
|
461
|
+
* 抽出来是因为"固化 id 全不可发起 → 回退应用设置再探一轮"要复用同一段逻辑。
|
|
462
|
+
*/
|
|
463
|
+
async function probeWorkflowCandidates(env, ids) {
|
|
464
|
+
const probed = await Promise.all(ids.map(async (id) => {
|
|
465
|
+
try {
|
|
466
|
+
const probeForm = await loadCreateForm(env, id);
|
|
467
|
+
return {
|
|
468
|
+
id,
|
|
469
|
+
probeForm,
|
|
470
|
+
probe: {
|
|
471
|
+
workflowId: id,
|
|
472
|
+
...(probeForm.workflowName ? { name: probeForm.workflowName } : {}),
|
|
473
|
+
launchable: true,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
return {
|
|
479
|
+
id,
|
|
480
|
+
probeForm: null,
|
|
481
|
+
probe: {
|
|
482
|
+
workflowId: id,
|
|
483
|
+
launchable: false,
|
|
484
|
+
reason: String(error?.message ?? error).slice(0, 160),
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}));
|
|
489
|
+
const probes = [];
|
|
490
|
+
const loadedForms = new Map();
|
|
491
|
+
for (const item of probed) {
|
|
492
|
+
if (item.probeForm)
|
|
493
|
+
loadedForms.set(item.id, item.probeForm);
|
|
494
|
+
probes.push(item.probe);
|
|
495
|
+
}
|
|
496
|
+
return { probes, loadedForms };
|
|
497
|
+
}
|
|
240
498
|
const ebMeetingCreateFlowPrepare = {
|
|
241
499
|
name: 'meeting.eb.meeting.create.flow.prepare',
|
|
242
500
|
resource: 'eb',
|
|
243
501
|
method: 'meeting.eb.meeting.create.flow.prepare',
|
|
244
502
|
action: 'meeting.eb.meeting.create.flow.prepare',
|
|
245
|
-
summary: '
|
|
503
|
+
summary: '流程建会(准备):用固化流程候选逐个探测发起权限 —— 仅 1 个可发起就自动用它、多个都可发起则返回选项让用户选、'
|
|
504
|
+
+ '都不可发起报「没有发起权限」;不传 begin_datetime 时自动推荐「人+会议室都空闲」的时段。只出预览与确认票据,不提交',
|
|
246
505
|
capability: 'eb.meeting',
|
|
247
506
|
risk: 'read-before-write',
|
|
248
507
|
requiresConfirmation: true,
|
|
249
508
|
inputSchema: schemaOf({
|
|
250
509
|
mt_name: { type: 'string', description: '会议名称(必填)' },
|
|
251
|
-
begin_datetime: {
|
|
252
|
-
|
|
510
|
+
begin_datetime: {
|
|
511
|
+
...SCHEMA_DATETIME_MINUTE,
|
|
512
|
+
description: '开始时间(yyyy-MM-dd HH:mm)。**可以不传**:不传会按参会人与会议室空闲情况推荐 3 个时段让你选;'
|
|
513
|
+
+ '只传开始时间、不传结束时间时,按 durationMinutes(默认 60 分钟)自动算结束时间',
|
|
514
|
+
},
|
|
515
|
+
end_datetime: {
|
|
516
|
+
...SCHEMA_DATETIME_MINUTE,
|
|
517
|
+
description: '结束时间(yyyy-MM-dd HH:mm);不传则按 durationMinutes 从开始时间推算(默认 60 分钟)',
|
|
518
|
+
},
|
|
253
519
|
mt_desc: { type: 'string', description: '会议内容/说明' },
|
|
254
520
|
address: { type: 'string', description: '会议室(名称或 id,按流程表单字段要求)' },
|
|
255
521
|
customize_address: { type: 'string', description: '自定义会议地点' },
|
|
@@ -257,6 +523,11 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
257
523
|
total_member: { type: 'number', description: '参会人数' },
|
|
258
524
|
caller: { type: 'string', description: '召集人' },
|
|
259
525
|
contacter: { type: 'string', description: '联系人' },
|
|
526
|
+
mt_type: {
|
|
527
|
+
type: 'string',
|
|
528
|
+
description: '会议类型:可传类型名称或 id(先用 `meeting.eb.type.list` 查有哪些)。'
|
|
529
|
+
+ '**很多租户的流程表单把「会议类型」设为必填**,不传会被 `workflow.createRequest` 打回缺字段',
|
|
530
|
+
},
|
|
260
531
|
other_remark: { type: 'string', description: '备注' },
|
|
261
532
|
workflowId: {
|
|
262
533
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
|
@@ -266,93 +537,62 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
266
537
|
},
|
|
267
538
|
autoRoom: { type: 'boolean', description: '默认 false:未指定会议室时返回候选让用户选;传 true 才自动采用推荐' },
|
|
268
539
|
allowNoRoom: { type: 'boolean', description: '确实不需要会议室(线上会等)时传 true,避免被“请选择会议室”拦住' },
|
|
540
|
+
timeConfirmed: {
|
|
541
|
+
type: 'boolean',
|
|
542
|
+
description: '⛔ **时间的"用户已确认"标记**。只有以下两种情况才传 true:'
|
|
543
|
+
+ '① 用户原话里明确给出了时间(如"明天 9 点到 10 点");'
|
|
544
|
+
+ '② 用户在时段选项里点选后,把 `options[].value` **整体**原样回传(value 自带该标记)。'
|
|
545
|
+
+ '不传 = 未确认:即使你传了 begin_datetime,CLI 也**不会**当成已定,而是返回时段选项让用户点选。'
|
|
546
|
+
+ '⛔ 用户只说"明天/上午/下午"这类模糊说法时,**必须不传**,让 CLI 给候选',
|
|
547
|
+
},
|
|
548
|
+
roomConfirmed: {
|
|
549
|
+
type: 'boolean',
|
|
550
|
+
description: '⛔ **会议室的"用户已确认"标记**。只有以下两种情况才传 true:'
|
|
551
|
+
+ '① 用户原话明确指定了某间会议室;② 用户在房间选项里点选后把 `options[].value` 整体原样回传。'
|
|
552
|
+
+ '不传 = 未确认:即使传了 address,CLI 也会把"你指定的这间"和推荐候选一起列出来让用户点选。'
|
|
553
|
+
+ '⛔ 不要自己从 room.list / room.recommend 里挑一间塞进 address —— 那是替用户拍板',
|
|
554
|
+
},
|
|
269
555
|
extraFields: {
|
|
270
556
|
type: 'object',
|
|
271
557
|
description: '按**流程表单里的字段名**填额外字段,形如 {"参会客户": "xx公司", "联系方式": "138xxxx"}'
|
|
272
558
|
+ ':用于租户在流程表单上自建、CLI 内置别名里没有的字段。键必须与该流程表单的字段名完全一致'
|
|
273
|
-
+ '(prepare 的 warnings 会列出表单现有全部字段名);匹配不到的会进 unmapped
|
|
559
|
+
+ '(prepare 的 warnings 会列出表单现有全部字段名);匹配不到的会进 unmapped 提示,不会瞎填。'
|
|
560
|
+
+ '⛔ **会议地点 / 参会人员 / 召集人 / 联系人 / 会议类型 不要塞这里** —— 它们有专用入参'
|
|
561
|
+
+ '(address / hrm_members / caller / contacter / mt_type),CLI 会把值补成浏览按钮要的'
|
|
562
|
+
+ ' `{id,name}` 对象;塞进 extraFields 会按纯文本提交,服务端会打回「请按名称选择」',
|
|
274
563
|
},
|
|
275
|
-
|
|
564
|
+
dateFrom: {
|
|
565
|
+
type: 'string',
|
|
566
|
+
description: '没给具体时间时按哪一天推荐时段(yyyy-MM-dd;不传默认明天)',
|
|
567
|
+
},
|
|
568
|
+
durationMinutes: {
|
|
569
|
+
type: 'number',
|
|
570
|
+
description: '会议时长(分钟,默认 60):只给了开始时间时用来算结束时间;推荐时段时也按这个时长找',
|
|
571
|
+
},
|
|
572
|
+
workStartHour: { type: 'number', description: '推荐时段的最早小时(默认 9)' },
|
|
573
|
+
workEndHour: { type: 'number', description: '推荐时段的最晚小时(默认 18)' },
|
|
574
|
+
fieldValues: {
|
|
575
|
+
type: 'object',
|
|
576
|
+
additionalProperties: true,
|
|
577
|
+
description: '按**表单字段 datakey(fieldId)**直接给值,形如 {"1325677252711735881": "研讨会"}。'
|
|
578
|
+
+ '优先级最高(同名字段以它为准):适合字段名带提示语、或租户自建字段这类按中文名猜不到的情况。'
|
|
579
|
+
+ 'datakey 从 prepare 返回的 preview.mapped(形如 "会议名称(1325677252711735881)")里取;'
|
|
580
|
+
+ '每个流程的 datakey 固定不变,可长期硬编码。',
|
|
581
|
+
},
|
|
582
|
+
flowChoice: {
|
|
583
|
+
type: 'string',
|
|
584
|
+
enum: ['auto', 'ask'],
|
|
585
|
+
description: '多个建会流程**都有发起权限**时怎么办:**默认(不传 / ask)= 返回选项让用户挑**;'
|
|
586
|
+
+ 'auto = 自动取优先级最高的那个(省一轮,但选错流程会漏字段,慎用)。'
|
|
587
|
+
+ '只有 1 个有权限时不受此参数影响,CLI 直接发起',
|
|
588
|
+
},
|
|
589
|
+
}, { required: ['mt_name'] }),
|
|
276
590
|
handler: async (args, env) => {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const probes = [];
|
|
283
|
-
const loadedForms = new Map();
|
|
284
|
-
if (!workflowId && candidates.length > 1) {
|
|
285
|
-
// 有多个建会流程候选(应用设置配了多个,或入参传了多个 id):**逐个探测当前人员能否发起**
|
|
286
|
-
// (createForm 能载入 = 有发起权限),不能默认拿第一个就提交 —— 用户可能对第一个没有发起权限,
|
|
287
|
-
// 也可能各流程表单字段完全不同(2026-10-28 / 2026-10-29 用户两次要求)。
|
|
288
|
-
// 并行探测:每个流程一次 createForm(内含多次 OA 请求),串行会成倍放大等待
|
|
289
|
-
const probed = await Promise.all(candidates.map(async (id) => {
|
|
290
|
-
try {
|
|
291
|
-
const probeForm = await loadCreateForm(env, id);
|
|
292
|
-
return {
|
|
293
|
-
id,
|
|
294
|
-
probeForm,
|
|
295
|
-
probe: {
|
|
296
|
-
workflowId: id,
|
|
297
|
-
...(probeForm.workflowName ? { name: probeForm.workflowName } : {}),
|
|
298
|
-
launchable: true,
|
|
299
|
-
},
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
catch (error) {
|
|
303
|
-
return {
|
|
304
|
-
id,
|
|
305
|
-
probeForm: null,
|
|
306
|
-
probe: {
|
|
307
|
-
workflowId: id,
|
|
308
|
-
launchable: false,
|
|
309
|
-
reason: String(error?.message ?? error).slice(0, 160),
|
|
310
|
-
},
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
}));
|
|
314
|
-
for (const item of probed) {
|
|
315
|
-
if (item.probeForm)
|
|
316
|
-
loadedForms.set(item.id, item.probeForm);
|
|
317
|
-
probes.push(item.probe);
|
|
318
|
-
}
|
|
319
|
-
const launchable = probes.filter((probe) => probe.launchable);
|
|
320
|
-
if (launchable.length === 0) {
|
|
321
|
-
throw meetingValidationError('flow_not_launchable', `${sourceLabel}了 ${candidates.length} 个建会流程,但当前账号一个都发起不了(无发起权限或流程已停用):`
|
|
322
|
-
+ probes.map((probe) => `${probe.workflowId}(${probe.reason ?? '不可发起'})`).join(';'), { probes });
|
|
323
|
-
}
|
|
324
|
-
if (launchable.length > 1) {
|
|
325
|
-
// 仍有多个可选 → 交给用户挑,不替用户决定(用户明确要求"以选择的方式")
|
|
326
|
-
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
327
|
-
status: 'NEEDS_SELECTION',
|
|
328
|
-
message: `${sourceLabel}了 ${candidates.length} 个建会流程,当前账号可发起其中 ${launchable.length} 个,`
|
|
329
|
-
+ '请让用户选择要触发哪一个 —— **不要默认取第一个**(各流程表单字段可能完全不同,选错会漏字段;'
|
|
330
|
-
+ '2026-10-29 真实事故:取第一个导致字段全填不进另一条流程,用户以为"系统只支持一条流程")',
|
|
331
|
-
options: launchable.map((probe, index) => ({
|
|
332
|
-
key: `W${index + 1}`,
|
|
333
|
-
label: probe.name ? `${probe.name}(${probe.workflowId})` : probe.workflowId,
|
|
334
|
-
value: { workflowId: probe.workflowId },
|
|
335
|
-
reason: '当前账号有发起权限',
|
|
336
|
-
// 把该流程的字段名如实带出来:不同流程字段差异很大(有的有设备需求/涉密项目,有的没有),
|
|
337
|
-
// 用户要靠这个才能选对(2026-10-29 用户反馈:选错方式后字段全填不进去)。
|
|
338
|
-
fields: (loadedForms.get(probe.workflowId)?.fields ?? []).map((f) => f.name).filter(Boolean),
|
|
339
|
-
})),
|
|
340
|
-
probes,
|
|
341
|
-
}, {
|
|
342
|
-
warnings: [
|
|
343
|
-
`${sourceLabel}了 ${candidates.length} 个建会流程,其中 ${launchable.length} 个当前账号可发起;`
|
|
344
|
-
+ '**必须把这几个流程做成选项让用户挑一个,禁止自己替用户选**;选定后带单个 workflowId 重新 prepare。',
|
|
345
|
-
...probes.filter((probe) => !probe.launchable)
|
|
346
|
-
.map((probe) => `流程 ${probe.workflowId} 当前账号无法发起(${probe.reason ?? '无权限'}),已排除`),
|
|
347
|
-
],
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
workflowId = launchable[0].workflowId;
|
|
351
|
-
}
|
|
352
|
-
const form = loadedForms.get(workflowId) ?? await loadCreateForm(env, workflowId);
|
|
353
|
-
if (!form.continuation) {
|
|
354
|
-
throw meetingValidationError('flow_context_missing', '建会流程表单没有返回 continuation,无法继续提交');
|
|
355
|
-
}
|
|
591
|
+
// ⚠️ 2026-10-30 顺序调整:**先确认四要素(名称 / 时间 / 参会人 / 地点),最后才定流程**。
|
|
592
|
+
// 流程探测与选择整体后移到房间闸门之后(见下方「流程闸门」);这里先占位,
|
|
593
|
+
// 好让前面的时间/会议室选择分支仍能带出"当前用的是哪条流程"(此刻通常还是空的)。
|
|
594
|
+
let workflowId = '';
|
|
595
|
+
let flowPickNote = null;
|
|
356
596
|
// 走流程也要有"选会议室 / 选时间"的体验(2026-10-28 用户要求)。
|
|
357
597
|
// ⚠️ 性能:会议室推荐、参会人冲突、共同空闲时段三件事**互相独立**,必须并行发;
|
|
358
598
|
// 串行会把 3 段网络耗时叠加成"建个会要等好几分钟"(用户 2026-10-28 反馈)。
|
|
@@ -361,11 +601,118 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
361
601
|
// 适用场景:人、时间、房间都已经确定,只想尽快把会建出来(可省掉 3 组额外请求与一次 ESB 调用)。
|
|
362
602
|
const enrich = args.enrich !== false;
|
|
363
603
|
const beginText = String(args.begin_datetime ?? '').trim();
|
|
364
|
-
|
|
365
|
-
const
|
|
604
|
+
let endText = String(args.end_datetime ?? '').trim();
|
|
605
|
+
const rawMembers = String(args.hrm_members ?? '')
|
|
366
606
|
.split(/[,;,;\s]+/)
|
|
367
607
|
.map((item) => item.trim())
|
|
368
608
|
.filter((item) => item !== '');
|
|
609
|
+
// ⛔ 参会人冲突检测(conflict.member)与共同空闲时段(schedule.free)**要的是员工 id**,
|
|
610
|
+
// 传姓名会被接口拒 —— 两路 Promise 都是 `.catch(() => null)`,于是"检测没跑"和"检测通过"
|
|
611
|
+
// 在返回里长得一模一样,用户以为查过了(2026-10-30 真机:agent 传"易鹏",两路全部静默跳过)。
|
|
612
|
+
// 所以这里先把姓名换成 id:纯数字直接用,姓名走 hrm.employee.query 反查(查不到保持原名,不猜)。
|
|
613
|
+
const nameLike = rawMembers.filter((item) => !/^\d+$/.test(item));
|
|
614
|
+
const nameToId = nameLike.length > 0
|
|
615
|
+
? await import('./schedule.js')
|
|
616
|
+
.then((m) => m.resolveMemberIdsByName(env, nameLike))
|
|
617
|
+
.catch(() => new Map())
|
|
618
|
+
: new Map();
|
|
619
|
+
const memberIds = rawMembers.map((item) => nameToId.get(item) ?? item);
|
|
620
|
+
// ③ 时间闸门:**传了具体时间 ≠ 用户已经确认**。
|
|
621
|
+
// 2026-10-30 真机事故:用户只说"明天上午",agent 自己挑了个 9:00–10:00 传进来,
|
|
622
|
+
// CLI 当成"已定"直接进确认摘要,用户看到「时间:09:00–10:00」质问"谁决定的"。
|
|
623
|
+
// 所以只有 `timeConfirmed: true`(用户原话明确给了时间,或用户在选项里点选后把 value 原样回传)
|
|
624
|
+
// 才认这个时间;否则一律返回时段选项让用户点 —— 宁可多问一轮,也不替用户拍板。
|
|
625
|
+
const timeConfirmed = args.timeConfirmed === true;
|
|
626
|
+
// ⛔ 2026-10-30 真机:agent 在用户只说"明天上午"时就自己塞了 workflowId,
|
|
627
|
+
// 于是它脑内"流程已定",回复里先讲流程再问时间(用户投诉顺序反了)。
|
|
628
|
+
// 时间/地点闸门阶段流程根本没定 —— 这里只做提示,不阻断(用户可能确实点名了流程,
|
|
629
|
+
// 那种情况下这个提示也算说明"流程还没到提交")。
|
|
630
|
+
const callerWorkflowId = String(args.workflowId ?? '').trim();
|
|
631
|
+
const earlyWorkflowNote = callerWorkflowId === ''
|
|
632
|
+
? ''
|
|
633
|
+
: `⛔ 你传了 workflowId=${callerWorkflowId},但**流程还没到确认步骤**(顺序是 名称→时间→参会人→地点→流程)。`
|
|
634
|
+
+ '本次只处理时间/地点;⛔ 不要在回复里说"按 X 流程发起"——四要素定完后 CLI 会返回流程选项让用户点。';
|
|
635
|
+
const beginHasTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(beginText);
|
|
636
|
+
const endHasTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(endText);
|
|
637
|
+
if (beginHasTime && !endHasTime) {
|
|
638
|
+
// 只给了开始时间:按 durationMinutes(默认 60)补齐结束时间,不必打断用户。
|
|
639
|
+
const duration = Number(args.durationMinutes) > 0 ? Math.round(Number(args.durationMinutes)) : 60;
|
|
640
|
+
const startMs = Date.parse(beginText.slice(0, 16).replace(' ', 'T'));
|
|
641
|
+
if (Number.isFinite(startMs))
|
|
642
|
+
endText = formatMinute(new Date(startMs + duration * 60000));
|
|
643
|
+
}
|
|
644
|
+
if (!beginHasTime || !timeConfirmed) {
|
|
645
|
+
const day = /^\d{4}-\d{2}-\d{2}/.test(beginText)
|
|
646
|
+
? beginText.slice(0, 10)
|
|
647
|
+
: (String(args.dateFrom ?? '').trim().slice(0, 10) || tomorrowIso());
|
|
648
|
+
const duration = Number(args.durationMinutes) > 0 ? Math.round(Number(args.durationMinutes)) : 60;
|
|
649
|
+
const { getMeetingOperation } = await import('../registry.js');
|
|
650
|
+
const suggested = await getMeetingOperation('meeting.eb.meeting.create.suggest').handler({
|
|
651
|
+
dateFrom: day,
|
|
652
|
+
durationMinutes: duration,
|
|
653
|
+
hrm_members: memberIds.join(','),
|
|
654
|
+
maxSlots: 3,
|
|
655
|
+
topN: 3,
|
|
656
|
+
...(Number(args.total_member) > 0 ? { memberCount: Number(args.total_member) } : {}),
|
|
657
|
+
...(args.workStartHour !== undefined ? { workStartHour: Number(args.workStartHour) } : {}),
|
|
658
|
+
...(args.workEndHour !== undefined ? { workEndHour: Number(args.workEndHour) } : {}),
|
|
659
|
+
}, env).catch((error) => ({ data: { slots: [] }, slotError: String(error?.message ?? error).slice(0, 160) }));
|
|
660
|
+
const slotOptions = (suggested?.data?.slots ?? []);
|
|
661
|
+
const slotWarnings = [
|
|
662
|
+
...(suggested?.warnings ?? []),
|
|
663
|
+
...(suggested?.slotError ? [`时段推荐未跑成功:${suggested.slotError}`] : []),
|
|
664
|
+
...(memberIds.length === 0
|
|
665
|
+
? ['⚠️ 没有参会人员,无法核验"人是否空闲" —— 下面只是**会议室有空**的时段,请如实说明。']
|
|
666
|
+
: []),
|
|
667
|
+
];
|
|
668
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
669
|
+
status: 'NEEDS_SELECTION',
|
|
670
|
+
selectionType: 'slot',
|
|
671
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'T1' },
|
|
672
|
+
message: slotOptions.length > 0
|
|
673
|
+
? `${beginHasTime
|
|
674
|
+
? `你传入的时间 ${beginText}~${endText} **未经用户确认**(没带 timeConfirmed)`
|
|
675
|
+
: '没有给出具体时间'},已按参会人与会议室空闲情况推荐 ${slotOptions.length} 个时段`
|
|
676
|
+
+ `(${day},时长 ${duration} 分钟)—— ⛔ 必须让用户在选项里点选,`
|
|
677
|
+
+ '**不要**把任何一个候选写成"已定"或放进确认摘要'
|
|
678
|
+
: `${beginHasTime
|
|
679
|
+
? `你传入的时间 ${beginText}~${endText} 未经用户确认(没带 timeConfirmed)`
|
|
680
|
+
: '没有给出具体时间'},且 ${day} 没能找到「参会人空闲 + 会议室空闲」的时段,`
|
|
681
|
+
+ '请让用户换个日期或指定时间',
|
|
682
|
+
options: slotOptions.map((slot, index) => ({
|
|
683
|
+
key: `T${index + 1}`,
|
|
684
|
+
label: String(slot.label ?? ''),
|
|
685
|
+
// ⛔ 时间不在 slot 顶层,而在 slot.value 里(suggest 的返回结构)—— 取错会让调用方
|
|
686
|
+
// 拿不到 begin/end,只能再问用户一次时间(2026-10-30 冒烟实测踩到)。
|
|
687
|
+
// `timeConfirmed: true` 在这里是**关键**:用户点选后 value 原样回传,下一轮才被认作"已确认";
|
|
688
|
+
// agent 自己编的时间不带这个标记,就永远停在选项这一步。
|
|
689
|
+
value: {
|
|
690
|
+
begin_datetime: String(slot.value?.begin ?? slot.begin ?? '').trim(),
|
|
691
|
+
end_datetime: String(slot.value?.end ?? slot.end ?? '').trim(),
|
|
692
|
+
address: slot.value?.address ?? null,
|
|
693
|
+
timeConfirmed: true,
|
|
694
|
+
},
|
|
695
|
+
reason: String(slot.reason ?? ''),
|
|
696
|
+
rooms: slot.rooms ?? [],
|
|
697
|
+
})),
|
|
698
|
+
day,
|
|
699
|
+
durationMinutes: duration,
|
|
700
|
+
memberIds,
|
|
701
|
+
// ⛔ 这里**不能**出现 workflowId:流程确认在四要素之后(2026-10-30 顺序调整)。
|
|
702
|
+
// 返回 workflowId 会让 agent 误以为"流程已定",于是在回复里先讲流程、再问时间。
|
|
703
|
+
workflowPending: true,
|
|
704
|
+
hint: '选定后把 value **整体**原样带回重新调用(含 timeConfirmed: true);'
|
|
705
|
+
+ 'address 已自动带上该时段可用的会议室,仍需让用户单独确认或用 roomConfirmed: true',
|
|
706
|
+
}, {
|
|
707
|
+
warnings: [
|
|
708
|
+
`${beginHasTime
|
|
709
|
+
? `本次**没有提交**:你传入的时间 ${beginText}~${endText} 没有用户确认标记(timeConfirmed),已改为给候选。`
|
|
710
|
+
: '本次**没有提交**:时间未确定(缺 begin_datetime)。'}已按 ${day} 推荐时段,请用选项让用户挑一个。`,
|
|
711
|
+
...slotWarnings,
|
|
712
|
+
...(earlyWorkflowNote ? [earlyWorkflowNote] : []),
|
|
713
|
+
],
|
|
714
|
+
});
|
|
715
|
+
}
|
|
369
716
|
let addressInput = args.address;
|
|
370
717
|
// 记录"用户有没有自己指定会议室":后面决定文案是"按你的指定"还是"自动选择"。
|
|
371
718
|
const hadAddressInput = String(args.address ?? '').trim() !== '';
|
|
@@ -377,29 +724,52 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
377
724
|
const startedAt = Date.now();
|
|
378
725
|
// 推荐照常跑:**即使调用方指定了会议室**也要跑 —— 一来说明"你指定的那间是否被占用",
|
|
379
726
|
// 二来冲突时能立刻给出替代候选(2026-10-29 用户明确要求:指定了也要判冲突、冲突了要推荐)。
|
|
727
|
+
// ⛔ 这几路增强检测失败**必须留下痕迹**:以前统一 `.catch(() => null)`,
|
|
728
|
+
// 结果"没查"和"查了没冲突"在返回里长得一模一样 —— 用户以为核验过了(2026-10-30 真机事故)。
|
|
729
|
+
const enrichFailures = [];
|
|
730
|
+
const noteFailure = (label) => (error) => {
|
|
731
|
+
enrichFailures.push(`${label}未完成:${String(error?.message ?? error).slice(0, 120)}`);
|
|
732
|
+
return null;
|
|
733
|
+
};
|
|
734
|
+
// ⛔ 会议人数要含召集人:`hrm_members` 通常只列"其他参会人",召集人是自己。
|
|
735
|
+
// 以前直接用 `memberIds.length`,1 个参会人就按 1 人算 → 容量下限 2 人的房间把它判死,
|
|
736
|
+
// 出现「可用 1 间却被过滤成 0 候选」(2026-10-30 真机:易鹏 1 人 → 测试会议室1 1/2 被排除)。
|
|
737
|
+
const declaredTotal = Number(args.total_member) > 0 ? Number(args.total_member) : null;
|
|
738
|
+
const callerToken = String(args.caller ?? '').trim();
|
|
739
|
+
const callerInMembers = callerToken !== '' && (rawMembers.includes(callerToken) || memberIds.includes(callerToken));
|
|
740
|
+
const roomMemberCount = declaredTotal
|
|
741
|
+
?? (memberIds.length > 0 ? memberIds.length + (callerInMembers ? 0 : 1) : undefined);
|
|
380
742
|
const roomTask = (enrich && beginText && endText)
|
|
381
743
|
? recommendRooms(env, {
|
|
382
744
|
beginDatetime: beginText,
|
|
383
745
|
endDatetime: endText,
|
|
384
|
-
...(
|
|
746
|
+
...(roomMemberCount ? { memberCount: roomMemberCount } : {}),
|
|
747
|
+
// 建会是"要下单"的场景:宁可多一次请求也必须拿最新台账(24h 缓存会让刚导入的会议室看不到)。
|
|
748
|
+
refreshRooms: true,
|
|
385
749
|
topN: 3,
|
|
386
|
-
}).catch(()
|
|
750
|
+
}).catch(noteFailure('会议室推荐'))
|
|
387
751
|
: Promise.resolve(null);
|
|
388
752
|
// ⛔ 指定了会议室时,**单独查它在该时段是否已被占用**(query_roomusage_list)。
|
|
389
753
|
// 以前 `!addressInput` 直接跳过整段 → "指定了一间被占用的房"照样往下走,用户到页面上才发现。
|
|
390
754
|
const addressId = String(addressInput ?? '').trim();
|
|
391
755
|
const addressIsRoomId = /^\d+$/.test(addressId);
|
|
756
|
+
// ④ 会议室闸门:与时间同理 —— 传了 address **不等于**用户确认过这间房。
|
|
757
|
+
// 只有 `roomConfirmed: true`(用户明确说了哪间,或在选项里点选后把 value 原样回传)才认;
|
|
758
|
+
// 否则返回房间选项让用户点。2026-10-30 同一事故:agent 把推荐房间写进确认摘要当定论。
|
|
759
|
+
const roomConfirmed = args.roomConfirmed === true;
|
|
392
760
|
const roomConflictTask = (enrich && beginText && endText && addressIsRoomId)
|
|
393
761
|
? (async () => {
|
|
394
762
|
const { getMeetingOperation } = await import('../registry.js');
|
|
395
763
|
return getMeetingOperation('meeting.eb.conflict.room').handler({ beginDatetime: beginText, endDatetime: endText, address: addressId }, env);
|
|
396
|
-
})().catch(()
|
|
764
|
+
})().catch(noteFailure('会议室**占用**检测'))
|
|
397
765
|
: Promise.resolve(null);
|
|
766
|
+
// ⛔ 这三路检测失败**必须留下痕迹**:以前统一 `.catch(() => null)`,
|
|
767
|
+
// 结果"没查"和"查了没冲突"在返回里长得一模一样 —— 用户以为核验过了(2026-10-30 真机事故)。
|
|
398
768
|
const conflictTask = (enrich && beginText && endText && memberIds.length > 0)
|
|
399
769
|
? (async () => {
|
|
400
770
|
const { getMeetingOperation } = await import('../registry.js');
|
|
401
771
|
return getMeetingOperation('meeting.eb.conflict.member').handler({ beginDatetime: beginText, endDatetime: endText, hrmMembers: memberIds.join(',') }, env);
|
|
402
|
-
})().catch(()
|
|
772
|
+
})().catch(noteFailure('参会人**会议占用**检测'))
|
|
403
773
|
: Promise.resolve(null);
|
|
404
774
|
const availabilityTask = (enrich && beginText && endText && memberIds.length > 0)
|
|
405
775
|
? findCommonFreeSlots(env, {
|
|
@@ -410,23 +780,55 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
410
780
|
maxSlots: 3,
|
|
411
781
|
maxDays: 1,
|
|
412
782
|
includeSchedule: true,
|
|
413
|
-
}).catch(()
|
|
783
|
+
}).catch(noteFailure('参会人**日程空闲**检测'))
|
|
414
784
|
: Promise.resolve(null);
|
|
415
785
|
const [roomRec, memberConflict, free, roomConflict] = await Promise.all([
|
|
416
786
|
roomTask, conflictTask, availabilityTask, roomConflictTask,
|
|
417
787
|
]);
|
|
418
788
|
timings.parallelMs = Date.now() - startedAt;
|
|
789
|
+
// 参会人冲突 / 共同空闲时段**已经并行算完了**。即使接下来要先让用户选会议室,
|
|
790
|
+
// 也必须把这两项结论一起交出去 —— 否则用户选完房才知道时间撞了,等于白选一次
|
|
791
|
+
// (2026-10-30 真机:会议室查不到 → 卡在选房 → 冲突结论永远拿不到,用户以为"没判断")。
|
|
792
|
+
const memberConflicts = (memberConflict?.data?.conflicts ?? []);
|
|
793
|
+
if (memberConflicts.length > 0)
|
|
794
|
+
decisions.memberConflicts = memberConflicts;
|
|
795
|
+
const freeSlots = free?.slots ?? [];
|
|
796
|
+
if (free) {
|
|
797
|
+
decisions.scheduleItems = free.scheduleItems;
|
|
798
|
+
decisions.availabilityCost = free.cost;
|
|
799
|
+
if (freeSlots.length > 0) {
|
|
800
|
+
decisions.suggestedSlots = freeSlots;
|
|
801
|
+
decisions.slotOptions = freeSlots.map((slot, index) => ({
|
|
802
|
+
key: `T${index + 1}`,
|
|
803
|
+
label: `${slot.begin.slice(11)}~${slot.end.slice(11)}(${day}) · 全部参会人空闲`,
|
|
804
|
+
value: { begin: slot.begin, end: slot.end },
|
|
805
|
+
reason: '该时段所有参会人都没有会议与日程占用(已同时核对会议回执与日程)',
|
|
806
|
+
}));
|
|
807
|
+
}
|
|
808
|
+
else if (free.blockingSummary) {
|
|
809
|
+
decisions.dayBlocked = { day, summary: free.blockingSummary };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// 选房阶段也要能看到"时间到底冲不冲突",否则等于没查。
|
|
813
|
+
const conflictBrief = {
|
|
814
|
+
memberConflicts,
|
|
815
|
+
suggestedSlots: freeSlots,
|
|
816
|
+
...(enrichFailures.length > 0 ? { enrichFailures } : {}),
|
|
817
|
+
};
|
|
818
|
+
const failureWarnings = enrichFailures.map((failure) => `⚠️ ${failure} —— **这不代表没有冲突**,请如实告知用户该项未核验,不要声称"时间已确认空闲"`);
|
|
419
819
|
// 会议室候选(指定/未指定都用这一份):冲突时直接拿它当替代方案。
|
|
420
820
|
const roomOptions = (roomRec?.candidates ?? []).map((candidate, index) => ({
|
|
421
821
|
key: `R${index + 1}`,
|
|
422
822
|
label: `${candidate.name}${candidate.capacity.max > 0 ? `(可容 ${candidate.capacity.max} 人)` : ''}`
|
|
423
823
|
+ `${index === 0 ? ' · 推荐' : ''}`,
|
|
424
|
-
|
|
824
|
+
// roomConfirmed: true = 用户点选该选项即代表"确认用这间",原样回传后下一轮才被认。
|
|
825
|
+
value: { id: candidate.id, name: candidate.name, roomConfirmed: true },
|
|
425
826
|
reason: candidate.reasons.join(';'),
|
|
426
827
|
}));
|
|
427
828
|
// ① 指定的会议室该时段已被占用 → 不替用户改,但必须拦下来并给替代候选。
|
|
428
829
|
const roomConflictData = roomConflict?.data;
|
|
429
|
-
|
|
830
|
+
// 只有"用户已确认这间房"时才按占用拦截;未确认的只是候选,交给下面的选房分支处理。
|
|
831
|
+
if (addressInput && roomConfirmed && roomConflictData?.hasConflict === true) {
|
|
430
832
|
const busy = Array.isArray(roomConflictData.conflicts) ? roomConflictData.conflicts : [];
|
|
431
833
|
decisions.roomConflict = {
|
|
432
834
|
address: addressId,
|
|
@@ -439,8 +841,10 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
439
841
|
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
440
842
|
status: 'NEEDS_SELECTION',
|
|
441
843
|
selectionType: 'room_conflict',
|
|
844
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'R1' },
|
|
442
845
|
message: `你指定的会议室在 ${beginText}~${endText} 已被占用(${busy.length} 条占用记录),`
|
|
443
846
|
+ '本次**没有提交**。请改用下面空闲的会议室,或调整会议时间',
|
|
847
|
+
workflowPending: true,
|
|
444
848
|
options: roomOptions,
|
|
445
849
|
recommended: roomOptions[0]?.value ?? null,
|
|
446
850
|
conflicts: busy,
|
|
@@ -466,18 +870,53 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
466
870
|
// 会议室**不替用户决定**(用户明确要求:即使是常用会议室,也必须推荐出来让选择)。
|
|
467
871
|
// 只有显式 `autoRoom: true` 才自动选中;否则返回 NEEDS_SELECTION,把候选交给调用方去问。
|
|
468
872
|
const autoRoom = args.autoRoom === true;
|
|
469
|
-
|
|
873
|
+
// 传了房间但没用户确认标记 → 同样要给选项:把"你指定的那间"排到第一位,其余房间照常列在下面。
|
|
874
|
+
// 2026-10-30 事故:agent 自己挑了「测试会议室1」传进来,CLI 直接用,用户问"会议室没让我选"。
|
|
875
|
+
const needRoomPick = !addressInput || !roomConfirmed;
|
|
876
|
+
let pickOptions = roomOptions;
|
|
877
|
+
if (addressInput && !roomConfirmed) {
|
|
878
|
+
const hit = roomOptions.find((item) => String(item.value?.id) === addressId);
|
|
879
|
+
const specified = hit
|
|
880
|
+
? { ...hit, label: `${hit.label}(你指定的,待确认)`, value: { ...hit.value, roomConfirmed: true } }
|
|
881
|
+
: {
|
|
882
|
+
key: 'R0',
|
|
883
|
+
label: `${addressInput}(你指定的,待确认)`,
|
|
884
|
+
value: { ...(addressIsRoomId ? { id: addressId } : {}), name: addressInput, roomConfirmed: true },
|
|
885
|
+
reason: '你传入的会议室,未提交、未确认',
|
|
886
|
+
};
|
|
887
|
+
pickOptions = [specified, ...roomOptions.filter((item) => String(item.value?.id) !== addressId)]
|
|
888
|
+
.map((item, index) => ({ ...item, key: `R${index + 1}` }));
|
|
889
|
+
}
|
|
890
|
+
if (needRoomPick && !autoRoom && pickOptions.length > 0 && args.allowNoRoom !== true) {
|
|
470
891
|
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
471
892
|
status: 'NEEDS_SELECTION',
|
|
472
893
|
selectionType: 'room',
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
894
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'R1' },
|
|
895
|
+
message: addressInput
|
|
896
|
+
? `你传入的会议室「${addressInput}」**未经用户确认**(没带 roomConfirmed),已把它和其它候选一起列出来`
|
|
897
|
+
+ ' —— ⛔ 必须让用户在选项里点选,不要直接提交'
|
|
898
|
+
: `未指定会议室,请从 ${pickOptions.length} 个候选里选择(已按人数/常用/时段占用排序)`,
|
|
899
|
+
// 固化自动选的流程要带出来:调用方下一轮带 address 重调时不用再猜用的是哪条流程。
|
|
900
|
+
workflowPending: true,
|
|
901
|
+
options: pickOptions,
|
|
902
|
+
recommended: pickOptions[0]?.value ?? null,
|
|
903
|
+
hint: '选定后把 value **整体**原样带回(含 roomConfirmed: true);'
|
|
904
|
+
+ '确实不需要会议室请传 allowNoRoom: true',
|
|
905
|
+
...conflictBrief,
|
|
477
906
|
}, {
|
|
478
907
|
warnings: [
|
|
479
|
-
|
|
908
|
+
addressInput
|
|
909
|
+
? `会议室「${addressInput}」未经用户确认:本次**没有**提交。`
|
|
910
|
+
: '未指定会议室:本次**没有**提交,也没有替用户挑房间。',
|
|
480
911
|
'如需直接建会并沿用推荐,可传 autoRoom: true;不需要会议室则传 allowNoRoom: true。',
|
|
912
|
+
...(earlyWorkflowNote ? [earlyWorkflowNote] : []),
|
|
913
|
+
...(memberConflicts.length > 0
|
|
914
|
+
? [`⚠️ 注意:参会人在该时段已有 ${memberConflicts.length} 场会议冲突 —— 选房前先把这条告诉用户,别让他选完才发现。`]
|
|
915
|
+
: []),
|
|
916
|
+
...(freeSlots.length > 0
|
|
917
|
+
? [`当天全部参会人都空闲的时段:${freeSlots.map((s) => `${s.begin.slice(11)}~${s.end.slice(11)}`).join('、')}`]
|
|
918
|
+
: []),
|
|
919
|
+
...failureWarnings,
|
|
481
920
|
],
|
|
482
921
|
});
|
|
483
922
|
}
|
|
@@ -501,40 +940,199 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
501
940
|
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
502
941
|
status: 'NEEDS_SELECTION',
|
|
503
942
|
selectionType: 'room_unavailable',
|
|
943
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'C2' },
|
|
504
944
|
message: '未指定会议室,且按人数/时段没能推荐出可用的空闲会议室。'
|
|
505
945
|
+ '请让用户从下面三条里选一条,**不要**直接提交一个没有地点的会议',
|
|
946
|
+
workflowPending: true,
|
|
506
947
|
options: [
|
|
507
948
|
{ key: 'C1', label: '我指定具体会议室(表单里点选,或告诉我会议室名称/id)', value: { action: 'specify' } },
|
|
508
949
|
{ key: 'C2', label: '改用线上会议(腾讯会议/视频)', value: { action: 'no_room' } },
|
|
509
950
|
{ key: 'C3', label: '先按"地点待定"提交', value: { action: 'allow_no_room' } },
|
|
510
951
|
],
|
|
511
952
|
roomUnavailable: decisions.roomUnavailable,
|
|
953
|
+
...conflictBrief,
|
|
512
954
|
}, {
|
|
513
955
|
warnings: [
|
|
514
956
|
`会议室推荐无结果:${String(decisions.roomUnavailable.reason)}。本次**没有提交**。`,
|
|
515
957
|
'确实不需要会议室(线上会)就带 allowNoRoom: true 重新调用;要指定房间就带 address。',
|
|
958
|
+
...(memberConflicts.length > 0
|
|
959
|
+
? [`⚠️ 另外:参会人在该时段已有 ${memberConflicts.length} 场会议冲突 —— 换时间可能比换房间更划算。`]
|
|
960
|
+
: []),
|
|
961
|
+
...(freeSlots.length > 0
|
|
962
|
+
? [`当天全部参会人都空闲的时段:${freeSlots.map((s) => `${s.begin.slice(11)}~${s.end.slice(11)}`).join('、')}`]
|
|
963
|
+
: []),
|
|
964
|
+
...failureWarnings,
|
|
516
965
|
],
|
|
517
966
|
});
|
|
518
967
|
}
|
|
519
|
-
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
968
|
+
// 兜底:会议室推荐整段没跑出来(roomRec 为空)时,传了房间但**没用户确认**的也不能默默通过。
|
|
969
|
+
if (enrich && addressInput && !roomConfirmed && roomOptions.length === 0 && args.allowNoRoom !== true) {
|
|
970
|
+
const specified = {
|
|
971
|
+
key: 'R1',
|
|
972
|
+
label: `${addressInput}(你指定的,待确认)`,
|
|
973
|
+
value: { ...(addressIsRoomId ? { id: addressId } : {}), name: addressInput, roomConfirmed: true },
|
|
974
|
+
reason: '你传入的会议室,未提交、未确认',
|
|
975
|
+
};
|
|
976
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
977
|
+
status: 'NEEDS_SELECTION',
|
|
978
|
+
selectionType: 'room',
|
|
979
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'R1' },
|
|
980
|
+
message: '你传入的会议室未经用户确认,且本次没能推荐出其它候选 —— '
|
|
981
|
+
+ '请让用户确认这一间,或换一间 / 改时段',
|
|
982
|
+
workflowPending: true,
|
|
983
|
+
options: [specified],
|
|
984
|
+
recommended: specified.value,
|
|
985
|
+
hint: '用户确认用这间就把 value 原样带回(含 roomConfirmed: true)',
|
|
986
|
+
}, {
|
|
987
|
+
warnings: ['会议室未经用户确认,本次**没有**提交。', ...failureWarnings],
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
// ══════════════ 流程闸门(2026-10-30 顺序调整:放在四要素之后) ══════════════
|
|
991
|
+
// 用户要求:「先确认 时间/人/地点/名称 这四要素,再选流程发起」,不是反过来。
|
|
992
|
+
// 理由:流程只是承载体,用户先要确定"这会到底开不开、什么时候开、在哪开、谁参加",
|
|
993
|
+
// 到这一步都定了,最后再让他挑"用哪条流程走审批"。所以时间/房间闸门都在上面,
|
|
994
|
+
// 到这里四要素已经齐了(时间带 timeConfirmed、地点带 roomConfirmed),只剩流程没定。
|
|
995
|
+
const resolved = await resolveWorkflowId(env, args);
|
|
996
|
+
// 地点还原成**人看得懂的名字**(address 入参可能是 id):用户看确认摘要不能是一串数字。
|
|
997
|
+
const confirmedAddressId = String(addressInput ?? '').trim();
|
|
998
|
+
const confirmedAddressLabel = confirmedAddressId === ''
|
|
999
|
+
? ''
|
|
1000
|
+
: (String(roomOptions.find((item) => String(item.value?.id ?? '') === confirmedAddressId)
|
|
1001
|
+
?.value?.name ?? '') || confirmedAddressId);
|
|
1002
|
+
const confirmedWhen = `${beginText}~${String(endText).slice(11) || endText}`;
|
|
1003
|
+
let candidates = resolved.candidates;
|
|
1004
|
+
workflowId = resolved.workflowId;
|
|
1005
|
+
// 候选来源决定文案:固化配置(带环境名)/ 调用方传入 / 应用设置配置。
|
|
1006
|
+
let sourceLabel = resolved.source === 'argument'
|
|
1007
|
+
? '调用方传入'
|
|
1008
|
+
: (resolved.source === 'fixed'
|
|
1009
|
+
? `固化配置(${resolved.label ?? '默认'})`
|
|
1010
|
+
: '应用设置配置');
|
|
1011
|
+
let probes = [];
|
|
1012
|
+
let loadedForms = new Map();
|
|
1013
|
+
// 固化自用场景:多个流程都能发起时自动取优先级最高的,不再打断用户去选(2026-10-30 要求)。
|
|
1014
|
+
const autoPick = shouldAutoPickFlow(args);
|
|
1015
|
+
if (!workflowId && candidates.length > 1) {
|
|
1016
|
+
// 有多个建会流程候选:**逐个探测当前人员能否发起**(createForm 能载入 = 有发起权限)。
|
|
1017
|
+
// 并行探测:每个流程一次 createForm(内含多次 OA 请求),串行会成倍放大等待。
|
|
1018
|
+
let probed = await probeWorkflowCandidates(env, candidates);
|
|
1019
|
+
probes = probed.probes;
|
|
1020
|
+
loadedForms = probed.loadedForms;
|
|
1021
|
+
let launchable = probes.filter((probe) => probe.launchable);
|
|
1022
|
+
if (launchable.length === 0 && resolved.source === 'fixed') {
|
|
1023
|
+
// 固化 id 在当前环境一个都发起不了(换租户/换环境时会出现)→ 回退应用设置再探一轮,
|
|
1024
|
+
// 别因为固化就把"流程建会"这条路堵死。
|
|
1025
|
+
const settings = await fetchEbAppSettings(env, 'meeting.eb.meeting.create.flow.prepare');
|
|
1026
|
+
const fallbackIds = String(settings.mtNewWf ?? '')
|
|
1027
|
+
.split(/[,;,;\s]+/)
|
|
1028
|
+
.map((item) => item.trim())
|
|
1029
|
+
.filter((item) => item !== '');
|
|
1030
|
+
if (fallbackIds.length > 0) {
|
|
1031
|
+
probed = await probeWorkflowCandidates(env, fallbackIds);
|
|
1032
|
+
probes = probed.probes;
|
|
1033
|
+
loadedForms = probed.loadedForms;
|
|
1034
|
+
candidates = fallbackIds;
|
|
1035
|
+
sourceLabel = '应用设置配置';
|
|
1036
|
+
launchable = probes.filter((probe) => probe.launchable);
|
|
1037
|
+
}
|
|
533
1038
|
}
|
|
534
|
-
|
|
535
|
-
|
|
1039
|
+
if (launchable.length === 0) {
|
|
1040
|
+
// 一个都发不起 → 就是"没有流程发起权限"(2026-10-30 用户要求明确提示,别让 agent 反复重试或改走直连)。
|
|
1041
|
+
throw meetingValidationError('flow_not_launchable', `当前账号**没有发起建会流程的权限**:${sourceLabel}的 ${candidates.length} 个建会流程都发起不了`
|
|
1042
|
+
+ `(无发起权限或流程已停用)——${probes.map((probe) => `${probe.workflowId}(${probe.reason ?? '不可发起'})`).join(';')}。`
|
|
1043
|
+
+ '请如实告知用户:需要找管理员开通该流程的发起权限,或请有权限的同事代为发起;'
|
|
1044
|
+
+ '**不要**改用直连建会绕过流程管控。', { probes });
|
|
1045
|
+
}
|
|
1046
|
+
if (launchable.length > 1 && !autoPick) {
|
|
1047
|
+
// 仍有多个可选 → 交给用户挑,不替用户决定(用户明确要求"以选择的方式")
|
|
1048
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
1049
|
+
status: 'NEEDS_SELECTION',
|
|
1050
|
+
// ⚠️ 以前漏了这个字段(agent 拿到 undefined 无法判断是哪类选择,2026-10-30 真机发现)。
|
|
1051
|
+
selectionType: 'flow',
|
|
1052
|
+
// 明确告诉调用方"这是一道选择题,请用选项控件呈现",不要降级成文本列表
|
|
1053
|
+
// (2026-10-30 用户反馈:agent 把会议室候选写成文本 + "需要你指定一间",没给选项卡)。
|
|
1054
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'W1' },
|
|
1055
|
+
// 四要素已经确认到这一步了:摘要一起给出去,用户只需点一下流程,就能直接提交。
|
|
1056
|
+
confirmed: {
|
|
1057
|
+
mt_name: String(args.mt_name ?? ''),
|
|
1058
|
+
begin_datetime: beginText,
|
|
1059
|
+
end_datetime: endText,
|
|
1060
|
+
address: confirmedAddressId || null,
|
|
1061
|
+
addressName: confirmedAddressLabel || null,
|
|
1062
|
+
hrm_members: memberIds,
|
|
1063
|
+
},
|
|
1064
|
+
message: `会议要素已确认(${String(args.mt_name ?? '')} · ${confirmedWhen}`
|
|
1065
|
+
+ `${confirmedAddressLabel ? ` · ${confirmedAddressLabel}` : ' · 无会议室'});`
|
|
1066
|
+
+ `当前账号对 ${sourceLabel}的 ${candidates.length} 个建会流程**都有发起权限**,`
|
|
1067
|
+
+ '请让用户最后选一下「用哪条流程发起」—— **不要默认取第一个**(各流程表单字段可能完全不同,'
|
|
1068
|
+
+ '选错会漏字段;2026-10-29 真实事故:取第一个导致字段全填不进另一条流程,'
|
|
1069
|
+
+ '用户以为"系统只支持一条流程")。⚠️ 只有其中 1 个有权限时 CLI 会直接发起,不会走到这一步',
|
|
1070
|
+
options: launchable.map((probe, index) => ({
|
|
1071
|
+
key: `W${index + 1}`,
|
|
1072
|
+
label: probe.name ? `${probe.name}(${probe.workflowId})` : probe.workflowId,
|
|
1073
|
+
// ⛔ 把已确认的四要素一起带回去:用户点选流程后**原样回传 value** 就能直接出确认摘要,
|
|
1074
|
+
// 不用再走一遍时间/房间闸门(否则"先定四要素再选流程"会多出一轮无效往返)。
|
|
1075
|
+
value: {
|
|
1076
|
+
workflowId: probe.workflowId,
|
|
1077
|
+
begin_datetime: beginText,
|
|
1078
|
+
end_datetime: endText,
|
|
1079
|
+
timeConfirmed: true,
|
|
1080
|
+
...(confirmedAddressId
|
|
1081
|
+
? { address: confirmedAddressId, roomConfirmed: true }
|
|
1082
|
+
: {}),
|
|
1083
|
+
},
|
|
1084
|
+
reason: '当前账号有发起权限',
|
|
1085
|
+
// 把该流程的字段名如实带出来:不同流程字段差异很大(有的有设备需求/涉密项目,有的没有),
|
|
1086
|
+
// 用户要靠这个才能选对(2026-10-29 用户反馈:选错方式后字段全填不进去)。
|
|
1087
|
+
fields: (loadedForms.get(probe.workflowId)?.fields ?? []).map((f) => f.name).filter(Boolean),
|
|
1088
|
+
})),
|
|
1089
|
+
probes,
|
|
1090
|
+
}, {
|
|
1091
|
+
warnings: [
|
|
1092
|
+
`${sourceLabel}:共 ${candidates.length} 个建会流程,其中 ${launchable.length} 个当前账号可发起;`
|
|
1093
|
+
+ '**必须把这几个流程做成选项让用户挑一个,禁止自己替用户选**;选定后带单个 workflowId 重新 prepare。',
|
|
1094
|
+
...probes.filter((probe) => !probe.launchable)
|
|
1095
|
+
.map((probe) => `流程 ${probe.workflowId} 当前账号无法发起(${probe.reason ?? '无权限'}),已排除`),
|
|
1096
|
+
],
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
workflowId = launchable[0].workflowId;
|
|
1100
|
+
if (launchable.length === 1 && candidates.length > 1) {
|
|
1101
|
+
// 只有一条有权限 → 直接发起,但要说明"为什么没让你选"(其余流程是无权限,不是被忽略)。
|
|
1102
|
+
const only = launchable[0];
|
|
1103
|
+
flowPickNote = `${sourceLabel}:共 ${candidates.length} 个建会流程,当前账号只有`
|
|
1104
|
+
+ `「${only.name ?? only.workflowId}」(${only.workflowId})有发起权限,已直接用它发起`;
|
|
1105
|
+
}
|
|
1106
|
+
if (launchable.length > 1) {
|
|
1107
|
+
// 固化自用:多个可发起时按候选顺序自动取第一个 —— 但必须留痕说明"还有别的可用流程",
|
|
1108
|
+
// 不能默默替用户决定(2026-10-30:用户要的是"直接发起",不是"什么都别告诉我")。
|
|
1109
|
+
const picked = launchable[0];
|
|
1110
|
+
const others = launchable.slice(1)
|
|
1111
|
+
.map((probe) => (probe.name ? `${probe.name}(${probe.workflowId})` : probe.workflowId));
|
|
1112
|
+
flowPickNote = `${sourceLabel}:共 ${candidates.length} 个建会流程,当前账号可发起 ${launchable.length} 个,`
|
|
1113
|
+
+ `已按优先级自动选用「${picked.name ?? picked.workflowId}」(${picked.workflowId});`
|
|
1114
|
+
+ `其他可用流程:${others.join('、')} —— 要用它们请显式传 workflowId。`;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
// 单一候选(固化只配了 1 条 / 应用设置只配了 1 条 / 调用方指定了 1 条)时没有走"探测"分支,
|
|
1118
|
+
// 载入失败同样是"没权限或流程停用" —— 必须给同一句人话,而不是把表单错误原样抛出去
|
|
1119
|
+
// (2026-10-30:用户要求"都没权限就明确提示没有发起权限")。
|
|
1120
|
+
let form = loadedForms.get(workflowId);
|
|
1121
|
+
if (!form) {
|
|
1122
|
+
try {
|
|
1123
|
+
form = await loadCreateForm(env, workflowId);
|
|
1124
|
+
}
|
|
1125
|
+
catch (error) {
|
|
1126
|
+
throw meetingValidationError('flow_not_launchable', `当前账号**没有发起建会流程的权限**(或流程已停用):建会流程 ${workflowId} 的表单载入失败 —— `
|
|
1127
|
+
+ `${String(error?.message ?? error).slice(0, 200)}。`
|
|
1128
|
+
+ '请如实告知用户:需要找管理员开通该流程的发起权限,或请有权限的同事代为发起;'
|
|
1129
|
+
+ '**不要**改用直连建会绕过流程管控。', { workflowId });
|
|
536
1130
|
}
|
|
537
1131
|
}
|
|
1132
|
+
if (!form.continuation) {
|
|
1133
|
+
throw meetingValidationError('flow_context_missing', '建会流程表单没有返回 continuation,无法继续提交');
|
|
1134
|
+
}
|
|
1135
|
+
// 参会人冲突 / 空闲时段已在选房之前处理完(见上方 conflictBrief)。
|
|
538
1136
|
decisions.timings = timings;
|
|
539
1137
|
const shaped = await shapeBrowserValues(env, {
|
|
540
1138
|
mt_name: args.mt_name,
|
|
@@ -553,10 +1151,39 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
553
1151
|
? args.extraFields
|
|
554
1152
|
: {}),
|
|
555
1153
|
});
|
|
556
|
-
|
|
1154
|
+
// datakey 固化:上次解析出的「语义键 → fieldId」直接复用(表单字段改名也不影响),
|
|
1155
|
+
// 本次解析结果再写回,形成"猜一次、之后一直用 datakey"的效果。
|
|
1156
|
+
const preferredFields = await readFlowFieldMap(env, workflowId);
|
|
1157
|
+
const { main, mapped, unmapped, resolved: resolvedFields } = mapFields(form.fields, shaped.values, preferredFields);
|
|
1158
|
+
await writeFlowFieldMap(env, workflowId, form.workflowName, resolvedFields);
|
|
557
1159
|
const warnings = [];
|
|
1160
|
+
// 固化自动选流程要留痕:调用方/用户能看到"用的是哪条、还有哪些可选"。
|
|
1161
|
+
if (flowPickNote)
|
|
1162
|
+
warnings.push(flowPickNote);
|
|
558
1163
|
for (const note of shaped.notes)
|
|
559
1164
|
warnings.push(note);
|
|
1165
|
+
// datakey 直填(2026-10-30 用户建议):按表单字段的 **datakey** 直接给值,跳过中文名匹配。
|
|
1166
|
+
// 用在「字段名带提示语、别名表猜不到」或「租户自建字段」场景,比按中文名匹配稳。
|
|
1167
|
+
if (args.fieldValues && typeof args.fieldValues === 'object' && !Array.isArray(args.fieldValues)) {
|
|
1168
|
+
const knownIds = new Set(form.fields.map((field) => field.id));
|
|
1169
|
+
for (const [fieldId, raw] of Object.entries(args.fieldValues)) {
|
|
1170
|
+
if (raw === undefined || raw === null || raw === '')
|
|
1171
|
+
continue;
|
|
1172
|
+
if (!knownIds.has(fieldId)) {
|
|
1173
|
+
warnings.push(`fieldValues 里的 datakey「${fieldId}」不在该流程表单里,已忽略(先用 prepare 不带 fieldValues 跑一次,看 unmapped/表单字段清单)`);
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
main[fieldId] = raw;
|
|
1177
|
+
mapped[`datakey:${fieldId}`] = fieldId;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// 增强检测没跑成功必须如实报出:否则调用方会把"未核验"当成"已核验且无冲突"。
|
|
1181
|
+
if (enrichFailures.length > 0) {
|
|
1182
|
+
decisions.enrichFailures = enrichFailures;
|
|
1183
|
+
for (const failure of enrichFailures) {
|
|
1184
|
+
warnings.push(`⚠️ ${failure} —— **这不代表没有冲突**,请如实告知用户该项未核验,不要声称"时间已确认空闲"`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
560
1187
|
const picked = decisions.autoRoom?.picked;
|
|
561
1188
|
// ⚠️ 只在"用户没指定会议室、由 CLI 自动选"时才说这句。
|
|
562
1189
|
// 以前无条件输出"未指定会议室 → 已自动选择",用户明明传了 address 也被这么讲(2026-10-29 真机踩到);
|
|
@@ -567,19 +1194,32 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
567
1194
|
+ `另有候选 ${(decisions.roomOptions ?? []).slice(1).map((item) => item.label).join(' / ') || '无'}`);
|
|
568
1195
|
}
|
|
569
1196
|
const slotOptions = decisions.slotOptions ?? [];
|
|
570
|
-
|
|
571
|
-
|
|
1197
|
+
const memberConflictCount = decisions.memberConflicts?.length ?? 0;
|
|
1198
|
+
if (memberConflictCount > 0 && slotOptions.length > 0) {
|
|
1199
|
+
// ⚠️ 以前只要 slotOptions 非空就断言"参会人时间有冲突" —— 而 slotOptions 只是"当天全部空闲时段",
|
|
1200
|
+
// 当前时段本身空闲时也会命中,于是出现"memberConflicts=0 却提示有冲突"的自相矛盾(2026-10-30 真机)。
|
|
1201
|
+
warnings.push(`参会人时间有冲突(${memberConflictCount} 条占用);当天所有人都空的时段:`
|
|
1202
|
+
+ slotOptions.map((item) => `[${item.key}] ${item.label}`).join(';')
|
|
572
1203
|
+ '——请用选项方式让用户挑(选定后重新 prepare 并改时间)。');
|
|
573
1204
|
}
|
|
1205
|
+
else if (memberConflictCount === 0 && slotOptions.length > 0) {
|
|
1206
|
+
warnings.push('该时段参会人没有会议/日程占用(已核对会议回执与日程)。'
|
|
1207
|
+
+ `当天其他共同空闲时段:${slotOptions.map((item) => `[${item.key}] ${item.label}`).join(';')}`);
|
|
1208
|
+
}
|
|
574
1209
|
else if (decisions.dayBlocked?.summary) {
|
|
575
1210
|
warnings.push(String(decisions.dayBlocked.summary));
|
|
576
1211
|
}
|
|
577
|
-
|
|
578
|
-
|
|
1212
|
+
// mt_name 不算"漏填":表单没有「会议名称」字段时它已经用作流程标题(下面单独说明),
|
|
1213
|
+
// 混在 unmapped 里会让 agent 以为还需要额外补字段。
|
|
1214
|
+
const unmappedReal = unmapped.filter((name) => name !== 'mt_name');
|
|
1215
|
+
if (unmappedReal.length > 0) {
|
|
1216
|
+
warnings.push(`以下字段没有在建会流程表单里找到同名字段,未自动填写:${unmappedReal.join('、')}`
|
|
579
1217
|
+ `(表单现有字段:${form.fields.map((field) => field.name).join('、')})`);
|
|
580
1218
|
}
|
|
581
1219
|
if (!main[form.fields.find((field) => field.name === '会议名称')?.id ?? ''] && !mapped.mt_name) {
|
|
582
|
-
|
|
1220
|
+
// 不是配错:EB 的两条建会流程里,「会议申请流程」本身就没有「会议名称」字段,用流程标题代替。
|
|
1221
|
+
warnings.push('该建会流程的表单没有「会议名称」字段(部分流程用流程标题代替会议名称,属正常配置,不是流程配错)。'
|
|
1222
|
+
+ '如需会议名称独立成字段,请改选带该字段的流程(如「会议申请流程(NEW)」)。');
|
|
583
1223
|
}
|
|
584
1224
|
const session = await env.host.getSession();
|
|
585
1225
|
const continuation = await env.continuation.issue({
|
|
@@ -593,6 +1233,8 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
593
1233
|
mtName: String(args.mt_name),
|
|
594
1234
|
data: { main },
|
|
595
1235
|
surfaceContinuation: form.continuation,
|
|
1236
|
+
// 供 apply 在「会议还没生成」时按申请单信息预生成邀请函(预置函)
|
|
1237
|
+
invitationFields: shaped.readable,
|
|
596
1238
|
},
|
|
597
1239
|
fingerprint: JSON.stringify({ workflowId, main }),
|
|
598
1240
|
}, 10 * 60 * 1000);
|
|
@@ -602,6 +1244,9 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
602
1244
|
workflowId,
|
|
603
1245
|
workflowCandidates: candidates,
|
|
604
1246
|
...(probes.length > 0 ? { workflowProbes: probes } : {}),
|
|
1247
|
+
...(flowPickNote
|
|
1248
|
+
? { workflowAutoPicked: { workflowId, source: resolved.source, note: flowPickNote } }
|
|
1249
|
+
: {}),
|
|
605
1250
|
requestName: String(args.mt_name),
|
|
606
1251
|
fieldMapping: mapped,
|
|
607
1252
|
formData: { main },
|
|
@@ -613,6 +1258,49 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
613
1258
|
}, warnings.length > 0 ? { warnings } : {});
|
|
614
1259
|
},
|
|
615
1260
|
};
|
|
1261
|
+
/**
|
|
1262
|
+
* 按申请单已知信息预生成邀请函(**预置函**)。
|
|
1263
|
+
*
|
|
1264
|
+
* 会议还没生成时也必须给一份:流程还在审批、或小e 环境根本不回写服务端(BROWSER_FILL)时,
|
|
1265
|
+
* 用户要的是"提交完立刻能拿到文件",而不是等审批通过再回来补。取不到的字段由模板渲染成【待补充】。
|
|
1266
|
+
*/
|
|
1267
|
+
export async function renderPreInvitation(payload, env) {
|
|
1268
|
+
const fields = payload?.invitationFields ?? {};
|
|
1269
|
+
const started = Date.now();
|
|
1270
|
+
try {
|
|
1271
|
+
const { getMeetingOperation } = await import('../registry.js');
|
|
1272
|
+
const render = getMeetingOperation('meeting.invitation.render');
|
|
1273
|
+
const rendered = await render.handler({
|
|
1274
|
+
mt_name: String(fields.mt_name ?? payload?.mtName ?? '').trim(),
|
|
1275
|
+
begin_datetime: String(fields.begin_datetime ?? '').trim(),
|
|
1276
|
+
end_datetime: String(fields.end_datetime ?? '').trim(),
|
|
1277
|
+
address: String(fields.address ?? fields.customize_address ?? '').trim(),
|
|
1278
|
+
hrm_members: String(fields.hrm_members ?? '').trim(),
|
|
1279
|
+
mt_desc: String(fields.mt_desc ?? '').trim(),
|
|
1280
|
+
org_members: String(fields.org_members ?? '').trim(),
|
|
1281
|
+
caller: String(fields.caller ?? '').trim(),
|
|
1282
|
+
contacter: String(fields.contacter ?? '').trim(),
|
|
1283
|
+
total_member: String(fields.total_member ?? '').trim(),
|
|
1284
|
+
}, env);
|
|
1285
|
+
// 用 pending 标记「这份是按申请单预生成的,不是会议最终数据」:
|
|
1286
|
+
// 调用方据此提示用户"会议生成后可用 mtId 重新生成一版拿完整字段"。
|
|
1287
|
+
return {
|
|
1288
|
+
invitation: { pending: true, preRendered: true, ...(rendered?.data ?? {}) },
|
|
1289
|
+
ms: Date.now() - started,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
catch (error) {
|
|
1293
|
+
return {
|
|
1294
|
+
invitation: {
|
|
1295
|
+
pending: true,
|
|
1296
|
+
preRendered: false,
|
|
1297
|
+
ok: false,
|
|
1298
|
+
error: String(error?.message ?? error).slice(0, 200),
|
|
1299
|
+
},
|
|
1300
|
+
ms: Date.now() - started,
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
616
1304
|
const ebMeetingCreateFlowApply = {
|
|
617
1305
|
name: 'meeting.eb.meeting.create.flow.apply',
|
|
618
1306
|
resource: 'eb',
|
|
@@ -676,6 +1364,35 @@ const ebMeetingCreateFlowApply = {
|
|
|
676
1364
|
});
|
|
677
1365
|
}
|
|
678
1366
|
if (!requestId) {
|
|
1367
|
+
const submitStatus = String(submittedData.status ?? submitted?.status ?? '');
|
|
1368
|
+
if (submitStatus === 'BROWSER_FILL') {
|
|
1369
|
+
// 小e 环境:工作流**不向服务端写入**,改由宿主的浏览器页面完成填单(2026-10-15 裁定)。
|
|
1370
|
+
// 所以这里永远拿不到 requestId / 会议 id —— 这不是失败,但也**不是"流程已提交"**,
|
|
1371
|
+
// 不能报错把整轮卡死(用户要的表单就在 finalMarkdown 里)。
|
|
1372
|
+
// ⛔ 邀请函必须照给:它是预置函,按申请单已知信息生成,不依赖会议是否已生成。
|
|
1373
|
+
const pre = await renderPreInvitation(payload, env);
|
|
1374
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.apply', {
|
|
1375
|
+
status: 'BROWSER_FILL',
|
|
1376
|
+
workflowId: payload.workflowId,
|
|
1377
|
+
requestName: payload.requestName,
|
|
1378
|
+
requestId: null,
|
|
1379
|
+
meetingId: null,
|
|
1380
|
+
contextId: submittedData.contextId ?? null,
|
|
1381
|
+
// 宿主要靠这段 <browsertool> 才能把表单拉到浏览器里填,必须原样透出
|
|
1382
|
+
finalMarkdown: submittedData.finalMarkdown ?? null,
|
|
1383
|
+
message: String(submittedData.message ?? ''),
|
|
1384
|
+
invitation: pre.invitation,
|
|
1385
|
+
timings: { submitMs: Date.now() - submitStartedAt, invitationMs: pre.ms },
|
|
1386
|
+
}, {
|
|
1387
|
+
warnings: [
|
|
1388
|
+
'当前是小e 环境:本次**没有向服务端写入**,建会流程改由浏览器页面完成填单(见 finalMarkdown)。'
|
|
1389
|
+
+ '因此还没有 requestId / 会议 id,也**不能说"流程已提交"**。',
|
|
1390
|
+
`邀请函已按申请单信息**预生成**(预置函,${String(pre.invitation.filePath ?? '见 filePath')}):`
|
|
1391
|
+
+ '请下载后人工核对、自行发送;流程审批通过、会议生成后可用 mtId 再跑一次 meeting.invitation.render 拿完整字段版本。',
|
|
1392
|
+
'⛔ 不要因为"没有 requestId / 没有会议 id"重复 apply —— 填单要在那个浏览器页面里点提交。',
|
|
1393
|
+
],
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
679
1396
|
// 没拿到 requestId 时**不能**说"流程已提交":常见原因是必填项缺失(工作流只在 saveFormData 前校验),
|
|
680
1397
|
// 也可能只是存了草稿。把缺失项原样交回去,让调用方补字段,而不是含糊地说"已提交"。
|
|
681
1398
|
const missing = submittedData.missingFields ?? submittedData.requiredMissing ?? submittedData.missing ?? [];
|
|
@@ -741,9 +1458,16 @@ const ebMeetingCreateFlowApply = {
|
|
|
741
1458
|
}
|
|
742
1459
|
}
|
|
743
1460
|
else {
|
|
1461
|
+
// ⛔ 不等于"没有邀请函"。邀请函本质是**预置函**(项目人员下载后自己改、自己发,
|
|
1462
|
+
// 2026-10-30 用户明确要求),所以会议还在审批、尚未生成时,也要用申请单已知信息
|
|
1463
|
+
// 先出一份,取不到的字段渲染成【待补充】。这样调用方/用户提交完立刻就能拿到文件。
|
|
1464
|
+
const pre = await renderPreInvitation(payload, env);
|
|
1465
|
+
invitation = pre.invitation;
|
|
1466
|
+
timings.invitationMs = pre.ms;
|
|
744
1467
|
warnings.push(`流程已提交(requestId=${requestId || '未返回'}),但 ${waitSeconds}s 内没在会议表里查到「${payload.mtName}」——`
|
|
745
|
-
+ '
|
|
746
|
-
+ '
|
|
1468
|
+
+ '多数情况是还在审批中。已按申请单信息**预生成**邀请函(属预置函,请人工核对后自行发送);'
|
|
1469
|
+
+ '审批通过、会议生成后可用 mtId 再跑一次 meeting.invitation.render 拿到完整字段版本。'
|
|
1470
|
+
+ '⛔ 本 operation 不重复提交流程 —— 不要因为"没有会议 id"就重复 apply。');
|
|
747
1471
|
}
|
|
748
1472
|
return meetingSuccess('meeting.eb.meeting.create.flow.apply', {
|
|
749
1473
|
status: 'COMPLETE',
|
|
@@ -778,8 +1502,15 @@ const ebMeetingCreateFlowQuick = {
|
|
|
778
1502
|
requiresConfirmation: true,
|
|
779
1503
|
inputSchema: schemaOf({
|
|
780
1504
|
mt_name: { type: 'string', description: '会议名称(必填)' },
|
|
781
|
-
begin_datetime: {
|
|
782
|
-
|
|
1505
|
+
begin_datetime: {
|
|
1506
|
+
...SCHEMA_DATETIME_MINUTE,
|
|
1507
|
+
description: '开始时间(yyyy-MM-dd HH:mm)。**可以不传**:不传会按参会人与会议室空闲情况推荐 3 个时段让你选;'
|
|
1508
|
+
+ '只传开始时间、不传结束时间时,按 durationMinutes(默认 60 分钟)自动算结束时间',
|
|
1509
|
+
},
|
|
1510
|
+
end_datetime: {
|
|
1511
|
+
...SCHEMA_DATETIME_MINUTE,
|
|
1512
|
+
description: '结束时间(yyyy-MM-dd HH:mm);不传则按 durationMinutes 从开始时间推算(默认 60 分钟)',
|
|
1513
|
+
},
|
|
783
1514
|
mt_desc: { type: 'string', description: '会议内容/说明' },
|
|
784
1515
|
address: { type: 'string', description: '会议室(id 或名称;不传则返回候选让用户选,不会自动决定)' },
|
|
785
1516
|
customize_address: { type: 'string', description: '自定义会议地点' },
|
|
@@ -787,6 +1518,11 @@ const ebMeetingCreateFlowQuick = {
|
|
|
787
1518
|
total_member: { type: 'number', description: '参会人数' },
|
|
788
1519
|
caller: { type: 'string', description: '召集人' },
|
|
789
1520
|
contacter: { type: 'string', description: '联系人' },
|
|
1521
|
+
mt_type: {
|
|
1522
|
+
type: 'string',
|
|
1523
|
+
description: '会议类型:可传类型名称或 id(先用 `meeting.eb.type.list` 查有哪些)。'
|
|
1524
|
+
+ '**很多租户的流程表单把「会议类型」设为必填**,不传会被 `workflow.createRequest` 打回缺字段',
|
|
1525
|
+
},
|
|
790
1526
|
other_remark: { type: 'string', description: '备注' },
|
|
791
1527
|
workflowId: {
|
|
792
1528
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
|
@@ -794,6 +1530,16 @@ const ebMeetingCreateFlowQuick = {
|
|
|
794
1530
|
},
|
|
795
1531
|
autoRoom: { type: 'boolean', description: '默认 false:不指定会议室时返回候选让用户选;传 true 才自动采用推荐会议室' },
|
|
796
1532
|
allowNoRoom: { type: 'boolean', description: '确实不需要会议室(线上会等)时传 true' },
|
|
1533
|
+
timeConfirmed: {
|
|
1534
|
+
type: 'boolean',
|
|
1535
|
+
description: '时间的"用户已确认"标记:用户原话明确给了时间、或在时段选项里点选后原样回传 value(自带标记)时才传 true;'
|
|
1536
|
+
+ '不传 = 未确认,CLI 会返回时段选项让用户点选',
|
|
1537
|
+
},
|
|
1538
|
+
roomConfirmed: {
|
|
1539
|
+
type: 'boolean',
|
|
1540
|
+
description: '会议室的"用户已确认"标记:用户原话明确指定、或在房间选项里点选后原样回传 value(自带标记)时才传 true;'
|
|
1541
|
+
+ '不传 = 未确认,CLI 会把指定房间和候选一起列出来让用户点选',
|
|
1542
|
+
},
|
|
797
1543
|
confirm: { type: 'boolean', description: '必须显式 true 才会真正提交' },
|
|
798
1544
|
waitSeconds: { type: 'number', description: '提交后等待会议生成的秒数(默认 20;算法是立即查一次 + 5s 再查一次,仍未生成就返回 pending)' },
|
|
799
1545
|
enrich: { type: 'boolean', description: '是否顺带做会议室推荐/冲突检测/空闲时段(默认 true;人、时间、房间都已确定时传 false 可显著加快)' },
|
|
@@ -801,7 +1547,29 @@ const ebMeetingCreateFlowQuick = {
|
|
|
801
1547
|
type: 'object',
|
|
802
1548
|
description: '按流程表单字段名填额外字段(租户自建字段用这个口子带进去),形如 {"参会客户": "xx公司"}',
|
|
803
1549
|
},
|
|
804
|
-
|
|
1550
|
+
dateFrom: {
|
|
1551
|
+
type: 'string',
|
|
1552
|
+
description: '没给具体时间时按哪一天推荐时段(yyyy-MM-dd;不传默认明天)',
|
|
1553
|
+
},
|
|
1554
|
+
durationMinutes: {
|
|
1555
|
+
type: 'number',
|
|
1556
|
+
description: '会议时长(分钟,默认 60):只给了开始时间时用来算结束时间;推荐时段时也按这个时长找',
|
|
1557
|
+
},
|
|
1558
|
+
workStartHour: { type: 'number', description: '推荐时段的最早小时(默认 9)' },
|
|
1559
|
+
workEndHour: { type: 'number', description: '推荐时段的最晚小时(默认 18)' },
|
|
1560
|
+
fieldValues: {
|
|
1561
|
+
type: 'object',
|
|
1562
|
+
additionalProperties: true,
|
|
1563
|
+
description: '按**表单字段 datakey(fieldId)**直接给值,形如 {"1325677252711735881": "研讨会"};'
|
|
1564
|
+
+ '优先级最高,适合字段名带提示语或租户自建字段。datakey 见 prepare 的 preview.mapped',
|
|
1565
|
+
},
|
|
1566
|
+
flowChoice: {
|
|
1567
|
+
type: 'string',
|
|
1568
|
+
enum: ['auto', 'ask'],
|
|
1569
|
+
description: '多个建会流程**都有发起权限**时:默认(不传 / ask)= 返回选项让用户挑;'
|
|
1570
|
+
+ 'auto = 自动取优先级最高的。只有 1 个有权限时直接发起',
|
|
1571
|
+
},
|
|
1572
|
+
}, { required: ['mt_name', 'confirm'] }),
|
|
805
1573
|
handler: async (args, env) => {
|
|
806
1574
|
if (args.confirm !== true) {
|
|
807
1575
|
throw meetingValidationError('confirmation_required', '一步建会会直接提交流程,必须显式传 confirm=true;如只想看方案,请用 meeting.eb.meeting.create.flow.prepare');
|
|
@@ -833,6 +1601,10 @@ const ebMeetingCreateFlowQuick = {
|
|
|
833
1601
|
requestId: applied?.data?.requestId,
|
|
834
1602
|
meetingId: applied?.data?.meetingId ?? null,
|
|
835
1603
|
invitation: applied?.data?.invitation ?? null,
|
|
1604
|
+
// 小e 环境(BROWSER_FILL)不写服务端,表单靠这段 <browsertool> 在浏览器里填 —— 必须一起透出,
|
|
1605
|
+
// 否则宿主拿不到表单,用户看到的就是"提交了但没反应"。
|
|
1606
|
+
...(applied?.data?.contextId ? { contextId: (applied?.data).contextId } : {}),
|
|
1607
|
+
...(applied?.data?.finalMarkdown ? { finalMarkdown: (applied?.data).finalMarkdown } : {}),
|
|
836
1608
|
decisions: prepared?.data?.preview?.decisions ?? null,
|
|
837
1609
|
timings: {
|
|
838
1610
|
...(prepared?.data?.preview?.decisions?.timings ?? {}),
|
|
@@ -845,7 +1617,148 @@ const ebMeetingCreateFlowQuick = {
|
|
|
845
1617
|
}, warnings.length > 0 ? { warnings } : {});
|
|
846
1618
|
},
|
|
847
1619
|
};
|
|
1620
|
+
/**
|
|
1621
|
+
* 建会建议(只读):给出「所有人都空闲 **且** 该时段有可用会议室」的时间候选。
|
|
1622
|
+
*
|
|
1623
|
+
* 为什么需要(2026-10-30 用户要求):用户只说"明天"没说几点时,以前的处理只能是弹一个空输入框
|
|
1624
|
+
* 让他自己填——等于把"找时间"这件最费脑子的事丢回给用户。而这信息 CLI 本来就能算:
|
|
1625
|
+
* `findCommonFreeSlots`(人员维度:会议回执 + 日程)→ `recommendRooms`(该时段空闲的会议室,含容量筛选)
|
|
1626
|
+
* 组合起来就能直接给"明天 10:00–11:00,测试会议室1 空着"这种答案,不需要人填。
|
|
1627
|
+
*
|
|
1628
|
+
* ⛔ 用户明确要求两条都必须满足:**人员都空闲** + **会议室也空闲**。
|
|
1629
|
+
* 所以这里会把"该时段没有任何可用会议室"的候选直接剔除,不给"时间有了但没房间"的假选项。
|
|
1630
|
+
*/
|
|
1631
|
+
const ebMeetingCreateSuggest = {
|
|
1632
|
+
name: 'meeting.eb.meeting.create.suggest',
|
|
1633
|
+
resource: 'eb',
|
|
1634
|
+
method: 'meeting.eb.meeting.create.suggest',
|
|
1635
|
+
action: 'meeting.eb.meeting.create.suggest',
|
|
1636
|
+
summary: '建会建议:给定日期范围与参会人,推荐「所有人都空闲 + 该时段有可用会议室」的时间候选(只读,不建会)',
|
|
1637
|
+
capability: 'eb.meeting',
|
|
1638
|
+
risk: 'read',
|
|
1639
|
+
inputSchema: schemaOf({
|
|
1640
|
+
dateFrom: { type: 'string', description: '起始日期(必填,yyyy-MM-dd)' },
|
|
1641
|
+
dateTo: { type: 'string', description: '结束日期(可选,默认同 dateFrom;最多跨 14 天)' },
|
|
1642
|
+
durationMinutes: { type: 'number', description: '会议时长(分钟,默认 60)' },
|
|
1643
|
+
hrm_members: { type: 'string', description: '参会人员(必填,员工 id 或姓名,逗号分隔;姓名会自动反查 id)' },
|
|
1644
|
+
memberCount: { type: 'number', description: '参会人数(可选,用于会议室容量筛选;不传则按 hrm_members 个数)' },
|
|
1645
|
+
workStartHour: { type: 'number', description: '只在几点以后找(默认 9)' },
|
|
1646
|
+
workEndHour: { type: 'number', description: '只在几点以前找(默认 18)' },
|
|
1647
|
+
maxSlots: { type: 'number', description: '最多给几个时间候选(默认 3,最大 8)' },
|
|
1648
|
+
topN: { type: 'number', description: '每个时间候选最多带几间会议室(默认 3)' },
|
|
1649
|
+
includeSchedule: { type: 'boolean', description: '是否把日程(ESB)也算进占用(默认 true)' },
|
|
1650
|
+
}, { required: ['dateFrom', 'hrm_members'] }),
|
|
1651
|
+
handler: async (args, env) => {
|
|
1652
|
+
const dateFrom = String(args.dateFrom ?? '').trim().slice(0, 10);
|
|
1653
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateFrom)) {
|
|
1654
|
+
throw meetingValidationError('datetime_invalid', `dateFrom 必须是 yyyy-MM-dd,收到「${String(args.dateFrom ?? '')}」`);
|
|
1655
|
+
}
|
|
1656
|
+
const dateTo = String(args.dateTo ?? '').trim().slice(0, 10) || dateFrom;
|
|
1657
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) {
|
|
1658
|
+
throw meetingValidationError('datetime_invalid', `dateTo 必须是 yyyy-MM-dd,收到「${String(args.dateTo ?? '')}」`);
|
|
1659
|
+
}
|
|
1660
|
+
const durationMinutes = Number(args.durationMinutes) > 0 ? Number(args.durationMinutes) : 60;
|
|
1661
|
+
const maxSlots = Math.max(1, Math.min(8, Number(args.maxSlots) || 3));
|
|
1662
|
+
const topN = Math.max(1, Math.min(5, Number(args.topN) || 3));
|
|
1663
|
+
// 参会人:纯数字当 id,姓名走 hrm.employee.query 反查(与 prepare 同一套口径)。
|
|
1664
|
+
const rawMembers = String(args.hrm_members ?? '')
|
|
1665
|
+
.split(/[,;,;\s]+/)
|
|
1666
|
+
.map((item) => item.trim())
|
|
1667
|
+
.filter((item) => item !== '');
|
|
1668
|
+
const nameLike = rawMembers.filter((item) => !/^\d+$/.test(item));
|
|
1669
|
+
const nameToId = nameLike.length > 0
|
|
1670
|
+
? await import('./schedule.js')
|
|
1671
|
+
.then((m) => m.resolveMemberIdsByName(env, nameLike))
|
|
1672
|
+
.catch(() => new Map())
|
|
1673
|
+
: new Map();
|
|
1674
|
+
const memberIds = rawMembers.map((item) => nameToId.get(item) ?? item);
|
|
1675
|
+
const unresolved = rawMembers.filter((item) => nameToId.get(item) === undefined && !/^\d+$/.test(item));
|
|
1676
|
+
// 会议人数要含召集人(自己):`hrm_members` 通常只列"其他参会人"。
|
|
1677
|
+
// 用当前登录 userId 判断自己是否已在列表里,避免重复 +1(2026-10-30:只传易鹏时按 1 人算,
|
|
1678
|
+
// 容量下限 2 人的房间被误判"未达建议下限")。
|
|
1679
|
+
const selfId = await env.host.getSession().then((s) => String(s?.userId ?? '')).catch(() => '');
|
|
1680
|
+
const selfIncluded = selfId !== '' && memberIds.includes(selfId);
|
|
1681
|
+
const memberCount = Number(args.memberCount) > 0
|
|
1682
|
+
? Number(args.memberCount)
|
|
1683
|
+
: (memberIds.length > 0 ? memberIds.length + (selfIncluded ? 0 : 1) : undefined);
|
|
1684
|
+
const warnings = [];
|
|
1685
|
+
if (unresolved.length > 0) {
|
|
1686
|
+
warnings.push(`⚠️ 这些参会人没查到员工 id:${unresolved.join('、')} —— 已跳过,其空闲情况**未核验**`);
|
|
1687
|
+
}
|
|
1688
|
+
const free = await findCommonFreeSlots(env, {
|
|
1689
|
+
rangeStart: `${dateFrom} 00:00`,
|
|
1690
|
+
rangeEnd: `${dateTo} 23:59`,
|
|
1691
|
+
durationMinutes,
|
|
1692
|
+
members: memberIds,
|
|
1693
|
+
maxSlots,
|
|
1694
|
+
maxDays: 14,
|
|
1695
|
+
includeSchedule: args.includeSchedule !== false,
|
|
1696
|
+
...(args.workStartHour !== undefined ? { workStartHour: Number(args.workStartHour) } : {}),
|
|
1697
|
+
...(args.workEndHour !== undefined ? { workEndHour: Number(args.workEndHour) } : {}),
|
|
1698
|
+
});
|
|
1699
|
+
// 每个"人空闲"的时段,再并行查该时段的可用会议室(用户要求:会议室也必须空闲)。
|
|
1700
|
+
// 先强制刷新一次台账预热:否则 N 个时段会各自判断 24h 缓存、可能读到旧清单,
|
|
1701
|
+
// 也可能同时发起 N 次全量拉取(2026-10-30:用户刚导入 100+ 间,推荐仍只看到 1 间)。
|
|
1702
|
+
await loadRooms(env, { refresh: true }).catch(() => null);
|
|
1703
|
+
const withRooms = await Promise.all((free.slots ?? []).map(async (slot) => {
|
|
1704
|
+
try {
|
|
1705
|
+
const rec = await recommendRooms(env, {
|
|
1706
|
+
beginDatetime: slot.begin,
|
|
1707
|
+
endDatetime: slot.end,
|
|
1708
|
+
...(memberCount ? { memberCount } : {}),
|
|
1709
|
+
topN,
|
|
1710
|
+
});
|
|
1711
|
+
return {
|
|
1712
|
+
begin: slot.begin,
|
|
1713
|
+
end: slot.end,
|
|
1714
|
+
rooms: rec.candidates.map((candidate) => ({
|
|
1715
|
+
id: candidate.id,
|
|
1716
|
+
name: candidate.name,
|
|
1717
|
+
capacityMax: candidate.capacity.max,
|
|
1718
|
+
reasons: candidate.reasons,
|
|
1719
|
+
})),
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
catch {
|
|
1723
|
+
return { begin: slot.begin, end: slot.end, rooms: [] };
|
|
1724
|
+
}
|
|
1725
|
+
}));
|
|
1726
|
+
// 没有可用会议室的时段直接剔除 —— 不给"时间有了但没房间"的假选项。
|
|
1727
|
+
const usable = withRooms.filter((slot) => slot.rooms.length > 0);
|
|
1728
|
+
if (usable.length === 0) {
|
|
1729
|
+
const why = (free.slots ?? []).length === 0
|
|
1730
|
+
? '该范围内所有参会人没有共同的空闲时段'
|
|
1731
|
+
: `${(free.slots ?? []).length} 个「人空闲」的时段里,没有一个还能订到会议室`;
|
|
1732
|
+
warnings.push(`没找到同时满足「人员空闲 + 会议室空闲」的时间候选:${why}。`
|
|
1733
|
+
+ '请如实告知用户,并建议换个日期范围、缩短时长,或改为线上会议(allowNoRoom: true)。');
|
|
1734
|
+
if (free.blockingSummary)
|
|
1735
|
+
warnings.push(`当天占用概况:${free.blockingSummary}`);
|
|
1736
|
+
}
|
|
1737
|
+
return meetingSuccess('meeting.eb.meeting.create.suggest', {
|
|
1738
|
+
dateFrom,
|
|
1739
|
+
dateTo,
|
|
1740
|
+
durationMinutes,
|
|
1741
|
+
// 同样是"让用户点一下":时段候选用选项控件呈现,别写成文本列表
|
|
1742
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'T1' },
|
|
1743
|
+
slots: usable.map((slot, index) => ({
|
|
1744
|
+
key: `T${index + 1}`,
|
|
1745
|
+
label: `${slot.begin.slice(5, 10)} ${slot.begin.slice(11)}~${slot.end.slice(11)}`,
|
|
1746
|
+
value: { begin: slot.begin, end: slot.end, address: slot.rooms[0]?.id ?? null },
|
|
1747
|
+
reason: `${memberCount ? `${memberCount} 人全部空闲(会议回执 + 日程已核);` : ''}`
|
|
1748
|
+
+ `${slot.rooms.length} 间会议室可用:${slot.rooms.map((room) => room.name).join('、')}`,
|
|
1749
|
+
rooms: slot.rooms,
|
|
1750
|
+
})),
|
|
1751
|
+
// 把"人空闲但没房间"的时段也带出来,方便调用方解释为什么被剔除
|
|
1752
|
+
skippedSlots: withRooms.filter((slot) => slot.rooms.length === 0),
|
|
1753
|
+
memberIds,
|
|
1754
|
+
scheduleItems: free.scheduleItems,
|
|
1755
|
+
idle: free.idle,
|
|
1756
|
+
cost: free.cost,
|
|
1757
|
+
}, warnings.length > 0 ? { warnings } : {});
|
|
1758
|
+
},
|
|
1759
|
+
};
|
|
848
1760
|
export const meetingEbCreateFlowOperations = [
|
|
1761
|
+
ebMeetingCreateSuggest,
|
|
849
1762
|
ebMeetingCreateFlowPrepare,
|
|
850
1763
|
ebMeetingCreateFlowApply,
|
|
851
1764
|
ebMeetingCreateFlowQuick,
|