Java Restaurant Billing System 如何统计每日结果
结论
不要在 Java 内存中累积当天的全部交易。可以按下面的方式处理:
- 每次结账都写入数据库,并记录准确的结账时间。
- 在订单明细中保存商品数量,以及成交时的单价、成本和税率。
- 使用 SQL 按日期分组,计算每天的
Total Bill、Total SST、Total Cost和Total Profit。 - 将原材料拆分为结构化的配方数据。继续使用字符串拼接,无法正确统计每天的原材料用量。
推荐的数据库结构
数据库至少需要订单表和订单明细表:
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 负责录入并保存每笔完整交易,数据库负责按时间范围汇总。这样,即使程序关闭或重启,每日统计仍会保留,也可以随时生成日报、周报和月报。
备注:内容仅供参考。
