3 小时以前 55d694aaae4e421b5e55cd941500e08f85cb5d4d
feat(sales): 添加框架合同订单功能并优化销售台账管理

- 新增合同类型字段(普通销售合同、框架合同、框架订单)
- 实现框架订单创建和查询接口
- 添加框架合同累计发货数量统计功能
- 重构生产计划关联逻辑,支持框架订单独立交货日期
- 优化销售台账金额更新方式,避免字段覆盖问题
- 增加框架合同删除时子订单校验机制

fix(customer): 修复客户回收功能中的安全检查和数据更新问题

- 添加客户ID和存在性校验
- 重构客户回收权限验证逻辑
- 使用UpdateWrapper进行精确字段更新
- 添加共享关系清理机制

feat(device): 添加设备区域管理和年度维护计划功能

- 在设备台账中增加区域ID和名称字段
- 实现按区域递归筛选设备功能
- 新增年度维护任务调度支持
- 优化巡检任务设备存在性验证

feat(measuring): 添加计量器具台账批量导入导出功能

- 实现Excel模板下载和批量导入功能
- 添加数据校验和去重机制
- 优化台账导入时的错误处理
- 新增码重复性检查功能

feat(production): 优化生产计划与销售台账关联验证

- 添加销售来源关联性校验
- 实现销售产品与台账匹配验证
- 支持框架订单产品关联机制
- 优化生产计划创建和编辑流程
已添加15个文件
已修改29个文件
2230 ■■■■■ 文件已修改
doc/20260827_框架合同与框架订单前端联调文档.md 290 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260827_设备区域前端联调文档.md 277 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/计量器具台账批量导入前端对接文档.md 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/设备保养年度保养前端联调说明.md 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/migration_device_area.sql 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/migration_framework_contract.sql 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/CustomerServiceImpl.java 27 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/controller/DeviceAreaController.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/controller/DeviceLedgerController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/dto/DeviceAreaDto.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/dto/DeviceLedgerDto.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/mapper/DeviceAreaMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/mapper/DeviceLedgerMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/pojo/DeviceArea.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/pojo/DeviceLedger.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/IDeviceAreaService.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/IDeviceLedgerService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/DeviceAreaServiceImpl.java 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/DeviceLedgerServiceImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/MaintenanceTaskJob.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/MaintenanceTaskScheduler.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/vo/DeviceAreaTreeDto.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/inspectiontask/service/impl/InspectionTaskServiceImpl.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/inspectiontask/service/impl/TimingTaskServiceImpl.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/measuringinstrumentledger/controller/MeasuringInstrumentLedgerController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/measuringinstrumentledger/dto/MeasuringInstrumentLedgerImportDto.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/measuringinstrumentledger/mapper/MeasuringInstrumentLedgerMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/measuringinstrumentledger/service/MeasuringInstrumentLedgerService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/measuringinstrumentledger/service/impl/MeasuringInstrumentLedgerServiceImpl.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionPlanServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesLedgerDto.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/SalesLedgerMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesLedger.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesLedgerProduct.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java 147 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/vo/FrameworkOrderVo.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/vo/SalesLedgerVo.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/device/DeviceLedgerMapper.xml 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/measuringinstrumentledger/MeasuringInstrumentLedgerMapper.xml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesLedgerMapper.xml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260827_¿ò¼ÜºÏͬÓë¿ò¼Ü¶©µ¥Ç°¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,290 @@
# æ¡†æž¶åˆåŒä¸Žæ¡†æž¶è®¢å•前端联调文档
## ä¸€ã€ä¸šåŠ¡è¯´æ˜Ž
销售台账中的销售合同按框架合同处理:
- æ¡†æž¶åˆåŒåªç™»è®°åˆåŒåŸºç¡€ä¿¡æ¯å’Œäº§å“èŒƒå›´ã€‚
- æ¡†æž¶åˆåŒä¸å¡«å†™æ•°é‡å’Œäº¤è´§æ—¥æœŸã€‚
- ä¸€ä¸ªæ¡†æž¶åˆåŒä¸‹å¯ä»¥åˆ›å»ºå¤šä¸ªæ¡†æž¶è®¢å•。
- æ¡†æž¶è®¢å•只登记产品和交货日期,不填写数量。
- å®žé™…数量只在每次发货时填写,保存于发货明细。
- ä¸€ä¸ªæ¡†æž¶è®¢å•产品可以多次分批发货。
- æ¡†æž¶åˆåŒäº§å“ä¸èƒ½ç›´æŽ¥å‘货,必须选择框架订单产品。
- æ¡†æž¶åˆåŒåˆ›å»ºæ—¶ä¸ç”Ÿæˆç”Ÿäº§è®¡åˆ’;框架订单创建时自动生成生产计划(需求数量留空)。
- ç”Ÿäº§æ•°é‡åœ¨ç”Ÿäº§è®¡åˆ’下发时填写,生产计划下发时不受销售台账数量限制。
## äºŒã€åˆåŒç±»åž‹
| contractType | å«ä¹‰ | æ•°é‡ | äº¤è´§æ—¥æœŸ | æ˜¯å¦è‡ªåŠ¨ç”Ÿæˆç”Ÿäº§è®¡åˆ’ |
| --- | --- | --- | --- | --- |
| 1 | æ™®é€šé”€å”®åˆåŒ | æŒ‰åŽŸæœ‰ä¸šåŠ¡ | æŒ‰åŽŸæœ‰ä¸šåŠ¡ | æŒ‰åŽŸæœ‰ä¸šåŠ¡ |
| 2 | æ¡†æž¶åˆåŒ | ä¸å¡«å†™ | ä¸å¡«å†™ | å¦ |
| 3 | æ¡†æž¶è®¢å• | ä¸å¡«å†™ | äº§å“æ˜Žç»†å¡«å†™ | æ˜¯ï¼ˆéœ€æ±‚数量留空,下发时填写) |
## ä¸‰ã€åˆ›å»ºæˆ–保存框架合同
接口:
```text
POST /sales/ledger/addOrUpdateSalesLedger
```
请求示例:
```json
{
  "contractType": 2,
  "customerId": 1001,
  "customerContractNo": "客户合同号",
  "projectName": "项目名称",
  "salesman": "业务员",
  "entryDate": "2026-08-27",
  "executionDate": "2026-08-27",
  "paymentMethod": "按合同约定",
  "remarks": "框架合同",
  "productData": [
    {
      "productId": 10,
      "productModelId": 101,
      "productCategory": "产品名称",
      "specificationModel": "规格型号",
      "unit": "ä»¶",
      "quantity": null,
      "deliveryDate": null,
      "isProduction": false,
      "type": 1
    }
  ]
}
```
前端要求:
- `contractType` ä¼  `2`。
- äº§å“æ•°é‡ä¼  `null` æˆ–不传,不传空字符串。
- äº§å“äº¤è´§æ—¥æœŸä¼  `null` æˆ–不传。
- ä¸è¦æ ¹æ®æ¡†æž¶åˆåŒäº§å“æ•°é‡æŽ§åˆ¶å‘货数量。
- ä¸è¦åœ¨ä¿å­˜æˆåŠŸåŽè‡ªåŠ¨è·³è½¬ç”Ÿäº§è®¡åˆ’åˆ›å»ºã€‚
## å››ã€åˆ›å»ºæ¡†æž¶è®¢å•
接口:
```text
POST /sales/ledger/createFrameworkOrder
```
请求示例:
```json
{
  "id": 10001,
  "remarks": "第一批框架订单",
  "productData": [
    {
      "parentProductId": 20001,
      "deliveryDate": "2026-09-15"
    },
    {
      "parentProductId": 20002,
      "deliveryDate": "2026-09-20"
    }
  ]
}
```
字段说明:
- `id`:父框架合同 ID。
- `remarks`:框架订单备注。
- `productData[].parentProductId`:父框架合同产品 ID。
- `productData[].deliveryDate`:当前框架订单产品交货日期,必填。
- ä¸è¦ä¼ æ•°é‡ï¼Œæ¡†æž¶è®¢å•产品数量固定为空。
- åŒä¸€æ¡†æž¶è®¢å•中不能重复选择同一个父合同产品。
成功返回:沿用系统 `toAjax` è¿”回格式,成功时表示框架订单已创建。
失败场景:
- æœªä¼ çˆ¶åˆåŒ ID:请选择框架合同。
- çˆ¶å•不是框架合同:父台账不是框架合同。
- æœªé€‰æ‹©äº§å“ï¼šè¯·å¡«å†™æ¡†æž¶è®¢å•产品。
- æœªå¡«å†™çˆ¶äº§å“æˆ–交货日期:框架订单产品必须关联父产品并填写交货日期。
- çˆ¶äº§å“ä¸å±žäºŽè¯¥æ¡†æž¶åˆåŒï¼šæ¡†æž¶è®¢å•产品不属于该框架合同。
## äº”、查询框架订单列表
接口:
```text
GET /sales/ledger/listFrameworkOrders?parentContractId=10001
```
返回示例:
```json
{
  "code": 200,
  "data": [
    {
      "id": 30001,
      "salesContractNo": "销售合同号",
      "contractType": 3,
      "parentContractId": 10001,
      "customerId": 1001,
      "customerName": "客户名称",
      "projectName": "项目名称",
      "remarks": "第一批框架订单",
      "shippedQuantity": 120
    }
  ]
}
```
说明:
- `contractType` å›ºå®šä¸º `3`。
- `orderedQuantity` ä¸ä½œä¸ºæ•°é‡æ¥æºï¼Œæ¡†æž¶è®¢å•不记录合同数量。
- `shippedQuantity` æ˜¯è¯¥æ¡†æž¶è®¢å•下所有已发货发货明细数量之和。
- `shippedQuantity` ä»…统计状态为“已发货”的发货单。
- æ— å‘货记录时 `shippedQuantity` è¿”回 `0`。
## å…­ã€æŸ¥è¯¢æ¡†æž¶åˆåŒç´¯è®¡å‘货数量
接口:
```text
GET /sales/ledger/frameworkContractShippedQuantity?parentContractId=10001
```
返回示例:
```json
{
  "code": 200,
  "data": 320
}
```
该数量为父框架合同下所有框架订单、所有已发货发货单明细的数量合计。该接口只用于展示统计,不用于限制后续发货数量。
## ä¸ƒã€åˆ›å»ºå‘货申请
接口:
```text
POST /shippingInfo/addReq
```
现有发货请求中继续使用:
```json
{
  "salesLedgerId": 30001,
  "salesLedgerProductId": 30101,
  "batchNoDetailList": [
    {
      "stockInventoryId": 5001,
      "batchNo": "RK20260827001",
      "productModelId": 101,
      "quantity": 80
    }
  ],
  "shippingAddress": "收货地址",
  "type": "物流"
}
```
联调规则:
- `salesLedgerId` å¿…须与 `salesLedgerProductId` æ‰€å±žå°è´¦ä¸€è‡´ã€‚
- `salesLedgerProductId` å¿…须关联普通销售合同产品或框架订单产品。
- ä¸å…è®¸ä½¿ç”¨æ¡†æž¶åˆåŒäº§å“ ID å‘货。
- `batchNoDetailList` ä¸­çš„æ•°é‡æ˜¯æœ¬æ¬¡å®žé™…发货数量,必填且大于零。
- åŒä¸€ä¸ªæ¡†æž¶è®¢å•产品可以多次提交发货申请。
- ä¸èƒ½åŒæ—¶å­˜åœ¨å¤šä¸ªâ€œå¾…审核”或“审核中”的同产品发货申请。
- å·²å‘货历史不会阻止下一次分批发货。
- å‘货数量不与销售台账数量比较,最终以库存批次可用量和库存扣减结果为准。
## å…«ã€ç”Ÿäº§è®¡åˆ’联调
创建框架订单时,后端会为每个框架订单产品**自动生成一条生产计划**:
- `salesLedgerId`、`salesLedgerProductId` è‡ªåŠ¨ç»‘å®šå½“å‰æ¡†æž¶è®¢å•ã€‚
- `productModelId` å–框架订单产品型号。
- `qtyRequired`(需求数量)**留空**,生产数量在生产计划下发时填写。
- `requiredDate`、`promisedDeliveryDate` å–框架订单产品交货日期。
- `source = é”€å”®`,状态为待下发。
- æ¡†æž¶åˆåŒåˆ›å»ºæ—¶ä¸ç”Ÿæˆç”Ÿäº§è®¡åˆ’。
下发生产订单时使用下发请求中的数量,不依赖销售台账产品 `quantity`,也不校验计划需求数量,可继续分批下发。
`validateSalesSource` æ ¡éªŒè§„则不变:
- `salesLedgerId` å’Œ `salesLedgerProductId` å¿…须同时填写,或同时不填写。
- å…³è”销售来源时,销售产品必须属于该销售台账。
- é”€å”®å°è´¦ç±»åž‹åªèƒ½æ˜¯æ™®é€šé”€å”®åˆåŒ `1` æˆ–框架订单 `3`。
- ä¸èƒ½ç›´æŽ¥å…³è”框架合同 `2`。
- `productModelId` å¿…须与销售产品型号一致。
## ä¹ã€é¡µé¢äº¤äº’建议
### æ¡†æž¶åˆåŒé¡µé¢
- åˆåŒç±»åž‹é€‰æ‹©â€œæ¡†æž¶åˆåŒâ€åŽéšè—æˆ–禁用合同数量输入。
- éšè—æˆ–禁用合同级交货日期。
- äº§å“æ˜Žç»†éšè—æ•°é‡å’Œäº¤è´§æ—¥æœŸè¾“入。
- ä¿å­˜åŽæä¾›â€œåˆ›å»ºæ¡†æž¶è®¢å•”入口。
### æ¡†æž¶è®¢å•页面
- å±•示父框架合同信息,只读。
- äº§å“é€‰æ‹©èŒƒå›´ä¸ºçˆ¶æ¡†æž¶åˆåŒäº§å“ã€‚
- æ¯ä¸ªäº§å“åªå¡«å†™äº¤è´§æ—¥æœŸã€‚
- éšè—æ•°é‡è¾“入。
- ä¿å­˜æˆåŠŸåŽå±•ç¤ºè®¢å•ç¼–å·å’Œè®¢å•äº§å“åˆ—è¡¨ã€‚
### å‘货页面
- é€‰æ‹©é”€å”®åˆåŒæ—¶ï¼Œæ¡†æž¶åˆåŒä¸èƒ½ä½œä¸ºç›´æŽ¥å‘货台账。
- æ¡†æž¶åˆåŒåº”展开选择其下框架订单,再选择框架订单产品。
- æ¯æ¬¡å‘货填写实际发货数量和库存批次。
- å·²å‘货后仍允许再次创建新的发货批次。
### ç”Ÿäº§è®¡åˆ’页面
- æ¡†æž¶è®¢å•创建后自动生成生产计划记录(需求数量为空)。
- ç”Ÿäº§è®¡åˆ’数量在生产计划下发时单独填写。
- ä¸ä»Žé”€å”®å°è´¦äº§å“æ•°é‡å›žå¡«ç”Ÿäº§è®¡åˆ’数量。
## åã€è”调验收流程
1. åˆ›å»ºä¸€æ¡æ¡†æž¶åˆåŒï¼Œäº§å“ä¸å¡«å†™æ•°é‡å’Œäº¤è´§æ—¥æœŸã€‚
2. æŸ¥è¯¢æ¡†æž¶åˆåŒè¯¦æƒ…,确认合同类型为 `2`。
3. åŸºäºŽåŒä¸€æ¡†æž¶åˆåŒåˆ›å»ºä¸¤ä¸ªæ¡†æž¶è®¢å•。
4. ç¡®è®¤ä¸¤ä¸ªæ¡†æž¶è®¢å•均为 `contractType = 3`,产品数量为空。
5. åˆ›å»ºæ¡†æž¶è®¢å•后,生产计划列表应出现对应计划,`qtyRequired` ä¸ºç©ºï¼Œ`source = é”€å”®`,需求日期为产品交货日期。
6. ä¸ºç¬¬ä¸€ä¸ªæ¡†æž¶è®¢å•产品创建第一笔发货申请。
7. å®¡æ ¸å¹¶å®Œæˆå‘货。
8. ä¸ºåŒä¸€ä¸ªæ¡†æž¶è®¢å•产品创建第二笔发货申请。
9. æŸ¥è¯¢æ¡†æž¶è®¢å•列表,确认 `shippedQuantity` ä¸ºä¸¤æ¬¡å‘货数量之和。
10. æŸ¥è¯¢æ¡†æž¶åˆåŒç´¯è®¡å‘货数量,确认包含所有框架订单的已发货数量。
11. å¯¹è‡ªåŠ¨ç”Ÿæˆçš„ç”Ÿäº§è®¡åˆ’å¡«å†™ä¸‹å‘æ•°é‡å¹¶ä¸‹å‘ï¼Œç¡®è®¤ä¸ä¾èµ–é”€å”®äº§å“æ•°é‡ã€‚
12. å°è¯•直接使用框架合同产品发货,接口应返回错误。
13. å°è¯•为同一产品同时提交两笔待审核发货申请,第二笔应被拒绝。
## åä¸€ã€å½“前后端验证状态
已完成:
- æ¡†æž¶åˆåŒã€æ¡†æž¶è®¢å•数据字段。
- æ¡†æž¶è®¢å•创建接口。
- æ¡†æž¶è®¢å•创建时自动生成生产计划(需求数量留空)。
- æ¡†æž¶è®¢å•列表接口。
- æ¡†æž¶åˆåŒç´¯è®¡å·²å‘货数量接口。
- æ¡†æž¶åˆåŒäº§å“ç›´æŽ¥å‘货拦截。
- ç”Ÿäº§è®¡åˆ’销售来源归属校验。
- æ•°æ®åº“迁移及目标数据库字段验证。
说明:后端已通过 `mvn compile` ç¼–译验证。
doc/20260827_Éè±¸ÇøÓòǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,277 @@
# è®¾å¤‡åŒºåŸŸï¼ˆè½¦é—´/位置)前端联调文档
## ä¸€ã€ä¸šåŠ¡è¯´æ˜Ž
设备台账需按车间/区域汇总设备,车间/位置可自行新增,且支持子父级递归:
- æ–°å¢žç‹¬ç«‹â€œè®¾å¤‡åŒºåŸŸâ€æ¦‚念,车间、区域、位置统一作为区域节点。
- åŒºåŸŸæ”¯æŒä»»æ„å±‚级父子递归。
- è®¾å¤‡å°è´¦å…³è”一个区域(`area_id`)。
- æŒ‰åŒºåŸŸæ±‡æ€»è®¾å¤‡æ—¶ï¼Œæ±‡æ€»è¯¥åŒºåŸŸåŠå…¶æ‰€æœ‰å­çº§åŒºåŸŸçš„设备。
- è®¾å¤‡å°è´¦é¡µé¢å»ºè®®é‡‡ç”¨â€œå·¦ä¾§åŒºåŸŸæ ‘ + å³ä¾§è®¾å¤‡åˆ—表”形式。
## äºŒã€æ•°æ®åº“迁移
执行 `sql/migration_device_area.sql`(已做幂等处理,`device_area` è¡¨å·²å­˜åœ¨æ—¶è·³è¿‡å»ºè¡¨ã€`area_id` åˆ—已存在时跳过加列,可安全重复执行),内容:
1. æ–°å¢žè¡¨ `device_area`(主键无其它约束):
   | å­—段 | ç±»åž‹ | è¯´æ˜Ž |
   | --- | --- | --- |
   | `id` | bigint | ä¸»é”®è‡ªå¢ž |
   | `name` | varchar(255) | åŒºåŸŸ/车间/位置名称 |
   | `parent_id` | bigint | çˆ¶çº§åŒºåŸŸID,根节点为 NULL |
   | `ancestors` | varchar(1000) | ç¥–级路径,如 `1,2,3` |
   | `sort` | int | æŽ’序 |
   | `dept_id` | bigint | éƒ¨é—¨ID(可选) |
   | `tenant_id` | bigint | ç§Ÿæˆ·ID |
   | `create_user` | int | åˆ›å»ºäºº |
   | `create_time` | datetime | åˆ›å»ºæ—¶é—´ |
   | `update_user` | int | æ›´æ–°äºº |
   | `update_time` | datetime | æ›´æ–°æ—¶é—´ |
2. `device_ledger` å¢žåŠ  `area_id bigint NULL`(设备区域ID)。
## ä¸‰ã€æŽ¥å£åˆ—表
| æŽ¥å£ | æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
| --- | --- | --- | --- |
| æŸ¥è¯¢åŒºåŸŸæ ‘ | GET | `/device/area/tree` | è¿”回树形区域及递归设备数量 |
| æ–°å¢žåŒºåŸŸ | POST | `/device/area` | æ–°å¢žåŒºåŸŸ/车间/位置 |
| ä¿®æ”¹åŒºåŸŸ | PUT | `/device/area` | ä¿®æ”¹åŒºåŸŸåç§°/父级/排序 |
| åˆ é™¤åŒºåŸŸ | DELETE | `/device/area/{ids}` | åˆ é™¤åŒºåŸŸ |
| è®¾å¤‡åˆ—表 | GET | `/device/ledger/page` | æ”¯æŒæŒ‰åŒºåŸŸé€’归筛选(新增 `areaId` å‚数) |
| è®¾å¤‡é€‰æ‹©åˆ—表 | GET | `/device/ledger/getDeviceLedger` | å·¡æ£€é€‰è®¾å¤‡ç”¨ï¼Œæ”¯æŒ `areaId` é€’归筛选,返回 `areaName` |
## å››ã€åŒºåŸŸæ ‘接口
### æŸ¥è¯¢åŒºåŸŸæ ‘
```text
GET /device/area/tree
```
返回示例:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": [
    {
      "id": 1,
      "parentId": null,
      "label": "一号车间",
      "deviceCount": 8,
      "children": [
        {
          "id": 2,
          "parentId": 1,
          "label": "冲压区",
          "deviceCount": 5,
          "children": []
        },
        {
          "id": 3,
          "parentId": 1,
          "label": "装配区",
          "deviceCount": 3,
          "children": []
        }
      ]
    }
  ]
}
```
字段说明:
- `id`:区域ID。
- `parentId`:父级区域ID,根节点为 `null`。
- `label`:区域显示名称,前端 el-tree ç›´æŽ¥ç”¨ `label` æ¸²æŸ“。
- `deviceCount`:**该区域及所有子级区域**的设备数量(递归汇总)。
前端建议直接用于 `el-tree`:
```html
<el-tree
  :data="areaTree"
  node-key="id"
  :props="{ label: 'label', children: 'children' }"
  @node-click="onAreaClick"
/>
```
点击节点时,把 `id` ä½œä¸º `areaId` ä¼ ç»™è®¾å¤‡åˆ—表查询。
## äº”、新增区域
```text
POST /device/area
Content-Type: application/json
```
请求体:
```json
{
  "name": "二号车间",
  "parentId": null,
  "sort": 1
}
```
- `name`:必填,区域名称。
- `parentId`:父级区域ID,创建根节点时传 `null` æˆ–不传。
- `sort`:排序,可空。
成功返回 `AjaxResult.success()`。
失败场景:
- åç§°ä¸ºç©ºï¼š`区域名称不能为空`。
- çˆ¶çº§åŒºåŸŸä¸å­˜åœ¨ï¼š`父级区域不存在`。
## å…­ã€ä¿®æ”¹åŒºåŸŸ
```text
PUT /device/area
Content-Type: application/json
```
请求体:
```json
{
  "id": 2,
  "name": "冲压车间",
  "parentId": 1,
  "sort": 1
}
```
支持修改名称、父级(拖动调整层级)、排序。后端自动维护 `ancestors` ç¥–级路径;修改父级时前端无需传 `ancestors`。
失败场景:
- æœªä¼ ID:`区域ID不能为空`。
- åŒºåŸŸä¸å­˜åœ¨ï¼š`区域不存在`。
- æŒ‚载到自身:`不能将区域挂载到自身`。
- æŒ‚载到自己的子级:`不能将区域挂载到自己的子级下`(防止循环)。
## ä¸ƒã€åˆ é™¤åŒºåŸŸ
```text
DELETE /device/area/{ids}
```
示例:
```text
DELETE /device/area/2,3
```
成功返回 `AjaxResult.success()`。
失败场景:
- è¯¥åŒºåŸŸå­˜åœ¨å­çº§åŒºåŸŸï¼Œè¯·å…ˆåˆ é™¤å­çº§ã€‚
- è¯¥åŒºåŸŸä¸‹å­˜åœ¨è®¾å¤‡ï¼Œè¯·å…ˆç§»é™¤è®¾å¤‡å…³è”。
前端建议:删除前弹窗确认;若后端返回上述错误提示,直接展示给用户。
## å…«ã€è®¾å¤‡åˆ—表按区域筛选
在原有设备台账分页接口增加 `areaId` å‚数:
```text
GET /device/ledger/page?areaId=1&pageNum=1&pageSize=10
```
新增行为:
- è¿”回字段新增 `areaId`、`areaName`(区域名称),供列表展示。
- å½“ `areaId` æœ‰å€¼æ—¶ï¼ŒæŸ¥è¯¢è¯¥åŒºåŸŸ**及其所有子级区域**下的设备(通过 `ancestors` ç¥–级匹配,前端无需关心)。
示例返回记录:
```json
{
  "records": [
    {
      "id": 100,
      "deviceName": "液压机",
      "deviceModel": "YQ-100",
      "areaId": 2,
      "areaName": "冲压区",
      "...": "..."
    }
  ]
}
```
## ä¹ã€é¡µé¢äº¤äº’建议
### è®¾å¤‡å°è´¦åˆ—表页
- å·¦ä¾§å±•示区域树(`/device/area/tree`)。
- æ ‘顶固定一个“全部设备”入口,点击时 `areaId` ä¼ ç©ºï¼ŒæŸ¥è¯¢æ‰€æœ‰è®¾å¤‡ã€‚
- ç‚¹å‡»ä»»æ„åŒºåŸŸèŠ‚ç‚¹ï¼Œå³ä¾§è®¾å¤‡åˆ—è¡¨æŒ‰è¯¥åŒºåŸŸåŠå…¶å­çº§é€’å½’ç­›é€‰ã€‚
- åˆ—表增加“所属区域”列,显示 `areaName`。
- æ–°å¢ž/编辑设备时,用区域树选择器(可级联展开)选择所属区域,提交时传 `areaId`。
### åŒºåŸŸç®¡ç†
- å¯åœ¨åŒºåŸŸæ ‘上直接“新增同级/新增子级”节点。
- æ”¯æŒæ‹–拽调整父子关系(调用 PUT ä¼ æ–° `parentId`)。
- åˆ é™¤æŒ‰é’®åœ¨æ— å­çº§ä¸”无设备关联时才可用。
### å·¡æ£€é€‰æ‹©è®¾å¤‡
巡检任务(`/inspectionTask/addOrEditInspectionTask`)与定时巡检(`/timingTask/addOrEditTimingTask`)创建/编辑时选择巡检设备,建议改为按车间/位置筛选:
- å¼¹çª—左侧展示区域树(`GET /device/area/tree`),顶部固定“全部设备”。
- ç‚¹å‡»åŒºåŸŸèŠ‚ç‚¹åŽï¼Œå³ä¾§è°ƒ `GET /device/ledger/getDeviceLedger?areaId=x` åŠ è½½è¯¥åŒºåŸŸï¼ˆå«å­çº§ï¼‰è®¾å¤‡åˆ—è¡¨ã€‚
- é€‰ä¸­è®¾å¤‡åŽå›žå¡« `taskId`(设备ID)与 `taskName`(设备名称),随巡检任务一起提交。
- `getDeviceLedger` è¿”回示例:
  ```json
  [
    {
      "id": 100,
      "deviceName": "液压机",
      "deviceModel": "YQ-100",
      "deviceBrand": "XX",
      "type": "压力机",
      "areaId": 2,
      "areaName": "冲压区"
    }
  ]
  ```
- `areaId` ä¸ä¼ æ—¶è¿”回全部设备(兼容旧调用)。
后端新增校验:巡检任务/定时巡检保存时,若 `taskId` å¯¹åº”设备不存在,返回 `所选设备不存在,请重新选择`。
## åã€è”调验收流程
1. åˆ›å»ºä¸€ä¸ªæ ¹åŒºåŸŸâ€œä¸€å·è½¦é—´â€ã€‚
2. åœ¨â€œä¸€å·è½¦é—´â€ä¸‹åˆ›å»ºâ€œå†²åŽ‹åŒºâ€â€œè£…é…åŒºâ€ã€‚
3. æŸ¥è¯¢åŒºåŸŸæ ‘,确认层级正确,`deviceCount` å…¨éƒ¨ä¸º 0。
4. æ–°å¢ž 2 å°è®¾å¤‡æŒ‚到“冲压区”,1 å°æŒ‚到“装配区”。
5. æŸ¥è¯¢åŒºåŸŸæ ‘,确认“冲压区”`deviceCount` ä¸º 2,“装配区”为 1,“一号车间”为 3(递归汇总)。
6. è®¾å¤‡åˆ—表传 `areaId=一号车间`,确认返回 3 å°è®¾å¤‡ï¼ˆå«å­çº§ï¼‰ã€‚
7. è®¾å¤‡åˆ—表传 `areaId=冲压区`,确认只返回 2 å°ã€‚
8. å°è¯•删除“一号车间”,应报“存在子级区域”。
9. å°è¯•删除“冲压区”,应报“区域下存在设备”。
10. å°†â€œè£…配区”父级改为“冲压区”,再查区域树,确认层级变化命中循环校验不失败。
11. æ–°å¢ž/编辑设备时选择区域,确认 `areaId` æ­£ç¡®ä¿å­˜ä¸”列表显示 `areaName`。
### å·¡æ£€é€‰è®¾å¤‡è”è°ƒ
1. å·¡æ£€ä»»åŠ¡æ–°å»ºï¼Œç‚¹é€‰è®¾å¤‡å¼¹çª—ï¼Œç¡®è®¤å·¦ä¾§å‡ºçŽ°åŒºåŸŸæ ‘ã€‚
2. ç‚¹å‡»â€œä¸€å·è½¦é—´â€ï¼Œå³ä¾§åº”显示该车间(含子级)全部设备。
3. ç‚¹å‡»â€œå†²åŽ‹åŒºâ€ï¼Œå³ä¾§åªæ˜¾ç¤ºå†²åŽ‹åŒºè®¾å¤‡ã€‚
4. é€‰ä¸­è®¾å¤‡åŽæäº¤ï¼Œå·¡æ£€ä»»åŠ¡ä¿å­˜æˆåŠŸï¼Œ`taskId`/`taskName` æ­£ç¡®å›žæ˜¾ã€‚
5. æ‰‹å·¥æž„造一个不存在的设备ID提交,应返回 `所选设备不存在,请重新选择`。
6. å®šæ—¶å·¡æ£€é¡µé¢æ‰§è¡ŒåŒæ ·æ“ä½œï¼Œè¡Œä¸ºä¸€è‡´ã€‚
doc/¼ÆÁ¿Æ÷¾ß̨ÕËÅúÁ¿µ¼Èëǰ¶Ë¶Ô½ÓÎĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,242 @@
# è®¡é‡å™¨å…·å°è´¦æ‰¹é‡å¯¼å…¥å‰ç«¯å¯¹æŽ¥æ–‡æ¡£
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
计量器具台账支持通过 Excel æ–‡ä»¶æ‰¹é‡æ–°å¢žæ•°æ®ã€‚
本功能包含:
- ä¸‹è½½ Excel å¯¼å…¥æ¨¡æ¿
- ä¸Šä¼  Excel æ–‡ä»¶æ‰¹é‡å¯¼å…¥
- å¯¼å…¥å‰è¿›è¡Œæ•°æ®æ ¡éªŒ
- æ ¡éªŒå¤±è´¥æ—¶æ•´æ‰¹æ•°æ®ä¸å†™å…¥æ•°æ®åº“
- è‡ªåŠ¨ä½¿ç”¨å½“å‰ç™»å½•ç”¨æˆ·å’Œå½“å‰ç§Ÿæˆ·ä¿¡æ¯
- è‡ªåŠ¨è®¡ç®—é¢„è®¡ä¸‹æ¬¡æ£€å®šæ—¥æœŸ
接口基础路径:
```text
/measuringInstrumentLedger
```
接口请求需要携带项目现有登录认证信息。
## äºŒã€ä¸‹è½½å¯¼å…¥æ¨¡æ¿
### è¯·æ±‚信息
```text
POST /measuringInstrumentLedger/downloadTemplate
```
### è¯·æ±‚参数
无。
### å‰ç«¯ç¤ºä¾‹
```javascript
const response = await request({
  url: '/measuringInstrumentLedger/downloadTemplate',
  method: 'post',
  responseType: 'blob'
})
const blob = new Blob([response], {
  type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = '计量器具台账导入模板.xlsx'
link.click()
URL.revokeObjectURL(link.href)
```
项目已有请求封装的,按项目请求封装方式设置 `responseType: 'blob'` å³å¯ã€‚
## ä¸‰ã€æ‰¹é‡å¯¼å…¥
### è¯·æ±‚信息
```text
POST /measuringInstrumentLedger/import
```
### è¯·æ±‚类型
```text
multipart/form-data
```
### è¯·æ±‚参数
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| file | File | æ˜¯ | Excel æ–‡ä»¶ï¼Œä»…支持 `.xls` å’Œ `.xlsx` |
### å‰ç«¯ç¤ºä¾‹
```javascript
const formData = new FormData()
formData.append('file', file)
const result = await request({
  url: '/measuringInstrumentLedger/import',
  method: 'post',
  data: formData,
  headers: {
    'Content-Type': 'multipart/form-data'
  }
})
```
注意:不要将文件转成 JSON,也不要使用 `@RequestBody` æ–¹å¼æäº¤ã€‚
## å››ã€Excel æ¨¡æ¿åˆ—
模板第一行为表头,数据从第二行开始。
| Excel è¡¨å¤´ | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| è®¡é‡å™¨å…·ç¼–号 | æ–‡æœ¬ | æ˜¯ | å½“前租户内不能重复 |
| è®¡é‡å™¨å…·åç§° | æ–‡æœ¬ | æ˜¯ | ä¸èƒ½å¡«å†™ç©ºå€¼ |
| è§„格型号 | æ–‡æœ¬ | å¦ | è®¡é‡å™¨å…·è§„格或型号 |
| æœ€è¿‘一次检定日期 | æ—¥æœŸ | å¦ | å»ºè®®æ ¼å¼ï¼š`yyyy-MM-dd` |
| æ£€å®šæœ‰æ•ˆæœŸ | æ•´æ•° | å¦ | å•位:天;填写时必须大于 0 |
| å½•入日期 | æ—¥æœŸ | å¦ | å»ºè®®æ ¼å¼ï¼š`yyyy-MM-dd`;不填写时使用当前日期 |
| å•位 | æ–‡æœ¬ | å¦ | ä½¿ç”¨å•位 |
| éƒ¨é—¨ID | æ•´æ•° | å¦ | éƒ¨é—¨ ID |
| å®‰è£…位置 | æ–‡æœ¬ | å¦ | è®¡é‡å™¨å…·å®‰è£…位置 |
| æ£€å®šå‘¨æœŸ | æ–‡æœ¬ | å¦ | æ£€å®šå‘¨æœŸ |
### æ—¥æœŸå’Œæœ‰æ•ˆæœŸè§„则
当同时填写以下两列时,后端自动计算预计下次检定日期:
```text
预计下次检定日期 = æœ€è¿‘一次检定日期 + æ£€å®šæœ‰æ•ˆæœŸï¼ˆå¤©ï¼‰
```
Excel ä¸éœ€è¦å¡«å†™â€œé¢„计下次检定日期”。
以下字段不需要、也不允许通过 Excel ç»´æŠ¤ï¼š
- ID
- å½•入人 ID
- å½•入人名称
- ç§Ÿæˆ· ID
- çŠ¶æ€
- åˆ›å»ºäººã€åˆ›å»ºæ—¶é—´
- ä¿®æ”¹äººã€ä¿®æ”¹æ—¶é—´
这些字段由后端根据当前登录信息或系统规则自动处理。
## äº”、成功返回
成功时返回项目统一成功结果,提示信息类似:
```json
{
  "code": 200,
  "msg": "成功导入 10 æ¡æ•°æ®"
}
```
前端建议:
1. å…³é—­ä¸Šä¼ å¼¹çª—。
2. æç¤º `msg` å†…容。
3. åˆ·æ–°å°è´¦åˆ†é¡µåˆ—表。
## å…­ã€å¤±è´¥è¿”回
### 1. æœªé€‰æ‹©æ–‡ä»¶
```json
{
  "code": 500,
  "msg": "请上传 Excel æ–‡ä»¶"
}
```
### 2. æ–‡ä»¶æ ¼å¼ä¸æ­£ç¡®
```json
{
  "code": 500,
  "msg": "只支持导入 xls æˆ– xlsx æ–‡ä»¶"
}
```
### 3. æ–‡ä»¶æ²¡æœ‰æ•°æ®
```json
{
  "code": 500,
  "msg": "Excel ä¸­æ²¡æœ‰å¯å¯¼å…¥çš„æ•°æ®"
}
```
### 4. æ•°æ®æ ¡éªŒå¤±è´¥
```json
{
  "code": 500,
  "msg": "导入校验失败:计量器具编号不能为空;计量器具编号在 Excel ä¸­é‡å¤ï¼›æ£€å®šæœ‰æ•ˆæœŸå¿…须大于 0"
}
```
常见校验提示:
| æç¤º | å¤„理建议 |
|---|---|
| è®¡é‡å™¨å…·ç¼–号不能为空 | è¡¥å……“计量器具编号” |
| è®¡é‡å™¨å…·åç§°ä¸èƒ½ä¸ºç©º | è¡¥å……“计量器具名称” |
| è®¡é‡å™¨å…·ç¼–号在 Excel ä¸­é‡å¤ | åˆ é™¤æˆ–修改重复编号 |
| è®¡é‡å™¨å…·ç¼–号已存在:xxx | ä¿®æ”¹è¯¥ç¼–号,避免与当前租户已有台账重复 |
| æ£€å®šæœ‰æ•ˆæœŸå¿…须大于 0 | å¡«å†™å¤§äºŽ 0 çš„æ•´æ•° |
| å¯¼å…¥å¤±è´¥ï¼Œè¯·æ£€æŸ¥ Excel å†…容 | æ£€æŸ¥æ–‡ä»¶æ ¼å¼ã€è¡¨å¤´å’Œå•元格类型 |
## ä¸ƒã€é¡µé¢äº¤äº’建议
建议在台账列表页面增加两个按钮:
- ä¸‹è½½æ¨¡æ¿
- æ‰¹é‡å¯¼å…¥
推荐交互流程:
1. ç”¨æˆ·ç‚¹å‡»â€œä¸‹è½½æ¨¡æ¿â€ã€‚
2. å‰ç«¯ä¸‹è½½å¹¶ä¿å­˜ Excel æ¨¡æ¿ã€‚
3. ç”¨æˆ·æŒ‰ç…§æ¨¡æ¿å¡«å†™æ•°æ®ã€‚
4. ç”¨æˆ·ç‚¹å‡»â€œæ‰¹é‡å¯¼å…¥â€ã€‚
5. å¼¹å‡ºæ–‡ä»¶é€‰æ‹©æ¡†ï¼Œä»…允许选择 `.xls`、`.xlsx` æ–‡ä»¶ã€‚
6. é€‰æ‹©æ–‡ä»¶åŽä¸Šä¼ ã€‚
7. ä¸Šä¼ æœŸé—´æŒ‰é’®æ˜¾ç¤ºåŠ è½½çŠ¶æ€ï¼Œå¹¶ç¦æ­¢é‡å¤æäº¤ã€‚
8. æ ¹æ®æŽ¥å£è¿”回的 `code` å’Œ `msg` æ˜¾ç¤ºç»“果。
9. å¯¼å…¥æˆåŠŸåŽåˆ·æ–°åˆ—è¡¨ã€‚
10. å¯¼å…¥å¤±è´¥æ—¶ä¿ç•™å¼¹çª—,方便用户重新选择文件。
## å…«ã€å‰ç«¯æ–‡ä»¶é€‰æ‹©é™åˆ¶
```javascript
const accept = '.xls,.xlsx'
```
前端可以先校验文件扩展名,但后端校验仍然必须保留。
建议同时校验:
- æ–‡ä»¶ä¸èƒ½ä¸ºç©º
- æ–‡ä»¶æ‰©å±•名为 `.xls` æˆ– `.xlsx`
- æ–‡ä»¶å¤§å°ä¸è¶…过项目统一上传限制
## ä¹ã€é‡è¦æ³¨æ„äº‹é¡¹
1. ä¸€æ¬¡å¯¼å…¥ä¸­åªè¦æœ‰ä¸€æ¡æ•°æ®æ ¡éªŒå¤±è´¥ï¼Œæ•´æ‰¹æ•°æ®éƒ½ä¸ä¼šå†™å…¥ã€‚
2. è®¡é‡å™¨å…·ç¼–号按当前租户校验,不同租户之间可以使用相同编号。
3. Excel ä¸­çš„录入人、租户、状态等字段不会作为有效导入数据使用。
4. â€œæœ€è¿‘一次检定日期”和“检定有效期”可以不填写;如果都填写,后端自动计算预计下次检定日期。
5. Excel è¡¨å¤´å¿…须使用模板中的名称,不要自行修改表头文字。
6. å¯¼å…¥æŽ¥å£ä¸æ˜¯ JSON æŽ¥å£ï¼Œå¿…须使用 `FormData` ä¸Šä¼ æ–‡ä»¶ã€‚
7. å½“前仓库不包含前端代码,以上代码为前端对接示例,具体请求函数名称按前端项目实际封装调整。
doc/É豸±£ÑøÄê¶È±£ÑøÇ°¶ËÁªµ÷˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,171 @@
# è®¾å¤‡ä¿å…»å¹´åº¦ä¿å…»å‰ç«¯è”调说明
## 1. å˜æ›´è¯´æ˜Ž
设备保养定时任务新增“年度保养”任务频率。
- é¢‘率编码:`YEARLY`
- å‰ç«¯æ˜¾ç¤ºåç§°ï¼š`年度保养`
- åŸºç¡€è·¯å¾„:`/deviceMaintenanceTask`
- é€‚用接口:新增、修改、查询设备保养定时任务
## 2. æŽ¥å£æ¸…单
| è¯·æ±‚方式 | æŽ¥å£ | è¯´æ˜Ž |
|---|---|---|
| `GET` | `/deviceMaintenanceTask/listPage` | æŸ¥è¯¢è®¾å¤‡ä¿å…»å®šæ—¶ä»»åŠ¡ |
| `POST` | `/deviceMaintenanceTask/add` | æ–°å¢žè®¾å¤‡ä¿å…»å®šæ—¶ä»»åŠ¡ |
| `POST` | `/deviceMaintenanceTask/update` | ä¿®æ”¹è®¾å¤‡ä¿å…»å®šæ—¶ä»»åŠ¡ |
| `DELETE` | `/deviceMaintenanceTask/delete` | åˆ é™¤è®¾å¤‡ä¿å…»å®šæ—¶ä»»åŠ¡ |
## 3. é¢‘率选项
前端任务频率下拉框增加以下选项:
| æ˜¾ç¤ºåç§° | æäº¤å€¼ |
|---|---|
| æ¯æ—¥ä¿å…» | `DAILY` |
| æ¯å‘¨ä¿å…» | `WEEKLY` |
| æ¯æœˆä¿å…» | `MONTHLY` |
| æ¯å­£åº¦ä¿å…» | `QUARTERLY` |
| å¹´åº¦ä¿å…» | `YEARLY` |
`frequencyType` å¿…须提交编码值,不要提交中文名称。
## 4. å¹´åº¦ä¿å…»å‚数格式
年度保养的 `frequencyDetail` æ ¼å¼ä¸ºï¼š
```text
月份,日期,时间
```
示例:
```text
12,31,09:00
```
参数说明:
| ä½ç½® | å†…容 | æ ¼å¼ | ç¤ºä¾‹ |
|---|---|---|---|
| ç¬¬ 1 æ®µ | æœˆä»½ | `1-12` | `12` |
| ç¬¬ 2 æ®µ | æ—¥æœŸ | `1-31` | `31` |
| ç¬¬ 3 æ®µ | æ‰§è¡Œæ—¶é—´ | `HH:mm` | `09:00` |
前端建议使用“月份选择器 + æ—¥æœŸé€‰æ‹©å™¨ + æ—¶é—´é€‰æ‹©å™¨â€ç”Ÿæˆè¯¥å­—符串,不要让用户手工输入逗号分隔格式。
## 5. æ–°å¢žè¯·æ±‚示例
接口:`POST /deviceMaintenanceTask/add`
```json
{
  "taskName": "空压机年度保养任务",
  "taskId": 1001,
  "deviceModel": "C-01",
  "machineryCategory": "生产设备",
  "maintenancePerson": "张三",
  "frequencyType": "YEARLY",
  "frequencyDetail": "12,31,09:00",
  "remarks": "每年年末保养"
}
```
新增成功后,后端会根据频率详情计算 `nextExecutionTime`,并创建年度 Quartz å®šæ—¶ä»»åŠ¡ã€‚
## 6. ä¿®æ”¹è¯·æ±‚示例
接口:`POST /deviceMaintenanceTask/update`
```json
{
  "id": 1,
  "taskName": "空压机年度保养任务",
  "taskId": 1001,
  "maintenancePerson": "张三",
  "frequencyType": "YEARLY",
  "frequencyDetail": "6,15,14:30",
  "remarks": "每年六月保养"
}
```
修改频率或频率详情后,后端会重新生成定时任务触发器。
## 7. æŸ¥è¯¢è¿”回示例
接口:`GET /deviceMaintenanceTask/listPage`
年度任务返回示例:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "records": [
      {
        "id": 1,
        "taskName": "空压机年度保养任务",
        "taskId": 1001,
        "maintenancePerson": "张三",
        "frequencyType": "YEARLY",
        "frequencyDetail": "12,31,09:00",
        "nextExecutionTime": "2026-12-31 09:00:00",
        "lastExecutionTime": null,
        "isActive": true,
        "remarks": "每年年末保养"
      }
    ],
    "total": 1
  }
}
```
列表展示时建议将:
- `YEARLY` æ˜¾ç¤ºä¸ºâ€œå¹´åº¦ä¿å…»â€
- `12,31,09:00` æ˜¾ç¤ºä¸ºâ€œæ¯å¹´12月31日 09:00”
## 8. å®šæ—¶æ‰§è¡Œè§„则
1. å¹´åº¦ä»»åŠ¡é¦–æ¬¡æ‰§è¡Œæ—¶é—´å–å½“å‰å¹´ä»½ä¸­é…ç½®çš„æœˆä»½ã€æ—¥æœŸå’Œæ—¶é—´ã€‚
2. å¦‚果当前年份的执行时间已经过去,则首次执行安排到下一年度。
3. ä»»åŠ¡æ‰§è¡ŒåŽï¼ŒåŽç«¯è‡ªåŠ¨è®¡ç®—ä¸‹ä¸€å¹´åº¦æ‰§è¡Œæ—¶é—´ã€‚
4. é…ç½®æ—¥æœŸè¶…过目标月份实际天数时,按目标月份最后一天执行。例如:
   - `2,31,09:00` æŒ‰ 2 æœˆæœ€åŽä¸€å¤©æ‰§è¡Œã€‚
5. ä¿å…»ä»»åŠ¡æ‰§è¡ŒåŽä¼šè‡ªåŠ¨ç”Ÿæˆè®¾å¤‡ä¿å…»è®°å½•ï¼Œå¹¶åŒæ­¥ä»¥ä¸‹å­—æ®µï¼š
   - `frequencyType = YEARLY`
   - `frequencyDetail = å¹´åº¦ä»»åŠ¡é…ç½®å€¼`
   - `maintenanceActuallyName = maintenancePerson`
## 9. å‰ç«¯æ ¡éªŒè¦æ±‚
提交年度保养任务前应校验:
- æœˆä»½èŒƒå›´ä¸º `1-12`
- æ—¥æœŸèŒƒå›´ä¸º `1-31`
- æ—¶é—´æ ¼å¼ä¸º `HH:mm`
- `frequencyDetail` å¿…须为三段逗号分隔值
- `frequencyType` æäº¤ `YEARLY`
推荐错误提示:
- â€œå¹´åº¦ä¿å…»æ ¼å¼åº”为:月份,日期,时间,例如 12,31,09:00”
- â€œæœˆä»½å¿…须在1-12之间”
- â€œæ—¥æœŸå¿…须在1-31之间”
- â€œæ—¶é—´æ ¼å¼å¿…须为 HH:mm”
## 10. è”调测试清单
- [ ] æ–°å¢žé¢‘率下拉框显示“年度保养”。
- [ ] æ–°å¢žä»»åŠ¡æäº¤ `frequencyType=YEARLY`。
- [ ] æ–°å¢žä»»åŠ¡æäº¤ `frequencyDetail=12,31,09:00` æˆåŠŸã€‚
- [ ] åˆ—表中正确显示“年度保养”。
- [ ] ç¼–辑年度任务并修改月份、日期、时间成功。
- [ ] ç¼–辑后 `nextExecutionTime` æŒ‰æ–°é…ç½®é‡æ–°è®¡ç®—。
- [ ] æ ¡éªŒæœˆä»½ä¸º 0 æˆ– 13 æ—¶ä¸èƒ½æäº¤ã€‚
- [ ] æ ¡éªŒæ—¥æœŸä¸º 0 æˆ– 32 æ—¶ä¸èƒ½æäº¤ã€‚
- [ ] æ ¡éªŒæ—¶é—´æ ¼å¼é”™è¯¯æ—¶ä¸èƒ½æäº¤ã€‚
- [ ] ä½¿ç”¨ `2,31,09:00` éªŒè¯æœˆæœ«æ—¥æœŸå¤„理符合产品预期。
sql/migration_device_area.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
-- è®¾å¤‡åŒºåŸŸï¼ˆè½¦é—´/位置)父子递归结构
CREATE TABLE IF NOT EXISTS `device_area` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `name` varchar(255) NOT NULL COMMENT '区域/车间/位置名称',
    `parent_id` bigint NULL COMMENT '父级区域ID,根节点为NULL',
    `ancestors` varchar(1000) NULL COMMENT '祖级路径,如 1,2,3',
    `sort` int NULL COMMENT '排序',
    `dept_id` bigint NULL COMMENT '部门ID',
    `tenant_id` bigint NULL COMMENT '租户ID',
    `create_user` int NULL COMMENT '创建人',
    `create_time` datetime NULL COMMENT '创建时间',
    `update_user` int NULL COMMENT '更新人',
    `update_time` datetime NULL COMMENT '更新时间',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备区域表(车间/位置)';
-- è®¾å¤‡å°è´¦å…³è”区域(幂等:已存在列时跳过)
SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'device_ledger' AND COLUMN_NAME = 'area_id');
SET @ddl = IF(@col_exists = 0,
    'ALTER TABLE `device_ledger` ADD COLUMN `area_id` bigint NULL COMMENT ''设备区域ID'' AFTER `dept_id`',
    'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
sql/migration_framework_contract.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
-- æ¡†æž¶åˆåŒä¸Žæ¡†æž¶è®¢å•关系(幂等:已存在的列/字段跳过,可安全重复执行)
-- sales_ledger.contract_type / parent_contract_id
SET @c1 = (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sales_ledger' AND COLUMN_NAME = 'contract_type');
SET @ddl1 = IF(@c1 = 0,
    'ALTER TABLE `sales_ledger` ADD COLUMN `contract_type` tinyint NOT NULL DEFAULT 1 COMMENT ''合同类型:1普通销售合同,2框架合同,3框架订单'' AFTER `sales_contract_no`',
    'SELECT 1');
PREPARE stmt1 FROM @ddl1;
EXECUTE stmt1;
DEALLOCATE PREPARE stmt1;
SET @c2 = (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sales_ledger' AND COLUMN_NAME = 'parent_contract_id');
SET @ddl2 = IF(@c2 = 0,
    'ALTER TABLE `sales_ledger` ADD COLUMN `parent_contract_id` bigint NULL COMMENT ''父框架合同ID'' AFTER `contract_type`',
    'SELECT 1');
PREPARE stmt2 FROM @ddl2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;
-- sales_ledger_product.quantity å…è®¸ä¸ºç©º
SET @c3 = (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sales_ledger_product' AND COLUMN_NAME = 'quantity'
      AND IS_NULLABLE = 'NO');
SET @ddl3 = IF(@c3 = 1,
    'ALTER TABLE `sales_ledger_product` MODIFY COLUMN `quantity` decimal(18, 2) NULL COMMENT ''数量,框架合同和框架订单不记录数量,实际发货数量记录在发货明细''',
    'SELECT 1');
PREPARE stmt3 FROM @ddl3;
EXECUTE stmt3;
DEALLOCATE PREPARE stmt3;
-- sales_ledger_product.parent_product_id
SET @c4 = (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sales_ledger_product' AND COLUMN_NAME = 'parent_product_id');
SET @ddl4 = IF(@c4 = 0,
    'ALTER TABLE `sales_ledger_product` ADD COLUMN `parent_product_id` bigint NULL COMMENT ''父框架合同产品ID'' AFTER `sales_ledger_id`',
    'SELECT 1');
PREPARE stmt4 FROM @ddl4;
EXECUTE stmt4;
DEALLOCATE PREPARE stmt4;
-- sales_ledger_product.delivery_date
SET @c5 = (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sales_ledger_product' AND COLUMN_NAME = 'delivery_date');
SET @ddl5 = IF(@c5 = 0,
    'ALTER TABLE `sales_ledger_product` ADD COLUMN `delivery_date` date NULL COMMENT ''框架订单交货日期'' AFTER `parent_product_id`',
    'SELECT 1');
PREPARE stmt5 FROM @ddl5;
EXECUTE stmt5;
DEALLOCATE PREPARE stmt5;
src/main/java/com/ruoyi/basic/service/impl/CustomerServiceImpl.java
@@ -3,6 +3,7 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -365,20 +366,34 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void recycleCustomer(CustomerDto customerDto) {
        if (customerDto == null || customerDto.getId() == null) {
            throw new RuntimeException("客户ID不能为空");
        }
        Customer customer = customerMapper.selectById(customerDto.getId());
        if (customer.getType() == 1 && customer.getIsAssigned() == 1) {  // å…¬æµ·ä¸”已分配
        if (customer == null) {
            throw new RuntimeException("客户档案不存在");
        }
        boolean assigned = Objects.equals(customer.getIsAssigned(), 1)
                || Objects.equals(customer.getUsageStatus(), 1L)
                || (customer.getUsageUser() != null && customer.getUsageUser() > 0);
        if (!assigned) {
            throw new RuntimeException("该客户当前未处于领用状态,无法回收");
        }
            validatePublicSeaRecyclePermission(customer.getId());
            customer.setIsAssigned(0);
            customer.setUsageStatus(0L);
            customer.setUsageUser(0L);
            customerMapper.updateById(customer);
        int updated = customerMapper.update(null, new UpdateWrapper<Customer>()
                .eq("id", customer.getId())
                .set("is_assigned", 0)
                .set("usage_status", 0)
                .set("usage_user", null));
        if (updated == 0) {
            throw new RuntimeException("客户回收失败,请刷新后重试");
        }
            // åˆ é™¤è¯¥å®¢æˆ·çš„æ‰€æœ‰å…±äº«å…³ç³»
            customerUserService.remove(
                new QueryWrapper<CustomerUser>().lambda()
                    .eq(CustomerUser::getCustomerId, customerDto.getId())
            );
        }
    }
    // å®¢æˆ·å…±äº«
src/main/java/com/ruoyi/device/controller/DeviceAreaController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package com.ruoyi.device.controller;
import com.ruoyi.device.dto.DeviceAreaDto;
import com.ruoyi.device.service.IDeviceAreaService;
import com.ruoyi.framework.web.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "设备区域管理")
@RequestMapping("/device/area")
@RestController
@AllArgsConstructor
public class DeviceAreaController {
    private final IDeviceAreaService deviceAreaService;
    @Operation(summary = "查询设备区域树(含递归设备数量)")
    @GetMapping("/tree")
    public AjaxResult tree() {
        return AjaxResult.success(deviceAreaService.selectAreaTree());
    }
    @Operation(summary = "新增设备区域")
    @PostMapping()
    public AjaxResult add(@RequestBody DeviceAreaDto dto) {
        return deviceAreaService.addArea(dto);
    }
    @Operation(summary = "修改设备区域")
    @PutMapping()
    public AjaxResult update(@RequestBody DeviceAreaDto dto) {
        return deviceAreaService.updateArea(dto);
    }
    @Operation(summary = "删除设备区域")
    @DeleteMapping("/{ids}")
    public AjaxResult delete(@PathVariable("ids") List<Long> ids) {
        return deviceAreaService.deleteArea(ids);
    }
}
src/main/java/com/ruoyi/device/controller/DeviceLedgerController.java
@@ -1,7 +1,6 @@
package com.ruoyi.device.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.device.dto.DeviceLedgerDto;
@@ -95,10 +94,9 @@
    @GetMapping("getDeviceLedger")
    @Operation(summary = "获取设备台账")
    public AjaxResult getDeviceLedger() {
        return AjaxResult.success(deviceLedgerService.list(new QueryWrapper<DeviceLedger>().lambda()
                .select(DeviceLedger::getId, DeviceLedger::getDeviceName, DeviceLedger::getDeviceModel)));
    @Operation(summary = "获取设备台账(巡检选择设备,支持按区域筛选)")
    public AjaxResult getDeviceLedger(Long areaId) {
        return AjaxResult.success(deviceLedgerService.selectDeviceOptions(areaId));
    }
    @GetMapping("scanDevice")
src/main/java/com/ruoyi/device/dto/DeviceAreaDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.ruoyi.device.dto;
import lombok.Data;
/**
 * è®¾å¤‡åŒºåŸŸ DTO
 */
@Data
public class DeviceAreaDto {
    /**
     * ä¸»é”®ID
     */
    private Long id;
    /**
     * åŒºåŸŸ/车间/位置名称
     */
    private String name;
    /**
     * çˆ¶çº§åŒºåŸŸID
     */
    private Long parentId;
    /**
     * æŽ’序
     */
    private Integer sort;
    /**
     * éƒ¨é—¨ID
     */
    private Long deptId;
}
src/main/java/com/ruoyi/device/dto/DeviceLedgerDto.java
@@ -162,4 +162,15 @@
    @Schema(description = "设备类型")
    private String type;
    /**
     * è®¾å¤‡åŒºåŸŸID
     */
    private Long areaId;
    /**
     * è®¾å¤‡åŒºåŸŸåç§°ï¼ˆéžæ•°æ®åº“字段,列表展示)
     */
    @TableField(exist = false)
    private String areaName;
}
src/main/java/com/ruoyi/device/mapper/DeviceAreaMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package com.ruoyi.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.device.pojo.DeviceArea;
import org.apache.ibatis.annotations.Mapper;
/**
 * è®¾å¤‡åŒºåŸŸ Mapper
 */
@Mapper
public interface DeviceAreaMapper extends BaseMapper<DeviceArea> {
}
src/main/java/com/ruoyi/device/mapper/DeviceLedgerMapper.java
@@ -19,6 +19,12 @@
    IPage<DeviceLedgerDto> queryPage(Page page, @Param("deviceLedger") DeviceLedgerDto deviceLedgerDto);
    /**
     * è®¾å¤‡é€‰æ‹©åˆ—表(巡检等模块选设备用),支持按区域递归筛选
     * @param areaId åŒºåŸŸID,可为空表示全部
     */
    List<DeviceLedgerDto> selectDeviceOptions(@Param("areaId") Long areaId);
    List<DeviceLedgerExeclDto> deviceLedgerExportList(DeviceLedger deviceLedger);
    @InterceptorIgnore(tenantLine = "true")
src/main/java/com/ruoyi/device/pojo/DeviceArea.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
package com.ruoyi.device.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/**
 * è®¾å¤‡åŒºåŸŸå®žä½“类(车间/位置,父子递归)
 */
@Data
@TableName("device_area")
@Schema
public class DeviceArea {
    /**
     * ä¸»é”®ID,自增
     */
    private Long id;
    /**
     * åŒºåŸŸ/车间/位置名称
     */
    @Schema(description = "区域/车间/位置名称")
    private String name;
    /**
     * çˆ¶çº§åŒºåŸŸID,根节点为NULL
     */
    @Schema(description = "父级区域ID")
    private Long parentId;
    /**
     * ç¥–级路径,如 1,2,3
     */
    @Schema(description = "祖级路径")
    private String ancestors;
    /**
     * æŽ’序
     */
    @Schema(description = "排序")
    private Integer sort;
    /**
     * éƒ¨é—¨ID
     */
    private Long deptId;
    /**
     * ç§Ÿæˆ·ID
     */
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    /**
     * åˆ›å»ºäºº
     */
    @TableField(fill = FieldFill.INSERT)
    private Integer createUser;
    /**
     * åˆ›å»ºæ—¶é—´
     */
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
    /**
     * æ›´æ–°äºº
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Integer updateUser;
    /**
     * æ›´æ–°æ—¶é—´
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
}
src/main/java/com/ruoyi/device/pojo/DeviceLedger.java
@@ -161,4 +161,15 @@
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    /**
     * è®¾å¤‡åŒºåŸŸID
     */
    private Long areaId;
    /**
     * è®¾å¤‡åŒºåŸŸåç§°ï¼ˆéžæ•°æ®åº“字段,列表展示)
     */
    @TableField(exist = false)
    private String areaName;
}
src/main/java/com/ruoyi/device/service/IDeviceAreaService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.ruoyi.device.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.device.dto.DeviceAreaDto;
import com.ruoyi.device.pojo.DeviceArea;
import com.ruoyi.device.vo.DeviceAreaTreeDto;
import com.ruoyi.framework.web.domain.AjaxResult;
import java.util.List;
/**
 * è®¾å¤‡åŒºåŸŸ Service
 */
public interface IDeviceAreaService extends IService<DeviceArea> {
    /**
     * æŸ¥è¯¢åŒºåŸŸæ ‘(含各节点递归设备数量)
     */
    List<DeviceAreaTreeDto> selectAreaTree();
    /**
     * æ–°å¢žåŒºåŸŸ
     */
    AjaxResult addArea(DeviceAreaDto dto);
    /**
     * ä¿®æ”¹åŒºåŸŸ
     */
    AjaxResult updateArea(DeviceAreaDto dto);
    /**
     * åˆ é™¤åŒºåŸŸï¼ˆæœ‰å­çº§æˆ–关联设备时禁止删除)
     */
    AjaxResult deleteArea(List<Long> ids);
}
src/main/java/com/ruoyi/device/service/IDeviceLedgerService.java
@@ -10,10 +10,16 @@
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
public interface IDeviceLedgerService  extends IService<DeviceLedger> {
    IPage<DeviceLedgerDto> queryPage(Page page, DeviceLedgerDto deviceLedger);
    /**
     * è®¾å¤‡é€‰æ‹©åˆ—表(巡检等模块选设备用),支持按区域递归筛选
     */
    List<DeviceLedgerDto> selectDeviceOptions(Long areaId);
    AjaxResult saveDeviceLedger(DeviceLedgerDto deviceLedgerDto);
    AjaxResult updateDeviceLedger(DeviceLedgerDto deviceLedgerDto);
src/main/java/com/ruoyi/device/service/impl/DeviceAreaServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,194 @@
package com.ruoyi.device.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.device.dto.DeviceAreaDto;
import com.ruoyi.device.mapper.DeviceAreaMapper;
import com.ruoyi.device.mapper.DeviceLedgerMapper;
import com.ruoyi.device.pojo.DeviceArea;
import com.ruoyi.device.pojo.DeviceLedger;
import com.ruoyi.device.service.IDeviceAreaService;
import com.ruoyi.device.vo.DeviceAreaTreeDto;
import com.ruoyi.framework.web.domain.AjaxResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
 * è®¾å¤‡åŒºåŸŸ Service å®žçް
 */
@Service
@RequiredArgsConstructor
public class DeviceAreaServiceImpl extends ServiceImpl<DeviceAreaMapper, DeviceArea> implements IDeviceAreaService {
    private final DeviceAreaMapper deviceAreaMapper;
    private final DeviceLedgerMapper deviceLedgerMapper;
    @Override
    public List<DeviceAreaTreeDto> selectAreaTree() {
        List<DeviceArea> all = this.list(new LambdaQueryWrapper<DeviceArea>().orderByAsc(DeviceArea::getSort));
        if (all == null || all.isEmpty()) {
            return new ArrayList<>();
        }
        // ç»Ÿè®¡æ¯ä¸ªåŒºåŸŸç›´æŽ¥æŒ‚载的设备数量
        Map<Long, Long> directCountMap = deviceLedgerMapper.selectList(
                        new LambdaQueryWrapper<DeviceLedger>()
                                .select(DeviceLedger::getAreaId)
                                .isNotNull(DeviceLedger::getAreaId))
                .stream()
                .filter(d -> d.getAreaId() != null)
                .collect(Collectors.groupingBy(DeviceLedger::getAreaId, Collectors.counting()));
        Map<Long, DeviceArea> areaMap = all.stream()
                .collect(Collectors.toMap(DeviceArea::getId, a -> a));
        // é€’归汇总每个节点的设备数量(含所有子级)
        Map<Long, Long> countMap = directCountMap.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        for (DeviceArea area : all) {
            accumulate(area.getId(), directCountMap.getOrDefault(area.getId(), 0L), countMap, areaMap);
        }
        List<Long> rootIds = all.stream()
                .filter(a -> a.getParentId() == null || !areaMap.containsKey(a.getParentId()))
                .map(DeviceArea::getId)
                .collect(Collectors.toList());
        List<DeviceAreaTreeDto> tree = new ArrayList<>();
        for (Long rootId : rootIds) {
            DeviceArea root = areaMap.get(rootId);
            tree.add(buildNode(root, all, countMap));
        }
        return tree;
    }
    // å°†å½“前节点数量累加到所有祖级节点
    private void accumulate(Long areaId, Long count, Map<Long, Long> countMap, Map<Long, DeviceArea> areaMap) {
        DeviceArea area = areaMap.get(areaId);
        if (area == null) {
            return;
        }
        countMap.merge(areaId, count, Long::sum);
        // æ²¿ç¥–先链继续累加(避免祖先链循环依赖)
        if (area.getParentId() != null && areaMap.containsKey(area.getParentId())) {
            accumulate(area.getParentId(), count, countMap, areaMap);
        }
    }
    private DeviceAreaTreeDto buildNode(DeviceArea area, List<DeviceArea> all, Map<Long, Long> countMap) {
        DeviceAreaTreeDto node = new DeviceAreaTreeDto();
        node.setId(area.getId());
        node.setParentId(area.getParentId());
        node.setLabel(area.getName());
        node.setDeviceCount(countMap.getOrDefault(area.getId(), 0L));
        for (DeviceArea child : all) {
            if (area.getId().equals(child.getParentId())) {
                node.getChildren().add(buildNode(child, all, countMap));
            }
        }
        return node;
    }
    @Override
    public AjaxResult addArea(DeviceAreaDto dto) {
        if (StringUtils.isEmpty(dto.getName())) {
            return AjaxResult.error("区域名称不能为空");
        }
        DeviceArea area = new DeviceArea();
        area.setName(dto.getName());
        area.setParentId(dto.getParentId());
        area.setSort(dto.getSort());
        area.setDeptId(dto.getDeptId());
        area.setTenantId(SecurityUtils.getLoginUser().getTenantId());
        if (dto.getParentId() != null) {
            DeviceArea parent = this.getById(dto.getParentId());
            if (parent == null) {
                return AjaxResult.error("父级区域不存在");
            }
            area.setAncestors(StringUtils.isEmpty(parent.getAncestors())
                    ? String.valueOf(parent.getId())
                    : parent.getAncestors() + "," + parent.getId());
        }
        this.save(area);
        return AjaxResult.success();
    }
    @Override
    public AjaxResult updateArea(DeviceAreaDto dto) {
        if (dto.getId() == null) {
            return AjaxResult.error("区域ID不能为空");
        }
        DeviceArea area = this.getById(dto.getId());
        if (area == null) {
            return AjaxResult.error("区域不存在");
        }
        // ä¸å…è®¸æŠŠåŒºåŸŸæŒ‚到自己的子级下,避免循环
        if (dto.getParentId() != null && dto.getParentId().equals(dto.getId())) {
            return AjaxResult.error("不能将区域挂载到自身");
        }
        if (dto.getParentId() != null && isDescendant(dto.getId(), dto.getParentId())) {
            return AjaxResult.error("不能将区域挂载到自己的子级下");
        }
        area.setName(dto.getName());
        area.setSort(dto.getSort());
        area.setDeptId(dto.getDeptId());
        if (!java.util.Objects.equals(dto.getParentId(), area.getParentId())) {
            area.setParentId(dto.getParentId());
            if (dto.getParentId() == null) {
                area.setAncestors(null);
            } else {
                DeviceArea parent = this.getById(dto.getParentId());
                if (parent == null) {
                    return AjaxResult.error("父级区域不存在");
                }
                area.setAncestors(StringUtils.isEmpty(parent.getAncestors())
                        ? String.valueOf(parent.getId())
                        : parent.getAncestors() + "," + parent.getId());
            }
        }
        this.updateById(area);
        return AjaxResult.success();
    }
    private boolean isDescendant(Long targetId, Long candidateParentId) {
        DeviceArea cur = this.getById(candidateParentId);
        while (cur != null && cur.getParentId() != null) {
            if (cur.getParentId().equals(targetId)) {
                return true;
            }
            cur = this.getById(cur.getParentId());
        }
        return false;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult deleteArea(List<Long> ids) {
        for (Long id : ids) {
            DeviceArea area = this.getById(id);
            if (area == null) {
                return AjaxResult.error("区域不存在");
            }
            long childCount = this.count(new LambdaQueryWrapper<DeviceArea>().eq(DeviceArea::getParentId, id));
            if (childCount > 0) {
                return AjaxResult.error("该区域存在子级区域,请先删除子级");
            }
            long deviceCount = deviceLedgerMapper.selectCount(
                    new LambdaQueryWrapper<DeviceLedger>().eq(DeviceLedger::getAreaId, id));
            if (deviceCount > 0) {
                return AjaxResult.error("该区域下存在设备,请先移除设备关联");
            }
        }
        this.removeByIds(ids);
        return AjaxResult.success();
    }
}
src/main/java/com/ruoyi/device/service/impl/DeviceLedgerServiceImpl.java
@@ -48,6 +48,11 @@
    }
    @Override
    public List<DeviceLedgerDto> selectDeviceOptions(Long areaId) {
        return deviceLedgerMapper.selectDeviceOptions(areaId);
    }
    @Override
    public AjaxResult saveDeviceLedger(DeviceLedgerDto deviceLedgerDto) {
        LambdaQueryWrapper<DeviceLedger> deviceLedgerLambdaQueryWrapper = new LambdaQueryWrapper<>();
        deviceLedgerLambdaQueryWrapper.eq(DeviceLedger::getDeviceName,deviceLedgerDto.getDeviceName());
src/main/java/com/ruoyi/device/service/impl/MaintenanceTaskJob.java
@@ -124,6 +124,8 @@
                    return calculateMonthlyNextTime(frequencyDetail, currentTime);
                case "QUARTERLY":
                    return calculateQuarterlyNextTime(frequencyDetail, currentTime);
                case "YEARLY":
                    return calculateYearlyNextTime(frequencyDetail, currentTime);
                default:
                    throw new IllegalArgumentException("不支持的频率类型: " + frequencyType);
            }
@@ -221,6 +223,23 @@
        );
    }
    private LocalDateTime calculateYearlyNextTime(String detail, LocalDateTime current) {
        String[] parts = detail.split(",");
        int month = Integer.parseInt(parts[0]);
        int day = Integer.parseInt(parts[1]);
        LocalTime time = LocalTime.parse(parts[2]);
        int year = current.getYear();
        LocalDateTime nextTime = LocalDateTime.of(java.time.LocalDate.of(year, month,
                Math.min(day, YearMonth.of(year, month).lengthOfMonth())), time);
        if (!nextTime.isAfter(current)) {
            year++;
            nextTime = LocalDateTime.of(java.time.LocalDate.of(year, month,
                    Math.min(day, YearMonth.of(year, month).lengthOfMonth())), time);
        }
        return nextTime;
    }
    /**
     * è§£æžæ˜ŸæœŸå‡ å­—符串
     */
src/main/java/com/ruoyi/device/service/impl/MaintenanceTaskScheduler.java
@@ -160,6 +160,8 @@
                return convertMonthlyToCron(task.getFrequencyDetail());
            case "QUARTERLY":
                return convertQuarterlyToCron(task.getFrequencyDetail());
            case "YEARLY":
                return convertYearlyToCron(task.getFrequencyDetail());
            default:
                throw new IllegalArgumentException("不支持的频率类型: " + task.getFrequencyType());
        }
@@ -204,6 +206,14 @@
                quarterStartMonth);
    }
    private String convertYearlyToCron(String frequencyDetail) {
        String[] parts = validateAndSplit(frequencyDetail, ",", 3);
        int month = validateMonth(parts[0]);
        int day = validateDayOfMonth(parts[1]);
        LocalTime time = parseTime(parts[2]);
        return String.format("0 %d %d %d %d ?", time.getMinute(), time.getHour(), day, month);
    }
    // æ–°å¢žéªŒè¯æœˆä»½çš„æ–¹æ³•(1-12)
    private int validateMonth(String monthStr) {
        try {
src/main/java/com/ruoyi/device/vo/DeviceAreaTreeDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package com.ruoyi.device.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
 * è®¾å¤‡åŒºåŸŸæ ‘ VO(前端 el-tree ç»“构)
 */
@Data
public class DeviceAreaTreeDto {
    private Long id;
    private Long parentId;
    /**
     * èŠ‚ç‚¹åç§°
     */
    private String label;
    /**
     * å½“前节点及所有子级区域下的设备数量
     */
    private Long deviceCount;
    private List<DeviceAreaTreeDto> children = new ArrayList<>();
}
src/main/java/com/ruoyi/inspectiontask/service/impl/InspectionTaskServiceImpl.java
@@ -161,6 +161,7 @@
            }
        }
        validateInspectionInput(inspectionTaskDto, oldInspectionTask);
        validateDeviceExists(inspectionTaskDto, oldInspectionTask);
        InspectionTask inspectionTask = new InspectionTask();
        BeanUtils.copyProperties(inspectionTaskDto, inspectionTask);
@@ -190,6 +191,20 @@
        return i;
    }
    private void validateDeviceExists(InspectionTaskDto inspectionTaskDto, InspectionTask oldInspectionTask) {
        Integer taskId = inspectionTaskDto.getTaskId();
        if (taskId == null && oldInspectionTask != null) {
            taskId = oldInspectionTask.getTaskId();
        }
        if (taskId == null || taskId <= 0) {
            return;
        }
        DeviceLedger deviceLedger = deviceLedgerMapper.selectById(taskId.longValue());
        if (deviceLedger == null) {
            throw new IllegalArgumentException("所选设备不存在,请重新选择");
        }
    }
    private void validateInspectionInput(InspectionTaskDto inspectionTaskDto, InspectionTask oldInspectionTask) {
        String inspectionResult = inspectionTaskDto.getInspectionResult();
        if (StringUtils.isBlank(inspectionResult) && oldInspectionTask != null) {
src/main/java/com/ruoyi/inspectiontask/service/impl/TimingTaskServiceImpl.java
@@ -35,6 +35,7 @@
    private final TimingTaskMapper timingTaskMapper;
    private final TimingTaskScheduler timingTaskScheduler;
    private final SysUserMapper sysUserMapper;
    private final com.ruoyi.device.mapper.DeviceLedgerMapper deviceLedgerMapper;
    private static final int ENABLED = 1;
    private static final int DISABLED = 0;
@@ -131,6 +132,7 @@
                throw new IllegalArgumentException("定时任务不存在");
            }
        }
        validateDeviceExists(timingTaskDto, oldTimingTask);
        TimingTask timingTask = new TimingTask();
        BeanUtils.copyProperties(timingTaskDto, timingTask);
        timingTask.setIsEnabled(resolveEnabledValue(timingTask.getIsEnabled(), oldTimingTask));
@@ -181,6 +183,19 @@
        }
    }
    private void validateDeviceExists(TimingTaskDto timingTaskDto, TimingTask oldTimingTask) {
        Integer taskId = timingTaskDto.getTaskId();
        if (taskId == null && oldTimingTask != null) {
            taskId = oldTimingTask.getTaskId();
        }
        if (taskId == null || taskId <= 0) {
            return;
        }
        if (deviceLedgerMapper.selectById(taskId.longValue()) == null) {
            throw new IllegalArgumentException("所选设备不存在,请重新选择");
        }
    }
    public LocalDateTime calculateFirstExecutionTime(TimingTask task) {
        // æ ¹æ®é¢‘率类型和详情计算首次执行时间
        String frequencyType = task.getFrequencyType();
@@ -194,8 +209,9 @@
            // å¦‚果是每月执行,计算下个月的具体日期
            return calculateMonthlyFirstExecution(task.getFrequencyDetail());
        } else if ("QUARTERLY".equals(frequencyType)) {
            // è‡ªå®šä¹‰é¢‘率,如每小时、每30分钟等
            return calculateCustomFirstExecution(task.getFrequencyDetail());
        } else if ("YEARLY".equals(frequencyType)) {
            return calculateAnnualFirstExecution(task.getFrequencyDetail());
        } else {
            throw new IllegalArgumentException("不支持的频率类型: " + task.getFrequencyType());
        }
@@ -314,6 +330,32 @@
        return targetDateTime;
    }
    private LocalDateTime calculateAnnualFirstExecution(String frequencyDetail) {
        String[] parts = frequencyDetail.split(",");
        if (parts.length != 3) {
            throw new IllegalArgumentException("参数格式错误,应为'12,31,09:00'格式");
        }
        int month;
        int day;
        try {
            month = Integer.parseInt(parts[0].trim());
            day = Integer.parseInt(parts[1].trim());
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("月份或日期格式错误", e);
        }
        if (month < 1 || month > 12 || day < 1 || day > 31) {
            throw new IllegalArgumentException("月份必须在1-12之间,日期必须在1-31之间");
        }
        LocalTime time = LocalTime.parse(parts[2].trim(), DateTimeFormatter.ofPattern("HH:mm"));
        LocalDateTime now = LocalDateTime.now();
        int year = now.getYear();
        LocalDateTime target = LocalDateTime.of(LocalDate.of(year, month, Math.min(day, YearMonth.of(year, month).lengthOfMonth())), time);
        return target.isAfter(now) ? target : LocalDateTime.of(LocalDate.of(year + 1, month,
                Math.min(day, YearMonth.of(year + 1, month).lengthOfMonth())), time);
    }
    private LocalDateTime calculateCustomFirstExecution(String frequencyDetail) {
        return null;
    }
src/main/java/com/ruoyi/measuringinstrumentledger/controller/MeasuringInstrumentLedgerController.java
@@ -21,6 +21,7 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@@ -108,6 +109,19 @@
        return update ? AjaxResult.success("检定成功") : AjaxResult.error("检定失败");
    }
    @PostMapping("/downloadTemplate")
    @Operation(summary = "下载计量器具台账导入模板")
    public void downloadTemplate(HttpServletResponse response) {
        measuringInstrumentLedgerService.downloadTemplate(response);
    }
    @PostMapping("/import")
    @Operation(summary = "批量导入计量器具台账")
    @Log(title = "批量导入计量器具台账", businessType = BusinessType.IMPORT)
    public AjaxResult importData(@RequestParam("file") MultipartFile file) {
        return measuringInstrumentLedgerService.importData(file);
    }
    /**
     * å¯¼å‡ºè®¡é‡å™¨å…·å°è´¦
     */
src/main/java/com/ruoyi/measuringinstrumentledger/dto/MeasuringInstrumentLedgerImportDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package com.ruoyi.measuringinstrumentledger.dto;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import lombok.Data;
import java.util.Date;
@Data
public class MeasuringInstrumentLedgerImportDto {
    @Excel(name = "计量器具编号")
    private String code;
    @Excel(name = "计量器具名称")
    private String name;
    @Excel(name = "规格型号")
    private String model;
    @Excel(name = "最近一次检定日期", dateFormat = "yyyy-MM-dd")
    private Date mostDate;
    @Excel(name = "检定有效期")
    private Integer valid;
    @Excel(name = "录入日期", dateFormat = "yyyy-MM-dd")
    private Date recordDate;
    @Excel(name = "单位")
    private String unit;
    @Excel(name = "部门ID")
    private Long deptId;
    @Excel(name = "安装位置")
    private String instationLocation;
    @Excel(name = "检定周期")
    private String cycle;
}
src/main/java/com/ruoyi/measuringinstrumentledger/mapper/MeasuringInstrumentLedgerMapper.java
@@ -6,6 +6,7 @@
import com.ruoyi.measuringinstrumentledger.pojo.MeasuringInstrumentLedger;
import org.apache.ibatis.annotations.Param;
import java.util.Collection;
import java.util.List;
/**
@@ -24,4 +25,6 @@
    IPage<MeasuringInstrumentLedger> listPage(Page page,@Param("req") MeasuringInstrumentLedger measuringInstrumentLedger);
    List<MeasuringInstrumentLedger> listPage(@Param("req") MeasuringInstrumentLedger measuringInstrumentLedger);
    List<String> selectExistingCodes(@Param("tenantId") Long tenantId, @Param("codes") Collection<String> codes);
}
src/main/java/com/ruoyi/measuringinstrumentledger/service/MeasuringInstrumentLedgerService.java
@@ -3,8 +3,10 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.measuringinstrumentledger.dto.MeasuringInstrumentLedgerDto;
import com.ruoyi.measuringinstrumentledger.pojo.MeasuringInstrumentLedger;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@@ -34,6 +36,10 @@
    void export(HttpServletResponse response);
    AjaxResult importData(MultipartFile file);
    void downloadTemplate(HttpServletResponse response);
    /**
     * æ–°å¢ž
     *
src/main/java/com/ruoyi/measuringinstrumentledger/service/impl/MeasuringInstrumentLedgerServiceImpl.java
@@ -2,11 +2,14 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.enums.FileNameType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.measuringinstrumentledger.dto.MeasuringInstrumentLedgerImportDto;
import com.ruoyi.measuringinstrumentledger.dto.MeasuringInstrumentLedgerDto;
import com.ruoyi.measuringinstrumentledger.mapper.MeasuringInstrumentLedgerMapper;
import com.ruoyi.measuringinstrumentledger.mapper.MeasuringInstrumentLedgerRecordMapper;
@@ -25,7 +28,14 @@
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -89,6 +99,90 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult importData(MultipartFile file) {
        if (file == null || file.isEmpty()) {
            return AjaxResult.error("请上传 Excel æ–‡ä»¶");
        }
        String filename = file.getOriginalFilename();
        if (filename == null || !(filename.endsWith(".xls") || filename.endsWith(".xlsx"))) {
            return AjaxResult.error("只支持导入 xls æˆ– xlsx æ–‡ä»¶");
        }
        try {
            ExcelUtil<MeasuringInstrumentLedgerImportDto> util = new ExcelUtil<>(MeasuringInstrumentLedgerImportDto.class);
            List<MeasuringInstrumentLedgerImportDto> rows = util.importExcel(file.getInputStream());
            if (CollectionUtils.isEmpty(rows)) {
                return AjaxResult.error("Excel ä¸­æ²¡æœ‰å¯å¯¼å…¥çš„æ•°æ®");
            }
            Set<String> codes = new HashSet<>();
            Map<Integer, String> errors = new LinkedHashMap<>();
            List<MeasuringInstrumentLedger> ledgers = new ArrayList<>();
            Long tenantId = SecurityUtils.getLoginUser().getTenantId();
            Long userId = SecurityUtils.getUserId();
            String userName = SecurityUtils.getLoginUser().getNickName();
            for (int i = 0; i < rows.size(); i++) {
                MeasuringInstrumentLedgerImportDto row = rows.get(i);
                int excelRow = i + 2;
                String code = StringUtils.trim(row.getCode());
                String name = StringUtils.trim(row.getName());
                if (!StringUtils.hasText(code)) {
                    errors.put(excelRow, "计量器具编号不能为空");
                    continue;
                }
                if (!StringUtils.hasText(name)) {
                    errors.put(excelRow, "计量器具名称不能为空");
                    continue;
                }
                if (!codes.add(code)) {
                    errors.put(excelRow, "计量器具编号在 Excel ä¸­é‡å¤");
                    continue;
                }
                if (row.getValid() != null && row.getValid() <= 0) {
                    errors.put(excelRow, "检定有效期必须大于 0");
                    continue;
                }
                MeasuringInstrumentLedger ledger = new MeasuringInstrumentLedger();
                ledger.setCode(code);
                ledger.setName(name);
                ledger.setModel(StringUtils.trim(row.getModel()));
                ledger.setMostDate(row.getMostDate());
                ledger.setValid(row.getValid());
                ledger.setRecordDate(row.getRecordDate() == null ? new Date() : row.getRecordDate());
                ledger.setUnit(StringUtils.trim(row.getUnit()));
                ledger.setDeptId(row.getDeptId());
                ledger.setInstationLocation(StringUtils.trim(row.getInstationLocation()));
                ledger.setCycle(StringUtils.trim(row.getCycle()));
                ledger.setUserId(userId);
                ledger.setUserName(userName);
                ledger.setTenantId(tenantId);
                if (row.getMostDate() != null && row.getValid() != null) {
                    ledger.setNextDate(new Date(row.getMostDate().getTime() + 86400000L * row.getValid()));
                }
                ledgers.add(ledger);
            }
            if (!codes.isEmpty()) {
                for (String code : measuringInstrumentLedgerMapper.selectExistingCodes(tenantId, codes)) {
                    errors.put(-1, "计量器具编号已存在:" + code);
                }
            }
            if (!errors.isEmpty()) {
                return AjaxResult.error("导入校验失败:" + errors.values().stream().limit(100).collect(Collectors.joining(";")));
            }
            saveBatch(ledgers);
            return AjaxResult.success("成功导入 " + ledgers.size() + " æ¡æ•°æ®");
        } catch (Exception e) {
            log.error("计量器具台账导入失败", e);
            return AjaxResult.error("导入失败,请检查 Excel å†…容");
        }
    }
    @Override
    public void downloadTemplate(HttpServletResponse response) {
        ExcelUtil<MeasuringInstrumentLedgerImportDto> util = new ExcelUtil<>(MeasuringInstrumentLedgerImportDto.class);
        util.importTemplateExcel(response, "计量器具台账导入模板");
    }
    @Override
    public void export(HttpServletResponse response) {
        List<MeasuringInstrumentLedger> list = measuringInstrumentLedgerMapper.listPage(new MeasuringInstrumentLedger());
        ExcelUtil<MeasuringInstrumentLedger> util = new ExcelUtil<MeasuringInstrumentLedger>(MeasuringInstrumentLedger.class);
src/main/java/com/ruoyi/production/service/impl/ProductionPlanServiceImpl.java
@@ -24,6 +24,10 @@
import com.ruoyi.production.pojo.ProductionPlan;
import com.ruoyi.production.service.ProductionOrderService;
import com.ruoyi.production.service.ProductionPlanService;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -50,6 +54,8 @@
    private final ProductionOrderService productionOrderService;
    private final ProductModelMapper productModelMapper;
    private final ProductMapper productMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    @Override
    public IPage<ProductionPlanVo> listPage(Page<ProductionPlanDto> page, ProductionPlanDto productionPlanDto) {
@@ -151,6 +157,7 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean add(ProductionPlanDto dto) {
        validateSalesSource(dto);
        // æ–°å¢žä¸»ç”Ÿäº§è®¡åˆ’
        if (StringUtils.isBlank(dto.getMpsNo())) {
            String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
@@ -173,6 +180,7 @@
            throw new ServiceException("编辑失败,数据不能为空");
        }
        validateSalesSource(dto);
        ProductionPlan old = getById(dto.getId());
        if (old == null) {
            throw new ServiceException("编辑失败,生产计划不存在");
@@ -321,6 +329,32 @@
        util.exportExcel(response, exportList, "主生产计划");
    }
    private void validateSalesSource(ProductionPlanDto dto) {
        if (dto == null) {
            throw new ServiceException("生产计划不能为空");
        }
        if (dto.getSalesLedgerId() == null && dto.getSalesLedgerProductId() == null) {
            return;
        }
        if (dto.getSalesLedgerId() == null || dto.getSalesLedgerProductId() == null) {
            throw new ServiceException("销售台账和销售产品必须同时填写");
        }
        SalesLedgerProduct product = salesLedgerProductMapper.selectById(dto.getSalesLedgerProductId());
        if (product == null || !Objects.equals(product.getSalesLedgerId(), dto.getSalesLedgerId())) {
            throw new ServiceException("销售产品与销售台账不匹配");
        }
        SalesLedger ledger = salesLedgerMapper.selectById(dto.getSalesLedgerId());
        if (ledger == null || (!Objects.equals(ledger.getContractType(), 1)
                && !Objects.equals(ledger.getContractType(), 3))) {
            throw new ServiceException("生产计划只能关联普通销售产品或框架订单产品");
        }
        if (dto.getProductModelId() == null) {
            dto.setProductModelId(product.getProductModelId());
        } else if (!Objects.equals(dto.getProductModelId(), product.getProductModelId())) {
            throw new ServiceException("生产计划产品型号与销售产品不一致");
        }
    }
    /**
     * æ ¡éªŒä¸»ç”Ÿäº§è®¡åˆ’号唯一性,可通过 excludeId æŽ’除当前记录。
     */
src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java
@@ -166,6 +166,24 @@
        return toAjax(salesLedgerService.addOrUpdateSalesLedger(salesLedgerDto));
    }
    @PostMapping("/createFrameworkOrder")
    @Operation(summary = "创建框架订单")
    public AjaxResult createFrameworkOrder(@RequestBody SalesLedgerDto salesLedgerDto) {
        return toAjax(salesLedgerService.createFrameworkOrder(salesLedgerDto));
    }
    @GetMapping("/listFrameworkOrders")
    @Operation(summary = "查询框架合同下的框架订单")
    public AjaxResult listFrameworkOrders(@RequestParam("parentContractId") Long parentContractId) {
        return AjaxResult.success(salesLedgerService.listFrameworkOrders(parentContractId));
    }
    @GetMapping("/frameworkContractShippedQuantity")
    @Operation(summary = "查询框架合同累计已发货数量")
    public AjaxResult frameworkContractShippedQuantity(@RequestParam("parentContractId") Long parentContractId) {
        return AjaxResult.success(salesLedgerService.getFrameworkContractShippedQuantity(parentContractId));
    }
    /**
     * åˆ é™¤é”€å”®å°è´¦
     */
src/main/java/com/ruoyi/sales/dto/SalesLedgerDto.java
@@ -17,6 +17,8 @@
public class SalesLedgerDto {
    private Long id;
    private String salesContractNo;
    private Integer contractType = 1;
    private Long parentContractId;
    private String customerContractNo;
    private String projectName;
    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
src/main/java/com/ruoyi/sales/mapper/SalesLedgerMapper.java
@@ -90,4 +90,14 @@
    IPage<PurchaseReportVo> selectPurchaseReportVoPage(Page page, @Param("customerName") String customerName);
    /**
     * æŸ¥è¯¢æ¡†æž¶åˆåŒä¸‹çš„æ¡†æž¶è®¢å•列表,并关联汇总已发货数量
     */
    List<com.ruoyi.sales.vo.FrameworkOrderVo> listFrameworkOrders(@Param("parentContractId") Long parentContractId);
    /**
     * æŒ‰æ¡†æž¶åˆåŒç»Ÿè®¡ç´¯è®¡å‘货数量
     */
    BigDecimal sumShippedByParentContract(@Param("parentContractId") Long parentContractId);
}
src/main/java/com/ruoyi/sales/pojo/SalesLedger.java
@@ -35,6 +35,16 @@
    private String salesContractNo;
    /**
     * åˆåŒç±»åž‹ï¼š1普通销售合同,2框架合同,3框架订单
     */
    private Integer contractType = 1;
    /**
     * çˆ¶æ¡†æž¶åˆåŒID
     */
    private Long parentContractId;
    /**
     * å®¢æˆ·åˆåŒå·
     */
    @Excel(name = "客户合同号", type = Excel.Type.IMPORT)
src/main/java/com/ruoyi/sales/pojo/SalesLedgerProduct.java
@@ -39,6 +39,17 @@
    private Long salesLedgerId;
    /**
     * çˆ¶æ¡†æž¶åˆåŒäº§å“ID
     */
    private Long parentProductId;
    /**
     * æ¡†æž¶è®¢å•交货日期
     */
    @JsonFormat(pattern = "yyyy-MM-dd")
    private java.time.LocalDate deliveryDate;
    /**
     * é¢„警数量
     */
    private BigDecimal warnNum;
src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java
@@ -11,6 +11,7 @@
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.vo.FrameworkOrderVo;
import com.ruoyi.sales.vo.SalesLedgerVo;
import org.springframework.web.multipart.MultipartFile;
@@ -33,6 +34,12 @@
    int addOrUpdateSalesLedger(SalesLedgerDto salesLedgerDto);
    int createFrameworkOrder(SalesLedgerDto salesLedgerDto);
    List<FrameworkOrderVo> listFrameworkOrders(Long parentContractId);
    BigDecimal getFrameworkContractShippedQuantity(Long parentContractId);
    List<SalesLedgerProduct> getSalesLedgerProductListByRelateId(@NotNull Long relateId,@NotNull SaleEnum type);
    List<SalesLedgerProduct> getSalesLedgerProductListByIds(@Nullable List<Long> relateIds, @NotNull SaleEnum type);
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java
@@ -2,7 +2,9 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.procurementrecord.utils.StockUtils;
@@ -31,9 +33,9 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
@@ -227,22 +229,34 @@
     * æ–°å¢žç”Ÿäº§æ•°æ®
     */
    public void addProductionData(SalesLedgerProduct salesLedgerProduct) {
        //先判断该产品是否需要生产
        if (!BooleanUtils.isTrue(salesLedgerProduct.getIsProduction())) {
        SalesLedger salesLedger = salesLedgerMapper.selectById(salesLedgerProduct.getSalesLedgerId());
        if (salesLedger == null) {
            return;
        }
        SalesLedger salesLedger = salesLedgerMapper.selectById(salesLedgerProduct.getSalesLedgerId());
        boolean isFrameworkOrder = Objects.equals(salesLedger.getContractType(), 3);
        // æ¡†æž¶åˆåŒä¸ç”Ÿæˆç”Ÿäº§è®¡åˆ’;框架订单固定生成(数量留空,下发时填写)
        if (Objects.equals(salesLedger.getContractType(), 2)) {
            return;
        }
        if (!isFrameworkOrder && !BooleanUtils.isTrue(salesLedgerProduct.getIsProduction())) {
            return;
        }
        ProductionPlan productionPlan = new ProductionPlan();
        productionPlan.setSalesLedgerId(salesLedgerProduct.getSalesLedgerId());
        productionPlan.setSalesLedgerProductId(salesLedgerProduct.getId());
        productionPlan.setMpsNo(generateNextPlanNo(com.ruoyi.common.utils.DateUtils.toLocalDate(salesLedger.getEntryDate())
                .format(DateTimeFormatter.ofPattern("yyyyMMdd"))));
        // è®¡åˆ’单号日期前缀:登记日期为空时取当前日期
        java.time.LocalDate planDate = salesLedger.getEntryDate() != null
                ? com.ruoyi.common.utils.DateUtils.toLocalDate(salesLedger.getEntryDate())
                : java.time.LocalDate.now();
        productionPlan.setMpsNo(generateNextPlanNo(planDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"))));
        productionPlan.setProductModelId(salesLedgerProduct.getProductModelId());
        productionPlan.setQtyRequired(salesLedgerProduct.getQuantity());
        productionPlan.setSource("销售");
        productionPlan.setStatus(0);
        productionPlan.setRequiredDate(salesLedger.getDeliveryDate());//需求日期=交货日期
        productionPlan.setPromisedDeliveryDate(salesLedger.getDeliveryDate());//承诺日期=交货日期
        // éœ€æ±‚日期=交货日期,框架订单取产品交货日期,其余取主表交货日期
        LocalDate deliveryDate = isFrameworkOrder ? salesLedgerProduct.getDeliveryDate() : salesLedger.getDeliveryDate();
        productionPlan.setRequiredDate(deliveryDate);//需求日期=交货日期
        productionPlan.setPromisedDeliveryDate(deliveryDate);//承诺日期=交货日期
        productionPlanMapper.insert(productionPlan);
    }
@@ -287,20 +301,11 @@
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        try {
            S entity = mainEntityClass.getDeclaredConstructor().newInstance();
            Field idField = mainEntityClass.getDeclaredField("id");
            idField.setAccessible(true);
            idField.set(entity, mainId);
            Field amountField = mainEntityClass.getDeclaredField("contractAmount");
            amountField.setAccessible(true);
            amountField.set(entity, totalAmount);
            mainMapper.updateById(entity);
        } catch (Exception e) {
            throw new RuntimeException("动态更新主表金额失败", e);
        }
        // ä»…更新金额列,避免整实体 updateById æŠŠå…¶å®ƒéžç©ºå­—段(如 contract_type é»˜è®¤å€¼ 1)一并覆盖
        UpdateWrapper<S> wrapper = Wrappers.update();
        wrapper.eq("id", mainId)
                .set("contract_amount", totalAmount);
        mainMapper.update(null, wrapper);
    }
    @Override
    public R judgmentInventory(SalesLedgerProduct salesLedgerProduct) {
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
@@ -1,6 +1,7 @@
package com.ruoyi.sales.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
@@ -49,6 +50,7 @@
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.ShippingInfo;
import com.ruoyi.sales.service.ISalesLedgerService;
import com.ruoyi.sales.vo.FrameworkOrderVo;
import com.ruoyi.sales.vo.SalesLedgerVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -64,7 +66,6 @@
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
@@ -421,8 +422,7 @@
                    salesLedgerProduct.setApproveStatus(0);
                    salesLedgerProduct.setIsProduction(salesLedgerProductImportDto.getIsProduction() == 1);
                    salesLedgerProductMapper.insert(salesLedgerProduct);
                    // æ·»åŠ ç”Ÿäº§æ•°æ®
                    salesLedgerProductServiceImpl.addProductionData(salesLedgerProduct);
                    addProductionDataIfNeeded(salesLedgerProduct);
                }
            }
@@ -508,6 +508,11 @@
        if (CollectionUtils.isEmpty(idList)) {
            return 0;
        }
        long childCount = salesLedgerMapper.selectCount(new LambdaQueryWrapper<SalesLedger>()
                .in(SalesLedger::getParentContractId, idList));
        if (childCount > 0) {
            throw new BaseException("框架合同存在框架订单,不能删除");
        }
        // åˆ é™¤é”€å”®ç®¡ç†æ•°æ®
        LambdaQueryWrapper<SalesLedgerProduct> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.in(SalesLedgerProduct::getSalesLedgerId, idList)
@@ -551,6 +556,10 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int addOrUpdateSalesLedger(SalesLedgerDto salesLedgerDto) {
        log.info("addOrUpdateSalesLedger æ”¶åˆ° contractType={}, parentContractId={}, id={}",
                salesLedgerDto == null ? null : salesLedgerDto.getContractType(),
                salesLedgerDto == null ? null : salesLedgerDto.getParentContractId(),
                salesLedgerDto == null ? null : salesLedgerDto.getId());
        // 1. æ ¡éªŒå®¢æˆ·ä¿¡æ¯
        Customer customer = customerMapper.selectById(salesLedgerDto.getCustomerId());
        if (customer == null) {
@@ -559,6 +568,7 @@
        // 2. DTO转Entity
        SalesLedger salesLedger = convertToEntity(salesLedgerDto);
        log.info("convertToEntity åŽ contractType={}", salesLedger.getContractType());
        salesLedger.setCustomerName(customer.getCustomerName());
        salesLedger.setTenantId(customer.getTenantId());
        // å½•入人:如果未传则使用当前登录用户
@@ -576,6 +586,9 @@
        } else {
            salesLedgerMapper.updateById(salesLedger);
        }
        // è¯Šæ–­ï¼šinsert/update åŽå›žæŸ¥æ•°æ®åº“实际写入的 contract_type
        SalesLedger dbCheckLedger = salesLedgerMapper.selectById(salesLedger.getId());
        log.info("DB å®žé™… contract_type={}, id={}", dbCheckLedger == null ? null : dbCheckLedger.getContractType(), salesLedger.getId());
        // 4. å¤„理子表数据
@@ -609,6 +622,86 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int createFrameworkOrder(SalesLedgerDto dto) {
        if (dto == null || dto.getId() == null) {
            throw new BaseException("请选择框架合同");
        }
        SalesLedger parent = salesLedgerMapper.selectById(dto.getId());
        if (parent == null || !Objects.equals(parent.getContractType(), 2)) {
            throw new BaseException("父台账不是框架合同");
        }
        if (CollectionUtils.isEmpty(dto.getProductData())) {
            throw new BaseException("请填写框架订单产品");
        }
        SalesLedger order = new SalesLedger();
        order.setCustomerContractNo(parent.getCustomerContractNo());
        order.setProjectName(parent.getProjectName());
        order.setSalesman(parent.getSalesman());
        order.setCustomerId(parent.getCustomerId());
        order.setCustomerName(parent.getCustomerName());
        order.setEntryPerson(String.valueOf(SecurityUtils.getUserId()));
        order.setRemarks(dto.getRemarks());
        order.setTenantId(parent.getTenantId());
        order.setExecutionDate(parent.getExecutionDate());
        order.setPaymentMethod(parent.getPaymentMethod());
        order.setEntryDate(parent.getEntryDate());
        order.setId(null);
        order.setContractType(3);
        order.setParentContractId(parent.getId());
        order.setSalesContractNo(generateSalesContractNo(new Date()));
        order.setDeliveryDate(null);
        order.setContractAmount(BigDecimal.ZERO);
        order.setNetContractAmount(BigDecimal.ZERO);
        salesLedgerMapper.insert(order);
        Set<Long> parentProductIds = dto.getProductData().stream()
                .map(SalesLedgerProduct::getParentProductId)
                .collect(Collectors.toSet());
        if (parentProductIds.contains(null) || parentProductIds.size() != dto.getProductData().size()) {
            throw new BaseException("框架订单产品必须关联不同的父产品");
        }
        Map<Long, SalesLedgerProduct> parentProducts = salesLedgerProductMapper.selectBatchIds(parentProductIds).stream()
                .collect(Collectors.toMap(SalesLedgerProduct::getId, Function.identity()));
        for (SalesLedgerProduct requestProduct : dto.getProductData()) {
            if (requestProduct.getDeliveryDate() == null) {
                throw new BaseException("框架订单产品必须关联父产品并填写交货日期");
            }
            SalesLedgerProduct parentProduct = parentProducts.get(requestProduct.getParentProductId());
            if (parentProduct == null || !Objects.equals(parentProduct.getSalesLedgerId(), parent.getId())
                    || !Objects.equals(parentProduct.getType(), SaleEnum.SALE.getCode())) {
                throw new BaseException("框架订单产品不属于该框架合同");
            }
            SalesLedgerProduct product = new SalesLedgerProduct();
            BeanUtils.copyProperties(parentProduct, product);
            product.setId(null);
            product.setSalesLedgerId(order.getId());
            product.setParentProductId(parentProduct.getId());
            product.setQuantity(null);
            product.setDeliveryDate(requestProduct.getDeliveryDate());
            product.setIsProduction(false);
            product.setType(SaleEnum.SALE.getCode());
            if (product.getTaxRate() == null) {
                product.setTaxRate(BigDecimal.ZERO);
            }
            salesLedgerProductMapper.insert(product);
            // æ¡†æž¶è®¢å•创建时生成生产计划(数量留空,下发时填写)
            salesLedgerProductServiceImpl.addProductionData(product);
        }
        return 1;
    }
    @Override
    public List<FrameworkOrderVo> listFrameworkOrders(Long parentContractId) {
        return salesLedgerMapper.listFrameworkOrders(parentContractId);
    }
    @Override
    public BigDecimal getFrameworkContractShippedQuantity(Long parentContractId) {
        return salesLedgerMapper.sumShippedByParentContract(parentContractId);
    }
    @Override
    public void handleSalesLedgerProducts(Long salesLedgerId, List<SalesLedgerProduct> products, SaleEnum type) {
        // æŒ‰ID分组,区分新增和更新的记录
        Map<Boolean, List<SalesLedgerProduct>> partitionedProducts = products.stream()
@@ -629,10 +722,20 @@
        if (!insertList.isEmpty()) {
            for (SalesLedgerProduct salesLedgerProduct : insertList) {
                salesLedgerProduct.setType(type.getCode());
                salesLedgerProductMapper.insert(salesLedgerProduct);
                // æ·»åŠ ç”Ÿäº§æ•°æ®
                salesLedgerProductServiceImpl.addProductionData(salesLedgerProduct);
                if (salesLedgerProduct.getTaxRate() == null) {
                    salesLedgerProduct.setTaxRate(BigDecimal.ZERO);
            }
                salesLedgerProductMapper.insert(salesLedgerProduct);
                addProductionDataIfNeeded(salesLedgerProduct);
            }
        }
    }
    private void addProductionDataIfNeeded(SalesLedgerProduct product) {
        SalesLedger ledger = salesLedgerMapper.selectById(product.getSalesLedgerId());
        if (ledger != null && !Objects.equals(ledger.getContractType(), 2)
                && !Objects.equals(ledger.getContractType(), 3)) {
            salesLedgerProductServiceImpl.addProductionData(product);
        }
    }
@@ -729,30 +832,22 @@
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        // æž„造主表更新对象
        try {
            S entity = mainEntityClass.getDeclaredConstructor().newInstance();
            Field idField = mainEntityClass.getDeclaredField("id");
            idField.setAccessible(true);
            idField.set(entity, mainId);
            Field amountField = mainEntityClass.getDeclaredField("contractAmount");
            amountField.setAccessible(true);
            amountField.set(entity, totalAmount);
        // ä»…更新金额列,避免整实体 updateById æŠŠå…¶å®ƒéžç©ºå­—段(如 contract_type é»˜è®¤å€¼ 1)一并覆盖
        UpdateWrapper<S> wrapper = Wrappers.update();
        wrapper.eq("id", mainId)
                .set("contract_amount", totalAmount);
            // å®žé™…合同金额初始值等于合同金额
        boolean hasNetAmount;
            try {
                Field netAmountField = mainEntityClass.getDeclaredField("netContractAmount");
                netAmountField.setAccessible(true);
                netAmountField.set(entity, totalAmount);
            } catch (NoSuchFieldException ignored) {
                // å­ç±»å¯èƒ½æ²¡æœ‰è¯¥å­—段,忽略
            mainEntityClass.getDeclaredField("netContractAmount");
            hasNetAmount = true;
        } catch (NoSuchFieldException e) {
            hasNetAmount = false;
            }
            mainMapper.updateById(entity);
        } catch (Exception e) {
            throw new RuntimeException("动态更新主表金额失败", e);
        if (hasNetAmount) {
            wrapper.set("net_contract_amount", totalAmount);
        }
        mainMapper.update(null, wrapper);
    }
    // å†…部类用于存储聚合结果
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java
@@ -23,9 +23,12 @@
import com.ruoyi.sales.dto.ShippingApproveDto;
import com.ruoyi.sales.dto.ShippingInfoDto;
import com.ruoyi.sales.dto.ShippingProductDetailDto;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.mapper.ShippingProductDetailMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.ShippingInfo;
import com.ruoyi.sales.pojo.ShippingProductDetail;
import com.ruoyi.sales.service.ShippingInfoService;
@@ -52,6 +55,8 @@
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final StockUtils stockUtils;
@@ -172,9 +177,25 @@
    @Override
    public boolean addReq(ShippingInfoDto req) {
        // æ ¡éªŒè¯¥äº§å“æ˜¯å¦å·²æœ‰å¾…审核/审核中的发货记录,防止重复提交
        // æ¡†æž¶åˆåŒåªç”¨äºŽç™»è®°äº§å“èŒƒå›´ï¼Œå®žé™…发货必须关联框架订单产品。
        Long salesLedgerProductId = req.getSalesLedgerProductId();
        if (salesLedgerProductId != null) {
            SalesLedgerProduct product = salesLedgerProductMapper.selectById(salesLedgerProductId);
            if (product == null) {
                throw new RuntimeException("销售产品不存在");
            }
            if (req.getSalesLedgerId() != null
                    && !java.util.Objects.equals(req.getSalesLedgerId(), product.getSalesLedgerId())) {
                throw new RuntimeException("发货台账与销售产品不匹配");
            }
            SalesLedger ledger = salesLedgerMapper.selectById(product.getSalesLedgerId());
            if (ledger == null || (!java.util.Objects.equals(ledger.getContractType(), 1)
                    && !java.util.Objects.equals(ledger.getContractType(), 3))) {
                throw new RuntimeException("该销售产品不允许发货");
            }
        }
        // æ ¡éªŒè¯¥äº§å“æ˜¯å¦å·²æœ‰å¾…审核/审核中的发货记录,防止重复提交
        if (salesLedgerProductId != null) {
            long pendingCount = this.count(new LambdaQueryWrapper<ShippingInfo>()
                    .eq(ShippingInfo::getSalesLedgerProductId, salesLedgerProductId)
                    .in(ShippingInfo::getStatus, "待审核", "审核中"));
src/main/java/com/ruoyi/sales/vo/FrameworkOrderVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package com.ruoyi.sales.vo;
import com.ruoyi.sales.pojo.SalesLedger;
import lombok.Data;
import java.math.BigDecimal;
/**
 * æ¡†æž¶åˆåŒä¸‹çš„æ¡†æž¶è®¢å•列表及汇总
 */
@Data
public class FrameworkOrderVo extends SalesLedger {
    private BigDecimal shippedQuantity;
    private BigDecimal orderedQuantity;
}
src/main/java/com/ruoyi/sales/vo/SalesLedgerVo.java
@@ -4,10 +4,15 @@
import com.ruoyi.sales.pojo.SalesLedger;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class SalesLedgerVo extends SalesLedger {
    private List<StorageBlobVO> storageBlobVOs;
    private BigDecimal shippedQuantity;
    private Integer frameworkOrderCount;
}
src/main/resources/mapper/device/DeviceLedgerMapper.xml
@@ -31,9 +31,12 @@
        dl.tenant_id,
        dl.is_depr,
        dl.annual_depreciation_amount,
        dl.type
        dl.type,
        dl.area_id,
        da.name AS area_name
        FROM device_ledger dl
        left join sys_user su on dl.create_user = su.user_id
        left join device_area da on dl.area_id = da.id
        <where>
            <!-- è®¾å¤‡åç§° -->
            <if test="deviceLedger.deviceName != null and deviceLedger.deviceName != ''">
@@ -76,9 +79,53 @@
            <if test="deviceLedger.tenantId != null">
                AND tenant_id = #{deviceLedger.tenantId}
            </if>
            <!-- åŒºåŸŸID:含该区域及其所有子级(通过祖级路径匹配) -->
            <if test="deviceLedger.areaId != null">
                AND (
                    dl.area_id = #{deviceLedger.areaId}
                    OR EXISTS (
                        SELECT 1 FROM device_area da2
                        WHERE da2.id = dl.area_id
                          AND (da2.ancestors = #{deviceLedger.areaId}
                               OR da2.ancestors LIKE CONCAT(#{deviceLedger.areaId}, ',%')
                               OR da2.ancestors LIKE CONCAT('%,', #{deviceLedger.areaId}, ',%')
                               OR da2.ancestors LIKE CONCAT('%,', #{deviceLedger.areaId}))
                    )
                )
            </if>
        </where>
        ORDER BY create_time DESC
    </select>
    <select id="selectDeviceOptions" resultType="com.ruoyi.device.dto.DeviceLedgerDto">
        SELECT
        dl.id,
        dl.device_name,
        dl.device_model,
        dl.device_brand,
        dl.type,
        dl.area_id,
        da.name AS area_name
        FROM device_ledger dl
        left join device_area da on dl.area_id = da.id
        <where>
            <if test="areaId != null">
                AND (
                    dl.area_id = #{areaId}
                    OR EXISTS (
                        SELECT 1 FROM device_area da2
                        WHERE da2.id = dl.area_id
                          AND (da2.ancestors = #{areaId}
                               OR da2.ancestors LIKE CONCAT(#{areaId}, ',%')
                               OR da2.ancestors LIKE CONCAT('%,', #{areaId}, ',%')
                               OR da2.ancestors LIKE CONCAT('%,', #{areaId}))
                    )
                )
            </if>
        </where>
        ORDER BY dl.create_time DESC
    </select>
    <select id="deviceLedgerExportList" resultType="com.ruoyi.device.execl.DeviceLedgerExeclDto">
    </select>
src/main/resources/mapper/measuringinstrumentledger/MeasuringInstrumentLedgerMapper.xml
@@ -64,4 +64,14 @@
        </where>
        ORDER BY create_time DESC
    </select>
    <select id="selectExistingCodes" resultType="java.lang.String">
        SELECT code
        FROM measuring_instrument_ledger
        WHERE tenant_id = #{tenantId}
        AND code IN
        <foreach collection="codes" item="code" open="(" separator="," close=")">
            #{code}
        </foreach>
    </select>
</mapper>
src/main/resources/mapper/sales/SalesLedgerMapper.xml
@@ -141,4 +141,28 @@
        order by sl.entry_date desc
    </select>
    <select id="listFrameworkOrders" resultType="com.ruoyi.sales.vo.FrameworkOrderVo">
        select sl.*, IFNULL(shipped.shipped_quantity, 0) AS shippedQuantity
        from sales_ledger sl
        left join (
            select slp.sales_ledger_id, SUM(spd.quantity) AS shipped_quantity
            from sales_ledger_product slp
            inner join shipping_info si on slp.id = si.sales_ledger_product_id and si.status = '已发货'
            inner join shipping_product_detail spd on si.id = spd.shipping_info_id
            group by slp.sales_ledger_id
        ) shipped on sl.id = shipped.sales_ledger_id
        where sl.parent_contract_id = #{parentContractId}
          and sl.contract_type = 3
        order by sl.execution_date desc
    </select>
    <select id="sumShippedByParentContract" resultType="java.math.BigDecimal">
        select IFNULL(SUM(spd.quantity), 0)
        from sales_ledger slo
        inner join sales_ledger_product slp on slo.id = slp.sales_ledger_id
        inner join shipping_info si on slp.id = si.sales_ledger_product_id and si.status = '已发货'
        inner join shipping_product_detail spd on si.id = spd.shipping_info_id
        where slo.parent_contract_id = #{parentContractId}
    </select>
</mapper>