Files
LabelChange-server/.trae/documents/metrics_calculation_plan.md
2026-06-01 16:30:29 +08:00

30 KiB
Raw Blame History

订单指标系统计算实现计划

项目目标

实现复杂的业务指标系统包括24小时换单完成率、订单考核时间、标签率计算等指标。这些指标涉及多表关联、时间逻辑判断和复杂的业务规则。


时区说明

重要:

  • 到仓时间 (ReceiptTime): UTC-5 时区
  • 标签推送时间 (LabelRetrievedAt): UTC+0 时区
  • 扫描时间 (CreatedAt): UTC+0 时区
  • 其他所有时间字段: UTC+0 时区
  • 数据拉取时间: UTC-5 时区显示

时间比较时需进行时区转换。


核心业务逻辑分析

1. 标签率计算Label Rate

定义:

  • 第一阶段(未开始扫描): 标签率 = 当前有标签的订单数 / 该交接单关联的总订单数
  • 第二阶段(已开始扫描): 标签率 = 在第一条扫描记录时间之前有标签的订单数 / 该交接单关联的总订单数(固定不变

计算逻辑:

1. 获取交接单关联的所有订单
2. 检查是否存在该交接单的扫描记录
   - 如果不存在:标签率 = 当前有标签的订单数 / 总订单数
   - 如果存在:
     a. 找到第一条扫描记录的时间(任意结果)
     b. 统计在此时间点前 LabelRetrievedAt <= 第一扫描时间 的订单
     c. 标签率 = 满足条件的订单数 / 总订单数
3. 标签率一旦固定后就不再变化

关键理解:

  • 标签率是交接单在现场开始扫描时的一个时间切片快照
  • 此后即使新增了更多有标签的订单,标签率也不变
  • 标签率 >= 80% 为"高标签率"< 80% 为"低标签率"

数据关系:

  • 交接单号 ← → BillOfLadingNumber / MasterPackageNumber (需要在订单表中查找)
  • 订单中的 LabelRetrievedAt 与 第一条扫描记录的 CreatedAt 比较

2. 考核时间计算Assessment Time

场景A标签率 >= 80%

  • 前置条件有到货时间ReceiptTime且标签率 >= 80%
  • 规则:
    • 如果收货时间 <= 当天 16:00 → 考核时间 = 次日 16:00
    • 如果收货时间 > 当天 16:00 → 考核时间 = 次日 23:59

场景B标签率 < 80%

  • 前置条件:标签率 < 80%
  • 规则:以包裹换单完成时间作为考核时间
    • 换单完成时间 = 该订单的第一条扫描成功记录Result=0的时间

考核记录日期: 哪天完成考核,记录在哪天


3. 24小时换单完成率24H Label Exchange Completion Rate

分子: 标签率 >= 80% 的订单中在考核时间内有扫描成功记录Result=0的订单数

分母: 标签率 >= 80% 的订单总数

公式: 完成率 = (考核时间内成功扫描的订单数) / (标签率 >= 80% 的订单总数) * 100%

时间判断: 以 UTC 时间对比


4. 考核订单群体Assessment Order Group

定义: 有到货时间 AND 标签率 >= 80% 的那部分有标签订单

包含条件:

  • ReceiptTime 不为 NULL有收货时间
  • 标签率 >= 80%(或更严格的条件:订单有标签且标签率 >= 80%

5. 额外指标定义

5.1 当天新增换单数Daily New Replace Count

定义: 到仓时间是当天UTC-5的交接单中有标签的总订单数

计算逻辑:

1. 选取 ReceiptTime 在当天UTC-5的所有到货交接单注意时区转换
2. 获取这些交接单关联的所有订单(通过 BillOfLadingNumber 或 MasterPackageNumber
3. 筛选其中 Label 不为 NULL 且非空的订单
4. 统计总数

代码示例:

public async Task<int> GetDailyNewReplaceCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);  // 返回 UTC 时间表示
    var dateEnd = GetUtc5DateEnd(date);      // 返回 UTC 时间表示
    
    // 获取该日期内到仓的所有交接单ReceiptTime 在 UTC-5 当天)
    var arrivalForms = await _arrivalHandoverFormService
        .GetArrivalHandoverFormsByDateRangeAsync(dateStart, dateEnd);
    
    if (arrivalForms.Count == 0)
        return 0;
    
    var handoverNumbers = arrivalForms.Select(f => f.HandoverNumber).ToList();
    
    // 获取这些交接单关联的所有订单
    var orders = await _labelReplaceRepository
        .GetOrdersByHandoverNumbersAsync(handoverNumbers);
    
    // 统计有标签的订单数Label 不为 NULL 且非空)
    int count = orders.Count(o => !string.IsNullOrEmpty(o.Label));
    
    return count;
}

5.2 累计要换的总单数Cumulative Total Replace Count

定义: 历史上所有有标签但是没有扫描完成记录的订单(不包含当天新增)

计算逻辑:

1. 获取所有 Label 不为 NULL 的订单
2. 排除当天新增的订单ReceiptTime 是当天的)
3. 筛选这些订单中没有成功扫描记录Result=0的
4. 统计数量

5.3 当天应该换单数Daily Should Replace Count

定义: 标签率 >= 80% 且到仓时间是当天的订单总数

计算逻辑:

1. 选取 ReceiptTime 在当天UTC-5的所有到货交接单
2. 计算每个交接单的标签率
3. 筛选标签率 >= 80% 的交接单
4. 统计这些交接单关联的订单总数

5.4 当日换单完成数Daily Completion Count

定义: 扫描完成时间是当日UTC-5的订单总数

计算逻辑:

1. 获取所有有成功扫描记录的订单Result=0即 ScanResult.ReturnedLabel
2. 注意CreatedAt 是 UTC+0需要转换为 UTC-5 后判断是否在当日
3. 筛选其中扫描时间转换后对应当天UTC-5的订单
4. 去重统计(同一订单即使有多条成功扫描也只计算一次)

代码示例:

public async Task<int> GetDailyCompletionCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);  // UTC 时间
    var dateEnd = GetUtc5DateEnd(date);      // UTC 时间
    
    // 获取该日期范围内有成功扫描记录的订单
    var successfulScans = await _labelScanService
        .GetScanRecordsByDateRangeAsync(dateStart, dateEnd, ScanResult.ReturnedLabel);
    
    if (successfulScans == null || successfulScans.Count == 0)
        return 0;
    
    // 去重统计订单数
    var uniqueWaybills = successfulScans
        .Select(s => s.NeutralWaybillNumber)
        .Distinct()
        .Count();
    
    return uniqueWaybills;
}

重要:在 Repository 层 GetScanRecordsByDateRangeAsync 方法中,对 CreatedAtUTC+0进行查询时参数 dateStart 和 dateEnd 已是 UTC 时间,可直接作为 SQL WHERE 条件。

5.5 当日STOP数Daily STOP Count

定义: 扫描结果是完成Result=0且描述包含"STOP"关键字的扫描记录其创建时间是当日UTC-5的订单总数

计算逻辑:

1. 获取当日UTC-5所有成功扫描记录Result=0即 ScanResult.ReturnedLabel
2. 筛选其中 Description 包含"STOP"关键字的记录
3. 去重统计对应的订单数

代码示例:

public async Task<int> GetDailyStopCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);  // UTC 时间
    var dateEnd = GetUtc5DateEnd(date);      // UTC 时间
    
    // 获取该日期范围内所有成功扫描记录
    var successfulScans = await _labelScanService
        .GetScanRecordsByDateRangeAsync(dateStart, dateEnd, ScanResult.ReturnedLabel);
    
    if (successfulScans == null || successfulScans.Count == 0)
        return 0;
    
    // 筛选 Description 包含 "STOP" 的记录,去重统计
    var stopCount = successfulScans
        .Where(s => s.Description != null && s.Description.Contains("STOP", StringComparison.OrdinalIgnoreCase))
        .Select(s => s.NeutralWaybillNumber)
        .Distinct()
        .Count();
    
    return stopCount;
}

5.6 当日标签推送数Daily Label Push Count

定义: 标签推送时间是当天UTC-5的订单数

计算逻辑:

1. 获取所有 LabelRetrievedAt 不为 NULL 的订单
2. 将 LabelRetrievedAtUTC+0转换为 UTC-5
3. 筛选时间在当天的订单
4. 统计数量

5.7 当天换单完成率Daily Completion Rate

定义: 当日换单完成数 / 当天应该换单数

计算逻辑:

完成率 = (Daily Completion Count) / (Daily Should Replace Count) * 100%

5.8 当日扫描数Daily Scan Count

定义: 扫描时间是当天UTC-5的扫描记录总数不去重

计算逻辑:

1. 获取当日UTC-5内所有扫描记录任意结果
2. 统计记录总数(不进行去重)

代码示例:

public async Task<int> GetDailyScanCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);  // UTC 时间
    var dateEnd = GetUtc5DateEnd(date);      // UTC 时间
    
    // 获取该日期范围内的所有扫描记录
    var allScans = await _labelScanService
        .GetScanRecordsByDateRangeAsync(dateStart, dateEnd, resultFilter: null);
    
    return allScans?.Count ?? 0;
}

5.9 16点前到仓包裹数Before Noon Arrived Count

定义: 到仓时间在当天 16:00 之前UTC-5的订单总数

说明:此指标用于统计分时段到仓的包裹数,用于后续分析和考核时间的关联。

计算逻辑:

1. 选取 ReceiptTime 在当天UTC-5且时间 <= 16:00 的订单
2. 统计数量

5.10 16点后到仓包裹数Afternoon Arrived Count

定义: 到仓时间在当天 16:00 之后UTC-5的订单总数

说明:与 16点前到仓包裹数配套用于分时段统计。

计算逻辑:

1. 选取 ReceiptTime 在当天UTC-5且时间 > 16:00 的订单
2. 统计数量

5.11 16点前考核通过包裹数Before Noon Passed Count

定义: 16点前到仓且标签率 >= 80% 的订单中,在考核时间内完成的订单数

说明:配套 5.9 指标,统计早上到仓的订单中有多少在次日 16:00 前完成考核。

计算逻辑:

1. 获取当天 ReceiptTime <= 16:00 且标签率 >= 80% 的订单
2. 这些订单的考核时间为次日 16:00
3. 统计其中有成功扫描记录且时间 <= 次日 16:00 的订单

5.12 16点后考核通过包裹数Afternoon Passed Count

定义: 16点后到仓且标签率 >= 80% 的订单中,在考核时间内完成的订单数

说明:配套 5.10 指标,统计下午到仓的订单中有多少在次日 23:59 前完成考核。

计算逻辑:

1. 获取当天 ReceiptTime > 16:00 且标签率 >= 80% 的订单
2. 这些订单的考核时间为次日 23:59
3. 统计其中有成功扫描记录且时间 <= 次日 23:59 的订单

实现方案

第一阶段数据模型与DTO扩展

1.1 新增DTO类

MetricsCalculationDto.cs - 存储中间计算结果

- NeutralWaybillNumber
- LabelRate (第一次扫描时的标签率)
- FirstScanTime (第一条扫描记录的时间)
- FirstScanResult (第一条扫描记录的结果)
- ReceiptTime (收货时间)
- AssessmentTime (考核时间)
- IsHighLabelRate (标签率 >= 80%)
- CompletedOnTime (是否在考核时间内完成)
- CompletionTime (实际完成时间)
- HandoverNumber (交接单号)
- ArrivalDate (到仓日期)

LabelRateMetricsDto.cs - 交接单级别的标签率

- HandoverNumber (交接单号)
- TotalOrderCount (总订单数)
- LabeledOrderCount (有标签的订单数)
- LabelRate (标签率百分比)
- FirstScanTime (现场首次扫描时间)

Daily24HCompletionRateDto.cs - 日统计完成率

- Date (日期, UTC-5)
- DailyNewReplaceCount (当天新增换单数)
- CumulativeTotalReplaceCount (累计要换的总单数)
- UnfinishedFailureCount (换单失败未完结订单)
- DailyFailureCount (当日换单失败)
- DailySuccessCount (当日换单成功数)
- DailyStopCount (当日STOP数)
- DailyShouldReplaceCount (当天应该换单数)
- HighLabelRateOrderCount (标签率 >= 80% 的订单数)
- CompletedOnTimeCount (按时完成的订单数)
- Rate24Hour (24小时完成率百分比)
- DailyCompletionRate (当天换单完成率百分比)
- DailyLabelPushCount (当日标签推送数)
- DailyScanCount (当日扫描数)
- BeforeNoonArrivedCount (16点前到仓包裹数)
- AfternoonArrivedCount (16点后到仓包裹数)
- BeforeNoonPassedCount (16点前考核通过包裹数)
- AfternoonPassedCount (16点后考核通过包裹数)
- DataFetchTime (数据拉取时间, UTC-5)

1.2 新增Entity可选如果需要持久化计算结果

MetricsCalculationResultEntity.cs - 计算结果缓存表

- Id (主键)
- HandoverNumber
- NeutralWaybillNumber
- LabelRate
- AssessmentTime
- AssessmentDate
- CompletedOnTime
- CreatedAt
- CalculatedAt

第二阶段Repository层扩展

ILabelReplaceRepository 中添加方法:

2.1 LabelReplaceRepository 扩展方法

// 获取指定交接单号对应的所有订单
Task<List<LabelReplaceEntity>> GetOrdersByHandoverNumberAsync(string handoverNumber)

// 获取指定交接单号列表对应的所有订单
Task<List<LabelReplaceEntity>> GetOrdersByHandoverNumbersAsync(List<string> handoverNumbers)

// 获取所有有标签的订单(不分页)
Task<List<LabelReplaceEntity>> GetAllOrdersWithLabelsAsync()

// 获取指定日期范围内有标签的订单
Task<List<LabelReplaceEntity>> GetOrdersWithLabelsByDateRangeAsync(
    DateTime startDate, DateTime endDate)

2.2 LabelScanRepository 扩展方法

// 获取指定日期范围内的扫描记录(指定结果)
Task<List<LabelScanEntity>> GetScanRecordsByDateRangeAsync(
    DateTime startDate, DateTime endDate, ScanResult? resultFilter = null)

// 获取指定中性面单号列表的所有扫描记录
Task<List<LabelScanEntity>> GetScanRecordsByNeutralWaybillNumbersAsync(
    List<string> neutralWaybillNumbers)

// 获取指定中性面单号的第一条扫描记录
Task<LabelScanEntity> GetFirstScanRecordByWaybillNumberAsync(string neutralWaybillNumber)

// 批量获取多个中性面单号的第一条扫描记录
Task<Dictionary<string, LabelScanEntity>> GetFirstScanRecordsByWaybillNumbersAsync(
    List<string> neutralWaybillNumbers)

// 检查指定时间前是否有成功扫描
Task<bool> HasSuccessScanBeforeAsync(string neutralWaybillNumber, DateTime beforeTime)

// 获取指定中性面单号的第一条成功扫描
Task<LabelScanEntity> GetFirstSuccessScanAsync(string neutralWaybillNumber)

2.3 ArrivalHandoverFormRepository 扩展方法

// 获取指定日期范围内的到货交接单
Task<List<ArrivalHandoverFormEntity>> GetArrivalHandoverFormsByDateRangeAsync(
    DateTime startDate, DateTime endDate)

2.4 关键实现提示

时区转换注意事项:

  • ReceiptTimeUTC-5进行时间比较前需转换为 UTC 再进行 SQL 比较
  • LabelRetrievedAtUTC+0直接比较
  • CreatedAtUTC+0直接比较
  • 在应用层判断"16:00"时需使用本地时间UTC-5进行判断

标签率计算的核心逻辑:

  • 找到交接单关联的所有订单
  • 检查是否存在该交接单的扫描记录
  • 如果存在,找到第一条扫描记录的时间点,统计该时间点前有标签的订单数
  • 如果不存在,统计当前有标签的订单数
  • 标签率 = 满足条件的订单数 / 总数

第三阶段Service层实现

3.1 新建 IMetricsCalculationService 接口

public interface IMetricsCalculationService
{
    // 时间转换辅助方法
    DateTime ConvertUtcToUtc5(DateTime utcTime);
    DateTime ConvertUtc5ToUtc(DateTime utc5Time);
    DateTime GetUtc5Today();
    DateTime GetUtc5DateStart(DateTime utc5Date);
    DateTime GetUtc5DateEnd(DateTime utc5Date);
    
    // 基础指标计算
    Task<LabelRateMetricsDto> GetLabelRateAsync(string handoverNumber);
    Task<MetricsCalculationDto> GetOrderMetricsAsync(string neutralWaybillNumber);
    
    // 当日指标计算
    Task<int> GetDailyNewReplaceCountAsync(DateTime date);
    Task<int> GetCumulativeTotalReplaceCountAsync(DateTime date);
    Task<int> GetDailyCompletionCountAsync(DateTime date);
    Task<int> GetDailyStopCountAsync(DateTime date);
    Task<int> GetDailyLabelPushCountAsync(DateTime date);
    Task<int> GetDailyUnfinishedFailureCountAsync();
    Task<int> GetDailyFailureCountAsync(DateTime date);
    Task<int> GetDailySuccessCountAsync(DateTime date);
    Task<int> GetDailyShouldReplaceCountAsync(DateTime date);
    Task<int> GetBeforeNoonArrivedCountAsync(DateTime date);
    Task<int> GetAfternoonArrivedCountAsync(DateTime date);
    Task<int> GetBeforeNoonPassedCountAsync(DateTime date);
    Task<int> GetAfternoonPassedCountAsync(DateTime date);
    Task<int> GetDailyScanCountAsync(DateTime date);
    
    // 24小时和每日完成率
    Task<double> Calculate24HCompletionRateAsync(DateTime date);
    Task<double> CalculateDailyCompletionRateAsync(DateTime date);
    
    // 完整日统计
    Task<Daily24HCompletionRateDto> GetDailySummaryAsync(DateTime date);
    
    // 批量计算多个订单的指标
    Task<List<MetricsCalculationDto>> GetBatchOrderMetricsAsync(List<string> neutralWaybillNumbers);
    
    // 获取指定日期范围的每日统计
    Task<List<Daily24HCompletionRateDto>> GetDailySummariesAsync(
        DateTime startDate, DateTime endDate);
    
    // 重新计算并缓存某个交接单的标签率
    Task<bool> RecalculateAndCacheLabelRateAsync(string handoverNumber);
}

3.2 实现 MetricsCalculationService

时区处理辅助方法:

private const int UTC_5_OFFSET = -5;

public DateTime ConvertUtcToUtc5(DateTime utcTime)
{
    return utcTime.AddHours(UTC_5_OFFSET);
}

public DateTime ConvertUtc5ToUtc(DateTime utc5Time)
{
    return utc5Time.AddHours(-UTC_5_OFFSET);
}

public DateTime GetUtc5Today()
{
    var now = DateTime.UtcNow;
    var utc5Now = ConvertUtcToUtc5(now);
    return utc5Now.Date;
}

public DateTime GetUtc5DateStart(DateTime utc5Date)
{
    // 返回 UTC-5 日期的开始时间0:00:00但以 UTC 时间表示
    var utc5Start = utc5Date.Date;
    return ConvertUtc5ToUtc(utc5Start);
}

public DateTime GetUtc5DateEnd(DateTime utc5Date)
{
    // 返回 UTC-5 日期的结束时间23:59:59但以 UTC 时间表示
    var utc5End = utc5Date.Date.AddDays(1).AddSeconds(-1);
    return ConvertUtc5ToUtc(utc5End);
}

当日新增换单数计算:

public async Task<int> GetDailyNewReplaceCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);
    var dateEnd = GetUtc5DateEnd(date);
    
    // 获取该日期内到仓的所有交接单
    var arrivalForms = await _arrivalHandoverFormService
        .GetArrivalHandoverFormsByDateRangeAsync(dateStart, dateEnd);
    
    if (arrivalForms.Count == 0)
        return 0;
    
    var handoverNumbers = arrivalForms.Select(f => f.HandoverNumber).ToList();
    
    // 获取这些交接单关联的所有订单
    var orders = await _labelReplaceRepository
        .GetOrdersByHandoverNumbersAsync(handoverNumbers);
    
    // 统计有标签的订单数
    int count = orders.Count(o => !string.IsNullOrEmpty(o.Label));
    
    return count;
}

累计要换的总单数计算:

public async Task<int> GetCumulativeTotalReplaceCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);
    
    // 获取所有有标签的订单
    var labeledOrders = await _labelReplaceRepository
        .GetAllOrdersWithLabelsAsync();
    
    // 排除当天新增的订单
    var pastOrders = labeledOrders
        .Where(o => o.LabelRetrievedAt < dateStart)
        .ToList();
    
    // 获取所有这些订单的扫描记录
    var waybills = pastOrders.Select(o => o.NeutralWaybillNumber).ToList();
    var scans = await _labelScanService
        .GetScanRecordsByNeutralWaybillNumbersAsync(waybills);
    
    // 统计没有成功扫描记录的订单
    var successfulWaybills = scans
        .Where(s => s.Result == ScanResult.ReturnedLabel)
        .Select(s => s.NeutralWaybillNumber)
        .ToHashSet();
    
    int count = pastOrders.Count(o => !successfulWaybills.Contains(o.NeutralWaybillNumber));
    
    return count;
}

标签率计算方法:

private async Task<double> CalculateLabelRateAtFirstScanAsync(string handoverNumber)
{
    // 获取该交接单关联的所有订单
    var orders = await _labelReplaceRepository.GetOrdersByHandoverNumberAsync(handoverNumber);
    
    if (orders.Count == 0)
        return 0;
    
    // 获取该交接单所有订单的扫描记录
    var waybills = orders.Select(o => o.NeutralWaybillNumber).ToList();
    var allScans = await _labelScanService.GetScanRecordsByNeutralWaybillNumbersAsync(waybills);
    
    // 情况1交接单不存在任何扫描记录
    if (allScans == null || allScans.Count == 0)
    {
        // 返回当前标签率
        int labeledCount = orders.Count(o => !string.IsNullOrEmpty(o.Label));
        return (double)labeledCount / orders.Count;
    }
    
    // 情况2交接单存在扫描记录找到最早的扫描时间
    var earliestScanTime = allScans.Min(s => s.CreatedAt);
    
    // 统计在最早扫描时间之前有标签的订单数
    int labeledAtScanTime = 0;
    foreach (var order in orders)
    {
        if (!string.IsNullOrEmpty(order.Label))
        {
            // LabelRetrievedAt 可能为 null如果为 null 则使用 CreatedAt
            var labelRetrievedAt = order.LabelRetrievedAt ?? order.CreatedAt;
            if (labelRetrievedAt <= earliestScanTime)
            {
                labeledAtScanTime++;
            }
        }
    }
    
    return (double)labeledAtScanTime / orders.Count;
}

当天应该换单数计算:

public async Task<int> GetDailyShouldReplaceCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);
    var dateEnd = GetUtc5DateEnd(date);
    
    // 获取该日期内到仓的所有交接单
    var arrivalForms = await _arrivalHandoverFormService
        .GetArrivalHandoverFormsByDateRangeAsync(dateStart, dateEnd);
    
    if (arrivalForms.Count == 0)
        return 0;
    
    int totalCount = 0;
    
    // 计算每个交接单的标签率
    foreach (var form in arrivalForms)
    {
        var labelRate = await CalculateLabelRateAtFirstScanAsync(form.HandoverNumber);
        
        // 只统计标签率 >= 80% 的交接单关联的订单
        if (labelRate >= 0.80)
        {
            var orders = await _labelReplaceRepository
                .GetOrdersByHandoverNumberAsync(form.HandoverNumber);
            totalCount += orders.Count;
        }
    }
    
    return totalCount;
}

**当日换单完成数计算**:

```csharp
public async Task<int> GetDailyCompletionCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);
    var dateEnd = GetUtc5DateEnd(date);
    
    // 获取该日期内有成功扫描记录的订单
    var successfulScans = await _labelScanService
        .GetScanRecordsByDateRangeAsync(dateStart, dateEnd, ScanResult.ReturnedLabel);
    
    // 去重订单,统计数量
    var uniqueWaybills = successfulScans
        .Select(s => s.NeutralWaybillNumber)
        .Distinct()
        .Count();
    
    return uniqueWaybills;
}

当日STOP数计算:

public async Task<int> GetDailyStopCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);
    var dateEnd = GetUtc5DateEnd(date);
    
    // 获取该日期内所有成功扫描记录
    var successfulScans = await _labelScanService
        .GetScanRecordsByDateRangeAsync(dateStart, dateEnd, ScanResult.ReturnedLabel);
    
    // 筛选 Description 包含 "STOP" 的记录
    var stopScans = successfulScans
        .Where(s => s.Description != null && s.Description.Contains("STOP"))
        .Select(s => s.NeutralWaybillNumber)
        .Distinct()
        .Count();
    
    return stopScans;
}

当日标签推送数计算:

public async Task<int> GetDailyLabelPushCountAsync(DateTime date)
{
    // date 是 UTC-5 格式的日期
    var dateStart = GetUtc5DateStart(date);
    var dateEnd = GetUtc5DateEnd(date);
    
    // 获取所有有标签推送时间的订单
    var allOrders = await _labelReplaceRepository.GetAllOrdersWithLabelsAsync();
    
    // 筛选推送时间在该日期内的订单
    int count = allOrders.Count(o => 
        o.LabelRetrievedAt.HasValue && 
        o.LabelRetrievedAt >= dateStart && 
        o.LabelRetrievedAt <= dateEnd
    );
    
    return count;
}

完整日统计计算:

public async Task<Daily24HCompletionRateDto> GetDailySummaryAsync(DateTime date)
{
    // 并行计算所有指标
    var newReplaceCountTask = GetDailyNewReplaceCountAsync(date);
    var cumulativeCountTask = GetCumulativeTotalReplaceCountAsync(date);
    var completionCountTask = GetDailyCompletionCountAsync(date);
    var stopCountTask = GetDailyStopCountAsync(date);
    var labelPushCountTask = GetDailyLabelPushCountAsync(date);
    var shouldReplaceCountTask = GetDailyShouldReplaceCountAsync(date);
    var scanCountTask = GetDailyScanCountAsync(date);
    var beforeNoonCountTask = GetBeforeNoonArrivedCountAsync(date);
    var afternoonCountTask = GetAfternoonArrivedCountAsync(date);
    var beforeNoonPassedTask = GetBeforeNoonPassedCountAsync(date);
    var afternoonPassedTask = GetAfternoonPassedCountAsync(date);
    
    await Task.WhenAll(
        newReplaceCountTask, cumulativeCountTask, completionCountTask,
        stopCountTask, labelPushCountTask, shouldReplaceCountTask, scanCountTask,
        beforeNoonCountTask, afternoonCountTask, beforeNoonPassedTask, afternoonPassedTask
    );
    
    int newReplaceCount = await newReplaceCountTask;
    int cumulativeCount = await cumulativeCountTask;
    int completionCount = await completionCountTask;
    int stopCount = await stopCountTask;
    int labelPushCount = await labelPushCountTask;
    int shouldReplaceCount = await shouldReplaceCountTask;
    int scanCount = await scanCountTask;
    int beforeNoonCount = await beforeNoonCountTask;
    int afternoonCount = await afternoonCountTask;
    int beforeNoonPassed = await beforeNoonPassedTask;
    int afternoonPassed = await afternoonPassedTask;
    
    // 计算完成率
    double dailyCompletionRate = shouldReplaceCount > 0
        ? (double)completionCount / shouldReplaceCount * 100
        : 0;
    
    double rate24Hour = shouldReplaceCount > 0
        ? (double)completionCount / shouldReplaceCount * 100
        : 0;
    
    int unfinishedFailureCount = cumulativeCount;
    int dailyFailureCount = shouldReplaceCount - completionCount;
    int dailySuccessCount = completionCount;
    
    return new Daily24HCompletionRateDto
    {
        Date = date,
        DailyNewReplaceCount = newReplaceCount,
        CumulativeTotalReplaceCount = cumulativeCount,
        UnfinishedFailureCount = unfinishedFailureCount,
        DailyFailureCount = dailyFailureCount,
        DailySuccessCount = dailySuccessCount,
        DailyStopCount = stopCount,
        DailyShouldReplaceCount = shouldReplaceCount,
        DailyCompletionRate = $"{dailyCompletionRate:F2}%",
        Rate24Hour = $"{rate24Hour:F2}%",
        DailyLabelPushCount = labelPushCount,
        DailyScanCount = scanCount,
        BeforeNoonArrivedCount = beforeNoonCount,
        AfternoonArrivedCount = afternoonCount,
        BeforeNoonPassedCount = beforeNoonPassed,
        AfternoonPassedCount = afternoonPassed,
        DataFetchTime = ConvertUtcToUtc5(DateTime.UtcNow)
    };
}

第四阶段Controller层扩展

新增 MetricsController 端点:

[ApiController]
[Route("api/[controller]")]
public class MetricsController : ControllerBase
{
    private readonly IMetricsCalculationService _metricsService;

    [HttpGet("label-rate")]
    public async Task<IActionResult> GetLabelRate([FromQuery] string handoverNumber)
    
    [HttpGet("order-assessment")]
    public async Task<IActionResult> GetOrderAssessmentMetrics([FromQuery] string neutralWaybillNumber)
    
    [HttpGet("daily-summary")]
    public async Task<IActionResult> GetDailySummary([FromQuery] string date = null)
    
    [HttpGet("daily-summaries")]
    public async Task<IActionResult> GetDailySummaries([FromQuery] string startDate, [FromQuery] string endDate)
    
    [HttpPost("recalculate-label-rate")]
    public async Task<IActionResult> RecalculateLabelRate([FromBody] RecalculateLabelRateRequest request)
}

或扩展现有 DashboardController:

[HttpGet("daily-metrics")]
public async Task<IActionResult> GetDailyMetrics([FromQuery] string date = null)

[HttpGet("date-range-metrics")]  
public async Task<IActionResult> GetDateRangeMetrics([FromQuery] string startDate, [FromQuery] string endDate)

第五阶段:测试与验证

5.1 单元测试

  • 测试标签率计算边界情况0个订单、全有标签、全无标签、未扫描状态
  • 测试各项指标计算(多种时区场景)
  • 测试边界时间UTC vs UTC-5 转换)

5.2 集成测试

  • 端到端的完整流程测试
  • 使用真实数据验证结果的准确性
  • 性能测试(大数据量的计算速度)

第六阶段:异步处理与优化

6.1 异步任务处理

  • 大批量指标计算可使用后台任务
  • 定期(如每小时)更新一次统计数据

6.2 查询优化

  • 优化 Repository 层的 SQL 查询,避免 N+1 问题
  • 使用预加载Include优化关联查询性能

实现步骤(优先级排序)

  1. 第一步: 创建DTO和接口定义
  2. 第二步: Repository层方法实现
  3. 第三步: Service层核心算法实现
  4. 第四步: Controller端点实现
  5. 第五步: 测试与验证
  6. 第六步: 异步处理与性能优化
  7. 第七步: 文档完善和代码审查

关键注意事项

  1. 时区处理: 确保所有时间比较都使用 UTC再根据需要转换为特定时区
  2. Null处理: ReceiptTime可能为NULL需要特殊处理
  3. 多次重复: 同一个订单可能有多条扫描记录,需要去重和排序
  4. 性能: 涉及多表JOIN和复杂计算需要优化SQL查询
  5. 数据一致性: 计算过程中数据可能变化,需要事务保证

依赖关系

  • 现有的 LabelReplaceService
  • 现有的 LabelScanService
  • 现有的 ArrivalHandoverFormService
  • 可能需要新增 ILabelScanRepository 的扫描查询方法