云
2026-10-31 bf5f52656e128f578ed42c7979c31cf4b476ce46
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
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, "保存库存导入记录失败");
        }
    }
}