SQL 如何正确计算未来的 Billing Cycle Start Date
结论
原来的计算逻辑确实有问题。CEILING() 已经会将不足一个完整周期的月份差向上取整,后续的日期判断又可能多加一个完整周期,造成重复进位。
可以按下面的顺序计算:
- 确定报告日期所在的周期序号;
- 以原始 Billing Start Date 为基准生成候选周期日期;
- 比较候选日期与报告日期;
- 如果候选日期不是未来日期,就再增加一个 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 就不会被判断为不同周期。
备注:内容仅供参考。
