@xiaoyuanhao/yunzhi-cli 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/lib/arguments.cjs +7 -4
  2. package/lib/attendance-clock-entry.cjs +31 -0
  3. package/lib/attendance-configuration-entry.cjs +35 -0
  4. package/lib/attendance-correction-applications.cjs +64 -0
  5. package/lib/attendance-correction-approval.cjs +135 -0
  6. package/lib/attendance-correction-start.cjs +47 -0
  7. package/lib/attendance-leave-enums.cjs +193 -0
  8. package/lib/attendance-leave-ids.cjs +15 -0
  9. package/lib/attendance-leave-list-filters.cjs +21 -0
  10. package/lib/attendance-leave-route.cjs +20 -0
  11. package/lib/attendance-operation-result.cjs +97 -0
  12. package/lib/attendance-report-inbox.cjs +42 -0
  13. package/lib/attendance-report-remarks.cjs +62 -0
  14. package/lib/attendance-statistics.cjs +49 -0
  15. package/lib/attendance.cjs +404 -0
  16. package/lib/auth.cjs +1 -1
  17. package/lib/business-schemas.cjs +3 -1
  18. package/lib/cli.cjs +294 -1
  19. package/lib/command-guidance.cjs +1162 -1
  20. package/lib/command-reference.cjs +54 -0
  21. package/lib/contact-help.cjs +43 -0
  22. package/lib/contact-mobile.cjs +14 -0
  23. package/lib/contact.cjs +85 -0
  24. package/lib/correction-help.cjs +57 -0
  25. package/lib/dormitory-replenishment.cjs +63 -0
  26. package/lib/dormitory-report-detail.cjs +76 -0
  27. package/lib/dormitory-report-id.cjs +8 -0
  28. package/lib/dormitory-report-summary.cjs +66 -0
  29. package/lib/dormitory-report-view.cjs +36 -0
  30. package/lib/dormitory-rooms.cjs +41 -0
  31. package/lib/dormitory-scope.cjs +18 -0
  32. package/lib/dormitory-statistics.cjs +30 -0
  33. package/lib/download.cjs +17 -8
  34. package/lib/gateway.cjs +9 -4
  35. package/lib/leave-approval-receipt.cjs +126 -0
  36. package/lib/leave-attachment-upload.cjs +110 -0
  37. package/lib/leave-form-users.cjs +48 -0
  38. package/lib/leave-start-receipt.cjs +64 -0
  39. package/lib/leave.cjs +457 -0
  40. package/lib/replenishment-records.cjs +39 -0
  41. package/lib/result.cjs +1 -0
  42. package/lib/saved-report-summary.cjs +16 -0
  43. package/lib/school-contacts.cjs +24 -0
  44. package/lib/school-help.cjs +53 -0
  45. package/lib/school-schema.cjs +38 -0
  46. package/lib/student-application-help.cjs +76 -0
  47. package/lib/student-leave-form.cjs +108 -0
  48. package/lib/student-leave-operations.cjs +24 -0
  49. package/lib/student-leave-query.cjs +75 -0
  50. package/lib/student-leave-resumption.cjs +2 -0
  51. package/lib/student-leave-start.cjs +34 -0
  52. package/lib/student-leave.cjs +51 -0
  53. package/lib/student-query-help.cjs +57 -0
  54. package/lib/student-replenishment.cjs +64 -0
  55. package/lib/student-report-detail.cjs +39 -0
  56. package/lib/student-report-summary.cjs +30 -0
  57. package/lib/teacher-approval-help.cjs +132 -0
  58. package/lib/teacher-leave-files.cjs +112 -0
  59. package/lib/teacher-leave-form-check.cjs +185 -0
  60. package/lib/teacher-leave-query.cjs +99 -0
  61. package/lib/teacher-own-leave-help.cjs +134 -0
  62. package/lib/workflow-entry.cjs +42 -0
  63. package/package.json +1 -1
@@ -0,0 +1,30 @@
1
+ const { options, required: requireArguments } = require('./arguments.cjs');
2
+ const { required, nullable, invalid, Failure } = require('./result.cjs');
3
+ const gateway = require('./gateway.cjs');
4
+ const { reportSchema, validReport } = require('./saved-report-summary.cjs');
5
+ const counterNames = ['total_number', 'normal_in_number', 'normal_out_number', 'replenish_number', 'late_number', 'early_leave_number', 'leave_number', 'no_record_number'];
6
+ const counts = { detail_type: nullable('string'), parent_dept_id: nullable('string'), parent_dept_name: nullable('string'), dept_id: nullable('string'), dept_name: nullable('string'),
7
+ ...Object.fromEntries(counterNames.map(name => [name, required('integer')])) };
8
+ const shape = { tenant_id: required('string'), user_id: required('string'), statistics_time: nullable('integer'), total: required(counts), classes: required([counts]), report: required(reportSchema) };
9
+ function validateQuery(payload) {
10
+ requireArguments(payload, 'report_id');
11
+ if (!/^[1-9][0-9]{0,9}$/.test(payload.report_id) || Number(payload.report_id) > 2147483647) throw invalid('--report-id 必须是本人收到的有效学校或年级校门报告ID。');
12
+ }
13
+ function validate(result, payload) {
14
+ const data = result.value.data; const report = data.report;
15
+ if (!validReport(report, payload.report_id, ['2', '3']) || data.statistics_time != null && data.statistics_time < 0
16
+ || [data.total, ...data.classes].some(row => counterNames.some(name => row[name] < 0))) {
17
+ const failure = new Failure(1, 'api', 'invalid_response', '校门报告汇总返回的报告关联、统计数或时间缺失或不一致。');
18
+ if (result.value.request_id) failure.result.value.request_id = result.value.request_id; throw failure;
19
+ }
20
+ return result;
21
+ }
22
+ async function execute(args) {
23
+ const payload = options(args, ['report_id'], [], [], true); validateQuery(payload);
24
+ try { return validate(await gateway.execute('attendance-leave:read', 'student-attendance/report-summary', payload, shape), payload); }
25
+ catch (failure) { addContext(failure, payload); throw failure; }
26
+ }
27
+ function addContext(failure, payload) {
28
+ if (failure instanceof Failure) failure.result.value.error.details = { ...failure.result.value.error.details, report_id: payload.report_id };
29
+ }
30
+ module.exports = { execute, counts, counterNames };
@@ -0,0 +1,132 @@
1
+ // Help text only; command signatures, parsing, execution and result validation are unchanged.
2
+ const fieldInput = [
3
+ '最多 100 项的 JSON 数组,每项为 {"field_key":"实际字段键","value":值}。只传本次修改;省略字段或已有行保留保存值,不用默认值覆盖。',
4
+ '文本用字符串;数字用 JSON 数字或数字文本;日期 YYYY-MM-DD,日期时间 YYYY-MM-DD HH:mm:ss(Asia/Shanghai)。value 直接使用 JSON 值,不再次编码成字符串。',
5
+ '单选/下拉/搜索下拉用 option_id 字符串,多选用不重复的 option_id 数组。实际单选类选项 code=attr-radio-other 时,可用 {"option_id":"实际ID","other_text":"补充说明"};多选不支持此格式。',
6
+ '人员用 [{"tenant_id":"学校ID","user_id":"人员ID"}],部门用 [{"tenant_id":"学校ID","department_id":"部门ID"}],组织用 [{"tenant_id":"组织ID"}]。使用当前候选,单选最多一项;姓名由服务端补齐。',
7
+ '清空可编辑非必填字段:文本/日期/单选/下拉用 "",多选/人员/部门/组织/附件用 []。只读、隐藏、计算目标及 link/separator 展示字段不提交;显式填写隐藏字段会被拒绝。',
8
+ '@文件使用 UTF-8 JSON 数组,可带 BOM;支持 "@/路径/修改.json"、"@C:/Users/Teacher/Documents/修改.json"。JSON 文件及 local_files 中的相对路径均相对命令工作目录。',
9
+ ].join('\n ');
10
+ const attachmentInput = [
11
+ '新增并替换该字段全部文件(local_files 为非空路径数组):{"local_files":["./证明.pdf"]}。省略字段保留旧文件;[] 清空可选附件。所有本地文件先检查,再上传并提交同意。',
12
+ '选留旧文件并新增:{"retain_media_ids":["旧附件ID"],"local_files":["./新证明.pdf"]}。旧 ID 取详情 attachments 的 media_id,须属于当前任务、字段和行。',
13
+ '复用已确认上传结果:object_key 字符串数组;与旧文件组合时用 {"retain_media_ids":[...],"upload_keys":[...]},两数组都必填、可为空。有剩余本地文件时,可将 upload_keys 与 local_files、retain_media_ids 组合。展示 URL 和文件组 ID 不能作为上传或选留身份。',
14
+ '只支持当前可编辑且 attachment_upload_supported=true 的字段。单文件不超过 200 MiB;图片每字段每行新旧合计最多 10 张,执行学校 minSize/maxSize(MiB,含边界);最大值未配置或为 0 时为 50 MiB。旧文件大小等必要信息不完整时服务端会拒绝选留。',
15
+ ].join('\n ');
16
+ const rowInput = [
17
+ '父字段 value 为行操作数组。修改已有行:{"group_code":"详情中的行标识","fields":[{"field_key":"子字段键","value":"内容"}]}。',
18
+ '新增行:{"action":"add","fields":[...]},行标识由服务端生成;删除行:{"action":"remove","group_code":"行标识"},不带 fields。子附件写在该行 fields 中。',
19
+ '父表和修改的子字段须可编辑;父表可编辑时可删除整行,行内可以包含只读字段。同一行不能重复操作,最终至少保留一行;支持同次新增替代行再删除旧行。',
20
+ '新增行不复制旧行答案或只读字段,省略的新行可编辑附件按默认值及必填规则处理。只支持单层子表;行操作与同意一起提交或回滚。',
21
+ ].join('\n ');
22
+ const decisionResult = [
23
+ 'execution_status:COMPLETED 当前节点处理完成,NOT_EXECUTED 未执行审批,UNCONFIRMED 结果不确定。application_status:processing 整单处理中,completed 流程已完成;节点完成不等于整单通过或考勤更新。',
24
+ 'post_processing_status:SUBMITTED 后处理已提交,FAILED 后处理失败,NOT_STARTED 未开始;后处理失败不撤销已确认的审批。message 为具体说明;task_id/application_id/request_id 用于查询定位。',
25
+ ].join('\n ');
26
+ const fileResult = [
27
+ 'attachment_uploads:本次已确认上传的 object_key/file_name/size_bytes/file_transferred;field_key、input_parent_field_key、input_row_index 定位本次输入,行序号从 0 开始并包含增改删操作。后续失败时已成功上传的回执仍保留。',
28
+ 'attachment_preparations:文件准备结果,retained 是选留的旧来源,copies 是新上传文件的副本;group_id/group_execution_status/verified 描述该步骤,不能代替审批结果。group_codes 为实际行路径,须与字段及输入位置一起读取。retained.media_id 不是新文件组的文件 ID。',
29
+ 'attachment_preparations_complete=true 才表示准备回执完整;缺失或 false 表示有未知部分,attachment_preparation_error 说明问题。NOT_EXECUTED 不保证没有文件产物。审批或文件结果不确定时不自动重试、重传或删除;确认未审批且上传成功后,才可复用相应上传键。',
30
+ ].join('\n ');
31
+ const formOutput = [
32
+ 'field_key/type/label/description:提交键、类型和学校说明;value/display_value 为实际保存值和展示文本。default_value 为配置默认值,不覆盖保存值;null 表示未知或未提供。',
33
+ 'editable/required:只填写当前显示且可编辑的非计算字段,必填仅在显示时生效。已办和抄送字段只读。groups[].fields 为已有行,row_template 为新增行定义;支持读取嵌套字段不代表支持嵌套写入。',
34
+ 'options:option_id 用于提交,code 用于联动,default_selected 为配置默认选择。candidate_users/candidate_departments/candidate_organizations 为候选;固定范围不能用自由搜索绕过,来源失败不能当空候选。',
35
+ 'visibility:scope=form/row;any_of 中每个 field_key 与自己的 option_code 配对,任一当前可见控制题命中即显示。requires_visible_controller 要求控制题可见;隐藏题旧值不触发下游。子表逐行判断,不跨行;循环依赖报错。',
36
+ 'validation_rules/number_range/format_rules:必填、格式、查重、数值边界等规则。minimum/maximum 为精确十进制字符串,null 为无界,inclusive=true 含端点;decimal_places/digits/rounding 由服务端处理。规则列表 null 为未知、[] 为未列规则,都不取消服务端校验。',
37
+ 'calculation/calculation_links:学校配置的计算及关联,结果由服务端生成;只填写来源,不执行配置表达式或直接填写计算目标。只读目标保留原值。选项或人员候选由函数生成,不表示该选择器只读。',
38
+ 'attachment_upload_supported:是否支持该字段上传,仍须可编辑。images 历史值可为 [{url,width?,height?}],仅展示;attachments 为空不说明这些图片不存在。',
39
+ ].join('\n ');
40
+
41
+ function render(command, usage) {
42
+ const [space, action] = command.split(' ');
43
+ const correction = space === 'attendance' && action.startsWith('correction-');
44
+ if (!['leave', 'student-leave'].includes(space) && !correction) return null;
45
+ const resumed = action.startsWith('resumption-');
46
+ const kind = correction ? action.slice('correction-'.length) : resumed ? action.slice('resumption-'.length) : action;
47
+ if (!['approvals', 'approval', 'approve', 'reject', 'approval-form-people-search', 'approval-entry'].includes(kind)) return null;
48
+ const subject = correction ? '补卡' : resumed ? '销假' : '请假';
49
+ const actor = space === 'student-leave' ? '学生' : '教师';
50
+ const prefix = correction ? 'correction-' : resumed ? 'resumption-' : '';
51
+ const task = '--task-id 必填;审批列表或详情返回的 task_id。';
52
+ const category = '--category 必填;pending 待办、handled 本人实际已办、copied 收到的抄送;无默认值。';
53
+ const notice = '--notice-id copied 必填,取同一记录的 notice_id;其他类别不得传入。';
54
+ let purpose, parameters, examples, result, details = '', inputs = '', limits;
55
+ if (kind === 'approvals') {
56
+ purpose = correction ? '查询本人收到的补卡审批任务:待办、已办或抄送。' : `查询当前老师的${actor}${subject}待办、已办或抄送。`;
57
+ parameters = [category,
58
+ `--applicant-id / --applicant-name 可选;申请人(实际提交申请的人)的 ID 精确匹配、姓名按包含匹配;值为 1–100 个字符。${space === 'student-leave' ? '学生申请的申请人可能是家长或老师。' : ''}`,
59
+ ...(correction ? [] : ['--subject-id / --subject-name 可选;实际请假对象的 ID 精确匹配、姓名按包含匹配;值为 1–100 个字符,同名时用 ID 缩小范围。']),
60
+ `--start-date + --end-date 可选;${correction ? '补卡日期' : resumed ? '销假生效至原请假结束的时段' : '请假时段'},YYYY-MM-DD,须成对提供,含首尾日期${correction ? '' : ',按时段重叠匹配'}。`,
61
+ '--submitted-start-date + --submitted-end-date 可选;申请最近一次提交日期,YYYY-MM-DD,含首尾日期;不是审批处理日期。',
62
+ '--page 可选;默认 1,范围 1..2147483647。', '--page-size 可选;默认 20,范围 1..50。'];
63
+ examples = [`yunzhi-cli ${command} --category pending`];
64
+ result = [
65
+ 'items 为任务列表,page/page_size/total/total_pages 为分页;category/tenant_id/reviewer_id 为查询范围。',
66
+ correction ? '每项包含 task_id、application_id、标题、提交时间、申请人 applicant_id 和实际处理人 handler_id。' : `每项包含 task_id、application_id、标题、提交时间、申请人 applicant_id、实际处理人 handler_id 和请假人员 leave_subjects。${resumed ? 'application_id 指销假申请,leave_subjects 关联原请假。' : ''}关联为空时不能推断申请人就是请假人或申请未提交。`,
67
+ 'task_status:pending 待处理、transferred 转交、completed 已处理、returned 退回、withdrawn 撤回;抄送另有 notice_id/notice_read。未知信息保留 null。',
68
+ ].join('\n ');
69
+ limits = `已办包含退回、转交,不等于同意;抄送查询不标记已读。人员与两组日期条件可组合,按 AND 筛选后分页;日期按北京时间自然日。未传筛选项不限制该项。完整结论仍需覆盖匹配结果的所有分页。${correction ? '' : '筛选请假对象或业务时段时读取可见工作流表单,不等待考勤侧记录同步;必要字段不可读或列表发生变化会报错,不返回部分筛选结果。'}`;
70
+ } else if (kind === 'approval') {
71
+ purpose = `读取${actor}${subject}审批详情、字段要求、进度及附件链接。`;
72
+ parameters = [category, task, notice];
73
+ examples = [`yunzhi-cli ${command} --category pending --task-id <task_id>`, `yunzhi-cli ${command} --category copied --task-id <task_id> --notice-id <notice_id>`];
74
+ result = [
75
+ 'task 为任务及申请关联,fields 为可见表单,progress 为审批进度,节点 status 使用 pending/transferred/completed/returned/withdrawn,node_type 使用 start/normal/end;顶层 status 为 processing/completed 等流程状态,status_description 为原业务说明。',
76
+ 'can_approve/can_reject 为当前是否允许同意/退回;opinion_visible/opinion_required 为意见要求;reject_return_targets 中 start 为发起节点、previous 为上一节点。执行时服务端重新检查办理资格。',
77
+ 'attachments:field_key/field_label/group_codes 定位字段和行,media_id 为选留旧文件的标识,file_name/file_type/size_bytes 为文件信息,download_url/expires_at 为授权 HTTPS 链接和到期毫秒时间戳。直接使用宿主文件工具读取,不附加业务认证头;过期后重查详情。',
78
+ ].join('\n ');
79
+ details = '\n表单字段说明(修改时按需阅读):\n ' + formOutput + '\n';
80
+ result += '\n 仅 pending 详情返回 form_check,描述当前保存内容的本地检查结果;同意前 CLI 按本次修改重新检查,服务端最终校验。';
81
+ limits = `已办和抄送不能审批,读取抄送不标记已读。${correction ? '申请完成不代表补卡记录或考勤已更新。' : '申请人与实际请假人可能不同,关联缺失不代表提交失败。'}字段输入格式见 ${space} ${prefix}approve --help。`;
82
+ } else if (kind === 'approve') {
83
+ purpose = `同意${actor}${subject}的当前待办,可同时修改字段和上传附件。`;
84
+ parameters = [task, '--opinion 可选;学校节点要求必填时须提供。' + (correction ? '最多 200 字。' : ''), '--field-values 可选;修改字段的 JSON 数组或 @文件路径;省略保留已保存内容,学校必填项仍须满足。'];
85
+ examples = [`yunzhi-cli ${command} --task-id <task_id> --opinion "同意"`, `yunzhi-cli ${command} --task-id <task_id> --field-values "@修改.json"`];
86
+ result = correction ? decisionResult.replace('后处理失败不撤销已确认的审批。', '审批已成功、后续处理失败时,分别报告。') : decisionResult;
87
+ details = '\n字段输入(需要修改时阅读):\n ' + fieldInput
88
+ + '\n\n附件输入(作为字段 value):\n ' + attachmentInput
89
+ + '\n\n修改.json 示例(字段键须替换为当前任务的实际字段键):\n' + JSON.stringify([
90
+ { field_key: '实际说明字段键', value: '已核实' }, { field_key: '实际附件字段键', value: { local_files: ['./证明.pdf'] } },
91
+ ], null, 2) + '\n\n子表输入(需要改行时阅读):\n ' + rowInput
92
+ + '\n\n附件结果(有文件操作时阅读):\n ' + fileResult + '\n';
93
+ limits = '只用于当前待办;提交时按保存值和本次修改检查权限、显示联动、必填及计算,包括未修改行的可见必填项。CLI 在上传和审批前检查当前填写内容;计算由服务端完成,隐藏或只读历史值保持服务端控制。上传成功不代表同意成功,审批结果不确定时不自动重复提交。';
94
+ if (correction) {
95
+ inputs = '\n字段输入(需要修改时阅读):\n ' + fieldInput
96
+ + '\n\n附件输入(需要修改时阅读,作为字段 value):\n ' + attachmentInput
97
+ + '\n\n修改.json 示例(替换为当前任务的实际字段键):\n'
98
+ + '[{"field_key":"实际说明字段键","value":"已核实"},\n {"field_key":"实际附件字段键","value":{"local_files":["./证明.pdf"]}}]\n'
99
+ + '\n子表输入(需要改行时阅读):\n ' + rowInput + '\n';
100
+ details = '\n附件结果(有文件操作时阅读):\n ' + fileResult + '\n';
101
+ limits = '只办理当前待办。服务端按保存值和本次修改检查权限、联动、必填及计算,包括未修改行的可见必填项;CLI 先读取当前待办,检查联动、必填及审批意见,通过后才上传附件和提交审批。计算和隐藏、只读历史值由服务端处理。上传成功不代表审批成功,结果不确定时先核实,不重复提交。';
102
+ }
103
+ } else if (kind === 'reject') {
104
+ purpose = `将${actor}${subject}待办退回允许的节点,供补充或重新办理。`;
105
+ parameters = [task, '--return-to 必填;start 发起节点、previous 上一节点,仅使用详情 reject_return_targets 允许的值。', '--opinion 可选;学校节点要求必填时须提供。' + (correction ? '最多 200 字。' : '')];
106
+ examples = [`yunzhi-cli ${command} --task-id <task_id> --return-to start --opinion "请补充材料"`];
107
+ result = 'action=RETURN;execution_status 为 COMPLETED(当前退回完成)、NOT_EXECUTED(未执行)或 UNCONFIRMED(不确定)。post_processing_status 为 SUBMITTED(已提交后处理)/FAILED(后处理失败)/NOT_STARTED(未开始);message 说明原因,task_id/application_id/request_id 用于定位。';
108
+ limits = '这是退回流程,不是终止整单的拒绝。不接受字段或附件修改;结果不确定时不重复提交。后处理失败不撤销已确认的退回。';
109
+ } else if (kind === 'approval-form-people-search') {
110
+ purpose = `填写${actor}${subject}审批表单中的人员字段时,按姓名查找可选人员。`;
111
+ parameters = [task, '--field-key 必填;当前可编辑且允许自由搜索的人员字段,子表父字段也须可编辑。', '--keyword 必填;非空姓名线索。'];
112
+ examples = [`yunzhi-cli ${command} --task-id <task_id> --field-key <field_key> --keyword 李`];
113
+ result = '返回 task_id/application_id/form_id/field_key 及 candidates;未知部门为 null。选人输入使用 candidates[].tenant_id/user_id,姓名由服务端补齐。exhaustive=false,最多 10 人,无分页,不代表完整名单。';
114
+ limits = '仅当前可办理待办;固定候选范围使用详情候选。搜索不保存字段或审批,正式同意时重新校验所选人员范围。';
115
+ } else {
116
+ purpose = `获取${actor}${subject}待办在原微信客户端的办理入口。`;
117
+ parameters = [task];
118
+ examples = [`yunzhi-cli ${command} --task-id <task_id>`];
119
+ result = correction ? 'url 为完整可信入口,kind=APPROVAL_CONTINUATION;task_id/application_id 标明补卡审批对象。' : 'url 为完整可信入口,kind=APPROVAL_CONTINUATION;task_id/application_id/program_code/purpose 标明办理对象。';
120
+ limits = '用于真实签名、定位等必须由老师完成的步骤。提供返回的链接,并说明需在手机微信中办理;电脑端用户转到手机微信的校园号继续。具体菜单路径仅使用已核实信息,无需探测用户设备。仅当前可办理待办;入口不会保存或传递 AI 已填答案,也不执行审批。老师核对页面内容后办理;入口不可用时说明限制,不自行拼接地址。';
121
+ }
122
+ const scope = kind === 'approve' ? 'attendance-leave:read + attendance-leave:approve'
123
+ : 'attendance-leave' + (['approve', 'reject'].includes(kind) ? ':approve' : ':read');
124
+ if (correction) {
125
+ if (kind === 'reject') limits = '只退回流程,不终止整单;不接受字段或附件修改。结果不确定时不重复提交;退回已成功、后续处理失败时,分别报告。';
126
+ return `${purpose}\n\n用法:yunzhi-cli ${command} ${usage}\n参数:\n ${parameters.map(text => text.replace(';无默认值。', '。')).join('\n ')}\n\n常用示例:\n ${examples.join('\n ')}\n${inputs}\n返回:\n ${result}\n${details}\n${['approve', 'reject'].includes(kind) ? '校验与失败处理' : '注意'}:\n ${limits}\n 所需权限:${scope}。\n`;
127
+ }
128
+ if (space === 'student-leave') result += '\n application_id 引用对应请假或销假的统一 id;leave_subjects[].leave_id 引用原请假。审批仍以 task_id 定位当前节点。';
129
+ return `${purpose}\n\n用法:yunzhi-cli ${command} ${usage}\n参数:\n ${parameters.join('\n ')}\n 参数不可重复传入;必填参数无默认值。\n\n常用示例:\n ${examples.join('\n ')}\n\n返回:\n ${result}\n\n限制:\n ${limits}\n 所需权限:${scope};服务端检查当前学校、老师和任务资格。\n默认输出 JSON;失败原因和处理提示见 error.message/error.hint。\n${details}`;
130
+ }
131
+
132
+ module.exports = { render };
@@ -0,0 +1,112 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const { invalid, Failure, required, nullable, fields } = require('./result.cjs');
4
+
5
+ // Local paths never reach the business service. Only the prepared object keys do.
6
+ async function submit(command, payload, send, namespace = 'leave') {
7
+ const approval = ['approve', 'resumption-approve', 'correction-approve'].includes(command);
8
+ const pending = [], uploaded = [];
9
+ function collect(values, parent = null, rowIndex = null) {
10
+ for (const field of values || []) {
11
+ const value = field.value;
12
+ if (value && !Array.isArray(value) && typeof value === 'object' && Object.hasOwn(value, 'local_files')) {
13
+ const retaining = approval && Object.hasOwn(value, 'retain_media_ids');
14
+ const reusing = approval && Object.hasOwn(value, 'upload_keys');
15
+ if (Object.keys(value).some(key => key !== 'local_files' && !(approval && ['retain_media_ids', 'upload_keys'].includes(key)))
16
+ || retaining && (!Array.isArray(value.retain_media_ids) || new Set(value.retain_media_ids).size !== value.retain_media_ids.length
17
+ || value.retain_media_ids.some(id => typeof id !== 'string' || !id.trim()))
18
+ || reusing && (!Array.isArray(value.upload_keys) || new Set(value.upload_keys).size !== value.upload_keys.length
19
+ || value.upload_keys.some(key => typeof key !== 'string' || !key.trim()))
20
+ || !Array.isArray(value.local_files) || !value.local_files.length
21
+ || value.local_files.some(file => typeof file !== 'string' || !file.trim()))
22
+ throw invalid(approval ? '审批附件使用非空 local_files 路径数组,可附加不重复的 retain_media_ids 或 upload_keys 数组,不能混入其他属性。'
23
+ : '附件字段 value 使用 {"local_files":["本地文件路径"]},不能混入其他属性。');
24
+ pending.push({ field, paths: value.local_files, parent, rowIndex, retained: retaining ? value.retain_media_ids : null,
25
+ reused: reusing ? value.upload_keys : [] });
26
+ } else if (Array.isArray(value)) {
27
+ value.forEach((row, index) => {
28
+ if (row && typeof row === 'object' && Array.isArray(row.fields)) {
29
+ if (parent !== null) throw invalid('教师请假和销假附件不支持嵌套子表。');
30
+ if (approval && (Object.keys(row).length !== 2 || !(typeof row.group_code === 'string' && row.group_code.trim() && !Object.hasOwn(row, 'action')
31
+ || row.action === 'add' && !Object.hasOwn(row, 'group_code'))))
32
+ throw invalid('审批附件所在行须为已有行修改或新增行,不能混入删除行操作。');
33
+ collect(row.fields, field.field_key, index);
34
+ }
35
+ });
36
+ }
37
+ }
38
+ }
39
+ collect(payload.field_values);
40
+ // Catch missing or invalid local files before uploading any part of this request.
41
+ for (const entry of pending) for (const file of entry.paths) {
42
+ const name = path.basename(file);
43
+ if (!name || name.length > 255 || /[\x00-\x1f\x7f/\\]/.test(name) || ['.', '..'].includes(name))
44
+ throw invalid('附件文件名无效。');
45
+ let info;
46
+ try { info = await fs.stat(file); await fs.access(file, fs.constants.R_OK); }
47
+ catch { throw invalid('附件文件不可读取,上传尚未开始:' + file); }
48
+ if (!info.isFile() || info.size > 200 * 1024 * 1024)
49
+ throw invalid('附件必须为不超过 200 MiB 的普通文件:' + file);
50
+ }
51
+ try {
52
+ for (const entry of pending) {
53
+ const keys = [...entry.reused];
54
+ entry.field.value = entry.retained === null ? keys : { retain_media_ids: entry.retained, upload_keys: keys };
55
+ for (const file of entry.paths) {
56
+ const args = [...(approval ? ['--task-id', payload.task_id] : namespace === 'attendance' ? ['--date', payload.date, '--result-id', payload.result_id] : ['--workflow-code', payload.program_code]), '--field-key', entry.field.field_key, '--file', file];
57
+ if (command === 'resumption-submit') {
58
+ args.push('--leave-id', payload.leave_id);
59
+ if (namespace === 'student-leave' && payload.student_id !== undefined) args.push('--student-id', payload.student_id);
60
+ }
61
+ if (namespace === 'student-leave' && command === 'submit') args.push('--class-id', payload.class_id, '--student-id', payload.student_id);
62
+ const receipt = await require('./leave-attachment-upload.cjs').upload(namespace, command, args);
63
+ const data = receipt.value.data;
64
+ keys.push(data.object_key);
65
+ uploaded.push({ field_key: entry.field.field_key, input_parent_field_key: entry.parent, input_row_index: entry.rowIndex,
66
+ object_key: data.object_key, file_name: data.file_name, size_bytes: data.size_bytes, file_transferred: true });
67
+ }
68
+ }
69
+ const result = await send();
70
+ if (pending.length) result.value.data.attachment_uploads = uploaded;
71
+ return result;
72
+ } catch (error) {
73
+ if (error instanceof Failure && uploaded.length) error.result.value.error.attachment_uploads = uploaded;
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ function attachment(value, location) {
79
+ const item = fields(value, { field_key: required('string'), field_label: nullable('string'), group_codes: required(['string']),
80
+ media_id: required('string'), file_name: nullable('string'), file_type: nullable('string'), size_bytes: nullable('integer'),
81
+ download_url: required('string'), expires_at: required('integer') }, location);
82
+ const url = new URL(item.download_url);
83
+ if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.hash || !url.search
84
+ || item.expires_at <= Date.now() || item.expires_at > Date.now() + 900000
85
+ || item.size_bytes !== null && item.size_bytes < 0)
86
+ throw new Error('附件下载链接或有效期无效。');
87
+ return item;
88
+ }
89
+
90
+ function displayAttachments(data) {
91
+ if (!Array.isArray(data.attachments)) return data;
92
+ const items = data.attachments;
93
+ function collect(definitions, groups = []) {
94
+ for (const field of definitions || []) {
95
+ if (field.type === 'images' && Array.isArray(field.value) && !items.some(item => item.media_id
96
+ && item.field_key === field.field_key && JSON.stringify(item.group_codes) === JSON.stringify(groups))) for (const image of field.value) {
97
+ const url = new URL(image.url);
98
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.hash)
99
+ throw new Failure(1, 'api', 'invalid_response', '详情中的图片地址无效。');
100
+ if (!items.some(item => item.field_key === field.field_key && JSON.stringify(item.group_codes) === JSON.stringify(groups)
101
+ && item.download_url === image.url)) items.push({ field_key: field.field_key, field_label: field.label ?? null,
102
+ group_codes: groups, media_id: null, file_name: null, file_type: 'image', size_bytes: null,
103
+ download_url: image.url, expires_at: null });
104
+ }
105
+ for (const group of field.groups || []) collect(group.fields, [...groups, group.group_code]);
106
+ }
107
+ }
108
+ collect(data.fields);
109
+ return data;
110
+ }
111
+
112
+ module.exports = { submit, attachment, displayAttachments };
@@ -0,0 +1,185 @@
1
+ // Local guidance only. The service rechecks the current form and business rules at submission.
2
+ function evaluate(form, answers = [], request = {}) {
3
+ const approval = request.approval === true;
4
+ const visible_fields = [], issues = [];
5
+ const bindings = new Map(Object.entries(form.field_mappings || {}).map(([semantic, key]) => [key, semantic]));
6
+ const boundValues = { leaveId: form.leave_id, userName: form.user_name, leaveType: form.leave_type,
7
+ askForLeaveTime: form.leave_start_time, askForBackTime: form.leave_end_time,
8
+ revokeLeaveStartTime: request.resumption_time, revokeLeaveReason: request.reason, correctionTime: request.correction_time };
9
+ const empty = value => value == null || typeof value === 'string' && !value.trim()
10
+ || Array.isArray(value) && !value.length
11
+ || value && Array.isArray(value.local_files) && !value.local_files.length;
12
+ function issue(code, field, position, message, extra = {}) {
13
+ if (issues.some(item => item.code === code && item.field_key === field.field_key
14
+ && item.parent_field_key === position.parent_field_key && item.row_index === position.row_index && item.group_code === position.group_code)) return;
15
+ issues.push({ code, field_key: field.field_key, label: field.label || field.field_key, ...position, message, ...extra });
16
+ }
17
+ function scope(fields, supplied, position = {}) {
18
+ const definitions = new Map(), inputs = new Map(), values = new Map(), visibility = new Map(), visiting = new Set();
19
+ for (const field of fields) {
20
+ if (definitions.has(field.field_key)) { issue('invalid_rule', field, position, '表单字段键重复,无法判断联动。'); return; }
21
+ definitions.set(field.field_key, field);
22
+ }
23
+ for (const input of supplied) {
24
+ const field = definitions.get(input.field_key) || input;
25
+ if (!definitions.has(input.field_key)) issue('unknown_field', field, position, '当前表单没有此字段。');
26
+ if (inputs.has(input.field_key)) issue('duplicate_input', field, position, '同一字段不能重复填写。');
27
+ inputs.set(input.field_key, input.value);
28
+ }
29
+ for (const field of fields) {
30
+ const key = field.field_key, bound = position.parent_field_key === undefined && bindings.get(key);
31
+ let value;
32
+ if (bound) value = boundValues[bound];
33
+ else if (inputs.has(key)) value = inputs.get(key);
34
+ else if (approval) value = field.value;
35
+ else if (position.row_index > 0) {
36
+ // Native new rows retain scalar defaults, but do not copy row one's selections or files.
37
+ value = ['text', 'multiline_text', 'number'].includes(field.type) ? field.default_value : null;
38
+ } else value = field.value ?? field.default_value;
39
+ if (!approval && !bound && !inputs.has(key) && ['text', 'multiline_text', 'number'].includes(field.type)
40
+ && empty(value) && field.default_value != null) value = field.default_value;
41
+ if (!approval && value == null && field.type === 'choice' && !(position.row_index > 0) && !bound) {
42
+ const defaults = (field.options || []).filter(option => option.default_selected === true);
43
+ if (defaults.length > 1) issue('invalid_rule', field, position, '单选字段存在多个默认选项。');
44
+ else value = defaults[0]?.option_id;
45
+ }
46
+ values.set(key, value);
47
+ }
48
+ function visible(field) {
49
+ const key = field.field_key;
50
+ if (visibility.has(key)) return visibility.get(key);
51
+ if (visiting.has(key)) throw new Error('显示条件存在循环依赖。');
52
+ visiting.add(key);
53
+ let matches = !field.visibility;
54
+ for (const condition of field.visibility?.any_of || []) {
55
+ const controller = definitions.get(condition.field_key);
56
+ if (!controller || controller.type !== 'choice') throw new Error('显示条件必须引用同一表单或同一行的单选字段。');
57
+ const options = controller.options || [];
58
+ if (options.filter(option => option.code === condition.option_code).length !== 1)
59
+ throw new Error('显示条件对应的选项缺失或不唯一。');
60
+ const active = visible(controller); // Visit every dependency, including unmatched branches.
61
+ const value = values.get(controller.field_key);
62
+ const id = typeof value === 'string' ? value : value?.option_id;
63
+ const selected = options.filter(option => option.option_id === id);
64
+ if (active && !empty(value) && (selected.length !== 1 || !selected[0].code)) {
65
+ if (!inputs.has(controller.field_key)) throw new Error('控制题当前选择缺少有效的选项身份或编码。');
66
+ issue('invalid_input', controller, position, '控制题需要当前选项的有效 option_id。');
67
+ }
68
+ if (active && selected[0]?.code === condition.option_code) matches = true;
69
+ }
70
+ visiting.delete(key);
71
+ visibility.set(key, matches);
72
+ return matches;
73
+ }
74
+ try { for (const field of fields) visible(field); }
75
+ catch (error) {
76
+ issue('invalid_rule', fields.find(field => visiting.has(field.field_key)) || fields[0], position, error.message);
77
+ return;
78
+ }
79
+ for (const field of fields) {
80
+ const key = field.field_key, suppliedValue = inputs.has(key), value = values.get(key);
81
+ const bound = position.parent_field_key === undefined && bindings.get(key);
82
+ if (!visibility.get(key)) {
83
+ if (suppliedValue) issue('hidden_input', field, position, '该字段当前隐藏;请从本次输入移除,CLI 不会静默丢弃答案。');
84
+ if (bound) issue('invalid_rule', field, position, '系统绑定字段被显示条件隐藏,需修正学校表单配置。');
85
+ continue;
86
+ }
87
+ visible_fields.push({ field_key: key, label: field.label || key, ...position });
88
+ if (bound) {
89
+ if (suppliedValue) issue('readonly_input', field, position, '系统绑定字段不能通过 field_values 填写。');
90
+ if (bound === 'revokeLeaveReason' && field.required && empty(value))
91
+ issue('missing_required', field, position, '请补充销假说明。', { parameter: 'reason' });
92
+ continue;
93
+ }
94
+ if (!field.editable || field.computed || approval && field.calculation || ['link', 'separator'].includes(field.type)) {
95
+ if (suppliedValue) issue('readonly_input', field, position, '只读、计算或展示字段不能填写。');
96
+ continue;
97
+ }
98
+ if (field.type === 'table') {
99
+ if (position.parent_field_key !== undefined) { issue('unsupported_field', field, position, '暂不支持嵌套子表。'); continue; }
100
+ if (approval) {
101
+ const groups = new Map((field.groups || []).map(group => [group.group_code, group.fields]));
102
+ const template = field.groups?.[0]?.fields;
103
+ const rows = suppliedValue ? value : [];
104
+ if (!Array.isArray(rows)) { issue('invalid_input', field, position, '子表变更需要行数组。'); continue; }
105
+ const touched = new Set();
106
+ for (const [index, row] of rows.entries()) {
107
+ const rowPosition = { parent_field_key: key, row_index: index, ...(row?.group_code ? { group_code: row.group_code } : {}) };
108
+ if (row?.action === 'add' && !row.group_code && Array.isArray(row.fields) && template?.length) {
109
+ // The service uses the current first row's schema, clearing saved answers for additions.
110
+ const fresh = template.map(child => ({ ...child, value:
111
+ ['text', 'multiline_text', 'number'].includes(child.type) ? child.default_value : null }));
112
+ scope(fresh, row.fields, rowPosition);
113
+ } else if (row?.group_code && groups.has(row.group_code) && !touched.has(row.group_code)
114
+ && (row.action === 'remove' || Array.isArray(row.fields))) {
115
+ touched.add(row.group_code);
116
+ if (row.action !== 'remove') scope(groups.get(row.group_code), row.fields, rowPosition);
117
+ } else issue('invalid_input', field, rowPosition, '子表行不存在、重复或缺少新增行模板。');
118
+ }
119
+ for (const [group_code, children] of groups) if (!touched.has(group_code))
120
+ scope(children, [], { parent_field_key: key, group_code });
121
+ continue;
122
+ }
123
+ const template = field.groups?.length === 1 ? field.groups[0].fields : null;
124
+ if (!template?.length) { issue('invalid_rule', field, position, '新申请子表需要唯一的初始行模板。'); continue; }
125
+ const rows = suppliedValue ? value : [{ fields: [] }];
126
+ if (!Array.isArray(rows) || !rows.length || rows.some(row => !row || Object.keys(row).length !== 1 || !Array.isArray(row.fields))) {
127
+ issue('invalid_input', field, position, '子表至少一行,每行只包含 fields 数组。'); continue;
128
+ }
129
+ rows.forEach((row, index) => scope(template, row.fields, { parent_field_key: key, row_index: index }));
130
+ continue;
131
+ }
132
+ if (['signature', 'location'].includes(field.type) && (suppliedValue || field.required && (!approval || empty(value)))) {
133
+ issue('unsupported_field', field, position, '真实签名或定位需在支持的原客户端办理。');
134
+ continue;
135
+ }
136
+ if (field.required && empty(value)) {
137
+ issue('missing_required', field, position, '请补充当前显示的必填信息。');
138
+ continue;
139
+ }
140
+ // Validate supplied shapes, not native saved file groups or refreshed service defaults.
141
+ if (!suppliedValue) continue;
142
+ let problem;
143
+ if (['text', 'multiline_text', 'date', 'datetime'].includes(field.type) && typeof value !== 'string') problem = '该字段需要文本值。';
144
+ if (field.type === 'number' && typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value)))
145
+ problem = '该字段需要数字或数字文本;具体数值规则由服务端校验。';
146
+ if (field.type === 'choice') {
147
+ const id = typeof value === 'string' ? value : value?.option_id;
148
+ const option = field.options?.find(item => item.option_id === id);
149
+ if (id !== '' && !option) problem = '请选择表单返回的 option_id。';
150
+ if (typeof value === 'object' && (!option || option.code !== 'attr-radio-other'
151
+ || Object.keys(value).sort().join(',') !== 'option_id,other_text' || typeof value.other_text !== 'string' || !value.other_text.trim()))
152
+ problem = '其他选项需要有效的 option_id 和补充说明。';
153
+ }
154
+ if (field.type === 'choices' && (!Array.isArray(value) || new Set(value).size !== value.length
155
+ || value.some(id => typeof id !== 'string' || !field.options?.some(option => option.option_id === id)))) problem = '多选需要不重复的 option_id 数组。';
156
+ const identityKey = { person: 'user_id', people: 'user_id', department: 'department_id', departments: 'department_id', organization: 'tenant_id', organizations: 'tenant_id' }[field.type];
157
+ if (identityKey && (!Array.isArray(value) || ['person', 'department', 'organization'].includes(field.type) && value.length > 1
158
+ || value.some(item => !item || typeof item.tenant_id !== 'string' || !item.tenant_id.trim() || typeof item[identityKey] !== 'string' || !item[identityKey].trim())))
159
+ problem = '请使用候选人员、部门或组织的身份数组;单选最多一项。';
160
+ if (['files', 'images'].includes(field.type)) {
161
+ if (approval && value && !Array.isArray(value) && Array.isArray(value.retain_media_ids)) {
162
+ // The service checks ownership and retained media; local checks must not reinterpret these identities.
163
+ const uploads = value.local_files ?? value.upload_keys;
164
+ if (!Array.isArray(uploads) || [...value.retain_media_ids, ...uploads].some(item => typeof item !== 'string' || !item.trim()))
165
+ issue('invalid_input', field, position, '附件保留标识与新增文件需要有效数组。');
166
+ else if (field.required && !value.retain_media_ids.length && !uploads.length)
167
+ issue('missing_required', field, position, '请补充当前显示的必填附件。');
168
+ continue;
169
+ }
170
+ const files = Array.isArray(value) ? value : value?.local_files;
171
+ if (!Array.isArray(files) || files.some(file => typeof file !== 'string' || !file.trim())
172
+ || !Array.isArray(value) && Object.keys(value).join(',') !== 'local_files') problem = '附件需要 local_files 本地路径数组或已上传的 object_key 数组。';
173
+ else if (field.type === 'images' && files.length > (field.attachment_limits?.max_files ?? 10)) problem = '图片数量超过表单限制。';
174
+ else if (!Array.isArray(value) && field.attachment_upload_supported !== true) problem = '该字段当前不支持上传附件。';
175
+ }
176
+ if (problem) issue('invalid_input', field, position, problem);
177
+ }
178
+ }
179
+ scope(form.fields, answers);
180
+ if (form.leave_id && !request.resumption_time) issues.push({ code: 'missing_required', parameter: 'resumption_time', message: '请补充实际返岗时间。' });
181
+ return { status: issues.some(item => ['invalid_rule', 'unsupported_field'].includes(item.code)) ? 'blocked' : issues.length ? 'needs_input' : 'complete', visible_fields, issues,
182
+ server_validation_required: true };
183
+ }
184
+
185
+ module.exports = { evaluate };
@@ -0,0 +1,99 @@
1
+ const { options, required: requireArguments } = require('./arguments.cjs');
2
+ const { required, nullable, invalid, Failure } = require('./result.cjs');
3
+ const gateway = require('./gateway.cjs');
4
+ const statuses = ['draft', 'processing', 'completed', 'returned', 'rejected', 'withdrawn', 'unknown'];
5
+ const summary = {
6
+ id: required('string'), leave_id: nullable('string'), program_code: nullable('string'), title: nullable('string'),
7
+ workflow_available: required('boolean'), business_record_available: required('boolean'), status: required('string'),
8
+ workflow_status: nullable('integer'), workflow_status_name: nullable('string'), business_status: nullable('string'), business_status_name: nullable('string'),
9
+ start_time: nullable('string'), end_time: nullable('string'), leave_type: nullable('string'), reason: nullable('string'),
10
+ created_at: nullable('string'), updated_at: nullable('string'),
11
+ };
12
+ const progress = { task_id: nullable('string'), node_name: nullable('string'), node_type: nullable('integer'),
13
+ handler_name: nullable('string'), handler_time: nullable('string'), status: required('integer'),
14
+ status_description: nullable('string'), opinion: nullable('string') };
15
+ const resumption = { id: required('string'), status: nullable('string'),
16
+ status_name: nullable('string'), start_time: nullable('string'), end_time: nullable('string'), reason: nullable('string'),
17
+ apply_time: nullable('string'), update_time: nullable('string') };
18
+ function validId(id, prefix) {
19
+ return typeof id === 'string' && new RegExp('^' + prefix + '[ar]_[1-9][0-9]{0,18}$').test(id)
20
+ && BigInt(id.slice(prefix.length + 2)) <= 9223372036854775807n;
21
+ }
22
+ function validItem(item, resumed) {
23
+ const prefix = resumed ? 'resumption_' : 'leave_';
24
+ const workflowStates = { '-2': 'withdrawn', '-1': 'returned', 0: 'draft', 1: 'processing', 2: 'completed' };
25
+ return validId(item.id, prefix) && statuses.includes(item.status)
26
+ && (item.leave_id === null || resumed && validId(item.leave_id, 'leave_'))
27
+ && (item.workflow_available ? item.status === workflowStates[item.workflow_status] && Boolean(item.program_code?.trim())
28
+ : item.workflow_status === null && item.program_code === null);
29
+ }
30
+ async function execute(command, args, formField) {
31
+ const resumed = command.startsWith('resumption-'), show = command.endsWith('show');
32
+ const prefix = resumed ? 'resumption_' : 'leave_';
33
+ if (show) {
34
+ const payload = options(args, ['id'], [], [], true); requireArguments(payload, 'id');
35
+ if (!validId(payload.id, prefix)) throw invalid('--id 必须使用对应 list 返回的查询 ID。');
36
+ const result = await gateway.execute('attendance-leave:read', 'leave/' + command, payload, {
37
+ ...summary, tenant_id: required('string'), applicant_id: required('string'), form_id: nullable('string'),
38
+ fields: nullable([formField]), progress: nullable([progress]), progress_unavailable_reason: nullable('string'), attachments: nullable([require('./teacher-leave-files.cjs').attachment]),
39
+ available_actions: nullable(['string']), resumption_status: nullable('string'), resumption_status_name: nullable('string'),
40
+ resumption_source: nullable('integer'), resumption_applications: nullable([resumption]),
41
+ });
42
+ const data = result.value.data;
43
+ if (data.id !== payload.id || !validItem(data, resumed)
44
+ || (data.workflow_available ? data.fields === null || (data.progress === null ? !data.progress_unavailable_reason?.trim() : data.progress_unavailable_reason != null) || data.attachments === null || data.form_id === null
45
+ : data.fields !== null || data.progress !== null || data.progress_unavailable_reason != null || data.attachments !== null || data.form_id !== null))
46
+ fail(result, '本人查询详情与目标或来源状态不一致。');
47
+ require('./teacher-leave-files.cjs').displayAttachments(data);
48
+ return result;
49
+ }
50
+ const payload = { page: 1, page_size: 20, ...options(args, ['start_date', 'end_date', 'leave_type', 'status', 'reason', 'page', 'page_size', ...(!resumed ? ['scope'] : [])], [], [], true) };
51
+ if (payload.scope != null && !['mine', 'managed'].includes(payload.scope)) throw invalid('--scope 只支持 mine 或 managed。');
52
+ const managed = payload.scope === 'managed';
53
+ if (managed && (!payload.start_date || !payload.end_date || payload.status != null && payload.status !== 'completed'))
54
+ throw invalid('managed 必须提供起止日期,仅查询已生效请假,--status 只能省略或为 completed。');
55
+ for (const [key, maximum] of [['page', 2147483647], ['page_size', 100]]) {
56
+ if (!/^\d+$/.test(String(payload[key])) || +payload[key] < 1 || +payload[key] > maximum)
57
+ throw invalid('--' + key.replaceAll('_', '-') + ' 必须在 1 到 ' + maximum + ' 之间。');
58
+ payload[key] = +payload[key];
59
+ }
60
+ for (const key of ['start_date', 'end_date']) {
61
+ if (payload[key] !== undefined && (!/^\d{4}-\d{2}-\d{2}$/.test(payload[key]) || !Number.isFinite(Date.parse(payload[key]))
62
+ || new Date(payload[key]).toISOString().slice(0, 10) !== payload[key])) throw invalid('日期必须为有效的 YYYY-MM-DD。');
63
+ }
64
+ if (Boolean(payload.start_date) !== Boolean(payload.end_date) || payload.start_date > payload.end_date) throw invalid('日期必须成对传入且结束日期不早于开始日期。');
65
+ if (payload.status !== undefined && !statuses.includes(payload.status)) throw invalid('--status 必须为 ' + statuses.join('、') + '。');
66
+ if (payload.leave_type !== undefined && !payload.leave_type.trim() || payload.reason !== undefined && (!payload.reason.trim() || payload.reason.length > 250))
67
+ throw invalid('假种不能为空,事由必须为 1–250 字。');
68
+ const result = await gateway.execute('attendance-leave:read', 'leave/' + command, payload, {
69
+ tenant_id: required('string'),
70
+ ...(managed ? { operator_id: required('string'), scope: required('string'), period_basis: required('string') } : { applicant_id: required('string') }),
71
+ items: required([managed ? { ...summary, teacher_id: required('string'), teacher_name: nullable('string'), resumption_status: nullable('string') } : summary]),
72
+ page: required('integer'), page_size: required('integer'), total: required('integer'), total_pages: required('integer'),
73
+ });
74
+ const data = result.value.data;
75
+ if (data.page !== payload.page || data.page_size !== payload.page_size || data.total < 0 || data.total_pages !== Math.ceil(data.total / data.page_size)
76
+ || data.items.length !== Math.min(data.page_size, Math.max(0, data.total - (data.page - 1) * data.page_size))
77
+ || managed && (data.scope !== 'managed' || data.period_basis !== 'effective')
78
+ || new Set(data.items.map(item => item.id)).size !== data.items.length
79
+ || managed && data.items.some(item => !item.teacher_id.trim() || item.status !== 'completed' || item.business_status !== '1'
80
+ || !item.business_record_available || item.workflow_available || !effectiveOverlap(item, payload)
81
+ || payload.leave_type != null && item.leave_type !== payload.leave_type
82
+ || payload.reason != null && !item.reason?.toLowerCase().includes(payload.reason.toLowerCase()))
83
+ || data.items.some(item => !validItem(item, resumed) || payload.status !== undefined && item.status !== payload.status))
84
+ fail(result, '请假查询列表分页、标识、范围或状态不一致。');
85
+ return result;
86
+ }
87
+ function effectiveOverlap(item, payload) {
88
+ const valid = value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)
89
+ && Number.isFinite(Date.parse(value.replace(' ', 'T') + 'Z'))
90
+ && new Date(value.replace(' ', 'T') + 'Z').toISOString().slice(0, 19).replace('T', ' ') === value;
91
+ return valid(item.start_time) && valid(item.end_time) && item.start_time < item.end_time
92
+ && item.start_time.slice(0, 10) <= payload.end_date && item.end_time > payload.start_date + ' 00:00:00';
93
+ }
94
+ function fail(result, message) {
95
+ const failure = new Failure(1, 'api', 'invalid_response', message);
96
+ if (result.value.request_id) failure.result.value.request_id = result.value.request_id;
97
+ throw failure;
98
+ }
99
+ module.exports = { execute, validId };