# 钉钉同步后端修复测试报告

- 模块: `organization/dingtalk`
- 日期: `2026-03-10`
- 类型: `backend`

## 变更范围

- 将宜搭表单查询切换为“高级查询条件二代”接口：
  - `POST /v1.0/yida/forms/instances/advances/queryAll`
- 修正宜搭表单写回接口的 HTTP Method：
  - `POST /v1.0/yida/forms/instances/insertOrUpdate`
- 修复四级部门名称映射错误，避免旧员工信息同步写入三级部门名
- 修复出差多段行程跳过历史数据后 `approveId` 编号错位问题
- 修复假期延期写回的时间字符串格式，避免错误写入 UTC ISO 字符串
- 修复假期提醒在剩余延期天数小于等于 `0` 时仍回写 `已通知` 的偏差
- 修复外勤错误审批清理逻辑，改为直接扫描钉钉考勤侧 `procInst_id/approve_id`
- 清理测试环境已写入的错误外勤审批 `undefined-1`
- 新增钉钉审批撤销修复服务与页面面板，支持按同步类型、员工、审批号模式预览/撤销
- 修复审批撤销修复中“精确审批号跳过真实校验”的问题，统一改为先扫描真实考勤审批再执行撤销
- 修复员工信息旧表同步无差别重写问题，恢复为仅在字段或部门集合变化时才写入
- 新增员工信息新表工时字段映射：`全月标准工时`、`工时上限`
- 新增钉钉假期余额总览接口与页面，展示各员工各假期类型余额
- 新增年假释放计划接口与页面，展示年假释放表中的未来释放计划
- 新增钉钉假期余额快照表，余额总览页改为读取本地快照
- 新增手动刷新钉钉假期余额快照接口与页面入口
- 将手动触发考勤类同步与审批撤销修复的最大查询窗口改为后端强制最近 `60` 天
- 将定时触发的考勤类同步改为与 Python 一致的固定半小时整点窗口，而非不限时间范围
- 年假释放计划接口补充无计划员工返回，并给出未生成原因，前端矩阵页直接展示

## 测试范围与类型选择说明

- 单元测试
  - 验证宜搭查询接口路径是否已切换
  - 验证宜搭写回接口是否使用 `POST`
  - 验证四级部门映射结果
  - 验证出差同步在跳过历史行程后 `approveId` 仍连续正确
  - 验证错误外勤审批会从钉钉考勤记录中被识别并去重撤销
  - 验证审批撤销修复支持 `undefined-*` 通配预览
  - 验证审批撤销修复仅在精确审批号真实命中后才执行撤销
  - 验证精确审批号未命中时不会伪造待撤销记录
  - 验证手动触发考勤类任务未传时间范围时会自动限制为最近 60 天
  - 验证手动触发考勤类任务传入超过 60 天的开始日期时会自动收敛
  - 验证审批撤销修复未传时间范围时会自动限制为最近 60 天再扫描
  - 验证定时外勤同步在 `:15` 时使用上一整段半小时窗口
  - 验证定时出差同步在 `:45` 时使用当前小时前半段窗口
  - 验证员工信息旧表在数据未变化时不会重复写入
  - 验证员工信息旧表仅在字段或部门变化时写入对应记录
  - 验证员工信息新表会同步 `全月标准工时` 与 `工时上限`
  - 验证钉钉假期余额快照刷新会写入本地快照表
  - 验证钉钉假期余额总览会从本地快照正确汇总总额、已用和剩余天数
  - 验证年假释放计划会按员工与日期展开，并聚合同日释放天数
  - 验证无年假释放计划的员工也会返回，并带上未生成原因
  - 验证假期延期写回时间字符串格式
  - 验证假期提醒不会回写非正数延期天数
- 构建验证
  - 验证后端 TypeScript 编译通过
- 前端构建验证
  - 验证钉钉同步页面新增撤销面板未引入新的编译错误
- 联调验证
  - 真实扫描测试环境外勤审批，确认错误 `undefined-*` 记录数量
  - 真实调用钉钉撤销接口，清理错误外勤审批
  - 清理后再次扫描确认无残留

本次未执行 E2E。
原因：改动集中在钉钉下游服务 SDK 与同步服务内部实现，不涉及平台前端交互；且缺少可安全使用的真实钉钉凭证进行线上联调。

## 执行命令

```bash
cd /home/ffws01/Code/ffworkspace/backend
npx jest test/modules/organization/unit/dingtalk-sdk.service.spec.ts --runInBand --config '{"rootDir":".","testEnvironment":"node","moduleNameMapper":{"^@core/(.*)$":"<rootDir>/src/core/$1","^@common/(.*)$":"<rootDir>/src/common/$1","^@modules/(.*)$":"<rootDir>/src/modules/$1"},"transform":{"^.+\\.(t|j)sx?$":["ts-jest",{"tsconfig":"tsconfig.json"}]}}'
npx jest test/modules/organization/unit/dingtalk-sdk.service.spec.ts test/modules/organization/unit/dingtalk-sync.service.spec.ts test/modules/organization/unit/dingtalk-controller.spec.ts test/modules/organization/unit/dingtalk-repair.service.spec.ts --runInBand --config '{"rootDir":".","testEnvironment":"node","moduleNameMapper":{"^@core/(.*)$":"<rootDir>/src/core/$1","^@common/(.*)$":"<rootDir>/src/common/$1","^@modules/(.*)$":"<rootDir>/src/modules/$1"},"transform":{"^.+\\.(t|j)sx?$":["ts-jest",{"tsconfig":"tsconfig.json"}]}}'
npx jest test/modules/organization/unit/annual-leave-insight.service.spec.ts test/modules/organization/unit/employee-info-sync.service.spec.ts test/modules/organization/unit/dingtalk-sdk.service.spec.ts test/modules/organization/unit/dingtalk-sync.service.spec.ts test/modules/organization/unit/dingtalk-controller.spec.ts test/modules/organization/unit/dingtalk-repair.service.spec.ts --runInBand --config '{"rootDir":".","testEnvironment":"node","moduleNameMapper":{"^@core/(.*)$":"<rootDir>/src/core/$1","^@common/(.*)$":"<rootDir>/src/common/$1","^@modules/(.*)$":"<rootDir>/src/modules/$1"},"transform":{"^.+\\.(t|j)sx?$":["ts-jest",{"tsconfig":"tsconfig.json"}]}}'
npx jest test/modules/organization/unit/dingtalk-scheduler.service.spec.ts test/modules/organization/unit/dingtalk-repair.service.spec.ts test/modules/organization/unit/annual-leave-insight.service.spec.ts test/modules/organization/unit/employee-info-sync.service.spec.ts test/modules/organization/unit/dingtalk-sdk.service.spec.ts test/modules/organization/unit/dingtalk-sync.service.spec.ts test/modules/organization/unit/dingtalk-controller.spec.ts --runInBand --config '{"rootDir":".","testEnvironment":"node","moduleNameMapper":{"^@core/(.*)$":"<rootDir>/src/core/$1","^@common/(.*)$":"<rootDir>/src/common/$1","^@modules/(.*)$":"<rootDir>/src/modules/$1"},"transform":{"^.+\\.(t|j)sx?$":["ts-jest",{"tsconfig":"tsconfig.json"}]}}'
npm run build
npx dotenv -e .env -- ts-node -r tsconfig-paths/register <inline cleanup script>
cd /home/ffws01/Code/ffworkspace/frontend && npm run build
cd /home/ffws01/Code/ffworkspace/frontend && npx eslint src/app/'(modules)'/organization/dingtalk/page.tsx src/app/'(modules)'/organization/dingtalk/annual-leave/quotas/page.tsx src/app/'(modules)'/organization/dingtalk/annual-leave/release-plan/page.tsx src/services/api/dingtalk.ts src/locales/organization/zh.ts src/locales/organization/en.ts
cd /home/ffws01/Code/ffworkspace/backend && npm run prisma:generate
cd /home/ffws01/Code/ffworkspace/backend && npm run prisma:migrate:deploy
cd /home/ffws01/Code/ffworkspace/backend && npx jest test/modules/organization/unit/annual-leave-insight.service.spec.ts --runInBand --config '{"moduleFileExtensions":["js","json","ts"],"rootDir":".","testRegex":".*\\.spec\\.ts$","transform":{"^.+\\.(t|j)s$":"ts-jest"},"moduleNameMapper":{"^@/(.*)$":"<rootDir>/src/$1","^@core/(.*)$":"<rootDir>/src/core/$1"}}'
```

说明：
- 最后一条为本次实际执行逻辑，对应命令在本次会话中以内联 `ts-node` 脚本运行，作用等同于调用 `DingtalkController.cleanupInvalidFieldApprovals()` 进行 dry-run、实际撤销、复查。

## 结果

- 单元测试：通过（本次增量验证 `14/14`，历史累计验证保持通过）
- 单元测试：通过（新增快照相关验证 `3/3`）
- 后端构建：通过
- Prisma 迁移：通过（已应用 `20260311043000_add_dingtalk_leave_quota_snapshots`）
- 前端定向 ESLint：通过（本次新增钉钉页面与 API 文件）
- 后端构建：通过（本次补充无计划员工与原因展示后再次验证）
- 前端定向 ESLint：通过（年假释放计划矩阵页与类型定义、本地化文本）
- 前端构建：失败（阻断与本次改动无关，`meetingattendance` 模块在当前构建环境仍报 `date-fns-tz`、`docx`、`file-saver`、`jszip` 无法解析）
- 测试环境错误外勤审批扫描：命中 `5` 条 `undefined-1`
- 测试环境错误外勤审批撤销：成功 `5` 条，失败 `0` 条
- 撤销后复查：残留 `0` 条

## 最小复现步骤

1. 打开钉钉同步管理页或调用后端手动触发接口。
2. 触发任意依赖宜搭查询的任务，例如：
   - `DINGTALK_BUSINESS_TRIP`
   - `DINGTALK_FIELD_APPLICATION`
   - `DINGTALK_OVERTIME`
3. 检查后端日志，确认宜搭查询日志输出为：
   - `POST /forms/instances/advances/queryAll`
4. 触发任意会写回宜搭的任务，例如：
   - `DINGTALK_EMPLOYEE_INFO`
   - `DINGTALK_ANNUAL_LEAVE`
   - `DINGTALK_LEAVE_EXTENSION`
5. 检查后端日志，确认写回日志输出为：
   - `POST /forms/instances/insertOrUpdate`
6. 调用调试接口或直接执行后端上下文脚本：
   - `POST /api/v1/organization/dingtalk/debug/cleanup-invalid-field-approvals`
7. 先使用 `dryRun: true` 查看待清理记录，再使用 `dryRun: false` 执行撤销。
8. 再次执行 `dryRun: true`，确认返回 `total: 0`。

## 偏离与阻断

- 未做新的真实钉钉写入型联调验证；本次真实联调集中在错误审批扫描与撤销。
- 前端全量构建被仓库既有依赖缺失阻断，阻断文件位于 `meetingattendance` 模块，和本次钉钉页面改动无直接关系。

- 定向单测：通过（annual-leave-insight.service.spec.ts 4/4，新增余额详情释放/使用记录验证）
- 后端构建：通过
- 前端定向 ESLint：通过（假期余额详情页、API 类型、组织语言包）

- Prisma 迁移：通过（新增 dingtalk_annual_leave_release_plans，本地化年假释放计划表）
- 年假相关单测：通过（annual-leave-insight + annual-leave-sync，共 5/5）
- 年假释放逻辑：已切换为读取本地数据库中间表，不再写回宜搭年假释放中间表
- 年假中间表手动重算：通过（新增 release-plan/refresh 接口与页面按钮，仅重算本地计划，不发放钉钉额度）
- 年假相关单测：通过（annual-leave-insight + annual-leave-sync，共 6/6）
- 后端构建：通过
- 前端定向 ESLint：通过（release-plan 页面、API、语言包）
