weaver-work-cli 0.1.39 → 0.1.42

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 (197) hide show
  1. package/README.md +4 -2
  2. package/dist/chunks/{archive-ENZYBBME.js → archive-J4I6MCKF.js} +2 -2
  3. package/dist/chunks/{archive-oa-YWLEC5V4.js → archive-oa-BRZKKTO4.js} +2 -2
  4. package/dist/chunks/{asset-OOV6DWPA.js → asset-L7D4FAOZ.js} +2 -2
  5. package/dist/chunks/{asset-idle-RE632MY5.js → asset-idle-DADLLV75.js} +2 -2
  6. package/dist/chunks/{attachment-content-RWN2FCLL.js → attachment-content-K4C7VLWQ.js} +3 -3
  7. package/dist/chunks/{blog-PEPOJAL7.js → blog-BVGQ7NZL.js} +2 -2
  8. package/dist/chunks/{caizhilian-WYIQDVPJ.js → caizhilian-SAADNFD6.js} +2 -2
  9. package/dist/chunks/{calendar-CIARBAOX.js → calendar-Q7NQY5Z2.js} +61 -23
  10. package/dist/chunks/{chunk-F2EQXUEE.js → chunk-3KTFS4IA.js} +397 -159
  11. package/dist/chunks/{chunk-G2JSIUWF.js → chunk-4PD5NWAX.js} +89 -0
  12. package/dist/chunks/{chunk-HJYVHXUQ.js → chunk-4RAE6JXP.js} +1 -1
  13. package/dist/chunks/{chunk-CQHGNCK2.js → chunk-72CKAUAG.js} +41 -8
  14. package/dist/chunks/{chunk-K3RUEEOL.js → chunk-APVFJUQH.js} +2 -2
  15. package/dist/chunks/{chunk-BJGXMFGD.js → chunk-BBKDXQEC.js} +2 -2
  16. package/dist/chunks/chunk-BIPYLLVS.js +501 -0
  17. package/dist/chunks/{chunk-GVPJZXJ2.js → chunk-EKWAQSLU.js} +92 -50
  18. package/dist/chunks/{chunk-XXJOSZTQ.js → chunk-FOAA27U5.js} +381 -57
  19. package/dist/chunks/{chunk-I2HB737U.js → chunk-G4UR6NXN.js} +2 -2
  20. package/dist/chunks/{chunk-YS5DPN4T.js → chunk-GEWYMQME.js} +1 -1
  21. package/dist/chunks/{chunk-P2DAISYW.js → chunk-GHXCDPTM.js} +1 -1
  22. package/dist/chunks/{chunk-L4N7B7ZD.js → chunk-IZ7S7QUS.js} +4 -4
  23. package/dist/chunks/{chunk-NLK2RZKG.js → chunk-JPRO6ER2.js} +122 -2214
  24. package/dist/chunks/{chunk-36DIFZAH.js → chunk-LADPYOWC.js} +2 -2
  25. package/dist/chunks/chunk-LDZQPUIA.js +1238 -0
  26. package/dist/chunks/chunk-NKZRQXST.js +2546 -0
  27. package/dist/chunks/{chunk-EINRJHQ3.js → chunk-QKNKR4RH.js} +3 -3
  28. package/dist/chunks/{chunk-T7JDJJ7H.js → chunk-R4CHUSKC.js} +126 -19
  29. package/dist/chunks/{chunk-EZGFHJFW.js → chunk-SA6VBIHY.js} +88 -15
  30. package/dist/chunks/{chunk-ZXRNHZTE.js → chunk-TTPJJJC4.js} +2 -2
  31. package/dist/chunks/{chunk-KTCB5R5H.js → chunk-W7BEHOSR.js} +2 -2
  32. package/dist/chunks/{chunk-MUQUHKPG.js → chunk-YRSZQUD2.js} +2 -2
  33. package/dist/chunks/{conflict-DRMDOL56.js → conflict-F2OMZRTO.js} +2 -2
  34. package/dist/chunks/{contract-73MERZWN.js → contract-5BETHRSG.js} +2 -2
  35. package/dist/chunks/{cowork-HNGBPD7A.js → cowork-3MN6EQQO.js} +27 -10
  36. package/dist/chunks/create-flow-5FQNNMVO.js +55 -0
  37. package/dist/chunks/{ebuilder-form-OY6DVNRK.js → ebuilder-form-SBHOYBUD.js} +3 -3
  38. package/dist/chunks/{ehr-RAQNEQO7.js → ehr-KMOJULKG.js} +432 -1234
  39. package/dist/chunks/env-LB7NBL2O.js +19 -0
  40. package/dist/chunks/{esb-6XV7C7YV.js → esb-W2PJCFCX.js} +4 -4
  41. package/dist/chunks/factory-6ZFQXQ46.js +30 -0
  42. package/dist/chunks/{fna-XTSRB7G7.js → fna-ZQPCUV4R.js} +2 -2
  43. package/dist/chunks/group-chat-2HHAJN46.js +20 -0
  44. package/dist/chunks/{host-YMBXWFWX.js → host-4CBHGOHT.js} +3 -3
  45. package/dist/chunks/{host-A7J6GDUP.js → host-ATKVNZAT.js} +3 -3
  46. package/dist/chunks/{host-3F5UM5GN.js → host-BQH22NTQ.js} +3 -3
  47. package/dist/chunks/{host-YAQAPYNP.js → host-X2CSLFM6.js} +3 -3
  48. package/dist/chunks/{host-VGPVP7YF.js → host-YTODKMSL.js} +3 -3
  49. package/dist/chunks/{hrm-TLHKC545.js → hrm-FFM4UJFS.js} +4 -4
  50. package/dist/chunks/{im-YOUCFMUK.js → im-VEHKDQPB.js} +8 -8
  51. package/dist/chunks/{invoice-WNBS2B4C.js → invoice-LVIKSIIF.js} +3 -3
  52. package/dist/chunks/{jiuchuanhui-XABWS5RN.js → jiuchuanhui-X6DINO5P.js} +2 -2
  53. package/dist/chunks/{lazy-dispatch-NLZ5UQQI.js → lazy-dispatch-KSWTN5L6.js} +4 -4
  54. package/dist/chunks/{mail-LRYMBFNL.js → mail-VDKRGUD3.js} +9 -4
  55. package/dist/chunks/{meeting-ESXL6MFI.js → meeting-CLC6HP3Z.js} +12 -10
  56. package/dist/chunks/{okr-EOV65SSF.js → okr-CHG4OO3U.js} +2 -2
  57. package/dist/chunks/{plan-YFPVKW6Y.js → plan-XWR75667.js} +2 -2
  58. package/dist/chunks/{project-MDVZUYQW.js → project-5BXOOKMG.js} +40 -23
  59. package/dist/chunks/recruit-stage-DGMFNNDE.js +4507 -0
  60. package/dist/chunks/{registry-VT5Y2HLA.js → registry-7FFOXIAW.js} +4 -4
  61. package/dist/chunks/{registry-QCLZEDAP.js → registry-BKBZPBAJ.js} +10 -8
  62. package/dist/chunks/{registry-YX4CSEMD.js → registry-RHMW6ANW.js} +2 -2
  63. package/dist/chunks/{registry-NYPYMZXG.js → registry-Z43434VG.js} +1 -1
  64. package/dist/chunks/{room-recommend-L3MWH4JW.js → room-recommend-62H7ZQQ6.js} +4 -4
  65. package/dist/chunks/{schedule-UASAVDVD.js → schedule-FNXKH3DQ.js} +4 -4
  66. package/dist/chunks/{workflow-KDGIUCNW.js → workflow-ADHKCAJK.js} +4 -4
  67. package/dist/index.js +33 -28
  68. package/dist/internal/e10/auth/commands.js +110 -56
  69. package/dist/internal/e10/auth/messages.js +6 -0
  70. package/dist/internal/e10/auth/session.js +28 -1
  71. package/dist/internal/e10/auth/xiaoe.js +315 -48
  72. package/dist/internal/e10/auth-recovery.js +36 -5
  73. package/dist/internal/e10/client.js +13 -0
  74. package/dist/internal/e10/context.js +61 -9
  75. package/dist/internal/e10/index.js +1 -1
  76. package/dist/internal/e10/network-error.js +93 -0
  77. package/dist/internal/e10/request-runtime.js +15 -0
  78. package/dist/shortcuts/calendar/operations/read.js +31 -22
  79. package/dist/shortcuts/calendar/operations/write.js +45 -1
  80. package/dist/shortcuts/cowork/operations/discuss-write.js +5 -1
  81. package/dist/shortcuts/cowork/operations/item-write.js +5 -3
  82. package/dist/shortcuts/cowork/operations/member-write.js +3 -1
  83. package/dist/shortcuts/cowork/operations/permission-write.js +5 -1
  84. package/dist/shortcuts/cowork/operations/shared.js +7 -0
  85. package/dist/shortcuts/ehr/operations/attend-write.js +43 -1
  86. package/dist/shortcuts/ehr/operations/hr-contact.js +7 -4
  87. package/dist/shortcuts/ehr/operations/hr-report.js +13 -7
  88. package/dist/shortcuts/ehr/operations/recruit-candidate-import.js +134 -59
  89. package/dist/shortcuts/ehr/operations/recruit-write.js +262 -17
  90. package/dist/shortcuts/ehr/operations/recruit.js +10 -4
  91. package/dist/shortcuts/ehr/operations/shared.js +47 -0
  92. package/dist/shortcuts/hrm/operations/org.js +118 -0
  93. package/dist/shortcuts/index.js +5 -0
  94. package/dist/shortcuts/mail/operations/mail-send.js +8 -2
  95. package/dist/shortcuts/meeting/operations/eb/catalog.js +9 -2
  96. package/dist/shortcuts/meeting/operations/eb/create-flow.js +461 -127
  97. package/dist/shortcuts/meeting/operations/eb/crm-search.js +87 -5
  98. package/dist/shortcuts/meeting/operations/eb/factory.js +33 -0
  99. package/dist/shortcuts/meeting/operations/eb/flow-memory.js +49 -0
  100. package/dist/shortcuts/meeting/operations/eb/group-chat.js +605 -0
  101. package/dist/shortcuts/meeting/operations/eb/index.js +2 -0
  102. package/dist/shortcuts/meeting/operations/eb/person-cache.js +96 -0
  103. package/dist/shortcuts/meeting/operations/eb/room-recommend.js +44 -6
  104. package/dist/shortcuts/meeting/operations/eb/room-summary.js +2 -0
  105. package/dist/shortcuts/meeting/operations/eb/usage-cache.js +99 -0
  106. package/dist/shortcuts/meeting/operations/from-group-chat.js +35 -12
  107. package/dist/shortcuts/project/operations/read.js +6 -25
  108. package/dist/shortcuts/project/operations/shared.js +22 -0
  109. package/dist/shortcuts/project/operations/write.js +28 -2
  110. package/dist/shortcuts/recruit-stage/context.js +119 -0
  111. package/dist/shortcuts/recruit-stage/continuation.js +73 -0
  112. package/dist/shortcuts/recruit-stage/errors.js +65 -0
  113. package/dist/shortcuts/recruit-stage/host.js +109 -0
  114. package/dist/shortcuts/recruit-stage/index.js +115 -0
  115. package/dist/shortcuts/recruit-stage/manifest.js +29 -0
  116. package/dist/shortcuts/recruit-stage/operations/interview-arrange.js +722 -0
  117. package/dist/shortcuts/recruit-stage/operations/interview-feedback.js +269 -0
  118. package/dist/shortcuts/recruit-stage/operations/jd-create.js +630 -0
  119. package/dist/shortcuts/recruit-stage/operations/list.js +431 -0
  120. package/dist/shortcuts/recruit-stage/operations/registry.js +38 -0
  121. package/dist/shortcuts/recruit-stage/operations/resume-import.js +1198 -0
  122. package/dist/shortcuts/recruit-stage/operations/search.js +158 -0
  123. package/dist/shortcuts/recruit-stage/operations/types.js +25 -0
  124. package/dist/shortcuts/recruit-stage/school-tiers.json +318 -0
  125. package/dist/shortcuts/recruit-stage/shared.js +608 -0
  126. package/dist/shortcuts/workflow/operations/form.js +21 -2
  127. package/dist/shortcuts/workflow/operations/todoSmartConvert/aigroupRules.js +52 -1
  128. package/dist/shortcuts/workflow/operations/todoSmartConvert/detailFieldRules.js +4 -2
  129. package/dist/shortcuts/workflow/operations/todoSmartConvert/summaryCache.js +19 -11
  130. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoDetailCache.js +17 -24
  131. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoDetailFetcher.js +14 -9
  132. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.collect.js +78 -10
  133. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.core.js +3 -1
  134. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.detail.js +17 -11
  135. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.group.js +16 -40
  136. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.groupSummary.js +3 -2
  137. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.js +52 -15
  138. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.pipeline.js +16 -1
  139. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.smartGroup.js +151 -63
  140. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.subGroup.js +56 -0
  141. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.summary.js +53 -16
  142. package/docs/e10-auth.md +22 -1
  143. package/docs/operation-manual.md +8 -0
  144. package/package.json +1 -1
  145. package/scripts/install-skill-to-xiaoe.mjs +105 -0
  146. package/scripts/mcp-meeting.mjs +234 -0
  147. package/scripts/package-skill.mjs +4 -2
  148. package/skill-template/business-info.json +24 -0
  149. package/skill-template/domains/recruit-stage.md +29 -0
  150. package/skills/weaver-e10-calendar/SKILL.md +2 -1
  151. package/skills/weaver-e10-calendar/references/calendar-write.md +54 -0
  152. package/skills/weaver-e10-cowork/SKILL.md +1 -1
  153. package/skills/weaver-e10-cowork/references/cowork-write.md +3 -1
  154. package/skills/weaver-e10-jucailin/SKILL.md +1 -1
  155. package/skills/weaver-e10-jucailin/references/attend-write.md +8 -0
  156. package/skills/weaver-e10-jucailin/references/hr-contact.md +8 -0
  157. package/skills/weaver-e10-jucailin/references/hr-report.md +8 -0
  158. package/skills/weaver-e10-jucailin/references/recruit-create.md +2 -2
  159. package/skills/weaver-e10-jucailin/references/recruit-read.md +1 -1
  160. package/skills/weaver-e10-jucailin/references/recruit-write.md +4 -3
  161. package/skills/weaver-e10-mail/SKILL.md +1 -1
  162. package/skills/weaver-e10-mail/references/mail-send.md +2 -0
  163. package/skills/weaver-e10-meeting/SKILL.md +119 -26
  164. package/skills/weaver-e10-meeting/references/meeting-minute-file.md +25 -11
  165. package/skills/weaver-e10-meeting.zip +0 -0
  166. package/skills/weaver-e10-recruit-stage/SKILL.md +226 -0
  167. package/skills/weaver-e10-recruit-stage/agents/openai.yaml +119 -0
  168. package/skills/weaver-e10-recruit-stage/lib/recruit-xiaoe-file.mjs +188 -0
  169. package/skills/weaver-e10-recruit-stage/references/recruit-arrange-interview.md +322 -0
  170. package/skills/weaver-e10-recruit-stage/references/recruit-capability-check.md +127 -0
  171. package/skills/weaver-e10-recruit-stage/references/recruit-check-install.md +65 -0
  172. package/skills/weaver-e10-recruit-stage/references/recruit-detail-process.md +63 -0
  173. package/skills/weaver-e10-recruit-stage/references/recruit-edit-interview-feedback.md +142 -0
  174. package/skills/weaver-e10-recruit-stage/references/recruit-jd-create.md +73 -0
  175. package/skills/weaver-e10-recruit-stage/references/recruit-jd-template.md +56 -0
  176. package/skills/weaver-e10-recruit-stage/references/recruit-list-candidate.md +101 -0
  177. package/skills/weaver-e10-recruit-stage/references/recruit-list-interview.md +148 -0
  178. package/skills/weaver-e10-recruit-stage/references/recruit-match-candidates.md +77 -0
  179. package/skills/weaver-e10-recruit-stage/references/recruit-output-links.md +59 -0
  180. package/skills/weaver-e10-recruit-stage/references/recruit-resolve-creator.md +69 -0
  181. package/skills/weaver-e10-recruit-stage/references/recruit-resolve-metadata.md +241 -0
  182. package/skills/weaver-e10-recruit-stage/references/recruit-resume-import.md +438 -0
  183. package/skills/weaver-e10-recruit-stage/references/recruit-resume-parse.md +128 -0
  184. package/skills/weaver-e10-recruit-stage/references/recruit-search-candidate.md +146 -0
  185. package/skills/weaver-e10-recruit-stage/references/recruit-urge-feedback.md +69 -0
  186. package/skills/weaver-e10-shared/SKILL.md +2 -2
  187. package/skills/weaver-e10-shared/references/e10-auth-and-session.md +4 -2
  188. package/skills/weaver-e10-shijingran/SKILL.md +3 -2
  189. package/skills/weaver-e10-shijingran/references/operations.md +3 -1
  190. package/skills/weaver-e10-shijingran/references/source-manifest.json +41 -40
  191. package/skills/weaver-e10-shijingran/references/write-operations.md +2 -0
  192. package/skills/weaver-e10-workflow/SKILL.md +7 -1
  193. package/skills/weaver-e10-workflow/aigroup/default.json +23 -9
  194. package/skills/weaver-e10-workflow/references/todo-smart-convert.md +37 -21
  195. package/skills/weaver-e10-workflow/tools/todo-summary.cjs +135 -43
  196. package/skills/weaver-e10-yepiaotong/SKILL.md +9 -10
  197. package/skills/weaver-e10-yepiaotong/references/invoice-agent-entry.md +6 -3
@@ -0,0 +1,722 @@
1
+ /**
2
+ * recruit-stage.interview.arrange.* —— 安排面试(自 recruit-arrange-interview.mjs 1:1 移植)
3
+ *
4
+ * 页面「保存」实际执行的是按钮配置里的一串动作(HAR 实证,跨环境由 getFormButtons 运行时解析):
5
+ * ① 系统保存(saveFormData)→ ② esb(如「面试管理-新建日程」)→ ③ jsCode 调 assignedInterview
6
+ * → ④ esb(如「AI面试时发送提醒」)
7
+ * 本模块按同一顺序逐条执行,不写死任何按钮/动作 id;不认识的脚本动作跳过并在 warnings 明示。
8
+ *
9
+ * 三种操作:
10
+ * .prepare 只出确认摘要(含将执行的动作清单 + AI 面试候选方案),签发 continuation
11
+ * .apply 用户确认后执行(confirm + continuation + 指纹比对;AI 面试方案必填未选时阻断)
12
+ * .prefill 不落库:输出「人才卡片 URL + 面试管理列表 URL + jsExpr」;Agent 打开人才卡片 →
13
+ * (点「安排面试」打开表单)→ 执行 jsExpr 选中「面试方式=AI面试」→ 触发表单联动 → HR 核对保存
14
+ *
15
+ * 定位候选人(三选一,未带 talentId 时生效):mobile(人才库精确检索,唯一命中自动采用)/
16
+ * name(多条 → needChoice 输出候选让用户选,绝不自动挑一个)。
17
+ *
18
+ * AI 面试(type 含 AI/人工智能)时,邮箱与 AI 面试方案(digital_id)均为必填:
19
+ * 未指定方案时 prepare 输出 aiPlans 候选清单(关联字段候选接口 → 「面试数字人配置」列表兜底,
20
+ * objId 走 tag、listId 走菜单,均运行时解析),apply 直接阻断;绝不自动取第一条。
21
+ */
22
+ import { randomUUID } from 'node:crypto';
23
+ import { rsSuccess, schemaOf } from './types.js';
24
+ import { rsFail, rsValidationError, rsConfirmationError } from '../errors.js';
25
+ import { objIdOf, formIdOf, getFormLayout, getFormLayoutMeta, saveFormData, runEsb, getFormButtons, resolvePersonId, getDsOptions, cachedDsMap, findMenuNode, resolveCurrentUser, buildCardUrl, buildListLocateUrl, talentLinkExtra, pickSaveButtonActions, computeRequiredHint, randHex32, } from '../shared.js';
26
+ // 面试计划表单字段 dataKey(缓存 fields 里取)。
27
+ // 注意:digital_id 不放入此列表——它只在「面试方式含 AI」时才是必填,
28
+ // 放进来会让未部署数字人的环境连非 AI 面试都建不了(单独按需校验)。
29
+ const DATAKEYS = ['subject', 'start_time', 'end_time', 'interview_type', 'interview_session', 'interviewer',
30
+ 'position_manage_id', 'talent_id', 'candidate_manage_id', 'mobile', 'email', 'interview_location', 'notice_mould'];
31
+ // 面试计划常用字段中文名(仅用于把联动必填结果说人话;字段 id 一律运行时解析)
32
+ const FIELD_LABELS = {
33
+ subject: '面试主题', start_time: '开始时间', end_time: '结束时间', interview_type: '面试方式',
34
+ interview_session: '面试轮次', interviewer: '面试官', position_manage_id: '应聘职位',
35
+ talent_id: '人才', candidate_manage_id: '应聘管理', mobile: '手机', email: '邮箱',
36
+ digital_id: 'AI面试方案', interview_paper_id: '面试试卷', send_channel: '发送渠道',
37
+ notice_mould: '通知模板', affiliate_schedule: '关联日程', union_status: '状态',
38
+ interview_location: '面试地点',
39
+ };
40
+ const labelOf = (k) => (FIELD_LABELS[k] ? FIELD_LABELS[k] + '(' + k + ')' : k);
41
+ const ASSIGNED_API_RE = /\/api\/recruit\/candidate\/assignedInterview/;
42
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43
+ async function resolveCandidate(ctx, talentId) {
44
+ const { host, cache } = ctx;
45
+ const extra = { ebBusinessId: cache.lists?.talent?.listId || '' };
46
+ const hr = await host.request({ path: '/api/recruit/talent/hasRights?formDataId=' + talentId, method: 'POST', headers: extra });
47
+ if (!(hr?.data || []).includes('READ'))
48
+ throw rsFail('无 READ 权限');
49
+ const r1 = randHex32().slice(0, 6);
50
+ const pq = await host.request({ path: '/api/recruit/candidate/getCandidateCardPrepareQuery?talentId=' + talentId + '&randomkey_' + r1 + '=' + r1, method: 'GET', headers: extra });
51
+ const batchId = pq?.data?.batchId;
52
+ const v = randHex32().slice(0, 6);
53
+ // otherInfo 与 baseInfo 只依赖 batchId、互不引用 → 并行(省 1 RTT)。
54
+ // 两者都是只读 GET;失败时 Promise.all 抛第一个错,行为与串行等价(仅多一个无害请求)。
55
+ const [o, b2] = await Promise.all([
56
+ host.request({ path: '/api/recruit/candidate/getCandidateCardOtherInfo?talentId=' + talentId + '&batchId=' + batchId + '&v=' + v + '&randomkey_ab=ab', method: 'GET', headers: extra }),
57
+ host.request({ path: '/api/recruit/candidate/getCandidateCardBaseInfo?talentId=' + talentId + '&batchId=' + batchId + '&v=' + v + '&randomkey_cd=cd', method: 'GET', headers: extra }),
58
+ ]);
59
+ const b = o?.data?.currentCandidateBatch || {};
60
+ let name = b?.talent?.name;
61
+ // 手机号/邮箱必须显式取:页面是「按 talent_id 联动带出」,直接调 saveFormData 不会触发联动,
62
+ // 不写就会落空(AI面试要求邮箱必填)。取 BaseInfo 的 talent.telephone / talent.email。
63
+ let mobile = '';
64
+ let email = '';
65
+ const tb = b2?.data?.talent || {};
66
+ if (!name)
67
+ name = tb.hrm?.name || tb.name;
68
+ mobile = String(tb.telephone || tb.mobile || '').trim();
69
+ email = String(tb.email || '').trim();
70
+ return {
71
+ talentId,
72
+ name,
73
+ mobile,
74
+ email,
75
+ candidateManageId: b?.id || b?.candidateManageId || null,
76
+ positionId: b?.position?.id || null,
77
+ positionName: b?.position?.name,
78
+ };
79
+ }
80
+ // 候选人定位:mobile/name → talentId(人才库列表检索)。手机号优先且命中唯一即采用;
81
+ // 姓名可能多条 → 返回 candidates 由用户选择(绝不自动挑一个)。
82
+ async function locateTalent(ctx, opts) {
83
+ const { host, cache } = ctx;
84
+ const listId = cache.lists?.talent?.listId;
85
+ const objId = cache.forms?.talent_pool?.objId;
86
+ if (!listId || !objId)
87
+ throw rsFail('环境缓存缺少人才列表(lists.talent / forms.talent_pool)');
88
+ const fields = cache.fields[objId] || {};
89
+ const nameKey = fields.candidate_name || fields.name;
90
+ if (opts.mobile && !fields.telephone)
91
+ throw rsFail('环境缓存缺少人才手机字段(telephone)');
92
+ if (!opts.mobile && opts.name && !nameKey)
93
+ throw rsFail('环境缓存缺少人才姓名字段(candidate_name/name)');
94
+ const conds = {};
95
+ if (opts.mobile) {
96
+ const mf = 'field_' + fields.telephone;
97
+ conds[mf] = [{ [mf]: opts.mobile, [mf + '_condition_type']: 'like' }];
98
+ }
99
+ if (opts.name && !opts.mobile) {
100
+ const nf = 'field_' + nameKey;
101
+ conds[nf] = [{ [nf]: opts.name, [nf + '_condition_type']: 'like' }];
102
+ }
103
+ const body = {
104
+ current: 1, pageSize: 20, tableuid: randHex32(),
105
+ listId,
106
+ searchParamData: { searchType: '3', commonSearchFields: conds, type: 'and' },
107
+ terminal: 'PC', browser: 'Chrome', requiredMatch: 1, mapResult: 1, recheckQuery: 1,
108
+ isFollow: 0, isShareToMe: 0, filterCondition: {},
109
+ cusMenuId: cache.lists.talent.cusMenuId, urlPageTitle: '人才库',
110
+ menuName: cache.lists.talent.menuName || '人才管理',
111
+ };
112
+ const r = await host.request({
113
+ path: '/api/ebuilder/form/list/getListDatas?passid=' + objId + '&cid=' + objId,
114
+ body, headers: { ebBusinessId: listId },
115
+ });
116
+ const disp = r?.data?.displayData;
117
+ const raw = r?.data?.data || [];
118
+ const rows = Array.isArray(disp) && disp.length ? disp : raw;
119
+ return rows.map((x) => ({
120
+ talentId: x.form_data_id || x.id || null,
121
+ name: x.candidate_name || x.name || null,
122
+ mobile: x.telephone || null,
123
+ position: x.position_manage_id || x.position || null,
124
+ recruitStatus: x.recruit_status || null,
125
+ }));
126
+ }
127
+ // ── AI 面试方案(面试数字人配置表):objId 走 tag、listId 走菜单,全部运行时解析 ──
128
+ const PLAN_NAME_KEYS = ['msfa_name', 'digital_name', 'plan_name', 'scheme_name'];
129
+ function digitalMenuOf(cache) {
130
+ const hit = cache.digitalHumanMenu;
131
+ if (hit?.listId)
132
+ return hit;
133
+ const menus = cache.lists?.all_search || cache.menus || [];
134
+ return menus.find((m) => (m.menuName || '').includes('面试数字人'))
135
+ || menus.find((m) => (m.menuName || '').includes('数字人配置'))
136
+ || null;
137
+ }
138
+ async function digitalObjIdOf(ctx) {
139
+ const cached = ctx.cache.forms?.digital_human?.objId;
140
+ if (cached)
141
+ return String(cached);
142
+ const r = await ctx.host.request({ path: '/api/ebuilder/form/obj/getObjsByTag?appId=' + ctx.appId + '&tag=uf_rcrt_digital_human', method: 'GET' });
143
+ const d = r?.data;
144
+ return Array.isArray(d) && d.length ? String(d[0].id || '') : '';
145
+ }
146
+ // AI 面试方案候选:优先走「关联字段候选项」接口(不依赖菜单/列表,标准包未配菜单也可用),
147
+ // 失败再回退「面试数字人配置」列表。两种都不写死任何 id。
148
+ async function listDigitalPlans(opts) {
149
+ const { ctx, formId, layoutId, fieldId } = opts;
150
+ const { host, cache } = ctx;
151
+ // ① 关联字段候选(实测可用,返回 {id,name,ebObjId})
152
+ try {
153
+ const body = {
154
+ browserMultiple: false,
155
+ newly: 1,
156
+ searchValue: '',
157
+ formParam: JSON.stringify({ formId, layoutId, fieldId, filterItems: [], module: 'ebuilderform', dataDetails: [] }),
158
+ openScroll: true,
159
+ controlId: fieldId,
160
+ businessId: formId,
161
+ variableMap: {},
162
+ };
163
+ const r = await host.request({ path: '/api/ebuilder/form/common/browser/complete/ebuilder', body });
164
+ if (r?.status !== false && r?.data) {
165
+ const list = r.data.data || [];
166
+ const items = list.filter((x) => x && x.id).map((x) => ({
167
+ id: String(x.id), name: String(x.name || '').trim(), position: '', desc: '',
168
+ }));
169
+ return { available: true, items, source: 'associate-complete' };
170
+ }
171
+ }
172
+ catch { /* 回退列表 */ }
173
+ // ② 列表兜底(需要菜单「面试数字人配置」)
174
+ const menu = digitalMenuOf(cache);
175
+ if (!menu?.listId) {
176
+ return { available: false, items: [], reason: '候选接口不可用,且当前环境未配置「面试数字人配置」列表菜单' };
177
+ }
178
+ let objId = '';
179
+ try {
180
+ const liBody = { cusMenuId: menu.cusMenuId, listId: menu.listId, urlPageTitle: menu.menuName };
181
+ liBody['appId_' + menu.listId] = ctx.appId;
182
+ const li = await host.request({ path: '/api/ebuilder/form/list/listInit', body: liBody });
183
+ objId = String(li?.data?.objid || '');
184
+ }
185
+ catch { /* 回退 tag 解析 */ }
186
+ if (!objId)
187
+ objId = await digitalObjIdOf(ctx).catch(() => '');
188
+ if (!objId) {
189
+ return { available: false, items: [], reason: '未能解析「面试数字人配置」对象 id(listInit / tag 均失败)' };
190
+ }
191
+ const rows = [];
192
+ let cur = 1;
193
+ const size = 100;
194
+ for (;;) {
195
+ const body = {
196
+ current: cur, pageSize: size, tableuid: randHex32(),
197
+ listId: menu.listId,
198
+ searchParamData: { searchType: '3', type: 'and' },
199
+ terminal: 'PC', browser: 'Chrome', requiredMatch: 1, mapResult: 1,
200
+ recheckQuery: 1, isFollow: 0, isShareToMe: 0, filterCondition: {},
201
+ cusMenuId: menu.cusMenuId, urlPageTitle: menu.menuName, menuName: menu.menuName,
202
+ };
203
+ const r = await host.request({
204
+ path: `/api/ebuilder/form/list/getListDatas?passid=${objId}&cid=${objId}`,
205
+ body, headers: { ebBusinessId: menu.listId },
206
+ });
207
+ const page = r?.data?.data || [];
208
+ rows.push(...page);
209
+ if (page.length < size)
210
+ break;
211
+ cur++;
212
+ if (cur > 20)
213
+ break; // 防御:最多 2000 条
214
+ }
215
+ const items = rows.map((x) => {
216
+ const nk = PLAN_NAME_KEYS.find((k) => String(x[k] || '').trim());
217
+ return {
218
+ id: String(x.form_data_id || x.id || ''),
219
+ name: String(nk ? x[nk] : '').trim(),
220
+ position: String(x.zwmc || '').trim(),
221
+ desc: String(x.zwjs || '').trim(),
222
+ };
223
+ }).filter((x) => x.id);
224
+ return { available: true, items, listId: menu.listId, objId, menuName: menu.menuName, source: 'list' };
225
+ }
226
+ // 方案匹配:id 精确 → 名称全等 → 名称包含;多条命中视为歧义,绝不自动取第一条
227
+ function pickDigitalPlan(items, key) {
228
+ const k = String(key || '').trim();
229
+ if (!k)
230
+ return { plan: null, ambiguous: [] };
231
+ const byId = items.filter((x) => x.id === k);
232
+ if (byId.length === 1)
233
+ return { plan: byId[0], ambiguous: [] };
234
+ const byExact = items.filter((x) => x.name === k);
235
+ if (byExact.length === 1)
236
+ return { plan: byExact[0], ambiguous: [] };
237
+ const byLike = items.filter((x) => x.name.includes(k));
238
+ if (byLike.length === 1)
239
+ return { plan: byLike[0], ambiguous: [] };
240
+ return { plan: null, ambiguous: byLike };
241
+ }
242
+ function planBrief(items) {
243
+ return (items || []).map((x) => x.name + (x.position ? '(岗位:' + x.position + ')' : '') + ' [id=' + x.id + ']').join(';');
244
+ }
245
+ async function buildPlan(opts) {
246
+ const { ctx, talentId, positionId, interviewer, type, session, start, end, email, mobile, aiPlan } = opts;
247
+ const { host, appId, cache } = ctx;
248
+ const objId = objIdOf(cache, 'interview_plan');
249
+ const formId = formIdOf(cache, 'interview_plan');
250
+ const fields = cache.fields[objId] || {};
251
+ const fid = (k) => fields[k];
252
+ for (const k of DATAKEYS)
253
+ if (!fid(k))
254
+ throw rsFail('面试计划缺少字段 ' + k + '(ds/fields 解析不到)');
255
+ const cand = await resolveCandidate(ctx, talentId);
256
+ const positionIdFinal = positionId || (cand.positionId ? String(cand.positionId) : '');
257
+ if (!positionIdFinal) {
258
+ throw rsFail('该人才尚未关联职位(简历池人才):安排面试必须先选定一个职位。'
259
+ + '请从在招职位中推荐并由用户选定后,带 positionId 重试(prepare 可先出确认摘要)。');
260
+ }
261
+ const candManageId = cand.candidateManageId ? String(cand.candidateManageId) : '';
262
+ if (!candManageId)
263
+ throw rsFail('未定位到应聘管理记录(candidateManageId),请确认候选人已有应聘批次');
264
+ // 面试方式/轮次枚举:环境缓存 field_options 已有同名映射时直接反转使用(省 2 请求),未覆盖才回源
265
+ const typeMap = cachedDsMap(cache, objId, 'interview_type') || await getDsOptions(host, objId, fid('interview_type'), appId);
266
+ const sessionMap = cachedDsMap(cache, objId, 'interview_session') || await getDsOptions(host, objId, fid('interview_session'), appId);
267
+ const typeId = typeMap[type];
268
+ if (!typeId)
269
+ throw rsFail('面试方式「' + type + '」不在枚举中(' + Object.keys(typeMap).join('/') + ')');
270
+ const sessionId = sessionMap[session];
271
+ if (!sessionId)
272
+ throw rsFail('面试轮次「' + session + '」不在枚举中(' + Object.keys(sessionMap).join('/') + ')');
273
+ // 手机号/邮箱:页面按 talent_id 联动带出;直接落库必须显式写。用户显式输入优先于人才资料。
274
+ const emailFromTalent = String(cand.email || '').trim();
275
+ const mobileFromTalent = String(cand.mobile || '').trim();
276
+ const emailInput = String(email || '').trim();
277
+ const mobileInput = String(mobile || '').trim();
278
+ if (emailInput)
279
+ cand.email = emailInput;
280
+ if (mobileInput)
281
+ cand.mobile = mobileInput;
282
+ const emailSource = emailInput ? 'user' : (emailFromTalent ? 'talent' : 'none');
283
+ const mobileSource = mobileInput ? 'user' : (mobileFromTalent ? 'talent' : 'none');
284
+ // AI 面试:邮箱必填(服务端按邮箱发面试通知/录屏链接)。缺失时阻断创建,不静默写空、不降级。
285
+ const isAI = /AI|人工智能/i.test(String(type));
286
+ if (isAI && !cand.email) {
287
+ throw rsFail('AI面试必须填写候选人邮箱(必填):该人才资料未维护邮箱(talentId=' + talentId + ')。'
288
+ + '请用户提供邮箱后重试(email 参数);或先到人才资料补全邮箱再安排。');
289
+ }
290
+ if (cand.email && !EMAIL_RE.test(cand.email)) {
291
+ throw rsFail('候选人邮箱格式不正确:' + cand.email + '。请提供有效邮箱后重试。');
292
+ }
293
+ if (!cand.mobile) {
294
+ throw rsFail('未取到候选人手机号(talentId=' + talentId + '),请先在人才资料补全手机号,或用 mobile 参数指定');
295
+ }
296
+ // 面试官解析与 AI 方案解析互不依赖 → 提前启动、并行等待(省 1 RTT)。
297
+ // 包装成永不 reject 的 promise:即便 AI 方案校验先抛错提前退出,也不会产生 unhandledRejection;
298
+ // 真正 await 时若失败再抛原错,保持与串行完全一致的错误语义。
299
+ const interviewerTask = (async () => {
300
+ if (interviewer) {
301
+ return { id: await resolvePersonId(host, fid('interviewer'), interviewer), name: interviewer };
302
+ }
303
+ const cu = await resolveCurrentUser(host);
304
+ return cu ? { id: cu.id, name: cu.name } : { id: null, name: interviewer };
305
+ })().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
306
+ // AI 面试方案(digital_id)——与邮箱同为 AI 面试必填。未指定时不自动取第一条。
307
+ let aiPlanPicked = null;
308
+ let aiPlanOptions = { available: false, items: [] };
309
+ let layoutId = '';
310
+ const digitalFid = fid('digital_id');
311
+ let interviewerId = null;
312
+ let interviewerName = interviewer;
313
+ if (isAI) {
314
+ if (!digitalFid)
315
+ throw rsFail('面试计划表单缺少「AI面试方案」字段(dataKey=digital_id),无法安排 AI 面试');
316
+ layoutId = await getFormLayout(host, formId);
317
+ // 方案候选与面试官并行(方案依赖 layoutId,故在其后)
318
+ const [plans, ivr] = await Promise.all([listDigitalPlans({ ctx, formId, layoutId, fieldId: digitalFid }), interviewerTask]);
319
+ if (!ivr.ok)
320
+ throw ivr.error;
321
+ interviewerId = ivr.value.id;
322
+ interviewerName = ivr.value.name;
323
+ aiPlanOptions = plans;
324
+ if (!aiPlanOptions.available) {
325
+ throw rsFail('AI面试必须选择「AI面试方案」,但' + aiPlanOptions.reason + ';请先在该环境维护面试数字人配置');
326
+ }
327
+ if (!aiPlanOptions.items.length) {
328
+ throw rsFail('「面试数字人配置」暂无可选方案,请先在「面试数字人配置」中新增方案后再安排 AI 面试');
329
+ }
330
+ if (aiPlan) {
331
+ const { plan, ambiguous } = pickDigitalPlan(aiPlanOptions.items, aiPlan);
332
+ if (plan)
333
+ aiPlanPicked = plan;
334
+ else if (ambiguous.length > 1)
335
+ throw rsFail('AI面试方案「' + aiPlan + '」命中多个:' + planBrief(ambiguous) + ',请指明具体方案');
336
+ else
337
+ throw rsFail('未找到 AI面试方案「' + aiPlan + '」。可选:' + planBrief(aiPlanOptions.items));
338
+ }
339
+ }
340
+ else {
341
+ const ivr = await interviewerTask;
342
+ if (!ivr.ok)
343
+ throw ivr.error;
344
+ interviewerId = ivr.value.id;
345
+ interviewerName = ivr.value.name;
346
+ }
347
+ if (!interviewerId)
348
+ throw rsFail('面试官未指定且无法解析当前登录用户,请用 interviewer 参数指定');
349
+ const dataDetails = [
350
+ { formField: { id: fid('subject') }, content: (cand.positionName ? cand.positionName + '-' : '') + cand.name + '的面试' },
351
+ { formField: { id: fid('start_time') }, content: start },
352
+ { formField: { id: fid('end_time') }, content: end },
353
+ { formField: { id: fid('interview_type') }, dataOptions: [{ optionId: typeId, content: type }] },
354
+ { formField: { id: fid('interview_session') }, dataOptions: [{ optionId: sessionId, content: session }] },
355
+ { formField: { id: fid('interviewer') }, dataOptions: [{ optionId: interviewerId, content: interviewerName }] },
356
+ { formField: { id: fid('mobile') }, content: cand.mobile },
357
+ ...(cand.email ? [{ formField: { id: fid('email') }, content: cand.email }] : []),
358
+ { formField: { id: fid('position_manage_id') }, dataOptions: [{ optionId: positionIdFinal, content: cand.positionName }] },
359
+ { formField: { id: fid('talent_id') }, dataOptions: [{ optionId: talentId, content: cand.name }] },
360
+ { formField: { id: fid('candidate_manage_id') }, dataOptions: [{ optionId: candManageId, content: candManageId }] },
361
+ ];
362
+ if (aiPlanPicked) {
363
+ dataDetails.push({ formField: { id: digitalFid }, dataOptions: [{ optionId: aiPlanPicked.id, content: aiPlanPicked.name }] });
364
+ }
365
+ return {
366
+ host: ctx.host,
367
+ objId, formId, appId, dataDetails, cand, typeId, sessionId, interviewerId, isAI, layoutId,
368
+ typeOptions: Object.fromEntries(Object.entries(typeMap).map(([n, id]) => [String(id), n])),
369
+ aiPlanPicked, aiPlanOptions,
370
+ summary: {
371
+ action: '安排面试',
372
+ talent: cand.name + '(talentId=' + talentId + ')',
373
+ position: cand.positionName, type, session,
374
+ time: start + ' ~ ' + end, interviewer: interviewerName,
375
+ mobile: cand.mobile + '(来源:' + (mobileSource === 'user' ? '本次指定' : '人才资料') + ')',
376
+ email: (cand.email || '(未提供)') + '(来源:' + (emailSource === 'user' ? '本次指定' : emailSource === 'talent' ? '人才资料' : '缺失') + ')',
377
+ aiPlan: isAI
378
+ ? (aiPlanPicked ? aiPlanPicked.name + '(id=' + aiPlanPicked.id + ')' : '未选择(AI面试必填,需用户指定)')
379
+ : '(非 AI 面试,不适用)',
380
+ isAIInterview: isAI,
381
+ },
382
+ };
383
+ }
384
+ // 保存按钮动作链:system 由 saveFormData 完成,其余按 showOrder 顺序逐条执行
385
+ async function resolveSaveActions(plan) {
386
+ const layoutId = plan.layoutId || await getFormLayout(plan.host, plan.formId);
387
+ const acts = pickSaveButtonActions(await getFormButtons(plan.host, plan.objId, layoutId));
388
+ return acts.filter((a) => a.actionType !== 'system');
389
+ }
390
+ async function callAssignedInterview(host, formDataId) {
391
+ const r = await host.request({ path: '/api/recruit/candidate/assignedInterview?formDataId=' + formDataId, method: 'POST' });
392
+ // 页面判断用松散比较 res.code == '200',保持一致(code 可能是数字或字符串)
393
+ const ok = String(r?.code) === '200' || r?.status === true || r?.success === true;
394
+ if (!ok)
395
+ throw rsFail('分配面试(assignedInterview)失败:' + (r?.msg || JSON.stringify(r).slice(0, 200)));
396
+ return r?.data || {};
397
+ }
398
+ async function execSaveActions(plan, formDataId) {
399
+ const host = plan.host;
400
+ const executed = [];
401
+ const warnings = [];
402
+ let assigned = null;
403
+ let actions = [];
404
+ try {
405
+ actions = await resolveSaveActions(plan);
406
+ }
407
+ catch (e) {
408
+ warnings.push('读取「保存」按钮动作链失败:' + e.message + ';已按「保存 + 分配面试」兜底执行');
409
+ }
410
+ const hasAssigned = actions.some((a) => ASSIGNED_API_RE.test(a.ecode || ''));
411
+ if (!actions.length)
412
+ warnings.push('未解析到「保存」按钮的后续动作(该环境可能未配置,或按钮配置接口不可用);已按「保存 + 分配面试」兜底');
413
+ for (const a of actions) {
414
+ const base = { order: a.showOrder, name: a.name, type: a.actionType };
415
+ if (a.actionType === 'esb') {
416
+ const r = await runEsb(host, { objId: plan.objId, dataIds: formDataId, esbActionIds: a.id });
417
+ const ok = r?.status !== false;
418
+ executed.push({ ...base, ok, msg: r?.successMsg || r?.failMsg || '' });
419
+ if (!ok)
420
+ throw rsFail('「保存」后续动作「' + (a.name || a.id) + '」执行失败:' + (r?.failMsg || '未知原因'));
421
+ }
422
+ else if (a.actionType === 'jsCode') {
423
+ if (ASSIGNED_API_RE.test(a.ecode || '')) {
424
+ assigned = await callAssignedInterview(host, formDataId);
425
+ executed.push({ ...base, ok: true, msg: '等价调用 assignedInterview(分配面试)' });
426
+ }
427
+ else {
428
+ executed.push({ ...base, ok: null, msg: '自定义脚本动作,服务端无法等价执行,已跳过' });
429
+ warnings.push('动作「' + (a.name || a.id) + '」为自定义 jsCode,未执行;如需该效果请在页面点保存');
430
+ }
431
+ }
432
+ else {
433
+ executed.push({ ...base, ok: null, msg: '非服务端动作(' + a.actionType + '),已跳过' });
434
+ }
435
+ }
436
+ if (!hasAssigned) {
437
+ assigned = await callAssignedInterview(host, formDataId);
438
+ executed.push({ order: 999, name: '分配面试(兜底)', type: 'api', ok: true, msg: '该环境未把分配面试配置为保存动作' });
439
+ warnings.push('该环境「保存」动作链未解析到 assignedInterview,已按招聘业务契约兜底调用');
440
+ }
441
+ return { executed, warnings, assigned };
442
+ }
443
+ // 读取表单联动声明,算出「当前面试方式下必填、但本次未提供且无默认值」的字段(只读提示,不阻断)
444
+ async function resolveRequiredHint(plan, ctx) {
445
+ const fields = ctx.cache.fields[plan.objId] || {};
446
+ const meta = await getFormLayoutMeta(ctx.host, plan.formId);
447
+ if (!meta)
448
+ return { available: false, required: [], missing: [] };
449
+ return computeRequiredHint({
450
+ meta, fields,
451
+ typeName: plan.summary.type,
452
+ typeOptionId: plan.typeId,
453
+ typeOptions: plan.typeOptions || {},
454
+ dataDetails: plan.dataDetails,
455
+ });
456
+ }
457
+ // 人才卡片详情 URL(EB 详情页):{baseUrl}/sp/ebdpage/view/{ebdDetailPageId}?talentId={id}
458
+ function resolveTalentCardUrl(ctx, talentId) {
459
+ const pid = ctx.cache.ebdDetailPageId;
460
+ if (!pid)
461
+ return null;
462
+ return `${ctx.baseUrl}/sp/ebdpage/view/${pid}?talentId=${talentId}${talentLinkExtra(ctx.cache)}`;
463
+ }
464
+ // 面试管理列表 URL:优先环境缓存菜单,回退 allMenus 解析(别名匹配,跨环境不写死)
465
+ async function resolveInterviewListUrl(ctx) {
466
+ const { host, appId, baseUrl, cache } = ctx;
467
+ const cachedMenu = cache.lists?.interview;
468
+ if (cachedMenu?.listId && cachedMenu.combinationId) {
469
+ return `${baseUrl}/ebdapp/view/${appId}/combination/${cachedMenu.combinationId}-${cachedMenu.listId || cachedMenu.cusMenuId}/search/${cachedMenu.pageId || cachedMenu.cusMenuId}?terminal=PC&cusMenuId=${cachedMenu.cusMenuId}`;
470
+ }
471
+ const r = await host.request({ path: `/api/cusapp/app/allMenus?isFront=1&appId=${appId}&terminalType=PC`, method: 'GET' });
472
+ const flat = [];
473
+ (function walk(ns) { for (const n of (ns || []))
474
+ if (n && typeof n === 'object') {
475
+ flat.push(n);
476
+ walk(n.children || n.childs);
477
+ } })(r?.data?.cusAppMenus || r?.data);
478
+ const node = findMenuNode(flat, ['面试管理', '面试安排列表', '面试安排'], (n) => n.combinationId && n.id && n.pageId);
479
+ if (!node)
480
+ return null;
481
+ return `${baseUrl}/ebdapp/view/${appId}/combination/${node.combinationId}-${node.id}/search/${node.pageId}?terminal=PC&cusMenuId=${node.id}`;
482
+ }
483
+ // 默认时间:与页面「选 AI面试」联动一致 —— AI=当前时间~+7天,其它=1小时。分钟精度。
484
+ function nowAndEnd(type) {
485
+ const isAI = /AI|人工智能/i.test(String(type || ''));
486
+ const d = new Date();
487
+ const f = (x) => String(x).padStart(2, '0');
488
+ const fmt = (x) => x.getFullYear() + '-' + f(x.getMonth() + 1) + '-' + f(x.getDate()) + ' ' + f(x.getHours()) + ':' + f(x.getMinutes());
489
+ const s = fmt(d);
490
+ const e = fmt(new Date(d.getTime() + (isAI ? 7 * 24 * 3600 * 1000 : 3600 * 1000)));
491
+ return { s, e, isAI };
492
+ }
493
+ function shiftTime(str, addMs) {
494
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})$/.exec(String(str || '').trim());
495
+ if (!m)
496
+ return null;
497
+ const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]));
498
+ if (Number.isNaN(d.getTime()))
499
+ return null;
500
+ const t = new Date(d.getTime() + addMs);
501
+ const f = (x) => String(x).padStart(2, '0');
502
+ return t.getFullYear() + '-' + f(t.getMonth() + 1) + '-' + f(t.getDate()) + ' ' + f(t.getHours()) + ':' + f(t.getMinutes());
503
+ }
504
+ function makeCtx(env) {
505
+ return (async () => {
506
+ const host = env.host;
507
+ const session = await host.getSession();
508
+ const cache = await env.recruitContext();
509
+ return { host, baseUrl: session.baseUrl.replace(/\/+$/, ''), appId: cache.appId, cache };
510
+ })();
511
+ }
512
+ async function locateTalentId(ctx, args) {
513
+ if (args.talentId)
514
+ return String(args.talentId);
515
+ if (!args.mobile && !args.name) {
516
+ throw rsValidationError('talent_required', '请提供 talentId,或 mobile/name 之一用于定位候选人');
517
+ }
518
+ const cands = await locateTalent(ctx, { mobile: args.mobile, name: args.name });
519
+ if (!cands.length) {
520
+ throw rsFail('人才库未找到' + (args.mobile ? '手机号为 ' + args.mobile : '姓名为 ' + args.name) + '的候选人(检索条件:人才库列表,关键词 like)', { located: false, candidates: [] });
521
+ }
522
+ if (cands.length > 1) {
523
+ throw rsFail('匹配到多条同' + (args.mobile ? '手机号' : '姓名') + '候选人,请用户选择后再安排面试(绝不自动挑一个)', { located: false, needChoice: true, candidates: cands });
524
+ }
525
+ return String(cands[0].talentId);
526
+ }
527
+ const baseInput = {
528
+ talentId: { type: 'string', description: '人才 id(与 mobile/name 三选一)' },
529
+ mobile: { type: 'string', pattern: '^\\d{6,20}$', description: '手机号定位(唯一命中自动采用)' },
530
+ name: { type: 'string', description: '姓名定位(多条会要求用户选择)' },
531
+ positionId: { type: 'string', description: '应聘职位 id(简历池人才必填)' },
532
+ interviewer: { type: 'string', description: '面试官姓名(缺省=当前登录用户)' },
533
+ type: { type: 'string', description: '面试方式(现场面试/视频面试/电话面试/AI面试;含 AI 时邮箱与方案必填)' },
534
+ session: { type: 'string', description: '面试轮次(初试/复试/…)' },
535
+ start: { type: 'string', pattern: '^\\d{4}-\\d{1,2}-\\d{1,2}[ T]\\d{1,2}:\\d{2}$', description: '开始时间 YYYY-MM-DD HH:mm(缺省=当前时间)' },
536
+ end: { type: 'string', pattern: '^\\d{4}-\\d{1,2}-\\d{1,2}[ T]\\d{1,2}:\\d{2}$', description: '结束时间(缺省=AI +7天 / 其它 +1小时)' },
537
+ email: { type: 'string', description: '候选人邮箱(AI 面试必填;补录值优先于人才资料)' },
538
+ aiPlan: { type: 'string', description: 'AI 面试方案名或 id(AI 面试必填;不传则 prepare 列出候选)' },
539
+ };
540
+ async function prepare(args, env) {
541
+ const ctx = await makeCtx(env);
542
+ const talentId = await locateTalentId(ctx, args);
543
+ const auto = nowAndEnd(args.type || '现场面试');
544
+ const startTime = args.start || auto.s;
545
+ const endTime = args.end || (args.start ? (shiftTime(String(args.start), auto.isAI ? 7 * 24 * 3600 * 1000 : 3600 * 1000) || auto.e) : auto.e);
546
+ const plan = await buildPlan({
547
+ ctx, talentId,
548
+ positionId: args.positionId ? String(args.positionId) : undefined,
549
+ interviewer: args.interviewer ? String(args.interviewer) : undefined,
550
+ type: String(args.type || '现场面试'), session: String(args.session || '初试'),
551
+ start: String(startTime), end: String(endTime),
552
+ email: args.email ? String(args.email) : undefined,
553
+ mobile: args.mobile ? String(args.mobile) : undefined,
554
+ aiPlan: args.aiPlan ? String(args.aiPlan) : undefined,
555
+ });
556
+ plan.host = ctx.host;
557
+ const aiPlanCandidates = plan.isAI
558
+ ? (plan.aiPlanOptions.items || []).map((x) => ({ id: x.id, name: x.name, position: x.position }))
559
+ : [];
560
+ // 注:不再在 prepare 阶段解析「保存」按钮动作链——该信息属内部执行明细,
561
+ // 输出纪律明确禁止展示(openai.yaml:不展示 saveActions 等);apply 时由 execSaveActions 自解析。
562
+ // 省 1 个 RTT(getFormButtons)。
563
+ const requiredHint = await resolveRequiredHint(plan, ctx).catch(() => null);
564
+ if (requiredHint?.missing?.length)
565
+ requiredHint.missingLabels = requiredHint.missing.map(labelOf);
566
+ // 页面默认方案(defaultDetails 里 digital_id 的值)——标到候选上,方便用户直接说"用默认"
567
+ const defPlan = plan.isAI ? requiredHint?.defaultValues?.digital_id : null;
568
+ if (defPlan?.optionId) {
569
+ for (const x of aiPlanCandidates)
570
+ if (String(x.id) === String(defPlan.optionId))
571
+ x.isDefault = true;
572
+ }
573
+ const defaultAiPlan = defPlan?.optionId ? { id: defPlan.optionId, name: defPlan.content } : undefined;
574
+ const willSubmit = { formId: plan.formId, objId: plan.objId, dataDetails: plan.dataDetails };
575
+ const { createHash } = await import('node:crypto');
576
+ const fp = createHash('sha256').update(JSON.stringify(willSubmit)).digest('hex');
577
+ const continuation = await env.continuation.issue({ operation: 'recruit-stage.interview.arrange.apply', fingerprint: fp });
578
+ return rsSuccess('recruit-stage.interview.arrange.prepare', {
579
+ mode: 'prepare', ...plan.summary,
580
+ needAiPlan: plan.isAI && !plan.aiPlanPicked,
581
+ aiPlans: plan.isAI ? aiPlanCandidates : undefined,
582
+ defaultAiPlan,
583
+ aiPlanHint: plan.isAI && !plan.aiPlanPicked
584
+ ? 'AI面试方案为必填:请把 aiPlans 列给用户选择(isDefault=true 为页面默认方案),确认后带 aiPlan 重跑 apply'
585
+ : undefined,
586
+ requiredHint,
587
+ willSubmit,
588
+ }, {
589
+ continuation,
590
+ fingerprint: fp,
591
+ confirmHint: '用户明确确认后,携带 confirm:true 与本 continuation 调用 recruit-stage.interview.arrange.apply(AI 面试需先选定 aiPlan)',
592
+ });
593
+ }
594
+ async function apply(args, env) {
595
+ if (args.confirm !== true)
596
+ throw rsConfirmationError('recruit-stage.interview.arrange.apply', { hint: '写操作必须显式 confirm:true' });
597
+ if (!args.continuation)
598
+ throw rsValidationError('continuation_required', '缺少 continuation(来自 prepare 的 meta)');
599
+ const payload = await env.continuation.verify(String(args.continuation), 'recruit-stage.interview.arrange.apply');
600
+ const ctx = await makeCtx(env);
601
+ const talentId = await locateTalentId(ctx, args);
602
+ const auto = nowAndEnd(args.type || '现场面试');
603
+ const startTime = args.start || auto.s;
604
+ const endTime = args.end || (args.start ? (shiftTime(String(args.start), auto.isAI ? 7 * 24 * 3600 * 1000 : 3600 * 1000) || auto.e) : auto.e);
605
+ const plan = await buildPlan({
606
+ ctx, talentId,
607
+ positionId: args.positionId ? String(args.positionId) : undefined,
608
+ interviewer: args.interviewer ? String(args.interviewer) : undefined,
609
+ type: String(args.type || '现场面试'), session: String(args.session || '初试'),
610
+ start: String(startTime), end: String(endTime),
611
+ email: args.email ? String(args.email) : undefined,
612
+ mobile: args.mobile ? String(args.mobile) : undefined,
613
+ aiPlan: args.aiPlan ? String(args.aiPlan) : undefined,
614
+ });
615
+ plan.host = ctx.host;
616
+ // AI 面试方案必填:未选定直接阻断,不落库、不执行动作链
617
+ if (plan.isAI && !plan.aiPlanPicked) {
618
+ throw rsFail('AI面试必须选择「AI面试方案」(必填),当前未指定。可选方案:'
619
+ + (plan.aiPlanOptions.items?.length ? planBrief(plan.aiPlanOptions.items) : '(无)')
620
+ + '。请让用户选择后带 aiPlan 重试。');
621
+ }
622
+ const willSubmit = { formId: plan.formId, objId: plan.objId, dataDetails: plan.dataDetails };
623
+ const { createHash } = await import('node:crypto');
624
+ if (payload.fingerprint !== createHash('sha256').update(JSON.stringify(willSubmit)).digest('hex')) {
625
+ throw rsValidationError('fingerprint_mismatch', '待提交内容与 prepare 确认摘要不一致,请重新 prepare 确认后再 apply');
626
+ }
627
+ const formDataId = await saveFormData(ctx.host, {
628
+ appId: plan.appId, formId: plan.formId, objId: plan.objId,
629
+ name: '面试管理-' + plan.cand.name + ' ' + plan.summary.time, dataDetails: plan.dataDetails,
630
+ });
631
+ const actionsRun = await execSaveActions(plan, formDataId);
632
+ const detailUrl = buildCardUrl(ctx.baseUrl, plan.objId, formDataId);
633
+ const listLocateUrl = buildListLocateUrl(ctx.baseUrl, ctx.appId, (ctx.cache.lists?.interview?.listId) || '', plan.objId, formDataId);
634
+ return rsSuccess('recruit-stage.interview.arrange.apply', {
635
+ mode: 'apply', result: 'success', interviewFormId: formDataId,
636
+ interviewCardUrl: detailUrl,
637
+ viewLink: detailUrl ? `[查看 ${plan.cand.name || ''} 的面试安排详情](${detailUrl})` : null,
638
+ listLocateLink: listLocateUrl ? `[打开面试管理列表(已定位本次面试安排)](${listLocateUrl})` : null,
639
+ agentNote: '产物 = 两个 Markdown 链接(平台实证可点击):① viewLink 面试安排详情页 ② listLocateLink 面试管理列表并右侧滑出本次面试安排;按顺序原样放回答正文,缺哪个只给另一个、不输出半截。不要包 browsertool / <artifact> 标签。',
640
+ aiPlan: plan.isAI && plan.aiPlanPicked ? { id: plan.aiPlanPicked.id, name: plan.aiPlanPicked.name } : undefined,
641
+ saveActions: actionsRun.executed,
642
+ warnings: actionsRun.warnings,
643
+ assigned: actionsRun.assigned || {},
644
+ });
645
+ }
646
+ async function prefill(args, env) {
647
+ const ctx = await makeCtx(env);
648
+ const talentId = await locateTalentId(ctx, args);
649
+ const objId = objIdOf(ctx.cache, 'interview_plan');
650
+ const formId = formIdOf(ctx.cache, 'interview_plan');
651
+ const fields = ctx.cache.fields[objId] || {};
652
+ const typeKey = String(args.type || 'AI面试');
653
+ const typeFid = fields.interview_type;
654
+ if (!typeFid)
655
+ throw rsFail('面试计划缺少字段 interview_type(ds/fields 解析不到)');
656
+ const typeMap = cachedDsMap(ctx.cache, objId, 'interview_type') || await getDsOptions(ctx.host, objId, typeFid, ctx.appId);
657
+ const typeId = typeMap[typeKey];
658
+ if (!typeId)
659
+ throw rsFail('面试方式「' + typeKey + '」不在枚举中(' + Object.keys(typeMap).join('/') + ')');
660
+ const cardUrl = resolveTalentCardUrl(ctx, talentId);
661
+ const listUrl = await resolveInterviewListUrl(ctx).catch(() => null);
662
+ const jsExpr = `(() => { const W = window.WeFormSDK; const i = W && W.getWeFormInstance('ebuilderform', '${formId}');`
663
+ + ` if (!i) return 'no-form-instance(请先在人才卡片点「安排面试」打开面试安排表单)';`
664
+ + ` try { i.changeFieldValue('field${typeFid}', { value: '${typeId}' }); return 'ok 面试方式=${typeKey}'; }`
665
+ + ` catch (e) { return 'err:' + e.message; } })()`;
666
+ return rsSuccess('recruit-stage.interview.arrange.prefill', {
667
+ mode: 'prefill',
668
+ action: '安排面试(打开表单 + 选面试方式,人工核对保存;不落库)',
669
+ talentId,
670
+ interviewFormId: formId,
671
+ interviewType: typeKey,
672
+ interviewTypeOptionId: typeId,
673
+ talentCardUrl: cardUrl,
674
+ interviewListUrl: listUrl,
675
+ jsExpr,
676
+ steps: [
677
+ '1) 浏览器打开 talentCardUrl(需已登录该环境)→ 进入该候选人卡片',
678
+ '2) 点卡片上的「安排面试」→ 打开面试安排表单(主题/候选人/职位由表单默认值自动带入)',
679
+ '3) 在页面上下文执行 jsExpr → 选中「面试方式=' + typeKey + '」并触发联动(AI面试方案/面试官/开始结束时间自动带入)',
680
+ '4) 由 HR 核对(可按需调整)后点「保存」完成面试安排;本模式不写任何数据',
681
+ '5)(可选)打开 interviewListUrl 查看面试管理列表',
682
+ ],
683
+ });
684
+ }
685
+ export const interviewArrangeOperations = [
686
+ {
687
+ name: 'recruit-stage.interview.arrange.prepare',
688
+ resource: 'recruit.interview',
689
+ method: 'prepare',
690
+ action: 'interview.arrange.prepare',
691
+ summary: '安排面试确认摘要:定位候选人(talentId/手机号/姓名三选一)、解析面试方式/轮次枚举、AI 面试时列候选方案(绝不自动取第一条)与联动必填核对,不写入',
692
+ capability: 'recruit.interview.arrange',
693
+ risk: 'write',
694
+ requiresConfirmation: true,
695
+ inputSchema: schemaOf(baseInput, {}),
696
+ handler: prepare,
697
+ },
698
+ {
699
+ name: 'recruit-stage.interview.arrange.apply',
700
+ resource: 'recruit.interview',
701
+ method: 'apply',
702
+ action: 'interview.arrange.apply',
703
+ summary: '用户确认后安排面试:saveFormData → 按「保存」按钮动作链执行(ESB 日程/分配面试/AI 面试提醒),与页面点保存等价;返回双链接',
704
+ capability: 'recruit.interview.arrange',
705
+ risk: 'write',
706
+ requiresConfirmation: true,
707
+ inputSchema: schemaOf({ ...baseInput, confirm: { type: 'boolean' }, continuation: { type: 'string' } }, {}),
708
+ handler: apply,
709
+ },
710
+ {
711
+ name: 'recruit-stage.interview.arrange.prefill',
712
+ resource: 'recruit.interview',
713
+ method: 'prefill',
714
+ action: 'interview.arrange.prefill',
715
+ summary: '预填模式(人工保存):输出人才卡片 URL + 面试管理列表 URL + 选面试方式的 jsExpr,触发表单联动后由 HR 核对保存,不落库',
716
+ capability: 'recruit.interview.arrange',
717
+ risk: 'read',
718
+ inputSchema: schemaOf(baseInput, {}),
719
+ handler: prefill,
720
+ },
721
+ ];
722
+ void randomUUID;