weaver-work-cli 0.1.33 → 0.1.38
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-PDV724YK.js} +5 -4
- package/dist/chunks/{archive-oa-NW3OD4UV.js → archive-oa-4XE4MNXQ.js} +4 -3
- package/dist/chunks/{asset-7QJKMIDV.js → asset-PDL2QE2N.js} +8 -7
- package/dist/chunks/{attachment-content-OSAVA6K3.js → attachment-content-MDDGOE5E.js} +3 -3
- package/dist/chunks/{blog-I7SDPNW4.js → blog-UYR3EUXW.js} +5 -4
- package/dist/chunks/{caizhilian-O37ZIQNY.js → caizhilian-LG2X5NMF.js} +5 -4
- package/dist/chunks/{calendar-7KXE4ED3.js → calendar-FG4BAZHE.js} +4 -3
- package/dist/chunks/{chunk-Q6LWB5HX.js → chunk-6FTTSA45.js} +4 -3
- package/dist/chunks/{chunk-MQ2I5AA3.js → chunk-6LTFIFLM.js} +3 -2
- package/dist/chunks/{chunk-WRRJ4GG2.js → chunk-CORFRD7Q.js} +4 -3
- package/dist/chunks/{chunk-WY74UBJ6.js → chunk-DMV2TOMH.js} +2194 -240
- package/dist/chunks/{chunk-MEEFGWTM.js → chunk-ELYDWBZJ.js} +135 -10
- package/dist/chunks/{chunk-EEWPQE3O.js → chunk-FUWVHMVV.js} +7 -6
- package/dist/chunks/{chunk-HEL5XTYI.js → chunk-GGJFMNJM.js} +26 -4
- package/dist/chunks/{chunk-KAS45KNR.js → chunk-IZUDA353.js} +1 -1
- package/dist/chunks/{chunk-2FLWXFVS.js → chunk-J7USS3ED.js} +4 -4
- package/dist/chunks/{chunk-IHUYP6LO.js → chunk-JSMFPO43.js} +1 -1
- package/dist/chunks/{chunk-RBGOMK4Y.js → chunk-KKRL3REV.js} +5 -4
- package/dist/chunks/{chunk-OCU4RLHC.js → chunk-NI6GIPIM.js} +195 -36
- package/dist/chunks/{chunk-PRHJGPMA.js → chunk-NR6RKXPA.js} +2 -2
- package/dist/chunks/{chunk-D223PTXU.js → chunk-OJQUJZQF.js} +33 -7
- package/dist/chunks/{chunk-ILMBNVSG.js → chunk-UJJD4QWF.js} +4 -3
- package/dist/chunks/{chunk-6ATGWRZM.js → chunk-UZZBA6PV.js} +177 -14
- package/dist/chunks/{chunk-GFPXQIY2.js → chunk-VRYHGG6O.js} +2 -2
- package/dist/chunks/{chunk-NHA62QD7.js → chunk-XJY7G4DD.js} +3 -3
- package/dist/chunks/{chunk-VBCFFFGT.js → chunk-Z32KWY6G.js} +4 -1
- package/dist/chunks/{conflict-SFNEVJY5.js → conflict-BQZEQ4SP.js} +2 -2
- package/dist/chunks/contract-XIRAFTEV.js +9111 -0
- package/dist/chunks/{cowork-CKWYHXYB.js → cowork-BLG62BL2.js} +46 -26
- package/dist/chunks/{ebuilder-form-KOLJRVZD.js → ebuilder-form-NUKG5QW4.js} +3 -3
- package/dist/chunks/{ehr-WFOC3B4D.js → ehr-2STW5ILH.js} +174 -10
- package/dist/chunks/{esb-LJZTN663.js → esb-UUDQUNKY.js} +4 -4
- package/dist/chunks/{fna-F7QRIIZC.js → fna-6RCY4NU5.js} +15 -9
- package/dist/chunks/{host-VQ6JJXGQ.js → host-7RXEDMAH.js} +3 -3
- package/dist/chunks/{host-KXPTEWFS.js → host-EJZFROU3.js} +3 -3
- package/dist/chunks/{host-BCGUVEWX.js → host-FWDOSRH5.js} +3 -3
- package/dist/chunks/{host-DGE2TZD4.js → host-RHM3NPXV.js} +3 -3
- package/dist/chunks/{host-BQBRHVR3.js → host-UFF33TWJ.js} +3 -3
- package/dist/chunks/{hrm-NI6XAKW5.js → hrm-PKJ6ELSH.js} +3 -3
- package/dist/chunks/{im-7TRTF3KT.js → im-YJAXS7CO.js} +3 -3
- package/dist/chunks/{invoice-AOXWJKYT.js → invoice-HWVSXGX6.js} +3 -3
- package/dist/chunks/{jiuchuanhui-7NQWEZAW.js → jiuchuanhui-L7VKILZB.js} +4 -3
- package/dist/chunks/{lazy-dispatch-RNS4EE4S.js → lazy-dispatch-NMYEIHRH.js} +4 -4
- package/dist/chunks/{mail-VDHJAB4O.js → mail-B42RRU7G.js} +46 -24
- package/dist/chunks/{meeting-GA7XCNNP.js → meeting-CFYVBJSS.js} +77 -12
- package/dist/chunks/{okr-TQIRERPQ.js → okr-GCLF6V7W.js} +4 -3
- package/dist/chunks/{plan-4RY4LN2P.js → plan-ZVL7GBRI.js} +4 -3
- package/dist/chunks/{project-D6XT2OC5.js → project-4YWAAE2K.js} +4 -3
- package/dist/chunks/{registry-QKMI43Q6.js → registry-EP4VTHZS.js} +8 -8
- package/dist/chunks/{registry-JHRIDQ3A.js → registry-FRJ6H7BT.js} +4 -4
- package/dist/chunks/{registry-VOGKFFTR.js → registry-SG2PGVCF.js} +2 -2
- package/dist/chunks/{room-recommend-D4EUOOWY.js → room-recommend-R3JVUUAC.js} +4 -4
- package/dist/chunks/{schedule-XPFBHPCV.js → schedule-NIJBTYDO.js} +4 -4
- package/dist/chunks/{workflow-HLBZRWIY.js → workflow-BB24DFBY.js} +11 -4
- package/dist/index.js +31 -26
- package/dist/internal/e10/auth-recovery.js +114 -1
- package/dist/internal/e10/client.js +16 -2
- package/dist/internal/e10/fetch-timing.js +31 -0
- package/dist/internal/e10/request-runtime.js +108 -7
- package/dist/shortcuts/archive/host.js +3 -2
- package/dist/shortcuts/archive-oa/host.js +2 -1
- package/dist/shortcuts/asset/host.js +6 -5
- package/dist/shortcuts/blog/host.js +3 -2
- package/dist/shortcuts/caizhilian/host.js +3 -2
- package/dist/shortcuts/calendar/host.js +2 -1
- package/dist/shortcuts/contract/host.js +2 -1
- package/dist/shortcuts/cowork/host.js +10 -1
- package/dist/shortcuts/cowork/operations/types.js +17 -2
- package/dist/shortcuts/ebuilder-form/host.js +3 -1
- package/dist/shortcuts/ehr/host.js +2 -1
- package/dist/shortcuts/ehr/operations/recruit-write.js +35 -0
- package/dist/shortcuts/ehr/operations/recruit.js +147 -0
- package/dist/shortcuts/esb/host.js +2 -1
- package/dist/shortcuts/fna/host.js +2 -1
- package/dist/shortcuts/fna/operations/expense-chart.js +19 -7
- package/dist/shortcuts/hrm/host.js +2 -1
- package/dist/shortcuts/im/host.js +3 -2
- package/dist/shortcuts/index.js +5 -0
- package/dist/shortcuts/invoice/host.js +5 -4
- package/dist/shortcuts/jiuchuanhui/host.js +2 -1
- package/dist/shortcuts/mail/host.js +10 -1
- package/dist/shortcuts/mail/operations/types.js +17 -2
- package/dist/shortcuts/meeting/host.js +3 -2
- package/dist/shortcuts/meeting/index.js +79 -1
- package/dist/shortcuts/meeting/invitation/text.js +71 -0
- 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 +2105 -169
- package/dist/shortcuts/meeting/operations/eb/crm-search.js +478 -0
- package/dist/shortcuts/meeting/operations/eb/factory.js +161 -8
- package/dist/shortcuts/meeting/operations/eb/index.js +2 -0
- 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 +200 -28
- package/dist/shortcuts/okr/host.js +2 -1
- package/dist/shortcuts/plan/host.js +2 -1
- package/dist/shortcuts/project/host.js +2 -1
- package/dist/shortcuts/workflow/endpoints.js +8 -3
- package/dist/shortcuts/workflow/operations/form.js +341 -36
- 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 +499 -300
- package/skills/weaver-e10-meeting/references/meeting-eb.md +334 -0
- package/skills/weaver-e10-meeting/references/meeting-invitation.md +64 -6
- package/skills/weaver-e10-meeting.zip +0 -0
- package/skills/weaver-e10-okr/references/source-manifest.json +3 -3
- 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
- package/skills/weaver-e10-yimiaoban/references/source-manifest.json +3 -3
|
@@ -1,7 +1,12 @@
|
|
|
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 {
|
|
8
|
+
import { EB_BASE, ebBusinessSuccess, writeBody } from './factory.js';
|
|
9
|
+
import { loadRooms, recommendRooms } from './room-recommend.js';
|
|
5
10
|
/**
|
|
6
11
|
* 通过**流程**新建 EB 会议(`meeting.eb.meeting.create.flow`)。
|
|
7
12
|
*
|
|
@@ -30,11 +35,16 @@ const FIELD_ALIASES = {
|
|
|
30
35
|
customize_address: ['自定义会议地点', '自定义地点'],
|
|
31
36
|
hrm_members: ['参会人员', '参会人'],
|
|
32
37
|
total_member: ['参会人数', '人数'],
|
|
38
|
+
/** 参会客户(CustomerComponent 客户浏览按钮):值是客户 id 列表,不是客户名称文本。 */
|
|
39
|
+
crm_members: ['参会客户', '客户'],
|
|
40
|
+
crm_member_count: ['应到客户数', '客户数'],
|
|
33
41
|
caller: ['召集人', '发起人'],
|
|
34
42
|
contacter: ['联系人'],
|
|
35
43
|
mt_type: ['会议类型'],
|
|
36
44
|
other_remark: ['备注', '其他说明'],
|
|
37
45
|
};
|
|
46
|
+
/** 「召集人 / 联系人」两个人员字段的展示名(自动补齐时用来提示用户)。 */
|
|
47
|
+
const PERSON_FIELD_LABELS = { caller: '召集人', contacter: '联系人' };
|
|
38
48
|
function workflowEnv(env) {
|
|
39
49
|
return env;
|
|
40
50
|
}
|
|
@@ -88,37 +98,171 @@ async function resolveEmployeeNames(env, ids) {
|
|
|
88
98
|
}
|
|
89
99
|
return map;
|
|
90
100
|
}
|
|
91
|
-
/**
|
|
92
|
-
|
|
101
|
+
/**
|
|
102
|
+
* 把「员工 id 或姓名」解析成 `{ id, name }`。
|
|
103
|
+
*
|
|
104
|
+
* ⛔ 2026-10-31 修(用户实测反馈「参会人员没代入表单」):以前只拿这些值当 id 去反查姓名,
|
|
105
|
+
* 传姓名时得到的是 `{ id: "王丹丹", name: "王丹丹" }` —— 浏览按钮按 **id** 落值,
|
|
106
|
+
* 服务端解析不出员工,表单里就渲染成空。现在纯数字当 id、其余当姓名去 `hrm.employee.query`
|
|
107
|
+
* 换真实员工 id;同名有歧义(命中多条且没有全等)时不猜,留给调用方提示。
|
|
108
|
+
*/
|
|
109
|
+
async function resolvePersonTokens(env, tokens) {
|
|
110
|
+
const map = new Map();
|
|
111
|
+
const unique = [...new Set(tokens.map((token) => String(token).trim()).filter((token) => token !== ''))];
|
|
112
|
+
if (unique.length === 0)
|
|
113
|
+
return map;
|
|
114
|
+
const idLike = unique.filter((token) => /^\d{8,}$/.test(token));
|
|
115
|
+
const nameLike = unique.filter((token) => !/^\d{8,}$/.test(token));
|
|
116
|
+
if (idLike.length > 0) {
|
|
117
|
+
const names = await resolveEmployeeNames(env, idLike);
|
|
118
|
+
for (const id of idLike)
|
|
119
|
+
map.set(id, { id, name: names.get(id) ?? id });
|
|
120
|
+
}
|
|
121
|
+
if (nameLike.length > 0) {
|
|
122
|
+
try {
|
|
123
|
+
const { hrmEmployeeOperations } = await import('../../../hrm/operations/employee.js');
|
|
124
|
+
const query = hrmEmployeeOperations.find((operation) => operation.name === 'hrm.employee.query');
|
|
125
|
+
for (const name of nameLike) {
|
|
126
|
+
if (!query)
|
|
127
|
+
break;
|
|
128
|
+
const found = await query.handler({ keyword: name, pageSize: 10 }, env).catch(() => null);
|
|
129
|
+
const rows = (found?.data?.rows ?? []);
|
|
130
|
+
const exact = rows.find((row) => String(row.name ?? '').trim() === name);
|
|
131
|
+
const hit = exact ?? (rows.length === 1 ? rows[0] : undefined);
|
|
132
|
+
const id = String(hit?.id ?? '').trim();
|
|
133
|
+
if (id === '')
|
|
134
|
+
continue;
|
|
135
|
+
map.set(name, { id, name: String(hit?.name ?? name) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// 查不到就保持未解析,由调用方给出 warning
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return map;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 「召集人 / 联系人」的缺省值 = **当前登录人**(2026-10-31 用户要求)。
|
|
146
|
+
*
|
|
147
|
+
* ⛔ 背景:以前这两个字段只有调用方显式传 `caller` / `contacter` 才写值。换一个账号用
|
|
148
|
+
* ("其他人使用")时就是空的 —— 流程单上「召集人」「联系人」整块空白,表里看不出谁召集的,
|
|
149
|
+
* 邀请函也整段落成【待补充】。默认"自己召集、自己联系"才符合建会的常识。
|
|
150
|
+
*
|
|
151
|
+
* ⛔ 只有**能查到姓名**才返回:Employee 浏览按钮认的是员工 id,在不一致的租户(userId ≠ 员工 id)
|
|
152
|
+
* 里硬填会被工作流判成 `NEEDS_CONFIRMATION`,**提交直接卡住** —— 所以查不到宁可留空 +
|
|
153
|
+
* 提示调用方显式传,也不猜。
|
|
154
|
+
*/
|
|
155
|
+
export async function resolveSelfPerson(env) {
|
|
156
|
+
let session = null;
|
|
157
|
+
try {
|
|
158
|
+
session = await env.host?.getSession?.();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const id = String(session?.userId ?? '').trim();
|
|
164
|
+
if (id === '' || id === 'unknown')
|
|
165
|
+
return null;
|
|
166
|
+
const names = await resolveEmployeeNames(env, [id]);
|
|
167
|
+
const name = String(names.get(id) ?? '').trim();
|
|
168
|
+
if (name === '')
|
|
169
|
+
return null;
|
|
170
|
+
return { id, name };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 「召集人 / 联系人」缺省补齐(纯函数,便于测试):**只补没传的那个字段**,
|
|
174
|
+
* 显式传了就以传的为准(替他人建会 / 指定别的联系人时不覆盖)。
|
|
175
|
+
*/
|
|
176
|
+
export function applySelfPersonDefaults(args, self) {
|
|
177
|
+
const filled = [];
|
|
178
|
+
const missing = ['caller', 'contacter']
|
|
179
|
+
.filter((field) => String(args[field] ?? '').trim() === '');
|
|
180
|
+
if (missing.length === 0)
|
|
181
|
+
return { filled, warning: '' };
|
|
182
|
+
if (!self) {
|
|
183
|
+
// 两个都缺才算问题:只缺一个(比如只指定了召集人)不提示,交给正常流程。
|
|
184
|
+
return {
|
|
185
|
+
filled,
|
|
186
|
+
warning: missing.length === 2
|
|
187
|
+
? '⚠️ 没能确认当前登录用户对应的员工 ——「召集人」「联系人」会留空,'
|
|
188
|
+
+ '请显式传 caller / contacter(员工 id 或姓名)。'
|
|
189
|
+
: '',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
for (const field of missing) {
|
|
193
|
+
args[field] = self.id;
|
|
194
|
+
filled.push({ field, id: self.id, name: self.name });
|
|
195
|
+
}
|
|
196
|
+
return { filled, warning: '' };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 拆「会议室」入参的三种常见写法:`123456`(id)、`会议室名`、`会议室名(123456)`。
|
|
200
|
+
*
|
|
201
|
+
* ⛔ 2026-10-31 真机事故:以前只把**纯数字**当房间 id,其它一律按"名称"原样丢给表单。
|
|
202
|
+
* 于是「测试会议室1」这种(用户实际最常说的写法)**既不查存在性、也不查占用**,
|
|
203
|
+
* 直接进确认摘要 —— 用户到流程页面提交时才被打回,或更糟:占用的房间被当成可用。
|
|
204
|
+
*/
|
|
205
|
+
export function parseRoomRef(input) {
|
|
206
|
+
const text = String(input ?? '').trim();
|
|
207
|
+
if (!text)
|
|
208
|
+
return {};
|
|
209
|
+
const withId = /^(.*?)[((]\s*(\d{4,})\s*[))]$/.exec(text);
|
|
210
|
+
if (withId) {
|
|
211
|
+
const name = withId[1].trim();
|
|
212
|
+
const id = withId[2];
|
|
213
|
+
return name ? { id, name } : { id };
|
|
214
|
+
}
|
|
215
|
+
if (/^\d+$/.test(text))
|
|
216
|
+
return { id: text };
|
|
217
|
+
return { name: text };
|
|
218
|
+
}
|
|
219
|
+
/** 把 address 入参解析成会议室台账里的真实记录(id / 名称 / 名称(id) 都能认)。 */
|
|
220
|
+
export async function resolveAddressRoom(env, input) {
|
|
93
221
|
const text = String(input ?? '').trim();
|
|
94
222
|
if (!text)
|
|
95
223
|
return null;
|
|
224
|
+
const ref = parseRoomRef(text);
|
|
96
225
|
const { loadRooms } = await import('./room-recommend.js');
|
|
226
|
+
let rooms = [];
|
|
97
227
|
try {
|
|
98
|
-
|
|
99
|
-
const byId = rooms.find((room) => room.id === text);
|
|
100
|
-
if (byId)
|
|
101
|
-
return { id: byId.id, name: byId.name };
|
|
102
|
-
const byName = rooms.find((room) => room.name === text);
|
|
103
|
-
if (byName)
|
|
104
|
-
return { id: byName.id, name: byName.name };
|
|
228
|
+
rooms = (await loadRooms(env, {})).rooms;
|
|
105
229
|
}
|
|
106
230
|
catch {
|
|
107
|
-
//
|
|
231
|
+
// 台账读不到 ≠ 房间不存在:标记出来,让调用方不要据此判死
|
|
232
|
+
return { id: ref.id ?? '', name: ref.name ?? text, status: 'unknown', lookupFailed: true };
|
|
233
|
+
}
|
|
234
|
+
const hit = (ref.id ? rooms.find((room) => room.id === ref.id) : undefined)
|
|
235
|
+
?? (ref.name ? rooms.find((room) => room.name === ref.name) : undefined);
|
|
236
|
+
if (hit)
|
|
237
|
+
return { id: hit.id, name: hit.name, status: hit.status, statusText: hit.statusText };
|
|
238
|
+
// 纯 id 且台账里没有(刚建/缓存旧)→ 保留 id 继续走,别拦住
|
|
239
|
+
if (ref.id)
|
|
240
|
+
return { id: ref.id, name: ref.name ?? '', status: 'unknown' };
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
/** 会议室 id → {id,name}(优先会议室缓存,缓存没有就查一次详情)。 */
|
|
244
|
+
async function resolveRoomValue(env, input) {
|
|
245
|
+
const text = String(input ?? '').trim();
|
|
246
|
+
if (!text)
|
|
247
|
+
return null;
|
|
248
|
+
const resolved = await resolveAddressRoom(env, text).catch(() => null);
|
|
249
|
+
if (resolved?.id && !resolved.lookupFailed) {
|
|
250
|
+
return { id: resolved.id, name: resolved.name || resolved.id };
|
|
108
251
|
}
|
|
109
|
-
|
|
252
|
+
const ref = parseRoomRef(text);
|
|
253
|
+
if (ref.id) {
|
|
110
254
|
try {
|
|
111
255
|
const { getMeetingOperation } = await import('../registry.js');
|
|
112
|
-
const detail = await getMeetingOperation('meeting.eb.room.detail').handler({ id:
|
|
256
|
+
const detail = await getMeetingOperation('meeting.eb.room.detail').handler({ id: ref.id }, env);
|
|
113
257
|
const main = detail?.data?.mainTable ?? detail?.data?.data?.mainTable ?? {};
|
|
114
|
-
const name = String(main.room_name ?? '').trim();
|
|
258
|
+
const name = String(main.room_name ?? '').trim() || ref.name || '';
|
|
115
259
|
if (name)
|
|
116
|
-
return { id:
|
|
260
|
+
return { id: ref.id, name };
|
|
117
261
|
}
|
|
118
262
|
catch {
|
|
119
263
|
// 详情也拿不到就只传 id
|
|
120
264
|
}
|
|
121
|
-
return { id:
|
|
265
|
+
return { id: ref.id, name: ref.name || ref.id };
|
|
122
266
|
}
|
|
123
267
|
return null;
|
|
124
268
|
}
|
|
@@ -129,15 +273,20 @@ async function resolveRoomValue(env, input) {
|
|
|
129
273
|
* getRelateData 里匹配,匹配不到就返回 NEEDS_CONFIRMATION("无法唯一解析的选项")——
|
|
130
274
|
* 看起来像"流程建会走不通",其实是值格式不对。这里统一补成对象。
|
|
131
275
|
*/
|
|
132
|
-
async function shapeBrowserValues(env, values) {
|
|
276
|
+
export async function shapeBrowserValues(env, values) {
|
|
133
277
|
const notes = [];
|
|
134
278
|
const shaped = { ...values };
|
|
279
|
+
const readable = {};
|
|
135
280
|
if (shaped.address !== undefined && shaped.address !== '') {
|
|
136
281
|
const room = await resolveRoomValue(env, String(shaped.address));
|
|
137
282
|
if (room) {
|
|
138
283
|
shaped.address = room;
|
|
284
|
+
readable.address = room.name;
|
|
139
285
|
notes.push(`会议地点按浏览按钮格式提交:${room.name}(${room.id})`);
|
|
140
286
|
}
|
|
287
|
+
else {
|
|
288
|
+
readable.address = String(shaped.address);
|
|
289
|
+
}
|
|
141
290
|
}
|
|
142
291
|
const personFields = ['hrm_members', 'caller', 'contacter'];
|
|
143
292
|
const idPool = [];
|
|
@@ -149,45 +298,633 @@ async function shapeBrowserValues(env, values) {
|
|
|
149
298
|
idPool.push(...ids);
|
|
150
299
|
}
|
|
151
300
|
if (idPool.length > 0) {
|
|
152
|
-
const
|
|
153
|
-
const toObject = (
|
|
301
|
+
const people = await resolvePersonTokens(env, idPool);
|
|
302
|
+
const toObject = (token) => people.get(token) ?? { id: token, name: token };
|
|
303
|
+
const toName = (token) => people.get(token)?.name ?? token;
|
|
154
304
|
if (shaped.hrm_members !== undefined && shaped.hrm_members !== '') {
|
|
155
305
|
const ids = String(shaped.hrm_members).split(/[,;,;\s]+/).map((item) => item.trim()).filter((item) => item !== '');
|
|
156
306
|
shaped.hrm_members = ids.map(toObject);
|
|
307
|
+
readable.hrm_members = ids.map(toName).join('、');
|
|
157
308
|
notes.push(`参会人员按浏览按钮格式提交:${ids.length} 人`);
|
|
158
309
|
}
|
|
159
310
|
if (shaped.caller !== undefined && shaped.caller !== '') {
|
|
160
311
|
const id = String(shaped.caller).trim();
|
|
161
312
|
shaped.caller = toObject(id);
|
|
313
|
+
readable.caller = toName(id);
|
|
162
314
|
}
|
|
163
315
|
if (shaped.contacter !== undefined && shaped.contacter !== '') {
|
|
164
316
|
const id = String(shaped.contacter).trim();
|
|
165
317
|
shaped.contacter = toObject(id);
|
|
318
|
+
readable.contacter = toName(id);
|
|
319
|
+
}
|
|
320
|
+
// 解析不出员工的(同名歧义 / 花名册里没有)要点名,别让表单静默留空
|
|
321
|
+
const unresolved = [...new Set(idPool)].filter((token) => !people.has(token));
|
|
322
|
+
if (unresolved.length > 0) {
|
|
323
|
+
notes.push(`⚠️ 有 ${unresolved.length} 个人员没解析到员工:${unresolved.join('、')} —— `
|
|
324
|
+
+ '表单里的「参会人员/召集人/联系人」可能显示不出来。请改传员工 id,或确认姓名与花名册完全一致。');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// 参会客户(CustomerComponent 客户浏览按钮):与人员浏览按钮同形态 —— `[{id,name}]`。
|
|
328
|
+
// 服务端按 id 认客户,name 只用于列表显示,所以只有 id 时也照样提交(不留空行)。
|
|
329
|
+
if (shaped.crm_members !== undefined && shaped.crm_members !== '') {
|
|
330
|
+
const rows = [];
|
|
331
|
+
const push = (idRaw, nameRaw) => {
|
|
332
|
+
const id = String(idRaw ?? '').trim();
|
|
333
|
+
const name = String(nameRaw ?? '').trim();
|
|
334
|
+
if (id === '' && name === '')
|
|
335
|
+
return;
|
|
336
|
+
rows.push({ id, name });
|
|
337
|
+
};
|
|
338
|
+
// 单个 token 的宽容写法:`客户名(客户id)` 取括号里的 id;纯数字(雪花 id)当 id;
|
|
339
|
+
// 其余一律当**客户名**(2026-10-31 修:以前把纯名称整串当 id,导致「只传客户名」这种
|
|
340
|
+
// 最自然的写法反被判 crm_name_required 拒掉)。
|
|
341
|
+
const pushToken = (tokenRaw) => {
|
|
342
|
+
const token = String(tokenRaw ?? '').trim();
|
|
343
|
+
if (token === '')
|
|
344
|
+
return;
|
|
345
|
+
const matched = token.match(/^(.*?)[((]\s*([0-9A-Za-z_-]+)\s*[))]$/);
|
|
346
|
+
if (matched) {
|
|
347
|
+
push(matched[2], matched[1]);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (/^\d{8,}$/.test(token)) {
|
|
351
|
+
push(token, '');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
push('', token);
|
|
355
|
+
};
|
|
356
|
+
const raw = shaped.crm_members;
|
|
357
|
+
if (Array.isArray(raw)) {
|
|
358
|
+
for (const item of raw) {
|
|
359
|
+
if (item && typeof item === 'object') {
|
|
360
|
+
const obj = item;
|
|
361
|
+
push(obj.id ?? obj.sjID ?? obj.customerId, obj.name ?? obj.mc);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
// ⛔ 数组里的字符串**也要走 `名称(id)` 解析**(2026-10-31 实测漏了这一步:
|
|
365
|
+
// `["客户名(123)"]` 被整串当成 id 提交,服务端认不出客户、静默存空)。
|
|
366
|
+
// 同时容忍 `["c1, c2"]` 这种"一个元素里塞了多个"的写法。
|
|
367
|
+
for (const token of String(item).split(/[,;,;]+/).map((one) => one.trim()).filter((one) => one !== '')) {
|
|
368
|
+
pushToken(token);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
else if (typeof raw === 'object') {
|
|
374
|
+
const obj = raw;
|
|
375
|
+
push(obj.id ?? obj.sjID ?? obj.customerId, obj.name ?? obj.mc);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
// "id1,id2" 或 "客户A(123)" 混写
|
|
379
|
+
for (const token of String(raw).split(/[,;,;]+/).map((item) => item.trim()).filter((item) => item !== '')) {
|
|
380
|
+
pushToken(token);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (rows.length > 0) {
|
|
384
|
+
// 只给了客户名、没给 id ⇒ 用 `crm.search` 按名称补 id(「只传客户名」是最常见的用法)。
|
|
385
|
+
// 查不到就报 `crm_not_found` 让人去确认名称,**不静默丢客户**。
|
|
386
|
+
const nameOnly = rows.filter((row) => row.id === '' && row.name !== '');
|
|
387
|
+
if (nameOnly.length > 0) {
|
|
388
|
+
try {
|
|
389
|
+
const { getMeetingOperation } = await import('../registry.js');
|
|
390
|
+
const search = getMeetingOperation('meeting.eb.crm.search');
|
|
391
|
+
for (const row of nameOnly) {
|
|
392
|
+
const found = await search.handler({ keyword: row.name, limit: 5 }, env).catch(() => null);
|
|
393
|
+
const items = (found?.data?.items ?? []);
|
|
394
|
+
const hit = items.find((item) => String(item?.name ?? '').trim() === row.name) ?? items[0];
|
|
395
|
+
const id = String(hit?.customerId ?? '').trim();
|
|
396
|
+
if (id === '')
|
|
397
|
+
continue;
|
|
398
|
+
row.id = id;
|
|
399
|
+
const fullName = String(hit?.name ?? '').trim();
|
|
400
|
+
if (fullName !== '')
|
|
401
|
+
row.name = fullName;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// 查询失败按"查不到"处理,下面统一报错
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const unresolved = rows.filter((row) => row.id === '');
|
|
409
|
+
if (unresolved.length > 0) {
|
|
410
|
+
throw meetingValidationError('crm_not_found', `参会客户有 ${unresolved.length} 个在 CRM 里查不到:${unresolved.map((row) => row.name).join('、')}。`
|
|
411
|
+
+ '请确认客户名称,或先到客户表建档;也可以改用「客户名(客户id)」或 `[{id,name}]` 传 crm_members。');
|
|
412
|
+
}
|
|
413
|
+
shaped.crm_members = rows;
|
|
414
|
+
readable.crm_members = rows.map((row) => row.name || row.id).join('、');
|
|
415
|
+
notes.push(`参会客户按客户浏览按钮格式提交:${rows.length} 个客户`);
|
|
416
|
+
/**
|
|
417
|
+
* ⛔ 只有 id、没有名称 ⇒ **必须拦住**(2026-10-31 真机实证)。
|
|
418
|
+
*
|
|
419
|
+
* 客户浏览按钮(`CustomerComponent`)写入时服务端按「名称」落 `dataOptions.content`,
|
|
420
|
+
* 只给 id 的写法提交后会**静默存成空值**(单据详情里 `参会客户` 是 "",不报错、不告警)。
|
|
421
|
+
* 之前"服务端按 id 认客户"的说法是错的,用它就会悄悄丢数据。
|
|
422
|
+
* ⇒ 这里直接拒绝,并给出可用写法;客户名可从 `meeting.eb.crm.search` 的 `items[].name` 拿。
|
|
423
|
+
*/
|
|
424
|
+
const missingNames = rows.filter((row) => row.name === '');
|
|
425
|
+
if (missingNames.length > 0) {
|
|
426
|
+
throw meetingValidationError('crm_name_required', `参会客户有 ${missingNames.length} 个只给了 id、没有客户名称:${missingNames.map((row) => row.id).join('、')}。`
|
|
427
|
+
+ '客户浏览按钮必须带名称才能写入(只给 id 会被服务端静默存成空值)。'
|
|
428
|
+
+ '请先用 `meeting.eb.crm.search` 按客户名查到 `{ customerId, name }`,'
|
|
429
|
+
+ '再以 `[{id,name}]` 或「客户名(客户id)」的形式传 `crm_members`。');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
delete shaped.crm_members;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// 其余标量字段(会议名称/开始时间/结束时间/会议内容/人数…)原样给邀请函用
|
|
437
|
+
for (const [key, raw] of Object.entries(values)) {
|
|
438
|
+
if (readable[key] !== undefined)
|
|
439
|
+
continue;
|
|
440
|
+
if (raw === undefined || raw === null || raw === '')
|
|
441
|
+
continue;
|
|
442
|
+
if (typeof raw === 'object')
|
|
443
|
+
continue;
|
|
444
|
+
readable[key] = String(raw);
|
|
445
|
+
}
|
|
446
|
+
return { values: shaped, notes, readable };
|
|
447
|
+
}
|
|
448
|
+
/** 去掉字段名里的括号提示语并压掉空白:「开始时间(具体开始时间)」→「开始时间」。 */
|
|
449
|
+
function normalizeFieldName(name) {
|
|
450
|
+
return String(name ?? '')
|
|
451
|
+
.replace(/[((][^(())]*[))]/g, '')
|
|
452
|
+
.replace(/[\s\u3000]+/g, '')
|
|
453
|
+
.trim();
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* 「参会客户明细」明细表(2026-10-31 新增)。
|
|
457
|
+
*
|
|
458
|
+
* 结构:宿主表单里是一张明细表(真机 会议申请流程 = `uf_mt_work_chkhmx`,subFormId `1332370499762806784`),
|
|
459
|
+
* 列为 姓名/性别/职位/手机号码/备注。**subFormId 与列字段 id 各租户不同**,所以一律从
|
|
460
|
+
* `workflow.createForm` 的字段清单里按列名反查,不写死 id。
|
|
461
|
+
*
|
|
462
|
+
* ⛔ 明细字段只有在「发起节点布局」里摆上之后才会出现在 `fields` 里;没摆上(或摆在隐藏行)
|
|
463
|
+
* 时这里解析不到,必须如实告警,不能静默丢行。
|
|
464
|
+
*/
|
|
465
|
+
const CRM_DETAIL_COLUMNS = {
|
|
466
|
+
xm: ['姓名', '客户姓名', '参会人', '参会人姓名'],
|
|
467
|
+
xb: ['性别'],
|
|
468
|
+
zw: ['职位', '职务'],
|
|
469
|
+
sjhm: ['手机号码', '手机号', '手机', '电话', '联系电话'],
|
|
470
|
+
bz: ['备注', '说明'],
|
|
471
|
+
};
|
|
472
|
+
/** 从表单字段清单里认出「参会客户明细」那张表(判据:有「姓名」列,且得分最高)。 */
|
|
473
|
+
export function resolveCrmDetailTarget(fields) {
|
|
474
|
+
const groups = new Map();
|
|
475
|
+
for (const field of fields) {
|
|
476
|
+
const sid = String(field.subFormId ?? '').trim();
|
|
477
|
+
if (!sid)
|
|
478
|
+
continue;
|
|
479
|
+
const list = groups.get(sid);
|
|
480
|
+
if (list)
|
|
481
|
+
list.push(field);
|
|
482
|
+
else
|
|
483
|
+
groups.set(sid, [field]);
|
|
484
|
+
}
|
|
485
|
+
let best = null;
|
|
486
|
+
for (const [subFormId, list] of groups) {
|
|
487
|
+
const columns = {};
|
|
488
|
+
const labels = {};
|
|
489
|
+
for (const [semantic, aliases] of Object.entries(CRM_DETAIL_COLUMNS)) {
|
|
490
|
+
const hit = list.find((field) => aliases.includes(normalizeFieldName(field.name)));
|
|
491
|
+
if (!hit)
|
|
492
|
+
continue;
|
|
493
|
+
columns[semantic] = hit.id;
|
|
494
|
+
labels[semantic] = hit.name;
|
|
495
|
+
}
|
|
496
|
+
// 没有「姓名」列的明细表(会议议题/会议服务…)一律不算
|
|
497
|
+
if (!columns.xm)
|
|
498
|
+
continue;
|
|
499
|
+
const score = Object.keys(columns).length + (columns.sjhm ? 1 : 0) + (columns.zw ? 1 : 0);
|
|
500
|
+
if (!best || score > best.score)
|
|
501
|
+
best = { subFormId, columns, labels, score };
|
|
502
|
+
}
|
|
503
|
+
return best ? { subFormId: best.subFormId, columns: best.columns, labels: best.labels } : null;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* 从「会议名称/内容/备注」里认出用户明确点出的客户名(`客户:中国民航` / `客户是xx` / `参会客户为 xx`)。
|
|
507
|
+
*
|
|
508
|
+
* 存在的理由:模型最常见的跑偏是把客户名写进会议名称/内容就直接提交,`crm_members` 一个都没传,
|
|
509
|
+
* 最后「参会客户」浏览按钮是空的(2026-10-31 真机踩到)。这里只做**识别**,不替用户查 CRM 也不自动写入。
|
|
510
|
+
* 只认带分隔符或「是/为」的显式写法,避免把「客户需求/客户满意度」这类普通词当成客户名。
|
|
511
|
+
*/
|
|
512
|
+
export function detectCrmMentionInText(...texts) {
|
|
513
|
+
for (const text of texts) {
|
|
514
|
+
if (typeof text !== 'string')
|
|
515
|
+
continue;
|
|
516
|
+
const value = text.trim();
|
|
517
|
+
if (value === '')
|
|
518
|
+
continue;
|
|
519
|
+
const hit = /(?:参会|拜访|来访)?客户\s*(?:名称)?\s*(?:是|为|[::])\s*([^\s,,。;;、))]{2,40})/.exec(value);
|
|
520
|
+
if (hit?.[1])
|
|
521
|
+
return hit[1].trim();
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
/** 把调用方给的明细行统一成 `{xm,xb,zw,sjhm,bz}`(中英文键都认;姓名与手机号都空的行直接丢)。 */
|
|
526
|
+
export function normalizeCrmDetailRows(raw) {
|
|
527
|
+
if (raw === undefined || raw === null || raw === '')
|
|
528
|
+
return [];
|
|
529
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
530
|
+
const keyAlias = {
|
|
531
|
+
xm: 'xm', name: 'xm', customername: 'xm', 姓名: 'xm', 客户姓名: 'xm',
|
|
532
|
+
xb: 'xb', gender: 'xb', sex: 'xb', 性别: 'xb',
|
|
533
|
+
zw: 'zw', title: 'zw', position: 'zw', job: 'zw', 职位: 'zw', 职务: 'zw',
|
|
534
|
+
sjhm: 'sjhm', mobile: 'sjhm', phone: 'sjhm', tel: 'sjhm', 手机号码: 'sjhm', 手机号: 'sjhm', 电话: 'sjhm', 联系电话: 'sjhm',
|
|
535
|
+
bz: 'bz', remark: 'bz', note: 'bz', 备注: 'bz', 说明: 'bz',
|
|
536
|
+
};
|
|
537
|
+
const rows = [];
|
|
538
|
+
for (const item of list) {
|
|
539
|
+
if (item === undefined || item === null || typeof item !== 'object')
|
|
540
|
+
continue;
|
|
541
|
+
const row = {};
|
|
542
|
+
for (const [key, value] of Object.entries(item)) {
|
|
543
|
+
const semantic = keyAlias[String(key).trim().toLowerCase()] ?? keyAlias[String(key).trim()];
|
|
544
|
+
if (!semantic)
|
|
545
|
+
continue;
|
|
546
|
+
if (value === undefined || value === null)
|
|
547
|
+
continue;
|
|
548
|
+
const text = String(value).trim();
|
|
549
|
+
if (text === '')
|
|
550
|
+
continue;
|
|
551
|
+
row[semantic] = text;
|
|
552
|
+
}
|
|
553
|
+
if (!row.xm && !row.sjhm)
|
|
554
|
+
continue;
|
|
555
|
+
rows.push(row);
|
|
556
|
+
}
|
|
557
|
+
return rows;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* 从 `crm_members` 入参里取出「客户 + 联系人」。
|
|
561
|
+
*
|
|
562
|
+
* 调用方通常把 `meeting.eb.crm.search` 的 `items` 原样传进来(`[{customerId,name,contacts:[…]}]`),
|
|
563
|
+
* 联系人信息就在里面;只传 id/名称时 contacts 为空,由 `resolveAutoCrmDetailRows` 再去查。
|
|
564
|
+
*/
|
|
565
|
+
export function crmMemberEntriesFromArgs(raw) {
|
|
566
|
+
const list = Array.isArray(raw) ? raw : (raw === undefined || raw === null || raw === '' ? [] : [raw]);
|
|
567
|
+
const out = [];
|
|
568
|
+
const push = (idRaw, nameRaw, contactsRaw) => {
|
|
569
|
+
const id = String(idRaw ?? '').trim();
|
|
570
|
+
const name = String(nameRaw ?? '').trim();
|
|
571
|
+
if (id === '' && name === '')
|
|
572
|
+
return;
|
|
573
|
+
const contacts = (Array.isArray(contactsRaw) ? contactsRaw : [])
|
|
574
|
+
.map((item) => {
|
|
575
|
+
const row = (item ?? {});
|
|
576
|
+
return {
|
|
577
|
+
name: String(row.name ?? row.xm ?? '').trim(),
|
|
578
|
+
mobile: String(row.mobile ?? row.sjhm ?? row.phone ?? '').trim(),
|
|
579
|
+
...(String(row.dept ?? row.bm ?? '').trim() === ''
|
|
580
|
+
? {}
|
|
581
|
+
: { dept: String(row.dept ?? row.bm).trim() }),
|
|
582
|
+
};
|
|
583
|
+
})
|
|
584
|
+
.filter((one) => one.name !== '' || one.mobile !== '');
|
|
585
|
+
out.push({ id: id === '' ? name : id, name, contacts });
|
|
586
|
+
};
|
|
587
|
+
for (const item of list) {
|
|
588
|
+
if (item && typeof item === 'object') {
|
|
589
|
+
const obj = item;
|
|
590
|
+
push(obj.customerId ?? obj.sjID ?? obj.id, obj.name ?? obj.mc, obj.contacts ?? obj.lxr);
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
// `"客户名(123)"` / `"123"` 两种纯文本写法
|
|
594
|
+
const token = String(item).trim();
|
|
595
|
+
if (token === '')
|
|
596
|
+
continue;
|
|
597
|
+
const matched = token.match(/^(.*?)[((]\s*([0-9A-Za-z_-]+)\s*[))]$/);
|
|
598
|
+
if (matched)
|
|
599
|
+
push(matched[2], matched[1], undefined);
|
|
600
|
+
else
|
|
601
|
+
push(token, '', undefined);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* 联系人 → 「参会客户明细」行(姓名 / 手机号 / 备注=部门)。
|
|
608
|
+
*
|
|
609
|
+
* ⛔ 2026-10-31 用户要求:「带出了三个联系人,为什么要让我选,不能直接代入到流程里吗」。
|
|
610
|
+
* 明细表本来就是一人一行,所以**全部带入**,不再纠结"选哪一位";要只留某一位,
|
|
611
|
+
* 调用方显式传 `crm_member_details` 覆盖即可。
|
|
612
|
+
*/
|
|
613
|
+
export function crmContactsToDetailRows(entries) {
|
|
614
|
+
const rows = [];
|
|
615
|
+
const seen = new Set();
|
|
616
|
+
for (const entry of entries) {
|
|
617
|
+
for (const contact of entry.contacts) {
|
|
618
|
+
const xm = contact.name.trim();
|
|
619
|
+
const sjhm = contact.mobile.trim();
|
|
620
|
+
if (xm === '' && sjhm === '')
|
|
621
|
+
continue;
|
|
622
|
+
const key = `${xm}|${sjhm}`;
|
|
623
|
+
if (seen.has(key))
|
|
624
|
+
continue;
|
|
625
|
+
seen.add(key);
|
|
626
|
+
const row = { xm: xm === '' ? sjhm : xm };
|
|
627
|
+
if (sjhm !== '')
|
|
628
|
+
row.sjhm = sjhm;
|
|
629
|
+
if (contact.dept && contact.dept.trim() !== '')
|
|
630
|
+
row.bz = `部门:${contact.dept.trim()}`;
|
|
631
|
+
rows.push(row);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return rows;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* 把「已选客户」的联系人自动补成明细行:入参自带就用自带的,没带就回查一次 `crm.search`。
|
|
638
|
+
*
|
|
639
|
+
* 为什么回查:模型经常只把 `customerId/name` 传过来(把 contacts 丢了),
|
|
640
|
+
* 结果明细表空着、又反过来问用户"联系人是谁"——多一轮对话,用户明确说不要这样。
|
|
641
|
+
*/
|
|
642
|
+
async function resolveAutoCrmDetailRows(env, raw) {
|
|
643
|
+
const entries = crmMemberEntriesFromArgs(raw);
|
|
644
|
+
const missing = [];
|
|
645
|
+
/**
|
|
646
|
+
* ⛔ 2026-10-31 修:以前只在「入参一个联系人都没带」时才回查,结果调用方(小E)只传了
|
|
647
|
+
* 用户挑中的那一位时,会议明细里就真只有一位 —— 而客户在 CRM 里其实有好几位。
|
|
648
|
+
* 用户的要求是「有几位就全带几位」,所以现在**一律回查一次,按 CRM 实时数据补全**(取并集,
|
|
649
|
+
* 已带的额外字段如性别/备注保留)。只有 CRM 也查不到时才留空。
|
|
650
|
+
*/
|
|
651
|
+
if (entries.length > 0) {
|
|
652
|
+
try {
|
|
653
|
+
const { getMeetingOperation } = await import('../registry.js');
|
|
654
|
+
const search = getMeetingOperation('meeting.eb.crm.search');
|
|
655
|
+
for (const entry of entries) {
|
|
656
|
+
const keyword = (entry.name || entry.id).trim();
|
|
657
|
+
if (keyword === '') {
|
|
658
|
+
missing.push(entry.id);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const searchOnce = async (kw) => {
|
|
662
|
+
const found = await search.handler({ keyword: kw, limit: 5 }, env).catch(() => null);
|
|
663
|
+
const items = (found?.data?.items ?? []);
|
|
664
|
+
const hit = items.find((item) => String(item?.customerId ?? '') === entry.id) ?? items[0];
|
|
665
|
+
return (hit?.contacts ?? []);
|
|
666
|
+
};
|
|
667
|
+
let live = await searchOnce(keyword);
|
|
668
|
+
// 客户全名常带「--子公司、专项」后缀,动作流按全名可能一个都匹配不到;
|
|
669
|
+
// 这时退回「--」前的简称再搜一次(仍要按 customerId 认,别拿首个匹配凑数)。
|
|
670
|
+
if (live.length === 0 && keyword.includes('--')) {
|
|
671
|
+
const short = keyword.split('--')[0].trim();
|
|
672
|
+
if (short !== '')
|
|
673
|
+
live = await searchOnce(short);
|
|
674
|
+
}
|
|
675
|
+
if (live.length === 0) {
|
|
676
|
+
if (entry.contacts.length === 0)
|
|
677
|
+
missing.push(entry.name || entry.id);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const keyOf = (one) => `${String(one.name ?? '').trim()}|${String(one.mobile ?? '').trim()}`;
|
|
681
|
+
const merged = new Map();
|
|
682
|
+
for (const one of live)
|
|
683
|
+
merged.set(keyOf(one), one);
|
|
684
|
+
for (const one of entry.contacts) {
|
|
685
|
+
const key = keyOf(one);
|
|
686
|
+
const base = merged.get(key);
|
|
687
|
+
merged.set(key, base ? { ...base, ...one } : one);
|
|
688
|
+
}
|
|
689
|
+
entry.contacts = [...merged.values()];
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// 查不到就沿用入参自带的联系人,由调用方按 warning 处理,不阻断建会
|
|
166
694
|
}
|
|
167
695
|
}
|
|
168
|
-
return {
|
|
696
|
+
return {
|
|
697
|
+
rows: crmContactsToDetailRows(entries),
|
|
698
|
+
customers: entries.map((entry) => entry.name || entry.id).filter((one) => one !== ''),
|
|
699
|
+
missing,
|
|
700
|
+
};
|
|
169
701
|
}
|
|
170
|
-
/**
|
|
171
|
-
|
|
702
|
+
/**
|
|
703
|
+
* 按别名把会议字段映射到表单字段 **datakey**(fieldId)。
|
|
704
|
+
*
|
|
705
|
+
* @param preferred 上次解析出的「语义键 → datakey」。命中即直接用,**不再看中文名** ——
|
|
706
|
+
* 表单字段改名、或别名表没覆盖到(如「会议名称(请填写会议的具体名称)」这类带提示语的)也能填对。
|
|
707
|
+
*/
|
|
708
|
+
export function mapFields(fields, values, preferred = {}) {
|
|
172
709
|
const byName = new Map(fields.map((field) => [field.name.trim(), field]));
|
|
710
|
+
const byId = new Map(fields.map((field) => [field.id, field]));
|
|
711
|
+
// 归一化索引:去掉字段名里的括号提示语(「开始时间(具体开始时间)」→「开始时间」)。
|
|
712
|
+
// ⛔ 2026-10-30 真机踩到:NEW 建会流程的字段名全带提示语,按原名精确匹配会**全部落空** ——
|
|
713
|
+
// 表现为「会议名称/开始时间/结束时间都没填进去」,而 prepare 仍然显示成功。
|
|
714
|
+
const byNormalized = new Map();
|
|
715
|
+
for (const field of fields) {
|
|
716
|
+
const key = normalizeFieldName(field.name);
|
|
717
|
+
if (key && !byNormalized.has(key))
|
|
718
|
+
byNormalized.set(key, field);
|
|
719
|
+
}
|
|
173
720
|
const main = {};
|
|
174
721
|
const mapped = {};
|
|
175
722
|
const unmapped = [];
|
|
723
|
+
const resolved = {};
|
|
724
|
+
const findField = (aliases) => {
|
|
725
|
+
// ① 原名精确
|
|
726
|
+
for (const name of aliases) {
|
|
727
|
+
const field = byName.get(name);
|
|
728
|
+
if (field)
|
|
729
|
+
return field;
|
|
730
|
+
}
|
|
731
|
+
// ② 去掉括号提示语后精确
|
|
732
|
+
for (const name of aliases) {
|
|
733
|
+
const field = byNormalized.get(normalizeFieldName(name));
|
|
734
|
+
if (field)
|
|
735
|
+
return field;
|
|
736
|
+
}
|
|
737
|
+
// ③ 归一化后包含(兜住「会议名称(请填写…)」之外的变体,如「会议主题名称」)
|
|
738
|
+
for (const name of aliases) {
|
|
739
|
+
const needle = normalizeFieldName(name);
|
|
740
|
+
if (needle.length < 2)
|
|
741
|
+
continue;
|
|
742
|
+
for (const [key, field] of byNormalized) {
|
|
743
|
+
if (key.includes(needle))
|
|
744
|
+
return field;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return undefined;
|
|
748
|
+
};
|
|
176
749
|
for (const [key, raw] of Object.entries(values)) {
|
|
177
750
|
if (raw === undefined || raw === null || raw === '')
|
|
178
751
|
continue;
|
|
752
|
+
// ① 先用固化的 datakey(字段改名也不受影响);② 没有再按中文名别名匹配。
|
|
179
753
|
// 2026-10-28:租户自建字段不在别名表里 —— 此时按调用方给的**字段名原文**精确匹配,
|
|
180
754
|
// 这样 extraFields 能填流程表单上的任何字段(参会客户/联系方式之类),而不是只能填内置的十来个。
|
|
181
755
|
const aliases = FIELD_ALIASES[key] ?? [key];
|
|
182
|
-
const
|
|
756
|
+
const prefId = preferred[key];
|
|
757
|
+
const hit = (prefId ? byId.get(prefId) : undefined) ?? findField(aliases);
|
|
183
758
|
if (!hit) {
|
|
184
759
|
unmapped.push(key);
|
|
185
760
|
continue;
|
|
186
761
|
}
|
|
187
762
|
main[hit.id] = raw;
|
|
188
763
|
mapped[key] = `${hit.name}(${hit.id})`;
|
|
764
|
+
resolved[key] = hit.id;
|
|
765
|
+
}
|
|
766
|
+
return { main, mapped, unmapped, resolved };
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* 把「表单字段 → 即将提交的值」渲染成人话 Markdown 表格,用于**提交前让用户核对**。
|
|
770
|
+
*
|
|
771
|
+
* ⛔ 2026-10-31 用户诉求:「为什么没有直接发起流程表单让我确认重填后让我点击提交」。
|
|
772
|
+
* 浏览器填单那条路走不通(浏览按钮不赋值,见 submit_mode 说明),所以改用"把要提交的内容
|
|
773
|
+
* 原样列出来给用户过一眼"来替代:值里的 `{id,name}` 一律显示姓名,数组顿号连接,空值写 `—`。
|
|
774
|
+
*/
|
|
775
|
+
/** 预览时要换成姓名的字段(表单里的中文名)。 */
|
|
776
|
+
const PREVIEW_PERSON_FIELD_RE = /^(召集人|联系人|参会人员|会议创建人|主讲人|负责人|费用承担人)$/;
|
|
777
|
+
/**
|
|
778
|
+
* 把要展示的 `main` 里「纯 id」的人员字段换成姓名。
|
|
779
|
+
*
|
|
780
|
+
* 为什么不直接指望上游已经是 `{id,name}`:`caller`/`contacter` 是 CLI 自己补的默认值,
|
|
781
|
+
* `mapFields` 之后仍是裸 id(只有 `hrm_members` 走过 `{id,name}` 成型)。不换的话
|
|
782
|
+
* 提交前的核对表会写成「联系人 | 4980990112251498198」—— 用户看到的是一串数字,
|
|
783
|
+
* 2026-10-31 就因此被投诉过"联系人怎么显示成 id"。
|
|
784
|
+
*/
|
|
785
|
+
async function decoratePreviewPersonNames(env, main, fields, known = new Map()) {
|
|
786
|
+
const targets = fields.filter((field) => PREVIEW_PERSON_FIELD_RE.test(String(field?.name ?? '').trim()));
|
|
787
|
+
if (targets.length === 0)
|
|
788
|
+
return main;
|
|
789
|
+
const ID_ONLY_RE = /^\d{8,}$/;
|
|
790
|
+
/** 这一格是不是「只有 id、没有真名」——字符串形态,或 `{id,name}` 里 name 为空/等于 id。 */
|
|
791
|
+
const missingNameId = (value) => {
|
|
792
|
+
if (typeof value === 'string')
|
|
793
|
+
return ID_ONLY_RE.test(value.trim()) ? value.trim() : null;
|
|
794
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
795
|
+
const obj = value;
|
|
796
|
+
const id = String(obj.id ?? '').trim();
|
|
797
|
+
const name = String(obj.name ?? '').trim();
|
|
798
|
+
if (id !== '' && (name === '' || name === id))
|
|
799
|
+
return id;
|
|
800
|
+
}
|
|
801
|
+
return null;
|
|
802
|
+
};
|
|
803
|
+
const ids = new Set();
|
|
804
|
+
for (const field of targets) {
|
|
805
|
+
const value = main[field.id];
|
|
806
|
+
const one = missingNameId(value);
|
|
807
|
+
if (one)
|
|
808
|
+
ids.add(one);
|
|
809
|
+
else if (Array.isArray(value))
|
|
810
|
+
for (const item of value) {
|
|
811
|
+
const hit = missingNameId(item);
|
|
812
|
+
if (hit)
|
|
813
|
+
ids.add(hit);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (ids.size === 0)
|
|
817
|
+
return main;
|
|
818
|
+
let names = new Map(known);
|
|
819
|
+
const unknown = [...ids].filter((id) => !names.has(id));
|
|
820
|
+
if (unknown.length > 0) {
|
|
821
|
+
try {
|
|
822
|
+
const resolved = await resolveEmployeeNames(env, unknown);
|
|
823
|
+
for (const [id, name] of resolved)
|
|
824
|
+
names.set(id, name);
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
// 查不到就用已知的(至少调用方显式给的姓名能显示)
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (names.size === 0)
|
|
831
|
+
return main;
|
|
832
|
+
const rename = (value) => {
|
|
833
|
+
const one = missingNameId(value);
|
|
834
|
+
if (one)
|
|
835
|
+
return names.get(one) ?? value;
|
|
836
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
837
|
+
const obj = value;
|
|
838
|
+
const id = String(obj.id ?? '').trim();
|
|
839
|
+
const name = names.get(id);
|
|
840
|
+
if (name)
|
|
841
|
+
return { ...obj, name };
|
|
842
|
+
}
|
|
843
|
+
return value;
|
|
844
|
+
};
|
|
845
|
+
const decorated = { ...main };
|
|
846
|
+
for (const field of targets) {
|
|
847
|
+
const value = decorated[field.id];
|
|
848
|
+
decorated[field.id] = Array.isArray(value) ? value.map(rename) : rename(value);
|
|
849
|
+
}
|
|
850
|
+
return decorated;
|
|
851
|
+
}
|
|
852
|
+
export function renderFormPreview(fields, main) {
|
|
853
|
+
const fmt = (value) => {
|
|
854
|
+
if (value === undefined || value === null)
|
|
855
|
+
return '—';
|
|
856
|
+
if (Array.isArray(value)) {
|
|
857
|
+
const parts = value.map((one) => fmt(one)).filter((one) => one !== '—');
|
|
858
|
+
return parts.length > 0 ? parts.join('、') : '—';
|
|
859
|
+
}
|
|
860
|
+
if (typeof value === 'object') {
|
|
861
|
+
const obj = value;
|
|
862
|
+
const name = String(obj.name ?? obj.mc ?? '').trim();
|
|
863
|
+
if (name !== '')
|
|
864
|
+
return name.replace(/\|/gu, '/');
|
|
865
|
+
const id = String(obj.id ?? obj.customerId ?? obj.sjID ?? '').trim();
|
|
866
|
+
return id === '' ? '—' : id;
|
|
867
|
+
}
|
|
868
|
+
const text = String(value).trim();
|
|
869
|
+
return text === '' ? '—' : text.replace(/\|/gu, '/').replace(/\r?\n/gu, ' ');
|
|
870
|
+
};
|
|
871
|
+
const lines = ['| 字段 | 值 |', '| --- | --- |'];
|
|
872
|
+
const known = new Set();
|
|
873
|
+
for (const field of fields) {
|
|
874
|
+
known.add(field.id);
|
|
875
|
+
if (!(field.id in main))
|
|
876
|
+
continue;
|
|
877
|
+
lines.push(`| ${String(field.name).replace(/\|/gu, '/')} | ${fmt(main[field.id])} |`);
|
|
878
|
+
}
|
|
879
|
+
// 字段清单里没有、但确实要提交的(datakey 直填的历史字段)也要列出来,别悄悄带上去
|
|
880
|
+
for (const [id, value] of Object.entries(main)) {
|
|
881
|
+
if (known.has(id))
|
|
882
|
+
continue;
|
|
883
|
+
lines.push(`| ${id} | ${fmt(value)} |`);
|
|
884
|
+
}
|
|
885
|
+
return lines.join('\n');
|
|
886
|
+
}
|
|
887
|
+
function flowFieldCacheDir(env) {
|
|
888
|
+
const configured = env?.ctx?.config?.path?.();
|
|
889
|
+
const root = configured
|
|
890
|
+
? resolve(String(configured), '..')
|
|
891
|
+
: (process.env.WEAVER_WORK_CLI_HOME || process.env.WORK_CLI_HOME || resolve(homedir(), '.weaver-work-cli'));
|
|
892
|
+
return resolve(root, 'meeting', 'cache');
|
|
893
|
+
}
|
|
894
|
+
function flowFieldCacheFile(env, workflowId) {
|
|
895
|
+
return resolve(flowFieldCacheDir(env), `flow-fields-${workflowId}.json`);
|
|
896
|
+
}
|
|
897
|
+
/** 读固化映射;读不到/损坏都返回空对象(不阻断建会)。 */
|
|
898
|
+
export async function readFlowFieldMap(env, workflowId) {
|
|
899
|
+
try {
|
|
900
|
+
const file = flowFieldCacheFile(env, workflowId);
|
|
901
|
+
if (!existsSync(file))
|
|
902
|
+
return {};
|
|
903
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
904
|
+
return parsed && typeof parsed.mapping === 'object' && parsed.mapping ? parsed.mapping : {};
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
return {};
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/** 写固化映射;失败只影响"下次还要按名字猜",不影响本次结果,故吞掉异常。 */
|
|
911
|
+
async function writeFlowFieldMap(env, workflowId, workflowName, mapping) {
|
|
912
|
+
if (Object.keys(mapping).length === 0)
|
|
913
|
+
return;
|
|
914
|
+
try {
|
|
915
|
+
const file = flowFieldCacheFile(env, workflowId);
|
|
916
|
+
await mkdir(resolve(file, '..'), { recursive: true });
|
|
917
|
+
const cache = {
|
|
918
|
+
workflowId,
|
|
919
|
+
...(workflowName ? { workflowName } : {}),
|
|
920
|
+
savedAt: new Date().toISOString(),
|
|
921
|
+
mapping,
|
|
922
|
+
};
|
|
923
|
+
await writeFile(file, JSON.stringify(cache, null, 2), 'utf8');
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
/* 缓存写失败不阻断 */
|
|
189
927
|
}
|
|
190
|
-
return { main, mapped, unmapped };
|
|
191
928
|
}
|
|
192
929
|
/**
|
|
193
930
|
* 把 `workflowId` 入参规整成 id 列表。
|
|
@@ -215,7 +952,85 @@ export function normalizeWorkflowIds(raw) {
|
|
|
215
952
|
}
|
|
216
953
|
return ids;
|
|
217
954
|
}
|
|
218
|
-
/**
|
|
955
|
+
/**
|
|
956
|
+
* 自用固化:建会流程 id 列表,**顺序即优先级**(第一个当前账号能发起的直接就用)。
|
|
957
|
+
*
|
|
958
|
+
* 为什么固化(2026-10-30 用户明确要求):自用场景就固定这两条建会流程,不必每次都把
|
|
959
|
+
* "选流程"这道选择题抛给用户 —— 每次打断一轮对话,且选错流程会导致字段填不进另一条
|
|
960
|
+
* (2026-10-29 真实事故)。流程 id 写在这里而不是读应用设置,顺带省掉一次设置查询。
|
|
961
|
+
*
|
|
962
|
+
* 关闭/覆盖:
|
|
963
|
+
* - `WEAVER_MEETING_FIXED_FLOW=0` → 关掉固化,退回"读应用设置 mt_new_wf + 让用户选"的通用行为;
|
|
964
|
+
* - `WEAVER_MEETING_FIXED_FLOW_IDS=<id1,id2>` → 换一组默认流程(切租户/环境时用)。
|
|
965
|
+
*
|
|
966
|
+
* ⛔ 固化不等于"不校验权限":仍然逐个探测"当前账号能不能发起",全部发起不了才回退应用设置。
|
|
967
|
+
*/
|
|
968
|
+
const FIXED_FLOW_IDS = ['1325677244111462606', '1326190634427154474'];
|
|
969
|
+
const FIXED_FLOW_PROFILES = [
|
|
970
|
+
{
|
|
971
|
+
host: 'www.e-cology.com.cn',
|
|
972
|
+
tenantKey: 't7akvdnf84',
|
|
973
|
+
label: '泛微生产(泛微网络)',
|
|
974
|
+
ids: [...FIXED_FLOW_IDS],
|
|
975
|
+
},
|
|
976
|
+
];
|
|
977
|
+
function fixedFlowFlagOn() {
|
|
978
|
+
const flag = String(process.env.WEAVER_MEETING_FIXED_FLOW ?? '1').trim().toLowerCase();
|
|
979
|
+
return !(flag === '0' || flag === 'false' || flag === 'off');
|
|
980
|
+
}
|
|
981
|
+
function fixedFlowOverride() {
|
|
982
|
+
const override = String(process.env.WEAVER_MEETING_FIXED_FLOW_IDS ?? '').trim();
|
|
983
|
+
if (!override)
|
|
984
|
+
return [];
|
|
985
|
+
return override.split(/[,;,;\s]+/).map((item) => item.trim()).filter((item) => item !== '');
|
|
986
|
+
}
|
|
987
|
+
/** 无环境的固化列表(也供单测):环境变量覆盖 > 内置默认。 */
|
|
988
|
+
export function fixedFlowIds() {
|
|
989
|
+
if (!fixedFlowFlagOn())
|
|
990
|
+
return [];
|
|
991
|
+
const override = fixedFlowOverride();
|
|
992
|
+
return override.length > 0 ? override : [...FIXED_FLOW_IDS];
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* 按当前登录环境挑固化流程:host 必须命中配置(租户可取到才对租户,取不到只比 host)。
|
|
996
|
+
* 命中不了 → 返回空,表示"这个环境不启用固化",交回应用设置/入参决定。
|
|
997
|
+
*/
|
|
998
|
+
export async function fixedFlowIdsForEnv(env) {
|
|
999
|
+
if (!fixedFlowFlagOn())
|
|
1000
|
+
return { ids: [] };
|
|
1001
|
+
const override = fixedFlowOverride();
|
|
1002
|
+
if (override.length > 0)
|
|
1003
|
+
return { ids: override, label: '环境变量指定' };
|
|
1004
|
+
let baseUrl = '';
|
|
1005
|
+
let tenantKey = '';
|
|
1006
|
+
try {
|
|
1007
|
+
const session = await env?.host?.getSession?.();
|
|
1008
|
+
baseUrl = String(session?.baseUrl ?? '');
|
|
1009
|
+
tenantKey = String(session?.tenantKey ?? session?.tenantId ?? '');
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
/* 取不到登录环境(离线/单测)→ 按默认配置走 */
|
|
1013
|
+
}
|
|
1014
|
+
if (!baseUrl)
|
|
1015
|
+
return { ids: [...FIXED_FLOW_IDS], label: '默认固化配置(未取到登录环境)' };
|
|
1016
|
+
const host = baseUrl.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
|
|
1017
|
+
const hit = FIXED_FLOW_PROFILES.find((profile) => profile.host === host
|
|
1018
|
+
&& (!profile.tenantKey || !tenantKey || profile.tenantKey === tenantKey));
|
|
1019
|
+
return hit ? { ids: [...hit.ids], label: hit.label } : { ids: [] };
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* 多流程都能发起时,是否自动取第一个。
|
|
1023
|
+
*
|
|
1024
|
+
* ⛔ **默认不自动**(2026-10-30 用户明确纠正):候选流程**都有发起权限 → 弹窗让用户选**;
|
|
1025
|
+
* 只有一个有权限 → 直接发起(不弹窗);都没有权限 → 报"没有发起权限"。
|
|
1026
|
+
* 只有显式 `flowChoice: 'auto'`(或 `WEAVER_MEETING_FLOW_CHOICE=auto`)才自动取第一个。
|
|
1027
|
+
*/
|
|
1028
|
+
export function shouldAutoPickFlow(args) {
|
|
1029
|
+
if (args.flowChoice === 'auto')
|
|
1030
|
+
return true;
|
|
1031
|
+
return String(process.env.WEAVER_MEETING_FLOW_CHOICE ?? '').trim().toLowerCase() === 'auto';
|
|
1032
|
+
}
|
|
1033
|
+
/** 解析要用的建会流程 id:入参优先(可多个),其次固化列表,最后才是应用设置里的 mt_new_wf。 */
|
|
219
1034
|
export async function resolveWorkflowId(env, args) {
|
|
220
1035
|
const explicitList = normalizeWorkflowIds(args.workflowId);
|
|
221
1036
|
if (explicitList.length > 0) {
|
|
@@ -226,37 +1041,138 @@ export async function resolveWorkflowId(env, args) {
|
|
|
226
1041
|
source: 'argument',
|
|
227
1042
|
};
|
|
228
1043
|
}
|
|
1044
|
+
// 固化优先(2026-10-30):自用固定流程,省一次设置查询,也不受 mt_new_wf 配置影响。
|
|
1045
|
+
// 只有登录环境命中固化配置才启用(避免拿生产的流程 id 去撞测试环境)。
|
|
1046
|
+
const fixed = await fixedFlowIdsForEnv(env);
|
|
1047
|
+
if (fixed.ids.length > 0) {
|
|
1048
|
+
return {
|
|
1049
|
+
workflowId: fixed.ids.length === 1 ? fixed.ids[0] : '',
|
|
1050
|
+
candidates: fixed.ids,
|
|
1051
|
+
source: 'fixed',
|
|
1052
|
+
...(fixed.label ? { label: fixed.label } : {}),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
229
1055
|
const settings = await fetchEbAppSettings(env, 'meeting.eb.meeting.create.flow.prepare');
|
|
230
1056
|
const candidates = String(settings.mtNewWf ?? '')
|
|
231
1057
|
.split(/[,;,;\s]+/)
|
|
232
1058
|
.map((item) => item.trim())
|
|
233
1059
|
.filter((item) => item !== '');
|
|
234
1060
|
if (candidates.length === 0) {
|
|
235
|
-
throw meetingValidationError('flow_not_configured', '应用设置里没有配置「新建会议工作流」(mt_new_wf
|
|
1061
|
+
throw meetingValidationError('flow_not_configured', '应用设置里没有配置「新建会议工作流」(mt_new_wf 为空),也没有启用固化流程,无法走流程建会;'
|
|
1062
|
+
+ '请管理员在会议应用设置里配置,或用 WEAVER_MEETING_FIXED_FLOW_IDS 指定默认流程');
|
|
236
1063
|
}
|
|
237
1064
|
// 多个流程时先不拍板:由调用方探测"当前人员对每个流程有没有发起权限",再决定是否让用户选。
|
|
238
1065
|
return { workflowId: candidates.length === 1 ? candidates[0] : '', candidates, source: 'settings' };
|
|
239
1066
|
}
|
|
1067
|
+
function pad2(value) {
|
|
1068
|
+
return String(value).padStart(2, '0');
|
|
1069
|
+
}
|
|
1070
|
+
/** 明天的 yyyy-MM-dd:用户只说"开个会"没给日期时的兜底查找日。 */
|
|
1071
|
+
function tomorrowIso() {
|
|
1072
|
+
const date = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
1073
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
1074
|
+
}
|
|
1075
|
+
/** Date → "yyyy-MM-dd HH:mm"。 */
|
|
1076
|
+
function formatMinute(date) {
|
|
1077
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} `
|
|
1078
|
+
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* 并行探测一组流程 id 的可发起性(`workflow.createForm` 能载入 = 当前账号有发起权限)。
|
|
1082
|
+
* 抽出来是因为"固化 id 全不可发起 → 回退应用设置再探一轮"要复用同一段逻辑。
|
|
1083
|
+
*/
|
|
1084
|
+
async function probeWorkflowCandidates(env, ids) {
|
|
1085
|
+
const probed = await Promise.all(ids.map(async (id) => {
|
|
1086
|
+
try {
|
|
1087
|
+
const probeForm = await loadCreateForm(env, id);
|
|
1088
|
+
return {
|
|
1089
|
+
id,
|
|
1090
|
+
probeForm,
|
|
1091
|
+
probe: {
|
|
1092
|
+
workflowId: id,
|
|
1093
|
+
...(probeForm.workflowName ? { name: probeForm.workflowName } : {}),
|
|
1094
|
+
launchable: true,
|
|
1095
|
+
},
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
return {
|
|
1100
|
+
id,
|
|
1101
|
+
probeForm: null,
|
|
1102
|
+
probe: {
|
|
1103
|
+
workflowId: id,
|
|
1104
|
+
launchable: false,
|
|
1105
|
+
reason: String(error?.message ?? error).slice(0, 160),
|
|
1106
|
+
},
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
}));
|
|
1110
|
+
const probes = [];
|
|
1111
|
+
const loadedForms = new Map();
|
|
1112
|
+
for (const item of probed) {
|
|
1113
|
+
if (item.probeForm)
|
|
1114
|
+
loadedForms.set(item.id, item.probeForm);
|
|
1115
|
+
probes.push(item.probe);
|
|
1116
|
+
}
|
|
1117
|
+
return { probes, loadedForms };
|
|
1118
|
+
}
|
|
240
1119
|
const ebMeetingCreateFlowPrepare = {
|
|
241
1120
|
name: 'meeting.eb.meeting.create.flow.prepare',
|
|
242
1121
|
resource: 'eb',
|
|
243
1122
|
method: 'meeting.eb.meeting.create.flow.prepare',
|
|
244
1123
|
action: 'meeting.eb.meeting.create.flow.prepare',
|
|
245
|
-
summary: '
|
|
1124
|
+
summary: '流程建会(准备):用固化流程候选逐个探测发起权限 —— 仅 1 个可发起就自动用它、多个都可发起则返回选项让用户选、'
|
|
1125
|
+
+ '都不可发起报「没有发起权限」;不传 begin_datetime 时自动推荐「人+会议室都空闲」的时段。只出预览与确认票据,不提交。'
|
|
1126
|
+
+ '⚠️ 用户提到客户名(「客户是 X」)时**必须先** meeting.eb.crm.search {"keyword":"X"} 拿 customerId,'
|
|
1127
|
+
+ '再传 crm_members:[{id,name}];只把客户名写进会议名称/内容,表单「参会客户」就是空的',
|
|
246
1128
|
capability: 'eb.meeting',
|
|
247
1129
|
risk: 'read-before-write',
|
|
248
1130
|
requiresConfirmation: true,
|
|
249
1131
|
inputSchema: schemaOf({
|
|
250
1132
|
mt_name: { type: 'string', description: '会议名称(必填)' },
|
|
251
|
-
begin_datetime: {
|
|
252
|
-
|
|
1133
|
+
begin_datetime: {
|
|
1134
|
+
...SCHEMA_DATETIME_MINUTE,
|
|
1135
|
+
description: '开始时间(yyyy-MM-dd HH:mm)。**可以不传**:不传会按参会人与会议室空闲情况推荐 3 个时段让你选;'
|
|
1136
|
+
+ '只传开始时间、不传结束时间时,按 durationMinutes(默认 60 分钟)自动算结束时间',
|
|
1137
|
+
},
|
|
1138
|
+
end_datetime: {
|
|
1139
|
+
...SCHEMA_DATETIME_MINUTE,
|
|
1140
|
+
description: '结束时间(yyyy-MM-dd HH:mm);不传则按 durationMinutes 从开始时间推算(默认 60 分钟)',
|
|
1141
|
+
},
|
|
253
1142
|
mt_desc: { type: 'string', description: '会议内容/说明' },
|
|
254
1143
|
address: { type: 'string', description: '会议室(名称或 id,按流程表单字段要求)' },
|
|
255
1144
|
customize_address: { type: 'string', description: '自定义会议地点' },
|
|
256
1145
|
hrm_members: { type: 'string', description: '参会人员(逗号分隔员工 id 或姓名,按流程表单要求)' },
|
|
257
1146
|
total_member: { type: 'number', description: '参会人数' },
|
|
258
|
-
|
|
259
|
-
|
|
1147
|
+
crm_members: {
|
|
1148
|
+
oneOf: [{ type: 'string' }, { type: 'array', items: { oneOf: [{ type: 'string' }, { type: 'object' }] } }],
|
|
1149
|
+
description: '参会客户:**必须带客户名称**,写法 `[{id,name}]` 或「客户名(客户id)」或逗号分隔的这两种。'
|
|
1150
|
+
+ '客户先从 `meeting.eb.crm.search` 按客户名查出(入参 custom_name,出参 items[].customerId / name),'
|
|
1151
|
+
+ '该查询**一次只回首个匹配** ⇒ exact=false 时必须先让用户确认客户名再写这个字段。'
|
|
1152
|
+
+ '⛔ 只给 id 不给名称会被拒绝(客户浏览按钮按名称落值,只给 id 会静默存成空)',
|
|
1153
|
+
},
|
|
1154
|
+
crm_member_count: { type: 'number', description: '应到客户数;不传则按 crm_members 条数自动算' },
|
|
1155
|
+
crm_member_details: {
|
|
1156
|
+
type: 'array',
|
|
1157
|
+
description: '参会客户明细(宿主表单里的明细表,列 姓名/性别/职位/手机号码/备注):'
|
|
1158
|
+
+ '每行 `{"xm":"张三","xb":"男","zw":"经理","sjhm":"138...","bz":""}`,也接受中文键(姓名/性别/职位/手机号码/备注)。'
|
|
1159
|
+
+ '列字段 id 由 CLI 从表单里按列名反查,不用传。'
|
|
1160
|
+
+ '⚠️ 该明细表必须在发起节点布局里(否则 CLI 会提示"表单里没有参会客户明细"并忽略这些行)'
|
|
1161
|
+
+ '⛔ 默认不生效:客户联系人由 CLI 按 CRM 全量自动带入,这里只在 crm_detail_mode:"only" 时才用来"只写这几位"',
|
|
1162
|
+
items: { type: 'object' },
|
|
1163
|
+
},
|
|
1164
|
+
crm_detail_mode: {
|
|
1165
|
+
type: 'string',
|
|
1166
|
+
description: '明细行口径:不传(默认)= 按 CRM 把该客户**全部**联系人自动写入「参会客户明细」(一人一行,不用问用户选哪位);'
|
|
1167
|
+
+ '传 "only" = 只写 crm_member_details 里点名的这几位',
|
|
1168
|
+
},
|
|
1169
|
+
caller: { type: 'string', description: '召集人(员工 id 或姓名)。缺省 = 当前登录人,不用传;只有指定别人当召集人才传' },
|
|
1170
|
+
contacter: { type: 'string', description: '联系人(员工 id 或姓名)。缺省 = 当前登录人,不用传;只有指定别人当联系人才传' },
|
|
1171
|
+
mt_type: {
|
|
1172
|
+
type: 'string',
|
|
1173
|
+
description: '会议类型:可传类型名称或 id(先用 `meeting.eb.type.list` 查有哪些)。'
|
|
1174
|
+
+ '**很多租户的流程表单把「会议类型」设为必填**,不传会被 `workflow.createRequest` 打回缺字段',
|
|
1175
|
+
},
|
|
260
1176
|
other_remark: { type: 'string', description: '备注' },
|
|
261
1177
|
workflowId: {
|
|
262
1178
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
|
@@ -266,93 +1182,62 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
266
1182
|
},
|
|
267
1183
|
autoRoom: { type: 'boolean', description: '默认 false:未指定会议室时返回候选让用户选;传 true 才自动采用推荐' },
|
|
268
1184
|
allowNoRoom: { type: 'boolean', description: '确实不需要会议室(线上会等)时传 true,避免被“请选择会议室”拦住' },
|
|
1185
|
+
timeConfirmed: {
|
|
1186
|
+
type: 'boolean',
|
|
1187
|
+
description: '⛔ **时间的"用户已确认"标记**。只有以下两种情况才传 true:'
|
|
1188
|
+
+ '① 用户原话里明确给出了时间(如"明天 9 点到 10 点");'
|
|
1189
|
+
+ '② 用户在时段选项里点选后,把 `options[].value` **整体**原样回传(value 自带该标记)。'
|
|
1190
|
+
+ '不传 = 未确认:即使你传了 begin_datetime,CLI 也**不会**当成已定,而是返回时段选项让用户点选。'
|
|
1191
|
+
+ '⛔ 用户只说"明天/上午/下午"这类模糊说法时,**必须不传**,让 CLI 给候选',
|
|
1192
|
+
},
|
|
1193
|
+
roomConfirmed: {
|
|
1194
|
+
type: 'boolean',
|
|
1195
|
+
description: '⛔ **会议室的"用户已确认"标记**。只有以下两种情况才传 true:'
|
|
1196
|
+
+ '① 用户原话明确指定了某间会议室;② 用户在房间选项里点选后把 `options[].value` 整体原样回传。'
|
|
1197
|
+
+ '不传 = 未确认:即使传了 address,CLI 也会把"你指定的这间"和推荐候选一起列出来让用户点选。'
|
|
1198
|
+
+ '⛔ 不要自己从 room.list / room.recommend 里挑一间塞进 address —— 那是替用户拍板',
|
|
1199
|
+
},
|
|
269
1200
|
extraFields: {
|
|
270
1201
|
type: 'object',
|
|
271
1202
|
description: '按**流程表单里的字段名**填额外字段,形如 {"参会客户": "xx公司", "联系方式": "138xxxx"}'
|
|
272
1203
|
+ ':用于租户在流程表单上自建、CLI 内置别名里没有的字段。键必须与该流程表单的字段名完全一致'
|
|
273
|
-
+ '(prepare 的 warnings 会列出表单现有全部字段名);匹配不到的会进 unmapped
|
|
1204
|
+
+ '(prepare 的 warnings 会列出表单现有全部字段名);匹配不到的会进 unmapped 提示,不会瞎填。'
|
|
1205
|
+
+ '⛔ **会议地点 / 参会人员 / 召集人 / 联系人 / 会议类型 不要塞这里** —— 它们有专用入参'
|
|
1206
|
+
+ '(address / hrm_members / caller / contacter / mt_type),CLI 会把值补成浏览按钮要的'
|
|
1207
|
+
+ ' `{id,name}` 对象;塞进 extraFields 会按纯文本提交,服务端会打回「请按名称选择」',
|
|
274
1208
|
},
|
|
275
|
-
|
|
1209
|
+
dateFrom: {
|
|
1210
|
+
type: 'string',
|
|
1211
|
+
description: '没给具体时间时按哪一天推荐时段(yyyy-MM-dd;不传默认明天)',
|
|
1212
|
+
},
|
|
1213
|
+
durationMinutes: {
|
|
1214
|
+
type: 'number',
|
|
1215
|
+
description: '会议时长(分钟,默认 60):只给了开始时间时用来算结束时间;推荐时段时也按这个时长找',
|
|
1216
|
+
},
|
|
1217
|
+
workStartHour: { type: 'number', description: '推荐时段的最早小时(默认 9)' },
|
|
1218
|
+
workEndHour: { type: 'number', description: '推荐时段的最晚小时(默认 18)' },
|
|
1219
|
+
fieldValues: {
|
|
1220
|
+
type: 'object',
|
|
1221
|
+
additionalProperties: true,
|
|
1222
|
+
description: '按**表单字段 datakey(fieldId)**直接给值,形如 {"1325677252711735881": "研讨会"}。'
|
|
1223
|
+
+ '优先级最高(同名字段以它为准):适合字段名带提示语、或租户自建字段这类按中文名猜不到的情况。'
|
|
1224
|
+
+ 'datakey 从 prepare 返回的 preview.mapped(形如 "会议名称(1325677252711735881)")里取;'
|
|
1225
|
+
+ '每个流程的 datakey 固定不变,可长期硬编码。',
|
|
1226
|
+
},
|
|
1227
|
+
flowChoice: {
|
|
1228
|
+
type: 'string',
|
|
1229
|
+
enum: ['auto', 'ask'],
|
|
1230
|
+
description: '多个建会流程**都有发起权限**时怎么办:**默认(不传 / ask)= 返回选项让用户挑**;'
|
|
1231
|
+
+ 'auto = 自动取优先级最高的那个(省一轮,但选错流程会漏字段,慎用)。'
|
|
1232
|
+
+ '只有 1 个有权限时不受此参数影响,CLI 直接发起',
|
|
1233
|
+
},
|
|
1234
|
+
}, { required: ['mt_name'] }),
|
|
276
1235
|
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
|
-
}
|
|
1236
|
+
// ⚠️ 2026-10-30 顺序调整:**先确认四要素(名称 / 时间 / 参会人 / 地点),最后才定流程**。
|
|
1237
|
+
// 流程探测与选择整体后移到房间闸门之后(见下方「流程闸门」);这里先占位,
|
|
1238
|
+
// 好让前面的时间/会议室选择分支仍能带出"当前用的是哪条流程"(此刻通常还是空的)。
|
|
1239
|
+
let workflowId = '';
|
|
1240
|
+
let flowPickNote = null;
|
|
356
1241
|
// 走流程也要有"选会议室 / 选时间"的体验(2026-10-28 用户要求)。
|
|
357
1242
|
// ⚠️ 性能:会议室推荐、参会人冲突、共同空闲时段三件事**互相独立**,必须并行发;
|
|
358
1243
|
// 串行会把 3 段网络耗时叠加成"建个会要等好几分钟"(用户 2026-10-28 反馈)。
|
|
@@ -361,11 +1246,118 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
361
1246
|
// 适用场景:人、时间、房间都已经确定,只想尽快把会建出来(可省掉 3 组额外请求与一次 ESB 调用)。
|
|
362
1247
|
const enrich = args.enrich !== false;
|
|
363
1248
|
const beginText = String(args.begin_datetime ?? '').trim();
|
|
364
|
-
|
|
365
|
-
const
|
|
1249
|
+
let endText = String(args.end_datetime ?? '').trim();
|
|
1250
|
+
const rawMembers = String(args.hrm_members ?? '')
|
|
366
1251
|
.split(/[,;,;\s]+/)
|
|
367
1252
|
.map((item) => item.trim())
|
|
368
1253
|
.filter((item) => item !== '');
|
|
1254
|
+
// ⛔ 参会人冲突检测(conflict.member)与共同空闲时段(schedule.free)**要的是员工 id**,
|
|
1255
|
+
// 传姓名会被接口拒 —— 两路 Promise 都是 `.catch(() => null)`,于是"检测没跑"和"检测通过"
|
|
1256
|
+
// 在返回里长得一模一样,用户以为查过了(2026-10-30 真机:agent 传"易鹏",两路全部静默跳过)。
|
|
1257
|
+
// 所以这里先把姓名换成 id:纯数字直接用,姓名走 hrm.employee.query 反查(查不到保持原名,不猜)。
|
|
1258
|
+
const nameLike = rawMembers.filter((item) => !/^\d+$/.test(item));
|
|
1259
|
+
const nameToId = nameLike.length > 0
|
|
1260
|
+
? await import('./schedule.js')
|
|
1261
|
+
.then((m) => m.resolveMemberIdsByName(env, nameLike))
|
|
1262
|
+
.catch(() => new Map())
|
|
1263
|
+
: new Map();
|
|
1264
|
+
const memberIds = rawMembers.map((item) => nameToId.get(item) ?? item);
|
|
1265
|
+
// ③ 时间闸门:**传了具体时间 ≠ 用户已经确认**。
|
|
1266
|
+
// 2026-10-30 真机事故:用户只说"明天上午",agent 自己挑了个 9:00–10:00 传进来,
|
|
1267
|
+
// CLI 当成"已定"直接进确认摘要,用户看到「时间:09:00–10:00」质问"谁决定的"。
|
|
1268
|
+
// 所以只有 `timeConfirmed: true`(用户原话明确给了时间,或用户在选项里点选后把 value 原样回传)
|
|
1269
|
+
// 才认这个时间;否则一律返回时段选项让用户点 —— 宁可多问一轮,也不替用户拍板。
|
|
1270
|
+
const timeConfirmed = args.timeConfirmed === true;
|
|
1271
|
+
// ⛔ 2026-10-30 真机:agent 在用户只说"明天上午"时就自己塞了 workflowId,
|
|
1272
|
+
// 于是它脑内"流程已定",回复里先讲流程再问时间(用户投诉顺序反了)。
|
|
1273
|
+
// 时间/地点闸门阶段流程根本没定 —— 这里只做提示,不阻断(用户可能确实点名了流程,
|
|
1274
|
+
// 那种情况下这个提示也算说明"流程还没到提交")。
|
|
1275
|
+
const callerWorkflowId = String(args.workflowId ?? '').trim();
|
|
1276
|
+
const earlyWorkflowNote = callerWorkflowId === ''
|
|
1277
|
+
? ''
|
|
1278
|
+
: `⛔ 你传了 workflowId=${callerWorkflowId},但**流程还没到确认步骤**(顺序是 名称→时间→参会人→地点→流程)。`
|
|
1279
|
+
+ '本次只处理时间/地点;⛔ 不要在回复里说"按 X 流程发起"——四要素定完后 CLI 会返回流程选项让用户点。';
|
|
1280
|
+
const beginHasTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(beginText);
|
|
1281
|
+
const endHasTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(endText);
|
|
1282
|
+
if (beginHasTime && !endHasTime) {
|
|
1283
|
+
// 只给了开始时间:按 durationMinutes(默认 60)补齐结束时间,不必打断用户。
|
|
1284
|
+
const duration = Number(args.durationMinutes) > 0 ? Math.round(Number(args.durationMinutes)) : 60;
|
|
1285
|
+
const startMs = Date.parse(beginText.slice(0, 16).replace(' ', 'T'));
|
|
1286
|
+
if (Number.isFinite(startMs))
|
|
1287
|
+
endText = formatMinute(new Date(startMs + duration * 60000));
|
|
1288
|
+
}
|
|
1289
|
+
if (!beginHasTime || !timeConfirmed) {
|
|
1290
|
+
const day = /^\d{4}-\d{2}-\d{2}/.test(beginText)
|
|
1291
|
+
? beginText.slice(0, 10)
|
|
1292
|
+
: (String(args.dateFrom ?? '').trim().slice(0, 10) || tomorrowIso());
|
|
1293
|
+
const duration = Number(args.durationMinutes) > 0 ? Math.round(Number(args.durationMinutes)) : 60;
|
|
1294
|
+
const { getMeetingOperation } = await import('../registry.js');
|
|
1295
|
+
const suggested = await getMeetingOperation('meeting.eb.meeting.create.suggest').handler({
|
|
1296
|
+
dateFrom: day,
|
|
1297
|
+
durationMinutes: duration,
|
|
1298
|
+
hrm_members: memberIds.join(','),
|
|
1299
|
+
maxSlots: 3,
|
|
1300
|
+
topN: 3,
|
|
1301
|
+
...(Number(args.total_member) > 0 ? { memberCount: Number(args.total_member) } : {}),
|
|
1302
|
+
...(args.workStartHour !== undefined ? { workStartHour: Number(args.workStartHour) } : {}),
|
|
1303
|
+
...(args.workEndHour !== undefined ? { workEndHour: Number(args.workEndHour) } : {}),
|
|
1304
|
+
}, env).catch((error) => ({ data: { slots: [] }, slotError: String(error?.message ?? error).slice(0, 160) }));
|
|
1305
|
+
const slotOptions = (suggested?.data?.slots ?? []);
|
|
1306
|
+
const slotWarnings = [
|
|
1307
|
+
...(suggested?.warnings ?? []),
|
|
1308
|
+
...(suggested?.slotError ? [`时段推荐未跑成功:${suggested.slotError}`] : []),
|
|
1309
|
+
...(memberIds.length === 0
|
|
1310
|
+
? ['⚠️ 没有参会人员,无法核验"人是否空闲" —— 下面只是**会议室有空**的时段,请如实说明。']
|
|
1311
|
+
: []),
|
|
1312
|
+
];
|
|
1313
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
1314
|
+
status: 'NEEDS_SELECTION',
|
|
1315
|
+
selectionType: 'slot',
|
|
1316
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'T1' },
|
|
1317
|
+
message: slotOptions.length > 0
|
|
1318
|
+
? `${beginHasTime
|
|
1319
|
+
? `你传入的时间 ${beginText}~${endText} **未经用户确认**(没带 timeConfirmed)`
|
|
1320
|
+
: '没有给出具体时间'},已按参会人与会议室空闲情况推荐 ${slotOptions.length} 个时段`
|
|
1321
|
+
+ `(${day},时长 ${duration} 分钟)—— ⛔ 必须让用户在选项里点选,`
|
|
1322
|
+
+ '**不要**把任何一个候选写成"已定"或放进确认摘要'
|
|
1323
|
+
: `${beginHasTime
|
|
1324
|
+
? `你传入的时间 ${beginText}~${endText} 未经用户确认(没带 timeConfirmed)`
|
|
1325
|
+
: '没有给出具体时间'},且 ${day} 没能找到「参会人空闲 + 会议室空闲」的时段,`
|
|
1326
|
+
+ '请让用户换个日期或指定时间',
|
|
1327
|
+
options: slotOptions.map((slot, index) => ({
|
|
1328
|
+
key: `T${index + 1}`,
|
|
1329
|
+
label: String(slot.label ?? ''),
|
|
1330
|
+
// ⛔ 时间不在 slot 顶层,而在 slot.value 里(suggest 的返回结构)—— 取错会让调用方
|
|
1331
|
+
// 拿不到 begin/end,只能再问用户一次时间(2026-10-30 冒烟实测踩到)。
|
|
1332
|
+
// `timeConfirmed: true` 在这里是**关键**:用户点选后 value 原样回传,下一轮才被认作"已确认";
|
|
1333
|
+
// agent 自己编的时间不带这个标记,就永远停在选项这一步。
|
|
1334
|
+
value: {
|
|
1335
|
+
begin_datetime: String(slot.value?.begin ?? slot.begin ?? '').trim(),
|
|
1336
|
+
end_datetime: String(slot.value?.end ?? slot.end ?? '').trim(),
|
|
1337
|
+
address: slot.value?.address ?? null,
|
|
1338
|
+
timeConfirmed: true,
|
|
1339
|
+
},
|
|
1340
|
+
reason: String(slot.reason ?? ''),
|
|
1341
|
+
rooms: slot.rooms ?? [],
|
|
1342
|
+
})),
|
|
1343
|
+
day,
|
|
1344
|
+
durationMinutes: duration,
|
|
1345
|
+
memberIds,
|
|
1346
|
+
// ⛔ 这里**不能**出现 workflowId:流程确认在四要素之后(2026-10-30 顺序调整)。
|
|
1347
|
+
// 返回 workflowId 会让 agent 误以为"流程已定",于是在回复里先讲流程、再问时间。
|
|
1348
|
+
workflowPending: true,
|
|
1349
|
+
hint: '选定后把 value **整体**原样带回重新调用(含 timeConfirmed: true);'
|
|
1350
|
+
+ 'address 已自动带上该时段可用的会议室,仍需让用户单独确认或用 roomConfirmed: true',
|
|
1351
|
+
}, {
|
|
1352
|
+
warnings: [
|
|
1353
|
+
`${beginHasTime
|
|
1354
|
+
? `本次**没有提交**:你传入的时间 ${beginText}~${endText} 没有用户确认标记(timeConfirmed),已改为给候选。`
|
|
1355
|
+
: '本次**没有提交**:时间未确定(缺 begin_datetime)。'}已按 ${day} 推荐时段,请用选项让用户挑一个。`,
|
|
1356
|
+
...slotWarnings,
|
|
1357
|
+
...(earlyWorkflowNote ? [earlyWorkflowNote] : []),
|
|
1358
|
+
],
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
369
1361
|
let addressInput = args.address;
|
|
370
1362
|
// 记录"用户有没有自己指定会议室":后面决定文案是"按你的指定"还是"自动选择"。
|
|
371
1363
|
const hadAddressInput = String(args.address ?? '').trim() !== '';
|
|
@@ -377,29 +1369,69 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
377
1369
|
const startedAt = Date.now();
|
|
378
1370
|
// 推荐照常跑:**即使调用方指定了会议室**也要跑 —— 一来说明"你指定的那间是否被占用",
|
|
379
1371
|
// 二来冲突时能立刻给出替代候选(2026-10-29 用户明确要求:指定了也要判冲突、冲突了要推荐)。
|
|
1372
|
+
// ⛔ 这几路增强检测失败**必须留下痕迹**:以前统一 `.catch(() => null)`,
|
|
1373
|
+
// 结果"没查"和"查了没冲突"在返回里长得一模一样 —— 用户以为核验过了(2026-10-30 真机事故)。
|
|
1374
|
+
const enrichFailures = [];
|
|
1375
|
+
const noteFailure = (label) => (error) => {
|
|
1376
|
+
enrichFailures.push(`${label}未完成:${String(error?.message ?? error).slice(0, 120)}`);
|
|
1377
|
+
return null;
|
|
1378
|
+
};
|
|
1379
|
+
// 「召集人 / 联系人」缺省 = 当前登录人(2026-10-31 用户要求)。
|
|
1380
|
+
// ⛔ 以前只有调用方显式传值才写这两个字段:换个账号用(别人用)就整块空白,
|
|
1381
|
+
// 流程单上「召集人」「联系人」空着、邀请函也落成【待补充】。现在默认取当前登录人,
|
|
1382
|
+
// 只有显式传了别的值才以传的为准(各自独立兜底,只缺一个就只补一个)。
|
|
1383
|
+
const needSelfPerson = String(args.caller ?? '').trim() === '' || String(args.contacter ?? '').trim() === '';
|
|
1384
|
+
const autoPerson = applySelfPersonDefaults(args, needSelfPerson ? await resolveSelfPerson(env) : null);
|
|
1385
|
+
const autoFilledPerson = autoPerson.filled;
|
|
1386
|
+
const selfPersonWarning = autoPerson.warning;
|
|
1387
|
+
// ⛔ 会议人数要含召集人:`hrm_members` 通常只列"其他参会人",召集人是自己。
|
|
1388
|
+
// 以前直接用 `memberIds.length`,1 个参会人就按 1 人算 → 容量下限 2 人的房间把它判死,
|
|
1389
|
+
// 出现「可用 1 间却被过滤成 0 候选」(2026-10-30 真机:易鹏 1 人 → 测试会议室1 1/2 被排除)。
|
|
1390
|
+
const declaredTotal = Number(args.total_member) > 0 ? Number(args.total_member) : null;
|
|
1391
|
+
const callerToken = String(args.caller ?? '').trim();
|
|
1392
|
+
const callerInMembers = callerToken !== '' && (rawMembers.includes(callerToken) || memberIds.includes(callerToken));
|
|
1393
|
+
const roomMemberCount = declaredTotal
|
|
1394
|
+
?? (memberIds.length > 0 ? memberIds.length + (callerInMembers ? 0 : 1) : undefined);
|
|
380
1395
|
const roomTask = (enrich && beginText && endText)
|
|
381
1396
|
? recommendRooms(env, {
|
|
382
1397
|
beginDatetime: beginText,
|
|
383
1398
|
endDatetime: endText,
|
|
384
|
-
...(
|
|
1399
|
+
...(roomMemberCount ? { memberCount: roomMemberCount } : {}),
|
|
1400
|
+
// 建会是"要下单"的场景:宁可多一次请求也必须拿最新台账(24h 缓存会让刚导入的会议室看不到)。
|
|
1401
|
+
refreshRooms: true,
|
|
385
1402
|
topN: 3,
|
|
386
|
-
}).catch(()
|
|
1403
|
+
}).catch(noteFailure('会议室推荐'))
|
|
387
1404
|
: Promise.resolve(null);
|
|
388
1405
|
// ⛔ 指定了会议室时,**单独查它在该时段是否已被占用**(query_roomusage_list)。
|
|
389
1406
|
// 以前 `!addressInput` 直接跳过整段 → "指定了一间被占用的房"照样往下走,用户到页面上才发现。
|
|
1407
|
+
// ⛔ 2026-10-31 修:以前只有"纯数字 id"才查,传名称(用户最常说的「测试会议室1」)整段被跳过。
|
|
1408
|
+
// 现在统一先解析成台账里的真实房间(id/名称/名称(id) 都认),再查占用 + 存在性 + 封存状态。
|
|
390
1409
|
const addressId = String(addressInput ?? '').trim();
|
|
391
|
-
|
|
392
|
-
|
|
1410
|
+
// ④ 会议室闸门:与时间同理 —— 传了 address **不等于**用户确认过这间房。
|
|
1411
|
+
// 只有 `roomConfirmed: true`(用户明确说了哪间,或在选项里点选后把 value 原样回传)才认;
|
|
1412
|
+
// 否则返回房间选项让用户点。2026-10-30 同一事故:agent 把推荐房间写进确认摘要当定论。
|
|
1413
|
+
const roomConfirmed = args.roomConfirmed === true;
|
|
1414
|
+
const roomConflictTask = (enrich && beginText && endText && addressId !== '')
|
|
393
1415
|
? (async () => {
|
|
1416
|
+
const room = await resolveAddressRoom(env, addressId);
|
|
1417
|
+
// 名称对不上任何房间 → 直接判"没这间房",别把一串文本丢进表单等提交时才报错
|
|
1418
|
+
if (!room)
|
|
1419
|
+
return { unresolved: true, input: addressId };
|
|
1420
|
+
// 台账读失败 / 纯 id 台账里没有 → 没法判占用,但不拦(避免网络抖动被当成"房间不存在")
|
|
1421
|
+
if (!room.id || room.lookupFailed || room.status !== '0')
|
|
1422
|
+
return { room };
|
|
394
1423
|
const { getMeetingOperation } = await import('../registry.js');
|
|
395
|
-
|
|
396
|
-
|
|
1424
|
+
const result = await getMeetingOperation('meeting.eb.conflict.room').handler({ beginDatetime: beginText, endDatetime: endText, address: room.id }, env);
|
|
1425
|
+
return { room, ...(result?.data ?? {}) };
|
|
1426
|
+
})().catch(noteFailure('会议室**占用**检测'))
|
|
397
1427
|
: Promise.resolve(null);
|
|
1428
|
+
// ⛔ 这三路检测失败**必须留下痕迹**:以前统一 `.catch(() => null)`,
|
|
1429
|
+
// 结果"没查"和"查了没冲突"在返回里长得一模一样 —— 用户以为核验过了(2026-10-30 真机事故)。
|
|
398
1430
|
const conflictTask = (enrich && beginText && endText && memberIds.length > 0)
|
|
399
1431
|
? (async () => {
|
|
400
1432
|
const { getMeetingOperation } = await import('../registry.js');
|
|
401
1433
|
return getMeetingOperation('meeting.eb.conflict.member').handler({ beginDatetime: beginText, endDatetime: endText, hrmMembers: memberIds.join(',') }, env);
|
|
402
|
-
})().catch(()
|
|
1434
|
+
})().catch(noteFailure('参会人**会议占用**检测'))
|
|
403
1435
|
: Promise.resolve(null);
|
|
404
1436
|
const availabilityTask = (enrich && beginText && endText && memberIds.length > 0)
|
|
405
1437
|
? findCommonFreeSlots(env, {
|
|
@@ -410,26 +1442,111 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
410
1442
|
maxSlots: 3,
|
|
411
1443
|
maxDays: 1,
|
|
412
1444
|
includeSchedule: true,
|
|
413
|
-
}).catch(()
|
|
1445
|
+
}).catch(noteFailure('参会人**日程空闲**检测'))
|
|
414
1446
|
: Promise.resolve(null);
|
|
415
1447
|
const [roomRec, memberConflict, free, roomConflict] = await Promise.all([
|
|
416
1448
|
roomTask, conflictTask, availabilityTask, roomConflictTask,
|
|
417
1449
|
]);
|
|
418
1450
|
timings.parallelMs = Date.now() - startedAt;
|
|
1451
|
+
// 参会人冲突 / 共同空闲时段**已经并行算完了**。即使接下来要先让用户选会议室,
|
|
1452
|
+
// 也必须把这两项结论一起交出去 —— 否则用户选完房才知道时间撞了,等于白选一次
|
|
1453
|
+
// (2026-10-30 真机:会议室查不到 → 卡在选房 → 冲突结论永远拿不到,用户以为"没判断")。
|
|
1454
|
+
const memberConflicts = (memberConflict?.data?.conflicts ?? []);
|
|
1455
|
+
if (memberConflicts.length > 0)
|
|
1456
|
+
decisions.memberConflicts = memberConflicts;
|
|
1457
|
+
const freeSlots = free?.slots ?? [];
|
|
1458
|
+
if (free) {
|
|
1459
|
+
decisions.scheduleItems = free.scheduleItems;
|
|
1460
|
+
decisions.availabilityCost = free.cost;
|
|
1461
|
+
if (freeSlots.length > 0) {
|
|
1462
|
+
decisions.suggestedSlots = freeSlots;
|
|
1463
|
+
decisions.slotOptions = freeSlots.map((slot, index) => ({
|
|
1464
|
+
key: `T${index + 1}`,
|
|
1465
|
+
label: `${slot.begin.slice(11)}~${slot.end.slice(11)}(${day}) · 全部参会人空闲`,
|
|
1466
|
+
value: { begin: slot.begin, end: slot.end },
|
|
1467
|
+
reason: '该时段所有参会人都没有会议与日程占用(已同时核对会议回执与日程)',
|
|
1468
|
+
}));
|
|
1469
|
+
}
|
|
1470
|
+
else if (free.blockingSummary) {
|
|
1471
|
+
decisions.dayBlocked = { day, summary: free.blockingSummary };
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
// 选房阶段也要能看到"时间到底冲不冲突",否则等于没查。
|
|
1475
|
+
const conflictBrief = {
|
|
1476
|
+
memberConflicts,
|
|
1477
|
+
suggestedSlots: freeSlots,
|
|
1478
|
+
...(enrichFailures.length > 0 ? { enrichFailures } : {}),
|
|
1479
|
+
};
|
|
1480
|
+
const failureWarnings = enrichFailures.map((failure) => `⚠️ ${failure} —— **这不代表没有冲突**,请如实告知用户该项未核验,不要声称"时间已确认空闲"`);
|
|
419
1481
|
// 会议室候选(指定/未指定都用这一份):冲突时直接拿它当替代方案。
|
|
420
1482
|
const roomOptions = (roomRec?.candidates ?? []).map((candidate, index) => ({
|
|
421
1483
|
key: `R${index + 1}`,
|
|
422
1484
|
label: `${candidate.name}${candidate.capacity.max > 0 ? `(可容 ${candidate.capacity.max} 人)` : ''}`
|
|
423
1485
|
+ `${index === 0 ? ' · 推荐' : ''}`,
|
|
424
|
-
|
|
1486
|
+
// roomConfirmed: true = 用户点选该选项即代表"确认用这间",原样回传后下一轮才被认。
|
|
1487
|
+
value: { id: candidate.id, name: candidate.name, roomConfirmed: true },
|
|
425
1488
|
reason: candidate.reasons.join(';'),
|
|
426
1489
|
}));
|
|
427
1490
|
// ① 指定的会议室该时段已被占用 → 不替用户改,但必须拦下来并给替代候选。
|
|
428
|
-
const roomConflictData = roomConflict
|
|
429
|
-
|
|
1491
|
+
const roomConflictData = roomConflict;
|
|
1492
|
+
const resolvedAddress = roomConflictData?.room ?? null;
|
|
1493
|
+
const resolvedAddressId = resolvedAddress?.id || addressId;
|
|
1494
|
+
const addressDisplay = resolvedAddress?.name || addressId;
|
|
1495
|
+
// ①-a 名称对不上任何房间。
|
|
1496
|
+
// ⛔ 2026-10-31 真机:以前这种会一路走到 AWAITING_CONFIRMATION,地点按纯文本塞进表单,
|
|
1497
|
+
// 用户到流程页面提交时才被工作流打回「请按名称选择」—— 报错位置离真实原因太远。
|
|
1498
|
+
if (roomConflictData?.unresolved === true) {
|
|
1499
|
+
decisions.roomNotFound = { input: addressId, candidates: roomOptions.length };
|
|
1500
|
+
if (roomOptions.length > 0)
|
|
1501
|
+
decisions.roomOptions = roomOptions;
|
|
1502
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
1503
|
+
status: 'NEEDS_SELECTION',
|
|
1504
|
+
selectionType: 'room',
|
|
1505
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'R1' },
|
|
1506
|
+
message: `没找到名为「${addressId}」的会议室(会议室台账里没有这个名字),本次**没有提交**。`
|
|
1507
|
+
+ (roomOptions.length > 0
|
|
1508
|
+
? `请从下面 ${roomOptions.length} 个在该时段空闲的会议室里选一间,或核对一下会议室名称`
|
|
1509
|
+
: '该时段也没有可用的空闲会议室,请改会议时间或改线上会议(allowNoRoom: true)'),
|
|
1510
|
+
workflowPending: true,
|
|
1511
|
+
options: roomOptions,
|
|
1512
|
+
recommended: roomOptions[0]?.value ?? null,
|
|
1513
|
+
roomNotFound: decisions.roomNotFound,
|
|
1514
|
+
hint: '选定后把 value **整体**原样带回(含 roomConfirmed: true);'
|
|
1515
|
+
+ '核对名称可用 `meeting.eb.room.list` 看全量清单',
|
|
1516
|
+
}, {
|
|
1517
|
+
warnings: [
|
|
1518
|
+
`会议室「${addressId}」在台账里不存在,已拦下未提交。`,
|
|
1519
|
+
'⛔ 不要把它当成"用户指定的房间"写进确认摘要 —— 这间房根本没有被解析出来。',
|
|
1520
|
+
],
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
// ①-b 查到了,但已封存(status=1):不能拿去建会。
|
|
1524
|
+
if (resolvedAddress && resolvedAddress.status === '1') {
|
|
1525
|
+
decisions.roomSealed = { id: resolvedAddress.id, name: resolvedAddress.name };
|
|
1526
|
+
if (roomOptions.length > 0)
|
|
1527
|
+
decisions.roomOptions = roomOptions;
|
|
1528
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
1529
|
+
status: 'NEEDS_SELECTION',
|
|
1530
|
+
selectionType: 'room',
|
|
1531
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'R1' },
|
|
1532
|
+
message: `会议室「${resolvedAddress.name}」当前是**封存**状态(不可预约),本次**没有提交**。`
|
|
1533
|
+
+ (roomOptions.length > 0
|
|
1534
|
+
? '请改用下面空闲的会议室'
|
|
1535
|
+
: '该时段没有其它可用会议室,请改会议时间'),
|
|
1536
|
+
workflowPending: true,
|
|
1537
|
+
options: roomOptions,
|
|
1538
|
+
recommended: roomOptions[0]?.value ?? null,
|
|
1539
|
+
roomSealed: decisions.roomSealed,
|
|
1540
|
+
}, {
|
|
1541
|
+
warnings: [`会议室「${resolvedAddress.name}」(${resolvedAddress.id})已封存,不可预约,已拦下未提交。`],
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
// 只有"用户已确认这间房"时才按占用拦截;未确认的只是候选,交给下面的选房分支处理。
|
|
1545
|
+
if (addressInput && roomConfirmed && roomConflictData?.hasConflict === true) {
|
|
430
1546
|
const busy = Array.isArray(roomConflictData.conflicts) ? roomConflictData.conflicts : [];
|
|
431
1547
|
decisions.roomConflict = {
|
|
432
|
-
address:
|
|
1548
|
+
address: resolvedAddressId,
|
|
1549
|
+
addressName: addressDisplay,
|
|
433
1550
|
hasConflict: true,
|
|
434
1551
|
conflicts: busy,
|
|
435
1552
|
verdict: roomConflictData.verdict ?? 'conflict',
|
|
@@ -439,15 +1556,18 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
439
1556
|
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
440
1557
|
status: 'NEEDS_SELECTION',
|
|
441
1558
|
selectionType: 'room_conflict',
|
|
442
|
-
|
|
443
|
-
|
|
1559
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'R1' },
|
|
1560
|
+
message: `你指定的会议室「${addressDisplay}」在 ${beginText}~${endText} 已被占用`
|
|
1561
|
+
+ `(${busy.length} 条占用记录),本次**没有提交**。请改用下面空闲的会议室,或调整会议时间`,
|
|
1562
|
+
workflowPending: true,
|
|
444
1563
|
options: roomOptions,
|
|
445
1564
|
recommended: roomOptions[0]?.value ?? null,
|
|
446
1565
|
conflicts: busy,
|
|
1566
|
+
roomConflict: decisions.roomConflict,
|
|
447
1567
|
hint: '选定后把 value.id 作为 address 重新调用;要改时间就同时传新的 begin_datetime/end_datetime',
|
|
448
1568
|
}, {
|
|
449
1569
|
warnings: [
|
|
450
|
-
|
|
1570
|
+
`会议室「${addressDisplay}」(${resolvedAddressId})在该时段已有 ${busy.length} 条占用,已拦下未提交。`,
|
|
451
1571
|
roomOptions.length > 0
|
|
452
1572
|
? `已按人数/常用/空闲给出 ${roomOptions.length} 个替代会议室(R1 为首选)。`
|
|
453
1573
|
: '该时段没有可替代的空闲会议室,建议调整会议时间或改线上会议(allowNoRoom: true)。',
|
|
@@ -466,18 +1586,54 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
466
1586
|
// 会议室**不替用户决定**(用户明确要求:即使是常用会议室,也必须推荐出来让选择)。
|
|
467
1587
|
// 只有显式 `autoRoom: true` 才自动选中;否则返回 NEEDS_SELECTION,把候选交给调用方去问。
|
|
468
1588
|
const autoRoom = args.autoRoom === true;
|
|
469
|
-
|
|
1589
|
+
// 传了房间但没用户确认标记 → 同样要给选项:把"你指定的那间"排到第一位,其余房间照常列在下面。
|
|
1590
|
+
// 2026-10-30 事故:agent 自己挑了「测试会议室1」传进来,CLI 直接用,用户问"会议室没让我选"。
|
|
1591
|
+
const needRoomPick = !addressInput || !roomConfirmed;
|
|
1592
|
+
let pickOptions = roomOptions;
|
|
1593
|
+
if (addressInput && !roomConfirmed) {
|
|
1594
|
+
// 用**解析后的真实房间 id** 去匹配候选:传名称时以前匹配不上,会被错标成"未解析的指定项"。
|
|
1595
|
+
const hit = roomOptions.find((item) => String(item.value?.id) === resolvedAddressId);
|
|
1596
|
+
const specified = hit
|
|
1597
|
+
? { ...hit, label: `${hit.label}(你指定的,待确认)`, value: { ...hit.value, roomConfirmed: true } }
|
|
1598
|
+
: {
|
|
1599
|
+
key: 'R0',
|
|
1600
|
+
label: `${addressDisplay}(你指定的,待确认)`,
|
|
1601
|
+
value: { ...(resolvedAddressId ? { id: resolvedAddressId } : {}), name: addressDisplay, roomConfirmed: true },
|
|
1602
|
+
reason: '你传入的会议室,未提交、未确认',
|
|
1603
|
+
};
|
|
1604
|
+
pickOptions = [specified, ...roomOptions.filter((item) => String(item.value?.id) !== resolvedAddressId)]
|
|
1605
|
+
.map((item, index) => ({ ...item, key: `R${index + 1}` }));
|
|
1606
|
+
}
|
|
1607
|
+
if (needRoomPick && !autoRoom && pickOptions.length > 0 && args.allowNoRoom !== true) {
|
|
470
1608
|
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
471
1609
|
status: 'NEEDS_SELECTION',
|
|
472
1610
|
selectionType: 'room',
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1611
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'R1' },
|
|
1612
|
+
message: addressInput
|
|
1613
|
+
? `你传入的会议室「${addressDisplay}」**未经用户确认**(没带 roomConfirmed),已把它和其它候选一起列出来`
|
|
1614
|
+
+ ' —— ⛔ 必须让用户在选项里点选,不要直接提交'
|
|
1615
|
+
: `未指定会议室,请从 ${pickOptions.length} 个候选里选择(已按人数/常用/时段占用排序)`,
|
|
1616
|
+
// 固化自动选的流程要带出来:调用方下一轮带 address 重调时不用再猜用的是哪条流程。
|
|
1617
|
+
workflowPending: true,
|
|
1618
|
+
options: pickOptions,
|
|
1619
|
+
recommended: pickOptions[0]?.value ?? null,
|
|
1620
|
+
hint: '选定后把 value **整体**原样带回(含 roomConfirmed: true);'
|
|
1621
|
+
+ '确实不需要会议室请传 allowNoRoom: true',
|
|
1622
|
+
...conflictBrief,
|
|
477
1623
|
}, {
|
|
478
1624
|
warnings: [
|
|
479
|
-
|
|
1625
|
+
addressInput
|
|
1626
|
+
? `会议室「${addressDisplay}」未经用户确认:本次**没有**提交。`
|
|
1627
|
+
: '未指定会议室:本次**没有**提交,也没有替用户挑房间。',
|
|
480
1628
|
'如需直接建会并沿用推荐,可传 autoRoom: true;不需要会议室则传 allowNoRoom: true。',
|
|
1629
|
+
...(earlyWorkflowNote ? [earlyWorkflowNote] : []),
|
|
1630
|
+
...(memberConflicts.length > 0
|
|
1631
|
+
? [`⚠️ 注意:参会人在该时段已有 ${memberConflicts.length} 场会议冲突 —— 选房前先把这条告诉用户,别让他选完才发现。`]
|
|
1632
|
+
: []),
|
|
1633
|
+
...(freeSlots.length > 0
|
|
1634
|
+
? [`当天全部参会人都空闲的时段:${freeSlots.map((s) => `${s.begin.slice(11)}~${s.end.slice(11)}`).join('、')}`]
|
|
1635
|
+
: []),
|
|
1636
|
+
...failureWarnings,
|
|
481
1637
|
],
|
|
482
1638
|
});
|
|
483
1639
|
}
|
|
@@ -501,41 +1657,218 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
501
1657
|
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
502
1658
|
status: 'NEEDS_SELECTION',
|
|
503
1659
|
selectionType: 'room_unavailable',
|
|
1660
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'C2' },
|
|
504
1661
|
message: '未指定会议室,且按人数/时段没能推荐出可用的空闲会议室。'
|
|
505
1662
|
+ '请让用户从下面三条里选一条,**不要**直接提交一个没有地点的会议',
|
|
1663
|
+
workflowPending: true,
|
|
506
1664
|
options: [
|
|
507
1665
|
{ key: 'C1', label: '我指定具体会议室(表单里点选,或告诉我会议室名称/id)', value: { action: 'specify' } },
|
|
508
1666
|
{ key: 'C2', label: '改用线上会议(腾讯会议/视频)', value: { action: 'no_room' } },
|
|
509
1667
|
{ key: 'C3', label: '先按"地点待定"提交', value: { action: 'allow_no_room' } },
|
|
510
1668
|
],
|
|
511
1669
|
roomUnavailable: decisions.roomUnavailable,
|
|
1670
|
+
...conflictBrief,
|
|
512
1671
|
}, {
|
|
513
1672
|
warnings: [
|
|
514
1673
|
`会议室推荐无结果:${String(decisions.roomUnavailable.reason)}。本次**没有提交**。`,
|
|
515
1674
|
'确实不需要会议室(线上会)就带 allowNoRoom: true 重新调用;要指定房间就带 address。',
|
|
1675
|
+
...(memberConflicts.length > 0
|
|
1676
|
+
? [`⚠️ 另外:参会人在该时段已有 ${memberConflicts.length} 场会议冲突 —— 换时间可能比换房间更划算。`]
|
|
1677
|
+
: []),
|
|
1678
|
+
...(freeSlots.length > 0
|
|
1679
|
+
? [`当天全部参会人都空闲的时段:${freeSlots.map((s) => `${s.begin.slice(11)}~${s.end.slice(11)}`).join('、')}`]
|
|
1680
|
+
: []),
|
|
1681
|
+
...failureWarnings,
|
|
516
1682
|
],
|
|
517
1683
|
});
|
|
518
1684
|
}
|
|
519
|
-
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1685
|
+
// 兜底:会议室推荐整段没跑出来(roomRec 为空)时,传了房间但**没用户确认**的也不能默默通过。
|
|
1686
|
+
if (enrich && addressInput && !roomConfirmed && roomOptions.length === 0 && args.allowNoRoom !== true) {
|
|
1687
|
+
const specified = {
|
|
1688
|
+
key: 'R1',
|
|
1689
|
+
label: `${addressDisplay}(你指定的,待确认)`,
|
|
1690
|
+
value: { ...(resolvedAddressId ? { id: resolvedAddressId } : {}), name: addressDisplay, roomConfirmed: true },
|
|
1691
|
+
reason: '你传入的会议室,未提交、未确认',
|
|
1692
|
+
};
|
|
1693
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
1694
|
+
status: 'NEEDS_SELECTION',
|
|
1695
|
+
selectionType: 'room',
|
|
1696
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'R1' },
|
|
1697
|
+
message: '你传入的会议室未经用户确认,且本次没能推荐出其它候选 —— '
|
|
1698
|
+
+ '请让用户确认这一间,或换一间 / 改时段',
|
|
1699
|
+
workflowPending: true,
|
|
1700
|
+
options: [specified],
|
|
1701
|
+
recommended: specified.value,
|
|
1702
|
+
hint: '用户确认用这间就把 value 原样带回(含 roomConfirmed: true)',
|
|
1703
|
+
}, {
|
|
1704
|
+
warnings: ['会议室未经用户确认,本次**没有**提交。', ...failureWarnings],
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
// ══════════════ 流程闸门(2026-10-30 顺序调整:放在四要素之后) ══════════════
|
|
1708
|
+
// 用户要求:「先确认 时间/人/地点/名称 这四要素,再选流程发起」,不是反过来。
|
|
1709
|
+
// 理由:流程只是承载体,用户先要确定"这会到底开不开、什么时候开、在哪开、谁参加",
|
|
1710
|
+
// 到这一步都定了,最后再让他挑"用哪条流程走审批"。所以时间/房间闸门都在上面,
|
|
1711
|
+
// 到这里四要素已经齐了(时间带 timeConfirmed、地点带 roomConfirmed),只剩流程没定。
|
|
1712
|
+
const resolved = await resolveWorkflowId(env, args);
|
|
1713
|
+
// 地点还原成**人看得懂的名字**(address 入参可能是 id):用户看确认摘要不能是一串数字。
|
|
1714
|
+
// 名称/名称(id) 传进来时用解析出来的真实房间名,别再回显用户那串原文。
|
|
1715
|
+
const confirmedAddressId = resolvedAddressId;
|
|
1716
|
+
const confirmedAddressLabel = addressId === ''
|
|
1717
|
+
? ''
|
|
1718
|
+
: (resolvedAddress?.name
|
|
1719
|
+
|| String(roomOptions.find((item) => String(item.value?.id ?? '') === confirmedAddressId)
|
|
1720
|
+
?.value?.name ?? '')
|
|
1721
|
+
|| addressId);
|
|
1722
|
+
const confirmedWhen = `${beginText}~${String(endText).slice(11) || endText}`;
|
|
1723
|
+
let candidates = resolved.candidates;
|
|
1724
|
+
workflowId = resolved.workflowId;
|
|
1725
|
+
// 候选来源决定文案:固化配置(带环境名)/ 调用方传入 / 应用设置配置。
|
|
1726
|
+
let sourceLabel = resolved.source === 'argument'
|
|
1727
|
+
? '调用方传入'
|
|
1728
|
+
: (resolved.source === 'fixed'
|
|
1729
|
+
? `固化配置(${resolved.label ?? '默认'})`
|
|
1730
|
+
: '应用设置配置');
|
|
1731
|
+
let probes = [];
|
|
1732
|
+
let loadedForms = new Map();
|
|
1733
|
+
// 固化自用场景:多个流程都能发起时自动取优先级最高的,不再打断用户去选(2026-10-30 要求)。
|
|
1734
|
+
const autoPick = shouldAutoPickFlow(args);
|
|
1735
|
+
if (!workflowId && candidates.length > 1) {
|
|
1736
|
+
// 有多个建会流程候选:**逐个探测当前人员能否发起**(createForm 能载入 = 有发起权限)。
|
|
1737
|
+
// 并行探测:每个流程一次 createForm(内含多次 OA 请求),串行会成倍放大等待。
|
|
1738
|
+
let probed = await probeWorkflowCandidates(env, candidates);
|
|
1739
|
+
probes = probed.probes;
|
|
1740
|
+
loadedForms = probed.loadedForms;
|
|
1741
|
+
let launchable = probes.filter((probe) => probe.launchable);
|
|
1742
|
+
if (launchable.length === 0 && resolved.source === 'fixed') {
|
|
1743
|
+
// 固化 id 在当前环境一个都发起不了(换租户/换环境时会出现)→ 回退应用设置再探一轮,
|
|
1744
|
+
// 别因为固化就把"流程建会"这条路堵死。
|
|
1745
|
+
const settings = await fetchEbAppSettings(env, 'meeting.eb.meeting.create.flow.prepare');
|
|
1746
|
+
const fallbackIds = String(settings.mtNewWf ?? '')
|
|
1747
|
+
.split(/[,;,;\s]+/)
|
|
1748
|
+
.map((item) => item.trim())
|
|
1749
|
+
.filter((item) => item !== '');
|
|
1750
|
+
if (fallbackIds.length > 0) {
|
|
1751
|
+
probed = await probeWorkflowCandidates(env, fallbackIds);
|
|
1752
|
+
probes = probed.probes;
|
|
1753
|
+
loadedForms = probed.loadedForms;
|
|
1754
|
+
candidates = fallbackIds;
|
|
1755
|
+
sourceLabel = '应用设置配置';
|
|
1756
|
+
launchable = probes.filter((probe) => probe.launchable);
|
|
1757
|
+
}
|
|
533
1758
|
}
|
|
534
|
-
|
|
535
|
-
|
|
1759
|
+
if (launchable.length === 0) {
|
|
1760
|
+
// 一个都发不起 → 就是"没有流程发起权限"(2026-10-30 用户要求明确提示,别让 agent 反复重试或改走直连)。
|
|
1761
|
+
throw meetingValidationError('flow_not_launchable', `当前账号**没有发起建会流程的权限**:${sourceLabel}的 ${candidates.length} 个建会流程都发起不了`
|
|
1762
|
+
+ `(无发起权限或流程已停用)——${probes.map((probe) => `${probe.workflowId}(${probe.reason ?? '不可发起'})`).join(';')}。`
|
|
1763
|
+
+ '请如实告知用户:需要找管理员开通该流程的发起权限,或请有权限的同事代为发起;'
|
|
1764
|
+
+ '**不要**改用直连建会绕过流程管控。', { probes });
|
|
536
1765
|
}
|
|
1766
|
+
if (launchable.length > 1 && !autoPick) {
|
|
1767
|
+
// 仍有多个可选 → 交给用户挑,不替用户决定(用户明确要求"以选择的方式")
|
|
1768
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
1769
|
+
status: 'NEEDS_SELECTION',
|
|
1770
|
+
// ⚠️ 以前漏了这个字段(agent 拿到 undefined 无法判断是哪类选择,2026-10-30 真机发现)。
|
|
1771
|
+
selectionType: 'flow',
|
|
1772
|
+
// 明确告诉调用方"这是一道选择题,请用选项控件呈现",不要降级成文本列表
|
|
1773
|
+
// (2026-10-30 用户反馈:agent 把会议室候选写成文本 + "需要你指定一间",没给选项卡)。
|
|
1774
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'W1' },
|
|
1775
|
+
// 四要素已经确认到这一步了:摘要一起给出去,用户只需点一下流程,就能直接提交。
|
|
1776
|
+
confirmed: {
|
|
1777
|
+
mt_name: String(args.mt_name ?? ''),
|
|
1778
|
+
begin_datetime: beginText,
|
|
1779
|
+
end_datetime: endText,
|
|
1780
|
+
address: confirmedAddressId || null,
|
|
1781
|
+
addressName: confirmedAddressLabel || null,
|
|
1782
|
+
hrm_members: memberIds,
|
|
1783
|
+
},
|
|
1784
|
+
message: `会议要素已确认(${String(args.mt_name ?? '')} · ${confirmedWhen}`
|
|
1785
|
+
+ `${confirmedAddressLabel ? ` · ${confirmedAddressLabel}` : ' · 无会议室'});`
|
|
1786
|
+
+ `当前账号对 ${sourceLabel}的 ${candidates.length} 个建会流程**都有发起权限**,`
|
|
1787
|
+
+ '请让用户最后选一下「用哪条流程发起」—— **不要默认取第一个**(各流程表单字段可能完全不同,'
|
|
1788
|
+
+ '选错会漏字段;2026-10-29 真实事故:取第一个导致字段全填不进另一条流程,'
|
|
1789
|
+
+ '用户以为"系统只支持一条流程")。⚠️ 只有其中 1 个有权限时 CLI 会直接发起,不会走到这一步',
|
|
1790
|
+
options: launchable.map((probe, index) => ({
|
|
1791
|
+
key: `W${index + 1}`,
|
|
1792
|
+
label: probe.name ? `${probe.name}(${probe.workflowId})` : probe.workflowId,
|
|
1793
|
+
// ⛔ 把已确认的四要素一起带回去:用户点选流程后**原样回传 value** 就能直接出确认摘要,
|
|
1794
|
+
// 不用再走一遍时间/房间闸门(否则"先定四要素再选流程"会多出一轮无效往返)。
|
|
1795
|
+
value: {
|
|
1796
|
+
workflowId: probe.workflowId,
|
|
1797
|
+
begin_datetime: beginText,
|
|
1798
|
+
end_datetime: endText,
|
|
1799
|
+
timeConfirmed: true,
|
|
1800
|
+
...(confirmedAddressId
|
|
1801
|
+
? { address: confirmedAddressId, roomConfirmed: true }
|
|
1802
|
+
: {}),
|
|
1803
|
+
},
|
|
1804
|
+
reason: '当前账号有发起权限',
|
|
1805
|
+
// 把该流程的字段名如实带出来:不同流程字段差异很大(有的有设备需求/涉密项目,有的没有),
|
|
1806
|
+
// 用户要靠这个才能选对(2026-10-29 用户反馈:选错方式后字段全填不进去)。
|
|
1807
|
+
fields: (loadedForms.get(probe.workflowId)?.fields ?? []).map((f) => f.name).filter(Boolean),
|
|
1808
|
+
})),
|
|
1809
|
+
probes,
|
|
1810
|
+
}, {
|
|
1811
|
+
warnings: [
|
|
1812
|
+
`${sourceLabel}:共 ${candidates.length} 个建会流程,其中 ${launchable.length} 个当前账号可发起;`
|
|
1813
|
+
+ '**必须把这几个流程做成选项让用户挑一个,禁止自己替用户选**;选定后带单个 workflowId 重新 prepare。',
|
|
1814
|
+
...probes.filter((probe) => !probe.launchable)
|
|
1815
|
+
.map((probe) => `流程 ${probe.workflowId} 当前账号无法发起(${probe.reason ?? '无权限'}),已排除`),
|
|
1816
|
+
],
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
workflowId = launchable[0].workflowId;
|
|
1820
|
+
if (launchable.length === 1 && candidates.length > 1) {
|
|
1821
|
+
// 只有一条有权限 → 直接发起,但要说明"为什么没让你选"(其余流程是无权限,不是被忽略)。
|
|
1822
|
+
const only = launchable[0];
|
|
1823
|
+
flowPickNote = `${sourceLabel}:共 ${candidates.length} 个建会流程,当前账号只有`
|
|
1824
|
+
+ `「${only.name ?? only.workflowId}」(${only.workflowId})有发起权限,已直接用它发起`;
|
|
1825
|
+
}
|
|
1826
|
+
if (launchable.length > 1) {
|
|
1827
|
+
// 固化自用:多个可发起时按候选顺序自动取第一个 —— 但必须留痕说明"还有别的可用流程",
|
|
1828
|
+
// 不能默默替用户决定(2026-10-30:用户要的是"直接发起",不是"什么都别告诉我")。
|
|
1829
|
+
const picked = launchable[0];
|
|
1830
|
+
const others = launchable.slice(1)
|
|
1831
|
+
.map((probe) => (probe.name ? `${probe.name}(${probe.workflowId})` : probe.workflowId));
|
|
1832
|
+
flowPickNote = `${sourceLabel}:共 ${candidates.length} 个建会流程,当前账号可发起 ${launchable.length} 个,`
|
|
1833
|
+
+ `已按优先级自动选用「${picked.name ?? picked.workflowId}」(${picked.workflowId});`
|
|
1834
|
+
+ `其他可用流程:${others.join('、')} —— 要用它们请显式传 workflowId。`;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
// 单一候选(固化只配了 1 条 / 应用设置只配了 1 条 / 调用方指定了 1 条)时没有走"探测"分支,
|
|
1838
|
+
// 载入失败同样是"没权限或流程停用" —— 必须给同一句人话,而不是把表单错误原样抛出去
|
|
1839
|
+
// (2026-10-30:用户要求"都没权限就明确提示没有发起权限")。
|
|
1840
|
+
let form = loadedForms.get(workflowId);
|
|
1841
|
+
if (!form) {
|
|
1842
|
+
try {
|
|
1843
|
+
form = await loadCreateForm(env, workflowId);
|
|
1844
|
+
}
|
|
1845
|
+
catch (error) {
|
|
1846
|
+
throw meetingValidationError('flow_not_launchable', `当前账号**没有发起建会流程的权限**(或流程已停用):建会流程 ${workflowId} 的表单载入失败 —— `
|
|
1847
|
+
+ `${String(error?.message ?? error).slice(0, 200)}。`
|
|
1848
|
+
+ '请如实告知用户:需要找管理员开通该流程的发起权限,或请有权限的同事代为发起;'
|
|
1849
|
+
+ '**不要**改用直连建会绕过流程管控。', { workflowId });
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (!form.continuation) {
|
|
1853
|
+
throw meetingValidationError('flow_context_missing', '建会流程表单没有返回 continuation,无法继续提交');
|
|
537
1854
|
}
|
|
1855
|
+
// 参会人冲突 / 空闲时段已在选房之前处理完(见上方 conflictBrief)。
|
|
538
1856
|
decisions.timings = timings;
|
|
1857
|
+
// 参会客户:选了客户就自动算「应到客户数」,不让用户再手填一遍(填了以用户的为准)。
|
|
1858
|
+
const crmMemberCount = (() => {
|
|
1859
|
+
if (args.crm_member_count !== undefined && args.crm_member_count !== '')
|
|
1860
|
+
return args.crm_member_count;
|
|
1861
|
+
const raw = args.crm_members;
|
|
1862
|
+
if (raw === undefined || raw === null || raw === '')
|
|
1863
|
+
return undefined;
|
|
1864
|
+
if (Array.isArray(raw))
|
|
1865
|
+
return raw.length > 0 ? raw.length : undefined;
|
|
1866
|
+
const text = String(raw).trim();
|
|
1867
|
+
if (!text)
|
|
1868
|
+
return undefined;
|
|
1869
|
+
const count = text.split(/[,;,;]+/).map((item) => item.trim()).filter((item) => item !== '').length;
|
|
1870
|
+
return count > 0 ? count : undefined;
|
|
1871
|
+
})();
|
|
539
1872
|
const shaped = await shapeBrowserValues(env, {
|
|
540
1873
|
mt_name: args.mt_name,
|
|
541
1874
|
begin_datetime: args.begin_datetime,
|
|
@@ -545,6 +1878,8 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
545
1878
|
customize_address: args.customize_address,
|
|
546
1879
|
hrm_members: args.hrm_members,
|
|
547
1880
|
total_member: args.total_member,
|
|
1881
|
+
crm_members: args.crm_members,
|
|
1882
|
+
...(crmMemberCount === undefined ? {} : { crm_member_count: crmMemberCount }),
|
|
548
1883
|
caller: args.caller,
|
|
549
1884
|
contacter: args.contacter,
|
|
550
1885
|
other_remark: args.other_remark,
|
|
@@ -553,10 +1888,141 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
553
1888
|
? args.extraFields
|
|
554
1889
|
: {}),
|
|
555
1890
|
});
|
|
556
|
-
|
|
1891
|
+
// datakey 固化:上次解析出的「语义键 → fieldId」直接复用(表单字段改名也不影响),
|
|
1892
|
+
// 本次解析结果再写回,形成"猜一次、之后一直用 datakey"的效果。
|
|
1893
|
+
const preferredFields = await readFlowFieldMap(env, workflowId);
|
|
1894
|
+
const { main, mapped, unmapped, resolved: resolvedFields } = mapFields(form.fields, shaped.values, preferredFields);
|
|
1895
|
+
await writeFlowFieldMap(env, workflowId, form.workflowName, resolvedFields);
|
|
557
1896
|
const warnings = [];
|
|
1897
|
+
if (autoFilledPerson.length > 0) {
|
|
1898
|
+
const labels = autoFilledPerson.map((one) => `「${PERSON_FIELD_LABELS[one.field]}」`).join('');
|
|
1899
|
+
warnings.push(`${labels}没有传值,已按**当前登录人**补齐:${autoFilledPerson[0].name}(${autoFilledPerson[0].id})。`
|
|
1900
|
+
+ '要换成别人(例如替他人建会、指定别的联系人)请显式传 caller / contacter。');
|
|
1901
|
+
}
|
|
1902
|
+
else if (selfPersonWarning) {
|
|
1903
|
+
warnings.push(selfPersonWarning);
|
|
1904
|
+
}
|
|
1905
|
+
// 固化自动选流程要留痕:调用方/用户能看到"用的是哪条、还有哪些可选"。
|
|
1906
|
+
if (flowPickNote)
|
|
1907
|
+
warnings.push(flowPickNote);
|
|
558
1908
|
for (const note of shaped.notes)
|
|
559
1909
|
warnings.push(note);
|
|
1910
|
+
// 「提到客户却没传 crm_members」是最常见的一次跑偏:客户名被写进名称/内容就直接往下走,
|
|
1911
|
+
// 结果「参会客户」浏览按钮是空的,单子看着正常、客户却没进表。这里点名提示。
|
|
1912
|
+
const crmMentioned = detectCrmMentionInText(args.mt_name, args.mt_desc, args.other_remark);
|
|
1913
|
+
if (crmMentioned && !args.crm_members) {
|
|
1914
|
+
const hasCrmField = form.fields.some((field) => /参会客户/.test(String(field?.label ?? field?.name ?? field?.fieldName ?? '')));
|
|
1915
|
+
warnings.push(`会议名称/内容里提到客户「${crmMentioned}」,但本次没传 crm_members`
|
|
1916
|
+
+ (hasCrmField
|
|
1917
|
+
? ',该流程表单**有「参会客户」字段,现在会是空的**。请先 '
|
|
1918
|
+
+ `meeting.eb.crm.search {"keyword":"${crmMentioned}"} 拿 items[].customerId,`
|
|
1919
|
+
+ '再用 crm_members:[{id,name}] 重新提交(必须带客户名称)。'
|
|
1920
|
+
: '(该流程表单里没有「参会客户」字段,本条可忽略)。'));
|
|
1921
|
+
}
|
|
1922
|
+
// 传了客户就自动把**全部**联系人写成「参会客户明细」行(5 列:姓名/性别/职位/手机号码/备注),
|
|
1923
|
+
// 不需要调用方去"选一位"(2026-10-31 用户纠正:有几位带几位)。
|
|
1924
|
+
const crmMembersGiven = Array.isArray(args.crm_members)
|
|
1925
|
+
? args.crm_members.length > 0
|
|
1926
|
+
: String(args.crm_members ?? '').trim() !== '';
|
|
1927
|
+
// 「参会客户」是 `Ebuilder` 组件 —— **这是正常配置,别劝人去改表单**:同一张表单的「会议地点」
|
|
1928
|
+
// 也是 Ebuilder,一直写得好好的。它落到会议表那一列由 **ESB 字段映射**负责(用户已配好),
|
|
1929
|
+
// CLI 只负责把客户 id/名称写进流程单据,不再自己补写会议表。
|
|
1930
|
+
if (crmMembersGiven) {
|
|
1931
|
+
const crmFieldId = String(resolvedFields.crm_members ?? '').trim();
|
|
1932
|
+
const crmField = form.fields.find((field) => String(field?.id ?? '') === crmFieldId);
|
|
1933
|
+
const componentKey = String(crmField?.componentKey ?? '').toLowerCase();
|
|
1934
|
+
if (crmField && componentKey !== '' && componentKey !== 'customer' && componentKey !== 'customercomponent') {
|
|
1935
|
+
warnings.push(`「参会客户」字段(${crmFieldId})是 ${crmField.componentKey} 组件:客户 id 与名称照常写进流程单据,`
|
|
1936
|
+
+ '再由 ESB 的字段映射同步到会议表 —— 正常配置,不需要改表单。');
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
// 客户联系人:能自动带就**全部自动带进明细**,不再问用户"选哪一位"
|
|
1940
|
+
// (2026-10-31 用户原话:「带出了三个联系人,为什么要让我选择,不能直接代入到流程里面去吗」)。
|
|
1941
|
+
const providedCrmDetailRows = normalizeCrmDetailRows(args.crm_member_details);
|
|
1942
|
+
// ⛔ 默认按 CRM **全量**补全;只有显式 crm_detail_mode:"only" 才只写用户点名的那几位。
|
|
1943
|
+
// 2026-10-31 用户反馈「流程里只带出第一个联系人」——原因是调用方只把挑中的一位传了进来,
|
|
1944
|
+
// 旧逻辑一看"入参已有联系人"就不再回查,于是明细永远只有一位。
|
|
1945
|
+
const onlyDetailMode = String(args.crm_detail_mode ?? '').trim().toLowerCase() === 'only';
|
|
1946
|
+
let crmDetailRows = onlyDetailMode ? providedCrmDetailRows : [];
|
|
1947
|
+
if (crmMembersGiven && !onlyDetailMode) {
|
|
1948
|
+
const detailTarget = resolveCrmDetailTarget(form.fields);
|
|
1949
|
+
if (detailTarget) {
|
|
1950
|
+
const auto = await resolveAutoCrmDetailRows(env, args.crm_members);
|
|
1951
|
+
if (auto.rows.length > 0) {
|
|
1952
|
+
crmDetailRows = auto.rows;
|
|
1953
|
+
// 入参里显式给的额外列(性别/备注等)按姓名补回去,不全丢
|
|
1954
|
+
if (providedCrmDetailRows.length > 0) {
|
|
1955
|
+
const byName = new Map(providedCrmDetailRows.map((row) => [String(row.xm ?? '').trim(), row]));
|
|
1956
|
+
crmDetailRows = crmDetailRows.map((row) => {
|
|
1957
|
+
const given = byName.get(String(row.xm ?? '').trim());
|
|
1958
|
+
if (!given)
|
|
1959
|
+
return row;
|
|
1960
|
+
const merged = { ...row };
|
|
1961
|
+
for (const [key, value] of Object.entries(given)) {
|
|
1962
|
+
if (String(value ?? '').trim() !== '')
|
|
1963
|
+
merged[key] = String(value);
|
|
1964
|
+
}
|
|
1965
|
+
return merged;
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
warnings.push(`已自动把「${auto.customers.join('、')}」的 ${auto.rows.length} 位联系人写进「参会客户明细」:`
|
|
1969
|
+
+ `${auto.rows.map((row) => `${row.xm}${row.sjhm ? ` ${row.sjhm}` : ''}`).join('、')}`
|
|
1970
|
+
+ '(明细表本来就一人一行,不需要让用户挑)。确实只想要其中几位时,'
|
|
1971
|
+
+ '传 crm_detail_mode:"only" 再配 crm_member_details 才覆盖本次自动带入。');
|
|
1972
|
+
}
|
|
1973
|
+
else if (auto.missing.length > 0) {
|
|
1974
|
+
warnings.push(`已写「参会客户」,但没能自动带出「${auto.missing.join('、')}」的联系人(CRM 里查不到)。`
|
|
1975
|
+
+ '请把已查到的联系人直接告给用户核对;确实没有联系人时问一次姓名+手机号,'
|
|
1976
|
+
+ '再以 crm_member_details:[{"xm":"张三","sjhm":"13800000000"}] 重新 prepare。');
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
// 参会客户明细(明细表):行内用**列字段 id** 作 key,服务端按 id 认(名称列名各租户不同,不写死)。
|
|
1981
|
+
let detailPayload;
|
|
1982
|
+
if (crmDetailRows.length > 0) {
|
|
1983
|
+
const target = resolveCrmDetailTarget(form.fields);
|
|
1984
|
+
if (!target) {
|
|
1985
|
+
warnings.push(`传了 crm_member_details(${crmDetailRows.length} 行),但该建会流程的**发起节点表单里没有「参会客户明细」明细表**`
|
|
1986
|
+
+ '(或它被摆在隐藏行上),明细无法写入,已忽略这些行;主表的「参会客户」照常写入。');
|
|
1987
|
+
}
|
|
1988
|
+
else {
|
|
1989
|
+
detailPayload = {
|
|
1990
|
+
[target.subFormId]: crmDetailRows.map((row) => {
|
|
1991
|
+
const line = {};
|
|
1992
|
+
for (const [semantic, fieldId] of Object.entries(target.columns)) {
|
|
1993
|
+
const value = row[semantic];
|
|
1994
|
+
if (value !== undefined && value !== '')
|
|
1995
|
+
line[fieldId] = value;
|
|
1996
|
+
}
|
|
1997
|
+
return line;
|
|
1998
|
+
}),
|
|
1999
|
+
};
|
|
2000
|
+
warnings.push(`参会客户明细:${crmDetailRows.length} 行写入明细表 ${target.subFormId}`
|
|
2001
|
+
+ `(列:${Object.values(target.labels).join('/')})`);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
// datakey 直填(2026-10-30 用户建议):按表单字段的 **datakey** 直接给值,跳过中文名匹配。
|
|
2005
|
+
// 用在「字段名带提示语、别名表猜不到」或「租户自建字段」场景,比按中文名匹配稳。
|
|
2006
|
+
if (args.fieldValues && typeof args.fieldValues === 'object' && !Array.isArray(args.fieldValues)) {
|
|
2007
|
+
const knownIds = new Set(form.fields.map((field) => field.id));
|
|
2008
|
+
for (const [fieldId, raw] of Object.entries(args.fieldValues)) {
|
|
2009
|
+
if (raw === undefined || raw === null || raw === '')
|
|
2010
|
+
continue;
|
|
2011
|
+
if (!knownIds.has(fieldId)) {
|
|
2012
|
+
warnings.push(`fieldValues 里的 datakey「${fieldId}」不在该流程表单里,已忽略(先用 prepare 不带 fieldValues 跑一次,看 unmapped/表单字段清单)`);
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
main[fieldId] = raw;
|
|
2016
|
+
mapped[`datakey:${fieldId}`] = fieldId;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
// 增强检测没跑成功必须如实报出:否则调用方会把"未核验"当成"已核验且无冲突"。
|
|
2020
|
+
if (enrichFailures.length > 0) {
|
|
2021
|
+
decisions.enrichFailures = enrichFailures;
|
|
2022
|
+
for (const failure of enrichFailures) {
|
|
2023
|
+
warnings.push(`⚠️ ${failure} —— **这不代表没有冲突**,请如实告知用户该项未核验,不要声称"时间已确认空闲"`);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
560
2026
|
const picked = decisions.autoRoom?.picked;
|
|
561
2027
|
// ⚠️ 只在"用户没指定会议室、由 CLI 自动选"时才说这句。
|
|
562
2028
|
// 以前无条件输出"未指定会议室 → 已自动选择",用户明明传了 address 也被这么讲(2026-10-29 真机踩到);
|
|
@@ -567,20 +2033,39 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
567
2033
|
+ `另有候选 ${(decisions.roomOptions ?? []).slice(1).map((item) => item.label).join(' / ') || '无'}`);
|
|
568
2034
|
}
|
|
569
2035
|
const slotOptions = decisions.slotOptions ?? [];
|
|
570
|
-
|
|
571
|
-
|
|
2036
|
+
const memberConflictCount = decisions.memberConflicts?.length ?? 0;
|
|
2037
|
+
if (memberConflictCount > 0 && slotOptions.length > 0) {
|
|
2038
|
+
// ⚠️ 以前只要 slotOptions 非空就断言"参会人时间有冲突" —— 而 slotOptions 只是"当天全部空闲时段",
|
|
2039
|
+
// 当前时段本身空闲时也会命中,于是出现"memberConflicts=0 却提示有冲突"的自相矛盾(2026-10-30 真机)。
|
|
2040
|
+
warnings.push(`参会人时间有冲突(${memberConflictCount} 条占用);当天所有人都空的时段:`
|
|
2041
|
+
+ slotOptions.map((item) => `[${item.key}] ${item.label}`).join(';')
|
|
572
2042
|
+ '——请用选项方式让用户挑(选定后重新 prepare 并改时间)。');
|
|
573
2043
|
}
|
|
2044
|
+
else if (memberConflictCount === 0 && slotOptions.length > 0) {
|
|
2045
|
+
warnings.push('该时段参会人没有会议/日程占用(已核对会议回执与日程)。'
|
|
2046
|
+
+ `当天其他共同空闲时段:${slotOptions.map((item) => `[${item.key}] ${item.label}`).join(';')}`);
|
|
2047
|
+
}
|
|
574
2048
|
else if (decisions.dayBlocked?.summary) {
|
|
575
2049
|
warnings.push(String(decisions.dayBlocked.summary));
|
|
576
2050
|
}
|
|
577
|
-
|
|
578
|
-
|
|
2051
|
+
// mt_name 不算"漏填":表单没有「会议名称」字段时它已经用作流程标题(下面单独说明),
|
|
2052
|
+
// 混在 unmapped 里会让 agent 以为还需要额外补字段。
|
|
2053
|
+
// crm_member_count 同理:它是**按客户条数自动派生**的(用户没手填),表单没有「应到客户数」
|
|
2054
|
+
// 时静默忽略即可 —— 列出来会让 agent 以为要再补一个字段。
|
|
2055
|
+
const unmappedReal = unmapped.filter((name) => name !== 'mt_name' && name !== 'crm_member_count');
|
|
2056
|
+
if (unmappedReal.length > 0) {
|
|
2057
|
+
warnings.push(`以下字段没有在建会流程表单里找到同名字段,未自动填写:${unmappedReal.join('、')}`
|
|
579
2058
|
+ `(表单现有字段:${form.fields.map((field) => field.name).join('、')})`);
|
|
580
2059
|
}
|
|
581
2060
|
if (!main[form.fields.find((field) => field.name === '会议名称')?.id ?? ''] && !mapped.mt_name) {
|
|
582
|
-
|
|
2061
|
+
// 不是配错:EB 的两条建会流程里,「会议申请流程」本身就没有「会议名称」字段,用流程标题代替。
|
|
2062
|
+
warnings.push('该建会流程的表单没有「会议名称」字段(部分流程用流程标题代替会议名称,属正常配置,不是流程配错)。'
|
|
2063
|
+
+ '如需会议名称独立成字段,请改选带该字段的流程(如「会议申请流程(NEW)」)。');
|
|
583
2064
|
}
|
|
2065
|
+
// 客户 id 随凭证带到 apply:默认只用于邀请函/回显,**不再补写会议表**(那边由 ESB 字段映射负责)。
|
|
2066
|
+
const crmPatchIds = (Array.isArray(shaped.values.crm_members) ? shaped.values.crm_members : [])
|
|
2067
|
+
.map((row) => String(row?.id ?? '').trim())
|
|
2068
|
+
.filter((id) => id !== '');
|
|
584
2069
|
const session = await env.host.getSession();
|
|
585
2070
|
const continuation = await env.continuation.issue({
|
|
586
2071
|
operation: 'meeting.eb.meeting.create.flow.apply',
|
|
@@ -591,10 +2076,22 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
591
2076
|
workflowId,
|
|
592
2077
|
requestName: String(args.mt_name),
|
|
593
2078
|
mtName: String(args.mt_name),
|
|
594
|
-
data: { main },
|
|
2079
|
+
data: { main, ...(detailPayload ? { details: detailPayload } : {}) },
|
|
595
2080
|
surfaceContinuation: form.continuation,
|
|
2081
|
+
// 供 apply 在「会议还没生成」时按申请单信息预生成邀请函(预置函)
|
|
2082
|
+
invitationFields: shaped.readable,
|
|
2083
|
+
// 提交后回显要用「字段名 → 值」,字段清单先存下来(apply 阶段拿不到 createForm 结果)
|
|
2084
|
+
fieldLabels: form.fields.map((field) => ({ id: String(field.id), name: String(field.name) })),
|
|
2085
|
+
// 已确认的「人员 id → 姓名」,供 apply 回显时把裸 id 换成姓名
|
|
2086
|
+
...(autoFilledPerson.length > 0
|
|
2087
|
+
? { personLabels: Object.fromEntries(autoFilledPerson.map((one) => [one.id, one.name])) }
|
|
2088
|
+
: {}),
|
|
2089
|
+
// 会议记录的客户要靠 apply 补写(表单「参会客户」是 Ebuilder,单据↔会议表不同步)
|
|
2090
|
+
...(crmPatchIds.length > 0
|
|
2091
|
+
? { crmPatch: { ids: crmPatchIds, count: crmMemberCount === undefined ? crmPatchIds.length : crmMemberCount } }
|
|
2092
|
+
: {}),
|
|
596
2093
|
},
|
|
597
|
-
fingerprint: JSON.stringify({ workflowId, main }),
|
|
2094
|
+
fingerprint: JSON.stringify({ workflowId, main, ...(detailPayload ? { details: detailPayload } : {}) }),
|
|
598
2095
|
}, 10 * 60 * 1000);
|
|
599
2096
|
return meetingSuccess('meeting.eb.meeting.create.flow.prepare', {
|
|
600
2097
|
status: 'AWAITING_CONFIRMATION',
|
|
@@ -602,9 +2099,23 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
602
2099
|
workflowId,
|
|
603
2100
|
workflowCandidates: candidates,
|
|
604
2101
|
...(probes.length > 0 ? { workflowProbes: probes } : {}),
|
|
2102
|
+
...(flowPickNote
|
|
2103
|
+
? { workflowAutoPicked: { workflowId, source: resolved.source, note: flowPickNote } }
|
|
2104
|
+
: {}),
|
|
605
2105
|
requestName: String(args.mt_name),
|
|
606
2106
|
fieldMapping: mapped,
|
|
607
2107
|
formData: { main },
|
|
2108
|
+
// 人话版「即将提交的内容」—— 提交前必须先贴给用户核对(见 SKILL 建会章节)。
|
|
2109
|
+
formPreview: renderFormPreview(form.fields, await decoratePreviewPersonNames(env, main, form.fields, new Map(autoFilledPerson.map((one) => [one.id, one.name])))),
|
|
2110
|
+
// 召集人/联系人是不是"替用户默认填的"要说清楚,用户才知道能不能改。
|
|
2111
|
+
...(autoFilledPerson.length > 0
|
|
2112
|
+
? {
|
|
2113
|
+
autoFilled: Object.fromEntries(autoFilledPerson.map((one) => [PERSON_FIELD_LABELS[one.field], `${one.name}(${one.id})`])),
|
|
2114
|
+
}
|
|
2115
|
+
: {}),
|
|
2116
|
+
...(detailPayload
|
|
2117
|
+
? { detailRows: { subFormId: Object.keys(detailPayload)[0], count: crmDetailRows.length, rows: crmDetailRows } }
|
|
2118
|
+
: {}),
|
|
608
2119
|
formFieldCount: form.fields.length,
|
|
609
2120
|
...(Object.keys(decisions).length > 0 ? { decisions } : {}),
|
|
610
2121
|
},
|
|
@@ -613,6 +2124,123 @@ const ebMeetingCreateFlowPrepare = {
|
|
|
613
2124
|
}, warnings.length > 0 ? { warnings } : {});
|
|
614
2125
|
},
|
|
615
2126
|
};
|
|
2127
|
+
/**
|
|
2128
|
+
* 补写客户后的观察窗(毫秒)—— 每一轮「写 → 等 → 回读」,被覆盖就再补一次。
|
|
2129
|
+
* 会议刚生成时 ESB 会异步整行写主表,覆盖掉刚补的 `crm_members`。
|
|
2130
|
+
*/
|
|
2131
|
+
const CRM_PATCH_SETTLE_MS = [4000, 6000];
|
|
2132
|
+
/**
|
|
2133
|
+
* 把「参会客户」补写进**会议记录**(会议表 `crm_members` 存客户 id,多客户逗号分隔)。
|
|
2134
|
+
*
|
|
2135
|
+
* ⛔ 2026-10-31 用户纠正「Ebuilder 组件这个就是对的」—— 我之前判"组件错、要换回 Customer"是错的:
|
|
2136
|
+
* 同一张表单的「会议地点」也是 Ebuilder,一直写得好好的;Ebuilder 就是吃 `{id,name}` 的浏览按钮。
|
|
2137
|
+
* 真正的原因是**流程单据 → 会议表的字段映射不覆盖「参会客户」**:单据里客户关联正常
|
|
2138
|
+
* (`workflow.detail` 的 fieldValues 有「名(id)」),会议记录 `crm_members` 却一直是空
|
|
2139
|
+
* ⇒ 邀请函「客户单位」、客户统计、会议详情全拿不到客户。
|
|
2140
|
+
*
|
|
2141
|
+
* 会议表本身是 eBuilder 表单,可以绕过流程映射直接 `updateFormData/change_meeting` 写。
|
|
2142
|
+
* 写接口只回 `dataIds`(服务端对字段不做校验),所以**必须回读 `crm_members` 确认落库**。
|
|
2143
|
+
*/
|
|
2144
|
+
export async function patchMeetingCrmMembers(env, meetingId, ids, count, settleMsList = CRM_PATCH_SETTLE_MS) {
|
|
2145
|
+
const list = ids.map((id) => String(id ?? '').trim()).filter((id) => id !== '');
|
|
2146
|
+
if (String(meetingId).trim() === '' || list.length === 0) {
|
|
2147
|
+
return { ok: false, error: '没有会议 id 或客户 id' };
|
|
2148
|
+
}
|
|
2149
|
+
const mainTable = {
|
|
2150
|
+
id: String(meetingId).trim(),
|
|
2151
|
+
operate_type: 'UPDATE',
|
|
2152
|
+
crm_members: list.join(','),
|
|
2153
|
+
};
|
|
2154
|
+
const total = Number(count);
|
|
2155
|
+
if (Number.isFinite(total) && total > 0)
|
|
2156
|
+
mainTable.crm_total_member = String(total);
|
|
2157
|
+
const write = async () => {
|
|
2158
|
+
const session = await env.host.getSession();
|
|
2159
|
+
await env.host.request({
|
|
2160
|
+
method: 'POST',
|
|
2161
|
+
path: `${EB_BASE}/updateFormData/change_meeting`,
|
|
2162
|
+
query: { id: String(meetingId).trim() },
|
|
2163
|
+
operation: 'meeting.eb.meeting.create.flow.apply',
|
|
2164
|
+
body: writeBody(String(session.userId), { mainTable, details: {}, operateType: {} }),
|
|
2165
|
+
acceptBusiness: ebBusinessSuccess,
|
|
2166
|
+
});
|
|
2167
|
+
};
|
|
2168
|
+
const readBack = async () => {
|
|
2169
|
+
const { getMeetingOperation } = await import('../registry.js');
|
|
2170
|
+
const detail = getMeetingOperation('meeting.eb.meeting.detail');
|
|
2171
|
+
const read = await detail.handler({ id: String(meetingId).trim() }, env);
|
|
2172
|
+
return String(read?.data?.data?.crm_members ?? '').trim();
|
|
2173
|
+
};
|
|
2174
|
+
// ⛔ 写完立刻回读会「假成功」(2026-10-31 真机踩到):会议刚生成时回执/占用等 ESB 还在异步写主表,
|
|
2175
|
+
// 整行覆盖会把刚补的客户冲掉 —— 第一次回读非空、8 秒后再读又空了。所以每一轮都「写 → 等 → 再读」,
|
|
2176
|
+
// 被覆盖就再补一次。两次等待合计 10s,只在真的带了客户时才有这段开销。
|
|
2177
|
+
let lastError = '';
|
|
2178
|
+
for (const settleMs of settleMsList) {
|
|
2179
|
+
try {
|
|
2180
|
+
await write();
|
|
2181
|
+
}
|
|
2182
|
+
catch (error) {
|
|
2183
|
+
return { ok: false, error: String(error?.message ?? error).slice(0, 160) };
|
|
2184
|
+
}
|
|
2185
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
2186
|
+
try {
|
|
2187
|
+
const value = await readBack();
|
|
2188
|
+
if (value !== '')
|
|
2189
|
+
return { ok: true, value };
|
|
2190
|
+
lastError = '补写后回读 crm_members 仍为空(多半被会议初始化的后续写入覆盖了)';
|
|
2191
|
+
}
|
|
2192
|
+
catch (error) {
|
|
2193
|
+
lastError = `补写成功但回读失败:${String(error?.message ?? error).slice(0, 120)}`;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return { ok: false, error: lastError };
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* 按申请单已知信息预生成邀请函(**预置函**)。
|
|
2200
|
+
*
|
|
2201
|
+
* 会议还没生成时也必须给一份:流程还在审批、或小e 环境根本不回写服务端(BROWSER_FILL)时,
|
|
2202
|
+
* 用户要的是"提交完立刻能拿到文件",而不是等审批通过再回来补。取不到的字段由模板渲染成【待补充】。
|
|
2203
|
+
*/
|
|
2204
|
+
export async function renderPreInvitation(payload, env) {
|
|
2205
|
+
const fields = payload?.invitationFields ?? {};
|
|
2206
|
+
const started = Date.now();
|
|
2207
|
+
try {
|
|
2208
|
+
const { getMeetingOperation } = await import('../registry.js');
|
|
2209
|
+
const render = getMeetingOperation('meeting.invitation.render');
|
|
2210
|
+
const rendered = await render.handler({
|
|
2211
|
+
mt_name: String(fields.mt_name ?? payload?.mtName ?? '').trim(),
|
|
2212
|
+
begin_datetime: String(fields.begin_datetime ?? '').trim(),
|
|
2213
|
+
end_datetime: String(fields.end_datetime ?? '').trim(),
|
|
2214
|
+
address: String(fields.address ?? fields.customize_address ?? '').trim(),
|
|
2215
|
+
hrm_members: String(fields.hrm_members ?? '').trim(),
|
|
2216
|
+
mt_desc: String(fields.mt_desc ?? '').trim(),
|
|
2217
|
+
org_members: String(fields.org_members ?? '').trim(),
|
|
2218
|
+
caller: String(fields.caller ?? '').trim(),
|
|
2219
|
+
contacter: String(fields.contacter ?? '').trim(),
|
|
2220
|
+
total_member: String(fields.total_member ?? '').trim(),
|
|
2221
|
+
// 客户单位(邀请函开头「尊敬的 XXX」):建会时填了参会客户就直接带过去,
|
|
2222
|
+
// 否则整句永远是【待补充】—— 用户看到的就是"客户明明填了,邀请函里却没有"。
|
|
2223
|
+
extra: { guest_org: String(fields.crm_members ?? '').trim() },
|
|
2224
|
+
}, env);
|
|
2225
|
+
// 用 pending 标记「这份是按申请单预生成的,不是会议最终数据」:
|
|
2226
|
+
// 调用方据此提示用户"会议生成后可用 mtId 重新生成一版拿完整字段"。
|
|
2227
|
+
return {
|
|
2228
|
+
invitation: { pending: true, preRendered: true, ...(rendered?.data ?? {}) },
|
|
2229
|
+
ms: Date.now() - started,
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
catch (error) {
|
|
2233
|
+
return {
|
|
2234
|
+
invitation: {
|
|
2235
|
+
pending: true,
|
|
2236
|
+
preRendered: false,
|
|
2237
|
+
ok: false,
|
|
2238
|
+
error: String(error?.message ?? error).slice(0, 200),
|
|
2239
|
+
},
|
|
2240
|
+
ms: Date.now() - started,
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
616
2244
|
const ebMeetingCreateFlowApply = {
|
|
617
2245
|
name: 'meeting.eb.meeting.create.flow.apply',
|
|
618
2246
|
resource: 'eb',
|
|
@@ -625,6 +2253,13 @@ const ebMeetingCreateFlowApply = {
|
|
|
625
2253
|
inputSchema: schemaOf({
|
|
626
2254
|
continuation: { type: 'string', description: 'prepare 返回的确认票据(必填)' },
|
|
627
2255
|
confirm: { type: 'boolean', description: '必须显式传 true' },
|
|
2256
|
+
submit_mode: {
|
|
2257
|
+
type: 'string',
|
|
2258
|
+
enum: ['direct', 'browser'],
|
|
2259
|
+
description: '提交方式,默认 browser:在小e 里**弹出流程表单**,字段已预填,用户核对/修改后自己点提交。'
|
|
2260
|
+
+ 'direct = 直接写服务端(会议当场生成、邀请函用真实数据,但没有人工核对表单这一步);'
|
|
2261
|
+
+ '只有用户明确说"不用我确认,直接建"时才传 direct',
|
|
2262
|
+
},
|
|
628
2263
|
waitSeconds: { type: 'number', description: '提交后等待会议生成的最长秒数(默认 60,0 = 不等待)' },
|
|
629
2264
|
}, { required: ['continuation', 'confirm'] }),
|
|
630
2265
|
handler: async (args, env) => {
|
|
@@ -647,6 +2282,10 @@ const ebMeetingCreateFlowApply = {
|
|
|
647
2282
|
};
|
|
648
2283
|
const submitStartedAt = Date.now();
|
|
649
2284
|
const submit = getWorkflowOperation('workflow.createRequest');
|
|
2285
|
+
// 默认走**浏览器填单**(2026-10-31 用户纠正):小e 的填单页能按 id 把人员/客户带进去
|
|
2286
|
+
// (`xiaoEUrlParamScalar` 对浏览按钮取 `id`),弹出表单让用户核对/修改/自己点提交才是要的体验。
|
|
2287
|
+
// 传 submit_mode:"direct" 才改为服务端直提(会议当场生成,但没有人工确认这一步)。
|
|
2288
|
+
const submitMode = String(args.submit_mode ?? 'browser').trim();
|
|
650
2289
|
const submitted = await submit.handler({
|
|
651
2290
|
// 工作流的入参名是 context(createForm 返回的 continuation 就是它的 context 句柄)
|
|
652
2291
|
context: payload.surfaceContinuation,
|
|
@@ -654,6 +2293,7 @@ const ebMeetingCreateFlowApply = {
|
|
|
654
2293
|
action: 'submit',
|
|
655
2294
|
requestName: payload.requestName,
|
|
656
2295
|
confirm: true,
|
|
2296
|
+
...(submitMode === 'browser' ? {} : { forceServerSubmit: true }),
|
|
657
2297
|
}, wfEnv);
|
|
658
2298
|
const submittedData = submitted?.data ?? {};
|
|
659
2299
|
const requestId = String(submittedData.requestId ?? submittedData.id ?? '').trim();
|
|
@@ -676,6 +2316,35 @@ const ebMeetingCreateFlowApply = {
|
|
|
676
2316
|
});
|
|
677
2317
|
}
|
|
678
2318
|
if (!requestId) {
|
|
2319
|
+
const submitStatus = String(submittedData.status ?? submitted?.status ?? '');
|
|
2320
|
+
if (submitStatus === 'BROWSER_FILL') {
|
|
2321
|
+
// 小e 环境:工作流**不向服务端写入**,改由宿主的浏览器页面完成填单(2026-10-15 裁定)。
|
|
2322
|
+
// 所以这里永远拿不到 requestId / 会议 id —— 这不是失败,但也**不是"流程已提交"**,
|
|
2323
|
+
// 不能报错把整轮卡死(用户要的表单就在 finalMarkdown 里)。
|
|
2324
|
+
// ⛔ 邀请函必须照给:它是预置函,按申请单已知信息生成,不依赖会议是否已生成。
|
|
2325
|
+
const pre = await renderPreInvitation(payload, env);
|
|
2326
|
+
return meetingSuccess('meeting.eb.meeting.create.flow.apply', {
|
|
2327
|
+
status: 'BROWSER_FILL',
|
|
2328
|
+
workflowId: payload.workflowId,
|
|
2329
|
+
requestName: payload.requestName,
|
|
2330
|
+
requestId: null,
|
|
2331
|
+
meetingId: null,
|
|
2332
|
+
contextId: submittedData.contextId ?? null,
|
|
2333
|
+
// 宿主要靠这段 <browsertool> 才能把表单拉到浏览器里填,必须原样透出
|
|
2334
|
+
finalMarkdown: submittedData.finalMarkdown ?? null,
|
|
2335
|
+
message: String(submittedData.message ?? ''),
|
|
2336
|
+
invitation: pre.invitation,
|
|
2337
|
+
timings: { submitMs: Date.now() - submitStartedAt, invitationMs: pre.ms },
|
|
2338
|
+
}, {
|
|
2339
|
+
warnings: [
|
|
2340
|
+
'当前是小e 环境:本次**没有向服务端写入**,建会流程改由浏览器页面完成填单(见 finalMarkdown)。'
|
|
2341
|
+
+ '因此还没有 requestId / 会议 id,也**不能说"流程已提交"**。',
|
|
2342
|
+
`邀请函已按申请单信息**预生成**(预置函,${String(pre.invitation.filePath ?? '见 filePath')}):`
|
|
2343
|
+
+ '请下载后人工核对、自行发送;流程审批通过、会议生成后可用 mtId 再跑一次 meeting.invitation.render 拿完整字段版本。',
|
|
2344
|
+
'⛔ 不要因为"没有 requestId / 没有会议 id"重复 apply —— 填单要在那个浏览器页面里点提交。',
|
|
2345
|
+
],
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
679
2348
|
// 没拿到 requestId 时**不能**说"流程已提交":常见原因是必填项缺失(工作流只在 saveFormData 前校验),
|
|
680
2349
|
// 也可能只是存了草稿。把缺失项原样交回去,让调用方补字段,而不是含糊地说"已提交"。
|
|
681
2350
|
const missing = submittedData.missingFields ?? submittedData.requiredMissing ?? submittedData.missing ?? [];
|
|
@@ -722,13 +2391,41 @@ const ebMeetingCreateFlowApply = {
|
|
|
722
2391
|
}
|
|
723
2392
|
timings.pollMs = Date.now() - pollStart;
|
|
724
2393
|
}
|
|
2394
|
+
// 「参会客户」怎么落到会议表:**由 ESB 的字段映射负责**(流程单据 → 会议表 `ufd_crm_members_new`)。
|
|
2395
|
+
// 2026-10-31 用户确认:「你发起流程后,字段映射是靠 ESB 来的,这个我已经配置好了」。
|
|
2396
|
+
// ⛔ 所以 CLI **不再自己补写** `crm_members`:会议表里客户那一列的 dataKey 不是 `crm_members`
|
|
2397
|
+
// (写它既落不到页面,回读也永远为空),只会多出一条「客户没关联上」的假警报,把验收带偏。
|
|
2398
|
+
// 真要回退老行为时置 WEAVER_MEETING_CRM_PATCH=1。
|
|
2399
|
+
let crmLinked;
|
|
2400
|
+
const crmPatch = payload?.crmPatch;
|
|
2401
|
+
const crmPatchEnabled = process.env.WEAVER_MEETING_CRM_PATCH === '1';
|
|
2402
|
+
if (meetingId && crmPatchEnabled && Array.isArray(crmPatch?.ids) && crmPatch.ids.length > 0) {
|
|
2403
|
+
const patched = await patchMeetingCrmMembers(env, meetingId, crmPatch.ids, crmPatch.count);
|
|
2404
|
+
crmLinked = {
|
|
2405
|
+
ok: patched.ok,
|
|
2406
|
+
requested: crmPatch.ids.length,
|
|
2407
|
+
...(patched.value ? { readBack: patched.value } : {}),
|
|
2408
|
+
...(patched.error ? { error: patched.error } : {}),
|
|
2409
|
+
};
|
|
2410
|
+
if (patched.ok) {
|
|
2411
|
+
warnings.push(`「参会客户」已补写到会议记录(crm_members=${patched.value})。`);
|
|
2412
|
+
}
|
|
2413
|
+
else {
|
|
2414
|
+
warnings.push(`「参会客户」没有回读到(${patched.error ?? '未知原因'}):当前走的是 CLI 手工补写(WEAVER_MEETING_CRM_PATCH=1),`
|
|
2415
|
+
+ '不依赖 ESB 字段映射时才需要它。');
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
725
2418
|
let invitation = { pending: true };
|
|
726
2419
|
if (meetingId) {
|
|
727
2420
|
const invitationStart = Date.now();
|
|
728
2421
|
try {
|
|
729
2422
|
const { getMeetingOperation } = await import('../registry.js');
|
|
730
2423
|
const render = getMeetingOperation('meeting.invitation.render');
|
|
731
|
-
const rendered = await render.handler({
|
|
2424
|
+
const rendered = await render.handler({
|
|
2425
|
+
mtId: meetingId,
|
|
2426
|
+
// 同理:会议主表不存客户名,用建会时填的参会客户补「客户单位」
|
|
2427
|
+
extra: { guest_org: String(payload?.invitationFields?.crm_members ?? '').trim() },
|
|
2428
|
+
}, env);
|
|
732
2429
|
invitation = { pending: false, ...(rendered?.data ?? {}) };
|
|
733
2430
|
timings.invitationMs = Date.now() - invitationStart;
|
|
734
2431
|
}
|
|
@@ -741,9 +2438,16 @@ const ebMeetingCreateFlowApply = {
|
|
|
741
2438
|
}
|
|
742
2439
|
}
|
|
743
2440
|
else {
|
|
2441
|
+
// ⛔ 不等于"没有邀请函"。邀请函本质是**预置函**(项目人员下载后自己改、自己发,
|
|
2442
|
+
// 2026-10-30 用户明确要求),所以会议还在审批、尚未生成时,也要用申请单已知信息
|
|
2443
|
+
// 先出一份,取不到的字段渲染成【待补充】。这样调用方/用户提交完立刻就能拿到文件。
|
|
2444
|
+
const pre = await renderPreInvitation(payload, env);
|
|
2445
|
+
invitation = pre.invitation;
|
|
2446
|
+
timings.invitationMs = pre.ms;
|
|
744
2447
|
warnings.push(`流程已提交(requestId=${requestId || '未返回'}),但 ${waitSeconds}s 内没在会议表里查到「${payload.mtName}」——`
|
|
745
|
-
+ '
|
|
746
|
-
+ '
|
|
2448
|
+
+ '多数情况是还在审批中。已按申请单信息**预生成**邀请函(属预置函,请人工核对后自行发送);'
|
|
2449
|
+
+ '审批通过、会议生成后可用 mtId 再跑一次 meeting.invitation.render 拿到完整字段版本。'
|
|
2450
|
+
+ '⛔ 本 operation 不重复提交流程 —— 不要因为"没有会议 id"就重复 apply。');
|
|
747
2451
|
}
|
|
748
2452
|
return meetingSuccess('meeting.eb.meeting.create.flow.apply', {
|
|
749
2453
|
status: 'COMPLETE',
|
|
@@ -752,6 +2456,11 @@ const ebMeetingCreateFlowApply = {
|
|
|
752
2456
|
requestId,
|
|
753
2457
|
requestName: payload.requestName,
|
|
754
2458
|
meetingId: meetingId || null,
|
|
2459
|
+
// 参会客户有没有真的写进会议记录(表单侧是 Ebuilder,需 apply 补写;回读确认才算 ok)
|
|
2460
|
+
...(crmLinked ? { crmLinked } : {}),
|
|
2461
|
+
// 提交后回显「实际提交了什么」:字段名 → 值(人员/客户显示姓名,不显示 id)。
|
|
2462
|
+
// 用户要的就是"能看见内容",替代走不通的浏览器填单核对。
|
|
2463
|
+
submittedPreview: renderFormPreview(payload?.fieldLabels ?? [], await decoratePreviewPersonNames(env, (payload?.data?.main ?? {}), payload?.fieldLabels ?? [], new Map(Object.entries((payload?.personLabels ?? {}))))),
|
|
755
2464
|
invitation,
|
|
756
2465
|
result: submitted?.data ?? null,
|
|
757
2466
|
}, warnings.length > 0 ? { warnings } : {});
|
|
@@ -772,21 +2481,39 @@ const ebMeetingCreateFlowQuick = {
|
|
|
772
2481
|
resource: 'eb',
|
|
773
2482
|
method: 'meeting.eb.meeting.create.flow.quick',
|
|
774
2483
|
action: 'meeting.eb.meeting.create.flow.quick',
|
|
775
|
-
summary: '一步流程建会(prepare+apply
|
|
2484
|
+
summary: '一步流程建会(prepare+apply 合并):内部并行做会议室推荐/冲突检测/空闲时段,默认**弹浏览器表单**给用户核对提交'
|
|
2485
|
+
+ '(submit_mode=browser,小e 里预填人员/客户后由用户点提交;非小e 环境会自动写服务端);'
|
|
2486
|
+
+ '传 submit_mode=direct 才是服务端直提(拿 requestId + meetingId,会议当场生成、邀请函用真实数据)。'
|
|
2487
|
+
+ '多流程或需选择时不提交,返回选项。'
|
|
2488
|
+
+ '⚠️ 用户提到客户名时**必须先** meeting.eb.crm.search {"keyword":"X"} 拿 customerId 再传 crm_members:[{id,name}];'
|
|
2489
|
+
+ '只把客户名写进会议名称/内容而不传 crm_members 会被直接拒绝(crm_member_required)。'
|
|
2490
|
+
+ '⛔ 不要用 submit_mode=browser —— 小e 填单页不给浏览按钮赋值,参会人员/参会客户会整段丢失',
|
|
776
2491
|
capability: 'eb.meeting',
|
|
777
2492
|
risk: 'high-risk-write',
|
|
778
2493
|
requiresConfirmation: true,
|
|
779
2494
|
inputSchema: schemaOf({
|
|
780
2495
|
mt_name: { type: 'string', description: '会议名称(必填)' },
|
|
781
|
-
begin_datetime: {
|
|
782
|
-
|
|
2496
|
+
begin_datetime: {
|
|
2497
|
+
...SCHEMA_DATETIME_MINUTE,
|
|
2498
|
+
description: '开始时间(yyyy-MM-dd HH:mm)。**可以不传**:不传会按参会人与会议室空闲情况推荐 3 个时段让你选;'
|
|
2499
|
+
+ '只传开始时间、不传结束时间时,按 durationMinutes(默认 60 分钟)自动算结束时间',
|
|
2500
|
+
},
|
|
2501
|
+
end_datetime: {
|
|
2502
|
+
...SCHEMA_DATETIME_MINUTE,
|
|
2503
|
+
description: '结束时间(yyyy-MM-dd HH:mm);不传则按 durationMinutes 从开始时间推算(默认 60 分钟)',
|
|
2504
|
+
},
|
|
783
2505
|
mt_desc: { type: 'string', description: '会议内容/说明' },
|
|
784
2506
|
address: { type: 'string', description: '会议室(id 或名称;不传则返回候选让用户选,不会自动决定)' },
|
|
785
2507
|
customize_address: { type: 'string', description: '自定义会议地点' },
|
|
786
2508
|
hrm_members: { type: 'string', description: '参会人员 id,逗号分隔' },
|
|
787
2509
|
total_member: { type: 'number', description: '参会人数' },
|
|
788
|
-
caller: { type: 'string', description: '
|
|
789
|
-
contacter: { type: 'string', description: '
|
|
2510
|
+
caller: { type: 'string', description: '召集人(员工 id 或姓名)。缺省 = 当前登录人,不用传;只有指定别人当召集人才传' },
|
|
2511
|
+
contacter: { type: 'string', description: '联系人(员工 id 或姓名)。缺省 = 当前登录人,不用传;只有指定别人当联系人才传' },
|
|
2512
|
+
mt_type: {
|
|
2513
|
+
type: 'string',
|
|
2514
|
+
description: '会议类型:可传类型名称或 id(先用 `meeting.eb.type.list` 查有哪些)。'
|
|
2515
|
+
+ '**很多租户的流程表单把「会议类型」设为必填**,不传会被 `workflow.createRequest` 打回缺字段',
|
|
2516
|
+
},
|
|
790
2517
|
other_remark: { type: 'string', description: '备注' },
|
|
791
2518
|
workflowId: {
|
|
792
2519
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
|
@@ -794,6 +2521,16 @@ const ebMeetingCreateFlowQuick = {
|
|
|
794
2521
|
},
|
|
795
2522
|
autoRoom: { type: 'boolean', description: '默认 false:不指定会议室时返回候选让用户选;传 true 才自动采用推荐会议室' },
|
|
796
2523
|
allowNoRoom: { type: 'boolean', description: '确实不需要会议室(线上会等)时传 true' },
|
|
2524
|
+
timeConfirmed: {
|
|
2525
|
+
type: 'boolean',
|
|
2526
|
+
description: '时间的"用户已确认"标记:用户原话明确给了时间、或在时段选项里点选后原样回传 value(自带标记)时才传 true;'
|
|
2527
|
+
+ '不传 = 未确认,CLI 会返回时段选项让用户点选',
|
|
2528
|
+
},
|
|
2529
|
+
roomConfirmed: {
|
|
2530
|
+
type: 'boolean',
|
|
2531
|
+
description: '会议室的"用户已确认"标记:用户原话明确指定、或在房间选项里点选后原样回传 value(自带标记)时才传 true;'
|
|
2532
|
+
+ '不传 = 未确认,CLI 会把指定房间和候选一起列出来让用户点选',
|
|
2533
|
+
},
|
|
797
2534
|
confirm: { type: 'boolean', description: '必须显式 true 才会真正提交' },
|
|
798
2535
|
waitSeconds: { type: 'number', description: '提交后等待会议生成的秒数(默认 20;算法是立即查一次 + 5s 再查一次,仍未生成就返回 pending)' },
|
|
799
2536
|
enrich: { type: 'boolean', description: '是否顺带做会议室推荐/冲突检测/空闲时段(默认 true;人、时间、房间都已确定时传 false 可显著加快)' },
|
|
@@ -801,14 +2538,63 @@ const ebMeetingCreateFlowQuick = {
|
|
|
801
2538
|
type: 'object',
|
|
802
2539
|
description: '按流程表单字段名填额外字段(租户自建字段用这个口子带进去),形如 {"参会客户": "xx公司"}',
|
|
803
2540
|
},
|
|
804
|
-
|
|
2541
|
+
dateFrom: {
|
|
2542
|
+
type: 'string',
|
|
2543
|
+
description: '没给具体时间时按哪一天推荐时段(yyyy-MM-dd;不传默认明天)',
|
|
2544
|
+
},
|
|
2545
|
+
durationMinutes: {
|
|
2546
|
+
type: 'number',
|
|
2547
|
+
description: '会议时长(分钟,默认 60):只给了开始时间时用来算结束时间;推荐时段时也按这个时长找',
|
|
2548
|
+
},
|
|
2549
|
+
workStartHour: { type: 'number', description: '推荐时段的最早小时(默认 9)' },
|
|
2550
|
+
workEndHour: { type: 'number', description: '推荐时段的最晚小时(默认 18)' },
|
|
2551
|
+
fieldValues: {
|
|
2552
|
+
type: 'object',
|
|
2553
|
+
additionalProperties: true,
|
|
2554
|
+
description: '按**表单字段 datakey(fieldId)**直接给值,形如 {"1325677252711735881": "研讨会"};'
|
|
2555
|
+
+ '优先级最高,适合字段名带提示语或租户自建字段。datakey 见 prepare 的 preview.mapped',
|
|
2556
|
+
},
|
|
2557
|
+
flowChoice: {
|
|
2558
|
+
type: 'string',
|
|
2559
|
+
enum: ['auto', 'ask'],
|
|
2560
|
+
description: '多个建会流程**都有发起权限**时:默认(不传 / ask)= 返回选项让用户挑;'
|
|
2561
|
+
+ 'auto = 自动取优先级最高的。只有 1 个有权限时直接发起',
|
|
2562
|
+
},
|
|
2563
|
+
crm_skip: {
|
|
2564
|
+
type: 'boolean',
|
|
2565
|
+
description: '显式声明「这次不填参会客户」。'
|
|
2566
|
+
+ '会议名称/内容里出现 `客户:X`、`客户是X` 这类字样却**没传 `crm_members`**时,'
|
|
2567
|
+
+ '一步建会会直接拒绝(crm_member_required)——确认这些字样不是要上会表的客户,才传 true',
|
|
2568
|
+
},
|
|
2569
|
+
submit_mode: {
|
|
2570
|
+
type: 'string',
|
|
2571
|
+
enum: ['direct', 'browser'],
|
|
2572
|
+
description: '提交方式,默认 browser:在小e 里**弹出流程表单**(字段已预填含人员/客户),用户核对修改后自己点提交。'
|
|
2573
|
+
+ 'direct = 直接写服务端(会议当场生成、邀请函用真实数据,但没有人工核对表单这一步);'
|
|
2574
|
+
+ '只有用户明确说"不用我确认,直接建"时才传 direct',
|
|
2575
|
+
},
|
|
2576
|
+
}, { required: ['mt_name', 'confirm'] }),
|
|
805
2577
|
handler: async (args, env) => {
|
|
806
2578
|
if (args.confirm !== true) {
|
|
807
2579
|
throw meetingValidationError('confirmation_required', '一步建会会直接提交流程,必须显式传 confirm=true;如只想看方案,请用 meeting.eb.meeting.create.flow.prepare');
|
|
808
2580
|
}
|
|
2581
|
+
// 客户名写在名称/内容里、却没传 crm_members ⇒ 一步建会**直接拒绝**。
|
|
2582
|
+
// quick 没有预览环节,放过去就是一张「参会客户」空着的单子(2026-10-31 真机踩到),
|
|
2583
|
+
// 事后清理比在这儿拦一下贵得多。确认不需要填客户时传 crm_skip:true 放行。
|
|
2584
|
+
const crmMentioned = detectCrmMentionInText(args.mt_name, args.mt_desc, args.other_remark);
|
|
2585
|
+
const crmSkipped = args.crm_skip === true;
|
|
2586
|
+
if (crmMentioned && !args.crm_members && !crmSkipped) {
|
|
2587
|
+
throw meetingValidationError('crm_member_required', `会议名称/内容里提到客户「${crmMentioned}」,但没传 crm_members —— 提交后「参会客户」会是空的。`
|
|
2588
|
+
+ `先 meeting.eb.crm.search {"keyword":"${crmMentioned}"} 拿 items[].customerId,`
|
|
2589
|
+
+ '再把 crm_members:[{"id":"<customerId>","name":"<完整客户名>"}] 一起传'
|
|
2590
|
+
+ '(必须带名称,只给 id 会被静默存空);确实不需要填参会客户就传 crm_skip:true。');
|
|
2591
|
+
}
|
|
809
2592
|
const { getMeetingOperation } = await import('../registry.js');
|
|
810
2593
|
const prepareStartedAt = Date.now();
|
|
811
|
-
const
|
|
2594
|
+
const { submit_mode: _submitMode, confirm: _confirm, ...prepareArgs } = args;
|
|
2595
|
+
const prepared = await getMeetingOperation('meeting.eb.meeting.create.flow.prepare').handler(
|
|
2596
|
+
// submit_mode 只属于提交阶段,prepare 的 schema 不接受它(additionalProperties:false)
|
|
2597
|
+
prepareArgs, env);
|
|
812
2598
|
if (prepared?.data?.status === 'NEEDS_SELECTION') {
|
|
813
2599
|
return prepared; // 让用户先选流程,不提交
|
|
814
2600
|
}
|
|
@@ -821,6 +2607,9 @@ const ebMeetingCreateFlowQuick = {
|
|
|
821
2607
|
const applied = await getMeetingOperation('meeting.eb.meeting.create.flow.apply').handler({
|
|
822
2608
|
continuation,
|
|
823
2609
|
confirm: true,
|
|
2610
|
+
...(args.submit_mode
|
|
2611
|
+
? { submit_mode: args.submit_mode }
|
|
2612
|
+
: {}),
|
|
824
2613
|
...(args.waitSeconds !== undefined ? { waitSeconds: args.waitSeconds } : {}),
|
|
825
2614
|
}, env);
|
|
826
2615
|
const warnings = [
|
|
@@ -833,6 +2622,12 @@ const ebMeetingCreateFlowQuick = {
|
|
|
833
2622
|
requestId: applied?.data?.requestId,
|
|
834
2623
|
meetingId: applied?.data?.meetingId ?? null,
|
|
835
2624
|
invitation: applied?.data?.invitation ?? null,
|
|
2625
|
+
// 已提交内容回显(字段名 → 值),提交后可直接贴给用户看"到底填进去了什么"
|
|
2626
|
+
submittedPreview: applied?.data?.submittedPreview ?? null,
|
|
2627
|
+
// 小e 环境(BROWSER_FILL)不写服务端,表单靠这段 <browsertool> 在浏览器里填 —— 必须一起透出,
|
|
2628
|
+
// 否则宿主拿不到表单,用户看到的就是"提交了但没反应"。
|
|
2629
|
+
...(applied?.data?.contextId ? { contextId: (applied?.data).contextId } : {}),
|
|
2630
|
+
...(applied?.data?.finalMarkdown ? { finalMarkdown: (applied?.data).finalMarkdown } : {}),
|
|
836
2631
|
decisions: prepared?.data?.preview?.decisions ?? null,
|
|
837
2632
|
timings: {
|
|
838
2633
|
...(prepared?.data?.preview?.decisions?.timings ?? {}),
|
|
@@ -845,7 +2640,148 @@ const ebMeetingCreateFlowQuick = {
|
|
|
845
2640
|
}, warnings.length > 0 ? { warnings } : {});
|
|
846
2641
|
},
|
|
847
2642
|
};
|
|
2643
|
+
/**
|
|
2644
|
+
* 建会建议(只读):给出「所有人都空闲 **且** 该时段有可用会议室」的时间候选。
|
|
2645
|
+
*
|
|
2646
|
+
* 为什么需要(2026-10-30 用户要求):用户只说"明天"没说几点时,以前的处理只能是弹一个空输入框
|
|
2647
|
+
* 让他自己填——等于把"找时间"这件最费脑子的事丢回给用户。而这信息 CLI 本来就能算:
|
|
2648
|
+
* `findCommonFreeSlots`(人员维度:会议回执 + 日程)→ `recommendRooms`(该时段空闲的会议室,含容量筛选)
|
|
2649
|
+
* 组合起来就能直接给"明天 10:00–11:00,测试会议室1 空着"这种答案,不需要人填。
|
|
2650
|
+
*
|
|
2651
|
+
* ⛔ 用户明确要求两条都必须满足:**人员都空闲** + **会议室也空闲**。
|
|
2652
|
+
* 所以这里会把"该时段没有任何可用会议室"的候选直接剔除,不给"时间有了但没房间"的假选项。
|
|
2653
|
+
*/
|
|
2654
|
+
const ebMeetingCreateSuggest = {
|
|
2655
|
+
name: 'meeting.eb.meeting.create.suggest',
|
|
2656
|
+
resource: 'eb',
|
|
2657
|
+
method: 'meeting.eb.meeting.create.suggest',
|
|
2658
|
+
action: 'meeting.eb.meeting.create.suggest',
|
|
2659
|
+
summary: '建会建议:给定日期范围与参会人,推荐「所有人都空闲 + 该时段有可用会议室」的时间候选(只读,不建会)',
|
|
2660
|
+
capability: 'eb.meeting',
|
|
2661
|
+
risk: 'read',
|
|
2662
|
+
inputSchema: schemaOf({
|
|
2663
|
+
dateFrom: { type: 'string', description: '起始日期(必填,yyyy-MM-dd)' },
|
|
2664
|
+
dateTo: { type: 'string', description: '结束日期(可选,默认同 dateFrom;最多跨 14 天)' },
|
|
2665
|
+
durationMinutes: { type: 'number', description: '会议时长(分钟,默认 60)' },
|
|
2666
|
+
hrm_members: { type: 'string', description: '参会人员(必填,员工 id 或姓名,逗号分隔;姓名会自动反查 id)' },
|
|
2667
|
+
memberCount: { type: 'number', description: '参会人数(可选,用于会议室容量筛选;不传则按 hrm_members 个数)' },
|
|
2668
|
+
workStartHour: { type: 'number', description: '只在几点以后找(默认 9)' },
|
|
2669
|
+
workEndHour: { type: 'number', description: '只在几点以前找(默认 18)' },
|
|
2670
|
+
maxSlots: { type: 'number', description: '最多给几个时间候选(默认 3,最大 8)' },
|
|
2671
|
+
topN: { type: 'number', description: '每个时间候选最多带几间会议室(默认 3)' },
|
|
2672
|
+
includeSchedule: { type: 'boolean', description: '是否把日程(ESB)也算进占用(默认 true)' },
|
|
2673
|
+
}, { required: ['dateFrom', 'hrm_members'] }),
|
|
2674
|
+
handler: async (args, env) => {
|
|
2675
|
+
const dateFrom = String(args.dateFrom ?? '').trim().slice(0, 10);
|
|
2676
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateFrom)) {
|
|
2677
|
+
throw meetingValidationError('datetime_invalid', `dateFrom 必须是 yyyy-MM-dd,收到「${String(args.dateFrom ?? '')}」`);
|
|
2678
|
+
}
|
|
2679
|
+
const dateTo = String(args.dateTo ?? '').trim().slice(0, 10) || dateFrom;
|
|
2680
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) {
|
|
2681
|
+
throw meetingValidationError('datetime_invalid', `dateTo 必须是 yyyy-MM-dd,收到「${String(args.dateTo ?? '')}」`);
|
|
2682
|
+
}
|
|
2683
|
+
const durationMinutes = Number(args.durationMinutes) > 0 ? Number(args.durationMinutes) : 60;
|
|
2684
|
+
const maxSlots = Math.max(1, Math.min(8, Number(args.maxSlots) || 3));
|
|
2685
|
+
const topN = Math.max(1, Math.min(5, Number(args.topN) || 3));
|
|
2686
|
+
// 参会人:纯数字当 id,姓名走 hrm.employee.query 反查(与 prepare 同一套口径)。
|
|
2687
|
+
const rawMembers = String(args.hrm_members ?? '')
|
|
2688
|
+
.split(/[,;,;\s]+/)
|
|
2689
|
+
.map((item) => item.trim())
|
|
2690
|
+
.filter((item) => item !== '');
|
|
2691
|
+
const nameLike = rawMembers.filter((item) => !/^\d+$/.test(item));
|
|
2692
|
+
const nameToId = nameLike.length > 0
|
|
2693
|
+
? await import('./schedule.js')
|
|
2694
|
+
.then((m) => m.resolveMemberIdsByName(env, nameLike))
|
|
2695
|
+
.catch(() => new Map())
|
|
2696
|
+
: new Map();
|
|
2697
|
+
const memberIds = rawMembers.map((item) => nameToId.get(item) ?? item);
|
|
2698
|
+
const unresolved = rawMembers.filter((item) => nameToId.get(item) === undefined && !/^\d+$/.test(item));
|
|
2699
|
+
// 会议人数要含召集人(自己):`hrm_members` 通常只列"其他参会人"。
|
|
2700
|
+
// 用当前登录 userId 判断自己是否已在列表里,避免重复 +1(2026-10-30:只传易鹏时按 1 人算,
|
|
2701
|
+
// 容量下限 2 人的房间被误判"未达建议下限")。
|
|
2702
|
+
const selfId = await env.host.getSession().then((s) => String(s?.userId ?? '')).catch(() => '');
|
|
2703
|
+
const selfIncluded = selfId !== '' && memberIds.includes(selfId);
|
|
2704
|
+
const memberCount = Number(args.memberCount) > 0
|
|
2705
|
+
? Number(args.memberCount)
|
|
2706
|
+
: (memberIds.length > 0 ? memberIds.length + (selfIncluded ? 0 : 1) : undefined);
|
|
2707
|
+
const warnings = [];
|
|
2708
|
+
if (unresolved.length > 0) {
|
|
2709
|
+
warnings.push(`⚠️ 这些参会人没查到员工 id:${unresolved.join('、')} —— 已跳过,其空闲情况**未核验**`);
|
|
2710
|
+
}
|
|
2711
|
+
const free = await findCommonFreeSlots(env, {
|
|
2712
|
+
rangeStart: `${dateFrom} 00:00`,
|
|
2713
|
+
rangeEnd: `${dateTo} 23:59`,
|
|
2714
|
+
durationMinutes,
|
|
2715
|
+
members: memberIds,
|
|
2716
|
+
maxSlots,
|
|
2717
|
+
maxDays: 14,
|
|
2718
|
+
includeSchedule: args.includeSchedule !== false,
|
|
2719
|
+
...(args.workStartHour !== undefined ? { workStartHour: Number(args.workStartHour) } : {}),
|
|
2720
|
+
...(args.workEndHour !== undefined ? { workEndHour: Number(args.workEndHour) } : {}),
|
|
2721
|
+
});
|
|
2722
|
+
// 每个"人空闲"的时段,再并行查该时段的可用会议室(用户要求:会议室也必须空闲)。
|
|
2723
|
+
// 先强制刷新一次台账预热:否则 N 个时段会各自判断 24h 缓存、可能读到旧清单,
|
|
2724
|
+
// 也可能同时发起 N 次全量拉取(2026-10-30:用户刚导入 100+ 间,推荐仍只看到 1 间)。
|
|
2725
|
+
await loadRooms(env, { refresh: true }).catch(() => null);
|
|
2726
|
+
const withRooms = await Promise.all((free.slots ?? []).map(async (slot) => {
|
|
2727
|
+
try {
|
|
2728
|
+
const rec = await recommendRooms(env, {
|
|
2729
|
+
beginDatetime: slot.begin,
|
|
2730
|
+
endDatetime: slot.end,
|
|
2731
|
+
...(memberCount ? { memberCount } : {}),
|
|
2732
|
+
topN,
|
|
2733
|
+
});
|
|
2734
|
+
return {
|
|
2735
|
+
begin: slot.begin,
|
|
2736
|
+
end: slot.end,
|
|
2737
|
+
rooms: rec.candidates.map((candidate) => ({
|
|
2738
|
+
id: candidate.id,
|
|
2739
|
+
name: candidate.name,
|
|
2740
|
+
capacityMax: candidate.capacity.max,
|
|
2741
|
+
reasons: candidate.reasons,
|
|
2742
|
+
})),
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
catch {
|
|
2746
|
+
return { begin: slot.begin, end: slot.end, rooms: [] };
|
|
2747
|
+
}
|
|
2748
|
+
}));
|
|
2749
|
+
// 没有可用会议室的时段直接剔除 —— 不给"时间有了但没房间"的假选项。
|
|
2750
|
+
const usable = withRooms.filter((slot) => slot.rooms.length > 0);
|
|
2751
|
+
if (usable.length === 0) {
|
|
2752
|
+
const why = (free.slots ?? []).length === 0
|
|
2753
|
+
? '该范围内所有参会人没有共同的空闲时段'
|
|
2754
|
+
: `${(free.slots ?? []).length} 个「人空闲」的时段里,没有一个还能订到会议室`;
|
|
2755
|
+
warnings.push(`没找到同时满足「人员空闲 + 会议室空闲」的时间候选:${why}。`
|
|
2756
|
+
+ '请如实告知用户,并建议换个日期范围、缩短时长,或改为线上会议(allowNoRoom: true)。');
|
|
2757
|
+
if (free.blockingSummary)
|
|
2758
|
+
warnings.push(`当天占用概况:${free.blockingSummary}`);
|
|
2759
|
+
}
|
|
2760
|
+
return meetingSuccess('meeting.eb.meeting.create.suggest', {
|
|
2761
|
+
dateFrom,
|
|
2762
|
+
dateTo,
|
|
2763
|
+
durationMinutes,
|
|
2764
|
+
// 同样是"让用户点一下":时段候选用选项控件呈现,别写成文本列表
|
|
2765
|
+
ui: { component: 'choice', multiple: false, recommendedKey: 'T1' },
|
|
2766
|
+
slots: usable.map((slot, index) => ({
|
|
2767
|
+
key: `T${index + 1}`,
|
|
2768
|
+
label: `${slot.begin.slice(5, 10)} ${slot.begin.slice(11)}~${slot.end.slice(11)}`,
|
|
2769
|
+
value: { begin: slot.begin, end: slot.end, address: slot.rooms[0]?.id ?? null },
|
|
2770
|
+
reason: `${memberCount ? `${memberCount} 人全部空闲(会议回执 + 日程已核);` : ''}`
|
|
2771
|
+
+ `${slot.rooms.length} 间会议室可用:${slot.rooms.map((room) => room.name).join('、')}`,
|
|
2772
|
+
rooms: slot.rooms,
|
|
2773
|
+
})),
|
|
2774
|
+
// 把"人空闲但没房间"的时段也带出来,方便调用方解释为什么被剔除
|
|
2775
|
+
skippedSlots: withRooms.filter((slot) => slot.rooms.length === 0),
|
|
2776
|
+
memberIds,
|
|
2777
|
+
scheduleItems: free.scheduleItems,
|
|
2778
|
+
idle: free.idle,
|
|
2779
|
+
cost: free.cost,
|
|
2780
|
+
}, warnings.length > 0 ? { warnings } : {});
|
|
2781
|
+
},
|
|
2782
|
+
};
|
|
848
2783
|
export const meetingEbCreateFlowOperations = [
|
|
2784
|
+
ebMeetingCreateSuggest,
|
|
849
2785
|
ebMeetingCreateFlowPrepare,
|
|
850
2786
|
ebMeetingCreateFlowApply,
|
|
851
2787
|
ebMeetingCreateFlowQuick,
|