PAYATHON 2026

Java Restaurant Billing System 如何统计每日结果

数据小陈

结论

不要在 Java 内存中累积当天的全部交易。可以按下面的方式处理:

  1. 每次结账都写入数据库,并记录准确的结账时间。
  2. 在订单明细中保存商品数量,以及成交时的单价、成本和税率。
  3. 使用 SQL 按日期分组,计算每天的 Total Bill、Total SST、Total Cost 和 Total Profit。
  4. 将原材料拆分为结构化的配方数据。继续使用字符串拼接,无法正确统计每天的原材料用量。

推荐的数据库结构

数据库至少需要订单表和订单明细表:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    paid_at TIMESTAMP NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PAID'
);

CREATE TABLE order_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    food_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    unit_price DECIMAL(12, 2) NOT NULL,
    unit_cost DECIMAL(12, 2) NOT NULL,
    tax_rate DECIMAL(7, 6) NOT NULL DEFAULT 0.060000,

    CONSTRAINT fk_order_items_order
        FOREIGN KEY (order_id) REFERENCES orders(id)
);

unit_price、unit_cost 和 tax_rate 要保存交易发生时的值。否则,菜单价格或成本发生变化后,历史报表也会随之改变。

金额字段应使用 DECIMAL,Java 中使用对应的 BigDecimal。不要用 double 处理财务金额。

正确计算单笔订单

当前代码在商品循环中计算 SST:

pre = Total_Bill * 0.06;
Total_Bill = Total_Bill + pre;

这样会让前面已经含税的金额在下一轮再次参与计税。应先算完所有商品的小计,再统一计算税额。

BigDecimal subtotal = BigDecimal.ZERO;
BigDecimal totalCost = BigDecimal.ZERO;
BigDecimal taxRate = new BigDecimal("0.06");

for (Map.Entry<Integer, Integer> entry : orders.entrySet()) {
    int foodIndex = entry.getKey();
    int quantity = entry.getValue();

    BigDecimal unitPrice = prices.get(foodIndex);
    BigDecimal unitCost = costs.get(foodIndex);

    subtotal = subtotal.add(
        unitPrice.multiply(BigDecimal.valueOf(quantity))
    );

    totalCost = totalCost.add(
        unitCost.multiply(BigDecimal.valueOf(quantity))
    );
}

BigDecimal totalSst = subtotal
    .multiply(taxRate)
    .setScale(2, RoundingMode.HALF_UP);

BigDecimal totalBill = subtotal.add(totalSst);
BigDecimal grossProfit = subtotal.subtract(totalCost);

SST 通常是代政府收取的税款,不属于餐厅收入。因此,毛利润更适合使用下面的公式:

Gross Profit = Subtotal - Total Cost

如果业务要求计算”收到的全部金额减去成本”,可以使用 Total Bill - Total Cost。不过,这个结果包含应缴税款,不适合作为真实利润。

保存每笔交易

订单和订单明细应放在同一个数据库事务中写入:

String insertOrderSql =
    "INSERT INTO orders (paid_at, status) VALUES (CURRENT_TIMESTAMP, 'PAID')";

String insertItemSql =
    "INSERT INTO order_items " +
    "(order_id, food_id, quantity, unit_price, unit_cost, tax_rate) " +
    "VALUES (?, ?, ?, ?, ?, ?)";

conn.setAutoCommit(false);

try (
    PreparedStatement orderStmt = conn.prepareStatement(
        insertOrderSql,
        Statement.RETURN_GENERATED_KEYS
    );
    PreparedStatement itemStmt = conn.prepareStatement(insertItemSql)
) {
    orderStmt.executeUpdate();

    long orderId;

    try (ResultSet keys = orderStmt.getGeneratedKeys()) {
        if (!keys.next()) {
            throw new SQLException("Unable to obtain generated order ID");
        }
        orderId = keys.getLong(1);
    }

    for (Map.Entry<Long, Integer> entry : orders.entrySet()) {
        long foodId = entry.getKey();
        int quantity = entry.getValue();
        Food food = foodsById.get(foodId);

        itemStmt.setLong(1, orderId);
        itemStmt.setLong(2, foodId);
        itemStmt.setInt(3, quantity);
        itemStmt.setBigDecimal(4, food.getPrice());
        itemStmt.setBigDecimal(5, food.getCost());
        itemStmt.setBigDecimal(6, new BigDecimal("0.06"));
        itemStmt.addBatch();
    }

    itemStmt.executeBatch();
    conn.commit();
} catch (SQLException e) {
    conn.rollback();
    throw e;
} finally {
    conn.setAutoCommit(true);
}

这里要使用数据库中的 food_id,不要再把 ArrayList 下标当作商品标识。菜单顺序一旦改变,同一个下标可能会指向另一道菜。

可以使用下面的数据结构:

Map<Long, Food> foodsById = new HashMap<>();
Map<Long, Integer> orders = new HashMap<>();

按天汇总营业结果

下面的查询可以统计一段日期内每天的营业数据:

SELECT
    DATE(o.paid_at) AS business_day,

    ROUND(SUM(oi.quantity * oi.unit_price), 2) AS subtotal,

    ROUND(
        SUM(oi.quantity * oi.unit_price * oi.tax_rate),
        2
    ) AS total_sst,

    ROUND(
        SUM(oi.quantity * oi.unit_price * (1 + oi.tax_rate)),
        2
    ) AS total_bill,

    ROUND(
        SUM(oi.quantity * oi.unit_cost),
        2
    ) AS total_cost,

    ROUND(
        SUM(oi.quantity * (oi.unit_price - oi.unit_cost)),
        2
    ) AS total_profit

FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'PAID'
  AND o.paid_at >= ?
  AND o.paid_at < ?
GROUP BY DATE(o.paid_at)
ORDER BY business_day;

时间条件适合使用包含起始时间、不包含结束时间的半开区间:

[startOfDay, startOfNextDay)

不建议使用下面的条件:

WHERE DATE(paid_at) = ?

这种写法比较直观,但在列上调用 DATE() 可能影响索引的使用。通常,按时间范围查询更合适。

可以添加以下索引:

CREATE INDEX idx_orders_status_paid_at
ON orders (status, paid_at);

CREATE INDEX idx_order_items_order_id
ON order_items (order_id);

Java 查询指定日期

餐厅的营业日需要使用明确的业务时区。下面以 Asia/Kuala_Lumpur 为例。如果餐厅不在该时区,应替换为实际时区。

LocalDate day = LocalDate.of(2026, 9, 1);
ZoneId businessZone = ZoneId.of("Asia/Kuala_Lumpur");

Instant from = day.atStartOfDay(businessZone).toInstant();
Instant to = day.plusDays(1).atStartOfDay(businessZone).toInstant();

String sql = """
    SELECT
        COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS subtotal,
        COALESCE(
            SUM(oi.quantity * oi.unit_price * oi.tax_rate), 0
        ) AS total_sst,
        COALESCE(
            SUM(oi.quantity * oi.unit_price * (1 + oi.tax_rate)), 0
        ) AS total_bill,
        COALESCE(SUM(oi.quantity * oi.unit_cost), 0) AS total_cost,
        COALESCE(
            SUM(oi.quantity * (oi.unit_price - oi.unit_cost)), 0
        ) AS total_profit
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.id
    WHERE o.status = 'PAID'
      AND o.paid_at >= ?
      AND o.paid_at < ?
    """;

try (PreparedStatement stmt = conn.prepareStatement(sql)) {
    stmt.setTimestamp(1, Timestamp.from(from));
    stmt.setTimestamp(2, Timestamp.from(to));

    try (ResultSet rs = stmt.executeQuery()) {
        if (rs.next()) {
            BigDecimal subtotal = rs.getBigDecimal("subtotal");
            BigDecimal totalSst = rs.getBigDecimal("total_sst");
            BigDecimal totalBill = rs.getBigDecimal("total_bill");
            BigDecimal totalCost = rs.getBigDecimal("total_cost");
            BigDecimal totalProfit = rs.getBigDecimal("total_profit");

            System.out.println("Subtotal: RM " + subtotal);
            System.out.println("Total SST: RM " + totalSst);
            System.out.println("Total Bill: RM " + totalBill);
            System.out.println("Total Cost: RM " + totalCost);
            System.out.println("Total Profit: RM " + totalProfit);
        }
    }
}

如果数据库保存 UTC 时间,JDBC、数据库会话和 Java 转换必须使用一致的时区规则。不能直接把数据库中的午夜当作餐厅当地的午夜。

每日原材料用量

目前的 materials 是字符串:

Total_Materials = Total_Materials + materials.get(key);

这段代码只能拼接文字,无法计算当天用了多少克鸡肉或多少升油。要统计实际数量,需要增加原材料表和配方表:

CREATE TABLE ingredients (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    unit VARCHAR(20) NOT NULL
);

CREATE TABLE food_ingredients (
    food_id BIGINT NOT NULL,
    ingredient_id BIGINT NOT NULL,
    amount_per_item DECIMAL(12, 3) NOT NULL,

    PRIMARY KEY (food_id, ingredient_id),
    FOREIGN KEY (ingredient_id) REFERENCES ingredients(id)
);

例如,每份商品需要 150 g 鸡肉,卖出 10 份后,理论用量为 150 × 10 = 1500 g。

每天的原材料用量可以这样统计:

SELECT
    i.id,
    i.name,
    i.unit,
    ROUND(
        SUM(oi.quantity * fi.amount_per_item),
        3
    ) AS total_used
FROM orders o
JOIN order_items oi
    ON oi.order_id = o.id
JOIN food_ingredients fi
    ON fi.food_id = oi.food_id
JOIN ingredients i
    ON i.id = fi.ingredient_id
WHERE o.status = 'PAID'
  AND o.paid_at >= ?
  AND o.paid_at < ?
GROUP BY i.id, i.name, i.unit
ORDER BY i.name;

查询结果是根据销量和标准配方算出的”理论消耗”。如果还要记录损耗、退料、采购和实际库存变化,需要另外建立库存流水表。

现有代码还需要修正的问题

循环条件不能写成:

while (again = true)

这里执行的是赋值操作,所以结果永远是 true。应改为:

while (again)

或者在需要退出时执行:

again = false;

集合也不应使用原始类型:

ArrayList name = new ArrayList();
HashMap Oders = new HashMap();

至少要添加泛型:

List<String> names = new ArrayList<>();
List<BigDecimal> prices = new ArrayList<>();
Map<Long, Integer> orders = new HashMap<>();

Total_Cost 还必须乘以订购数量。如果只累加一次 cost.get(key),购买多份商品时的成本就会被低估:

totalCost = totalCost.add(
    unitCost.multiply(BigDecimal.valueOf(quantity))
);

Java 负责录入并保存每笔完整交易,数据库负责按时间范围汇总。这样,即使程序关闭或重启,每日统计仍会保留,也可以随时生成日报、周报和月报。

备注:内容仅供参考。