@xiaoyuanhao/yunzhi-cli 2.0.0 → 2.1.0

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 +74 -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,18 @@
1
+ const { required, nullable } = require('./result.cjs');
2
+ const schema = {
3
+ school_wide: required('boolean'), scope_classes: required([{ class_id: required('string'), class_name: required('string') }]),
4
+ scope_manage_groups: required([{ manage_id: required('string'), manage_name: required('string'), building_id: nullable('string'),
5
+ bedrooms: required([{ bedroom_id: required('string'), bedroom_name: required('string') }]) }]),
6
+ };
7
+ const positiveId = value => typeof value === 'string' && /^[1-9][0-9]*$/.test(value) && BigInt(value) <= 9223372036854775807n;
8
+ function valid(data, payload) {
9
+ const classes = new Set(data.scope_classes.map(row => row.class_id));
10
+ const groups = new Set(data.scope_manage_groups.map(row => row.manage_id));
11
+ return classes.size === data.scope_classes.length && data.scope_classes.every(row => !row.class_id.includes(','))
12
+ && (data.school_wide ? classes.size === 0 : classes.size > 0 || groups.size > 0)
13
+ && (data.school_wide || payload.class_id == null || classes.has(payload.class_id))
14
+ && groups.size === data.scope_manage_groups.length && (payload.manage_id == null || groups.has(payload.manage_id))
15
+ && data.scope_manage_groups.every(group => positiveId(group.manage_id) && (group.building_id == null || positiveId(group.building_id))
16
+ && new Set(group.bedrooms.map(room => room.bedroom_id)).size === group.bedrooms.length && group.bedrooms.every(room => positiveId(room.bedroom_id)));
17
+ }
18
+ module.exports = { schema, valid };
@@ -0,0 +1,30 @@
1
+ const { required, nullable, Failure } = require('./result.cjs');
2
+ const gateway = require('./gateway.cjs');
3
+ const countKeys = ['normal_in_count', 'late_in_count', 'no_in_count', 'normal_out_count', 'late_out_count', 'no_out_count', 'leave_count', 'unknown_count'];
4
+ const scope = require('./dormitory-scope.cjs');
5
+ async function execute(args) {
6
+ const payload = require('./attendance.cjs').managedQuery(args, 'dormitory-attendance', false);
7
+ const result = await gateway.execute('attendance-leave:read', 'dormitory-attendance/statistics', payload, {
8
+ tenant_id: required('string'), operator_id: required('string'), start_date: required('string'), end_date: required('string'),
9
+ target_user_id: nullable('string'), class_id: nullable('string'), manage_id: nullable('string'), name_filter: nullable('string'), rule_id: nullable('string'), period: nullable('integer'), status: nullable('integer'),
10
+ abnormal: required('boolean'), ...scope.schema,
11
+ queried_at: required('integer'), total_count: required('integer'), ...Object.fromEntries(countKeys.map(key => [key, required('integer')])),
12
+ });
13
+ const data = result.value.data;
14
+ const filteredCount = { 11: 'normal_out_count', 12: 'normal_in_count', 13: 'late_in_count', 14: 'late_out_count', 15: 'no_out_count', 16: 'no_in_count', '-6': 'leave_count' };
15
+ const valid = data.start_date === payload.start_date && data.end_date === payload.end_date
16
+ && ['target_user_id', 'class_id', 'manage_id', 'rule_id', 'period', 'status'].every(key => data[key] === (payload[key] ?? null))
17
+ && data.name_filter === (payload.name ?? null) && data.queried_at > 0 && data.total_count >= 0
18
+ && countKeys.every(key => data[key] >= 0) && countKeys.reduce((sum, key) => sum + BigInt(data[key]), 0n) === BigInt(data.total_count)
19
+ && (payload.status == null || data[filteredCount[payload.status]] === data.total_count)
20
+ && data.abnormal === Boolean(payload.abnormal)
21
+ && (!payload.abnormal || ['normal_in_count', 'normal_out_count', 'leave_count', 'unknown_count'].every(key => data[key] === 0))
22
+ && scope.valid(data, payload);
23
+ if (!valid) {
24
+ const failure = new Failure(1, 'api', 'invalid_response', '宿舍考勤统计的日期、筛选、职责范围或计数不一致。');
25
+ if (result.value.request_id) failure.result.value.request_id = result.value.request_id;
26
+ throw failure;
27
+ }
28
+ return result;
29
+ }
30
+ module.exports = { execute };
package/lib/download.cjs CHANGED
@@ -31,22 +31,25 @@ async function download(namespace, args) {
31
31
  const result = publicResult({ ...envelope, data: { ...data, local_path: target } }, {
32
32
  ...optionalFields({ file_id: 'string', kind: 'string', space: 'string', name: 'string', path: 'string', file_ext: 'string', size_bytes: 'integer' }), local_path: 'string',
33
33
  });
34
+ return saveDownload(data, target, parsed.overwrite, result);
35
+ }
36
+ async function saveDownload(data, target, overwrite, result, privateUrl = false) {
34
37
  try {
35
38
  const info = await fs.lstat(target);
36
- if (!parsed.overwrite) throw exists(target);
39
+ if (!overwrite) throw exists(target);
37
40
  if (!info.isFile() || info.isSymbolicLink()) throw local('覆盖目标必须是普通文件。');
38
41
  } catch (error) { if (error instanceof Failure) throw error; if (error.code !== 'ENOENT') throw attach(local('无法检查本地目标文件。'), error); }
39
42
  let handle, opened = false, primaryError;
40
43
  try {
41
- try { handle = await fs.open(target, parsed.overwrite ? 'w' : 'wx'); opened = true; }
44
+ try { handle = await fs.open(target, overwrite ? 'w' : 'wx'); opened = true; }
42
45
  catch (error) {
43
- if (!parsed.overwrite && error.code === 'EEXIST') throw attach(exists(target), error);
46
+ if (!overwrite && error.code === 'EEXIST') throw attach(exists(target), error);
44
47
  throw attach(local('无法打开本地目标文件。'), error);
45
48
  }
46
49
  let response;
47
- try { response = await fetch(data.download_url, { headers: { 'User-Agent': 'yunzhi-cli/' + version }, signal: AbortSignal.timeout(25 * 60 * 1000) }); }
50
+ try { response = await fetch(data.download_url, { headers: { 'User-Agent': 'yunzhi-cli/' + version }, signal: AbortSignal.timeout(25 * 60 * 1000), ...(privateUrl ? { redirect: 'error' } : {}) }); }
48
51
  catch (error) { throw network(error); }
49
- if (!response.ok) {
52
+ if (!response.ok || data.size_bytes === null && response.status !== 200) {
50
53
  const failure = network(new Error('HTTP ' + response.status + ' ' + response.statusText));
51
54
  try { await response.body?.cancel(); } catch (error) { cleanup(failure, error); }
52
55
  throw failure;
@@ -55,16 +58,22 @@ async function download(namespace, args) {
55
58
  try {
56
59
  for await (const chunk of response.body) {
57
60
  size += chunk.length;
58
- if (size > data.size_bytes) throw network(new Error('Download exceeds expected size: ' + size + ' > ' + data.size_bytes));
61
+ if (data.size_bytes !== null && size > data.size_bytes) throw network(new Error('Download exceeds expected size: ' + size + ' > ' + data.size_bytes));
59
62
  try { await handle.writeFile(chunk); } catch (error) { throw attach(local('无法写入本地文件。'), error); }
60
63
  }
61
64
  } catch (error) { if (error instanceof Failure) throw error; throw network(error); }
62
- if (size !== data.size_bytes) throw network(new Error('Download size mismatch: expected ' + data.size_bytes + ', received ' + size));
65
+ if (data.size_bytes !== null && size !== data.size_bytes) throw network(new Error('Download size mismatch: expected ' + data.size_bytes + ', received ' + size));
63
66
  try { await handle.sync(); await handle.close(); handle = undefined; }
64
67
  catch (error) { throw attach(local('无法写入本地文件。'), error); }
68
+ if (data.size_bytes === null) result.value.data.downloaded_bytes = size;
65
69
  return result;
66
70
  } catch (error) {
67
71
  primaryError = error;
72
+ if (privateUrl && error instanceof Failure && error.result.value.error.detail) {
73
+ // Preserve transfer diagnostics while withholding this credential-bearing URL.
74
+ for (const secret of [data.download_url, encodeURI(data.download_url), encodeURIComponent(data.download_url), new URL(data.download_url).search])
75
+ error.result.value.error.detail = error.result.value.error.detail.split(secret).join('[临时下载授权已隐藏]');
76
+ }
68
77
  if (opened && error instanceof Failure) Object.assign(error.result.value.error, { local_path: target, partial_file: true });
69
78
  throw error;
70
79
  } finally {
@@ -77,4 +86,4 @@ async function download(namespace, args) {
77
86
  }
78
87
  }
79
88
  }
80
- module.exports = { download };
89
+ module.exports = { download, saveDownload };
package/lib/gateway.cjs CHANGED
@@ -24,15 +24,20 @@ async function execute(scope, endpoint, payload, schema) {
24
24
  }
25
25
 
26
26
  async function request(config, endpoint, token, payload, signal = AbortSignal.timeout(80000)) {
27
- const address = gatewayURL(config) + '/api/v1/cli/' + endpoint;
28
- const response = await send(address, {
27
+ const address = gatewayURL(config) + '/api/v1/cli/' + require('./attendance-leave-route.cjs').endpoint(endpoint);
28
+ try {
29
+ const response = await send(address, {
29
30
  method: payload === undefined ? 'GET' : 'POST',
30
31
  headers: { 'Content-Type': 'application/json', 'User-Agent': 'yunzhi-cli/' + require('./version.cjs').version,
31
32
  ...(token ? { Authorization: 'Bearer ' + token } : {}) },
32
33
  body: payload === undefined ? undefined : JSON.stringify(payload),
33
34
  redirect: 'error', signal,
34
35
  });
35
- return responseEnvelope(response);
36
+ return await responseEnvelope(response);
37
+ } catch (error) {
38
+ require('./attendance-operation-result.cjs').transportFailure(endpoint, error);
39
+ throw error;
40
+ }
36
41
  }
37
42
 
38
43
  async function multipartRequest(config, endpoint, token, values, filePath) {
@@ -45,7 +50,7 @@ async function multipartRequest(config, endpoint, token, values, filePath) {
45
50
  for await (const chunk of fs.createReadStream(filePath)) yield chunk;
46
51
  yield Buffer.from(`\r\n--${boundary}--\r\n`);
47
52
  }
48
- const address = gatewayURL(config) + '/api/v1/cli/' + endpoint;
53
+ const address = gatewayURL(config) + '/api/v1/cli/' + require('./attendance-leave-route.cjs').endpoint(endpoint);
49
54
  const response = await send(address, {
50
55
  method: 'POST', body: Readable.from(parts()), duplex: 'half', redirect: 'error',
51
56
  headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`,
@@ -0,0 +1,126 @@
1
+ const gateway = require('./gateway.cjs');
2
+
3
+ function selection(field) {
4
+ let values = field.value;
5
+ if (Array.isArray(values)) values = { retain_media_ids: [], upload_keys: values };
6
+ if (!values || typeof values !== 'object' || Object.keys(values).length !== 2
7
+ || !Object.hasOwn(values, 'retain_media_ids') || !Object.hasOwn(values, 'upload_keys')) return null;
8
+ for (const list of Object.values(values)) {
9
+ if (!Array.isArray(list) || list.some(value => typeof value !== 'string' || !value.trim()) || new Set(list).size !== list.length) return null;
10
+ }
11
+ return { retain_media_ids: values.retain_media_ids, upload_keys: values.upload_keys };
12
+ }
13
+
14
+ const identity = field => JSON.stringify([field.input_parent_field_key ?? null, field.input_row_index ?? null, field.field_key]);
15
+ const location = field => JSON.stringify([field.field_key, field.group_codes ?? null]);
16
+
17
+ function requestedField(fields, receipt) {
18
+ const parent = receipt.input_parent_field_key, index = receipt.input_row_index, path = receipt.group_codes;
19
+ if (parent == null && index == null) return path == null ? fields.get(receipt.field_key) : null;
20
+ if (typeof parent !== 'string' || !parent.trim() || !Number.isSafeInteger(index) || index < 0
21
+ || !Array.isArray(path) || path.length !== 1 || typeof path[0] !== 'string' || !path[0].trim()) return null;
22
+ const table = fields.get(parent);
23
+ const rows = table?.value;
24
+ if (!Array.isArray(rows) || index >= rows.length) return null;
25
+ const row = rows[index], code = path[0];
26
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return null;
27
+ if (Object.keys(row).length !== 2 || !Array.isArray(row.fields)) return null;
28
+ if (row.action === 'add') {
29
+ if (!code.startsWith(`${parent}_`) || !/^[1-9][0-9]*$/.test(code.slice(parent.length + 1))
30
+ || rows.some(other => other?.group_code === code)) return null;
31
+ } else if (row.group_code !== code) return null;
32
+ let selected = null;
33
+ for (const child of row.fields) {
34
+ if (!child || typeof child !== 'object' || Array.isArray(child)) return null;
35
+ if (child.field_key !== receipt.field_key) continue;
36
+ if (selected || typeof child.field_key !== 'string' || Object.keys(child).length !== 2 || !Object.hasOwn(child, 'value')) return null;
37
+ selected = child;
38
+ }
39
+ return selected;
40
+ }
41
+
42
+ async function execute(scope, endpoint, payload, schema) {
43
+ const { config, token } = await require('./session.cjs').session(scope);
44
+ const envelope = await gateway.request(config, endpoint, token, payload);
45
+ const result = gateway.publicResult(envelope, schema);
46
+ let damaged = require('./leave-start-receipt.cjs').project(envelope, result, '审批');
47
+ const fields = new Map();
48
+ for (const field of payload.field_values || []) {
49
+ if (fields.has(field.field_key)) { damaged = true; fields.set(field.field_key, null); }
50
+ else fields.set(field.field_key, field);
51
+ }
52
+ const preparations = result.value.data.attachment_preparations;
53
+ const sources = envelope.data.attachment_preparations;
54
+ const fieldCounts = new Map(), locationCounts = new Map(), copyCounts = new Map();
55
+ const rowTargets = new Map(), rowSources = new Map();
56
+ for (const field of Array.isArray(sources) ? sources : []) if (field) {
57
+ if (field.input_parent_field_key != null) {
58
+ const inputRow = JSON.stringify([field.input_parent_field_key, field.input_row_index]);
59
+ const storedRow = JSON.stringify([field.input_parent_field_key, field.group_codes]);
60
+ if (!rowTargets.has(inputRow)) rowTargets.set(inputRow, new Set());
61
+ if (!rowSources.has(storedRow)) rowSources.set(storedRow, new Set());
62
+ rowTargets.get(inputRow).add(storedRow); rowSources.get(storedRow).add(inputRow);
63
+ }
64
+ fieldCounts.set(identity(field), (fieldCounts.get(identity(field)) || 0) + 1);
65
+ locationCounts.set(location(field), (locationCounts.get(location(field)) || 0) + 1);
66
+ for (const copy of Array.isArray(field.copies) ? field.copies : []) if (copy?.object_key) copyCounts.set(copy.object_key, (copyCounts.get(copy.object_key) || 0) + 1);
67
+ }
68
+ if (preparations) result.value.data.attachment_preparations = preparations.map((field, index) => {
69
+ const receipt = sources[index];
70
+ const input = field && requestedField(fields, receipt);
71
+ if (!input || fieldCounts.get(identity(receipt)) !== 1 || locationCounts.get(location(receipt)) !== 1) { damaged = true; return null; }
72
+ if (receipt.input_parent_field_key != null) {
73
+ if (rowTargets.get(JSON.stringify([receipt.input_parent_field_key, receipt.input_row_index])).size !== 1
74
+ || rowSources.get(JSON.stringify([receipt.input_parent_field_key, receipt.group_codes])).size !== 1) { damaged = true; return null; }
75
+ field.input_parent_field_key = receipt.input_parent_field_key; field.input_row_index = receipt.input_row_index;
76
+ }
77
+ const selected = selection(input);
78
+ let incomplete = !field.copies || !selected;
79
+ if (field.copies) field.copies = field.copies.map(copy => {
80
+ if (!copy || !selected?.upload_keys.includes(copy.source_object_key) || copyCounts.get(copy.object_key) !== 1
81
+ || copy.size_bytes > 200 * 1024 * 1024) {
82
+ incomplete = true; damaged = true; return null;
83
+ }
84
+ if (Object.values(copy).some(value => value == null)) incomplete = true;
85
+ return copy;
86
+ });
87
+ const source = envelope.data.attachment_preparations[index].retained;
88
+ if (!Array.isArray(source)) { field.retained = null; incomplete = true; }
89
+ else {
90
+ const counts = new Map();
91
+ for (const file of source) if (file?.media_id) counts.set(file.media_id, (counts.get(file.media_id) || 0) + 1);
92
+ field.retained = source.map(file => {
93
+ if (!file || !['media_id', 'file_name', 'file_type'].every(key => typeof file[key] === 'string' && file[key].trim())
94
+ || !Number.isSafeInteger(file.size_bytes) || file.size_bytes < 0 || file.size_bytes > 200 * 1024 * 1024
95
+ || !selected?.retain_media_ids.includes(file.media_id) || counts.get(file.media_id) !== 1) {
96
+ incomplete = true; damaged = true; return null;
97
+ }
98
+ return { media_id: file.media_id, file_name: file.file_name, file_type: file.file_type, size_bytes: file.size_bytes };
99
+ });
100
+ }
101
+ const allVerified = field.copies?.every(copy => copy?.verified === true && copy.execution_status === 'COMPLETED');
102
+ const noFiles = field.copies?.length === 0 && field.retained?.length === 0;
103
+ if (!incomplete && ['UNCONFIRMED', 'COMPLETED'].includes(field.group_execution_status) && (noFiles || !allVerified)) {
104
+ field.group_id = null; field.group_execution_status = null; damaged = true;
105
+ }
106
+ const copiedKeys = new Set((field.copies || []).map(copy => copy?.source_object_key));
107
+ const retainedIds = new Set((field.retained || []).map(file => file?.media_id));
108
+ const selectionComplete = selected && copiedKeys.size === field.copies?.length && retainedIds.size === field.retained?.length
109
+ && selected.upload_keys.length === copiedKeys.size && selected.upload_keys.every(key => copiedKeys.has(key))
110
+ && selected.retain_media_ids.length === retainedIds.size && selected.retain_media_ids.every(id => retainedIds.has(id));
111
+ const empty = noFiles && field.group_execution_status === 'NOT_EXECUTED' && field.group_id == null;
112
+ if (field.verified && (incomplete || !selectionComplete || !(empty || field.group_execution_status === 'COMPLETED' && allVerified))) {
113
+ field.verified = false; damaged = true;
114
+ }
115
+ if (field.group_execution_status !== 'COMPLETED' && field.group_id != null) {
116
+ field.group_id = null; field.group_execution_status = null; damaged = true;
117
+ }
118
+ if (incomplete || field.group_execution_status == null) damaged = true;
119
+ return field;
120
+ });
121
+ result.value.data.attachment_preparations_complete = envelope.data.attachment_preparations_complete === true && preparations != null && !damaged;
122
+ if (!result.value.data.attachment_preparations_complete)
123
+ result.value.data.attachment_preparation_error = '附件准备回执不完整,已确认的审批和附件事实保留;未知部分不能当作未执行,不要自动重试。';
124
+ return result;
125
+ }
126
+ module.exports = { execute, selection, requestedField };
@@ -0,0 +1,110 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const { Failure, success, invalid, cleanupFailure } = require('./result.cjs');
4
+ const { attach } = require('./diagnostics.cjs');
5
+ const { options, required } = require('./arguments.cjs');
6
+ const { session } = require('./session.cjs');
7
+ const { request } = require('./gateway.cjs');
8
+
9
+ async function upload(namespace, command, args) {
10
+ const approval = command.includes('approval-') || ['approve', 'resumption-approve', 'correction-approve'].includes(command);
11
+ const correction = namespace === 'attendance';
12
+ const resumption = command.startsWith('resumption-');
13
+ const business = approval ? '审批' : '申请';
14
+ const student = namespace === 'student-leave';
15
+ const targets = approval ? ['task_id'] : correction ? ['date', 'result_id'] : resumption ? ['leave_id'] : student ? ['class_id', 'student_id'] : [];
16
+ const parameters = [...(approval || correction ? [] : ['program_code']), 'field_key', 'file', ...targets];
17
+ const parsed = options(args, [...parameters, ...(student && resumption && !approval ? ['student_id'] : [])], [], ['file'], true);
18
+ required(parsed, ...parameters);
19
+ if (approval && (!/^[1-9][0-9]*$/.test(parsed.task_id) || BigInt(parsed.task_id) > 9223372036854775807n))
20
+ throw invalid('--task-id 必须是有效的正整数审批任务 ID。');
21
+ if (!approval && resumption && (student ? !require('./student-leave-query.cjs').validId(parsed.leave_id, false)
22
+ : !require('./teacher-leave-query.cjs').validId(parsed.leave_id, 'leave_')))
23
+ throw invalid('--leave-id 使用原请假返回的公开 ID。');
24
+ if (correction && !approval && (!/^\d{4}-\d{2}-\d{2}$/.test(parsed.date) || !Number.isFinite(Date.parse(parsed.date))
25
+ || new Date(parsed.date).toISOString().slice(0, 10) !== parsed.date)) throw invalid('--date 必须为真实有效的 YYYY-MM-DD 日期。');
26
+ const purpose = correction ? undefined : (student ? 'STUDENT' : 'TEACHER') + (resumption ? '_RESUMPTION' : '_LEAVE');
27
+ const fileName = path.basename(parsed.file);
28
+ if (fileName.length > 255 || /[\x00-\x1f\x7f/\\]/.test(fileName) || ['.', '..'].includes(fileName)) throw invalid('附件文件名无效。');
29
+ let handle, completed, primaryError, receipt, requestId;
30
+ let stage = 'local_file';
31
+ try { handle = await fs.open(parsed.file, 'r'); }
32
+ catch (error) { throw attach(new Failure(2, 'validation', 'invalid_local_file', '附件文件不可读取,上传尚未开始。'), error); }
33
+ try {
34
+ const info = await handle.stat();
35
+ if (!info.isFile() || info.size > 200 * 1024 * 1024) throw invalid('附件必须为不超过 200 MiB 的普通文件。');
36
+ const payload = { ...(approval || correction ? {} : { program_code: parsed.program_code }), field_key: parsed.field_key, file_name: fileName, size_bytes: info.size };
37
+ for (const target of targets) payload[target] = parsed[target];
38
+ if (student && resumption && !approval && parsed.student_id !== undefined) payload.student_id = parsed.student_id;
39
+ const { config, token } = await session('attendance-leave' + (approval ? ':approve' : ':write'));
40
+ stage = 'upload_authorization';
41
+ const endpoint = `${namespace}/${command}/attachment-prepare`;
42
+ const prepared = await request(config, endpoint, token, payload);
43
+ requestId = prepared.request_id;
44
+ const grant = prepared.data;
45
+ if (!validGrant(grant, payload, purpose, approval, correction)) throw new Failure(1, 'api', 'invalid_response', '附件上传授权与办理对象、文件或传输合同不一致,文件传输尚未开始。');
46
+ receipt = { ...(correction ? { tenant_id: grant.tenant_id, teacher_id: grant.teacher_id } : { purpose }), form_id: grant.form_id, field_key: grant.field_key,
47
+ ...(approval ? { task_id: grant.task_id, application_id: grant.application_id, approval_submitted: false }
48
+ : { program_code: grant.program_code, ...(correction ? { date: grant.date, result_id: grant.result_id } : { class_id: grant.class_id, student_id: grant.student_id, leave_id: grant.leave_id }), application_submitted: false }),
49
+ object_key: grant.object_key, file_name: grant.file_name, size_bytes: grant.size_bytes };
50
+ stage = 'file_transfer';
51
+ try {
52
+ if ((await handle.stat()).size !== info.size) throw new Error('附件源文件大小已变化。');
53
+ await require('./cos-upload.cjs').put(handle, info.size, { object_key: grant.object_key, bucket: grant.bucket, region: grant.region,
54
+ secret_id: grant.credentials.secret_id, secret_key: grant.credentials.secret_key, session_token: grant.credentials.session_token });
55
+ if ((await handle.stat()).size !== info.size) throw new Error('附件源文件大小已变化。');
56
+ }
57
+ catch (error) {
58
+ // Do not serialize storage responses or temporary storage credentials into the agent conversation.
59
+ throw new Failure(4, 'network', 'attachment_upload_unconfirmed', `附件传输结果待核实,尚未提交${business};保留本次对象标识,不自动重传。`,
60
+ { ...receipt, hint: `停止本文件的依赖操作;核实上传结果后再决定是否提交${business},不自动重试或删除对象。` });
61
+ }
62
+ completed = success({ ...receipt, file_transferred: true,
63
+ message: correction ? (approval ? '补卡审批附件字节已上传,尚未关联或提交审批。新 object_key 数组替换整个字段;保留旧文件并追加时,将同任务同字段同行的实际 media_id 放入 retain_media_ids,新键放入 upload_keys,两数组对象编码为 value,子表放入所选行;按老师明确决定调用 correction-approve。' : '补卡附件字节已上传,尚未关联或提交申请。将 object_key 放入对应字段 value 的 字符串数组;子表放入所选行,再按老师明确指令调用 correction-submit。') : approval ? '审批附件字节已上传,尚未关联或提交审批。将同一任务的 object_key 放入对应字段 value 的 字符串数组,再按老师明确决定调用对应 approve 或 resumption-approve。' : '附件字节已上传,尚未关联或提交申请。将 object_key 放入对应字段 value 的 字符串数组,再按老师指令提交申请。' });
64
+ if (prepared.request_id) completed.value.request_id = prepared.request_id;
65
+ return completed;
66
+ } catch (error) {
67
+ primaryError = error instanceof Failure ? error : attach(new Failure(5, 'local', 'attachment_file_error', '无法读取本地附件信息,文件传输尚未开始。'), error);
68
+ primaryError.result.value.error.upload_stage = stage;
69
+ if (requestId) primaryError.result.value.request_id = requestId;
70
+ throw primaryError;
71
+ } finally {
72
+ try { await handle.close(); }
73
+ catch (error) { throw cleanupFailure(primaryError || new Failure(5, 'local', 'storage_error', '附件上传已完成,但无法关闭本地源文件。'), completed, error); }
74
+ }
75
+ }
76
+
77
+ function validGrant(grant, payload, purpose, approval, correction) {
78
+ if (!grant || grant.purpose !== purpose || typeof grant.form_id !== 'string' || !/^[1-9][0-9]*$/.test(grant.form_id)
79
+ || BigInt(grant.form_id) > 9223372036854775807n || !Number.isSafeInteger(grant.expires_at) || grant.expires_at <= Date.now()
80
+ || typeof grant.object_key !== 'string' || !grant.object_key.trim()) return false;
81
+ if (approval && (!require('./attendance-leave-ids.cjs').applicationId(grant.application_id, purpose))) return false;
82
+ if (Object.entries(payload).some(([key, value]) => grant[key] !== value)
83
+ || ['class_id', 'student_id', 'leave_id'].some(key => (grant[key] ?? null) !== (payload[key] ?? null))) return false;
84
+ if (correction) {
85
+ if (typeof grant.tenant_id !== 'string' || !grant.tenant_id.trim() || typeof grant.teacher_id !== 'string' || !grant.teacher_id.trim()
86
+ || grant.expires_at > Date.now() + 900000 || Buffer.byteLength(grant.object_key) > 1023
87
+ || (approval ? grant.date != null || grant.result_id != null || grant.program_code != null
88
+ : typeof grant.program_code !== 'string' || !grant.program_code.trim() || grant.task_id != null || grant.application_id != null)) return false;
89
+
90
+ const encode = value => Buffer.from(value).toString('base64url');
91
+ const prefix = (approval ? 'workflow-approval-attachments/' : 'workflow-attachments/') + encode(grant.tenant_id) + '/' + encode(grant.teacher_id) + '/';
92
+ if (!grant.object_key.startsWith(prefix)) return false;
93
+ const path = grant.object_key.slice(prefix.length).split('/');
94
+ // Gateway verifies the native application binding; CLI treats application_id as opaque.
95
+ if (approval && (!/^[1-9][0-9]*$/.test(path[0]) || path[1] !== grant.task_id)) return false;
96
+ const context = approval ? path.slice(2) : path;
97
+ if (context[0] !== grant.form_id) return false;
98
+ const parts = context.slice(1);
99
+ if (parts.length !== 4 || !/^[1-9][0-9]*$/.test(parts[0]) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(parts[1])
100
+ || parts[2] !== String(payload.size_bytes) || parts[3] !== encode(payload.file_name)) return false;
101
+ }
102
+ const credentials = grant.credentials;
103
+ const hostPart = value => typeof value === 'string' && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
104
+ if (!hostPart(grant.bucket) || !hostPart(grant.region) || !credentials || typeof credentials !== 'object'
105
+ || Object.keys(credentials).length !== 4
106
+ || !['secret_id', 'secret_key', 'session_token'].every(key => typeof credentials[key] === 'string' && credentials[key].trim())
107
+ || typeof credentials.expires_at !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(credentials.expires_at)) return false;
108
+ return Number.isFinite(Date.parse(credentials.expires_at)) && Date.parse(credentials.expires_at) === grant.expires_at;
109
+ }
110
+ module.exports = { upload };
@@ -0,0 +1,48 @@
1
+ const { options, required: requireArguments } = require('./arguments.cjs');
2
+ const { required, nullable, invalid, Failure } = require('./result.cjs');
3
+ const { positiveId } = require('./attendance-leave-ids.cjs');
4
+ const gateway = require('./gateway.cjs');
5
+
6
+ async function execute(space, action, args) {
7
+ const approval = action.endsWith('approval-form-people-search');
8
+ const resumption = action.startsWith('resumption-');
9
+ const student = space === 'student-leave';
10
+ const correction = space === 'attendance';
11
+ const extra = approval ? ['task_id'] : correction ? ['date', 'result_id'] : resumption ? ['leave_id'] : student ? ['class_id', 'student_id'] : [];
12
+ const selectors = approval || correction ? [] : ['program_code'];
13
+ const payload = options(args, [...selectors, 'field_key', 'keyword', ...extra, ...(student && resumption && !approval ? ['student_id'] : [])], [], [], true);
14
+ requireArguments(payload, ...selectors, 'field_key', 'keyword', ...extra);
15
+ if (approval && !positiveId(payload.task_id)) throw invalid('--task-id 必须是当前待办审批任务的有效 ID。');
16
+ if (!approval && resumption && (student ? !require('./student-leave-query.cjs').validId(payload.leave_id, false) : !require('./teacher-leave-query.cjs').validId(payload.leave_id, 'leave_'))) throw invalid('--leave-id 必须是原请假记录的有效 ID。');
17
+ if (correction && !approval && (!/^\d{4}-\d{2}-\d{2}$/.test(payload.date) || !Number.isFinite(Date.parse(payload.date))
18
+ || new Date(payload.date).toISOString().slice(0, 10) !== payload.date)) throw invalid('--date 必须为有效的 YYYY-MM-DD 日期。');
19
+ const purpose = correction ? 'TEACHER_CORRECTION' : (student ? 'STUDENT_' : 'TEACHER_') + (resumption ? 'RESUMPTION' : 'LEAVE');
20
+ try {
21
+ const result = await gateway.execute('attendance-leave:read', space + '/' + action, payload, {
22
+ tenant_id: required('string'), user_id: required('string'), purpose: required('string'),
23
+ field_key: required('string'), form_id: required('string'), exhaustive: required('boolean'),
24
+ ...(approval ? { task_id: required('string'), application_id: required('string') }
25
+ : correction ? { date: required('string'), result_id: required('string'), program_code: required('string') }
26
+ : { program_code: required('string'), class_id: nullable('string'), student_id: nullable('string'), leave_id: nullable('string') }),
27
+ candidates: required([{ tenant_id: required('string'), tenant_name: nullable('string'), user_id: required('string'),
28
+ user_name: required('string'), departments: nullable([{ department_id: required('string'), department_name: required('string') }]) }]),
29
+ });
30
+ const data = result.value.data;
31
+ const contextMismatch = approval ? data.task_id !== payload.task_id || !require('./attendance-leave-ids.cjs').applicationId(data.application_id, purpose)
32
+ : correction ? data.date !== payload.date || data.result_id !== payload.result_id
33
+ : data.program_code !== payload.program_code || ['class_id', 'student_id', 'leave_id'].some(key => (data[key] ?? null) !== (payload[key] ?? null));
34
+ if (contextMismatch || data.purpose !== purpose || data.field_key !== payload.field_key
35
+ || !positiveId(data.form_id) || data.exhaustive !== false || data.candidates.length > 10
36
+ || data.candidates.some(person => person.tenant_id !== data.tenant_id)
37
+ || new Set(data.candidates.map(person => person.user_id)).size !== data.candidates.length) {
38
+ const failure = new Failure(1, 'api', 'invalid_response', '人员搜索返回的办理场景、表单或候选身份不一致。');
39
+ if (result.value.request_id) failure.result.value.request_id = result.value.request_id;
40
+ throw failure;
41
+ }
42
+ return result;
43
+ } catch (failure) {
44
+ if (failure instanceof Failure) failure.result.value.error.details = { ...failure.result.value.error.details, ...payload };
45
+ throw failure;
46
+ }
47
+ }
48
+ module.exports = { execute };
@@ -0,0 +1,64 @@
1
+ const gateway = require('./gateway.cjs');
2
+
3
+ // Attachment diagnostics cannot erase a separately confirmed application outcome.
4
+ async function execute(scope, endpoint, payload, schema, business = '申请') {
5
+ const { config, token } = await require('./session.cjs').session(scope);
6
+ const envelope = await gateway.request(config, endpoint, token, payload);
7
+ const result = gateway.publicResult(envelope, schema);
8
+ project(envelope, result, business);
9
+ return result;
10
+ }
11
+
12
+ function project(envelope, result, business) {
13
+ let damaged = false;
14
+ const unknown = () => { damaged = true; return null; };
15
+ const text = value => typeof value === 'string' && value.trim() ? value : unknown();
16
+ const status = value => ['NOT_EXECUTED', 'UNCONFIRMED', 'COMPLETED'].includes(value) ? value : unknown();
17
+ const verified = value => typeof value === 'boolean' ? value : unknown();
18
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
19
+ const copies = values => !Array.isArray(values) ? unknown() : values.map(value => {
20
+ if (!object(value)) return unknown();
21
+ const item = {
22
+ source_object_key: text(value.source_object_key), object_key: text(value.object_key), file_name: text(value.file_name),
23
+ size_bytes: Number.isSafeInteger(value.size_bytes) && value.size_bytes >= 0 ? value.size_bytes : unknown(),
24
+ execution_status: status(value.execution_status), verified: verified(value.verified),
25
+ };
26
+ if (item.verified && item.execution_status !== 'COMPLETED') item.verified = unknown();
27
+ return item;
28
+ });
29
+ const values = envelope.data.attachment_preparations;
30
+ result.value.data.attachment_preparations = values == null ? null : !Array.isArray(values) ? unknown() : values.map(value => {
31
+ if (!object(value)) return unknown();
32
+ const item = {
33
+ field_key: text(value.field_key), group_id: value.group_id == null ? null : text(value.group_id),
34
+ group_execution_status: status(value.group_execution_status), verified: verified(value.verified), copies: copies(value.copies),
35
+ };
36
+ if (Object.hasOwn(value, 'input_parent_field_key') || Object.hasOwn(value, 'input_row_index')) {
37
+ const topLevel = value.input_parent_field_key == null && value.input_row_index == null;
38
+ const row = typeof value.input_parent_field_key === 'string' && value.input_parent_field_key.trim()
39
+ && Number.isSafeInteger(value.input_row_index) && value.input_row_index >= 0;
40
+ item.input_parent_field_key = topLevel ? null : row ? value.input_parent_field_key : unknown();
41
+ item.input_row_index = topLevel ? null : row ? value.input_row_index : unknown();
42
+ }
43
+ if (Object.prototype.hasOwnProperty.call(value, 'retained')) {
44
+ const seen = new Set();
45
+ item.retained = !Array.isArray(value.retained) ? unknown() : value.retained.map(file => {
46
+ if (!object(file) || typeof file.media_id !== 'string' || !file.media_id.trim() || seen.has(file.media_id)) return unknown();
47
+ seen.add(file.media_id);
48
+ return { media_id: file.media_id, file_name: text(file.file_name), file_type: text(file.file_type),
49
+ size_bytes: Number.isSafeInteger(file.size_bytes) && file.size_bytes >= 0 && file.size_bytes <= 200 * 1024 * 1024 ? file.size_bytes : unknown() };
50
+ });
51
+ }
52
+ if (Object.prototype.hasOwnProperty.call(value, 'group_codes'))
53
+ item.group_codes = Array.isArray(value.group_codes) && value.group_codes.every(code => typeof code === 'string' && code.trim())
54
+ ? value.group_codes : unknown();
55
+ if (item.group_execution_status === 'COMPLETED' && !item.group_id) item.group_execution_status = unknown();
56
+ const empty = item.group_execution_status === 'NOT_EXECUTED' && item.group_id == null && item.copies?.length === 0
57
+ && (!Object.hasOwn(item, 'retained') || item.retained?.length === 0);
58
+ if (item.verified && item.group_execution_status !== 'COMPLETED' && !empty) item.verified = unknown();
59
+ return item;
60
+ });
61
+ if (damaged) result.value.data.attachment_preparation_error = `附件回执部分缺失或无效,对应事实标为未知;已确认的${business}和其他附件事实保留,不要自动重试或删除。`;
62
+ return damaged;
63
+ }
64
+ module.exports = { execute, project };