PAYATHON 2026

如何为多种发票结算方式设计规范化关系表

支付老李

结论

不要把现金、支票和贷项通知单的所有字段都塞进一张宽表。建议按以下方式拆分:

  • payment 保存现金和支票共有的收款信息。
  • payment_check 只保存支票特有的信息,并与 payment 建立一对一关系。
  • credit_note 保存因退货、折让等业务产生的贷项通知单。
  • 使用关联表记录付款或贷项通知单如何核销发票。
  • collector 应关联实际收款记录,而不是直接表示某张发票的结算状态。

现金和支票都属于付款方式,可以共用一个付款主表。贷项通知单则是冲减应收账款的业务凭证,不宜简单视为一种付款方式。

为什么需要核销关联表

实际业务中,一笔付款并不一定只对应一张发票。例如:

  • 一张支票可能同时支付多张发票。
  • 一张发票可能通过多次现金或支票付款结清。
  • 一张贷项通知单可能只冲减某张发票的部分金额。
  • 客户多付的金额可能暂时没有对应发票。
  • 收到付款后,财务人员可能过几天才决定核销哪些发票。

因此,付款与发票之间通常是多对多关系,需要单独建立 payment_application 表:

payment 1 ─── N payment_application N ─── 1 invoice

贷项通知单与发票之间也可以采用类似的关系:

credit_note 1 ─── N credit_note_application N ─── 1 invoice

这样可以明确区分“实际收到的金额”和“用于结清某张发票的金额”。

推荐的关系结构

核心关系如下:

customer
  ├── invoice
  ├── payment
  │     ├── payment_check
  │     └── payment_application ── invoice
  └── credit_note
        └── credit_note_application ── invoice

collector ── payment

各表的职责如下:

表职责
customer客户资料
invoice发票及应收金额
collector收款人员
payment收款主记录,包括现金和支票
payment_check支票专有属性
payment_application付款核销发票的金额
credit_note贷项通知单
credit_note_application贷项通知单冲减发票的金额

PostgreSQL 建表示例

下面的示例使用 PostgreSQL 语法。使用其他数据库时,可能需要调整 GENERATED ALWAYS AS IDENTITY、约束和索引的写法。

客户、发票和收款人员

CREATE TABLE customer (
    customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_name varchar(200) NOT NULL
);

CREATE TABLE collector (
    collector_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    collector_name varchar(200) NOT NULL
);

CREATE TABLE invoice (
    invoice_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL
        REFERENCES customer(customer_id),
    invoice_number varchar(50) NOT NULL,
    invoice_date date NOT NULL,
    total_amount numeric(18, 2) NOT NULL
        CHECK (total_amount >= 0),

    UNIQUE (customer_id, invoice_number)
);

不要在 invoice 中保存需要人工同步的 is_paid 字段。发票是否结清,应根据发票金额、付款核销金额和贷项核销金额计算。如果出于性能考虑需要缓存状态,也必须明确由哪个流程负责更新。

付款主表

CREATE TABLE payment (
    payment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL
        REFERENCES customer(customer_id),
    collector_id bigint
        REFERENCES collector(collector_id),

    payment_method varchar(20) NOT NULL
        CHECK (payment_method IN ('CASH', 'CHECK')),

    received_at timestamp NOT NULL,
    amount numeric(18, 2) NOT NULL
        CHECK (amount > 0),

    reference varchar(100),
    notes text
);

payment.amount 表示实际收到的整笔款项,不是分配给某张发票的核销金额。

如果收款人员是必填信息,可以将 collector_id 设置为 NOT NULL。如果银行转账、线上支付等付款没有现场收款人员,允许该字段为空通常更合适。

支票专有信息

CREATE TABLE payment_check (
    payment_id bigint PRIMARY KEY
        REFERENCES payment(payment_id)
        ON DELETE CASCADE,

    check_number varchar(100) NOT NULL,
    post_date date NOT NULL,
    bank_name varchar(200),
    account_reference varchar(100),

    UNIQUE (bank_name, check_number)
);

这里将 payment_id 同时用作主键和外键,表示每笔付款最多只能有一条支票详情。

check_value 通常不需要在 payment_check 中重复保存,因为它和 payment.amount 表示的是同一个金额。重复存储容易造成更新异常,例如付款金额已经改为 1,000,支票金额却仍是 900。

如果业务确实允许一笔付款包含多张支票,这个关系就不再是一对一。此时可以改为:

CREATE TABLE payment_check (
    payment_check_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_id bigint NOT NULL
        REFERENCES payment(payment_id)
        ON DELETE CASCADE,

    check_number varchar(100) NOT NULL,
    post_date date NOT NULL,
    check_amount numeric(18, 2) NOT NULL
        CHECK (check_amount > 0),
    bank_name varchar(200),

    UNIQUE (bank_name, check_number)
);

同时还要在事务或延迟约束中保证:

SUM(payment_check.check_amount) = payment.amount

普通 CHECK 约束无法计算跨多行的总和,通常需要通过业务事务、触发器或结算过程进行验证。

付款与发票的核销关系

CREATE TABLE payment_application (
    payment_application_id bigint
        GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    payment_id bigint NOT NULL
        REFERENCES payment(payment_id),
    invoice_id bigint NOT NULL
        REFERENCES invoice(invoice_id),

    applied_amount numeric(18, 2) NOT NULL
        CHECK (applied_amount > 0),
    applied_at timestamp NOT NULL,

    UNIQUE (payment_id, invoice_id)
);

例如,客户用一张 1,000 元的支票支付两张发票,可以这样记录:

INSERT INTO payment (
    customer_id,
    collector_id,
    payment_method,
    received_at,
    amount
) VALUES (
    10,
    3,
    'CHECK',
    CURRENT_TIMESTAMP,
    1000.00
);

-- 假设生成的 payment_id 为 501

INSERT INTO payment_check (
    payment_id,
    check_number,
    post_date,
    bank_name
) VALUES (
    501,
    'CHK-2026-0088',
    DATE '2026-09-15',
    'Example Bank'
);

INSERT INTO payment_application (
    payment_id,
    invoice_id,
    applied_amount,
    applied_at
) VALUES
    (501, 101, 600.00, CURRENT_TIMESTAMP),
    (501, 102, 400.00, CURRENT_TIMESTAMP);

数据库或业务层还应检查:

付款的累计核销金额 <= payment.amount

同时要确认付款与被核销的发票属于同一个客户。

贷项通知单的设计

贷项通知单应作为独立的业务凭证保存:

CREATE TABLE credit_note (
    credit_note_id bigint
        GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    customer_id bigint NOT NULL
        REFERENCES customer(customer_id),

    credit_note_number varchar(50) NOT NULL,
    issued_at timestamp NOT NULL,
    amount numeric(18, 2) NOT NULL
        CHECK (amount > 0),

    reason_code varchar(30) NOT NULL,
    notes text,

    UNIQUE (customer_id, credit_note_number)
);

贷项通知单冲减发票时,通过关联表记录具体金额:

CREATE TABLE credit_note_application (
    credit_note_application_id bigint
        GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    credit_note_id bigint NOT NULL
        REFERENCES credit_note(credit_note_id),
    invoice_id bigint NOT NULL
        REFERENCES invoice(invoice_id),

    applied_amount numeric(18, 2) NOT NULL
        CHECK (applied_amount > 0),
    applied_at timestamp NOT NULL,

    UNIQUE (credit_note_id, invoice_id)
);

如果业务明确规定,一张退货贷项通知单只能对应原始发票,可以在 credit_note 中增加 source_invoice_id。即便记录了来源发票,仍建议区分以下两个概念:

  • 贷项通知单由哪张发票产生。
  • 贷项余额最终用于冲减哪张发票。

在简单场景中,两者可能相同。允许客户将余额转用于其他发票时,两者就可能不同。

多付款项不一定是贷项通知单

客户多付的钱通常不会立即形成贷项通知单,而是成为尚未核销的客户余额。

例如:

payment.amount = 1,200
SUM(payment_application.applied_amount) = 1,000
unapplied_amount = 200

这 200 元可以:

  • 保留为客户预付款;
  • 用于核销客户之后产生的发票;
  • 退还给客户;
  • 按企业的会计政策转成其他凭证。

贷项通知单通常由退货、价格折让等业务产生。具体会计处理受当地法规和企业会计政策影响,应由财务人员确认。数据库不应强制把所有客户余额都转换成 credit_note。

查询发票余额

发票的未结金额可以根据核销记录计算:

SELECT
    i.invoice_id,
    i.invoice_number,
    i.total_amount,
    COALESCE(p.paid_amount, 0) AS paid_amount,
    COALESCE(c.credited_amount, 0) AS credited_amount,
    i.total_amount
        - COALESCE(p.paid_amount, 0)
        - COALESCE(c.credited_amount, 0) AS outstanding_amount
FROM invoice AS i
LEFT JOIN (
    SELECT
        invoice_id,
        SUM(applied_amount) AS paid_amount
    FROM payment_application
    GROUP BY invoice_id
) AS p
    ON p.invoice_id = i.invoice_id
LEFT JOIN (
    SELECT
        invoice_id,
        SUM(applied_amount) AS credited_amount
    FROM credit_note_application
    GROUP BY invoice_id
) AS c
    ON c.invoice_id = i.invoice_id;

如果系统还支持退款、付款撤销、贷项通知单作废或汇兑差额,应将这些动作设计为可追踪的反向交易,不能直接修改原金额或删除历史记录。

必须补充的业务约束

只靠外键无法保证应收数据正确,至少还要验证以下规则:

  • 支票付款必须有对应的 payment_check。
  • 现金付款不能有 payment_check。
  • 一笔付款只能核销同一客户的发票。
  • 一张贷项通知单只能核销同一客户的发票。
  • 付款的累计核销金额不能超过付款金额。
  • 贷项通知单的累计核销金额不能超过贷项金额。
  • 发票的累计冲减金额能否超过发票金额,需要由业务明确。
  • 支票号码在哪个范围内唯一,应根据业务确定。它通常不是全局唯一,可能需要结合银行、账户或客户建立唯一约束。
  • 金额应使用 numeric 或 decimal,不要使用 float。
  • 支票的收到日期、票面日期和实际入账日期需要分别记录。

涉及多行汇总或跨表客户一致性的规则,通常要在同一个数据库事务中锁定相关记录后再验证,也可以通过数据库触发器实现。如果只在应用界面上检查,并发操作时容易出现超额核销。

付款记录说明“收到了什么钱”,核销表说明“这笔钱结清了哪些发票”,贷项通知单说明“为什么减少应收”。将这三个概念分开后,现金、支票、部分付款、多发票付款和贷项冲减都能用清晰且可扩展的关系模型表示。

备注:内容仅供参考。