如何设计支持 Order、Return、Refund 和 Exchange 的数据库?
结论
不要把 Return、Refund 和 Exchange 都设计成一张”新订单”,也不要通过修改原始 OrderItem 来表示这些业务。
更合适的做法是分别记录不同的业务事实:
Order/OrderItem:记录客户最初购买的商品。Return/ReturnItem:记录客户实际退回的商品和退货原因。Refund:记录退还给客户的资金。退款可能分多次完成,也可能经过不同的支付渠道。Exchange:换货通常不是单独的库存动作,而是”退回旧商品 + 创建新订单或替换出库单”。Invoice:记录应收凭证。退货后通常需要开具CreditNote(红字发票、贷项通知单),换出的商品则可能需要新发票。
后续记录都应关联原始 OrderItem。原始订单和订单明细一旦确认,原则上就不应再覆盖数量、价格等字段来表示退换货,否则会破坏审计记录。
推荐的数据关系
核心关系可以表示为:
Order
└── OrderItem
├── ReturnItem ── Return
│ └── RefundAllocation ── Refund
└── ExchangeItem ── Exchange
└── ReplacementOrderItem ── OrderItem
这套关系需要支持以下情况:
- 一张订单可能发生多次退货。
- 一条订单明细可能分批退回。
- 一次退货可能包含多条订单明细。
- 一次退款可能涉及一次或多次支付交易。
- 一次换货可能退回多件旧商品,并产生多件替换商品。
- 新旧商品不一定一一对应,例如两件商品换一件价格更高的商品。
因此,不建议只在 OrderItem 上增加 IsReturned 或 ExchangeProductID。这些字段无法准确表示部分退货、多次退货、差价和多次退款。
一个可落地的关系模型
下面的示例省略了客户、地址、税率、币种换算和库存仓库等非核心字段。
CREATE TABLE Product (
ProductID BIGINT PRIMARY KEY,
Name VARCHAR(255) NOT NULL
);
CREATE TABLE CustomerOrder (
OrderID BIGINT PRIMARY KEY,
CustomerID BIGINT NOT NULL,
OrderDate TIMESTAMP NOT NULL,
Status VARCHAR(30) NOT NULL,
CurrencyCode CHAR(3) NOT NULL,
TotalAmount DECIMAL(18, 2) NOT NULL
);
CREATE TABLE OrderItem (
OrderItemID BIGINT PRIMARY KEY,
OrderID BIGINT NOT NULL,
ProductID BIGINT NOT NULL,
Quantity DECIMAL(18, 3) NOT NULL,
UnitPrice DECIMAL(18, 2) NOT NULL,
TaxAmount DECIMAL(18, 2) NOT NULL DEFAULT 0,
DiscountAmount DECIMAL(18, 2) NOT NULL DEFAULT 0,
FOREIGN KEY (OrderID) REFERENCES CustomerOrder(OrderID),
FOREIGN KEY (ProductID) REFERENCES Product(ProductID),
CHECK (Quantity > 0)
);
为 OrderItem 设置独立的 OrderItemID 很重要。只用 (OrderID, ProductID) 作为主键通常不够,因为同一商品可能因促销、批次、规格或单价不同,在同一订单中出现多行。
退货
CREATE TABLE CustomerReturn (
ReturnID BIGINT PRIMARY KEY,
OrderID BIGINT NOT NULL,
RequestedAt TIMESTAMP NOT NULL,
ReceivedAt TIMESTAMP NULL,
Status VARCHAR(30) NOT NULL,
ReasonNote VARCHAR(1000) NULL,
FOREIGN KEY (OrderID) REFERENCES CustomerOrder(OrderID)
);
CREATE TABLE ReturnItem (
ReturnItemID BIGINT PRIMARY KEY,
ReturnID BIGINT NOT NULL,
OrderItemID BIGINT NOT NULL,
Quantity DECIMAL(18, 3) NOT NULL,
ReasonCode VARCHAR(30) NOT NULL,
ConditionCode VARCHAR(30) NULL,
Resolution VARCHAR(30) NOT NULL,
RefundAmount DECIMAL(18, 2) NULL,
FOREIGN KEY (ReturnID) REFERENCES CustomerReturn(ReturnID),
FOREIGN KEY (OrderItemID) REFERENCES OrderItem(OrderItemID),
CHECK (Quantity > 0),
CHECK (RefundAmount IS NULL OR RefundAmount >= 0)
);
Resolution 可以使用以下业务值:
REFUND
EXCHANGE
STORE_CREDIT
REPAIR
REJECTED
至于使用枚举、检查约束还是状态字典表,应根据数据库类型和状态的变更频率决定。
CustomerReturn 表示一次退货申请或一张退货单,ReturnItem 则记录具体退回的原订单明细和数量。不要只在退货单中保存 ProductID,因为同一商品可能在不同订单中以不同价格售出。
退款与支付
退货和退款是两件事。商品可能已经收到,但退款还没有执行;系统也可能在无需退货的情况下发放部分退款,例如价格补偿。
CREATE TABLE Payment (
PaymentID BIGINT PRIMARY KEY,
OrderID BIGINT NOT NULL,
Provider VARCHAR(50) NOT NULL,
ProviderRef VARCHAR(255) NULL,
Amount DECIMAL(18, 2) NOT NULL,
CurrencyCode CHAR(3) NOT NULL,
Status VARCHAR(30) NOT NULL,
PaidAt TIMESTAMP NULL,
FOREIGN KEY (OrderID) REFERENCES CustomerOrder(OrderID),
CHECK (Amount > 0)
);
CREATE TABLE Refund (
RefundID BIGINT PRIMARY KEY,
PaymentID BIGINT NOT NULL,
ReturnID BIGINT NULL,
Amount DECIMAL(18, 2) NOT NULL,
CurrencyCode CHAR(3) NOT NULL,
Status VARCHAR(30) NOT NULL,
ProviderRef VARCHAR(255) NULL,
CreatedAt TIMESTAMP NOT NULL,
CompletedAt TIMESTAMP NULL,
FOREIGN KEY (PaymentID) REFERENCES Payment(PaymentID),
FOREIGN KEY (ReturnID) REFERENCES CustomerReturn(ReturnID),
CHECK (Amount > 0)
);
CREATE TABLE RefundAllocation (
RefundID BIGINT NOT NULL,
ReturnItemID BIGINT NOT NULL,
Amount DECIMAL(18, 2) NOT NULL,
PRIMARY KEY (RefundID, ReturnItemID),
FOREIGN KEY (RefundID) REFERENCES Refund(RefundID),
FOREIGN KEY (ReturnItemID) REFERENCES ReturnItem(ReturnItemID),
CHECK (Amount >= 0)
);
并非所有系统都需要 RefundAllocation。如果业务只要求退款关联整张退货单,可以省略该表。如果需要准确说明每一笔金额退给了哪件商品,就应保留。
退款状态通常至少包括:
PENDING
PROCESSING
SUCCEEDED
FAILED
CANCELLED
只有支付渠道确认成功后,退款才能视为完成。创建退款记录并不代表资金已经退回。
换货如何建模
一次换货通常包含三个动作:
- 接收原商品。
- 为替换商品创建新的履约或销售记录。
- 计算差价,并进行补收或退款。
可以增加以下换货关联表:
CREATE TABLE Exchange (
ExchangeID BIGINT PRIMARY KEY,
ReturnID BIGINT NOT NULL,
ReplacementOrderID BIGINT NOT NULL,
Status VARCHAR(30) NOT NULL,
CreatedAt TIMESTAMP NOT NULL,
FOREIGN KEY (ReturnID) REFERENCES CustomerReturn(ReturnID),
FOREIGN KEY (ReplacementOrderID)
REFERENCES CustomerOrder(OrderID)
);
CREATE TABLE ExchangeItem (
ExchangeItemID BIGINT PRIMARY KEY,
ExchangeID BIGINT NOT NULL,
ReturnItemID BIGINT NOT NULL,
ReplacementOrderItemID BIGINT NOT NULL,
FOREIGN KEY (ExchangeID) REFERENCES Exchange(ExchangeID),
FOREIGN KEY (ReturnItemID) REFERENCES ReturnItem(ReturnItemID),
FOREIGN KEY (ReplacementOrderItemID)
REFERENCES OrderItem(OrderItemID)
);
替换订单仍可使用正常的 CustomerOrder 和 OrderItem 表,再通过订单类型标明来源:
ALTER TABLE CustomerOrder
ADD COLUMN OrderType VARCHAR(30) NOT NULL DEFAULT 'SALE';
ALTER TABLE CustomerOrder
ADD COLUMN SourceOrderID BIGINT NULL;
ALTER TABLE CustomerOrder
ADD CONSTRAINT FK_Order_SourceOrder
FOREIGN KEY (SourceOrderID) REFERENCES CustomerOrder(OrderID);
例如:
OrderType = SALE
OrderType = EXCHANGE_REPLACEMENT
如果换出的商品更贵,应将差价作为替换订单上的新应收款处理;如果更便宜,则通过退款或门店余额返还差额。不要直接覆盖旧商品或新商品的价格。
是否需要 ExchangeItem,取决于业务要求。如果只需知道整张替换订单来自哪次换货,Exchange 已经足够。如果必须追踪每件旧商品具体换成了哪件新商品,则需要保留 ExchangeItem。
新 Invoice 是否是常见做法
重新打印或生成一张单据很常见,但界面上显示的”新 Invoice”,在会计意义上不一定都是普通销售发票。
常见的处理方式包括:
- 保留原始销售发票,不做修改。
- 针对退货部分开具
CreditNote、红字发票或其他冲销凭证。 - 为换出的新商品开具新的销售发票。
- 使用
OriginalInvoiceID或单独的关联表连接新旧凭证。 - 通过新收款或退款结清差价。
具体应使用红字发票、贷项通知单,还是作废后重开,取决于所在国家或地区的税务规则,不能只由数据库设计决定。
示例结构如下:
CREATE TABLE Invoice (
InvoiceID BIGINT PRIMARY KEY,
OrderID BIGINT NULL,
InvoiceType VARCHAR(30) NOT NULL,
OriginalInvoiceID BIGINT NULL,
IssuedAt TIMESTAMP NOT NULL,
CurrencyCode CHAR(3) NOT NULL,
TotalAmount DECIMAL(18, 2) NOT NULL,
Status VARCHAR(30) NOT NULL,
FOREIGN KEY (OrderID) REFERENCES CustomerOrder(OrderID),
FOREIGN KEY (OriginalInvoiceID) REFERENCES Invoice(InvoiceID)
);
CREATE TABLE InvoiceLine (
InvoiceLineID BIGINT PRIMARY KEY,
InvoiceID BIGINT NOT NULL,
OrderItemID BIGINT NULL,
ReturnItemID BIGINT NULL,
Description VARCHAR(500) NOT NULL,
Quantity DECIMAL(18, 3) NOT NULL,
UnitAmount DECIMAL(18, 2) NOT NULL,
TaxAmount DECIMAL(18, 2) NOT NULL DEFAULT 0,
FOREIGN KEY (InvoiceID) REFERENCES Invoice(InvoiceID),
FOREIGN KEY (OrderItemID) REFERENCES OrderItem(OrderItemID),
FOREIGN KEY (ReturnItemID) REFERENCES ReturnItem(ReturnItemID)
);
InvoiceType 可以是:
SALE
CREDIT_NOTE
REPLACEMENT
例如,客户把原发票中的一件商品换成另一件商品时,可以产生:
原始 Invoice
├── CreditNote:冲销被退回商品的金额
└── Replacement Invoice:记录换出商品及应付金额
如果业务允许一张凭证关联多张原始发票,单个 OriginalInvoiceID 就无法满足要求,此时应使用多对多关联表:
CREATE TABLE InvoiceRelation (
SourceInvoiceID BIGINT NOT NULL,
RelatedInvoiceID BIGINT NOT NULL,
RelationType VARCHAR(30) NOT NULL,
PRIMARY KEY (SourceInvoiceID, RelatedInvoiceID, RelationType),
FOREIGN KEY (SourceInvoiceID) REFERENCES Invoice(InvoiceID),
FOREIGN KEY (RelatedInvoiceID) REFERENCES Invoice(InvoiceID)
);
必须实施的业务校验
外键只能保证引用关系存在,无法保证退换货数据符合业务规则。以下约束还需要在事务或服务层实现:
- 同一
OrderItem的累计退货数量不能超过已交付数量。 - 已取消或未支付的商品不能通过正常退货流程重复退款。
- 累计成功退款金额不能超过可退金额。
- 退货数量、退款金额和库存入库数量不能共用同一字段。
- 退款必须使用幂等键,避免支付接口重试造成重复退款。
- 换货商品应保留当时的价格、税费和折扣快照。
- 只有通过质检的退货才能重新进入可销售库存。
- 所有状态变化都应记录时间、操作人和原因,重要财务记录不应物理删除。
校验累计退货数量时,逻辑可以写成:
SELECT
oi.OrderItemID,
oi.Quantity AS PurchasedQuantity,
COALESCE(SUM(
CASE
WHEN r.Status IN ('APPROVED', 'RECEIVED', 'COMPLETED')
THEN ri.Quantity
ELSE 0
END
), 0) AS ReturnedQuantity
FROM OrderItem oi
LEFT JOIN ReturnItem ri
ON ri.OrderItemID = oi.OrderItemID
LEFT JOIN CustomerReturn r
ON r.ReturnID = ri.ReturnID
WHERE oi.OrderItemID = :order_item_id
GROUP BY oi.OrderItemID, oi.Quantity;
实际写入时,应在同一个数据库事务中锁定相关记录,或采用其他并发控制方式。否则,两个并发退货请求可能都会在校验时看到”仍有可退数量”,最终造成超额退货。
不建议采用的设计
以下字段在简单演示中可以使用,但业务稍微复杂后就会遇到问题:
OrderItem.IsReturned
OrderItem.RefundedAmount
OrderItem.ExchangeProductID
它们很难准确表示以下情况:
- 购买 5 件,只退 2 件。
- 同一明细分两次退货。
- 一次退货分两次退款。
- 退款失败后重新发起。
- 两件旧商品换一件新商品。
- 换货产生补款或退款。
- 商品已退款,但无需退回。
- 商品已经退回,但质检后拒绝退款。
设计的重点并不是”再生成一张 Invoice”,而是分别建模订单、实物退货、资金退款、替换出库和财务凭证,再用稳定的 ID 连接每一步。这样才能处理部分退换、多次退款和差价,同时保留完整的审计链路。
备注:内容仅供参考。
