This commit is contained in:
dzc committed 2026-04-13 14:46:58 +08:00
commit 4edabb8da5
16 files changed
+733 -1

No files matched your search

@@ -18,4 +18,8 @@ public final class CanteenDeliveryOrderStatus {
public static final int COMPLETED = 3;
/** 已取消 */
public static final int CANCELLED = 4;
/**
* 预约单:已到单但未达「提前 N 分钟派骑手」时间点,不展示在骑手待配送列表。
*/
public static final int SCHEDULED_PENDING = 5;
}
@@ -5,6 +5,9 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
/**
@@ -59,4 +62,19 @@ public class CanteenDeliveryOrderSyncDTO {
@Schema(description = "完整收货地址(展示用)")
private String fullAddress;
@Schema(description = "IMMEDIATE立即送(默认)| SCHEDULED预约时段;与硬件订单一致")
private String expectDeliveryType;
@Schema(description = "预约送达日期(SCHEDULED 必填)")
private LocalDate expectDeliveryDate;
@Schema(description = "预约时段开始(SCHEDULED 必填)")
private LocalTime expectDeliveryStart;
@Schema(description = "预约时段结束(SCHEDULED 必填)")
private LocalTime expectDeliveryEnd;
@Schema(description = "立即送预计送达时间(可选,如前端计算的「大约 09:36 送达」)")
private LocalDateTime expectArriveTime;
}
@@ -7,7 +7,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@Data
@TenantTable
@@ -37,6 +39,31 @@ public class CanteenDeliveryOrderEntity extends Model<CanteenDeliveryOrderEntity
private String assignType;
private Long riderUserId;
private Integer status;
@Schema(description = "IMMEDIATE立即送 SCHEDULED预约时段")
private String expectDeliveryType;
@Schema(description = "预约送达日期(仅 SCHEDULED)")
private LocalDate expectDeliveryDate;
@Schema(description = "预约时段开始")
private LocalTime expectDeliveryStart;
@Schema(description = "预约时段结束")
private LocalTime expectDeliveryEnd;
@Schema(description = "立即送时预计送达时间(可选)")
private LocalDateTime expectArriveTime;
@Schema(description = "计划向骑手派单+推送时间(预约单;= 时段开始 - 配置分钟)")
private LocalDateTime scheduledRiderNotifyTime;
@Schema(description = "计划通知商家时间(预约单;= 时段开始 - 配置分钟)")
private LocalDateTime scheduledMerchantNotifyTime;
@Schema(description = "0 待通知商家 1 已通知(预约单)")
private Integer merchantNotifyDone;
private String pickupPhotoUrl;
private String deliveryPhotoUrl;
@@ -4,7 +4,9 @@ import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryCartItemDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
@Data
@@ -21,6 +23,14 @@ public class CanteenDeliveryOrderAppVO {
private String merchantLogo;
private String fullAddress;
private Integer status;
@Schema(description = "IMMEDIATE立即送 SCHEDULED预约时段")
private String expectDeliveryType;
private LocalDate expectDeliveryDate;
private LocalTime expectDeliveryStart;
private LocalTime expectDeliveryEnd;
private LocalDateTime expectArriveTime;
private String pickupPhotoUrl;
private String deliveryPhotoUrl;
private LocalDateTime createTime;
@@ -28,4 +28,19 @@ public interface CanteenDeliveryOrderService extends IService<CanteenDeliveryOrd
R<Void> pickup(Long riderUserId, CanteenDeliveryPhotoDTO dto);
R<Void> deliver(Long riderUserId, CanteenDeliveryPhotoDTO dto);
/**
* 预约单:处理已到点的商家通知、骑手派单+推送。由 XXL-JOB 定时触发(见 {@link com.bsd.pjcloud.canteen.job.CanteenDeliveryScheduledJob})。
*/
void runDueScheduledDeliveryTasks();
/**
* 单条:商家提醒(事务内行锁,幂等)。
*/
void notifyMerchantIfDue(Long deliveryOrderId);
/**
* 单条:骑手派单+推送(事务内行锁;无可用骑手时保持待调度状态下次重试)。
*/
void dispatchScheduledRiderIfDue(Long deliveryOrderId);
}
@@ -7,9 +7,11 @@ import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteParamService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.canteen.constants.CanteenDeliveryAssignType;
import com.bsd.pjcloud.canteen.constants.CanteenDeliveryOrderStatus;
import com.bsd.pjcloud.canteen.constants.CanteenDeliveryScheduleConstants;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryCartItemDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryPhotoDTO;
@@ -21,6 +23,7 @@ import com.bsd.pjcloud.canteen.mapper.CanteenDeliveryAssignSeqMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDeliveryOrderMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenRiderBuildingBindMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenRiderPoolMapper;
import com.bsd.pjcloud.canteen.push.CanteenDeliveryMerchantNotifyPublisher;
import com.bsd.pjcloud.canteen.push.CanteenDeliveryRiderPushPublisher;
import com.bsd.pjcloud.canteen.service.CanteenDeliveryOrderService;
import com.bsd.pjcloud.canteen.vo.app.CanteenDeliveryOrderAppVO;
@@ -32,15 +35,18 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Slf4j
@Service
@RequiredArgsConstructor
public class CanteenDeliveryOrderServiceImpl extends ServiceImpl<CanteenDeliveryOrderMapper, CanteenDeliveryOrderEntity>
@@ -53,6 +59,8 @@ public class CanteenDeliveryOrderServiceImpl extends ServiceImpl<CanteenDelivery
private final RemoteDeptService remoteDeptService;
private final ObjectMapper objectMapper;
private final CanteenDeliveryRiderPushPublisher riderPushPublisher;
private final CanteenDeliveryMerchantNotifyPublisher merchantNotifyPublisher;
private final RemoteParamService remoteParamService;
private final RemoteHardOrderService remoteHardOrderService;
@Override
@@ -89,11 +97,52 @@ public class CanteenDeliveryOrderServiceImpl extends ServiceImpl<CanteenDelivery
entity.setMerchantLogo(dto.getLogoPic());
entity.setBuildingId(dto.getBuildingId());
entity.setFullAddress(dto.getFullAddress());
String rawExpectType = StrUtil.emptyToDefault(dto.getExpectDeliveryType(), CanteenDeliveryScheduleConstants.EXPECT_TYPE_IMMEDIATE).trim();
if (CanteenDeliveryScheduleConstants.EXPECT_TYPE_SCHEDULED.equalsIgnoreCase(rawExpectType)) {
entity.setExpectDeliveryType(CanteenDeliveryScheduleConstants.EXPECT_TYPE_SCHEDULED);
} else {
entity.setExpectDeliveryType(CanteenDeliveryScheduleConstants.EXPECT_TYPE_IMMEDIATE);
}
entity.setExpectDeliveryDate(dto.getExpectDeliveryDate());
entity.setExpectDeliveryStart(dto.getExpectDeliveryStart());
entity.setExpectDeliveryEnd(dto.getExpectDeliveryEnd());
entity.setExpectArriveTime(dto.getExpectArriveTime());
try {
entity.setCarListJson(objectMapper.writeValueAsString(dto.getCarList()));
} catch (JsonProcessingException e) {
return R.failed("购物车序列化失败");
}
String expectType = entity.getExpectDeliveryType();
if (!CanteenDeliveryScheduleConstants.EXPECT_TYPE_SCHEDULED.equals(expectType)
|| entity.getExpectDeliveryDate() == null || entity.getExpectDeliveryStart() == null) {
entity.setMerchantNotifyDone(1);
entity.setScheduledMerchantNotifyTime(null);
entity.setScheduledRiderNotifyTime(null);
assignRiderOnCreate(entity);
save(entity);
scheduleRiderPushAfterAssign(entity);
return R.ok(entity.getId());
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime slotStart = LocalDateTime.of(entity.getExpectDeliveryDate(), entity.getExpectDeliveryStart());
int riderBefore = resolveNotifyBeforeMinutes(CanteenDeliveryScheduleConstants.PARAM_RIDER_NOTIFY_BEFORE_MINUTES,
CanteenDeliveryScheduleConstants.DEFAULT_RIDER_NOTIFY_BEFORE_MINUTES);
int merchantBefore = resolveNotifyBeforeMinutes(CanteenDeliveryScheduleConstants.PARAM_MERCHANT_NOTIFY_BEFORE_MINUTES,
CanteenDeliveryScheduleConstants.DEFAULT_MERCHANT_NOTIFY_BEFORE_MINUTES);
entity.setScheduledRiderNotifyTime(slotStart.minusMinutes(riderBefore));
entity.setScheduledMerchantNotifyTime(slotStart.minusMinutes(merchantBefore));
entity.setMerchantNotifyDone(0);
if (entity.getScheduledRiderNotifyTime().isAfter(now)) {
entity.setStatus(CanteenDeliveryOrderStatus.SCHEDULED_PENDING);
entity.setAssignType(CanteenDeliveryAssignType.NONE);
entity.setRiderUserId(null);
save(entity);
return R.ok(entity.getId());
}
assignRiderOnCreate(entity);
save(entity);
scheduleRiderPushAfterAssign(entity);
@@ -334,6 +383,113 @@ public class CanteenDeliveryOrderServiceImpl extends ServiceImpl<CanteenDelivery
return R.ok();
}
@Override
public void runDueScheduledDeliveryTasks() {
LocalDateTime now = LocalDateTime.now();
List<CanteenDeliveryOrderEntity> merchantDue = list(Wrappers.<CanteenDeliveryOrderEntity>lambdaQuery()
.eq(CanteenDeliveryOrderEntity::getExpectDeliveryType, CanteenDeliveryScheduleConstants.EXPECT_TYPE_SCHEDULED)
.and(w -> w.eq(CanteenDeliveryOrderEntity::getMerchantNotifyDone, 0)
.or()
.isNull(CanteenDeliveryOrderEntity::getMerchantNotifyDone))
.isNotNull(CanteenDeliveryOrderEntity::getScheduledMerchantNotifyTime)
.le(CanteenDeliveryOrderEntity::getScheduledMerchantNotifyTime, now)
.select(CanteenDeliveryOrderEntity::getId)
.last("LIMIT 500"));
for (CanteenDeliveryOrderEntity row : merchantDue) {
try {
notifyMerchantIfDue(row.getId());
} catch (Exception ex) {
log.warn("notifyMerchantIfDue id={}", row.getId(), ex);
}
}
List<CanteenDeliveryOrderEntity> riderDue = list(Wrappers.<CanteenDeliveryOrderEntity>lambdaQuery()
.eq(CanteenDeliveryOrderEntity::getStatus, CanteenDeliveryOrderStatus.SCHEDULED_PENDING)
.isNotNull(CanteenDeliveryOrderEntity::getScheduledRiderNotifyTime)
.le(CanteenDeliveryOrderEntity::getScheduledRiderNotifyTime, now)
.select(CanteenDeliveryOrderEntity::getId)
.last("LIMIT 500"));
for (CanteenDeliveryOrderEntity row : riderDue) {
try {
dispatchScheduledRiderIfDue(row.getId());
} catch (Exception ex) {
log.warn("dispatchScheduledRiderIfDue id={}", row.getId(), ex);
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void notifyMerchantIfDue(Long deliveryOrderId) {
if (deliveryOrderId == null) {
return;
}
CanteenDeliveryOrderEntity e = getOne(Wrappers.<CanteenDeliveryOrderEntity>lambdaQuery()
.eq(CanteenDeliveryOrderEntity::getId, deliveryOrderId)
.last("FOR UPDATE"));
if (e == null) {
return;
}
if (!CanteenDeliveryScheduleConstants.EXPECT_TYPE_SCHEDULED.equalsIgnoreCase(
StrUtil.trim(StrUtil.emptyToDefault(e.getExpectDeliveryType(), "")))) {
return;
}
Integer done = e.getMerchantNotifyDone();
if (done != null && done != 0) {
return;
}
if (e.getScheduledMerchantNotifyTime() == null || e.getScheduledMerchantNotifyTime().isAfter(LocalDateTime.now())) {
return;
}
merchantNotifyPublisher.publishScheduledOrderNotice(e);
e.setMerchantNotifyDone(1);
updateById(e);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void dispatchScheduledRiderIfDue(Long deliveryOrderId) {
if (deliveryOrderId == null) {
return;
}
CanteenDeliveryOrderEntity e = getOne(Wrappers.<CanteenDeliveryOrderEntity>lambdaQuery()
.eq(CanteenDeliveryOrderEntity::getId, deliveryOrderId)
.last("FOR UPDATE"));
if (e == null || !Objects.equals(e.getStatus(), CanteenDeliveryOrderStatus.SCHEDULED_PENDING)) {
return;
}
if (e.getScheduledRiderNotifyTime() == null || e.getScheduledRiderNotifyTime().isAfter(LocalDateTime.now())) {
return;
}
assignRiderOnCreate(e);
if (e.getRiderUserId() == null) {
e.setStatus(CanteenDeliveryOrderStatus.SCHEDULED_PENDING);
updateById(e);
return;
}
updateById(e);
scheduleRiderPushAfterAssign(e);
}
private int resolveNotifyBeforeMinutes(String paramKey, int defaultVal) {
try {
R<String> r = remoteParamService.getByKey(paramKey);
if (r == null || !r.isOk() || StrUtil.isBlank(r.getData())) {
return defaultVal;
}
int v = Integer.parseInt(r.getData().trim());
if (v < 0) {
return defaultVal;
}
if (v > 24 * 60) {
return 24 * 60;
}
return v;
} catch (Exception ex) {
return defaultVal;
}
}
/**
* 来源订单号即硬件外卖订单主键(与同步时 dto.id 一致)。非数字则跳过(非硬件单)。
* 同步失败抛异常,触发事务回滚,避免本地状态与硬件订单不一致。
@@ -53,7 +53,7 @@ public class HardAppCreateOrderDto {
// 地址
private HardAppTakeOutAddressDto address;
// 预约时间信息
// 预约时间信息(外卖:立即送/预约时段)
private HardAppTakeOutApptDto appt;
}
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.hard.api.dto.order.app;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@@ -27,6 +28,7 @@ public class HardAppCreateTakeOutOrderDto {
* 外卖预约信息
*/
@NotNull(message = "预约时间信息不能为空")
@Schema(description = "立即送/预约时段;字段见 HardAppTakeOutApptDto")
private HardAppTakeOutApptDto appt;
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.hard.api.vo.order.app;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/**
* 某一配送日的可预约时段列表(多天查询时按天分组)。
*/
@Data
@Schema(description = "单日可预约配送时段")
public class HardTakeOutDeliveryTimeDayVO {
@Schema(description = "配送日")
private LocalDate expectDate;
@Schema(description = "该日可选时段")
private List<HardTakeOutDeliveryTimeSlotVO> slots;
}
@@ -0,0 +1,48 @@
package com.bsd.pjcloud.hard.api.vo.order.app;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/**
* 配送详情页:根据商户 {@code jobTime} 生成可预约时段;支持连续多天(今天/明天/后天等)。
*/
@Data
@Schema(description = "外卖可预约配送时段(按商户营业时间切分)")
public class HardTakeOutDeliveryTimeOptionVO {
@Schema(description = "商户ID")
private Long merchantId;
@Schema(description = "商户名称")
private String merchantName;
@Schema(description = "原始营业时间串,如 09:00-12:00,16:00-19:00")
private String jobTime;
@Schema(description = "本次查询的起始配送日(未传 expectDate 时服务端为「今天」)")
private LocalDate startDate;
@Schema(description = "连续查询的天数(含起始日),如 3 表示今天+明天+后天")
private Integer dayCount;
@Schema(description = "每格时长(分钟)")
private Integer slotMinutes;
@Schema(description = "「今天」时,过滤掉当前时刻之前不足 leadMinutes 分钟的格子;其它日期不按此过滤")
private Integer leadMinutes;
@Schema(description = "jobTime 是否解析出有效营业时段")
private Boolean jobTimeValid;
@Schema(description = "按天分组的时段;前端左侧「今天/明天」切换时取对应元素的 slots")
private List<HardTakeOutDeliveryTimeDayVO> daySlots;
/**
* 仅当 {@code dayCount == 1} 时与 {@link #daySlots} 中单日 slots 一致,便于旧前端兼容;多天时请使用 {@link #daySlots}。
*/
@Schema(description = "单日查询时与 daySlots[0].slots 相同;多天时为 null")
private List<HardTakeOutDeliveryTimeSlotVO> slots;
}
@@ -0,0 +1,23 @@
package com.bsd.pjcloud.hard.api.vo.order.app;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalTime;
/**
* 外卖预约可选时段(与 {@link com.bsd.pjcloud.hard.api.dto.order.app.HardAppTakeOutApptDto} 中 expectDeliveryStart/End 对应)。
*/
@Data
@Schema(description = "外卖预约可选时段")
public class HardTakeOutDeliveryTimeSlotVO {
@Schema(description = "时段开始")
@JsonFormat(pattern = "HH:mm:ss")
private LocalTime expectDeliveryStart;
@Schema(description = "时段结束")
@JsonFormat(pattern = "HH:mm:ss")
private LocalTime expectDeliveryEnd;
}
@@ -40,12 +40,14 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateTakeOutOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeOptionVO;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.api.vo.order.HardOnlineCanteenStatsVo;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOnlineCanteenStatsService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.HardTakeOutDeliveryTimeOptionService;
import com.bsd.pjcloud.hard.service.HardOrderSyncService;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -60,8 +62,10 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.IOException;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
@@ -82,6 +86,7 @@ import java.util.Map;
public class HardOrderController {
private final HardOrderService hardwareOrderService;
private final HardTakeOutDeliveryTimeOptionService takeOutDeliveryTimeOptionService;
private final HardManagerService hardManagerService;
private final HardOnlineCanteenStatsService hardOnlineCanteenStatsService;
private final HardOrderSyncService hardOrderSyncService;
@@ -206,6 +211,20 @@ public class HardOrderController {
return R.ok(hardwareOrderService.appCreateOrder(dto));
}
@Operation(summary = "外卖-查询可预约配送时段(支持连续多天)",
description = "配送详情页:按商户 MchManage.jobTime 切分时段;与下单校验一致。"
+ "不传 expectDate 时从「今天」起算;days 为连续天数(含起始日),如 days=3 表示今天+明天+后天。"
+ "仅「日历日为今天」时按 leadMinutes 过滤已过近的格子。")
@GetMapping("/app/take-out/delivery-time-slots")
public R<HardTakeOutDeliveryTimeOptionVO> takeOutDeliveryTimeSlots(
@RequestParam Long merchantId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expectDate,
@RequestParam(required = false) Integer days,
@RequestParam(required = false) Integer slotMinutes,
@RequestParam(required = false) Integer leadMinutes) {
return takeOutDeliveryTimeOptionService.listDeliveryTimeSlots(merchantId, expectDate, days, slotMinutes, leadMinutes);
}
@Operation(summary = "app创建食堂外卖订单", description = "app创建食堂外卖订单")
@PostMapping("/app/create-take-out-order")
@Idempotent(expireTime = 3, key = "'/app/hard-order/app/create-take-out-order' + #dto.getCarParentId() ", info = "正在创建订单中,请勿重复操作")
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeOptionVO;
import java.time.LocalDate;
/**
* 外卖配送详情:按商户 {@code jobTime} 生成可下单预约时段。
*/
public interface HardTakeOutDeliveryTimeOptionService {
/**
* @param merchantId 商户主键
* @param expectDate 起始配送日,{@code null} 表示从「今天」起算
* @param days 连续天数(含起始日),默认 1,最大 14;如 3 表示今天+明天+后天(起始日为今天时)
* @param slotMinutes 切分步长(分钟),默认 30
* @param leadMinutes 仅对「日历日为今天」的格子生效:去掉当前时刻 + leadMinutes 之前的格子;默认 30
*/
R<HardTakeOutDeliveryTimeOptionVO> listDeliveryTimeSlots(Long merchantId, LocalDate expectDate, Integer days, Integer slotMinutes, Integer leadMinutes);
}
@@ -0,0 +1,118 @@
package com.bsd.pjcloud.hard.service.impl;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeDayVO;
import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeOptionVO;
import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeSlotVO;
import com.bsd.pjcloud.hard.service.HardTakeOutDeliveryTimeOptionService;
import com.bsd.pjcloud.hard.util.MchJobTimeUtils;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class HardTakeOutDeliveryTimeOptionServiceImpl implements HardTakeOutDeliveryTimeOptionService {
private static final int DEFAULT_SLOT_MINUTES = 30;
private static final int DEFAULT_LEAD_MINUTES = 30;
private static final int DEFAULT_DAY_COUNT = 1;
private static final int MAX_DAY_COUNT = 14;
private static final int MAX_SLOT_MINUTES = 24 * 60;
private final RemoteMchService remoteMchService;
@Override
public R<HardTakeOutDeliveryTimeOptionVO> listDeliveryTimeSlots(Long merchantId, LocalDate expectDate, Integer days, Integer slotMinutes, Integer leadMinutes) {
if (merchantId == null) {
return R.failed("商户ID不能为空");
}
LocalDate today = LocalDate.now();
LocalDate startDate = expectDate != null ? expectDate : today;
if (startDate.isBefore(today)) {
return R.failed("不能选择过去的日期");
}
int dayCount = days == null || days < 1 ? DEFAULT_DAY_COUNT : Math.min(days, MAX_DAY_COUNT);
int sm = slotMinutes == null || slotMinutes <= 0 ? DEFAULT_SLOT_MINUTES : Math.min(slotMinutes, MAX_SLOT_MINUTES);
int lm = leadMinutes == null || leadMinutes < 0 ? DEFAULT_LEAD_MINUTES : leadMinutes;
R<MchManageVo> r = remoteMchService.getById(merchantId);
if (r == null || !r.isOk() || r.getData() == null) {
return R.failed("商户不存在");
}
MchManageVo mch = r.getData();
String jobTime = mch.getJobTime();
HardTakeOutDeliveryTimeOptionVO vo = new HardTakeOutDeliveryTimeOptionVO();
vo.setMerchantId(merchantId);
vo.setMerchantName(mch.getName());
vo.setJobTime(jobTime);
vo.setStartDate(startDate);
vo.setDayCount(dayCount);
vo.setSlotMinutes(sm);
vo.setLeadMinutes(lm);
if (MchJobTimeUtils.isBlankJobTime(jobTime)) {
vo.setJobTimeValid(false);
vo.setDaySlots(List.of());
vo.setSlots(List.of());
return R.ok(vo);
}
List<MchJobTimeUtils.TimeRange> ranges = MchJobTimeUtils.splitJobTimeIntoSlots(jobTime, sm);
if (ranges.isEmpty()) {
vo.setJobTimeValid(false);
vo.setDaySlots(List.of());
vo.setSlots(List.of());
return R.ok(vo);
}
vo.setJobTimeValid(true);
List<HardTakeOutDeliveryTimeDayVO> daySlots = new ArrayList<>(dayCount);
for (int i = 0; i < dayCount; i++) {
LocalDate d = startDate.plusDays(i);
HardTakeOutDeliveryTimeDayVO dayVo = new HardTakeOutDeliveryTimeDayVO();
dayVo.setExpectDate(d);
dayVo.setSlots(buildSlotsForCalendarDay(d, ranges, today, lm));
daySlots.add(dayVo);
}
vo.setDaySlots(daySlots);
if (dayCount == 1 && daySlots.size() == 1) {
vo.setSlots(daySlots.get(0).getSlots());
} else {
vo.setSlots(null);
}
return R.ok(vo);
}
private static List<HardTakeOutDeliveryTimeSlotVO> buildSlotsForCalendarDay(
LocalDate expectDate,
List<MchJobTimeUtils.TimeRange> ranges,
LocalDate today,
int leadMinutes) {
LocalDateTime cutoff = null;
if (expectDate.equals(today)) {
cutoff = LocalDateTime.now().plusMinutes(leadMinutes);
}
List<HardTakeOutDeliveryTimeSlotVO> slots = new ArrayList<>(ranges.size());
for (MchJobTimeUtils.TimeRange tr : ranges) {
if (cutoff != null) {
LocalDateTime slotStart = LocalDateTime.of(expectDate, tr.start);
if (slotStart.isBefore(cutoff)) {
continue;
}
}
HardTakeOutDeliveryTimeSlotVO row = new HardTakeOutDeliveryTimeSlotVO();
row.setExpectDeliveryStart(tr.start);
row.setExpectDeliveryEnd(tr.end);
slots.add(row);
}
return slots;
}
}
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson.JSON;
@@ -54,6 +55,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppTakeOutApptDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto;
import com.bsd.pjcloud.hard.api.dto.order.xfj.HardXfjFastCreateOrderDto;
import com.bsd.pjcloud.hard.api.dto.spu.DeductionSpuStockDto;
@@ -70,6 +72,7 @@ import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.util.MchJobTimeUtils;
import com.bsd.pjcloud.hard.api.vo.wechat.WechatMicropayVo;
import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
@@ -602,6 +605,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
UserInfo userDetail = userService.getInnerByUserId(dto.getUserId()).getData();
if (ObjectUtil.isEmpty(userDetail)) throw new RuntimeException("用户不存在不存在");
validateTakeOutExpectDelivery(dto);
// 如果是预约判断是否在预约时间段内
boolean inReserveTime = isInReserveTime(dto.getApptId());
if (!inReserveTime) throw new RuntimeException("不在预约时间段内");
@@ -624,6 +629,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 查询商户
MchManageVo hardManager = remoteMchService.getById(parentCar.getMerchantId()).getData();
if (ObjectUtil.isEmpty(hardManager)) throw new RuntimeException("商户不存在");
validateTakeOutApptAgainstMerchantJobTime(hardManager, dto.getAppt());
// 查询父购物车
List<HardCarEntity> carList = hardCarService.getByParentCarId(dto.getCarParentId());
if (ObjectUtil.isEmpty(carList)) throw new RuntimeException("购物车不存在");
@@ -726,6 +732,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
canteenDeliveryOrderSyncDTO.setId(String.valueOf(orderObj.getId()));
canteenDeliveryOrderSyncDTO.setFullAddress(dto.getAddress().getFullAddress());
canteenDeliveryOrderSyncDTO.setBuildingId(dto.getAddress().getBuildingId());
fillCanteenExpectDeliveryFromAppt(canteenDeliveryOrderSyncDTO, dto.getAppt());
remoteCanteenDeliveryOrderService.syncFromOrder(canteenDeliveryOrderSyncDTO);
return orderObj.getId();
@@ -1888,6 +1895,82 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
);
}
/**
* 预约/立即送时间须落在商户 {@link com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity#getJobTime()} 营业时段内(如 {@code 09:00-12:00,16:00-19:00})。
*/
private void validateTakeOutApptAgainstMerchantJobTime(MchManageVo merchant, HardAppTakeOutApptDto appt) {
if (appt == null || merchant == null) {
return;
}
String type = StrUtil.emptyToDefault(appt.getExpectDeliveryType(), "IMMEDIATE").trim();
if ("IMMEDIATE".equalsIgnoreCase(type)) {
if (appt.getExpectArriveTime() != null) {
if (MchJobTimeUtils.isBlankJobTime(merchant.getJobTime())) {
throw new RuntimeException("商户未配置营业时间");
}
if (!MchJobTimeUtils.isTimeWithinJobTime(merchant.getJobTime(), appt.getExpectArriveTime().toLocalTime())) {
throw new RuntimeException("预计送达时间不在商户营业时间内,请重新选择");
}
}
return;
}
if (!"SCHEDULED".equalsIgnoreCase(type)) {
return;
}
if (MchJobTimeUtils.isBlankJobTime(merchant.getJobTime())) {
throw new RuntimeException("商户未配置营业时间,暂不可预约配送");
}
if (MchJobTimeUtils.mergeJobTimeIntervals(merchant.getJobTime()).isEmpty()) {
throw new RuntimeException("商户营业时间格式无效,请先在后台正确配置 jobTime");
}
if (!MchJobTimeUtils.isDeliverySlotWithinJobTime(merchant.getJobTime(), appt.getExpectDeliveryStart(), appt.getExpectDeliveryEnd())) {
throw new RuntimeException("预约时段不在商户营业时间内,请重新选择");
}
}
/**
* 外卖预约配送:字段在 {@link HardAppTakeOutApptDto};SCHEDULED 须带日期与时段;立即送默认 IMMEDIATE。
*/
private void validateTakeOutExpectDelivery(HardAppCreateOrderDto dto) {
if (dto.getAppt() == null) {
return;
}
HardAppTakeOutApptDto a = dto.getAppt();
String t = StrUtil.emptyToDefault(a.getExpectDeliveryType(), "IMMEDIATE");
if (StrUtil.isBlank(t) || "IMMEDIATE".equalsIgnoreCase(t.trim())) {
return;
}
if (!"SCHEDULED".equalsIgnoreCase(t.trim())) {
throw new RuntimeException("配送时间类型无效");
}
if (a.getExpectDeliveryDate() == null || a.getExpectDeliveryStart() == null || a.getExpectDeliveryEnd() == null) {
throw new RuntimeException("预约配送须选择日期与时段");
}
if (!a.getExpectDeliveryEnd().isAfter(a.getExpectDeliveryStart())) {
throw new RuntimeException("预约时段结束须晚于开始");
}
}
/**
* 将外卖请求中的 {@code appt} 写入食堂外送同步 DTO(与 {@link HardTakeOutApptEntity} 语义一致)。
*/
private void fillCanteenExpectDeliveryFromAppt(CanteenDeliveryOrderSyncDTO sync, HardAppTakeOutApptDto appt) {
if (appt == null) {
sync.setExpectDeliveryType("IMMEDIATE");
return;
}
String raw = StrUtil.emptyToDefault(appt.getExpectDeliveryType(), "IMMEDIATE").trim();
if ("SCHEDULED".equalsIgnoreCase(raw)) {
sync.setExpectDeliveryType("SCHEDULED");
} else {
sync.setExpectDeliveryType("IMMEDIATE");
}
sync.setExpectDeliveryDate(appt.getExpectDeliveryDate());
sync.setExpectDeliveryStart(appt.getExpectDeliveryStart());
sync.setExpectDeliveryEnd(appt.getExpectDeliveryEnd());
sync.setExpectArriveTime(appt.getExpectArriveTime());
}
// 是否在预约时间段内
private boolean isInReserveTime(Long apptId) {
if (ObjectUtil.isEmpty(apptId)) return true;
@@ -0,0 +1,167 @@
package com.bsd.pjcloud.hard.util;
import cn.hutool.core.util.StrUtil;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* 商户 {@code jobTime} 解析:如 {@code "09:00-12:00,12:00-14:00,16:00-19:00"},用于校验预约/预计送达时刻是否在营业时间内。
*/
public final class MchJobTimeUtils {
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("HH:mm");
/** 兼容 jobTime 配置与前端 LocalTime 序列化(可带秒) */
private static final DateTimeFormatter HM_OPTIONAL_SEC = new DateTimeFormatterBuilder()
.appendPattern("HH:mm")
.optionalStart()
.appendPattern(":ss")
.optionalEnd()
.toFormatter();
private MchJobTimeUtils() {
}
public static boolean isBlankJobTime(String jobTime) {
return StrUtil.isBlank(StrUtil.trim(jobTime));
}
/**
* 预约配送时段 [slotStart, slotEnd] 是否完全落在某一合并后的营业时段内(闭区间,含边界)。
*/
public static boolean isDeliverySlotWithinJobTime(String jobTime, LocalTime slotStart, LocalTime slotEnd) {
if (slotStart == null || slotEnd == null) {
return false;
}
if (!slotEnd.isAfter(slotStart)) {
return false;
}
List<TimeRange> merged = mergeJobTimeIntervals(jobTime);
if (merged.isEmpty()) {
return false;
}
for (TimeRange r : merged) {
if (!r.start.isAfter(slotStart) && !r.end.isBefore(slotEnd)) {
return true;
}
}
return false;
}
/**
* 单个时刻(如立即送预计送达的时间部分)是否落在营业时间内。
*/
public static boolean isTimeWithinJobTime(String jobTime, LocalTime t) {
if (t == null) {
return false;
}
List<TimeRange> merged = mergeJobTimeIntervals(jobTime);
for (TimeRange r : merged) {
if (!t.isBefore(r.start) && !t.isAfter(r.end)) {
return true;
}
}
return false;
}
/**
* 解析并合并相邻或重叠的时段(如 09:00-12:00 与 12:00-14:00 合并为 09:00-14:00)。
*/
public static List<TimeRange> mergeJobTimeIntervals(String jobTime) {
List<TimeRange> raw = new ArrayList<>();
if (isBlankJobTime(jobTime)) {
return raw;
}
for (String part : jobTime.split(",")) {
part = StrUtil.trim(part);
if (StrUtil.isBlank(part)) {
continue;
}
String[] se = part.split("-");
if (se.length != 2) {
continue;
}
LocalTime a = parseHm(StrUtil.trim(se[0]));
LocalTime b = parseHm(StrUtil.trim(se[1]));
if (a == null || b == null) {
continue;
}
if (!b.isAfter(a)) {
continue;
}
raw.add(new TimeRange(a, b));
}
if (raw.isEmpty()) {
return raw;
}
raw.sort(Comparator.comparing(r -> r.start));
List<TimeRange> merged = new ArrayList<>();
TimeRange cur = raw.get(0);
for (int i = 1; i < raw.size(); i++) {
TimeRange next = raw.get(i);
if (!next.start.isAfter(cur.end)) {
if (next.end.isAfter(cur.end)) {
cur = new TimeRange(cur.start, next.end);
}
} else {
merged.add(cur);
cur = next;
}
}
merged.add(cur);
return merged;
}
/**
* 将合并后的营业时段按固定步长切成预约格子(整段落在原时段内;最后不足一个步长则丢弃)。
*
* @param jobTime 商户 jobTime 原始串
* @param slotMinutes 每格时长(分钟),如 30
*/
public static List<TimeRange> splitJobTimeIntoSlots(String jobTime, int slotMinutes) {
if (slotMinutes <= 0) {
return List.of();
}
List<TimeRange> merged = mergeJobTimeIntervals(jobTime);
List<TimeRange> out = new ArrayList<>();
for (TimeRange r : merged) {
LocalTime cursor = r.start;
while (true) {
LocalTime slotEnd = cursor.plusMinutes(slotMinutes);
if (slotEnd.isAfter(r.end)) {
break;
}
out.add(new TimeRange(cursor, slotEnd));
cursor = slotEnd;
}
}
return out;
}
private static LocalTime parseHm(String s) {
try {
return LocalTime.parse(s, HM_OPTIONAL_SEC);
} catch (DateTimeParseException e) {
try {
return LocalTime.parse(s, HM);
} catch (DateTimeParseException e2) {
return null;
}
}
}
public static final class TimeRange {
public final LocalTime start;
public final LocalTime end;
public TimeRange(LocalTime start, LocalTime end) {
this.start = start;
this.end = end;
}
}
}