feat:周期餐段

This commit is contained in:
dzc committed 2026-07-21 21:04:18 +08:00
1 parent be37903a1a
commit 6b2a95cc65
34 files changed
+2010 -106

No files matched your search

@@ -0,0 +1,4 @@
-- 预约规则增加限制模式。历史规则继续按原有动态时间窗执行。
ALTER TABLE hard_appt
ADD COLUMN reserve_mode varchar(32) NOT NULL DEFAULT 'TIME_WINDOW'
COMMENT '预约限制模式(TIME_WINDOW动态时间窗,WEEKLY_CYCLE按周集中预约)' AFTER end_time_type;
+99
View File
@@ -0,0 +1,99 @@
-- 可配置周期预约规则。上一版 reserve_mode 已进入测试环境,因此本脚本只做增量变更。
CREATE TABLE hard_appt_cycle_rule
(
id BIGINT(19) auto_increment comment '编号'
primary key,
rule_name VARCHAR(64) not null comment '规则名称',
mch_id BIGINT(19) not null comment '商户ID',
open_weekday TINYINT not null comment '开放星期(1周一至7周日)',
open_time TIME not null comment '开放时间',
close_weekday TINYINT not null comment '截止星期(1周一至7周日)',
close_time TIME not null comment '截止时间',
target_start_offset_days SMALLINT not null comment '截止日期后第几天开始可预约',
target_day_count SMALLINT not null comment '连续可预约天数',
status VARCHAR(32) not null comment '状态',
sort INT default 0 not null comment '排序',
remark VARCHAR(256) null comment '备注',
create_by VARCHAR(64) null comment '创建人',
create_time DATETIME null comment '创建时间',
update_by VARCHAR(64) null comment '修改人',
update_time DATETIME null comment '修改时间',
del_flag CHAR default '0' null comment '删除标记',
tenant_id BIGINT(19) null comment '租户ID',
dept_id BIGINT(19) null comment '部门ID',
index idx_hard_appt_cycle_rule_mch_status (tenant_id, mch_id, status, del_flag)
) comment '周期预约规则';
CREATE TABLE hard_appt_cycle_rule_item
(
id BIGINT(19) auto_increment comment '编号'
primary key,
rule_id BIGINT(19) not null comment '周期预约规则ID',
mch_id BIGINT(19) not null comment '商户ID',
target_day_offset SMALLINT not null comment '相对目标起始日偏移(从0开始)',
hour_type VARCHAR(32) not null comment '餐端字典值(daily_menu)',
create_by VARCHAR(64) null comment '创建人',
create_time DATETIME null comment '创建时间',
update_by VARCHAR(64) null comment '修改人',
update_time DATETIME null comment '修改时间',
del_flag CHAR default '0' null comment '删除标记',
tenant_id BIGINT(19) null comment '租户ID',
index idx_hard_appt_cycle_item_rule (tenant_id, rule_id, del_flag),
index idx_hard_appt_cycle_item_hour (tenant_id, mch_id, hour_type, del_flag)
) comment '周期预约目标日餐段明细';
ALTER TABLE hard_order
ADD COLUMN appt_rule_id BIGINT(19) NULL COMMENT '周期预约规则ID' AFTER appt_id,
ADD INDEX idx_hard_order_appt_rule (tenant_id, appt_rule_id, del_flag);
-- 将测试环境中上一版固定周期规则转换为每个商户一条默认可配置规则。
INSERT INTO hard_appt_cycle_rule
(rule_name, mch_id, open_weekday, open_time, close_weekday, close_time,
target_start_offset_days, target_day_count, status, sort, remark,
create_time, del_flag, tenant_id, dept_id)
SELECT '默认周期预约规则',
a.mch_id,
5,
'17:00:00',
7,
'22:00:00',
2,
7,
CASE WHEN SUM(CASE WHEN a.status = '1' THEN 1 ELSE 0 END) > 0 THEN '1' ELSE '0' END,
0,
'由固定周期预约规则自动迁移',
NOW(),
'0',
a.tenant_id,
MAX(a.dept_id)
FROM hard_appt a
WHERE a.reserve_mode = 'WEEKLY_CYCLE'
AND a.del_flag = '0'
GROUP BY a.tenant_id, a.mch_id;
INSERT INTO hard_appt_cycle_rule_item
(rule_id, mch_id, target_day_offset, hour_type, create_time, del_flag, tenant_id)
SELECT r.id,
a.mch_id,
offsets.target_day_offset,
a.hour_type,
NOW(),
'0',
a.tenant_id
FROM hard_appt_cycle_rule r
INNER JOIN hard_appt a
ON r.mch_id = a.mch_id
AND r.tenant_id <=> a.tenant_id
AND a.reserve_mode = 'WEEKLY_CYCLE'
AND a.hour_type IS NOT NULL
AND a.del_flag = '0'
CROSS JOIN (SELECT 0 AS target_day_offset
UNION ALL SELECT 1
UNION ALL SELECT 2
UNION ALL SELECT 3
UNION ALL SELECT 4
UNION ALL SELECT 5
UNION ALL SELECT 6) offsets
WHERE r.rule_name = '默认周期预约规则'
AND r.remark = '由固定周期预约规则自动迁移'
AND r.del_flag = '0';
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.hard.api.constant;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 订餐预约限制模式。
*/
@AllArgsConstructor
@Getter
public enum HardApptReserveModeEnum {
TIME_WINDOW("TIME_WINDOW", "动态时间窗"),
WEEKLY_CYCLE("WEEKLY_CYCLE", "按周集中预约"),
;
private final String type;
private final String description;
public static HardApptReserveModeEnum valueOfByType(String type) {
if (ObjectUtil.isEmpty(type)) {
return TIME_WINDOW;
}
return Arrays.stream(values())
.filter(value -> ObjectUtil.equals(type, value.getType()))
.findFirst()
.orElse(null);
}
}
@@ -35,4 +35,13 @@ public enum HardApptTimeTypeEnum {
return valueOfByType(type);
}
public static HardApptTimeTypeEnum valueOfByDay(int day) {
return Arrays.stream(values()).filter(value -> value.getDay() == day).findFirst().orElse(null);
}
public static boolean isReservationDateType(String type) {
HardApptTimeTypeEnum value = valueOfByType(type);
return value != null && value.getDay() >= 0;
}
}
@@ -15,6 +15,7 @@ public enum HardHourTypeEnum {
MORNING("1", "早餐"),
NOONING("2", "午餐"),
NIGHT("3", "晚餐"),
LATE_NIGHT("4", "夜间餐"),
;
private final String type;
private final String description;
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 周期预约规则提交对象。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class HardApptCycleRuleDto extends HardApptCycleRuleEntity {
private List<HardApptCycleRuleItemDto> itemList;
}
@@ -0,0 +1,14 @@
package com.bsd.pjcloud.hard.api.dto;
import lombok.Data;
/**
* 周期规则目标日餐段提交项。
*/
@Data
public class HardApptCycleRuleItemDto {
private Integer targetDayOffset;
private String hourType;
}
@@ -44,6 +44,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private LocalDateTime[] apptUseDateTimeList;
/** 预约用餐日期下限,用于查询今天及之后的未核销预约。 */
private LocalDateTime apptUseDateStart;
private List<String> orderTypeCodes;
private List<String> orderPayTypeCodes;
@@ -10,6 +10,8 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@@ -50,9 +52,18 @@ public class HardAppCreateOrderDto {
// 预约id
private Long apptId;
// 周期预约规则id;动态时间窗不传
private Long apptRuleId;
// 餐端;周期预约必传,动态时间窗由 apptId 对应规则确定
private String hourType;
// 预约时间类型
private String apptTimeType;
// 实际预约日期;新客户端必须传递,apptTimeType 仅用于兼容旧客户端
private LocalDate apptDate;
// 地址
private HardAppTakeOutAddressDto address;
@@ -0,0 +1,91 @@
package com.bsd.pjcloud.hard.api.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 商户周期预约规则。
*/
@Data
@TenantTable
@TableName("hard_appt_cycle_rule")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "周期预约规则")
public class HardApptCycleRuleEntity extends Model<HardApptCycleRuleEntity> {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "编号")
private Long id;
@Schema(description = "规则名称")
private String ruleName;
@Schema(description = "商户ID")
private Long mchId;
@Schema(description = "开放星期(1周一至7周日)")
private Integer openWeekday;
@Schema(description = "开放时间")
private LocalTime openTime;
@Schema(description = "截止星期(1周一至7周日)")
private Integer closeWeekday;
@Schema(description = "截止时间")
private LocalTime closeTime;
@Schema(description = "截止日期后第几天开始可预约")
private Integer targetStartOffsetDays;
@Schema(description = "连续可预约天数")
private Integer targetDayCount;
@Schema(description = "状态")
private String status;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "备注")
private String remark;
@Schema(description = "部门ID")
private Long deptId;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建人")
private String createBy;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改人")
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改时间")
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
@Schema(description = "删除标记")
private String delFlag;
@Schema(description = "租户ID")
private Long tenantId;
}
@@ -0,0 +1,66 @@
package com.bsd.pjcloud.hard.api.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 周期预约规则中的目标日餐段。
*/
@Data
@TenantTable
@TableName("hard_appt_cycle_rule_item")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "周期预约目标日餐段明细")
public class HardApptCycleRuleItemEntity extends Model<HardApptCycleRuleItemEntity> {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "编号")
private Long id;
@Schema(description = "周期预约规则ID")
private Long ruleId;
@Schema(description = "商户ID")
private Long mchId;
@Schema(description = "相对目标起始日偏移(从0开始)")
private Integer targetDayOffset;
@Schema(description = "餐端字典值(daily_menu)")
private String hourType;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建人")
private String createBy;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改人")
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改时间")
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
@Schema(description = "删除标记")
private String delFlag;
@Schema(description = "租户ID")
private Long tenantId;
}
@@ -65,6 +65,12 @@ public class HardApptEntity extends Model<HardApptEntity> {
@Schema(description = "结束时间")
private String endTimeType;
/**
* 预约限制模式
*/
@Schema(description = "预约限制模式(TIME_WINDOW动态时间窗,WEEKLY_CYCLE按周集中预约)")
private String reserveMode;
/**
* 预约时间段
*/
@@ -136,4 +142,4 @@ public class HardApptEntity extends Model<HardApptEntity> {
*/
@Schema(description = "租户ID")
private Long tenantId;
}
}
@@ -129,6 +129,12 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
@Schema(description = "预约时间段id")
private Long apptId;
/**
* 周期预约规则id
*/
@Schema(description = "周期预约规则id")
private Long apptRuleId;
/**
* 预约使用时间(天)
*/
@@ -303,4 +309,4 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
*/
@Schema(description = "租户ID")
private Long tenantId;
}
}
@@ -0,0 +1,15 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleItemEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 周期预约规则餐段明细。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class HardApptCycleRuleItemVo extends HardApptCycleRuleItemEntity {
private String hourTypeName;
}
@@ -0,0 +1,23 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 周期预约规则详情。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class HardApptCycleRuleVo extends HardApptCycleRuleEntity {
private String merchantName;
private Integer itemCount;
private Integer apptCount;
private List<HardApptCycleRuleItemVo> itemList;
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.hard.api.vo;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/**
* App v3 按实际用餐日期聚合的预约餐段。
*/
@Data
public class HardApptDateOptionsVo {
/** 实际用餐日期。 */
private LocalDate apptDate;
/** 当日期属于旧版今天/明天/后天范围时返回,用于平滑升级。 */
private String apptTimeType;
private List<HardApptOptionVo> apptList;
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* App 订餐预约餐段选项。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class HardApptOptionVo extends HardApptEntity {
/** 周期预约规则ID;动态时间窗为 null。 */
private Long apptRuleId;
/** 周期预约直接使用餐端名称;动态时间窗继续使用 hard_appt.title。 */
private String hourTypeName;
/** 当前时刻是否允许提交该日期和餐段。 */
private Boolean available;
/** 不可预约时供客户端展示的简短原因。 */
private String unavailableReason;
/** 该日期和餐段本批次的实际开放时间。 */
private LocalDateTime reserveStartDateTime;
/** 该日期和餐段本批次的实际截止时间。 */
private LocalDateTime reserveEndDateTime;
}
@@ -77,6 +77,8 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
private String apptHourType;
private String apptReserveMode;
private LocalTime apptStartTime;
private LocalTime apptEndTime;
@@ -12,15 +12,14 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.dto.HardApptDto;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeTypeVo;
import com.bsd.pjcloud.hard.api.vo.HardApptVo;
import com.bsd.pjcloud.hard.service.HardApptReservationService;
import com.bsd.pjcloud.hard.service.HardApptService;
import com.bsd.pjcloud.hard.service.HardApptTimeService;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -31,12 +30,7 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 设备预约管理
@@ -49,7 +43,7 @@ import java.util.stream.Collectors;
public class HardApptController {
private final HardApptService hardApptService;
private final HardApptTimeService hardApptTimeService;
private final HardApptReservationService hardApptReservationService;
/* ************************************* start app start ************************************* */
@@ -60,6 +54,7 @@ public class HardApptController {
List<HardApptEntity> list = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getMchId()), HardApptEntity::getMchId, dto.getMchId())
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
.eq(HardApptEntity::getReserveMode, HardApptReserveModeEnum.TIME_WINDOW.getType())
.orderByAsc(HardApptEntity::getHourType)
.orderByDesc(HardApptEntity::getSort)
);
@@ -70,38 +65,14 @@ public class HardApptController {
@ApiCryptoAes
@GetMapping("/app/v2/list")
public R<List<HardApptTimeCascadeVo>> appV2List(@ParameterObject HardApptDto dto) {
if (ObjectUtil.isEmpty(dto.getMchId())) throw new RuntimeException("商户ID不能为空");
return R.ok(hardApptReservationService.listLegacyOptions(dto.getMchId()));
}
ArrayList<HardApptTimeCascadeVo> resultList = new ArrayList<>();
List<HardApptTimeEntity> apptTimeList = hardApptTimeService.list(new LambdaQueryWrapper<HardApptTimeEntity>()
.eq(HardApptTimeEntity::getMchId, dto.getMchId())
);
if (ObjectUtil.isEmpty(apptTimeList)) return R.ok(resultList);
// 查询mch商户下所有的预约
List<HardApptEntity> apptList = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>().eq(HardApptEntity::getMchId, dto.getMchId()));
// 分组
Map<String, List<HardApptTimeEntity>> apptTimeTypeMap = apptTimeList.stream().collect(Collectors.groupingBy(HardApptTimeEntity::getType));
LocalDate now = LocalDate.now();
for (Map.Entry<String, List<HardApptTimeEntity>> entries : apptTimeTypeMap.entrySet()) {
String key = entries.getKey();
HardApptTimeCascadeVo entity = new HardApptTimeCascadeVo();
HardApptTimeTypeEnum apptTimeType = HardApptTimeTypeEnum.valueOfByTypeOrToday(key);
entity.setApptTimeType(apptTimeType.getType());
entity.setApptTimeWeekStr(now.plusDays(apptTimeType.getDay()));
List<Long> appTIdList = apptTimeList.stream().filter(v -> ObjectUtil.equals(key, v.getType())).map(HardApptTimeEntity::getApptId).toList();
List<HardApptEntity> targetApptList = apptList.stream().filter(v -> appTIdList.contains(v.getId())).toList();
entity.setApptList(targetApptList);
resultList.add(entity);
}
return R.ok(resultList);
@Operation(summary = "app预约时间列表v3", description = "按实际用餐日期返回动态时间窗与按周集中预约餐段")
@ApiCryptoAes
@GetMapping("/app/v3/list")
public R<List<HardApptDateOptionsVo>> appV3List(@ParameterObject HardApptDto dto) {
return R.ok(hardApptReservationService.listOptions(dto.getMchId()));
}
/* ************************************* end app end ************************************* */
@@ -0,0 +1,77 @@
package com.bsd.pjcloud.hard.controller;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.hard.api.dto.HardApptCycleRuleDto;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleVo;
import com.bsd.pjcloud.hard.service.HardApptCycleRuleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/hard-appt-cycle-rule")
@Tag(description = "hard-appt-cycle-rule", name = "周期预约规则管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardApptCycleRuleController {
private final HardApptCycleRuleService cycleRuleService;
@Operation(summary = "周期预约规则分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('hard_hardAppt_view')")
public R<Page<HardApptCycleRuleVo>> page(@ParameterObject Page page,
@ParameterObject HardApptCycleRuleDto dto) {
return R.ok(cycleRuleService.scopePage(page, dto, DataScope.of()));
}
@Operation(summary = "周期预约规则详情")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('hard_hardAppt_view')")
public R<HardApptCycleRuleVo> detail(@PathVariable Long id) {
return R.ok(cycleRuleService.detail(id));
}
@Operation(summary = "新增周期预约规则")
@SysLog("新增周期预约规则")
@PostMapping
@PreAuthorize("@pms.hasPermission('hard_hardAppt_add')")
public R<Long> create(@RequestBody HardApptCycleRuleDto dto) {
return R.ok(cycleRuleService.create(dto));
}
@Operation(summary = "修改周期预约规则")
@SysLog("修改周期预约规则")
@PutMapping
@PreAuthorize("@pms.hasPermission('hard_hardAppt_edit')")
public R<Long> edit(@RequestBody HardApptCycleRuleDto dto) {
return R.ok(cycleRuleService.edit(dto));
}
@Operation(summary = "删除周期预约规则")
@SysLog("删除周期预约规则")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('hard_hardAppt_del')")
public R<Void> delete(@RequestBody Long[] ids) {
cycleRuleService.delete(CollUtil.toList(ids));
return R.ok();
}
}
@@ -74,6 +74,7 @@ import java.time.LocalDate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -140,19 +141,13 @@ public class HardOrderController {
@GetMapping("/app/user-appt-list")
public R<List<HardOrderVo>> userApptList() {
HardOrderDto dto = new HardOrderDto();
LocalDateTime now = LocalDateTime.now();
LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
PjcloudUser user = SecurityUtils.getUser();
dto.setUserId(user.getId());
dto.setIsFindCarList(true);
dto.setIsFindApptList(true);
dto.setCreateTimeList(
new LocalDateTime[]{
now.withHour(0).withMinute(0).withSecond(0),
now.withHour(23).withMinute(59).withSecond(59)
}
);
dto.setApptUseDateStart(today.atStartOfDay());
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType()));
return R.ok(hardwareOrderService.scopeList(dto, null));
@@ -289,7 +284,13 @@ public class HardOrderController {
@ApiCryptoAes
@GetMapping("/app/create-order")
@Idempotent(expireTime = 1, key = "'/app/hard-order/app/create-order' + #carParentId ", info = "正在创建订单中,请勿重复操作")
public R appCreateOrder(@RequestParam Long carParentId, @RequestParam Long apptId, @RequestParam(required = false) String apptTimeType, @RequestParam HardTypeEnum hardType) {
public R appCreateOrder(@RequestParam Long carParentId,
@RequestParam(required = false) Long apptId,
@RequestParam(required = false) Long apptRuleId,
@RequestParam(required = false) String hourType,
@RequestParam(required = false) String apptTimeType,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate apptDate,
@RequestParam HardTypeEnum hardType) {
PjcloudUser user = SecurityUtils.getUser();
HardAppCreateOrderDto dto = HardAppCreateOrderDto.builder()
.carParentId(carParentId)
@@ -299,6 +300,9 @@ public class HardOrderController {
.orderType(HardOrderTypeEnum.RESERVE)
.payType(HardPayTypeEnum.WALLET_DEFAULT)
.apptId(apptId)
.apptRuleId(apptRuleId)
.hourType(hourType)
.apptDate(apptDate)
.build();
if (ObjectUtil.isNotEmpty(apptTimeType)) {
dto.setApptTimeType(apptTimeType);
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.hard.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleItemEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HardApptCycleRuleItemMapper extends PjcloudBaseMapper<HardApptCycleRuleItemEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.hard.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HardApptCycleRuleMapper extends PjcloudBaseMapper<HardApptCycleRuleEntity> {
}
@@ -0,0 +1,25 @@
package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.hard.api.dto.HardApptCycleRuleDto;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleVo;
import java.util.List;
public interface HardApptCycleRuleService extends IService<HardApptCycleRuleEntity> {
Page<HardApptCycleRuleVo> scopePage(Page page, HardApptCycleRuleDto dto, DataScope dataScope);
HardApptCycleRuleVo detail(Long id);
Long create(HardApptCycleRuleDto dto);
Long edit(HardApptCycleRuleDto dto);
void delete(List<Long> ids);
List<HardApptCycleRuleVo> listEnabledConfigurations(Long mchId);
}
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import java.time.LocalDate;
import java.util.List;
/**
* 订餐预约规则查询与下单校验。
*/
public interface HardApptReservationService {
List<HardApptTimeCascadeVo> listLegacyOptions(Long mchId);
List<HardApptDateOptionsVo> listOptions(Long mchId);
ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType,
LocalDate apptDate, String legacyApptTimeType);
record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId) {
}
}
@@ -0,0 +1,305 @@
package com.bsd.pjcloud.hard.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.dto.HardApptCycleRuleDto;
import com.bsd.pjcloud.hard.api.dto.HardApptCycleRuleItemDto;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleItemEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleItemVo;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleVo;
import com.bsd.pjcloud.hard.mapper.HardApptCycleRuleItemMapper;
import com.bsd.pjcloud.hard.mapper.HardApptCycleRuleMapper;
import com.bsd.pjcloud.hard.service.HardApptCycleRuleService;
import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator;
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 org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class HardApptCycleRuleServiceImpl
extends ServiceImpl<HardApptCycleRuleMapper, HardApptCycleRuleEntity>
implements HardApptCycleRuleService {
private final HardApptCycleRuleItemMapper itemMapper;
private final RemoteMchService remoteMchService;
@Override
public Page<HardApptCycleRuleVo> scopePage(Page page, HardApptCycleRuleDto dto, DataScope dataScope) {
LambdaQueryWrapper<HardApptCycleRuleEntity> wrapper = new LambdaQueryWrapper<HardApptCycleRuleEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getMchId()), HardApptCycleRuleEntity::getMchId, dto.getMchId())
.like(ObjectUtil.isNotEmpty(dto.getRuleName()), HardApptCycleRuleEntity::getRuleName, dto.getRuleName())
.eq(ObjectUtil.isNotEmpty(dto.getStatus()), HardApptCycleRuleEntity::getStatus, dto.getStatus())
.orderByDesc(HardApptCycleRuleEntity::getSort)
.orderByDesc(HardApptCycleRuleEntity::getCreateTime);
Page<HardApptCycleRuleEntity> entityPage = baseMapper.selectPageByScope(page, wrapper, dataScope);
Page<HardApptCycleRuleVo> result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
List<HardApptCycleRuleVo> records = toVos(entityPage.getRecords(), false);
fillMerchantNames(records);
result.setRecords(records);
return result;
}
@Override
public HardApptCycleRuleVo detail(Long id) {
HardApptCycleRuleEntity entity = baseMapper.selectById(id);
if (entity == null) {
throw new RuntimeException("周期预约规则不存在");
}
return toVos(List.of(entity), true).get(0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(HardApptCycleRuleDto dto) {
List<HardApptCycleRuleItemDto> items = normalizeAndValidate(dto, null);
HardApptCycleRuleEntity entity = BeanUtil.toBean(dto, HardApptCycleRuleEntity.class);
baseMapper.insert(entity);
saveItems(entity, items);
return entity.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long edit(HardApptCycleRuleDto dto) {
if (dto.getId() == null) {
throw new RuntimeException("周期预约规则ID不能为空");
}
HardApptCycleRuleEntity existing = baseMapper.selectById(dto.getId());
if (existing == null) {
throw new RuntimeException("周期预约规则不存在");
}
if (ObjectUtil.notEqual(existing.getMchId(), dto.getMchId())) {
throw new RuntimeException("周期预约规则不允许更换商户");
}
List<HardApptCycleRuleItemDto> items = normalizeAndValidate(dto, dto.getId());
baseMapper.updateById(BeanUtil.toBean(dto, HardApptCycleRuleEntity.class));
itemMapper.delete(new LambdaQueryWrapper<HardApptCycleRuleItemEntity>()
.eq(HardApptCycleRuleItemEntity::getRuleId, dto.getId()));
saveItems(dto, items);
return dto.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
itemMapper.delete(new LambdaQueryWrapper<HardApptCycleRuleItemEntity>()
.in(HardApptCycleRuleItemEntity::getRuleId, ids));
baseMapper.deleteBatchIds(ids);
}
@Override
public List<HardApptCycleRuleVo> listEnabledConfigurations(Long mchId) {
if (mchId == null) {
return List.of();
}
List<HardApptCycleRuleEntity> rules = baseMapper.selectList(
new LambdaQueryWrapper<HardApptCycleRuleEntity>()
.eq(HardApptCycleRuleEntity::getMchId, mchId)
.eq(HardApptCycleRuleEntity::getStatus, YesNoEnum.YES.getCode())
.orderByDesc(HardApptCycleRuleEntity::getSort)
.orderByAsc(HardApptCycleRuleEntity::getId));
return toVos(rules, true);
}
private List<HardApptCycleRuleItemDto> normalizeAndValidate(HardApptCycleRuleDto dto, Long excludeId) {
dto.setRuleName(dto.getRuleName() == null ? null : dto.getRuleName().trim());
if (ObjectUtil.isEmpty(dto.getRuleName()) || ObjectUtil.isEmpty(dto.getMchId())) {
throw new RuntimeException("规则名称和商户不能为空");
}
if (ObjectUtil.isEmpty(dto.getStatus())) {
dto.setStatus(YesNoEnum.YES.getCode());
} else if (!ObjectUtil.equals(dto.getStatus(), YesNoEnum.YES.getCode())
&& !ObjectUtil.equals(dto.getStatus(), YesNoEnum.NO.getCode())) {
throw new RuntimeException("周期预约规则状态无效");
}
if (dto.getSort() == null) {
dto.setSort(0);
}
HardApptCycleRuleCalculator.validate(dto);
long sameNameCount = baseMapper.selectCount(new LambdaQueryWrapper<HardApptCycleRuleEntity>()
.eq(HardApptCycleRuleEntity::getMchId, dto.getMchId())
.eq(HardApptCycleRuleEntity::getRuleName, dto.getRuleName())
.ne(excludeId != null, HardApptCycleRuleEntity::getId, excludeId));
if (sameNameCount > 0) {
throw new RuntimeException("同一商户的规则名称不能重复");
}
List<HardApptCycleRuleItemDto> items = normalizeItems(dto.getItemList(), dto.getTargetDayCount());
R<MchManageVo> merchantResult = remoteMchService.getById(dto.getMchId());
MchManageVo merchant = merchantResult == null || !merchantResult.isOk() ? null : merchantResult.getData();
if (merchant == null) {
throw new RuntimeException("商户不存在");
}
dto.setDeptId(merchant.getDeptId());
if (YesNoEnum.YES.getCode().equals(dto.getStatus())) {
validateConflict(dto, items, excludeId);
}
dto.setItemList(items);
return items;
}
private List<HardApptCycleRuleItemDto> normalizeItems(List<HardApptCycleRuleItemDto> source, int dayCount) {
if (source == null || source.isEmpty()) {
throw new RuntimeException("至少需要配置一个目标日餐段");
}
Map<String, HardApptCycleRuleItemDto> distinct = new LinkedHashMap<>();
for (HardApptCycleRuleItemDto item : source) {
if (item == null || item.getTargetDayOffset() == null || ObjectUtil.isEmpty(item.getHourType())) {
throw new RuntimeException("目标日餐段配置不完整");
}
if (item.getTargetDayOffset() < 0 || item.getTargetDayOffset() >= dayCount) {
throw new RuntimeException("目标日偏移超出连续预约天数");
}
HardHourTypeEnum hourType = HardHourTypeEnum.valueOfByType(item.getHourType());
if (hourType == null) {
throw new RuntimeException("目标日餐端无效");
}
item.setHourType(hourType.getType());
distinct.put(item.getTargetDayOffset() + ":" + item.getHourType(), item);
}
return new ArrayList<>(distinct.values());
}
/**
* 周规则无限重复,同餐端只要落在相同目标星期,就会在未来某个实际日期重合。
*/
private void validateConflict(HardApptCycleRuleDto candidate,
List<HardApptCycleRuleItemDto> candidateItems,
Long excludeId) {
List<HardApptCycleRuleEntity> existingRules = baseMapper.selectList(
new LambdaQueryWrapper<HardApptCycleRuleEntity>()
.eq(HardApptCycleRuleEntity::getMchId, candidate.getMchId())
.eq(HardApptCycleRuleEntity::getStatus, YesNoEnum.YES.getCode())
.ne(excludeId != null, HardApptCycleRuleEntity::getId, excludeId));
if (existingRules.isEmpty()) {
return;
}
Map<Long, HardApptCycleRuleEntity> ruleMap = existingRules.stream()
.collect(Collectors.toMap(HardApptCycleRuleEntity::getId, value -> value));
List<HardApptCycleRuleItemEntity> existingItems = itemMapper.selectList(
new LambdaQueryWrapper<HardApptCycleRuleItemEntity>()
.in(HardApptCycleRuleItemEntity::getRuleId, ruleMap.keySet()));
Map<ConflictKey, HardApptCycleRuleEntity> occupied = new HashMap<>();
for (HardApptCycleRuleItemEntity item : existingItems) {
HardApptCycleRuleEntity rule = ruleMap.get(item.getRuleId());
if (rule != null) {
occupied.putIfAbsent(new ConflictKey(item.getHourType(),
HardApptCycleRuleCalculator.targetWeekday(rule, item.getTargetDayOffset())), rule);
}
}
for (HardApptCycleRuleItemDto item : candidateItems) {
int weekday = HardApptCycleRuleCalculator.targetWeekday(candidate, item.getTargetDayOffset());
HardApptCycleRuleEntity conflictRule = occupied.get(new ConflictKey(item.getHourType(), weekday));
if (conflictRule != null) {
throw new RuntimeException(String.format("餐段【%s】在周%s与规则【%s】冲突",
hourTypeLabel(item.getHourType()), weekdayLabel(weekday),
conflictRule.getRuleName()));
}
}
}
private void saveItems(HardApptCycleRuleEntity rule, List<HardApptCycleRuleItemDto> items) {
List<HardApptCycleRuleItemEntity> entities = items.stream().map(item -> {
HardApptCycleRuleItemEntity entity = new HardApptCycleRuleItemEntity();
entity.setRuleId(rule.getId());
entity.setMchId(rule.getMchId());
entity.setTargetDayOffset(item.getTargetDayOffset());
entity.setHourType(item.getHourType());
return entity;
}).toList();
itemMapper.insert(entities);
}
private List<HardApptCycleRuleVo> toVos(List<HardApptCycleRuleEntity> rules, boolean includeItems) {
if (rules == null || rules.isEmpty()) {
return List.of();
}
List<Long> ruleIds = rules.stream().map(HardApptCycleRuleEntity::getId).toList();
List<HardApptCycleRuleItemEntity> items = itemMapper.selectList(
new LambdaQueryWrapper<HardApptCycleRuleItemEntity>()
.in(HardApptCycleRuleItemEntity::getRuleId, ruleIds)
.orderByAsc(HardApptCycleRuleItemEntity::getTargetDayOffset)
.orderByAsc(HardApptCycleRuleItemEntity::getHourType));
Map<Long, List<HardApptCycleRuleItemEntity>> itemGroups = items.stream()
.collect(Collectors.groupingBy(HardApptCycleRuleItemEntity::getRuleId));
return rules.stream().map(rule -> {
HardApptCycleRuleVo vo = BeanUtil.toBean(rule, HardApptCycleRuleVo.class);
List<HardApptCycleRuleItemEntity> ruleItems = itemGroups.getOrDefault(rule.getId(), List.of());
vo.setItemCount(ruleItems.size());
vo.setApptCount((int) ruleItems.stream().map(HardApptCycleRuleItemEntity::getHourType).distinct().count());
if (includeItems) {
List<HardApptCycleRuleItemVo> itemVos = ruleItems.stream().map(item -> {
HardApptCycleRuleItemVo itemVo = BeanUtil.toBean(item, HardApptCycleRuleItemVo.class);
itemVo.setHourTypeName(hourTypeLabel(item.getHourType()));
return itemVo;
}).toList();
vo.setItemList(itemVos);
}
return vo;
}).toList();
}
private void fillMerchantNames(List<HardApptCycleRuleVo> records) {
List<Long> mchIds = records.stream().map(HardApptCycleRuleVo::getMchId)
.filter(ObjectUtil::isNotNull).distinct().toList();
if (mchIds.isEmpty()) {
return;
}
R<List<MchManageVo>> merchantResult = remoteMchService.getByIds(mchIds);
List<MchManageVo> merchants = merchantResult == null || !merchantResult.isOk()
? List.of() : merchantResult.getData();
if (merchants == null || merchants.isEmpty()) {
return;
}
Map<Long, String> merchantNames = merchants.stream()
.filter(merchant -> ObjectUtil.isAllNotEmpty(merchant.getId(), merchant.getName()))
.collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left));
records.forEach(record -> record.setMerchantName(merchantNames.get(record.getMchId())));
}
private static String weekdayLabel(int weekday) {
return switch (weekday) {
case 1 -> "一";
case 2 -> "二";
case 3 -> "三";
case 4 -> "四";
case 5 -> "五";
case 6 -> "六";
case 7 -> "日";
default -> String.valueOf(weekday);
};
}
private static String hourTypeLabel(String hourType) {
HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType);
return value == null ? hourType : value.getDescription();
}
private record ConflictKey(String hourType, int weekday) {
}
}
@@ -0,0 +1,403 @@
package com.bsd.pjcloud.hard.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleItemVo;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleVo;
import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptOptionVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import com.bsd.pjcloud.hard.service.HardApptCycleRuleService;
import com.bsd.pjcloud.hard.service.HardApptReservationService;
import com.bsd.pjcloud.hard.service.HardApptService;
import com.bsd.pjcloud.hard.service.HardApptTimeService;
import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* 预约列表和下单共用同一套规则,避免客户端展示状态与服务端最终校验不一致。
*/
@Service
@RequiredArgsConstructor
public class HardApptReservationServiceImpl implements HardApptReservationService {
static final ZoneId RESERVATION_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private final HardApptService hardApptService;
private final HardApptTimeService hardApptTimeService;
private final HardApptCycleRuleService cycleRuleService;
@Override
public List<HardApptTimeCascadeVo> listLegacyOptions(Long mchId) {
validateMerchantId(mchId);
List<HardApptEntity> dynamicAppts = findEnabledAppts(mchId).stream()
.filter(appt -> resolveMode(appt) == HardApptReserveModeEnum.TIME_WINDOW)
.toList();
if (dynamicAppts.isEmpty()) {
return List.of();
}
Map<String, Set<Long>> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts);
LocalDate today = nowInShanghai().toLocalDate();
List<HardApptTimeCascadeVo> result = new ArrayList<>();
for (int day = 0; day <= HardApptTimeTypeEnum.AFTER_TOMORROW.getDay(); day++) {
HardApptTimeTypeEnum dateType = HardApptTimeTypeEnum.valueOfByDay(day);
Set<Long> mappedIds = apptIdsByDateType.getOrDefault(dateType.getType(), Set.of());
List<HardApptEntity> options = dynamicAppts.stream()
.filter(appt -> mappedIds.contains(appt.getId()))
.toList();
if (options.isEmpty()) {
continue;
}
HardApptTimeCascadeVo dateOptions = new HardApptTimeCascadeVo();
dateOptions.setApptTimeType(dateType.getType());
dateOptions.setApptTimeWeekStr(today.plusDays(day));
dateOptions.setApptList(options);
result.add(dateOptions);
}
return result;
}
@Override
public List<HardApptDateOptionsVo> listOptions(Long mchId) {
return listOptions(mchId, nowInShanghai());
}
List<HardApptDateOptionsVo> listOptions(Long mchId, LocalDateTime now) {
validateMerchantId(mchId);
List<HardApptEntity> allAppts = findEnabledAppts(mchId);
LocalDate today = now.toLocalDate();
Map<LocalDate, List<HardApptOptionVo>> optionsByDate = new TreeMap<>();
List<HardApptEntity> dynamicAppts = allAppts.stream()
.filter(appt -> resolveMode(appt) == HardApptReserveModeEnum.TIME_WINDOW)
.toList();
Map<String, Set<Long>> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts);
for (HardApptTimeTypeEnum dateType : HardApptTimeTypeEnum.values()) {
if (!HardApptTimeTypeEnum.isReservationDateType(dateType.getType())) {
continue;
}
Set<Long> mappedIds = apptIdsByDateType.getOrDefault(dateType.getType(), Set.of());
LocalDate targetDate = today.plusDays(dateType.getDay());
dynamicAppts.stream()
.filter(appt -> mappedIds.contains(appt.getId()))
.map(appt -> buildOption(appt, evaluateAvailability(appt, targetDate, now)))
.forEach(option -> optionsByDate.computeIfAbsent(targetDate, key -> new ArrayList<>()).add(option));
}
for (HardApptCycleRuleVo rule : cycleRuleService.listEnabledConfigurations(mchId)) {
HardApptCycleRuleCalculator.CycleBatch batch;
try {
HardApptCycleRuleCalculator.validate(rule);
batch = HardApptCycleRuleCalculator.resolveBatch(rule, now);
} catch (RuntimeException ignored) {
// 管理端会阻止非法规则;这里跳过历史脏数据,避免一条配置导致整个商户列表不可用。
continue;
}
Availability availability = cycleAvailability(rule, batch);
for (HardApptCycleRuleItemVo item : rule.getItemList()) {
HardHourTypeEnum hourType = HardHourTypeEnum.valueOfByType(item.getHourType());
if (hourType == null || item.getTargetDayOffset() == null
|| item.getTargetDayOffset() < 0
|| item.getTargetDayOffset() >= rule.getTargetDayCount()) {
continue;
}
LocalDate targetDate = batch.targetStartDate().plusDays(item.getTargetDayOffset());
HardApptOptionVo option = buildCycleOption(hourType, availability);
option.setApptRuleId(rule.getId());
optionsByDate.computeIfAbsent(targetDate, key -> new ArrayList<>()).add(option);
}
}
return optionsByDate.entrySet().stream().map(entry -> {
HardApptDateOptionsVo dateOptions = new HardApptDateOptionsVo();
dateOptions.setApptDate(entry.getKey());
long offset = ChronoUnit.DAYS.between(today, entry.getKey());
HardApptTimeTypeEnum legacyType = HardApptTimeTypeEnum.valueOfByDay((int) offset);
dateOptions.setApptTimeType(legacyType == null ? null : legacyType.getType());
dateOptions.setApptList(entry.getValue());
return dateOptions;
}).toList();
}
@Override
public ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType,
LocalDate apptDate, String legacyApptTimeType) {
return validateReservation(mchId, apptId, apptRuleId, hourType, apptDate, legacyApptTimeType, nowInShanghai());
}
ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType, LocalDate apptDate,
String legacyApptTimeType, LocalDateTime now) {
if (ObjectUtil.isEmpty(mchId)) {
throw new RuntimeException("预约信息不完整");
}
if (apptRuleId != null) {
return validateCycleReservation(mchId, apptRuleId, hourType, apptDate, now);
}
if (ObjectUtil.isEmpty(apptId)) {
throw new RuntimeException("动态预约餐段不能为空");
}
HardApptEntity appt = hardApptService.getById(apptId);
if (appt == null) {
throw new RuntimeException("预约餐段不存在");
}
if (ObjectUtil.notEqual(mchId, appt.getMchId())) {
throw new RuntimeException("预约餐段与当前商户不匹配");
}
if (ObjectUtil.notEqual(YesNoEnum.YES.getCode(), appt.getStatus())) {
throw new RuntimeException("预约餐段已停用");
}
HardApptReserveModeEnum reserveMode = resolveMode(appt);
if (reserveMode == null) {
throw new RuntimeException("预约规则模式无效");
}
LocalDate targetDate;
if (reserveMode != HardApptReserveModeEnum.TIME_WINDOW) {
throw new RuntimeException("周期预约请升级客户端后重试");
} else {
targetDate = resolveLegacyApptDate(apptDate, legacyApptTimeType, now.toLocalDate());
long dayOffset = ChronoUnit.DAYS.between(now.toLocalDate(), targetDate);
HardApptTimeTypeEnum dateType = HardApptTimeTypeEnum.valueOfByDay((int) dayOffset);
if (dateType == null || !HardApptTimeTypeEnum.isReservationDateType(dateType.getType())) {
throw new RuntimeException("该预约日期不在可选范围内");
}
long mappingCount = hardApptTimeService.count(new LambdaQueryWrapper<HardApptTimeEntity>()
.eq(HardApptTimeEntity::getMchId, mchId)
.eq(HardApptTimeEntity::getApptId, apptId)
.eq(HardApptTimeEntity::getType, dateType.getType()));
if (mappingCount == 0) {
throw new RuntimeException("该日期未配置此预约餐段");
}
}
Availability availability = evaluateAvailability(appt, targetDate, now);
if (!availability.available()) {
throw new RuntimeException(availability.unavailableReason());
}
return new ReservationValidation(appt, appt.getHourType(), targetDate, null);
}
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType,
LocalDate apptDate, LocalDateTime now) {
if (apptDate == null || HardHourTypeEnum.valueOfByType(hourType) == null) {
throw new RuntimeException("周期预约需要选择预约日期和餐端");
}
List<HardApptCycleRuleVo> rules = cycleRuleService.listEnabledConfigurations(mchId);
HardApptCycleRuleVo selected = rules.stream()
.filter(rule -> ObjectUtil.equals(rule.getId(), apptRuleId))
.findFirst()
.orElseThrow(() -> new RuntimeException("周期预约规则不存在或已停用"));
CycleMatch match = matchCycleRule(selected, hourType, apptDate, now);
if (match == null) {
throw new RuntimeException("该日期未配置此预约餐段");
}
if (!match.availability().available()) {
throw new RuntimeException(match.availability().unavailableReason());
}
return new ReservationValidation(null, hourType, apptDate, selected.getId());
}
private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType,
LocalDate apptDate, LocalDateTime now) {
try {
HardApptCycleRuleCalculator.validate(rule);
} catch (RuntimeException ignored) {
return null;
}
HardApptCycleRuleCalculator.CycleBatch batch = HardApptCycleRuleCalculator.resolveBatch(rule, now);
long offset = ChronoUnit.DAYS.between(batch.targetStartDate(), apptDate);
if (offset < 0 || offset >= rule.getTargetDayCount()) {
return null;
}
boolean configured = rule.getItemList() != null && rule.getItemList().stream()
.anyMatch(item -> ObjectUtil.equals(item.getHourType(), hourType)
&& ObjectUtil.equals(item.getTargetDayOffset(), (int) offset));
if (!configured) {
return null;
}
return new CycleMatch(rule, cycleAvailability(rule, batch));
}
private HardApptOptionVo buildOption(HardApptEntity appt, Availability availability) {
HardApptOptionVo option = BeanUtil.toBean(appt, HardApptOptionVo.class);
option.setAvailable(availability.available());
option.setUnavailableReason(availability.unavailableReason());
option.setReserveStartDateTime(availability.startDateTime());
option.setReserveEndDateTime(availability.endDateTime());
return option;
}
private HardApptOptionVo buildCycleOption(HardHourTypeEnum hourType, Availability availability) {
HardApptOptionVo option = new HardApptOptionVo();
option.setReserveMode(HardApptReserveModeEnum.WEEKLY_CYCLE.getType());
option.setHourType(hourType.getType());
option.setHourTypeName(hourType.getDescription());
option.setTitle(hourType.getDescription());
option.setAvailable(availability.available());
option.setUnavailableReason(availability.unavailableReason());
option.setReserveStartDateTime(availability.startDateTime());
option.setReserveEndDateTime(availability.endDateTime());
return option;
}
static Availability evaluateAvailability(HardApptEntity appt, LocalDate apptDate, LocalDateTime now) {
HardApptReserveModeEnum reserveMode = HardApptReserveModeEnum.valueOfByType(appt.getReserveMode());
if (reserveMode == null) {
return Availability.unavailable("预约规则模式无效");
}
if (reserveMode == HardApptReserveModeEnum.WEEKLY_CYCLE) {
return Availability.unavailable("周期预约规则配置不完整");
}
HardApptTimeTypeEnum startType = HardApptTimeTypeEnum.valueOfByType(appt.getStartTimeType());
HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType());
if (startType == null || endType == null || appt.getStartTime() == null || appt.getEndTime() == null) {
return Availability.unavailable("预约规则配置不完整");
}
LocalDateTime startDateTime = apptDate.plusDays(startType.getDay()).atTime(appt.getStartTime());
LocalDateTime endDateTime = apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime());
if (!endDateTime.isAfter(startDateTime)) {
return Availability.unavailable("预约规则时间范围无效");
}
if (now.isBefore(startDateTime)) {
return Availability.unavailable("预约尚未开放", startDateTime, endDateTime);
}
if (now.isAfter(endDateTime)) {
return Availability.unavailable("预约已截止", startDateTime, endDateTime);
}
// 业务要求开始和结束准点均可提交,因此使用闭区间 [start, end]。
return Availability.available(startDateTime, endDateTime);
}
private static LocalDate resolveLegacyApptDate(LocalDate apptDate, String legacyApptTimeType, LocalDate today) {
if (apptDate != null) {
return apptDate;
}
HardApptTimeTypeEnum legacyType = HardApptTimeTypeEnum.valueOfByTypeOrToday(legacyApptTimeType);
if (legacyType == null || !HardApptTimeTypeEnum.isReservationDateType(legacyType.getType())) {
throw new RuntimeException("预约日期类型无效");
}
return today.plusDays(legacyType.getDay());
}
private static Availability cycleAvailability(HardApptCycleRuleVo rule,
HardApptCycleRuleCalculator.CycleBatch batch) {
LocalDateTime start = batch.window().startDateTime();
LocalDateTime end = batch.window().endDateTime();
if (batch.open()) {
return Availability.available(start, end);
}
String reason = "周" + weekdayLabel(rule.getOpenWeekday())
+ rule.getOpenTime().format(TIME_FORMATTER) + "开放";
return Availability.unavailable(reason, start, end);
}
private List<HardApptEntity> findEnabledAppts(Long mchId) {
return hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
.orderByAsc(HardApptEntity::getHourType)
.orderByDesc(HardApptEntity::getSort));
}
private Map<String, Set<Long>> groupMappingsByDateType(Long mchId, List<HardApptEntity> appts) {
if (apptListIsEmpty(appts)) {
return Map.of();
}
Set<Long> apptIds = appts.stream().map(HardApptEntity::getId)
.filter(ObjectUtil::isNotNull).collect(Collectors.toSet());
if (apptIds.isEmpty()) {
return Map.of();
}
List<HardApptTimeEntity> mappings = hardApptTimeService.list(
new LambdaQueryWrapper<HardApptTimeEntity>()
.eq(HardApptTimeEntity::getMchId, mchId)
.in(HardApptTimeEntity::getApptId, apptIds));
return mappings.stream()
.filter(mapping -> ObjectUtil.isAllNotEmpty(mapping.getType(), mapping.getApptId()))
.filter(mapping -> HardApptTimeTypeEnum.isReservationDateType(mapping.getType()))
.collect(Collectors.groupingBy(
HardApptTimeEntity::getType,
LinkedHashMap::new,
Collectors.mapping(HardApptTimeEntity::getApptId, Collectors.toCollection(HashSet::new))
));
}
private static boolean apptListIsEmpty(List<HardApptEntity> appts) {
return appts == null || appts.isEmpty();
}
private static HardApptReserveModeEnum resolveMode(HardApptEntity appt) {
return HardApptReserveModeEnum.valueOfByType(appt.getReserveMode());
}
private static void validateMerchantId(Long mchId) {
if (ObjectUtil.isEmpty(mchId)) {
throw new RuntimeException("商户ID不能为空");
}
}
private static LocalDateTime nowInShanghai() {
return ZonedDateTime.now(RESERVATION_ZONE).toLocalDateTime();
}
private static String weekdayLabel(int weekday) {
return switch (weekday) {
case 1 -> "一";
case 2 -> "二";
case 3 -> "三";
case 4 -> "四";
case 5 -> "五";
case 6 -> "六";
case 7 -> "日";
default -> String.valueOf(weekday);
};
}
record Availability(boolean available, String unavailableReason,
LocalDateTime startDateTime, LocalDateTime endDateTime) {
static Availability available(LocalDateTime startDateTime, LocalDateTime endDateTime) {
return new Availability(true, null, startDateTime, endDateTime);
}
static Availability unavailable(String reason) {
return unavailable(reason, null, null);
}
static Availability unavailable(String reason, LocalDateTime startDateTime, LocalDateTime endDateTime) {
return new Availability(false, reason, startDateTime, endDateTime);
}
}
record CycleMatch(HardApptCycleRuleVo rule, Availability availability) {
}
}
@@ -5,8 +5,9 @@ import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum;
import com.bsd.pjcloud.hard.api.dto.HardApptDto;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
@@ -19,9 +20,11 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
@@ -41,7 +44,9 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
* 新增
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(HardApptDto dto) {
normalizeAndValidateRule(dto, HardApptReserveModeEnum.TIME_WINDOW.getType());
// title不能重复
if (existTitle(dto.getTitle(), dto.getMchId(), null)) {
throw new RuntimeException("标题不能重复");
@@ -60,7 +65,13 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
* 修改
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long edit(HardApptDto dto) {
HardApptEntity existing = baseMapper.selectById(dto.getId());
if (existing == null) {
throw new RuntimeException("时间预约不存在");
}
normalizeAndValidateRule(dto, existing.getReserveMode());
// title不能重复
if (existTitle(dto.getTitle(), dto.getMchId(), dto.getId())) {
throw new RuntimeException("标题不能重复");
@@ -79,8 +90,11 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
* 删除
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void delByIds(ArrayList<Long> list) {
if (ObjectUtil.isEmpty(list)) return;
hardApptTimeMapper.delete(new LambdaQueryWrapper<HardApptTimeEntity>()
.in(HardApptTimeEntity::getApptId, list));
baseMapper.deleteBatchIds(list);
}
@@ -136,13 +150,15 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
// 创建预约时间
private void createApptTime(HardApptEntity apptEntity, List<String> reserveTimeList) {
if (!ObjectUtil.isAllNotEmpty(apptEntity.getId(), apptEntity.getMchId(), reserveTimeList)) {
return;
if (!ObjectUtil.isAllNotEmpty(apptEntity.getId(), apptEntity.getMchId())) {
throw new RuntimeException("预约餐段信息不完整");
}
hardApptTimeMapper.delete(new LambdaQueryWrapper<HardApptTimeEntity>()
.eq(HardApptTimeEntity::getApptId, apptEntity.getId())
.eq(HardApptTimeEntity::getMchId, apptEntity.getMchId())
);
if (ObjectUtil.isEmpty(reserveTimeList)) {
return;
}
ArrayList<HardApptTimeEntity> addList = new ArrayList<>();
reserveTimeList.forEach(v -> {
HardApptTimeEntity entity = new HardApptTimeEntity();
@@ -154,6 +170,56 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
hardApptTimeMapper.insert(addList);
}
/**
* 服务端负责规范化规则,避免绕过后台页面提交无效模式、非法日期或倒置时间窗。
*/
private void normalizeAndValidateRule(HardApptDto dto, String fallbackMode) {
String modeValue = ObjectUtil.defaultIfEmpty(dto.getReserveMode(),
ObjectUtil.defaultIfEmpty(fallbackMode, HardApptReserveModeEnum.TIME_WINDOW.getType()));
HardApptReserveModeEnum reserveMode = HardApptReserveModeEnum.valueOfByType(modeValue);
if (reserveMode == null) {
throw new RuntimeException("预约限制模式无效");
}
dto.setReserveMode(reserveMode.getType());
if (reserveMode == HardApptReserveModeEnum.WEEKLY_CYCLE) {
// 周模式由独立周期规则配置,不保留旧的相对日期映射,避免切换模式后误命中脏数据。
dto.setReserveTimeList(List.of());
dto.setStartTimeType(null);
dto.setStartTime(null);
dto.setEndTimeType(null);
dto.setEndTime(null);
return;
}
if (ObjectUtil.isEmpty(dto.getReserveTimeList())) {
throw new RuntimeException("可预约日期不能为空");
}
List<String> reserveTimeList = dto.getReserveTimeList().stream()
.filter(ObjectUtil::isNotEmpty)
.distinct()
.toList();
if (reserveTimeList.isEmpty()
|| reserveTimeList.stream().anyMatch(type -> !HardApptTimeTypeEnum.isReservationDateType(type))) {
throw new RuntimeException("可预约日期无效");
}
dto.setReserveTimeList(reserveTimeList);
if (!ObjectUtil.isAllNotEmpty(dto.getStartTimeType(), dto.getStartTime(), dto.getEndTimeType(), dto.getEndTime())) {
throw new RuntimeException("动态时间窗配置不完整");
}
HardApptTimeTypeEnum startType = HardApptTimeTypeEnum.valueOfByType(dto.getStartTimeType());
HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(dto.getEndTimeType());
if (startType == null || endType == null) {
throw new RuntimeException("动态时间窗日期类型无效");
}
long startMinutes = startType.getDay() * 24L * 60L + dto.getStartTime().getHour() * 60L + dto.getStartTime().getMinute();
long endMinutes = endType.getDay() * 24L * 60L + dto.getEndTime().getHour() * 60L + dto.getEndTime().getMinute();
if (endMinutes <= startMinutes) {
throw new RuntimeException("结束时间必须大于开始时间");
}
}
private boolean existTitle(String title, Long mchId, Long id) {
Long count = baseMapper.selectCount(new LambdaQueryWrapper<HardApptEntity>()
.eq(ObjectUtil.isNotEmpty(title), HardApptEntity::getTitle, title)
@@ -174,11 +240,16 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
for (HardApptVo item : records) {
List<HardApptTimeEntity> apptTimeList = targetList.stream().filter(v -> ObjectUtil.equals(item.getId(), v.getApptId())).toList();
if (ObjectUtil.isNotEmpty(apptTimeList)) {
List<String> typeList = apptTimeList.stream().map(HardApptTimeEntity::getType).toList();
List<String> typeList = apptTimeList.stream()
.map(HardApptTimeEntity::getType)
.filter(HardApptTimeTypeEnum::isReservationDateType)
.distinct()
.sorted(Comparator.comparingInt(type -> HardApptTimeTypeEnum.valueOfByType(type).getDay()))
.toList();
item.setReserveTimeList(typeList);
}
}
}
}
}
}
@@ -34,7 +34,6 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum;
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
@@ -124,7 +123,6 @@ import org.springframework.web.context.request.RequestContextHolder;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@@ -151,6 +149,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private final HardBroadcastService hardBroadcastService;
private final HardSpuService hardSpuService;
private final HardApptService hardApptService;
private final HardApptReservationService hardApptReservationService;
private final HardOrderAddressService hardOrderAddressService;
private final HardUserMapAddressService hardUserMapAddressService;
private final HardTakeOutApptService hardTakeOutApptService;
@@ -272,6 +271,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (ObjectUtil.isEmpty(apptDetail)) return;
detail.setApptTitle(apptDetail.getTitle());
detail.setApptHourType(apptDetail.getHourType());
detail.setApptReserveMode(apptDetail.getReserveMode());
detail.setApptStartTime(apptDetail.getStartTime());
detail.setApptEndTime(apptDetail.getEndTime());
});
@@ -539,18 +539,21 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 查询用户
UserInfo userDetail = userService.getInnerByUserId(dto.getUserId()).getData();
if (ObjectUtil.isEmpty(userDetail)) throw new RuntimeException("用户不存在不存在");
// 如果是预约判断是否在预约时间段内
boolean inReserveTime = isInReserveTime(dto.getApptTimeType(), dto.getApptId());
if (!inReserveTime) throw new RuntimeException("不在预约时间段内");
// 先以购物车商户作为可信来源,再统一校验预约餐段、日期映射和开放时间。
HardParentCarEntity parentCar = hardCarService.getParentCarById(dto.getCarParentId());
if (ObjectUtil.isEmpty(parentCar)) throw new RuntimeException("购物车不存在");
HardApptReservationService.ReservationValidation reservation = null;
if (ObjectUtil.equals(dto.getOrderType(), HardOrderTypeEnum.RESERVE)) {
reservation = hardApptReservationService.validateReservation(
parentCar.getMerchantId(), dto.getApptId(), dto.getApptRuleId(),
dto.getHourType(), dto.getApptDate(), dto.getApptTimeType());
}
// 判断是否有重复的预约订单
Long reserveCount = isHaveCanteenReserve(dto.getUserId());
if (reserveCount > 3) throw new RuntimeException("您有" + reserveCount + "笔未核销的预约订单,请先核销");
// 钱包
WalletManageEntity wallet = remoteWalletService.getWallet(dto.getUserId(), dto.getWalletType()).getData();
if (ObjectUtil.isEmpty(wallet)) throw new RuntimeException("用户钱包不存在");
// 查询父订单
HardParentCarEntity parentCar = hardCarService.getParentCarById(dto.getCarParentId());
if (ObjectUtil.isEmpty(parentCar)) throw new RuntimeException("购物车不存在");
// 查询商户
MchManageVo hardManager = remoteMchService.getById(parentCar.getMerchantId()).getData();
if (ObjectUtil.isEmpty(hardManager)) throw new RuntimeException("商户不存在");
@@ -596,12 +599,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.productDesc(subject)
.build();
if (ObjectUtil.equals(dto.getOrderType(), HardOrderTypeEnum.RESERVE)) {
// 查询时间段
HardApptEntity hardAppt = hardApptService.getById(dto.getApptId());
if (ObjectUtil.isEmpty(hardAppt)) throw new RuntimeException("预约时间段不存在");
orderObj.setHourType(hardAppt.getHourType());
orderObj.setApptId(hardAppt.getId());
orderObj.setApptUseDate(getApptTime(dto.getApptTimeType()));
orderObj.setHourType(reservation.hourType());
orderObj.setApptId(reservation.appt() == null ? null : reservation.appt().getId());
orderObj.setApptRuleId(reservation.apptRuleId());
orderObj.setApptUseDate(reservation.apptDate());
}
if (isPay) orderObj.setWalletId(wallet.getId());
baseMapper.insert(orderObj);
@@ -627,9 +628,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
validateTakeOutExpectDelivery(dto);
// 如果是预约判断是否在预约时间段内
boolean inReserveTime = isInReserveTime(null, dto.getApptId());
if (!inReserveTime) throw new RuntimeException("不在预约时间段内");
// 判断是否有重复的预约订单
Long reserveCount = isHaveCanteenReserve(dto.getUserId());
if (reserveCount > 3) throw new RuntimeException("您有" + reserveCount + "笔未核销的预约订单,请先核销");
@@ -2258,32 +2256,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
sync.setExpectArriveTime(appt.getExpectArriveTime());
}
// 是否在预约时间段内
private boolean isInReserveTime(String apptTimeType, Long apptId) {
if (ObjectUtil.isEmpty(apptTimeType)) {
apptTimeType = HardApptTimeTypeEnum.TODAY.getType();
}
if (ObjectUtil.isEmpty(apptId)) return true;
HardApptEntity apptDetail = hardApptService.getById(apptId);
if (ObjectUtil.isEmpty(apptDetail)) {
throw new RuntimeException("请先配置预约时间段");
}
HardApptTimeTypeEnum startTimeType = HardApptTimeTypeEnum.valueOfByTypeOrToday(apptDetail.getStartTimeType());
HardApptTimeTypeEnum endTimeType = HardApptTimeTypeEnum.valueOfByTypeOrToday(apptDetail.getEndTimeType());
LocalDate now = getApptTime(apptTimeType);
LocalDateTime startTime = apptDetail.getStartTime().atDate(now).plusDays(startTimeType.getDay());
LocalDateTime endTime = apptDetail.getEndTime().atDate(now).plusDays(endTimeType.getDay());
return LocalDateTime.now().isAfter(startTime) && LocalDateTime.now().isBefore(endTime);
}
private LocalDate getApptTime(String apptTimeType) {
if (ObjectUtil.isEmpty(apptTimeType)) return LocalDate.now();
HardApptTimeTypeEnum apptTimeTypeEnum = HardApptTimeTypeEnum.valueOfByTypeOrToday(apptTimeType);
return LocalDate.now().plusDays(apptTimeTypeEnum.getDay());
}
/* ************************************* end 支付相关 end ************************************* */
private HardOrderServiceImpl getSelf() {
@@ -2387,6 +2359,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (ObjectUtil.isEmpty(target)) continue;
v.setApptTitle(target.getTitle());
v.setApptHourType(target.getHourType());
v.setApptReserveMode(target.getReserveMode());
v.setApptStartTime(target.getStartTime());
v.setApptEndTime(target.getEndTime());
}
@@ -2394,4 +2367,4 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
});
CompletableFuture.allOf(deptFuture, mchFuture, deviceFuture, currentDeptFuture, walletFuture, findCarListFuture, findApptListFuture).join();
}
}
}
@@ -0,0 +1,103 @@
package com.bsd.pjcloud.hard.service.support;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleEntity;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
/**
* 周期预约日期计算器。所有周规则都以 ISO 周一为锚点,避免跨月、跨年时依赖字符串或自然周编号。
*/
public final class HardApptCycleRuleCalculator {
public static final int MIN_WEEKDAY = DayOfWeek.MONDAY.getValue();
public static final int MAX_WEEKDAY = DayOfWeek.SUNDAY.getValue();
public static final int MIN_TARGET_OFFSET_DAYS = 1;
public static final int MAX_TARGET_OFFSET_DAYS = 31;
public static final int MIN_TARGET_DAY_COUNT = 1;
public static final int MAX_TARGET_DAY_COUNT = 31;
private HardApptCycleRuleCalculator() {
}
public static void validate(HardApptCycleRuleEntity rule) {
if (rule.getOpenWeekday() == null || rule.getOpenWeekday() < MIN_WEEKDAY
|| rule.getOpenWeekday() > MAX_WEEKDAY
|| rule.getCloseWeekday() == null || rule.getCloseWeekday() < MIN_WEEKDAY
|| rule.getCloseWeekday() > MAX_WEEKDAY) {
throw new RuntimeException("开放和截止星期必须在周一至周日之间");
}
if (rule.getOpenTime() == null || rule.getCloseTime() == null) {
throw new RuntimeException("开放和截止时间不能为空");
}
if (rule.getOpenWeekday().equals(rule.getCloseWeekday())
&& !rule.getCloseTime().isAfter(rule.getOpenTime())) {
throw new RuntimeException("同一天的截止时间必须晚于开放时间");
}
if (rule.getTargetStartOffsetDays() == null
|| rule.getTargetStartOffsetDays() < MIN_TARGET_OFFSET_DAYS
|| rule.getTargetStartOffsetDays() > MAX_TARGET_OFFSET_DAYS) {
throw new RuntimeException("目标起始天数必须在1至31天之间");
}
if (rule.getTargetDayCount() == null
|| rule.getTargetDayCount() < MIN_TARGET_DAY_COUNT
|| rule.getTargetDayCount() > MAX_TARGET_DAY_COUNT) {
throw new RuntimeException("连续预约天数必须在1至31天之间");
}
}
/**
* 返回当前正在开放的批次;不在开放期时返回下一批次,供 App 展示下一次可预约日期。
*/
public static CycleBatch resolveBatch(HardApptCycleRuleEntity rule, LocalDateTime now) {
LocalDate monday = now.toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
CycleWindow previous = windowForMonday(rule, monday.minusWeeks(1));
if (previous.contains(now)) {
return batch(rule, previous, true);
}
CycleWindow current = windowForMonday(rule, monday);
if (current.contains(now)) {
return batch(rule, current, true);
}
if (now.isBefore(current.startDateTime())) {
return batch(rule, current, false);
}
return batch(rule, windowForMonday(rule, monday.plusWeeks(1)), false);
}
public static CycleWindow windowForMonday(HardApptCycleRuleEntity rule, LocalDate monday) {
LocalDate openDate = monday.plusDays(rule.getOpenWeekday() - 1L);
LocalDate closeDate = monday.plusDays(rule.getCloseWeekday() - 1L);
if (rule.getCloseWeekday() < rule.getOpenWeekday()) {
closeDate = closeDate.plusWeeks(1);
}
return new CycleWindow(openDate.atTime(rule.getOpenTime()), closeDate.atTime(rule.getCloseTime()));
}
public static int targetWeekday(HardApptCycleRuleEntity rule, int targetDayOffset) {
int zeroBased = rule.getCloseWeekday() - 1
+ rule.getTargetStartOffsetDays()
+ targetDayOffset;
return Math.floorMod(zeroBased, 7) + 1;
}
private static CycleBatch batch(HardApptCycleRuleEntity rule, CycleWindow window, boolean open) {
LocalDate targetStartDate = window.endDateTime().toLocalDate()
.plusDays(rule.getTargetStartOffsetDays());
return new CycleBatch(window, targetStartDate, open);
}
public record CycleWindow(LocalDateTime startDateTime, LocalDateTime endDateTime) {
public boolean contains(LocalDateTime value) {
// 开放和截止准点均允许提交,使用闭区间。
return !value.isBefore(startDateTime) && !value.isAfter(endDateTime);
}
}
public record CycleBatch(CycleWindow window, LocalDate targetStartDate, boolean open) {
}
}
@@ -22,6 +22,7 @@
a.pay_type,
a.hour_type,
a.appt_id,
a.appt_rule_id,
a.appt_use_date,
a.sys_type,
a.total_price,
@@ -171,6 +172,9 @@
<if test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
and a.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]}
</if>
<if test="dto.apptUseDateStart != null">
and a.appt_use_date &gt;= #{dto.apptUseDateStart}
</if>
<if test="dto.payTimeList != null and dto.payTimeList.length == 2">
and a.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]}
</if>
@@ -908,4 +912,4 @@
limit #{dto.limit}
</if>
</select>
</mapper>
</mapper>
@@ -0,0 +1,116 @@
package com.bsd.pjcloud.hard.service.impl;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.dto.HardApptCycleRuleDto;
import com.bsd.pjcloud.hard.api.dto.HardApptCycleRuleItemDto;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptCycleRuleItemEntity;
import com.bsd.pjcloud.hard.mapper.HardApptCycleRuleItemMapper;
import com.bsd.pjcloud.hard.mapper.HardApptCycleRuleMapper;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.Test;
import java.time.LocalTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HardApptCycleRuleServiceImplTest {
@Test
void enabledRuleRejectsSameSegmentOnSameTargetWeekday() {
Fixture fixture = fixture(YesNoEnum.YES.getCode());
RuntimeException exception = assertThrows(RuntimeException.class,
() -> fixture.service().create(fixture.candidate()));
assertTrue(exception.getMessage().contains("早餐"));
assertTrue(exception.getMessage().contains("周二"));
assertTrue(exception.getMessage().contains("已有规则"));
}
@Test
void disabledRuleAllowsOverlappingTargetWeekday() {
Fixture fixture = fixture(YesNoEnum.NO.getCode());
assertDoesNotThrow(() -> fixture.service().create(fixture.candidate()));
}
@Test
void invalidRuleStatusIsRejected() {
Fixture fixture = fixture("UNKNOWN");
RuntimeException exception = assertThrows(RuntimeException.class,
() -> fixture.service().create(fixture.candidate()));
assertTrue(exception.getMessage().contains("状态无效"));
}
private Fixture fixture(String candidateStatus) {
HardApptCycleRuleMapper ruleMapper = mock(HardApptCycleRuleMapper.class);
HardApptCycleRuleItemMapper itemMapper = mock(HardApptCycleRuleItemMapper.class);
RemoteMchService remoteMchService = mock(RemoteMchService.class);
TestableCycleRuleService service = new TestableCycleRuleService(itemMapper, remoteMchService);
service.useMapper(ruleMapper);
when(ruleMapper.selectCount(any())).thenReturn(0L);
MchManageVo merchant = new MchManageVo();
merchant.setDeptId(20L);
when(remoteMchService.getById(10L)).thenReturn(R.ok(merchant));
HardApptCycleRuleEntity existingRule = new HardApptCycleRuleEntity();
existingRule.setId(200L);
existingRule.setMchId(10L);
existingRule.setRuleName("已有规则");
existingRule.setCloseWeekday(7);
existingRule.setTargetStartOffsetDays(2);
existingRule.setStatus(YesNoEnum.YES.getCode());
when(ruleMapper.selectList(any())).thenReturn(List.of(existingRule));
HardApptCycleRuleItemEntity existingItem = new HardApptCycleRuleItemEntity();
existingItem.setRuleId(200L);
existingItem.setMchId(10L);
existingItem.setTargetDayOffset(0);
existingItem.setHourType("1");
when(itemMapper.selectList(any())).thenReturn(List.of(existingItem));
HardApptCycleRuleDto candidate = new HardApptCycleRuleDto();
candidate.setRuleName("新规则");
candidate.setMchId(10L);
candidate.setOpenWeekday(5);
candidate.setOpenTime(LocalTime.of(17, 0));
candidate.setCloseWeekday(7);
candidate.setCloseTime(LocalTime.of(22, 0));
candidate.setTargetStartOffsetDays(2);
candidate.setTargetDayCount(7);
candidate.setStatus(candidateStatus);
HardApptCycleRuleItemDto item = new HardApptCycleRuleItemDto();
item.setTargetDayOffset(0);
item.setHourType("1");
candidate.setItemList(List.of(item));
return new Fixture(service, candidate);
}
private record Fixture(TestableCycleRuleService service, HardApptCycleRuleDto candidate) {
}
private static final class TestableCycleRuleService extends HardApptCycleRuleServiceImpl {
private TestableCycleRuleService(HardApptCycleRuleItemMapper itemMapper,
RemoteMchService remoteMchService) {
super(itemMapper, remoteMchService);
}
private void useMapper(HardApptCycleRuleMapper mapper) {
this.baseMapper = mapper;
}
}
}
@@ -0,0 +1,356 @@
package com.bsd.pjcloud.hard.service.impl;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleItemVo;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleVo;
import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import com.bsd.pjcloud.hard.service.HardApptCycleRuleService;
import com.bsd.pjcloud.hard.service.HardApptService;
import com.bsd.pjcloud.hard.service.HardApptTimeService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HardApptReservationServiceImplTest {
private static final LocalDate APPT_DATE = LocalDate.of(2026, 7, 21);
@Test
void timeWindowIncludesStartAndEndInstants() {
HardApptEntity appt = timeWindowAppt();
assertTrue(HardApptReservationServiceImpl.evaluateAvailability(
appt, APPT_DATE, LocalDateTime.of(2026, 7, 20, 17, 0)).available());
assertTrue(HardApptReservationServiceImpl.evaluateAvailability(
appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0)).available());
}
@Test
void timeWindowRejectsInstantsOutsideClosedInterval() {
HardApptEntity appt = timeWindowAppt();
assertFalse(HardApptReservationServiceImpl.evaluateAvailability(
appt, APPT_DATE, LocalDateTime.of(2026, 7, 20, 16, 59, 59)).available());
assertFalse(HardApptReservationServiceImpl.evaluateAvailability(
appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available());
}
@Test
void configurableCycleIncludesBothBoundaryInstants() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
assertTrue(HardApptCycleRuleCalculator.resolveBatch(
rule, LocalDateTime.of(2026, 7, 24, 17, 0)).open());
assertTrue(HardApptCycleRuleCalculator.resolveBatch(
rule, LocalDateTime.of(2026, 7, 26, 22, 0)).open());
assertFalse(HardApptCycleRuleCalculator.resolveBatch(
rule, LocalDateTime.of(2026, 7, 24, 16, 59, 59, 999_999_999)).open());
assertFalse(HardApptCycleRuleCalculator.resolveBatch(
rule, LocalDateTime.of(2026, 7, 26, 22, 0, 0, 1)).open());
}
@Test
void configurableCycleOnlyAcceptsConfiguredOffsetsAndSegments() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
HardApptReservationServiceImpl service = reservationService(weeklyAppt(1L, 10L), 0, List.of(rule));
LocalDateTime fridayOpen = LocalDateTime.of(2026, 7, 24, 18, 0);
assertEquals(LocalDate.of(2026, 7, 28),
service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 7, 28), null, fridayOpen).apptDate());
assertEquals(LocalDate.of(2026, 8, 3),
service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 8, 3), null, fridayOpen).apptDate());
assertThrows(RuntimeException.class,
() -> service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 8, 4), null, fridayOpen));
}
@Test
void arbitraryWeekdaysAndCrossWeekWindowsAreCalculatedFromConfiguration() {
HardApptCycleRuleVo mondayRule = cycleRule(100L, 10L, 1, LocalTime.of(8, 0),
3, LocalTime.of(18, 0), 3, 5, "1");
HardApptCycleRuleCalculator.CycleBatch mondayBatch = HardApptCycleRuleCalculator.resolveBatch(
mondayRule, LocalDateTime.of(2026, 7, 20, 8, 0));
assertEquals(LocalDate.of(2026, 7, 25), mondayBatch.targetStartDate());
HardApptCycleRuleVo crossWeek = cycleRule(101L, 10L, 5, LocalTime.of(17, 0),
2, LocalTime.of(10, 0), 1, 3, "1");
HardApptCycleRuleCalculator.CycleBatch crossWeekBatch = HardApptCycleRuleCalculator.resolveBatch(
crossWeek, LocalDateTime.of(2026, 7, 27, 12, 0));
assertTrue(crossWeekBatch.open());
assertEquals(LocalDate.of(2026, 7, 29), crossWeekBatch.targetStartDate());
}
@Test
void cycleHandlesCrossYearAndThirtyOneDayRange() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 31, "1");
HardApptCycleRuleCalculator.CycleBatch batch = HardApptCycleRuleCalculator.resolveBatch(
rule, LocalDateTime.of(2027, 1, 1, 17, 0));
assertTrue(batch.open());
assertEquals(LocalDate.of(2027, 1, 5), batch.targetStartDate());
assertEquals(LocalDate.of(2027, 2, 4), batch.targetStartDate().plusDays(30));
}
@Test
void cycleValidatesSameDayWindowAndOneDayRangeBounds() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 1, LocalTime.of(8, 0),
1, LocalTime.of(8, 0), 1, 1, "1");
assertThrows(RuntimeException.class, () -> HardApptCycleRuleCalculator.validate(rule));
rule.setCloseTime(LocalTime.of(9, 0));
assertDoesNotThrow(() -> HardApptCycleRuleCalculator.validate(rule));
HardApptCycleRuleCalculator.CycleBatch batch = HardApptCycleRuleCalculator.resolveBatch(
rule, LocalDateTime.of(2026, 7, 20, 8, 30));
assertEquals(LocalDate.of(2026, 7, 21), batch.targetStartDate());
rule.setTargetStartOffsetDays(0);
assertThrows(RuntimeException.class, () -> HardApptCycleRuleCalculator.validate(rule));
rule.setTargetStartOffsetDays(1);
rule.setTargetDayCount(32);
assertThrows(RuntimeException.class, () -> HardApptCycleRuleCalculator.validate(rule));
}
@Test
void v3ListCanContainDynamicAndWeeklyModesTogether() {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setStatus(YesNoEnum.YES.getCode());
HardApptCycleRuleVo cycleRule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "2");
HardApptTimeEntity mapping = new HardApptTimeEntity();
mapping.setMchId(10L);
mapping.setApptId(1L);
mapping.setType(HardApptTimeTypeEnum.TODAY.getType());
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any()))
.thenReturn(List.of(dynamic));
when(timeService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptTimeEntity>>any()))
.thenReturn(List.of(mapping));
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
when(cycleRuleService.listEnabledConfigurations(10L)).thenReturn(List.of(cycleRule));
List<HardApptDateOptionsVo> result = new HardApptReservationServiceImpl(apptService, timeService, cycleRuleService)
.listOptions(10L, LocalDateTime.of(2026, 7, 24, 17, 0));
assertEquals(8, result.size());
assertEquals(1, result.get(0).getApptList().size());
assertEquals(7, result.stream()
.filter(day -> day.getApptList().stream()
.anyMatch(option -> HardApptReserveModeEnum.WEEKLY_CYCLE.getType().equals(option.getReserveMode())))
.count());
}
@Test
void v3ListOmitsEmptyTargetDaysAndKeepsDailySegmentAssignments() {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 3, "1");
rule.setItemList(List.of(cycleItem(100L, 10L, 0, "1"), cycleItem(100L, 10L, 2, "3")));
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any()))
.thenReturn(List.of());
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
when(cycleRuleService.listEnabledConfigurations(10L)).thenReturn(List.of(rule));
List<HardApptDateOptionsVo> result = new HardApptReservationServiceImpl(apptService, timeService, cycleRuleService)
.listOptions(10L, LocalDateTime.of(2026, 7, 24, 18, 0));
assertEquals(List.of(LocalDate.of(2026, 7, 28), LocalDate.of(2026, 7, 30)),
result.stream().map(HardApptDateOptionsVo::getApptDate).toList());
assertEquals(List.of("1", "3"), result.stream()
.flatMap(day -> day.getApptList().stream())
.map(HardApptEntity::getHourType)
.toList());
}
@Test
void legacyV2ListOnlyReturnsDynamicRules() {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setStatus(YesNoEnum.YES.getCode());
HardApptEntity weekly = weeklyAppt(2L, 10L);
HardApptTimeEntity mapping = new HardApptTimeEntity();
mapping.setMchId(10L);
mapping.setApptId(1L);
mapping.setType(HardApptTimeTypeEnum.TODAY.getType());
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any()))
.thenReturn(List.of(dynamic, weekly));
when(timeService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptTimeEntity>>any()))
.thenReturn(List.of(mapping));
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
List<HardApptTimeCascadeVo> result =
new HardApptReservationServiceImpl(apptService, timeService, cycleRuleService)
.listLegacyOptions(10L);
assertEquals(1, result.size());
assertEquals(List.of(1L), result.get(0).getApptList().stream().map(HardApptEntity::getId).toList());
}
@Test
void validationRejectsWrongMerchantDisabledRuleTamperedDateAndMissingWeeklyDate() {
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setStatus(YesNoEnum.YES.getCode());
LocalDateTime inWindow = LocalDateTime.of(2026, 7, 20, 18, 0);
HardApptReservationServiceImpl service = reservationService(dynamic, 1, List.of());
assertThrows(RuntimeException.class,
() -> service.validateReservation(99L, 1L, null, null, APPT_DATE, null, inWindow));
assertThrows(RuntimeException.class,
() -> service.validateReservation(10L, 1L, null, null, APPT_DATE.plusDays(3), null, inWindow));
dynamic.setStatus(YesNoEnum.NO.getCode());
assertThrows(RuntimeException.class,
() -> reservationService(dynamic, 1, List.of())
.validateReservation(10L, 1L, null, null, APPT_DATE, null, inWindow));
HardApptEntity weekly = weeklyAppt(2L, 10L);
assertThrows(RuntimeException.class,
() -> reservationService(weekly, 0, List.of())
.validateReservation(10L, 2L, null, null, null, null, inWindow));
}
@Test
void dynamicValidationRejectsDateWithoutMapping() {
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setStatus(YesNoEnum.YES.getCode());
assertThrows(RuntimeException.class, () -> reservationService(dynamic, 0, List.of())
.validateReservation(10L, 1L, null, null, APPT_DATE, null,
LocalDateTime.of(2026, 7, 20, 18, 0)));
}
@Test
void weeklyValidationRejectsTamperedRuleDateAndHourType() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
HardApptReservationServiceImpl service = reservationService(weekly, 0, List.of(rule));
LocalDateTime now = LocalDateTime.of(2026, 7, 24, 18, 0);
assertEquals(100L, service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 7, 28), null, now).apptRuleId());
assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 999L, "1",
LocalDate.of(2026, 7, 28), null, now));
assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 8, 10), null, now));
assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 100L, "3",
LocalDate.of(2026, 7, 28), null, now));
}
@Test
void weeklyValidationRequiresRuleId() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
HardApptCycleRuleVo firstRule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
HardApptReservationServiceImpl service = reservationService(weekly, 0, List.of(firstRule));
LocalDate targetDate = LocalDate.of(2026, 7, 28);
LocalDateTime now = LocalDateTime.of(2026, 7, 24, 18, 0);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.validateReservation(10L, null, null, "1", targetDate, null, now));
assertTrue(exception.getMessage().contains("动态预约餐段不能为空"));
assertEquals(100L, service.validateReservation(10L, null, 100L, "1", targetDate, null, now).apptRuleId());
}
private HardApptEntity timeWindowAppt() {
HardApptEntity appt = new HardApptEntity();
appt.setReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType());
appt.setStartTimeType(HardApptTimeTypeEnum.YESTERDAY.getType());
appt.setStartTime(LocalTime.of(17, 0));
appt.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
appt.setEndTime(LocalTime.of(10, 0));
return appt;
}
private HardApptEntity weeklyAppt(Long id, Long mchId) {
HardApptEntity appt = new HardApptEntity();
appt.setId(id);
appt.setMchId(mchId);
appt.setStatus(YesNoEnum.YES.getCode());
appt.setReserveMode(HardApptReserveModeEnum.WEEKLY_CYCLE.getType());
return appt;
}
private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount,
List<HardApptCycleRuleVo> rules) {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
when(apptService.getById(appt.getId())).thenReturn(appt);
when(timeService.count(any())).thenReturn(mappingCount);
when(cycleRuleService.listEnabledConfigurations(appt.getMchId())).thenReturn(rules);
return new HardApptReservationServiceImpl(apptService, timeService, cycleRuleService);
}
private HardApptCycleRuleVo cycleRule(Long id, Long mchId,
int openWeekday, LocalTime openTime,
int closeWeekday, LocalTime closeTime,
int targetStartOffset, int targetDayCount,
String hourType) {
HardApptCycleRuleVo rule = new HardApptCycleRuleVo();
rule.setId(id);
rule.setMchId(mchId);
rule.setOpenWeekday(openWeekday);
rule.setOpenTime(openTime);
rule.setCloseWeekday(closeWeekday);
rule.setCloseTime(closeTime);
rule.setTargetStartOffsetDays(targetStartOffset);
rule.setTargetDayCount(targetDayCount);
rule.setStatus(YesNoEnum.YES.getCode());
List<HardApptCycleRuleItemVo> items = new ArrayList<>();
for (int day = 0; day < targetDayCount; day++) {
HardApptCycleRuleItemVo item = new HardApptCycleRuleItemVo();
item.setRuleId(id);
item.setMchId(mchId);
item.setTargetDayOffset(day);
item.setHourType(hourType);
items.add(item);
}
rule.setItemList(items);
return rule;
}
private HardApptCycleRuleItemVo cycleItem(Long ruleId, Long mchId, int dayOffset, String hourType) {
HardApptCycleRuleItemVo item = new HardApptCycleRuleItemVo();
item.setRuleId(ruleId);
item.setMchId(mchId);
item.setTargetDayOffset(dayOffset);
item.setHourType(hourType);
return item;
}
}
@@ -76,7 +76,8 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
private static final List<String> GUARDIAN_WARD_VISIBLE_STATUSES = List.of(
TaskCheckStatusEnum.PADDING.getCode(),
TaskCheckStatusEnum.RESULT.getCode(),
TaskCheckStatusEnum.REJECT.getCode());
TaskCheckStatusEnum.REJECT.getCode()
);
private final SysUserService sysUserService;
private final SysDeptService sysDeptService;
@@ -200,8 +201,8 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
/**
* 分页查询当前监护人名下全部或指定被监护人的人脸审核记录。
*
* @param page 分页参数
* @param wardUserId 被监护人用户ID,为空时查询全部被监护人
* @param page 分页参数
* @param wardUserId 被监护人用户ID,为空时查询全部被监护人
* @param processStatus 审核状态
* @return 人脸审核记录分页数据
*/