PAYATHON 2026

如何在单个列中存储12个月的付款状态?

支付小周

结论

不要把 12 个月的状态编码成 111111111111 这样的字符串。这相当于把 12 个字段塞进一个字段,反而增加了查询、校验和修改的难度。

更合适的做法是为每个账户的每个月保存一条付款记录。生成操作提示时,先按账户汇总,再按业务优先级判断:

  1. 任意月份为 Account Hold:返回 Account Hold
  2. 否则,只要存在 Unpaid:返回需要付款的月份
  3. 否则,全部为 Paid:返回 Fully Paid

这样就不必穷举所有可能的状态组合。

推荐的表结构

月份本身是数据,不适合作为列名。可以采用下面的表结构:

CREATE TABLE dbo.Payment
(
    AccountId    INT          NOT NULL,
    PaymentMonth DATE         NOT NULL,
    Status       VARCHAR(20)  NOT NULL,

    CONSTRAINT PK_Payment
        PRIMARY KEY (AccountId, PaymentMonth),

    CONSTRAINT CK_Payment_Status
        CHECK (Status IN ('Paid', 'Unpaid', 'Account Hold')),

    CONSTRAINT CK_Payment_Month
        CHECK (PaymentMonth = DATEFROMPARTS(
            YEAR(PaymentMonth),
            MONTH(PaymentMonth),
            1
        ))
);

每条记录对应一个账户在某个月的付款状态:

INSERT dbo.Payment (AccountId, PaymentMonth, Status)
VALUES
    (1, '2026-01-01', 'Paid'),
    (1, '2026-02-01', 'Paid'),
    (2, '2026-01-01', 'Unpaid'),
    (2, '2026-02-01', 'Paid'),
    (3, '2026-12-01', 'Account Hold');

实际系统通常还需要记录年份。将月份统一保存为当月第一天,例如使用 PaymentMonth,比单独保存月份名称更方便筛选、排序和跨年度查询。

从规范化数据生成操作

下面的示例使用 STRING_AGG 汇总所有未付款月份。该函数适用于 SQL Server 2017 及更高版本:

WITH PaymentSummary AS
(
    SELECT
        AccountId,
        MAX(CASE
                WHEN Status = 'Account Hold' THEN 1
                ELSE 0
            END) AS HasAccountHold,
        SUM(CASE
                WHEN Status = 'Unpaid' THEN 1
                ELSE 0
            END) AS UnpaidCount,
        STRING_AGG(
            CASE
                WHEN Status = 'Unpaid'
                THEN DATENAME(MONTH, PaymentMonth)
            END,
            ', '
        ) WITHIN GROUP (ORDER BY PaymentMonth) AS UnpaidMonths
    FROM dbo.Payment
    WHERE PaymentMonth >= '2026-01-01'
      AND PaymentMonth <  '2027-01-01'
    GROUP BY AccountId
)
SELECT
    AccountId,
    CASE
        WHEN HasAccountHold = 1
            THEN 'Account Hold'
        WHEN UnpaidCount > 0
            THEN CONCAT('Need to pay ', UnpaidMonths)
        ELSE 'Fully Paid'
    END AS Action
FROM PaymentSummary;

STRING_AGG 会忽略表达式返回的 NULL,所以这里只会拼接状态为 Unpaid 的月份。

DATENAME 返回的月份名称受 SQL Server 会话语言设置影响。如果结果必须固定为英文或中文,建议使用月份字典表,不要依赖服务器的语言设置。

暂时不能修改现有表时

在咨询项目中,往往无法立即调整现有表结构。此时可以在查询中使用 CROSS APPLY,临时把 12 个列转换成 12 行,再沿用相同的汇总逻辑。

原表需要有一个能唯一标识记录的主键。下面用 PaymentId 表示这个主键:

WITH MonthlyStatus AS
(
    SELECT
        p.PaymentId,
        m.MonthNumber,
        m.MonthName,
        m.Status
    FROM dbo.Payments AS p
    CROSS APPLY
    (
        VALUES
            (1,  'January',   p.January),
            (2,  'February',  p.February),
            (3,  'March',     p.March),
            (4,  'April',     p.April),
            (5,  'May',       p.May),
            (6,  'June',      p.June),
            (7,  'July',      p.July),
            (8,  'August',    p.August),
            (9,  'September', p.September),
            (10, 'October',   p.October),
            (11, 'November',  p.November),
            (12, 'December',  p.December)
    ) AS m (MonthNumber, MonthName, Status)
),
PaymentSummary AS
(
    SELECT
        PaymentId,
        MAX(CASE
                WHEN Status = 'Account Hold' THEN 1
                ELSE 0
            END) AS HasAccountHold,
        SUM(CASE
                WHEN Status = 'Unpaid' THEN 1
                ELSE 0
            END) AS UnpaidCount,
        STRING_AGG(
            CASE WHEN Status = 'Unpaid' THEN MonthName END,
            ', '
        ) WITHIN GROUP (ORDER BY MonthNumber) AS UnpaidMonths
    FROM MonthlyStatus
    GROUP BY PaymentId
)
SELECT
    PaymentId,
    CASE
        WHEN HasAccountHold = 1
            THEN 'Account Hold'
        WHEN UnpaidCount > 0
            THEN CONCAT('Need to pay ', UnpaidMonths)
        ELSE 'Fully Paid'
    END AS Action
FROM PaymentSummary;

题目中的三条数据会分别得到以下结果:

Fully Paid
Need to pay January
Account Hold

如果有多个月份的状态是 Unpaid,结果会类似:

Need to pay January, March, November

为什么不建议使用状态编码字符串

111111111111 这类编码会带来不少实际问题:

  • 使用者必须记住每个字符的位置及其数字含义。
  • 更新某个月的状态时,需要修改字符串中的特定字符。
  • 很难通过约束阻止非法长度或非法状态。
  • 查询某个月是否欠费时,需要截取字符串。
  • 增加新状态、扩展月份范围或处理跨年度数据都很麻烦。
  • 代码无法直接表达对应的业务含义。

位掩码可以压缩多个二元标记,但这里有两种以上的状态,而且还要输出具体月份,所以也不适合作为主要的业务存储格式。

还需要明确的业务规则

上述代码假定 Account Hold 的优先级高于欠费。实际使用前,还需要确认以下规则:

  • Account Hold 是否属于月度付款状态。如果它表示整个账户被冻结,更适合放在账户表的 AccountStatus 字段中。
  • 付款记录少于 12 条时,应当视为未付款、数据缺失,还是直接忽略。
  • NULL 表示状态未知、尚未录入,还是等同于 Unpaid。
  • 是否允许同一账户在同一个月出现多条付款状态。
  • 查询结果是否需要区分年份。

处理这类问题时,不必穷举所有状态组合。先把数据整理成每月一行,再按照明确的业务优先级进行条件聚合即可。

备注:内容仅供参考。