package cn.iocoder.yudao.module.mes.service.wm.materialstock;
|
|
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.extra.spring.SpringUtil;
|
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
|
import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi;
|
import cn.iocoder.yudao.module.mdm.api.unit.dto.MdmUnitMeasureRespDTO;
|
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
|
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockAdjustReqVO;
|
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmStockImportExcelVO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.stockimport.MesWmStockImportRecordDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
|
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
|
import cn.iocoder.yudao.module.mes.dal.mysql.wm.batch.MesWmBatchMapper;
|
import cn.iocoder.yudao.module.mes.dal.mysql.wm.stockimport.MesWmStockImportRecordMapper;
|
import cn.iocoder.yudao.module.mes.dal.mysql.wm.warehouse.MesWmWarehouseMapper;
|
import cn.iocoder.yudao.module.mes.dal.mysql.wm.warehouse.MesWmWarehouseLocationMapper;
|
import cn.iocoder.yudao.module.mes.dal.mysql.wm.warehouse.MesWmWarehouseAreaMapper;
|
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
|
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
|
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
|
import jakarta.annotation.Resource;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import java.math.BigDecimal;
|
import java.util.*;
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_STOCK_IMPORT_INVALID;
|
|
@Service
|
@Slf4j
|
public class MesWmStockImportService {
|
@Resource private MdmItemApi itemApi;
|
@Resource private MdmUnitMeasureApi unitApi;
|
@Resource private MesMdmItemService mdmItemService;
|
@Resource private MesWmWarehouseMapper warehouseMapper;
|
@Resource private MesWmWarehouseLocationMapper locationMapper;
|
@Resource private MesWmWarehouseAreaMapper areaMapper;
|
@Resource private MesWmBatchMapper batchMapper;
|
@Resource private MesWmStockImportRecordMapper recordMapper;
|
@Resource private MesWmMaterialStockService stockService;
|
@Resource private SrmSupplierApi supplierApi;
|
|
public Map<String, Object> importRows(List<MesWmStockImportExcelVO> rows) {
|
if (CollUtil.isEmpty(rows)) throw exception(WM_STOCK_IMPORT_INVALID, "导入文件为空");
|
List<String> created = new ArrayList<>();
|
Map<String, String> failures = new LinkedHashMap<>();
|
boolean hasData = false;
|
for (int i = 0; i < rows.size(); i++) {
|
MesWmStockImportExcelVO row = rows.get(i);
|
if (row == null || cn.hutool.core.bean.BeanUtil.beanToMap(row).values().stream()
|
.noneMatch(v -> v != null && (!(v instanceof String) || StrUtil.isNotBlank((String) v)))) continue;
|
hasData = true;
|
String label = "第" + (i + 2) + "行";
|
try {
|
SpringUtil.getBean(MesWmStockImportService.class).importRow(row);
|
created.add(label + "(" + row.getReference().trim() + ")");
|
} catch (DuplicateKeyException ex) {
|
boolean used = StrUtil.isNotBlank(row.getReference()) && recordMapper.selectByReference(row.getReference().trim()) != null;
|
failures.put(label, used ? "导入明细编号已使用,未重复入账" : "库存记录发生并发冲突,请刷新后重试");
|
} catch (ServiceException ex) {
|
failures.put(label, ex.getMessage());
|
} catch (RuntimeException ex) {
|
log.error("[importRows] 库存导入失败,行号={}", i + 2, ex);
|
failures.put(label, "导入失败,请检查物料、仓库、批次和系统配置");
|
}
|
}
|
if (!hasData) throw exception(WM_STOCK_IMPORT_INVALID, "导入文件没有数据行");
|
Map<String, Object> result = new LinkedHashMap<>();
|
result.put("createStockRows", created); result.put("failureStockRows", failures);
|
return result;
|
}
|
|
/** 插入幂等记录、写入库存及流水在一个事务中提交。 */
|
@Transactional(rollbackFor = Exception.class)
|
public void importRow(MesWmStockImportExcelVO row) {
|
if (StrUtil.isBlank(row.getReference()) || StrUtil.isBlank(row.getItemCode()) || StrUtil.isBlank(row.getUnitCode()) || StrUtil.isBlank(row.getWarehouseCode())
|
|| StrUtil.isBlank(row.getLocationCode()) || StrUtil.isBlank(row.getAreaCode())) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "导入明细编号、物料、主单位、仓库、库区和库位编码不能为空");
|
}
|
String reference = row.getReference().trim();
|
if (reference.length() > 100) throw exception(WM_STOCK_IMPORT_INVALID, "导入明细编号最多 100 字符");
|
BigDecimal quantity = row.getQuantity();
|
if (quantity == null || quantity.signum() <= 0 || quantity.stripTrailingZeros().scale() > 6 || quantity.stripTrailingZeros().precision() - quantity.stripTrailingZeros().scale() > 18) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "入库数量须大于零,整数最多 18 位、小数最多 6 位");
|
}
|
if (row.getProduceDate() != null && row.getExpireDate() != null && row.getExpireDate().isBefore(row.getProduceDate())) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "有效期不能早于生产日期");
|
}
|
if (row.getQualityStatus() != null && (row.getQualityStatus() < 0 || row.getQualityStatus() > 2)) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "质量状态只能为 0、1 或 2");
|
}
|
MdmItemRespDTO dto = itemApi.getItemByCode(row.getItemCode().trim()).getCheckedData();
|
if (dto == null) throw exception(WM_STOCK_IMPORT_INVALID, "物料编码不存在");
|
MdmUnitMeasureRespDTO unit = unitApi.getUnitMeasureByCode(row.getUnitCode().trim()).getCheckedData();
|
if (unit == null || unit.getStatus() != null && unit.getStatus() != 0 || !Objects.equals(dto.getUnitMeasureId(), unit.getId())) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "主单位编码不存在、已停用或与物料主单位不一致");
|
}
|
MesMdmItemDO item = mdmItemService.validateItemExistsAndEnable(dto.getId());
|
if (Boolean.TRUE.equals(item.getIsSerialManaged())) throw exception(WM_STOCK_IMPORT_INVALID, "序列号管理物料须通过序列号入库,不能批量导入数量");
|
if (item.getIsBatchManaged() == null) throw exception(WM_STOCK_IMPORT_INVALID, "物料批次管理配置不完整");
|
boolean batchManaged = Boolean.TRUE.equals(item.getIsBatchManaged());
|
boolean hasBatchData = StrUtil.isNotBlank(row.getBatchCode()) || row.getProduceDate() != null || row.getExpireDate() != null
|
|| StrUtil.isNotBlank(row.getLotNumber()) || row.getQualityStatus() != null;
|
if (batchManaged && StrUtil.isBlank(row.getBatchCode())) throw exception(WM_STOCK_IMPORT_INVALID, "批次管理物料必须填写批次号");
|
if (!batchManaged && hasBatchData) throw exception(WM_STOCK_IMPORT_INVALID, "非批次管理物料不能填写批次信息");
|
MesWmWarehouseDO warehouse = warehouseMapper.selectByCode(row.getWarehouseCode().trim());
|
if (warehouse == null) throw exception(WM_STOCK_IMPORT_INVALID, "仓库编码不存在");
|
MesWmWarehouseLocationDO location = locationMapper.selectByCode(warehouse.getId(), row.getLocationCode().trim());
|
if (location == null) throw exception(WM_STOCK_IMPORT_INVALID, "库区不存在或不属于该仓库");
|
MesWmWarehouseAreaDO area = areaMapper.selectByCode(location.getId(), row.getAreaCode().trim());
|
if (area == null) throw exception(WM_STOCK_IMPORT_INVALID, "库位不存在或不属于该库区");
|
// 取得仓库和库区当前状态并保持锁,避免导入时被并发冻结。
|
warehouse = warehouseMapper.selectOne(new LambdaQueryWrapperX<MesWmWarehouseDO>()
|
.eq(MesWmWarehouseDO::getId, warehouse.getId()).last("FOR UPDATE"));
|
location = locationMapper.selectOne(new LambdaQueryWrapperX<MesWmWarehouseLocationDO>()
|
.eq(MesWmWarehouseLocationDO::getId, location.getId()).last("FOR UPDATE"));
|
if (warehouse == null || location == null || !warehouse.getId().equals(location.getWarehouseId())
|
|| Boolean.TRUE.equals(warehouse.getFrozen()) || Boolean.TRUE.equals(location.getFrozen())) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "仓库或库区不存在、层级已变更或已冻结");
|
}
|
Long vendorId = null;
|
if (StrUtil.isNotBlank(row.getSupplierCode())) {
|
List<SrmSupplierRespDTO> suppliers = supplierApi.getSupplierSimpleList().getCheckedData();
|
List<SrmSupplierRespDTO> matched = suppliers.stream().filter(v -> StrUtil.equalsIgnoreCase(v.getCode(), row.getSupplierCode().trim())).toList();
|
if (matched.size() != 1 || matched.get(0).getStatus() != null && matched.get(0).getStatus() != 0) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "供应商编码不存在、未准入或已停用");
|
}
|
vendorId = matched.get(0).getId();
|
}
|
MesWmMaterialStockAdjustReqVO adjust = new MesWmMaterialStockAdjustReqVO();
|
adjust.setItemId(item.getId()); adjust.setWarehouseId(warehouse.getId()); adjust.setLocationId(location.getId());
|
adjust.setAreaId(area.getId()); adjust.setQuantity(quantity); adjust.setVendorId(vendorId);
|
if (batchManaged) {
|
String batchCode = row.getBatchCode().trim();
|
MesWmBatchDO existing = batchMapper.selectOne(new LambdaQueryWrapperX<MesWmBatchDO>()
|
.eq(MesWmBatchDO::getCode, batchCode).last("FOR UPDATE"));
|
if (existing != null) {
|
if (!item.getId().equals(existing.getItemId())
|
|| vendorId != null && !vendorId.equals(existing.getVendorId())
|
|| row.getProduceDate() != null && (existing.getProduceDate() == null || !row.getProduceDate().equals(existing.getProduceDate().toLocalDate()))
|
|| row.getExpireDate() != null && (existing.getExpireDate() == null || !row.getExpireDate().equals(existing.getExpireDate().toLocalDate()))
|
|| StrUtil.isNotBlank(row.getLotNumber()) && !StrUtil.equals(row.getLotNumber().trim(), existing.getLotNumber())
|
|| row.getQualityStatus() != null && !row.getQualityStatus().equals(existing.getQualityStatus())) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "已有批次与物料、供应商或批次属性不一致");
|
}
|
if (!Integer.valueOf(1).equals(existing.getQualityStatus())) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "批次质量状态不是合格,不能作为可用库存导入");
|
}
|
adjust.setBatchId(existing.getId());
|
adjust.setVendorId(existing.getVendorId());
|
} else {
|
throw exception(WM_STOCK_IMPORT_INVALID, "批次不存在,请先在批次管理中建立批次");
|
}
|
}
|
// 同一库位的并发导入串行化,保护库存初始化和混放检查。
|
area = areaMapper.selectOne(new LambdaQueryWrapperX<MesWmWarehouseAreaDO>()
|
.eq(MesWmWarehouseAreaDO::getId, area.getId()).last("FOR UPDATE"));
|
if (area == null || !location.getId().equals(area.getLocationId()) || Boolean.TRUE.equals(area.getFrozen()) || area.getStatus() != null && area.getStatus() != 0) throw exception(WM_STOCK_IMPORT_INVALID, "库位不存在、层级已变更、已停用或已冻结");
|
MesWmStockImportRecordDO record = new MesWmStockImportRecordDO();
|
record.setReference(reference); record.setItemId(item.getId()); record.setWarehouseId(warehouse.getId());
|
record.setLocationId(location.getId()); record.setAreaId(area.getId()); record.setQuantity(quantity);
|
recordMapper.insert(record); // 唯一键保证重复上传和并发不会重复入账
|
Long stockId = stockService.importMaterialStockIn(adjust, record.getId(), reference);
|
record.setStockId(stockId);
|
if (stockId == null || recordMapper.updateById(record) != 1) {
|
throw exception(WM_STOCK_IMPORT_INVALID, "保存库存导入记录失败");
|
}
|
}
|
}
|