|
@@ -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 视为小数比例;>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;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|