刘云鑫 пре 2 недеља
родитељ
комит
1e8e86bea3

+ 46 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreOrder.java

@@ -132,6 +132,52 @@ public class StoreOrder {
     @TableField("remark")
     private String remark;
 
+    // ---------- 以下字段为「预订结账」提交时写入,用于记录加收/减免/整单折扣/免单及原因(与优惠券 discount_amount 区分) ----------
+
+    @ApiModelProperty(value = "是否有其他费用:0否 1是")
+    @TableField("has_other_fee")
+    private Integer hasOtherFee;
+
+    @ApiModelProperty(value = "其他费用金额")
+    @TableField("other_fee_amount")
+    private BigDecimal otherFeeAmount;
+
+    @ApiModelProperty(value = "其他费用收款原因")
+    @TableField("other_fee_reason")
+    private String otherFeeReason;
+
+    @ApiModelProperty(value = "是否有手动减免:0否 1是")
+    @TableField("has_manual_reduction")
+    private Integer hasManualReduction;
+
+    @ApiModelProperty(value = "手动减免金额")
+    @TableField("manual_reduction_amount")
+    private BigDecimal manualReductionAmount;
+
+    @ApiModelProperty(value = "手动减免原因")
+    @TableField("manual_reduction_reason")
+    private String manualReductionReason;
+
+    @ApiModelProperty(value = "整单折扣:0否 1是")
+    @TableField("has_whole_discount")
+    private Integer hasWholeDiscount;
+
+    @ApiModelProperty(value = "整单折扣比例(0-1)")
+    @TableField("whole_discount_ratio")
+    private BigDecimal wholeDiscountRatio;
+
+    @ApiModelProperty(value = "整单折扣原因")
+    @TableField("whole_discount_reason")
+    private String wholeDiscountReason;
+
+    @ApiModelProperty(value = "整单免单:0否 1是")
+    @TableField("is_free_order")
+    private Integer isFreeOrder;
+
+    @ApiModelProperty(value = "整单免单原因")
+    @TableField("free_order_reason")
+    private String freeOrderReason;
+
     @ApiModelProperty(value = "删除标记, 0:未删除, 1:已删除")
     @TableField("delete_flag")
     @TableLogic

+ 14 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreOrderDetail.java

@@ -66,6 +66,20 @@ public class StoreOrderDetail {
     @TableField("subtotal_amount")
     private BigDecimal subtotalAmount;
 
+    // ---------- 以下字段为「预订结账」时按行维护;提交结账后 subtotal_amount 会按免单/折扣重算 ----------
+
+    @ApiModelProperty(value = "行免单:0否 1是")
+    @TableField("free_dish")
+    private Integer freeDish;
+
+    @ApiModelProperty(value = "行折扣:0否 1是")
+    @TableField("discount_flag")
+    private Integer discountFlag;
+
+    @ApiModelProperty(value = "行折扣数:比例0-1(如0.8);若大于1按百分制折算")
+    @TableField("discount_number")
+    private BigDecimal discountNumber;
+
     @ApiModelProperty(value = "添加该菜品的用户ID")
     @TableField("add_user_id")
     private Integer addUserId;

+ 29 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingCheckoutLineSubmitDTO.java

@@ -0,0 +1,29 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+
+/**
+ * 预订结账提交:单行明细调整
+ */
+@Data
+@ApiModel(description = "预订结账提交-明细行")
+public class StoreBookingCheckoutLineSubmitDTO {
+
+    @NotNull(message = "明细ID不能为空")
+    @ApiModelProperty(value = "订单明细ID", required = true)
+    private Integer detailId;
+
+    @ApiModelProperty("行免单:0否 1是")
+    private Integer freeDish;
+
+    @ApiModelProperty("行折扣:0否 1是")
+    private Integer discountFlag;
+
+    @ApiModelProperty("行折扣数:0-1 比例;若传 80 表示 8 折(按百分制折算)")
+    private BigDecimal discountNumber;
+}

+ 75 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingCheckoutSubmitDTO.java

@@ -0,0 +1,75 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 预订结账提交(对应 POST /store/booking/order/checkout/submit)。
+ * lines 必须包含本单全部明细行且 detailId 与库中一一对应;clearTable 为 true 时会释放所有 current_order_id 指向本单的桌台。
+ */
+@Data
+@ApiModel(description = "预订结账提交")
+public class StoreBookingCheckoutSubmitDTO {
+
+    @NotNull(message = "门店ID不能为空")
+    @ApiModelProperty(value = "门店ID", required = true)
+    private Integer storeId;
+
+    @NotNull(message = "桌台ID不能为空")
+    @ApiModelProperty(value = "当前操作的桌台ID", required = true)
+    private Integer tableId;
+
+    @ApiModelProperty("订单ID(可选;不传则按桌台解析待支付订单)")
+    private Integer orderId;
+
+    @NotNull(message = "支付方式不能为空")
+    @ApiModelProperty(value = "支付方式:1微信 2支付宝 3现金", required = true)
+    private Integer payType;
+
+    @ApiModelProperty("true:结账并清桌(释放本单关联的所有桌台);false:仅结账(不写回桌台空闲)")
+    private Boolean clearTable;
+
+    @ApiModelProperty("是否有其他费用:0否 1是")
+    private Integer hasOtherFee;
+
+    @ApiModelProperty("其他费用金额")
+    private BigDecimal otherFeeAmount;
+
+    @ApiModelProperty("其他费用收款原因")
+    private String otherFeeReason;
+
+    @ApiModelProperty("是否有手动减免:0否 1是")
+    private Integer hasManualReduction;
+
+    @ApiModelProperty("手动减免金额")
+    private BigDecimal manualReductionAmount;
+
+    @ApiModelProperty("手动减免原因")
+    private String manualReductionReason;
+
+    @ApiModelProperty("整单折扣:0否 1是")
+    private Integer hasWholeDiscount;
+
+    @ApiModelProperty("整单折扣比例 0-1")
+    private BigDecimal wholeDiscountRatio;
+
+    @ApiModelProperty("整单折扣原因")
+    private String wholeDiscountReason;
+
+    @ApiModelProperty("整单免单:0否 1是")
+    private Integer isFreeOrder;
+
+    @ApiModelProperty("整单免单原因")
+    private String freeOrderReason;
+
+    @Valid
+    @NotNull(message = "明细行不能为空")
+    @ApiModelProperty(value = "全部明细行(须覆盖本单所有明细)", required = true)
+    private List<StoreBookingCheckoutLineSubmitDTO> lines;
+}

+ 42 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutLineVo.java

@@ -0,0 +1,42 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 预订结账页:订单明细行(含行免单/折扣)
+ */
+@Data
+@ApiModel(description = "预订结账-菜品明细行")
+public class StoreBookingCheckoutLineVo {
+
+    @ApiModelProperty("明细ID")
+    private Integer detailId;
+
+    @ApiModelProperty("菜品ID")
+    private Integer cuisineId;
+
+    @ApiModelProperty("菜品名称")
+    private String cuisineName;
+
+    @ApiModelProperty("成交单价")
+    private BigDecimal unitPrice;
+
+    @ApiModelProperty("数量")
+    private Integer quantity;
+
+    @ApiModelProperty("小计金额(当前库中值;结账提交后会按免单/折扣重算)")
+    private BigDecimal subtotalAmount;
+
+    @ApiModelProperty("行免单:0否 1是")
+    private Integer freeDish;
+
+    @ApiModelProperty("行折扣:0否 1是")
+    private Integer discountFlag;
+
+    @ApiModelProperty("行折扣数:比例0-1")
+    private BigDecimal discountNumber;
+}

+ 71 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutPrepVo.java

@@ -0,0 +1,71 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 预订结账页:进入结账时的订单与费用预览(对应 GET /store/booking/order/checkout/prep)。
+ * goodsAmountRaw 为单价×数量之和,供服务费「按消费金额」规则作基数;serviceFeeForCurrentSlot 为当前时刻规则命中值。
+ */
+@Data
+@ApiModel(description = "预订结账-页面数据")
+public class StoreBookingCheckoutPrepVo {
+
+    @ApiModelProperty("订单ID")
+    private Integer orderId;
+
+    @ApiModelProperty("订单号")
+    private String orderNo;
+
+    @ApiModelProperty("门店ID")
+    private Integer storeId;
+
+    @ApiModelProperty("当前点击的桌台ID")
+    private Integer tableId;
+
+    @ApiModelProperty("预约ID(无预约则为空)")
+    private Integer userReservationId;
+
+    @ApiModelProperty("同一预约/同一订单下的桌号展示(如:A01、A02)")
+    private String tableNumbersDisplay;
+
+    @ApiModelProperty("同一预约下桌台ID列表")
+    private List<Integer> tableIdsUnderReservation;
+
+    @ApiModelProperty("就餐人数")
+    private Integer dinerCount;
+
+    @ApiModelProperty("订单状态")
+    private Integer orderStatus;
+
+    @ApiModelProperty("支付状态")
+    private Integer payStatus;
+
+    @ApiModelProperty("菜品原价合计(明细小计之和,未扣行免单/行折扣)")
+    private BigDecimal goodsAmountRaw;
+
+    @ApiModelProperty("餐具费")
+    private BigDecimal tablewareFee;
+
+    @ApiModelProperty("订单上已快照的服务费(下单时可能已写入)")
+    private BigDecimal serviceFeeOnOrder;
+
+    @ApiModelProperty("按当前时刻+桌台匹配规则计算的当前时段服务费合计(预览)")
+    private BigDecimal serviceFeeForCurrentSlot;
+
+    @ApiModelProperty("优惠券优惠金额")
+    private BigDecimal couponDiscountAmount;
+
+    @ApiModelProperty("当前库中订单总金额")
+    private BigDecimal totalAmount;
+
+    @ApiModelProperty("当前库中实付金额")
+    private BigDecimal payAmount;
+
+    @ApiModelProperty("明细列表(含行免单/折扣字段)")
+    private List<StoreBookingCheckoutLineVo> lines;
+}

+ 27 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutResultVo.java

@@ -0,0 +1,27 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 预订结账提交结果
+ */
+@Data
+@ApiModel(description = "预订结账提交结果")
+public class StoreBookingCheckoutResultVo {
+
+    @ApiModelProperty("订单ID")
+    private Integer orderId;
+
+    @ApiModelProperty("订单号")
+    private String orderNo;
+
+    @ApiModelProperty("实付金额")
+    private BigDecimal payAmount;
+
+    @ApiModelProperty("是否已执行清桌")
+    private Boolean clearedTables;
+}

+ 48 - 0
alien-store/src/main/java/shop/alien/store/controller/StoreBookingOrderController.java

@@ -8,9 +8,13 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.dto.StoreBookingChangeTableDTO;
+import shop.alien.entity.store.dto.StoreBookingCheckoutSubmitDTO;
 import shop.alien.entity.store.dto.StoreBookingPlaceOrderDTO;
 import shop.alien.entity.store.vo.StoreBookingChangeTableUnifiedResultVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutPrepVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutResultVo;
 import shop.alien.entity.store.vo.StoreBookingPlaceOrderResultVo;
+import shop.alien.store.service.StoreBookingCheckoutService;
 import shop.alien.store.service.StoreBookingOrderService;
 
 import javax.validation.Valid;
@@ -26,7 +30,10 @@ import javax.validation.Valid;
 @RequiredArgsConstructor
 public class StoreBookingOrderController {
 
+    /** 下单、换桌 */
     private final StoreBookingOrderService storeBookingOrderService;
+    /** 结账页查询与提交(与下单服务拆分,职责单一) */
+    private final StoreBookingCheckoutService storeBookingCheckoutService;
 
     @ApiOperationSupport(order = 1)
     @ApiOperation("提交订单(主表 store_order,明细 store_order_detail;联系人 dining_contact_name / dining_gender 1男2女。若该桌已有待支付订单则为加餐,可多次加餐;可选 targetOrderId 指定订单)")
@@ -64,4 +71,45 @@ public class StoreBookingOrderController {
             return R.fail("换桌失败:" + e.getMessage());
         }
     }
+
+    /**
+     * GET /checkout/prep:只读,给前端渲染结账页;异常分业务异常与系统异常,便于前端提示。
+     */
+    @ApiOperationSupport(order = 3)
+    @ApiOperation("结账页数据:按桌解析待支付订单(多桌共享时以 current_order_id 为准)、预约下复合桌号、明细含行免单/折扣、当前时段服务费合计")
+    @GetMapping("/checkout/prep")
+    public R<StoreBookingCheckoutPrepVo> checkoutPrep(
+            @RequestParam Integer storeId,
+            @RequestParam Integer tableId) {
+        log.info("StoreBookingOrderController.checkoutPrep?storeId={}, tableId={}", storeId, tableId);
+        try {
+            return R.data(storeBookingCheckoutService.prepCheckout(storeId, tableId));
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            log.warn("checkoutPrep biz: {}", e.getMessage());
+            return R.fail(e.getMessage());
+        } catch (Exception e) {
+            log.error("checkoutPrep error", e);
+            return R.fail("查询结账数据失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * POST /checkout/submit:写库;@Valid 校验非空字段,明细行由 DTO 内嵌校验配合服务层全集校验。
+     */
+    @ApiOperationSupport(order = 4)
+    @ApiOperation("提交结账:写回订单其他费用/减免/整单折扣/免单及明细行;完成支付;clearTable=true 时结账并清桌(释放本单关联桌台)")
+    @PostMapping("/checkout/submit")
+    public R<StoreBookingCheckoutResultVo> checkoutSubmit(@Valid @RequestBody StoreBookingCheckoutSubmitDTO dto) {
+        log.info("StoreBookingOrderController.checkoutSubmit?storeId={}, tableId={}, orderId={}, clearTable={}",
+                dto.getStoreId(), dto.getTableId(), dto.getOrderId(), dto.getClearTable());
+        try {
+            return R.data(storeBookingCheckoutService.submitCheckout(dto));
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            log.warn("checkoutSubmit biz: {}", e.getMessage());
+            return R.fail(e.getMessage());
+        } catch (Exception e) {
+            log.error("checkoutSubmit error", e);
+            return R.fail("结账失败:" + e.getMessage());
+        }
+    }
 }

+ 30 - 0
alien-store/src/main/java/shop/alien/store/service/StoreBookingCheckoutService.java

@@ -0,0 +1,30 @@
+package shop.alien.store.service;
+
+import shop.alien.entity.store.dto.StoreBookingCheckoutSubmitDTO;
+import shop.alien.entity.store.vo.StoreBookingCheckoutPrepVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutResultVo;
+
+/**
+ * 预订场景:结账页查询与提交(仅操作 store_order / store_order_detail,不经 alien-dining)。
+ * <p>
+ * 订单解析顺序与换桌/加餐一致:多桌共一单时以 {@link shop.alien.entity.store.StoreTable#getCurrentOrderId()} 为准。
+ */
+public interface StoreBookingCheckoutService {
+
+    /**
+     * 结账页「只读预览」:校验门店与桌台,解析待支付订单,组装复合桌号、明细行(含行免单/折扣字段)、
+     * 并按当前时刻匹配服务费规则得到 {@link shop.alien.entity.store.vo.StoreBookingCheckoutPrepVo#getServiceFeeForCurrentSlot()}。
+     *
+     * @param storeId 门店 ID,用于校验桌台归属
+     * @param tableId 用户点击结账的桌台 ID(服务费按此桌匹配规则)
+     */
+    StoreBookingCheckoutPrepVo prepCheckout(Integer storeId, Integer tableId);
+
+    /**
+     * 结账「写库提交」:校验订单与桌台会话一致后,按请求重算每行小计、订单总金额与实付,写入结账扩展字段,
+     * 将订单置为已支付;若 {@link shop.alien.entity.store.dto.StoreBookingCheckoutSubmitDTO#getClearTable()} 为 true 则释放本单关联桌台。
+     *
+     * @param dto 必须包含与本单明细行一一对应的行列表(不可漏行、不可重复 detailId)
+     */
+    StoreBookingCheckoutResultVo submitCheckout(StoreBookingCheckoutSubmitDTO dto);
+}

+ 16 - 0
alien-store/src/main/java/shop/alien/store/service/StoreServiceFeeRuleService.java

@@ -7,6 +7,8 @@ import shop.alien.entity.store.vo.StoreBookingTableVo;
 import shop.alien.entity.store.vo.StoreServiceFeeRuleDetailVo;
 import shop.alien.entity.store.vo.StoreServiceFeeRuleListVo;
 
+import java.math.BigDecimal;
+import java.util.Date;
 import java.util.List;
 
 public interface StoreServiceFeeRuleService {
@@ -29,5 +31,19 @@ public interface StoreServiceFeeRuleService {
      * @return 本次关闭数量
      */
     int autoCloseExpiredCustomRules();
+
+    /**
+     * 按门店、桌台与指定时刻,汇总当前命中的服务费规则金额(规则类型:1 按人数 × 单价、2 按桌固定、3 按消费金额 × 比例)。
+     * <p>
+     * 实现要点:先查出该桌所有启用规则,再过滤「日期区间」「星期掩码」「时段 [start,end)」,对每条命中规则累加金额。
+     *
+     * @param storeId       门店
+     * @param tableId       桌台(规则按桌维度配置)
+     * @param dinerCount    就餐人数,用于类型 1;为空或 ≤0 时按 1 人计
+     * @param consumeAmount 类型 3 的基数,一般为菜品折后合计(结账提交时用重算后的 goodsSum)
+     * @param at              判定「当前时段」的时间点(预览与提交可用同一时刻)
+     * @return 合计服务费,保留两位小数;参数不全时返回 0
+     */
+    BigDecimal computeMatchedServiceFee(Integer storeId, Integer tableId, Integer dinerCount, BigDecimal consumeAmount, Date at);
 }
 

+ 517 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingCheckoutServiceImpl.java

@@ -0,0 +1,517 @@
+package shop.alien.store.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import shop.alien.entity.store.StoreOrder;
+import shop.alien.entity.store.StoreOrderDetail;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.UserReservationTable;
+import shop.alien.entity.store.dto.StoreBookingCheckoutLineSubmitDTO;
+import shop.alien.entity.store.dto.StoreBookingCheckoutSubmitDTO;
+import shop.alien.entity.store.vo.StoreBookingCheckoutLineVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutPrepVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutResultVo;
+import shop.alien.mapper.StoreOrderDetailMapper;
+import shop.alien.mapper.StoreOrderMapper;
+import shop.alien.mapper.StoreTableMapper;
+import shop.alien.mapper.UserReservationTableMapper;
+import shop.alien.store.service.StoreBookingCheckoutService;
+import shop.alien.store.service.StoreServiceFeeRuleService;
+import shop.alien.util.common.JwtUtil;
+import com.alibaba.fastjson.JSONObject;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 预订结账核心实现。
+ * prepCheckout:只读,用于前端进入结账页展示订单、复合桌号、行级免单/折扣字段、当前时段服务费预览。
+ * submitCheckout:事务内写明细与主单,计算实付,置支付成功;可选按订单批量释放桌台(清桌)。
+ * 实付公式(摘要):先按行重算小计,再 goodsSum + 餐具 + 服务费 + 其他费用 - 优惠券 - 手动减免,
+ * 可选再乘整单折扣比例;若整单免单则实付为 0;totalAmount 存折前含费毛额 gross,payAmount 存实付。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(rollbackFor = Exception.class)
+public class StoreBookingCheckoutServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOrder> implements StoreBookingCheckoutService {
+
+    private final StoreOrderDetailMapper storeOrderDetailMapper;
+    private final StoreTableMapper storeTableMapper;
+    private final UserReservationTableMapper userReservationTableMapper;
+    private final StoreServiceFeeRuleService storeServiceFeeRuleService;
+
+    /**
+     * 结账页预览:不修改数据库。
+     */
+    @Override
+    public StoreBookingCheckoutPrepVo prepCheckout(Integer storeId, Integer tableId) {
+        // 1) 入参校验:门店与桌台必须同时传入
+        if (storeId == null || tableId == null) {
+            throw new IllegalArgumentException("门店ID与桌台ID不能为空");
+        }
+        // 2) 加载桌台并校验归属,防止跨门店查单
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table == null || !storeId.equals(table.getStoreId())) {
+            throw new IllegalArgumentException("桌台不存在或不属于该门店");
+        }
+        // 3) 解析「本桌当前应对应的待支付订单」(优先 current_order_id,解决一桌多单台共一单)
+        StoreOrder order = resolvePendingOrderForCheckout(storeId, table);
+        if (order == null) {
+            throw new IllegalArgumentException("当前桌台没有待支付订单");
+        }
+
+        // 4) 读出订单全部明细,组装前端行 VO,并累加「菜品原价合计」goodsRaw(单价×数量,不受行免单/折扣影响,供服务费类型3作基数)
+        List<StoreOrderDetail> details = listDetails(order.getId());
+        BigDecimal goodsRaw = BigDecimal.ZERO;
+        List<StoreBookingCheckoutLineVo> lines = new ArrayList<>();
+        for (StoreOrderDetail d : details) {
+            StoreBookingCheckoutLineVo lv = new StoreBookingCheckoutLineVo();
+            lv.setDetailId(d.getId());
+            lv.setCuisineId(d.getCuisineId());
+            lv.setCuisineName(d.getCuisineName());
+            lv.setUnitPrice(d.getUnitPrice());
+            lv.setQuantity(d.getQuantity());
+            lv.setSubtotalAmount(d.getSubtotalAmount());
+            // nz:库中 null 视为 0,避免前端展示 null
+            lv.setFreeDish(nz(d.getFreeDish()));
+            lv.setDiscountFlag(nz(d.getDiscountFlag()));
+            lv.setDiscountNumber(d.getDiscountNumber());
+            lines.add(lv);
+
+            BigDecimal unit = d.getUnitPrice() != null ? d.getUnitPrice() : BigDecimal.ZERO;
+            int qty = d.getQuantity() != null ? d.getQuantity() : 0;
+            goodsRaw = goodsRaw.add(unit.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        // 5) 用「当前时间 + 本桌 + 就餐人数 + 消费基数」匹配规则,得到预览服务费(与提交时算法一致,仅时刻可能不同)
+        Date now = new Date();
+        BigDecimal servicePreview = storeServiceFeeRuleService.computeMatchedServiceFee(
+                storeId, tableId, order.getDinerCount(), goodsRaw, now);
+
+        // 6) 填充返回 VO:主单快照 + 复合桌号 + 明细行
+        StoreBookingCheckoutPrepVo vo = new StoreBookingCheckoutPrepVo();
+        vo.setOrderId(order.getId());
+        vo.setOrderNo(order.getOrderNo());
+        vo.setStoreId(order.getStoreId());
+        vo.setTableId(tableId);
+        vo.setUserReservationId(order.getUserReservationId());
+        vo.setDinerCount(order.getDinerCount());
+        vo.setOrderStatus(order.getOrderStatus());
+        vo.setPayStatus(order.getPayStatus());
+        vo.setGoodsAmountRaw(goodsRaw);
+        vo.setTablewareFee(nzAmount(order.getTablewareFee()));
+        vo.setServiceFeeOnOrder(order.getServiceFee());
+        vo.setServiceFeeForCurrentSlot(servicePreview);
+        vo.setCouponDiscountAmount(nzAmount(order.getDiscountAmount()));
+        vo.setTotalAmount(order.getTotalAmount());
+        vo.setPayAmount(order.getPayAmount());
+        vo.setLines(lines);
+
+        // 7) 复合桌号:优先从预约关联表取多桌;无预约则取 current_order_id 绑定的桌;再兜底订单主桌
+        TableDisplay td = buildTableNumbersDisplay(order, tableId);
+        vo.setTableNumbersDisplay(td.display);
+        vo.setTableIdsUnderReservation(td.tableIds);
+        return vo;
+    }
+
+    /**
+     * 提交结账:写明细、写主单、置已支付,可选清桌。
+     */
+    @Override
+    public StoreBookingCheckoutResultVo submitCheckout(StoreBookingCheckoutSubmitDTO dto) {
+        if (dto == null) {
+            throw new IllegalArgumentException("请求体不能为空");
+        }
+        Integer storeId = dto.getStoreId();
+        Integer tableId = dto.getTableId();
+        if (storeId == null || tableId == null) {
+            throw new IllegalArgumentException("门店ID与桌台ID不能为空");
+        }
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table == null || !storeId.equals(table.getStoreId())) {
+            throw new IllegalArgumentException("桌台不存在或不属于该门店");
+        }
+
+        // 1) 定位订单:可显式传 orderId,否则与 prep 相同规则从桌台解析
+        StoreOrder order = null;
+        if (dto.getOrderId() != null) {
+            order = this.getById(dto.getOrderId());
+            if (order == null || !storeId.equals(order.getStoreId())) {
+                throw new IllegalArgumentException("订单不存在或不属于该门店");
+            }
+        }
+        if (order == null) {
+            order = resolvePendingOrderForCheckout(storeId, table);
+        }
+        if (order == null) {
+            throw new IllegalArgumentException("当前桌台没有待支付订单");
+        }
+        // 2) 必须是待支付(orderStatus=0 且 payStatus=0)
+        if (!isPending(order)) {
+            throw new IllegalArgumentException("订单不是待支付状态,无法结账");
+        }
+        // 3) 会话一致性:防止拿 A 桌订单在 B 桌提交(含预约多桌场景)
+        if (!belongsToTableSession(order, table)) {
+            throw new IllegalArgumentException("订单与当前桌台会话不一致,无法结账");
+        }
+
+        // 4) 校验「开关为 1 时必填金额/原因」等业务规则
+        validateCheckoutFlags(dto);
+
+        // 5) 明细行必须全集匹配:请求中的 detailId 集合与库中完全一致(防止漏改行或重复行)
+        List<StoreOrderDetail> dbLines = listDetails(order.getId());
+        Map<Integer, StoreOrderDetail> byId = dbLines.stream().collect(Collectors.toMap(StoreOrderDetail::getId, x -> x));
+        Set<Integer> expected = new HashSet<>(byId.keySet());
+        Set<Integer> got = dto.getLines().stream().map(StoreBookingCheckoutLineSubmitDTO::getDetailId).collect(Collectors.toSet());
+        if (!expected.equals(got)) {
+            throw new IllegalArgumentException("明细行须覆盖本单全部菜品行且不可重复");
+        }
+
+        Map<Integer, StoreBookingCheckoutLineSubmitDTO> patch = dto.getLines().stream()
+                .collect(Collectors.toMap(StoreBookingCheckoutLineSubmitDTO::getDetailId, x -> x));
+
+        // 6) 逐行写回免单/折扣,并重算 subtotal_amount;累加 goodsSum(折后菜品合计)
+        BigDecimal goodsSum = BigDecimal.ZERO;
+        Date now = new Date();
+        Integer userId = currentUserId();
+
+        for (StoreOrderDetail d : dbLines) {
+            StoreBookingCheckoutLineSubmitDTO p = patch.get(d.getId());
+            int free = p.getFreeDish() != null && p.getFreeDish() == 1 ? 1 : 0;
+            int disc = p.getDiscountFlag() != null && p.getDiscountFlag() == 1 ? 1 : 0;
+            BigDecimal lineSub = computeLineSubtotal(d, free, disc, p.getDiscountNumber());
+            goodsSum = goodsSum.add(lineSub);
+
+            d.setFreeDish(free);
+            d.setDiscountFlag(disc);
+            // 未开折扣时清空 discount_number,避免脏数据;开折扣时存归一化后的比例(0~1)
+            d.setDiscountNumber(disc == 1 ? normalizeDiscountRate(p.getDiscountNumber()) : null);
+            d.setSubtotalAmount(lineSub);
+            d.setUpdatedTime(now);
+            d.setUpdatedUserId(userId);
+            storeOrderDetailMapper.updateById(d);
+        }
+
+        // 7) 费用层:餐具取订单原值;服务费按「重算后的 goodsSum」再算一遍(与预览可能因行折扣而变化)
+        BigDecimal tableware = nzAmount(order.getTablewareFee());
+        BigDecimal serviceFee = storeServiceFeeRuleService.computeMatchedServiceFee(
+                storeId, tableId, order.getDinerCount(), goodsSum, now);
+
+        // 8) 其他费用、优惠券、手动减免(后两者来自订单或请求)
+        BigDecimal other = Objects.equals(dto.getHasOtherFee(), 1)
+                ? nzAmount(dto.getOtherFeeAmount())
+                : BigDecimal.ZERO;
+        BigDecimal coupon = nzAmount(order.getDiscountAmount());
+        BigDecimal manual = Objects.equals(dto.getHasManualReduction(), 1)
+                ? nzAmount(dto.getManualReductionAmount())
+                : BigDecimal.ZERO;
+
+        // 9) 毛额 gross = 菜品 + 餐具 + 服务费 + 其他费用;中间值 x 先减券、减手动减免,再按需乘整单折扣
+        BigDecimal gross = goodsSum.add(tableware).add(serviceFee).add(other);
+        BigDecimal x = gross.subtract(coupon).subtract(manual);
+        if (Objects.equals(dto.getHasWholeDiscount(), 1)) {
+            x = x.multiply(normalizeDiscountRate(dto.getWholeDiscountRatio())).setScale(2, RoundingMode.HALF_UP);
+        }
+        BigDecimal pay;
+        if (Objects.equals(dto.getIsFreeOrder(), 1)) {
+            pay = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        } else {
+            // 避免出现负实付
+            pay = x.max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
+        }
+
+        // 10) 回写主单金额与结账扩展字段
+        order.setTotalAmount(gross.setScale(2, RoundingMode.HALF_UP));
+        order.setPayAmount(pay);
+        order.setServiceFee(serviceFee);
+        order.setHasOtherFee(nz(dto.getHasOtherFee()));
+        order.setOtherFeeAmount(Objects.equals(dto.getHasOtherFee(), 1) ? dto.getOtherFeeAmount() : null);
+        order.setOtherFeeReason(trimToNull(dto.getOtherFeeReason()));
+        order.setHasManualReduction(nz(dto.getHasManualReduction()));
+        order.setManualReductionAmount(Objects.equals(dto.getHasManualReduction(), 1) ? dto.getManualReductionAmount() : null);
+        order.setManualReductionReason(trimToNull(dto.getManualReductionReason()));
+        order.setHasWholeDiscount(nz(dto.getHasWholeDiscount()));
+        order.setWholeDiscountRatio(Objects.equals(dto.getHasWholeDiscount(), 1) ? normalizeDiscountRate(dto.getWholeDiscountRatio()) : null);
+        order.setWholeDiscountReason(trimToNull(dto.getWholeDiscountReason()));
+        order.setIsFreeOrder(nz(dto.getIsFreeOrder()));
+        order.setFreeOrderReason(trimToNull(dto.getFreeOrderReason()));
+
+        // 11) 标记已支付(与 alien-dining 约定:orderStatus=1 已支付,payStatus=1)
+        order.setOrderStatus(1);
+        order.setPayStatus(1);
+        order.setPayType(dto.getPayType());
+        order.setPayTime(now);
+        order.setPayTradeNo("CHK" + System.currentTimeMillis());
+        order.setUpdatedTime(now);
+        order.setUpdatedUserId(userId);
+        this.updateById(order);
+
+        // 12) 仅当 clearTable=true:释放所有 current_order_id 指向本单的桌台(多桌一单时一并清空)
+        boolean clear = Boolean.TRUE.equals(dto.getClearTable());
+        if (clear) {
+            clearTablesForOrder(storeId, order.getId(), userId, now);
+        }
+
+        StoreBookingCheckoutResultVo vo = new StoreBookingCheckoutResultVo();
+        vo.setOrderId(order.getId());
+        vo.setOrderNo(order.getOrderNo());
+        vo.setPayAmount(pay);
+        vo.setClearedTables(clear);
+        log.info("预订结账完成 orderId={} pay={} clearTable={}", order.getId(), pay, clear);
+        return vo;
+    }
+
+    /**
+     * 判断当前操作的桌台是否允许操作该订单:
+     * 1) 桌台 current_order_id 指向本单(多桌绑同一订单时的主路径);
+     * 2) 订单主表 table_id 等于本桌(单桌或主桌);
+     * 3) 预约关联表包含本桌(预约多桌、指针未同步时的兜底)。
+     */
+    private boolean belongsToTableSession(StoreOrder order, StoreTable table) {
+        if (table.getCurrentOrderId() != null && table.getCurrentOrderId().equals(order.getId())) {
+            return true;
+        }
+        if (order.getTableId() != null && order.getTableId().equals(table.getId())) {
+            return true;
+        }
+        if (order.getUserReservationId() != null) {
+            LambdaQueryWrapper<UserReservationTable> q = new LambdaQueryWrapper<>();
+            q.eq(UserReservationTable::getReservationId, order.getUserReservationId())
+                    .eq(UserReservationTable::getTableId, table.getId());
+            if (userReservationTableMapper.selectCount(q) > 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 校验「开关为 1」时依赖字段是否齐全,避免写入半成数据。
+     */
+    private void validateCheckoutFlags(StoreBookingCheckoutSubmitDTO dto) {
+        if (Objects.equals(dto.getHasOtherFee(), 1)) {
+            if (dto.getOtherFeeAmount() == null || dto.getOtherFeeAmount().compareTo(BigDecimal.ZERO) < 0) {
+                throw new IllegalArgumentException("开启其他费用时请填写有效金额");
+            }
+            if (!StringUtils.hasText(dto.getOtherFeeReason())) {
+                throw new IllegalArgumentException("开启其他费用时请填写收款原因");
+            }
+        }
+        if (Objects.equals(dto.getHasManualReduction(), 1)) {
+            if (dto.getManualReductionAmount() == null || dto.getManualReductionAmount().compareTo(BigDecimal.ZERO) < 0) {
+                throw new IllegalArgumentException("开启手动减免时请填写有效减免金额");
+            }
+            if (!StringUtils.hasText(dto.getManualReductionReason())) {
+                throw new IllegalArgumentException("开启手动减免时请填写减免原因");
+            }
+        }
+        if (Objects.equals(dto.getHasWholeDiscount(), 1)) {
+            if (dto.getWholeDiscountRatio() == null || dto.getWholeDiscountRatio().compareTo(BigDecimal.ZERO) <= 0) {
+                throw new IllegalArgumentException("开启整单折扣时请填写折扣比例");
+            }
+            if (!StringUtils.hasText(dto.getWholeDiscountReason())) {
+                throw new IllegalArgumentException("开启整单折扣时请填写折扣原因");
+            }
+        }
+        if (Objects.equals(dto.getIsFreeOrder(), 1) && !StringUtils.hasText(dto.getFreeOrderReason())) {
+            throw new IllegalArgumentException("整单免单时请填写免单原因");
+        }
+        for (StoreBookingCheckoutLineSubmitDTO line : dto.getLines()) {
+            if (Objects.equals(line.getDiscountFlag(), 1) && line.getDiscountNumber() == null) {
+                throw new IllegalArgumentException("明细行开启折扣时请填写折扣数,detailId=" + line.getDetailId());
+            }
+        }
+    }
+
+    /**
+     * 计算单行折后小计:免单为 0;否则为 单价×数量,若开折扣再 × 折扣比例。
+     */
+    private static BigDecimal computeLineSubtotal(StoreOrderDetail d, int free, int discountFlag, BigDecimal discountNumber) {
+        BigDecimal unit = d.getUnitPrice() != null ? d.getUnitPrice() : BigDecimal.ZERO;
+        int qty = d.getQuantity() != null ? d.getQuantity() : 0;
+        BigDecimal base = unit.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+        if (free == 1) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        if (discountFlag == 1) {
+            return base.multiply(normalizeDiscountRate(discountNumber)).setScale(2, RoundingMode.HALF_UP);
+        }
+        return base;
+    }
+
+    /**
+     * 将前端传入的折扣数统一为 (0,1] 乘数:≤1 视为小数比例;&gt;1 视为「百分制」如 80→0.8。
+     */
+    private static BigDecimal normalizeDiscountRate(BigDecimal n) {
+        if (n == null) {
+            return BigDecimal.ONE;
+        }
+        if (n.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        if (n.compareTo(BigDecimal.ONE) > 0) {
+            return n.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP).min(BigDecimal.ONE);
+        }
+        return n.min(BigDecimal.ONE);
+    }
+
+    /** 查询订单下全部明细,按 id 升序,保证展示与提交顺序稳定 */
+    private List<StoreOrderDetail> listDetails(Integer orderId) {
+        LambdaQueryWrapper<StoreOrderDetail> q = new LambdaQueryWrapper<>();
+        q.eq(StoreOrderDetail::getOrderId, orderId).orderByAsc(StoreOrderDetail::getId);
+        return storeOrderDetailMapper.selectList(q);
+    }
+
+    /**
+     * 与下单模块一致:优先信任桌台 {@code current_order_id},否则查「本桌 table_id + 待支付」且至多一笔。
+     */
+    private StoreOrder resolvePendingOrderForCheckout(Integer storeId, StoreTable table) {
+        if (table.getCurrentOrderId() != null) {
+            StoreOrder byPtr = this.getById(table.getCurrentOrderId());
+            if (isPending(byPtr) && storeId.equals(byPtr.getStoreId())) {
+                return byPtr;
+            }
+        }
+        LambdaQueryWrapper<StoreOrder> q = new LambdaQueryWrapper<>();
+        q.eq(StoreOrder::getStoreId, storeId)
+                .eq(StoreOrder::getTableId, table.getId())
+                .eq(StoreOrder::getDeleteFlag, 0)
+                .eq(StoreOrder::getOrderStatus, 0)
+                .eq(StoreOrder::getPayStatus, 0)
+                .orderByDesc(StoreOrder::getId);
+        List<StoreOrder> list = this.list(q);
+        if (list.size() > 1) {
+            throw new IllegalStateException("该桌存在多笔待支付订单,数据异常");
+        }
+        return list.isEmpty() ? null : list.get(0);
+    }
+
+    /** 未删除且 orderStatus=0、payStatus=0 视为待支付 */
+    private static boolean isPending(StoreOrder o) {
+        return o != null
+                && (o.getDeleteFlag() == null || o.getDeleteFlag() == 0)
+                && Objects.equals(o.getOrderStatus(), 0)
+                && Objects.equals(o.getPayStatus(), 0);
+    }
+
+    /**
+     * 清桌:将所有「当前仍指向本单」的桌台置空闲(current_order_id 清空、状态 0、人数清空)。
+     * 条件中带 currentOrderId=orderId,避免并发下误改他单。
+     */
+    private void clearTablesForOrder(Integer storeId, Integer orderId, Integer userId, Date now) {
+        LambdaQueryWrapper<StoreTable> q = new LambdaQueryWrapper<>();
+        q.eq(StoreTable::getStoreId, storeId).eq(StoreTable::getCurrentOrderId, orderId);
+        List<StoreTable> tables = storeTableMapper.selectList(q);
+        for (StoreTable t : tables) {
+            LambdaUpdateWrapper<StoreTable> u = new LambdaUpdateWrapper<>();
+            u.eq(StoreTable::getId, t.getId())
+                    .eq(StoreTable::getCurrentOrderId, orderId)
+                    .set(StoreTable::getCurrentOrderId, null)
+                    .set(StoreTable::getStatus, 0)
+                    .set(StoreTable::getDinerCount, null)
+                    .set(StoreTable::getUpdatedTime, now);
+            if (userId != null) {
+                u.set(StoreTable::getUpdatedUserId, userId);
+            }
+            storeTableMapper.update(null, u);
+        }
+    }
+
+    /**
+     * 生成「A01、A02」展示串:优先预约下多桌;若无预约数据则找所有绑定本订单的桌;最后兜底订单主桌。
+     *
+     * @param requestTableId 预留参数(例如将来按「点击桌」高亮);当前未参与计算
+     */
+    private TableDisplay buildTableNumbersDisplay(StoreOrder order, Integer requestTableId) {
+        List<Integer> ids = new ArrayList<>();
+        if (order.getUserReservationId() != null) {
+            LambdaQueryWrapper<UserReservationTable> q = new LambdaQueryWrapper<>();
+            q.eq(UserReservationTable::getReservationId, order.getUserReservationId())
+                    .orderByAsc(UserReservationTable::getSort);
+            List<UserReservationTable> urs = userReservationTableMapper.selectList(q);
+            for (UserReservationTable ur : urs) {
+                if (ur.getTableId() != null) {
+                    ids.add(ur.getTableId());
+                }
+            }
+        }
+        if (ids.isEmpty()) {
+            LambdaQueryWrapper<StoreTable> tq = new LambdaQueryWrapper<>();
+            tq.eq(StoreTable::getStoreId, order.getStoreId())
+                    .eq(StoreTable::getCurrentOrderId, order.getId());
+            List<StoreTable> bound = storeTableMapper.selectList(tq);
+            ids = bound.stream().map(StoreTable::getId).collect(Collectors.toList());
+        }
+        if (ids.isEmpty() && order.getTableId() != null) {
+            ids.add(order.getTableId());
+        }
+        ids = ids.stream().distinct().collect(Collectors.toList());
+
+        List<String> names = new ArrayList<>();
+        for (Integer tid : ids) {
+            StoreTable t = storeTableMapper.selectById(tid);
+            if (t != null && StringUtils.hasText(t.getTableNumber())) {
+                names.add(t.getTableNumber().trim());
+            }
+        }
+        names.sort(Comparator.naturalOrder());
+        String display = names.isEmpty() ? "" : String.join("、", names);
+        return new TableDisplay(ids, display);
+    }
+
+    /** 从 JWT 取操作人,未登录时返回 null,不阻断结账 */
+    private static Integer currentUserId() {
+        try {
+            JSONObject j = JwtUtil.getCurrentUserInfo();
+            return j != null ? j.getInteger("userId") : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /** Integer 空值当 0,用于前端展示 flags */
+    private static Integer nz(Integer v) {
+        return v != null ? v : 0;
+    }
+
+    /** 金额空值当 0 */
+    private static BigDecimal nzAmount(BigDecimal v) {
+        return v != null ? v : BigDecimal.ZERO;
+    }
+
+    /** 全空白字符串存库为 null,避免仅空格占位 */
+    private static String trimToNull(String s) {
+        if (!StringUtils.hasText(s)) {
+            return null;
+        }
+        String t = s.trim();
+        return t.isEmpty() ? null : t;
+    }
+
+    /** 复合桌号构建的临时结果:tableIds 用于列表,display 用于「、」分隔展示 */
+    private static final class TableDisplay {
+        final List<Integer> tableIds;
+        final String display;
+
+        TableDisplay(List<Integer> tableIds, String display) {
+            this.tableIds = tableIds;
+            this.display = display;
+        }
+    }
+}

+ 3 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingOrderServiceImpl.java

@@ -643,6 +643,9 @@ public class StoreBookingOrderServiceImpl extends ServiceImpl<StoreOrderMapper,
             detail.setUnitPrice(unit);
             detail.setQuantity(qty);
             detail.setSubtotalAmount(sub);
+            // 新写入的明细默认未做行免单/行折扣;结账时由 StoreBookingCheckoutServiceImpl 再改
+            detail.setFreeDish(0);
+            detail.setDiscountFlag(0);
             detail.setAddUserId(userId);
             detail.setAddUserPhone(userPhone);
             detail.setIsAddDish(addDish ? 1 : 0);

+ 100 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreServiceFeeRuleServiceImpl.java

@@ -22,11 +22,14 @@ import shop.alien.store.service.StoreBookingTableService;
 import shop.alien.store.service.StoreServiceFeeRuleService;
 import shop.alien.util.common.JwtUtil;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -47,6 +50,7 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
 
     private static final String MODE_PERMANENT = "PERMANENT";
     private static final String MODE_CUSTOM = "CUSTOM";
+    private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
     private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
     private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
 
@@ -516,5 +520,101 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
         }
         return null;
     }
+
+    /**
+     * 结账/预览用:在指定时间点,汇总某店某桌「当前命中」的服务费。
+     * 每条规则行对应一个时段切片;同一桌可有多条记录,命中多条则金额相加。
+     */
+    @Override
+    public BigDecimal computeMatchedServiceFee(Integer storeId, Integer tableId, Integer dinerCount, BigDecimal consumeAmount, Date at) {
+        if (storeId == null || tableId == null || at == null) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        // 人数至少按 1 人计费,避免 null/0 导致按人规则为 0
+        int people = dinerCount != null && dinerCount > 0 ? dinerCount : 1;
+        BigDecimal consume = consumeAmount != null ? consumeAmount : BigDecimal.ZERO;
+
+        // 统一用上海时区拆出「日期」「时刻」「星期位图」:weekdayBit 与 weekday_mask 按位与判断是否生效
+        LocalDateTime ldt = at.toInstant().atZone(SHANGHAI).toLocalDateTime();
+        LocalDate today = ldt.toLocalDate();
+        LocalTime nowTime = ldt.toLocalTime();
+        int weekdayBit = 1 << (ldt.getDayOfWeek().getValue() - 1);
+
+        java.sql.Date todaySql = java.sql.Date.valueOf(today);
+
+        // 拉出该桌下所有启用规则(含多条时段),再在内存里过滤日期/星期/时间窗
+        LambdaQueryWrapper<StoreServiceFeeRule> w = new LambdaQueryWrapper<>();
+        w.eq(StoreServiceFeeRule::getStoreId, storeId)
+                .eq(StoreServiceFeeRule::getTableId, tableId)
+                .eq(StoreServiceFeeRule::getStatus, 1);
+        List<StoreServiceFeeRule> rules = ruleMapper.selectList(w);
+        if (rules == null || rules.isEmpty()) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+
+        BigDecimal sum = BigDecimal.ZERO;
+        for (StoreServiceFeeRule r : rules) {
+            if (!isRuleDateActive(r, todaySql)) {
+                continue;
+            }
+            if (r.getWeekdayMask() == null || (r.getWeekdayMask() & weekdayBit) == 0) {
+                continue;
+            }
+            if (r.getStartTime() == null || r.getEndTime() == null) {
+                continue;
+            }
+            // 时间窗左闭右开 [start, end),与配置端「开始必须早于结束」一致
+            if (nowTime.isBefore(r.getStartTime()) || !nowTime.isBefore(r.getEndTime())) {
+                continue;
+            }
+            sum = sum.add(computeSingleRuleAmount(r, people, consume));
+        }
+        return sum.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    /** 自定义日期是否在区间内;永久生效则始终为 true */
+    private boolean isRuleDateActive(StoreServiceFeeRule r, java.sql.Date todaySql) {
+        if (MODE_PERMANENT.equals(r.getEffectiveMode())) {
+            return true;
+        }
+        if (!MODE_CUSTOM.equals(r.getEffectiveMode())) {
+            return false;
+        }
+        if (r.getStartDate() == null || r.getEndDate() == null) {
+            return false;
+        }
+        LocalDate s = new java.sql.Date(r.getStartDate().getTime()).toLocalDate();
+        LocalDate e = new java.sql.Date(r.getEndDate().getTime()).toLocalDate();
+        LocalDate t = todaySql.toLocalDate();
+        return !t.isBefore(s) && !t.isAfter(e);
+    }
+
+    /**
+     * 单条规则金额:feeType 1=人数×单价;2=每桌固定;3=消费额×比例(feeValue&gt;1 时按百分比理解,先 /100)。
+     */
+    private BigDecimal computeSingleRuleAmount(StoreServiceFeeRule r, int dinerCount, BigDecimal consumeAmount) {
+        BigDecimal v = r.getFeeValue() != null ? r.getFeeValue() : BigDecimal.ZERO;
+        Integer ft = r.getFeeType();
+        if (ft == null) {
+            return BigDecimal.ZERO;
+        }
+        if (ft == 1) {
+            return v.multiply(BigDecimal.valueOf(dinerCount)).setScale(2, RoundingMode.HALF_UP);
+        }
+        if (ft == 2) {
+            return v.setScale(2, RoundingMode.HALF_UP);
+        }
+        if (ft == 3) {
+            if (consumeAmount.compareTo(BigDecimal.ZERO) <= 0) {
+                return BigDecimal.ZERO;
+            }
+            BigDecimal ratio = v;
+            if (ratio.compareTo(BigDecimal.ONE) > 0) {
+                ratio = ratio.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP);
+            }
+            return consumeAmount.multiply(ratio).setScale(2, RoundingMode.HALF_UP);
+        }
+        return BigDecimal.ZERO;
+    }
 }