weaver-work-cli 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/shortcuts/invoice/host.js +5 -4
  2. package/dist/shortcuts/meeting/operations/change.js +7 -4
  3. package/dist/shortcuts/meeting/operations/create.js +5 -3
  4. package/dist/shortcuts/meeting/operations/eb/association.js +151 -0
  5. package/dist/shortcuts/meeting/operations/eb/attendance-summary.js +153 -0
  6. package/dist/shortcuts/meeting/operations/eb/catalog.js +185 -13
  7. package/dist/shortcuts/meeting/operations/eb/conflict.js +17 -11
  8. package/dist/shortcuts/meeting/operations/eb/decision.js +405 -0
  9. package/dist/shortcuts/meeting/operations/eb/factory.js +222 -35
  10. package/dist/shortcuts/meeting/operations/eb/index.js +18 -1
  11. package/dist/shortcuts/meeting/operations/eb/link.js +212 -0
  12. package/dist/shortcuts/meeting/operations/eb/task-progress.js +123 -0
  13. package/dist/shortcuts/meeting/operations/env.js +78 -61
  14. package/dist/shortcuts/meeting/operations/lifecycle.js +8 -6
  15. package/dist/shortcuts/meeting/operations/link.js +125 -0
  16. package/dist/shortcuts/meeting/operations/receipts.js +54 -21
  17. package/dist/shortcuts/meeting/operations/registry.js +2 -0
  18. package/dist/shortcuts/meeting/operations/shared.js +41 -0
  19. package/dist/shortcuts/meeting/operations/signs.js +14 -4
  20. package/dist/shortcuts/project/operations/read.js +7 -6
  21. package/docs/e10-auth.md +4 -0
  22. package/docs/invoice.md +3 -0
  23. package/package.json +1 -1
  24. package/skills/weaver-e10-meeting/SKILL.md +28 -11
  25. package/skills/weaver-e10-meeting/references/meeting-eb.md +172 -7
  26. package/skills/weaver-e10-meeting/references/meeting-receipts-signs.md +7 -2
  27. package/skills/weaver-e10-meeting/references/meeting-verification.md +271 -0
  28. package/skills/weaver-e10-meeting.zip +0 -0
  29. package/skills/weaver-e10-shijingran/SKILL.md +68 -3
  30. package/skills/weaver-e10-shijingran/references/disabled-capabilities.md +22 -0
  31. package/skills/weaver-e10-shijingran/references/source-manifest.json +12 -4
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { meetingConfirmationError, meetingPartialError, meetingValidationError } from '../../errors.js';
3
3
  import { meetingSuccess, SCHEMA_ID } from '../types.js';
4
- import { assertDateTimeMinute, toIdString } from '../shared.js';
4
+ import { assertDateTimeMinute, assertDateTimeSecond, toIdString } from '../shared.js';
5
5
  import { isWriteKind } from './catalog.js';
6
6
  /** EB(eBuilder formdata)通用路径前缀(资料核实)。 */
7
7
  export const EB_BASE = '/api/ebuilder/form/formdata/v2';
@@ -44,15 +44,15 @@ function stableStringify(value) {
44
44
  }
45
45
  return JSON.stringify(value ?? null);
46
46
  }
47
- function fingerprint(value) {
47
+ export function fingerprint(value) {
48
48
  return createHash('sha256').update(stableStringify(value)).digest('hex').slice(0, 32);
49
49
  }
50
- function requireConfirm(args, action) {
50
+ export function requireConfirm(args, action) {
51
51
  if (args.confirm !== true) {
52
52
  throw meetingConfirmationError(action, { hint: '重新调用时传入 confirm=true 与 prepare 返回的 continuation' });
53
53
  }
54
54
  }
55
- async function verifyLease(args, action, env) {
55
+ export async function verifyLease(args, action, env) {
56
56
  const lease = await env.continuation.verify(args.continuation, action);
57
57
  const session = await env.host.getSession();
58
58
  if (lease.baseUrl !== session.baseUrl || lease.profile !== session.profile || lease.userId !== session.userId) {
@@ -71,19 +71,31 @@ function isNetworkError(error) {
71
71
  * 该缺陷曾导致 CLI 的 EB 读操作全线失败,并连带使 `meeting.env.detect` 的 EB 冒烟失败、
72
72
  * 环境被误判为 standard 版。
73
73
  */
74
- export function queryBody(userId) {
75
- return { datajson: { mainTable: {}, header: { operator: userId } } };
74
+ export function queryBody(userId, page) {
75
+ return {
76
+ datajson: {
77
+ mainTable: {},
78
+ // ⚠️ 分页必须放在 body 的 pageInfo 里(值为字符串):只放 URL query 时 EB 会忽略,
79
+ // 恒定返回首页 10 条(2026-09-21 真机暴露:pageSize=60 仍只回 10 条)。
80
+ ...(page ? { pageInfo: { pageNo: String(page.pageNo), pageSize: String(page.pageSize) } } : {}),
81
+ header: { operator: userId },
82
+ },
83
+ };
76
84
  }
77
85
  /** 详情接口 body(getFormDataByPk):顶层 mainTable 传 id。 */
78
86
  function detailBody(userId, id) {
79
87
  return { datajson: { mainTable: { id }, header: { operator: userId } } };
80
88
  }
81
- /** 写操作 body:datas 数组包裹 mainTable(漏用 datas 会报 datas 节点未找到)。 */
82
- function writeBody(userId, mainTable) {
89
+ /** 写操作 body:datas 数组包裹 mainTable(+ 明细节点);漏用 datas 会报 datas 节点未找到。 */
90
+ export function writeBody(userId, payload) {
91
+ const datas = { mainTable: payload.mainTable, ...payload.details };
92
+ const operationinfo = { printCost: false, asyncPostProcess: false, asyncMark: false };
93
+ if (Object.keys(payload.operateType).length > 0)
94
+ operationinfo.operateType = payload.operateType;
83
95
  return {
84
96
  datajson: {
85
- operationinfo: { printCost: false, asyncPostProcess: false, asyncMark: false },
86
- datas: [{ mainTable }],
97
+ operationinfo,
98
+ datas: [datas],
87
99
  header: { operator: userId },
88
100
  },
89
101
  };
@@ -159,7 +171,14 @@ function buildReadOperation(endpoint) {
159
171
  body: detailBody(session.userId, id),
160
172
  acceptBusiness,
161
173
  });
162
- const data = response?.datajson?.mainTable ?? response?.datajson ?? response;
174
+ // EB 的 getFormDataByPk 与 list 一致,把数据放在 `datajson.datas[].mainTable`。
175
+ // 早期实现直接返回整个 datajson → data 变成 `{datas,status}`(2026-09-21 真机发现)。
176
+ const { rows } = extractEbRows(response);
177
+ const data = rows[0]?.mainTable ?? response?.datajson?.mainTable ?? null;
178
+ if (data === undefined || data === null) {
179
+ // 不存在的 id 必须明确报「未找到」,否则调用方会拿着空对象继续往下走。
180
+ throw meetingValidationError('not_found', `${endpoint.interfacePk} 未找到 id=${id} 的数据(可能已被删除,或当前账号无权限)`, { id, interfacePk: endpoint.interfacePk });
181
+ }
163
182
  return meetingSuccess(endpoint.operation, { id, data }, endpoint.notes ? { meta: { notes: endpoint.notes } } : {});
164
183
  }
165
184
  const query = {};
@@ -199,21 +218,75 @@ function buildReadOperation(endpoint) {
199
218
  path,
200
219
  operation: endpoint.operation,
201
220
  query: { ...query, pageNo, pageSize },
202
- body: queryBody(session.userId),
221
+ // 分页同时进 body.pageInfo:EB 只认 body 里的 pageInfo,仅放 URL query 会被忽略。
222
+ body: queryBody(session.userId, { pageNo, pageSize }),
203
223
  acceptBusiness,
204
224
  });
205
225
  const { rows, total: listTotal } = extractEbRows(response);
226
+ // count 接口与实际行数不一致时(本租户 `query_mt_topic_count` 恒返回 0,而 list 有数据),
227
+ // 以"实际取到的行数"为下限,避免出现 total=0 却有数据的自相矛盾结果。
228
+ const resolvedTotal = total ?? listTotal ?? rows.length;
229
+ // 结果后处理(如决议任务进度 / 签到回执出席汇总):只**增**一个字段,原始 rows 原样返回。
230
+ // 统一注入 `page`(本页 + 总数),避免各实现把"当页行数"误当成总数(2026-09-22 踩到:
231
+ // 签到全量 261 条、pageSize=200 时,summary 里写的 total 只有 200)。
232
+ const pageMeta = {
233
+ pageNo,
234
+ pageSize,
235
+ pageCount: rows.length,
236
+ total: Math.max(resolvedTotal, rows.length),
237
+ };
238
+ const summarized = endpoint.summarizeRows
239
+ ? endpoint.summarizeRows(rows, {
240
+ pageCount: rows.length,
241
+ total: pageMeta.total,
242
+ pageNo,
243
+ pageSize,
244
+ })
245
+ : undefined;
246
+ const progress = summarized === undefined
247
+ ? undefined
248
+ : summarized !== null && typeof summarized === 'object'
249
+ ? { ...summarized, page: pageMeta }
250
+ : { value: summarized, page: pageMeta };
206
251
  return meetingSuccess(endpoint.operation, {
207
- total: total ?? listTotal ?? rows.length,
252
+ total: Math.max(resolvedTotal, rows.length),
208
253
  pageNo,
209
254
  pageSize,
210
255
  count: rows.length,
256
+ ...(progress ? { progress } : {}),
211
257
  rows,
212
258
  filters: query,
213
259
  }, endpoint.notes ? { meta: { notes: endpoint.notes } } : {});
214
260
  },
215
261
  };
216
262
  }
263
+ /**
264
+ * 主键是否走 URL query。
265
+ *
266
+ * 2026-09-21 真机实测:`update_meeting_sign_info` **只**把主键放在 body(`datas[].mainTable.id`) 时,
267
+ * 服务端报「主键传入不能为空」—— 该接口的主键需**通过 URL 传递**。
268
+ * 因此对声明了 `pkQueryParam` 的接口,把主键**同时**放进 query(body 仍保留 id,满足文档「id 必填」)。
269
+ */
270
+ function writePkQuery(endpoint, payload) {
271
+ // delete 一律把主键放 URL:真机实测 `delete_roomusage` 只在 body 传 id 会报「主键传入不能为空」
272
+ // (与 `update_meeting_sign_info` 同因);update 按接口的 pkQueryParam 声明。
273
+ // 多带一个 `?id=` 对已支持 body 主键的接口无害(`room.delete` / `update_room` 均已实测通过)。
274
+ const param = endpoint.pkQueryParam ?? (endpoint.kind === 'delete' ? 'id' : undefined);
275
+ if (!param)
276
+ return undefined;
277
+ const value = payload.mainTable.id;
278
+ if (value === undefined || value === null || value === '')
279
+ return undefined;
280
+ return { [param]: value };
281
+ }
282
+ /** 预览用的完整路径(带主键 query)。 */
283
+ function pathWithPk(endpoint, basePath, payload) {
284
+ const query = writePkQuery(endpoint, payload);
285
+ if (!query)
286
+ return basePath;
287
+ const search = new URLSearchParams(Object.entries(query).map(([key, item]) => [key, String(item)])).toString();
288
+ return `${basePath}?${search}`;
289
+ }
217
290
  /** 计算小时差(支持小数,如 1.5),用于 create_meeting 的 mt_hours。 */
218
291
  function diffHours(begin, end) {
219
292
  const parse = (text) => {
@@ -230,38 +303,108 @@ function diffHours(begin, end) {
230
303
  const hours = (to - from) / 3_600_000;
231
304
  return hours > 0 ? Math.round(hours * 100) / 100 : undefined;
232
305
  }
233
- /** 写操作字段归一化:白名单过滤 + 日期格式校验 + 必填校验 + 会议建会特化默认值。 */
234
- function normalizeEbWrite(endpoint, args, userId) {
235
- const fields = endpoint.kind === 'delete' ? ['id'] : endpoint.fields ?? [];
306
+ /** 单条明细行归一化:白名单过滤 + 日期校验 + 逐行必填校验。 */
307
+ function normalizeDetailRow(spec, row, index) {
308
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
309
+ throw meetingValidationError('invalid_detail_row', `${spec.node}[${index}] 必须是对象`, { node: spec.node, index });
310
+ }
311
+ const source = row;
236
312
  const out = {};
313
+ for (const field of spec.fields) {
314
+ const value = source[field];
315
+ if (value === undefined || value === null)
316
+ continue;
317
+ const isDateField = (spec.dateFields ?? []).includes(field) || (spec.secondDateFields ?? []).includes(field);
318
+ // 日期字段的**显式清空**:传纯空白 ⇒ 落库为空串。
319
+ // 必要性(2026-09-21 真机):`finish_date` 是**服务端必填**,把已填日期改回"未完成"时必须传它;
320
+ // 而空串在下面会被跳过 ⇒ 服务端报「存为未填写的必填字段: finish_date」(页面生成的未完成记录同样是空值)。
321
+ if (isDateField && typeof value === 'string' && value.trim() === '') {
322
+ out[field] = '';
323
+ continue;
324
+ }
325
+ if (value === '')
326
+ continue;
327
+ out[field] = (spec.dateFields ?? []).includes(field)
328
+ ? assertDateTimeMinute(value, `${spec.node}[${index}].${field}`)
329
+ : (spec.secondDateFields ?? []).includes(field)
330
+ ? assertDateTimeSecond(value, `${spec.node}[${index}].${field}`)
331
+ : value;
332
+ }
333
+ // 枚举校验:EB 对非法枚举值常不报错(或静默忽略),入口拦下并给出可选值最省排查成本。
334
+ for (const [field, allowed] of Object.entries(spec.enumFields ?? {})) {
335
+ const value = out[field];
336
+ if (value === undefined)
337
+ continue;
338
+ if (!allowed.includes(String(value))) {
339
+ throw meetingValidationError('invalid_enum', `${spec.node} 第 ${index + 1} 条 ${field} 取值非法:${String(value)}(可选:${allowed.join(' / ')})`, { node: spec.node, index, field, value: String(value), allowed });
340
+ }
341
+ }
342
+ const missing = (spec.requiredFields ?? []).filter((field) => out[field] === undefined);
343
+ if (missing.length > 0) {
344
+ throw meetingValidationError('missing_required_field', `${spec.node} 第 ${index + 1} 条缺少必填字段:${missing.join(', ')}`, { node: spec.node, index, missing });
345
+ }
346
+ return out;
347
+ }
348
+ /**
349
+ * 写操作字段归一化:白名单过滤 + 日期格式校验 + 必填校验 + 会议建会特化默认值 + 明细行归一化。
350
+ *
351
+ * 明细缺失/行数不足的报错要**指名到底哪个节点、第几条、缺哪个字段** —— 决议一次可能几十条明细,
352
+ * 只报“缺少必填字段”会让调用方无从下手。
353
+ */
354
+ export function normalizeEbWrite(endpoint, args, userId) {
355
+ const fields = endpoint.kind === 'delete' ? ['id'] : endpoint.fields ?? [];
356
+ const mainTable = {};
237
357
  for (const field of fields) {
238
358
  const value = args[field];
239
359
  if (value === undefined || value === null || value === '')
240
360
  continue;
241
361
  if ((endpoint.dateFields ?? []).includes(field)) {
242
- out[field] = assertDateTimeMinute(value, field);
362
+ mainTable[field] = assertDateTimeMinute(value, field);
363
+ }
364
+ else if ((endpoint.secondDateFields ?? []).includes(field)) {
365
+ mainTable[field] = assertDateTimeSecond(value, field);
243
366
  }
244
367
  else {
245
- out[field] = value;
368
+ mainTable[field] = value;
246
369
  }
247
370
  }
248
371
  if (endpoint.interfacePk === 'create_meeting') {
249
- if (out.caller === undefined)
250
- out.caller = userId;
251
- if (out.contacter === undefined)
252
- out.contacter = userId;
253
- const hours = typeof out.begin_datetime === 'string' && typeof out.end_datetime === 'string'
254
- ? diffHours(out.begin_datetime, out.end_datetime)
372
+ if (mainTable.caller === undefined)
373
+ mainTable.caller = userId;
374
+ if (mainTable.contacter === undefined)
375
+ mainTable.contacter = userId;
376
+ const hours = typeof mainTable.begin_datetime === 'string' && typeof mainTable.end_datetime === 'string'
377
+ ? diffHours(mainTable.begin_datetime, mainTable.end_datetime)
255
378
  : undefined;
256
379
  if (hours !== undefined)
257
- out.mt_hours = hours;
380
+ mainTable.mt_hours = hours;
258
381
  }
259
382
  const required = endpoint.kind === 'delete' ? ['id'] : endpoint.requiredFields ?? [];
260
- const missing = required.filter((field) => out[field] === undefined);
383
+ const missing = required.filter((field) => mainTable[field] === undefined);
261
384
  if (missing.length > 0) {
262
385
  throw meetingValidationError('missing_required_field', `缺少必填字段:${missing.join(', ')}`, { missing });
263
386
  }
264
- return out;
387
+ const details = {};
388
+ for (const spec of endpoint.details ?? []) {
389
+ const raw = args[spec.node];
390
+ const minRows = spec.minRows ?? 0;
391
+ if (raw === undefined || raw === null) {
392
+ if (minRows > 0) {
393
+ throw meetingValidationError('missing_required_field', `缺少必填明细:${spec.node} 至少 ${minRows} 条${spec.purpose ? `(${spec.purpose})` : ''}`, { node: spec.node, minRows });
394
+ }
395
+ continue;
396
+ }
397
+ if (!Array.isArray(raw)) {
398
+ throw meetingValidationError('invalid_detail', `${spec.node} 必须是数组,每个元素为一条明细对象`, { node: spec.node });
399
+ }
400
+ const rows = raw.map((row, index) => normalizeDetailRow(spec, row, index));
401
+ if (rows.length < minRows) {
402
+ throw meetingValidationError('missing_required_field', `${spec.node} 至少 ${minRows} 条明细(当前 ${rows.length} 条)`, { node: spec.node, minRows, actual: rows.length });
403
+ }
404
+ if (rows.length > 0)
405
+ details[spec.node] = rows;
406
+ }
407
+ return { mainTable, details, operateType: {} };
265
408
  }
266
409
  function buildWriteInputSchema(endpoint, includeConfirm) {
267
410
  const properties = {};
@@ -275,21 +418,53 @@ function buildWriteInputSchema(endpoint, includeConfirm) {
275
418
  else {
276
419
  for (const field of endpoint.fields ?? []) {
277
420
  const isDate = (endpoint.dateFields ?? []).includes(field);
421
+ const isSecondDate = (endpoint.secondDateFields ?? []).includes(field);
278
422
  const required = (endpoint.requiredFields ?? []).includes(field);
279
423
  properties[field] = isDate
280
424
  ? { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$', description: `${field}(yyyy-MM-dd HH:mm,不带秒)` }
281
- : { type: 'string', description: `${field}${required ? '(必填)' : ''}` };
425
+ : isSecondDate
426
+ ? { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$', description: `${field}(yyyy-MM-dd HH:mm:ss,带秒)` }
427
+ : { type: 'string', description: `${field}${required ? '(必填)' : ''}` };
428
+ }
429
+ for (const spec of endpoint.details ?? []) {
430
+ const requiredFields = spec.requiredFields ?? [];
431
+ properties[spec.node] = {
432
+ type: 'array',
433
+ description: `${spec.purpose ?? `${spec.node} 明细`};每条字段:${spec.fields.join(', ')}`
434
+ + `${requiredFields.length > 0 ? `;每条必填:${requiredFields.join(', ')}` : ''}`
435
+ + `${(spec.minRows ?? 0) > 0 ? `;最少 ${spec.minRows} 条` : ''}`,
436
+ items: {
437
+ type: 'object',
438
+ additionalProperties: false,
439
+ properties: Object.fromEntries(spec.fields.map((field) => [
440
+ field,
441
+ (spec.dateFields ?? []).includes(field)
442
+ ? { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$', description: `${field}(yyyy-MM-dd HH:mm)` }
443
+ : (spec.secondDateFields ?? []).includes(field)
444
+ ? { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$', description: `${field}(yyyy-MM-dd HH:mm:ss,带秒)` }
445
+ : { type: 'string', description: `${field}${requiredFields.includes(field) ? '(必填)' : ''}` },
446
+ ])),
447
+ required: requiredFields,
448
+ },
449
+ };
282
450
  }
283
451
  }
284
- const requiredBase = endpoint.kind === 'delete' ? ['id'] : endpoint.requiredFields ?? [];
452
+ const requiredBase = endpoint.kind === 'delete'
453
+ ? ['id']
454
+ : [
455
+ ...(endpoint.requiredFields ?? []),
456
+ ...(endpoint.details ?? []).filter((spec) => (spec.minRows ?? 0) > 0).map((spec) => spec.node),
457
+ ];
285
458
  return {
286
459
  type: 'object',
287
460
  additionalProperties: false,
288
461
  properties,
289
- required: includeConfirm ? ['confirm', 'continuation', ...requiredBase] : requiredBase,
462
+ // apply(includeConfirm=true)只要求确认票据:业务字段已在 prepare 时写入 continuation 快照,
463
+ // 由 apply 从快照取用(properties 仍保留业务字段,允许调用方冗余传入)。
464
+ required: includeConfirm ? ['confirm', 'continuation'] : requiredBase,
290
465
  };
291
466
  }
292
- function buildWriteOperations(endpoint, preflight) {
467
+ function buildWriteOperations(endpoint, preflight, afterWrite) {
293
468
  const prepareName = `${endpoint.operation}.prepare`;
294
469
  const applyName = `${endpoint.operation}.apply`;
295
470
  const writeOp = EB_WRITE_OP[endpoint.kind];
@@ -325,7 +500,7 @@ function buildWriteOperations(endpoint, preflight) {
325
500
  interfacePk: endpoint.interfacePk,
326
501
  kind: endpoint.kind,
327
502
  body: payload,
328
- path,
503
+ path: pathWithPk(endpoint, path, payload),
329
504
  notes: endpoint.notes ?? [],
330
505
  },
331
506
  continuation: token,
@@ -347,17 +522,22 @@ function buildWriteOperations(endpoint, preflight) {
347
522
  requireConfirm(args, applyName);
348
523
  const lease = await verifyLease(args, applyName, env);
349
524
  const session = await env.host.getSession();
350
- const payload = normalizeEbWrite(endpoint, args, session.userId);
525
+ // payload **以 continuation 内的快照为准**:apply 的入参只有 confirm + continuation,
526
+ // 用 args 重新归一化会必然缺字段(2026-09-21 真机实测:回执更新 apply 报 missing_required_field)。
527
+ // 与 meeting.eb.sign.mark / decision.publish 的独立实现保持一致。
528
+ const payload = lease.payload;
351
529
  if (lease.fingerprint !== fingerprint(payload)) {
352
530
  // 带上两侧快照:`target_changed` 否则无从判断到底哪个字段变了(排查成本极高)。
353
531
  throw meetingValidationError('target_changed', '提交内容与 prepare 时的快照不一致,请重新执行 prepare', { expected: lease.payload, actual: payload });
354
532
  }
533
+ const pkQuery = writePkQuery(endpoint, payload);
355
534
  let response;
356
535
  try {
357
536
  response = await env.host.request({
358
537
  method: 'POST',
359
538
  path,
360
539
  operation: applyName,
540
+ ...(pkQuery ? { query: pkQuery } : {}),
361
541
  body: writeBody(session.userId, payload),
362
542
  acceptBusiness,
363
543
  });
@@ -373,10 +553,15 @@ function buildWriteOperations(endpoint, preflight) {
373
553
  if (dj.status === false) {
374
554
  throw meetingValidationError('eb_write_not_applied', `${endpoint.interfacePk} 返回 status=false,服务端未生效,请核对字段与权限后重试`);
375
555
  }
556
+ const rawIds = dj.dataIds ?? dj.ids;
557
+ const dataIds = (Array.isArray(rawIds) ? rawIds : []).map((item) => String(item));
558
+ // 附加写入(当前仅建会:补建 占用 / 回执 / 签到)。主写入已成功,钩子内部不抛错。
559
+ const association = afterWrite ? await afterWrite({ endpoint, payload, dataIds, env }) : undefined;
376
560
  return meetingSuccess(applyName, {
377
561
  status: 'COMPLETE',
378
562
  interfacePk: endpoint.interfacePk,
379
- dataIds: dj.dataIds ?? dj.ids ?? undefined,
563
+ dataIds: rawIds ?? undefined,
564
+ ...(association ? { association } : {}),
380
565
  result: response,
381
566
  });
382
567
  },
@@ -385,5 +570,7 @@ function buildWriteOperations(endpoint, preflight) {
385
570
  }
386
571
  /** 把声明式目录转换为可注册的 operation 列表(读=1 个,写=prepare+apply 2 个)。 */
387
572
  export function buildEbOperations(endpoints, options = {}) {
388
- return endpoints.flatMap((endpoint) => (isWriteKind(endpoint.kind) ? buildWriteOperations(endpoint, options.preflight) : [buildReadOperation(endpoint)]));
573
+ return endpoints.flatMap((endpoint) => (isWriteKind(endpoint.kind)
574
+ ? buildWriteOperations(endpoint, options.preflight, options.afterWrite)
575
+ : [buildReadOperation(endpoint)]));
389
576
  }
@@ -1,6 +1,8 @@
1
1
  import { EB_ENDPOINTS } from './catalog.js';
2
2
  import { buildEbOperations } from './factory.js';
3
3
  import { ebMeetingWritePreflight, meetingEbConflictOperations } from './conflict.js';
4
+ import { meetingEbDecisionOperations } from './decision.js';
5
+ import { ebMeetingAssociation } from './association.js';
4
6
  /**
5
7
  * 会议 EB 版(eBuilder)operations。
6
8
  *
@@ -15,8 +17,23 @@ import { ebMeetingWritePreflight, meetingEbConflictOperations } from './conflict
15
17
  * 仅提醒只给 warnings、检测失败视为冲突);
16
18
  * - `meetingEbConflictOperations` 另提供「只查不建」的 `meeting.eb.conflict.room` /
17
19
  * `meeting.eb.conflict.member`(后者为 2 次请求方案,无 N+1)。
20
+ *
21
+ * 会议决议(2026-09-21 新增):
22
+ * - catalog 新增 7 个 endpoint(决议概述 4 + 决议任务 2 + 会议标记 1),其中
23
+ * `create_mt_decision` / `update_mt_decision_task` 带 detail1 明细(见 catalog 的 details 声明);
24
+ * - `meetingEbDecisionOperations` 提供语义化入口:`meeting.eb.decision.publish`(写决议 + 写回会议,
25
+ * 一步到位,避免漏掉"把会议标记为已决议")与 `meeting.eb.sign.mark`(补签走 update,防止误建记录)。
26
+ *
27
+ * 建会关联数据补建(2026-09-21 新增):
28
+ * - `ebMeetingAssociation` 挂到 apply 之后:`create_meeting` 只写会议主表,**不会**生成
29
+ * 占用 / 回执 / 签到,而这三样是会议室冲突检测、参会人冲突检测与补签的硬依赖,故由 CLI 补齐
30
+ * (见 association.ts:主写入已成功,补建失败只回报 warnings,不中断)。
18
31
  */
19
32
  export const meetingEbOperations = [
20
- ...buildEbOperations(EB_ENDPOINTS, { preflight: ebMeetingWritePreflight }),
33
+ ...buildEbOperations(EB_ENDPOINTS, {
34
+ preflight: ebMeetingWritePreflight,
35
+ afterWrite: ebMeetingAssociation,
36
+ }),
21
37
  ...meetingEbConflictOperations,
38
+ ...meetingEbDecisionOperations,
22
39
  ];
@@ -0,0 +1,212 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { meetingStateDir } from '../env.js';
5
+ /**
6
+ * 会议「打开链接」口径(与 standalone 版 meeting-link.mjs 对齐):
7
+ *
8
+ * - **EB 版**:`{baseUrl}/sp/ebdfpage/card/0/{objid}/{dataid}`(`card` 后的 `0` 恒为 0)。
9
+ * `objid` **不是**固定值,也不是 formId —— 必须按表 tag 用 getObjsByTag 解析,且随环境变化,
10
+ * 因此按 `baseUrl|tenantKey` 长期缓存(`<stateDir>/meeting/obj-ids.json`)。
11
+ * - **标准版**:会议详情 `/sp/meeting/meetingSingle?meetingId=`;会议室 / 会议类型**没有单页**。
12
+ *
13
+ * ⚠️ 长 id 精度:appId / objid 都是 15 位以上整数,超 2^53 会被 JSON.parse 舍入;
14
+ * 会议 host 已开启 `bigIntStrings`,此处只做字符串提取,**禁止**自行 Number() 转换。
15
+ */
16
+ /** 会议 EB 各表单 tag(源码 MeetingEbFormTagEnum + 全仓调用核实)。 */
17
+ export const MEETING_EB_TAGS = {
18
+ meeting: 'uf_mt_detail',
19
+ room: 'uf_mt_room_base',
20
+ roomType: 'uf_mt_room_type',
21
+ roomUsage: 'uf_mt_room_usage',
22
+ setting: 'uf_mt_base_set',
23
+ mtType: 'uf_mt_type',
24
+ sign: 'uf_mt_sign',
25
+ receipt: 'uf_mt_receipt_member',
26
+ topic: 'uf_mt_topic',
27
+ minutes: 'uf_mt_minutes',
28
+ };
29
+ /** 会议 EB 应用 tag(getAppIdsByTag 的入参)。 */
30
+ export const MEETING_EB_APP_TAG = 'meetingEb_wsd';
31
+ const APP_TAG_PATH = '/api/meetingEb/form/getAppIdsByTag';
32
+ const OBJS_BY_TAG_PATH = '/api/meetingEb/form/getObjsByTag';
33
+ /** 标准版没有单页地址的对象(standalone 口径:只输出数据,不附链接)。 */
34
+ export const STANDARD_NO_PAGE_TYPES = ['room', 'mtType'];
35
+ function cacheFile(ctx) {
36
+ return resolve(meetingStateDir(ctx), 'obj-ids.json');
37
+ }
38
+ function emptyCache() {
39
+ return { schemaVersion: 1, environments: {} };
40
+ }
41
+ async function readObjCache(ctx) {
42
+ const file = cacheFile(ctx);
43
+ if (!existsSync(file))
44
+ return emptyCache();
45
+ try {
46
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
47
+ if (!parsed || typeof parsed !== 'object' || !parsed.environments)
48
+ return emptyCache();
49
+ return parsed;
50
+ }
51
+ catch {
52
+ return emptyCache();
53
+ }
54
+ }
55
+ /** objid 缓存写入失败只降级为 warning(下次重新解析即可),不阻断链接生成。 */
56
+ async function writeObjCache(ctx, envKey, entry) {
57
+ try {
58
+ const file = cacheFile(ctx);
59
+ await mkdir(resolve(file, '..'), { recursive: true });
60
+ const cache = await readObjCache(ctx);
61
+ cache.environments[envKey] = entry;
62
+ await writeFile(file, JSON.stringify(cache, null, 2), 'utf8');
63
+ return undefined;
64
+ }
65
+ catch (error) {
66
+ return `objid 缓存写入失败(不影响本次链接):${error instanceof Error ? error.message : String(error)}`;
67
+ }
68
+ }
69
+ /** 从任意形态(数字/字符串/数组/对象)中提取第一个长数字 id。 */
70
+ export function firstLongId(value) {
71
+ if (value === null || value === undefined)
72
+ return undefined;
73
+ if (Array.isArray(value)) {
74
+ for (const item of value) {
75
+ const found = firstLongId(item);
76
+ if (found)
77
+ return found;
78
+ }
79
+ return undefined;
80
+ }
81
+ if (typeof value === 'object') {
82
+ const record = value;
83
+ for (const key of ['id', 'appId', 'objId', 'idobjId', 'value', 'data', 'result']) {
84
+ if (record[key] !== undefined) {
85
+ const found = firstLongId(record[key]);
86
+ if (found)
87
+ return found;
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+ const text = String(value).trim();
93
+ const match = /\d{15,}/u.exec(text) ?? /^\d+$/u.exec(text);
94
+ return match ? match[0] : undefined;
95
+ }
96
+ /** 从 getObjsByTag 响应里挑出目标 tag 的表对象(优先 tableName 非空项,对齐 CommonUtils.getFormObjInfo)。 */
97
+ export function pickEbObj(value) {
98
+ const raw = value && typeof value === 'object' && !Array.isArray(value)
99
+ ? value.data ?? value
100
+ : value;
101
+ const list = Array.isArray(raw) ? raw : [];
102
+ const candidates = list.filter((item) => Boolean(item) && typeof item === 'object');
103
+ const obj = candidates.find((item) => String(item.tableName ?? '').trim() !== '') ?? candidates[0];
104
+ if (!obj)
105
+ return undefined;
106
+ const id = obj.id ?? obj.objId;
107
+ if (id === undefined || id === null || String(id).trim() === '')
108
+ return undefined;
109
+ return {
110
+ objId: String(id),
111
+ tableName: String(obj.tableName ?? ''),
112
+ showName: String(obj.showName ?? obj.objName ?? obj.name ?? ''),
113
+ };
114
+ }
115
+ async function fetchAppId(host, warnings) {
116
+ try {
117
+ const response = await host.request({
118
+ method: 'POST',
119
+ path: APP_TAG_PATH,
120
+ operation: 'meeting.link',
121
+ body: { tag: MEETING_EB_APP_TAG },
122
+ // getAppIdsByTag 的响应是裸数字数组/`{data:[...]}`,没有 code/status 语义,
123
+ // 默认 isBusinessSuccess 会把它判成业务失败 ⇒ 必须放宽业务判定,自行提取。
124
+ acceptBusiness: () => true,
125
+ });
126
+ const appId = firstLongId(response);
127
+ if (!appId)
128
+ warnings.push('会议 EB 应用 id 解析失败(getAppIdsByTag 未返回 appId),无法生成 EB 链接');
129
+ return appId ?? null;
130
+ }
131
+ catch (error) {
132
+ warnings.push(`会议 EB 应用 id 解析失败:${error instanceof Error ? error.message : String(error)}`);
133
+ return null;
134
+ }
135
+ }
136
+ async function fetchObj(host, appId, tag) {
137
+ const response = await host.request({
138
+ method: 'POST',
139
+ path: OBJS_BY_TAG_PATH,
140
+ operation: 'meeting.link',
141
+ body: { appId, tag },
142
+ acceptBusiness: () => true,
143
+ });
144
+ return pickEbObj(response);
145
+ }
146
+ /**
147
+ * 解析会议 EB 各表 tag → objid(按 `baseUrl|tenantKey` 长期缓存)。
148
+ * 解析失败只写入 warnings 并把对应 tag 留空,由调用方降级为「不附链接」,不抛错。
149
+ */
150
+ export async function resolveMeetingEbObjs(host, ctx, options = { tags: [] }) {
151
+ const session = await host.getSession();
152
+ const envKey = `${session.baseUrl}|${session.tenantKey}`;
153
+ const warnings = [];
154
+ const cache = await readObjCache(ctx);
155
+ const entry = cache.environments[envKey];
156
+ let appId = entry?.appId ?? null;
157
+ const objs = { ...(entry?.objs ?? {}) };
158
+ const uniqueTags = [...new Set(options.tags.filter((tag) => tag && tag.trim() !== ''))];
159
+ const forced = options.refresh === true;
160
+ const missing = forced ? uniqueTags : uniqueTags.filter((tag) => !objs[tag]?.objId);
161
+ // 缓存命中且无需刷新时直接用缓存。
162
+ // 注意:条件不要抽成 `const needAppId = ...` 再判断 —— TS 的「别名条件收窄」会把 appId
163
+ // 窄化成 string,导致后面重新赋值 appId(string|null)报 TS2322。
164
+ if (forced === false && appId !== null && missing.length === 0 && uniqueTags.length > 0) {
165
+ return { appId, objs, fromCache: true, warnings };
166
+ }
167
+ if (forced === true)
168
+ appId = null;
169
+ if (appId === null)
170
+ appId = await fetchAppId(host, warnings);
171
+ if (appId === null)
172
+ return { appId: null, objs, fromCache: false, warnings };
173
+ for (const tag of missing) {
174
+ try {
175
+ const obj = await fetchObj(host, appId, tag);
176
+ if (obj)
177
+ objs[tag] = obj;
178
+ else
179
+ warnings.push(`tag=${tag} 未解析到 objid,该对象不附链接`);
180
+ }
181
+ catch (error) {
182
+ warnings.push(`tag=${tag} 解析 objid 失败:${error instanceof Error ? error.message : String(error)}`);
183
+ }
184
+ }
185
+ const cacheWarning = await writeObjCache(ctx, envKey, {
186
+ appId,
187
+ objs,
188
+ updatedAt: new Date().toISOString(),
189
+ });
190
+ if (cacheWarning)
191
+ warnings.push(cacheWarning);
192
+ return { appId, objs, fromCache: false, warnings };
193
+ }
194
+ /** EB 卡片页路径:`/sp/ebdfpage/card/0/{objid}/{dataid}`(`0` 恒为 0)。 */
195
+ export function buildEbCardPath(objId, dataId) {
196
+ return `/sp/ebdfpage/card/0/${encodeURIComponent(objId)}/${encodeURIComponent(dataId)}`;
197
+ }
198
+ /** 标准版会议详情路径:`/sp/meeting/meetingSingle?meetingId={id}`。 */
199
+ export function buildStandardMeetingPath(meetingId) {
200
+ return `/sp/meeting/meetingSingle?meetingId=${encodeURIComponent(meetingId)}`;
201
+ }
202
+ /** 拼接绝对地址(baseUrl 结尾斜杠归一化)。 */
203
+ export function meetingAbsoluteUrl(baseUrl, path) {
204
+ if (!path)
205
+ return null;
206
+ const root = baseUrl.replace(/\/+$/u, '');
207
+ return `${root}${path.startsWith('/') ? path : `/${path}`}`;
208
+ }
209
+ /** 生成 Markdown 内联链接(url 为空时返回 undefined,调用方只输出名称)。 */
210
+ export function meetingMarkdownLink(name, url) {
211
+ return url ? `[${name}](${url})` : undefined;
212
+ }