云
4 天以前 87733eb3623593da247c051593d64b8ff9e1d3a4
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/batch/MesProBatchServiceImpl.java
@@ -7,6 +7,10 @@
import cn.iocoder.yudao.module.mes.controller.admin.pro.batch.vo.MesProBatchPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.batch.vo.MesProBatchPreGenerateReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.batch.vo.MesProBatchSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeBindBatchReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi;
import cn.iocoder.yudao.module.mes.controller.admin.pro.pallet.vo.MesProPalletBindBatchReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchWorkerDO;
@@ -16,10 +20,10 @@
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchWorkerMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.redis.MesCodeRedisDAO;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBatchStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.pro.line.MesProLineService;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletService;
import cn.iocoder.yudao.module.mes.service.trace.integrity.MesTraceIntegrityService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
@@ -29,12 +33,14 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
@@ -50,6 +56,12 @@
@Slf4j
public class MesProBatchServiceImpl implements MesProBatchService {
    /**
     * 批次号格式:4 位年份 + 3 位流水(如 2026001)
     */
    private static final Pattern BATCH_CODE_PATTERN = Pattern.compile("^[0-9]{7}$");
    private static final int BATCH_CODE_MAX_SERIAL = 999;
    @Resource
    private MesProBatchMapper batchMapper;
    @Resource
@@ -61,12 +73,16 @@
    @Resource
    private MesProPalletService palletService;
    @Resource
    private MesProBagCodeService bagCodeService;
    @Resource
    private MesMdmItemService mdmItemService;
    @Resource
    private MdmUnitMeasureApi unitMeasureApi;
    @Resource
    private MesCodeRedisDAO codeRedisDao;
    @Resource
    private MesMdAutoCodeRecordService autoCodeRecordService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
@@ -82,11 +98,11 @@
        validateRelations(createReqVO);
        createReqVO.setItemId(resolveBindingItem(null, createReqVO));
        // 2. 生成批次号(编码规则:前缀 + 日期 + 产线编码 + 流水号)
        String code = generateBatchCode(line.getCode());
        // 2. 确定批次号(人工输入优先,未输入则按「年份+3位流水」自动生成)
        String code = resolveBatchCode(createReqVO.getCode(), null);
        // 3. 插入批次(产线编码/名称冗余回填;勾选绑定托盘时批次直接进入生产中)
        boolean bindPallets = CollUtil.isNotEmpty(createReqVO.getPalletIds());
        boolean hasBindings = hasBindings(createReqVO);
        MesProBatchDO batch = BeanUtils.toBean(createReqVO, MesProBatchDO.class)
                .setCode(code)
                .setBusinessCode(codeRedisDao.generate())
@@ -94,7 +110,7 @@
                .setLineName(line.getName())
                .setBagQuantity(0)
                .setPalletQuantity(0)
                .setStatus(bindPallets ? MesProBatchStatusEnum.PRODUCING.getStatus()
                .setStatus(hasBindings ? MesProBatchStatusEnum.PRODUCING.getStatus()
                        : MesProBatchStatusEnum.DRAFT.getStatus());
        batchMapper.insert(batch);
@@ -102,6 +118,7 @@
        insertBatchWorkers(batch.getId(), createReqVO.getWorkers());
        // 5. 绑定托盘(级联生成正式托盘码并录入托盘下袋码,失败整体回滚)
        bindPalletsToBatch(batch.getId(), createReqVO.getPalletIds());
        bindTonBagsToBatch(batch.getId(), createReqVO.getTonBagIds());
        // 6. 生成批次完整性摘要
        integrityService.recordBatchIntegrity(batch.getId());
        return batch.getId();
@@ -139,18 +156,18 @@
        if (StrUtil.isNotEmpty(batch.getCode())) {
            throw exception(PRO_BATCH_ALREADY_FILLED);
        }
        // 2. 校验关联数据并生成正式批次号
        // 2. 校验关联数据并确定正式批次号(人工输入优先,未输入则自动生成)
        MesProLineDO line = lineService.validateLineExistsAndEnable(fillReqVO.getLineId());
        validateRelations(fillReqVO);
        fillReqVO.setItemId(resolveBindingItem(batch, fillReqVO));
        String code = generateBatchCode(line.getCode());
        String code = resolveBatchCode(fillReqVO.getCode(), null);
        // 3. 落库业务信息(保留临时批次码用于历史扫码追溯;产线编码/名称冗余回填;勾选绑定托盘时批次直接进入生产中)
        boolean bindPallets = CollUtil.isNotEmpty(fillReqVO.getPalletIds());
        boolean hasBindings = hasBindings(fillReqVO);
        MesProBatchDO updateObj = BeanUtils.toBean(fillReqVO, MesProBatchDO.class)
                .setCode(code)
                .setLineCode(line.getCode())
                .setLineName(line.getName())
                .setStatus(bindPallets ? MesProBatchStatusEnum.PRODUCING.getStatus()
                .setStatus(hasBindings ? MesProBatchStatusEnum.PRODUCING.getStatus()
                        : MesProBatchStatusEnum.DRAFT.getStatus());
        batchMapper.updateById(updateObj);
        // 4. 重建生产人员配置
@@ -158,6 +175,7 @@
        insertBatchWorkers(fillReqVO.getId(), fillReqVO.getWorkers());
        // 5. 绑定托盘(级联生成正式托盘码并录入托盘下袋码,失败整体回滚)
        bindPalletsToBatch(fillReqVO.getId(), fillReqVO.getPalletIds());
        bindTonBagsToBatch(fillReqVO.getId(), fillReqVO.getTonBagIds());
        // 6. 生成批次完整性摘要(补录正式信息后覆盖)
        integrityService.recordBatchIntegrity(fillReqVO.getId());
    }
@@ -173,23 +191,20 @@
        validateRelations(updateReqVO);
        updateReqVO.setItemId(resolveBindingItem(oldBatch, updateReqVO));
        // 3. 未生成正式批次号或产线变化时重新生成批次号(批次号包含产线编码要素)
        // 3. 批次号仅含年份与流水号,生成后不再随产线变化;未生成正式批次号时补生成
        boolean codeEmpty = StrUtil.isEmpty(oldBatch.getCode());
        boolean lineChanged = !Objects.equals(oldBatch.getLineId(), updateReqVO.getLineId());
        String code = oldBatch.getCode();
        String code = codeEmpty ? resolveBatchCode(updateReqVO.getCode(), oldBatch.getId()) : oldBatch.getCode();
        MesProBatchDO updateObj = BeanUtils.toBean(updateReqVO, MesProBatchDO.class);
        if (codeEmpty || lineChanged) {
            // 变更产线时,目标产线必须启用;未变更则仅校验存在(避免停用产线的历史批次无法编辑其他字段)
            MesProLineDO line = (lineChanged || codeEmpty)
                    ? lineService.validateLineExistsAndEnable(updateReqVO.getLineId())
                    : lineService.validateLineExists(updateReqVO.getLineId());
            code = generateBatchCode(line.getCode());
        if (lineChanged || codeEmpty) {
            // 变更产线时,目标产线必须启用
            MesProLineDO line = lineService.validateLineExistsAndEnable(updateReqVO.getLineId());
            updateObj.setLineCode(line.getCode()).setLineName(line.getName());
        }
        // 4. 更新批次(勾选绑定托盘时批次直接进入生产中;取消勾选不回退,已绑定托盘不可解绑)
        boolean bindPallets = CollUtil.isNotEmpty(updateReqVO.getPalletIds());
        boolean hasBindings = hasBindings(updateReqVO);
        updateObj.setCode(code);
        if (bindPallets) {
        if (hasBindings) {
            updateObj.setStatus(MesProBatchStatusEnum.PRODUCING.getStatus());
        }
        batchMapper.updateById(updateObj);
@@ -199,6 +214,7 @@
        insertBatchWorkers(updateReqVO.getId(), updateReqVO.getWorkers());
        // 6. 绑定新增勾选的托盘(级联生成正式托盘码并录入托盘下袋码,失败整体回滚)
        bindPalletsToBatch(updateReqVO.getId(), updateReqVO.getPalletIds());
        bindTonBagsToBatch(updateReqVO.getId(), updateReqVO.getTonBagIds());
        // 7. 摘要覆盖范围含品种/产线/生产日期/计划数量,编辑后重新生成避免误报篡改
        integrityService.recordBatchIntegrity(updateReqVO.getId());
    }
@@ -252,6 +268,18 @@
    @Override
    public MesProBatchDO getBatch(Long id) {
        return batchMapper.selectById(id);
    }
    @Override
    public String getBindingMode(Long id) {
        if (!palletMapper.selectListByBatchId(id).isEmpty()) return "pallet";
        return bagCodeMapper.selectListByBatchId(id).stream()
                .filter(bag -> bag.getPalletId() == null)
                .map(MesProBagCodeDO::getItemId)
                .filter(Objects::nonNull)
                .distinct()
                .anyMatch(this::isTonItem)
                ? "tonBag" : null;
    }
    @Override
@@ -344,6 +372,15 @@
     * 生成正式托盘码并级联录入托盘下未绑批次的袋码,失败整体回滚
     */
    private Long resolveBindingItem(MesProBatchDO batch, MesProBatchSaveReqVO req) {
        boolean palletsRequested = CollUtil.isNotEmpty(req.getPalletIds());
        boolean tonsRequested = CollUtil.isNotEmpty(req.getTonBagIds());
        if (palletsRequested && tonsRequested) throw bindingError("绑定托盘和绑定吨袋只能选择一种");
        if (batch != null && (palletsRequested || tonsRequested)) {
            String existingMode = getBindingMode(batch.getId());
            if ((tonsRequested && "pallet".equals(existingMode))
                    || (palletsRequested && "tonBag".equals(existingMode)))
                throw bindingError("该批次已绑定另一种码,绑定托盘和绑定吨袋只能选择一种");
        }
        Long itemId = batch == null ? null : batch.getItemId();
        if (batch != null) {
            for (Long existing : bagCodeMapper.selectDistinctItemIdsByBatchId(batch.getId()))
@@ -359,10 +396,46 @@
            if (candidate == null) throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "托盘缺少品种,请先绑定同品种袋码");
            itemId = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.merge(itemId, candidate);
        }
        // 请求里的品种只能与已绑定/待绑定托盘一致,不能通过编辑改变批次品种。
        if (tonsRequested) {
            if (req.getTonBagIds().contains(null)) throw bindingError("请选择有效的吨袋");
            List<Long> ids = req.getTonBagIds().stream().distinct().sorted().toList();
            List<MesProBagCodeDO> bags = bagCodeMapper.selectListByIdsForUpdate(ids);
            if (bags.size() != ids.size()) throw bindingError("所选吨袋不存在");
            for (MesProBagCodeDO bag : bags) {
                if (bag.getBatchId() != null || bag.getPalletId() != null || !isTonItem(bag.getItemId()))
                    throw bindingError("只能绑定未录入批次、没有托盘且计量单位为吨的袋码");
                itemId = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.merge(itemId, bag.getItemId());
            }
        }
        // 请求里的品种只能与已绑定或待绑定的托盘/吨袋一致,不能通过编辑改变批次品种。
        if (req.getItemId() != null && !Objects.equals(req.getItemId(), itemId))
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "品种由绑定托盘带入,不可修改为其他品种");
            throw bindingError("品种由绑定托盘或吨袋带入,不可修改为其他品种");
        return itemId;
    }
    private boolean hasBindings(MesProBatchSaveReqVO req) {
        return CollUtil.isNotEmpty(req.getPalletIds()) || CollUtil.isNotEmpty(req.getTonBagIds());
    }
    private boolean isTonItem(Long itemId) {
        if (itemId == null) return false;
        MesMdmItemDO item = mdmItemService.getItem(itemId);
        if (item == null || item.getUnitMeasureId() == null) return false;
        var result = unitMeasureApi.getUnitMeasure(item.getUnitMeasureId());
        return result != null && result.isSuccess() && result.getData() != null
                && "吨".equals(StrUtil.trim(result.getData().getType()));
    }
    private cn.iocoder.yudao.framework.common.exception.ServiceException bindingError(String message) {
        return new cn.iocoder.yudao.framework.common.exception.ServiceException(400, message);
    }
    private void bindTonBagsToBatch(Long batchId, List<Long> tonBagIds) {
        if (CollUtil.isEmpty(tonBagIds)) return;
        MesProBagCodeBindBatchReqVO req = new MesProBagCodeBindBatchReqVO();
        req.setBatchId(batchId);
        req.setIds(tonBagIds);
        bagCodeService.bindBatch(req);
    }
    private void bindPalletsToBatch(Long batchId, List<Long> palletIds) {
@@ -376,16 +449,50 @@
    }
    /**
     * 生成批次号
     * <p>
     * 编码规则:固定前缀(PC) + 日期(yyyyMMdd) + 输入字符(产线编码) + 流水号(按日循环)
     * 确定批次号:人工输入优先,未输入则按「当前年份 + 3 位流水」自动生成
     *
     * @param lineCode 产线编码
     * @return 批次号
     * @param requestedCode 人工输入的批次号,可为空
     * @param selfId        当前批次编号(更新时排除自身,新增传 null)
     * @return 最终批次号
     */
    private String generateBatchCode(String lineCode) {
        String inputChar = StrUtil.emptyToDefault(lineCode, "");
        return autoCodeRecordService.generateAutoCode(MesMdAutoCodeRuleCodeEnum.PRO_BATCH_CODE.getCode(), inputChar);
    private String resolveBatchCode(String requestedCode, Long selfId) {
        if (StrUtil.isBlank(requestedCode)) {
            return generateNextBatchCode();
        }
        String code = StrUtil.trim(requestedCode);
        validateBatchCode(code, selfId);
        return code;
    }
    /**
     * 校验批次号格式(4 位年份 + 3 位流水,如 2026001)及唯一性
     */
    private void validateBatchCode(String code, Long selfId) {
        if (!BATCH_CODE_PATTERN.matcher(code).matches()) {
            throw exception(PRO_BATCH_CODE_INVALID);
        }
        int year = Integer.parseInt(code.substring(0, 4));
        int serial = Integer.parseInt(code.substring(4));
        if (year < 2000 || year > 2100 || serial < 1) {
            throw exception(PRO_BATCH_CODE_INVALID);
        }
        MesProBatchDO existing = batchMapper.selectByCodeIgnoreDeleted(code);
        if (existing != null && !Objects.equals(existing.getId(), selfId)) {
            throw exception(PRO_BATCH_CODE_DUPLICATE, code);
        }
    }
    /**
     * 生成当前年份的下一个批次号
     */
    private String generateNextBatchCode() {
        String year = String.valueOf(LocalDate.now().getYear());
        Integer maxSerial = batchMapper.selectMaxCodeSerialByYear(year);
        int serial = codeRedisDao.allocateBatchNo(year, maxSerial);
        if (serial > BATCH_CODE_MAX_SERIAL) {
            throw exception(PRO_BATCH_CODE_EXHAUSTED);
        }
        return year + String.format("%03d", serial);
    }
    /**