@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,399 @@
1
+ // 组织架构:`org --tree|--show|--create|--move|--rename|--leader-set|--archive|--explain-scope`
2
+ // (ORG-001 issue #80 / ORG-003 issue #83)—— Rust 侧 `commands/org.rs` 的逐条移植。
3
+ //
4
+ // 与 `report` 同一取向:CLI 是**薄封装**。树的一致性(`path` / 闭包表 / `depth`)全在
5
+ // 服务端与数据库触发器上,这里只做三件事:
6
+ //
7
+ // 1. **参数即约束**:动作与组合在本地判定,拼错 = 退出码 2、**一个请求都不发**
8
+ // (`--depth 0`、`--create` 缺 `--name`、`--tree` 带着 `--key` 都是这一类);
9
+ // 2. **高风险闸门**:`--move`(整棵子树换父)与 `--archive`(组织行为不可逆)必须 `--yes`;
10
+ // 3. **写操作带幂等键**:建 / 移 / 改名 / 设负责人由 CLI 自动生成 —— 只有 `--archive`
11
+ // 例外(Rust 侧就是 `RequestSpec::default()`:既没有 body 也没有幂等键,逐字照抄)。
12
+ //
13
+ // 几处"为什么"(这些口径只在 Rust 源码里有,契约表里写不下):
14
+ //
15
+ // * **`--key` 是身份不是属性**:建部门时能指定,之后没有任何命令能改它(服务端也没有
16
+ // 这个口子),`--rename` 只动显示名。所以本文件里找不到任何写 `key` 的路径 —— 不是漏了。
17
+ // * **`--move` 为什么是高风险**:移动 = 重算继承、**不保留原权限**(决策 63),整棵子树的
18
+ // `path` / `depth` / 闭包一起变,协同方按租户级 `org_revision` 对齐。它不是"改个字段",
19
+ // 而是"这批人的可见范围当场变了",所以要 `--yes` + 服务端回 `affected_subtree_size`。
20
+ // * **`--archive` 为什么还有子部门 / 成员时是 409 而不是硬删**:平台**没有物理删除**部门的
21
+ // 口子 —— 历史行、审批记录、审计还要指着它(软删除口径:`--show` 仍取得到,带 `status`)。
22
+ // 归档也**不级联**:先把人挪走、子部门移走,服务端把"还剩几个"放进
23
+ // `details.remaining_children / remaining_members` 回给调用方(这是 409 → 退出码 5)。
24
+ // * **`--explain-scope` 为什么只回"声明 + 锚点 + 子树节点数"**:展开结果**不落库、不出网**
25
+ // (ORG-003 §2.6)。回完整部门 ID 列表等于把整份授权拓扑吐给任何一个登录用户,而
26
+ // "他为什么看得到"这个问题用 声明 + 锚点 + 规模 就已经答完了。
27
+ // * **`--tenant` 在 `--move` 上只是"允许"而不是"要发"**:服务端从 `dept_id` 自己解析归属
28
+ // 企业(跨企业的 `parent` 一律按"不存在"处理),所以 body 里只有 `parent` 一个字段。
29
+ // * **`--show` / `--tree` 是读**:只要 login,可见企业由服务端 `resolve_tenants` 夹紧 ——
30
+ // 点名看不见的企业是 403(不是空树),跨企业 `dept_id` 与"不存在"逐字同一响应。
31
+
32
+ import { flagOn, flagValue, intFlag } from '../core/args.js';
33
+ import { CliError } from '../core/error.js';
34
+ import { dataOf, idempotencyKey, requestSpec } from '../core/runtime.js';
35
+ import { sortKeysDeep } from '../core/util.js';
36
+ import { queryPairs } from '../core/values.js';
37
+
38
+ /**
39
+ * 8 个动作旗标,顺序与 Rust `OrgArgs::action()` 里 `exactly_one` 的候选表**逐字一致**
40
+ * (多动作报错时列名字的顺序取自它)。
41
+ */
42
+ const ACTIONS = [
43
+ '--tree',
44
+ '--show',
45
+ '--create',
46
+ '--move',
47
+ '--rename',
48
+ '--leader-set',
49
+ '--archive',
50
+ '--explain-scope',
51
+ ];
52
+
53
+ /** `exactly_one` 的 hint(Rust 原文,含那个"建部门"的例子)。 */
54
+ const ACTION_HINT = '例:xinhe-oa-cli org --tree --json(建部门:org --create --name 全科 --tenant kashu)';
55
+
56
+ /** `--move` 的 `--yes` 闸门文案(Rust 原文,一个字都不改)。 */
57
+ const MOVE_WHAT = 'org --move(整棵子树换父:路径 / 深度 / 闭包全重算,协同方按 org_revision 对齐)';
58
+ /** `--archive` 的 `--yes` 闸门文案。 */
59
+ const ARCHIVE_WHAT = 'org --archive(归档后不再出现在默认树里;有子部门/成员时服务端会拒绝)';
60
+
61
+ /** `--create` 成功后在 `data` 上补的一句"下一步"(Rust 原文)。 */
62
+ const CREATE_NEXT_STEP =
63
+ '把人挂进来:xinhe-oa-cli member --set-dept <DEPT_ID> --user <USER_ID>(首个部门自动为主部门)';
64
+ /** `--move` 成功后在 `data` 上补的一句"为什么 dept_id 不用跟着改"(Rust 原文)。 */
65
+ const MOVE_NOTE = '移动后子树内部门的 dept_id 不变(身份不动),只有 path/depth 变';
66
+
67
+ /** `org` 的分发:先定动作(本地,退出码 2),再按动作发一个请求。 */
68
+ export async function handleOrg({ args, positional, runtime, command }) {
69
+ // 数值旗标先于动作分发解析:Rust 侧 `--sort` / `--depth` 是 clap 的类型化字段,
70
+ // 报错发生在 `OrgArgs::action()` **之前**(`--sort abc` 是 clap 的"值不合法",
71
+ // 而不是"这个动作不接受 --sort")。顺序照抄,错误分类才一致。
72
+ const sort = intFlag(args, '--sort');
73
+ const depth = intFlag(args, '--depth', { min: 0 });
74
+
75
+ const action = pickAction(args, positional, command);
76
+ rejectExtra(action, args);
77
+
78
+ switch (action) {
79
+ case '--tree':
80
+ return tree(runtime, args, depth);
81
+ case '--show':
82
+ return show(runtime, requiredValue(flagValue(args, '--show'), '--show <DEPT_ID>'));
83
+ case '--create':
84
+ return create(runtime, args, sort);
85
+ case '--move': {
86
+ const deptId = requiredValue(flagValue(args, '--move'), '--move <DEPT_ID>');
87
+ const parent = requiredValue(flagValue(args, '--parent'), '--move 的 --parent <DEPT_ID>');
88
+ return move(runtime, deptId, parent);
89
+ }
90
+ case '--rename': {
91
+ const deptId = requiredValue(flagValue(args, '--rename'), '--rename <DEPT_ID>');
92
+ requiredValue(flagValue(args, '--name'), '--rename 的 --name <NAME>');
93
+ return rename(runtime, args, deptId);
94
+ }
95
+ case '--leader-set': {
96
+ const deptId = requiredValue(flagValue(args, '--leader-set'), '--leader-set <DEPT_ID>');
97
+ requiredValue(flagValue(args, '--leader'), '--leader-set 的 --leader <USER_ID>');
98
+ return leaderSet(runtime, args, deptId);
99
+ }
100
+ case '--archive':
101
+ return archive(runtime, requiredValue(flagValue(args, '--archive'), '--archive <DEPT_ID>'));
102
+ default: {
103
+ // `--explain-scope`(ORG-003):`--user` 必填,`--action` 缺省 read。
104
+ const user = requiredValue(flagValue(args, '--user'), '--explain-scope 的 --user <USER_ID>');
105
+ const dataset = trimNonEmpty(flagValue(args, '--dataset'));
106
+ const scopeAction = trimNonEmpty(flagValue(args, '--action')) ?? 'read';
107
+ return explainScope(runtime, user, dataset, scopeAction);
108
+ }
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // 本地判定(一律**不发请求**)
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * 用户到底给了哪个动作(Rust `OrgArgs::action()` 的 `exactly_one`)。
118
+ *
119
+ * 动作旗标有两个来源,缺一不可:
120
+ *
121
+ * 1. **`args`**:解析器会把写在命令名里的动作旗标补回 `args`(`org --tree` → `--tree: true`),
122
+ * 这是主来源;
123
+ * 2. **命令名**:留着当兜底(`org --tree` 这类名字本身就是"动作即命令名")。
124
+ *
125
+ * 但有一处必须小心,否则会把 `--move` 的 `--yes` 提示顶掉成"两个动作":解析器在首选候选
126
+ * 解析失败时会**回退到最宽松的兄弟命令**,而回退时命令名并没有真的从 argv 里被消费掉 ——
127
+ * 那个域名 token 会漏进 `positional`。例如 `org --move ou_1 --parent ou_2`(没给 `--yes`)
128
+ * 落到这里时 `command.name` 是 `org --explain-scope`(结构体里必填项最少的那个),可用户
129
+ * 根本没敲 `--explain-scope`。判据:`positional` 里出现域名 token ⇒ 命令名是回退产物,
130
+ * 只认 `args` —— 这样"没给 `--yes`"才会走到 `require_yes`,给出与 Rust 一致的高风险提示。
131
+ */
132
+ function pickAction(args, positional, command) {
133
+ const on = new Set();
134
+ for (const flag of ACTIONS) {
135
+ if (flagOn(args, flag)) on.add(flag);
136
+ }
137
+ const nameConsumed = !positional.includes('org');
138
+ if (nameConsumed) {
139
+ const nameTokens = (command?.name ?? '').split(' ');
140
+ const fromName = ACTIONS.find((flag) => nameTokens.includes(flag));
141
+ if (fromName !== undefined) on.add(fromName);
142
+ }
143
+ const picked = ACTIONS.filter((flag) => on.has(flag));
144
+ if (picked.length === 1) return picked[0];
145
+ if (picked.length === 0) {
146
+ throw CliError.usage('`org` 必须给一个动作').withHint(ACTION_HINT);
147
+ }
148
+ throw CliError.usage(
149
+ `\`org\` 一次只能给一个动作,收到 ${picked.length} 个:${picked.join(' / ')}`,
150
+ ).withHint(ACTION_HINT);
151
+ }
152
+
153
+ /** 每个动作只接受自己的参数;给了别人的 = 用法错误(Rust `OrgArgs::reject_extra`)。 */
154
+ function rejectExtra(action, args) {
155
+ const allowed = {
156
+ '--tree': ['--tenant', '--dept', '--depth'],
157
+ '--show': [],
158
+ '--create': ['--parent', '--name', '--key', '--leader', '--sort', '--tenant'],
159
+ '--move': ['--parent', '--tenant'],
160
+ '--rename': ['--name'],
161
+ '--leader-set': ['--leader'],
162
+ '--explain-scope': ['--user', '--dataset', '--action'],
163
+ '--archive': [],
164
+ }[action];
165
+ // 检查顺序 = Rust 的顺序(报错时列出来的次序也是它)。
166
+ const candidates = [
167
+ '--parent',
168
+ '--name',
169
+ '--key',
170
+ '--leader',
171
+ '--sort',
172
+ '--tenant',
173
+ '--dept',
174
+ '--depth',
175
+ '--user',
176
+ '--dataset',
177
+ '--action',
178
+ ];
179
+ const bad = candidates.filter((flag) => flagOn(args, flag) && !allowed.includes(flag));
180
+ if (bad.length === 0) return;
181
+ throw CliError.usage(`\`org ${action}\` 不接受 ${bad.join(' / ')}`).withHint(
182
+ `跑 \`xinhe-oa-cli schema org ${action}\` 看这个动作的参数面`,
183
+ );
184
+ }
185
+
186
+ /**
187
+ * 必填取值:**trim 之后为空 = 没给**(`--name ""` 与 `--name " "` 都是用法错误)。
188
+ * 文案与 hint 照抄 Rust 的 `required_value`,包括那个把整段 `what` 拼进 schema 命令的怪 hint。
189
+ */
190
+ function requiredValue(raw, what) {
191
+ const value = trimNonEmpty(raw);
192
+ if (value === null) {
193
+ throw CliError.usage(`\`org ${what}\` 缺这个必填值`).withHint(
194
+ `跑 \`xinhe-oa-cli schema org ${what}\` 看参数面`,
195
+ );
196
+ }
197
+ return value;
198
+ }
199
+
200
+ /** `Option<&str>` 的 trim:空白 → null(Rust `trim_non_empty`)。 */
201
+ function trimNonEmpty(raw) {
202
+ if (raw === null || raw === undefined) return null;
203
+ const text = String(raw).trim();
204
+ return text.length > 0 ? text : null;
205
+ }
206
+
207
+ /** 往 body 里塞一个"trim 后非空"的字符串字段(Rust `insert_trim`)。 */
208
+ function insertTrim(body, field, raw) {
209
+ const value = trimNonEmpty(raw);
210
+ if (value !== null) body[field] = value;
211
+ }
212
+
213
+ /**
214
+ * 收尾:`data` 先按**字典序**递归排键,再交给 runtime 渲染。
215
+ *
216
+ * 为什么在命令层排一次:Rust 那边 `data` 是 `serde_json::Map`,也就是 `BTreeMap` ——
217
+ * 键序**永远是字典序**。人类可读面(`--format table|md`)的列序/行序直接取自键序,
218
+ * 而 Node 的 `JSON.parse` 保留服务端下发的顺序,于是同一份数据会渲染出不同的列序
219
+ * (`--json` 无此问题:`renderSuccess` 自己会排)。这里排一次,`table` / `md` 也与
220
+ * Rust 逐字符一致;core 若日后统一在渲染层排序,这一句是幂等的、可以删。
221
+ */
222
+ function finish(runtime, response, data) {
223
+ runtime.finishFrom(response, sortKeysDeep(data));
224
+ }
225
+
226
+ function isPlainObject(value) {
227
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // 读
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /**
235
+ * `org --tree [--tenant <t>] [--dept <d>] [--depth <n>]`:部门树。
236
+ *
237
+ * 三个 query 参数的**顺序**与 Rust 一致(tenant → dept_id → depth):服务端不依赖顺序,
238
+ * 但抓包对拍时顺序也是"同一个请求"的一部分。
239
+ */
240
+ async function tree(runtime, args, depth) {
241
+ if (depth === 0) {
242
+ // `--depth 0` 是"什么都看不到",属于参数没意义,不是服务端该接的活。
243
+ throw CliError.usage('--depth 必须 ≥ 1(0 层 = 什么都看不到)').withHint(
244
+ '例:xinhe-oa-cli org --tree --depth 2 --json',
245
+ );
246
+ }
247
+ const query = queryPairs([
248
+ ['tenant', trimNonEmpty(flagValue(args, '--tenant'))],
249
+ ['dept_id', trimNonEmpty(flagValue(args, '--dept'))],
250
+ ['depth', depth === null ? null : String(depth)],
251
+ ]);
252
+ const spec = requestSpec({ query });
253
+ if (runtime.dryRun) return runtime.dryRunReport('GET', '/org/tree', spec);
254
+ const response = await runtime.sendAuthed('GET', '/org/tree', spec);
255
+ finish(runtime, response, dataOf(response));
256
+ }
257
+
258
+ /**
259
+ * `org --show <DEPT_ID>`:部门详情。**含已归档**(软删除口径:`status` / `archived_at`
260
+ * 仍在,取得到才答得了"这个部门当时叫什么")。
261
+ */
262
+ async function show(runtime, deptId) {
263
+ const path = `/org/units/${deptId}`;
264
+ const spec = requestSpec();
265
+ if (runtime.dryRun) return runtime.dryRunReport('GET', path, spec);
266
+ const response = await runtime.sendAuthed('GET', path, spec);
267
+ finish(runtime, response, dataOf(response));
268
+ }
269
+
270
+ /**
271
+ * `org --explain-scope --user <uid> [--dataset <slug>] [--action read]`(ORG-003)。
272
+ *
273
+ * 输出就是服务端 `data` 原样(与 `--show` 同口径,不做二次加工):声明 + 锚点 +
274
+ * 子树节点数。`--dataset` 缺省 = 该人全部数据集的声明;`--action` 缺省 `read`。
275
+ */
276
+ async function explainScope(runtime, userId, dataset, action) {
277
+ const query = queryPairs([
278
+ ['user_id', userId],
279
+ ['dataset', dataset],
280
+ ['action', action],
281
+ ]);
282
+ const spec = requestSpec({ query });
283
+ if (runtime.dryRun) {
284
+ return runtime.dryRunReport('GET', '/admin/grants/scope-explain', spec);
285
+ }
286
+ const response = await runtime.sendAuthed('GET', '/admin/grants/scope-explain', spec);
287
+ finish(runtime, response, dataOf(response));
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // 写
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /**
295
+ * `org --create --name <n> [--parent <p>] [--key <k>] [--leader <u>] [--sort <n>] [--tenant <t>]`。
296
+ *
297
+ * `--parent` 缺省 = 该租户根部门;首个部门自动补根(服务端的事)。
298
+ * `--leader` 在这里**只落值不做成员校验**(校验归 `--leader-set`:负责人须为该部门或其后代
299
+ * 的在职成员)—— 建部门时人可能还没挂进来,先卡校验会让"先建部门再挂人"这条路走不通。
300
+ */
301
+ async function create(runtime, args, sort) {
302
+ requiredValue(flagValue(args, '--name'), '--create 的 --name <NAME>');
303
+ const body = {};
304
+ insertTrim(body, 'name', flagValue(args, '--name'));
305
+ insertTrim(body, 'parent', flagValue(args, '--parent'));
306
+ insertTrim(body, 'key', flagValue(args, '--key'));
307
+ insertTrim(body, 'leader', flagValue(args, '--leader'));
308
+ insertTrim(body, 'tenant', flagValue(args, '--tenant'));
309
+ if (sort !== null) body.sort = sort;
310
+ const spec = requestSpec({
311
+ // 键序对齐 Rust:`serde_json::Map` 是 BTreeMap,请求体在线上是**字典序**的。
312
+ body: sortBodyKeys(body),
313
+ idempotencyKey: idempotencyKey('org-create', null),
314
+ });
315
+ if (runtime.dryRun) return runtime.dryRunReport('POST', '/org/units', spec);
316
+ const response = await runtime.sendAuthed('POST', '/org/units', spec);
317
+ const data = dataOf(response);
318
+ if (isPlainObject(data)) data.next_step = CREATE_NEXT_STEP;
319
+ finish(runtime, response, data);
320
+ }
321
+
322
+ /**
323
+ * `org --move <DEPT_ID> --parent <DEPT_ID>`:**高风险写,必须 `--yes`**。
324
+ *
325
+ * 服务端回 `affected_subtree_size`(整棵子树多少个部门跟着动)与新的 `org_revision`
326
+ * (跨部门协同的租户级水位)—— 这两个数就是"这次移动影响了多少人"的答案。
327
+ *
328
+ * body 只有 `parent`:服务端从 `dept_id` 自己解析归属企业,跨企业的 `parent` 一律按
329
+ * "不存在"处理,所以 `--tenant` 允许写、但**不发**(Rust 也是这么做的)。
330
+ */
331
+ async function move(runtime, deptId, parent) {
332
+ runtime.requireYes(MOVE_WHAT, `xinhe-oa-cli org --move ${deptId} --parent ${parent}`);
333
+ const path = `/org/units/${deptId}/move`;
334
+ const spec = requestSpec({
335
+ body: { parent },
336
+ idempotencyKey: idempotencyKey('org-move', null),
337
+ });
338
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
339
+ const response = await runtime.sendAuthed('POST', path, spec);
340
+ const data = dataOf(response);
341
+ if (isPlainObject(data)) data.note = MOVE_NOTE;
342
+ finish(runtime, response, data);
343
+ }
344
+
345
+ /**
346
+ * `org --rename <DEPT_ID> --name <n>`:只改显示名。
347
+ *
348
+ * `key` / `path` 一个字都不动 —— 它们是**身份**(授权、审计、外部系统都按 key / path 指过来),
349
+ * 允许改名顺带换 key 等于让所有既有引用静默指空。所以这里连参数都没有。
350
+ */
351
+ async function rename(runtime, args, deptId) {
352
+ const path = `/org/units/${deptId}/rename`;
353
+ const spec = requestSpec({
354
+ body: { name: trimNonEmpty(flagValue(args, '--name')) },
355
+ idempotencyKey: idempotencyKey('org-rename', null),
356
+ });
357
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
358
+ const response = await runtime.sendAuthed('POST', path, spec);
359
+ finish(runtime, response, dataOf(response));
360
+ }
361
+
362
+ /** `org --leader-set <DEPT_ID> --leader <USER_ID>`:负责人须为本子树在职成员(服务端判)。 */
363
+ async function leaderSet(runtime, args, deptId) {
364
+ const path = `/org/units/${deptId}/leader`;
365
+ const spec = requestSpec({
366
+ // 字段名是 `user_id` 而不是 `leader`:服务端这个口子收的是"人",不是"leader 这个角色"。
367
+ body: { user_id: trimNonEmpty(flagValue(args, '--leader')) },
368
+ idempotencyKey: idempotencyKey('org-leader', null),
369
+ });
370
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
371
+ const response = await runtime.sendAuthed('POST', path, spec);
372
+ finish(runtime, response, dataOf(response));
373
+ }
374
+
375
+ /**
376
+ * `org --archive <DEPT_ID>`:**高风险写,必须 `--yes`**。
377
+ *
378
+ * 还有子部门 / 在职成员时服务端拒绝(409 `org.dept_not_empty`,details 给
379
+ * `remaining_children` / `remaining_members`)—— 先把人挪走、子部门移走,再归档。
380
+ *
381
+ * 这个口子**既没有 body 也没有幂等键**(Rust 侧就是 `RequestSpec::default()`):
382
+ * 归档是"把状态置为 archived"的幂等语义,重放一次结果相同,不需要服务端去重。
383
+ * 不照抄这一点的话,抓包对拍会多出一个 `Idempotency-Key` 头。
384
+ */
385
+ async function archive(runtime, deptId) {
386
+ runtime.requireYes(ARCHIVE_WHAT, `xinhe-oa-cli org --archive ${deptId}`);
387
+ const path = `/org/units/${deptId}/archive`;
388
+ const spec = requestSpec();
389
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
390
+ const response = await runtime.sendAuthed('POST', path, spec);
391
+ finish(runtime, response, dataOf(response));
392
+ }
393
+
394
+ /** 浅排对象键(对齐 serde_json 的 BTreeMap;`sort` 是数字,不受影响)。 */
395
+ function sortBodyKeys(body) {
396
+ const out = {};
397
+ for (const key of Object.keys(body).sort()) out[key] = body[key];
398
+ return out;
399
+ }