PAYATHON 2026

如何计算已过周期数并生成 billing cycle ID

数据阿明

结论

可以直接计算,无需维护周期查询表。

如果每个周期从每月第 X 天开始,首个周期的开始日期是 Y,计算 billing cycle ID 只需三步:

  1. 找出目标日期对应的周期开始月份。
  2. 计算该月份与首个周期开始月份之间的月份差。
  3. 将月份差加 1。

当 X = 1 时,公式如下:

cycleId =
    (目标年份 - 首个周期年份) × 12
    + (目标月份 - 首个周期月份)
    + 1

例如,首个周期从 2006/01/01 开始:

2006/05/07 对应的 cycleId
= (2006 - 2006) × 12 + (5 - 1) + 1
= 5

通用计算方法

假设:

  • Y 是第一个周期的开始日期,该周期的 ID 为 1
  • 每月第 X 天开始一个新周期
  • 目标日期为 D

如果 D 的日期小于 X,它属于上个月开始的周期;否则,它属于当月开始的周期。

先确定目标日期对应的周期开始月份:

如果 day(D) >= X:
    cycleMonth(D) = D 所在月份
否则:
    cycleMonth(D) = D 所在月份的上一个月

再计算周期 ID:

cycleId =
    monthDifference(cycleMonth(Y), cycleMonth(D))
    + 1

月份差的计算方式是:

monthDifference(A, B) =
    (year(B) - year(A)) × 12
    + month(B) - month(A)

例如,每月第 10 天开始新周期,第一个周期从 2006/01/10 开始:

2006/05/07  属于 2006/04/10 开始的周期,ID = 4
2006/05/10  属于 2006/05/10 开始的周期,ID = 5
2006/05/31  属于 2006/05/10 开始的周期,ID = 5

伪代码实现

function getCycleMonth(date, billingDay):
    effectiveDay = min(billingDay, daysInMonth(date.year, date.month))
    monthStart = firstDayOfMonth(date)

    if date.day >= effectiveDay:
        return monthStart
    else:
        return addMonths(monthStart, -1)

function getBillingCycleId(date, firstBillingDate, billingDay):
    targetMonth = getCycleMonth(date, billingDay)
    firstMonth = getCycleMonth(firstBillingDate, billingDay)

    months =
        (targetMonth.year - firstMonth.year) * 12
        + targetMonth.month
        - firstMonth.month

    if months < 0:
        return null

    return months + 1

min(billingDay, daysInMonth(...)) 用于处理每月第 29、30 或 31 天开始周期的情况。例如,周期规定从每月第 31 天开始,而二月没有第 31 天,那么二月的周期开始日就是二月最后一天。

SQL Server 示例

下面的查询支持跨年,也支持在 X = 29、30 或 31 时按月末截断:

DECLARE @TargetDate       date = '2006-05-07';
DECLARE @FirstBillingDate date = '2006-01-01';
DECLARE @BillingDay       int  = 1;

DECLARE @TargetMonth date =
    DATEFROMPARTS(YEAR(@TargetDate), MONTH(@TargetDate), 1);

DECLARE @FirstMonth date =
    DATEFROMPARTS(
        YEAR(@FirstBillingDate),
        MONTH(@FirstBillingDate),
        1
    );

DECLARE @TargetEffectiveDay int =
    CASE
        WHEN @BillingDay > DAY(EOMONTH(@TargetDate))
            THEN DAY(EOMONTH(@TargetDate))
        ELSE @BillingDay
    END;

DECLARE @FirstEffectiveDay int =
    CASE
        WHEN @BillingDay > DAY(EOMONTH(@FirstBillingDate))
            THEN DAY(EOMONTH(@FirstBillingDate))
        ELSE @BillingDay
    END;

DECLARE @TargetCycleMonth date =
    CASE
        WHEN DAY(@TargetDate) >= @TargetEffectiveDay
            THEN @TargetMonth
        ELSE DATEADD(month, -1, @TargetMonth)
    END;

DECLARE @FirstCycleMonth date =
    CASE
        WHEN DAY(@FirstBillingDate) >= @FirstEffectiveDay
            THEN @FirstMonth
        ELSE DATEADD(month, -1, @FirstMonth)
    END;

SELECT
    CASE
        WHEN @TargetCycleMonth < @FirstCycleMonth THEN NULL
        ELSE DATEDIFF(month, @FirstCycleMonth, @TargetCycleMonth) + 1
    END AS BillingCycleId;

使用示例数据时,结果是:

BillingCycleId
--------------
5

如果业务能够保证 X = 1,SQL 可以简化为:

SELECT
    CASE
        WHEN @TargetDate < @FirstBillingDate THEN NULL
        ELSE DATEDIFF(
            month,
            DATEFROMPARTS(
                YEAR(@FirstBillingDate),
                MONTH(@FirstBillingDate),
                1
            ),
            DATEFROMPARTS(
                YEAR(@TargetDate),
                MONTH(@TargetDate),
                1
            )
        ) + 1
    END AS BillingCycleId;

注意事项

明确 Y 的含义

这套算法假设 Y 是第一个周期的实际开始日期,并且该周期的 ID 为 1。如果 Y 只是首次出账日期,而周期此前已经开始,就需要先确定实际的周期开始月份。

目标日期早于第一个周期

早于 Y 的日期通常不应得到有效 ID。可以返回 NULL、抛出异常,也可以根据业务规则返回 0 或负数。

每月第 29~31 天的规则

由于各月天数不同,需要提前确定业务规则。常见做法是:

实际开始日 = min(X, 当月最后一天)

例如 X = 31:

2026-01-31
2026-02-28
2026-03-31
2026-04-30

如果业务规则是跳过没有第 X 天的月份,就需要使用另一套算法,不能直接套用上述月末截断逻辑。

使用左闭右开的日期区间

如果数据中包含时间,建议将周期表示为:

[startDate, nextStartDate)

这个区间包含 startDate,但不包含下一个周期的开始时间。这样既能避免使用 23:59:59.999 作为结束时间引起的精度问题,也无需单独保存 EndDate。

备注:内容仅供参考。