# 组织管理（hrm）

`hrm` 是 E10 组织管理的聚合命令，覆盖**员工（employee）**、**组织（org）**、**岗位（position）** 三个业务域，全部为只读查询。

```text
weaver-work-cli hrm --help
weaver-work-cli hrm schema
weaver-work-cli --json hrm run <operation> --input -
```

`schema` 是能力清单的唯一事实来源，包含每个 operation 的 `inputSchema`、`risk`，以及禁用清单 `withheldOperations`（代理商查询 `hrm.employee.dls`）。

## 实现位置

| 路径 | 职责 |
| --- | --- |
| `src/shortcuts/hrm/index.ts` | 命令注册与 JSON envelope |
| `src/shortcuts/hrm/manifest.ts` | `schema` 契约与禁用边界声明 |
| `src/shortcuts/hrm/host.ts` | E10 会话桥接、HRM 页面上下文 Referer、JSON 请求、响应归一化 |
| `src/shortcuts/hrm/operations/` | 按业务域拆分的 operation 实现（employee / org / position / resolve） |
| `src/shortcuts/hrm/operations/resolve.ts` | 多维（虚拟组织维度）解析：取全量多维 → 名称精确/模糊匹配 → 带 `companyId` 查部门/分部 → 回候选 |
| `src/shortcuts/hrm/operations/registry.ts` | operation 注册表与去重校验 |

## 认证

复用 `src/internal/e10` 的 E10 会话（`requireE10Session`），请求自动携带 `Cookie`、`eteamsid` 与 `X-Requested-With: XMLHttpRequest`。业务代码不接触认证目录。

请求路径强制以 `/api/` 开头，并拒绝绝对路径、跨域 URL 与路径逃逸。

HRM 接口必须携带页面上下文 `Referer`（默认 `<baseUrl>/hrm/main`），否则网关长时间无响应；组织卡片与岗位卡片详情由 operation 覆盖为 `/sp/hrm/orgCard/{id}`、`/sp/hrm/positionCard/{id}`。

## 跨模块约定

三个业务域统一走 E10 HRM 内部浏览/详情接口，响应统一 `WeaResult` 契约，由 `host.ts` 与 `operations/shared.ts` 归一化：

**响应成功判定**：统一 `code=200`。`403`（接口开关未开启）抛 `api/permission_denied`，其余业务失败抛 `api/business_error`。

**数据行位置（下表描述的是上游原始响应，不是 CLI 输出）**：

| 接口类型 | 行字段 | CLI `data.total` 的来源 |
| --- | --- | --- |
| 员工浏览 `employee.query` | `data.data` | 本页行数（**不调 count 接口**，上游 `data.count` 恒为 0） |
| 下属查询 `subordinate.mine` | `data.data` | 本页行数 |
| 组织浏览 `org.query` | `data.data` | 上游 `data.total` |
| 岗位浏览 `position.query` | `data.data` | 上游 `data.total` |
| 岗位批量 `position.list` | `data.data` | 上游 `data.total`（自动翻页累加） |
| 批量人员 `employee.batch` | `data.userList` | 行数 |
| 下属按 id `subordinate.by-id` | `data.page.result` | 上游 `data.page.totalCount`（**真实总数**，另附 `data.page.hasNext/totalPages`） |

CLI 已把上表差异归一化：**列表类统一输出 envelope 的 `data.rows` + `data.total`，分页参数在 `meta`（current/pageSize）**；不要把上游的 `data.data` / `data.page.result` 当作 CLI 输出路径。⚠️ 注意 `employee.query`/`subordinate.mine`/`dept.members` 的 `data.total` 是**本页行数**（想要真实总数只能按 `current` 翻页到 `rows.length < pageSize`），而 `org.query`/`position.*`/`subordinate.by-id` 的 total 才是上游真值。

**详情接口**（`employee.detail`/`org.detail`/`position.detail`）返回单对象：上游 `WeaForm` 的业务字段位于其 `data.data`，CLI 会**展平上移一层**，因此 CLI 输出中业务字段**直接挂在 envelope 的 `data` 下，没有 `data.data` 嵌套**（`items`/`groups`/`layout` 等前端表单元数据已丢弃），并附 `link`。其中 `org.detail`/`position.detail` **只透出显式投影后的少量字段**：`org.detail` → `id/name/type/code/fullPath/status/parentName?/subcompanyName?/description?/remark?/link`（**不含 `employee_count`**）；`position.detail` → `id/name/code/parentName?/jobsetName?/jobsetTypeName?/grade?/link`（**不含 `fullName`/`department`**）。这两个接口都没有 `full` 开关。

**`employee.detail` 默认投影**：完整卡片有 85 个顶层键（~5.3KB），其中大部分是登录/管理元数据（`password`/`last_android`/`hideFieldList`/`setting`/各 `*_admin`…）。默认只回 `DETAIL_CORE_FIELDS` 白名单的 26 个键（~1.2KB）：实体数组字段（`department`/`subcompany`/`position`/`personnel_status`/`jobsetid`/`jobset_level`/`location`）只留 `{id, name}`，人员数组字段（`superior`/`other_superior`/`assistant`）只留 `{id, name, statusCode, avatar}`，`avatar` 只留 1 个 `url`。需要完整卡片时传 `full: true`（该开关只影响 `employee.detail`；`org.detail`/`position.detail` 没有该开关，且它们已只透出上面列出的显式投影字段）。

**「人员列表」默认投影**：`subordinate.mine` / `dept.members`（`formatMember` 行 366B）与 `subordinate.by-id`（原始行 960B，其中上游 `superior` 对象一项就占 485B）默认都走 `formatPersonBrief`——每行只回 `{id, name, link}`（~116~120B/行）。需要部门/岗位/电话/上级等列时传 `full: true`（`subordinate.mine`/`dept.members` 回 `formatMember` 行，`subordinate.by-id` 回 `formatSubordinateFull` 行）。

**`employee.query` 默认投影**：默认走 `formatQueryRowBrief`——每行只回**人员信息** 6 键 `{id, name, department, position, statusCode, link}`（368B → 186B，省 49%）。裁掉的是：另两套 id（`userid` 登录信息 id / `cid` IM 联系人 id）、本接口恒为空串的 `mobile`、以及 `departmentId` + `deptLink`。传 `full: true` 回原来的 `formatMember` 完整行。

**三套 id 口径**：`id` = 人员 id（驼峰 `userId` 同值；卡片链接 / `employee.detail` / `employee.batch.ids` / `subordinate.by-id` 只认它）；`userid` = 登录信息 id（登录账号场景）；`cid`/`imCid` = IM 联系人 id（IM 聊天 / 在线状态）。三者禁止混用。

**长整型 id 一律按字符串传递**；**手机号已脱敏**（3 位前缀 + `****` + 末 4 位）。

## 多维（虚拟组织维度）解析

E10 组织树按多维切分，`department`/`subcompany` 浏览接口不传 `companyId` 时默认落到「行政组织」维度，因此「按名称找部门/分部」需要先定位多维 id。

`hrm.org.resolve` 把这段编排收在 CLI 内（多维全部取自 `/api/bs/hrm/companyVirtual/listCompanyVirtual`），**一次调用完成两级匹配**：维度名称 → `companyId` → 组织名称。名称匹配分三级，**只有前两级会自动往下走**：

| 级别 | 判定 | CLI 行为 |
| --- | --- | --- |
| ① 精确 | 忽略大小写/空白的全等（服务端 `eq`/等于） | 唯一 → 继续；多个 → `ambiguous` |
| ② 包含（模糊） | 名称是关键字子串（服务端 `in`/包含 + 客户端忽略大小写等值优先） | 唯一 → 继续；多个 → `ambiguous` |
| ③ **近似** | 编辑距离阈值内（打字级差异，如「客服维度」→「客户维度」） | **只回 `suggestions`，`status: not_found`，绝不继续** |

| operation | 关键输入 | 说明 |
| --- | --- | --- |
| `hrm.org.resolve` | `virtual` + `name`（首调）；`virtualId` / `orgId`（复调）；`maxCandidates`（候选上限，1~200，默认 20） | 不传 `type` 时先按部门、再按分部自动识别 |

> 多维清单（维度名 + id）**不单独暴露 operation**，由 `hrm.org.resolve` 内部获取与处理（匹配用 + `not_found` 回 `availableVirtuals` 供纠错）。「列出该租户有哪些多维」**暂不支持**，已在 `withheldOperations` 声明；`availableVirtuals` 不得被当作列维度接口（禁止构造不存在的维度名去套取）。

🔴 **结果判定看 `data.status`（不是 `ok`）**，三种结果都 `ok: true`、退出 0：

| `data.status` | 输出 | 调用方动作 |
| --- | --- | --- |
| `resolved` | `org`（`id`/`name`/`type`/`fullPath`/`virtualId`/`link`…）、`virtual`、`matchType`；不传 `type` 且存在同名另一类型节点时另回 `alsoMatched[]` | 直接使用 `org`；有 `alsoMatched` 时必须一并告知用户 |
| `ambiguous` | `stage`（`virtual`/`org`）、`candidates[]`（默认 ≤20，可调）、`candidateTotal`、`truncated`、`message` | 列候选让用户确认，确认后把 `id` 当 `orgId`（维度歧义当 `virtualId`）复调 |
| `not_found` | `suggestions[]`（近似名称，≤5）/`suggestionTotal`、`availableVirtuals`（`stage=virtual`）、`message` | **原样转述**（含"不存在"），**禁止自行替换名称重试**；用户确认后再用 `virtualId`/`orgId` 复调 |

`data.total` 与「权威总数」的关系同 `org.query`：组织浏览接口的 `data.total` 是上游真值（非本页行数）；本操作调用 `current=1`，上游浏览分页取 `max(100, maxCandidates)`，不做翻页。`fullPath`/`parentPath` 中的层级分隔符已由 CLI 统一为 `/`（部分租户上游用 `|`，如泛微网络租户的 `supDepartment`）。

不传 `type` 时本操作**同时检索部门与分部**（多 1 次浏览请求），因此不会出现「同名部门/分部被静默二选一」：唯一命中时按部门优先返回 `org`，另一类型的同名节点回在 `alsoMatched`；多命中时统一走 `ambiguous`。

## 详情页卡片链接

查询命中后，回答中一律用 `[名称](link)` 形式输出每个命中实体（`link` 由 CLI 在每行提供）：

| 卡片 | link 路径 | 来源 operation |
| --- | --- | --- |
| 人员卡片 | `/sp/hrm/profileInfo/{id}` | `employee.query`/`employee.batch`/`subordinate.*`/`employee.detail` |
| 组织卡片 | `/sp/hrm/orgCard/{id}` | `org.query`/`org.detail`/`org.resolve` |
| 岗位卡片 | `/sp/hrm/positionCard/{id}` | `position.query`/`position.detail`/`position.list` |

## 调用示例

Windows PowerShell：

```powershell
weaver-work-cli --json hrm run hrm.employee.query --input-json '{"keyword":"张三"}'
```

macOS/Linux（bash/zsh）：

```bash
printf '%s\n' '{"keyword":"张三"}' | weaver-work-cli --json hrm run hrm.employee.query --input -
```

## 禁用边界

`withheldOperations` 中声明的能力不实现，也不要绕过：

- **代理商查询**（`hrm.employee.dls`）：`containsDls=true` 仅泛微自用系统域名支持（自用域名白名单），客户/其他环境禁止触发。
- **列多维**（`hrm.org.virtuals`）：已下线并入 `hrm.org.resolve`；「列出该租户有哪些多维」暂不支持，不得构造假查询绕取 `availableVirtuals`。

## 测试

```text
node --test --import tsx "test/business/hrm-operations.test.ts" "test/business/hrm-host.test.ts" "test/business/hrm-command.test.ts"
```

覆盖 manifest/registry 一致性、请求形状断言、收敛条件与全量拉取拦截、响应归一化、Referer 注入与路径白名单、错误形状。
