diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/constants/CanteenOrderingFoodStatus.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/constants/CanteenOrderingFoodStatus.java new file mode 100644 index 00000000..229d8240 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/constants/CanteenOrderingFoodStatus.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.canteen.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 报餐状态 + */ +@Getter +@AllArgsConstructor +public enum CanteenOrderingFoodStatus { + BOOKED(0, "已报餐"), + CANCELLED(1, "已取消"), + VERIFIED(2, "已核销"); + + private final int code; + private final String label; + + public static CanteenOrderingFoodStatus ofCode(Integer code) { + if (code == null) { + return null; + } + for (CanteenOrderingFoodStatus s : values()) { + if (s.code == code) { + return s; + } + } + return null; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSelectionDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSelectionDTO.java new file mode 100644 index 00000000..4efdd2c2 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSelectionDTO.java @@ -0,0 +1,20 @@ +package com.bsd.pjcloud.canteen.dto.ordering; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@Schema(description = "报餐日期餐段选择") +public class OrderingFoodSelectionDTO { + + @NotNull(message = "就餐日期不能为空") + @Schema(description = "就餐日期 yyyy-MM-dd") + private LocalDate diningDate; + + @NotNull(message = "餐段不能为空") + @Schema(description = "餐段序号(对应 meal-segments 返回的 segmentIndex)") + private Integer segmentIndex; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSelectionItemDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSelectionItemDTO.java new file mode 100644 index 00000000..1525bdc5 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSelectionItemDTO.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.canteen.dto.ordering; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@Schema(description = "报餐明细项(一次提交内的一条日期+餐段)") +public class OrderingFoodSelectionItemDTO { + + @Schema(description = "就餐日期") + private LocalDate diningDate; + + @Schema(description = "餐段序号") + private Integer segmentIndex; + + @Schema(description = "餐段类型 1早餐 2午餐 3晚餐 4宵夜") + private Integer mealSegmentType; + + @Schema(description = "餐段名称") + private String mealSegmentName; + + @Schema(description = "餐段开始 HH:mm") + private String segmentStartTime; + + @Schema(description = "餐段结束 HH:mm") + private String segmentEndTime; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSpuItemDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSpuItemDTO.java new file mode 100644 index 00000000..81cde1e2 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSpuItemDTO.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.canteen.dto.ordering; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "报餐所选菜品项") +public class OrderingFoodSpuItemDTO { + + @NotNull(message = "菜品ID不能为空") + @Schema(description = "线上菜品ID") + private Long id; + + @NotBlank(message = "菜品名称不能为空") + @Schema(description = "菜品名称") + private String name; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSubmitDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSubmitDTO.java new file mode 100644 index 00000000..9f3b0a53 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/ordering/OrderingFoodSubmitDTO.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.canteen.dto.ordering; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "提交报餐") +public class OrderingFoodSubmitDTO { + + @NotBlank + @Schema(description = "商户ID") + private String merchantId; + + @NotNull(message = "请填写报餐人数") + @Min(value = 1, message = "报餐人数至少为1") + @Schema(description = "报餐人数") + private Integer peopleCount; + + @Schema(description = "备注(选填)") + private String remark; + + @NotEmpty(message = "请选择报餐菜品") + @Valid + @Schema(description = "所选菜品(至少一项)") + private List spuItems; + + @NotEmpty(message = "请选择报餐日期与餐段") + @Valid + @Schema(description = "报餐选择(支持未来7天多餐段批量)") + private List selections; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenOrderingFoodEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenOrderingFoodEntity.java new file mode 100644 index 00000000..11736837 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenOrderingFoodEntity.java @@ -0,0 +1,84 @@ +package com.bsd.pjcloud.canteen.entity; + +import com.baomidou.mybatisplus.annotation.*; +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.LocalDate; +import java.time.LocalDateTime; + +@Data +@TenantTable +@TableName("canteen_ordering_food") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "食堂报餐记录") +public class CanteenOrderingFoodEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + private Long deptId; + private String merchantId; + private String merchantName; + private Long userId; + private String userName; + + @Schema(description = "就餐开始日期(多选时取最早)") + private LocalDate diningDate; + + @Schema(description = "就餐结束日期(多选时取最晚,单日与 diningDate 相同)") + private LocalDate diningDateEnd; + + @Schema(description = "本单日期餐段条数") + private Integer selectionCount; + + @Schema(description = "报餐明细JSON") + private String selectionsJson; + + @Schema(description = "就餐摘要") + private String summaryText; + + @Schema(description = "餐段序号(兼容旧数据单条)") + private Integer mealSegmentIndex; + + @Schema(description = "餐段类型 1早餐 2午餐 3晚餐 4宵夜") + private Integer mealSegmentType; + + private String mealSegmentName; + private String segmentStartTime; + private String segmentEndTime; + + @Schema(description = "报餐人数") + private Integer peopleCount; + + private String remark; + + @Schema(description = "所选菜品JSON") + private String spuItemsJson; + + @Schema(description = "0已报餐 1已取消 2已核销") + private Integer status; + + private LocalDateTime cancelTime; + private LocalDateTime verifyTime; + + @TableField(fill = FieldFill.INSERT) + private String createBy; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodAppVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodAppVO.java new file mode 100644 index 00000000..38ea8f0d --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodAppVO.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSelectionItemDTO; +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSpuItemDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Schema(description = "报餐记录(App)") +public class OrderingFoodAppVO { + + private Long id; + private String merchantId; + private String merchantName; + private Long userId; + private String userName; + private LocalDate diningDate; + private LocalDate diningDateEnd; + private Integer selectionCount; + private String summaryText; + private List selections; + private Integer mealSegmentIndex; + private Integer mealSegmentType; + private String mealSegmentName; + private String segmentStartTime; + private String segmentEndTime; + private Integer peopleCount; + private String remark; + private List spuItems; + private Integer status; + private String statusLabel; + private LocalDateTime createTime; + private LocalDateTime cancelTime; + private LocalDateTime verifyTime; + private Boolean canCancel; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodMealSegmentVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodMealSegmentVO.java new file mode 100644 index 00000000..20810300 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodMealSegmentVO.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "报餐餐段(来自商户营业时间)") +public class OrderingFoodMealSegmentVO { + + @Schema(description = "餐段序号(提交报餐时用)") + private Integer segmentIndex; + + @Schema(description = "餐段类型 1早餐 2午餐 3晚餐 4宵夜") + private Integer mealSegmentType; + + @Schema(description = "餐段名称") + private String mealSegmentName; + + @Schema(description = "开始时间 HH:mm") + private String startTime; + + @Schema(description = "结束时间 HH:mm") + private String endTime; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDayDetailVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDayDetailVO.java new file mode 100644 index 00000000..4c78fe12 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDayDetailVO.java @@ -0,0 +1,26 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "报餐统计-某日详情") +public class OrderingFoodStatsDayDetailVO { + + @Schema(description = "日期 yyyy-MM-dd") + private String diningDate; + + @Schema(description = "商户ID") + private String merchantId; + + @Schema(description = "商户名称") + private String merchantName; + + @Schema(description = "当日报餐人数合计") + private Long totalPeopleCount; + + @Schema(description = "各餐段明细") + private List segments; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDishItemVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDishItemVO.java new file mode 100644 index 00000000..d9139cae --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDishItemVO.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "报餐统计-菜品份数") +public class OrderingFoodStatsDishItemVO { + + @Schema(description = "菜品ID") + private Long spuId; + + @Schema(description = "菜品名称") + private String spuName; + + @Schema(description = "份数(按报餐人数累计)") + private Long portionCount; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDishTotalExportVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDishTotalExportVO.java new file mode 100644 index 00000000..92e95445 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsDishTotalExportVO.java @@ -0,0 +1,16 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "报餐统计导出-菜品合计") +public class OrderingFoodStatsDishTotalExportVO { + + @ExcelProperty("菜品名称") + private String spuName; + + @ExcelProperty("份数合计") + private Long portionCount; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsRecordBriefVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsRecordBriefVO.java new file mode 100644 index 00000000..133508a7 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsRecordBriefVO.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSpuItemDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "报餐统计-明细记录") +public class OrderingFoodStatsRecordBriefVO { + + @Schema(description = "报餐记录ID") + private Long id; + + @Schema(description = "报餐人") + private String userName; + + @Schema(description = "报餐人数") + private Integer peopleCount; + + @Schema(description = "所选菜品") + private List spuItems; + + @Schema(description = "备注") + private String remark; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsRowVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsRowVO.java new file mode 100644 index 00000000..a133962f --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsRowVO.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import com.alibaba.excel.annotation.ExcelIgnore; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "报餐统计行(按日)") +public class OrderingFoodStatsRowVO { + + @ExcelProperty("日期") + @Schema(description = "日期 yyyy-MM-dd") + private String diningDate; + + @ExcelIgnore + @Schema(description = "各餐段人数(动态,来自商户 jobTime)") + private List segmentCounts; + + @ExcelProperty("早餐") + private Long breakfastCount; + + @ExcelProperty("午餐") + private Long lunchCount; + + @ExcelProperty("晚餐") + private Long dinnerCount; + + @ExcelProperty("宵夜") + private Long midnightSnackCount; + + @ExcelProperty("合计人数") + private Long totalPeopleCount; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentCountVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentCountVO.java new file mode 100644 index 00000000..0b60003b --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentCountVO.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "报餐统计-餐段人数") +public class OrderingFoodStatsSegmentCountVO { + + @Schema(description = "餐段序号") + private Integer segmentIndex; + + @Schema(description = "餐段类型 1早餐 2午餐 3晚餐 4宵夜") + private Integer mealSegmentType; + + @Schema(description = "餐段名称") + private String mealSegmentName; + + @Schema(description = "报餐人数合计") + private Long peopleCount; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentDetailVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentDetailVO.java new file mode 100644 index 00000000..29d970c9 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentDetailVO.java @@ -0,0 +1,35 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "报餐统计-某日餐段明细") +public class OrderingFoodStatsSegmentDetailVO { + + @Schema(description = "餐段序号") + private Integer segmentIndex; + + @Schema(description = "餐段名称") + private String mealSegmentName; + + @Schema(description = "开始时间 HH:mm") + private String startTime; + + @Schema(description = "结束时间 HH:mm") + private String endTime; + + @Schema(description = "报餐人数合计") + private Long peopleCount; + + @Schema(description = "报餐笔数") + private Long recordCount; + + @Schema(description = "菜品份数汇总") + private List dishes; + + @Schema(description = "报餐明细") + private List records; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentDishExportVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentDishExportVO.java new file mode 100644 index 00000000..5ff13048 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSegmentDishExportVO.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "报餐统计导出-餐段菜品明细") +public class OrderingFoodStatsSegmentDishExportVO { + + @ExcelProperty("日期") + private String diningDate; + + @ExcelProperty("餐段") + private String mealSegmentName; + + @ExcelProperty("时段") + private String segmentTime; + + @ExcelProperty("菜品名称") + private String spuName; + + @ExcelProperty("份数") + private Long portionCount; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSummaryVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSummaryVO.java new file mode 100644 index 00000000..c875a49d --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OrderingFoodStatsSummaryVO.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.canteen.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "报餐统计汇总") +public class OrderingFoodStatsSummaryVO { + + @Schema(description = "商户ID") + private String merchantId; + + @Schema(description = "商户名称") + private String merchantName; + + @Schema(description = "报餐记录条数(未取消)") + private Long recordCount; + + @Schema(description = "报餐人数合计(people_count求和)") + private Long totalPeopleCount; + + @Schema(description = "商户餐段配置(动态表头)") + private List segments; + + @Schema(description = "按日统计行") + private List rows; +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOrderingFoodController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOrderingFoodController.java new file mode 100644 index 00000000..183f22c8 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOrderingFoodController.java @@ -0,0 +1,244 @@ +package com.bsd.pjcloud.canteen.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSubmitDTO; +import com.bsd.pjcloud.canteen.entity.CanteenOrderingFoodEntity; +import com.bsd.pjcloud.canteen.service.CanteenOrderingFoodService; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodAppVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodMealSegmentVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsDayDetailVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsRowVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsSummaryVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.excel.annotation.Sheet; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springdoc.core.annotations.ParameterObject; + +import java.time.LocalDate; +import java.util.List; + +/** + * 食堂报餐(移动端) + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/canteenOrderingFood") +@Tag(name = "食堂报餐") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class CanteenOrderingFoodController { + + private final CanteenOrderingFoodService canteenOrderingFoodService; + + @GetMapping("/app/meal-segments") + @Operation(summary = "报餐餐段列表", description = "根据商户营业时间 jobTime 解析餐段(早餐/午餐/晚餐/宵夜)") + public R> mealSegments(@RequestParam String merchantId) { + return canteenOrderingFoodService.listMealSegments(merchantId); + } + + @GetMapping("/app/next-seven-days") + @Operation(summary = "可报餐日期", description = "从今天起连续7天 yyyy-MM-dd") + public R> nextSevenDays() { + return canteenOrderingFoodService.listNextSevenDays(); + } + + @SysLog("提交报餐") + @PostMapping("/app/submit") + @Operation(summary = "提交报餐", description = "支持批量选择未来7天内多个日期餐段") + public R submit(@Valid @RequestBody OrderingFoodSubmitDTO dto) { + return canteenOrderingFoodService.submit(dto); + } + + @GetMapping("/app/my/page") + @Operation(summary = "我的报餐记录", description = "分页:current、size;可选 startDate、endDate") + public R> myPage( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @ParameterObject Page page) { + return canteenOrderingFoodService.pageMyRecords(startDate, endDate, page); + } + + @GetMapping("/app/my/{id}") + @Operation(summary = "我的报餐详情") + public R myDetail(@PathVariable Long id) { + return canteenOrderingFoodService.detailMyRecord(id); + } + + @SysLog("取消报餐") + @PostMapping("/app/my/cancel/{id}") + @Operation(summary = "取消报餐") + public R cancel(@PathVariable Long id) { + return canteenOrderingFoodService.cancelMyRecord(id); + } + + @GetMapping("/app/mch/stats") + @Operation(summary = "商户报餐统计", description = "仅商户角色;按日汇总各餐段报餐人数") + public R mchStats( + @RequestParam(required = false) String merchantId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId) { + return canteenOrderingFoodService.statsForMerchant(merchantId, startDate, endDate, deptId, userId); + } + + @ResponseExcel(name = "报餐统计", sheets = { + @Sheet(sheetName = "每日统计"), + @Sheet(sheetName = "菜品合计") + }) + @GetMapping("/app/mch/stats/export") + @Operation(summary = "商户报餐统计导出Excel", description = "Sheet1 每日餐段人数;Sheet2 筛选范围内各菜品份数合计") + public List> mchStatsExport( + @RequestParam(required = false) String merchantId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId) { + return canteenOrderingFoodService.exportStatsForMerchant(merchantId, startDate, endDate, deptId, userId); + } + + @GetMapping("/app/mch/stats/detail") + @Operation(summary = "商户报餐统计-某日详情", description = "按餐段汇总人数与菜品份数;segmentIndex 可选,仅查看某一餐段") + public R mchStatsDetail( + @RequestParam(required = false) String merchantId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate diningDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId, + @RequestParam(required = false) Integer segmentIndex) { + return canteenOrderingFoodService.statsDayDetailForMerchant(merchantId, diningDate, deptId, userId, segmentIndex); + } + + @ResponseExcel(name = "报餐日详情", sheets = { + @Sheet(sheetName = "餐段菜品"), + @Sheet(sheetName = "菜品合计") + }) + @GetMapping("/app/mch/stats/detail/export") + @Operation(summary = "商户报餐统计-某日详情导出", description = "Sheet1 各餐段菜品份数;Sheet2 当日内各菜品份数合计") + public List> mchStatsDetailExport( + @RequestParam(required = false) String merchantId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate diningDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId, + @RequestParam(required = false) Integer segmentIndex) { + return canteenOrderingFoodService.exportStatsDayDetailForMerchant(merchantId, diningDate, deptId, userId, segmentIndex); + } + + @GetMapping("/app/mch/filter-depts") + @Operation(summary = "统计筛选-部门列表") + public R> filterDepts() { + return canteenOrderingFoodService.filterDeptsForMerchant(); + } + + @GetMapping("/app/mch/filter-users") + @Operation(summary = "统计筛选-员工列表", description = "deptId 为空时取当前用户部门及下级全部用户") + public R> filterUsers(@RequestParam(required = false) Long deptId) { + return canteenOrderingFoodService.filterUsersForMerchant(deptId); + } + + // ---------- PC 后台 ---------- + + @GetMapping("/page") + @Operation(summary = "PC-报餐记录分页", description = "按商户、就餐日期、部门、员工、状态筛选;日期范围最多31天") + @PreAuthorize("@pms.hasPermission('canteen_canteenOrderingFoodRecord_view')") + public R> adminPage( + @RequestParam String merchantId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId, + @RequestParam(required = false) String userName, + @RequestParam(required = false) Integer status, + @ParameterObject Page page) { + return canteenOrderingFoodService.pageAdminRecords(merchantId, startDate, endDate, deptId, userId, userName, + status, page); + } + + @GetMapping("/detail/{id}") + @Operation(summary = "PC-报餐记录详情") + @PreAuthorize("@pms.hasPermission('canteen_canteenOrderingFoodRecord_view')") + public R adminDetail(@PathVariable Long id) { + return canteenOrderingFoodService.adminRecordDetail(id); + } + + @GetMapping("/stats") + @Operation(summary = "PC-报餐统计", description = "按日汇总各餐段人数;startDate/endDate 最多31天") + @PreAuthorize("@pms.hasPermission('canteen_canteenOrderingFoodStats_view')") + public R adminStats( + @RequestParam String merchantId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId) { + return canteenOrderingFoodService.statsForAdmin(merchantId, startDate, endDate, deptId, userId); + } + + @ResponseExcel(name = "报餐统计", sheets = { + @Sheet(sheetName = "每日统计"), + @Sheet(sheetName = "菜品合计") + }) + @GetMapping("/stats/export") + @Operation(summary = "PC-报餐统计导出") + @PreAuthorize("@pms.hasPermission('canteen_canteenOrderingFoodStats_export')") + public List> adminStatsExport( + @RequestParam String merchantId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId) { + return canteenOrderingFoodService.exportStatsForAdmin(merchantId, startDate, endDate, deptId, userId); + } + + @GetMapping("/stats/detail") + @Operation(summary = "PC-报餐统计某日详情") + @PreAuthorize("@pms.hasPermission('canteen_canteenOrderingFoodStats_view')") + public R adminStatsDetail( + @RequestParam String merchantId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate diningDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId, + @RequestParam(required = false) Integer segmentIndex) { + return canteenOrderingFoodService.statsDayDetailForAdmin(merchantId, diningDate, deptId, userId, segmentIndex); + } + + @ResponseExcel(name = "报餐日详情", sheets = { + @Sheet(sheetName = "餐段菜品"), + @Sheet(sheetName = "菜品合计") + }) + @GetMapping("/stats/detail/export") + @Operation(summary = "PC-报餐统计某日详情导出") + @PreAuthorize("@pms.hasPermission('canteen_canteenOrderingFoodStats_export')") + public List> adminStatsDetailExport( + @RequestParam String merchantId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate diningDate, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId, + @RequestParam(required = false) Integer segmentIndex) { + return canteenOrderingFoodService.exportStatsDayDetailForAdmin(merchantId, diningDate, deptId, userId, + segmentIndex); + } + + @GetMapping("/filter-depts") + @Operation(summary = "PC-筛选部门列表") + @PreAuthorize("@pms.hasPermission('canteen_canteenOrderingFoodRecord_view') or @pms.hasPermission('canteen_canteenOrderingFoodStats_view')") + public R> adminFilterDepts() { + return canteenOrderingFoodService.filterDeptsForAdmin(); + } + + @GetMapping("/filter-users") + @Operation(summary = "PC-筛选员工列表") + @PreAuthorize("@pms.hasPermission('canteen_canteenOrderingFoodRecord_view') or @pms.hasPermission('canteen_canteenOrderingFoodStats_view')") + public R> adminFilterUsers(@RequestParam(required = false) Long deptId) { + return canteenOrderingFoodService.filterUsersForAdmin(deptId); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenOrderingFoodMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenOrderingFoodMapper.java new file mode 100644 index 00000000..7e5a5c4e --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenOrderingFoodMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenOrderingFoodEntity; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CanteenOrderingFoodMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenOrderingFoodService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenOrderingFoodService.java new file mode 100644 index 00000000..e1543c8f --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenOrderingFoodService.java @@ -0,0 +1,71 @@ +package com.bsd.pjcloud.canteen.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSubmitDTO; +import com.bsd.pjcloud.canteen.entity.CanteenOrderingFoodEntity; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodAppVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodMealSegmentVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsDayDetailVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsRowVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsSummaryVO; +import com.bsd.pjcloud.common.core.util.R; + +import java.time.LocalDate; +import java.util.List; + +public interface CanteenOrderingFoodService extends IService { + + R> listMealSegments(String merchantId); + + R> listNextSevenDays(); + + R submit(OrderingFoodSubmitDTO dto); + + R> pageMyRecords(LocalDate startDate, LocalDate endDate, Page page); + + R detailMyRecord(Long id); + + R cancelMyRecord(Long id); + + R statsForMerchant(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId); + + List> exportStatsForMerchant(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId); + + List> exportStatsDayDetailForMerchant(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex); + + R statsDayDetailForMerchant(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex); + + R> filterDeptsForMerchant(); + + R> filterUsersForMerchant(Long deptId); + + /** PC 后台:报餐记录分页(本部门及下级商户范围) */ + R> pageAdminRecords(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId, String userName, Integer status, + Page page); + + R adminRecordDetail(Long id); + + R statsForAdmin(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId); + + List> exportStatsForAdmin(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId); + + R statsDayDetailForAdmin(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex); + + List> exportStatsDayDetailForAdmin(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex); + + R> filterDeptsForAdmin(); + + R> filterUsersForAdmin(Long deptId); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOrderingFoodServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOrderingFoodServiceImpl.java new file mode 100644 index 00000000..4db955cc --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOrderingFoodServiceImpl.java @@ -0,0 +1,1157 @@ +package com.bsd.pjcloud.canteen.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.canteen.constants.CanteenOrderingFoodStatus; +import com.bsd.pjcloud.common.core.constant.CommonRoleConstant; +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSelectionDTO; +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSelectionItemDTO; +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSpuItemDTO; +import com.bsd.pjcloud.canteen.dto.ordering.OrderingFoodSubmitDTO; +import com.bsd.pjcloud.canteen.entity.CanteenOrderingFoodEntity; +import com.bsd.pjcloud.canteen.mapper.CanteenOrderingFoodMapper; +import com.bsd.pjcloud.canteen.service.CanteenOrderingFoodService; +import com.bsd.pjcloud.canteen.util.MchJobTimeMealSegmentUtils; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodAppVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodMealSegmentVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsDayDetailVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsDishItemVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsRecordBriefVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsDishTotalExportVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsRowVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsSegmentCountVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsSegmentDetailVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsSegmentDishExportVO; +import com.bsd.pjcloud.canteen.vo.app.OrderingFoodStatsSummaryVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CanteenOrderingFoodServiceImpl extends ServiceImpl + implements CanteenOrderingFoodService { + + private static final int MAX_FUTURE_DAYS = 7; + private static final int MAX_PAGE_SIZE = 100; + /** 商户统计/导出:就餐日期区间最多 31 天(约一个月) */ + private static final int MAX_STATS_RANGE_DAYS = 31; + private static final DateTimeFormatter YMD = DateTimeFormatter.ISO_LOCAL_DATE; + + private final RemoteMchService remoteMchService; + private final RemoteUserService remoteUserService; + private final RemoteDeptService remoteDeptService; + private final ObjectMapper objectMapper; + + @Override + public R> listMealSegments(String merchantId) { + MchManageVo mch = loadMerchantOrFail(merchantId); + if (mch == null) { + return R.failed("商户不存在"); + } + if (!isOrderingFoodEnabled(mch)) { + return R.failed("该商户未启用报餐"); + } + List segments = MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime()); + if (CollUtil.isEmpty(segments)) { + return R.failed("商户未配置营业时间,无法获取餐段"); + } + return R.ok(segments); + } + + @Override + public R> listNextSevenDays() { + LocalDate today = LocalDate.now(); + List days = new ArrayList<>(MAX_FUTURE_DAYS); + for (int i = 0; i < MAX_FUTURE_DAYS; i++) { + days.add(today.plusDays(i).format(YMD)); + } + return R.ok(days); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R submit(OrderingFoodSubmitDTO dto) { + var login = SecurityUtils.getUser(); + if (login == null || login.getId() == null) { + return R.failed("未登录"); + } + MchManageVo mch = loadMerchantOrFail(dto.getMerchantId()); + if (mch == null) { + return R.failed("商户不存在"); + } + if (!isOrderingFoodEnabled(mch)) { + return R.failed("该商户未启用报餐"); + } + if (CollUtil.isEmpty(dto.getSelections())) { + return R.failed("请选择报餐日期与餐段"); + } + if (CollUtil.isEmpty(dto.getSpuItems())) { + return R.failed("请选择报餐菜品"); + } + String spuErr = validateSpuItems(dto.getSpuItems()); + if (spuErr != null) { + return R.failed(spuErr); + } + if (dto.getPeopleCount() == null || dto.getPeopleCount() < 1) { + return R.failed("请填写报餐人数"); + } + String jobTime = mch.getJobTime(); + if (MchJobTimeMealSegmentUtils.isBlankJobTime(jobTime)) { + return R.failed("商户未配置营业时间"); + } + + LocalDate minDate = LocalDate.now(); + LocalDate maxDate = minDate.plusDays(MAX_FUTURE_DAYS - 1); + R> buildR = buildSelectionItems(dto.getSelections(), jobTime, minDate, maxDate); + if (!buildR.isOk()) { + return R.failed(buildR.getMsg()); + } + List items = buildR.getData(); + String conflict = findSelectionConflict(login.getId(), dto.getMerchantId(), items); + if (conflict != null) { + return R.failed(conflict); + } + + SysUser sysUser = resolveSysUser(login.getId()); + LocalDate diningStart = items.stream().map(OrderingFoodSelectionItemDTO::getDiningDate) + .min(LocalDate::compareTo).orElse(minDate); + LocalDate diningEnd = items.stream().map(OrderingFoodSelectionItemDTO::getDiningDate) + .max(LocalDate::compareTo).orElse(diningStart); + OrderingFoodSelectionItemDTO first = items.get(0); + + CanteenOrderingFoodEntity entity = new CanteenOrderingFoodEntity(); + entity.setDeptId(sysUser != null ? sysUser.getDeptId() : null); + entity.setMerchantId(dto.getMerchantId()); + entity.setMerchantName(mch.getName()); + entity.setUserId(login.getId()); + entity.setUserName(sysUser != null ? sysUser.getName() : login.getUsername()); + entity.setDiningDate(diningStart); + entity.setDiningDateEnd(diningEnd); + entity.setSelectionCount(items.size()); + entity.setSelectionsJson(serializeSelections(items)); + entity.setSummaryText(buildSummaryText(items)); + entity.setMealSegmentIndex(first.getSegmentIndex()); + entity.setMealSegmentType(first.getMealSegmentType()); + entity.setMealSegmentName(first.getMealSegmentName()); + entity.setSegmentStartTime(first.getSegmentStartTime()); + entity.setSegmentEndTime(first.getSegmentEndTime()); + entity.setPeopleCount(dto.getPeopleCount()); + entity.setRemark(StrUtil.trim(dto.getRemark())); + entity.setSpuItemsJson(serializeSpuItems(dto.getSpuItems())); + entity.setStatus(CanteenOrderingFoodStatus.BOOKED.getCode()); + save(entity); + return R.ok(1); + } + + @Override + public R> pageMyRecords(LocalDate startDate, LocalDate endDate, + Page page) { + Long uid = loginUserIdOrNull(); + if (uid == null) { + return R.failed("未登录"); + } + Page p = normalizePage(page); + var q = Wrappers.lambdaQuery() + .eq(CanteenOrderingFoodEntity::getUserId, uid); + applyDiningDateRangeFilter(q, startDate, endDate); + q.orderByDesc(CanteenOrderingFoodEntity::getCreateTime); + Page entityPage = page(p, q); + return R.ok(toVoPage(entityPage)); + } + + @Override + public R detailMyRecord(Long id) { + Long uid = loginUserIdOrNull(); + if (uid == null) { + return R.failed("未登录"); + } + CanteenOrderingFoodEntity e = getById(id); + if (e == null || !Objects.equals(e.getUserId(), uid)) { + return R.failed("记录不存在"); + } + return R.ok(toAppVo(e)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R cancelMyRecord(Long id) { + Long uid = loginUserIdOrNull(); + if (uid == null) { + return R.failed("未登录"); + } + CanteenOrderingFoodEntity e = getById(id); + if (e == null || !Objects.equals(e.getUserId(), uid)) { + return R.failed("记录不存在"); + } + if (!Objects.equals(e.getStatus(), CanteenOrderingFoodStatus.BOOKED.getCode())) { + return R.failed("当前状态不可取消"); + } + e.setStatus(CanteenOrderingFoodStatus.CANCELLED.getCode()); + e.setCancelTime(LocalDateTime.now()); + updateById(e); + return R.ok(); + } + + @Override + public R statsForMerchant(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId) { + R roleR = checkOnlineCanteenMchRole(); + if (!roleR.isOk()) { + return R.failed(roleR.getMsg()); + } + R> mchIdsR = resolveMerchantIdStringsForLoginUser(); + if (!mchIdsR.isOk()) { + return R.failed(mchIdsR.getMsg()); + } + String mchId = resolveStatsMerchantId(merchantId, mchIdsR.getData()); + if (mchId == null) { + return R.failed("请选择有效商户"); + } + if (!mchIdsR.getData().contains(mchId)) { + return R.failed("无权查看该商户数据"); + } + R rangeR = normalizeDateRange(startDate, endDate); + if (!rangeR.isOk()) { + return R.failed(rangeR.getMsg()); + } + LocalDate[] range = rangeR.getData(); + return R.ok(buildStatsSummary(mchId, range[0], range[1], deptId, userId)); + } + + @Override + public R statsDayDetailForMerchant(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex) { + R roleR = checkOnlineCanteenMchRole(); + if (!roleR.isOk()) { + return R.failed(roleR.getMsg()); + } + R> mchIdsR = resolveMerchantIdStringsForLoginUser(); + if (!mchIdsR.isOk()) { + return R.failed(mchIdsR.getMsg()); + } + String mchId = resolveStatsMerchantId(merchantId, mchIdsR.getData()); + if (mchId == null) { + return R.failed("请选择有效商户"); + } + if (!mchIdsR.getData().contains(mchId)) { + return R.failed("无权查看该商户数据"); + } + if (diningDate == null) { + return R.failed("请选择日期"); + } + return R.ok(buildStatsDayDetail(mchId, diningDate, deptId, userId, segmentIndex)); + } + + @Override + public List> exportStatsForMerchant(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId) { + R roleR = checkOnlineCanteenMchRole(); + if (!roleR.isOk()) { + return List.of(List.of(), List.of()); + } + R> mchIdsR = resolveMerchantIdStringsForLoginUser(); + if (!mchIdsR.isOk()) { + return List.of(List.of(), List.of()); + } + String mchId = resolveStatsMerchantId(merchantId, mchIdsR.getData()); + if (mchId == null || !mchIdsR.getData().contains(mchId)) { + return List.of(List.of(), List.of()); + } + R rangeR = normalizeDateRange(startDate, endDate); + if (!rangeR.isOk()) { + return List.of(List.of(), List.of()); + } + LocalDate[] range = rangeR.getData(); + OrderingFoodStatsSummaryVO summary = buildStatsSummary(mchId, range[0], range[1], deptId, userId); + List booked = queryBookedRecords(mchId, range[0], range[1], deptId, userId); + List dishTotals = toDishTotalExportRows( + aggregateDishTotals(booked, range[0], range[1])); + List dailyRows = summary.getRows() != null ? summary.getRows() : List.of(); + return List.of(dailyRows, dishTotals); + } + + @Override + public List> exportStatsDayDetailForMerchant(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex) { + R roleR = checkOnlineCanteenMchRole(); + if (!roleR.isOk()) { + return List.of(List.of(), List.of()); + } + R> mchIdsR = resolveMerchantIdStringsForLoginUser(); + if (!mchIdsR.isOk()) { + return List.of(List.of(), List.of()); + } + String mchId = resolveStatsMerchantId(merchantId, mchIdsR.getData()); + if (mchId == null || !mchIdsR.getData().contains(mchId)) { + return List.of(List.of(), List.of()); + } + if (diningDate == null) { + return List.of(List.of(), List.of()); + } + OrderingFoodStatsDayDetailVO detail = buildStatsDayDetail(mchId, diningDate, deptId, userId, segmentIndex); + List segmentRows = buildSegmentDishExportRows(detail); + List dayTotals = toDishTotalExportRows(mergeDayDishTotals(detail)); + return List.of(segmentRows, dayTotals); + } + + @Override + public R> filterDeptsForMerchant() { + R roleR = checkOnlineCanteenMchRole(); + if (!roleR.isOk()) { + return R.failed(roleR.getMsg()); + } + return listFilterDepts(); + } + + @Override + public R> filterUsersForMerchant(Long deptId) { + R roleR = checkOnlineCanteenMchRole(); + if (!roleR.isOk()) { + return R.failed(roleR.getMsg()); + } + return listFilterUsers(deptId); + } + + @Override + public R> pageAdminRecords(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId, String userName, Integer status, + Page page) { + R mchR = resolveAdminMerchantId(merchantId); + if (!mchR.isOk()) { + return R.failed(mchR.getMsg()); + } + Page p = normalizePage(page); + var q = Wrappers.lambdaQuery() + .eq(CanteenOrderingFoodEntity::getMerchantId, mchR.getData()) + .eq(deptId != null, CanteenOrderingFoodEntity::getDeptId, deptId) + .eq(userId != null, CanteenOrderingFoodEntity::getUserId, userId) + .like(StrUtil.isNotBlank(userName), CanteenOrderingFoodEntity::getUserName, userName) + .eq(status != null, CanteenOrderingFoodEntity::getStatus, status); + applyDiningDateRangeFilter(q, startDate, endDate); + q.orderByDesc(CanteenOrderingFoodEntity::getCreateTime); + return R.ok(toVoPage(page(p, q))); + } + + @Override + public R adminRecordDetail(Long id) { + if (id == null) { + return R.failed("记录不存在"); + } + CanteenOrderingFoodEntity e = getById(id); + if (e == null) { + return R.failed("记录不存在"); + } + R mchR = resolveAdminMerchantId(e.getMerchantId()); + if (!mchR.isOk()) { + return R.failed(mchR.getMsg()); + } + return R.ok(toAppVo(e)); + } + + @Override + public R statsForAdmin(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId) { + R mchR = resolveAdminMerchantId(merchantId); + if (!mchR.isOk()) { + return R.failed(mchR.getMsg()); + } + R rangeR = normalizeDateRange(startDate, endDate); + if (!rangeR.isOk()) { + return R.failed(rangeR.getMsg()); + } + LocalDate[] range = rangeR.getData(); + return R.ok(buildStatsSummary(mchR.getData(), range[0], range[1], deptId, userId)); + } + + @Override + public List> exportStatsForAdmin(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId) { + R mchR = resolveAdminMerchantId(merchantId); + if (!mchR.isOk()) { + return List.of(List.of(), List.of()); + } + R rangeR = normalizeDateRange(startDate, endDate); + if (!rangeR.isOk()) { + return List.of(List.of(), List.of()); + } + LocalDate[] range = rangeR.getData(); + OrderingFoodStatsSummaryVO summary = buildStatsSummary(mchR.getData(), range[0], range[1], deptId, userId); + List booked = queryBookedRecords(mchR.getData(), range[0], range[1], deptId, userId); + List dishTotals = toDishTotalExportRows( + aggregateDishTotals(booked, range[0], range[1])); + List dailyRows = summary.getRows() != null ? summary.getRows() : List.of(); + return List.of(dailyRows, dishTotals); + } + + @Override + public R statsDayDetailForAdmin(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex) { + R mchR = resolveAdminMerchantId(merchantId); + if (!mchR.isOk()) { + return R.failed(mchR.getMsg()); + } + if (diningDate == null) { + return R.failed("请选择日期"); + } + return R.ok(buildStatsDayDetail(mchR.getData(), diningDate, deptId, userId, segmentIndex)); + } + + @Override + public List> exportStatsDayDetailForAdmin(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex) { + R mchR = resolveAdminMerchantId(merchantId); + if (!mchR.isOk() || diningDate == null) { + return List.of(List.of(), List.of()); + } + OrderingFoodStatsDayDetailVO detail = buildStatsDayDetail(mchR.getData(), diningDate, deptId, userId, segmentIndex); + List segmentRows = buildSegmentDishExportRows(detail); + List dayTotals = toDishTotalExportRows(mergeDayDishTotals(detail)); + return List.of(segmentRows, dayTotals); + } + + @Override + public R> filterDeptsForAdmin() { + return listFilterDepts(); + } + + @Override + public R> filterUsersForAdmin(Long deptId) { + return listFilterUsers(deptId); + } + + private R> listFilterDepts() { + R> mchIdsR = resolveMerchantIdStringsForLoginUser(); + if (!mchIdsR.isOk()) { + return R.failed(mchIdsR.getMsg()); + } + Long rootDeptId = resolveLoginUserDeptId(); + if (rootDeptId == null) { + return R.failed("用户部门不存在"); + } + R> deptR = remoteDeptService.getDescendantList(rootDeptId); + if (deptR != null && deptR.isOk() && CollUtil.isNotEmpty(deptR.getData())) { + return R.ok(deptR.getData()); + } + return R.ok(List.of()); + } + + private R> listFilterUsers(Long deptId) { + R> mchIdsR = resolveMerchantIdStringsForLoginUser(); + if (!mchIdsR.isOk()) { + return R.failed(mchIdsR.getMsg()); + } + List deptIds; + if (deptId != null) { + deptIds = List.of(deptId); + } else { + Long rootDeptId = resolveLoginUserDeptId(); + if (rootDeptId == null) { + return R.failed("用户部门不存在"); + } + R> deptR = remoteDeptService.getDescendantList(rootDeptId); + if (deptR != null && deptR.isOk() && CollUtil.isNotEmpty(deptR.getData())) { + deptIds = deptR.getData().stream().map(SysDept::getDeptId).distinct().toList(); + } else { + deptIds = List.of(rootDeptId); + } + } + R> userR = remoteUserService.getUserIdListByDeptIdList(deptIds); + if (userR != null && userR.isOk() && userR.getData() != null) { + return R.ok(userR.getData()); + } + return R.ok(List.of()); + } + + private R resolveAdminMerchantId(String merchantId) { + if (StrUtil.isBlank(merchantId)) { + return R.failed("请选择食堂商户"); + } + R> mchIdsR = resolveMerchantIdStringsForLoginUser(); + if (!mchIdsR.isOk()) { + return R.failed(mchIdsR.getMsg()); + } + String mchId = merchantId.trim(); + if (!mchIdsR.getData().contains(mchId)) { + return R.failed("无权查看该商户数据"); + } + return R.ok(mchId); + } + + private OrderingFoodStatsSummaryVO buildStatsSummary(String merchantId, LocalDate startDate, LocalDate endDate, + Long deptId, Long userId) { + MchManageVo mch = loadMerchantOrFail(merchantId); + List segments = mch != null + ? MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime()) + : List.of(); + var q = Wrappers.lambdaQuery() + .eq(CanteenOrderingFoodEntity::getMerchantId, merchantId) + .eq(CanteenOrderingFoodEntity::getStatus, CanteenOrderingFoodStatus.BOOKED.getCode()) + .eq(deptId != null, CanteenOrderingFoodEntity::getDeptId, deptId) + .eq(userId != null, CanteenOrderingFoodEntity::getUserId, userId); + applyDiningDateRangeFilter(q, startDate, endDate); + List list = list(q); + + Map rowMap = new java.util.LinkedHashMap<>(); + for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) { + rowMap.put(d, createStatsRow(d, segments)); + } + + long totalPeople = 0; + for (CanteenOrderingFoodEntity e : list) { + int people = e.getPeopleCount() != null ? e.getPeopleCount() : 0; + for (OrderingFoodSelectionItemDTO item : resolveSelectionItems(e)) { + if (item.getDiningDate() == null) { + continue; + } + if (item.getDiningDate().isBefore(startDate) || item.getDiningDate().isAfter(endDate)) { + continue; + } + totalPeople += people; + OrderingFoodStatsRowVO row = rowMap.computeIfAbsent(item.getDiningDate(), + d -> createStatsRow(d, segments)); + row.setTotalPeopleCount(row.getTotalPeopleCount() + people); + addSegmentPeople(row, item.getSegmentIndex(), item.getMealSegmentType(), people); + } + } + + List rows = new ArrayList<>(rowMap.values()); + + OrderingFoodStatsSummaryVO summary = new OrderingFoodStatsSummaryVO(); + summary.setMerchantId(merchantId); + summary.setMerchantName(mch != null ? mch.getName() : null); + summary.setRecordCount((long) list.size()); + summary.setTotalPeopleCount(totalPeople); + summary.setSegments(segments); + summary.setRows(rows); + return summary; + } + + private OrderingFoodStatsDayDetailVO buildStatsDayDetail(String merchantId, LocalDate diningDate, + Long deptId, Long userId, Integer segmentIndex) { + MchManageVo mch = loadMerchantOrFail(merchantId); + List segmentDefs = mch != null + ? MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime()) + : List.of(); + if (CollUtil.isEmpty(segmentDefs)) { + OrderingFoodStatsDayDetailVO empty = new OrderingFoodStatsDayDetailVO(); + empty.setDiningDate(diningDate.format(YMD)); + empty.setMerchantId(merchantId); + empty.setMerchantName(mch != null ? mch.getName() : null); + empty.setTotalPeopleCount(0L); + empty.setSegments(List.of()); + return empty; + } + + var q = Wrappers.lambdaQuery() + .eq(CanteenOrderingFoodEntity::getMerchantId, merchantId) + .eq(CanteenOrderingFoodEntity::getStatus, CanteenOrderingFoodStatus.BOOKED.getCode()) + .eq(deptId != null, CanteenOrderingFoodEntity::getDeptId, deptId) + .eq(userId != null, CanteenOrderingFoodEntity::getUserId, userId); + applyDiningDateRangeFilter(q, diningDate, diningDate); + List list = list(q); + + Map detailMap = new HashMap<>(); + for (OrderingFoodMealSegmentVO seg : segmentDefs) { + if (segmentIndex != null && !Objects.equals(seg.getSegmentIndex(), segmentIndex)) { + continue; + } + OrderingFoodStatsSegmentDetailVO detail = new OrderingFoodStatsSegmentDetailVO(); + detail.setSegmentIndex(seg.getSegmentIndex()); + detail.setMealSegmentName(seg.getMealSegmentName()); + detail.setStartTime(seg.getStartTime()); + detail.setEndTime(seg.getEndTime()); + detail.setPeopleCount(0L); + detail.setRecordCount(0L); + detail.setDishes(new ArrayList<>()); + detail.setRecords(new ArrayList<>()); + detailMap.put(seg.getSegmentIndex(), detail); + } + + long dayTotalPeople = 0; + for (CanteenOrderingFoodEntity e : list) { + int people = e.getPeopleCount() != null ? e.getPeopleCount() : 0; + List spuItems = deserializeSpuItems(e.getSpuItemsJson()); + for (OrderingFoodSelectionItemDTO item : resolveSelectionItems(e)) { + if (item.getDiningDate() == null || !item.getDiningDate().equals(diningDate)) { + continue; + } + if (segmentIndex != null && !Objects.equals(item.getSegmentIndex(), segmentIndex)) { + continue; + } + OrderingFoodStatsSegmentDetailVO detail = detailMap.get(item.getSegmentIndex()); + if (detail == null) { + continue; + } + dayTotalPeople += people; + detail.setPeopleCount(detail.getPeopleCount() + people); + detail.setRecordCount(detail.getRecordCount() + 1); + mergeDishCounts(detail.getDishes(), spuItems, people); + OrderingFoodStatsRecordBriefVO brief = new OrderingFoodStatsRecordBriefVO(); + brief.setId(e.getId()); + brief.setUserName(e.getUserName()); + brief.setPeopleCount(people); + brief.setSpuItems(spuItems); + brief.setRemark(e.getRemark()); + detail.getRecords().add(brief); + } + } + + List segmentDetails = segmentDefs.stream() + .filter(seg -> segmentIndex == null || Objects.equals(seg.getSegmentIndex(), segmentIndex)) + .map(seg -> detailMap.get(seg.getSegmentIndex())) + .filter(Objects::nonNull) + .toList(); + for (OrderingFoodStatsSegmentDetailVO detail : segmentDetails) { + detail.setDishes(detail.getDishes().stream() + .sorted(Comparator.comparing(OrderingFoodStatsDishItemVO::getPortionCount).reversed() + .thenComparing(OrderingFoodStatsDishItemVO::getSpuName, Comparator.nullsLast(String::compareTo))) + .toList()); + } + + OrderingFoodStatsDayDetailVO vo = new OrderingFoodStatsDayDetailVO(); + vo.setDiningDate(diningDate.format(YMD)); + vo.setMerchantId(merchantId); + vo.setMerchantName(mch != null ? mch.getName() : null); + vo.setTotalPeopleCount(dayTotalPeople); + vo.setSegments(segmentDetails); + return vo; + } + + private OrderingFoodStatsRowVO createStatsRow(LocalDate date, List segments) { + OrderingFoodStatsRowVO row = new OrderingFoodStatsRowVO(); + row.setDiningDate(date.format(YMD)); + row.setBreakfastCount(0L); + row.setLunchCount(0L); + row.setDinnerCount(0L); + row.setMidnightSnackCount(0L); + row.setTotalPeopleCount(0L); + List counts = new ArrayList<>(); + for (OrderingFoodMealSegmentVO seg : segments) { + OrderingFoodStatsSegmentCountVO count = new OrderingFoodStatsSegmentCountVO(); + count.setSegmentIndex(seg.getSegmentIndex()); + count.setMealSegmentType(seg.getMealSegmentType()); + count.setMealSegmentName(seg.getMealSegmentName()); + count.setPeopleCount(0L); + counts.add(count); + } + row.setSegmentCounts(counts); + return row; + } + + private void addSegmentPeople(OrderingFoodStatsRowVO row, Integer segmentIndex, Integer mealSegmentType, long people) { + if (CollUtil.isNotEmpty(row.getSegmentCounts()) && segmentIndex != null) { + for (OrderingFoodStatsSegmentCountVO count : row.getSegmentCounts()) { + if (Objects.equals(count.getSegmentIndex(), segmentIndex)) { + count.setPeopleCount(count.getPeopleCount() + people); + break; + } + } + } + int type = mealSegmentType != null ? mealSegmentType : 0; + switch (type) { + case 1 -> row.setBreakfastCount(row.getBreakfastCount() + people); + case 2 -> row.setLunchCount(row.getLunchCount() + people); + case 3 -> row.setDinnerCount(row.getDinnerCount() + people); + case 4 -> row.setMidnightSnackCount(row.getMidnightSnackCount() + people); + default -> { + } + } + } + + private void mergeDishCounts(List dishes, List spuItems, + int people) { + if (CollUtil.isEmpty(spuItems)) { + return; + } + for (OrderingFoodSpuItemDTO spu : spuItems) { + if (spu == null || spu.getId() == null) { + continue; + } + OrderingFoodStatsDishItemVO found = dishes.stream() + .filter(d -> Objects.equals(d.getSpuId(), spu.getId())) + .findFirst() + .orElse(null); + if (found == null) { + found = new OrderingFoodStatsDishItemVO(); + found.setSpuId(spu.getId()); + found.setSpuName(spu.getName()); + found.setPortionCount(0L); + dishes.add(found); + } else if (StrUtil.isBlank(found.getSpuName()) && StrUtil.isNotBlank(spu.getName())) { + found.setSpuName(spu.getName()); + } + found.setPortionCount(found.getPortionCount() + people); + } + } + + private List queryBookedRecords(String merchantId, LocalDate startDate, + LocalDate endDate, Long deptId, Long userId) { + var q = Wrappers.lambdaQuery() + .eq(CanteenOrderingFoodEntity::getMerchantId, merchantId) + .eq(CanteenOrderingFoodEntity::getStatus, CanteenOrderingFoodStatus.BOOKED.getCode()) + .eq(deptId != null, CanteenOrderingFoodEntity::getDeptId, deptId) + .eq(userId != null, CanteenOrderingFoodEntity::getUserId, userId); + applyDiningDateRangeFilter(q, startDate, endDate); + return list(q); + } + + private List aggregateDishTotals(List booked, + LocalDate startDate, LocalDate endDate) { + List dishes = new ArrayList<>(); + for (CanteenOrderingFoodEntity e : booked) { + int people = e.getPeopleCount() != null ? e.getPeopleCount() : 0; + List spuItems = deserializeSpuItems(e.getSpuItemsJson()); + for (OrderingFoodSelectionItemDTO item : resolveSelectionItems(e)) { + if (item.getDiningDate() == null) { + continue; + } + if (item.getDiningDate().isBefore(startDate) || item.getDiningDate().isAfter(endDate)) { + continue; + } + mergeDishCounts(dishes, spuItems, people); + } + } + return sortDishItems(dishes); + } + + private List mergeDayDishTotals(OrderingFoodStatsDayDetailVO detail) { + List dishes = new ArrayList<>(); + if (detail == null || CollUtil.isEmpty(detail.getSegments())) { + return dishes; + } + for (OrderingFoodStatsSegmentDetailVO seg : detail.getSegments()) { + if (CollUtil.isEmpty(seg.getDishes())) { + continue; + } + for (OrderingFoodStatsDishItemVO dish : seg.getDishes()) { + if (dish == null || dish.getSpuId() == null) { + continue; + } + OrderingFoodStatsDishItemVO found = dishes.stream() + .filter(d -> Objects.equals(d.getSpuId(), dish.getSpuId())) + .findFirst() + .orElse(null); + if (found == null) { + found = new OrderingFoodStatsDishItemVO(); + found.setSpuId(dish.getSpuId()); + found.setSpuName(dish.getSpuName()); + found.setPortionCount(0L); + dishes.add(found); + } else if (StrUtil.isBlank(found.getSpuName()) && StrUtil.isNotBlank(dish.getSpuName())) { + found.setSpuName(dish.getSpuName()); + } + long add = dish.getPortionCount() != null ? dish.getPortionCount() : 0L; + found.setPortionCount(found.getPortionCount() + add); + } + } + return sortDishItems(dishes); + } + + private List sortDishItems(List dishes) { + return dishes.stream() + .sorted(Comparator.comparing(OrderingFoodStatsDishItemVO::getPortionCount).reversed() + .thenComparing(OrderingFoodStatsDishItemVO::getSpuName, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List toDishTotalExportRows(List items) { + if (CollUtil.isEmpty(items)) { + return List.of(); + } + return items.stream().map(item -> { + OrderingFoodStatsDishTotalExportVO row = new OrderingFoodStatsDishTotalExportVO(); + row.setSpuName(item.getSpuName()); + row.setPortionCount(item.getPortionCount()); + return row; + }).toList(); + } + + private List buildSegmentDishExportRows(OrderingFoodStatsDayDetailVO detail) { + List rows = new ArrayList<>(); + if (detail == null || CollUtil.isEmpty(detail.getSegments())) { + return rows; + } + for (OrderingFoodStatsSegmentDetailVO seg : detail.getSegments()) { + String segmentTime = StrUtil.blankToDefault(seg.getStartTime(), "") + + "-" + StrUtil.blankToDefault(seg.getEndTime(), ""); + if (CollUtil.isEmpty(seg.getDishes())) { + continue; + } + for (OrderingFoodStatsDishItemVO dish : seg.getDishes()) { + OrderingFoodStatsSegmentDishExportVO row = new OrderingFoodStatsSegmentDishExportVO(); + row.setDiningDate(detail.getDiningDate()); + row.setMealSegmentName(seg.getMealSegmentName()); + row.setSegmentTime(segmentTime); + row.setSpuName(dish.getSpuName()); + row.setPortionCount(dish.getPortionCount()); + rows.add(row); + } + } + return rows; + } + + private R normalizeDateRange(LocalDate startDate, LocalDate endDate) { + LocalDate today = LocalDate.now(); + LocalDate start = startDate != null ? startDate : today; + LocalDate end = endDate != null ? endDate : today.plusDays(6); + if (start.isAfter(end)) { + LocalDate tmp = start; + start = end; + end = tmp; + } + long days = java.time.temporal.ChronoUnit.DAYS.between(start, end) + 1; + if (days > MAX_STATS_RANGE_DAYS) { + return R.failed("查询日期范围最多一个月(" + MAX_STATS_RANGE_DAYS + "天)"); + } + return R.ok(new LocalDate[]{start, end}); + } + + private String resolveStatsMerchantId(String merchantId, List allowed) { + if (StrUtil.isNotBlank(merchantId)) { + return merchantId.trim(); + } + return CollUtil.isNotEmpty(allowed) ? allowed.get(0) : null; + } + + private R checkOnlineCanteenMchRole() { + var login = SecurityUtils.getUser(); + if (login == null || login.getId() == null) { + return R.failed("未登录"); + } + R userInfoR = remoteUserService.getInnerByUserId(login.getId()); + if (userInfoR == null || !userInfoR.isOk() || userInfoR.getData() == null) { + return R.failed("用户信息不存在"); + } + String[] roleCodes = userInfoR.getData().getRoleCodes(); + if (roleCodes == null || !Arrays.asList(roleCodes).contains(CommonRoleConstant.ONLINE_CANTEEN_MCH)) { + return R.failed("无权访问报餐统计"); + } + return R.ok(); + } + + private R> resolveMerchantIdStringsForLoginUser() { + var user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return R.failed("未登录"); + } + Long rootDeptId = resolveLoginUserDeptId(); + if (rootDeptId == null) { + return R.failed("用户部门不存在"); + } + List deptIds; + R> deptR = remoteDeptService.getDescendantList(rootDeptId); + if (deptR != null && deptR.isOk() && CollUtil.isNotEmpty(deptR.getData())) { + deptIds = deptR.getData().stream().map(SysDept::getDeptId).distinct().toList(); + } else { + deptIds = List.of(rootDeptId); + } + R> mchR = remoteMchService.getByDeptIds(deptIds); + if (mchR == null || !mchR.isOk() || CollUtil.isEmpty(mchR.getData())) { + String msg = mchR != null && StrUtil.isNotBlank(mchR.getMsg()) ? mchR.getMsg() : "商户不存在"; + return R.failed(msg); + } + List ids = mchR.getData().stream() + .map(MchManageVo::getId) + .filter(Objects::nonNull) + .map(String::valueOf) + .distinct() + .toList(); + if (CollUtil.isEmpty(ids)) { + return R.failed("商户不存在"); + } + return R.ok(ids); + } + + private Long resolveLoginUserDeptId() { + var login = SecurityUtils.getUser(); + if (login == null || login.getId() == null) { + return null; + } + SysUser u = resolveSysUser(login.getId()); + return u != null ? u.getDeptId() : null; + } + + private Long loginUserIdOrNull() { + var login = SecurityUtils.getUser(); + if (login == null || login.getId() == null) { + return null; + } + return login.getId(); + } + + private SysUser resolveSysUser(Long userId) { + R r = remoteUserService.getInnerByUserId(userId); + if (r != null && r.isOk() && r.getData() != null) { + return r.getData().getSysUser(); + } + return null; + } + + private MchManageVo loadMerchantOrFail(String merchantId) { + if (StrUtil.isBlank(merchantId)) { + return null; + } + try { + R r = remoteMchService.getById(Long.parseLong(merchantId.trim())); + if (r != null && r.isOk()) { + return r.getData(); + } + } catch (NumberFormatException ex) { + log.warn("invalid merchantId: {}", merchantId); + } + return null; + } + + private boolean isOrderingFoodEnabled(MchManageVo mch) { + MchManageEntity.FunctionScope scope = mch.getFunctionScope(); + return scope != null && Boolean.TRUE.equals(scope.getOrderingFoodEnable()); + } + + private String validateSpuItems(List items) { + for (OrderingFoodSpuItemDTO item : items) { + if (item == null || item.getId() == null || StrUtil.isBlank(item.getName())) { + return "报餐菜品信息不完整"; + } + } + return null; + } + + private String serializeSpuItems(List items) { + if (CollUtil.isEmpty(items)) { + return null; + } + try { + return objectMapper.writeValueAsString(items); + } catch (Exception e) { + throw new IllegalStateException("菜品序列化失败", e); + } + } + + private List deserializeSpuItems(String json) { + if (StrUtil.isBlank(json)) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (Exception e) { + return List.of(); + } + } + + private Page normalizePage(Page page) { + Page p = page != null ? page : new Page<>(1, 10); + if (p.getCurrent() < 1) { + p.setCurrent(1); + } + if (p.getSize() < 1) { + p.setSize(10); + } else if (p.getSize() > MAX_PAGE_SIZE) { + p.setSize(MAX_PAGE_SIZE); + } + return p; + } + + private Page toVoPage(Page entityPage) { + Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + voPage.setRecords(entityPage.getRecords().stream().map(this::toAppVo).toList()); + return voPage; + } + + private OrderingFoodAppVO toAppVo(CanteenOrderingFoodEntity e) { + List selections = resolveSelectionItems(e); + OrderingFoodAppVO vo = new OrderingFoodAppVO(); + vo.setId(e.getId()); + vo.setMerchantId(e.getMerchantId()); + vo.setMerchantName(e.getMerchantName()); + vo.setUserId(e.getUserId()); + vo.setUserName(e.getUserName()); + vo.setDiningDate(e.getDiningDate()); + vo.setDiningDateEnd(e.getDiningDateEnd() != null ? e.getDiningDateEnd() : e.getDiningDate()); + vo.setSelectionCount(e.getSelectionCount() != null ? e.getSelectionCount() : selections.size()); + vo.setSummaryText(StrUtil.isNotBlank(e.getSummaryText()) ? e.getSummaryText() : buildSummaryText(selections)); + vo.setSelections(selections); + if (!selections.isEmpty()) { + OrderingFoodSelectionItemDTO first = selections.get(0); + vo.setMealSegmentIndex(first.getSegmentIndex()); + vo.setMealSegmentType(first.getMealSegmentType()); + vo.setMealSegmentName(first.getMealSegmentName()); + vo.setSegmentStartTime(first.getSegmentStartTime()); + vo.setSegmentEndTime(first.getSegmentEndTime()); + } else { + vo.setMealSegmentIndex(e.getMealSegmentIndex()); + vo.setMealSegmentType(e.getMealSegmentType()); + vo.setMealSegmentName(e.getMealSegmentName()); + vo.setSegmentStartTime(e.getSegmentStartTime()); + vo.setSegmentEndTime(e.getSegmentEndTime()); + } + vo.setPeopleCount(e.getPeopleCount()); + vo.setRemark(e.getRemark()); + vo.setSpuItems(deserializeSpuItems(e.getSpuItemsJson())); + vo.setStatus(e.getStatus()); + CanteenOrderingFoodStatus st = CanteenOrderingFoodStatus.ofCode(e.getStatus()); + vo.setStatusLabel(st != null ? st.getLabel() : ""); + vo.setCreateTime(e.getCreateTime()); + vo.setCancelTime(e.getCancelTime()); + vo.setVerifyTime(e.getVerifyTime()); + vo.setCanCancel(Objects.equals(e.getStatus(), CanteenOrderingFoodStatus.BOOKED.getCode())); + return vo; + } + + private void applyDiningDateRangeFilter(LambdaQueryWrapper q, + LocalDate startDate, LocalDate endDate) { + if (startDate != null) { + q.and(w -> w.ge(CanteenOrderingFoodEntity::getDiningDateEnd, startDate) + .or(w2 -> w2.isNull(CanteenOrderingFoodEntity::getDiningDateEnd) + .ge(CanteenOrderingFoodEntity::getDiningDate, startDate))); + } + if (endDate != null) { + q.le(CanteenOrderingFoodEntity::getDiningDate, endDate); + } + } + + private R> buildSelectionItems(List raw, + String jobTime, + LocalDate minDate, + LocalDate maxDate) { + Set dedup = new HashSet<>(); + List items = new ArrayList<>(); + for (OrderingFoodSelectionDTO sel : raw) { + if (sel.getDiningDate() == null || sel.getSegmentIndex() == null) { + return R.failed("报餐日期或餐段不完整"); + } + if (sel.getDiningDate().isBefore(minDate) || sel.getDiningDate().isAfter(maxDate)) { + return R.failed("仅支持报餐未来" + MAX_FUTURE_DAYS + "天内"); + } + if (!MchJobTimeMealSegmentUtils.isValidSegmentIndex(jobTime, sel.getSegmentIndex())) { + return R.failed("餐段无效,请刷新后重试"); + } + String key = sel.getDiningDate() + "#" + sel.getSegmentIndex(); + if (!dedup.add(key)) { + return R.failed("存在重复的日期餐段选择"); + } + OrderingFoodMealSegmentVO segment = MchJobTimeMealSegmentUtils.findSegment(jobTime, sel.getSegmentIndex()); + OrderingFoodSelectionItemDTO item = new OrderingFoodSelectionItemDTO(); + item.setDiningDate(sel.getDiningDate()); + item.setSegmentIndex(sel.getSegmentIndex()); + item.setMealSegmentType(segment.getMealSegmentType()); + item.setMealSegmentName(segment.getMealSegmentName()); + item.setSegmentStartTime(segment.getStartTime()); + item.setSegmentEndTime(segment.getEndTime()); + items.add(item); + } + return R.ok(items); + } + + private String findSelectionConflict(Long userId, String merchantId, List newItems) { + Set newKeys = newItems.stream() + .map(i -> i.getDiningDate() + "#" + i.getSegmentIndex()) + .collect(Collectors.toSet()); + List booked = list(Wrappers.lambdaQuery() + .eq(CanteenOrderingFoodEntity::getUserId, userId) + .eq(CanteenOrderingFoodEntity::getMerchantId, merchantId) + .eq(CanteenOrderingFoodEntity::getStatus, CanteenOrderingFoodStatus.BOOKED.getCode())); + for (CanteenOrderingFoodEntity e : booked) { + for (OrderingFoodSelectionItemDTO exist : resolveSelectionItems(e)) { + String key = exist.getDiningDate() + "#" + exist.getSegmentIndex(); + if (newKeys.contains(key)) { + return exist.getDiningDate() + " " + exist.getMealSegmentName() + " 已报餐,请勿重复"; + } + } + } + return null; + } + + private List resolveSelectionItems(CanteenOrderingFoodEntity e) { + if (StrUtil.isNotBlank(e.getSelectionsJson())) { + List list = deserializeSelections(e.getSelectionsJson()); + if (CollUtil.isNotEmpty(list)) { + return list; + } + } + if (e.getDiningDate() != null && e.getMealSegmentIndex() != null) { + OrderingFoodSelectionItemDTO legacy = new OrderingFoodSelectionItemDTO(); + legacy.setDiningDate(e.getDiningDate()); + legacy.setSegmentIndex(e.getMealSegmentIndex()); + legacy.setMealSegmentType(e.getMealSegmentType()); + legacy.setMealSegmentName(e.getMealSegmentName()); + legacy.setSegmentStartTime(e.getSegmentStartTime()); + legacy.setSegmentEndTime(e.getSegmentEndTime()); + return List.of(legacy); + } + return List.of(); + } + + private String buildSummaryText(List items) { + if (CollUtil.isEmpty(items)) { + return ""; + } + if (items.size() == 1) { + OrderingFoodSelectionItemDTO i = items.get(0); + return i.getDiningDate().format(YMD) + " " + i.getMealSegmentName(); + } + LocalDate min = items.stream().map(OrderingFoodSelectionItemDTO::getDiningDate).min(LocalDate::compareTo).orElse(null); + LocalDate max = items.stream().map(OrderingFoodSelectionItemDTO::getDiningDate).max(LocalDate::compareTo).orElse(min); + String datePart = Objects.equals(min, max) + ? min.format(YMD) + : min.format(YMD) + " 至 " + max.format(YMD); + String segPart = items.stream() + .map(OrderingFoodSelectionItemDTO::getMealSegmentName) + .filter(StrUtil::isNotBlank) + .distinct() + .collect(Collectors.joining("、")); + return datePart + " · " + items.size() + "个餐段" + (StrUtil.isNotBlank(segPart) ? "(" + segPart + ")" : ""); + } + + private String serializeSelections(List items) { + try { + return objectMapper.writeValueAsString(items); + } catch (Exception ex) { + throw new IllegalStateException("报餐明细序列化失败", ex); + } + } + + private List deserializeSelections(String json) { + if (StrUtil.isBlank(json)) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CommonRoleConstant.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CommonRoleConstant.java index 56ab8e24..fcdcbe59 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CommonRoleConstant.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CommonRoleConstant.java @@ -35,4 +35,10 @@ public interface CommonRoleConstant { // 智能柜管理员 String HARD_DOOR_ADMIN_USER = "HARD_DOOR_ADMIN"; + /** 线上食堂商户(移动端:商品/商家/报餐统计) */ + String ONLINE_CANTEEN_MCH = "ONLINE_CANTEEN_MCH"; + + /** 食堂骑手(移动端配送) */ + String CANTEEN_RIDER = "CANTEEN_RIDER"; + }