diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/mealplan/CanteenMealPlanRandomDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/mealplan/CanteenMealPlanRandomDTO.java new file mode 100644 index 00000000..925ba62f --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/mealplan/CanteenMealPlanRandomDTO.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.canteen.dto.mealplan; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Data +@Schema(description = "按餐次分类配额随机排餐") +public class CanteenMealPlanRandomDTO { + + @Schema(description = "商户ID(可选,用于过滤通用/本商户菜谱)") + private String merchantId; + + @NotNull(message = "请选择开始日期") + private LocalDate startDate; + + @NotNull(message = "请填写排餐天数") + private Integer dayCount; + + @NotEmpty(message = "请勾选餐次") + private List mealSegments; + + @Schema(description = "已选食材;有则只在可做菜谱中随机") + private List materialIds; + + @NotEmpty(message = "请配置各餐次分类配额") + @Schema(description = "餐次配额,例如 [{mealSegment:1, quotas:{素菜:2,小荤:1}}]") + private List segmentQuotas; + + @Data + @Schema(description = "单餐次分类配额") + public static class SegmentQuota { + + @NotNull + private Integer mealSegment; + + @Schema(description = "分类 -> 份数,如 {大荤:1,小荤:1,素菜:2}") + private Map quotas; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/mealplan/CanteenMealPlanSaveDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/mealplan/CanteenMealPlanSaveDTO.java index 609ce38c..81d58639 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/mealplan/CanteenMealPlanSaveDTO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/mealplan/CanteenMealPlanSaveDTO.java @@ -32,6 +32,9 @@ public class CanteenMealPlanSaveDTO { private List selectedMaterialIds; + @Schema(description = "餐次分类配额(随机排餐配置)") + private List segmentCategoryQuotas; + private Integer matchMode; private Integer status; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMealPlanEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMealPlanEntity.java index c43ad07e..5400b5d7 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMealPlanEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMealPlanEntity.java @@ -4,6 +4,7 @@ import com.alibaba.excel.annotation.ExcelIgnore; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanRandomDTO; import com.bsd.pjcloud.common.core.util.TenantTable; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -41,6 +42,10 @@ public class CanteenMealPlanEntity extends Model { @TableField(value = "selected_material_ids_json", typeHandler = JacksonTypeHandler.class) private List selectedMaterialIds; + @Schema(description = "餐次分类配额") + @TableField(value = "segment_category_quotas_json", typeHandler = JacksonTypeHandler.class) + private List segmentCategoryQuotas; + private Integer matchMode; @Schema(description = "0草稿 1已定稿") diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMenuRecipeCategoryEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMenuRecipeCategoryEntity.java new file mode 100644 index 00000000..93a728d3 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMenuRecipeCategoryEntity.java @@ -0,0 +1,52 @@ +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.LocalDateTime; + +@Data +@TenantTable +@TableName("canteen_menu_recipe_category") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "排菜菜谱分类") +public class CanteenMenuRecipeCategoryEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long deptId; + + @Schema(description = "分类名称") + private String name; + + @Schema(description = "排序,越小越靠前") + private Integer sortOrder; + + @Schema(description = "0启用 1停用") + private Integer status; + + private String remark; + + @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/entity/CanteenMenuRecipeEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMenuRecipeEntity.java index 5acb6787..b0001550 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMenuRecipeEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenMenuRecipeEntity.java @@ -32,6 +32,9 @@ public class CanteenMenuRecipeEntity extends Model { @Schema(description = "菜名") private String name; + @Schema(description = "菜谱分类(可维护)") + private String category; + @Schema(description = "图片") private String picUrl; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/util/CustomerRecipeExcelParser.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/util/CustomerRecipeExcelParser.java new file mode 100644 index 00000000..be8d1590 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/util/CustomerRecipeExcelParser.java @@ -0,0 +1,130 @@ +package com.bsd.pjcloud.canteen.util; + +import cn.hutool.core.util.StrUtil; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 客户菜谱 Excel 单元格解析(与 gen-customer-recipe-sql.js 规则一致) + */ +public final class CustomerRecipeExcelParser { + + private static final Pattern TRAIL_RE = Pattern.compile("^(.+?)\\s*(\\d+(?:\\.\\d+)?)\\s*(斤|只|包|瓶|盒|袋|根)$"); + + private static final Pattern LEAD_NUM_RE = Pattern.compile("^(\\d+(?:\\.\\d+)?)\\s*(斤|只|条|包|瓶|盒)?(.+)$"); + + private static final Pattern LEAD_CN_RE = Pattern.compile("^([一二三四五六七八九十两]+)\\s*(条|斤|只|包)?(.+)$"); + + private static final Pattern QTY_RE = Pattern.compile("^(\\d+(?:\\.\\d+)?)\\s*(斤|只|包|瓶|盒|袋|个)?$"); + + private static final Map CN_NUM = new HashMap<>(); + + static { + CN_NUM.put("一", 1); + CN_NUM.put("二", 2); + CN_NUM.put("两", 2); + CN_NUM.put("三", 3); + CN_NUM.put("四", 4); + CN_NUM.put("五", 5); + CN_NUM.put("六", 6); + CN_NUM.put("七", 7); + CN_NUM.put("八", 8); + CN_NUM.put("九", 9); + CN_NUM.put("十", 10); + } + + private CustomerRecipeExcelParser() { + } + + public static String cleanName(String raw) { + if (raw == null) { + return ""; + } + return raw.replace('(', '(') + .replace(')', ')') + .replaceAll("\\s+", " ") + .trim() + .replace("(斤)", "") + .replace("(盒)", "") + .replace("(块)", "") + .trim(); + } + + public static ParsedMaterial parseMaterialCell(Object nameRaw, Object qtyRaw) { + String name = nameRaw == null ? "" : String.valueOf(nameRaw).trim(); + if (StrUtil.isBlank(name)) { + return null; + } + + BigDecimal qty = null; + String unitHint = null; + if (qtyRaw != null && StrUtil.isNotBlank(String.valueOf(qtyRaw))) { + String qtyStr = String.valueOf(qtyRaw).trim(); + Matcher qm = QTY_RE.matcher(qtyStr); + if (qm.matches()) { + qty = new BigDecimal(qm.group(1)); + if (qm.group(2) != null) { + unitHint = qm.group(2); + } + } + else if (qtyStr.contains("包")) { + Matcher m = Pattern.compile("(\\d+)").matcher(qtyStr); + if (m.find()) { + qty = new BigDecimal(m.group(1)); + } + unitHint = "包"; + } + else { + try { + qty = new BigDecimal(qtyStr); + } + catch (Exception ignored) { + qty = null; + } + } + } + + Matcher m = TRAIL_RE.matcher(name); + if (m.matches()) { + return new ParsedMaterial(cleanName(m.group(1)), + qty != null ? qty : new BigDecimal(m.group(2)), + m.group(3)); + } + + if (!name.isEmpty() && Character.isDigit(name.charAt(0))) { + m = LEAD_NUM_RE.matcher(name); + if (m.matches() && m.group(3) != null && m.group(3).matches(".*[\\u4e00-\\u9fa5].*")) { + String unitPart = m.group(2); + String matName = "条".equals(unitPart) ? cleanName(unitPart + m.group(3)) : cleanName(m.group(3)); + String unit = "条".equals(unitPart) ? "条" : (unitPart != null ? unitPart : "斤"); + return new ParsedMaterial(matName, qty != null ? qty : new BigDecimal(m.group(1)), unit); + } + } + + m = LEAD_CN_RE.matcher(name); + if (m.matches() && m.group(3) != null && CN_NUM.containsKey(m.group(1))) { + String unitPart = m.group(2); + String matName = "条".equals(unitPart) ? cleanName(unitPart + m.group(3)) : cleanName(m.group(3)); + String unit = unitPart != null ? unitPart : "条"; + return new ParsedMaterial(matName, qty != null ? qty : BigDecimal.valueOf(CN_NUM.get(m.group(1))), unit); + } + + name = name.replaceAll("([\\u4e00-\\u9fa5])0+$", "$1").trim(); + String n = cleanName(name); + if (StrUtil.isBlank(n)) { + return null; + } + String unit = unitHint != null ? unitHint : "斤"; + if (("鸡蛋".equals(n) || "鹌鹑蛋".equals(n)) && qty != null && qty.compareTo(BigDecimal.ONE) >= 0) { + unit = "个"; + } + return new ParsedMaterial(n, qty, unit); + } + + public record ParsedMaterial(String name, BigDecimal quantity, String unit) { + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/mealplan/CanteenMealPlanVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/mealplan/CanteenMealPlanVO.java index a8f118a5..be14ff07 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/mealplan/CanteenMealPlanVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/mealplan/CanteenMealPlanVO.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.canteen.vo.mealplan; import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanItemDTO; +import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanRandomDTO; import com.bsd.pjcloud.canteen.vo.menurecipe.CanteenMenuRecipeVO; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -29,6 +30,8 @@ public class CanteenMealPlanVO { private List selectedMaterialIds; + private List segmentCategoryQuotas; + private Integer matchMode; private Integer status; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/menurecipe/CanteenMenuRecipeImportResultVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/menurecipe/CanteenMenuRecipeImportResultVO.java new file mode 100644 index 00000000..2d5635e8 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/menurecipe/CanteenMenuRecipeImportResultVO.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.canteen.vo.menurecipe; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "菜谱 Excel 导入结果") +public class CanteenMenuRecipeImportResultVO { + + @Schema(description = "新建食材数") + private int materialCreated; + + @Schema(description = "复用已有食材数") + private int materialReused; + + @Schema(description = "新建菜谱数") + private int recipeCreated; + + @Schema(description = "跳过行数(无菜名/重复等)") + private int skipped; + + @Schema(description = "分类") + private String category; + + @Schema(description = "提示信息") + private String message; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/menurecipe/CanteenMenuRecipeVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/menurecipe/CanteenMenuRecipeVO.java index 979d20c9..03b7c9de 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/menurecipe/CanteenMenuRecipeVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/menurecipe/CanteenMenuRecipeVO.java @@ -18,6 +18,9 @@ public class CanteenMenuRecipeVO { private String name; + @Schema(description = "菜谱分类:大荤/小荤/素菜") + private String category; + private String picUrl; private String remark; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMealPlanController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMealPlanController.java index 3461710f..5475ccfc 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMealPlanController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMealPlanController.java @@ -3,6 +3,8 @@ package com.bsd.pjcloud.canteen.controller; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanItemDTO; +import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanRandomDTO; import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanSaveDTO; import com.bsd.pjcloud.canteen.entity.CanteenMealPlanEntity; import com.bsd.pjcloud.canteen.service.CanteenMealPlanService; @@ -77,6 +79,13 @@ public class CanteenMealPlanController { return R.ok(canteenMealPlanService.removePlans(ids)); } + @Operation(summary = "按餐次分类配额随机排餐", description = "返回明细项,不直接落库;前端可覆盖课表后再保存") + @PostMapping("/generate-random") + @PreAuthorize("@pms.hasPermission('canteen_canteenMealPlan_add') or @pms.hasPermission('canteen_canteenMealPlan_edit')") + public R> generateRandom(@Valid @RequestBody CanteenMealPlanRandomDTO dto) { + return R.ok(canteenMealPlanService.generateRandom(dto)); + } + @ResponseExcel(name = "排餐明细") @Operation(summary = "导出明细(日期+餐次+菜名)") @GetMapping("/export/{id}") diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMenuRecipeCategoryController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMenuRecipeCategoryController.java new file mode 100644 index 00000000..75388971 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMenuRecipeCategoryController.java @@ -0,0 +1,80 @@ +package com.bsd.pjcloud.canteen.controller; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.entity.CanteenMenuRecipeCategoryEntity; +import com.bsd.pjcloud.canteen.service.CanteenMenuRecipeCategoryService; +import com.bsd.pjcloud.common.core.util.R; +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 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.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/canteenMenuRecipeCategory") +@Tag(name = "排菜菜谱分类") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class CanteenMenuRecipeCategoryController { + + private final CanteenMenuRecipeCategoryService categoryService; + + @Operation(summary = "分页查询") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('canteen_canteenMenuRecipeCategory_view')") + public R> page(@ParameterObject Page page, + @ParameterObject CanteenMenuRecipeCategoryEntity query) { + var wrapper = Wrappers.lambdaQuery() + .like(StrUtil.isNotBlank(query.getName()), CanteenMenuRecipeCategoryEntity::getName, query.getName()) + .eq(query.getStatus() != null, CanteenMenuRecipeCategoryEntity::getStatus, query.getStatus()) + .orderByAsc(CanteenMenuRecipeCategoryEntity::getSortOrder) + .orderByDesc(CanteenMenuRecipeCategoryEntity::getCreateTime); + return R.ok(categoryService.pageScope(page, wrapper)); + } + + @Operation(summary = "启用分类列表(下拉)") + @GetMapping("/list-enabled") + @PreAuthorize("@pms.hasPermission('canteen_canteenMenuRecipeCategory_view') or @pms.hasPermission('canteen_canteenMenuRecipe_view') or @pms.hasPermission('canteen_canteenMealPlan_view')") + public R> listEnabled() { + return R.ok(categoryService.listEnabled()); + } + + @Operation(summary = "详情") + @GetMapping("/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenMenuRecipeCategory_view')") + public R getById(@PathVariable Long id) { + return R.ok(categoryService.getById(id)); + } + + @SysLog("新增菜谱分类") + @Operation(summary = "新增") + @PostMapping + @PreAuthorize("@pms.hasPermission('canteen_canteenMenuRecipeCategory_add')") + public R save(@RequestBody CanteenMenuRecipeCategoryEntity entity) { + return R.ok(categoryService.saveCategory(entity)); + } + + @SysLog("修改菜谱分类") + @Operation(summary = "修改") + @PutMapping + @PreAuthorize("@pms.hasPermission('canteen_canteenMenuRecipeCategory_edit')") + public R update(@RequestBody CanteenMenuRecipeCategoryEntity entity) { + return R.ok(categoryService.updateCategory(entity)); + } + + @SysLog("删除菜谱分类") + @Operation(summary = "删除") + @DeleteMapping + @PreAuthorize("@pms.hasPermission('canteen_canteenMenuRecipeCategory_del')") + public R remove(@RequestBody Long[] ids) { + return R.ok(categoryService.removeCategories(ids)); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMenuRecipeController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMenuRecipeController.java index 4ef33e90..9a5f88cd 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMenuRecipeController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenMenuRecipeController.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.canteen.dto.menurecipe.CanteenMenuRecipeMatchDTO; import com.bsd.pjcloud.canteen.entity.CanteenMenuRecipeEntity; import com.bsd.pjcloud.canteen.service.CanteenMenuRecipeService; +import com.bsd.pjcloud.canteen.vo.menurecipe.CanteenMenuRecipeImportResultVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.util.SecurityUtils; @@ -17,6 +18,7 @@ import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpHeaders; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -36,6 +38,7 @@ public class CanteenMenuRecipeController { @ParameterObject CanteenMenuRecipeEntity query) { var wrapper = Wrappers.lambdaQuery() .like(StrUtil.isNotBlank(query.getName()), CanteenMenuRecipeEntity::getName, query.getName()) + .eq(StrUtil.isNotBlank(query.getCategory()), CanteenMenuRecipeEntity::getCategory, query.getCategory()) .eq(StrUtil.isNotBlank(query.getMerchantId()), CanteenMenuRecipeEntity::getMerchantId, query.getMerchantId()) .eq(query.getStatus() != null, CanteenMenuRecipeEntity::getStatus, query.getStatus()) @@ -81,4 +84,15 @@ public class CanteenMenuRecipeController { public R> matchByMaterials(@RequestBody CanteenMenuRecipeMatchDTO dto) { return R.ok(canteenMenuRecipeService.matchByMaterials(dto)); } + + @SysLog("导入客户菜谱Excel") + @Operation(summary = "导入客户菜谱 Excel", description = "表头:序号/菜品/食材1/克重1...;整批归入指定分类(大荤/小荤/素菜)") + @PostMapping("/import-customer-excel") + @PreAuthorize("@pms.hasPermission('canteen_canteenMenuRecipe_add')") + public R importCustomerExcel( + @RequestParam("file") MultipartFile file, + @RequestParam("category") String category, + @RequestParam(value = "merchantId", required = false) String merchantId) { + return R.ok(canteenMenuRecipeService.importCustomerExcel(file, category, merchantId)); + } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenMenuRecipeCategoryMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenMenuRecipeCategoryMapper.java new file mode 100644 index 00000000..429bf314 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenMenuRecipeCategoryMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenMenuRecipeCategoryEntity; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CanteenMenuRecipeCategoryMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMealPlanService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMealPlanService.java index 77a21de8..a496e6ad 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMealPlanService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMealPlanService.java @@ -3,6 +3,8 @@ package com.bsd.pjcloud.canteen.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanItemDTO; +import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanRandomDTO; import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanSaveDTO; import com.bsd.pjcloud.canteen.entity.CanteenMealPlanEntity; import com.bsd.pjcloud.canteen.vo.mealplan.CanteenMealPlanExportRow; @@ -26,4 +28,9 @@ public interface CanteenMealPlanService extends IService List exportRows(Long id); void exportGridExcel(Long id, HttpServletResponse response); + + /** + * 按餐次分类配额随机生成排餐明细(不落库,由前端确认后再保存) + */ + List generateRandom(CanteenMealPlanRandomDTO dto); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMenuRecipeCategoryService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMenuRecipeCategoryService.java new file mode 100644 index 00000000..db5167fb --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMenuRecipeCategoryService.java @@ -0,0 +1,35 @@ +package com.bsd.pjcloud.canteen.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.canteen.entity.CanteenMenuRecipeCategoryEntity; + +import java.util.List; +import java.util.Set; + +public interface CanteenMenuRecipeCategoryService extends IService { + + Page pageScope(Page page, + LambdaQueryWrapper wrapper); + + Boolean saveCategory(CanteenMenuRecipeCategoryEntity entity); + + Boolean updateCategory(CanteenMenuRecipeCategoryEntity entity); + + Boolean removeCategories(Long[] ids); + + /** 启用中的分类名称列表(排菜/排餐下拉) */ + List listEnabled(); + + /** 启用中的分类名称集合(一次查出,避免循环打库) */ + Set enabledNameSet(); + + /** 校验并归一化分类名;不存在或已停用返回 null */ + String normalize(String category); + + /** 使用已缓存名称集归一化,避免重复查库 */ + String normalize(String category, Set enabledNames); + + boolean existsEnabledName(String name); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMenuRecipeService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMenuRecipeService.java index 919315ce..f714f1a7 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMenuRecipeService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenMenuRecipeService.java @@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.canteen.dto.menurecipe.CanteenMenuRecipeMatchDTO; import com.bsd.pjcloud.canteen.entity.CanteenMenuRecipeEntity; +import com.bsd.pjcloud.canteen.vo.menurecipe.CanteenMenuRecipeImportResultVO; import com.bsd.pjcloud.canteen.vo.menurecipe.CanteenMenuRecipeVO; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -21,5 +23,18 @@ public interface CanteenMenuRecipeService extends IService matchByMaterials(CanteenMenuRecipeMatchDTO dto); + /** + * 按选用食材匹配可做菜谱(批量查询,无 N+1) + * @param brief true 时仅返回 id/name/category/summary,供排餐匹配与随机使用 + */ + List matchByMaterials(CanteenMenuRecipeMatchDTO dto, boolean brief); + + default List matchByMaterials(CanteenMenuRecipeMatchDTO dto) { + return matchByMaterials(dto, true); + } + + /** + * 导入客户菜谱 Excel(列:序号、菜品、食材1、克重1…),整批归入指定分类 + */ + CanteenMenuRecipeImportResultVO importCustomerExcel(MultipartFile file, String category, String merchantId); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMealPlanServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMealPlanServiceImpl.java index c6b5becb..b51d9fc1 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMealPlanServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMealPlanServiceImpl.java @@ -8,6 +8,7 @@ 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.canteen.dto.mealplan.CanteenMealPlanItemDTO; +import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanRandomDTO; import com.bsd.pjcloud.canteen.dto.mealplan.CanteenMealPlanSaveDTO; import com.bsd.pjcloud.canteen.dto.menurecipe.CanteenMenuRecipeMatchDTO; import com.bsd.pjcloud.canteen.entity.CanteenErpMaterialEntity; @@ -19,6 +20,7 @@ import com.bsd.pjcloud.canteen.mapper.CanteenMealPlanItemMapper; import com.bsd.pjcloud.canteen.mapper.CanteenMealPlanMapper; import com.bsd.pjcloud.canteen.mapper.CanteenMenuRecipeMapper; import com.bsd.pjcloud.canteen.service.CanteenMealPlanService; +import com.bsd.pjcloud.canteen.service.CanteenMenuRecipeCategoryService; import com.bsd.pjcloud.canteen.service.CanteenMenuRecipeService; import com.bsd.pjcloud.canteen.vo.mealplan.CanteenMealPlanExportRow; import com.bsd.pjcloud.canteen.vo.mealplan.CanteenMealPlanMaterialBriefVO; @@ -61,6 +63,7 @@ public class CanteenMealPlanServiceImpl extends ServiceImpl generateRandom(CanteenMealPlanRandomDTO dto) { + if (dto.getDayCount() == null || dto.getDayCount() < 1) { + throw new IllegalArgumentException("排餐天数无效"); + } + List pool; + if (CollUtil.isNotEmpty(dto.getMaterialIds())) { + CanteenMenuRecipeMatchDTO matchDTO = new CanteenMenuRecipeMatchDTO(); + matchDTO.setMaterialIds(dto.getMaterialIds()); + matchDTO.setMerchantId(dto.getMerchantId()); + pool = menuRecipeService.matchByMaterials(matchDTO, true); + } + else { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(CanteenMenuRecipeEntity::getStatus, 0) + .select(CanteenMenuRecipeEntity::getId, CanteenMenuRecipeEntity::getName, + CanteenMenuRecipeEntity::getCategory); + if (StrUtil.isNotBlank(dto.getMerchantId())) { + wrapper.and(w -> w.eq(CanteenMenuRecipeEntity::getMerchantId, dto.getMerchantId()) + .or().isNull(CanteenMenuRecipeEntity::getMerchantId) + .or().eq(CanteenMenuRecipeEntity::getMerchantId, "")); + } + List recipes = menuRecipeMapper.selectListByScope(wrapper, DataScope.of()); + pool = recipes.stream().map(r -> { + CanteenMenuRecipeVO vo = new CanteenMenuRecipeVO(); + vo.setId(r.getId()); + vo.setName(r.getName()); + vo.setCategory(r.getCategory()); + return vo; + }).toList(); + } + if (CollUtil.isEmpty(pool)) { + throw new IllegalArgumentException("没有可排的菜谱,请先导入/维护菜谱或调整食材"); + } + + Set enabledCats = recipeCategoryService.enabledNameSet(); + Map> byCategory = new HashMap<>(); + for (CanteenMenuRecipeVO r : pool) { + String cat = recipeCategoryService.normalize(r.getCategory(), enabledCats); + if (cat == null) { + continue; + } + byCategory.computeIfAbsent(cat, k -> new ArrayList<>()).add(r); + } + + Map> quotaMap = new LinkedHashMap<>(); + for (CanteenMealPlanRandomDTO.SegmentQuota sq : dto.getSegmentQuotas()) { + if (sq.getMealSegment() == null || sq.getQuotas() == null || sq.getQuotas().isEmpty()) { + continue; + } + Map normalized = new LinkedHashMap<>(); + sq.getQuotas().forEach((k, v) -> { + String cat = recipeCategoryService.normalize(k, enabledCats); + if (cat != null && v != null && v > 0) { + normalized.put(cat, v); + } + }); + if (!normalized.isEmpty()) { + quotaMap.put(sq.getMealSegment(), normalized); + } + } + if (quotaMap.isEmpty()) { + throw new IllegalArgumentException("请配置各餐次分类配额(如早餐:素菜2、小荤1)"); + } + + Random random = new Random(); + Map> recentByCat = new HashMap<>(); + Map> recentSetByCat = new HashMap<>(); + List items = new ArrayList<>(); + + for (int day = 0; day < dto.getDayCount(); day++) { + LocalDate planDate = dto.getStartDate().plusDays(day); + for (Integer seg : dto.getMealSegments()) { + Map quotas = quotaMap.get(seg); + if (quotas == null || quotas.isEmpty()) { + continue; + } + Set usedInCell = new HashSet<>(); + int sort = 0; + for (Map.Entry e : quotas.entrySet()) { + String cat = e.getKey(); + int need = e.getValue(); + List candidates = byCategory.get(cat); + if (CollUtil.isEmpty(candidates)) { + continue; + } + candidates = new ArrayList<>(candidates); + Collections.shuffle(candidates, random); + Deque recent = recentByCat.computeIfAbsent(cat, k -> new ArrayDeque<>()); + Set recentSet = recentSetByCat.computeIfAbsent(cat, k -> new HashSet<>()); + candidates.sort((a, b) -> Boolean.compare(recentSet.contains(a.getId()), recentSet.contains(b.getId()))); + int picked = 0; + for (CanteenMenuRecipeVO recipe : candidates) { + if (picked >= need) { + break; + } + if (!usedInCell.add(recipe.getId())) { + continue; + } + CanteenMealPlanItemDTO item = new CanteenMealPlanItemDTO(); + item.setPlanDate(planDate); + item.setMealSegment(seg); + item.setRecipeId(recipe.getId()); + item.setRecipeName(recipe.getName()); + item.setSortOrder(sort++); + items.add(item); + recent.addLast(recipe.getId()); + recentSet.add(recipe.getId()); + while (recent.size() > 12) { + Long old = recent.removeFirst(); + recentSet.remove(old); + } + picked++; + } + } + } + } + return items; + } + private CanteenMealPlanEntity toEntity(CanteenMealPlanSaveDTO dto) { CanteenMealPlanEntity entity = BeanUtils.toBean(dto, CanteenMealPlanEntity.class); if (entity.getDayCount() == null || entity.getDayCount() < 1) { diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMenuRecipeCategoryServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMenuRecipeCategoryServiceImpl.java new file mode 100644 index 00000000..a165e369 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMenuRecipeCategoryServiceImpl.java @@ -0,0 +1,158 @@ +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.canteen.entity.CanteenMenuRecipeCategoryEntity; +import com.bsd.pjcloud.canteen.entity.CanteenMenuRecipeEntity; +import com.bsd.pjcloud.canteen.mapper.CanteenMenuRecipeCategoryMapper; +import com.bsd.pjcloud.canteen.mapper.CanteenMenuRecipeMapper; +import com.bsd.pjcloud.canteen.service.CanteenMenuRecipeCategoryService; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CanteenMenuRecipeCategoryServiceImpl + extends ServiceImpl + implements CanteenMenuRecipeCategoryService { + + private final CanteenMenuRecipeMapper menuRecipeMapper; + + @Override + public Page pageScope(Page page, + LambdaQueryWrapper wrapper) { + return baseMapper.selectPageByScope(page, wrapper, DataScope.of()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean saveCategory(CanteenMenuRecipeCategoryEntity entity) { + String name = StrUtil.trim(entity.getName()); + if (StrUtil.isBlank(name)) { + throw new IllegalArgumentException("分类名称不能为空"); + } + if (existsName(name, null)) { + throw new IllegalArgumentException("分类名称已存在:" + name); + } + entity.setName(name); + if (entity.getSortOrder() == null) { + entity.setSortOrder(0); + } + if (entity.getStatus() == null) { + entity.setStatus(0); + } + if (entity.getDeptId() == null) { + entity.setDeptId(SecurityUtils.getUser().getDeptId()); + } + return baseMapper.insert(entity) > 0; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateCategory(CanteenMenuRecipeCategoryEntity entity) { + if (entity.getId() == null) { + throw new IllegalArgumentException("分类ID不能为空"); + } + CanteenMenuRecipeCategoryEntity old = baseMapper.selectById(entity.getId()); + if (old == null) { + throw new IllegalArgumentException("分类不存在"); + } + String name = StrUtil.trim(entity.getName()); + if (StrUtil.isBlank(name)) { + throw new IllegalArgumentException("分类名称不能为空"); + } + if (existsName(name, entity.getId())) { + throw new IllegalArgumentException("分类名称已存在:" + name); + } + String oldName = old.getName(); + entity.setName(name); + baseMapper.updateById(entity); + // 改名时同步菜谱上的分类字段 + if (!name.equals(oldName)) { + CanteenMenuRecipeEntity patch = new CanteenMenuRecipeEntity(); + patch.setCategory(name); + menuRecipeMapper.update(patch, Wrappers.lambdaUpdate() + .eq(CanteenMenuRecipeEntity::getCategory, oldName)); + } + return Boolean.TRUE; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean removeCategories(Long[] ids) { + if (ids == null || ids.length == 0) { + return Boolean.TRUE; + } + List list = baseMapper.selectBatchIds(CollUtil.toList(ids)); + for (CanteenMenuRecipeCategoryEntity cat : list) { + long used = menuRecipeMapper.selectCount(Wrappers.lambdaQuery() + .eq(CanteenMenuRecipeEntity::getCategory, cat.getName())); + if (used > 0) { + throw new IllegalArgumentException("分类「" + cat.getName() + "」已被 " + used + " 道菜谱使用,无法删除"); + } + } + return removeByIds(CollUtil.toList(ids)); + } + + @Override + public List listEnabled() { + return baseMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenMenuRecipeCategoryEntity::getStatus, 0) + .orderByAsc(CanteenMenuRecipeCategoryEntity::getSortOrder) + .orderByAsc(CanteenMenuRecipeCategoryEntity::getId)); + } + + @Override + public Set enabledNameSet() { + return listEnabled().stream() + .map(CanteenMenuRecipeCategoryEntity::getName) + .filter(StrUtil::isNotBlank) + .map(String::trim) + .collect(Collectors.toSet()); + } + + @Override + public String normalize(String category) { + return normalize(category, enabledNameSet()); + } + + @Override + public String normalize(String category, Set enabledNames) { + if (StrUtil.isBlank(category)) { + return null; + } + String name = category.trim(); + if (enabledNames == null || enabledNames.isEmpty()) { + return null; + } + return enabledNames.contains(name) ? name : null; + } + + @Override + public boolean existsEnabledName(String name) { + if (StrUtil.isBlank(name)) { + return false; + } + return enabledNameSet().contains(name.trim()); + } + + private boolean existsName(String name, Long excludeId) { + LambdaQueryWrapper w = Wrappers.lambdaQuery() + .eq(CanteenMenuRecipeCategoryEntity::getName, name); + if (excludeId != null) { + w.ne(CanteenMenuRecipeCategoryEntity::getId, excludeId); + } + return baseMapper.selectCount(w) > 0; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMenuRecipeServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMenuRecipeServiceImpl.java index 741ea21c..a71cb2e7 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMenuRecipeServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenMenuRecipeServiceImpl.java @@ -1,6 +1,8 @@ package com.bsd.pjcloud.canteen.service.impl; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.excel.EasyExcel; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -14,15 +16,20 @@ import com.bsd.pjcloud.canteen.mapper.CanteenErpMaterialMapper; import com.bsd.pjcloud.canteen.mapper.CanteenMenuRecipeMapper; import com.bsd.pjcloud.canteen.mapper.CanteenMenuRecipeMaterialMapper; import com.bsd.pjcloud.canteen.mapper.CanteenOnlineSpuMapper; +import com.bsd.pjcloud.canteen.service.CanteenMenuRecipeCategoryService; import com.bsd.pjcloud.canteen.service.CanteenMenuRecipeService; +import com.bsd.pjcloud.canteen.util.CustomerRecipeExcelParser; +import com.bsd.pjcloud.canteen.vo.menurecipe.CanteenMenuRecipeImportResultVO; import com.bsd.pjcloud.canteen.vo.menurecipe.CanteenMenuRecipeMaterialVO; import com.bsd.pjcloud.canteen.vo.menurecipe.CanteenMenuRecipeVO; import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.util.*; import java.util.stream.Collectors; @@ -35,6 +42,7 @@ public class CanteenMenuRecipeServiceImpl extends ServiceImpl pageScope(Page page, @@ -50,6 +58,7 @@ public class CanteenMenuRecipeServiceImpl extends ServiceImpllambdaQuery() .eq(CanteenMenuRecipeMaterialEntity::getRecipeId, entity.getId())); @@ -96,7 +106,7 @@ public class CanteenMenuRecipeServiceImpl extends ServiceImpl matchByMaterials(CanteenMenuRecipeMatchDTO dto) { + public List matchByMaterials(CanteenMenuRecipeMatchDTO dto, boolean brief) { if (dto == null || CollUtil.isEmpty(dto.getMaterialIds())) { return Collections.emptyList(); } @@ -112,26 +122,243 @@ public class CanteenMenuRecipeServiceImpl extends ServiceImpl recipeIds = recipes.stream().map(CanteenMenuRecipeEntity::getId).toList(); - List allLines = materialMapper.selectList( - Wrappers.lambdaQuery() - .in(CanteenMenuRecipeMaterialEntity::getRecipeId, recipeIds) - .orderByAsc(CanteenMenuRecipeMaterialEntity::getSortOrder)); - Map> recipeMaterialMap = allLines.stream() - .collect(Collectors.groupingBy(CanteenMenuRecipeMaterialEntity::getRecipeId, - Collectors.mapping(CanteenMenuRecipeMaterialEntity::getMaterialId, Collectors.toList()))); - List matched = new ArrayList<>(); + List recipeIds = recipes.stream().map(CanteenMenuRecipeEntity::getId).toList(); + List allLines = selectMaterialsByRecipeIds(recipeIds); + Map> linesByRecipe = allLines.stream() + .collect(Collectors.groupingBy(CanteenMenuRecipeMaterialEntity::getRecipeId)); + + List matchedRecipes = new ArrayList<>(); for (CanteenMenuRecipeEntity recipe : recipes) { - List needIds = recipeMaterialMap.getOrDefault(recipe.getId(), Collections.emptyList()); - if (CollUtil.isEmpty(needIds)) { + List lines = linesByRecipe.getOrDefault(recipe.getId(), Collections.emptyList()); + if (CollUtil.isEmpty(lines)) { continue; } - if (selected.containsAll(needIds)) { - matched.add(getDetail(recipe.getId())); + boolean coverAll = true; + for (CanteenMenuRecipeMaterialEntity line : lines) { + if (line.getMaterialId() == null || !selected.contains(line.getMaterialId())) { + coverAll = false; + break; + } + } + if (coverAll) { + matchedRecipes.add(recipe); } } - return matched; + if (matchedRecipes.isEmpty()) { + return Collections.emptyList(); + } + + Set materialIds = matchedRecipes.stream() + .flatMap(r -> linesByRecipe.getOrDefault(r.getId(), Collections.emptyList()).stream()) + .map(CanteenMenuRecipeMaterialEntity::getMaterialId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map materialMap = materialIds.isEmpty() ? Collections.emptyMap() + : erpMaterialMapper.selectBatchIds(materialIds).stream() + .collect(Collectors.toMap(CanteenErpMaterialEntity::getId, m -> m, (a, b) -> a)); + + Map spuNameMap = Collections.emptyMap(); + if (!brief) { + Set spuIds = matchedRecipes.stream().map(CanteenMenuRecipeEntity::getOnlineSpuId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (!spuIds.isEmpty()) { + spuNameMap = onlineSpuMapper.selectBatchIds(spuIds).stream() + .collect(Collectors.toMap(CanteenOnlineSpuEntity::getId, CanteenOnlineSpuEntity::getName, (a, b) -> a)); + } + } + + List result = new ArrayList<>(matchedRecipes.size()); + for (CanteenMenuRecipeEntity recipe : matchedRecipes) { + List lines = linesByRecipe.getOrDefault(recipe.getId(), Collections.emptyList()); + CanteenMenuRecipeVO vo = new CanteenMenuRecipeVO(); + vo.setId(recipe.getId()); + vo.setName(recipe.getName()); + vo.setCategory(recipe.getCategory()); + vo.setStatus(recipe.getStatus()); + vo.setMerchantId(recipe.getMerchantId()); + if (!brief) { + vo.setDeptId(recipe.getDeptId()); + vo.setPicUrl(recipe.getPicUrl()); + vo.setRemark(recipe.getRemark()); + vo.setOnlineSpuId(recipe.getOnlineSpuId()); + vo.setCreateTime(recipe.getCreateTime()); + if (recipe.getOnlineSpuId() != null) { + vo.setOnlineSpuName(spuNameMap.get(recipe.getOnlineSpuId())); + } + List materialVos = new ArrayList<>(lines.size()); + for (CanteenMenuRecipeMaterialEntity line : lines) { + CanteenMenuRecipeMaterialVO mv = new CanteenMenuRecipeMaterialVO(); + mv.setId(line.getId()); + mv.setMaterialId(line.getMaterialId()); + mv.setQuantity(line.getQuantity()); + mv.setSortOrder(line.getSortOrder()); + CanteenErpMaterialEntity mat = materialMap.get(line.getMaterialId()); + if (mat != null) { + mv.setMaterialName(mat.getName()); + mv.setUnit(mat.getUnit()); + } + materialVos.add(mv); + } + vo.setMaterialList(materialVos); + } + String summary = lines.stream().map(line -> { + CanteenErpMaterialEntity m = materialMap.get(line.getMaterialId()); + return m != null ? m.getName() : String.valueOf(line.getMaterialId()); + }).collect(Collectors.joining("、")); + vo.setMaterialSummary(summary); + result.add(vo); + } + return result; + } + + /** 按菜谱ID分批拉取明细,避免超长 IN */ + private List selectMaterialsByRecipeIds(List recipeIds) { + if (CollUtil.isEmpty(recipeIds)) { + return Collections.emptyList(); + } + List all = new ArrayList<>(); + final int batchSize = 500; + for (int i = 0; i < recipeIds.size(); i += batchSize) { + List batch = recipeIds.subList(i, Math.min(i + batchSize, recipeIds.size())); + all.addAll(materialMapper.selectList(Wrappers.lambdaQuery() + .in(CanteenMenuRecipeMaterialEntity::getRecipeId, batch) + .orderByAsc(CanteenMenuRecipeMaterialEntity::getSortOrder))); + } + return all; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public CanteenMenuRecipeImportResultVO importCustomerExcel(MultipartFile file, String category, String merchantId) { + String cat = recipeCategoryService.normalize(category); + if (cat == null) { + throw new IllegalArgumentException("分类无效或已停用,请先在「菜谱分类」中维护"); + } + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("请上传 Excel 文件"); + } + + List> rows; + try { + rows = EasyExcel.read(file.getInputStream()).sheet().headRowNumber(0).doReadSync(); + } + catch (Exception e) { + throw new IllegalArgumentException("Excel 读取失败:" + e.getMessage()); + } + if (CollUtil.isEmpty(rows) || rows.size() < 2) { + throw new IllegalArgumentException("Excel 无有效数据(需含表头+至少一行)"); + } + + CanteenMenuRecipeImportResultVO result = new CanteenMenuRecipeImportResultVO(); + result.setCategory(cat); + Long deptId = SecurityUtils.getUser().getDeptId(); + String mch = StrUtil.blankToDefault(merchantId, null); + + Map materialCache = new HashMap<>(); + Set existingRecipeNames = baseMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenMenuRecipeEntity::getCategory, cat) + .select(CanteenMenuRecipeEntity::getName)) + .stream() + .map(CanteenMenuRecipeEntity::getName) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + int created = 0; + int skipped = 0; + for (int i = 1; i < rows.size(); i++) { + Map row = rows.get(i); + if (row == null || row.isEmpty()) { + skipped++; + continue; + } + String dishName = StrUtil.trim(cell(row, 1)); + if (StrUtil.isBlank(dishName)) { + skipped++; + continue; + } + if (existingRecipeNames.contains(dishName)) { + skipped++; + continue; + } + + List lines = new ArrayList<>(); + for (int c = 2; c <= 10; c += 2) { + CustomerRecipeExcelParser.ParsedMaterial parsed = CustomerRecipeExcelParser.parseMaterialCell( + cell(row, c), cell(row, c + 1)); + if (parsed == null || StrUtil.isBlank(parsed.name())) { + continue; + } + CanteenErpMaterialEntity material = resolveOrCreateMaterial(parsed, materialCache, result, deptId); + CanteenMenuRecipeMaterialEntity line = new CanteenMenuRecipeMaterialEntity(); + line.setMaterialId(material.getId()); + line.setQuantity(parsed.quantity()); + line.setSortOrder(lines.size()); + lines.add(line); + } + + CanteenMenuRecipeEntity recipe = new CanteenMenuRecipeEntity(); + recipe.setName(dishName); + recipe.setCategory(cat); + recipe.setMerchantId(mch); + recipe.setDeptId(deptId); + recipe.setStatus(0); + recipe.setMaterialList(lines); + saveDeep(recipe); + existingRecipeNames.add(dishName); + created++; + } + + result.setRecipeCreated(created); + result.setSkipped(skipped); + result.setMessage(String.format("分类[%s]:新建菜谱%d道,新建食材%d种,复用食材%d种,跳过%d行", + cat, created, result.getMaterialCreated(), result.getMaterialReused(), skipped)); + return result; + } + + private CanteenErpMaterialEntity resolveOrCreateMaterial(CustomerRecipeExcelParser.ParsedMaterial parsed, + Map cache, CanteenMenuRecipeImportResultVO result, Long deptId) { + String key = parsed.name(); + CanteenErpMaterialEntity cached = cache.get(key); + if (cached != null) { + result.setMaterialReused(result.getMaterialReused() + 1); + upgradeUnitIfNeeded(cached, parsed.unit()); + return cached; + } + CanteenErpMaterialEntity existing = erpMaterialMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenErpMaterialEntity::getName, key) + .eq(CanteenErpMaterialEntity::getMaterialType, 2) + .last("LIMIT 1")); + if (existing != null) { + cache.put(key, existing); + result.setMaterialReused(result.getMaterialReused() + 1); + upgradeUnitIfNeeded(existing, parsed.unit()); + return existing; + } + CanteenErpMaterialEntity created = new CanteenErpMaterialEntity(); + created.setName(key); + created.setUnit(StrUtil.blankToDefault(parsed.unit(), "斤")); + created.setMaterialType(2); + created.setDeptId(deptId); + erpMaterialMapper.insert(created); + cache.put(key, created); + result.setMaterialCreated(result.getMaterialCreated() + 1); + return created; + } + + private void upgradeUnitIfNeeded(CanteenErpMaterialEntity material, String unit) { + if (material == null || StrUtil.isBlank(unit)) { + return; + } + if ("斤".equals(material.getUnit()) && !"斤".equals(unit)) { + material.setUnit(unit); + erpMaterialMapper.updateById(material); + } + } + + private static String cell(Map row, int col) { + Object v = row.get(col); + return v == null ? null : String.valueOf(v); } private void saveMaterials(Long recipeId, List list) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/MallProductSkuEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/MallProductSkuEntity.java index f2ef6079..0d18a7c2 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/MallProductSkuEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/MallProductSkuEntity.java @@ -36,6 +36,12 @@ public class MallProductSkuEntity extends Model { @Schema(description="SPU ID") private Long productId; + /** + * 商户ID(冗余,SKU编码在商户内唯一) + */ + @Schema(description="商户ID") + private Long merchantId; + /** * SKU编码 */ diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/MallProductSkuMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/MallProductSkuMapper.java index fe0aca3a..40cb55f8 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/MallProductSkuMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/MallProductSkuMapper.java @@ -6,9 +6,18 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Update; +import java.util.List; + @Mapper public interface MallProductSkuMapper extends PjcloudBaseMapper { + /** + * 统计商户下已存在的 SKU 编码数量(含已软删记录,与唯一索引一致) + */ + long countExistingSkuCodes(@Param("merchantId") Long merchantId, + @Param("skuCodes") List skuCodes, + @Param("excludeProductId") Long excludeProductId); + /** * 更新库存 * @param skuId SKU ID diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/MallProductSkuService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/MallProductSkuService.java index 39505640..25996644 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/MallProductSkuService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/MallProductSkuService.java @@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.supermarket.entity.MallProductSkuEntity; import com.bsd.pjcloud.supermarket.dto.SkuStockChangeDTO; +import java.util.List; + public interface MallProductSkuService extends IService { /** @@ -19,4 +21,9 @@ public interface MallProductSkuService extends IService { * 批量增减库存(正数增加,负数扣减),任一失败则返回false */ boolean deductStockBatch(SkuStockChangeDTO changes); + + /** + * 统计商户下已存在的 SKU 编码数量(含已软删记录) + */ + long countExistingSkuCodes(Long merchantId, List skuCodes, Long excludeProductId); } \ No newline at end of file diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/MallProductServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/MallProductServiceImpl.java index 86b58d4a..a124bbe3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/MallProductServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/MallProductServiceImpl.java @@ -63,10 +63,12 @@ public class MallProductServiceImpl extends ServiceImpl skuList = productCreateDTO.getSkuList().stream() .map(skuDTO -> { MallProductSkuEntity sku = new MallProductSkuEntity(); sku.setProductId(product.getId()); + sku.setMerchantId(merchantId); sku.setSkuCode(skuDTO.getSkuCode()); sku.setSkuName(skuDTO.getSkuName()); sku.setPrice(skuDTO.getPrice()); @@ -135,7 +137,14 @@ public class MallProductServiceImpl extends ServiceImpllambdaUpdate() + .set(MallProductSkuEntity::getMerchantId, product.getMerchantId()) + .eq(MallProductSkuEntity::getProductId, product.getId()) + ); // 计算总库存 List currentSkus = mallProductSkuService.list( @@ -165,7 +174,7 @@ public class MallProductServiceImpl extends ServiceImpl newSkuList) { + private void updateSkusForProduct(Long productId, Long merchantId, List newSkuList) { // 1. 获取数据库中现有的SKU列表 List existingSkus = mallProductSkuService.list( Wrappers.lambdaQuery() @@ -200,6 +209,7 @@ public class MallProductServiceImpl extends ServiceImpl { MallProductSkuEntity sku = new MallProductSkuEntity(); sku.setProductId(productId); + sku.setMerchantId(merchantId); sku.setSkuCode(skuDTO.getSkuCode()); sku.setSkuName(skuDTO.getSkuName()); sku.setPrice(skuDTO.getPrice()); @@ -220,6 +230,7 @@ public class MallProductServiceImpl extends ServiceImpl skuCodes = productCreateDTO.getSkuList().stream() .map(MallProductSkuCreateDTO::getSkuCode) .collect(Collectors.toList()); - - LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); - wrapper.in(MallProductSkuEntity::getSkuCode, skuCodes); - if (productCreateDTO.getId() != null) { - // 编辑时排除当前商品的SKU - wrapper.ne(MallProductSkuEntity::getProductId, productCreateDTO.getId()); - } - - long existingCount = mallProductSkuService.count(wrapper); + + long existingCount = mallProductSkuService.countExistingSkuCodes( + productCreateDTO.getMerchantId(), + skuCodes, + productCreateDTO.getId() + ); if (existingCount > 0) { - throw new RuntimeException("SKU编码已存在,请使用不同的编码"); + throw new RuntimeException("该商户下SKU编码已存在,请使用不同的编码"); } } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/MallProductSkuServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/MallProductSkuServiceImpl.java index a4ddd8c0..c896fdc1 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/MallProductSkuServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/MallProductSkuServiceImpl.java @@ -9,6 +9,8 @@ import com.bsd.pjcloud.supermarket.dto.SkuStockChangeDTO; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + /** * 共富商城商品SKU * @@ -59,4 +61,12 @@ public class MallProductSkuServiceImpl extends ServiceImpl skuCodes, Long excludeProductId) { + if (merchantId == null || skuCodes == null || skuCodes.isEmpty()) { + return 0; + } + return baseMapper.countExistingSkuCodes(merchantId, skuCodes, excludeProductId); + } } \ No newline at end of file diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/MallProductSkuMapper.xml b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/MallProductSkuMapper.xml index 4daad875..6ab065f2 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/MallProductSkuMapper.xml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/MallProductSkuMapper.xml @@ -6,6 +6,7 @@ + @@ -23,4 +24,17 @@ + + \ No newline at end of file diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/util/PersonNameUtils.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/util/PersonNameUtils.java new file mode 100644 index 00000000..9ae2eae6 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/util/PersonNameUtils.java @@ -0,0 +1,78 @@ +package com.bsd.pjcloud.admin.api.util; + +import cn.hutool.core.util.StrUtil; + +import java.util.regex.Pattern; + +/** + * 姓名/昵称:支持中文,或英文(单词间可空格,如 John Smith) + */ +public final class PersonNameUtils { + + public static final String CODE_NAME_EMPTY = "sys.person.name.empty"; + + public static final String CODE_NICKNAME_EMPTY = "sys.person.nickname.empty"; + + public static final String CODE_NAME_INVALID = "sys.person.name.invalid"; + + public static final String CODE_NICKNAME_INVALID = "sys.person.nickname.invalid"; + + public static final String CODE_NAME_TOO_LONG = "sys.person.name.too.long"; + + private static final int MAX_LEN = 32; + + private static final Pattern PERSON_NAME_PATTERN = Pattern.compile( + "^(?:[\\u4E00-\\u9FA5]{1,32}|[A-Za-z]+(?:\\s[A-Za-z]+){0,8}|[\\u4E00-\\u9FA5A-Za-z]+(?:\\s[\\u4E00-\\u9FA5A-Za-z]+){0,8})$"); + + private PersonNameUtils() { + } + + public static String normalize(String value) { + if (value == null) { + return null; + } + return value.trim().replaceAll("\\s+", " "); + } + + public static boolean isValid(String value) { + if (StrUtil.isBlank(value)) { + return false; + } + String normalized = normalize(value); + return normalized.length() <= MAX_LEN && PERSON_NAME_PATTERN.matcher(normalized).matches(); + } + + /** + * @return i18n 错误码,无错误返回 null + */ + public static String validateErrorCode(String value, boolean nickname) { + if (StrUtil.isBlank(value)) { + return nickname ? CODE_NICKNAME_EMPTY : CODE_NAME_EMPTY; + } + String normalized = normalize(value); + if (normalized.length() > MAX_LEN) { + return CODE_NAME_TOO_LONG; + } + if (!PERSON_NAME_PATTERN.matcher(normalized).matches()) { + return nickname ? CODE_NICKNAME_INVALID : CODE_NAME_INVALID; + } + return null; + } + + /** @deprecated 使用 validateErrorCode + MsgUtils */ + @Deprecated + public static String validateOrNull(String value, String fieldLabel) { + String code = validateErrorCode(value, "昵称".equals(fieldLabel)); + if (code == null) { + return null; + } + if (CODE_NAME_TOO_LONG.equals(code)) { + return fieldLabel + "不能超过" + MAX_LEN + "个字符"; + } + if (CODE_NAME_EMPTY.equals(code) || CODE_NICKNAME_EMPTY.equals(code)) { + return fieldLabel + "不能为空"; + } + return fieldLabel + "仅支持中文,或英文(单词间可用空格,如 John Smith)"; + } + +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index d2a8fa06..a3878995 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -22,6 +22,7 @@ package com.bsd.pjcloud.admin.controller; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; +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; @@ -325,6 +326,9 @@ public class SysUserController { @SysLog("修改个人信息") @PutMapping("/personal/edit") public R updateUserInfo(@Valid @RequestBody UserDTO userDto) { + if (StrUtil.isBlank(userDto.getUsername())) { + userDto.setUsername(SecurityUtils.getUser().getUsername()); + } return userService.updateUserInfo(userDto); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index b63c72b9..de9fe4ca 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -40,6 +40,7 @@ import com.bsd.pjcloud.admin.api.constant.UserStateEnum; import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO; import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.util.PersonNameUtils; import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.admin.api.entity.*; import com.bsd.pjcloud.admin.api.entity.user.SysUserTagEntity; @@ -389,14 +390,28 @@ public class SysUserServiceImpl extends ServiceImpl impl @Override @CacheEvict(value = CacheConstants.USER_DETAILS, key = "#userDto.username") public R updateUserInfo(UserDTO userDto) { + if (StrUtil.isBlank(userDto.getUsername())) { + userDto.setUsername(SecurityUtils.getUser().getUsername()); + } + String nameErr = PersonNameUtils.validateOrNull(userDto.getName(), "姓名"); + if (nameErr != null) { + return R.failed(nameErr); + } + String nicknameErr = PersonNameUtils.validateOrNull(userDto.getNickname(), "昵称"); + if (nicknameErr != null) { + return R.failed(nicknameErr); + } Long userId = SecurityUtils.getUser().getId(); SysUser sysUser = new SysUser(); - sysUser.setPhone(userDto.getPhone()); sysUser.setUserId(userId); sysUser.setAvatar(userDto.getAvatar()); - sysUser.setNickname(userDto.getNickname()); - sysUser.setName(userDto.getName()); + sysUser.setNickname(PersonNameUtils.normalize(userDto.getNickname())); + sysUser.setName(PersonNameUtils.normalize(userDto.getName())); sysUser.setEmail(userDto.getEmail()); + // 个人资料:未传手机号时不修改(移动端手机号不可编辑) + if (StrUtil.isNotBlank(userDto.getPhone()) && !userDto.getPhone().contains("*")) { + sysUser.setPhone(userDto.getPhone()); + } return R.ok(this.updateById(sysUser)); } @@ -405,9 +420,16 @@ public class SysUserServiceImpl extends ServiceImpl impl @Audit(name = "用户更新", spel = "@sysUserMapper.selectById(#userDto.userId)") @CacheEvict(value = CacheConstants.USER_DETAILS, key = "#userDto.username") public Boolean updateUser(UserDTO userDto) { + Long userId = resolveUserIdForUpdate(userDto); + if (userId == null) { + throw new RuntimeException("用户ID不能为空"); + } + userDto.setUserId(userId); + // 更新用户表信息 SysUser sysUser = new SysUser(); BeanUtils.copyProperties(userDto, sysUser); + sysUser.setUserId(userId); sysUser.setUpdateTime(LocalDateTime.now()); if (StrUtil.isNotBlank(userDto.getPassword())) { @@ -416,10 +438,20 @@ public class SysUserServiceImpl extends ServiceImpl impl sysUser.setPasswordExpireFlag(UserExpireEnum.EXPIRE.getCode()); } + // 脱敏手机号不参与更新,避免把 138****1234 写回库 + if (StrUtil.isNotBlank(userDto.getPhone()) && userDto.getPhone().contains("*")) { + SysUser dbUser = this.getById(userId); + sysUser.setPhone(dbUser != null ? dbUser.getPhone() : null); + } + // 判断用户username是否重复 - if (existUsername(userDto.getUserId(), userDto.getUsername())) { + if (existUsername(userId, userDto.getUsername())) { throw new RuntimeException("该用户名已存在"); } + // 判断手机号是否重复 + if (StrUtil.isNotBlank(sysUser.getPhone()) && existPhone(userId, sysUser.getPhone())) { + throw new RuntimeException(MsgUtils.getMessage(ErrorCodes.SYS_USER_PHONE_EXISTING, sysUser.getPhone())); + } this.updateById(sysUser); @@ -801,6 +833,20 @@ public class SysUserServiceImpl extends ServiceImpl impl String message = MsgUtils.getMessage(ErrorCodes.SYS_USER_PHONE_EXISTING, userDto.getPhone()); throw new RuntimeException(message); } + if (StrUtil.isNotBlank(userDto.getName())) { + String nameErr = PersonNameUtils.validateOrNull(userDto.getName(), "姓名"); + if (nameErr != null) { + throw new RuntimeException(nameErr); + } + userDto.setName(PersonNameUtils.normalize(userDto.getName())); + } + if (StrUtil.isNotBlank(userDto.getNickname())) { + String nicknameErr = PersonNameUtils.validateOrNull(userDto.getNickname(), "昵称"); + if (nicknameErr != null) { + throw new RuntimeException(nicknameErr); + } + userDto.setNickname(PersonNameUtils.normalize(userDto.getNickname())); + } SysUser sysUser = new SysUser(); BeanUtils.copyProperties(userDto, sysUser); @@ -1150,4 +1196,38 @@ public class SysUserServiceImpl extends ServiceImpl impl } return count > 0; } + + private boolean existPhone(Long userId, String phone) { + if (StrUtil.isBlank(phone) || phone.contains("*")) { + return false; + } + Long count = baseMapper.selectCount(new LambdaQueryWrapper() + .eq(SysUser::getPhone, phone) + .ne(ObjectUtil.isNotEmpty(userId), SysUser::getUserId, userId) + ); + if (ObjectUtil.isNotEmpty(userId)) { + return count >= 1; + } + return count > 0; + } + + /** + * UserDTO 子类重复声明了 userId,Jackson 可能只写入父类字段,需兼容读取。 + */ + private Long resolveUserIdForUpdate(UserDTO userDto) { + if (userDto.getUserId() != null) { + return userDto.getUserId(); + } + try { + java.lang.reflect.Field field = SysUser.class.getDeclaredField("userId"); + field.setAccessible(true); + Object value = field.get(userDto); + if (value instanceof Long) { + return (Long) value; + } + } catch (ReflectiveOperationException ignored) { + // ignore + } + return null; + } } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml index 625362b4..f0afa050 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml @@ -243,7 +243,7 @@ - AND (u.phone LIKE #{nameOrPhone} OR u.name LIKE #{nameOrPhone} ) + AND (u.phone LIKE #{nameOrPhone} OR u.name LIKE #{nameOrPhone} OR u.username LIKE #{nameOrPhone}) and u.user_id IN diff --git a/scripts/gen-customer-recipe-sql.js b/scripts/gen-customer-recipe-sql.js new file mode 100644 index 00000000..5271faa5 --- /dev/null +++ b/scripts/gen-customer-recipe-sql.js @@ -0,0 +1,217 @@ +const fs = require('fs'); +const XLSX = require('xlsx'); + +const files = [ + { file: '大荤.xlsx', cat: '大荤' }, + { file: '小荤.xlsx', cat: '小荤' }, + { file: '素菜.xlsx', cat: '素菜' }, +]; + +const TRAIL_RE = /^(.+?)\s*(\d+(?:\.\d+)?)\s*(斤|只|包|瓶|盒|袋|根)$/; +const LEAD_NUM_RE = /^(\d+(?:\.\d+)?)\s*(斤|只|条|包|瓶|盒)?(.+)$/; +const LEAD_CN_RE = /^([一二三四五六七八九十两]+)\s*(条|斤|只|包)?(.+)$/; +const CN_NUM = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 }; + +function escapeSql(s) { + return String(s).replace(/\\/g, '\\\\').replace(/'/g, "''"); +} + +function cleanName(raw) { + return String(raw || '') + .replace(/(/g, '(') + .replace(/)/g, ')') + .replace(/\s+/g, ' ') + .trim() + .replace(/\(斤\)/g, '') + .replace(/\(盒\)/g, '') + .replace(/\(块\)/g, '') + .trim(); +} + +function parseMaterialCell(nameRaw, qtyRaw) { + let name = String(nameRaw || '').trim(); + if (!name) return null; + + let qty = qtyRaw === '' || qtyRaw === null || qtyRaw === undefined ? null : qtyRaw; + let unitHint = null; + + if (typeof qty === 'string') { + const qm = String(qty).trim().match(/^(\d+(?:\.\d+)?)\s*(斤|只|包|瓶|盒|袋|个)?$/); + if (qm) { + qty = Number(qm[1]); + if (qm[2]) unitHint = qm[2]; + } else if (String(qty).includes('包')) { + const m = String(qty).match(/(\d+)/); + qty = m ? Number(m[1]) : null; + unitHint = '包'; + } else { + qty = null; + } + } + + let m = name.match(TRAIL_RE); + if (m) { + return { + name: cleanName(m[1]), + quantity: qty != null ? Number(qty) : Number(m[2]), + unit: m[3], + }; + } + + if (/^\d/.test(name)) { + m = name.match(LEAD_NUM_RE); + if (m && m[3] && /[\u4e00-\u9fa5]/.test(m[3])) { + // 1.5条肉 -> 条肉 + const matName = m[2] === '条' ? cleanName(m[2] + m[3]) : cleanName(m[3]); + return { + name: matName, + quantity: qty != null ? Number(qty) : Number(m[1]), + unit: m[2] === '条' ? '条' : m[2] || '斤', + }; + } + } + + m = name.match(LEAD_CN_RE); + if (m && m[3] && CN_NUM[m[1]]) { + // 两条肉 -> 条肉 + const matName = m[2] === '条' ? cleanName(m[2] + m[3]) : cleanName(m[3]); + return { + name: matName, + quantity: qty != null ? Number(qty) : CN_NUM[m[1]], + unit: m[2] || '条', + }; + } + + // 黑木耳0 + name = name.replace(/([\u4e00-\u9fa5])0+$/u, '$1').trim(); + const n = cleanName(name); + if (!n) return null; + + let unit = unitHint || '斤'; + if ((n === '鸡蛋' || n === '鹌鹑蛋') && qty != null && Number(qty) >= 1) unit = '个'; + + return { + name: n, + quantity: qty != null && !Number.isNaN(Number(qty)) ? Number(qty) : null, + unit, + }; +} + +const materialMap = new Map(); +const recipes = []; +let matSeq = 904000001; +let recipeSeq = 905000001; +let linkSeq = 906000001; + +function getOrCreateMaterial(parsed) { + const key = parsed.name; + if (!materialMap.has(key)) { + materialMap.set(key, { id: matSeq++, name: key, unit: parsed.unit || '斤' }); + } else { + const cur = materialMap.get(key); + if (cur.unit === '斤' && parsed.unit && parsed.unit !== '斤') cur.unit = parsed.unit; + } + return materialMap.get(key); +} + +for (const f of files) { + const wb = XLSX.readFile('C:/Users/Administrator/Desktop/' + f.file); + const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, defval: '' }); + for (let i = 1; i < rows.length; i++) { + const r = rows[i]; + const dishName = String(r[1] || '').trim(); + if (!dishName) continue; + + const mats = []; + for (let c = 2; c <= 10; c += 2) { + const parsed = parseMaterialCell(r[c], r[c + 1]); + if (!parsed || !parsed.name) continue; + const mat = getOrCreateMaterial(parsed); + mats.push({ + materialId: mat.id, + name: mat.name, + quantity: parsed.quantity, + sort: mats.length, + }); + } + + recipes.push({ + id: recipeSeq++, + name: dishName, + category: f.cat, + materials: mats, + }); + } +} + +const out = []; +out.push('-- ============================================='); +out.push('-- 客户食材+菜谱导入(大荤/小荤/素菜 Excel)'); +out.push('-- 生成时间: ' + new Date().toISOString()); +out.push('-- ID 段: 食材 904xxxxxx / 菜谱 905xxxxxx / 明细 906xxxxxx'); +out.push('-- 请确认与库中已有 ID 不冲突;tenant_id=1;material_type=2(线上食材)'); +out.push('-- 用量:按客户原表“克重”数值写入(业务习惯多为斤),单位写入食材 unit'); +out.push('-- 菜谱 remark = 分类(大荤/小荤/素菜);无食材的菜谱仍导入菜名'); +out.push('-- ============================================='); +out.push(''); +out.push('-- ---------- 1. 食材(' + materialMap.size + ' 种) ----------'); +out.push('INSERT INTO `canteen_erp_material`'); +out.push('(`id`, `tenant_id`, `dept_id`, `name`, `unit`, `material_type`, `nutrition_tags`, `del_flag`, `create_by`, `create_time`)'); +out.push('VALUES'); + +const mats = [...materialMap.values()].sort((a, b) => a.id - b.id); +mats.forEach((m, idx) => { + const comma = idx === mats.length - 1 ? ';' : ','; + out.push(`(${m.id}, 1, NULL, '${escapeSql(m.name)}', '${escapeSql(m.unit)}', 2, NULL, '0', 'admin', NOW())${comma}`); +}); + +out.push(''); +out.push('-- ---------- 2. 排菜菜谱(' + recipes.length + ' 道) ----------'); +out.push('INSERT INTO `canteen_menu_recipe`'); +out.push('(`id`, `tenant_id`, `dept_id`, `merchant_id`, `name`, `remark`, `status`, `del_flag`, `create_by`, `create_time`)'); +out.push('VALUES'); +recipes.forEach((r, idx) => { + const comma = idx === recipes.length - 1 ? ';' : ','; + out.push(`(${r.id}, 1, NULL, NULL, '${escapeSql(r.name)}', '${escapeSql(r.category)}', 0, '0', 'admin', NOW())${comma}`); +}); + +out.push(''); +out.push('-- ---------- 3. 菜谱食材明细 ----------'); +const links = []; +for (const r of recipes) { + for (const m of r.materials) { + links.push({ + id: linkSeq++, + recipeId: r.id, + materialId: m.materialId, + quantity: m.quantity, + sort: m.sort, + comment: `${r.category}-${r.name}-${m.name}`, + }); + } +} +out.push('INSERT INTO `canteen_menu_recipe_material` (`id`, `recipe_id`, `material_id`, `quantity`, `sort_order`, `create_time`) VALUES'); +links.forEach((l, idx) => { + let qtySql = 'NULL'; + if (l.quantity != null && !Number.isNaN(l.quantity)) { + qtySql = Number.isInteger(l.quantity) + ? l.quantity.toFixed(2) + : String(Math.round(l.quantity * 1000) / 1000); + } + const comma = idx === links.length - 1 ? ';' : ','; + out.push(`(${l.id}, ${l.recipeId}, ${l.materialId}, ${qtySql}, ${l.sort}, NOW())${comma} -- ${escapeSql(l.comment)}`); +}); + +out.push(''); +out.push('-- 统计: 食材 ' + mats.length + ' / 菜谱 ' + recipes.length + ' / 明细 ' + links.length); +out.push('-- 按分类: ' + files.map((f) => f.cat + '=' + recipes.filter((r) => r.category === f.cat).length).join(', ')); + +const path = 'C:/workcode/java/biz-api-cloud-zax/db/1030pjcloudx_canteen_customer_recipe_import.sql'; +fs.writeFileSync(path, out.join('\n'), 'utf8'); +console.log('written', path); +console.log('materials', mats.length, 'recipes', recipes.length, 'links', links.length); +console.log( + 'empty-material recipes:', + recipes.filter((r) => r.materials.length === 0).map((r) => r.category + ':' + r.name).join(', '), +); +console.log('sample materials:', mats.slice(0, 20).map((m) => m.name + '(' + m.unit + ')').join(', '));