支持企微通讯录缺失手机号(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -168,9 +168,10 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history
|
|||||||
- `login|bind` always keeps `snsapi_base`; only explicit `onboard` uses
|
- `login|bind` always keeps `snsapi_base`; only explicit `onboard` uses
|
||||||
`snsapi_privateinfo`.
|
`snsapi_privateinfo`.
|
||||||
- The member must be visible to the app, active, return the same userid from
|
- The member must be visible to the app, active, return the same userid from
|
||||||
both identity calls, and authorize a valid phone plus personal email or
|
both identity calls, and authorize a valid personal email or business email.
|
||||||
business email. A blank/invalid personal email may fall back to a valid
|
Phone may be absent; when returned it must be normalized and checked for
|
||||||
business email.
|
uniqueness. A blank/invalid personal email may fall back to a valid business
|
||||||
|
email.
|
||||||
- `user_ticket`, OAuth code, access token and raw provider responses exist only
|
- `user_ticket`, OAuth code, access token and raw provider responses exist only
|
||||||
in request memory. Do not persist them or include them in Cookie, audit,
|
in request memory. Do not persist them or include them in Cookie, audit,
|
||||||
response details or logs; provider/network failures use fixed safe codes.
|
response details or logs; provider/network failures use fixed safe codes.
|
||||||
@@ -190,8 +191,9 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history
|
|||||||
- Provider/network failure -> `wecom_api_failed` or safe auth error; no raw
|
- Provider/network failure -> `wecom_api_failed` or safe auth error; no raw
|
||||||
payload, token or ticket in output/logs.
|
payload, token or ticket in output/logs.
|
||||||
- Userid mismatch -> `identity_mismatch`; inactive member -> `member_inactive`.
|
- Userid mismatch -> `identity_mismatch`; inactive member -> `member_inactive`.
|
||||||
- Missing/invalid phone, both emails invalid, or unresolved department ->
|
- Both emails invalid or unresolved department -> `missing_required_field`.
|
||||||
`missing_required_field`.
|
- Phone absent -> continue with `NULL`; phone present but conflicting ->
|
||||||
|
`employee_conflict`.
|
||||||
- Multiple employees, unique-field race or cross-binding -> `employee_conflict`.
|
- Multiple employees, unique-field race or cross-binding -> `employee_conflict`.
|
||||||
- Department binding points to missing/archived data -> `department_invalid`;
|
- Department binding points to missing/archived data -> `department_invalid`;
|
||||||
multiple same-name OA departments -> `department_ambiguous`.
|
multiple same-name OA departments -> `department_ambiguous`.
|
||||||
@@ -232,3 +234,92 @@ ordinary login -> snsapi_base
|
|||||||
one-time onboard -> explicit private consent -> safe unique employee-only write
|
one-time onboard -> explicit private consent -> safe unique employee-only write
|
||||||
business role assignment -> separate administrator-audited operation
|
business role assignment -> separate administrator-audited operation
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Scenario: WeCom Directory Export With Optional Phone
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: importing an administrator-exported WeCom directory XLSX, or
|
||||||
|
changing `Employee.phone` across database, API, HR sync, OAuth and UI layers.
|
||||||
|
- Purpose: allow a real member to remain temporarily without a phone while
|
||||||
|
preserving stable identity, uniqueness and privacy controls.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- DB: `Employee.phone String? @unique`; migration drops only the `NOT NULL`
|
||||||
|
constraint and keeps the unique index.
|
||||||
|
- CLI: `npm run import:wecom-directory-export -- --input=- --dry-run`.
|
||||||
|
- Apply: `WECOM_DIRECTORY_EXPORT_APPLY=1 ... --input=- --apply
|
||||||
|
--actor-employee-no=<employee-no>`.
|
||||||
|
- User API and frontend employee/user types expose `phone: string | null`;
|
||||||
|
create/update accepts an omitted or explicit `null` phone.
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- Missing phone is stored as SQL `NULL`, never `""`, a repeated placeholder or
|
||||||
|
a synthesized fake number. Multiple `NULL` rows are valid; non-null phones
|
||||||
|
remain unique.
|
||||||
|
- WeCom account and valid enterprise email remain required for directory-export
|
||||||
|
onboarding. Account, non-null phone and enterprise email duplicates block
|
||||||
|
the input.
|
||||||
|
- An empty source phone never clears an existing OA phone. Only an explicitly
|
||||||
|
supplied valid phone participates in update and unique-conflict checks.
|
||||||
|
- The XLSX is accepted only from stdin and defaults to dry-run. Apply requires
|
||||||
|
both `--apply` and `WECOM_DIRECTORY_EXPORT_APPLY=1`, plus an active actor.
|
||||||
|
- CLI output contains counts and categories only. The workbook, full local path,
|
||||||
|
names, accounts, phones, emails, departments and raw rows must not enter Git,
|
||||||
|
Trellis artifacts or reports.
|
||||||
|
- New accounts receive only `employee`; finance, CEO and admin roles remain a
|
||||||
|
separate administrator-audited operation.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing phone cell -> valid profile with `phone=null`.
|
||||||
|
- Supplied malformed phone -> incomplete/blocked; do not downgrade it to an
|
||||||
|
absent phone and do not write it.
|
||||||
|
- Missing/invalid enterprise email, account, name or department -> skipped or
|
||||||
|
blocked as `missing_required_field`.
|
||||||
|
- Duplicate account/non-null phone/enterprise email in the workbook ->
|
||||||
|
`duplicate_values`; error output contains counts only.
|
||||||
|
- Existing OA unique-field or binding conflict -> conflict; no automatic merge.
|
||||||
|
- `--apply` without confirmation env or active actor -> refuse before sync.
|
||||||
|
- Migration rollback with any `phone IS NULL` row -> stop; do not fill fake
|
||||||
|
values merely to restore `NOT NULL`.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: five members have unique phones, one member has no phone, all six have
|
||||||
|
unique accounts and enterprise emails; dry-run plans six records and reports
|
||||||
|
only aggregate counts.
|
||||||
|
- Base: a later export still omits a member phone; the member's existing OA
|
||||||
|
phone is preserved.
|
||||||
|
- Bad: write an empty string or shared placeholder, use personal email in place
|
||||||
|
of the required enterprise email for bulk identity, or auto-grant a business
|
||||||
|
role during import.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- Prisma validation plus a migration review proving only `DROP NOT NULL`.
|
||||||
|
- Unit tests for instruction rows/Chinese headers, five-present/one-missing,
|
||||||
|
malformed phone, missing enterprise email and duplicate account/phone/email.
|
||||||
|
- Route/service tests for create/update/import `null`, non-empty unique checks,
|
||||||
|
OAuth missing phone and empty-source preservation of an existing phone.
|
||||||
|
- Frontend typecheck/build and displays that render a clear empty-phone label.
|
||||||
|
- Full backend regression, Trellis governance, sensitive-content scan and
|
||||||
|
`git diff --check`; a real workbook may be parsed only into aggregate counts
|
||||||
|
and remains Git-ignored.
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```js
|
||||||
|
phone: source.phone || "00000000000"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```js
|
||||||
|
phone: source.phone || null
|
||||||
|
// Existing employee updates omit `phone` entirely when the source is empty.
|
||||||
|
```
|
||||||
|
|||||||
@@ -108,6 +108,15 @@ GET /auth/wecom/login?mode=onboard
|
|||||||
- 已有绑定员工只补齐经授权的规范化资料且不改角色;新员工在单事务内创建 employee、`employee` 角色、企微绑定和脱敏审计。任何一步失败均不留下部分账号。
|
- 已有绑定员工只补齐经授权的规范化资料且不改角色;新员工在单事务内创建 employee、`employee` 角色、企微绑定和脱敏审计。任何一步失败均不留下部分账号。
|
||||||
- 自助开通不替代管理员职责分配;`finance`、`ceo` 仍沿用角色接口的 token version、会话撤销和审计合同。
|
- 自助开通不替代管理员职责分配;`finance`、`ceo` 仍沿用角色接口的 token version、会话撤销和审计合同。
|
||||||
|
|
||||||
|
## 企微通讯录导出与手机号可空
|
||||||
|
|
||||||
|
- 管理员导出的 XLSX 只作为一次性受控输入,不进入 Git、Trellis、报告或生产文件系统;CLI 支持从 stdin 读取,默认仅 dry-run。
|
||||||
|
- 入口识别企微官方说明行和中文表头,将姓名、账号、职务、部门、手机、邮箱/企业邮箱、激活/禁用状态归一化为既有 HR master profile,再复用部门解析、员工匹配、企微绑定、审计和 `employee` 初始角色语义。
|
||||||
|
- `Employee.phone` 改为 nullable unique:缺失值统一保存为 `NULL`,非空值继续唯一。企业邮箱仍必填且唯一,企微账号仍是绑定主键;不生成占位手机号。
|
||||||
|
- 来源未提供手机号时,不得把既有 OA 手机号清空;来源提供手机号时才进入变更和唯一性检查。本人授权同样允许手机号缺失,但邮箱或企业邮箱仍必须有效。
|
||||||
|
- apply 需要命令参数和独立环境变量双门,且真实运行另行批准;dry-run 与 apply 的报告都只含计数、动作、冲突类别和脱敏摘要。
|
||||||
|
- 生产迁移回滚前必须确认不存在 `phone IS NULL`;否则停止回滚,不用假数据填充。
|
||||||
|
|
||||||
## 部署目录统一设计约束
|
## 部署目录统一设计约束
|
||||||
|
|
||||||
- 现场是混合部署,不能把“统一目录”处理成普通 rsync:必须以当前活动 PostgreSQL 数据和附件挂载为唯一数据事实源。
|
- 现场是混合部署,不能把“统一目录”处理成普通 rsync:必须以当前活动 PostgreSQL 数据和附件挂载为唯一数据事实源。
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
- [x] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。
|
- [x] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。
|
||||||
- [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。
|
- [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。
|
||||||
- [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。
|
- [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。
|
||||||
|
- [x] 将 `Employee.phone` 改为 nullable unique,并同步员工创建/批量导入、企微 HR master、本人授权、登录查询和前端空值合同;不执行生产 migration。
|
||||||
|
- [x] 增加企微通讯录 XLSX/stdin 默认 dry-run 入口,复用 HR master 写入语义,apply 使用独立双门且本阶段不执行。
|
||||||
|
- [x] 用完全合成的通讯录覆盖“5 个手机号 + 1 个缺失”、重复账号/手机号/邮箱、说明行/中文表头、既有手机号不被空来源清除和报告隐私门禁。
|
||||||
|
|
||||||
## 推荐文件边界
|
## 推荐文件边界
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@
|
|||||||
- `cd server && npm run prisma:validate`
|
- `cd server && npm run prisma:validate`
|
||||||
- `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js`
|
- `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js`
|
||||||
- `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js tests/unit/wecomOAuthOnboarding.service.test.js`
|
- `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js tests/unit/wecomOAuthOnboarding.service.test.js`
|
||||||
|
- `cd server && npm test -- --run tests/unit/routes/users.import.test.js tests/unit/wecomHrMaster.service.test.js tests/unit/wecomOAuthOnboarding.service.test.js tests/unit/scripts/import-wecom-directory-export.test.js`
|
||||||
- `bash -n deploy/trialrun/deploy.sh`
|
- `bash -n deploy/trialrun/deploy.sh`
|
||||||
- `bash -n deploy/trialrun/install-warch3-ops-timer.sh`
|
- `bash -n deploy/trialrun/install-warch3-ops-timer.sh`
|
||||||
|
|
||||||
@@ -71,6 +75,13 @@
|
|||||||
- 已从活动 DB 容器与活动 server 附件挂载生成正确批次并复制到 ARM 备份机;DB catalog、manifest、源/目标产物 SHA-256、附件 tar 与 0 个普通文件计数均通过,且未伪造旧实例 build SHA。ARM 只承担异机备份与恢复演练职责,不运行 OA 服务。
|
- 已从活动 DB 容器与活动 server 附件挂载生成正确批次并复制到 ARM 备份机;DB catalog、manifest、源/目标产物 SHA-256、附件 tar 与 0 个普通文件计数均通过,且未伪造旧实例 build SHA。ARM 只承担异机备份与恢复演练职责,不运行 OA 服务。
|
||||||
- 已在 ARM 备份机用不发布端口的一次性 PostgreSQL 16 容器恢复正确批次:58 项成功迁移、12 张关键表计数和 0 个附件普通文件均与冻结基线一致,包含镜像拉取的实测 RTO 为 33 秒。演练后容器、临时附件目录和本轮新增镜像均已删除,未运行 OA server;应用 `/ready` 留待生产部署环境验证。
|
- 已在 ARM 备份机用不发布端口的一次性 PostgreSQL 16 容器恢复正确批次:58 项成功迁移、12 张关键表计数和 0 个附件普通文件均与冻结基线一致,包含镜像拉取的实测 RTO 为 33 秒。演练后容器、临时附件目录和本轮新增镜像均已删除,未运行 OA server;应用 `/ready` 留待生产部署环境验证。
|
||||||
|
|
||||||
|
## 手机号可空与通讯录导入本地证据(2026-07-27)
|
||||||
|
|
||||||
|
- 后端全量 Vitest:163 个测试文件、1016 个测试通过;补充双 apply 门断言后,通讯录导入聚焦 8 项再次通过。新增覆盖普通用户创建/显式清空、CSV 空手机号、HR 空来源保留既有值、OAuth 无手机号建档、通讯录说明行/中文表头、企业邮箱门禁、非法手机号、重复字段、apply 双门及脱敏状态。
|
||||||
|
- `prisma validate`、四个后端改动文件语法、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。
|
||||||
|
- 管理员提供的真实通讯录仅执行解析级只读检查:6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库、未执行 apply、未分配角色、未发送企微消息,文件保持 Git 排除。
|
||||||
|
- 最终只读复核确认 migration 仅删除手机号 `NOT NULL`、唯一索引保留;导入及本人开通只创建 `employee`,不自动授予 `finance`、`ceo`、`admin` 或 `system_admin`;空来源更新省略 phone 字段,真实文件名、完整路径和个人字段未进入差异。
|
||||||
|
|
||||||
## 生产部署与 timer 收口证据(2026-07-27)
|
## 生产部署与 timer 收口证据(2026-07-27)
|
||||||
|
|
||||||
- 已在获批停机窗口把生产切换到唯一 Compose project;活动 DB 与附件从停机冻结副本进入新的独立目录,旧容器保持停止,旧数据目录原位保留作回滚点。
|
- 已在获批停机窗口把生产切换到唯一 Compose project;活动 DB 与附件从停机冻结副本进入新的独立目录,旧容器保持停止,旧数据目录原位保留作回滚点。
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
- 经用户逐项授权后,修正企微通讯录与人事助手的双 Secret 读取分工,并用脱敏 dry-run 唯一确认两名既有企微成员。
|
- 经用户逐项授权后,修正企微通讯录与人事助手的双 Secret 读取分工,并用脱敏 dry-run 唯一确认两名既有企微成员。
|
||||||
- 唯一匹配且必填资料、冲突检查通过后,将两名成员同步为 OA 员工,并分别配置 `employee + finance` 与 `employee + ceo`,形成不同自然人的付款制单/复核职责。
|
- 唯一匹配且必填资料、冲突检查通过后,将两名成员同步为 OA 员工,并分别配置 `employee + finance` 与 `employee + ceo`,形成不同自然人的付款制单/复核职责。
|
||||||
- 增加独立的一次性企微敏感资料授权入口:普通登录继续使用 `snsapi_base`,首次开通使用 `snsapi_privateinfo`;未绑定的在职可见成员授权成功后只自动获得 `employee` 角色。
|
- 增加独立的一次性企微敏感资料授权入口:普通登录继续使用 `snsapi_base`,首次开通使用 `snsapi_privateinfo`;未绑定的在职可见成员授权成功后只自动获得 `employee` 角色。
|
||||||
|
- 支持管理员提供的企微通讯录 XLSX 通过 stdin 做脱敏 dry-run,并允许个别成员暂缺手机号;企业邮箱、企微账号、姓名和部门仍作为建档门禁。
|
||||||
- 修复部署脚本与现场端口、目录、容器和构建标识契约,统一真实运行来源;切换前保留活动数据、环境文件和旧容器回滚点。
|
- 修复部署脚本与现场端口、目录、容器和构建标识契约,统一真实运行来源;切换前保留活动数据、环境文件和旧容器回滚点。
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -37,10 +38,11 @@
|
|||||||
- 将真实内部实例核查列为后续同任务外部门禁:运行 commit/访问方式/TLS、真实职责分配、异机副本和恢复后 `/ready` 均需另行批准和脱敏留证。
|
- 将真实内部实例核查列为后续同任务外部门禁:运行 commit/访问方式/TLS、真实职责分配、异机副本和恢复后 `/ready` 均需另行批准和脱敏留证。
|
||||||
- 企微通讯录 Secret 只用于 `/user/list_id`;自建应用 Secret 用于 `/user/get`、`/department/list` 和获授权的人事助手 `/hr/get_staff_info`,不得回退到已废弃的人事助手 Secret。
|
- 企微通讯录 Secret 只用于 `/user/list_id`;自建应用 Secret 用于 `/user/get`、`/department/list` 和获授权的人事助手 `/hr/get_staff_info`,不得回退到已废弃的人事助手 Secret。
|
||||||
- 真实同步前只输出匹配数量、身份是否不同、缺失字段数、冲突数和计划创建/更新数,不输出姓名、userid、手机号、邮箱、部门原文或原始 payload。
|
- 真实同步前只输出匹配数量、身份是否不同、缺失字段数、冲突数和计划创建/更新数,不输出姓名、userid、手机号、邮箱、部门原文或原始 payload。
|
||||||
|
- `Employee.phone` 允许为 `NULL`,但不得写空字符串或假手机号;非空手机号继续保持唯一。企微账号和企业邮箱必须非空且唯一,缺手机号成员使用企微或邮箱登录,后续可由本人授权或管理员补齐。
|
||||||
- 真实写入必须复用现有员工同步、角色、会话撤销和审计语义;财务处理人与 CEO 复核人必须是两个不同企微身份,且不得把 `admin` 当作普通业务复核角色。
|
- 真实写入必须复用现有员工同步、角色、会话撤销和审计语义;财务处理人与 CEO 复核人必须是两个不同企微身份,且不得把 `admin` 当作普通业务复核角色。
|
||||||
- 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并实际同意手机号及邮箱或企业邮箱授权。
|
- 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并至少实际同意邮箱或企业邮箱授权;手机号可暂缺,但返回时必须规范化并参与唯一性检查。
|
||||||
- `user_ticket` 只在服务端内存中用于一次 `getuserdetail` 调用,不写日志、Cookie、数据库、审计或错误详情;授权 code 和原始敏感响应同样不得持久化。
|
- `user_ticket` 只在服务端内存中用于一次 `getuserdetail` 调用,不写日志、Cookie、数据库、审计或错误详情;授权 code 和原始敏感响应同样不得持久化。
|
||||||
- 自助开通按企微 userid、手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。
|
- 自助开通按企微 userid、非空手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。
|
||||||
- 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。
|
- 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。
|
||||||
- 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。
|
- 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。
|
||||||
- Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。
|
- Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。
|
||||||
@@ -66,14 +68,15 @@
|
|||||||
- [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。
|
- [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。
|
||||||
- [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。
|
- [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。
|
||||||
- [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。
|
- [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。
|
||||||
|
- [x] 本地合成企微导出证明 5 人有手机号、1 人手机号为空时 dry-run 可通过;空值保存为 `NULL`,非空手机号/邮箱/企微账号重复均被拒绝,报告不含个人字段原文。
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- 不读取真实附件正文,不执行生产恢复、数据库 reset、迁移历史改写或付款/报销业务写入。
|
- 不读取真实附件正文,不执行生产恢复、数据库 reset、迁移历史改写或付款/报销业务写入。
|
||||||
- 不修改 Prisma schema、公开业务 API、权限模型、审批链或付款状态机;仅修复既有 HR 同步服务的凭证分工,并通过既有角色模型写入获批职责。
|
- 除已批准的 `Employee.phone` nullable 兼容外,不扩大修改其他 Prisma schema、权限模型、审批链或付款状态机;公开用户 API 仅同步手机号可空合同,并通过既有角色模型写入获批职责。
|
||||||
- 不引入 MinIO、云备份 SDK、全量灾备平台、自动跨地域复制或 24×7 值守。
|
- 不引入 MinIO、云备份 SDK、全量灾备平台、自动跨地域复制或 24×7 值守。
|
||||||
- 不在仓库提交备份文件、恢复数据、环境变量、凭证、真实账号或完整本地路径。
|
- 不在仓库提交备份文件、恢复数据、环境变量、凭证、真实账号或完整本地路径。
|
||||||
- 不发送企微消息、不创建企微审批模板、不把手机号/邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。
|
- 不发送企微消息、不创建企微审批模板、不把邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。
|
||||||
- 不在本任务完成企微调休、导航、报销或模块退役;它们属于后续批次。
|
- 不在本任务完成企微调休、导航、报销或模块退役;它们属于后续批次。
|
||||||
|
|
||||||
## Test Plan
|
## Test Plan
|
||||||
@@ -97,13 +100,16 @@
|
|||||||
|
|
||||||
先改进可恢复性和防误操作,再接触真实环境。默认工作流只支持影子恢复;真实恢复不是普通脚本路径,而是需要停机、负责人确认、成对备份、回滚点和独立授权的事故操作。
|
先改进可恢复性和防误操作,再接触真实环境。默认工作流只支持影子恢复;真实恢复不是普通脚本路径,而是需要停机、负责人确认、成对备份、回滚点和独立授权的事故操作。
|
||||||
|
|
||||||
企微敏感字段采用“日常静默登录 + 一次性本人授权开通”的双入口。批量同步不猜测敏感资料,不修改全局员工字段可空性;自助开通只授予 `employee`,业务角色继续由管理员审计写入。
|
企微敏感字段采用“日常静默登录 + 一次性本人授权开通”的双入口。批量同步不猜测敏感资料;手机号按已批准范围改为全局 nullable,空值统一保存为 `NULL`。自助开通只授予 `employee`,业务角色继续由管理员审计写入。
|
||||||
|
|
||||||
## Local Evidence
|
## Local Evidence
|
||||||
|
|
||||||
- 已使用一次性 PostgreSQL 16 容器和合成附件完成多轮往返演练,最近一次本地参考 RTO 为 6 秒。
|
- 已使用一次性 PostgreSQL 16 容器和合成附件完成多轮往返演练,最近一次本地参考 RTO 为 6 秒。
|
||||||
- 演练覆盖 manifest/校验和、错误确认、正式风格库名、损坏 DB、损坏 tar、非空附件目录、默认试运行附件目录和危险 tar 路径拒绝。
|
- 演练覆盖 manifest/校验和、错误确认、正式风格库名、损坏 DB、损坏 tar、非空附件目录、默认试运行附件目录和危险 tar 路径拒绝。
|
||||||
- 该证据不包含真实业务数据、真实运行实例、异机副本或部署后 `/ready`,因此最后一项验收保持未完成。
|
- 该证据不包含真实业务数据、真实运行实例、异机副本或部署后 `/ready`,因此最后一项验收保持未完成。
|
||||||
|
- 2026-07-27 完成手机号 nullable 本地切片:Prisma 仅新增 `phone DROP NOT NULL` migration,用户创建/更新/批量导入、HR master、本人授权和前端空值合同已同步;本轮没有执行 migration 或写入真实员工。
|
||||||
|
- 合成通讯录覆盖说明行/中文表头、5 个手机号加 1 个缺失、非法手机号、缺企业邮箱、重复账号/非空手机号/企业邮箱、双重 apply 门和脱敏报告。管理员提供的真实 XLSX 仅在内存中只读解析为 6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库,文件保持 Git 排除。
|
||||||
|
- 最终本地门禁:后端全量 163 个测试文件、1016 个测试通过;补充双 apply 门测试后,通讯录导入聚焦 8 项再次通过。Prisma validate、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。文档审计保留仓库既有 22 个未注册文件 warning,无新增 error。
|
||||||
|
|
||||||
## Live Read-Only Evidence
|
## Live Read-Only Evidence
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export type EmployeeSummary = {
|
|||||||
id: string;
|
id: string;
|
||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
@@ -231,7 +231,7 @@ export type EmployeeProfile = {
|
|||||||
id: string;
|
id: string;
|
||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
employmentStatus: EmployeeEmploymentStatus;
|
employmentStatus: EmployeeEmploymentStatus;
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ export type MeResponse = {
|
|||||||
id: string;
|
id: string;
|
||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
directManagerId: string | null;
|
directManagerId: string | null;
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ export type UserSummary = {
|
|||||||
id: string;
|
id: string;
|
||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
@@ -89,7 +89,7 @@ export async function fetchUsers(params: {
|
|||||||
|
|
||||||
export async function createUser(payload: {
|
export async function createUser(payload: {
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
departmentId?: string;
|
departmentId?: string;
|
||||||
directManagerId?: string | null;
|
directManagerId?: string | null;
|
||||||
|
|||||||
@@ -802,7 +802,7 @@ export function EmployeeProfilePage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-zinc-500">手机 / 邮箱</div>
|
<div className="text-sm text-zinc-500">手机 / 邮箱</div>
|
||||||
<div className="mt-2 text-sm text-zinc-900">
|
<div className="mt-2 text-sm text-zinc-900">
|
||||||
{profile.employee.phone} / {profile.employee.email}
|
{profile.employee.phone ?? "未填写"} / {profile.employee.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export function UserManagementPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
<div>{item.email}</div>
|
<div>{item.email}</div>
|
||||||
<div className="text-zinc-500">{item.phone}</div>
|
<div className="text-zinc-500">{item.phone ?? "手机号未填写"}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||||
<TableCell className="text-xs">{formatRoleCodes(item.roleCodes)}</TableCell>
|
<TableCell className="text-xs">{formatRoleCodes(item.roleCodes)}</TableCell>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Label } from "../../components/ui/label";
|
|||||||
|
|
||||||
export interface CreateUserFormData {
|
export interface CreateUserFormData {
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
departmentId: string | undefined;
|
departmentId: string | undefined;
|
||||||
directManagerId: string | null;
|
directManagerId: string | null;
|
||||||
@@ -61,7 +61,6 @@ export function CreateUserDialog({
|
|||||||
const canSubmit =
|
const canSubmit =
|
||||||
!isPending &&
|
!isPending &&
|
||||||
trimmedName !== "" &&
|
trimmedName !== "" &&
|
||||||
trimmedPhone !== "" &&
|
|
||||||
trimmedEmail !== "" &&
|
trimmedEmail !== "" &&
|
||||||
rolesArray.length >= 1;
|
rolesArray.length >= 1;
|
||||||
|
|
||||||
@@ -84,7 +83,7 @@ export function CreateUserDialog({
|
|||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
onSubmit({
|
onSubmit({
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
phone: trimmedPhone,
|
phone: trimmedPhone || null,
|
||||||
email: trimmedEmail,
|
email: trimmedEmail,
|
||||||
departmentId: departmentId || undefined,
|
departmentId: departmentId || undefined,
|
||||||
directManagerId: directManagerId ? directManagerId : null,
|
directManagerId: directManagerId ? directManagerId : null,
|
||||||
@@ -117,13 +116,13 @@ export function CreateUserDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="phone">手机号</Label>
|
<Label htmlFor="phone">手机号(可暂不填写)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
data-testid="users-create-phone"
|
data-testid="users-create-phone"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
placeholder="13800000001"
|
placeholder="后续可由本人授权或管理员补齐"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface UserImportDialogProps {
|
|||||||
const TEMPLATE_CSV = [
|
const TEMPLATE_CSV = [
|
||||||
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
||||||
"E1001,李经理,13800000001,[email protected],行政部,经理,,manager,2026-03-01",
|
"E1001,李经理,13800000001,[email protected],行政部,经理,,manager,2026-03-01",
|
||||||
"E1002,张三,13800000002,[email protected],行政部,员工,E1001,employee,2026-03-15",
|
"E1002,张三,,[email protected],行政部,员工,E1001,employee,2026-03-15",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
function toCsvCell(value: string) {
|
function toCsvCell(value: string) {
|
||||||
@@ -205,10 +205,10 @@ export function UserImportDialog({ open, onOpenChange, onImported }: UserImportD
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-zinc-500">
|
||||||
必填列:employeeNo、name、phone、email、departmentName
|
必填列:employeeNo、name、email、departmentName
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-zinc-500">
|
||||||
选填列:title、directManagerEmployeeNo、roles、hireDate
|
选填列:phone、title、directManagerEmployeeNo、roles、hireDate
|
||||||
</div>
|
</div>
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-xs text-zinc-600">
|
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-xs text-zinc-600">
|
||||||
|
|||||||
+2
-1
@@ -40,9 +40,10 @@
|
|||||||
| 企业微信回调 IP | [获取企业微信回调 IP 段](https://developer.work.weixin.qq.com/document/path/92521) | `/cgi-bin/getcallbackip` | `GET` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | 用于回调入口放行和安全审计,不进入业务数据。 |
|
| 企业微信回调 IP | [获取企业微信回调 IP 段](https://developer.work.weixin.qq.com/document/path/92521) | `/cgi-bin/getcallbackip` | `GET` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | 用于回调入口放行和安全审计,不进入业务数据。 |
|
||||||
| 网页授权链接 | [构造网页授权链接](https://developer.work.weixin.qq.com/document/path/91022) | `connect/oauth2/authorize` | 浏览器跳转 | `WECOM_AGENT_SECRET` 所属应用 | `CAN_REUSE_NOW` | 企业微信工作台入口和 deeplink 回 OA 使用;只改变登录体验,不改变 OA 权限。 |
|
| 网页授权链接 | [构造网页授权链接](https://developer.work.weixin.qq.com/document/path/91022) | `connect/oauth2/authorize` | 浏览器跳转 | `WECOM_AGENT_SECRET` 所属应用 | `CAN_REUSE_NOW` | 企业微信工作台入口和 deeplink 回 OA 使用;只改变登录体验,不改变 OA 权限。 |
|
||||||
| 访问用户身份 | [获取访问用户身份](https://developer.work.weixin.qq.com/document/path/91023) | `/cgi-bin/auth/getuserinfo` | `GET` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | 获取当前企业微信 userid 后匹配 OA 绑定;不能仅靠手机号/邮箱授权登录。 |
|
| 访问用户身份 | [获取访问用户身份](https://developer.work.weixin.qq.com/document/path/91023) | `/cgi-bin/auth/getuserinfo` | `GET` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | 获取当前企业微信 userid 后匹配 OA 绑定;不能仅靠手机号/邮箱授权登录。 |
|
||||||
| 成员本人敏感资料授权 | [网页授权链接](https://developer.work.weixin.qq.com/document/path/91022)、[访问用户身份](https://developer.work.weixin.qq.com/document/path/91023)、[访问用户敏感信息](https://developer.work.weixin.qq.com/document/path/95833) | `snsapi_privateinfo` + `/cgi-bin/auth/getuserinfo` + `/cgi-bin/auth/getuserdetail` | 浏览器跳转 + `GET/POST` | `WECOM_AGENT_SECRET` 所属应用 | `LOCAL_IMPLEMENTED_EXTERNAL_CONFIG_PENDING` | 普通登录继续用 `snsapi_base`;仅默认关闭的一次性开通入口请求私密授权。成员同意后,服务端用短时 `user_ticket` 立即换取手机及个人邮箱或企业邮箱;票据、code 和原始响应不落库、不写 Cookie、日志或审计。管理员仍需在应用详情选择相应敏感字段,成员需本人确认。 |
|
| 成员本人敏感资料授权 | [网页授权链接](https://developer.work.weixin.qq.com/document/path/91022)、[访问用户身份](https://developer.work.weixin.qq.com/document/path/91023)、[访问用户敏感信息](https://developer.work.weixin.qq.com/document/path/95833) | `snsapi_privateinfo` + `/cgi-bin/auth/getuserinfo` + `/cgi-bin/auth/getuserdetail` | 浏览器跳转 + `GET/POST` | `WECOM_AGENT_SECRET` 所属应用 | `LOCAL_IMPLEMENTED_EXTERNAL_CONFIG_PENDING` | 普通登录继续用 `snsapi_base`;仅默认关闭的一次性开通入口请求私密授权。成员同意后,服务端用短时 `user_ticket` 立即换取已授权字段;个人邮箱或企业邮箱仍是建档必填,手机号可暂缺,返回时才规范化并检查唯一。票据、code 和原始响应不落库、不写 Cookie、日志或审计。管理员仍需在应用详情选择相应敏感字段,成员需本人确认。 |
|
||||||
| 应用消息 | [发送应用消息](https://developer.work.weixin.qq.com/document/path/90236) | `/cgi-bin/message/send` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | OA 主办审批只发脱敏标题、类型、编号、状态和 OA deeplink;不发送高敏附件。 |
|
| 应用消息 | [发送应用消息](https://developer.work.weixin.qq.com/document/path/90236) | `/cgi-bin/message/send` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | OA 主办审批只发脱敏标题、类型、编号、状态和 OA deeplink;不发送高敏附件。 |
|
||||||
| 通讯录概述 | [通讯录管理](https://developer.work.weixin.qq.com/document/path/90193) | 通讯录成员、部门、回调和导入导出 | 混合 | `WECOM_CONTACT_SECRET` | `CAN_REUSE_NOW_PARTIAL` | 通讯录是单独 Secret 域,和工作台应用 Secret 分开管理。 |
|
| 通讯录概述 | [通讯录管理](https://developer.work.weixin.qq.com/document/path/90193) | 通讯录成员、部门、回调和导入导出 | 混合 | `WECOM_CONTACT_SECRET` | `CAN_REUSE_NOW_PARTIAL` | 通讯录是单独 Secret 域,和工作台应用 Secret 分开管理。 |
|
||||||
|
| 管理后台通讯录导出 | 企业微信管理后台导出 XLSX | `npm run import:wecom-directory-export -- --input=- --dry-run` | 本地 stdin | 无 Secret | `LOCAL_IMPLEMENTED_NOT_DEPLOYED` | 作为批量 API 缺少敏感字段时的一次性管理员输入;只接受 stdin、默认 dry-run,真实 apply 需参数和环境变量双门及另行批准。企微账号和企业邮箱必填且唯一,手机号可暂缺并保存为 `NULL`;空来源不清除 OA 既有手机号,报告只输出计数和类别,原文件不入 Git。 |
|
||||||
| 成员 ID 清单 | [获取成员 ID 列表](https://developer.work.weixin.qq.com/document/path/96067) | `/cgi-bin/user/list_id` | `POST` | `WECOM_CONTACT_SECRET` | `CAN_REUSE_NOW` | WOUA/WHMS 全量候选必须走该接口,请求体只使用 `cursor`/`limit`,返回用户-部门关系后本地按 userid 去重;不再用旧 `/cgi-bin/user/list` 做全量读取。 |
|
| 成员 ID 清单 | [获取成员 ID 列表](https://developer.work.weixin.qq.com/document/path/96067) | `/cgi-bin/user/list_id` | `POST` | `WECOM_CONTACT_SECRET` | `CAN_REUSE_NOW` | WOUA/WHMS 全量候选必须走该接口,请求体只使用 `cursor`/`limit`,返回用户-部门关系后本地按 userid 去重;不再用旧 `/cgi-bin/user/list` 做全量读取。 |
|
||||||
| 读取成员 | [读取成员](https://developer.work.weixin.qq.com/document/path/90196) | `/cgi-bin/user/get` | `GET` | `WECOM_AGENT_SECRET` | `OPTIONAL_ENRICHMENT` | 用自建应用 token 补齐 userid 对应的姓名、状态和部门等可见资料。2022-06-20 后新建应用的手机、邮箱和企业邮箱不能以批量接口缺失推断为企微不支持;这些字段按成员本人敏感资料授权链路获取。 |
|
| 读取成员 | [读取成员](https://developer.work.weixin.qq.com/document/path/90196) | `/cgi-bin/user/get` | `GET` | `WECOM_AGENT_SECRET` | `OPTIONAL_ENRICHMENT` | 用自建应用 token 补齐 userid 对应的姓名、状态和部门等可见资料。2022-06-20 后新建应用的手机、邮箱和企业邮箱不能以批量接口缺失推断为企微不支持;这些字段按成员本人敏感资料授权链路获取。 |
|
||||||
| 获取部门成员 | [获取部门成员](https://developer.work.weixin.qq.com/document/path/90200) | `/cgi-bin/user/simplelist` | `GET` | `WECOM_CONTACT_SECRET` | `LEGACY_NOT_DEFAULT` | 仅作历史参考,当前全量读取不依赖它。 |
|
| 获取部门成员 | [获取部门成员](https://developer.work.weixin.qq.com/document/path/90200) | `/cgi-bin/user/simplelist` | `GET` | `WECOM_CONTACT_SECRET` | `LEGACY_NOT_DEFAULT` | 仅作历史参考,当前全量读取不依赖它。 |
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
||||||
"audit:org-master-data": "node src/scripts/audit-org-master-data.js",
|
"audit:org-master-data": "node src/scripts/audit-org-master-data.js",
|
||||||
"audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js",
|
"audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js",
|
||||||
|
"import:wecom-directory-export": "node src/scripts/import-wecom-directory-export.js",
|
||||||
"repair:oa-only-smoke": "node src/scripts/repair-oa-only-smoke-accounts.js",
|
"repair:oa-only-smoke": "node src/scripts/repair-oa-only-smoke-accounts.js",
|
||||||
"check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.js",
|
"check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.js",
|
||||||
"check:wecom-approval-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js",
|
"check:wecom-approval-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Enterprise WeChat directory exports may omit a member phone number.
|
||||||
|
-- PostgreSQL unique constraints continue to reject duplicate non-null phones
|
||||||
|
-- while allowing multiple employees to remain temporarily unfilled.
|
||||||
|
ALTER TABLE "employees"
|
||||||
|
ALTER COLUMN "phone" DROP NOT NULL;
|
||||||
@@ -578,7 +578,7 @@ model Employee {
|
|||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
|
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
|
||||||
name String
|
name String
|
||||||
phone String @unique
|
phone String? @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
department_id String @db.Uuid
|
department_id String @db.Uuid
|
||||||
legal_entity_id String? @db.Uuid
|
legal_entity_id String? @db.Uuid
|
||||||
|
|||||||
+19
-14
@@ -58,10 +58,15 @@ const normalizedEmailSchema = z
|
|||||||
.max(200)
|
.max(200)
|
||||||
.transform((value) => value.toLowerCase());
|
.transform((value) => value.toLowerCase());
|
||||||
|
|
||||||
|
const nullablePhoneSchema = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" && value.trim() === "" ? null : value),
|
||||||
|
z.string().trim().min(6).max(30).nullable().optional(),
|
||||||
|
);
|
||||||
|
|
||||||
const createBodySchema = z.object({
|
const createBodySchema = z.object({
|
||||||
employeeNo: z.string().trim().min(1).max(50).optional(),
|
employeeNo: z.string().trim().min(1).max(50).optional(),
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
phone: z.string().min(6).max(30),
|
phone: nullablePhoneSchema,
|
||||||
email: normalizedEmailSchema,
|
email: normalizedEmailSchema,
|
||||||
departmentId: z.string().uuid().optional(),
|
departmentId: z.string().uuid().optional(),
|
||||||
title: z.string().min(1).max(100).optional(),
|
title: z.string().min(1).max(100).optional(),
|
||||||
@@ -74,7 +79,7 @@ const createBodySchema = z.object({
|
|||||||
|
|
||||||
const updateBodySchema = z.object({
|
const updateBodySchema = z.object({
|
||||||
name: z.string().trim().min(1).max(100).optional(),
|
name: z.string().trim().min(1).max(100).optional(),
|
||||||
phone: z.string().trim().min(6).max(30).optional(),
|
phone: nullablePhoneSchema,
|
||||||
email: normalizedEmailSchema.optional(),
|
email: normalizedEmailSchema.optional(),
|
||||||
departmentId: z.string().uuid().optional(),
|
departmentId: z.string().uuid().optional(),
|
||||||
title: z.string().trim().min(1).max(100).optional(),
|
title: z.string().trim().min(1).max(100).optional(),
|
||||||
@@ -98,8 +103,8 @@ const sensitiveBodySchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "phone", "email", "departmentName"];
|
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "email", "departmentName"];
|
||||||
const USER_IMPORT_OPTIONAL_HEADERS = ["title", "directManagerEmployeeNo", "roles", "hireDate"];
|
const USER_IMPORT_OPTIONAL_HEADERS = ["phone", "title", "directManagerEmployeeNo", "roles", "hireDate"];
|
||||||
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
|
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
|
||||||
const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([
|
const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([
|
||||||
"text/csv",
|
"text/csv",
|
||||||
@@ -773,11 +778,9 @@ function validateImportRowFields(row) {
|
|||||||
pushImportRowError(row, "name", "too_long");
|
pushImportRowError(row, "name", "too_long");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalized.phone) {
|
if (normalized.phone && normalized.phone.length < 6) {
|
||||||
pushImportRowError(row, "phone", "required");
|
|
||||||
} else if (normalized.phone.length < 6) {
|
|
||||||
pushImportRowError(row, "phone", "too_short");
|
pushImportRowError(row, "phone", "too_short");
|
||||||
} else if (normalized.phone.length > 30) {
|
} else if (normalized.phone && normalized.phone.length > 30) {
|
||||||
pushImportRowError(row, "phone", "too_long");
|
pushImportRowError(row, "phone", "too_long");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,7 +837,7 @@ function createDepartmentResolutionMap(departments) {
|
|||||||
function createEmployeeLookupMaps(employees) {
|
function createEmployeeLookupMaps(employees) {
|
||||||
return {
|
return {
|
||||||
byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])),
|
byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])),
|
||||||
byPhone: new Map(employees.map((item) => [item.phone, item])),
|
byPhone: new Map(employees.filter((item) => item.phone).map((item) => [item.phone, item])),
|
||||||
byEmail: new Map(employees.map((item) => [item.email, item])),
|
byEmail: new Map(employees.map((item) => [item.email, item])),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -912,7 +915,9 @@ async function buildUserImportPreview(prisma, rawRows, options = {}) {
|
|||||||
pushImportRowError(row, "employeeNo", "duplicate_existing");
|
pushImportRowError(row, "employeeNo", "duplicate_existing");
|
||||||
}
|
}
|
||||||
|
|
||||||
const phoneConflict = existingLookup.byPhone.get(row.normalized.phone);
|
const phoneConflict = row.normalized.phone
|
||||||
|
? existingLookup.byPhone.get(row.normalized.phone)
|
||||||
|
: null;
|
||||||
if (phoneConflict) {
|
if (phoneConflict) {
|
||||||
pushImportRowError(row, "phone", "duplicate_existing");
|
pushImportRowError(row, "phone", "duplicate_existing");
|
||||||
}
|
}
|
||||||
@@ -1012,7 +1017,7 @@ async function createSystemUser(tx, options) {
|
|||||||
? { employee_no: options.employeeNo.trim() }
|
? { employee_no: options.employeeNo.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
name: options.name,
|
name: options.name,
|
||||||
phone: options.phone,
|
phone: options.phone || null,
|
||||||
email: options.email.trim().toLowerCase(),
|
email: options.email.trim().toLowerCase(),
|
||||||
department_id: options.departmentId,
|
department_id: options.departmentId,
|
||||||
title: options.title ?? "员工",
|
title: options.title ?? "员工",
|
||||||
@@ -1573,7 +1578,7 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
const employee = await createSystemUser(tx, {
|
const employee = await createSystemUser(tx, {
|
||||||
employeeNo: row.normalized.employeeNo,
|
employeeNo: row.normalized.employeeNo,
|
||||||
name: row.normalized.name,
|
name: row.normalized.name,
|
||||||
phone: row.normalized.phone,
|
phone: row.normalized.phone || null,
|
||||||
email: row.normalized.email,
|
email: row.normalized.email,
|
||||||
departmentId: row.resolvedDepartmentId,
|
departmentId: row.resolvedDepartmentId,
|
||||||
title: row.normalized.title,
|
title: row.normalized.title,
|
||||||
@@ -1742,7 +1747,7 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
if (typeof body.name === "string" && body.name !== current.name) {
|
if (typeof body.name === "string" && body.name !== current.name) {
|
||||||
changes.name = { before: current.name, after: body.name };
|
changes.name = { before: current.name, after: body.name };
|
||||||
}
|
}
|
||||||
if (typeof body.phone === "string" && body.phone !== current.phone) {
|
if (Object.prototype.hasOwnProperty.call(body, "phone") && (body.phone ?? null) !== current.phone) {
|
||||||
changes.phone = { before: current.phone, after: body.phone };
|
changes.phone = { before: current.phone, after: body.phone };
|
||||||
}
|
}
|
||||||
if (typeof body.email === "string" && body.email !== current.email) {
|
if (typeof body.email === "string" && body.email !== current.email) {
|
||||||
@@ -1768,7 +1773,7 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
...(typeof body.name === "string" ? { name: body.name } : {}),
|
...(typeof body.name === "string" ? { name: body.name } : {}),
|
||||||
...(typeof body.phone === "string" ? { phone: body.phone } : {}),
|
...(Object.prototype.hasOwnProperty.call(body, "phone") ? { phone: body.phone ?? null } : {}),
|
||||||
...(typeof body.email === "string" ? { email: body.email } : {}),
|
...(typeof body.email === "string" ? { email: body.email } : {}),
|
||||||
...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}),
|
...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}),
|
||||||
...(typeof body.title === "string" ? { title: body.title } : {}),
|
...(typeof body.title === "string" ? { title: body.title } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require("dotenv/config");
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const ExcelJS = require("exceljs");
|
||||||
|
|
||||||
|
const { createPrismaClient } = require("../prismaClient");
|
||||||
|
const {
|
||||||
|
maskEmail,
|
||||||
|
maskExternalUserId,
|
||||||
|
maskPhone,
|
||||||
|
runWecomHrMasterSync,
|
||||||
|
} = require("../services/integrations/wecomHrMaster");
|
||||||
|
|
||||||
|
const APPLY_CONFIRM_ENV = "WECOM_DIRECTORY_EXPORT_APPLY";
|
||||||
|
const REQUIRED_HEADERS = ["姓名", "账号", "部门", "企业邮箱"];
|
||||||
|
const SAFE_EMPTY_PHONE = null;
|
||||||
|
|
||||||
|
class DirectoryExportError extends Error {
|
||||||
|
constructor(code, details = {}) {
|
||||||
|
super(code);
|
||||||
|
this.name = "DirectoryExportError";
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
process.stdout.write(`Usage:
|
||||||
|
node src/scripts/import-wecom-directory-export.js [--input=-] [--dry-run] [--format=json]
|
||||||
|
WECOM_DIRECTORY_EXPORT_APPLY=1 node src/scripts/import-wecom-directory-export.js --input=- --apply --actor-employee-no=<no>
|
||||||
|
|
||||||
|
Reads an Enterprise WeChat directory XLSX from stdin. Dry-run is the default.
|
||||||
|
Reports contain counts and categories only; personal field values are never printed.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
input: "-",
|
||||||
|
mode: "dry_run",
|
||||||
|
format: "text",
|
||||||
|
actorEmployeeNo: "",
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--help" || arg === "-h") options.help = true;
|
||||||
|
else if (arg === "--dry-run") options.mode = "dry_run";
|
||||||
|
else if (arg === "--apply") options.mode = "apply";
|
||||||
|
else if (arg === "--input") options.input = argv[++index] ?? "";
|
||||||
|
else if (arg.startsWith("--input=")) options.input = arg.slice("--input=".length);
|
||||||
|
else if (arg === "--format") options.format = argv[++index] ?? "";
|
||||||
|
else if (arg.startsWith("--format=")) options.format = arg.slice("--format=".length);
|
||||||
|
else if (arg === "--actor-employee-no") options.actorEmployeeNo = argv[++index] ?? "";
|
||||||
|
else if (arg.startsWith("--actor-employee-no=")) {
|
||||||
|
options.actorEmployeeNo = arg.slice("--actor-employee-no=".length);
|
||||||
|
} else throw new DirectoryExportError("unsupported_argument");
|
||||||
|
}
|
||||||
|
if (options.input !== "-") throw new DirectoryExportError("stdin_required");
|
||||||
|
if (!new Set(["text", "json"]).has(options.format)) throw new DirectoryExportError("invalid_format");
|
||||||
|
if (options.mode === "apply") {
|
||||||
|
if (process.env[APPLY_CONFIRM_ENV] !== "1") throw new DirectoryExportError("apply_confirmation_required");
|
||||||
|
if (!/^[A-Za-z0-9_-]{1,80}$/.test(options.actorEmployeeNo)) {
|
||||||
|
throw new DirectoryExportError("actor_required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellToString(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||||
|
if (typeof value === "object") {
|
||||||
|
if (Array.isArray(value.richText)) return value.richText.map((item) => item.text ?? "").join("").trim();
|
||||||
|
if (value.text != null) return String(value.text).trim();
|
||||||
|
if (value.result != null) return cellToString(value.result);
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHeaderRow(worksheet) {
|
||||||
|
const maximum = Math.min(50, worksheet.rowCount);
|
||||||
|
for (let rowNumber = 1; rowNumber <= maximum; rowNumber += 1) {
|
||||||
|
const labels = worksheet.getRow(rowNumber).values.slice(1).map(cellToString);
|
||||||
|
if (REQUIRED_HEADERS.every((header) => labels.includes(header))) {
|
||||||
|
return { rowNumber, labels };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new DirectoryExportError("required_headers_missing", { missingHeaderCount: REQUIRED_HEADERS.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(value) {
|
||||||
|
const raw = cellToString(value).toLowerCase();
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw) ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(value) {
|
||||||
|
const raw = cellToString(value);
|
||||||
|
if (!raw) return { value: SAFE_EMPTY_PHONE, invalid: false };
|
||||||
|
const normalized = raw.replace(/[\s-]/g, "");
|
||||||
|
if (!/^\+?\d{6,30}$/.test(normalized)) {
|
||||||
|
return { value: SAFE_EMPTY_PHONE, invalid: true };
|
||||||
|
}
|
||||||
|
return { value: normalized, invalid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProfile(data) {
|
||||||
|
const externalUserId = cellToString(data["账号"]).slice(0, 128);
|
||||||
|
const name = cellToString(data["姓名"]).slice(0, 100);
|
||||||
|
const phone = normalizePhone(data["手机"]);
|
||||||
|
const email = normalizeEmail(data["企业邮箱"]);
|
||||||
|
const departmentName = cellToString(data["部门"]).slice(0, 100);
|
||||||
|
const title = cellToString(data["职务"]).slice(0, 100) || "员工";
|
||||||
|
const activation = cellToString(data["激活状态"]);
|
||||||
|
const disabled = cellToString(data["禁用状态"]);
|
||||||
|
const active = (!activation || activation === "已激活")
|
||||||
|
&& (!disabled || new Set(["否", "未禁用", "正常", "0"]).has(disabled));
|
||||||
|
const missingRequiredFields = [];
|
||||||
|
for (const [field, value] of [
|
||||||
|
["externalUserId", externalUserId],
|
||||||
|
["name", name],
|
||||||
|
["email", email],
|
||||||
|
["department", departmentName],
|
||||||
|
]) {
|
||||||
|
if (!value) missingRequiredFields.push(field);
|
||||||
|
}
|
||||||
|
if (phone.invalid) missingRequiredFields.push("phone_invalid");
|
||||||
|
return {
|
||||||
|
externalUserId,
|
||||||
|
name,
|
||||||
|
phone: phone.value,
|
||||||
|
email,
|
||||||
|
title,
|
||||||
|
departmentExternalId: departmentName,
|
||||||
|
departmentName,
|
||||||
|
managerExternalUserId: "",
|
||||||
|
hireDate: null,
|
||||||
|
sourceStatus: active ? "active" : "inactive",
|
||||||
|
missingRequiredFields,
|
||||||
|
sourceSummary: {
|
||||||
|
externalUserIdPrefix: maskExternalUserId(externalUserId),
|
||||||
|
phoneMasked: maskPhone(phone.value),
|
||||||
|
emailMasked: maskEmail(email),
|
||||||
|
hasPhone: Boolean(phone.value),
|
||||||
|
hasEmail: Boolean(email),
|
||||||
|
hasDepartment: Boolean(departmentName),
|
||||||
|
sourceStatus: active ? "active" : "inactive",
|
||||||
|
sourceKind: "wecom_directory_export",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDuplicateValues(profiles, key) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const value = String(profile[key] ?? "").trim().toLowerCase();
|
||||||
|
if (!value) continue;
|
||||||
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.values()].filter((count) => count > 1).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseDirectoryExport(buffer) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(buffer);
|
||||||
|
const worksheet = workbook.worksheets[0];
|
||||||
|
if (!worksheet) throw new DirectoryExportError("worksheet_missing");
|
||||||
|
const header = findHeaderRow(worksheet);
|
||||||
|
const profiles = [];
|
||||||
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber <= header.rowNumber) return;
|
||||||
|
const data = {};
|
||||||
|
header.labels.forEach((label, index) => {
|
||||||
|
if (label) data[label] = cellToString(row.getCell(index + 1).value);
|
||||||
|
});
|
||||||
|
if (Object.values(data).some(Boolean)) profiles.push(buildProfile(data));
|
||||||
|
});
|
||||||
|
if (!profiles.length) throw new DirectoryExportError("member_rows_missing");
|
||||||
|
|
||||||
|
const duplicateCounts = {
|
||||||
|
account: countDuplicateValues(profiles, "externalUserId"),
|
||||||
|
phone: countDuplicateValues(profiles, "phone"),
|
||||||
|
email: countDuplicateValues(profiles, "email"),
|
||||||
|
};
|
||||||
|
if (Object.values(duplicateCounts).some((count) => count > 0)) {
|
||||||
|
throw new DirectoryExportError("duplicate_values", duplicateCounts);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profiles,
|
||||||
|
sourceSummary: {
|
||||||
|
memberCount: profiles.length,
|
||||||
|
activeCount: profiles.filter((profile) => profile.sourceStatus === "active").length,
|
||||||
|
phonePresentCount: profiles.filter((profile) => Boolean(profile.phone)).length,
|
||||||
|
phoneMissingCount: profiles.filter((profile) => !profile.phone).length,
|
||||||
|
emailPresentCount: profiles.filter((profile) => Boolean(profile.email)).length,
|
||||||
|
incompleteCount: profiles.filter((profile) => profile.missingRequiredFields.length > 0).length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveActorId(prisma, employeeNo) {
|
||||||
|
if (!employeeNo) return null;
|
||||||
|
const employee = await prisma.employee.findUnique({
|
||||||
|
where: { employee_no: employeeNo },
|
||||||
|
select: { id: true, status: true, archived_at: true },
|
||||||
|
});
|
||||||
|
if (!employee || employee.status !== "active" || employee.archived_at) {
|
||||||
|
throw new DirectoryExportError("actor_unavailable");
|
||||||
|
}
|
||||||
|
return employee.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSafeReport(options, parsed, syncRun) {
|
||||||
|
const summary = syncRun?.summary ?? {};
|
||||||
|
const blocked = Number(summary.failed ?? 0) > 0
|
||||||
|
|| Number(summary.conflicts ?? 0) > 0
|
||||||
|
|| Number(summary.skipped ?? 0) > 0
|
||||||
|
|| parsed.sourceSummary.incompleteCount > 0;
|
||||||
|
return {
|
||||||
|
issueId: "WECOM_DIRECTORY_EXPORT",
|
||||||
|
status: blocked
|
||||||
|
? "BLOCKED_DIRECTORY_EXPORT"
|
||||||
|
: options.mode === "apply"
|
||||||
|
? "PASS_DIRECTORY_EXPORT_APPLY"
|
||||||
|
: "PASS_DIRECTORY_EXPORT_DRY_RUN",
|
||||||
|
mode: options.mode,
|
||||||
|
source: parsed.sourceSummary,
|
||||||
|
sync: {
|
||||||
|
created: Number(summary.created ?? 0),
|
||||||
|
updated: Number(summary.updated ?? 0),
|
||||||
|
noop: Number(summary.noop ?? 0),
|
||||||
|
skipped: Number(summary.skipped ?? 0),
|
||||||
|
conflicts: Number(summary.conflicts ?? 0),
|
||||||
|
failed: Number(summary.failed ?? 0),
|
||||||
|
planned: Number(summary.planned ?? 0),
|
||||||
|
success: Number(summary.success ?? 0),
|
||||||
|
},
|
||||||
|
privacy: "counts/categories only; no names, accounts, phones, emails, departments, paths, or raw rows",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printReport(report, format) {
|
||||||
|
if (format === "json") {
|
||||||
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.stdout.write(`[wecom-directory-export] status=${report.status} mode=${report.mode}\n`);
|
||||||
|
process.stdout.write(`[wecom-directory-export] members=${report.source.memberCount} phonePresent=${report.source.phonePresentCount} phoneMissing=${report.source.phoneMissingCount} incomplete=${report.source.incompleteCount}\n`);
|
||||||
|
process.stdout.write(`[wecom-directory-export] planned=${report.sync.planned} conflicts=${report.sync.conflicts} skipped=${report.sync.skipped} failed=${report.sync.failed}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let resources = null;
|
||||||
|
try {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
if (options.help) {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = await parseDirectoryExport(await fs.promises.readFile(0));
|
||||||
|
resources = createPrismaClient();
|
||||||
|
const actorId = await resolveActorId(resources.prisma, options.actorEmployeeNo);
|
||||||
|
const syncRun = await runWecomHrMasterSync({
|
||||||
|
prisma: resources.prisma,
|
||||||
|
config: null,
|
||||||
|
actorId,
|
||||||
|
mode: options.mode,
|
||||||
|
limit: parsed.profiles.length,
|
||||||
|
sourceProfiles: parsed.profiles,
|
||||||
|
sourceLabel: "manual_export",
|
||||||
|
});
|
||||||
|
const report = buildSafeReport(options, parsed, syncRun);
|
||||||
|
printReport(report, options.format);
|
||||||
|
if (report.status.startsWith("BLOCKED")) process.exitCode = 2;
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof DirectoryExportError ? error.code : "directory_export_failed";
|
||||||
|
process.stderr.write(`[wecom-directory-export][ERROR] ${code}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await resources?.prisma?.$disconnect?.().catch(() => {});
|
||||||
|
await resources?.pool?.end?.().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DirectoryExportError,
|
||||||
|
buildProfile,
|
||||||
|
buildSafeReport,
|
||||||
|
countDuplicateValues,
|
||||||
|
findHeaderRow,
|
||||||
|
parseArgs,
|
||||||
|
parseDirectoryExport,
|
||||||
|
};
|
||||||
@@ -468,7 +468,7 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new
|
|||||||
};
|
};
|
||||||
|
|
||||||
profile.missingRequiredFields = [];
|
profile.missingRequiredFields = [];
|
||||||
for (const field of ["externalUserId", "name", "phone", "email"]) {
|
for (const field of ["externalUserId", "name", "email"]) {
|
||||||
if (!isMeaningfulString(profile[field])) profile.missingRequiredFields.push(field);
|
if (!isMeaningfulString(profile[field])) profile.missingRequiredFields.push(field);
|
||||||
}
|
}
|
||||||
if (!isMeaningfulString(profile.departmentExternalId) && !isMeaningfulString(profile.departmentName)) {
|
if (!isMeaningfulString(profile.departmentExternalId) && !isMeaningfulString(profile.departmentName)) {
|
||||||
@@ -721,18 +721,21 @@ async function detectUniqueConflicts(prisma, employeeId, profile) {
|
|||||||
|
|
||||||
function buildEmployeeChanges(employee, profile, department) {
|
function buildEmployeeChanges(employee, profile, department) {
|
||||||
if (!employee) {
|
if (!employee) {
|
||||||
return {
|
const changes = {
|
||||||
name: buildChange("name", null, profile.name),
|
name: buildChange("name", null, profile.name),
|
||||||
phone: buildChange("phone", null, profile.phone),
|
|
||||||
email: buildChange("email", null, profile.email),
|
email: buildChange("email", null, profile.email),
|
||||||
department: buildChange("department", null, department.name),
|
department: buildChange("department", null, department.name),
|
||||||
title: buildChange("title", null, profile.title),
|
title: buildChange("title", null, profile.title),
|
||||||
};
|
};
|
||||||
|
if (profile.phone) changes.phone = buildChange("phone", null, profile.phone);
|
||||||
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes = {};
|
const changes = {};
|
||||||
if (employee.name !== profile.name) changes.name = buildChange("name", employee.name, profile.name);
|
if (employee.name !== profile.name) changes.name = buildChange("name", employee.name, profile.name);
|
||||||
if (employee.phone !== profile.phone) changes.phone = buildChange("phone", employee.phone, profile.phone);
|
if (profile.phone && employee.phone !== profile.phone) {
|
||||||
|
changes.phone = buildChange("phone", employee.phone, profile.phone);
|
||||||
|
}
|
||||||
if (employee.email !== profile.email) changes.email = buildChange("email", employee.email, profile.email);
|
if (employee.email !== profile.email) changes.email = buildChange("email", employee.email, profile.email);
|
||||||
if (department.id && employee.department_id !== department.id) {
|
if (department.id && employee.department_id !== department.id) {
|
||||||
changes.department = buildChange("department", employee.department_id, department.name);
|
changes.department = buildChange("department", employee.department_id, department.name);
|
||||||
@@ -854,7 +857,7 @@ async function processProfile(prisma, runId, profile, context) {
|
|||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
phone: profile.phone,
|
...(profile.phone ? { phone: profile.phone } : {}),
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
department_id: department.id,
|
department_id: department.id,
|
||||||
title: profile.title,
|
title: profile.title,
|
||||||
@@ -866,7 +869,7 @@ async function processProfile(prisma, runId, profile, context) {
|
|||||||
: await prisma.employee.create({
|
: await prisma.employee.create({
|
||||||
data: {
|
data: {
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
phone: profile.phone,
|
phone: profile.phone || null,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
department_id: department.id,
|
department_id: department.id,
|
||||||
title: profile.title,
|
title: profile.title,
|
||||||
@@ -1077,6 +1080,8 @@ async function runWecomHrMasterSync({
|
|||||||
userids: rawUserids,
|
userids: rawUserids,
|
||||||
limit: rawLimit,
|
limit: rawLimit,
|
||||||
fetchImpl = fetch,
|
fetchImpl = fetch,
|
||||||
|
sourceProfiles = null,
|
||||||
|
sourceLabel = "manual_export",
|
||||||
}) {
|
}) {
|
||||||
const mode = normalizeMode(rawMode);
|
const mode = normalizeMode(rawMode);
|
||||||
const userids = normalizeUserids(rawUserids);
|
const userids = normalizeUserids(rawUserids);
|
||||||
@@ -1095,12 +1100,17 @@ async function runWecomHrMasterSync({
|
|||||||
|
|
||||||
let source = null;
|
let source = null;
|
||||||
try {
|
try {
|
||||||
source = await fetchWecomHrProfiles({
|
source = Array.isArray(sourceProfiles)
|
||||||
config,
|
? {
|
||||||
userids,
|
profiles: sourceProfiles.slice(0, limit),
|
||||||
limit,
|
tokenSource: { offline: toSafeString(sourceLabel, 40) || "manual_export" },
|
||||||
fetchImpl,
|
}
|
||||||
});
|
: await fetchWecomHrProfiles({
|
||||||
|
config,
|
||||||
|
userids,
|
||||||
|
limit,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof WecomHrMasterSyncError && error.code === "wecom_token_unavailable"
|
const status = error instanceof WecomHrMasterSyncError && error.code === "wecom_token_unavailable"
|
||||||
? "skipped"
|
? "skipped"
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ async function fetchWecomOAuthOnboardingProfile({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const missing = [];
|
const missing = [];
|
||||||
for (const field of ["name", "phone", "email", "departmentExternalId", "departmentName"]) {
|
for (const field of ["name", "email", "departmentExternalId", "departmentName"]) {
|
||||||
if (!profile[field]) missing.push(field);
|
if (!profile[field]) missing.push(field);
|
||||||
}
|
}
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
@@ -186,7 +186,12 @@ async function resolveEmployee(tx, profile) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const contactMatches = await tx.employee.findMany({
|
const contactMatches = await tx.employee.findMany({
|
||||||
where: { OR: [{ phone: profile.phone }, { email: profile.email }] },
|
where: {
|
||||||
|
OR: [
|
||||||
|
...(profile.phone ? [{ phone: profile.phone }] : []),
|
||||||
|
{ email: profile.email },
|
||||||
|
],
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
@@ -229,7 +234,7 @@ async function provisionWecomOAuthEmployee({
|
|||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
phone: profile.phone,
|
...(profile.phone ? { phone: profile.phone } : {}),
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
department_id: departmentId,
|
department_id: departmentId,
|
||||||
title: profile.title,
|
title: profile.title,
|
||||||
@@ -241,7 +246,7 @@ async function provisionWecomOAuthEmployee({
|
|||||||
: await tx.employee.create({
|
: await tx.employee.create({
|
||||||
data: {
|
data: {
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
phone: profile.phone,
|
phone: profile.phone || null,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
department_id: departmentId,
|
department_id: departmentId,
|
||||||
title: profile.title,
|
title: profile.title,
|
||||||
@@ -294,7 +299,7 @@ async function provisionWecomOAuthEmployee({
|
|||||||
extra: {
|
extra: {
|
||||||
created: !existing,
|
created: !existing,
|
||||||
source: "wecom_oauth",
|
source: "wecom_oauth",
|
||||||
fieldPresence: { phone: true, email: true },
|
fieldPresence: { phone: Boolean(profile.phone), email: true },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -219,6 +219,33 @@ describe("users import routes", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts an import without a phone header and persists a missing phone as null", async () => {
|
||||||
|
const { app, employeeCreate } = await buildApp({
|
||||||
|
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
||||||
|
});
|
||||||
|
const multipartRequest = buildMultipartRequest({
|
||||||
|
filename: "users.csv",
|
||||||
|
contentType: "text/csv",
|
||||||
|
content: [
|
||||||
|
"employeeNo,name,email,departmentName,title,roles,hireDate",
|
||||||
|
"E006,NoPhone,[email protected],行政部,员工,employee,2026-07-27",
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/users/import/apply",
|
||||||
|
...multipartRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(employeeCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ phone: null }),
|
||||||
|
select: expect.any(Object),
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects dry-run uploads with unsupported mime type", async () => {
|
it("rejects dry-run uploads with unsupported mime type", async () => {
|
||||||
const { app } = await buildApp();
|
const { app } = await buildApp();
|
||||||
const multipartRequest = buildMultipartRequest({
|
const multipartRequest = buildMultipartRequest({
|
||||||
|
|||||||
@@ -62,7 +62,27 @@ async function buildApp({
|
|||||||
roles: [{ role_code: "employee" }],
|
roles: [{ role_code: "employee" }],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const employeeUpdate = vi.fn();
|
const employeeUpdate = vi.fn(async ({ data }) => ({
|
||||||
|
...(currentEmployee ?? {
|
||||||
|
id: USER_ID,
|
||||||
|
employee_no: "HYD000321",
|
||||||
|
name: "Alice",
|
||||||
|
phone: "13800000000",
|
||||||
|
email: "[email protected]",
|
||||||
|
status: "active",
|
||||||
|
department_id: DEPARTMENT_ID,
|
||||||
|
title: "员工",
|
||||||
|
direct_manager_id: null,
|
||||||
|
hire_date: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
archived_at: null,
|
||||||
|
created_at: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
updated_at: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
roles: [{ role_code: "employee" }],
|
||||||
|
wecom_identity_binding: null,
|
||||||
|
wecom_directory_sync_runs: [],
|
||||||
|
}),
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
const employeeRoleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
const employeeRoleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||||
const employeeRoleFindMany = vi.fn().mockResolvedValue(currentRoles.map((role_code) => ({ role_code })));
|
const employeeRoleFindMany = vi.fn().mockResolvedValue(currentRoles.map((role_code) => ({ role_code })));
|
||||||
const employeeRoleDeleteMany = vi.fn().mockResolvedValue({ count: currentRoles.length });
|
const employeeRoleDeleteMany = vi.fn().mockResolvedValue({ count: currentRoles.length });
|
||||||
@@ -139,10 +159,6 @@ async function buildApp({
|
|||||||
version: 1,
|
version: 1,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
wecomDirectorySyncRun: {
|
|
||||||
create: wecomSyncRunCreate,
|
|
||||||
},
|
|
||||||
wecomIdentityBinding: {},
|
|
||||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
});
|
});
|
||||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||||
@@ -243,6 +259,45 @@ describe("usersRoutes write flows", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a user with a null phone when the field is omitted", async () => {
|
||||||
|
const { app, employeeCreate } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/users",
|
||||||
|
payload: {
|
||||||
|
name: "暂缺手机号成员",
|
||||||
|
email: "[email protected]",
|
||||||
|
roles: ["employee"],
|
||||||
|
syncWecomDirectory: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(employeeCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ phone: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an administrator to explicitly clear an existing phone to null", async () => {
|
||||||
|
const { app, employeeUpdate } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/users/${USER_ID}`,
|
||||||
|
payload: { phone: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(employeeUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ phone: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects employeeNo changes on patch", async () => {
|
it("rejects employeeNo changes on patch", async () => {
|
||||||
const { app, employeeFindUnique, employeeUpdate } = await buildApp();
|
const { app, employeeFindUnique, employeeUpdate } = await buildApp();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
const ExcelJS = require("exceljs");
|
||||||
|
|
||||||
|
const {
|
||||||
|
DirectoryExportError,
|
||||||
|
buildSafeReport,
|
||||||
|
parseArgs,
|
||||||
|
parseDirectoryExport,
|
||||||
|
} = require("../../../src/scripts/import-wecom-directory-export");
|
||||||
|
|
||||||
|
async function buildDirectoryWorkbook(rows) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet("通讯录");
|
||||||
|
worksheet.addRow(["填写须知:本行为企微导出说明"]);
|
||||||
|
worksheet.addRow([]);
|
||||||
|
worksheet.addRow([
|
||||||
|
"姓名", "账号", "别名", "职务", "部门", "性别", "手机", "座机",
|
||||||
|
"邮箱", "地址", "企业邮箱", "视频号", "激活状态", "禁用状态", "微信插件",
|
||||||
|
]);
|
||||||
|
for (const row of rows) {
|
||||||
|
worksheet.addRow([
|
||||||
|
row.name,
|
||||||
|
row.account,
|
||||||
|
"",
|
||||||
|
row.title ?? "员工",
|
||||||
|
row.department ?? "总部",
|
||||||
|
"",
|
||||||
|
row.phone ?? "",
|
||||||
|
"",
|
||||||
|
row.email ?? "",
|
||||||
|
"",
|
||||||
|
row.businessEmail,
|
||||||
|
"",
|
||||||
|
"已激活",
|
||||||
|
"",
|
||||||
|
"未关注",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WeCom directory export importer", () => {
|
||||||
|
it("requires stdin, the apply environment gate, and an actor for apply", () => {
|
||||||
|
const previous = process.env.WECOM_DIRECTORY_EXPORT_APPLY;
|
||||||
|
delete process.env.WECOM_DIRECTORY_EXPORT_APPLY;
|
||||||
|
try {
|
||||||
|
expect(() => parseArgs(["--input=directory.xlsx"])).toThrowError(
|
||||||
|
expect.objectContaining({ code: "stdin_required" }),
|
||||||
|
);
|
||||||
|
expect(() => parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toThrowError(
|
||||||
|
expect.objectContaining({ code: "apply_confirmation_required" }),
|
||||||
|
);
|
||||||
|
process.env.WECOM_DIRECTORY_EXPORT_APPLY = "1";
|
||||||
|
expect(() => parseArgs(["--apply"])).toThrowError(
|
||||||
|
expect.objectContaining({ code: "actor_required" }),
|
||||||
|
);
|
||||||
|
expect(parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toEqual(
|
||||||
|
expect.objectContaining({ input: "-", mode: "apply", actorEmployeeNo: "HYD000001" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previous == null) delete process.env.WECOM_DIRECTORY_EXPORT_APPLY;
|
||||||
|
else process.env.WECOM_DIRECTORY_EXPORT_APPLY = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the official Chinese header after instruction rows and accepts one missing phone", async () => {
|
||||||
|
const rows = Array.from({ length: 6 }, (_, index) => ({
|
||||||
|
name: `合成员${index + 1}`,
|
||||||
|
account: `member-${index + 1}`,
|
||||||
|
phone: index === 5 ? "" : `1990000000${index + 1}`,
|
||||||
|
businessEmail: `member-${index + 1}@example.test`,
|
||||||
|
}));
|
||||||
|
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook(rows));
|
||||||
|
|
||||||
|
expect(parsed.sourceSummary).toEqual({
|
||||||
|
memberCount: 6,
|
||||||
|
activeCount: 6,
|
||||||
|
phonePresentCount: 5,
|
||||||
|
phoneMissingCount: 1,
|
||||||
|
emailPresentCount: 6,
|
||||||
|
incompleteCount: 0,
|
||||||
|
});
|
||||||
|
expect(parsed.profiles[5].phone).toBeNull();
|
||||||
|
expect(parsed.profiles[5].missingRequiredFields).toEqual([]);
|
||||||
|
|
||||||
|
const report = buildSafeReport(
|
||||||
|
{ mode: "dry_run" },
|
||||||
|
parsed,
|
||||||
|
{ summary: { created: 5, updated: 1, planned: 6 } },
|
||||||
|
);
|
||||||
|
const serialized = JSON.stringify(report);
|
||||||
|
expect(report.status).toBe("PASS_DIRECTORY_EXPORT_DRY_RUN");
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(serialized).not.toContain(row.name);
|
||||||
|
expect(serialized).not.toContain(row.account);
|
||||||
|
expect(serialized).not.toContain(row.businessEmail);
|
||||||
|
if (row.phone) expect(serialized).not.toContain(row.phone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["account", { account: "member-1", phone: "19900000002", businessEmail: "[email protected]" }],
|
||||||
|
["phone", { account: "member-2", phone: "19900000001", businessEmail: "[email protected]" }],
|
||||||
|
["email", { account: "member-2", phone: "19900000002", businessEmail: "[email protected]" }],
|
||||||
|
])("rejects duplicate %s values without returning the duplicated value", async (_field, duplicateRow) => {
|
||||||
|
const buffer = await buildDirectoryWorkbook([
|
||||||
|
{
|
||||||
|
name: "合成员一",
|
||||||
|
account: "member-1",
|
||||||
|
phone: "19900000001",
|
||||||
|
businessEmail: "[email protected]",
|
||||||
|
},
|
||||||
|
{ name: "合成员二", ...duplicateRow },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let caught;
|
||||||
|
try {
|
||||||
|
await parseDirectoryExport(buffer);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(DirectoryExportError);
|
||||||
|
expect(caught.code).toBe("duplicate_values");
|
||||||
|
const serialized = JSON.stringify(caught);
|
||||||
|
expect(serialized).not.toContain("[email protected]");
|
||||||
|
expect(serialized).not.toContain("19900000001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a valid enterprise email even when a personal email is present", async () => {
|
||||||
|
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
|
||||||
|
name: "合成员一",
|
||||||
|
account: "member-1",
|
||||||
|
phone: "19900000001",
|
||||||
|
email: "[email protected]",
|
||||||
|
businessEmail: "",
|
||||||
|
}]));
|
||||||
|
|
||||||
|
expect(parsed.sourceSummary.incompleteCount).toBe(1);
|
||||||
|
expect(parsed.profiles[0].missingRequiredFields).toContain("email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a supplied invalid phone instead of treating it as an absent phone", async () => {
|
||||||
|
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
|
||||||
|
name: "合成员一",
|
||||||
|
account: "member-1",
|
||||||
|
phone: "not-a-phone",
|
||||||
|
businessEmail: "[email protected]",
|
||||||
|
}]));
|
||||||
|
|
||||||
|
expect(parsed.sourceSummary.phoneMissingCount).toBe(1);
|
||||||
|
expect(parsed.sourceSummary.incompleteCount).toBe(1);
|
||||||
|
expect(parsed.profiles[0].missingRequiredFields).toContain("phone_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a distinct success status for an apply report", async () => {
|
||||||
|
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
|
||||||
|
name: "合成员一",
|
||||||
|
account: "member-1",
|
||||||
|
phone: "",
|
||||||
|
businessEmail: "[email protected]",
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const report = buildSafeReport(
|
||||||
|
{ mode: "apply" },
|
||||||
|
parsed,
|
||||||
|
{ summary: { created: 1, success: 1 } },
|
||||||
|
);
|
||||||
|
expect(report.status).toBe("PASS_DIRECTORY_EXPORT_APPLY");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,6 +67,38 @@ describe("wecom HR master sync helpers", () => {
|
|||||||
expect(profile.missingRequiredFields).toContain("email");
|
expect(profile.missingRequiredFields).toContain("email");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a profile with no phone and does not plan clearing an existing phone", () => {
|
||||||
|
const profile = normalizeWecomHrProfile({
|
||||||
|
contact: {
|
||||||
|
userid: "missing-phone",
|
||||||
|
name: "缺手机号成员",
|
||||||
|
biz_mail: "[email protected]",
|
||||||
|
department: [1],
|
||||||
|
enable: 1,
|
||||||
|
},
|
||||||
|
staff: {},
|
||||||
|
departmentMap: new Map([["1", { externalId: "1", name: "行政部" }]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.phone).toBe("");
|
||||||
|
expect(profile.missingRequiredFields).toEqual([]);
|
||||||
|
|
||||||
|
const changes = __testables.buildEmployeeChanges(
|
||||||
|
{
|
||||||
|
id: "employee-1",
|
||||||
|
name: profile.name,
|
||||||
|
phone: "19900000009",
|
||||||
|
email: profile.email,
|
||||||
|
department_id: "department-1",
|
||||||
|
title: "员工",
|
||||||
|
wecom_identity_binding: { external_user_id: profile.externalUserId },
|
||||||
|
},
|
||||||
|
profile,
|
||||||
|
{ id: "department-1", name: "行政部" },
|
||||||
|
);
|
||||||
|
expect(changes).not.toHaveProperty("phone");
|
||||||
|
});
|
||||||
|
|
||||||
it("marks inactive WeCom members as confirmation-required instead of active", () => {
|
it("marks inactive WeCom members as confirmation-required instead of active", () => {
|
||||||
const profile = normalizeWecomHrProfile({
|
const profile = normalizeWecomHrProfile({
|
||||||
contact: {
|
contact: {
|
||||||
|
|||||||
@@ -101,6 +101,20 @@ describe("wecom OAuth onboarding service", () => {
|
|||||||
});
|
});
|
||||||
expect(profile.email).toBe("[email protected]");
|
expect(profile.email).toBe("[email protected]");
|
||||||
|
|
||||||
|
const profileWithoutPhone = await fetchWecomOAuthOnboardingProfile({
|
||||||
|
accessToken: "agent-token",
|
||||||
|
externalUserId: "member-fields",
|
||||||
|
userTicket: "ticket",
|
||||||
|
fetchImpl: buildFetch({
|
||||||
|
errcode: 0,
|
||||||
|
userid: "member-fields",
|
||||||
|
mobile: "",
|
||||||
|
biz_mail: "[email protected]",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(profileWithoutPhone.phone).toBe("");
|
||||||
|
expect(profileWithoutPhone.email).toBe("[email protected]");
|
||||||
|
|
||||||
await expect(fetchWecomOAuthOnboardingProfile({
|
await expect(fetchWecomOAuthOnboardingProfile({
|
||||||
accessToken: "agent-token",
|
accessToken: "agent-token",
|
||||||
externalUserId: "member-fields",
|
externalUserId: "member-fields",
|
||||||
@@ -238,6 +252,52 @@ describe("wecom OAuth onboarding service", () => {
|
|||||||
expect(persisted).not.toContain("[email protected]");
|
expect(persisted).not.toContain("[email protected]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a member with a null phone when the authorized profile has no phone", async () => {
|
||||||
|
const tx = {
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue({ id: "employee-1", token_version: 0 }),
|
||||||
|
},
|
||||||
|
integrationBinding: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ local_id: "department-1" }),
|
||||||
|
},
|
||||||
|
department: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "department-1", archived_at: null }),
|
||||||
|
},
|
||||||
|
employeeRole: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) };
|
||||||
|
|
||||||
|
await provisionWecomOAuthEmployee({
|
||||||
|
prisma,
|
||||||
|
request: buildRequest(),
|
||||||
|
profile: {
|
||||||
|
externalUserId: "member-no-phone",
|
||||||
|
name: "缺手机号成员",
|
||||||
|
phone: "",
|
||||||
|
email: "[email protected]",
|
||||||
|
departmentExternalId: "1",
|
||||||
|
departmentName: "总部",
|
||||||
|
title: "员工",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tx.employee.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ phone: null }),
|
||||||
|
select: expect.any(Object),
|
||||||
|
});
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
extra: expect.objectContaining({ fieldPresence: { phone: false, email: true } }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects contact data that resolves to multiple OA employees", async () => {
|
it("rejects contact data that resolves to multiple OA employees", async () => {
|
||||||
const tx = {
|
const tx = {
|
||||||
wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) },
|
wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户