# APAC Effective Sales Owner：前期结论与 Lambda 改造方案

状态：同步 Task 已实现，尚未部署或接入现有路由

创建日期：2026-08-13

适用范围：`apac_lambda_tasks/`

## 1. 目标

APAC PLG 主动通知需要识别一个 Tenant 是否已有销售人员跟进，避免把已有
Sales Owner 的客户重复推送到普通通知流程。

本文档总结 2026-07-29 完成的 Nexus CRM 数据扫描，并给出一种不修改
DevOps Tenant 数据契约的 Lambda 改造方案。本文中的 Effective Sales Owner
优先级仍是讨论项；在业务规则确认前，不应改变现有生产路由。

## 2. 已完成的工作

### 2.1 数据访问与对象确认

已经确认可以通过 BigQuery 查询以下 Nexus CRM 原始对象：

- `gcp-digital-bi.nexuscrm_final.nexuscrm_org_c`
- `gcp-digital-bi.nexuscrm_final.nexuscrm_account`
- `gcp-digital-bi.nexuscrm_final.nexuscrm_lead`
- `gcp-digital-bi.nexuscrm_final.nexuscrm_opportunity`
- `gcp-digital-bi.nexuscrm_final.nexuscrm_user`

`nexuscrm_user` 是必要对象。Account、Lead 和 Opportunity 中通常只有
`OwnerId`；必须通过 `OwnerId -> nexuscrm_user.Id` 才能取得 Owner 的姓名、
邮箱和 Active 状态。

### 2.2 全量扫描

以当时 `regional_support.apac_active_tenants` 中的 Active Tenant 为范围，共扫描
3,265 个 Tenant，并完成 Tenant 与 Org、Account、Lead、Opportunity、User 的
批量关联。扫描使用一条集合式 BigQuery 查询，而不是逐 Tenant 调用 API。

扫描结果已生成加密 CSV，用于离线核对。仓库和日志中不保存未加密的个人邮箱。

### 2.3 已确认的字段语义

Sales Owner 的权威映射应为：

```text
Account.OwnerId     -> nexuscrm_user.Id -> User.Name / Email / IsActive
Lead.OwnerId        -> nexuscrm_user.Id -> User.Name / Email / IsActive
Opportunity.OwnerId -> nexuscrm_user.Id -> User.Name / Email / IsActive
```

以下字段不应直接作为主 Sales Owner：

- `Account.TAM__c`：TAM，不是 Sales Owner；
- `Opportunity.Pre_Sales_Owner__c`：售前，不是 Opportunity 主 Owner；
- Account/Lead 的 `Partner_Sales_Email__c`：覆盖率低，且语义不同；
- `Opportunity.Opportunity_Owner_Email__c`：数据稀疏，并可能与当前 User 表邮箱不一致。

因此，Owner 邮箱应以 `nexuscrm_user.Email` 为准。业务对象上的冗余邮箱最多用于
数据质量审计，不应用作正式判定来源。

### 2.4 Tenant 到 CRM 对象的关联

前期扫描采用以下关系：

1. 使用 `nexuscrm_org_c.ID__c` 匹配 Tenant ID；
2. 从最新且未删除的 Org 记录取得 `Account__c` 和 `Lead__c`；
3. Account ID 按以下顺序补齐：
   - `Org.Account__c`；
   - `Lead.ConvertedAccountId`；
   - `Lead.AssociateAccount__c`；
   - `Lead.BN_Account__c`；
4. Opportunity 通过 Account ID 关联，并兼容 `Lead.ConvertedOpportunityId`；
5. 所有 Owner 均通过 User 表解析。

当同一个 CRM 对象存在多份同步记录时，应优先使用最新的未删除记录。前期查询
按 `LastModifiedDate`、`_record_loaded_at` 降序去重。

### 2.5 多个未关闭 Opportunity 的选择规则

一个 Tenant 可能存在多个未关闭 Opportunity。前期扫描为了稳定地输出一个
Opportunity Owner，采用以下确定性排序：

1. 只考虑 `IsClosed = false` 且未删除的 Opportunity；
2. Owner 当前为 Active 的记录优先；
3. `HasOpenActivity = true` 的记录优先；
4. `LastModifiedDate` 较新的记录优先；
5. `CreatedDate` 较新的记录优先；
6. 最后按 Opportunity ID 排序，保证重复执行得到相同结果。

该规则解决的是“多个未关闭 Opportunity 中选哪一个”的问题，不等同于最终的
Account、Opportunity、Lead Owner 优先级。

## 3. 当前 Lambda 的实际行为

当前数据流为：

```text
DevOps tenants
  -> Dispatcher
  -> Monitor SQS
  -> Worker
  -> apac_active_tenants / apac_sales_owned_tenants
```

Dispatcher 从 DevOps `tenants` 表读取单一字段 `sales_owner_email`。该字段由
DevOps 的 BigQuery enricher 从聚合表的 `account_owner_email` 写入，因此当前
Lambda 实际只看到了 Account Owner 的一个邮箱值，无法看到：

- Account Owner 的 Active 状态；
- Lead Owner；
- Opportunity Owner；
- Opportunity 是否关闭、是否有 Open Activity；
- 多个 Opportunity 的选择依据；
- Owner 的数据来源和判定规则版本。

修改前 Worker 的路由顺序为：

```text
强制 Sales-owned Tenant
  -> sales_owned

否则命中 HubSpot Target
  -> active，并强制 P0

否则 DevOps sales_owner_email 命中 APAC_SALES_EMAILS
  -> sales_owned

否则
  -> active
```

因此，现有实现是“单一 Account Owner 邮箱 + 静态 APAC Sales 邮箱集合”的判断，
还不是 Effective Sales Owner 计算。

## 4. 约束与确定的架构方向

本方案不把 CRM 候选 Owner 同步到 DevOps Tenant 数据契约，也不要求扩展
`dws_cloud_tenants_basic_info`。

建议新增一个独立 Lambda Task：

```text
EventBridge
  -> Dispatcher 选择当天 Tenant
      -> 同步调用 Effective Sales Owner Sync Lambda
          -> 读取 Nexus CRM BigQuery 原始表
          -> 写入本批 Owner 快照
          -> 更新两个 Tenant 结果表中已经存在的行
      -> 成功后发送 Monitor SQS（携带 sync_id）
          -> Worker 读取本批快照并写入/移动 Tenant 行
```

不建议 Worker 为每条 SQS 消息直接查询 BigQuery，原因包括：

- 会把跨云查询延迟加入每个 Tenant 的处理路径；
- 3,000 多个 Tenant 会产生大量小查询，成本和失败面更大；
- BigQuery 暂时不可用时会直接影响 Worker 和 SQS 重试；
- 无法形成同一批次的一致快照；
- 更难审计规则版本、数据时间和新旧判定差异。

独立 Task 采用一次集合式查询和一次批量落库，使 Worker 只做低延迟的本地读取。

## 5. 建议的独立 Task 设计

### 5.1 调度和执行方式

建议新函数名：

`APAC-Effective-Sales-Owner-Sync-Task`

Dispatcher 严格按以下顺序运行：

```text
Dispatcher 选出同一批 tenant_ids
  -> 同步调用 Effective Sales Owner Sync Lambda
  -> 成功后继续 fan-out
  -> Monitor SQS
  -> Worker
  -> Scoring
  -> Summary 按现有日报时间运行
```

Dispatcher 为本批次提供唯一 `sync_id`，Owner Sync 成功后返回同一个值。
Dispatcher 把该 `sync_id` 放进每一条
Monitor SQS 消息；Worker 必须读取该批次的 Owner 判定，不能随意读取“最新一条”。
Owner Sync 失败时 Dispatcher 不发送任何 Monitor SQS 消息，由现有调度重试和
Lambda 告警暴露失败。

这样可以保证同一天进入 Worker 的 Tenant 使用同一批 CRM 数据，而且每个被处理
的 Tenant 都确实执行过 Sales Owner 判定。

新 Task 也应支持手工调用以下模式：

- 全量同步；
- 指定 Tenant ID 的样例验证；
- dry-run，只返回聚合计数而不落库。

每天的正式流程必须等待 Owner Sync 成功；不采用两个互相独立的定时任务。

### 5.2 BigQuery 查询职责

一条集合式 Standard SQL 完成：

1. 读取最新未删除的 Org；
2. 关联 Lead 并补齐 Account ID；
3. 关联 Account；
4. 关联 User，取得各 Owner 的 Active 状态；
5. 对未关闭 Opportunity 做确定性排序；
6. 输出 Account、Lead、选中 Opportunity 三组候选；
7. 为每个目标 Tenant 输出明确的判定状态，包括找到 Owner、没有 CRM Org、没有
   Active Owner 或映射冲突；
8. 按已确认的业务优先级计算 Effective Sales Owner；
9. 输出规则版本和 BigQuery 数据时间。

建议将业务优先级集中在查询或一个纯函数中，并通过 `rule_version` 固定版本，避免
散落在多个 Lambda 文件里。

### 5.3 快照表

建议新建：

`apac_effective_sales_owner_snapshot`

初期建议保存以下非敏感或最小必要字段：

| 字段 | 说明 |
| --- | --- |
| `tenant_id` | Tenant 主键 |
| `account_id` / `lead_id` | CRM 关联证据 |
| `account_owner_id` | Account Owner CRM User ID |
| `account_owner_active` | Account Owner 是否 Active |
| `lead_owner_id` | Lead Owner CRM User ID |
| `lead_owner_active` | Lead Owner 是否 Active |
| `selected_opportunity_id` | 按确定性规则选择的 Opportunity |
| `opportunity_stage` | 选中 Opportunity 的 Stage |
| `opportunity_has_open_activity` | 是否有 Open Activity |
| `opportunity_owner_id` | Opportunity Owner CRM User ID |
| `opportunity_owner_active` | Opportunity Owner 是否 Active |
| `effective_owner_id` | 最终选中的 CRM User ID |
| `effective_owner_email` | 最终 Owner 的标准化邮箱；不保存未选中的候选邮箱 |
| `effective_owner_source` | `account`、`opportunity`、`lead` 或 `none` |
| `effective_owner_active` | 最终 Owner 是否 Active |
| `decision_status` | 找到 Owner、无 CRM Org、无 Active Owner或映射冲突等明确结果 |
| `rule_version` | 判定规则版本 |
| `source_synced_at` | BigQuery 源数据时间 |
| `snapshot_synced_at` | 本次同步完成时间 |
| `sync_id` | 一次完整同步的标识 |

姓名和邮箱属于个人信息。当前业务需要在 APAC Tenant 结果中直接查看最终 Owner，
因此快照只保存被选中的标准化 `effective_owner_email`，不保存 Account、Opportunity、
Lead 三组未选中候选的邮箱。Owner Sync 在同一事务中直接更新两个 Tenant 结果表中
已经存在的行；Worker 使用消息指定的快照为尚未存在的 Tenant 首次写入
`effective_sales_owner_email` 和 `effective_sales_owner_source`，并在 Tenant 跨表移动时
携带这两个字段。Summary 直接展示这两个最终判定字段，不再使用旧
`sales_owner_email` 代表当前判定结果。APAC Sales 名单只由 Worker 用于路由，不属于
Owner Sync 的职责。日志、指标和 Lambda 返回值不输出邮箱；数据库访问权限和快照
保留周期应继续按个人信息处理。

为避免半批数据，建议使用 staging 表加事务切换，或按 `sync_id` 写入完整新快照，
成功后更新单行 sync state 指针。Worker 读取 SQS 消息指定的 `sync_id`。

快照表示某次 Owner Sync 的完整计算结果集合，不是新的业务数据源。即使某个
Tenant 没有 CRM Org 或没有有效 Owner，也必须保存一条明确的判定结果，不能用
“查不到快照行”代替“没有 Owner”。

### 5.4 认证

不应把 GCP Service Account JSON 私钥放入 Lambda 环境变量、部署包或仓库。

当前实现采用 AWS Secrets Manager 作为过渡方案：Lambda 环境变量只保存 Secret
ARN，并通过独立执行角色的 `secretsmanager:GetSecretValue` 在运行时读取 JSON。
Secret 明文不会写入日志、数据库或返回值。该方案可以在现有本地 key 获得 BigQuery
权限后先完成联调，但长期仍建议迁移到以下无长期私钥方案。

推荐使用 AWS IAM Role 与 GCP Workload Identity Federation：

1. 为新 Lambda 使用独立 AWS IAM Role；
2. 在 GCP 建立仅信任该 Role 的 Workload Identity Provider；
3. 映射到专用 GCP Service Account；
4. 该 Service Account 只获得：
   - 五张 Nexus CRM 原始表的只读权限；
   - `gcp-resilience-dev` 中执行 BigQuery Job 的最小权限；
5. Lambda 通过短期联合身份取得访问令牌，不保存长期 GCP Key。

具体 IAM 资源和信任条件应在实施前由 Cloud/Security Owner 复核。

### 5.5 失败与陈旧快照处理

建议同步 Task 遵循：

- BigQuery 查询或落库失败时，不覆盖最近一次成功快照；
- 记录最近尝试时间、状态、错误摘要和最近成功时间；
- 不在日志中输出姓名、邮箱或完整查询结果；
- Owner Sync 失败时，不启动当天的 Dispatcher；
- Worker 找不到消息指定 `sync_id` 的 Tenant 判定时，使该 SQS 消息失败并重试；
- 正式流程不自动回退到旧 `sales_owner_email`，避免部分 Tenant 绕过新判定。

## 6. Effective Sales Owner 规则：待讨论方案

当前第一版规则为 Active Account Owner 优先，其次为选中的未关闭 Opportunity 的
Active Owner；Lead 保留为关联证据，但不参与最终判定。

`ApprovalStatus__c = 'Approved'` 目前不作为整个 Effective Sales Owner 判定的硬门槛。
Approved 表示 Account 是否属于正式有效客户，而 Effective Sales Owner 回答的是
是否已经有人跟进，两者分别记录。Account Owner 只要仍 Active 且有邮箱即可参与，
不受 Approved 状态限制。

对比两种口径影响的 Tenant 数量后，再决定 Approved 是否只限制 Account Owner。
无论 Account 是否 Approved，Opportunity 和 Lead 候选都不能因此直接丢弃。

### 方案 A：Opportunity 优先

```text
在职的选中 Opportunity Owner
  -> 在职 Account Owner
  -> 在职 Lead Owner
  -> none
```

优点：更能反映当前正在推进的商机。

风险：Opportunity Owner 可能是临时协作人，且 Stage/Activity 的业务语义需要确认。

### 方案 B：Account 优先

```text
有效 Account 的在职 Account Owner
  -> 在职的选中 Opportunity Owner
  -> 在职 Lead Owner
  -> none
```

优点：与当前 `sales_owner_email` 行为最接近，切换风险较小。

风险：Account Owner 未及时维护时，可能忽略更活跃的 Opportunity Owner。

### 方案 C：只输出候选，不自动合并

同步 Task 保存三类候选和证据，但暂不计算唯一 Effective Owner。Worker 继续沿用
现有路由；数据报告展示冲突分布，由业务侧确认规则后再启用最终字段。

优点：最安全，便于先验证覆盖率和冲突。

风险：不能立即解决最终路由问题。

当前决定先采用方案 C 作为验证阶段，再根据真实冲突统计选择 A 或 B。

## 6.1 验证结论（2026-08-13）

已使用 3,265 个 APAC Active Tenant 执行聚合验证。验证 SQL 与不含姓名、邮箱、
Owner ID、Tenant ID 的统计报告见：

- [`sql/effective_sales_owner_validation.sql`](sql/effective_sales_owner_validation.sql)
- [`EFFECTIVE-SALES-OWNER-VALIDATION-REPORT.md`](EFFECTIVE-SALES-OWNER-VALIDATION-REPORT.md)

当前建议作为第一版讨论基线：

1. Active Account Owner 作为主候选；
2. Account Owner 缺失或 inactive 时，使用 Active、未关闭 Opportunity Owner；
3. Lead Owner 默认只记录为证据，不直接参与 Effective Sales Owner；
4. `ApprovalStatus__c` 作为 Account 状态记录，不阻断 Opportunity 等其他候选；
5. 排除 `Closed Lost` 等逻辑上已关闭、但 `IsClosed` 数据异常的 Opportunity；
6. 其他未关闭 Stage 暂不限制，inactive Owner 继续跳到下一候选。

上述优先级不是实现定案。当前 Active Account 与 Active Opportunity 没有 Owner
冲突，因此只能说明两种优先级在本次样本结果相同，不能证明未来永远等价。

## 7. Worker 接入方案

Worker 接入后使用消息指定 `sync_id` 的快照，不回退到 DevOps
`sales_owner_email`。已经确认：

- HubSpot Target 继续优先于 Sales Owner；
- 无论 Effective Owner 是否属于 APAC Sales，都要保存 Effective Owner 及其来源；
- 只有 Effective Owner 命中 APAC Sales 名单时，Tenant 才进入 Sales-owned；
- Owner 为 inactive 时跳过并继续检查下一候选，但保留 inactive 候选作为审计证据。

尚需确认的既有例外：

1. 三个强制 Sales-owned Tenant 是否继续保留。

若沿用当前逻辑，完整顺序将是：

```text
强制 Sales-owned Tenant
  -> sales_owned

否则 HubSpot Target
  -> active + P0

否则 Effective Sales Owner 命中 APAC_SALES_EMAILS
  -> sales_owned

否则
  -> active
```

## 8. 需要讨论并明确的问题

后续真实数据验证仍需确认：

1. Account、Opportunity、Lead 的最终优先级采用方案 A、B，还是其他 if/else；
2. Lead Owner 是否属于 Sales，还是 SDR 参考信息，不参与最终判定；
3. Approved 是否只限制 Account Owner，还是完全不作为 Owner 门槛；
4. Opportunity 除 `IsClosed = false` 外，是否还要限制特定 Stage；
5. `HasOpenActivity` 是否应高于 `LastModifiedDate`；
6. 三个强制 Sales-owned Tenant 是否继续保留；
7. 是否需要在报表中展示 Owner 姓名或邮箱；若需要，需另行设计个人信息保护。

已经确认：

- inactive Owner 跳过并检查下一候选；
- 只有 Effective Owner 命中 APAC Sales 名单才进入 Sales-owned；
- 即使未命中 APAC Sales，也记录 Effective Owner；
- HubSpot Target 继续优先；
- Owner Sync 必须先于 Dispatcher 成功；
- Worker 找不到本批次的明确判定时必须重试，不能绕过 Owner 判断。

## 9. 讨论确认后的实施清单

当前实施进度：

1. 已完成 Effective Sales Owner Sync Lambda、查询模块和单元测试；
2. 已在生产库创建快照表、sync state 表及两个 Tenant 表的新字段；本次 nullable
   兼容调整尚未执行；
3. 已在 SAM 模板中新增函数、日志、告警和最小 Secret 读取权限；
4. 当前使用 Secrets Manager 中的 Service Account JSON 作为过渡认证；
5. 待为该 Service Account 授予五张原始表只读权限和 BigQuery Job 权限；
6. 已完成 Dispatcher 同步调用、`sync_id` 传递和 Worker 精确快照读取代码；
7. 已完成 Owner Sync 对已有 Tenant 行的直接更新，以及 Worker 首次写入和跨表携带；
8. 待 GCP 权限完成后，先部署同步 Task 并手工 dry-run 验证；
9. 待样例与全量快照验证成功后，再部署 Dispatcher 和 Worker 接入；
10. 观察完整业务周期并评审结果。

## 10. 本讨论稿未做的事项

- 已部署第一版 Owner Sync Lambda 和数据库结构；本次修正版尚未部署；
- 已修改 Dispatcher 和 Worker 源码，但本次修改尚未部署；
- 未部署 AWS 或修改 GCP IAM；
- 未改变生产路由；
- 未在仓库保存客户或员工邮箱。
