如何为多种发票结算方式设计规范化关系表
结论
不要把现金、支票和贷项通知单的所有字段都塞进一张宽表。建议按以下方式拆分:
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。 - 支票的收到日期、票面日期和实际入账日期需要分别记录。
涉及多行汇总或跨表客户一致性的规则,通常要在同一个数据库事务中锁定相关记录后再验证,也可以通过数据库触发器实现。如果只在应用界面上检查,并发操作时容易出现超额核销。
付款记录说明“收到了什么钱”,核销表说明“这笔钱结清了哪些发票”,贷项通知单说明“为什么减少应收”。将这三个概念分开后,现金、支票、部分付款、多发票付款和贷项冲减都能用清晰且可扩展的关系模型表示。
备注:内容仅供参考。
