liyong
4 天以前 3acb4bda764d615bc69d18339921cc92e0024cdd
生产订单物料需求修改
已修改17个文件
已添加2个文件
717 ■■■■■ 文件已修改
docs/work_order_material_requirement.md 257 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/purchase/ErpPurchaseOrderApi.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/api/purchase/ErpPurchaseOrderApiImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderRespVO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderSaveReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseOrderDO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseOrderSourceTypeEnum.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/MesProWorkOrderBomController.java 259 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/vo/MesProWorkOrderItemRespVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/route/MesProRouteProductBomMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstock/MesWmMaterialStockMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/route/MesProRouteProductBomService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/route/MesProRouteProductBomServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierServiceImpl.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/work_order_material_requirement.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,257 @@
# å·¥å•物料需求列表(含库存校验)- å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- å·¥å•详情页 / å·¥å•物料需求 Tab
- å·¥å•列表 â†’ ç‰©æ–™éœ€æ±‚查看(弹窗或侧边栏)
- **ERP é‡‡è´­è®¢å•新增页面**(库存不足时跳转)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. å·¥å•详情页 â†’ ç‰©æ–™éœ€æ±‚ Tab:传入当前工单 `workOrderId`
2. æŽ¥å£æ ¹æ®å·¥å•的最后一道工序投料,沿工序产出链向前递推,展开出所有底层原材料的总需求数量
3. éœ€æ±‚数量 = æ¯å•位成品用量 Ã— å·¥å•生产数量
4. è”查库存返回可用量和充足标识,前端据此展示库存状态
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/pro/work-order-bom/item-list-by-work-order-id` | èŽ·å¾—å·¥å•ç‰©æ–™éœ€æ±‚åˆ—è¡¨ï¼ˆå«åº“å­˜ï¼‰ |
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| workOrderId | Long | æ˜¯ | å·¥å•编号 |
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| itemId | Long | ç‰©æ–™ç¼–号 |
| itemCode | String | ç‰©æ–™ç¼–码 |
| itemName | String | ç‰©æ–™åç§° |
| itemSpecification | String | è§„格型号 |
| unitMeasureName | String | å•位名称 |
| quantity | BigDecimal | éœ€æ±‚数量(工单数量 Ã— BOM ç”¨é‡ï¼‰ |
| stockQuantity | BigDecimal | å¯ç”¨åº“存(在库 - å†»ç»“ - å ç”¨ï¼‰ |
| purchaseOrderQuantity | BigDecimal | åœ¨é€”采购数(来源为本工单的采购订单未入库数量汇总) |
| stockSufficient | Boolean | åº“存是否充足(可用库存 + åœ¨é€”采购数 >= éœ€æ±‚数量),true=是 false=否 |
| itemOrProduct | String | ç‰©æ–™/产品标识 |
**响应示例:**
```json
{
  "code": 0,
  "data": [
    {
      "itemId": 41,
      "itemCode": "ITEM_20260716004",
      "itemName": "铝杆",
      "itemSpecification": "φ9.5mm",
      "unitMeasureName": "kg",
      "quantity": 600.00,
      "stockQuantity": 500.00,
      "purchaseOrderQuantity": 200.00,
      "stockSufficient": true,
      "itemOrProduct": "ITEM"
    },
    {
      "itemId": 44,
      "itemCode": "ITEM_20260716008",
      "itemName": "镀锌钢线",
      "itemSpecification": "φ1.6mm",
      "unitMeasureName": "kg",
      "quantity": 100.00,
      "stockQuantity": 50.00,
      "purchaseOrderQuantity": 0.00,
      "stockSufficient": false,
      "itemOrProduct": "ITEM"
    }
  ]
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| itemCode | åˆ—表列 | ç‰©æ–™ç¼–码 |
| itemName | åˆ—表列 | ç‰©æ–™åç§° |
| itemSpecification | åˆ—表列 | è§„格型号 |
| unitMeasureName | åˆ—表列 | å•位 |
| quantity | åˆ—表列 | éœ€æ±‚数量,右对齐,千分位格式 |
| stockQuantity | åˆ—表列 | å¯ç”¨åº“存,右对齐,千分位格式 |
| purchaseOrderQuantity | åˆ—表列 | åœ¨é€”采购数,右对齐,千分位格式 |
| stockSufficient | åˆ—表列 | åº“存状态,见下方状态展示规则 |
### åº“存状态展示规则
| stockSufficient | å±•示文本 | å»ºè®®é¢œè‰² | è¯´æ˜Ž |
|-----------------|---------|---------|------|
| true | å……è¶³ | ç»¿è‰² | å¯ç”¨åº“å­˜ â‰¥ éœ€æ±‚数量 |
| false | ä¸è¶³ | çº¢è‰² | å¯ç”¨åº“å­˜ < éœ€æ±‚数量 |
### æ•°æ®å¸¦å…¥å…³ç³»
| æ¥æº | å¸¦å…¥å­—段 | è¯´æ˜Ž |
|------|----------|------|
| å·¥å•详情 | workOrderId | é€šè¿‡é¡µé¢è·¯ç”±å‚数或行数据获取,传给接口 |
## æŽ¥å£è°ƒç”¨æ—¶åº
```
工单详情页加载
  â”œâ”€â”€ GET /mes/pro/work-order/get?workOrderId=47 â†’ èŽ·å–å·¥å•åŸºæœ¬ä¿¡æ¯
  â”œâ”€â”€ GET /mes/pro/work-order-process/list-by-work-order-id?workOrderId=47 â†’ èŽ·å–å·¥åºåˆ—è¡¨
  â””── GET /mes/pro/work-order-bom/item-list-by-work-order-id?workOrderId=47 â†’ èŽ·å–ç‰©æ–™éœ€æ±‚ï¼ˆå«åº“å­˜ï¼‰
```
无需前置调用,传入 workOrderId å³å¯ç‹¬ç«‹è¯·æ±‚。
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| å·¥å•无工序 | è¿”回空列表 |
| å·¥å•æ—  BOM æŠ•æ–™ | è¿”回空列表 |
| å·¥å•产品未关联工艺路线 | è¿”回空列表 |
| ç‰©æ–™éœ€æ±‚计算 | ä»Žæœ€åŽä¸€é“工序(sort æœ€å¤§ï¼‰çš„æŠ•料出发,沿工序产出链向前递推展开 |
| æ•°é‡è®¡ç®— | æ¯å•位成品用量 Ã— å·¥å•生产数量(quantity) |
| å¯ç”¨åº“存计算 | æ±‡æ€»æ‰€æœ‰åº“位的(在库数量 - å†»ç»“数量 - å ç”¨æ•°é‡ï¼‰ï¼Œâ‰¤0 çš„库位不计入 |
| åœ¨é€”采购数计算 | æŸ¥è¯¢æ¥æºä¸ºæœ¬å·¥å•(sourceType=1, sourceId=工单ID)的采购订单明细,汇总(订购数量 - å·²å…¥åº“数量) |
| å……足判断 | å¯ç”¨åº“å­˜ + åœ¨é€”采购数 >= éœ€æ±‚数量 |
## é‡‡è´­ç”³è¯·ï¼ˆåº“存不足时跳转)
### è§¦å‘条件
列表中 `stockSufficient = false` çš„行,在操作列展示「**申请采购**」按钮。
### è·³è½¬ç›®æ ‡
ERP é‡‡è´­è®¢å•新增页面:`/erp/purchase-order/create`(前端路由,非接口路径)
### æ•°æ®å¸¦å…¥
从物料需求行 + å½“前工单携带以下数据跳转到采购订单新增页面:
**工单来源字段(采购订单独有的追溯字段):**
| æ¥æºå­—段 | ç›®æ ‡å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|----------|----------|------|------|
| å·¥å• `id` | `sourceId` | Long | æ¥æºå•据编号 |
| å·¥å• `code` | `sourceNo` | String | æ¥æºå•号 |
| â€” | `sourceType` | Integer | å›ºå®šä¼  `1`(生产工单) |
**产品行字段:**
| æ¥æºå­—段 | ç›®æ ‡å­—段 | è¯´æ˜Ž |
|----------|----------|------|
| `itemId` | `items[].productId` | äº§å“ç¼–号 |
| `itemCode` | â€” | äº§å“ç¼–码,供页面显示 |
| `itemName` | â€” | äº§å“åç§°ï¼Œä¾›é¡µé¢æ˜¾ç¤º |
| `itemSpecification` | â€” | è§„格型号,供页面显示 |
| `quantity - stockQuantity` | `items[].count` | å»ºè®®é‡‡è´­æ•°é‡ï¼ˆç¼ºå£ = éœ€æ±‚ - åº“存) |
| `unitMeasureName` | â€” | å•位名称,供页面显示 |
### é¡µé¢è·³è½¬æµç¨‹
```
物料需求列表(库存不足行)
  â”‚
  â”œâ”€ ç‚¹å‡»ã€Œç”³è¯·é‡‡è´­ã€æŒ‰é’®
  â”‚
  â”œâ”€ è·³è½¬åˆ° ERP é‡‡è´­è®¢å•新增页面
  â”‚     æºå¸¦ï¼šå·¥å•来源信息(sourceId、sourceNo、sourceType=1)
  â”‚           äº§å“è¡Œä¿¡æ¯ï¼ˆproductId、数量、编码、名称、规格、单位)
  â”‚
  â””─ é‡‡è´­è®¢å•新增页面
        â”œâ”€ è‡ªåŠ¨é¢„å¡«æ¥æºå­—æ®µï¼ˆä¸å¯ç¼–è¾‘ï¼‰
        â”œâ”€ è‡ªåŠ¨é¢„å¡«äº§å“è¡Œï¼ˆproductId + count + unit)
        â”œâ”€ ç”¨æˆ·é€‰æ‹©ä¾›åº”商(supplierId,必填)
        â”œâ”€ ç”¨æˆ·è¡¥å……单价、税率等信息
        â””─ æäº¤ POST /erp/purchase-order/create
```
### äº¤äº’说明
| åœºæ™¯ | å¤„理方式 |
|------|----------|
| å•个物料不足 | ç‚¹å‡»è¯¥è¡Œã€Œç”³è¯·é‡‡è´­ã€â†’ è·³è½¬é‡‡è´­è®¢å•页面,预填 1 è¡Œäº§å“ |
| å¤šä¸ªç‰©æ–™ä¸è¶³ | å¯é€è¡Œç‚¹å‡»åˆ†åˆ«ç”³è¯·ï¼Œæˆ–支持多选后批量带到同一个采购订单 |
| å…¨éƒ¨å……è¶³ | ä¸å±•示「申请采购」按钮 |
| å¯ç”¨åº“存为 0 | `stockSufficient = false`,同样展示「申请采购」,缺口 = å…¨éƒ¨éœ€æ±‚数量 |
### é‡‡è´­è®¢å•创建 API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/erp/purchase-order/create` | åˆ›å»ºé‡‡è´­è®¢å• |
**完整请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | æ¥æº |
|------|------|------|------|
| `sourceType` | Integer | å¦ | å›ºå®š `1`(生产工单),由页面携带 |
| `sourceNo` | String | å¦ | å·¥å•编码 `workOrder.code` |
| `sourceId` | Long | å¦ | å·¥å• ID `workOrder.id` |
| `supplierId` | Long | æ˜¯ | ç”¨æˆ·åœ¨é‡‡è´­é¡µé¢é€‰æ‹©ä¾›åº”商 |
| `orderTime` | LocalDateTime | æ˜¯ | é»˜è®¤å½“天 |
| `items[].productId` | Long | æ˜¯ | ç‰©æ–™éœ€æ±‚çš„ `itemId` |
| `items[].productUnitId` | Long | æ˜¯ | ç‰©æ–™å¯¹åº”的计量单位 ID |
| `items[].count` | BigDecimal | æ˜¯ | ç¼ºå£æ•°é‡ï¼ˆéœ€æ±‚ - åº“存) |
| `items[].productPrice` | BigDecimal | å¦ | ç”¨æˆ·æ‰‹å·¥å¡«å†™ |
| `items[].taxPercent` | BigDecimal | å¦ | ç”¨æˆ·æ‰‹å·¥å¡«å†™ |
**请求示例:**
```json
{
  "sourceType": 1,
  "sourceNo": "GD20260730001",
  "sourceId": 47,
  "supplierId": 10,
  "orderTime": "2026-07-30",
  "items": [
    {
      "productId": 41,
      "productUnitId": 5,
      "count": 100.00,
      "productPrice": 12.50,
      "taxPercent": 13.00
    }
  ]
}
```
**响应:**
```json
{
  "code": 0,
  "data": {
    "id": 123,
    "no": "CGD20260730001",
    "sourceType": 1,
    "sourceNo": "GD20260730001",
    "sourceId": 47,
    "sourceTypeName": "生产工单",
    "supplierId": 10,
    "items": [...]
  }
}
```
## æ³¨æ„äº‹é¡¹
- `stockSufficient` ä¸º `false` æ—¶ï¼Œå‰ç«¯åº”**差异化展示**(如红色高亮),并在操作列显示「申请采购」按钮
- å¯ç”¨åº“存为 0 æ—¶ï¼Œ`stockQuantity` è¿”回 `0`,`stockSufficient` ä¸º `false`
- æŽ¥å£è¿”回的物料列表可能为空(工单无工序/无 BOM/未关联路线),前端需处理空状态提示
- æ•°é‡å­—段为 `BigDecimal`,前端展示时注意小数位处理,建议保留 2 ä½å°æ•°
- è·³è½¬é‡‡è´­è®¢å•时,`productUnitId` éœ€è¦é€šè¿‡ç‰©æ–™è¯¦æƒ…接口获取(当前物料需求列表未返回此字段),或者采购订单页面根据 `productId` è‡ªè¡ŒæŸ¥è¯¢é»˜è®¤å•位
- åŒä¸€ä¸ªç‰©æ–™å¯èƒ½åœ¨å¤šä¸ªå·¥å•中都需要采购,采购订单页面应支持用户修改数量
- æ¥æºå­—段 `sourceType`、`sourceNo`、`sourceId` åœ¨é‡‡è´­è®¢å•页面展示为**只读**,不允许用户修改
- é‡‡è´­è®¢å•详情/列表中可通过 `sourceTypeName`(如"生产工单")和 `sourceNo`(工单编码)追溯到来源工单
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java
@@ -7,6 +7,7 @@
import tools.jackson.databind.ValueDeserializer;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@@ -20,7 +21,8 @@
    public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer();
    private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    @Override
    public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
@@ -34,9 +36,13 @@
            long millis = Long.parseLong(text);
            return LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault());
        } catch (NumberFormatException e) {
            // ä¸æ˜¯çº¯æ•°å­—,按默认日期格式解析
            // ä¸æ˜¯çº¯æ•°å­—,按日期格式解析
        }
        return LocalDateTime.parse(text, DEFAULT_FORMATTER);
        // çº¯æ—¥æœŸæ ¼å¼ï¼ˆå¦‚ "2026-07-29"),补全为当天 00:00:00
        if (text.length() == 10) {
            return LocalDate.parse(text, DATE_FORMATTER).atStartOfDay();
        }
        return LocalDateTime.parse(text, DATE_TIME_FORMATTER);
    }
}
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/purchase/ErpPurchaseOrderApi.java
@@ -84,4 +84,13 @@
     */
    CommonResult<Long> createPurchaseOrder(ErpPurchaseOrderCreateReqDTO createReqDTO);
    /**
     * æŒ‰æ¥æºæŸ¥è¯¢é‡‡è´­è®¢å•明细列表(用于追溯来源工单的采购情况)
     *
     * @param sourceType æ¥æºç±»åž‹
     * @param sourceId   æ¥æºå•据编号
     * @return é‡‡è´­è®¢å•明细列表
     */
    CommonResult<List<ErpPurchaseOrderItemRespDTO>> getPurchaseOrderItemListBySource(Integer sourceType, Long sourceId);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/api/purchase/ErpPurchaseOrderApiImpl.java
@@ -118,4 +118,20 @@
        return success(purchaseOrderService.createPurchaseOrder(saveReqVO));
    }
    @Override
    public CommonResult<List<ErpPurchaseOrderItemRespDTO>> getPurchaseOrderItemListBySource(Integer sourceType, Long sourceId) {
        if (sourceType == null || sourceId == null) {
            return success(Collections.emptyList());
        }
        // 1. æŒ‰æ¥æºæŸ¥è¯¢é‡‡è´­è®¢å•
        List<ErpPurchaseOrderDO> orders = purchaseOrderService.getPurchaseOrderListBySource(sourceType, sourceId);
        if (orders.isEmpty()) {
            return success(Collections.emptyList());
        }
        // 2. æŸ¥è¯¢æ‰€æœ‰è®¢å•的明细
        List<Long> orderIds = orders.stream().map(ErpPurchaseOrderDO::getId).toList();
        List<ErpPurchaseOrderItemDO> items = purchaseOrderItemMapper.selectListByOrderIds(orderIds);
        return success(BeanUtils.toBean(items, ErpPurchaseOrderItemRespDTO.class));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java
@@ -13,6 +13,7 @@
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderSaveReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderItemDO;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseOrderSourceTypeEnum;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseOrderService;
@@ -128,7 +129,9 @@
                    item.setStockCount(BigDecimal.ZERO);
                    MapUtils.findAndThen(itemMap, item.getProductId(), mdmItem -> item.setProductName(mdmItem.getName())
                            .setProductBarCode(mdmItem.getBarCode()));
                })).setAttachmentList(storageAttachmentApi.listAttachments("erp_purchase_order", id))));
                })).setAttachmentList(storageAttachmentApi.listAttachments("erp_purchase_order", id))
                        .setSourceTypeName(ErpPurchaseOrderSourceTypeEnum.WORK_ORDER.getType()
                                .equals(purchaseOrder.getSourceType()) ? "生产工单" : null)));
    }
    @GetMapping("/page")
@@ -185,6 +188,8 @@
            purchaseOrder.setProductNames(CollUtil.join(purchaseOrder.getItems(), ",", ErpPurchaseOrderRespVO.Item::getProductName));
            MapUtils.findAndThen(supplierMap, purchaseOrder.getSupplierId(), supplier -> purchaseOrder.setSupplierName(supplier.getName()));
            MapUtils.findAndThen(userMap, Long.parseLong(purchaseOrder.getCreator()), user -> purchaseOrder.setCreatorName(user.getNickname()));
            purchaseOrder.setSourceTypeName(ErpPurchaseOrderSourceTypeEnum.WORK_ORDER.getType()
                    .equals(purchaseOrder.getSourceType()) ? "生产工单" : null);
        });
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderRespVO.java
@@ -64,6 +64,20 @@
    @Schema(description = "定金金额,单位:元", requiredMode = Schema.RequiredMode.REQUIRED, example = "7127")
    private BigDecimal depositPrice;
    @Schema(description = "来源类型", example = "1")
    @ExcelProperty("来源类型")
    private Integer sourceType;
    @Schema(description = "来源单号", example = "GD20260730001")
    @ExcelProperty("来源单号")
    private String sourceNo;
    @Schema(description = "来源单据编号", example = "48")
    private Long sourceId;
    @Schema(description = "来源类型名称", example = "生产工单")
    private String sourceTypeName;
    @Schema(description = "备注", example = "你猜")
    @ExcelProperty("备注")
    private String remark;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderSaveReqVO.java
@@ -32,6 +32,15 @@
    @Schema(description = "定金金额,单位:元", example = "7127")
    private BigDecimal depositPrice;
    @Schema(description = "来源类型", example = "1")
    private Integer sourceType;
    @Schema(description = "来源单号", example = "GD20260730001")
    private String sourceNo;
    @Schema(description = "来源单据编号", example = "48")
    private Long sourceId;
    @Schema(description = "备注", example = "你猜")
    private String remark;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseOrderDO.java
@@ -92,6 +92,23 @@
    private BigDecimal depositPrice;
    /**
     * æ¥æºç±»åž‹
     *
     * æžšä¸¾ {@link cn.iocoder.yudao.module.erp.enums.ErpPurchaseOrderSourceTypeEnum}
     */
    private Integer sourceType;
    /**
     * æ¥æºå•号
     */
    private String sourceNo;
    /**
     * æ¥æºå•据编号
     *
     * å…³è”来源单据表的主键 ID
     */
    private Long sourceId;
    /**
     * å¤‡æ³¨
     */
    private String remark;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java
@@ -81,4 +81,13 @@
                .orderByDesc(ErpPurchaseOrderDO::getId));
    }
    /**
     * æŒ‰æ¥æºæŸ¥è¯¢é‡‡è´­è®¢å•(用于追溯来源工单对应的采购订单)
     */
    default List<ErpPurchaseOrderDO> selectListBySource(Integer sourceType, Long sourceId) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseOrderDO>()
                .eq(ErpPurchaseOrderDO::getSourceType, sourceType)
                .eq(ErpPurchaseOrderDO::getSourceId, sourceId));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseOrderSourceTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.erp.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * ERP é‡‡è´­è®¢å•来源类型枚举
 *
 * @author èŠ‹é“æºç 
 */
@RequiredArgsConstructor
@Getter
public enum ErpPurchaseOrderSourceTypeEnum implements ArrayValuable<Integer> {
    WORK_ORDER(1, "生产工单");
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(ErpPurchaseOrderSourceTypeEnum::getType).toArray(Integer[]::new);
    /**
     * ç±»åž‹å€¼
     */
    private final Integer type;
    /**
     * ç±»åž‹å
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java
@@ -149,6 +149,15 @@
    List<ErpPurchaseOrderDO> getPurchaseOrderList(List<Long> ids);
    /**
     * æŒ‰æ¥æºæŸ¥è¯¢é‡‡è´­è®¢å•列表
     *
     * @param sourceType æ¥æºç±»åž‹
     * @param sourceId   æ¥æºå•据编号
     * @return é‡‡è´­è®¢å•列表
     */
    List<ErpPurchaseOrderDO> getPurchaseOrderListBySource(Integer sourceType, Long sourceId);
    /**
     * æ›´æ–°é‡‡è´­è®¢å•的入库状态
     *
     * æ ¹æ®æ˜Žç»†å…¥åº“数量自动计算订单状态:
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java
@@ -451,6 +451,14 @@
    }
    @Override
    public List<ErpPurchaseOrderDO> getPurchaseOrderListBySource(Integer sourceType, Long sourceId) {
        if (sourceType == null || sourceId == null) {
            return Collections.emptyList();
        }
        return purchaseOrderMapper.selectListBySource(sourceType, sourceId);
    }
    @Override
    public void updatePurchaseOrderInStatus(Long orderId) {
        // 1. èŽ·å–é‡‡è´­è®¢å•
        ErpPurchaseOrderDO order = validatePurchaseOrderExists(orderId);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/MesProWorkOrderBomController.java
@@ -12,14 +12,24 @@
import cn.iocoder.yudao.module.mes.controller.admin.pro.workorder.vo.bom.MesProWorkOrderBomSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemTypeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.unitmeasure.MesMdUnitMeasureDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductBomDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderBomDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderProcessDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
import cn.iocoder.yudao.module.erp.api.purchase.ErpPurchaseOrderApi;
import cn.iocoder.yudao.module.erp.api.purchase.dto.ErpPurchaseOrderItemRespDTO;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemTypeService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdProductBomService;
import cn.iocoder.yudao.module.mes.service.md.unitmeasure.MesMdUnitMeasureService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProductBomService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProductService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderBomService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -31,6 +41,7 @@
import java.math.BigDecimal;
import java.util.*;
import java.util.Comparator;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
@@ -44,13 +55,23 @@
    @Resource
    private MesProWorkOrderBomService workOrderBomService;
    @Resource
    private MesProWorkOrderService workOrderService;
    @Resource
    private MesProWorkOrderProcessService workOrderProcessService;
    @Resource
    private MesMdItemService itemService;
    @Resource
    private MesMdUnitMeasureService unitMeasureService;
    @Resource
    private MesMdItemTypeService itemTypeService;
    @Resource
    private MesMdProductBomService productBomService;
    private MesProRouteProductService routeProductService;
    @Resource
    private MesProRouteProductBomService routeProductBomService;
    @Resource
    private MesWmMaterialStockMapper materialStockMapper;
    @Resource
    private ErpPurchaseOrderApi purchaseOrderApi;
    @PostMapping("/create")
    @Operation(summary = "创建工单 BOM")
@@ -102,40 +123,67 @@
    @PreAuthorize("@ss.hasPermission('mes:pro-work-order:query')")
    public CommonResult<List<MesProWorkOrderItemRespVO>> getWorkOrderBomItemListByWorkOrderId(
            @RequestParam("workOrderId") Long workOrderId) {
        // 1. èŽ·å–å·¥å• BOM è¡Œ
        // 1. èŽ·å–å·¥å•
        MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderId);
        if (workOrder == null) {
            return success(Collections.emptyList());
        }
        // 2. èŽ·å–å·¥å•å·¥åºåˆ—è¡¨ï¼ˆå«å„å·¥åºçš„æŠ•æ–™æ˜Žç»†ä¸Žäº§å‡ºå“ï¼‰
        List<MesProWorkOrderProcessDO> processList = workOrderProcessService
                .getWorkOrderProcessListByWorkOrderId(workOrderId);
        if (CollUtil.isEmpty(processList)) {
            return success(Collections.emptyList());
        }
        // 3. èŽ·å–å·¥å• BOM è¡Œï¼ˆå„工序的投料记录,按 processId åˆ†ç»„使用)
        List<MesProWorkOrderBomDO> bomList = workOrderBomService.getWorkOrderBomListByWorkOrderId(workOrderId);
        if (CollUtil.isEmpty(bomList)) {
            return success(Collections.emptyList());
        }
        // 2. é€å±‚展开,得到叶子物料 itemId â†’ quantity
        Map<Long, BigDecimal> leafItems = buildWorkOrderItems(bomList);
        // 4. é€šè¿‡äº§å“ID反查工艺路线(工单 routeId å¯èƒ½ä¸ºç©ºï¼‰
        Long routeId = null;
        if (workOrder.getProductId() != null) {
            MesProRouteProductDO routeProduct = routeProductService.getRouteProductByItemId(workOrder.getProductId());
            if (routeProduct != null) {
                routeId = routeProduct.getRouteId();
            }
        }
        // 5. æŒ‰å·¥åºäº§å‡ºé“¾é€å±‚展开 BOM,得到叶子物料(每单位成品的用量)
        Map<Long, BigDecimal> leafItems = buildWorkOrderItems(processList, bomList, routeId);
        if (CollUtil.isEmpty(leafItems)) {
            return success(Collections.emptyList());
        }
        // 3. æ‹¼æŽ¥ VO å­—段
        Map<Long, MesMdItemDO> itemMap = itemService.getItemMap(leafItems.keySet());
        Map<Long, MesMdUnitMeasureDO> unitMeasureMap = unitMeasureService.getUnitMeasureMap(
                convertSet(itemMap.values(), MesMdItemDO::getUnitMeasureId));
        Map<Long, MesMdItemTypeDO> itemTypeMap = itemTypeService.getItemTypeMap(
                convertSet(itemMap.values(), MesMdItemDO::getItemTypeId));
        List<MesProWorkOrderItemRespVO> result = new ArrayList<>(leafItems.size());
        // 6. ä¹˜ä»¥å·¥å•生产数量,得到总需求
        BigDecimal orderQty = workOrder.getQuantity() != null ? workOrder.getQuantity() : BigDecimal.ONE;
        Map<Long, BigDecimal> totalRequirements = new LinkedHashMap<>();
        for (Map.Entry<Long, BigDecimal> entry : leafItems.entrySet()) {
            MesMdItemDO item = itemMap.get(entry.getKey());
            if (item == null) {
                continue;
            }
            MesProWorkOrderItemRespVO vo = new MesProWorkOrderItemRespVO()
                    .setItemId(item.getId()).setQuantity(entry.getValue())
                    .setItemCode(item.getCode()).setItemName(item.getName()).setItemSpecification(item.getSpecification());
            MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
                    unitMeasure -> vo.setUnitMeasureName(unitMeasure.getName()));
            MapUtils.findAndThen(itemTypeMap, item.getItemTypeId(),
                    itemType -> vo.setItemOrProduct(itemType.getItemOrProduct()));
            result.add(vo);
            totalRequirements.put(entry.getKey(), entry.getValue().multiply(orderQty));
        }
        return success(result);
        // 7. æŸ¥è¯¢æ¥æºä¸ºæœ¬å·¥å•的采购订单明细,按产品汇总在途采购数
        //    sourceType=1 è¡¨ç¤ºæ¥æºç±»åž‹ä¸º"生产工单"
        Map<Long, BigDecimal> purchaseOrderQtyMap = new HashMap<>();
        List<ErpPurchaseOrderItemRespDTO> poItems = purchaseOrderApi
                .getPurchaseOrderItemListBySource(1, workOrderId)
                .getCheckedData();
        if (CollUtil.isNotEmpty(poItems)) {
            for (ErpPurchaseOrderItemRespDTO poItem : poItems) {
                // åœ¨é€” = è®¢è´­æ•°é‡ - å·²å…¥åº“数量
                BigDecimal inCount = poItem.getInCount() != null ? poItem.getInCount() : BigDecimal.ZERO;
                BigDecimal inTransit = poItem.getCount().subtract(inCount);
                if (inTransit.compareTo(BigDecimal.ZERO) > 0) {
                    purchaseOrderQtyMap.merge(poItem.getProductId(), inTransit, BigDecimal::add);
                }
            }
        }
        // 8. æŸ¥è¯¢åº“存,拼接 VO
        return success(buildItemRespVOList(totalRequirements, purchaseOrderQtyMap));
    }
    // ==================== æ‹¼æŽ¥ VO ====================
@@ -164,47 +212,160 @@
        });
    }
    // ==================== ç‰©æ–™éœ€æ±‚逐层展开 ====================
    // ==================== ç‰©æ–™éœ€æ±‚展开(工序产出链 + è·¯ç”±äº§å“ BOM) ====================
    /**
     * é€å±‚ IN æŸ¥è¯¢å±•开工单 BOM åˆ°å¶å­ç‰©æ–™ï¼Œåˆå¹¶åŒç‰©æ–™çš„需求数量
     * æŒ‰å·¥åºäº§å‡ºé“¾é€å±‚展开物料需求。
     *
     * @param bomList å·¥å• BOM åˆ—表
     * @return å¶å­ç‰©æ–™ Map:itemId â†’ åˆå¹¶åŽçš„需求数量
     * <p><b>核心逻辑:</b></p>
     * <ol>
     *   <li>找出关键工序(keyFlag=true),以它的投料为展开起点</li>
     *   <li>构建产出品→工序投料的映射({@code outputItemId â†’ per-unit bomItems})</li>
     *   <li>逐层展开:若物料是某工序的产出品 â†’ ç”¨è¯¥å·¥åºçš„æŠ•料配方替代;
     *       å¦åˆ™æŸ¥è·¯ç”±äº§å“ BOM â†’ ç”¨ BOM å­é¡¹æ›¿ä»£ï¼›
     *       ä¸¤è€…都没有 â†’ å¶å­ç‰©æ–™ï¼ˆå¤–购件),累加到结果</li>
     * </ol>
     *
     * <p>这样避免了把中间工序的投料直接纳入起点导致的重复计算问题。</p>
     *
     * @param processList å·¥å•工序列表(按 sort å‡åºï¼‰
     * @param bomList     å·¥å• BOM è¡Œï¼ˆæ‰€æœ‰å·¥åºçš„æŠ•料明细)
     * @param routeId     å·¥è‰ºè·¯çº¿ç¼–号(可为 null,null æ—¶è·³è¿‡è·¯ç”± BOM æŸ¥è¯¢ï¼‰
     * @return å¶å­ç‰©æ–™ Map:itemId â†’ éœ€æ±‚数量
     */
    private Map<Long, BigDecimal> buildWorkOrderItems(List<MesProWorkOrderBomDO> bomList) {
        // 1. åˆå§‹åŒ–:收集第一层物料 ID å’Œå¯¹åº”数量
        Map<Long, BigDecimal> currentLayer = new LinkedHashMap<>();
        for (MesProWorkOrderBomDO bom : bomList) {
            currentLayer.merge(bom.getItemId(), bom.getQuantity(), BigDecimal::add);
    private Map<Long, BigDecimal> buildWorkOrderItems(List<MesProWorkOrderProcessDO> processList,
                                                       List<MesProWorkOrderBomDO> bomList,
                                                       Long routeId) {
        // 1. æž„建产出品 â†’ å·¥åºæŠ•料配方(per-unit)
        //    outputItemId â†’ [{itemId, quantity}, ...]
        Map<Long, List<MesProWorkOrderBomDO>> processBomMap = new HashMap<>();
        for (MesProWorkOrderProcessDO process : processList) {
            if (process.getOutputItemId() == null) {
                continue;
            }
            List<MesProWorkOrderBomDO> items = new ArrayList<>();
            for (MesProWorkOrderBomDO bom : bomList) {
                if (bom.getProcessId().equals(process.getProcessId())) {
                    items.add(bom);
                }
            }
            processBomMap.put(process.getOutputItemId(), items);
        }
        // 2. é€å±‚ IN æŸ¥è¯¢å±•å¼€ BOM
        // 2. å–最后一道工序(sort æœ€å¤§ï¼‰ä½œä¸ºå±•开起点,逐层向前递推
        MesProWorkOrderProcessDO lastProcess = processList.stream()
                .max(Comparator.comparing(MesProWorkOrderProcessDO::getSort))
                .orElse(null);
        if (lastProcess == null) {
            return Collections.emptyMap();
        }
        // 3. ä»¥æœ€åŽä¸€é“工序的投料为起点
        Map<Long, BigDecimal> currentLayer = new LinkedHashMap<>();
        for (MesProWorkOrderBomDO bom : bomList) {
            if (bom.getProcessId().equals(lastProcess.getProcessId())) {
                currentLayer.merge(bom.getItemId(), bom.getQuantity(), BigDecimal::add);
            }
        }
        if (CollUtil.isEmpty(currentLayer)) {
            return Collections.emptyMap();
        }
        // 4. é€å±‚展开
        Map<Long, BigDecimal> leafItems = new LinkedHashMap<>();
        for (int i = 0; i < Byte.MAX_VALUE && CollUtil.isNotEmpty(currentLayer); i++) {
            // 2.1 æ‰¹é‡æŸ¥è¯¢å½“前层所有物料的子 BOM
            List<MesMdProductBomDO> subBomList = productBomService.getProductBomListByItemIds(currentLayer.keySet());
            // 2.2 æŒ‰ itemId åˆ†ç»„
            Map<Long, List<MesMdProductBomDO>> subBomMap = convertMultiMap(subBomList, MesMdProductBomDO::getItemId);
            // 2.3 åŒºåˆ†å¶å­èŠ‚ç‚¹å’Œéžå¶å­èŠ‚ç‚¹
            Map<Long, BigDecimal> nextLayer = new LinkedHashMap<>();
            // 4.1 æ‰¹é‡æŸ¥è¯¢è·¯ç”±äº§å“ BOM(用于非工序产出的物料展开)
            Map<Long, List<MesProRouteProductBomDO>> routeSubBomMap;
            if (routeId != null) {
                List<MesProRouteProductBomDO> routeSubBomList = routeProductBomService
                        .getRouteProductBomListByProductIds(routeId, currentLayer.keySet());
                routeSubBomMap = convertMultiMap(routeSubBomList, MesProRouteProductBomDO::getProductId);
            } else {
                routeSubBomMap = Collections.emptyMap();
            }
            for (Map.Entry<Long, BigDecimal> entry : currentLayer.entrySet()) {
                Long itemId = entry.getKey();
                BigDecimal quantity = entry.getValue();
                List<MesMdProductBomDO> children = subBomMap.get(itemId);
                // æƒ…况一:叶子节点,累加到结果
                if (CollUtil.isEmpty(children)) {
                    leafItems.merge(itemId, quantity, BigDecimal::add);
                BigDecimal qty = entry.getValue();
                // æƒ…况一:物料是某工序的产出品 â†’ ç”¨å·¥åºé…æ–¹æ›¿ä»£
                List<MesProWorkOrderBomDO> processBom = processBomMap.get(itemId);
                if (CollUtil.isNotEmpty(processBom)) {
                    for (MesProWorkOrderBomDO child : processBom) {
                        nextLayer.merge(child.getItemId(), qty.multiply(child.getQuantity()), BigDecimal::add);
                    }
                    continue;
                }
                // æƒ…况二:非叶子,子物料进入下一层
                for (MesMdProductBomDO child : children) {
                    nextLayer.merge(child.getBomItemId(), quantity.multiply(child.getQuantity()), BigDecimal::add);
                // æƒ…况二:路由产品 BOM ä¸­æœ‰ä¸‹çº§ â†’ å±•å¼€
                List<MesProRouteProductBomDO> routeChildren = routeSubBomMap.get(itemId);
                if (CollUtil.isNotEmpty(routeChildren)) {
                    for (MesProRouteProductBomDO child : routeChildren) {
                        nextLayer.merge(child.getItemId(), qty.multiply(child.getQuantity()), BigDecimal::add);
                    }
                    continue;
                }
                // æƒ…况三:叶子物料
                leafItems.merge(itemId, qty, BigDecimal::add);
            }
            currentLayer = nextLayer;
        }
        return leafItems;
    }
    // ==================== æ‹¼æŽ¥ç‰©æ–™éœ€æ±‚ VO(含库存校验) ====================
    private List<MesProWorkOrderItemRespVO> buildItemRespVOList(Map<Long, BigDecimal> requirements,
                                                                  Map<Long, BigDecimal> purchaseOrderQtyMap) {
        // 1. æ‰¹é‡èŽ·å–ç‰©æ–™åŸºç¡€ä¿¡æ¯
        Map<Long, MesMdItemDO> itemMap = itemService.getItemMap(requirements.keySet());
        Map<Long, MesMdUnitMeasureDO> unitMeasureMap = unitMeasureService.getUnitMeasureMap(
                convertSet(itemMap.values(), MesMdItemDO::getUnitMeasureId));
        Map<Long, MesMdItemTypeDO> itemTypeMap = itemTypeService.getItemTypeMap(
                convertSet(itemMap.values(), MesMdItemDO::getItemTypeId));
        // 2. æ‰¹é‡æŸ¥è¯¢åº“存,按 itemId æ±‡æ€»å¯ç”¨é‡ï¼ˆåœ¨åº“ - å†»ç»“ - å ç”¨ï¼‰
        Map<Long, BigDecimal> stockMap = new HashMap<>();
        List<MesWmMaterialStockDO> stockList = materialStockMapper.selectListByItemIds(requirements.keySet());
        for (MesWmMaterialStockDO stock : stockList) {
            BigDecimal frozen = stock.getFrozenQuantity() != null ? stock.getFrozenQuantity() : BigDecimal.ZERO;
            BigDecimal reserved = stock.getReservedQuantity() != null ? stock.getReservedQuantity() : BigDecimal.ZERO;
            BigDecimal available = stock.getQuantity().subtract(frozen).subtract(reserved);
            if (available.compareTo(BigDecimal.ZERO) > 0) {
                stockMap.merge(stock.getItemId(), available, BigDecimal::add);
            }
        }
        // 3. æ‹¼æŽ¥ VO
        List<MesProWorkOrderItemRespVO> result = new ArrayList<>(requirements.size());
        for (Map.Entry<Long, BigDecimal> entry : requirements.entrySet()) {
            MesMdItemDO item = itemMap.get(entry.getKey());
            if (item == null) {
                continue;
            }
            BigDecimal requiredQty = entry.getValue();
            BigDecimal stockQty = stockMap.getOrDefault(item.getId(), BigDecimal.ZERO);
            BigDecimal purchaseQty = purchaseOrderQtyMap.getOrDefault(item.getId(), BigDecimal.ZERO);
            // å……足判断 = å¯ç”¨åº“å­˜ + åœ¨é€”采购数 >= éœ€æ±‚数量
            BigDecimal totalSupply = stockQty.add(purchaseQty);
            MesProWorkOrderItemRespVO vo = new MesProWorkOrderItemRespVO()
                    .setItemId(item.getId()).setQuantity(requiredQty)
                    .setItemCode(item.getCode()).setItemName(item.getName()).setItemSpecification(item.getSpecification())
                    .setStockQuantity(stockQty)
                    .setPurchaseOrderQuantity(purchaseQty)
                    .setStockSufficient(totalSupply.compareTo(requiredQty) >= 0);
            MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
                    unitMeasure -> vo.setUnitMeasureName(unitMeasure.getName()));
            MapUtils.findAndThen(itemTypeMap, item.getItemTypeId(),
                    itemType -> vo.setItemOrProduct(itemType.getItemOrProduct()));
            result.add(vo);
        }
        return result;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/vo/MesProWorkOrderItemRespVO.java
@@ -27,6 +27,15 @@
    @Schema(description = "需求数量", example = "1000.00")
    private BigDecimal quantity;
    @Schema(description = "可用库存", example = "500.00")
    private BigDecimal stockQuantity;
    @Schema(description = "在途采购数(来源为本工单的采购订单数量汇总)", example = "200.00")
    private BigDecimal purchaseOrderQuantity;
    @Schema(description = "库存是否充足(可用库存 + åœ¨é€”采购数 >= éœ€æ±‚数量)", example = "true")
    private Boolean stockSufficient;
    @Schema(description = "物料/产品标识", example = "ITEM")
    private String itemOrProduct;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/route/MesProRouteProductBomMapper.java
@@ -5,6 +5,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductBomDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
@@ -46,4 +47,10 @@
                .eq(MesProRouteProductBomDO::getProductId, productId));
    }
    default List<MesProRouteProductBomDO> selectListByProductIds(Long routeId, Collection<Long> productIds) {
        return selectList(new LambdaQueryWrapperX<MesProRouteProductBomDO>()
                .eq(MesProRouteProductBomDO::getRouteId, routeId)
                .in(MesProRouteProductBomDO::getProductId, productIds));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstock/MesWmMaterialStockMapper.java
@@ -183,4 +183,16 @@
                .orderByAsc(MesWmMaterialStockDO::getReceiptTime));
    }
    /**
     * æŒ‰ç‰©æ–™ç¼–号批量查询库存记录(用于物料需求库存校验)
     */
    default List<MesWmMaterialStockDO> selectListByItemIds(Collection<Long> itemIds) {
        if (itemIds == null || itemIds.isEmpty()) {
            return java.util.Collections.emptyList();
        }
        return selectList(new LambdaQueryWrapperX<MesWmMaterialStockDO>()
                .in(MesWmMaterialStockDO::getItemId, itemIds)
                .ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/route/MesProRouteProductBomService.java
@@ -4,6 +4,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductBomDO;
import jakarta.validation.Valid;
import java.util.Collection;
import java.util.List;
/**
@@ -62,4 +63,13 @@
     */
    List<MesProRouteProductBomDO> getRouteProductBomListByRouteIdAndProductId(Long routeId, Long productId);
    /**
     * æŒ‰å·¥è‰ºè·¯çº¿å’Œå¤šä¸ªäº§å“æ‰¹é‡æŸ¥è¯¢ BOM åˆ—表
     *
     * @param routeId å·¥è‰ºè·¯çº¿ç¼–号
     * @param productIds äº§å“ç‰©æ–™ç¼–号集合
     * @return BOM åˆ—表
     */
    List<MesProRouteProductBomDO> getRouteProductBomListByProductIds(Long routeId, Collection<Long> productIds);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/route/MesProRouteProductBomServiceImpl.java
@@ -13,6 +13,7 @@
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.Collection;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -137,4 +138,12 @@
        return routeProductBomMapper.selectListByRouteIdAndProductId(routeId, productId);
    }
    @Override
    public List<MesProRouteProductBomDO> getRouteProductBomListByProductIds(Long routeId, Collection<Long> productIds) {
        if (CollUtil.isEmpty(productIds)) {
            return java.util.Collections.emptyList();
        }
        return routeProductBomMapper.selectListByProductIds(routeId, productIds);
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierServiceImpl.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.srm.service.supplier;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.srm.controller.admin.supplier.vo.SrmSupplierPageReqVO;
@@ -14,7 +15,10 @@
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@Service
@@ -79,6 +83,9 @@
    @Override
    public List<SrmSupplierDO> getSupplierList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return Collections.emptyList();
        }
        return supplierMapper.selectBatchIds(ids);
    }