# 聚才林人事云（ehr）

`ehr` 是 E10 聚才林人事云的聚合命令，覆盖**人事档案（hr）**、**出勤管理（attend）**、**薪酬管理（salary）**、**招聘（recruit，读 + 写）** 四个业务域。

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

`schema` 是能力清单的唯一事实来源，包含每个 operation 的 `inputSchema`、`risk`、`requiresConfirmation`，以及固定数据源 `formId` 常量表 `dataSourceIds` 和禁用清单 `withheldOperations`。

## 实现位置

| 路径 | 职责 |
| --- | --- |
| `src/shortcuts/ehr/index.ts` | 命令注册与 JSON envelope |
| `src/shortcuts/ehr/manifest.ts` | `schema` 契约与禁用边界声明 |
| `src/shortcuts/ehr/host.ts` | E10 会话桥接、认证头、form/JSON 请求、响应归一化 |
| `src/shortcuts/ehr/continuation.ts` | 写操作确认链的 HMAC-SHA256 token |
| `src/shortcuts/ehr/operations/` | 按业务域拆分的 operation 实现 |
| `src/shortcuts/ehr/operations/registry.ts` | operation 注册表与去重校验 |

## 认证

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

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

## 子系统差异归一化

三个子系统由不同团队开发，契约不一致，`host.ts` 与 `operations/shared.ts` 负责归一化。

**响应成功判定**

| 子系统 | 契约 |
| --- | --- |
| 出勤 | `success` 布尔 |
| 人事档案 | `code=200` |
| 薪酬个人报表 | `status` 布尔 |

**分页**：统一提取 `pageNo`/`current`/`pageSize`/`total`/`totalCount`/`totalPage`/`tableId`。
- 表格类接口（出勤/人事/薪酬）总数用 **`total`**；数据源计数接口用 **`totalCount`**——两者都要提取，已在 `normalizePaged` 中覆盖。
- 出勤统计接口不返回 page 对象（它是范围聚合，不是分页列表）。
- `pageSize` **由服务端固定，传参无效**：通讯录 100，出勤与报表类 20。全量数据必须递增 `current` 翻页。

**数据行位置（三种命名，均已在真实 E10 实测）**：

| 接口 | 数据行字段 |
|---|---|
| `hr.contact.search` | `data` |
| 出勤表格类（`abnormal`/`signrecord`/`orbit`/`worktime`/`leave`/`appeal`）+ hr 报表（`report.*`/`props.table`） | **`displayData`**（`data` 为空数组） |
| `attend.report.get` | **`datas`** |

注意 `birthday.list` 是例外——它的行在 `data`（同模块内也不统一）。

CLI 原样透传整个响应对象，不做重命名；取用方按上表判断。

**每页条数**：由服务端按接口独立配置，**`pageSize` 传参无效**（实测）：通讯录 100、出勤表格类 20、`worktime`/`leave.report` 10、hr 报表 20、薪酬 `bill.list`/`employee.list` 20、`empchange.list` 100。全量数据一律递增 `current` 翻页。

**成功响应的 `data` 恒存在**：业务数据为 `undefined` 时（如社保福利、调薪记录为空）统一补为 `null`，避免 `JSON.stringify` 省略该键导致调用方需判断键是否存在。

**二次身份验证 token**：工资单相关 4 个接口要求携带 `token`（与登录凭证无关，不由认证链路提供）。`bill.list` 走 body，其余三个走 query。用户未提供时 CLI 自动补固定协议值 `DONT_NEED`；用户主动提供时用原值。缺失会返回 `checkSecondFailNoToken`，验证失败返回 `checkSecondFail`，服务异常返回 `checkSecondError`（此时**不得绕过验证读取数据**）。见 `shared.ts` 的 `resolveSecondAuthToken`。

**历史教训（2026-09-04）**：`bill.get` 曾持续返回 `code=500`，一度被判定为"服务端问题"（当时绕过 CLI 直接发 HTTP 也是 500，看似佐证）。真实原因是**未传 `token`**——直接 HTTP 对照时同样没传，所以对照组也失败，形成了误导性证据。补齐 `token` 后两个工资单 ID 均正常返回。结论：**构造对照实验时，必须确保对照组与实验组的差异只有唯一变量**。

**语义差异（需调用方注意）**：通讯录默认只查在职（`personnelStatus=["normal"]`），人员状态变更台账默认包含离职人员。

## 数据源接口

HR 报表类接口走 `application/x-www-form-urlencoded`（`sourceType=LOGIC` + `queryDto` 为 JSON 字符串），不是 JSON body。`queryDto` 由 CLI 从结构化参数构造：

```json
{
  "formId": "754217732232708651",
  "conditions": [{ "field": "timePoint", "value": "2026-02-28", "compareType": "eq" }],
  "pageNo": 1,
  "pageSize": 100
}
```

**日期范围条件必须拆成 `gte` + `lte` 两个条件**，这是 `hw.dutyDate` / `turn.dateRange` / `v2DeptEmpMove.month` / `dismiss.dismissDate` 的唯一正确传法。引擎取前 2 个 `conditionValue` 作为起止日期。

```json
"conditions": [
  { "field": "dutyDate", "value": "2026-01-01", "compareType": "gte" },
  { "field": "dutyDate", "value": "2026-12-31", "compareType": "lte" }
]
```

只有 `tp.timePoint` 是单条件，用 `eq`。

**不要用 `between`**（2026-09-04 修正，此前误判为可用）：实测 `_inner_params` 回显为 `paramTp: "2026-01-01,2026-12-31"`（服务端只收到单个字符串），返回条数与全量相同，即**退化为不过滤**；`hw` 等数据源传 `between` 则直接报 500。

验证条件是否真生效：查返回记录的 `_inner_params`（如 `paramDutyDate: ["2026-01-01","2026-12-31"]`），它回显服务端实际收到的起止值；并用不同范围对比条数（实测 2026 全年 2535 vs 2026-06 单月 2534，证明过滤生效）。

`COMPARE_TYPES` 白名单含 `eq`/`in`/`like`/`gtAndEq`/`ltAndEq`/`gte`/`lte`/`between`，具体以 `ds.fields` 返回的 `conditionType` 为准。

`getListData` 走 `/api/bs/hr/ai/ds/getListData`，旧路径 `/api/bs/hr/ds/getListData` 已废弃。

## 写操作确认链

出勤域有 4 组写操作，全部采用 `prepare -> apply`：

| 用途 | prepare | apply |
| --- | --- | --- |
| PC 端打卡 | `ehr.attend.card.check.prepare` | `ehr.attend.card.check.apply` |
| 创建申诉 | `ehr.attend.appeal.save.prepare` | `ehr.attend.appeal.save.apply` |
| 处理申诉 | `ehr.attend.appeal.result.prepare` | `ehr.attend.appeal.result.apply` |
| 报表重算 | `ehr.attend.report.recalc.prepare` | `ehr.attend.report.recalc.apply` |

协议：

1. `prepare` 只预览并生成 continuation，不发送写请求。
2. `apply` 必须带 `confirm=true` 与 `prepare` 返回的 continuation。
3. continuation 绑定 `baseUrl`/`profile`/`userId`，10 分钟有效；租户或账号变化会触发 `context_mismatch`。
4. 关键参数与 `prepare` 快照不一致时触发 `target_changed`，必须重新 `prepare`。
5. 网络中断抛出 `partial/write_uncertain`（退出码 `partial`），**禁止自动重试**，先做只读回查。

PC 打卡凭证 `sign` = `md5("wea" + "web" + empId + ETEAMSID + tenantKey 小写) + "ver"`，在 `apply` 阶段按当前会话实时构造，continuation 中不携带签名材料。

## 调用示例

Windows PowerShell：

```powershell
weaver-work-cli --json ehr run ehr.attend.summary.get --input-json '{"beginDate":"2026-03-01","endDate":"2026-03-31"}'
```

macOS/Linux（bash/zsh）：

```bash
printf '%s\n' '{"beginDate":"2026-03-01","endDate":"2026-03-31"}' | weaver-work-cli --json ehr run ehr.attend.summary.get --input -
```

## 招聘查询（recruit）

招聘域第一期提供 4 个读 operation（`operations/recruit.ts`）：

| operation | 用途 |
| --- | --- |
| `ehr.recruit.context.resolve` | 五层解析结果查看（调试/兜底用） |
| `ehr.recruit.candidate.search` | 人才库姓名/手机号模糊搜索（无筛选 = 列出全部） |
| `ehr.recruit.candidate.list` | demand/position/entry/offer 四类业务列表 |
| `ehr.recruit.candidate.card` | 人才详情卡片（权限闸门 + 四卡片并行） |

**五层运行时解析**：招聘接口不依赖固定路径，CLI 在业务调用前自动完成
`getAppIds(tag=weaver-recruit-service) -> getObjsByTag -> allMenus -> ds/fields -> ds/options`，
拿到 appId/objId/listId/cusMenuId/fieldId/optionId。菜单名按关键词匹配（跨版本菜单名不同，
解析不到必须报错，禁止臆造）。解析产物缓存于进程内 + 磁盘双层，磁盘缓存根 = `E10_SKILL_CACHE_DIR`
环境变量，未设置回退 CLI 数据目录 `WEAVER_WORK_CLI_HOME`（默认用户主目录下 `.weaver-work-cli`）的
`env-cache/recruit-ops/<host>_<port>/<appId>.json`，与独立技能 `weaver-e10-jucailin` 同构（设同一
`E10_SKILL_CACHE_DIR` 即可共享）；缓存目录内唯一 `<appId>.json`
直接命中时跳过安装闸门（省 1 次 RTT），缺失时自动全量解析并回写缓存。

**displayData 显示层策略**：EB 列表接口（`mapResult=1`）响应同时含 `data.data`（原始层，
人员/字典字段为裸大 ID）与 `data.displayData`（显示层，服务端已内联姓名与字典中文）。
列表 operation 优先取 displayData（零额外请求）；缺失时回退原始层——字典映射 + 纯 ID 创建人
经 `/api/hrm/common/getEmployeeByIds`（`devtype: 5` 头，100 个一批）批量反查兜底。显示层行清理
剔除 `DISPLAY_UI_KEYS`（operate/isToDo/todoPropSpan/flow_status 等）与 `xxxSpan`/`xxxAction` 动态后缀。

**详情卡片**：`hasRights`（READ 闸门，严格前置）与 `getCandidateCardPrepareQuery` 并行
（3RTT→2RTT），通过后四张卡片（BaseInfo/OtherInfo GET + InterviewInfo/Remark POST）并行；
面试/沟通 `pageSize=100` 防默认 10 条静默截断。姓名内联解析兼容三种形态：数组 `name`、
对象 `username`（备注创建人）、字符串 JSON。唯一仍需本地翻译的是 `baseInfo.recruitStatus`。
每请求随机串：`tableuid` 32 位 hex、`randomkey_`/`v` 6 位 hex。

招聘 EB 接口要求 `ebBusinessId: <listId>` 请求头，`ds/fields` 要求 multipart/form-data，
两者由 `host.ts` 的 `headers`/`multipart` 通道支持（认证头仍由运行时统一注入并保护）。

**招聘写操作**（`operations/recruit-write.ts`）：candidate.create / interview.arrange / offer.send / entry.process / interview.feedback.edit / interview.feedback.urge 六对 prepare→apply 已实现（迁移自 ehr 技能 recruit-* 写脚本），全部走确认链协议：prepare（只读定位/查重/枚举与人员解析 + 摘要 + continuation 10 分钟）→ 用户确认 → apply（confirm + 指纹校验 + 会话绑定校验 + 部分操作 apply 阶段二次查重）；多步写入（saveFormData → initTalentBatch / sendOffer / saveEstPreEntry / runEsb）中断报 `partial/write_uncertain` 禁止自动重试。Offer 邮件、面试通知（notify）、催评为 L3 外部通知。esbActionIds 全部运行时按钮解析（getFormButtons/getListButtons），解析不到阻断不编造。入职撤销（entry.cancel.change）暂缓实现。

## 禁用边界

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

- **招聘撤销类写操作**（`recruit.entry.cancel.change`）：撤销动作破坏性最强且依赖独立入口链路，暂缓迁移；招聘其余读/写操作已可用。
- **移动端打卡**（`attend.card.sign.mobile`）：依赖真实定位、WiFi/蓝牙、设备凭证，Agent 无法获取，服务端必拒。严禁伪造参数重试。

## 测试

```text
node --test --import tsx "test/business/ehr-recruit.test.ts" "test/business/ehr-recruit-write.test.ts" "test/business/ehr-operations.test.ts" "test/business/ehr-host.test.ts"
```

覆盖 manifest/registry 一致性、请求形状断言、写操作确认链、响应归一化、打卡签名算法与路径白名单，招聘域另覆盖 displayData 优先、原始层兜底反查、权限闸门前置、prepare/apply 全协议（指纹篡改拦截、二次查重、partial 报告、按钮解析失败阻断）与错误形状。
