feat(销售台账/产品维护): 图号必填唯一、合同号唯一、明细产品编码与导入物料编码

需求(江苏隆义 2026-10-21):

1. 基础数据-产品维护:规格型号新增“图号”字段
- 新增/编辑必填(后端 ServiceException 校验)且全局唯一(排除自身)
- 产品型号表格新增“图号”列
- 产品导入模板三个 sheet 增加“图号”列,导入时若填写则校验唯一,未填写保持兼容

2. 营销管理-销售台账
- 新增销售台账“自动生成”复选框默认不勾选(前端)
- 销售合同号唯一校验(新增/编辑均校验,编辑排除自身),支持字母、数字及特殊字符,
重复时提示“销售合同号 XXX 已存在(销售单号:XXX)”
- 已生产(生产计划已下发)的销售订单放开编辑:isEdit 仅受“已发货”限制,
hasProductionRecord 保留给前端用于只放开时间类字段

3. 销售台账-产品信息
- 产品明细新增“产品编码”字段(product_code),选择产品自动带出,
handleSalesLedgerProducts 中按产品型号兜底回填
- 导入新增“物料编码”列(SalesLedgerProductImportDto.materialCode),落库到 product_code,
未填写时按产品大类+规格型号回查 product_model.product_code
- 导入/新增出现重复单号时直接失败,并回显原有销售单号(不再静默跳过);
同时校验导入文件内部重复
- 顺带修复导入“是否生产”列填 1/0 时 readConverterExp 反转导致 Integer 为 null、
NPE(getIsProduction()==1)的问题,改为兼容 1/0、是/否

DDL:sql/20261021_longyi_drawing_no_product_code.sql
(product_model.drawing_no、sales_ledger_product.product_code,含存量 product_code 回填)
已修改10个文件
已添加1个文件
182 ■■■■■ 文件已修改
sql/20261021_longyi_drawing_no_product_code.sql 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/dto/ProductImportRowDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/pojo/ProductModel.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/ProductModelServiceImpl.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesLedgerProductImportDto.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesLedgerProduct.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/ProductModelMapper.xml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/static/产品导入模板.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/static/销售台账导入模板.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261021_longyi_drawing_no_product_code.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
-- ============================================================
-- æ±Ÿè‹éš†ä¹‰ï¼ˆprofile: jsnygcjx)本次需求 DDL
-- æ‰§è¡Œåº“:product-inventory-management-jsnygcjxkj
-- éœ€æ±‚1:产品维护-规格型号新增“图号”(必填、唯一)
-- éœ€æ±‚3:销售台账产品明细新增“产品编码”(导入列名:物料编码)
-- ============================================================
-- 1. äº§å“åž‹å·è¡¨ï¼šå›¾å·
ALTER TABLE `product_model`
    ADD COLUMN `drawing_no` varchar(255) NULL COMMENT '图号' AFTER `product_code`;
-- å¯é€‰ï¼šç¡®è®¤åŽ†å²æ•°æ®æ— é‡å¤å›¾å·ï¼ˆå½“å‰å‡ä¸º NULL)后,再建唯一索引
-- ALTER TABLE `product_model` ADD UNIQUE KEY `uk_product_model_drawing_no` (`drawing_no`);
-- 2. é”€å”®å°è´¦äº§å“æ˜Žç»†ï¼šäº§å“ç¼–码(= äº§å“ç»´æŠ¤ä¸­çš„产品编号 product_code)
ALTER TABLE `sales_ledger_product`
    ADD COLUMN `product_code` varchar(255) NULL COMMENT '产品编码' AFTER `specification_model`;
-- 3. å­˜é‡æ•°æ®å›žå¡«ï¼šæŒ‰äº§å“åž‹å·å…³è”回填产品编码
UPDATE `sales_ledger_product` slp
    JOIN `product_model` pm ON pm.id = slp.product_model_id
SET slp.product_code = pm.product_code
WHERE slp.product_model_id IS NOT NULL
  AND (slp.product_code IS NULL OR slp.product_code = '');
-- 4. å¯é€‰ï¼šé”€å”®åˆåŒå·å”¯ä¸€ï¼ˆç¡®è®¤æ— é‡å¤åŽå¯å¼€å¯ï¼‰
-- ALTER TABLE `sales_ledger` ADD UNIQUE KEY `uk_sales_ledger_contract_no` (`sales_contract_no`);
src/main/java/com/ruoyi/basic/dto/ProductImportRowDto.java
@@ -25,6 +25,9 @@
    @Excel(name = "规格型号")
    private String model;
    @Excel(name = "图号")
    private String drawingNo;
    @Excel(name = "单位")
    private String unit;
src/main/java/com/ruoyi/basic/pojo/ProductModel.java
@@ -41,6 +41,12 @@
    private String productCode;
    /**
     * å›¾å·ï¼ˆå¿…填、唯一)
     */
    @Excel(name = "图号")
    private String drawingNo;
    /**
     * å•位
     */
    @Excel(name = "单位")
src/main/java/com/ruoyi/basic/service/impl/ProductModelServiceImpl.java
@@ -58,9 +58,16 @@
    public int addOrEditProductModel(ProductModelDto productModelDto) {
        String model = StringUtils.trim(productModelDto.getModel());
        String productCode = StringUtils.trim(productModelDto.getProductCode());
        String drawingNo = StringUtils.trim(productModelDto.getDrawingNo());
        productModelDto.setModel(model);
        productModelDto.setProductCode(productCode);
        productModelDto.setDrawingNo(drawingNo);
        // å›¾å·å¿…å¡«
        if (StringUtils.isEmpty(drawingNo)) {
            throw new ServiceException("图号不能为空");
        }
        checkModelAndProductCodeUnique(model, productCode, productModelDto.getId());
        checkDrawingNoUnique(drawingNo, productModelDto.getId());
        if (productModelDto.getId() == null) {
            ProductModel productModel = new ProductModel();
@@ -68,6 +75,23 @@
            return productModelMapper.insert(productModel);
        } else {
            return productModelMapper.updateById(productModelDto);
        }
    }
    /**
     * å›¾å·å”¯ä¸€æ ¡éªŒï¼ˆå…¨å±€ï¼Œç¼–辑时排除自身)
     */
    private void checkDrawingNoUnique(String drawingNo, Long currentId) {
        if (StringUtils.isEmpty(drawingNo)) {
            return;
        }
        LambdaQueryWrapper<ProductModel> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ProductModel::getDrawingNo, drawingNo)
                .ne(currentId != null, ProductModel::getId, currentId)
                .last("limit 1");
        ProductModel duplicateProductModel = productModelMapper.selectOne(queryWrapper);
        if (duplicateProductModel != null) {
            throw new ServiceException("图号" + drawingNo + "已经存在");
        }
    }
@@ -200,10 +224,17 @@
                    code = generateUniqueCode(generatedCodes);
                }
                // å›¾å·ï¼šå¯¼å…¥æ—¶å¯é€‰ï¼Œå¡«å†™åˆ™æ ¡éªŒå”¯ä¸€
                String drawingNo = trimToNull(row.getDrawingNo());
                if (drawingNo != null) {
                    checkDrawingNoUnique(drawingNo, null);
                }
                ProductModel productModel = new ProductModel();
                productModel.setProductId(leafId);
                productModel.setProductCode(code);
                productModel.setModel(model);
                productModel.setDrawingNo(drawingNo);
                productModel.setUnit(trimToNull(row.getUnit()));
                productModelMapper.insert(productModel);
                successCount++;
src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java
@@ -257,9 +257,8 @@
            boolean planIssued = isFlagTrue(editFlag == null ? null : editFlag.getPlanIssued());
            boolean shipped = isFlagTrue(editFlag == null ? null : editFlag.getShipped());
            //  éœ€è¦ç”Ÿäº§çš„订单:生产计划已下发(含部分下发)则不允许编辑
            //  èµ°åº“存的订单:已发货则不允许编辑,否则可以编辑
            salesLedgerVo.setIsEdit(needsProduction ? !planIssued : !shipped);
            //  å·²å‘货的订单不允许编辑;需要生产且生产计划已下发(含部分下发)的订单允许编辑,前端只放开时间类字段
            salesLedgerVo.setIsEdit(!shipped);
            salesLedgerVo.setHasProductionRecord(planIssued);
            salesLedgerVo.setStorageBlobVOs(fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum.FILE, RecordTypeEnum.SALES_LEDGER, ledgerId));
src/main/java/com/ruoyi/sales/dto/SalesLedgerProductImportDto.java
@@ -32,6 +32,12 @@
    private String specificationModel;
    /**
     * ç‰©æ–™ç¼–码(对应产品编码 product_model.product_code)
     */
    @Excel(name = "物料编码")
    private String materialCode;
    /**
     * å•位
     */
    @Excel(name = "单位")
@@ -74,8 +80,8 @@
    private Boolean isChecked;
    /**
     * æ˜¯å¦ç”Ÿäº§
     * æ˜¯å¦ç”Ÿäº§ï¼ˆå¡« 1/是 è¡¨ç¤ºç”Ÿäº§ï¼Œ0/否 è¡¨ç¤ºä¸ç”Ÿäº§ï¼‰
     */
    @Excel(name = "是否生产", readConverterExp = "0=否,1=是")
    private Integer isProduction;
    @Excel(name = "是否生产")
    private String isProduction;
}
src/main/java/com/ruoyi/sales/pojo/SalesLedgerProduct.java
@@ -56,6 +56,12 @@
    private String specificationModel;
    /**
     * äº§å“ç¼–码(即产品维护中的产品编号 product_model.product_code)
     */
    @Excel(name = "物料编码")
    private String productCode;
    /**
     * å•位
     */
    @Excel(name = "单位")
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
@@ -19,6 +19,7 @@
import com.ruoyi.basic.mapper.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.pojo.Customer;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.enums.FileNameType;
import com.ruoyi.common.enums.SaleEnum;
@@ -361,13 +362,34 @@
            // å½•入人数据
            List<SysUser> sysUsers = sysUserMapper.selectList(new LambdaQueryWrapper<SysUser>().in(SysUser::getNickName,
                    salesLedgerImportDtoList.stream().map(SalesLedgerImportDto::getEntryPerson).collect(Collectors.toList())));
            for (SalesLedgerImportDto salesLedgerImportDto : salesLedgerImportDtoList) {
                SalesLedger salesLedger1 = salesLedgerMapper.selectOne(new LambdaQueryWrapper<SalesLedger>()
                        .eq(SalesLedger::getSalesContractNo, salesLedgerImportDto.getSalesContractNo())
                        .last("LIMIT 1"));
                if (salesLedger1 != null) {
            // é”€å”®å•号重复校验:文件内重复或库内已存在的,直接失败并回显原有销售单号
            List<String> duplicateContractNos = new ArrayList<>();
            Set<String> importContractNos = new HashSet<>();
            for (SalesLedgerImportDto item : salesLedgerImportDtoList) {
                String contractNo = StringUtils.trim(item.getSalesContractNo());
                if (StringUtils.isEmpty(contractNo)) {
                    continue;
                }
                if (!importContractNos.add(contractNo)) {
                    duplicateContractNos.add(contractNo);
                }
            }
            if (CollectionUtils.isNotEmpty(duplicateContractNos)) {
                return AjaxResult.error("导入文件中存在重复的销售单号:" + String.join("、", duplicateContractNos));
            }
            if (CollectionUtils.isNotEmpty(importContractNos)) {
                List<SalesLedger> existLedgers = salesLedgerMapper.selectList(new LambdaQueryWrapper<SalesLedger>()
                        .in(SalesLedger::getSalesContractNo, importContractNos)
                        .select(SalesLedger::getId, SalesLedger::getSalesContractNo));
                if (CollectionUtils.isNotEmpty(existLedgers)) {
                    String existNos = existLedgers.stream()
                            .map(SalesLedger::getSalesContractNo)
                            .distinct()
                            .collect(Collectors.joining("、"));
                    return AjaxResult.error("以下销售单号已存在,不能重复新增:" + existNos);
                }
            }
            for (SalesLedgerImportDto salesLedgerImportDto : salesLedgerImportDtoList) {
                SalesLedger salesLedger = new SalesLedger();
                BeanUtils.copyProperties(salesLedgerImportDto, salesLedger);
                salesLedger.setExecutionDate(DateUtils.toLocalDate(salesLedgerImportDto.getExecutionDate()));
@@ -408,6 +430,11 @@
                    BeanUtils.copyProperties(salesLedgerProductImportDto, salesLedgerProduct);
                    salesLedgerProduct.setSalesLedgerId(salesLedger.getId());
                    salesLedgerProduct.setType(1);
                    // ç‰©æ–™ç¼–码 -> äº§å“ç¼–码
                    String materialCode = StringUtils.trim(salesLedgerProductImportDto.getMaterialCode());
                    if (StringUtils.isNotEmpty(materialCode)) {
                        salesLedgerProduct.setProductCode(materialCode);
                    }
                    AmountUtils.normalizeSalesLedgerProduct(salesLedgerProduct);
                    // è®¡ç®—不含税总价
                    salesLedgerProduct.setTaxExclusiveTotalPrice(
@@ -422,6 +449,10 @@
                            .ifPresent(map -> {
                                salesLedgerProduct.setProductModelId(Long.parseLong(map.get("modelId").toString()));
                                salesLedgerProduct.setProductId(Long.parseLong(map.get("id").toString()));
                                // æœªå¡«ç‰©æ–™ç¼–码时,按产品型号回填产品编码
                                if (StringUtils.isEmpty(salesLedgerProduct.getProductCode()) && map.get("productCode") != null) {
                                    salesLedgerProduct.setProductCode(map.get("productCode").toString());
                                }
                            });
//                    salesLedgerProduct.setProductId(productList.stream()
//                            .filter(product -> product.getProductName().equals(salesLedgerProduct.getProductCategory()))
@@ -436,7 +467,7 @@
                    salesLedgerProduct.setRegister(loginUser.getNickName());
                    salesLedgerProduct.setRegisterDate(LocalDateTime.now());
                    salesLedgerProduct.setApproveStatus(0);
                    salesLedgerProduct.setIsProduction(salesLedgerProductImportDto.getIsProduction() == 1);
                    salesLedgerProduct.setIsProduction(parseProductionFlag(salesLedgerProductImportDto.getIsProduction()));
                    salesLedgerProductMapper.insert(salesLedgerProduct);
                    // æ·»åŠ ç”Ÿäº§æ•°æ®
                    salesLedgerProductServiceImpl.addProductionData(salesLedgerProduct);
@@ -582,9 +613,14 @@
        AmountUtils.normalizeContractAmount(salesLedger);
        salesLedger.setCustomerName(customer.getCustomerName());
        salesLedger.setTenantId(customer.getTenantId());
        // 3. æ–°å¢žæˆ–更新主表
        // 3. é”€å”®åˆåŒå·å”¯ä¸€æ ¡éªŒï¼ˆæ”¯æŒå­—母、数字及特殊字符,不做字符集限制)
        String contractNo = StringUtils.trim(salesLedger.getSalesContractNo());
        if (StringUtils.isNotEmpty(contractNo)) {
            checkSalesContractNoUnique(contractNo, salesLedger.getId());
            salesLedger.setSalesContractNo(contractNo);
        }
        // 4. æ–°å¢žæˆ–更新主表
        if (salesLedger.getId() == null) {
            String contractNo = salesLedger.getSalesContractNo();
            if (StringUtils.isEmpty(contractNo)) {
                contractNo = generateSalesContractNo();
            }
@@ -595,7 +631,7 @@
        }
        // 4. å¤„理子表数据
        // 5. å¤„理子表数据
        List<SalesLedgerProduct> productList = salesLedgerDto.getProductData();
        if (productList != null && !productList.isEmpty()) {
            AmountUtils.normalizeSalesLedgerProducts(productList);
@@ -615,8 +651,31 @@
        return 1;
    }
    /**
     * é”€å”®åˆåŒå·å”¯ä¸€æ ¡éªŒï¼ˆç¼–辑时排除自身)
     */
    private void checkSalesContractNoUnique(String salesContractNo, Long currentId) {
        LambdaQueryWrapper<SalesLedger> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(SalesLedger::getSalesContractNo, salesContractNo)
                .ne(currentId != null, SalesLedger::getId, currentId)
                .last("limit 1");
        SalesLedger duplicate = salesLedgerMapper.selectOne(queryWrapper);
        if (duplicate != null) {
            throw new BaseException("销售合同号" + salesContractNo + "已存在(销售单号:" + duplicate.getSalesContractNo() + "),请重新填写");
        }
    }
    @Override
    public void handleSalesLedgerProducts(Long salesLedgerId, List<SalesLedgerProduct> products, SaleEnum type) {
        // äº§å“ç¼–码兜底:前端未传时按产品型号回填
        products.forEach(p -> {
            if (StringUtils.isEmpty(p.getProductCode()) && p.getProductModelId() != null) {
                ProductModel productModel = productModelMapper.selectById(p.getProductModelId());
                if (productModel != null) {
                    p.setProductCode(productModel.getProductCode());
                }
            }
        });
        // æŒ‰ID分组,区分新增和更新的记录
        Map<Boolean, List<SalesLedgerProduct>> partitionedProducts = products.stream()
                .peek(p -> p.setSalesLedgerId(salesLedgerId))
@@ -645,6 +704,17 @@
        }
    }
    /**
     * è§£æžå¯¼å…¥çš„“是否生产”列:支持 1/0、是/否
     */
    private static boolean parseProductionFlag(String value) {
        if (StringUtils.isEmpty(value)) {
            return false;
        }
        String v = value.trim();
        return "1".equals(v) || "是".equals(v) || "true".equalsIgnoreCase(v) || "yes".equalsIgnoreCase(v);
    }
    private SalesLedger convertToEntity(SalesLedgerDto dto) {
        SalesLedger entity = new SalesLedger();
        BeanUtils.copyProperties(dto, entity);
src/main/resources/mapper/basic/ProductModelMapper.xml
@@ -146,7 +146,9 @@
        select p.id as id,
               pm.id as modelId,
               p.product_name as productName ,
               pm.model as  model
               pm.model as  model,
               pm.product_code as productCode,
               pm.drawing_no as drawingNo
        from product_model pm
        left join product p on p.id = pm.product_id
        order by p.id,pm.id desc
src/main/resources/static/²úÆ·µ¼ÈëÄ£°å.xlsx
Binary files differ
src/main/resources/static/ÏúÊŲ̂Õ˵¼ÈëÄ£°å.xlsx
Binary files differ