PAYATHON 2026

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 保存”这次交易如何付款”。按这个边界拆分后,打印收据、统计销售、调整商品价格,以及以后增加退款或混合付款都会更容易。

备注:内容仅供参考。