如何将不同 account types 关联到 Payments 和 Invoices?
结论
建议建立统一的主体表,例如 party,并将 agency 和 customer 作为它的子类型。invoice 和 payment 统一引用 party.id:
invoice.bill_to_party_id表示被开票方。payment.payer_party_id表示付款方。agency.party_id、customer.party_id采用“主键同时也是外键”的一对一关系。- Payment 与 Invoice 的核销关系单独保存在
payment_allocation中。
这个表不适合命名为 payee。在会计语境中,payee 通常是收款方,而这里的主体既可能付款,也可能接收账单。party、billing_party 或 account 都更准确。
如果 party 只保存主键,查询名称时确实需要 JOIN 子表。这并不违反规范化原则,但也说明名称、状态等共有属性更适合放在 party 中。多数列表查询因此只需访问一个表,读取类型特有的信息时再 JOIN 子表。
推荐的数据模型
party
├── agency
└── customer
invoice ── bill_to_party_id ──> party
payment ── payer_party_id ──> party
payment ── payment_allocation ── invoice
建模时需要分清以下业务概念:
- Customer:被保险人、投保人或直接购买者。
- Agency:销售渠道或代收机构。
- Billing party:实际承担账单的主体。
- Payer:实际付款的主体。
- Payment allocation:记录某笔付款核销了哪些 Invoice。
保单由 Agency 销售,不代表 Invoice 一定开给 Agency。销售渠道和结算主体应分别建模。
PostgreSQL 示例
下面使用 PostgreSQL 语法。其他关系型数据库也可以采用相同的结构,但自增列、约束和触发器的语法可能不同。
CREATE TYPE party_type AS ENUM ('agency', 'customer');
CREATE TABLE party (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
party_type party_type NOT NULL,
display_name text NOT NULL,
billing_status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE agency (
party_id bigint PRIMARY KEY
REFERENCES party(id) ON DELETE RESTRICT,
agency_code text NOT NULL UNIQUE,
license_number text
);
CREATE TABLE customer (
party_id bigint PRIMARY KEY
REFERENCES party(id) ON DELETE RESTRICT,
customer_number text NOT NULL UNIQUE,
first_name text,
last_name text
);
CREATE TABLE invoice (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
bill_to_party_id bigint NOT NULL
REFERENCES party(id) ON DELETE RESTRICT,
invoice_number text NOT NULL UNIQUE,
issued_at date NOT NULL,
due_at date,
total_amount numeric(18, 2) NOT NULL
CHECK (total_amount >= 0),
currency_code char(3) NOT NULL,
status text NOT NULL
);
CREATE TABLE payment (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payer_party_id bigint NOT NULL
REFERENCES party(id) ON DELETE RESTRICT,
received_at timestamptz NOT NULL,
amount numeric(18, 2) NOT NULL
CHECK (amount > 0),
currency_code char(3) NOT NULL,
external_reference text
);
CREATE TABLE payment_allocation (
payment_id bigint NOT NULL
REFERENCES payment(id) ON DELETE CASCADE,
invoice_id bigint NOT NULL
REFERENCES invoice(id) ON DELETE RESTRICT,
allocated_amount numeric(18, 2) NOT NULL
CHECK (allocated_amount > 0),
PRIMARY KEY (payment_id, invoice_id)
);
CREATE INDEX invoice_bill_to_party_idx
ON invoice (bill_to_party_id);
CREATE INDEX payment_payer_party_idx
ON payment (payer_party_id);
CREATE INDEX payment_allocation_invoice_idx
ON payment_allocation (invoice_id);
现实中常见以下情况,因此 payment_allocation 很有必要:
- 一笔 Payment 核销多张 Invoice。
- 一张 Invoice 由多笔 Payment 分期支付。
- Payment 已经收到,但暂时没有指定对应的 Invoice。
- Payment 金额超过 Invoice,需要保留未分配余额。
除非业务能够明确保证始终是“一笔 Payment 对应一张 Invoice”,否则不建议直接在 payment 中保存 invoice_id。
创建 Agency 或 Customer
创建主体及其子类型记录时,应在同一个事务内完成:
BEGIN;
WITH new_party AS (
INSERT INTO party (party_type, display_name)
VALUES ('agency', 'Example Insurance Agency')
RETURNING id
)
INSERT INTO agency (party_id, agency_code, license_number)
SELECT id, 'AG-10027', 'LIC-88991'
FROM new_party;
COMMIT;
创建 Customer 的方式相同:
BEGIN;
WITH new_party AS (
INSERT INTO party (party_type, display_name)
VALUES ('customer', '张三')
RETURNING id
)
INSERT INTO customer (
party_id,
customer_number,
first_name,
last_name
)
SELECT id, 'CU-20481', '三', '张'
FROM new_party;
COMMIT;
之后创建 Payment 时,无须扫描 agency 和 customer:
INSERT INTO payment (
payer_party_id,
received_at,
amount,
currency_code,
external_reference
)
VALUES (
12345,
CURRENT_TIMESTAMP,
1200.00,
'CNY',
'BANK-20260831-001'
);
数据库会通过 payment.payer_party_id -> party.id 外键检查付款主体是否存在。
如何列出所有可能的付款方
如果公共展示字段保存在 party 中,一次查询即可:
SELECT
id,
party_type,
display_name
FROM party
WHERE billing_status = 'active'
ORDER BY display_name;
只有查询类型特有字段时才需要 JOIN:
SELECT
p.id,
p.party_type,
p.display_name,
a.agency_code,
c.customer_number
FROM party AS p
LEFT JOIN agency AS a
ON a.party_id = p.id
LEFT JOIN customer AS c
ON c.party_id = p.id
WHERE p.billing_status = 'active'
ORDER BY p.display_name;
一条 SQL 中包含多个 JOIN,不等于应用程序执行了多次 table select。查询是否高效,主要取决于索引、过滤条件、数据量和执行计划,而不是涉及了多少张表。
如果 party 只有主键,没有 display_name 等公共信息,列表查询就只能通过 UNION ALL 或多个 JOIN 获取名称:
SELECT
p.id,
'agency' AS party_type,
a.agency_name AS display_name
FROM party AS p
JOIN agency AS a
ON a.party_id = p.id
UNION ALL
SELECT
p.id,
'customer' AS party_type,
c.customer_name AS display_name
FROM party AS p
JOIN customer AS c
ON c.party_id = p.id;
这种设计仍然符合规范化要求,但不方便频繁查询主体列表。是否应该把 display_name、账单地址、联系方式或结算状态上移到 party,应看这些字段在不同主体类型中是否具有一致的语义,而不是单纯为了减少 JOIN 而复制数据。
需要补充的完整性约束
只使用上述外键,还无法完全保证以下条件:
party_type = 'agency'的记录一定存在于agency。- 同一个
party不会同时出现在agency和customer中。 - 子表类型始终与
party.party_type一致。
“父类型必须且只能对应一个子类型”通常需要通过以下方式之一保证:
- 所有写入都通过受控的 stored procedure 完成。
- 使用数据库 trigger 检查。
- 在应用服务层执行检查,并配合事务。
- 调整键结构,让子表外键同时包含
id和类型字段。
例如,可以使用复合键加强类型一致性:
ALTER TABLE party
ADD CONSTRAINT party_id_type_unique
UNIQUE (id, party_type);
ALTER TABLE agency
ADD COLUMN party_type party_type
NOT NULL DEFAULT 'agency'
CHECK (party_type = 'agency');
ALTER TABLE agency
ADD CONSTRAINT agency_party_fk
FOREIGN KEY (party_id, party_type)
REFERENCES party (id, party_type);
customer 可以采用相同方式,并将类型限制为 'customer'。不过,要保证“每个 party 必须存在于某一个子表”,通常仍需使用事务化写入、延迟约束或 trigger。
不推荐的设计
两组独立外键
例如:
invoice.agency_id
invoice.customer_id
这种设计有两个问题:
- 必须保证两个字段中恰好有一个非 NULL。
- 每增加一种结算主体,都要修改
invoice、payment和相关查询。
虽然可以增加约束:
CHECK (
(agency_id IS NOT NULL AND customer_id IS NULL)
OR
(agency_id IS NULL AND customer_id IS NOT NULL)
)
但这种结构不易扩展,也容易让付款、退款、余额和对账逻辑出现重复。
entity_type + entity_id
例如:
payer_type = 'agency'
payer_id = 123
这是常见的 polymorphic association,但普通外键无法验证 payer_id 是否真的存在于指定表中。Agency 或 Customer 被删除或修改后,可能留下孤立记录,因此不适合核心财务数据模型。
仅使用 joining tables
分别建立 agency_invoice、customer_invoice、agency_payment 和 customer_payment,会把同一种关系拆成多套结构。除非一张 Invoice 本来就允许同时分配给多个结算主体,否则没有必要建立这些 joining tables。
更贴近会计场景的扩展
如果同一个 Customer 或 Agency 可以拥有多个结算账户,例如需要区分币种、分公司或付款条件,可以在 party 与交易之间增加 billing_account:
CREATE TABLE billing_account (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
party_id bigint NOT NULL
REFERENCES party(id) ON DELETE RESTRICT,
account_number text NOT NULL UNIQUE,
currency_code char(3) NOT NULL,
payment_terms_days integer NOT NULL DEFAULT 0
CHECK (payment_terms_days >= 0),
status text NOT NULL DEFAULT 'active'
);
然后让 invoice 和 payment 引用 billing_account.id。这种结构适用于以下情况:
- 一个主体有多个应收账户。
- 不同币种需要分别结算。
- Agency 的不同营业单位需要独立对账。
- 需要保存信用额度、账期或账户余额。
如果当前业务中每个 Agency 或 Customer 始终只有一个结算身份,直接引用 party 就够了,无须过早增加 billing_account。
注意事项
- Invoice 上的金额、币种、开票对象和地址通常应保存交易发生时的快照。不要完全依赖主体表中的当前信息,否则客户改名或修改地址后,历史 Invoice 也可能随之变化。
- Payment 的付款方不一定是 Invoice 的被开票方。如果允许 Agency 代 Customer 付款,就不要强制两者相同。
- 金额应使用
numeric/decimal,不要使用float。 - 对已经参与财务交易的主体,删除时应优先改为停用状态,不要级联删除 Invoice 或 Payment。
display_name适合搜索和列表展示,具有法律效力的名称则可以作为 Invoice 快照单独保存。- 如果 Agency 和 Customer 将来可能是同一个法律主体承担的不同角色,可以进一步拆出
party_role,不必限制一个party只能属于一种类型。
总体而言,使用 party 超类型和 agency、customer 子类型,既合适也符合规范化原则。设计重点不是消除所有 JOIN,而是把公共身份数据放在 party 中,把类型专属数据留在子表,同时分别表达开票、付款和核销这三种不同的关系。
备注:内容仅供参考。
