# Upsert 的 existing 分支漏写副作用：老数据永远享受不到新逻辑

**日期**: 2026-04-30
**触发**: 正式版钉钉员工的"在职历史"列表为空，新员工正常但老员工没有第 1 段。
**适用**: 任何 `if (existing) update else create` 的同步/upsert 逻辑

## 现象

`employee-management.service.ts` 同步钉钉员工：

```ts
if (existing) {
  await employeeRepo.update(...);
  // ❌ 这里没有 ensureInitialPeriod
  if (wasTerminated) await addRejoinPeriod(...);  // 只覆盖再入职
} else {
  await employeeRepo.create(...);
  if (parsed.joinDate) await ensureInitialPeriod(...);  // ✅ 只新员工有
}
```

`ensureInitialPeriod` 是后来加的副作用。加之前创建的所有员工（正式版上几乎所有人）永远走 `existing` 分支，所以**这段新逻辑从未对老数据生效**。每次同步都是更新基础字段就完事，第 1 段在职历史永远缺失。

## 根因

upsert 的两个分支不对称：副作用只挂在 `else`（新建）侧。开发者写新 feature 时往往只想"新员工要补这个"，忘了"老员工每次同步也应该自检"。

## 修复模式

幂等的副作用应该在两个分支都调用：

```ts
if (existing) {
  await employeeRepo.update(...);
  if (wasTerminated && parsed.joinDate) {
    await addRejoinPeriod(...);
  } else if (parsed.joinDate) {
    await ensureInitialPeriod(...);  // ← 新增：老员工兜底
  }
} else {
  await employeeRepo.create(...);
  if (parsed.joinDate) await ensureInitialPeriod(...);
}
```

`ensureInitialPeriod` 内部已有 `count > 0 return` 守卫，对已有数据是 no-op，所以无脑调用安全。

## 经验提炼

**规则**：在 upsert / sync-loop 里加新副作用时，问自己一句——

> 「这个副作用对**已经存在的老记录**也应该生效吗？」

如果答案是 yes，就必须挂在 `if (existing)` 分支（或两侧都挂）。只挂 `else` 等于声明"这条规则只对未来新数据生效"，绝大多数场景下这不是想要的语义。

**配套要求**：副作用本身要写成幂等的（用 `count`/`findFirst` 守卫），这样在 `existing` 分支无脑调用零代价，下次 sync 自动给所有老数据回填。

不要走"单独写一个 backfill 脚本，跑一次就完事"的路——脚本忘记跑、跑错环境、新增的字段又要再写一个脚本，最后变成一堆一次性 ops 任务。把回填逻辑放进同步主流程一劳永逸。

## 反模式信号

代码里出现这些就要警觉：

- `initXxx()` / `backfillXxx()` 这种"一次性后台任务"方法。本案的 `initEmploymentPeriods()` 就是——它的存在说明同步主流程有缺口。
- 同步函数的 `if (existing)` 分支只更新字段、`else` 分支干一堆事。
- "新员工自动生成 X" 文案出现在产品需求里，但代码里没对应的 `existing` 分支兜底。
