simple EPOS 收据的 relational database 设计建议
结论
现有的保存方式不合理。Product 表应当存放商品目录,而不是每次销售产生的商品记录。订单、订单明细和付款信息也不能只通过 orderID 混在 Product、Payment 两张表中。
至少需要拆分出以下核心表:
users:登录用户或收银员products:商品目录orders:每笔订单的整体信息order_items:订单中的商品明细payments:付款记录
各表之间的关系如下:
users 1 ─── N orders
orders 1 ─── N order_items
products 1 ─── N order_items
orders 1 ─── N payments
如果折扣规则较复杂,可以再增加 discounts 和订单折扣关联表。对于简单的 EPOS 系统,也可以先把折扣金额直接保存在订单或订单明细中。
为什么不能把销售数据写入 Product 表
products 表描述当前有哪些商品,包括商品名称、条码、当前售价和是否可售。order_items 表记录的是某次交易具体卖出了什么。
两类数据的生命周期并不相同。如果每次下单都向 Product 表添加记录,会产生很多问题:
- 同一种商品会被重复保存;
- 商品改名或调价后,历史收据可能随之变化;
- 无法准确表示一张订单购买了多个商品;
- 数量、成交价和折扣等交易数据无处存放;
- 删除商品可能破坏历史订单;
- 退款、分次付款和销售统计很难实现。
订单明细必须保留交易发生时的价格和商品名称快照。商品目录以后即使发生变化,已经打印的收据也不能跟着改变。
推荐的数据表设计
下面以支持标准 SQL 约束的关系型数据库为例。不同数据库在自增列、时间类型和布尔类型的语法上可能略有差异。
用户表
CREATE TABLE users (
user_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(150),
active BOOLEAN NOT NULL DEFAULT TRUE
);
密码只能以安全哈希的形式保存,不能存储明文。
商品表
CREATE TABLE products (
product_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku VARCHAR(64) NOT NULL UNIQUE,
product_name VARCHAR(200) NOT NULL,
current_price DECIMAL(12, 2) NOT NULL CHECK (current_price >= 0),
active BOOLEAN NOT NULL DEFAULT TRUE
);
金额字段应使用 DECIMAL,不要使用 FLOAT 或 DOUBLE,以免产生舍入误差。
订单表
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_number VARCHAR(50) NOT NULL UNIQUE,
cashier_user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
subtotal DECIMAL(12, 2) NOT NULL DEFAULT 0,
discount_amount DECIMAL(12, 2) NOT NULL DEFAULT 0,
total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
paid_at TIMESTAMP NULL,
CONSTRAINT fk_orders_cashier
FOREIGN KEY (cashier_user_id) REFERENCES users(user_id),
CONSTRAINT chk_order_status
CHECK (status IN ('OPEN', 'PAID', 'CANCELLED', 'REFUNDED')),
CONSTRAINT chk_order_amounts
CHECK (
subtotal >= 0
AND discount_amount >= 0
AND total_amount >= 0
AND discount_amount <= subtotal
)
);
order_id 是数据库内部使用的主键,order_number 是打印在收据上的业务编号。最好将两者分开,因为收据编号可能包含门店、日期或终端编号等格式要求。
订单明细表
CREATE TABLE order_items (
order_item_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT,
product_name_snapshot VARCHAR(200) NOT NULL,
unit_price DECIMAL(12, 2) NOT NULL,
quantity DECIMAL(12, 3) NOT NULL,
discount_amount DECIMAL(12, 2) NOT NULL DEFAULT 0,
line_total DECIMAL(12, 2) NOT NULL,
CONSTRAINT fk_order_items_order
FOREIGN KEY (order_id) REFERENCES orders(order_id),
CONSTRAINT fk_order_items_product
FOREIGN KEY (product_id) REFERENCES products(product_id),
CONSTRAINT chk_order_item_values
CHECK (
unit_price >= 0
AND quantity > 0
AND discount_amount >= 0
AND line_total >= 0
)
);
这里有意同时保存 product_id 和 product_name_snapshot:
product_id用于关联商品和销售统计;product_name_snapshot用于保留成交时的商品名称;unit_price必须记录成交时的单价,打印历史收据时不能重新读取products.current_price。
如果系统允许删除商品,可以将其设置为 active = FALSE。对于已经出现在历史订单中的商品,通常不要物理删除。如果业务确实要求删除,可以根据数据库能力考虑把外键设置为 ON DELETE SET NULL。
付款表
CREATE TABLE payments (
payment_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
payment_method VARCHAR(20) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
payment_status VARCHAR(20) NOT NULL,
transaction_ref VARCHAR(100),
paid_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_payments_order
FOREIGN KEY (order_id) REFERENCES orders(order_id),
CONSTRAINT chk_payment_method
CHECK (payment_method IN ('CASH', 'CARD')),
CONSTRAINT chk_payment_status
CHECK (payment_status IN ('PENDING', 'COMPLETED', 'FAILED', 'REFUNDED')),
CONSTRAINT chk_payment_amount
CHECK (amount > 0)
);
即使当前界面只允许在现金和银行卡之间二选一,也建议保留一张订单对应多条付款记录的结构。以后增加混合付款、付款重试、退款或礼品卡时,就不必重新设计数据库。
如果能确定系统永远只允许一次付款,可以对 payments.order_id 添加 UNIQUE 约束。不过,EPOS 系统通常不适合过早限制为一对一关系。
正确的保存流程
开始录入商品时,可以先在数据库中创建一条状态为 OPEN 的订单,也可以将订单暂存在 JavaFX 内存中,等用户点击 PAY 后再统一写入。两种方式都可以:
- 如果需要支持挂单、断电恢复或多终端协作,应提前创建
OPEN订单; - 如果业务很简单,而且不需要恢复未付款内容,可以等用户点击
PAY后再统一持久化。
订单、明细和付款记录必须在同一个数据库事务中写入:
BEGIN;
INSERT INTO orders (
order_number,
cashier_user_id,
status,
subtotal,
discount_amount,
total_amount
) VALUES (
'POS-20260901-000123',
7,
'OPEN',
30.00,
5.00,
25.00
);
-- 获取刚创建的 order_id,具体语法取决于所用数据库。
INSERT INTO order_items (
order_id,
product_id,
product_name_snapshot,
unit_price,
quantity,
discount_amount,
line_total
) VALUES (
:order_id,
42,
'Coffee',
10.00,
3,
5.00,
25.00
);
INSERT INTO payments (
order_id,
payment_method,
amount,
payment_status,
transaction_ref
) VALUES (
:order_id,
'CARD',
25.00,
'COMPLETED',
:transaction_ref
);
UPDATE orders
SET status = 'PAID',
paid_at = CURRENT_TIMESTAMP
WHERE order_id = :order_id;
COMMIT;
任何一步失败,都应执行:
ROLLBACK;
否则可能出现订单已经保存但明细缺失,或者付款成功后订单仍显示未支付的情况。
在 Java 中,应关闭自动提交并统一提交事务:
connection.setAutoCommit(false);
try {
long orderId = insertOrder(connection, order);
insertOrderItems(connection, orderId, items);
insertPayment(connection, orderId, payment);
markOrderAsPaid(connection, orderId);
connection.commit();
} catch (SQLException e) {
connection.rollback();
throw e;
} finally {
connection.setAutoCommit(true);
}
所有 SQL 参数都应通过 PreparedStatement 传入,不要直接拼接用户输入。
金额和折扣如何处理
金额计算需要采用明确且统一的规则。例如:
line_total = unit_price × quantity - line_discount
subtotal = 所有明细折扣前金额之和
total_amount = subtotal - order_discount
还需要提前确定以下规则:
- 折扣作用于单品还是整张订单;
- 百分比折扣如何舍入;
- 税额在折扣前还是折扣后计算;
- 退款使用原始成交价还是当前价格。
如果只支持整单折扣,使用 orders.discount_amount 就足够了。如果支持单品折扣,则使用 order_items.discount_amount。只有在需要处理优惠券、会员折扣、促销组合和折扣审批时,才需要增加独立的折扣表,例如:
CREATE TABLE order_discounts (
order_discount_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
discount_type VARCHAR(30) NOT NULL,
description VARCHAR(200),
amount DECIMAL(12, 2) NOT NULL CHECK (amount >= 0),
FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
不要只保存折扣百分比。历史订单至少还要记录最终实际减免的金额。
还可能需要哪些表
是否增加下面这些表,要根据实际需求决定。简单版本不必一次全部建立:
customers:会员、客户信息和积分;stores:多门店;registers:收银终端;cash_sessions:开班、交班和现金差额;inventory_transactions:入库、销售扣减、退货和盘点;refunds、refund_items:部分退款或整单退款;tax_rates:多税率或税率历史;payment_methods:需要在后台配置付款方式时使用;receipts:需要记录打印次数、收据编号或电子收据状态时使用。
库存数量也不应只在商品表中反复覆盖。如果以后需要审计库存变化,应记录每一次库存变动,再据此计算或维护当前库存。
关键注意事项
订单保存后,不应通过修改原订单明细来处理退款或更正。更稳妥的方式是创建退款记录或反向交易,保留完整的审计轨迹。
还要注意以下几点:
- 为外键列建立索引,例如
order_items.order_id和payments.order_id; - 为
order_number、sku等业务编号添加唯一约束; - 对付款成功后的重复点击进行幂等处理,避免重复生成订单或重复扣款;
- 订单状态不能只由界面按钮决定,数据库中的状态变化也需要校验;
- 收据应读取
orders和order_items中保存的交易快照,不能读取商品当前的名称和价格; - 不要保存完整银行卡号、CVV 等敏感信息,只保留支付服务返回的安全引用和必要的脱敏信息;
- 如果付款由外部终端处理,数据库事务无法真正回滚外部扣款,因此还要设计支付重试、状态查询和对账流程。
可以用一句话概括各表的边界:products 保存”卖什么”,orders 保存”发生了一次交易”,order_items 保存”这次交易卖了什么”,payments 保存”这次交易如何付款”。按这个边界拆分后,打印收据、统计销售、调整商品价格,以及以后增加退款或混合付款都会更容易。
备注:内容仅供参考。
