From 6b2a95cc6561011f5393dc8cc9af56fdfc26d103 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 21 Jul 2026 21:04:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=91=A8=E6=9C=9F=E9=A4=90=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260721_add_hard_appt_reserve_mode.sql | 4 + db/20260722_add_hard_appt_cycle_rule.sql | 99 +++++ .../api/constant/HardApptReserveModeEnum.java | 32 ++ .../api/constant/HardApptTimeTypeEnum.java | 9 + .../api/constant/order/HardHourTypeEnum.java | 1 + .../hard/api/dto/HardApptCycleRuleDto.java | 17 + .../api/dto/HardApptCycleRuleItemDto.java | 14 + .../hard/api/dto/order/HardOrderDto.java | 3 + .../dto/order/app/HardAppCreateOrderDto.java | 11 + .../api/entity/HardApptCycleRuleEntity.java | 91 ++++ .../entity/HardApptCycleRuleItemEntity.java | 66 +++ .../hard/api/entity/HardApptEntity.java | 8 +- .../api/entity/order/HardOrderEntity.java | 8 +- .../hard/api/vo/HardApptCycleRuleItemVo.java | 15 + .../hard/api/vo/HardApptCycleRuleVo.java | 23 + .../hard/api/vo/HardApptDateOptionsVo.java | 21 + .../pjcloud/hard/api/vo/HardApptOptionVo.java | 33 ++ .../hard/api/vo/order/HardOrderVo.java | 2 + .../hard/controller/HardApptController.java | 53 +-- .../HardApptCycleRuleController.java | 77 ++++ .../controller/order/HardOrderController.java | 22 +- .../mapper/HardApptCycleRuleItemMapper.java | 9 + .../hard/mapper/HardApptCycleRuleMapper.java | 9 + .../service/HardApptCycleRuleService.java | 25 ++ .../service/HardApptReservationService.java | 24 ++ .../impl/HardApptCycleRuleServiceImpl.java | 305 +++++++++++++ .../impl/HardApptReservationServiceImpl.java | 403 ++++++++++++++++++ .../service/impl/HardApptServiceImpl.java | 83 +++- .../impl/order/HardOrderServiceImpl.java | 61 +-- .../support/HardApptCycleRuleCalculator.java | 103 +++++ .../mapper/order/HardOrderMapper.xml | 6 +- .../HardApptCycleRuleServiceImplTest.java | 116 +++++ .../HardApptReservationServiceImplTest.java | 356 ++++++++++++++++ .../service/impl/SysUserFaceServiceImpl.java | 7 +- 34 files changed, 2010 insertions(+), 106 deletions(-) create mode 100644 db/20260721_add_hard_appt_reserve_mode.sql create mode 100644 db/20260722_add_hard_appt_cycle_rule.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptReserveModeEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptCycleRuleDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptCycleRuleItemDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptCycleRuleEntity.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptCycleRuleItemEntity.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptCycleRuleItemVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptCycleRuleVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptDateOptionsVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptCycleRuleController.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptCycleRuleItemMapper.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptCycleRuleMapper.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptCycleRuleService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImpl.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/support/HardApptCycleRuleCalculator.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImplTest.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java diff --git a/db/20260721_add_hard_appt_reserve_mode.sql b/db/20260721_add_hard_appt_reserve_mode.sql new file mode 100644 index 000000000..acfcb8e7c --- /dev/null +++ b/db/20260721_add_hard_appt_reserve_mode.sql @@ -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; diff --git a/db/20260722_add_hard_appt_cycle_rule.sql b/db/20260722_add_hard_appt_cycle_rule.sql new file mode 100644 index 000000000..12509a634 --- /dev/null +++ b/db/20260722_add_hard_appt_cycle_rule.sql @@ -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'; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptReserveModeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptReserveModeEnum.java new file mode 100644 index 000000000..ba15b55f1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptReserveModeEnum.java @@ -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); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptTimeTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptTimeTypeEnum.java index a42048ccf..af614ce3c 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptTimeTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptTimeTypeEnum.java @@ -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; + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java index 2fe85d8ef..eb206c679 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java @@ -15,6 +15,7 @@ public enum HardHourTypeEnum { MORNING("1", "早餐"), NOONING("2", "午餐"), NIGHT("3", "晚餐"), + LATE_NIGHT("4", "夜间餐"), ; private final String type; private final String description; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptCycleRuleDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptCycleRuleDto.java new file mode 100644 index 000000000..675ddfd5b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptCycleRuleDto.java @@ -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 itemList; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptCycleRuleItemDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptCycleRuleItemDto.java new file mode 100644 index 000000000..0631e411a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptCycleRuleItemDto.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.hard.api.dto; + +import lombok.Data; + +/** + * 周期规则目标日餐段提交项。 + */ +@Data +public class HardApptCycleRuleItemDto { + + private Integer targetDayOffset; + + private String hourType; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index 919f786be..6d881a058 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -44,6 +44,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private LocalDateTime[] apptUseDateTimeList; + /** 预约用餐日期下限,用于查询今天及之后的未核销预约。 */ + private LocalDateTime apptUseDateStart; + private List orderTypeCodes; private List orderPayTypeCodes; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateOrderDto.java index b511e45ff..176449424 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateOrderDto.java @@ -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; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptCycleRuleEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptCycleRuleEntity.java new file mode 100644 index 000000000..339d23fc8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptCycleRuleEntity.java @@ -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 { + + @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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptCycleRuleItemEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptCycleRuleItemEntity.java new file mode 100644 index 000000000..7dba1ce69 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptCycleRuleItemEntity.java @@ -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 { + + @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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptEntity.java index 267b1991e..2ac9ad317 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptEntity.java @@ -65,6 +65,12 @@ public class HardApptEntity extends Model { @Schema(description = "结束时间") private String endTimeType; + /** + * 预约限制模式 + */ + @Schema(description = "预约限制模式(TIME_WINDOW动态时间窗,WEEKLY_CYCLE按周集中预约)") + private String reserveMode; + /** * 预约时间段 */ @@ -136,4 +142,4 @@ public class HardApptEntity extends Model { */ @Schema(description = "租户ID") private Long tenantId; -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index c79daaadd..cbb3d7674 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -129,6 +129,12 @@ public class HardOrderEntity extends Model { @Schema(description = "预约时间段id") private Long apptId; + /** + * 周期预约规则id + */ + @Schema(description = "周期预约规则id") + private Long apptRuleId; + /** * 预约使用时间(天) */ @@ -303,4 +309,4 @@ public class HardOrderEntity extends Model { */ @Schema(description = "租户ID") private Long tenantId; -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptCycleRuleItemVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptCycleRuleItemVo.java new file mode 100644 index 000000000..8bf3196b1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptCycleRuleItemVo.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptCycleRuleVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptCycleRuleVo.java new file mode 100644 index 000000000..dc9341fec --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptCycleRuleVo.java @@ -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 itemList; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptDateOptionsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptDateOptionsVo.java new file mode 100644 index 000000000..ab92076c8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptDateOptionsVo.java @@ -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 apptList; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java new file mode 100644 index 000000000..d5a81f060 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index 11d936db6..49500dd64 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -77,6 +77,8 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac private String apptHourType; + private String apptReserveMode; + private LocalTime apptStartTime; private LocalTime apptEndTime; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java index 165e7c173..c856b4fa0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java @@ -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 list = hardApptService.list(new LambdaQueryWrapper() .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> appV2List(@ParameterObject HardApptDto dto) { - if (ObjectUtil.isEmpty(dto.getMchId())) throw new RuntimeException("商户ID不能为空"); + return R.ok(hardApptReservationService.listLegacyOptions(dto.getMchId())); + } - ArrayList resultList = new ArrayList<>(); - - List apptTimeList = hardApptTimeService.list(new LambdaQueryWrapper() - .eq(HardApptTimeEntity::getMchId, dto.getMchId()) - ); - if (ObjectUtil.isEmpty(apptTimeList)) return R.ok(resultList); - // 查询mch商户下所有的预约 - List apptList = hardApptService.list(new LambdaQueryWrapper().eq(HardApptEntity::getMchId, dto.getMchId())); - // 分组 - Map> apptTimeTypeMap = apptTimeList.stream().collect(Collectors.groupingBy(HardApptTimeEntity::getType)); - - LocalDate now = LocalDate.now(); - - for (Map.Entry> 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 appTIdList = apptTimeList.stream().filter(v -> ObjectUtil.equals(key, v.getType())).map(HardApptTimeEntity::getApptId).toList(); - List 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> appV3List(@ParameterObject HardApptDto dto) { + return R.ok(hardApptReservationService.listOptions(dto.getMchId())); } /* ************************************* end app end ************************************* */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptCycleRuleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptCycleRuleController.java new file mode 100644 index 000000000..a53b53789 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptCycleRuleController.java @@ -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(@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 detail(@PathVariable Long id) { + return R.ok(cycleRuleService.detail(id)); + } + + @Operation(summary = "新增周期预约规则") + @SysLog("新增周期预约规则") + @PostMapping + @PreAuthorize("@pms.hasPermission('hard_hardAppt_add')") + public R create(@RequestBody HardApptCycleRuleDto dto) { + return R.ok(cycleRuleService.create(dto)); + } + + @Operation(summary = "修改周期预约规则") + @SysLog("修改周期预约规则") + @PutMapping + @PreAuthorize("@pms.hasPermission('hard_hardAppt_edit')") + public R edit(@RequestBody HardApptCycleRuleDto dto) { + return R.ok(cycleRuleService.edit(dto)); + } + + @Operation(summary = "删除周期预约规则") + @SysLog("删除周期预约规则") + @DeleteMapping + @PreAuthorize("@pms.hasPermission('hard_hardAppt_del')") + public R delete(@RequestBody Long[] ids) { + cycleRuleService.delete(CollUtil.toList(ids)); + return R.ok(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index d46a1ca93..151c5003b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -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> 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); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptCycleRuleItemMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptCycleRuleItemMapper.java new file mode 100644 index 000000000..1d2cea9b3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptCycleRuleItemMapper.java @@ -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 { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptCycleRuleMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptCycleRuleMapper.java new file mode 100644 index 000000000..00842fa3e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptCycleRuleMapper.java @@ -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 { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptCycleRuleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptCycleRuleService.java new file mode 100644 index 000000000..65591382c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptCycleRuleService.java @@ -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 { + + Page scopePage(Page page, HardApptCycleRuleDto dto, DataScope dataScope); + + HardApptCycleRuleVo detail(Long id); + + Long create(HardApptCycleRuleDto dto); + + Long edit(HardApptCycleRuleDto dto); + + void delete(List ids); + + List listEnabledConfigurations(Long mchId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java new file mode 100644 index 000000000..4ecfdf6df --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -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 listLegacyOptions(Long mchId); + + List 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) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImpl.java new file mode 100644 index 000000000..63ceabf44 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImpl.java @@ -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 + implements HardApptCycleRuleService { + + private final HardApptCycleRuleItemMapper itemMapper; + private final RemoteMchService remoteMchService; + + @Override + public Page scopePage(Page page, HardApptCycleRuleDto dto, DataScope dataScope) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 entityPage = baseMapper.selectPageByScope(page, wrapper, dataScope); + Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + List 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 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 items = normalizeAndValidate(dto, dto.getId()); + baseMapper.updateById(BeanUtil.toBean(dto, HardApptCycleRuleEntity.class)); + itemMapper.delete(new LambdaQueryWrapper() + .eq(HardApptCycleRuleItemEntity::getRuleId, dto.getId())); + saveItems(dto, items); + return dto.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void delete(List ids) { + if (ids == null || ids.isEmpty()) { + return; + } + itemMapper.delete(new LambdaQueryWrapper() + .in(HardApptCycleRuleItemEntity::getRuleId, ids)); + baseMapper.deleteBatchIds(ids); + } + + @Override + public List listEnabledConfigurations(Long mchId) { + if (mchId == null) { + return List.of(); + } + List rules = baseMapper.selectList( + new LambdaQueryWrapper() + .eq(HardApptCycleRuleEntity::getMchId, mchId) + .eq(HardApptCycleRuleEntity::getStatus, YesNoEnum.YES.getCode()) + .orderByDesc(HardApptCycleRuleEntity::getSort) + .orderByAsc(HardApptCycleRuleEntity::getId)); + return toVos(rules, true); + } + + private List 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() + .eq(HardApptCycleRuleEntity::getMchId, dto.getMchId()) + .eq(HardApptCycleRuleEntity::getRuleName, dto.getRuleName()) + .ne(excludeId != null, HardApptCycleRuleEntity::getId, excludeId)); + if (sameNameCount > 0) { + throw new RuntimeException("同一商户的规则名称不能重复"); + } + + List items = normalizeItems(dto.getItemList(), dto.getTargetDayCount()); + R 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 normalizeItems(List source, int dayCount) { + if (source == null || source.isEmpty()) { + throw new RuntimeException("至少需要配置一个目标日餐段"); + } + Map 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 candidateItems, + Long excludeId) { + List existingRules = baseMapper.selectList( + new LambdaQueryWrapper() + .eq(HardApptCycleRuleEntity::getMchId, candidate.getMchId()) + .eq(HardApptCycleRuleEntity::getStatus, YesNoEnum.YES.getCode()) + .ne(excludeId != null, HardApptCycleRuleEntity::getId, excludeId)); + if (existingRules.isEmpty()) { + return; + } + Map ruleMap = existingRules.stream() + .collect(Collectors.toMap(HardApptCycleRuleEntity::getId, value -> value)); + List existingItems = itemMapper.selectList( + new LambdaQueryWrapper() + .in(HardApptCycleRuleItemEntity::getRuleId, ruleMap.keySet())); + Map 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 items) { + List 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 toVos(List rules, boolean includeItems) { + if (rules == null || rules.isEmpty()) { + return List.of(); + } + List ruleIds = rules.stream().map(HardApptCycleRuleEntity::getId).toList(); + List items = itemMapper.selectList( + new LambdaQueryWrapper() + .in(HardApptCycleRuleItemEntity::getRuleId, ruleIds) + .orderByAsc(HardApptCycleRuleItemEntity::getTargetDayOffset) + .orderByAsc(HardApptCycleRuleItemEntity::getHourType)); + Map> itemGroups = items.stream() + .collect(Collectors.groupingBy(HardApptCycleRuleItemEntity::getRuleId)); + + return rules.stream().map(rule -> { + HardApptCycleRuleVo vo = BeanUtil.toBean(rule, HardApptCycleRuleVo.class); + List ruleItems = itemGroups.getOrDefault(rule.getId(), List.of()); + vo.setItemCount(ruleItems.size()); + vo.setApptCount((int) ruleItems.stream().map(HardApptCycleRuleItemEntity::getHourType).distinct().count()); + if (includeItems) { + List 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 records) { + List mchIds = records.stream().map(HardApptCycleRuleVo::getMchId) + .filter(ObjectUtil::isNotNull).distinct().toList(); + if (mchIds.isEmpty()) { + return; + } + R> merchantResult = remoteMchService.getByIds(mchIds); + List merchants = merchantResult == null || !merchantResult.isOk() + ? List.of() : merchantResult.getData(); + if (merchants == null || merchants.isEmpty()) { + return; + } + Map 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) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java new file mode 100644 index 000000000..6391b61e3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -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 listLegacyOptions(Long mchId) { + validateMerchantId(mchId); + List dynamicAppts = findEnabledAppts(mchId).stream() + .filter(appt -> resolveMode(appt) == HardApptReserveModeEnum.TIME_WINDOW) + .toList(); + if (dynamicAppts.isEmpty()) { + return List.of(); + } + + Map> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts); + LocalDate today = nowInShanghai().toLocalDate(); + List result = new ArrayList<>(); + for (int day = 0; day <= HardApptTimeTypeEnum.AFTER_TOMORROW.getDay(); day++) { + HardApptTimeTypeEnum dateType = HardApptTimeTypeEnum.valueOfByDay(day); + Set mappedIds = apptIdsByDateType.getOrDefault(dateType.getType(), Set.of()); + List 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 listOptions(Long mchId) { + return listOptions(mchId, nowInShanghai()); + } + + List listOptions(Long mchId, LocalDateTime now) { + validateMerchantId(mchId); + List allAppts = findEnabledAppts(mchId); + LocalDate today = now.toLocalDate(); + Map> optionsByDate = new TreeMap<>(); + + List dynamicAppts = allAppts.stream() + .filter(appt -> resolveMode(appt) == HardApptReserveModeEnum.TIME_WINDOW) + .toList(); + Map> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts); + for (HardApptTimeTypeEnum dateType : HardApptTimeTypeEnum.values()) { + if (!HardApptTimeTypeEnum.isReservationDateType(dateType.getType())) { + continue; + } + Set 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() + .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 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 findEnabledAppts(Long mchId) { + return hardApptService.list(new LambdaQueryWrapper() + .eq(HardApptEntity::getMchId, mchId) + .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode()) + .orderByAsc(HardApptEntity::getHourType) + .orderByDesc(HardApptEntity::getSort)); + } + + private Map> groupMappingsByDateType(Long mchId, List appts) { + if (apptListIsEmpty(appts)) { + return Map.of(); + } + Set apptIds = appts.stream().map(HardApptEntity::getId) + .filter(ObjectUtil::isNotNull).collect(Collectors.toSet()); + if (apptIds.isEmpty()) { + return Map.of(); + } + List mappings = hardApptTimeService.list( + new LambdaQueryWrapper() + .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 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) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java index 1d77a5f28..e141c9168 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java @@ -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 list) { if (ObjectUtil.isEmpty(list)) return; + hardApptTimeMapper.delete(new LambdaQueryWrapper() + .in(HardApptTimeEntity::getApptId, list)); baseMapper.deleteBatchIds(list); } @@ -136,13 +150,15 @@ public class HardApptServiceImpl extends ServiceImpl reserveTimeList) { - if (!ObjectUtil.isAllNotEmpty(apptEntity.getId(), apptEntity.getMchId(), reserveTimeList)) { - return; + if (!ObjectUtil.isAllNotEmpty(apptEntity.getId(), apptEntity.getMchId())) { + throw new RuntimeException("预约餐段信息不完整"); } hardApptTimeMapper.delete(new LambdaQueryWrapper() .eq(HardApptTimeEntity::getApptId, apptEntity.getId()) - .eq(HardApptTimeEntity::getMchId, apptEntity.getMchId()) ); + if (ObjectUtil.isEmpty(reserveTimeList)) { + return; + } ArrayList addList = new ArrayList<>(); reserveTimeList.forEach(v -> { HardApptTimeEntity entity = new HardApptTimeEntity(); @@ -154,6 +170,56 @@ public class HardApptServiceImpl extends ServiceImpl 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() .eq(ObjectUtil.isNotEmpty(title), HardApptEntity::getTitle, title) @@ -174,11 +240,16 @@ public class HardApptServiceImpl extends ServiceImpl apptTimeList = targetList.stream().filter(v -> ObjectUtil.equals(item.getId(), v.getApptId())).toList(); if (ObjectUtil.isNotEmpty(apptTimeList)) { - List typeList = apptTimeList.stream().map(HardApptTimeEntity::getType).toList(); + List typeList = apptTimeList.stream() + .map(HardApptTimeEntity::getType) + .filter(HardApptTimeTypeEnum::isReservationDateType) + .distinct() + .sorted(Comparator.comparingInt(type -> HardApptTimeTypeEnum.valueOfByType(type).getDay())) + .toList(); item.setReserveTimeList(typeList); } } } } -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index c200d6735..83648e868 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -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 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 3) throw new RuntimeException("您有" + reserveCount + "笔未核销的预约订单,请先核销"); @@ -2258,32 +2256,6 @@ public class HardOrderServiceImpl extends ServiceImpl 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) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index d43855a81..9aa6c3eee 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -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 @@ and a.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]} + + and a.appt_use_date >= #{dto.apptUseDateStart} + and a.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]} @@ -908,4 +912,4 @@ limit #{dto.limit} - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImplTest.java new file mode 100644 index 000000000..04b60a00b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImplTest.java @@ -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; + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java new file mode 100644 index 000000000..a36096f90 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java @@ -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.>any())) + .thenReturn(List.of(dynamic)); + when(timeService.list(org.mockito.ArgumentMatchers.>any())) + .thenReturn(List.of(mapping)); + + HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class); + when(cycleRuleService.listEnabledConfigurations(10L)).thenReturn(List.of(cycleRule)); + List 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.>any())) + .thenReturn(List.of()); + HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class); + when(cycleRuleService.listEnabledConfigurations(10L)).thenReturn(List.of(rule)); + + List 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.>any())) + .thenReturn(List.of(dynamic, weekly)); + when(timeService.list(org.mockito.ArgumentMatchers.>any())) + .thenReturn(List.of(mapping)); + HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class); + + List 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 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 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; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index e6efc5ca2..2c5a16efb 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -76,7 +76,8 @@ public class SysUserFaceServiceImpl extends ServiceImpl 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