13-24-statistics-methods-unify-workstatus.md 14 KB

Story 13.24: 统一统计方法使用 orderPerson.workStatus

Status: review

元数据

  • Epic: Epic 13 - 跨端数据同步测试
  • 状态: ready-for-dev
  • 优先级: P1 (数据准确性问题)
  • 故事点: 5
  • 依赖: Story 13.23 完成

用户故事

作为企业管理员, 我在企业小程序的数据统计页查看各类分布图和统计数据时, 我希望所有统计都使用统一的"在职"定义(基于订单人员实际工作状态), 以便各项数据之间保持一致和可比较。

问题背景

当前问题: 统计模块中存在数据不一致问题,Story 13.23 已修复了 getEmploymentCount 方法,但其他统计方法仍使用错误的统计口径。

根本原因: 统计模块中多个方法使用 disabled_person.jobStatus = 1 进行"在职人员"过滤,而不是使用 order_person.work_status = 'working'

影响的方法:

  1. getDisabilityTypeDistribution - 残疾类型分布图
  2. getGenderDistribution - 性别分布图
  3. getAgeDistribution - 年龄分布图
  4. getHouseholdDistribution - 户籍分布图
  5. getSalaryDistribution - 薪资分布图
  6. getEmploymentRate - 在职率统计

技术细节:

  • disabled_person.jobStatus = 1 是残疾人表的旧字段,可能与实际工作状态不同步
  • order_person.work_status = 'working' 是订单人员表的实际工作状态,反映真实工作情况
  • 使用不同的统计口径会导致各统计图表之间数据不一致

验收标准

AC 1: 残疾类型分布使用正确的统计口径

Given 企业小程序数据统计页 When 查看残疾类型分布图 Then 统计应基于 order_person.work_status = 'working' 进行过滤 And 总人数应与首页仪表板在职人数一致

AC 2: 性别分布使用正确的统计口径

Given 企业小程序数据统计页 When 查看性别分布图 Then 统计应基于 order_person.work_status = 'working' 进行过滤 And 总人数应与首页仪表板在职人数一致

AC 3: 年龄分布使用正确的统计口径

Given 企业小程序数据统计页 When 查看年龄分布图 Then 统计应基于 order_person.work_status = 'working' 进行过滤 And 总人数应与首页仪表板在职人数一致

AC 4: 户籍分布使用正确的统计口径

Given 企业小程序数据统计页 When 查看户籍分布图 Then 统计应基于 order_person.work_status = 'working' 进行过滤 And 总人数应与首页仪表板在职人数一致

AC 5: 薪资分布使用正确的统计口径

Given 企业小程序数据统计页 When 查看薪资分布图 Then 统计应基于 order_person.work_status = 'working' 进行过滤 And 只统计在职人员的薪资

AC 6: 在职率使用正确的统计口径

Given 企业小程序数据统计页 When 查看在职率统计 Then 在职人数应基于 order_person.work_status = 'working' 统计 And 与首页仪表板在职人数保持一致

AC 7: 所有分布图总人数一致

Given 企业小程序数据统计页 When 查看各分布图 Then 各分布图的总人数应相同 And 与首页仪表板在职人数一致

AC 8: E2E 测试通过

Given 修改完成后 When 运行数据统计页 E2E 测试 Then 所有测试应该通过

任务

任务 0: 分析现有代码和修复方案

  • 分析 statistics.service.ts 中 6 个需要修复的方法
  • 确认每个方法当前使用 jobStatus = 1 的位置
  • 设计修复方案(从 disabledPerson 改为 orderPerson,从 jobStatus=1 改为 workStatus='working')
  • 评估对各方法查询逻辑的影响

任务 1: 重构 getDisabilityTypeDistribution 方法

  • 修改查询从 disabledPersonRepository 改为使用 orderPersonRepository
  • 将过滤条件从 jobStatus = 1 改为 workStatus = 'working'
  • 通过 innerJoin 关联 person 表获取 disabilityType
  • 保持 companyId 过滤逻辑不变
  • 验证返回结果正确

任务 2: 重构 getGenderDistribution 方法

  • 修改查询从 disabledPersonRepository 改为使用 orderPersonRepository
  • 将过滤条件从 jobStatus = 1 改为 workStatus = 'working'
  • 通过 innerJoin 关联 person 表获取 gender
  • 保持 companyId 过滤逻辑不变
  • 验证返回结果正确

任务 3: 重构 getAgeDistribution 方法

  • 修改查询从 disabledPersonRepository 改为使用 orderPersonRepository
  • 将过滤条件从 jobStatus = 1 改为 workStatus = 'working'
  • 通过 innerJoin 关联 person 表获取 birth_date
  • 保持年龄分组逻辑不变
  • 验证返回结果正确

任务 4: 重构 getHouseholdDistribution 方法

  • 修改查询从 disabledPersonRepository 改为使用 orderPersonRepository
  • 将过滤条件从 jobStatus = 1 改为 workStatus = 'working'
  • 通过 innerJoin 关联 person 表获取 province/city
  • 保持 AreaEntity LEFT JOIN 逻辑不变
  • 验证返回结果正确

任务 5: 重构 getSalaryDistribution 方法

  • 当前已使用 orderPersonRepository,但关联查询仍使用 dp.jobStatus = 1
  • 将过滤条件从 dp.jobStatus = 1 改为 op.workStatus = 'working'
  • 保持薪资分组逻辑不变
  • 验证返回结果正确

任务 6: 重构 getEmploymentRate 方法

  • 修改在职人数统计从 disabledPersonRepository 改为 orderPersonRepository
  • 将过滤条件从 jobStatus = 1 改为 workStatus = 'working'
  • 总人数保持使用 getCompanyDisabledPersonIds 获取
  • 验证在职率计算正确

任务 7: 单元测试验证

  • 为每个修改的方法编写或更新单元测试
  • 测试应验证使用正确的 workStatus 过滤条件
  • 测试应包含不同 work_status 的场景
  • 确保所有单元测试通过

任务 8: E2E 测试验证

  • 使用 Playwright MCP 验证各分布图与首页一致
  • 运行数据统计页 E2E 测试套件
  • 验证所有分布图总人数一致
  • 验证所有测试通过

任务 9: 代码审查问题修复 (2026-01-18 新增)

  • 修复 getAverageSalary 方法缺少 workStatus='working' 过滤条件
  • 修复 getEmploymentRate 方法使用 COUNT(DISTINCT op.personId) 去重
  • 运行类型检查确认无错误
  • 运行构建确认成功

Dev Notes

相关文件

  • 主要修改文件: allin-packages/statistics-module/src/services/statistics.service.ts
  • 测试文件: allin-packages/statistics-module/test/statistics.service.spec.ts

当前实现分析

1. getDisabilityTypeDistribution (行 41-79)

// 当前实现(错误)
const query = this.disabledPersonRepository
  .createQueryBuilder('dp')
  .select('dp.disabilityType', 'key')
  .addSelect('COUNT(dp.id)', 'value')
  .where('dp.id IN (:...personIds)', { personIds })
  .andWhere('dp.disabilityType IS NOT NULL')
  .andWhere('dp.jobStatus = :jobStatus', { jobStatus: 1 })  // 错误
  .groupBy('dp.disabilityType');

2. getGenderDistribution (行 86-124)

// 当前实现(错误)
const query = this.disabledPersonRepository
  .createQueryBuilder('dp')
  .select('dp.gender', 'key')
  .addSelect('COUNT(dp.id)', 'value')
  .where('dp.id IN (:...personIds)', { personIds })
  .andWhere('dp.gender IS NOT NULL')
  .andWhere('dp.jobStatus = :jobStatus', { jobStatus: 1 })  // 错误
  .groupBy('dp.gender');

3. getAgeDistribution (行 131-191)

// 当前实现(错误)
const ageQuery = this.disabledPersonRepository
  .createQueryBuilder('dp')
  .select('dp.id', 'id')
  .addSelect(/* 年龄分组 SQL */)
  .where('dp.id IN (:...personIds)', { personIds })
  .andWhere('dp.birth_date IS NOT NULL')
  .andWhere('dp.jobStatus = :jobStatus', { jobStatus: 1 });  // 错误

4. getHouseholdDistribution (行 198-251)

// 当前实现(错误)
const query = this.disabledPersonRepository
  .createQueryBuilder('dp')
  .leftJoin(/* AreaEntity 关联 */)
  .select('COALESCE(provinceArea.name, dp.province)', 'province')
  .addSelect('COALESCE(cityArea.name, dp.city)', 'city')
  .addSelect('COUNT(dp.id)', 'value')
  .where('dp.id IN (:...personIds)', { personIds })
  .andWhere('dp.province IS NOT NULL')
  .andWhere('dp.jobStatus = :jobStatus', { jobStatus: 1 })  // 错误
  .groupBy('dp.province, dp.city, provinceArea.name, cityArea.name');

5. getSalaryDistribution (行 308-381)

// 当前实现(错误 - 已使用 orderPerson 但关联条件错误)
const query = this.orderPersonRepository
  .createQueryBuilder('op')
  .innerJoin('op.order', 'order')
  .innerJoin('op.person', 'dp')
  .select('op.salaryDetail', 'salary')
  .where('order.companyId = :companyId', { companyId })
  .andWhere('op.salaryDetail IS NOT NULL')
  .andWhere('op.salaryDetail > 0')
  .andWhere('dp.jobStatus = :jobStatus', { jobStatus: 1 });  // 应改为 op.workStatus

6. getEmploymentRate (行 450-481)

// 当前实现(错误)
const employedCount = await this.disabledPersonRepository
  .createQueryBuilder('dp')
  .where('dp.id IN (:...personIds)', { personIds })
  .andWhere('dp.jobStatus = :jobStatus', { jobStatus: 1 })  // 错误
  .getCount();

修复方案示例

getDisabilityTypeDistribution 为例:

// 修复后的实现(正确)
async getDisabilityTypeDistribution(companyId: number): Promise<{
  companyId: number;
  stats: StatItem[];
  total: number;
}> {
  // 直接从 orderPerson 表统计,关联 person 获取 disabilityType
  const query = this.orderPersonRepository
    .createQueryBuilder('op')
    .innerJoin('op.order', 'order')
    .innerJoin('op.person', 'dp')
    .select('dp.disabilityType', 'key')
    .addSelect('COUNT(DISTINCT dp.id)', 'value')  // 使用 DISTINCT 避免重复计数
    .where('order.companyId = :companyId', { companyId })
    .andWhere('op.workStatus = :workStatus', { workStatus: 'working' })
    .andWhere('dp.disabilityType IS NOT NULL')
    .groupBy('dp.disabilityType');

  const rawStats = await query.getRawMany();
  const total = rawStats.reduce((sum, item) => sum + parseInt(item.value), 0);

  const stats = rawStats.map(item => ({
    key: item.key,
    value: parseInt(item.value),
    percentage: total > 0 ? (parseInt(item.value) / total) * 100 : 0
  }));

  return {
    companyId,
    stats,
    total
  };
}

技术要点

  1. 查询重构原则:

    • disabledPersonRepository 改为 orderPersonRepository
    • 添加 innerJoin('op.person', 'dp') 关联残疾人表
    • 添加 innerJoin('op.order', 'order') 获取 companyId 过滤
    • 使用 op.workStatus = 'working' 替代 dp.jobStatus = 1
  2. DISTINCT 使用:

    • 当从 orderPerson 查询 person 字段时,需要使用 COUNT(DISTINCT dp.id) 避免重复计数
    • 因为一个人员可能对应多个订单
  3. 性能考虑:

    • 新实现避免了先获取 personIds 的额外查询
    • 数据库可以更好地优化 JOIN 查询性能
  4. 兼容性考虑:

    • 保持返回的数据结构不变
    • 保持 API 接口不变

测试策略

  1. 单元测试:

    • 测试每个方法使用正确的 workStatus 过滤条件
    • 测试包含不同 work_status 的场景
    • 测试验证返回数据的正确性
  2. 集成测试:

    • 验证各分布图之间数据一致性
    • 验证与首页 overview API 的一致性
  3. E2E 测试:

    • 使用 Playwright MCP 验证小程序数据一致性
    • 运行数据统计页 E2E 测试套件

回归测试检查点

  • 残疾类型分布图显示正常
  • 性别分布图显示正常
  • 年龄分布图显示正常
  • 户籍分布图显示正常
  • 薪资分布图显示正常
  • 在职率统计显示正常
  • 各分布图总人数一致
  • 与首页仪表板在职人数一致

References

相关文档

相关代码

  • allin-packages/statistics-module/src/services/statistics.service.ts - 统计服务实现
  • allin-packages/statistics-module/src/routes/statistics.routes.ts - 统计 API 路由

Dev Agent Record

Agent Model Used

Claude (d8d-model)

Debug Log References

N/A

Completion Notes List

  • Story 创建于 2026-01-18
  • 依赖于 Story 13.23(已修复 getEmploymentCount)
  • 需要系统性修复 6 个统计方法使用统一的统计口径
  • ✅ 2026-01-18: 已完成所有 6 个统计方法的重构:
    • getDisabilityTypeDistribution: 从 disabledPersonRepository 改为 orderPersonRepository,使用 workStatus='working'
    • getGenderDistribution: 从 disabledPersonRepository 改为 orderPersonRepository,使用 workStatus='working'
    • getAgeDistribution: 从 disabledPersonRepository 改为 orderPersonRepository,使用 workStatus='working'
    • getHouseholdDistribution: 从 disabledPersonRepository 改为 orderPersonRepository,使用 workStatus='working'
    • getSalaryDistribution: 从 dp.jobStatus=1 改为 op.workStatus='working'
    • getEmploymentRate: 在职人数统计从 disabledPersonRepository 改为 orderPersonRepository,使用 workStatus='working'
  • 所有方法使用 COUNT(DISTINCT dp.id) 避免重复计数
  • Type check 通过,Build 成功
  • ✅ 2026-01-18: 代码审查后额外修复:
    • getAverageSalary: 添加 workStatus='working' 过滤条件(原代码遗漏)
    • getEmploymentRate: 使用 COUNT(DISTINCT op.personId) 去重(避免在职率 > 100%)

File List

主要修改文件:

  • allin-packages/statistics-module/src/services/statistics.service.ts (修改 6 个方法)

Change Log

  • 2026-01-18: 创建 Story,系统性修复统计方法使用 orderPerson.workStatus
  • 2026-01-18: 完成所有 6 个统计方法的重构,统一使用 orderPerson.workStatus='working'