PAYATHON 2026

SQL 如何正确计算未来的 Billing Cycle Start Date

数据阿明

结论

原来的计算逻辑确实有问题。CEILING() 已经会将不足一个完整周期的月份差向上取整,后续的日期判断又可能多加一个完整周期,造成重复进位。

可以按下面的顺序计算:

  1. 确定报告日期所在的周期序号;
  2. 以原始 Billing Start Date 为基准生成候选周期日期;
  3. 比较候选日期与报告日期;
  4. 如果候选日期不是未来日期,就再增加一个 Billing Cycle。

不要把月份差和 DAY() 拆开比较。这两个值无法准确表示两个完整日期的先后关系。

原逻辑为什么会出错

问题在于下面两段计算同时存在:

CEILING(DATEDIFF(month, @Billingdate, @ReportRundDate) * 1.0 / @BillingCycle)

以及:

CASE
    WHEN DAY(@ReportRundDate) > DAY(@Billingdate) THEN 1
    ELSE 0
END

DATEDIFF(month, ...) 计算的是跨越了多少个月份边界,而不是相隔了多少个完整月。

例如:

DATEDIFF(month, '2019-07-31', '2019-08-01')

结果是 1,但这两个日期实际上只相差一天。此时再使用 CEILING() 并判断日期部分,就可能把周期多推进一次。

单独比较 DAY() 同样不可靠,因为每个月的天数不同,而且 DATEADD(month, ...) 处理月末日期时会自动调整。例如:

SELECT DATEADD(month, 1, CAST('2019-01-31' AS date));

结果为:

2019-02-28

因此,应该先算出实际的候选周期日期,再比较完整日期。

推荐写法

下面的代码计算严格晚于报告日期的下一次 Billing Cycle Start Date:

DECLARE @ReportRunDate date = '2019-09-01',
        @BillingDate   date = '2019-07-01',
        @BillingCycle  int  = 3;

IF @BillingCycle <= 0
    THROW 50001, '@BillingCycle must be greater than 0.', 1;

DECLARE @CompletedCycles int =
    CASE
        WHEN @ReportRunDate < @BillingDate THEN 0
        ELSE DATEDIFF(month, @BillingDate, @ReportRunDate) / @BillingCycle
    END;

DECLARE @CandidateDate date =
    DATEADD(
        month,
        @CompletedCycles * @BillingCycle,
        @BillingDate
    );

DECLARE @NextBillingStartDate date =
    CASE
        WHEN @CandidateDate > @ReportRunDate
            THEN @CandidateDate
        ELSE DATEADD(month, @BillingCycle, @CandidateDate)
    END;

SELECT @NextBillingStartDate AS NextBillingStartDate;

这种写法不需要 CEILING(),也不用单独比较日期部分。

同时返回周期开始和结束日期

如果还要显示完整周期,并约定周期结束日期为下一个周期开始日期的前一天,可以这样写:

DECLARE @ReportRunDate date = '2019-09-01',
        @BillingDate   date = '2019-07-01',
        @BillingCycle  int  = 3;

IF @BillingCycle <= 0
    THROW 50001, '@BillingCycle must be greater than 0.', 1;

DECLARE @CompletedCycles int =
    CASE
        WHEN @ReportRunDate < @BillingDate THEN 0
        ELSE DATEDIFF(month, @BillingDate, @ReportRunDate) / @BillingCycle
    END;

DECLARE @CandidateDate date =
    DATEADD(
        month,
        @CompletedCycles * @BillingCycle,
        @BillingDate
    );

DECLARE @NextBillingStartDate date =
    CASE
        WHEN @CandidateDate > @ReportRunDate
            THEN @CandidateDate
        ELSE DATEADD(month, @BillingCycle, @CandidateDate)
    END;

DECLARE @NextBillingEndDate date =
    DATEADD(
        day,
        -1,
        DATEADD(month, @BillingCycle, @NextBillingStartDate)
    );

SELECT
    @NextBillingStartDate AS NextBillingStartDate,
    @NextBillingEndDate   AS NextBillingEndDate,
    CONVERT(char(10), @NextBillingStartDate, 103)
        + ' - '
        + CONVERT(char(10), @NextBillingEndDate, 103) AS BillingPeriod;

最好让 SQL 分别返回 date 类型的列,再由 SSRS 负责日期格式。只有需要直接生成展示文本时,才使用 CONVERT() 拼接。

示例中的 3 日是怎么来的

如果 Billing Start Date 是 2018-03-02,周期是三个月,那么按照通常的以 Billing Start Date 为周期锚点的规则,后续日期应该是:

2018-06-02
2018-09-02
2018-12-02

问题中期望得到 2018-06-03 和 2018-09-03,说明业务规则可能不是每三个月的 2 日开始新周期,而是从 Billing Date 的次日开始。

如果 @BillingDate 表示上一周期的截止日期,需要先把锚点增加一天:

DECLARE @CycleAnchorDate date = DATEADD(day, 1, @BillingDate);

然后用 @CycleAnchorDate 替换前面代码中的 @BillingDate:

DECLARE @ReportRunDate date = '2018-03-05',
        @BillingDate   date = '2018-03-02',
        @BillingCycle  int  = 3;

DECLARE @CycleAnchorDate date = DATEADD(day, 1, @BillingDate);

DECLARE @CompletedCycles int =
    CASE
        WHEN @ReportRunDate < @CycleAnchorDate THEN 0
        ELSE DATEDIFF(month, @CycleAnchorDate, @ReportRunDate)
             / @BillingCycle
    END;

DECLARE @CandidateDate date =
    DATEADD(
        month,
        @CompletedCycles * @BillingCycle,
        @CycleAnchorDate
    );

SELECT
    CASE
        WHEN @CandidateDate > @ReportRunDate
            THEN @CandidateDate
        ELSE DATEADD(month, @BillingCycle, @CandidateDate)
    END AS NextBillingStartDate;

按照这个业务规则:

  • 2018-03-05 运行报告时,结果是 2018-06-03;
  • 2018-06-05 运行报告时,结果是 2018-09-03。

首先要确定 @BillingDate 表示周期开始日,还是上一周期的截止日。只有后一种情况才需要执行 DATEADD(day, 1, @BillingDate)。

当天是否算下一次周期

上面的代码使用了:

@CandidateDate > @ReportRunDate

如果报告恰好在 Billing Cycle Start Date 当天运行,它会返回再下一个周期,即严格晚于当天的日期。

如果当天的周期日期也应该返回,可以改成:

@CandidateDate >= @ReportRunDate

使用 datetime 时,还需要处理时间部分。如果业务只按自然日计算,建议先转换成 date:

DECLARE @RunDate date = CAST(@ReportRunDate AS date);

这样,同一天的 00:00:00 和 15:30:00 就不会被判断为不同周期。

备注:内容仅供参考。