feat(purchase): 新增采购草稿简易新增功能并完善生产订单库存数量显示

- 在IPurchaseLedgerService中添加saveShortagePurchaseDraft方法用于保存采购草稿
- 实现ProductionOrderPickServiceImpl中的fillStockQuantity方法填充产品库存总量
- 更新ProductionOrderPickVo中stockQuantity字段描述为按productModelId汇总的库存总量
- 从PurchaseLedger中移除templateId字段
- 在PurchaseLedgerController中新增saveShortagePurchaseDraft接口用于简易新增采购草稿
- 为PurchaseLedgerDto添加ccUserId和ccUserName字段用于抄送人信息
- 实现PurchaseLedgerServiceImpl中的saveShortagePurchaseDraft完整业务逻辑
- 添加resolveShortagePurchaseCopyUserId等辅助方法处理抄送人相关逻辑
- 创建生产订单领料库存数量前端联调文档说明stockQuantity字段使用方式
- 创建采购台账简易新增与采购申请通知前端联调文档说明草稿保存流程
已添加2个文件
已修改7个文件
378 ■■■■■ 文件已修改
doc/20260527_ProductionOrderController_pick库存数量前端联调文档.md 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260527_采购台账简易新增_采购申请通知前端联调文档.md 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderPickVo.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/controller/PurchaseLedgerController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/dto/PurchaseLedgerDto.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/pojo/PurchaseLedger.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/IPurchaseLedgerService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260527_ProductionOrderController_pick¿â´æÊýÁ¿Ç°¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
# `ProductionOrderController.pick` æŽ¥å£å‰ç«¯è”调文档
## æŽ¥å£ä¿¡æ¯
- æŽ¥å£åœ°å€ï¼š`GET /productionOrder/pick/{productionOrderId}`
- ä½œç”¨ï¼šæ ¹æ®ç”Ÿäº§è®¢å•ID查询 BOM é¢†æ–™å•明细
- æœ¬æ¬¡è¡¥å……字段:`stockQuantity`
## å­—段说明
`stockQuantity` ä¸ºäº§å“åº“存总量,口径如下:
- æŒ‰ `productModelId` æ±‡æ€»
- åªç»Ÿè®¡äº§å“åº“存表 `stock_inventory`
- ä¸åŒºåˆ†æ‰¹å· `batchNo`
- å–值为当前产品规格下所有库存数量之和
## è¿”回示例
```json
[
  {
    "id": 1001,
    "productionOrderId": 20001,
    "productModelId": 30001,
    "productName": "原材料A",
    "model": "A-01",
    "unit": "kg",
    "pickQuantity": 120,
    "stockQuantity": 860,
    "batchNoList": ["B20260501", "B20260508"]
  }
]
```
## å‰ç«¯è”调注意点
- åˆ—表展示时直接使用 `stockQuantity` å³å¯ï¼Œä¸éœ€è¦å‰ç«¯å†æŒ‰æ‰¹å·ç´¯åŠ ã€‚
- å¦‚æžœ `stockQuantity` ä¸º `0`,表示该产品规格当前没有可用库存。
- `batchNoList` ä»ç„¶ä¿ç•™ï¼Œç”¨äºŽæ‰¹å·å±•示或后续批号选择,不影响 `stockQuantity` çš„计算。
- è¯¥å­—段属于明细返回字段,前端只要拿到 `pick` æŽ¥å£è¿”回就能展示,无需额外请求库存接口。
## éªŒè¯æ–¹å¼
- æ‰“开生产订单详情页
- è°ƒç”¨ `GET /productionOrder/pick/{productionOrderId}`
- ç¡®è®¤æ¯ä¸€æ¡ BOM é¢†æ–™æ˜Žç»†éƒ½è¿”回 `stockQuantity`
- æ ¸å¯¹åº“存数量与库存模块中同一 `productModelId` çš„库存总量一致
doc/20260527_²É¹ºÌ¨Õ˼òÒ×ÐÂÔö_²É¹ºÉêÇë֪ͨǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
# é‡‡è´­å°è´¦ç®€æ˜“新增与采购申请通知前端联调文档
## 1. ç›®æ ‡
本次新增的是采购台账的“简易新增”能力,专门用于生产领料库存不足场景。
这个入口的行为是:
1. å…ˆä¿å­˜ä¸€æ¡é‡‡è´­è‰ç¨¿ï¼Œä¸è¿›å…¥é‡‡è´­å®¡æ ¸ã€‚
2. è‡ªåŠ¨ç»™æŠ„é€äººå‘é€ä¸€æ¡é‡‡è´­ç”³è¯·æé†’æ¶ˆæ¯ã€‚
3. åŽŸç”³è¯·äººä¼šè¢«å†™å…¥é‡‡è´­å°è´¦å¤‡æ³¨ã€‚
4. é‡‡è´­å°è´¦å½•入人固定为抄送人。
5. æŠ„送人补全采购订单信息后,再走原有正式新增接口进入采购审核。
## 2. æ–°å¢žæŽ¥å£
### 2.1 ä¿å­˜é‡‡è´­è‰ç¨¿
- è¯·æ±‚方式:`POST`
- æŽ¥å£è·¯å¾„:`/purchase/ledger/saveShortagePurchaseDraft`
- è¯·æ±‚体:`PurchaseLedgerDto`
### 2.2 ä»ç„¶ä¿ç•™çš„æ­£å¼æäº¤æŽ¥å£
- è¯·æ±‚方式:`POST`
- æŽ¥å£è·¯å¾„:`/purchase/ledger/addOrEditPurchase`
这个接口继续走原有采购审核流程。前端在草稿补全完成后,仍然使用它提交正式采购单。
## 3. è¯·æ±‚字段
保存草稿时,前端至少传这些字段:
- `salesContractNo`:销售订单号
- `productData`:采购产品明细数组
- `ccUserId`:抄送人 ID
可选字段按 `PurchaseLedgerDto` åŽŸæœ‰ç»“æž„ä¼ å…¥å³å¯ï¼Œä¾‹å¦‚ï¼š
- `purchaseContractNumber`
- `supplierId`
- `supplierName`
- `entryDate`
- `remarks`
- `storageBlobDTOS`
- `ccUserName`
说明:
- `purchaseContractNumber` å¦‚果不传,后端会自动生成。
- è‰ç¨¿ä¿å­˜é˜¶æ®µä¸è¦æ±‚补全采购审核相关信息。
## 4. è¿”回结果
草稿保存成功后,返回采购台账主键 `id`。
示例:
```json
{
  "code": 200,
  "msg": "保存成功",
  "data": 12345
}
```
前端应保存这个 `id`,后续补全时直接带着这个 `id` è°ƒç”¨æ­£å¼æäº¤æŽ¥å£ã€‚
## 5. åŽç«¯è¡Œä¸º
### 5.1 è‰ç¨¿ä¿å­˜é€»è¾‘
后端会根据 `salesContractNo` æŸ¥è¯¢é”€å”®è®¢å•,并把销售订单信息回填到采购草稿里,包括:
- `salesLedgerId`
- `salesContractNo`
- `projectName`
同时:
- `approvalStatus` å›ºå®šä¸º `0`,表示草稿
- äº§å“æ˜Žç»†ä¼šæŒ‰é‡‡è´­å°è´¦å­è¡¨ä¿å­˜
- ä¸ä¼šåˆ›å»ºé‡‡è´­å®¡æ‰¹æµç¨‹
- `recorderId` å’Œ `recorderName` ä¼šè®¾ç½®ä¸ºæŠ„送人
### 5.2 å¤‡æ³¨è§„则
后端会把原申请人信息写入采购台账备注,备注内容类似:
- `原申请人:张三,由抄送人李四补全采购订单信息后提交审核。`
如果前端已经传了备注,后端会在原备注后追加这段说明。
### 5.3 é‡‡è´­ç”³è¯·æé†’消息
草稿创建后,后端会自动发送一条站内消息 / APP æé†’给抄送人:
- æ ‡é¢˜ï¼š`采购申请提醒`
- å†…容:提示该销售订单对应的采购申请已创建,需要补全采购订单信息后再提交审核
- è·³è½¬åœ°å€ï¼š`/purchaseLedger/edit?id={id}`
## 6. å‰ç«¯è”调建议
### 6.1 æ–°å»ºè‰ç¨¿é¡µé¢
前端建议新增一个“简易采购申请”入口,字段最少只放:
- é”€å”®è®¢å•号
- æŠ„送人
- é‡‡è´­äº§å“æ˜Žç»†
保存后跳转到采购草稿详情或编辑页,继续补全供应商、录入日期、备注等信息。
### 6.2 è‰ç¨¿ç¼–辑页
草稿详情页需要支持:
- è¯»å–草稿详情
- ç¼–辑采购产品明细
- è¡¥å…¨é‡‡è´­å•主表信息
- æœ€ç»ˆè°ƒç”¨ `/purchase/ledger/addOrEditPurchase` æ­£å¼æäº¤
### 6.3 æ¶ˆæ¯ä¸­å¿ƒ
如果前端有消息列表页,用户点击这条采购申请提醒后,应跳到采购草稿编辑页。
## 7. è°ƒè¯•点
1. `salesContractNo` ä¸å­˜åœ¨æ—¶ï¼ŒåŽç«¯ä¼šç›´æŽ¥æŠ¥é”™ã€‚
2. è‰ç¨¿ä¿å­˜åŽä¸ä¼šç”Ÿæˆå®¡æ‰¹å•。
3. è‰ç¨¿è¡¥å…¨åŽå†èµ°æ­£å¼æäº¤æŽ¥å£ï¼Œæ‰ä¼šè¿›å…¥å®¡æ‰¹æµã€‚
4. æ¶ˆæ¯æŽ¥æ”¶äººæ˜¯æŠ„送人,不是原申请人。
5. åŽŸç”³è¯·äººä¿¡æ¯åªä¼šå†™å…¥å¤‡æ³¨ã€‚
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderPickVo.java
@@ -15,7 +15,7 @@
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "库存数量")
    @Schema(description = "产品库存总量(按 productModelId æ±‡æ€»ï¼Œä¸åŒºåˆ†æ‰¹å·ï¼‰")
    private BigDecimal stockQuantity;
    @Schema(description = "领用数量")
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java
@@ -189,6 +189,7 @@
            return Collections.emptyList();
        }
        List<ProductionOrderPickVo> detailList = baseMapper.listPickedDetailByOrderId(productionOrderId);
        fillStockQuantity(detailList);
        fillBatchNoList(detailList);
        fillSelectableBatchNoList(detailList);
        return detailList;
@@ -1121,6 +1122,34 @@
        }
    }
    private void fillStockQuantity(List<ProductionOrderPickVo> detailList) {
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
        Set<Long> productModelIdSet = detailList.stream()
                .map(ProductionOrderPickVo::getProductModelId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (productModelIdSet.isEmpty()) {
            return;
        }
        List<StockInventory> stockList = stockInventoryMapper.selectList(
                Wrappers.<StockInventory>lambdaQuery()
                        .in(StockInventory::getProductModelId, productModelIdSet));
        Map<Long, BigDecimal> stockQuantityMap = new HashMap<>();
        for (StockInventory stockInventory : stockList) {
            if (stockInventory == null || stockInventory.getProductModelId() == null) {
                continue;
            }
            stockQuantityMap.merge(stockInventory.getProductModelId(),
                    defaultDecimal(stockInventory.getQualitity()),
                    BigDecimal::add);
        }
        for (ProductionOrderPickVo detail : detailList) {
            detail.setStockQuantity(stockQuantityMap.getOrDefault(detail.getProductModelId(), BigDecimal.ZERO));
        }
    }
    private String buildBatchNoGroupKey(ProductionOrderPickVo detail) {
        // æž„建批次聚合分组键。
        return detail.getProductionOrderId() + "|"
src/main/java/com/ruoyi/purchase/controller/PurchaseLedgerController.java
@@ -137,6 +137,17 @@
    }
    /**
     * é‡‡è´­è‰ç¨¿ç®€æ˜“新增
     */
    @Log(title = "采购台账", businessType = BusinessType.INSERT)
    @PostMapping("/saveShortagePurchaseDraft")
    @Operation(summary = "简易新增采购草稿")
    public AjaxResult saveShortagePurchaseDraft(@RequestBody PurchaseLedgerDto purchaseLedgerDto) throws Exception {
        Long id = purchaseLedgerService.saveShortagePurchaseDraft(purchaseLedgerDto);
        return AjaxResult.success("保存成功", id);
    }
    /**
     * æŸ¥è¯¢é‡‡è´­æ¨¡æ¿
     */
    @Operation(summary = "/查询采购模板")
src/main/java/com/ruoyi/purchase/dto/PurchaseLedgerDto.java
@@ -69,6 +69,16 @@
    private String recorderName;
    /**
     * æŠ„送人ID
     */
    private Long ccUserId;
    /**
     * æŠ„送人姓名
     */
    private String ccUserName;
    /**
     * é”€å”®åˆåŒå·
     */
    @Excel(name = "销售合同号")
src/main/java/com/ruoyi/purchase/pojo/PurchaseLedger.java
@@ -158,7 +158,4 @@
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    @Schema(description = "模板id")
    private Long templateId;
}
src/main/java/com/ruoyi/purchase/service/IPurchaseLedgerService.java
@@ -23,6 +23,8 @@
    int addOrEditPurchase(PurchaseLedgerDto purchaseLedgerDto) throws Exception;
    Long saveShortagePurchaseDraft(PurchaseLedgerDto purchaseLedgerDto) throws Exception;
    void addQualityInspect(PurchaseLedger purchaseLedger, SalesLedgerProduct saleProduct);
    int deletePurchaseLedgerByIds(Long[] ids);
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java
@@ -10,6 +10,7 @@
import com.ruoyi.approve.pojo.ApproveProcess;
import com.ruoyi.approve.service.impl.ApproveProcessServiceImpl;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.common.enums.ApprovalStatusEnum;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.mapper.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
@@ -30,6 +31,7 @@
import com.ruoyi.procurementrecord.pojo.ProcurementRecordStorage;
import com.ruoyi.project.system.domain.SysUser;
import com.ruoyi.project.system.mapper.SysUserMapper;
import com.ruoyi.project.system.service.ISysNoticeService;
import com.ruoyi.purchase.dto.PurchaseLedgerDto;
import com.ruoyi.purchase.dto.PurchaseLedgerImportDto;
import com.ruoyi.purchase.dto.PurchaseLedgerProductImportDto;
@@ -99,6 +101,7 @@
    private final QualityInspectParamMapper qualityInspectParamMapper;
    private final ApproveProcessServiceImpl approveProcessService;
    private final ProcurementRecordMapper procurementRecordStorageMapper;
    private final ISysNoticeService sysNoticeService;
    private final FileUtil fileUtil;
    @Override
@@ -174,7 +177,91 @@
        return 1;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long saveShortagePurchaseDraft(PurchaseLedgerDto purchaseLedgerDto) throws Exception {
        if (purchaseLedgerDto == null) {
            throw new BaseException("采购台账数据不能为空");
        }
        if (StringUtils.isBlank(purchaseLedgerDto.getSalesContractNo())) {
            throw new BaseException("销售订单号不能为空");
        }
        if (CollectionUtils.isEmpty(purchaseLedgerDto.getProductData())) {
            throw new BaseException("采购产品信息不能为空");
        }
        SalesLedger salesLedger = salesLedgerMapper.selectOne(new LambdaQueryWrapper<SalesLedger>()
                .eq(SalesLedger::getSalesContractNo, purchaseLedgerDto.getSalesContractNo())
                .last("limit 1"));
        if (salesLedger == null) {
            throw new BaseException("销售订单不存在");
        }
        PurchaseLedger purchaseLedger = new PurchaseLedger();
        BeanUtils.copyProperties(purchaseLedgerDto, purchaseLedger);
        purchaseLedger.setSalesLedgerId(salesLedger.getId());
        purchaseLedger.setSalesContractNo(salesLedger.getSalesContractNo());
        purchaseLedger.setProjectName(salesLedger.getProjectName());
        if (purchaseLedger.getEntryDate() == null) {
            purchaseLedger.setEntryDate(salesLedger.getEntryDate() != null ? salesLedger.getEntryDate() : new Date());
        }
        if (!StringUtils.hasText(purchaseLedger.getPurchaseContractNumber())) {
            purchaseLedger.setPurchaseContractNumber(getPurchaseNo());
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        Long currentUserId = loginUser != null && loginUser.getUserId() != null ? loginUser.getUserId() : null;
        if (loginUser != null && loginUser.getTenantId() != null) {
            purchaseLedger.setTenantId(loginUser.getTenantId());
        }
        SysUser recorderUser = resolveShortagePurchaseRecorderUser(purchaseLedgerDto, currentUserId);
        if (ObjectUtils.isNotEmpty(recorderUser)) {
            purchaseLedger.setRecorderId(recorderUser.getUserId());
            purchaseLedger.setRecorderName(recorderUser.getNickName());
            purchaseLedger.setPhoneNumber(recorderUser.getPhonenumber());
        }
//        String originalApplicantName = resolveOriginalApplicantName(salesLedger); // æº¯æºé”€å”®ç”³è¯·äºº
        String originalApplicantName = SecurityUtils.getLoginUser().getNickName();  // å½“前登录用户
        purchaseLedger.setRemarks(mergeShortagePurchaseRemark(
                purchaseLedger.getRemarks(),
                originalApplicantName,
                ObjectUtils.isNotEmpty(recorderUser) ? recorderUser.getNickName() : null
        ));
        purchaseLedger.setApprovalStatus(ApprovalStatusEnum.DRAFT.getCode());
        boolean isNewDraft = purchaseLedger.getId() == null;
        if (isNewDraft) {
            purchaseLedgerMapper.insert(purchaseLedger);
        } else {
            PurchaseLedger dbPurchaseLedger = purchaseLedgerMapper.selectById(purchaseLedger.getId());
            if (dbPurchaseLedger == null) {
                throw new BaseException("采购台账不存在");
            }
            if (!ApprovalStatusEnum.DRAFT.getCode().equals(dbPurchaseLedger.getApprovalStatus())) {
                throw new BaseException("非草稿状态的采购台账不允许通过简易新增修改");
            }
            purchaseLedgerMapper.updateById(purchaseLedger);
        }
        handleSalesLedgerProducts(purchaseLedger.getId(), purchaseLedgerDto.getProductData(), purchaseLedgerDto.getType());
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.PURCHASE_LEDGER, purchaseLedger.getId(), purchaseLedgerDto.getStorageBlobDTOS());
        if (isNewDraft) {
            Long noticeUserId = resolveShortagePurchaseCopyUserId(purchaseLedgerDto, currentUserId);
            if (noticeUserId != null && noticeUserId > 0) {
                sysNoticeService.simpleNoticeByUser(
                        "采购申请提醒",
                        "销售订单号 " + salesLedger.getSalesContractNo() + " çš„采购申请已创建,请补全采购订单信息后提交审核。",
                        Collections.singletonList(noticeUserId),
                        "/procurementManagement/procurementLedger?purchaseContractNumber=" + purchaseLedger.getPurchaseContractNumber()
                );
            }
        }
        return purchaseLedger.getId();
    }
    public void addQualityInspect(PurchaseLedger purchaseLedger, SalesLedgerProduct saleProduct) {
        QualityInspect qualityInspect = new QualityInspect();
        qualityInspect.setInspectType(0);
@@ -626,6 +713,59 @@
        approveProcessService.addApprove(approveProcessVO);
    }
    private Long resolveShortagePurchaseCopyUserId(PurchaseLedgerDto purchaseLedgerDto, Long currentUserId) {
        if (purchaseLedgerDto != null && purchaseLedgerDto.getCcUserId() != null) {
            return purchaseLedgerDto.getCcUserId();
        }
        return currentUserId;
    }
    private SysUser resolveShortagePurchaseRecorderUser(PurchaseLedgerDto purchaseLedgerDto, Long currentUserId) {
        if (purchaseLedgerDto != null && purchaseLedgerDto.getCcUserId() != null) {
            SysUser ccUser = sysUserMapper.selectUserById(purchaseLedgerDto.getCcUserId());
            if (ccUser != null) {
                return ccUser;
            }
        }
        if (purchaseLedgerDto != null && StringUtils.isNotBlank(purchaseLedgerDto.getCcUserName())) {
            SysUser ccUser = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>()
                    .eq(SysUser::getNickName, purchaseLedgerDto.getCcUserName())
                    .last("limit 1"));
            if (ccUser != null) {
                return ccUser;
            }
        }
        return currentUserId == null ? null : sysUserMapper.selectUserById(currentUserId);
    }
    private String resolveOriginalApplicantName(SalesLedger salesLedger) {
        if (salesLedger == null) {
            return null;
        }
        if (salesLedger.getCreateUser() != null) {
            SysUser applicant = sysUserMapper.selectUserById(salesLedger.getCreateUser().longValue());
            if (applicant != null && StringUtils.hasText(applicant.getNickName())) {
                return applicant.getNickName();
            }
        }
        if (StringUtils.hasText(salesLedger.getEntryPerson())) {
            return salesLedger.getEntryPerson();
        }
        return null;
    }
    private String mergeShortagePurchaseRemark(String originalRemark, String applicantName, String recorderName) {
        String sentence = "原申请人:" + (StringUtils.hasText(applicantName) ? applicantName : "未识别")
                + ",由抄送人" + (StringUtils.hasText(recorderName) ? recorderName : "未识别")
                + "补全采购订单信息后提交审核。";
        if (!StringUtils.hasText(originalRemark)) {
            return sentence;
        }
        if (originalRemark.contains("原申请人:")) {
            return originalRemark;
        }
        return originalRemark + "\\n" + sentence;
    }
    /**
     * ä¸‹åˆ’线命名转驼峰命名
     */