4 天以前 7188f28998d47c286e34cbd525986603cfc19ba9
Merge remote-tracking branch 'origin/dev_business' into dev_business
已修改36个文件
已添加6个文件
1439 ■■■■ 文件已修改
docs/business_price_field_unification.md 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java 63 ●●●● 补丁 | 查看 | 原始文档 | 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/controller/admin/purchase/vo/request/ErpPurchaseRequestRespVO.java 31 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/ErpSaleOrderController.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderItemRespVO.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/convert/ErpPurchaseOrderInStatusConvert.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/convert/ErpPurchaseRequestStatusConvert.java 48 ●●●●● 补丁 | 查看 | 原始文档 | 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/ErpPurchaseOrderInStatusEnum.java 20 ●●●●● 补丁 | 查看 | 原始文档 | 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/enums/ErpPurchaseRequestStatusEnum.java 20 ●●●●● 补丁 | 查看 | 原始文档 | 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-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeSaveReqVO.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeEducationMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeEmergencyContactMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeWorkHistoryMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 66 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/vo/MesCalPlanRespVO.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/vo/MesCalPlanSaveReqVO.java 5 ●●●● 补丁 | 查看 | 原始文档 | 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/controller/admin/wm/salesnotice/vo/line/MesWmSalesNoticeLineSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/cal/plan/MesCalPlanDO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | 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/batch/MesWmBatchMapper.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstock/MesWmMaterialStockMapper.java 19 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/cal/team/MesCalTeamShiftServiceImpl.java 52 ●●●●● 补丁 | 查看 | 原始文档 | 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-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/batch/MesWmBatchServiceImpl.java 159 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/business_price_field_unification.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
# å•†æœº price å­—段统一为 businessPrice â€” å‰ç«¯è”调方案
## é—®é¢˜èƒŒæ™¯
前端产品编辑表使用 `contractPrice` ä½œä¸ºä»·æ ¼å­—段发送给后端,但后端使用的是 `businessPrice`(商机价格)。两者是同一个概念,需要统一为 `businessPrice`。
## æ¶‰åŠé¡µé¢
- å•†æœºæ–°å¢ž/编辑弹窗(`src/views/crm/business/modules/form.vue`)
- äº§å“ç¼–辑表组件(`src/views/crm/product/components/edit-table.vue`)
- äº§å“ç¼–辑表列定义(`src/views/crm/product/components/data.ts`)
## API è¯´æ˜Ž
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| PUT | `/crm/business/update` | æ›´æ–°å•†æœº |
| POST | `/crm/business/create` | æ–°å¢žå•†æœº |
**请求体字段变更(`products[]` å†…):**
| å­—段 | å˜æ›´ | è¯´æ˜Ž |
|------|------|------|
| `contractPrice` | âŒ ç§»é™¤ | æ”¹ä¸º `businessPrice` |
| `businessPrice` | âœ… ç»Ÿä¸€ä½¿ç”¨ | å•†æœºä»·æ ¼ |
| `totalProductPrice` | âœ… å·²æœ‰ | äº§å“æ€»ä»·ï¼ˆåŽç«¯å·²æ–°å¢žæ”¯æŒï¼‰ |
| `totalPrice` | âœ… å·²æœ‰ | å•†æœºæ€»ä»·ï¼ˆæŠ˜åŽï¼‰ï¼ˆåŽç«¯å·²æ–°å¢žæ”¯æŒï¼‰ |
## å‰ç«¯ä¿®æ”¹ç‚¹
### 1. `edit-table.vue` â€” å­—段名 `contractPrice` â†’ `businessPrice`
文件:`src/views/crm/product/components/edit-table.vue`
涉及 6 å¤„:
| è¡Œå· | ä¿®æ”¹ |
|------|------|
| 55 | `row.contractPrice` â†’ `row.businessPrice` |
| 57 | `row.contractPrice` â†’ `row.businessPrice` |
| 63 | `row.contractPrice` â†’ `row.businessPrice` |
| 70 | `row.contractPrice` â†’ `row.businessPrice` |
| 132 | `contractPrice` â†’ `businessPrice`(template slot name) |
| 134 | `row.contractPrice` â†’ `row.businessPrice`(v-model) |
### 2. `data.ts` â€” ç¼–辑表列定义
文件:`src/views/crm/product/components/data.ts`
第 91-95 è¡Œï¼Œç¼–辑表(非 business è§†å›¾ï¼‰çš„列定义:
```
field: 'contractPrice'  â†’  field: 'businessPrice'
title: '合同价(元)'    â†’  title: '商机价格(元)'
slots: { default: 'contractPrice' }  â†’  slots: { default: 'businessPrice' }
```
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ›´æ–°å•†æœºæ—¶äº§å“ `businessPrice` ä¸º null | åŽç«¯è‡ªåŠ¨ç”¨æ—§æ•°æ®çš„ `businessPrice` merge(无需前端处理) |
| æ–°å¢žå•†æœºæ—¶ | `businessPrice` å¿…填,不可为 null |
| äº§å“ `totalPrice` | å‰ç«¯å¯ç›´æŽ¥ä¼ è®¡ç®—好的值,后端优先使用;未传时后端用 `businessPrice * count` è‡ªåŠ¨è®¡ç®— |
| å¤´çº§ `totalProductPrice` / `totalPrice` | å‰ç«¯å¯ç›´æŽ¥ä¼ ï¼ŒåŽç«¯ä¸å†è¦†ç›– |
## æ³¨æ„äº‹é¡¹
- **不涉及的文件**:`contract`、`statistics`、`report`、`saleTrace` ä¸­çš„ `contractPrice` æ˜¯å…¶ä»–业务概念,不用改
- äº§å“ `id` å­—段后端已新增支持,更新时传 `id` å³å¯æ ‡è¯†å·²æœ‰äº§å“
- å‰ç«¯ `businessPrice` çš„初始化值来自 `product.salesPrice`(选中产品时的默认价)
- æ›´æ–°æ—¶åªéœ€ä¼ è¦ä¿®æ”¹çš„字段,未传字段后端自动从旧数据继承
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-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.framework.excel.core.util;
import cn.idev.excel.ExcelWriter;
import cn.idev.excel.FastExcelFactory;
import cn.idev.excel.converters.longconverter.LongStringConverter;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
@@ -33,12 +34,22 @@
    public static <T> void write(HttpServletResponse response, String filename, String sheetName,
                                 Class<T> head, List<T> data) throws IOException {
        // è¾“出 Excel
        FastExcelFactory.write(response.getOutputStream(), head)
        ExcelWriter excelWriter = FastExcelFactory.write(response.getOutputStream(), head)
                .autoCloseStream(false) // ä¸è¦è‡ªåŠ¨å…³é—­ï¼Œäº¤ç»™ Servlet è‡ªå·±å¤„理
                .registerWriteHandler(new ColumnWidthMatchStyleStrategy()) // åŸºäºŽ column é•¿åº¦ï¼Œè‡ªåŠ¨é€‚é…ã€‚æœ€å¤§ 255 å®½åº¦
                .registerWriteHandler(new SelectSheetWriteHandler(head)) // åŸºäºŽå›ºå®š sheet å®žçŽ°ä¸‹æ‹‰æ¡†
                .registerConverter(new LongStringConverter()) // é¿å… Long ç±»åž‹ä¸¢å¤±ç²¾åº¦
                .sheet(sheetName).doWrite(data);
                .build();
        try {
            excelWriter.write(data, FastExcelFactory.writerSheet(sheetName).build());
        } finally {
            // finish() å°† workbook å†™å…¥åˆ° response è¾“出流
            excelWriter.finish();
            // close() è§¦å‘ dispose() æ¸…理临时文件;
            // POI SXSSFWorkbook.dispose() å¯èƒ½è¾“出 WARN "Failed to dispose sheet",
            // è¿™æ˜¯ POI å·²çŸ¥é—®é¢˜ï¼ˆä¸´æ—¶æ–‡ä»¶æ¸…理时 BufferedWriter å·²å…³é—­ï¼‰ï¼Œä¸å½±å“å¯¼å‡ºç»“æžœ
            excelWriter.close();
        }
        // è®¾ç½® header å’Œ contentType。写在最后的原因是,避免报错时,响应 contentType å·²ç»è¢«ä¿®æ”¹äº†
        response.addHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename));
        response.setContentType("application/vnd.ms-excel;charset=UTF-8");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java
@@ -58,6 +58,12 @@
    @NotNull(message = "整单折扣不能为空")
    private BigDecimal discountPercent;
    @Schema(description = "产品总价", example = "12000.00")
    private BigDecimal totalProductPrice;
    @Schema(description = "商机总价(折后)", example = "11280.00")
    private BigDecimal totalPrice;
    @Schema(description = "备注", example = "随便")
    @DiffLogField(name = "备注")
    private String remark;
@@ -73,6 +79,9 @@
    @NoArgsConstructor
    @AllArgsConstructor
    public static class BusinessProduct {
        @Schema(description = "产品关联编号", example = "5")
        private Long id;
        @Schema(description = "MDM ç‰©æ–™ç¼–号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
        @NotNull(message = "物料编号不能为空")
@@ -94,6 +103,9 @@
        @NotNull(message = "数量不能为空")
        private BigDecimal count;
        @Schema(description = "总计价格(businessPrice * count)", example = "12000.00")
        private BigDecimal totalPrice;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java
@@ -134,8 +134,9 @@
        updateReqVO.setOwnerUserId(null).setStatusTypeId(null); // ä¸å…è®¸æ›´æ–°çš„字段
        // 1.1 æ ¡éªŒå­˜åœ¨
        CrmBusinessDO oldBusiness = validateBusinessExists(updateReqVO.getId());
        // 1.2 æ ¡éªŒäº§å“é¡¹çš„æœ‰æ•ˆæ€§
        List<CrmBusinessProductDO> businessProducts = validateBusinessProducts(updateReqVO.getProducts());
        // 1.2 æ ¡éªŒäº§å“é¡¹çš„æœ‰æ•ˆæ€§ï¼ˆä¼ å…¥æ—§äº§å“åˆ—表用于合并 null å­—段)
        List<CrmBusinessProductDO> oldProducts = businessProductMapper.selectListByBusinessId(updateReqVO.getId());
        List<CrmBusinessProductDO> businessProducts = validateBusinessProducts(updateReqVO.getProducts(), oldProducts);
        // 1.3 æ ¡éªŒå…³è”字段
        validateRelationDataExists(updateReqVO);
@@ -147,7 +148,7 @@
        updateBusinessProduct(updateObj.getId(), businessProducts);
        // 3. è®°å½•操作日志上下文
        updateReqVO.setOwnerUserId(oldBusiness.getOwnerUserId()); // é¿å…æ“ä½œæ—¥å¿—出现“删除负责人”的情况
        updateReqVO.setOwnerUserId(oldBusiness.getOwnerUserId()); // é¿å…æ“ä½œæ—¥å¿—出现"删除负责人"的情况
        LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldBusiness, CrmBusinessSaveReqVO.class));
        LogRecordContext.putVariable("businessName", oldBusiness.getName());
    }
@@ -216,6 +217,18 @@
    }
    private List<CrmBusinessProductDO> validateBusinessProducts(List<CrmBusinessSaveReqVO.BusinessProduct> list) {
        return validateBusinessProducts(list, Collections.emptyList());
    }
    /**
     * æ ¡éªŒäº§å“é¡¹çš„æœ‰æ•ˆæ€§ï¼ˆæ›´æ–°åœºæ™¯åˆå¹¶æ—§æ•°æ®ï¼‰
     *
     * @param list       å‰ç«¯ä¼ å…¥çš„产品列表
     * @param oldProducts å·²æœ‰äº§å“åˆ—表(用于合并 null å­—段)
     * @return å¤„理后的产品 DO åˆ—表
     */
    private List<CrmBusinessProductDO> validateBusinessProducts(List<CrmBusinessSaveReqVO.BusinessProduct> list,
                                                                  List<CrmBusinessProductDO> oldProducts) {
        // 1. æ ¡éªŒç‰©æ–™å­˜åœ¨
        Set<Long> itemIds = convertSet(list, CrmBusinessSaveReqVO.BusinessProduct::getItemId);
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(itemIds);
@@ -224,15 +237,47 @@
                throw exception(PRODUCT_NOT_EXISTS);
            }
        }
        // 2. è½¬åŒ–为 CrmBusinessProductDO åˆ—表
        return convertList(list, o -> BeanUtils.toBean(o, CrmBusinessProductDO.class,
                item -> item.setTotalPrice(MoneyUtils.priceMultiply(item.getBusinessPrice(), item.getCount()))));
        // 2. æž„建旧产品 Map(按 id ç´¢å¼•)
        Map<Long, CrmBusinessProductDO> oldProductMap = convertMap(oldProducts, CrmBusinessProductDO::getId);
        // 3. è½¬åŒ–为 CrmBusinessProductDO åˆ—表(合并旧数据)
        return convertList(list, o -> {
            CrmBusinessProductDO product = BeanUtils.toBean(o, CrmBusinessProductDO.class);
            // æ›´æ–°åœºæ™¯ï¼šå¦‚果产品已有 id ä¸”有旧数据,则合并 null å­—段
            if (o.getId() != null && oldProductMap.containsKey(o.getId())) {
                CrmBusinessProductDO old = oldProductMap.get(o.getId());
                if (product.getBusinessPrice() == null) {
                    product.setBusinessPrice(old.getBusinessPrice());
                }
                if (product.getItemPrice() == null) {
                    product.setItemPrice(old.getItemPrice());
                }
                if (product.getCount() == null) {
                    product.setCount(old.getCount());
                }
                if (product.getItemUnitId() == null) {
                    product.setItemUnitId(old.getItemUnitId());
                }
            }
            // ä¼˜å…ˆä½¿ç”¨å‰ç«¯ä¼ å…¥çš„ totalPrice,否则通过 businessPrice * count è®¡ç®—
            if (o.getTotalPrice() != null) {
                product.setTotalPrice(o.getTotalPrice());
            } else {
                product.setTotalPrice(MoneyUtils.priceMultiply(product.getBusinessPrice(), product.getCount()));
            }
            return product;
        });
    }
    private void calculateTotalPrice(CrmBusinessDO business, List<CrmBusinessProductDO> businessProducts) {
        business.setTotalProductPrice(getSumValue(businessProducts, CrmBusinessProductDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO));
        BigDecimal discountPrice = MoneyUtils.priceMultiplyPercent(business.getTotalProductPrice(), business.getDiscountPercent());
        business.setTotalPrice(business.getTotalProductPrice().subtract(discountPrice));
        // totalProductPrice:优先使用前端传入值,否则从产品汇总
        if (business.getTotalProductPrice() == null) {
            business.setTotalProductPrice(getSumValue(businessProducts, CrmBusinessProductDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO));
        }
        // totalPrice:优先使用前端传入值,否则按折扣计算
        if (business.getTotalPrice() == null) {
            BigDecimal discountPrice = MoneyUtils.priceMultiplyPercent(business.getTotalProductPrice(), business.getDiscountPercent());
            business.setTotalPrice(business.getTotalProductPrice().subtract(discountPrice));
        }
    }
    @Override
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/controller/admin/purchase/vo/request/ErpPurchaseRequestRespVO.java
@@ -1,5 +1,8 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request;
import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -11,75 +14,95 @@
@Schema(description = "管理后台 - ERP é‡‡è´­ç”³è¯· Response VO")
@Data
@ExcelIgnoreUnannotated
public class ErpPurchaseRequestRespVO {
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @ExcelIgnore
    private Long id;
    @Schema(description = "采购申请单号", requiredMode = Schema.RequiredMode.REQUIRED, example = "CGSQ001")
    @ExcelProperty("采购申请单号")
    private String no;
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    @ExcelProperty(value = "状态", converter = cn.iocoder.yudao.module.erp.convert.ErpPurchaseRequestStatusConvert.class)
    private Integer status;
    @Schema(description = "工作流编号", example = "1043")
    private String processInstanceId;
    @Schema(description = "申请人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @ExcelProperty("申请人编号")
    private Long requestUserId;
    @Schema(description = "申请人姓名", example = "张三")
    @ExcelProperty("申请人姓名")
    private String requestUserName;
    @Schema(description = "申请部门编号", example = "1")
    @ExcelProperty("申请部门编号")
    private Long requestDeptId;
    @Schema(description = "申请部门名称", example = "生产部")
    @ExcelProperty("申请部门名称")
    private String requestDeptName;
    @Schema(description = "申请时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("申请时间")
    private LocalDateTime requestTime;
    @Schema(description = "申请理由", example = "生产需要")
    @ExcelProperty("申请理由")
    private String requestReason;
    @Schema(description = "供应商编号", example = "1")
    @ExcelProperty("供应商编号")
    private Long supplierId;
    @Schema(description = "供应商名称", example = "供应商A")
    @ExcelProperty("供应商名称")
    private String supplierName;
    @Schema(description = "定金金额", example = "1000.00")
    @ExcelProperty("定金金额")
    private BigDecimal depositPrice;
    @Schema(description = "合计数量", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("合计数量")
    private BigDecimal totalCount;
    @Schema(description = "合计价格", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("合计价格")
    private BigDecimal totalPrice;
    @Schema(description = "产品名称列表", example = "产品A,产品B")
    @ExcelProperty("产品名称列表")
    private String productNames;
    @Schema(description = "生成的采购订单编号", example = "1")
    @ExcelIgnore
    private Long orderId;
    @Schema(description = "生成的采购订单号", example = "CGDD001")
    @ExcelProperty("采购订单号")
    private String orderNo;
    @Schema(description = "入库状态(0:未入库 1:部分入库 2:全部入库)", example = "0")
    @Schema(description = "入库状态", example = "0")
    @ExcelProperty(value = "入库状态", converter = cn.iocoder.yudao.module.erp.convert.ErpPurchaseOrderInStatusConvert.class)
    private Integer inStatus;
    @Schema(description = "备注", example = "备注")
    @ExcelProperty("备注")
    private String remark;
    @Schema(description = "附件列表")
    @ExcelIgnore
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "创建时间")
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
    @Schema(description = "申请明细列表")
    @ExcelIgnore
    private java.util.List<Item> items;
    @Schema(description = "申请明细项")
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/ErpSaleOrderController.java
@@ -10,6 +10,7 @@
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderItemRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderSaveReqVO;
@@ -119,6 +120,20 @@
        return success(true);
    }
    @GetMapping("/item/list-by-order-id")
    @Operation(summary = "查询销售订单的物料明细列表(用于发货通知单创建)")
    @Parameter(name = "orderId", description = "销售订单编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('erp:sale-order:query')")
    public CommonResult<List<ErpSaleOrderItemRespVO>> getSaleOrderItemListByOrderId(@RequestParam("orderId") Long orderId) {
        List<ErpSaleOrderItemDO> itemList = saleOrderService.getSaleOrderItemListByOrderId(orderId);
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(
                convertSet(itemList, ErpSaleOrderItemDO::getProductId));
        return success(BeanUtils.toBean(itemList, ErpSaleOrderItemRespVO.class, vo -> {
            MapUtils.findAndThen(itemMap, vo.getProductId(), item ->
                    vo.setProductName(item.getName()).setProductBarCode(item.getBarCode()));
        }));
    }
    @GetMapping("/get")
    @Operation(summary = "获得销售订单")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderItemRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
 * ERP é”€å”®è®¢å•项 Response VO(供前端发货通知单创建时查询订单明细)
 */
@Schema(description = "管理后台 - ERP é”€å”®è®¢å•项 Response VO")
@Data
public class ErpSaleOrderItemRespVO {
    @Schema(description = "编号(销售订单行ID)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long id;
    @Schema(description = "销售订单编号", example = "1")
    private Long orderId;
    @Schema(description = "物料编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long productId;
    @Schema(description = "物料名称", example = "产品A")
    private String productName;
    @Schema(description = "物料条码", example = "12345678")
    private String productBarCode;
    @Schema(description = "计量单位编号", example = "1")
    private Long productUnitId;
    @Schema(description = "计量单位名称", example = "个")
    private String productUnitName;
    @Schema(description = "产品单价", example = "100.00")
    private BigDecimal productPrice;
    @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
    private BigDecimal count;
    @Schema(description = "总价", example = "1000.00")
    private BigDecimal totalPrice;
    @Schema(description = "备注", example = "备注")
    private String remark;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/convert/ErpPurchaseOrderInStatusConvert.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.erp.convert;
import cn.idev.excel.converters.Converter;
import cn.idev.excel.enums.CellDataTypeEnum;
import cn.idev.excel.metadata.GlobalConfiguration;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseOrderInStatusEnum;
/**
 * ERP é‡‡è´­å…¥åº“状态 Excel è½¬æ¢å™¨
 * <p>
 * å°† Integer å…¥åº“状态码转换为中文名称
 *
 * @author èŠ‹é“æºç 
 */
public class ErpPurchaseOrderInStatusConvert implements Converter<Integer> {
    @Override
    public Class<?> supportJavaTypeKey() {
        return Integer.class;
    }
    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        return CellDataTypeEnum.STRING;
    }
    @Override
    public WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty,
                                               GlobalConfiguration globalConfiguration) {
        if (value == null) {
            return new WriteCellData<>("");
        }
        ErpPurchaseOrderInStatusEnum statusEnum = ErpPurchaseOrderInStatusEnum.fromStatus(value);
        return new WriteCellData<>(statusEnum != null ? statusEnum.getName() : String.valueOf(value));
    }
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty,
                                     GlobalConfiguration globalConfiguration) {
        String label = cellData.getStringValue();
        ErpPurchaseOrderInStatusEnum statusEnum = ErpPurchaseOrderInStatusEnum.fromName(label);
        return statusEnum != null ? statusEnum.getStatus() : null;
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/convert/ErpPurchaseRequestStatusConvert.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.erp.convert;
import cn.idev.excel.converters.Converter;
import cn.idev.excel.enums.CellDataTypeEnum;
import cn.idev.excel.metadata.GlobalConfiguration;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseRequestStatusEnum;
/**
 * ERP é‡‡è´­ç”³è¯·çŠ¶æ€ Excel è½¬æ¢å™¨
 * <p>
 * å°† Integer çŠ¶æ€ç è½¬æ¢ä¸ºä¸­æ–‡åç§°ï¼Œç”¨äºŽ Excel å¯¼å‡º
 *
 * @author èŠ‹é“æºç 
 */
public class ErpPurchaseRequestStatusConvert implements Converter<Integer> {
    @Override
    public Class<?> supportJavaTypeKey() {
        return Integer.class;
    }
    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        return CellDataTypeEnum.STRING;
    }
    @Override
    public WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty,
                                               GlobalConfiguration globalConfiguration) {
        if (value == null) {
            return new WriteCellData<>("");
        }
        ErpPurchaseRequestStatusEnum statusEnum = ErpPurchaseRequestStatusEnum.fromStatus(value);
        return new WriteCellData<>(statusEnum != null ? statusEnum.getName() : String.valueOf(value));
    }
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty,
                                     GlobalConfiguration globalConfiguration) {
        String label = cellData.getStringValue();
        ErpPurchaseRequestStatusEnum statusEnum = ErpPurchaseRequestStatusEnum.fromName(label);
        return statusEnum != null ? statusEnum.getStatus() : null;
    }
}
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/ErpPurchaseOrderInStatusEnum.java
@@ -30,4 +30,24 @@
        return ARRAYS;
    }
    /**
     * æ ¹æ®çŠ¶æ€ç èŽ·å–æžšä¸¾
     */
    public static ErpPurchaseOrderInStatusEnum fromStatus(Integer status) {
        return Arrays.stream(values())
                .filter(e -> e.getStatus().equals(status))
                .findFirst()
                .orElse(null);
    }
    /**
     * æ ¹æ®åç§°èŽ·å–æžšä¸¾
     */
    public static ErpPurchaseOrderInStatusEnum fromName(String name) {
        return Arrays.stream(values())
                .filter(e -> e.getName().equals(name))
                .findFirst()
                .orElse(null);
    }
}
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/enums/ErpPurchaseRequestStatusEnum.java
@@ -35,4 +35,24 @@
        return ARRAYS;
    }
    /**
     * æ ¹æ®çŠ¶æ€ç èŽ·å–æžšä¸¾
     */
    public static ErpPurchaseRequestStatusEnum fromStatus(Integer status) {
        return Arrays.stream(values())
                .filter(e -> e.getStatus().equals(status))
                .findFirst()
                .orElse(null);
    }
    /**
     * æ ¹æ®åç§°èŽ·å–æžšä¸¾
     */
    public static ErpPurchaseRequestStatusEnum fromName(String name) {
        return Arrays.stream(values())
                .filter(e -> e.getName().equals(name))
                .findFirst()
                .orElse(null);
    }
}
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-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeSaveReqVO.java
@@ -94,14 +94,14 @@
    @Schema(description = "教育经历列表")
    @Valid
    private List<HrmEmployeeEducationSaveReqVO> educationList;
    private List<HrmEmployeeEducationSaveReqVO> educations;
    @Schema(description = "工作经历列表")
    @Valid
    private List<HrmEmployeeWorkHistorySaveReqVO> workHistoryList;
    private List<HrmEmployeeWorkHistorySaveReqVO> workHistories;
    @Schema(description = "紧急联系人列表")
    @Valid
    private List<HrmEmployeeEmergencyContactSaveReqVO> emergencyContactList;
    private List<HrmEmployeeEmergencyContactSaveReqVO> emergencyContacts;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeEducationMapper.java
@@ -19,4 +19,8 @@
        return selectList(HrmEmployeeEducationDO::getEmployeeId, employeeId);
    }
    default void deleteByEmployeeId(Long employeeId) {
        delete(HrmEmployeeEducationDO::getEmployeeId, employeeId);
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeEmergencyContactMapper.java
@@ -21,4 +21,8 @@
                .orderByAsc(HrmEmployeeEmergencyContactDO::getSort));
    }
    default void deleteByEmployeeId(Long employeeId) {
        delete(HrmEmployeeEmergencyContactDO::getEmployeeId, employeeId);
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeWorkHistoryMapper.java
@@ -19,4 +19,8 @@
        return selectList(HrmEmployeeWorkHistoryDO::getEmployeeId, employeeId);
    }
    default void deleteByEmployeeId(Long employeeId) {
        delete(HrmEmployeeWorkHistoryDO::getEmployeeId, employeeId);
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -113,24 +113,24 @@
        employeeMapper.insert(employee);
        Long employeeId = employee.getId();
        // 7. åŒæ­¥æ·»åŠ æ•™è‚²ç»åŽ†
        if (CollUtil.isNotEmpty(createReqVO.getEducationList())) {
            createReqVO.getEducationList().forEach(educationVO -> {
        if (CollUtil.isNotEmpty(createReqVO.getEducations())) {
            createReqVO.getEducations().forEach(educationVO -> {
                HrmEmployeeEducationDO education = BeanUtils.toBean(educationVO, HrmEmployeeEducationDO.class);
                education.setEmployeeId(employeeId);
                educationMapper.insert(education);
            });
        }
        // 8. åŒæ­¥æ·»åŠ å·¥ä½œç»åŽ†
        if (CollUtil.isNotEmpty(createReqVO.getWorkHistoryList())) {
            createReqVO.getWorkHistoryList().forEach(workHistoryVO -> {
        if (CollUtil.isNotEmpty(createReqVO.getWorkHistories())) {
            createReqVO.getWorkHistories().forEach(workHistoryVO -> {
                HrmEmployeeWorkHistoryDO workHistory = BeanUtils.toBean(workHistoryVO, HrmEmployeeWorkHistoryDO.class);
                workHistory.setEmployeeId(employeeId);
                workHistoryMapper.insert(workHistory);
            });
        }
        // 9. åŒæ­¥æ·»åŠ ç´§æ€¥è”ç³»äºº
        if (CollUtil.isNotEmpty(createReqVO.getEmergencyContactList())) {
            createReqVO.getEmergencyContactList().forEach(emergencyContactVO -> {
        if (CollUtil.isNotEmpty(createReqVO.getEmergencyContacts())) {
            createReqVO.getEmergencyContacts().forEach(emergencyContactVO -> {
                HrmEmployeeEmergencyContactDO emergencyContact = BeanUtils.toBean(emergencyContactVO, HrmEmployeeEmergencyContactDO.class);
                emergencyContact.setEmployeeId(employeeId);
                emergencyContactMapper.insert(emergencyContact);
@@ -148,6 +148,7 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateEmployee(HrmEmployeeSaveReqVO updateReqVO) {
        // æ ¡éªŒå­˜åœ¨
        HrmEmployeeDO employee = validateEmployeeExists(updateReqVO.getId());
@@ -160,20 +161,69 @@
                && employeeMapper.selectByPhone(updateReqVO.getPhone()) != null) {
            throw exception(EMPLOYEE_PHONE_EXISTS);
        }
        // æ›´æ–°
        // æ›´æ–°å‘˜å·¥ä¸»è¡¨
        HrmEmployeeDO updateObj = BeanUtils.toBean(updateReqVO, HrmEmployeeDO.class);
        // roleIds å­˜å‚¨ä¸º JSON
        if (CollUtil.isNotEmpty(updateReqVO.getRoleIds())) {
            updateObj.setRoleIds(JSONUtil.toJsonStr(updateReqVO.getRoleIds()));
        }
        employeeMapper.updateById(updateObj);
        Long employeeId = updateReqVO.getId();
        // åŒæ­¥æ•™è‚²ç»åކ
        syncEducationList(employeeId, updateReqVO.getEducations());
        // åŒæ­¥å·¥ä½œç»åކ
        syncWorkHistoryList(employeeId, updateReqVO.getWorkHistories());
        // åŒæ­¥ç´§æ€¥è”系人
        syncEmergencyContactList(employeeId, updateReqVO.getEmergencyContacts());
    }
    private void syncEducationList(Long employeeId, List<HrmEmployeeEducationSaveReqVO> newList) {
        educationMapper.deleteByEmployeeId(employeeId);
        if (CollUtil.isNotEmpty(newList)) {
            newList.forEach(vo -> {
                HrmEmployeeEducationDO education = BeanUtils.toBean(vo, HrmEmployeeEducationDO.class);
                education.setId(null);
                education.setEmployeeId(employeeId);
                educationMapper.insert(education);
            });
        }
    }
    private void syncWorkHistoryList(Long employeeId, List<HrmEmployeeWorkHistorySaveReqVO> newList) {
        workHistoryMapper.deleteByEmployeeId(employeeId);
        if (CollUtil.isNotEmpty(newList)) {
            newList.forEach(vo -> {
                HrmEmployeeWorkHistoryDO workHistory = BeanUtils.toBean(vo, HrmEmployeeWorkHistoryDO.class);
                workHistory.setId(null);
                workHistory.setEmployeeId(employeeId);
                workHistoryMapper.insert(workHistory);
            });
        }
    }
    private void syncEmergencyContactList(Long employeeId, List<HrmEmployeeEmergencyContactSaveReqVO> newList) {
        emergencyContactMapper.deleteByEmployeeId(employeeId);
        if (CollUtil.isNotEmpty(newList)) {
            newList.forEach(vo -> {
                HrmEmployeeEmergencyContactDO emergencyContact = BeanUtils.toBean(vo, HrmEmployeeEmergencyContactDO.class);
                emergencyContact.setId(null);
                emergencyContact.setEmployeeId(employeeId);
                emergencyContactMapper.insert(emergencyContact);
            });
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteEmployee(Long id) {
        // æ ¡éªŒå­˜åœ¨
        validateEmployeeExists(id);
        // åˆ é™¤
        // çº§è”删除子表
        educationMapper.deleteByEmployeeId(id);
        workHistoryMapper.deleteByEmployeeId(id);
        emergencyContactMapper.deleteByEmployeeId(id);
        // åˆ é™¤å‘˜å·¥
        employeeMapper.deleteById(id);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/vo/MesCalPlanRespVO.java
@@ -52,6 +52,10 @@
    @ExcelProperty("状态")
    private Integer status;
    @Schema(description = "是否排除周末", example = "true")
    @ExcelProperty("是否排除周末")
    private Boolean excludeWeekend;
    @Schema(description = "备注")
    @ExcelProperty("备注")
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/vo/MesCalPlanSaveReqVO.java
@@ -47,7 +47,10 @@
    @Schema(description = "状态", example = "0")
    private Integer status;
    @Schema(description = "是否排除周末", example = "true")
    private Boolean excludeWeekend;
    @Schema(description = "备注")
    private String remark;
    }
}
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/controller/admin/wm/salesnotice/vo/line/MesWmSalesNoticeLineSaveReqVO.java
@@ -18,6 +18,9 @@
    @NotNull(message = "发货通知单编号不能为空")
    private Long noticeId;
    @Schema(description = "销售订单明细ID", example = "1")
    private Long saleOrderItemId;
    @Schema(description = "物料编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "物料编号不能为空")
    private Long itemId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/cal/plan/MesCalPlanDO.java
@@ -78,6 +78,14 @@
     */
    private Integer status;
    /**
     * æ˜¯å¦æŽ’除周末
     *
     * true:周六日不生成排班记录(默认)
     * false:周六日正常排班(如注塑车间需要连续生产)
     */
    private Boolean excludeWeekend;
    /**
     * å¤‡æ³¨
     */
    private String remark;
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/batch/MesWmBatchMapper.java
@@ -72,12 +72,12 @@
                .betweenIfPresent(MesWmBatchDO::getExpireDate, reqVO.getExpireDate())
                .betweenIfPresent(MesWmBatchDO::getReceiptDate, reqVO.getReceiptDate())
                .orderByDesc(MesWmBatchDO::getId);
        if (permittedClientIds != null) {
            if (CollUtil.isEmpty(permittedClientIds)) {
                return PageResult.empty();
            }
            wrapper.in(MesWmBatchDO::getClientId, permittedClientIds);
        }
//        if (permittedClientIds != null) {
//            if (CollUtil.isEmpty(permittedClientIds)) {
//                return PageResult.empty();
//            }
//            wrapper.in(MesWmBatchDO::getClientId, permittedClientIds);
//        }
        return selectPage(reqVO, wrapper);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstock/MesWmMaterialStockMapper.java
@@ -137,12 +137,13 @@
        } else {
            wrapper.isNull(MesWmMaterialStockDO::getBatchId);
        }
        // vendorId=null æ—¶ç²¾ç¡®åŒ¹é… is null
        // vendorId=null æ—¶ä¸é™åˆ¶ï¼ŒåŒ¹é…ä»»æ„ä¾›åº”商的库存
        // ï¼ˆé¿å…å› ä¾›åº”商维度拆分,OUT äº‹åŠ¡æ‰¾ä¸åˆ°çœŸæ­£æœ‰åº“å­˜çš„è®°å½•è€Œæ‰£é”™åˆ° vendor_id IS NULL çš„记录)
        if (vendorId != null) {
            wrapper.eq(MesWmMaterialStockDO::getVendorId, vendorId);
        } else {
            wrapper.isNull(MesWmMaterialStockDO::getVendorId);
        }
        // å¤šè®°å½•时优先取库存量最多的(避免扣到零库存或负库存的记录)
        wrapper.last("ORDER BY quantity DESC LIMIT 1");
        return selectOne(wrapper);
    }
@@ -183,4 +184,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/cal/team/MesCalTeamShiftServiceImpl.java
@@ -1,13 +1,16 @@
package cn.iocoder.yudao.module.mes.service.cal.team;
import cn.iocoder.yudao.module.mes.controller.admin.cal.team.vo.shift.MesCalTeamShiftListReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.holiday.MesCalHolidayDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanShiftDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanTeamDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamShiftDO;
import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamShiftMapper;
import cn.iocoder.yudao.module.mes.enums.cal.MesCalHolidayTypeEnum;
import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftMethodEnum;
import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftTypeEnum;
import cn.iocoder.yudao.module.mes.service.cal.holiday.MesCalHolidayService;
import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService;
import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanShiftService;
import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService;
@@ -18,11 +21,14 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH;
@@ -49,6 +55,9 @@
    @Resource
    @Lazy
    private MesCalPlanTeamService planTeamService;
    @Resource
    @Lazy
    private MesCalHolidayService holidayService;
    @Transactional(rollbackFor = Exception.class)
    @Override
@@ -69,23 +78,56 @@
            throw exception(CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH);
        }
        // 2. è®¡ç®—日期跨度,遍历每天生成排班记录,最后统一写入数据库
        // 1.5 åŠ è½½æ—¥æœŸåŒºé—´å†…çš„å‡æ—¥æ•°æ®ï¼Œæž„å»º Map<日期, ç±»åž‹>
        Map<LocalDate, Integer> holidayMap = holidayService.getHolidayList(plan.getStartDate(), plan.getEndDate())
                .stream().collect(Collectors.toMap(h -> h.getDay().toLocalDate(), MesCalHolidayDO::getType));
        // 2. è®¡ç®—日期跨度,逐天判断工作日,生成排班记录
        LocalDate startDate = plan.getStartDate().toLocalDate();
        LocalDate endDate = plan.getEndDate().toLocalDate();
        long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
        boolean excludeWeekend = plan.getExcludeWeekend() != null && plan.getExcludeWeekend();
        List<MesCalTeamShiftDO> allRecords = new ArrayList<>();
        int shiftIndex = 0;
        int workdayOffset = 0; // ä»…工作日递增,用于倒班轮换计数
        for (int i = 0; i < days; i++) {
            LocalDate currentDate = startDate.plusDays(i);
            // 2.1 è·³è¿‡éžå·¥ä½œæ—¥
            if (!isWorkday(currentDate, holidayMap, excludeWeekend)) {
                continue;
            }
            LocalDateTime currentDay = currentDate.atStartOfDay();
            // 2.2.1 æ ¹æ®å€’班方式计算 shiftIndex
            shiftIndex = calculateShiftIndex(shiftIndex, i, currentDate, startDate, plan);
            // 2.2.1 æ ¹æ®å€’班方式计算 shiftIndex(以工作日偏移为准)
            shiftIndex = calculateShiftIndex(shiftIndex, workdayOffset, currentDate, startDate, plan);
            // 2.2.2 æ ¹æ®è½®ç­æ–¹å¼ç”ŸæˆæŽ’班记录,收集到列表
            buildRecordsForDay(allRecords, planId, currentDay, shiftIndex, plan.getShiftType(), teams, shifts);
            workdayOffset++;
        }
        // 3. æ‰¹é‡æ’入排班记录
        teamShiftMapper.insertBatch(allRecords);
    }
    /**
     * åˆ¤æ–­æŒ‡å®šæ—¥æœŸæ˜¯å¦ä¸ºå·¥ä½œæ—¥
     *
     * ä¼˜å…ˆçº§ï¼šholiday è¡¨ > å‘¨æœ«æŽ’除开关 > é»˜è®¤å·¥ä½œæ—¥
     */
    private boolean isWorkday(LocalDate date, Map<LocalDate, Integer> holidayMap, boolean excludeWeekend) {
        // 1. holiday è¡¨ä¼˜å…ˆï¼šæ˜¾å¼æ ‡è®°çš„æ—¥æœŸä»¥æ ‡è®°ä¸ºå‡†
        Integer holidayType = holidayMap.get(date);
        if (holidayType != null) {
            return MesCalHolidayTypeEnum.WORKDAY.getType().equals(holidayType);
        }
        // 2. è‹¥å¼€å¯äº†æŽ’除周末,周六日默认为休息日
        if (excludeWeekend) {
            DayOfWeek dow = date.getDayOfWeek();
            if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) {
                return false;
            }
        }
        // 3. é»˜è®¤å·¥ä½œæ—¥
        return true;
    }
    /**
@@ -96,6 +138,10 @@
        if (dayOffset == 0) {
            return shiftIndex; // ç¬¬ä¸€å¤©ä¸é€’增
        }
        // å•白班不需要倒班,shiftMethod ä¸º null,直接返回
        if (plan.getShiftMethod() == null) {
            return shiftIndex;
        }
        if (MesCalShiftMethodEnum.QUARTER.getMethod().equals(plan.getShiftMethod())) {
            // æŒ‰å­£åº¦è½®ç­ï¼šåˆ°äº†æ–°å­£åº¦çš„第一天
            LocalDate quarterStart = LocalDateTimeUtils.getQuarterStart(currentDate);
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-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/batch/MesWmBatchServiceImpl.java
@@ -97,122 +97,107 @@
        // 2. æ ¹æ®é…ç½®æ ¡éªŒå¿…填参数并清空不需要的参数
        MesWmBatchDO batch = MesWmBatchDO.builder().itemId(reqVO.getItemId()).build();
        if (config != null) {
            // ========== å¿…填校验(flag=true è¡¨ç¤ºè¯¥å­—段为必填) ==========
            // ç”Ÿäº§æ—¥æœŸ
            if (Boolean.TRUE.equals(config.getProduceDateFlag())) {
                if (ObjUtil.isNull(reqVO.getProduceDate())) {
                    throw exception(WM_BATCH_PRODUCE_DATE_REQUIRED);
                }
                batch.setProduceDate(reqVO.getProduceDate());
            if (Boolean.TRUE.equals(config.getProduceDateFlag()) && ObjUtil.isNull(reqVO.getProduceDate())) {
                throw exception(WM_BATCH_PRODUCE_DATE_REQUIRED);
            }
            // å…¥åº“日期
            if (Boolean.TRUE.equals(config.getReceiptDateFlag())) {
                if (ObjUtil.isNull(reqVO.getReceiptDate())) {
                    throw exception(WM_BATCH_RECEIPT_DATE_REQUIRED);
                }
                batch.setReceiptDate(reqVO.getReceiptDate());
            if (Boolean.TRUE.equals(config.getReceiptDateFlag()) && ObjUtil.isNull(reqVO.getReceiptDate())) {
                throw exception(WM_BATCH_RECEIPT_DATE_REQUIRED);
            }
            // æœ‰æ•ˆæœŸ
            if (Boolean.TRUE.equals(config.getExpireDateFlag())) {
                if (ObjUtil.isNull(reqVO.getExpireDate())) {
                    throw exception(WM_BATCH_EXPIRE_DATE_REQUIRED);
                }
                batch.setExpireDate(reqVO.getExpireDate());
            if (Boolean.TRUE.equals(config.getExpireDateFlag()) && ObjUtil.isNull(reqVO.getExpireDate())) {
                throw exception(WM_BATCH_EXPIRE_DATE_REQUIRED);
            }
            // ä¾›åº”商
            if (Boolean.TRUE.equals(config.getVendorFlag())) {
                if (ObjUtil.isNull(reqVO.getVendorId())) {
                    throw exception(WM_BATCH_VENDOR_REQUIRED);
                }
                batch.setVendorId(reqVO.getVendorId());
            if (Boolean.TRUE.equals(config.getVendorFlag()) && ObjUtil.isNull(reqVO.getVendorId())) {
                throw exception(WM_BATCH_VENDOR_REQUIRED);
            }
            // å®¢æˆ·
            if (Boolean.TRUE.equals(config.getClientFlag())) {
                if (ObjUtil.isNull(reqVO.getClientId())) {
                    throw exception(WM_BATCH_CLIENT_REQUIRED);
                }
                batch.setClientId(reqVO.getClientId());
            if (Boolean.TRUE.equals(config.getClientFlag()) && ObjUtil.isNull(reqVO.getClientId())) {
                throw exception(WM_BATCH_CLIENT_REQUIRED);
            }
            // é‡‡è´­è®¢å•编号
            if (Boolean.TRUE.equals(config.getPurchaseOrderCodeFlag())) {
                if (ObjUtil.isNull(reqVO.getPurchaseOrderCode())) {
                    throw exception(WM_BATCH_PURCHASE_ORDER_CODE_REQUIRED);
                }
                batch.setPurchaseOrderCode(reqVO.getPurchaseOrderCode());
            if (Boolean.TRUE.equals(config.getPurchaseOrderCodeFlag()) && ObjUtil.isNull(reqVO.getPurchaseOrderCode())) {
                throw exception(WM_BATCH_PURCHASE_ORDER_CODE_REQUIRED);
            }
            // é”€å”®è®¢å•编号
            if (Boolean.TRUE.equals(config.getSalesOrderCodeFlag())) {
                if (ObjUtil.isNull(reqVO.getSalesOrderCode())) {
                    throw exception(WM_BATCH_CUSTOMER_ORDER_CODE_REQUIRED);
                }
                batch.setSalesOrderCode(reqVO.getSalesOrderCode());
            if (Boolean.TRUE.equals(config.getSalesOrderCodeFlag()) && ObjUtil.isNull(reqVO.getSalesOrderCode())) {
                throw exception(WM_BATCH_CUSTOMER_ORDER_CODE_REQUIRED);
            }
            // ç”Ÿäº§å·¥å•
            if (Boolean.TRUE.equals(config.getWorkOrderFlag())) {
                if (ObjUtil.isNull(reqVO.getWorkOrderId())) {
                    throw exception(WM_BATCH_WORK_ORDER_REQUIRED);
                }
                batch.setWorkOrderId(reqVO.getWorkOrderId());
            if (Boolean.TRUE.equals(config.getWorkOrderFlag()) && ObjUtil.isNull(reqVO.getWorkOrderId())) {
                throw exception(WM_BATCH_WORK_ORDER_REQUIRED);
            }
            // ç”Ÿäº§ä»»åŠ¡
            if (Boolean.TRUE.equals(config.getTaskFlag())) {
                if (ObjUtil.isNull(reqVO.getTaskId())) {
                    throw exception(WM_BATCH_TASK_REQUIRED);
                }
                batch.setTaskId(reqVO.getTaskId());
            if (Boolean.TRUE.equals(config.getTaskFlag()) && ObjUtil.isNull(reqVO.getTaskId())) {
                throw exception(WM_BATCH_TASK_REQUIRED);
            }
            // å·¥ä½œç«™
            if (Boolean.TRUE.equals(config.getWorkstationFlag())) {
                if (ObjUtil.isNull(reqVO.getWorkstationId())) {
                    throw exception(WM_BATCH_WORKSTATION_REQUIRED);
                }
                batch.setWorkstationId(reqVO.getWorkstationId());
            if (Boolean.TRUE.equals(config.getWorkstationFlag()) && ObjUtil.isNull(reqVO.getWorkstationId())) {
                throw exception(WM_BATCH_WORKSTATION_REQUIRED);
            }
            // å·¥å…·
            if (Boolean.TRUE.equals(config.getToolFlag())) {
                if (ObjUtil.isNull(reqVO.getToolId())) {
                    throw exception(WM_BATCH_TOOL_REQUIRED);
                }
                batch.setToolId(reqVO.getToolId());
            if (Boolean.TRUE.equals(config.getToolFlag()) && ObjUtil.isNull(reqVO.getToolId())) {
                throw exception(WM_BATCH_TOOL_REQUIRED);
            }
            // æ¨¡å…·
            if (Boolean.TRUE.equals(config.getMoldFlag())) {
                if (ObjUtil.isNull(reqVO.getMoldId())) {
                    throw exception(WM_BATCH_MOLD_REQUIRED);
                }
                batch.setMoldId(reqVO.getMoldId());
            if (Boolean.TRUE.equals(config.getMoldFlag()) && ObjUtil.isNull(reqVO.getMoldId())) {
                throw exception(WM_BATCH_MOLD_REQUIRED);
            }
            // ç”Ÿäº§æ‰¹å·
            if (Boolean.TRUE.equals(config.getLotNumberFlag())) {
                if (ObjUtil.isNull(reqVO.getLotNumber())) {
                    throw exception(WM_BATCH_LOT_NUMBER_REQUIRED);
                }
                batch.setLotNumber(reqVO.getLotNumber());
            if (Boolean.TRUE.equals(config.getLotNumberFlag()) && ObjUtil.isNull(reqVO.getLotNumber())) {
                throw exception(WM_BATCH_LOT_NUMBER_REQUIRED);
            }
            // è´¨é‡çŠ¶æ€
            if (Boolean.TRUE.equals(config.getQualityStatusFlag())) {
                if (ObjUtil.isNull(reqVO.getQualityStatus())) {
                    throw exception(WM_BATCH_QUALITY_STATUS_REQUIRED);
                }
                batch.setQualityStatus(reqVO.getQualityStatus());
            if (Boolean.TRUE.equals(config.getQualityStatusFlag()) && ObjUtil.isNull(reqVO.getQualityStatus())) {
                throw exception(WM_BATCH_QUALITY_STATUS_REQUIRED);
            }
        }
        // æ— æ‰¹æ¬¡é…ç½®æ—¶ï¼Œä½¿ç”¨ä¼ å…¥çš„参数(宽松模式,适用于自定义入库等场景)
        else {
            if (reqVO.getProduceDate() != null) {
                batch.setProduceDate(reqVO.getProduceDate());
            }
            if (reqVO.getReceiptDate() != null) {
                batch.setReceiptDate(reqVO.getReceiptDate());
            }
            if (reqVO.getExpireDate() != null) {
                batch.setExpireDate(reqVO.getExpireDate());
            }
            if (reqVO.getVendorId() != null) {
                batch.setVendorId(reqVO.getVendorId());
            }
            if (reqVO.getClientId() != null) {
                batch.setClientId(reqVO.getClientId());
            }
        // ========== ä¿å­˜å­—段(无论有无配置,只要调用方传了就保存) ==========
        if (reqVO.getProduceDate() != null) {
            batch.setProduceDate(reqVO.getProduceDate());
        }
        if (reqVO.getReceiptDate() != null) {
            batch.setReceiptDate(reqVO.getReceiptDate());
        }
        if (reqVO.getExpireDate() != null) {
            batch.setExpireDate(reqVO.getExpireDate());
        }
        if (reqVO.getVendorId() != null) {
            batch.setVendorId(reqVO.getVendorId());
        }
        if (reqVO.getClientId() != null) {
            batch.setClientId(reqVO.getClientId());
        }
        if (reqVO.getPurchaseOrderCode() != null) {
            batch.setPurchaseOrderCode(reqVO.getPurchaseOrderCode());
        }
        if (reqVO.getSalesOrderCode() != null) {
            batch.setSalesOrderCode(reqVO.getSalesOrderCode());
        }
        if (reqVO.getWorkOrderId() != null) {
            batch.setWorkOrderId(reqVO.getWorkOrderId());
        }
        if (reqVO.getTaskId() != null) {
            batch.setTaskId(reqVO.getTaskId());
        }
        if (reqVO.getWorkstationId() != null) {
            batch.setWorkstationId(reqVO.getWorkstationId());
        }
        if (reqVO.getToolId() != null) {
            batch.setToolId(reqVO.getToolId());
        }
        if (reqVO.getMoldId() != null) {
            batch.setMoldId(reqVO.getMoldId());
        }
        if (reqVO.getLotNumber() != null) {
            batch.setLotNumber(reqVO.getLotNumber());
        }
        if (reqVO.getQualityStatus() != null) {
            batch.setQualityStatus(reqVO.getQualityStatus());
        }
        // 3.1 æƒ…况一:传入了 batchCode,查询是否存在该批次号
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,6 +15,8 @@
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.hutool.core.collection.CollUtil;
import java.util.Collection;
import java.util.Collections;