@xinhe-oa/cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,908 @@
1
+ // 汇报域:`report rule …`(规则配置面)+ `report --…`(报告面)—— docs/38 §9、契约 §2「汇报」。
2
+ //
3
+ // CLI 在这条链路上只是**薄封装**:模板校验、收件人解析、周期开窗、状态机全在服务端
4
+ // (`services/report_*`)。所以这一层只做三件事:
5
+ //
6
+ // 1. **参数即约束**:动作与组合约束本地判定,拼错就是退出码 2、**一个请求都不发**;
7
+ // 2. **参数形状**:JSON 旗标只校验"是不是合法 JSON"(语义错误由服务端
8
+ // `report.content_invalid` 原样展示 —— CLI 不重复实现模板校验,否则两套语义);
9
+ // 3. **写操作带幂等键**:建规则 / 存草稿 / 提交 / 催交由 CLI 自动生成
10
+ // (`report --submit` 同周期重跑返回首次结果,不会产生第二份)。
11
+ //
12
+ // ## 为什么 `report rule --create` 会顺手开当期(服务端行为,CLI 只如实带出)
13
+ //
14
+ // 开窗原本只有服务端 `report_tick`(cron)能做,而部署面没有任何调度配置 ——
15
+ // 症状是"规则建好了、命令全返回成功,但 `report --due` 永远为空、`--save/--submit`
16
+ // 一律 `report.period_not_open`",排障的人只会以为"日报功能是坏的"(docs/05 决策 74①)。
17
+ // 服务端从 2026-10-15 起在 `POST /reports/rules` 之后调用 `_open_current_period`,
18
+ // 于是响应多出一段 `period: {period_key, due_at, assignments_created, opened}`:
19
+ //
20
+ // - `period_key` 可以直接喂给 `report --save/--submit`,所以 CLI 把它写进 `next_step`,
21
+ // 省掉"自己猜周期键"这一步;
22
+ // - **只开当期、不补开历史**(`backfill_max=1`:刚建的规则补 30 期等于让全公司补写
23
+ // 一个月前的工作日志),历史补开仍归 tick;
24
+ // - `assignments_created=0` 是**要紧的信号**:说明 `fillers` 没展开出任何人
25
+ // (这条规则"谁都不该交"),十有八九是 `fillers` 写窄了。CLI **不额外造字段**
26
+ // 去解释它(`--json` 的 `data` 形状必须与 Rust 版逐字一致),只保证原样带出。
27
+ //
28
+ // ## 为什么显式旗标要覆盖 `--from` 的同名字段
29
+ //
30
+ // `--from` 是"一次给一整块 JSON"的便捷写法,`--template` / `--fillers` /
31
+ // `--recipients` / `--deadline` 是逐字段写法,两者**同时给时更具体的那个赢**
32
+ // (参数面与 `--update` 逐条对齐)。反过来的话,`--create --from '{…}' --fillers '{…}'`
33
+ // 会静默发出 `--from` 里的旧 `fillers` —— 而 `fillers` 决定"谁该交",
34
+ // 发错的症状同样是"员工 `report --due` 永远为空、看起来像功能坏了",命令却一个错都不报
35
+ // (docs/05 决策 74⑤ 修的就是这类静默失效)。写入顺序因此是刻意的:
36
+ // 先铺 `--from`,再写三个骨架字段(`key`/`name`/`cadence`,永远以旗标为准),
37
+ // 最后盖四个 JSON 旗标。
38
+ //
39
+ // ## 为什么 `--confirm` / `--reject` 与 `approval --approve/--reject` 是同一底座
40
+ //
41
+ // 报告的"确认"不是汇报域自己的状态机:服务端用的是通用审批 `base.approval`
42
+ // (`kind=report_confirm`,RPT-004),报告详情里的 `approval_id` 就是那一行。
43
+ // 所以这两条命令是**语义糖**:先 `GET /reports/{id}` 取 `approval_id`,再走
44
+ // `POST /approvals/{id}/approve|reject` —— 与 `approval --approve/--reject` 打的是
45
+ // 同一行、同一组端点。两条入口因此天然一致(一边确认了,另一边立刻看得到),
46
+ // 也不会出现"CLI 自己造一套确认语义、与服务端的审批状态打架"。
47
+ // 拿不到 `approval_id`(比如规则的 `require_confirm` 没开)时**不猜**:
48
+ // 回 `cli.report_not_confirmable`(退出码 5)并指路 `report --show`。
49
+
50
+ import fs from 'node:fs';
51
+
52
+ import { CliError, Kind, exactlyOne } from '../core/error.js';
53
+ import { flagValue, intFlag } from '../core/args.js';
54
+ import { dataOf, idempotencyKey, requestSpec } from '../core/runtime.js';
55
+ import { rustIoErrorText } from '../core/util.js';
56
+ import { sortKeysDeep } from '../core/util.js';
57
+
58
+ /**
59
+ * 报告实例状态(与 `args.rs` 的 `REPORT_STATUSES` / `base.report_instance` 一致;`all` 单独处理)。
60
+ *
61
+ * 注意:`core/values.js` 里的同名常量是**起草期**的旧值
62
+ * (`pending|filled|…|waived`、cadence 缺 `half_yearly|yearly`),与 0.1.7 的 Rust
63
+ * 行为不一致 —— 那组词其实更像 `report_assignment` 的状态。本域要求"与 Rust 逐字等价",
64
+ * 所以这里按 Rust 的 `args.rs` 重建;`core/` 不在本域可改范围内。
65
+ */
66
+ const REPORT_STATUSES = ['draft', 'submitted', 'late', 'missing', 'confirmed', 'rejected', 'reopened'];
67
+ /** 周期档(与 `services/report_periods.CADENCES` / `args.rs` 一致)。 */
68
+ const REPORT_CADENCES = ['daily', 'weekly', 'monthly', 'quarterly', 'half_yearly', 'yearly'];
69
+
70
+ /** `report --<动作>` 这一层能出现的"非动作"旗标(Rust `ReportArgs::only` 的检查清单,顺序照抄)。 */
71
+ const REPORT_SIDE_FLAGS = [
72
+ '--rule',
73
+ '--period',
74
+ '--content',
75
+ '--cc',
76
+ '--mine',
77
+ '--team',
78
+ '--dept',
79
+ '--status',
80
+ '--with-children',
81
+ '--include-sub',
82
+ '--note',
83
+ ];
84
+
85
+ /** `report rule --<动作>` 这一层能出现的"非动作"旗标(Rust `ReportRuleArgs::reject_extra`,顺序照抄)。 */
86
+ const RULE_SIDE_FLAGS = [
87
+ '--key',
88
+ '--name',
89
+ '--from',
90
+ '--rule',
91
+ '--period',
92
+ '--template',
93
+ '--fillers',
94
+ '--recipients',
95
+ '--deadline',
96
+ '--version',
97
+ ];
98
+
99
+ /**
100
+ * `report rule …` 与 `report --…` 共用一个处理函数(命令表把两者都指到这里)。
101
+ *
102
+ * 分支判据是**命令名**(`report rule` 是第一层子命令),不是"args 里有没有 `--rule`":
103
+ * 报告面也用 `--rule`(指定规则),拿它当开关会把 `report --due --rule x` 误判成规则面。
104
+ */
105
+ export async function handleReport({ args, command, runtime }) {
106
+ const domain = command.name.split(' ')[1];
107
+ if (domain === 'rule') return ruleDomain(args, runtime);
108
+ return reportDomain(args, runtime);
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // 参数面(Rust `args.rs` 的 `ReportRuleArgs::validate` / `ReportArgs::action` 逐条对齐)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * `report rule` 的动作分派 + 组合约束。
117
+ *
118
+ * 顺序照抄 Rust:**先本地校验完,再发请求**。错误提示先报哪一条是有讲究的
119
+ * (`--show "" --key k` 报的是"不接受 --key"而不是"值不能为空"),照抄才不会
120
+ * 让判卷拿到的 `message` 漂移。
121
+ */
122
+ async function ruleDomain(args, runtime) {
123
+ const action = exactlyOne(
124
+ [
125
+ ['--list', isGiven(args, '--list')],
126
+ ['--show', flagValue(args, '--show') !== null],
127
+ ['--create', isGiven(args, '--create')],
128
+ ['--update', flagValue(args, '--update') !== null],
129
+ ['--archive', flagValue(args, '--archive') !== null],
130
+ ['--preview', isGiven(args, '--preview')],
131
+ ],
132
+ 'report rule',
133
+ '六选一:--list | --show <RULE_ID|KEY> | --create | --update <RULE_ID> | --archive <RULE_ID> | --preview',
134
+ );
135
+
136
+ if (action === '--list') {
137
+ cadenceValue(args); // 非法档位本地就挡下(不发请求)
138
+ rejectExtra(args, 'report rule --list', ['--cadence']);
139
+ return ruleList(runtime, args);
140
+ }
141
+ if (action === '--show') {
142
+ rejectExtra(args, 'report rule --show', []);
143
+ return ruleShow(runtime, requiredValue(flagValue(args, '--show'), '--show <RULE_ID|KEY>'));
144
+ }
145
+ if (action === '--create') {
146
+ if (isBlank(flagValue(args, '--key'))) {
147
+ throw CliError.usage('`report rule --create` 必须给 --key <KEY>').withHint(
148
+ '例:xinhe-oa-cli report rule --create --key work_daily --name 工作日报 --cadence daily',
149
+ );
150
+ }
151
+ if (isBlank(flagValue(args, '--name'))) {
152
+ throw CliError.usage('`report rule --create` 必须给 --name <NAME>').withHint(
153
+ '例:xinhe-oa-cli report rule --create --key work_daily --name 工作日报 --cadence daily',
154
+ );
155
+ }
156
+ if (cadenceValue(args) === null) {
157
+ throw CliError.usage('`report rule --create` 必须给 --cadence <CADENCE>').withHint(
158
+ '例:--cadence daily',
159
+ );
160
+ }
161
+ // 四个 JSON 旗标与 `--from` 的互斥面**不**在这里拦:它们是 `--create` 的合法参数,
162
+ // 更具体的写法覆盖 `--from`(见文件头)。Rust 0.1.7 的 `validate()` 忘了把这四个
163
+ // 旗标加进白名单(`apply_rule_json_flags` 与 `schema` 自描述都是按"接受"写的),
164
+ // 于是那边会把 `--create --fillers …` 判成用法错误 —— 这是 Rust 侧的漏项,
165
+ // 本实现按契约 docs/31「汇报」行与 `schema report --json` 的参数面接受它们。
166
+ rejectExtra(args, 'report rule --create', [
167
+ '--key',
168
+ '--name',
169
+ '--from',
170
+ '--cadence',
171
+ '--template',
172
+ '--fillers',
173
+ '--recipients',
174
+ '--deadline',
175
+ ]);
176
+ return ruleCreate(runtime, args);
177
+ }
178
+ if (action === '--update') {
179
+ const id = requiredValue(flagValue(args, '--update'), '--update <RULE_ID>');
180
+ const touched =
181
+ ['--template', '--fillers', '--recipients', '--deadline'].some(
182
+ (flag) => flagValue(args, flag) !== null,
183
+ ) || flagValue(args, '--version') !== null;
184
+ if (!touched) {
185
+ throw CliError.usage(
186
+ '`report rule --update` 至少要给一个要改的字段:--template / --fillers / --recipients / --deadline',
187
+ ).withHint(
188
+ '例:xinhe-oa-cli report rule --update rrl_123 --deadline \'{"anchor":"period_end","offset":"PT10H"}\'',
189
+ );
190
+ }
191
+ rejectExtra(args, 'report rule --update', [
192
+ '--template',
193
+ '--fillers',
194
+ '--recipients',
195
+ '--deadline',
196
+ '--version',
197
+ ]);
198
+ return ruleUpdate(runtime, id, args);
199
+ }
200
+ if (action === '--archive') {
201
+ const id = requiredValue(flagValue(args, '--archive'), '--archive <RULE_ID>');
202
+ rejectExtra(args, 'report rule --archive', []);
203
+ return ruleArchive(runtime, id);
204
+ }
205
+ // `--preview`:Rust 只看"有没有给 --rule"(不看空白),空白时拼出的路径是
206
+ // `/reports/rules//preview` —— 服务端会拒,但那是服务端的事实,CLI 不替它猜。
207
+ if (args['--rule'] === undefined) {
208
+ throw CliError.usage('`report rule --preview` 必须给 --rule <RULE_ID|KEY>').withHint(
209
+ '例:xinhe-oa-cli report rule --preview --rule work_daily --period D:2026-10-07',
210
+ );
211
+ }
212
+ rejectExtra(args, 'report rule --preview', ['--rule', '--period']);
213
+ return rulePreview(runtime, args);
214
+ }
215
+
216
+ /** `report --<动作>` 的动作分派 + 组合约束(Rust `ReportArgs::action`)。 */
217
+ async function reportDomain(args, runtime) {
218
+ const picked = exactlyOne(
219
+ [
220
+ ['--due', isGiven(args, '--due')],
221
+ ['--save', isGiven(args, '--save')],
222
+ ['--submit', isGiven(args, '--submit')],
223
+ ['--list', isGiven(args, '--list')],
224
+ ['--show', flagValue(args, '--show') !== null],
225
+ ['--stats', isGiven(args, '--stats')],
226
+ ['--remind', isGiven(args, '--remind')],
227
+ ['--reopen', flagValue(args, '--reopen') !== null],
228
+ ['--waive', flagValue(args, '--waive') !== null],
229
+ ['--confirm', flagValue(args, '--confirm') !== null],
230
+ ['--reject', flagValue(args, '--reject') !== null],
231
+ ],
232
+ 'report',
233
+ '例:xinhe-oa-cli report --due --json(配置规则用 `report rule --list`)',
234
+ );
235
+
236
+ if (picked === '--due') {
237
+ only(args, 'report --due', ['--rule', '--period']);
238
+ return due(runtime, args);
239
+ }
240
+ if (picked === '--save') {
241
+ only(args, 'report --save', ['--rule', '--period', '--content']);
242
+ requireRule(args, 'report --save');
243
+ requirePeriod(args, 'report --save');
244
+ requireContent(args, 'report --save');
245
+ return save(runtime, args);
246
+ }
247
+ if (picked === '--submit') {
248
+ only(args, 'report --submit', ['--rule', '--period', '--content', '--cc']);
249
+ requireRule(args, 'report --submit');
250
+ requirePeriod(args, 'report --submit');
251
+ requireContent(args, 'report --submit');
252
+ return submit(runtime, args);
253
+ }
254
+ if (picked === '--list') {
255
+ only(args, 'report --list', ['--rule', '--period', '--mine', '--team', '--dept', '--status']);
256
+ // 三份清单不能混看:`--mine` 是我自己的、`--team` 是我的汇报子树、`--dept` 是整个部门。
257
+ const views = [isGiven(args, '--mine'), isGiven(args, '--team'), isGiven(args, '--dept')].filter(
258
+ Boolean,
259
+ ).length;
260
+ if (views > 1) {
261
+ throw CliError.usage('`--mine` / `--team` / `--dept` 只能给一个(三份清单,不能混看)').withHint(
262
+ '例:xinhe-oa-cli report --list --team --period M:2026-10 --json',
263
+ );
264
+ }
265
+ statusFilter(args);
266
+ return list(runtime, args);
267
+ }
268
+ if (picked === '--show') {
269
+ only(args, 'report --show', ['--with-children']);
270
+ return show(runtime, requiredValue(flagValue(args, '--show'), '--show <RPT_ID>'), args);
271
+ }
272
+ if (picked === '--stats') {
273
+ only(args, 'report --stats', ['--rule', '--period', '--dept', '--include-sub']);
274
+ requireRule(args, 'report --stats');
275
+ requirePeriod(args, 'report --stats');
276
+ return stats(runtime, args);
277
+ }
278
+ if (picked === '--remind') {
279
+ only(args, 'report --remind', ['--rule', '--period']);
280
+ requireRule(args, 'report --remind');
281
+ requirePeriod(args, 'report --remind');
282
+ return remind(runtime, args);
283
+ }
284
+ if (picked === '--reopen') {
285
+ only(args, 'report --reopen', ['--note']);
286
+ requireNote(args, 'report --reopen');
287
+ return reopen(runtime, requiredValue(flagValue(args, '--reopen'), '--reopen <RPT_ID>'), args);
288
+ }
289
+ if (picked === '--waive') {
290
+ only(args, 'report --waive', ['--note']);
291
+ requireNote(args, 'report --waive');
292
+ return waive(
293
+ runtime,
294
+ requiredValue(flagValue(args, '--waive'), '--waive <RPT_ID|ASSIGNMENT_ID>'),
295
+ args,
296
+ );
297
+ }
298
+ if (picked === '--confirm') {
299
+ only(args, 'report --confirm', []);
300
+ return decide(
301
+ runtime,
302
+ 'approve',
303
+ requiredValue(flagValue(args, '--confirm'), '--confirm <RPT_ID>'),
304
+ null,
305
+ );
306
+ }
307
+ only(args, 'report --reject', ['--note']);
308
+ // 顺序照抄 Rust:先判理由("驳回不给理由"是最常见的手误),再判 id 是不是空的。
309
+ const rejectNote = requireNote(args, 'report --reject');
310
+ return decide(
311
+ runtime,
312
+ 'reject',
313
+ requiredValue(flagValue(args, '--reject'), '--reject <RPT_ID>'),
314
+ rejectNote,
315
+ );
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // report rule …
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /** `report rule --list [--cadence C]` → `GET /reports/rules[?cadence=]`。 */
323
+ async function ruleList(runtime, args) {
324
+ const query = [];
325
+ const cadence = cadenceValue(args);
326
+ if (cadence !== null) query.push(['cadence', cadence]);
327
+ const spec = requestSpec({ query });
328
+ if (runtime.dryRun) return runtime.dryRunReport('GET', '/reports/rules', spec);
329
+ const response = await runtime.sendAuthed('GET', '/reports/rules', spec);
330
+ runtime.finishFrom(response, dataOf(response));
331
+ }
332
+
333
+ /** `report rule --show <rule_id|key>` → `GET /reports/rules/{id}`(收件人名已由服务端展开)。 */
334
+ async function ruleShow(runtime, id) {
335
+ const path = `/reports/rules/${id}`;
336
+ const spec = requestSpec();
337
+ if (runtime.dryRun) return runtime.dryRunReport('GET', path, spec);
338
+ const response = await runtime.sendAuthed('GET', path, spec);
339
+ runtime.finishFrom(response, dataOf(response));
340
+ }
341
+
342
+ /**
343
+ * 规则配置的四个 JSON 旗标 → 请求体字段(`--create` 与 `--update` **共用一处**)。
344
+ *
345
+ * 为什么必须共用:`--create` 原本只读 `--from`,这四个旗标**静默失效** ——
346
+ * `report rule --create --fillers '{…}'` 建出一条"谁都不该交"的规则,症状是
347
+ * 员工 `report --due` 永远为空、日报功能看起来是坏的,而命令不报任何错。
348
+ * 静默忽略一个明确给出来的参数,是这里最贵的坑(docs/05 决策 74⑤)。
349
+ */
350
+ function applyRuleJsonFlags(body, args) {
351
+ for (const [flag, field] of [
352
+ ['--template', 'template'],
353
+ ['--fillers', 'fillers'],
354
+ ['--recipients', 'recipients'],
355
+ ['--deadline', 'deadline'],
356
+ ]) {
357
+ const raw = flagValue(args, flag);
358
+ if (raw !== null) body[field] = parseJsonFlag(raw, flag);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * `report rule --create --key <k> --name <n> --cadence <c> [--from <json>]
364
+ * [--template <json>] [--fillers <json>] [--recipients <json>] [--deadline <json>]`
365
+ * → `POST /reports/rules`(幂等键自动生成)。
366
+ */
367
+ async function ruleCreate(runtime, args) {
368
+ const fromRaw = flagValue(args, '--from');
369
+ const body = fromRaw !== null ? { ...parseJsonObject(fromRaw, '--from') } : {};
370
+ // 三个"骨架字段"永远以旗标为准(`--from` 只补其余字段)—— 顺序敏感:
371
+ // 必须在 `--from` 之后、四个 JSON 旗标之前。
372
+ body.key = trimmed(flagValue(args, '--key'));
373
+ body.name = trimmed(flagValue(args, '--name'));
374
+ body.cadence = cadenceValue(args) ?? '';
375
+ applyRuleJsonFlags(body, args);
376
+
377
+ const spec = requestSpec({
378
+ body: sortKeysDeep(body),
379
+ idempotencyKey: idempotencyKey('report-rule-create'),
380
+ });
381
+ if (runtime.dryRun) return runtime.dryRunReport('POST', '/reports/rules', spec);
382
+ const response = await runtime.sendAuthed('POST', '/reports/rules', spec);
383
+ const data = dataOf(response);
384
+ if (isPlainObject(data)) {
385
+ // 服务端建规则时会**自动开当前周期**,所以预览要用它回的 `period.period_key`;
386
+ // 拿不到(老服务端)才退回让调用方自己给 —— 兼容路径,不是默认路径。
387
+ const period = data.period;
388
+ const periodKey =
389
+ isPlainObject(period) && typeof period.period_key === 'string' ? period.period_key : null;
390
+ data.next_step =
391
+ periodKey !== null
392
+ ? `解析预演一下:xinhe-oa-cli report rule --preview --rule <KEY> --period ${periodKey}`
393
+ : '解析预演一下再发:xinhe-oa-cli report rule --preview --rule <KEY> --period <PERIOD_KEY>';
394
+ }
395
+ runtime.finishFrom(response, data);
396
+ }
397
+
398
+ /** `report rule --update <rule_id> [--template] [--fillers] [--recipients] [--deadline] [--version N]`。 */
399
+ async function ruleUpdate(runtime, id, args) {
400
+ const body = {};
401
+ applyRuleJsonFlags(body, args);
402
+ // 乐观锁:不传 = 不带 version(由服务端判定);传了就原样带上(撞车 = write.version_conflict → 5)。
403
+ const version = intFlag(args, '--version', {
404
+ min: -9223372036854775808,
405
+ max: 9223372036854775807,
406
+ fallback: null,
407
+ });
408
+ if (version !== null) body.version = version;
409
+ const path = `/reports/rules/${id}`;
410
+ const spec = requestSpec({ body: sortKeysDeep(body) });
411
+ if (runtime.dryRun) return runtime.dryRunReport('PATCH', path, spec);
412
+ const response = await runtime.sendAuthed('PATCH', path, spec);
413
+ const data = dataOf(response);
414
+ if (isPlainObject(data)) {
415
+ data.note =
416
+ '改版只对**下个周期**生效:在途周期已开窗,实例冻结的是当时的规则版本(docs/38 §4.5)';
417
+ }
418
+ runtime.finishFrom(response, data);
419
+ }
420
+
421
+ /** `report rule --archive <rule_id>`:**高风险写,必须 `--yes`**。 */
422
+ async function ruleArchive(runtime, id) {
423
+ runtime.requireYes(
424
+ 'report rule --archive(归档后不再开新周期;有未关窗周期时服务端会拒绝)',
425
+ `xinhe-oa-cli report rule --archive ${id}`,
426
+ );
427
+ const path = `/reports/rules/${id}/archive`;
428
+ const spec = requestSpec();
429
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
430
+ const response = await runtime.sendAuthed('POST', path, spec);
431
+ runtime.finishFrom(response, dataOf(response));
432
+ }
433
+
434
+ /** `report rule --preview --rule <r> [--period <p>]`:解析预演,**不落库**。 */
435
+ async function rulePreview(runtime, args) {
436
+ const rule = trimmed(flagValue(args, '--rule'));
437
+ const body = {};
438
+ const period = flagValue(args, '--period');
439
+ if (!isBlank(period)) body.period_key = trimmed(period);
440
+ const path = `/reports/rules/${rule}/preview`;
441
+ const spec = requestSpec({ body: sortKeysDeep(body) });
442
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
443
+ const response = await runtime.sendAuthed('POST', path, spec);
444
+ runtime.finishFrom(response, dataOf(response));
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // 报告面
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * `report --due [--rule <r>] [--period <p>]`:待我填(含 `pending` 与 `draft`)。
453
+ *
454
+ * 走 `/reports/assignments`("该交而没交"是**一个事实**,`report_assignment` 是它的
455
+ * 真相源),`mine=true` 把它夹到"我"身上 —— 规则详情与草稿由服务端在这个视角里并进来。
456
+ */
457
+ async function due(runtime, args) {
458
+ const query = [['mine', 'true']];
459
+ pushQuery(query, 'rule_id', flagValue(args, '--rule'));
460
+ pushQuery(query, 'period_key', flagValue(args, '--period'));
461
+ const spec = requestSpec({ query });
462
+ if (runtime.dryRun) return runtime.dryRunReport('GET', '/reports/assignments', spec);
463
+ const response = await runtime.sendAuthed('GET', '/reports/assignments', spec);
464
+ runtime.finishFrom(response, dataOf(response));
465
+ }
466
+
467
+ /** `report --save --rule <r> --period <p> --content <json>`:存草稿。 */
468
+ async function save(runtime, args) {
469
+ const rule = requireRule(args, 'report --save');
470
+ const period = requirePeriod(args, 'report --save');
471
+ const content = parseJsonFlag(requireContent(args, 'report --save'), '--content');
472
+ const path = `/reports/${rule}/${period}`;
473
+ const spec = requestSpec({
474
+ body: sortKeysDeep({ content }),
475
+ idempotencyKey: idempotencyKey('report-save'),
476
+ });
477
+ if (runtime.dryRun) return runtime.dryRunReport('PUT', path, spec);
478
+ const response = await runtime.sendAuthed('PUT', path, spec);
479
+ const data = dataOf(response);
480
+ if (isPlainObject(data)) {
481
+ data.next_step = `提交:xinhe-oa-cli report --submit --rule ${rule} --period ${period} --content <JSON>`;
482
+ }
483
+ runtime.finishFrom(response, data);
484
+ }
485
+
486
+ /** `report --submit --rule <r> --period <p> --content <json> [--cc <uids>]`:提交(幂等)。 */
487
+ async function submit(runtime, args) {
488
+ const rule = requireRule(args, 'report --submit');
489
+ const period = requirePeriod(args, 'report --submit');
490
+ const content = parseJsonFlag(requireContent(args, 'report --submit'), '--content');
491
+ const body = { content };
492
+ const cc = ccUserIds(args);
493
+ if (cc.length > 0) body.cc = cc;
494
+ const path = `/reports/${rule}/${period}/submit`;
495
+ const spec = requestSpec({
496
+ body: sortKeysDeep(body),
497
+ // 幂等键必填:同周期重复提交返回首次结果,不产生第二份。
498
+ idempotencyKey: idempotencyKey('report-submit'),
499
+ });
500
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
501
+ const response = await runtime.sendAuthed('POST', path, spec);
502
+ const data = dataOf(response);
503
+ if (isPlainObject(data)) {
504
+ data.note = '提交后作者不可改:要改只能由上级/管理员 report --reopen(理由必填、留痕)';
505
+ }
506
+ runtime.finishFrom(response, data);
507
+ }
508
+
509
+ /**
510
+ * `report --list [--mine|--team|--dept <d>] [--rule] [--period] [--status]`。
511
+ *
512
+ * 三条路各自一个端点:`--team` → `/reports/team?scope=managed`,
513
+ * `--dept` → `/reports/team?scope=dept&dept_id=`,其余 → `/reports`。
514
+ */
515
+ async function list(runtime, args) {
516
+ const status = statusFilter(args);
517
+ let path;
518
+ const query = [];
519
+ const dept = flagValue(args, '--dept');
520
+ if (isGiven(args, '--team')) {
521
+ path = '/reports/team';
522
+ query.push(['scope', 'managed']);
523
+ } else if (!isBlank(dept)) {
524
+ path = '/reports/team';
525
+ query.push(['scope', 'dept']);
526
+ query.push(['dept_id', trimmed(dept)]);
527
+ } else {
528
+ path = '/reports';
529
+ if (isGiven(args, '--mine')) query.push(['mine', 'true']);
530
+ }
531
+ pushQuery(query, 'rule_id', flagValue(args, '--rule'));
532
+ pushQuery(query, 'period_key', flagValue(args, '--period'));
533
+ if (status !== null) query.push(['status', status]);
534
+ const spec = requestSpec({ query });
535
+ if (runtime.dryRun) return runtime.dryRunReport('GET', path, spec);
536
+ const response = await runtime.sendAuthed('GET', path, spec);
537
+ runtime.finishFrom(response, dataOf(response));
538
+ }
539
+
540
+ /** `report --show <rpt_id> [--with-children]`:详情(可选带汇总引用的子报告)。 */
541
+ async function show(runtime, id, args) {
542
+ const withChildren = isGiven(args, '--with-children');
543
+ const path = `/reports/${id}`;
544
+ const childrenPath = `/reports/${id}/children`;
545
+ const plain = requestSpec();
546
+ if (runtime.dryRun) {
547
+ if (!withChildren) return runtime.dryRunReport('GET', path, plain);
548
+ // 真跑是两步,dry-run 必须把两步都列出来(只列第一个会让人以为只发一次)。
549
+ return runtime.finish(
550
+ {
551
+ dry_run: true,
552
+ requests: [
553
+ runtime.dryRunRequest('GET', path, plain),
554
+ runtime.dryRunRequest('GET', childrenPath, plain),
555
+ ],
556
+ note: '--dry-run:没有发出任何请求。真跑会读详情 + 子报告引用两个 GET',
557
+ },
558
+ null,
559
+ null,
560
+ );
561
+ }
562
+ const response = await runtime.sendAuthed('GET', path, plain);
563
+ const data = dataOf(response);
564
+ if (withChildren) {
565
+ const children = await runtime.sendAuthed('GET', childrenPath, plain);
566
+ const childrenData = dataOf(children);
567
+ // 服务端给的是 `{children: [...]}`;并进详情时把它铺平成一个数组,形状更稳。
568
+ const merged =
569
+ isPlainObject(childrenData) && childrenData.children !== undefined
570
+ ? childrenData.children
571
+ : childrenData;
572
+ if (isPlainObject(data)) data.children = merged;
573
+ }
574
+ runtime.finishFrom(response, data);
575
+ }
576
+
577
+ /** `report --stats --rule <r> --period <p> [--dept <d>] [--include-sub]`:应交 / 已交 / 未交。 */
578
+ async function stats(runtime, args) {
579
+ const rule = requireRule(args, 'report --stats');
580
+ const period = requirePeriod(args, 'report --stats');
581
+ const query = [
582
+ ['rule_id', rule],
583
+ ['period_key', period],
584
+ ];
585
+ pushQuery(query, 'dept_id', flagValue(args, '--dept'));
586
+ if (isGiven(args, '--include-sub')) query.push(['include_sub', 'true']);
587
+ const spec = requestSpec({ query });
588
+ if (runtime.dryRun) return runtime.dryRunReport('GET', '/reports/assignments', spec);
589
+ const response = await runtime.sendAuthed('GET', '/reports/assignments', spec);
590
+ runtime.finishFrom(response, dataOf(response));
591
+ }
592
+
593
+ /** `report --remind --rule <r> --period <p>`:手动催交(**只发事件**)。 */
594
+ async function remind(runtime, args) {
595
+ const rule = requireRule(args, 'report --remind');
596
+ const period = requirePeriod(args, 'report --remind');
597
+ const spec = requestSpec({
598
+ body: sortKeysDeep({ rule_id: rule, period_key: period }),
599
+ idempotencyKey: idempotencyKey('report-remind'),
600
+ });
601
+ if (runtime.dryRun) return runtime.dryRunReport('POST', '/reports/remind', spec);
602
+ const response = await runtime.sendAuthed('POST', '/reports/remind', spec);
603
+ const data = dataOf(response);
604
+ if (isPlainObject(data)) {
605
+ data.note =
606
+ '平台只发事件(report.reminded),送达交给企业自己的工作流;同一 (rule, period, actor) 1 小时内只发一条';
607
+ }
608
+ runtime.finishFrom(response, data);
609
+ }
610
+
611
+ /** `report --reopen <rpt_id> --note <n>`:**高风险写,必须 `--yes`;理由必填**。 */
612
+ async function reopen(runtime, id, args) {
613
+ const note = requireNote(args, 'report --reopen');
614
+ runtime.requireYes(
615
+ 'report --reopen(退回后作者可重写,已提交版本留痕)',
616
+ `xinhe-oa-cli report --reopen ${id} --note "${note}"`,
617
+ );
618
+ const path = `/reports/${id}/reopen`;
619
+ const spec = requestSpec({ body: sortKeysDeep({ note }) });
620
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
621
+ const response = await runtime.sendAuthed('POST', path, spec);
622
+ runtime.finishFrom(response, dataOf(response));
623
+ }
624
+
625
+ /**
626
+ * `report --waive <rpt_id|assignment_id> --note <n>`:**高风险写,必须 `--yes`;理由必填**。
627
+ *
628
+ * 两种 id 走各自的子资源:`ras_*` 是 `report_assignment`(应交行),`rpt_*` 是报告实例。
629
+ * 免交是"该交而免交"的事实,落在 assignment 上;报告实例上则回填 `waived`。
630
+ */
631
+ async function waive(runtime, id, args) {
632
+ const note = requireNote(args, 'report --waive');
633
+ runtime.requireYes(
634
+ 'report --waive(免交必须留理由:谁免的、为什么)',
635
+ `xinhe-oa-cli report --waive ${id} --note "${note}"`,
636
+ );
637
+ const path = id.startsWith('ras_')
638
+ ? `/reports/assignments/${id}/waive`
639
+ : `/reports/${id}/waive`;
640
+ const spec = requestSpec({ body: sortKeysDeep({ note }) });
641
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
642
+ const response = await runtime.sendAuthed('POST', path, spec);
643
+ runtime.finishFrom(response, dataOf(response));
644
+ }
645
+
646
+ /**
647
+ * `report --confirm` / `--reject`:读详情的 `approval_id` → 走 `base.approval`。
648
+ *
649
+ * `action` 就是审批端点上的那一段(`approve` / `reject`),与
650
+ * `approval --approve/--reject` 打的是同一行 `base.approval`,两条入口互不冲突。
651
+ */
652
+ async function decide(runtime, action, id, note) {
653
+ if (action === 'reject') {
654
+ runtime.requireYes(
655
+ 'report --reject(驳回后实例回 reopened,理由写进 confirm_note)',
656
+ `xinhe-oa-cli report --reject ${id} --note "<理由>"`,
657
+ );
658
+ } else {
659
+ runtime.requireYes(
660
+ 'report --confirm(确认后报告进入 confirmed,不可再改)',
661
+ `xinhe-oa-cli report --confirm ${id}`,
662
+ );
663
+ }
664
+ const reportPath = `/reports/${id}`;
665
+ const plain = requestSpec();
666
+ if (runtime.dryRun) {
667
+ // 真跑是两步:先读详情拿 approval_id,再走审批端点。dry-run 把两步都列出来。
668
+ const approvalPath = `/approvals/<从详情取 approval_id>/${action}`;
669
+ const body = note !== null ? { note } : {};
670
+ return runtime.finish(
671
+ {
672
+ dry_run: true,
673
+ requests: [
674
+ runtime.dryRunRequest('GET', reportPath, plain),
675
+ runtime.dryRunRequest('POST', approvalPath, requestSpec({ body: sortKeysDeep(body) })),
676
+ ],
677
+ note: `--dry-run:没有发出任何请求。真跑会先 GET 详情取 approval_id,再 POST /approvals/<id>/${action}(与 approval --${action === 'approve' ? 'approve' : 'reject'} 同一底座)`,
678
+ },
679
+ null,
680
+ null,
681
+ );
682
+ }
683
+ const detail = await runtime.sendAuthed('GET', reportPath, plain);
684
+ const detailData = dataOf(detail);
685
+ const rawApprovalId = isPlainObject(detailData) ? detailData.approval_id : null;
686
+ const approvalId =
687
+ typeof rawApprovalId === 'string' && rawApprovalId.trim().length > 0
688
+ ? rawApprovalId.trim()
689
+ : null;
690
+ if (approvalId === null) {
691
+ // 不猜、也不退化成"直接改报告状态":没有待确认的审批 = 这份报告现在不该被确认。
692
+ throw new CliError(
693
+ Kind.Conflict,
694
+ 'cli.report_not_confirmable',
695
+ '这份报告没有待确认的审批(approval_id 为空)',
696
+ )
697
+ .withDetails({ report_id: id, approval_id: null })
698
+ .withHint(
699
+ '只有 require_confirm 的规则提交后才需要确认;用 `xinhe-oa-cli report --show <RPT_ID> --json` 看 status',
700
+ );
701
+ }
702
+ const body = note !== null ? { note } : {};
703
+ const response = await runtime.sendAuthed(
704
+ 'POST',
705
+ `/approvals/${approvalId}/${action}`,
706
+ requestSpec({ body: sortKeysDeep(body) }),
707
+ );
708
+ const data = dataOf(response);
709
+ if (isPlainObject(data)) {
710
+ // 审批端点的响应只说审批,补回"这是哪份报告"的两个 id,脚本才不用自己记。
711
+ data.report_id = id;
712
+ data.approval_id = approvalId;
713
+ }
714
+ runtime.finishFrom(response, data);
715
+ }
716
+
717
+ // ---------------------------------------------------------------------------
718
+ // 小工具(与 Rust 的 `trim_of` / `push_query` / `required_value` 逐条对齐)
719
+ // ---------------------------------------------------------------------------
720
+
721
+ /** `str::trim()`:旗标取值一律先去空白(`--rule " x "` 与 `--rule x` 是同一件事)。 */
722
+ function trimmed(raw) {
723
+ return raw === null || raw === undefined ? '' : String(raw).trim();
724
+ }
725
+
726
+ /** 旗标"给没给":`--x ""` 也算给了(Rust 的 `Option::is_some`)。 */
727
+ function isGiven(args, flag) {
728
+ return args[flag] !== undefined;
729
+ }
730
+
731
+ /** 取值缺失或全空白(Rust 的 `required_value` 判据)。 */
732
+ function isBlank(raw) {
733
+ return raw === null || raw === undefined || String(raw).trim().length === 0;
734
+ }
735
+
736
+ /** 旗标的值不能是空白(`--show ""` 这种等于没给)。 */
737
+ function requiredValue(raw, what) {
738
+ if (isBlank(raw)) throw CliError.usage(`${what} 的值不能为空`);
739
+ return trimmed(raw);
740
+ }
741
+
742
+ /** 查询参数:空白值不进 query(`--rule " "` 与没给一样)。 */
743
+ function pushQuery(query, key, value) {
744
+ if (value === null || value === undefined) return;
745
+ const text = trimmed(value);
746
+ if (text.length > 0) query.push([key, text]);
747
+ }
748
+
749
+ /** `--cadence` 的白名单校验(非法值本地判定 = 用法错误,退出码 2、不发请求)。 */
750
+ function cadenceValue(args) {
751
+ const raw = flagValue(args, '--cadence');
752
+ if (isBlank(raw)) return null;
753
+ const text = trimmed(raw);
754
+ if (REPORT_CADENCES.includes(text)) return text;
755
+ throw CliError.usage(
756
+ `--cadence 只认 ${REPORT_CADENCES.join(' | ')},收到 ${JSON.stringify(text)}`,
757
+ ).withHint('例:--cadence weekly');
758
+ }
759
+
760
+ /** `--status`:`all` = 不带过滤;非法值本地出口 2。 */
761
+ function statusFilter(args) {
762
+ const raw = flagValue(args, '--status');
763
+ if (raw === null) return null;
764
+ const text = trimmed(raw);
765
+ if (text.length === 0) return null;
766
+ if (text.toLowerCase() === 'all') return null;
767
+ if (REPORT_STATUSES.includes(text)) return text;
768
+ throw CliError.usage(
769
+ `--status 只认 ${REPORT_STATUSES.join(' | ')}(或 all),收到 ${JSON.stringify(text)}`,
770
+ ).withHint('例:xinhe-oa-cli report --list --status missing --json');
771
+ }
772
+
773
+ /** `--cc <uids>`:可重复,也接受逗号分隔;去空、去重(保序)。 */
774
+ function ccUserIds(args) {
775
+ const raw = args['--cc'];
776
+ const out = [];
777
+ const values = raw === undefined || raw === true ? [] : Array.isArray(raw) ? raw : [raw];
778
+ for (const item of values) {
779
+ for (const piece of String(item).split(',')) {
780
+ const text = piece.trim();
781
+ if (text.length > 0 && !out.includes(text)) out.push(text);
782
+ }
783
+ }
784
+ return out;
785
+ }
786
+
787
+ function requireRule(args, what) {
788
+ const raw = flagValue(args, '--rule');
789
+ if (isBlank(raw)) {
790
+ throw CliError.usage(`\`${what}\` 必须给 --rule <RULE_ID|KEY>`).withHint(
791
+ '先看有哪些规则:xinhe-oa-cli report rule --list --json',
792
+ );
793
+ }
794
+ return trimmed(raw);
795
+ }
796
+
797
+ function requirePeriod(args, what) {
798
+ const raw = flagValue(args, '--period');
799
+ if (isBlank(raw)) {
800
+ throw CliError.usage(`\`${what}\` 必须给 --period <PERIOD_KEY>`).withHint(
801
+ '周期键形如 D:2026-10-07 / W:2026-W41 / M:2026-10;看周期:xinhe-oa-cli report --list --rule <RULE> --json',
802
+ );
803
+ }
804
+ return trimmed(raw);
805
+ }
806
+
807
+ function requireContent(args, what) {
808
+ const raw = flagValue(args, '--content');
809
+ if (isBlank(raw)) {
810
+ throw CliError.usage(`\`${what}\` 必须给 --content <JSON>`).withHint(
811
+ '例:--content \'{"done":"走访 3 家"}\';长正文用 `--content @-` 从 stdin 读',
812
+ );
813
+ }
814
+ return trimmed(raw);
815
+ }
816
+
817
+ function requireNote(args, what) {
818
+ const raw = flagValue(args, '--note');
819
+ if (isBlank(raw)) {
820
+ throw CliError.usage(`\`${what}\` 必须给 --note <理由>`).withHint(
821
+ '理由会写进审计与事件(事后复盘用),不能省',
822
+ );
823
+ }
824
+ return trimmed(raw);
825
+ }
826
+
827
+ /** 只允许 `allowed` 里的旗标(其余给了就是用法错误,本地出口 2、不发请求)。 */
828
+ function only(args, what, allowed) {
829
+ const bad = REPORT_SIDE_FLAGS.filter(
830
+ (flag) => (flag === '--cc' ? ccUserIds(args).length > 0 : isGiven(args, flag)) && !allowed.includes(flag),
831
+ );
832
+ if (bad.length === 0) return;
833
+ throw CliError.usage(`\`${what}\` 不接受 ${bad.join(' / ')}`).withHint(
834
+ `跑 \`xinhe-oa-cli schema ${what}\` 看这个动作的参数面`,
835
+ );
836
+ }
837
+
838
+ /** `report rule` 侧的同一件事(旗标清单不同,Rust 里也是两份实现)。 */
839
+ function rejectExtra(args, what, allowed) {
840
+ const bad = RULE_SIDE_FLAGS.filter((flag) => isGiven(args, flag) && !allowed.includes(flag));
841
+ if (bad.length === 0) return;
842
+ throw CliError.usage(`\`${what}\` 不接受 ${bad.join(' / ')}`).withHint(
843
+ `跑 \`xinhe-oa-cli schema ${what}\` 看这个动作的参数面`,
844
+ );
845
+ }
846
+
847
+ /**
848
+ * 读 `--content` / `--from` 的取值:`@-` = 从 stdin 读(避开 argv 长度上限)。
849
+ *
850
+ * 只认**整个取值**就是 `@-` 的那种写法(`"--content @-"`),不做前缀展开 ——
851
+ * 与 Rust 一致。
852
+ */
853
+ function jsonInput(raw) {
854
+ if (trimmed(raw) !== '@-') return raw;
855
+ let buffer;
856
+ try {
857
+ buffer = fs.readFileSync(0, 'utf8'); // fd 0 = stdin;同步读,命令是一次性的
858
+ } catch (err) {
859
+ throw CliError.usage(`读不了 stdin:${rustIoErrorText(err)}`);
860
+ }
861
+ if (buffer.trim().length === 0) {
862
+ throw CliError.usage('`@-` 表示从 stdin 读 JSON,但 stdin 是空的').withHint(
863
+ '把 JSON 管进来:echo \'{"done":"走访 3 家"}\' | xinhe-oa-cli report --save --rule work_daily --period D:2026-10-07 --content @-',
864
+ );
865
+ }
866
+ return buffer;
867
+ }
868
+
869
+ /**
870
+ * 参数形状校验:**只判"是不是合法 JSON"**(语义校验在服务端,CLI 不重复实现)。
871
+ *
872
+ * 提示里必须带 `@-`:报告正文动辄上千字,命令行长度上限是现场最常撞的墙。
873
+ */
874
+ function parseJsonFlag(raw, flag) {
875
+ const text = jsonInput(raw);
876
+ try {
877
+ return JSON.parse(text);
878
+ } catch (err) {
879
+ throw CliError.usage(`${flag} 不是合法 JSON:${err.message}`).withHint(
880
+ `例:${flag} '{"done":"走访 3 家"}';长 JSON 用 \`${flag} @-\` 从 stdin 读`,
881
+ );
882
+ }
883
+ }
884
+
885
+ /** `--from` 这类"整块规则字段"必须是 JSON **对象**(数组/标量给不出字段,早点报错)。 */
886
+ function parseJsonObject(raw, flag) {
887
+ const value = parseJsonFlag(raw, flag);
888
+ if (!isPlainObject(value)) {
889
+ throw CliError.usage(`${flag} 要一个 JSON 对象,收到 ${jsonKind(value)}`).withHint(
890
+ `例:${flag} '{"template":[],"recipients":[{"type":"direct_leader"}]}'`,
891
+ );
892
+ }
893
+ return value;
894
+ }
895
+
896
+ /** JSON 值的类型名(与 Rust `json_kind` 逐字对齐,报错里给人看)。 */
897
+ function jsonKind(value) {
898
+ if (value === null) return 'null';
899
+ if (Array.isArray(value)) return '数组';
900
+ if (typeof value === 'boolean') return '布尔值';
901
+ if (typeof value === 'number') return '数字';
902
+ if (typeof value === 'string') return '字符串';
903
+ return '对象';
904
+ }
905
+
906
+ function isPlainObject(value) {
907
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
908
+ }