菜谱排餐

This commit is contained in:
zjf committed 2026-07-17 17:29:36 +08:00
1 parent d43fe27242
commit 41eb7952b0
30 files changed
+1422 -36

No files matched your search

@@ -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<Integer> mealSegments;
@Schema(description = "已选食材;有则只在可做菜谱中随机")
private List<Long> materialIds;
@NotEmpty(message = "请配置各餐次分类配额")
@Schema(description = "餐次配额,例如 [{mealSegment:1, quotas:{素菜:2,小荤:1}}]")
private List<SegmentQuota> segmentQuotas;
@Data
@Schema(description = "单餐次分类配额")
public static class SegmentQuota {
@NotNull
private Integer mealSegment;
@Schema(description = "分类 -> 份数,如 {大荤:1,小荤:1,素菜:2}")
private Map<String, Integer> quotas;
}
}
@@ -32,6 +32,9 @@ public class CanteenMealPlanSaveDTO {
private List<Long> selectedMaterialIds;
@Schema(description = "餐次分类配额(随机排餐配置)")
private List<CanteenMealPlanRandomDTO.SegmentQuota> segmentCategoryQuotas;
private Integer matchMode;
private Integer status;
@@ -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<CanteenMealPlanEntity> {
@TableField(value = "selected_material_ids_json", typeHandler = JacksonTypeHandler.class)
private List<Long> selectedMaterialIds;
@Schema(description = "餐次分类配额")
@TableField(value = "segment_category_quotas_json", typeHandler = JacksonTypeHandler.class)
private List<CanteenMealPlanRandomDTO.SegmentQuota> segmentCategoryQuotas;
private Integer matchMode;
@Schema(description = "0草稿 1已定稿")
@@ -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<CanteenMenuRecipeCategoryEntity> {
@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;
}
@@ -32,6 +32,9 @@ public class CanteenMenuRecipeEntity extends Model<CanteenMenuRecipeEntity> {
@Schema(description = "菜名")
private String name;
@Schema(description = "菜谱分类(可维护)")
private String category;
@Schema(description = "图片")
private String picUrl;
@@ -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<String, Integer> 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) {
}
}
@@ -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<Long> selectedMaterialIds;
private List<CanteenMealPlanRandomDTO.SegmentQuota> segmentCategoryQuotas;
private Integer matchMode;
private Integer status;
@@ -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;
}
@@ -18,6 +18,9 @@ public class CanteenMenuRecipeVO {
private String name;
@Schema(description = "菜谱分类:大荤/小荤/素菜")
private String category;
private String picUrl;
private String remark;
@@ -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<List<CanteenMealPlanItemDTO>> generateRandom(@Valid @RequestBody CanteenMealPlanRandomDTO dto) {
return R.ok(canteenMealPlanService.generateRandom(dto));
}
@ResponseExcel(name = "排餐明细")
@Operation(summary = "导出明细(日期+餐次+菜名)")
@GetMapping("/export/{id}")
@@ -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<CanteenMenuRecipeCategoryEntity>> page(@ParameterObject Page<CanteenMenuRecipeCategoryEntity> page,
@ParameterObject CanteenMenuRecipeCategoryEntity query) {
var wrapper = Wrappers.<CanteenMenuRecipeCategoryEntity>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<List<CanteenMenuRecipeCategoryEntity>> 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));
}
}
@@ -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.<CanteenMenuRecipeEntity>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<List<?>> 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<CanteenMenuRecipeImportResultVO> importCustomerExcel(
@RequestParam("file") MultipartFile file,
@RequestParam("category") String category,
@RequestParam(value = "merchantId", required = false) String merchantId) {
return R.ok(canteenMenuRecipeService.importCustomerExcel(file, category, merchantId));
}
}
@@ -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<CanteenMenuRecipeCategoryEntity> {
}
@@ -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<CanteenMealPlanEntity>
List<CanteenMealPlanExportRow> exportRows(Long id);
void exportGridExcel(Long id, HttpServletResponse response);
/**
* 按餐次分类配额随机生成排餐明细(不落库,由前端确认后再保存)
*/
List<CanteenMealPlanItemDTO> generateRandom(CanteenMealPlanRandomDTO dto);
}
@@ -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<CanteenMenuRecipeCategoryEntity> {
Page<CanteenMenuRecipeCategoryEntity> pageScope(Page<CanteenMenuRecipeCategoryEntity> page,
LambdaQueryWrapper<CanteenMenuRecipeCategoryEntity> wrapper);
Boolean saveCategory(CanteenMenuRecipeCategoryEntity entity);
Boolean updateCategory(CanteenMenuRecipeCategoryEntity entity);
Boolean removeCategories(Long[] ids);
/** 启用中的分类名称列表(排菜/排餐下拉) */
List<CanteenMenuRecipeCategoryEntity> listEnabled();
/** 启用中的分类名称集合(一次查出,避免循环打库) */
Set<String> enabledNameSet();
/** 校验并归一化分类名;不存在或已停用返回 null */
String normalize(String category);
/** 使用已缓存名称集归一化,避免重复查库 */
String normalize(String category, Set<String> enabledNames);
boolean existsEnabledName(String name);
}
@@ -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<CanteenMenuRecipeEnti
CanteenMenuRecipeVO getDetail(Long id);
List<CanteenMenuRecipeVO> matchByMaterials(CanteenMenuRecipeMatchDTO dto);
/**
* 按选用食材匹配可做菜谱(批量查询,无 N+1)
* @param brief true 时仅返回 id/name/category/summary,供排餐匹配与随机使用
*/
List<CanteenMenuRecipeVO> matchByMaterials(CanteenMenuRecipeMatchDTO dto, boolean brief);
default List<CanteenMenuRecipeVO> matchByMaterials(CanteenMenuRecipeMatchDTO dto) {
return matchByMaterials(dto, true);
}
/**
* 导入客户菜谱 Excel(列:序号、菜品、食材1、克重1…),整批归入指定分类
*/
CanteenMenuRecipeImportResultVO importCustomerExcel(MultipartFile file, String category, String merchantId);
}
@@ -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<CanteenMealPlanMappe
private final CanteenErpMaterialMapper erpMaterialMapper;
private final CanteenMenuRecipeService menuRecipeService;
private final CanteenMenuRecipeMapper menuRecipeMapper;
private final CanteenMenuRecipeCategoryService recipeCategoryService;
private final RemoteMchService remoteMchService;
@Override
@@ -200,11 +203,132 @@ public class CanteenMealPlanServiceImpl extends ServiceImpl<CanteenMealPlanMappe
response.setCharacterEncoding("utf-8");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
EasyExcel.write(response.getOutputStream()).sheet("排餐餐表").doWrite(rows);
} catch (IOException e) {
}
catch (IOException e) {
throw new RuntimeException("导出失败", e);
}
}
@Override
public List<CanteenMealPlanItemDTO> generateRandom(CanteenMealPlanRandomDTO dto) {
if (dto.getDayCount() == null || dto.getDayCount() < 1) {
throw new IllegalArgumentException("排餐天数无效");
}
List<CanteenMenuRecipeVO> 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<CanteenMenuRecipeEntity> wrapper = Wrappers.<CanteenMenuRecipeEntity>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<CanteenMenuRecipeEntity> 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<String> enabledCats = recipeCategoryService.enabledNameSet();
Map<String, List<CanteenMenuRecipeVO>> 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<Integer, Map<String, Integer>> quotaMap = new LinkedHashMap<>();
for (CanteenMealPlanRandomDTO.SegmentQuota sq : dto.getSegmentQuotas()) {
if (sq.getMealSegment() == null || sq.getQuotas() == null || sq.getQuotas().isEmpty()) {
continue;
}
Map<String, Integer> 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<String, Deque<Long>> recentByCat = new HashMap<>();
Map<String, Set<Long>> recentSetByCat = new HashMap<>();
List<CanteenMealPlanItemDTO> items = new ArrayList<>();
for (int day = 0; day < dto.getDayCount(); day++) {
LocalDate planDate = dto.getStartDate().plusDays(day);
for (Integer seg : dto.getMealSegments()) {
Map<String, Integer> quotas = quotaMap.get(seg);
if (quotas == null || quotas.isEmpty()) {
continue;
}
Set<Long> usedInCell = new HashSet<>();
int sort = 0;
for (Map.Entry<String, Integer> e : quotas.entrySet()) {
String cat = e.getKey();
int need = e.getValue();
List<CanteenMenuRecipeVO> candidates = byCategory.get(cat);
if (CollUtil.isEmpty(candidates)) {
continue;
}
candidates = new ArrayList<>(candidates);
Collections.shuffle(candidates, random);
Deque<Long> recent = recentByCat.computeIfAbsent(cat, k -> new ArrayDeque<>());
Set<Long> 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) {
@@ -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<CanteenMenuRecipeCategoryMapper, CanteenMenuRecipeCategoryEntity>
implements CanteenMenuRecipeCategoryService {
private final CanteenMenuRecipeMapper menuRecipeMapper;
@Override
public Page<CanteenMenuRecipeCategoryEntity> pageScope(Page<CanteenMenuRecipeCategoryEntity> page,
LambdaQueryWrapper<CanteenMenuRecipeCategoryEntity> 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.<CanteenMenuRecipeEntity>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<CanteenMenuRecipeCategoryEntity> list = baseMapper.selectBatchIds(CollUtil.toList(ids));
for (CanteenMenuRecipeCategoryEntity cat : list) {
long used = menuRecipeMapper.selectCount(Wrappers.<CanteenMenuRecipeEntity>lambdaQuery()
.eq(CanteenMenuRecipeEntity::getCategory, cat.getName()));
if (used > 0) {
throw new IllegalArgumentException("分类「" + cat.getName() + "」已被 " + used + " 道菜谱使用,无法删除");
}
}
return removeByIds(CollUtil.toList(ids));
}
@Override
public List<CanteenMenuRecipeCategoryEntity> listEnabled() {
return baseMapper.selectList(Wrappers.<CanteenMenuRecipeCategoryEntity>lambdaQuery()
.eq(CanteenMenuRecipeCategoryEntity::getStatus, 0)
.orderByAsc(CanteenMenuRecipeCategoryEntity::getSortOrder)
.orderByAsc(CanteenMenuRecipeCategoryEntity::getId));
}
@Override
public Set<String> 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<String> 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<CanteenMenuRecipeCategoryEntity> w = Wrappers.<CanteenMenuRecipeCategoryEntity>lambdaQuery()
.eq(CanteenMenuRecipeCategoryEntity::getName, name);
if (excludeId != null) {
w.ne(CanteenMenuRecipeCategoryEntity::getId, excludeId);
}
return baseMapper.selectCount(w) > 0;
}
}
@@ -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<CanteenMenuRecipeM
private final CanteenMenuRecipeMaterialMapper materialMapper;
private final CanteenErpMaterialMapper erpMaterialMapper;
private final CanteenOnlineSpuMapper onlineSpuMapper;
private final CanteenMenuRecipeCategoryService recipeCategoryService;
@Override
public Page<CanteenMenuRecipeEntity> pageScope(Page<CanteenMenuRecipeEntity> page,
@@ -50,6 +58,7 @@ public class CanteenMenuRecipeServiceImpl extends ServiceImpl<CanteenMenuRecipeM
if (entity.getStatus() == null) {
entity.setStatus(0);
}
entity.setCategory(recipeCategoryService.normalize(entity.getCategory()));
baseMapper.insert(entity);
saveMaterials(entity.getId(), entity.getMaterialList());
return Boolean.TRUE;
@@ -58,6 +67,7 @@ public class CanteenMenuRecipeServiceImpl extends ServiceImpl<CanteenMenuRecipeM
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateDeep(CanteenMenuRecipeEntity entity) {
entity.setCategory(recipeCategoryService.normalize(entity.getCategory()));
baseMapper.updateById(entity);
materialMapper.delete(Wrappers.<CanteenMenuRecipeMaterialEntity>lambdaQuery()
.eq(CanteenMenuRecipeMaterialEntity::getRecipeId, entity.getId()));
@@ -96,7 +106,7 @@ public class CanteenMenuRecipeServiceImpl extends ServiceImpl<CanteenMenuRecipeM
}
@Override
public List<CanteenMenuRecipeVO> matchByMaterials(CanteenMenuRecipeMatchDTO dto) {
public List<CanteenMenuRecipeVO> matchByMaterials(CanteenMenuRecipeMatchDTO dto, boolean brief) {
if (dto == null || CollUtil.isEmpty(dto.getMaterialIds())) {
return Collections.emptyList();
}
@@ -112,26 +122,243 @@ public class CanteenMenuRecipeServiceImpl extends ServiceImpl<CanteenMenuRecipeM
if (CollUtil.isEmpty(recipes)) {
return Collections.emptyList();
}
List<Long> recipeIds = recipes.stream().map(CanteenMenuRecipeEntity::getId).toList();
List<CanteenMenuRecipeMaterialEntity> allLines = materialMapper.selectList(
Wrappers.<CanteenMenuRecipeMaterialEntity>lambdaQuery()
.in(CanteenMenuRecipeMaterialEntity::getRecipeId, recipeIds)
.orderByAsc(CanteenMenuRecipeMaterialEntity::getSortOrder));
Map<Long, List<Long>> recipeMaterialMap = allLines.stream()
.collect(Collectors.groupingBy(CanteenMenuRecipeMaterialEntity::getRecipeId,
Collectors.mapping(CanteenMenuRecipeMaterialEntity::getMaterialId, Collectors.toList())));
List<CanteenMenuRecipeVO> matched = new ArrayList<>();
List<Long> recipeIds = recipes.stream().map(CanteenMenuRecipeEntity::getId).toList();
List<CanteenMenuRecipeMaterialEntity> allLines = selectMaterialsByRecipeIds(recipeIds);
Map<Long, List<CanteenMenuRecipeMaterialEntity>> linesByRecipe = allLines.stream()
.collect(Collectors.groupingBy(CanteenMenuRecipeMaterialEntity::getRecipeId));
List<CanteenMenuRecipeEntity> matchedRecipes = new ArrayList<>();
for (CanteenMenuRecipeEntity recipe : recipes) {
List<Long> needIds = recipeMaterialMap.getOrDefault(recipe.getId(), Collections.emptyList());
if (CollUtil.isEmpty(needIds)) {
List<CanteenMenuRecipeMaterialEntity> 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<Long> materialIds = matchedRecipes.stream()
.flatMap(r -> linesByRecipe.getOrDefault(r.getId(), Collections.emptyList()).stream())
.map(CanteenMenuRecipeMaterialEntity::getMaterialId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, CanteenErpMaterialEntity> materialMap = materialIds.isEmpty() ? Collections.emptyMap()
: erpMaterialMapper.selectBatchIds(materialIds).stream()
.collect(Collectors.toMap(CanteenErpMaterialEntity::getId, m -> m, (a, b) -> a));
Map<Long, String> spuNameMap = Collections.emptyMap();
if (!brief) {
Set<Long> 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<CanteenMenuRecipeVO> result = new ArrayList<>(matchedRecipes.size());
for (CanteenMenuRecipeEntity recipe : matchedRecipes) {
List<CanteenMenuRecipeMaterialEntity> 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<CanteenMenuRecipeMaterialVO> 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<CanteenMenuRecipeMaterialEntity> selectMaterialsByRecipeIds(List<Long> recipeIds) {
if (CollUtil.isEmpty(recipeIds)) {
return Collections.emptyList();
}
List<CanteenMenuRecipeMaterialEntity> all = new ArrayList<>();
final int batchSize = 500;
for (int i = 0; i < recipeIds.size(); i += batchSize) {
List<Long> batch = recipeIds.subList(i, Math.min(i + batchSize, recipeIds.size()));
all.addAll(materialMapper.selectList(Wrappers.<CanteenMenuRecipeMaterialEntity>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<Map<Integer, String>> 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<String, CanteenErpMaterialEntity> materialCache = new HashMap<>();
Set<String> existingRecipeNames = baseMapper.selectList(Wrappers.<CanteenMenuRecipeEntity>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<Integer, String> 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<CanteenMenuRecipeMaterialEntity> 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<String, CanteenErpMaterialEntity> 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.<CanteenErpMaterialEntity>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<Integer, String> row, int col) {
Object v = row.get(col);
return v == null ? null : String.valueOf(v);
}
private void saveMaterials(Long recipeId, List<CanteenMenuRecipeMaterialEntity> list) {
@@ -36,6 +36,12 @@ public class MallProductSkuEntity extends Model<MallProductSkuEntity> {
@Schema(description="SPU ID")
private Long productId;
/**
* 商户ID(冗余,SKU编码在商户内唯一)
*/
@Schema(description="商户ID")
private Long merchantId;
/**
* SKU编码
*/
@@ -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<MallProductSkuEntity> {
/**
* 统计商户下已存在的 SKU 编码数量(含已软删记录,与唯一索引一致)
*/
long countExistingSkuCodes(@Param("merchantId") Long merchantId,
@Param("skuCodes") List<String> skuCodes,
@Param("excludeProductId") Long excludeProductId);
/**
* 更新库存
* @param skuId SKU ID
@@ -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<MallProductSkuEntity> {
/**
@@ -19,4 +21,9 @@ public interface MallProductSkuService extends IService<MallProductSkuEntity> {
* 批量增减库存(正数增加,负数扣减),任一失败则返回false
*/
boolean deductStockBatch(SkuStockChangeDTO changes);
/**
* 统计商户下已存在的 SKU 编码数量(含已软删记录)
*/
long countExistingSkuCodes(Long merchantId, List<String> skuCodes, Long excludeProductId);
}
@@ -63,10 +63,12 @@ public class MallProductServiceImpl extends ServiceImpl<MallProductMapper, MallP
// 3. 处理SKU(如果有)
if (CollUtil.isNotEmpty(productCreateDTO.getSkuList())) {
Long merchantId = productCreateDTO.getMerchantId();
List<MallProductSkuEntity> 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 ServiceImpl<MallProductMapper, MallP
// 3. 处理SKU(精确处理SKU的增删改)
if (CollUtil.isNotEmpty(productCreateDTO.getSkuList())) {
updateSkusForProduct(product.getId(), productCreateDTO.getSkuList());
updateSkusForProduct(product.getId(), product.getMerchantId(), productCreateDTO.getSkuList());
// 商户变更时同步 SKU 冗余字段
mallProductSkuService.update(
Wrappers.<MallProductSkuEntity>lambdaUpdate()
.set(MallProductSkuEntity::getMerchantId, product.getMerchantId())
.eq(MallProductSkuEntity::getProductId, product.getId())
);
// 计算总库存
List<MallProductSkuEntity> currentSkus = mallProductSkuService.list(
@@ -165,7 +174,7 @@ public class MallProductServiceImpl extends ServiceImpl<MallProductMapper, MallP
* @param productId 商品ID
* @param newSkuList 新的SKU列表
*/
private void updateSkusForProduct(Long productId, List<MallProductSkuCreateDTO> newSkuList) {
private void updateSkusForProduct(Long productId, Long merchantId, List<MallProductSkuCreateDTO> newSkuList) {
// 1. 获取数据库中现有的SKU列表
List<MallProductSkuEntity> existingSkus = mallProductSkuService.list(
Wrappers.<MallProductSkuEntity>lambdaQuery()
@@ -200,6 +209,7 @@ public class MallProductServiceImpl extends ServiceImpl<MallProductMapper, MallP
.map(skuDTO -> {
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<MallProductMapper, MallP
MallProductSkuEntity existingSku = existingSkuMap.get(skuDTO.getSkuCode());
// 更新字段
existingSku.setSkuName(skuDTO.getSkuName());
existingSku.setMerchantId(merchantId);
existingSku.setPrice(skuDTO.getPrice());
existingSku.setCostPrice(skuDTO.getCostPrice());
existingSku.setStock(skuDTO.getStock());
@@ -310,6 +321,10 @@ public class MallProductServiceImpl extends ServiceImpl<MallProductMapper, MallP
* @param productCreateDTO 商品创建DTO
*/
private void validateProductData(MallProductCreateDTO productCreateDTO) {
if (productCreateDTO.getMerchantId() == null) {
throw new RuntimeException("商户ID不能为空");
}
// 如果有SKU,验证SKU数据
if (CollUtil.isNotEmpty(productCreateDTO.getSkuList())) {
// 验证SKU编码在当前请求中的唯一性
@@ -320,22 +335,19 @@ public class MallProductServiceImpl extends ServiceImpl<MallProductMapper, MallP
if (distinctSkuCodes != productCreateDTO.getSkuList().size()) {
throw new RuntimeException("SKU编码不能重复");
}
// 验证SKU编码全局唯一性(排除当前商品)
// 验证SKU编码在商户内唯一(排除当前商品)
List<String> skuCodes = productCreateDTO.getSkuList().stream()
.map(MallProductSkuCreateDTO::getSkuCode)
.collect(Collectors.toList());
LambdaQueryWrapper<MallProductSkuEntity> 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编码已存在,请使用不同的编码");
}
}
}
@@ -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<MallProductSkuMapper,
}
return true;
}
@Override
public long countExistingSkuCodes(Long merchantId, List<String> skuCodes, Long excludeProductId) {
if (merchantId == null || skuCodes == null || skuCodes.isEmpty()) {
return 0;
}
return baseMapper.countExistingSkuCodes(merchantId, skuCodes, excludeProductId);
}
}
@@ -6,6 +6,7 @@
<resultMap id="mallProductSkuMap" type="com.bsd.pjcloud.supermarket.entity.MallProductSkuEntity">
<id property="id" column="id"/>
<result property="productId" column="product_id"/>
<result property="merchantId" column="merchant_id"/>
<result property="skuCode" column="sku_code"/>
<result property="skuName" column="sku_name"/>
<result property="price" column="price"/>
@@ -23,4 +24,17 @@
<result property="delFlag" column="del_flag"/>
<result property="tenantId" column="tenant_id"/>
</resultMap>
<select id="countExistingSkuCodes" resultType="long">
SELECT COUNT(1)
FROM mall_product_sku
WHERE merchant_id = #{merchantId}
AND sku_code IN
<foreach collection="skuCodes" item="code" open="(" separator="," close=")">
#{code}
</foreach>
<if test="excludeProductId != null">
AND product_id != #{excludeProductId}
</if>
</select>
</mapper>
@@ -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)";
}
}
@@ -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);
}
@@ -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<SysUserMapper, SysUser> impl
@Override
@CacheEvict(value = CacheConstants.USER_DETAILS, key = "#userDto.username")
public R<Boolean> 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<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> 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<SysUser>()
.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;
}
}
@@ -243,7 +243,7 @@
</if>
<if test="query.nameOrPhone != null and query.nameOrPhone != ''">
<bind name="nameOrPhone" value="'%'+query.nameOrPhone+'%'"/>
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})
</if>
<if test="query.userIds != null and query.userIds.size() != 0">
and u.user_id IN
+217
View File
@@ -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(', '));