liyong
3 天以前 f57064d2fa563ee4cfeeccd715850ba8b8aa4f60
Merge remote-tracking branch 'origin/dev_New_pro' into dev_宁夏_英泽防锈
已添加10个文件
已修改63个文件
3820 ■■■■■ 文件已修改
.gitignore 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260515_device_maintenance_inspection_abnormal_acceptance.sql 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260515_设备巡检异常联动维修单_前端联调文档.md 170 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260516_制造智能助手前端联调文档.md 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/前端联调文档-设备报修保养财务模块改造.md 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/financial/FinLedgerRowVo.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/financial/FinVoucherDetailVo.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/financial/FinLedgerServiceImpl.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/financial/FinVoucherServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/ManufacturingAgent.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/ManufacturingIntentExecutor.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/ManufacturingAgentConfig.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/ManufacturingAiController.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/ManufacturingAgentTools.java 1035 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/bean/vo/ApproveProcessVO.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/ApproveProcessServiceImpl.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/dto/ProductModelDto.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/enums/RecordTypeEnum.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/ProductModelServiceImpl.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/ProductServiceImpl.java 27 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/collaborativeApproval/controller/SealApplicationManagementController.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/collaborativeApproval/dto/SealApplicationManagementDTO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/collaborativeApproval/service/impl/SealApplicationManagementServiceImpl.java 9 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/controller/DeviceRepairController.java 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/execl/DeviceRepairExeclDto.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/pojo/DeviceRepair.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/pojo/MaintenanceTask.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/IDeviceRepairService.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/DeviceRepairServiceImpl.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/MaintenanceTaskJob.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/framework/security/LoginUser.java 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/inspectiontask/pojo/InspectionTask.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/inspectiontask/pojo/TimingTask.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/inspectiontask/service/impl/InspectionTaskServiceImpl.java 236 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/inspectiontask/service/impl/TimingTaskJob.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/inspectiontask/service/impl/TimingTaskServiceImpl.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/utils/StockUtils.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionBomStructureServiceImpl.java 473 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOperationTaskServiceImpl.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/project/system/controller/SysLoginController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/pojo/ProductRecord.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/pojo/SalesLedgerProductTemplate.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/pojo/QualityInspect.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesQuotationProduct.java 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesQuotationServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java 9 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/controller/StockInventoryController.java 20 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockInventoryDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/execl/StockInRecordExportData.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/execl/StockInventoryExportData.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/execl/StockOutRecordExportData.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/execl/StockUnInventoryExportData.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockInventoryMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/StockInventoryService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockInRecordServiceImpl.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/logback.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/manufacturing-agent-prompt.txt 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/CustomerMapper.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/collaborativeApproval/SealApplicationManagementMapper.xml 29 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/device/DeviceRepairMapper.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/measuringinstrumentledger/MeasuringInstrumentLedgerMapper.xml 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/procurementrecord/ReturnSaleProductMapper.xml 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionOperationTaskMapper.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/purchase/PurchaseReturnOrdersMapper.xml 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesLedgerProductMapper.xml 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesQuotationMapper.xml 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockInventoryMapper.xml 345 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -4,7 +4,7 @@
.gradle
/build/
!gradle/wrapper/gradle-wrapper.jar
claude.md
target/
/${project.build.directory}/
!.mvn/wrapper/maven-wrapper.jar
doc/20260515_device_maintenance_inspection_abnormal_acceptance.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
ALTER TABLE `inspection_task`
    ADD COLUMN `inspection_project` VARCHAR(100) NULL COMMENT '巡检项目' AFTER `task_name`;
ALTER TABLE `inspection_task`
    ADD COLUMN `inspection_result` VARCHAR(1) NULL COMMENT '巡检结果 0异常 1正常' AFTER `remarks`,
    ADD COLUMN `abnormal_description` VARCHAR(500) NULL COMMENT '异常描述' AFTER `inspection_result`,
    ADD COLUMN `device_repair_id` BIGINT NULL COMMENT '关联维修单ID' AFTER `abnormal_description`,
    ADD COLUMN `acceptance_user_id` BIGINT NULL COMMENT '验收人ID' AFTER `device_repair_id`,
    ADD COLUMN `acceptance_name` VARCHAR(100) NULL COMMENT '验收人' AFTER `acceptance_user_id`;
ALTER TABLE `timing_task`
    ADD COLUMN `inspection_project` VARCHAR(100) NULL COMMENT '巡检项目' AFTER `task_name`,
    ADD COLUMN `is_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用 0否 1是' AFTER `is_active`;
CREATE INDEX `idx_inspection_task_device_repair_id`
    ON `inspection_task` (`device_repair_id`);
CREATE INDEX `idx_inspection_task_inspection_result`
    ON `inspection_task` (`inspection_result`);
CREATE INDEX `idx_timing_task_is_enabled`
    ON `timing_task` (`is_enabled`);
doc/20260515_É豸Ѳ¼ìÒì³£Áª¶¯Î¬ÐÞµ¥_ǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,170 @@
# è®¾å¤‡å·¡æ£€ä¸Žå®šæ—¶å·¡æ£€å‰ç«¯è”调文档(inspectiontask)
> æ›´æ–°æ—¥æœŸï¼š2026-05-15
> é€‚用模块:设备巡检任务 `inspectiontask`(`/inspectionTask`)与定时巡检任务(`/timingTask`)
## 1. æœ¬æ¬¡æ”¹åЍ
1. å·¡æ£€ä»»åŠ¡æ–°å¢žå­—æ®µï¼š
   - `inspectionProject`(巡检项目)
   - `inspectionResult`(巡检结果,`0`异常 / `1`正常,必填)
   - `abnormalDescription`(异常描述)
   - `deviceRepairId`(关联维修单ID,异常时后端自动回填)
   - `acceptanceUserId`(验收人ID)
   - `acceptanceName`(验收人)
2. å¼‚常校验规则:
   - `inspectionResult=1`(正常):照片非必填。
   - `inspectionResult=0`(异常):必须有照片,且必须填写 `abnormalDescription`。
3. å¼‚常联动规则:
   - å¼‚常保存后自动生成 `device_repair` å¹¶å›žå¡« `deviceRepairId`。
4. å®šæ—¶ä»»åŠ¡æ–°å¢žå­—æ®µï¼š
   - `inspectionProject`(巡检项目)
   - `isEnabled`(是否启用,`0`否 / `1`是)
5. å¤‡æ³¨å¸¦å…¥è§„则:
   - å®šæ—¶ä»»åŠ¡è‡ªåŠ¨ç”Ÿæˆå·¡æ£€è®°å½•æ—¶ï¼Œè‹¥å®šæ—¶ä»»åŠ¡ `remarks` æœ‰å€¼ï¼Œä¼šæ‹¼æŽ¥åˆ°å·¡æ£€è®°å½•备注中。
## 2. æ•°æ®åº“变更
联调前执行 SQL:
- [doc/20260515_device_maintenance_inspection_abnormal_acceptance.sql](/D:/牛马/南通/后端/product-inventory-management-after-jdk25/doc/20260515_device_maintenance_inspection_abnormal_acceptance.sql)
> è¯´æ˜Žï¼šè¯¥è„šæœ¬å½“å‰ä½œç”¨äºŽ `inspection_task` ä¸Ž `timing_task` ä¸¤å¼ è¡¨ï¼Œæ–‡ä»¶ååŽ†å²ä¿ç•™æœªæ”¹ã€‚
## 3. å·¡æ£€ä»»åŠ¡æŽ¥å£
### 3.1 ä¿å­˜æŽ¥å£
`POST /inspectionTask/addOrEditInspectionTask`
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| id | long | å¦ | æœ‰å€¼=修改,无值=新增 |
| taskId | int | å»ºè®®å¿…å¡« | è®¾å¤‡ID(用于异常自动建维修单) |
| taskName | string | å»ºè®®å¿…å¡« | è®¾å¤‡åç§° |
| inspectionProject | string | å¦ | å·¡æ£€é¡¹ç›® |
| inspectorId | string | å¦ | å·¡æ£€äººID,支持逗号分隔 |
| inspectionResult | string | æ˜¯ | `0`=异常,`1`=正常 |
| abnormalDescription | string | æ¡ä»¶å¿…å¡« | å¼‚常时必填 |
| acceptanceUserId | long | å¦ | éªŒæ”¶äººID |
| acceptanceName | string | å¦ | éªŒæ”¶äººå§“名 |
| commonFileListDTO | array | æ¡ä»¶å¿…å¡« | é™„件组1(异常时三组至少一组有图) |
| commonFileListAfterDTO | array | æ¡ä»¶å¿…å¡« | é™„件组2(异常时三组至少一组有图) |
| commonFileListBeforeDTO | array | æ¡ä»¶å¿…å¡« | é™„件组3(异常时三组至少一组有图) |
异常示例:
```json
{
  "taskId": 1001,
  "taskName": "空压机A-01",
  "inspectionProject": "润滑系统",
  "inspectorId": "12",
  "inspectionResult": "0",
  "abnormalDescription": "电机异响,温升偏高",
  "acceptanceUserId": 20,
  "commonFileListDTO": [
    {
      "id": 90001,
      "application": "file"
    }
  ]
}
```
正常示例:
```json
{
  "taskId": 1001,
  "taskName": "空压机A-01",
  "inspectionProject": "点检",
  "inspectorId": "12",
  "inspectionResult": "1",
  "acceptanceUserId": 20
}
```
### 3.2 åˆ—表接口
`GET /inspectionTask/list`
返回包含新增字段:
- `inspectionProject`
- `inspectionResult`
- `abnormalDescription`
- `deviceRepairId`
- `acceptanceUserId`
- `acceptanceName`
## 4. å®šæ—¶ä»»åŠ¡æŽ¥å£
### 4.1 ä¿å­˜æŽ¥å£
`POST /timingTask/addOrEditTimingTask`
新增/更新字段:
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| inspectionProject | string | å¦ | å·¡æ£€é¡¹ç›® |
| remarks | string | å¦ | å¤‡æ³¨ |
| isEnabled | int | å¦ | `0`=禁用,`1`=启用;不传默认启用 |
示例:
```json
{
  "taskName": "空压机A-01定时巡检",
  "inspectionProject": "月度巡检",
  "taskId": 1001,
  "inspectorIds": "12,13",
  "frequencyType": "DAILY",
  "frequencyDetail": "09:00",
  "remarks": "重点检查轴承温度",
  "isEnabled": 1
}
```
### 4.2 å¯ç”¨çŠ¶æ€è¡Œä¸º
1. `isEnabled=1`:任务进入调度,按频次自动生成巡检记录。
2. `isEnabled=0`:任务不调度;已存在调度会被移除。
### 4.3 å¤‡æ³¨å¸¦å…¥è§„则
定时任务自动生成巡检记录时:
1. å·¡æ£€è®°å½•备注固定前缀:`自动生成自定时任务ID: {id}`
2. å½“定时任务 `remarks` éžç©ºæ—¶ï¼Œæ‹¼æŽ¥ä¸ºï¼š
   `自动生成自定时任务ID: {id};{remarks}`
## 5. å¼‚常自动建维修单规则
当巡检记录 `inspectionResult=0` æ—¶ï¼š
1. è‹¥ `deviceRepairId` ä¸ºç©ºï¼ŒåŽç«¯è‡ªåŠ¨åˆ›å»º `device_repair`:
   - `deviceLedgerId`:来自 `taskId`
   - `deviceName`:优先 `taskName`,否则取设备台账名称
   - `remark`:异常描述
   - `status`:`0`(待维修)
2. è‹¥å·²æœ‰å…³è”维修单,仅同步更新维修单 `remark`。
## 6. å‰ç«¯æ”¹é€ å»ºè®®
1. å·¡æ£€è¡¨å•新增 `inspectionProject` è¾“入框。
2. å·¡æ£€è¡¨å•保留“正常/异常”联动校验:
   - å¼‚常时强制异常描述 + è‡³å°‘一组图片。
3. å®šæ—¶ä»»åŠ¡è¡¨å•æ–°å¢žâ€œæ˜¯å¦å¯ç”¨â€å¼€å…³å¹¶æ˜ å°„ `isEnabled`。
4. å®šæ—¶ä»»åŠ¡è¡¨å•æ–°å¢ž `inspectionProject` ä¸Ž `remarks` è¾“入项。
5. å·¡æ£€åˆ—表展示 `inspectionProject` å’Œ `deviceRepairId`(支持跳转维修单详情)。
## 7. è”调验收清单
1. å·¡æ£€æ–°å¢ž/修改可正确提交 `inspectionProject` å¹¶åœ¨åˆ—表回显。
2. å¼‚常巡检(有描述+有图)保存成功并回填 `deviceRepairId`。
3. å¼‚常巡检缺描述或缺图片时被拦截。
4. å®šæ—¶ä»»åŠ¡ `isEnabled=0` æ—¶ä¸å†è§¦å‘自动巡检记录。
5. å®šæ—¶ä»»åŠ¡ `isEnabled=1` æ—¶æŒ‰é¢‘次生成巡检记录。
6. å®šæ—¶ä»»åŠ¡æœ‰ `remarks` æ—¶ï¼Œè‡ªåŠ¨å·¡æ£€è®°å½•å¤‡æ³¨å¸¦ä¸Šè¯¥å†…å®¹ã€‚
doc/20260516_ÖÆÔìÖÇÄÜÖúÊÖǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,258 @@
# åˆ¶é€ æ™ºèƒ½åŠ©æ‰‹å‰ç«¯è”è°ƒæ–‡æ¡£ï¼ˆ`manufacturing-ai`)
> æ›´æ–°æ—¥æœŸï¼š2026-05-16
> é€‚用模块:生产现场、计划、工单、设备、质量、物料、异常处理
> èƒ½åŠ›èŒƒå›´ï¼šæŸ¥ã€é—®ã€åŠžã€é¢„è­¦ã€åˆ†æž
## 1. æŽ¥å£æ€»è§ˆ
1. æµå¼å¯¹è¯ï¼š`POST /manufacturing-ai/chat`
2. ä¼šè¯åˆ—表:`GET /manufacturing-ai/history/sessions`
3. ä¼šè¯æ¶ˆæ¯ï¼š`GET /manufacturing-ai/history/messages/{memoryId}`
4. åˆ é™¤ä¼šè¯ï¼š`DELETE /manufacturing-ai/history/{memoryId}`
说明:
- `/chat` ä¸º **SSE/流式文本** è¿”回(`text/stream;charset=utf-8`)。
- å‘½ä¸­â€œæŸ¥/预警/分析/办”工具时,流式最终内容是 **JSON å­—符串**(不是 `AjaxResult`)。
- æœªå‘½ä¸­å·¥å…·æ—¶ï¼Œè¿”回普通自然语言文本。
## 2. é‰´æƒä¸Žè¯·æ±‚头
- ç»Ÿä¸€ä½¿ç”¨ç³»ç»Ÿç™»å½•态(`Authorization` ä¸ŽçŽ°æœ‰æŽ¥å£ä¸€è‡´ï¼‰ã€‚
- `POST /manufacturing-ai/chat` è¯·æ±‚头:`Content-Type: application/json`。
## 3. å¯¹è¯æŽ¥å£
### 3.1 è¯·æ±‚
```http
POST /manufacturing-ai/chat
Content-Type: application/json
```
```json
{
  "memoryId": "mfg-ai-001",
  "message": "查设备西门子变频器的维修情况"
}
```
字段说明:
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- | --- |
| memoryId | string | æ˜¯ | ä¼šè¯ ID,前端生成并复用 |
| message | string | æ˜¯ | ç”¨æˆ·è¾“å…¥ |
### 3.2 è¿”回(流式)
```http
Content-Type: text/stream;charset=utf-8
```
前端处理建议:
1. æŒ‰æµæ‹¼æŽ¥å®Œæ•´æ–‡æœ¬ã€‚
2. å°è¯• `JSON.parse(fullText)`:
   - æˆåŠŸï¼šæŒ‰ç»“æž„åŒ–ç»“æžœæ¸²æŸ“ã€‚
   - å¤±è´¥ï¼šæŒ‰æ™®é€šèŠå¤©æ–‡æœ¬æ¸²æŸ“。
## 4. ç»“构化响应协议
### 4.1 é€šç”¨ç»“æž„
```json
{
  "success": true,
  "type": "manufacturing_device_repair_list",
  "description": "已返回设备维修记录。",
  "summary": {},
  "data": {},
  "charts": {}
}
```
### 4.2 `type` æžšä¸¾
| type | åœºæ™¯ |
| --- | --- |
| manufacturing_site_snapshot | ç”Ÿäº§çŽ°åœºæ¦‚è§ˆ |
| manufacturing_plan_list | ç”Ÿäº§è®¡åˆ’查询 |
| manufacturing_workorder_list | å·¥å•查询 |
| manufacturing_device_list | è®¾å¤‡å°è´¦æŸ¥è¯¢ |
| manufacturing_device_repair_list | è®¾å¤‡ç»´ä¿®è®°å½•查询 |
| manufacturing_quality_list | è´¨é‡æŸ¥è¯¢ |
| manufacturing_material_list | ç‰©æ–™åº“存查询 |
| manufacturing_exception_list | å¼‚常处理查询 |
| manufacturing_warning | é¢„警看板 |
| manufacturing_analysis | ç»è¥åˆ†æž |
| manufacturing_action_plan | åŠžç†å»ºè®®ï¼ˆåŠ¨ä½œå¡ï¼‰ |
## 5. â€œæŸ¥â€èƒ½åŠ›è”è°ƒè¦ç‚¹
### 5.1 è®¾å¤‡ç›¸å…³è·¯ç”±è§„则(关键)
- å½“用户输入包含 `ç»´ä¿®/报修/检修/维护`,设备域会返回 `manufacturing_device_repair_list`(查 `device_repair`)。
- æœªåŒ…含以上词时,返回 `manufacturing_device_list`(查设备台账)。
示例:
- `查设备A-01` -> `manufacturing_device_list`
- `查设备A-01维修情况` -> `manufacturing_device_repair_list`
### 5.2 ç»´ä¿®è®°å½•时间过滤规则(关键)
- ç”¨æˆ·æ˜Žç¡®å¸¦æ—¶é—´æ¡ä»¶ï¼ˆå¦‚“本月/上周/近7天/2026-05-01 åˆ° 2026-05-16”)才按时间过滤维修记录。
- æœªå¸¦æ—¶é—´æ¡ä»¶æ—¶ï¼Œä¸é»˜è®¤æŒ‰è¿‘ 30 å¤©æˆªæ–­ï¼Œé¿å…åŽ†å²ç»´ä¿®è®°å½•è¢«è¯¯è¿‡æ»¤ã€‚
### 5.3 å…³é”®è¯å¤„理规则(设备/维修)
- ç³»ç»Ÿä¼šæ¸…洗噪音词:`查询/查看/请/设备/维修情况/记录/信息` ç­‰ã€‚
- åŒæ—¶ä¼šé€šè¿‡è®¾å¤‡å°è´¦åŒ¹é… `deviceLedgerId` å…œåº•,再回查维修记录,降低“有数据但查不到”的概率。
### 5.4 åˆ—表结果约定
- åˆ—表数据统一在 `data.items`
- ç»Ÿè®¡æ‘˜è¦åœ¨ `summary`
常用字段:
| type | å¸¸ç”¨å­—段 |
| --- | --- |
| manufacturing_plan_list | `mpsNo`, `requiredDate`, `status` |
| manufacturing_workorder_list | `workOrderNo`, `planStartTime`, `planEndTime`, `status` |
| manufacturing_device_list | `deviceName`, `deviceModel`, `pendingRepairCount` |
| manufacturing_device_repair_list | `deviceName`, `deviceModel`, `repairTime`, `repairName`, `maintenanceName`, `status`, `createTime` |
## 6. â€œé¢„警”联调要点
- `type = manufacturing_warning`
- é¢„警明细在 `data.items`,每项包含:
  - `level`:`high` / `medium`
  - `title`
  - `count`
  - `detail`
状态口径:
- è®¾å¤‡â€œå¾…维修”统计按 `status = 0` è®¡ç®—(不再把其他状态计入待维修)。
## 7. â€œåˆ†æžâ€è”调要点
- `type = manufacturing_analysis`
- å…³é”®æŒ‡æ ‡åœ¨ `summary`
- æŒ‡æ ‡å¡åœ¨ `data.coreMetrics`
- å›¾è¡¨é…ç½®åœ¨ `charts`:
  - `charts.domainBarOption`
  - `charts.qualityPieOption`
图表配置可直接给 ECharts ä½¿ç”¨ã€‚
## 8. â€œåŠžâ€èƒ½åŠ›è”è°ƒè¦ç‚¹
当前“办”为 **办理建议模式**(AI è¾“出动作卡,前端确认后调用目标业务接口)。
- `type = manufacturing_action_plan`
- åŠ¨ä½œå¡æ•°ç»„ï¼š`data.actionCards`
动作卡字段:
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| code | åŠ¨ä½œç¼–ç  |
| name | åŠ¨ä½œåç§° |
| method | è¯·æ±‚方法 |
| targetApi | ç›®æ ‡ä¸šåŠ¡æŽ¥å£ |
| requiredFields | å¿…填字段 |
| examplePayload | ç¤ºä¾‹å‚æ•° |
| description | è¯´æ˜Ž |
内置动作示例:
1. `POST /productionOperationTask/assign`
2. `POST /device/repair`
3. `POST /quality/qualityUnqualified/deal`
4. `POST /stockInventory/addstockInventory`
5. `POST /procurementExceptionRecord/add`
## 9. ä¼šè¯ç®¡ç†æŽ¥å£
### 9.1 ä¼šè¯åˆ—表
```http
GET /manufacturing-ai/history/sessions
```
`AjaxResult.data` å­—段:
- `memoryId`
- `title`
- `lastMessage`
- `messageCount`
- `lastChatTime`
### 9.2 ä¼šè¯æ¶ˆæ¯
```http
GET /manufacturing-ai/history/messages/{memoryId}
```
`AjaxResult.data` å­—段:
- `role`:`user` / `assistant` / `system` / `tool`
- `content`
- `filePaths`
### 9.3 åˆ é™¤ä¼šè¯
```http
DELETE /manufacturing-ai/history/{memoryId}
```
返回标准 `AjaxResult`。
## 10. é”™è¯¯ä¸Žè¾¹ç•Œ
`/chat` å¸¸è§è¿”回文本:
- `memoryId不能为空`
- `message不能为空`
建议前端发送前先做必填校验。
## 11. å‰ç«¯è”调流程建议
1. ç™»å½•后创建并复用 `memoryId`。
2. è°ƒç”¨ `/manufacturing-ai/chat`,按 SSE æ‹¼æŽ¥å®Œæ•´æ–‡æœ¬ã€‚
3. å…ˆå°è¯• JSON è§£æžï¼š
   - æˆåŠŸï¼šæŒ‰ `type` è·¯ç”±åˆ°å¯¹åº” UI(列表/预警/分析/动作卡)。
   - å¤±è´¥ï¼šæŒ‰æ™®é€šèŠå¤©æ¶ˆæ¯å±•示。
4. â€œåŠžâ€åœºæ™¯ç”±ç”¨æˆ·ç¡®è®¤åŠ¨ä½œå¡åŽï¼Œå‰ç«¯è°ƒç”¨ `targetApi` å®Œæˆä¸šåŠ¡æäº¤ã€‚
5. é€šè¿‡åŽ†å²æŽ¥å£åšä¼šè¯å›žæ˜¾ä¸Žåˆ é™¤ã€‚
## 12. å‰ç«¯é›†æˆçº¦æŸï¼ˆæœ¬æ¬¡è¡¥å……)
### 12.1 æ™ºèƒ½ä½“新增与弹窗同步规则(强制)
1. å½“ `src/views/aiIndustrialBrain/index.vue` æ–°å¢žæ™ºèƒ½ä½“(`agents`)逻辑时,必须同步确认弹窗助手可用性。
2. å¼¹çª—助手统一由 `src/components/AIChatSidebar/assistants/index.js` çš„ `assistantRegistry` æ³¨å†Œã€‚
3. æ–°å¢žæ™ºèƒ½ä½“çš„ `key` è‹¥è¦åœ¨å¼¹çª—中可用,必须在 `assistantRegistry` ä¸­æä¾›åŒåé…ç½®ã€‚
4. æœªåœ¨ `assistantRegistry` æ³¨å†Œçš„æ™ºèƒ½ä½“,弹窗显示为 `pending`(开发中)态。
### 12.2 ç”Ÿäº§åŠ©æ‰‹æŽ¥å…¥çº¦å®š
1. ç”Ÿäº§åŠ©æ‰‹é…ç½®ä½äºŽ `src/components/AIChatSidebar/assistants/productionAssistant.js`,`apiBase = /manufacturing-ai`。
2. AI å·¥ä¸šå¤§è„‘中生产智能体进入弹窗后,默认使用 `production` åŠ©æ‰‹ã€‚
3. å…¨å±€å³ä¾§å¯¹è¯æ¡†åŠ©æ‰‹åˆ‡æ¢åˆ—è¡¨å·²åŒ…å«ï¼š
   - `general`(待办助理)
   - `purchase`(采购助理)
   - `production`(生产助理)
### 12.3 å­—段中文化展示规则
1. é¢å‘业务用户的字段名、标签、必填提示不直接展示英文 key。
2. `requiredFields`、`missingFields` æç¤ºéœ€è½¬æ¢ä¸ºä¸­æ–‡è·¯å¾„标签(示例:`缺少必填字段:工单号、计划结束时间`)。
3. ç»“构化列表列名、摘要指标、动作卡字段优先显示中文;英文 key ä»…用于接口通信与调试。
## 13. æœ¬æ¬¡æ›´æ–°è®°å½•(2026-05-16)
1. æ–°å¢žè®¾å¤‡ç»´ä¿®è®°å½•返回类型:`manufacturing_device_repair_list`。
2. ä¿®æ­£è®¾å¤‡åŸŸæ„å›¾åˆ†æµï¼š`ç»´ä¿®/报修/检修/维护` èµ°ç»´ä¿®è®°å½•,不再误走设备列表。
3. ä¿®æ­£ç»´ä¿®è®°å½•时间过滤:仅在用户明确时间条件时生效。
4. ä¿®æ­£å¾…维修统计口径:按 `status = 0` ç»Ÿè®¡ã€‚
5. æ–°å¢ž AI å·¥ä¸šå¤§è„‘智能体与弹窗同步维护规则:新增智能体必须同步注册弹窗助手。
6. ç”Ÿäº§åŠ©æ‰‹å·²æŽ¥å…¥å·¥ä¸šå¤§è„‘å¼¹çª—ä¸Žå…¨å±€å³ä¾§å¯¹è¯æ¡†åŠ©æ‰‹åˆ‡æ¢ã€‚
7. å¢žåŠ å­—æ®µä¸­æ–‡åŒ–å±•ç¤ºçº¦æŸï¼šé¿å…è‹±æ–‡å­—æ®µå¯¹ä¸šåŠ¡ç”¨æˆ·ç›´å‡ºã€‚
doc/ǰ¶ËÁªµ÷Îĵµ-É豸±¨ÐÞ±£Ñø²ÆÎñÄ£¿é¸ÄÔì.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,233 @@
# å‰ç«¯è”调文档(设备报修 / è®¾å¤‡ä¿å…»å®šæ—¶ä»»åŠ¡ / è´¢åŠ¡ç§‘ç›®æ€»è´¦ï¼‰
## 1. å˜æ›´èŒƒå›´
本次联调涉及 3 ä¸ªæ¨¡å—:
1. è´¢åŠ¡æ¨¡å—ï¼šç§‘ç›®æ€»è´¦åŽ»æŽ‰å‡­è¯å­—å·ã€æ‘˜è¦ï¼Œåªè¿”å›ž 1 æ¡åˆè®¡æ•°æ®ã€‚
2. è®¾å¤‡ä¿å…»å®šæ—¶ä»»åŠ¡ï¼šæ–°å¢ž `保养人` å­—段,定时任务生成保养记录时带入。
3. è®¾å¤‡æŠ¥ä¿®ï¼šç¡®è®¤æŠ¥ä¿®åŽæ–°å¢žéªŒæ”¶å®¡æ‰¹ï¼ŒéªŒæ”¶é€šè¿‡åŽæ‰ç®—完结。
---
## 2. æŽ¥å£æ¸…单
### 2.1 è´¢åŠ¡-科目总账
- **GET** `/financial/ledger/general`
- è¯´æ˜Žï¼šè¿”回科目总账合计,仅 1 æ¡è®°å½•。
#### è¯·æ±‚参数(Query)
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `subjectCode` | string | æ˜¯ | ç§‘目编码 |
| `startMonth` | string | æ˜¯ | å¼€å§‹æœˆä»½ï¼Œæ ¼å¼ `YYYY-MM` |
| `endMonth` | string | æ˜¯ | ç»“束月份,格式 `YYYY-MM` |
#### è¿”回结构
`R<List<FinLedgerRowVo>>`
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": [
    {
      "rowType": "yearly_total",
      "date": "2026-05-31",
      "debit": 12000.00,
      "credit": 8000.00,
      "direction": "借",
      "balance": 4000.00
    }
  ]
}
```
#### è”调注意
1. `data` å›ºå®šåªæœ‰ 1 æ¡ï¼ˆåˆè®¡ï¼‰ã€‚
2. `voucherNo`、`summary` ä¸è¿”回(不再展示凭证字号、摘要)。
---
### 2.2 è®¾å¤‡ä¿å…»å®šæ—¶ä»»åŠ¡ï¼ˆæ–°å¢žä¿å…»äººï¼‰
- åŸºç¡€è·¯å¾„:`/deviceMaintenanceTask`
- ç›¸å…³æŽ¥å£ï¼š
  - **POST** `/add`
  - **POST** `/update`
  - **GET** `/listPage`
#### æ–°å¢žå­—段
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `maintenancePerson` | string | ä¿å…»äºº |
#### æ–°å¢ž/更新请求示例
```json
{
  "id": 1,
  "taskName": "空压机保养任务",
  "taskId": 1001,
  "maintenancePerson": "张三",
  "frequencyType": "MONTHLY",
  "frequencyDetail": "10,09:00",
  "remarks": "每月例行保养"
}
```
#### å®šæ—¶ä»»åŠ¡ä¸‹å‘è¡Œä¸º
定时任务执行后,系统自动创建保养记录(`device_maintenance`)时会写入:
- `maintenanceActuallyName = maintenancePerson`
即前端在定时任务里维护的保养人,会自动带入到保养记录。
---
### 2.3 è®¾å¤‡æŠ¥ä¿®ï¼ˆç¡®è®¤åŽéªŒæ”¶å®¡æ‰¹ï¼‰
- åŸºç¡€è·¯å¾„:`/device/repair`
#### çŠ¶æ€å®šä¹‰
| çŠ¶æ€å€¼ | å«ä¹‰ |
|---|---|
| `0` | å¾…ç»´ä¿® |
| `3` | å¾…验收 |
| `1` | å®Œç»“ |
| `2` | å¤±è´¥ |
#### 2.3.1 ç»´ä¿®ç¡®è®¤ï¼ˆåŽŸç¡®è®¤æŠ¥ä¿®ï¼‰
- **POST** `/device/repair/repair`
- è¯´æ˜Žï¼šæäº¤åŽçŠ¶æ€ä»Ž `待维修(0)` è¿›å…¥ `待验收(3)`,不再直接完结。
请求示例:
```json
{
  "id": 10001,
  "maintenanceName": "李四",
  "maintenanceTime": "2026-05-14 10:30:00",
  "maintenanceResult": "更换轴承并试运行正常",
  "sparePartsUseList": [
    {
      "id": 501,
      "quantity": 2
    }
  ]
}
```
常见失败提示(用于前端弹窗):
- `报修记录不存在`
- `该报修已完结,不能重复确认维修`
- `该报修已提交验收审批`
- `备件 xxx æ•°é‡ä¸è¶³`
#### 2.3.2 éªŒæ”¶å®¡æ‰¹ï¼ˆæ–°å¢žï¼‰
- **POST** `/device/repair/acceptance`
- è¯´æ˜Žï¼šä»… `待验收(3)` å¯å®¡æ‰¹ï¼›å®¡æ‰¹é€šè¿‡åŽçŠ¶æ€æ”¹ä¸º `完结(1)`。
请求参数(Body):
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `id` | long | æ˜¯ | æŠ¥ä¿®è®°å½•ID |
| `acceptanceName` | string | æ˜¯ | éªŒæ”¶äºº |
| `acceptanceTime` | string | æ˜¯ | éªŒæ”¶æ—¶é—´ï¼Œæ ¼å¼ `yyyy-MM-dd HH:mm:ss` |
| `acceptanceRemark` | string | æ˜¯ | éªŒæ”¶å¤‡æ³¨ |
请求示例:
```json
{
  "id": 10001,
  "acceptanceName": "王五",
  "acceptanceTime": "2026-05-14 11:00:00",
  "acceptanceRemark": "维修项核验通过,设备运行正常"
}
```
常见失败提示:
- `报修记录id不能为空`
- `报修记录不存在`
- `该报修未进入待验收状态,不能审批`
- `验收人不能为空`
- `验收时间不能为空`
- `验收备注不能为空`
#### 2.3.3 æ™®é€šæ›´æ–°æŽ¥å£é™åˆ¶
- **PUT** `/device/repair`
- é™åˆ¶ï¼šä¸èƒ½é€šè¿‡æ™®é€šæ›´æ–°ç›´æŽ¥æŠŠçŠ¶æ€æ”¹æˆ `完结(1)`(必须走验收审批接口)。
- å¤±è´¥æç¤ºï¼š`请先提交验收审批,验收通过后才可完结`
---
## 3. è¿”回字段变更(报修列表/详情)
以下接口返回已新增验收字段:
- **GET** `/device/repair/page`
- **GET** `/device/repair/{id}`
新增返回字段:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `acceptanceName` | string | éªŒæ”¶äºº |
| `acceptanceTime` | string | éªŒæ”¶æ—¶é—´ |
| `acceptanceRemark` | string | éªŒæ”¶å¤‡æ³¨ |
---
## 4. å‰ç«¯æ”¹é€ å»ºè®®
1. æŠ¥ä¿®åˆ—表增加状态值 `3=待验收` çš„展示文案与筛选项。
2. â€œç¡®è®¤ç»´ä¿®â€æŒ‰é’®è°ƒç”¨ `/device/repair/repair`,成功后刷新为待验收状态。
3. æ–°å¢žâ€œéªŒæ”¶å®¡æ‰¹â€å¼¹çª—,必填:
   - éªŒæ”¶äºº
   - éªŒæ”¶æ—¶é—´
   - éªŒæ”¶å¤‡æ³¨
4. ç¦æ­¢åœ¨æ™®é€šç¼–辑页直接将状态置为完结。
5. è®¾å¤‡ä¿å…»å®šæ—¶ä»»åŠ¡æ–°å¢žâ€œä¿å…»äººâ€è¾“å…¥é¡¹ï¼Œå¹¶åœ¨åˆ—è¡¨/详情展示。
6. ç§‘目总账页面按单行合计渲染,不再显示凭证字号、摘要列。
---
## 5. è”调检查清单
1. ç§‘目总账查询返回 `data.length === 1`,且无 `voucherNo/summary`。
2. æ–°å¢žä¿å…»å®šæ—¶ä»»åŠ¡æ—¶ä¼  `maintenancePerson`,列表能回显。
3. å®šæ—¶ä»»åŠ¡è§¦å‘åŽï¼Œç”Ÿæˆçš„ä¿å…»è®°å½• `maintenanceActuallyName` ä¸Žå®šæ—¶ä»»åŠ¡ä¿å…»äººä¸€è‡´ã€‚
4. æŠ¥ä¿®å•流程:`0待维修 -> 3待验收 -> 1完结`。
5. å¾…验收单据未填验收人/验收时间/验收备注时,后端返回对应错误提示。
6. å°è¯•通过 `PUT /device/repair` ç›´æŽ¥è®¾ä¸ºå®Œç»“时,后端返回拦截提示。
---
## 6. æ•°æ®åº“变更(联调前确认)
```sql
ALTER TABLE maintenance_task
  ADD COLUMN maintenance_person VARCHAR(100) NULL COMMENT '保养人';
ALTER TABLE device_repair
  ADD COLUMN acceptance_name VARCHAR(100) NULL COMMENT '验收人',
  ADD COLUMN acceptance_time DATETIME NULL COMMENT '验收时间',
  ADD COLUMN acceptance_remark VARCHAR(500) NULL COMMENT '验收备注';
```
> è‹¥æœªæ‰§è¡Œä»¥ä¸Š SQL,相关接口会出现字段不存在异常。
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherDto.java
@@ -1,6 +1,7 @@
package com.ruoyi.account.bean.dto.financial;
import com.ruoyi.account.pojo.financial.FinVoucher;
import com.ruoyi.basic.dto.StorageBlobDTO;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -17,4 +18,6 @@
     * å‡­è¯æ˜Žç»†åˆ†å½•。
     */
    private List<FinVoucherEntryDto> entries;
    private List<StorageBlobDTO> storageBlobDTOs;
}
src/main/java/com/ruoyi/account/bean/vo/financial/FinLedgerRowVo.java
@@ -1,5 +1,6 @@
package com.ruoyi.account.bean.vo.financial;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.math.BigDecimal;
@@ -9,6 +10,7 @@
 * ç§‘目账行数据返回对象。
 */
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class FinLedgerRowVo {
    /**
src/main/java/com/ruoyi/account/bean/vo/financial/FinVoucherDetailVo.java
@@ -2,6 +2,8 @@
import com.ruoyi.account.pojo.financial.FinVoucher;
import com.ruoyi.account.pojo.financial.FinVoucherEntry;
import com.ruoyi.basic.dto.StorageBlobDTO;
import com.ruoyi.basic.dto.StorageBlobVO;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -18,4 +20,5 @@
     * å‡­è¯åˆ†å½•列表。
     */
    private List<FinVoucherEntry> entries;
    private List<StorageBlobVO> storageBlobVOList;
}
src/main/java/com/ruoyi/account/service/impl/financial/FinLedgerServiceImpl.java
@@ -41,7 +41,7 @@
        if (startMonth.isAfter(endMonth)) {
            throw new ServiceException("开始月份不能大于结束月份");
        }
        return buildLedgerRows(queryDto.getSubjectCode(), startMonth, endMonth, null, null);
        return Collections.singletonList(buildGeneralLedgerTotalRow(queryDto.getSubjectCode(), startMonth, endMonth));
    }
    @Override
@@ -117,6 +117,37 @@
        return rows;
    }
    private FinLedgerRowVo buildGeneralLedgerTotalRow(String subjectCode, YearMonth startMonth, YearMonth endMonth) {
        LocalDate startDate = startMonth.atDay(1);
        LocalDate endDate = endMonth.atEndOfMonth();
        List<FinLedgerEntryRecordVo> openingEntries = finVoucherEntryMapper.listPostedEntriesBefore(
                subjectCode, startDate, null, null
        );
        BigDecimal openingBalance = calculateBalance(openingEntries);
        List<FinLedgerEntryRecordVo> currentPeriodEntries = finVoucherEntryMapper.listPostedEntries(
                subjectCode, startDate, endDate, null, null
        );
        BigDecimal totalDebit = ZERO;
        BigDecimal totalCredit = ZERO;
        for (FinLedgerEntryRecordVo entry : currentPeriodEntries) {
            totalDebit = totalDebit.add(money(entry.getDebit()));
            totalCredit = totalCredit.add(money(entry.getCredit()));
        }
        BigDecimal endingBalance = openingBalance.add(totalDebit).subtract(totalCredit);
        FinLedgerRowVo totalRow = new FinLedgerRowVo();
        totalRow.setRowType("yearly_total");
        totalRow.setDate(endDate);
        totalRow.setDebit(money(totalDebit));
        totalRow.setCredit(money(totalCredit));
        totalRow.setBalance(money(endingBalance));
        totalRow.setDirection(resolveDirection(endingBalance));
        return totalRow;
    }
    private Map<YearMonth, List<FinLedgerEntryRecordVo>> groupEntriesByMonth(List<FinLedgerEntryRecordVo> entries) {
        Map<YearMonth, List<FinLedgerEntryRecordVo>> map = new LinkedHashMap<>();
        for (FinLedgerEntryRecordVo entry : entries) {
src/main/java/com/ruoyi/account/service/impl/financial/FinVoucherServiceImpl.java
@@ -15,6 +15,9 @@
import com.ruoyi.account.pojo.financial.FinVoucher;
import com.ruoyi.account.pojo.financial.FinVoucherEntry;
import com.ruoyi.account.service.financial.FinVoucherService;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.RequiredArgsConstructor;
@@ -38,6 +41,7 @@
    private final FinVoucherEntryMapper finVoucherEntryMapper;
    private final AccountSubjectMapper accountSubjectMapper;
    private final FileUtil fileUtil;
    @Override
    public IPage<FinVoucher> pageList(Page<FinVoucher> page, FinVoucherPageDto queryDto) {
@@ -80,6 +84,8 @@
        }
        save(voucher);
        saveEntries(voucher.getId(), validEntries);
        // 5. ä¿å­˜é”€å”®å°è´¦é™„ä»¶
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.FIN_VOUCHER, voucher.getId(), dto.getStorageBlobDTOs());
        return true;
    }
@@ -113,6 +119,7 @@
        deleteWrapper.eq(FinVoucherEntry::getVoucherId, voucher.getId());
        finVoucherEntryMapper.delete(deleteWrapper);
        saveEntries(voucher.getId(), validEntries);
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.FIN_VOUCHER, voucher.getId(), dto.getStorageBlobDTOs());
        return true;
    }
@@ -159,6 +166,7 @@
        FinVoucherDetailVo vo = new FinVoucherDetailVo();
        BeanUtils.copyProperties(voucher, vo);
        vo.setEntries(entries);
        vo.setStorageBlobVOList(fileUtil.getStorageBlobVOsByRecordTypeAndRecordId(RecordTypeEnum.FIN_VOUCHER, id));
        return vo;
    }
src/main/java/com/ruoyi/ai/assistant/ManufacturingAgent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.ai.assistant;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.spring.AiService;
import reactor.core.publisher.Flux;
import static dev.langchain4j.service.spring.AiServiceWiringMode.EXPLICIT;
@AiService(
        wiringMode = EXPLICIT,
        streamingChatModel = "qwenStreamingChatModel",
        chatMemoryProvider = "chatMemoryProviderManufacturing",
        tools = "manufacturingAgentTools"
)
public interface ManufacturingAgent {
    @SystemMessage(fromResource = "manufacturing-agent-prompt.txt")
    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
}
src/main/java/com/ruoyi/ai/assistant/ManufacturingIntentExecutor.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,136 @@
package com.ruoyi.ai.assistant;
import com.ruoyi.ai.tools.ManufacturingAgentTools;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class ManufacturingIntentExecutor {
    private static final Pattern LIMIT_PATTERN = Pattern.compile("(前|最近)?(\\d{1,2})条");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private final ManufacturingAgentTools manufacturingAgentTools;
    public ManufacturingIntentExecutor(ManufacturingAgentTools manufacturingAgentTools) {
        this.manufacturingAgentTools = manufacturingAgentTools;
    }
    public String tryExecute(String memoryId, String message) {
        if (!StringUtils.hasText(message)) {
            return null;
        }
        String text = message.trim();
        String keyword = extractKeyword(text);
        Integer limit = extractLimit(text);
        String startDate = extractStartDate(text);
        String endDate = extractEndDate(text);
        if (containsAny(text, "预警", "告警", "风险", "提醒")) {
            return manufacturingAgentTools.getWarningBoard(memoryId, startDate, endDate, text);
        }
        if (containsAny(text, "分析", "统计", "趋势", "看板", "报表", "总览")) {
            return manufacturingAgentTools.analyzeFactory(memoryId, startDate, endDate, text);
        }
        if (containsAny(text, "办", "处理", "派工", "安排", "闭环", "跟进", "处置")) {
            return manufacturingAgentTools.planActions(memoryId, text);
        }
        if (containsAny(text, "生产现场", "现场", "车间")) {
            return manufacturingAgentTools.queryDomain(memoryId, "site", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "计划", "排产", "mps")) {
            return manufacturingAgentTools.queryDomain(memoryId, "plan", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "工单", "作业单", "任务单", "任务")) {
            return manufacturingAgentTools.queryDomain(memoryId, "workorder", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "设备", "ç»´ä¿®", "保养", "故障")) {
            return manufacturingAgentTools.queryDomain(memoryId, "device", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "质量", "质检", "不合格", "检验")) {
            return manufacturingAgentTools.queryDomain(memoryId, "quality", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "物料", "库存", "库位", "入库", "出库")) {
            return manufacturingAgentTools.queryDomain(memoryId, "material", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "异常", "例外", "偏差")) {
            return manufacturingAgentTools.queryDomain(memoryId, "exception", keyword, limit, startDate, endDate, text);
        }
        return null;
    }
    private boolean containsAny(String text, String... keywords) {
        for (String keyword : keywords) {
            if (text.toLowerCase().contains(keyword.toLowerCase())) {
                return true;
            }
        }
        return false;
    }
    private Integer extractLimit(String text) {
        Matcher matcher = LIMIT_PATTERN.matcher(text);
        return matcher.find() ? Integer.parseInt(matcher.group(2)) : 10;
    }
    private String extractStartDate(String text) {
        Matcher matcher = DATE_PATTERN.matcher(text);
        return matcher.find() ? matcher.group(1) : null;
    }
    private String extractEndDate(String text) {
        Matcher matcher = DATE_PATTERN.matcher(text);
        if (!matcher.find()) {
            return null;
        }
        return matcher.find() ? matcher.group(1) : null;
    }
    private String extractKeyword(String text) {
        String cleaned = text
                .replace("查询", "")
                .replace("查看", "")
                .replace("帮我", "")
                .replace("请", "")
                .replace("一下", "")
                .replace("所有", "")
                .replace("全部", "")
                .replace("今年", "")
                .replace("本年", "")
                .replace("去年", "")
                .replace("本月", "")
                .replace("上月", "")
                .replace("本周", "")
                .replace("上周", "")
                .replace("今天", "")
                .replace("昨天", "")
                .replace("近30天", "")
                .replace("近7天", "")
                .replace("近15天", "")
                .replace("近60天", "")
                .replace("最近30天", "")
                .replace("最近7天", "")
                .replace("最近15天", "")
                .replace("最近60天", "")
                .replace("生产现场", "")
                .replace("现场", "")
                .replace("生产工单", "")
                .replace("生产", "")
                .replace("计划", "")
                .replace("排产", "")
                .replace("工单", "")
                .replace("设备", "")
                .replace("质量", "")
                .replace("物料", "")
                .replace("库存", "")
                .replace("异常", "")
                .replace("前10条", "")
                .replace("最近10条", "")
                .trim();
        return cleaned.length() >= 2 ? cleaned : null;
    }
}
src/main/java/com/ruoyi/ai/config/ManufacturingAgentConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package com.ruoyi.ai.config;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ManufacturingAgentConfig {
    @Bean
    ChatMemoryProvider chatMemoryProviderManufacturing(MongoChatMemoryStore mongoChatMemoryStore) {
        return memoryId -> MessageWindowChatMemory.builder()
                .id(memoryId)
                .maxMessages(30)
                .chatMemoryStore(mongoChatMemoryStore)
                .build();
    }
}
src/main/java/com/ruoyi/ai/controller/ManufacturingAiController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
package com.ruoyi.ai.controller;
import com.ruoyi.ai.assistant.ManufacturingAgent;
import com.ruoyi.ai.assistant.ManufacturingIntentExecutor;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.UserMessage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.List;
@Tag(name = "制造智能助手")
@RestController
@RequestMapping("/manufacturing-ai")
public class ManufacturingAiController extends BaseController {
    private final ManufacturingAgent manufacturingAgent;
    private final ManufacturingIntentExecutor manufacturingIntentExecutor;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiChatSessionService aiChatSessionService;
    public ManufacturingAiController(ManufacturingAgent manufacturingAgent,
                                     ManufacturingIntentExecutor manufacturingIntentExecutor,
                                     AiSessionUserContext aiSessionUserContext,
                                     MongoChatMemoryStore mongoChatMemoryStore,
                                     AiChatSessionService aiChatSessionService) {
        this.manufacturingAgent = manufacturingAgent;
        this.manufacturingIntentExecutor = manufacturingIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiChatSessionService = aiChatSessionService;
    }
    @Operation(summary = "制造对话")
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    public Flux<String> chat(@RequestBody ChatForm chatForm) {
        if (!StringUtils.hasText(chatForm.getMemoryId())) {
            return Flux.just("memoryId不能为空");
        }
        if (!StringUtils.hasText(chatForm.getMessage())) {
            return Flux.just("message不能为空");
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        String memoryId = chatForm.getMemoryId();
        String userMessage = chatForm.getMessage();
        aiSessionUserContext.bind(memoryId, loginUser);
        aiChatSessionService.touchSession(memoryId, loginUser, userMessage);
        String directResponse = manufacturingIntentExecutor.tryExecute(memoryId, userMessage);
        if (StringUtils.isNotEmpty(directResponse)) {
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(directResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(directResponse);
        }
        return manufacturingAgent.chat(memoryId, userMessage)
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
    }
    @Operation(summary = "制造会话列表")
    @GetMapping("/history/sessions")
    public AjaxResult listSessions() {
        return success(aiChatSessionService.listCurrentUserSessions(SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "制造会话消息")
    @GetMapping("/history/messages/{memoryId}")
    public AjaxResult listMessages(@PathVariable String memoryId) {
        return success(aiChatSessionService.listCurrentUserMessages(memoryId, SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "删除制造会话")
    @DeleteMapping("/history/{memoryId}")
    public AjaxResult deleteSession(@PathVariable String memoryId) {
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
}
src/main/java/com/ruoyi/ai/tools/ManufacturingAgentTools.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1035 @@
package com.ruoyi.ai.tools;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.device.mapper.DeviceDefectRecordMapper;
import com.ruoyi.device.mapper.DeviceLedgerMapper;
import com.ruoyi.device.mapper.DeviceRepairMapper;
import com.ruoyi.device.pojo.DeviceDefectRecord;
import com.ruoyi.device.pojo.DeviceLedger;
import com.ruoyi.device.pojo.DeviceRepair;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.procurementrecord.mapper.ProcurementExceptionRecordMapper;
import com.ruoyi.procurementrecord.pojo.ProcurementExceptionRecord;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionPlanMapper;
import com.ruoyi.production.mapper.ProductionProductMainMapper;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionPlan;
import com.ruoyi.production.pojo.ProductionProductMain;
import com.ruoyi.quality.mapper.QualityInspectMapper;
import com.ruoyi.quality.mapper.QualityUnqualifiedMapper;
import com.ruoyi.quality.pojo.QualityInspect;
import com.ruoyi.quality.pojo.QualityUnqualified;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInventory;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Component
public class ManufacturingAgentTools {
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final int DEFAULT_LIMIT = 10;
    private static final int MAX_LIMIT = 30;
    private static final int DEVICE_REPAIR_STATUS_PENDING = 0;
    private final ProductionPlanMapper productionPlanMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionProductMainMapper productionProductMainMapper;
    private final DeviceLedgerMapper deviceLedgerMapper;
    private final DeviceRepairMapper deviceRepairMapper;
    private final DeviceDefectRecordMapper deviceDefectRecordMapper;
    private final QualityInspectMapper qualityInspectMapper;
    private final QualityUnqualifiedMapper qualityUnqualifiedMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final ProcurementExceptionRecordMapper procurementExceptionRecordMapper;
    private final AiSessionUserContext aiSessionUserContext;
    public ManufacturingAgentTools(ProductionPlanMapper productionPlanMapper,
                                   ProductionOrderMapper productionOrderMapper,
                                   ProductionOperationTaskMapper productionOperationTaskMapper,
                                   ProductionProductMainMapper productionProductMainMapper,
                                   DeviceLedgerMapper deviceLedgerMapper,
                                   DeviceRepairMapper deviceRepairMapper,
                                   DeviceDefectRecordMapper deviceDefectRecordMapper,
                                   QualityInspectMapper qualityInspectMapper,
                                   QualityUnqualifiedMapper qualityUnqualifiedMapper,
                                   StockInventoryMapper stockInventoryMapper,
                                   ProcurementExceptionRecordMapper procurementExceptionRecordMapper,
                                   AiSessionUserContext aiSessionUserContext) {
        this.productionPlanMapper = productionPlanMapper;
        this.productionOrderMapper = productionOrderMapper;
        this.productionOperationTaskMapper = productionOperationTaskMapper;
        this.productionProductMainMapper = productionProductMainMapper;
        this.deviceLedgerMapper = deviceLedgerMapper;
        this.deviceRepairMapper = deviceRepairMapper;
        this.deviceDefectRecordMapper = deviceDefectRecordMapper;
        this.qualityInspectMapper = qualityInspectMapper;
        this.qualityUnqualifiedMapper = qualityUnqualifiedMapper;
        this.stockInventoryMapper = stockInventoryMapper;
        this.procurementExceptionRecordMapper = procurementExceptionRecordMapper;
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Tool(name = "查询制造业务域数据", value = "按业务域查询生产现场、计划、工单、设备、质量、物料、异常处理相关数据。")
    public String queryDomain(@ToolMemoryId String memoryId,
                              @P(value = "业务域,site/plan/workorder/device/quality/material/exception") String domain,
                              @P(value = "关键字,可不传", required = false) String keyword,
                              @P(value = "返回条数,默认10,最大30", required = false) Integer limit,
                              @P(value = "开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                              @P(value = "结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                              @P(value = "时间范围描述,例如今年、本月、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        int finalLimit = normalizeLimit(limit);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        boolean hasTimeConstraint = hasTimeConstraint(startDate, endDate, timeRange);
        String normalizedDomain = normalizeDomain(domain);
        return switch (normalizedDomain) {
            case "site" -> siteSnapshot(loginUser, range);
            case "plan" -> listProductionPlans(loginUser, keyword, finalLimit, range);
            case "workorder" -> listWorkOrders(loginUser, keyword, finalLimit, range);
            case "device" -> isRepairIntent(keyword, timeRange)
                    ? listDeviceRepairs(loginUser, normalizeDeviceQueryKeyword(keyword, timeRange), finalLimit, range, hasTimeConstraint)
                    : listDevices(loginUser, normalizeDeviceQueryKeyword(keyword, timeRange), finalLimit);
            case "repair" -> listDeviceRepairs(loginUser, normalizeDeviceQueryKeyword(keyword, timeRange), finalLimit, range, hasTimeConstraint);
            case "quality" -> listQualityIssues(loginUser, keyword, finalLimit, range);
            case "material" -> listMaterialInventory(loginUser, keyword, finalLimit);
            case "exception" -> listExceptions(loginUser, keyword, finalLimit, range);
            default -> jsonResponse(false, "manufacturing_query", "不支持的业务域: " + safe(domain), Map.of(), Map.of(), Map.of());
        };
    }
    @Tool(name = "制造预警看板", value = "计算计划、工单、设备、质量、物料、异常处理的预警信息。")
    public String getWarningBoard(@ToolMemoryId String memoryId,
                                  @P(value = "开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                                  @P(value = "结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                                  @P(value = "时间范围描述,例如今天、本周、本月、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        LocalDate today = LocalDate.now();
        long overduePlanCount = countOverduePlans(loginUser, today);
        long overdueWorkOrderCount = countOverdueWorkOrders(loginUser, today);
        long pendingRepairCount = countPendingRepairs(loginUser);
        long qualityOpenCount = countOpenQualityIssues(loginUser, range);
        long lowStockCount = countLowStock(loginUser);
        long exceptionCount = countExceptionRecords(loginUser, range);
        List<Map<String, Object>> warningItems = new ArrayList<>();
        if (overduePlanCount > 0) {
            warningItems.add(warningItem("high", "计划逾期", overduePlanCount, "有生产计划超过需求日期仍未完成"));
        }
        if (overdueWorkOrderCount > 0) {
            warningItems.add(warningItem("high", "工单逾期", overdueWorkOrderCount, "有工单计划结束日期已过仍未完工"));
        }
        if (pendingRepairCount > 0) {
            warningItems.add(warningItem("medium", "设备待维修", pendingRepairCount, "存在待维修/维修中的设备"));
        }
        if (qualityOpenCount > 0) {
            warningItems.add(warningItem("high", "质量未闭环", qualityOpenCount, "存在未处理完成的不合格记录"));
        }
        if (lowStockCount > 0) {
            warningItems.add(warningItem("medium", "物料低库存", lowStockCount, "库存数量低于或等于预警阈值"));
        }
        if (exceptionCount > 0) {
            warningItems.add(warningItem("medium", "异常记录", exceptionCount, "时间范围内存在异常处理记录"));
        }
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", range.start().toString());
        summary.put("endDate", range.end().toString());
        summary.put("warningCount", warningItems.size());
        summary.put("overduePlanCount", overduePlanCount);
        summary.put("overdueWorkOrderCount", overdueWorkOrderCount);
        summary.put("pendingRepairCount", pendingRepairCount);
        summary.put("qualityOpenCount", qualityOpenCount);
        summary.put("lowStockCount", lowStockCount);
        summary.put("exceptionCount", exceptionCount);
        return jsonResponse(true, "manufacturing_warning", "已返回制造预警看板。", summary,
                Map.of("items", warningItems), Map.of());
    }
    @Tool(name = "制造经营分析", value = "按时间范围输出制造关键指标,支持查、问、分析场景。")
    public String analyzeFactory(@ToolMemoryId String memoryId,
                                 @P(value = "开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                                 @P(value = "结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                                 @P(value = "时间范围描述,例如本月、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        long planTotal = countPlans(loginUser, range);
        long planCompleted = countPlansByStatus(loginUser, range, 2);
        long workOrderTotal = countWorkOrders(loginUser, range);
        long workOrderCompleted = countWorkOrdersByStatus(loginUser, range, 2);
        long workOrderInProgress = countWorkOrdersByStatus(loginUser, range, 1);
        long outputCount = countOutputs(loginUser, range);
        long deviceTotal = countDevices(loginUser);
        long pendingRepairCount = countPendingRepairs(loginUser);
        long qualityInspectTotal = countQualityInspect(loginUser, range);
        long qualityNgCount = countOpenQualityIssues(loginUser, range);
        long materialSkuCount = countInventorySku(loginUser);
        long lowStockCount = countLowStock(loginUser);
        long exceptionCount = countExceptionRecords(loginUser, range);
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", range.start().toString());
        summary.put("endDate", range.end().toString());
        summary.put("planTotal", planTotal);
        summary.put("planCompleted", planCompleted);
        summary.put("planCompletionRate", toRate(planCompleted, planTotal));
        summary.put("workOrderTotal", workOrderTotal);
        summary.put("workOrderCompleted", workOrderCompleted);
        summary.put("workOrderInProgress", workOrderInProgress);
        summary.put("workOrderCompletionRate", toRate(workOrderCompleted, workOrderTotal));
        summary.put("outputCount", outputCount);
        summary.put("deviceTotal", deviceTotal);
        summary.put("pendingRepairCount", pendingRepairCount);
        summary.put("qualityInspectTotal", qualityInspectTotal);
        summary.put("qualityNgCount", qualityNgCount);
        summary.put("qualityIssueRate", toRate(qualityNgCount, qualityInspectTotal));
        summary.put("materialSkuCount", materialSkuCount);
        summary.put("lowStockCount", lowStockCount);
        summary.put("exceptionCount", exceptionCount);
        List<Map<String, Object>> coreMetrics = List.of(
                metric("计划完成率", toRate(planCompleted, planTotal)),
                metric("工单完成率", toRate(workOrderCompleted, workOrderTotal)),
                metric("质量异常率", toRate(qualityNgCount, qualityInspectTotal)),
                metric("低库存占比", toRate(lowStockCount, materialSkuCount))
        );
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("domainBarOption", buildDomainBarOption(summary));
        charts.put("qualityPieOption", buildQualityPieOption(qualityInspectTotal, qualityNgCount));
        return jsonResponse(true, "manufacturing_analysis", "已返回制造分析结果。", summary,
                Map.of("coreMetrics", coreMetrics), charts);
    }
    @Tool(name = "生成制造办理建议", value = "根据用户问题输出可执行的办理动作建议,包括目标业务接口、必填字段和示例。")
    public String planActions(@ToolMemoryId String memoryId,
                              @P("用户诉求原文") String userQuery) {
        LoginUser loginUser = currentLoginUser(memoryId);
        List<Map<String, Object>> actionCards = new ArrayList<>();
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "工单", "派工", "作业")) {
            actionCards.add(actionCard(
                    "workorder_assign",
                    "工单派工",
                    "POST",
                    "/productionOperationTask/assign",
                    List.of("id", "userIds"),
                    Map.of("id", 10001, "userIds", "12,13"),
                    "将工单分配给指定人员,适用于现场调度。"));
        }
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "设备", "ç»´ä¿®", "故障")) {
            actionCards.add(actionCard(
                    "device_repair_create",
                    "创建设备维修单",
                    "POST",
                    "/device/repair",
                    List.of("deviceLedgerId", "deviceName", "repairName", "remark"),
                    Map.of("deviceLedgerId", 1001, "deviceName", "空压机A-01", "repairName", "张三", "remark", "异响并伴随温升"),
                    "新建维修单,进入设备异常处理闭环。"));
        }
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "质量", "不合格", "闭环")) {
            actionCards.add(actionCard(
                    "quality_unqualified_deal",
                    "处理不合格单",
                    "POST",
                    "/quality/qualityUnqualified/deal",
                    List.of("id", "dealResult", "dealName"),
                    Map.of("id", 3001, "dealResult", "返工后复检", "dealName", "李四"),
                    "对不合格记录执行处置并闭环。"));
        }
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "物料", "库存", "补料")) {
            actionCards.add(actionCard(
                    "material_inbound",
                    "补充库存",
                    "POST",
                    "/stockInventory/addstockInventory",
                    List.of("productModelId", "batchNo", "qualitity"),
                    Map.of("productModelId", 5001, "batchNo", "B2026051601", "qualitity", 120),
                    "当低库存预警触发时,增加库存数量。"));
        }
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "异常", "采购异常", "来料异常")) {
            actionCards.add(actionCard(
                    "procurement_exception_add",
                    "登记异常记录",
                    "POST",
                    "/procurementExceptionRecord/add",
                    List.of("purchaseLedgerId", "exceptionReason", "exceptionNum"),
                    Map.of("purchaseLedgerId", 888, "exceptionReason", "到料短缺", "exceptionNum", 24),
                    "登记采购/来料异常,便于后续追踪和分析。"));
        }
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("actionCount", actionCards.size());
        summary.put("userId", loginUser.getUserId());
        summary.put("tenantId", loginUser.getTenantId());
        return jsonResponse(true, "manufacturing_action_plan", "已生成办理建议,请前端引导用户确认后调用目标业务接口。",
                summary, Map.of("actionCards", actionCards), Map.of());
    }
    private String siteSnapshot(LoginUser loginUser, DateRange range) {
        long planTotal = countPlans(loginUser, range);
        long workOrderTotal = countWorkOrders(loginUser, range);
        long outputCount = countOutputs(loginUser, range);
        long deviceTotal = countDevices(loginUser);
        long pendingRepairCount = countPendingRepairs(loginUser);
        long qualityOpenCount = countOpenQualityIssues(loginUser, range);
        long lowStockCount = countLowStock(loginUser);
        long exceptionCount = countExceptionRecords(loginUser, range);
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", range.start().toString());
        summary.put("endDate", range.end().toString());
        summary.put("planTotal", planTotal);
        summary.put("workOrderTotal", workOrderTotal);
        summary.put("outputCount", outputCount);
        summary.put("deviceTotal", deviceTotal);
        summary.put("pendingRepairCount", pendingRepairCount);
        summary.put("qualityOpenCount", qualityOpenCount);
        summary.put("lowStockCount", lowStockCount);
        summary.put("exceptionCount", exceptionCount);
        return jsonResponse(true, "manufacturing_site_snapshot", "已返回生产现场概览。", summary, Map.of(), Map.of());
    }
    private String listProductionPlans(LoginUser loginUser, String keyword, int limit, DateRange range) {
        LambdaQueryWrapper<ProductionPlan> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        wrapper.ge(ProductionPlan::getRequiredDate, range.start()).le(ProductionPlan::getRequiredDate, range.end());
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ProductionPlan::getMpsNo, keyword)
                    .or().like(ProductionPlan::getRemark, keyword)
                    .or().like(ProductionPlan::getSource, keyword));
        }
        wrapper.orderByDesc(ProductionPlan::getRequiredDate, ProductionPlan::getId).last("limit " + limit);
        List<Map<String, Object>> items = defaultList(productionPlanMapper.selectList(wrapper)).stream()
                .map(this::toPlanItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_plan_list", "已返回生产计划列表。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private String listWorkOrders(LoginUser loginUser, String keyword, int limit, DateRange range) {
        LambdaQueryWrapper<ProductionOperationTask> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        wrapper.ge(ProductionOperationTask::getPlanStartTime, range.start())
                .le(ProductionOperationTask::getPlanEndTime, range.end());
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ProductionOperationTask::getWorkOrderNo, keyword)
                    .or().like(ProductionOperationTask::getUserIds, keyword));
        }
        wrapper.orderByDesc(ProductionOperationTask::getPlanEndTime, ProductionOperationTask::getId)
                .last("limit " + limit);
        List<Map<String, Object>> items = defaultList(productionOperationTaskMapper.selectList(wrapper)).stream()
                .map(this::toWorkOrderItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_workorder_list", "已返回工单列表。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private String listDevices(LoginUser loginUser, String keyword, int limit) {
        LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(DeviceLedger::getDeviceName, keyword)
                    .or().like(DeviceLedger::getDeviceModel, keyword)
                    .or().like(DeviceLedger::getDeviceBrand, keyword));
        }
        wrapper.orderByDesc(DeviceLedger::getId).last("limit " + limit);
        Map<Long, Long> pendingRepairMap = pendingRepairCountByDevice(loginUser);
        List<Map<String, Object>> items = defaultList(deviceLedgerMapper.selectList(wrapper)).stream()
                .map(item -> toDeviceItem(item, pendingRepairMap.getOrDefault(item.getId(), 0L)))
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_device_list", "已返回设备列表。",
                Map.of("count", items.size(), "keyword", safe(keyword)), Map.of("items", items), Map.of());
    }
    private String listDeviceRepairs(LoginUser loginUser, String keyword, int limit, DateRange range, boolean hasTimeConstraint) {
        LambdaQueryWrapper<DeviceRepair> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceRepair::getTenantId);
        Long currentDeptId = loginUser.getCurrentDeptId();
        if (currentDeptId != null) {
            wrapper.and(w -> w.eq(DeviceRepair::getDeptId, currentDeptId).or().isNull(DeviceRepair::getDeptId));
        }
        if (hasTimeConstraint) {
            wrapper.ge(DeviceRepair::getCreateTime, range.start().atStartOfDay())
                    .lt(DeviceRepair::getCreateTime, range.end().plusDays(1).atStartOfDay());
        }
        if (StringUtils.hasText(keyword)) {
            List<Long> matchedDeviceIds = findDeviceLedgerIdsByKeyword(loginUser, keyword);
            wrapper.and(w -> {
                w.like(DeviceRepair::getDeviceName, keyword)
                        .or().like(DeviceRepair::getDeviceModel, keyword)
                        .or().like(DeviceRepair::getRemark, keyword)
                        .or().like(DeviceRepair::getRepairName, keyword)
                        .or().like(DeviceRepair::getMaintenanceName, keyword);
                if (!matchedDeviceIds.isEmpty()) {
                    w.or().in(DeviceRepair::getDeviceLedgerId, matchedDeviceIds);
                }
            });
        }
        wrapper.orderByDesc(DeviceRepair::getCreateTime, DeviceRepair::getId).last("limit " + limit);
        List<Map<String, Object>> items = defaultList(deviceRepairMapper.selectList(wrapper)).stream()
                .map(this::toDeviceRepairItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_device_repair_list", "已返回设备维修记录。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private String listQualityIssues(LoginUser loginUser, String keyword, int limit, DateRange range) {
        LambdaQueryWrapper<QualityUnqualified> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), QualityUnqualified::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), QualityUnqualified::getDeptId);
        wrapper.ge(QualityUnqualified::getCheckTime, toDate(range.start()))
                .lt(QualityUnqualified::getCheckTime, toExclusiveEndDate(range.end()));
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(QualityUnqualified::getProductName, keyword)
                    .or().like(QualityUnqualified::getDefectivePhenomena, keyword)
                    .or().like(QualityUnqualified::getDealResult, keyword));
        }
        wrapper.orderByDesc(QualityUnqualified::getCheckTime, QualityUnqualified::getId).last("limit " + limit);
        List<Map<String, Object>> items = defaultList(qualityUnqualifiedMapper.selectList(wrapper)).stream()
                .map(this::toQualityItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_quality_list", "已返回质量异常列表。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private String listMaterialInventory(LoginUser loginUser, String keyword, int limit) {
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(StockInventory::getBatchNo, keyword)
                    .or().like(StockInventory::getProductModelId, keyword));
        }
        wrapper.orderByDesc(StockInventory::getId).last("limit " + limit);
        List<Map<String, Object>> items = defaultList(stockInventoryMapper.selectList(wrapper)).stream()
                .map(this::toMaterialItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_material_list", "已返回物料库存列表。",
                Map.of("count", items.size(), "keyword", safe(keyword)), Map.of("items", items), Map.of());
    }
    private String listExceptions(LoginUser loginUser, String keyword, int limit, DateRange range) {
        LambdaQueryWrapper<ProcurementExceptionRecord> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ProcurementExceptionRecord::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProcurementExceptionRecord::getDeptId);
        wrapper.ge(ProcurementExceptionRecord::getCreateTime, range.start().atStartOfDay())
                .lt(ProcurementExceptionRecord::getCreateTime, range.end().plusDays(1).atStartOfDay());
        if (StringUtils.hasText(keyword)) {
            wrapper.like(ProcurementExceptionRecord::getExceptionReason, keyword);
        }
        wrapper.orderByDesc(ProcurementExceptionRecord::getCreateTime, ProcurementExceptionRecord::getId)
                .last("limit " + limit);
        List<Map<String, Object>> items = defaultList(procurementExceptionRecordMapper.selectList(wrapper)).stream()
                .map(this::toExceptionItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_exception_list", "已返回异常处理列表。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private long countPlans(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ProductionPlan> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        wrapper.ge(ProductionPlan::getRequiredDate, range.start()).le(ProductionPlan::getRequiredDate, range.end());
        return productionPlanMapper.selectCount(wrapper);
    }
    private long countPlansByStatus(LoginUser loginUser, DateRange range, int status) {
        LambdaQueryWrapper<ProductionPlan> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        wrapper.ge(ProductionPlan::getRequiredDate, range.start())
                .le(ProductionPlan::getRequiredDate, range.end())
                .eq(ProductionPlan::getStatus, status);
        return productionPlanMapper.selectCount(wrapper);
    }
    private long countWorkOrders(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ProductionOperationTask> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        wrapper.ge(ProductionOperationTask::getPlanStartTime, range.start())
                .le(ProductionOperationTask::getPlanEndTime, range.end());
        return productionOperationTaskMapper.selectCount(wrapper);
    }
    private long countWorkOrdersByStatus(LoginUser loginUser, DateRange range, int status) {
        LambdaQueryWrapper<ProductionOperationTask> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        wrapper.ge(ProductionOperationTask::getPlanStartTime, range.start())
                .le(ProductionOperationTask::getPlanEndTime, range.end())
                .eq(ProductionOperationTask::getStatus, status);
        return productionOperationTaskMapper.selectCount(wrapper);
    }
    private long countOutputs(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ProductionProductMain> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionProductMain::getDeptId);
        wrapper.ge(ProductionProductMain::getCreateTime, range.start().atStartOfDay())
                .lt(ProductionProductMain::getCreateTime, range.end().plusDays(1).atStartOfDay());
        return productionProductMainMapper.selectCount(wrapper);
    }
    private long countDevices(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId);
        return deviceLedgerMapper.selectCount(wrapper);
    }
    private long countPendingRepairs(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceRepair> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceRepair::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceRepair::getDeptId);
        wrapper.eq(DeviceRepair::getStatus, DEVICE_REPAIR_STATUS_PENDING);
        return deviceRepairMapper.selectCount(wrapper);
    }
    private long countQualityInspect(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<QualityInspect> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), QualityInspect::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), QualityInspect::getDeptId);
        wrapper.ge(QualityInspect::getCheckTime, toDate(range.start()))
                .lt(QualityInspect::getCheckTime, toExclusiveEndDate(range.end()));
        return qualityInspectMapper.selectCount(wrapper);
    }
    private long countOpenQualityIssues(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<QualityUnqualified> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), QualityUnqualified::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), QualityUnqualified::getDeptId);
        wrapper.ge(QualityUnqualified::getCheckTime, toDate(range.start()))
                .lt(QualityUnqualified::getCheckTime, toExclusiveEndDate(range.end()))
                .ne(QualityUnqualified::getInspectState, 2);
        return qualityUnqualifiedMapper.selectCount(wrapper);
    }
    private long countInventorySku(LoginUser loginUser) {
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId);
        return stockInventoryMapper.selectCount(wrapper);
    }
    private long countLowStock(LoginUser loginUser) {
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId);
        wrapper.isNotNull(StockInventory::getWarnNum);
        List<StockInventory> stocks = defaultList(stockInventoryMapper.selectList(wrapper));
        return stocks.stream()
                .filter(this::isLowStock)
                .count();
    }
    private long countExceptionRecords(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ProcurementExceptionRecord> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ProcurementExceptionRecord::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProcurementExceptionRecord::getDeptId);
        wrapper.ge(ProcurementExceptionRecord::getCreateTime, range.start().atStartOfDay())
                .lt(ProcurementExceptionRecord::getCreateTime, range.end().plusDays(1).atStartOfDay());
        return procurementExceptionRecordMapper.selectCount(wrapper);
    }
    private long countOverduePlans(LoginUser loginUser, LocalDate today) {
        LambdaQueryWrapper<ProductionPlan> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        wrapper.lt(ProductionPlan::getRequiredDate, today).ne(ProductionPlan::getStatus, 2);
        return productionPlanMapper.selectCount(wrapper);
    }
    private long countOverdueWorkOrders(LoginUser loginUser, LocalDate today) {
        LambdaQueryWrapper<ProductionOperationTask> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        wrapper.lt(ProductionOperationTask::getPlanEndTime, today).ne(ProductionOperationTask::getStatus, 2);
        return productionOperationTaskMapper.selectCount(wrapper);
    }
    private Map<Long, Long> pendingRepairCountByDevice(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceRepair> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceRepair::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceRepair::getDeptId);
        wrapper.eq(DeviceRepair::getStatus, DEVICE_REPAIR_STATUS_PENDING);
        return defaultList(deviceRepairMapper.selectList(wrapper)).stream()
                .filter(item -> item.getDeviceLedgerId() != null)
                .collect(Collectors.groupingBy(DeviceRepair::getDeviceLedgerId, Collectors.counting()));
    }
    private Map<String, Object> toPlanItem(ProductionPlan item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("mpsNo", safe(item.getMpsNo()));
        map.put("requiredDate", formatDate(item.getRequiredDate()));
        map.put("promisedDeliveryDate", formatDate(item.getPromisedDeliveryDate()));
        map.put("qtyRequired", item.getQtyRequired());
        map.put("quantityIssued", item.getQuantityIssued());
        map.put("status", item.getStatus());
        map.put("source", safe(item.getSource()));
        map.put("remark", safe(item.getRemark()));
        return map;
    }
    private Map<String, Object> toWorkOrderItem(ProductionOperationTask item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("workOrderNo", safe(item.getWorkOrderNo()));
        map.put("productionOrderId", item.getProductionOrderId());
        map.put("planStartTime", formatDate(item.getPlanStartTime()));
        map.put("planEndTime", formatDate(item.getPlanEndTime()));
        map.put("actualStartTime", formatDate(item.getActualStartTime()));
        map.put("actualEndTime", formatDate(item.getActualEndTime()));
        map.put("planQuantity", item.getPlanQuantity());
        map.put("completeQuantity", item.getCompleteQuantity());
        map.put("status", item.getStatus());
        map.put("userIds", safe(item.getUserIds()));
        return map;
    }
    private Map<String, Object> toDeviceItem(DeviceLedger item, long pendingRepairCount) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("deviceName", safe(item.getDeviceName()));
        map.put("deviceModel", safe(item.getDeviceModel()));
        map.put("deviceBrand", safe(item.getDeviceBrand()));
        map.put("status", safe(item.getStatus()));
        map.put("storageLocation", safe(item.getStorageLocation()));
        map.put("supplierName", safe(item.getSupplierName()));
        map.put("pendingRepairCount", pendingRepairCount);
        return map;
    }
    private Map<String, Object> toDeviceRepairItem(DeviceRepair item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("deviceLedgerId", item.getDeviceLedgerId());
        map.put("deviceName", safe(item.getDeviceName()));
        map.put("deviceModel", safe(item.getDeviceModel()));
        map.put("repairTime", formatDate(item.getRepairTime()));
        map.put("repairName", safe(item.getRepairName()));
        map.put("maintenanceName", safe(item.getMaintenanceName()));
        map.put("maintenanceTime", formatDateTime(item.getMaintenanceTime()));
        map.put("maintenanceResult", safe(item.getMaintenanceResult()));
        map.put("acceptanceName", safe(item.getAcceptanceName()));
        map.put("acceptanceTime", formatDateTime(item.getAcceptanceTime()));
        map.put("status", item.getStatus());
        map.put("remark", safe(item.getRemark()));
        map.put("createTime", formatDateTime(item.getCreateTime()));
        return map;
    }
    private Map<String, Object> toQualityItem(QualityUnqualified item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("checkTime", formatDate(item.getCheckTime()));
        map.put("inspectState", item.getInspectState());
        map.put("productId", item.getProductId());
        map.put("productName", safe(item.getProductName()));
        map.put("model", safe(item.getModel()));
        map.put("quantity", item.getQuantity());
        map.put("defectivePhenomena", safe(item.getDefectivePhenomena()));
        map.put("dealResult", safe(item.getDealResult()));
        map.put("dealName", safe(item.getDealName()));
        map.put("dealTime", formatDate(item.getDealTime()));
        return map;
    }
    private Map<String, Object> toMaterialItem(StockInventory item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("productModelId", item.getProductModelId());
        map.put("batchNo", safe(item.getBatchNo()));
        map.put("qualitity", item.getQualitity());
        map.put("lockedQuantity", item.getLockedQuantity());
        map.put("warnNum", item.getWarnNum());
        map.put("lowStock", isLowStock(item));
        map.put("remark", safe(item.getRemark()));
        return map;
    }
    private Map<String, Object> toExceptionItem(ProcurementExceptionRecord item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("purchaseLedgerId", item.getPurchaseLedgerId());
        map.put("exceptionReason", safe(item.getExceptionReason()));
        map.put("exceptionNum", item.getExceptionNum());
        map.put("createTime", formatDateTime(item.getCreateTime()));
        return map;
    }
    private boolean isLowStock(StockInventory item) {
        BigDecimal quantity = item.getQualitity();
        BigDecimal warnNum = item.getWarnNum();
        if (quantity == null || warnNum == null) {
            return false;
        }
        return quantity.compareTo(warnNum) <= 0;
    }
    private Map<String, Object> warningItem(String level, String title, long count, String detail) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("level", level);
        map.put("title", title);
        map.put("count", count);
        map.put("detail", detail);
        return map;
    }
    private Map<String, Object> actionCard(String code,
                                           String name,
                                           String method,
                                           String targetApi,
                                           List<String> requiredFields,
                                           Map<String, Object> examplePayload,
                                           String description) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("code", code);
        map.put("name", name);
        map.put("method", method);
        map.put("targetApi", targetApi);
        map.put("requiredFields", requiredFields);
        map.put("examplePayload", examplePayload);
        map.put("description", description);
        return map;
    }
    private Map<String, Object> metric(String label, String value) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("label", label);
        map.put("value", value);
        return map;
    }
    private Map<String, Object> rangeSummary(DateRange range, int count, String keyword) {
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", range.start().toString());
        summary.put("endDate", range.end().toString());
        summary.put("count", count);
        summary.put("keyword", safe(keyword));
        return summary;
    }
    private Map<String, Object> buildDomainBarOption(Map<String, Object> summary) {
        List<String> xData = List.of("计划", "工单", "设备", "质量", "物料", "异常");
        List<Number> yData = List.of(
                numberValue(summary.get("planTotal")),
                numberValue(summary.get("workOrderTotal")),
                numberValue(summary.get("deviceTotal")),
                numberValue(summary.get("qualityNgCount")),
                numberValue(summary.get("lowStockCount")),
                numberValue(summary.get("exceptionCount"))
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "制造域关键数量", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "数量", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildQualityPieOption(long inspectTotal, long ngCount) {
        long passCount = Math.max(inspectTotal - ngCount, 0);
        List<Map<String, Object>> data = List.of(
                Map.of("name", "不合格", "value", ngCount),
                Map.of("name", "非不合格", "value", passCount)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "质量结果分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("name", "质量", "type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private int numberValue(Object value) {
        if (value instanceof Number number) {
            return number.intValue();
        }
        return 0;
    }
    private String toRate(long numerator, long denominator) {
        if (denominator <= 0) {
            return "0.00%";
        }
        BigDecimal rate = new BigDecimal(numerator)
                .multiply(new BigDecimal("100"))
                .divide(new BigDecimal(denominator), 2, RoundingMode.HALF_UP);
        return rate.toPlainString() + "%";
    }
    private String normalizeDomain(String domain) {
        if (!StringUtils.hasText(domain)) {
            return "";
        }
        String value = domain.trim().toLowerCase();
        return switch (value) {
            case "生产现场", "site", "factory", "workshop" -> "site";
            case "计划", "plan", "schedule" -> "plan";
            case "工单", "workorder", "work_order", "task" -> "workorder";
            case "设备", "device", "equipment" -> "device";
            case "ç»´ä¿®", "repair", "maintenance" -> "repair";
            case "质量", "quality", "qc" -> "quality";
            case "物料", "material", "inventory", "stock" -> "material";
            case "异常", "exception", "abnormal" -> "exception";
            default -> value;
        };
    }
    private boolean isRepairIntent(String keyword, String userQuery) {
        String query = safe(userQuery);
        return containsAny(safe(keyword), "ç»´ä¿®", "报修", "检修", "维护")
                || containsAny(query, "ç»´ä¿®", "报修", "检修", "维护");
    }
    private String normalizeDeviceQueryKeyword(String keyword, String userQuery) {
        String source = StringUtils.hasText(keyword) ? keyword : userQuery;
        if (!StringUtils.hasText(source)) {
            return null;
        }
        String cleaned = source
                .replace("查询", "")
                .replace("查看", "")
                .replace("帮我", "")
                .replace("请", "")
                .replace("查", "")
                .replace("设备", "")
                .replace("维修记录", "")
                .replace("维修情况", "")
                .replace("报修记录", "")
                .replace("报修情况", "")
                .replace("ç»´ä¿®", "")
                .replace("报修", "")
                .replace("情况", "")
                .replace("记录", "")
                .replace("信息", "")
                .replace("的", "")
                .replace("一下", "")
                .trim();
        return cleaned.length() >= 2 ? cleaned : null;
    }
    private List<Long> findDeviceLedgerIdsByKeyword(LoginUser loginUser, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return List.of();
        }
        LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
        Long currentDeptId = loginUser.getCurrentDeptId();
        if (currentDeptId != null) {
            wrapper.and(w -> w.eq(DeviceLedger::getDeptId, currentDeptId).or().isNull(DeviceLedger::getDeptId));
        }
        wrapper.and(w -> w.like(DeviceLedger::getDeviceName, keyword)
                .or().like(DeviceLedger::getDeviceModel, keyword)
                .or().like(DeviceLedger::getDeviceBrand, keyword));
        wrapper.orderByDesc(DeviceLedger::getId).last("limit 200");
        return defaultList(deviceLedgerMapper.selectList(wrapper)).stream()
                .map(DeviceLedger::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
    }
    private boolean hasTimeConstraint(String startDate, String endDate, String userQuery) {
        if (StringUtils.hasText(startDate) || StringUtils.hasText(endDate)) {
            return true;
        }
        if (!StringUtils.hasText(userQuery)) {
            return false;
        }
        String text = userQuery.trim();
        return containsAny(text, "今天", "昨天", "本周", "上周", "本月", "上月", "今年", "去年", "近", "最近");
    }
    private DateRange resolveDateRange(String startDate, String endDate, String timeRange) {
        LocalDate today = LocalDate.now();
        LocalDate start = parseLocalDate(startDate);
        LocalDate end = parseLocalDate(endDate);
        if (start != null || end != null) {
            LocalDate s = start != null ? start : end;
            LocalDate e = end != null ? end : start;
            if (s.isAfter(e)) {
                LocalDate temp = s;
                s = e;
                e = temp;
            }
            return new DateRange(s, e, s + "至" + e);
        }
        if (!StringUtils.hasText(timeRange)) {
            return new DateRange(today.minusDays(29), today, "近30天");
        }
        String text = timeRange.trim();
        if (text.contains("今天")) {
            return new DateRange(today, today, "今天");
        }
        if (text.contains("本周")) {
            LocalDate startOfWeek = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            return new DateRange(startOfWeek, today, "本周");
        }
        if (text.contains("本月")) {
            return new DateRange(today.withDayOfMonth(1), today, "本月");
        }
        if (text.contains("本年") || text.contains("今年")) {
            return new DateRange(today.withDayOfYear(1), today, "今年");
        }
        if (text.contains("去年")) {
            LocalDate firstDay = today.minusYears(1).withDayOfYear(1);
            LocalDate lastDay = today.minusYears(1).withMonth(12).withDayOfMonth(31);
            return new DateRange(firstDay, lastDay, "去年");
        }
        if (text.contains("上月")) {
            LocalDate startOfLastMonth = today.minusMonths(1).withDayOfMonth(1);
            return new DateRange(startOfLastMonth, startOfLastMonth.withDayOfMonth(startOfLastMonth.lengthOfMonth()), "上月");
        }
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("(近|最近)(\\d+)(天|周|个月|月|å¹´)").matcher(text);
        if (matcher.find()) {
            int amount = Integer.parseInt(matcher.group(2));
            String unit = matcher.group(3);
            LocalDate relativeStart = switch (unit) {
                case "天" -> today.minusDays(Math.max(amount - 1L, 0));
                case "周" -> today.minusWeeks(Math.max(amount, 1)).plusDays(1);
                case "个月", "月" -> today.minusMonths(Math.max(amount, 1)).plusDays(1);
                case "å¹´" -> today.minusYears(Math.max(amount, 1)).plusDays(1);
                default -> today.minusDays(29);
            };
            return new DateRange(relativeStart, today, "近" + amount + unit);
        }
        return new DateRange(today.minusDays(29), today, "近30天");
    }
    private LocalDate parseLocalDate(String text) {
        if (!StringUtils.hasText(text)) {
            return null;
        }
        return LocalDate.parse(text.trim(), DATE_FMT);
    }
    private Date toDate(LocalDate date) {
        return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private Date toExclusiveEndDate(LocalDate date) {
        return Date.from(date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private String formatDate(LocalDate date) {
        return date == null ? "" : DATE_FMT.format(date);
    }
    private String formatDate(Date date) {
        if (date == null) {
            return "";
        }
        return DATE_FMT.format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
    }
    private String formatDateTime(LocalDateTime time) {
        if (time == null) {
            return "";
        }
        return time.truncatedTo(ChronoUnit.SECONDS).toString().replace('T', ' ');
    }
    private int normalizeLimit(Integer limit) {
        if (limit == null || limit <= 0) {
            return DEFAULT_LIMIT;
        }
        return Math.min(limit, MAX_LIMIT);
    }
    private boolean containsAny(String text, String... values) {
        for (String value : values) {
            if (text.contains(value)) {
                return true;
            }
        }
        return false;
    }
    private <T> void applyTenantFilter(LambdaQueryWrapper<T> wrapper, Long tenantId, SFunction<T, Long> field) {
        if (tenantId != null) {
            wrapper.eq(field, tenantId);
        }
    }
    private <T> void applyDeptFilter(LambdaQueryWrapper<T> wrapper, Long deptId, SFunction<T, Long> field) {
        if (deptId != null) {
            wrapper.eq(field, deptId);
        }
    }
    private LoginUser currentLoginUser(String memoryId) {
        LoginUser loginUser = aiSessionUserContext.get(memoryId);
        if (loginUser != null) {
            return loginUser;
        }
        return SecurityUtils.getLoginUser();
    }
    private String safe(Object value) {
        return value == null ? "" : String.valueOf(value).replace('\n', ' ').replace('\r', ' ');
    }
    private <T> List<T> defaultList(List<T> list) {
        return list == null ? List.of() : list;
    }
    private String jsonResponse(boolean success,
                                String type,
                                String description,
                                Map<String, Object> summary,
                                Map<String, Object> data,
                                Map<String, Object> charts) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("success", success);
        result.put("type", type);
        result.put("description", description);
        result.put("summary", summary == null ? Map.of() : summary);
        result.put("data", data == null ? Map.of() : data);
        result.put("charts", charts == null ? Map.of() : charts);
        return JSON.toJSONString(result);
    }
    private record DateRange(LocalDate start, LocalDate end, String label) {
    }
}
src/main/java/com/ruoyi/approve/bean/vo/ApproveProcessVO.java
@@ -75,5 +75,5 @@
     */
    private BigDecimal maintenancePrice;
    private List<StorageBlobDTO> storageBlobDTOList;
    private List<StorageBlobDTO> storageBlobDTOS;
}
src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java
@@ -220,6 +220,8 @@
                    //更改出库审核状态(待确认改成待审核)
                    stockUtils.shipmentStatus(StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode(), shippingInfo.getId());
                } else if (status.equals(3)) {
                    //删除原本(待确认)的出库审核状态
                    stockUtils.deleteStockOutRecord(shippingInfo.getId(), StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode());
                    shippingInfo.setStatus("审核拒绝");
                } else if (status.equals(1)) {
                    shippingInfo.setStatus("审核中");
src/main/java/com/ruoyi/approve/service/impl/ApproveProcessServiceImpl.java
@@ -138,7 +138,7 @@
                .collect(Collectors.joining(","));
        approveNodeService.initApproveNodes(nodeIdStr, no, approveProcessVO.getApproveDeptId());
        // é™„件绑定
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.APPROVE_PROCESS, approveProcess.getId(), approveProcessVO.getStorageBlobDTOList());
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.APPROVE_PROCESS, approveProcess.getId(), approveProcessVO.getStorageBlobDTOS());
        /*消息通知*/
        Long id = nodeIds.getFirst();
        if (approveProcess.getApproveType() == 8) {
src/main/java/com/ruoyi/basic/dto/ProductModelDto.java
@@ -3,9 +3,11 @@
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.production.bean.dto.ProductStructureDto;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@EqualsAndHashCode(callSuper = true)
@Data
public class ProductModelDto extends ProductModel {
    private List<ProductStructureDto> productStructureList;
src/main/java/com/ruoyi/basic/enums/RecordTypeEnum.java
@@ -204,6 +204,7 @@
    SALES_REFUND_AMOUNT_ORDER("sales_refund_amount_order"),
    SALES_RECEIPT_RETURN("sales_receipt_return"),
    ACCOUNT_EXPENSE("account_expense"),
    FIN_VOUCHER("fin_voucher"),
    ACCOUNT_FILE("account_file");
    private final String type;
src/main/java/com/ruoyi/basic/service/impl/ProductModelServiceImpl.java
@@ -18,8 +18,11 @@
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.sales.dto.LossProductModelDto;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.technology.mapper.TechnologyBomMapper;
import com.ruoyi.technology.pojo.TechnologyBom;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -43,10 +46,16 @@
    private final ProductMapper productMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final TechnologyBomMapper technologyBomMapper;
    private ProductModelMapper productModelMapper;
    @Override
    public int addOrEditProductModel(ProductModelDto productModelDto) {
        String model = StringUtils.trim(productModelDto.getModel());
        String productCode = StringUtils.trim(productModelDto.getProductCode());
        productModelDto.setModel(model);
        productModelDto.setProductCode(productCode);
        checkModelAndProductCodeUnique(model, productCode, productModelDto.getId());
        if (productModelDto.getId() == null) {
            ProductModel productModel = new ProductModel();
@@ -54,6 +63,21 @@
            return productModelMapper.insert(productModel);
        } else {
            return productModelMapper.updateById(productModelDto);
        }
    }
    private void checkModelAndProductCodeUnique(String model, String productCode, Long currentId) {
        if (StringUtils.isEmpty(model) || StringUtils.isEmpty(productCode)) {
            return;
        }
        LambdaQueryWrapper<ProductModel> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ProductModel::getModel, model)
                .eq(ProductModel::getProductCode, productCode)
                .ne(currentId != null, ProductModel::getId, currentId)
                .last("limit 1");
        ProductModel duplicateProductModel = productModelMapper.selectOne(queryWrapper);
        if (duplicateProductModel != null) {
            throw new ServiceException("对应的型号" + model + "的产品编码" + productCode + "已经存在");
        }
    }
@@ -66,6 +90,14 @@
            throw new RuntimeException("已经存在该产品的销售台账和采购台账");
        }
        // æ˜¯å¦å­˜åœ¨BOM
        List<TechnologyBom> technologyBoms = technologyBomMapper.selectList(new QueryWrapper<TechnologyBom>()
                .lambda().in(TechnologyBom::getProductModelId, ids));
        if (CollectionUtils.isNotEmpty(technologyBoms)) {
            throw new RuntimeException("已经存在该产品的BOM数据");
        }
        return productModelMapper.deleteBatchIds(Arrays.asList(ids));
    }
src/main/java/com/ruoyi/basic/service/impl/ProductServiceImpl.java
@@ -13,12 +13,10 @@
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.basic.service.IProductService;
import com.ruoyi.basic.vo.ProductModelVo;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.AjaxResult;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.Arrays;
@@ -94,17 +92,18 @@
        if (ObjectUtils.isEmpty(productDto.getParentId())) {
            throw new IllegalArgumentException("请选择父节点");
        }
        String productName = StringUtils.trim(productDto.getProductName());
        if (StringUtils.isEmpty(productName)) {
            throw new IllegalArgumentException("产品名称不能为空");
        }
        productDto.setProductName(productName);
        checkProductNameUnique(productDto.getParentId(), productName, productDto.getId());
        if (productDto.getId() == null) {
            // æ–°å¢žäº§å“é€»è¾‘
            if (productDto.getParentId() == null) {
                // è‹¥æœªæŒ‡å®šçˆ¶èŠ‚ç‚¹ï¼Œé»˜è®¤ä¸ºæ ¹èŠ‚ç‚¹ï¼ˆparentId è®¾ä¸º null)
                productDto.setParentId(null);
            } else {
                // æ£€æŸ¥çˆ¶èŠ‚ç‚¹æ˜¯å¦å­˜åœ¨ï¼ˆå¯é€‰ï¼Œæ ¹æ®ä¸šåŠ¡éœ€æ±‚ï¼‰
                Product parent = productMapper.selectById(productDto.getParentId());
                if (parent == null) {
                    throw new IllegalArgumentException("父节点不存在,无法添加子产品");
                }
            }
            return productMapper.insert(productDto);
        } else {
@@ -118,6 +117,18 @@
        }
    }
    private void checkProductNameUnique(Long parentId, String productName, Long currentId) {
        LambdaQueryWrapper<Product> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Product::getParentId, parentId)
                .eq(Product::getProductName, productName)
                .ne(currentId != null, Product::getId, currentId)
                .last("limit 1");
        Product duplicateProduct = productMapper.selectOne(queryWrapper);
        if (duplicateProduct != null) {
            throw new IllegalArgumentException("对应的" + productName + "已经存在");
        }
    }
    @Override
    public int delProductByIds(Long[] ids) {
        // 1. åˆ é™¤å­è¡¨ product_model ä¸­å…³è”的数据
src/main/java/com/ruoyi/collaborativeApproval/controller/SealApplicationManagementController.java
@@ -2,6 +2,10 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.approve.pojo.KnowledgeBase;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.collaborativeApproval.dto.SealApplicationManagementDTO;
import com.ruoyi.collaborativeApproval.pojo.SealApplicationManagement;
import com.ruoyi.collaborativeApproval.service.SealApplicationManagementService;
import com.ruoyi.common.utils.SecurityUtils;
@@ -27,6 +31,7 @@
public class SealApplicationManagementController {
    private SealApplicationManagementService sealApplicationManagementService;
    private ISysNoticeService sysNoticeService;
    private FileUtil fileUtil;
    @GetMapping("/getList")
    @Operation(summary = "分页查询")
@@ -36,8 +41,13 @@
    @PostMapping("/add")
    @Operation(summary = "新增")
    public AjaxResult add(@RequestBody SealApplicationManagement sealApplicationManagement){
    public AjaxResult add(@RequestBody SealApplicationManagementDTO sealApplicationManagement){
        sealApplicationManagementService.save(sealApplicationManagement);
        // 5. ä¿å­˜é”€å”®å°è´¦é™„ä»¶
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE,
                RecordTypeEnum.SEAL_APPLICATION_MANAGEMENT,
                sealApplicationManagement.getId(),
                sealApplicationManagement.getStorageBlobDTOs());
        //消息通知
        sysNoticeService.simpleNoticeByUser("用印审批",
                "申请编号:"+sealApplicationManagement.getApplicationNum()+"\t"
@@ -49,7 +59,12 @@
    @PostMapping("/update")
    @Operation(summary = "修改")
    public AjaxResult update(@RequestBody SealApplicationManagement sealApplicationManagement){
    public AjaxResult update(@RequestBody SealApplicationManagementDTO sealApplicationManagement){
        // 5. ä¿å­˜é”€å”®å°è´¦é™„ä»¶
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE,
                RecordTypeEnum.SEAL_APPLICATION_MANAGEMENT,
                sealApplicationManagement.getId(),
                sealApplicationManagement.getStorageBlobDTOs());
        return AjaxResult.success(sealApplicationManagementService.updateById(sealApplicationManagement));
    }
@@ -59,6 +74,9 @@
        if (CollectionUtils.isEmpty(ids)) {
            throw new RuntimeException("请传入要删除的ID");
        }
        fileUtil.deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordIds(ApplicationTypeEnum.FILE,
                RecordTypeEnum.SEAL_APPLICATION_MANAGEMENT,
                ids);
        return AjaxResult.success(sealApplicationManagementService.removeBatchByIds(ids));
    }
src/main/java/com/ruoyi/collaborativeApproval/dto/SealApplicationManagementDTO.java
@@ -1,7 +1,11 @@
package com.ruoyi.collaborativeApproval.dto;
import com.ruoyi.basic.dto.StorageBlobDTO;
import com.ruoyi.basic.dto.StorageBlobVO;
import com.ruoyi.collaborativeApproval.pojo.SealApplicationManagement;
import lombok.Data;
import java.util.List;
@Data
public class SealApplicationManagementDTO extends SealApplicationManagement {
@@ -11,4 +15,8 @@
    //审批人
    private String approveUserName;
    private List<StorageBlobDTO> storageBlobDTOs;
    private List<StorageBlobVO> storageBlobVOList;
}
src/main/java/com/ruoyi/collaborativeApproval/service/impl/SealApplicationManagementServiceImpl.java
@@ -3,6 +3,8 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.collaborativeApproval.dto.SealApplicationManagementDTO;
import com.ruoyi.collaborativeApproval.mapper.SealApplicationManagementMapper;
import com.ruoyi.collaborativeApproval.pojo.SealApplicationManagement;
@@ -14,9 +16,14 @@
@RequiredArgsConstructor
public class SealApplicationManagementServiceImpl extends ServiceImpl<SealApplicationManagementMapper, SealApplicationManagement> implements SealApplicationManagementService {
    private final SealApplicationManagementMapper sealApplicationManagementMapper;
    private final FileUtil fileUtil;
    @Override
    public IPage<SealApplicationManagementDTO> listPage(Page page, SealApplicationManagement sealApplicationManagement) {
        return sealApplicationManagementMapper.listPage(page, sealApplicationManagement);
        IPage<SealApplicationManagementDTO> sealApplicationManagementDTOIPage = sealApplicationManagementMapper.listPage(page, sealApplicationManagement);
        sealApplicationManagementDTOIPage.getRecords().forEach(item -> {
            item.setStorageBlobVOList(fileUtil.getStorageBlobVOsByRecordTypeAndRecordId(RecordTypeEnum.SEAL_APPLICATION_MANAGEMENT, item.getId()));
        });
        return sealApplicationManagementDTOIPage;
    }
}
src/main/java/com/ruoyi/device/controller/DeviceRepairController.java
@@ -46,10 +46,16 @@
        return deviceRepairService.updateDeviceRepair(deviceRepairDto);
    }
    @PostMapping ("repair")
    @PostMapping ("/repair")
    @Operation(summary = "设备维修")
    public AjaxResult repair( @RequestBody DeviceRepairDto deviceRepairDto) {
        return deviceRepairService.updateDeviceRepair(deviceRepairDto);
        return deviceRepairService.confirmRepair(deviceRepairDto);
    }
    @PostMapping ("/acceptance")
    @Operation(summary = "设备报修验收审批")
    public AjaxResult acceptance(@RequestBody DeviceRepairDto deviceRepairDto) {
        return deviceRepairService.approveRepairAcceptance(deviceRepairDto);
    }
    @DeleteMapping("/{ids}")
src/main/java/com/ruoyi/device/execl/DeviceRepairExeclDto.java
@@ -47,6 +47,18 @@
    @Excel(name = "维修结果")
    private String maintenanceResult;
    @Schema(description = "验收人")
    @Excel(name = "验收人")
    private String acceptanceName;
    @Schema(description = "验收时间")
    @Excel(name = "验收时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime acceptanceTime;
    @Schema(description = "验收备注")
    @Excel(name = "验收备注")
    private String acceptanceRemark;
    @Schema(description = "状态")
    @Excel(name = "状态")
    private String statusStr;
src/main/java/com/ruoyi/device/pojo/DeviceRepair.java
@@ -50,7 +50,19 @@
    @Schema(description = "维修结果")
    private String maintenanceResult;
    @Schema(description = "验收人")
    private String acceptanceName;
    @Schema(description = "验收时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime acceptanceTime;
    @Schema(description = "验收备注")
    private String acceptanceRemark;
    @Schema(description = "状态 0 å¾…ç»´ä¿® 1完结 2 å¤±è´¥")
    // 0:待维修 1:完结 2:失败 3:待验收
    private Integer status;
    @Schema(description = "创建时间")
src/main/java/com/ruoyi/device/pojo/MaintenanceTask.java
@@ -44,6 +44,10 @@
    @Schema(description = "设备id")
    private Long taskId;
    @Schema(description = "保养人")
    @Excel(name = "保养人")
    private String maintenancePerson;
    @Schema(description = "频次")
    @Excel(name = "频次")
    private String frequencyType;
src/main/java/com/ruoyi/device/service/IDeviceRepairService.java
@@ -19,6 +19,10 @@
    AjaxResult updateDeviceRepair(DeviceRepairDto deviceRepairDto);
    AjaxResult confirmRepair(DeviceRepairDto deviceRepairDto);
    AjaxResult approveRepairAcceptance(DeviceRepairDto deviceRepairDto);
    void export(HttpServletResponse response, Long[] ids);
    DeviceRepairVo detailById(Long id);
src/main/java/com/ruoyi/device/service/impl/DeviceRepairServiceImpl.java
@@ -47,6 +47,11 @@
    private final SparePartsRequisitionRecordService sparePartsRequisitionRecordService;
    private final FileUtil fileUtil;
    private static final int STATUS_PENDING_REPAIR = 0;
    private static final int STATUS_COMPLETED = 1;
    private static final int STATUS_FAILED = 2;
    private static final int STATUS_PENDING_ACCEPTANCE = 3;
    @Override
    public IPage<DeviceRepairVo> queryPage(Page page, DeviceRepairDto deviceRepairDto) {
        IPage<DeviceRepairVo> pageDto = deviceRepairMapper.queryPage(page, deviceRepairDto);
@@ -62,6 +67,9 @@
        DeviceLedger byId = deviceLedgerService.getById(deviceRepairDto.getDeviceLedgerId());
        deviceRepairDto.setDeviceName(byId.getDeviceName());
        deviceRepairDto.setDeviceModel(byId.getDeviceModel());
        if (deviceRepairDto.getStatus() == null) {
            deviceRepairDto.setStatus(STATUS_PENDING_REPAIR);
        }
        boolean save = this.save(deviceRepairDto);
        if (save) {
            // å¤„理图片上传
@@ -75,6 +83,15 @@
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult updateDeviceRepair(DeviceRepairDto deviceRepairDto) {
        DeviceRepair oldDeviceRepair = this.getById(deviceRepairDto.getId());
        if (oldDeviceRepair == null) {
            return AjaxResult.error("报修记录不存在");
        }
        if (deviceRepairDto.getStatus() != null
                && deviceRepairDto.getStatus() == STATUS_COMPLETED
                && (oldDeviceRepair.getStatus() == null
                || oldDeviceRepair.getStatus() != STATUS_COMPLETED)) {
            return AjaxResult.error("请先提交验收审批,验收通过后才可完结");
        }
        // å¤„理备件使用情况
        if (CollectionUtils.isNotEmpty(deviceRepairDto.getSparePartsUseList())) {
            List<Long> sparePartIds = new ArrayList<>();
@@ -131,6 +148,58 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult confirmRepair(DeviceRepairDto deviceRepairDto) {
        DeviceRepair oldDeviceRepair = this.getById(deviceRepairDto.getId());
        if (oldDeviceRepair == null) {
            return AjaxResult.error("报修记录不存在");
        }
        if (oldDeviceRepair.getStatus() != null && oldDeviceRepair.getStatus() == STATUS_COMPLETED) {
            return AjaxResult.error("该报修已完结,不能重复确认维修");
        }
        if (oldDeviceRepair.getStatus() != null && oldDeviceRepair.getStatus() == STATUS_PENDING_ACCEPTANCE) {
            return AjaxResult.error("该报修已提交验收审批");
        }
        deviceRepairDto.setStatus(STATUS_PENDING_ACCEPTANCE);
        return updateDeviceRepair(deviceRepairDto);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult approveRepairAcceptance(DeviceRepairDto deviceRepairDto) {
        if (deviceRepairDto.getId() == null) {
            return AjaxResult.error("报修记录id不能为空");
        }
        DeviceRepair oldDeviceRepair = this.getById(deviceRepairDto.getId());
        if (oldDeviceRepair == null) {
            return AjaxResult.error("报修记录不存在");
        }
        if (oldDeviceRepair.getStatus() == null || oldDeviceRepair.getStatus() != STATUS_PENDING_ACCEPTANCE) {
            return AjaxResult.error("该报修未进入待验收状态,不能审批");
        }
        if (StringUtils.isBlank(deviceRepairDto.getAcceptanceName())) {
            return AjaxResult.error("验收人不能为空");
        }
        if (deviceRepairDto.getAcceptanceTime() == null) {
            return AjaxResult.error("验收时间不能为空");
        }
        if (StringUtils.isBlank(deviceRepairDto.getAcceptanceRemark())) {
            return AjaxResult.error("验收备注不能为空");
        }
        DeviceRepair update = new DeviceRepair();
        update.setId(deviceRepairDto.getId());
        update.setAcceptanceName(deviceRepairDto.getAcceptanceName());
        update.setAcceptanceTime(deviceRepairDto.getAcceptanceTime());
        update.setAcceptanceRemark(deviceRepairDto.getAcceptanceRemark());
        update.setStatus(STATUS_COMPLETED);
        if (this.updateById(update)) {
            return AjaxResult.success();
        }
        return AjaxResult.error("验收审批失败");
    }
    @Override
    public void export(HttpServletResponse response, Long[] ids) {
        if (ids == null || ids.length == 0) {
            List<DeviceRepair> supplierManageList = this.list();
@@ -138,24 +207,19 @@
            supplierManageList.stream().forEach(deviceRepair -> {
                DeviceRepairExeclDto deviceRepairExeclDto = new DeviceRepairExeclDto();
                BeanUtils.copyProperties(deviceRepair,deviceRepairExeclDto);
                deviceRepairExeclDto.setStatusStr(deviceRepair.getStatus() == 0 ? "待维修" : deviceRepair.getStatus() == 1 ? "完结" : "失败");
                deviceRepairExeclDto.setStatusStr(resolveStatusText(deviceRepair.getStatus()));
                deviceLedgerExeclDtos.add(deviceRepairExeclDto);
            });
            ExcelUtil<DeviceRepairExeclDto> util = new ExcelUtil<DeviceRepairExeclDto>(DeviceRepairExeclDto.class);
            util.exportExcel(response, deviceLedgerExeclDtos, "设备报修导出");
        }else {
            ArrayList<Long> arrayList = new ArrayList<>();
            Arrays.stream(ids).map(id -> {
                return arrayList.add( id);
            });
            ArrayList<Long> arrayList = new ArrayList<>(Arrays.asList(ids));
            List<DeviceRepair> supplierManageList = deviceRepairMapper.selectBatchIds(arrayList);
            ArrayList<DeviceRepairExeclDto> deviceLedgerExeclDtos = new ArrayList<>();
            supplierManageList.stream().forEach(deviceRepair -> {
                DeviceRepairExeclDto deviceRepairExeclDto = new DeviceRepairExeclDto();
                BeanUtils.copyProperties(deviceRepair,deviceRepairExeclDto);
                deviceRepairExeclDto.setStatusStr(deviceRepair.getStatus() == 0 ? "待维修" : deviceRepair.getStatus() == 1 ? "完结" : "失败");
                deviceRepairExeclDto.setStatusStr(resolveStatusText(deviceRepair.getStatus()));
                deviceLedgerExeclDtos.add(deviceRepairExeclDto);
            });
            ExcelUtil<DeviceRepairExeclDto> util = new ExcelUtil<DeviceRepairExeclDto>(DeviceRepairExeclDto.class);
@@ -164,6 +228,25 @@
    }
    private String resolveStatusText(Integer status) {
        if (status == null) {
            return "";
        }
        if (status == STATUS_PENDING_REPAIR) {
            return "待维修";
        }
        if (status == STATUS_COMPLETED) {
            return "完结";
        }
        if (status == STATUS_FAILED) {
            return "失败";
        }
        if (status == STATUS_PENDING_ACCEPTANCE) {
            return "待验收";
        }
        return "未知";
    }
    @Override
    public DeviceRepairVo detailById(Long id) {
        DeviceRepairVo vo = deviceRepairMapper.detailById(id);
src/main/java/com/ruoyi/device/service/impl/MaintenanceTaskJob.java
@@ -93,6 +93,7 @@
        inspectionTask.setMaintenanceTaskId(timingTask.getId());
        inspectionTask.setDeviceLedgerId(timingTask.getTaskId());
        inspectionTask.setMaintenancePlanTime(LocalDateTime.now());
        inspectionTask.setMaintenanceActuallyName(timingTask.getMaintenancePerson());
        inspectionTask.setFrequencyType(timingTask.getFrequencyType());
        inspectionTask.setFrequencyDetail(timingTask.getFrequencyDetail());
        inspectionTask.setTenantId(timingTask.getTenantId());
src/main/java/com/ruoyi/framework/security/LoginUser.java
@@ -301,7 +301,10 @@
    public void setUser(SysUser user)
    {
        this.user = user;
        this.aiEnabled = user == null ? null : user.getAiEnabled();
        if (user != null && user.getAiEnabled() != null)
        {
            this.aiEnabled = user.getAiEnabled();
        }
    }
    @Override
src/main/java/com/ruoyi/inspectiontask/pojo/InspectionTask.java
@@ -30,6 +30,10 @@
    @Excel(name = "巡检任务名称")
    private String taskName;
    @Schema(description = "巡检项目")
    @Excel(name = "巡检项目")
    private String inspectionProject;
    @Schema(description = "设备id")
    private Integer taskId;
@@ -44,6 +48,22 @@
    @Excel(name = "备注")
    private String remarks;
    @Schema(description = "巡检结果 0 å¼‚常 1 æ­£å¸¸")
    private String inspectionResult;
    @Schema(description = "异常描述")
    private String abnormalDescription;
    @Schema(description = "关联维修单ID")
    private Long deviceRepairId;
    @Schema(description = "验收人ID")
    private Long acceptanceUserId;
    @Schema(description = "验收人")
    @Excel(name = "验收人")
    private String acceptanceName;
    @Schema(description = "任务登记人ID")
    private Long registrantId;
src/main/java/com/ruoyi/inspectiontask/pojo/TimingTask.java
@@ -33,6 +33,10 @@
    @Excel(name = "巡检任务名称")
    private String taskName;
    @Schema(description = "巡检项目")
    @Excel(name = "巡检项目")
    private String inspectionProject;
    @Schema(description = "设备id")
    private Integer taskId;
@@ -60,6 +64,10 @@
    @Schema(description = "是否激活")
    private boolean isActive;
    @Schema(description = "是否启用 0否 1是")
    @Excel(name = "是否启用", readConverterExp = "0=否,1=是")
    private Integer isEnabled;
    @Schema(description = "备注")
    @Excel(name = "备注")
    private String remarks;
src/main/java/com/ruoyi/inspectiontask/service/impl/InspectionTaskServiceImpl.java
@@ -5,12 +5,17 @@
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.basic.dto.StorageBlobDTO;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.device.mapper.DeviceLedgerMapper;
import com.ruoyi.device.mapper.DeviceRepairMapper;
import com.ruoyi.device.pojo.DeviceLedger;
import com.ruoyi.device.pojo.DeviceRepair;
import com.ruoyi.inspectiontask.dto.InspectionTaskDto;
import com.ruoyi.inspectiontask.mapper.InspectionTaskMapper;
import com.ruoyi.inspectiontask.pojo.InspectionTask;
@@ -23,6 +28,7 @@
import org.springframework.transaction.annotation.Transactional;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -43,6 +49,14 @@
    private final FileUtil fileUtil;
    private final DeviceRepairMapper deviceRepairMapper;
    private final DeviceLedgerMapper deviceLedgerMapper;
    private static final String INSPECTION_RESULT_ABNORMAL = "0";
    private static final String INSPECTION_RESULT_NORMAL = "1";
    private static final int REPAIR_STATUS_PENDING = 0;
    @Override
    public IPage<InspectionTaskDto> selectInspectionTaskList(Page<InspectionTask> page, InspectionTaskDto inspectionTaskDto) {
        LambdaQueryWrapper<InspectionTask> queryWrapper = new LambdaQueryWrapper<>();
@@ -50,14 +64,15 @@
        if (StringUtils.isNotBlank(inspectionTaskDto.getTaskName())) {
            queryWrapper.like(InspectionTask::getTaskName, inspectionTaskDto.getTaskName());
        }
        if (StringUtils.isNotBlank(inspectionTaskDto.getInspectionProject())) {
            queryWrapper.like(InspectionTask::getInspectionProject, inspectionTaskDto.getInspectionProject());
        }
        IPage<InspectionTask> entityPage = inspectionTaskMapper.selectPage(page, queryWrapper);
        //  æ— æ•°æ®æå‰è¿”回
        if (CollectionUtils.isEmpty(entityPage.getRecords())) {
            return new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
        }
        // èŽ·å–id集合
        List<Long> ids = entityPage.getRecords().stream().map(InspectionTask::getId).collect(Collectors.toList());
        //登记人ids
        List<Long> registrantIds = entityPage.getRecords().stream().map(InspectionTask::getRegistrantId).collect(Collectors.toList());
        // æ‰¹é‡æŸ¥è¯¢ç™»è®°äºº
@@ -68,9 +83,6 @@
        } else {
            sysUserMap = new HashMap<>();
        }
        //巡检人ids
        List<String> inspectorIds = entityPage.getRecords().stream().map(InspectionTask::getInspectorId).collect(Collectors.toList());
        //获取所有不重复的用户ID
        Set<Long> allUserIds = entityPage.getRecords().stream()
                .map(InspectionTask::getInspectorId) // èŽ·å–"2,3"这样的字符串
@@ -140,24 +152,230 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int addOrEditInspectionTask(InspectionTaskDto inspectionTaskDto) {
        InspectionTask oldInspectionTask = null;
        if (Objects.nonNull(inspectionTaskDto.getId())) {
            oldInspectionTask = inspectionTaskMapper.selectById(inspectionTaskDto.getId());
            if (oldInspectionTask == null) {
                throw new IllegalArgumentException("巡检任务不存在");
            }
        }
        validateInspectionInput(inspectionTaskDto, oldInspectionTask);
        InspectionTask inspectionTask = new InspectionTask();
        BeanUtils.copyProperties(inspectionTaskDto, inspectionTask);
        inspectionTask.setRegistrantId(SecurityUtils.getLoginUser().getUserId());
        inspectionTask.setRegistrant(SecurityUtils.getLoginUser().getUsername());
        fillAcceptanceInfo(inspectionTask, oldInspectionTask);
        int i;
        if (Objects.isNull(inspectionTaskDto.getId())) {
            i = inspectionTaskMapper.insert(inspectionTask);
        } else {
            i = inspectionTaskMapper.updateById(inspectionTask);
        }
        // ä¿å­˜æ–‡ä»¶
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.INSPECTION_TASK, inspectionTask.getId(), inspectionTaskDto.getCommonFileListDTO());
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.AFTER_FILE, RecordTypeEnum.INSPECTION_TASK, inspectionTask.getId(), inspectionTaskDto.getCommonFileListAfterDTO());
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.BEFORE_FILE, RecordTypeEnum.INSPECTION_TASK, inspectionTask.getId(), inspectionTaskDto.getCommonFileListBeforeDTO());
        if (i <= 0) {
            return i;
        }
        Long linkedRepairId = syncRepairOrderIfAbnormal(inspectionTask, oldInspectionTask);
        if (linkedRepairId != null && !Objects.equals(linkedRepairId, inspectionTask.getDeviceRepairId())) {
            InspectionTask relationUpdate = new InspectionTask();
            relationUpdate.setId(inspectionTask.getId());
            relationUpdate.setDeviceRepairId(linkedRepairId);
            inspectionTaskMapper.updateById(relationUpdate);
            inspectionTask.setDeviceRepairId(linkedRepairId);
        }
        // ä¿å­˜æ–‡ä»¶ï¼ˆå­—段不传则保留历史)
        saveInspectionAttachments(inspectionTask.getId(), inspectionTaskDto);
        return i;
    }
    private void validateInspectionInput(InspectionTaskDto inspectionTaskDto, InspectionTask oldInspectionTask) {
        String inspectionResult = inspectionTaskDto.getInspectionResult();
        if (StringUtils.isBlank(inspectionResult) && oldInspectionTask != null) {
            inspectionResult = oldInspectionTask.getInspectionResult();
        }
        if (StringUtils.isBlank(inspectionResult)) {
            throw new IllegalArgumentException("请选择巡检结果");
        }
        if (!INSPECTION_RESULT_ABNORMAL.equals(inspectionResult) && !INSPECTION_RESULT_NORMAL.equals(inspectionResult)) {
            throw new IllegalArgumentException("巡检结果仅支持:0-异常,1-正常");
        }
        inspectionTaskDto.setInspectionResult(inspectionResult);
        if (!INSPECTION_RESULT_ABNORMAL.equals(inspectionResult)) {
            return;
        }
        String abnormalDescription = inspectionTaskDto.getAbnormalDescription();
        if (StringUtils.isBlank(abnormalDescription) && oldInspectionTask != null) {
            abnormalDescription = oldInspectionTask.getAbnormalDescription();
        }
        if (StringUtils.isBlank(abnormalDescription)) {
            throw new IllegalArgumentException("巡检结果为异常时,异常描述不能为空");
        }
        inspectionTaskDto.setAbnormalDescription(abnormalDescription);
        if (!hasAnyInspectionPhotoAfterSave(inspectionTaskDto, oldInspectionTask)) {
            throw new IllegalArgumentException("巡检结果为异常时,必须上传至少一张照片");
        }
    }
    private boolean hasAnyInspectionPhotoAfterSave(InspectionTaskDto inspectionTaskDto, InspectionTask oldInspectionTask) {
        Long recordId = oldInspectionTask == null ? null : oldInspectionTask.getId();
        return hasApplicationPhotoAfterSave(inspectionTaskDto.getCommonFileListDTO(), ApplicationTypeEnum.FILE, recordId)
                || hasApplicationPhotoAfterSave(inspectionTaskDto.getCommonFileListAfterDTO(), ApplicationTypeEnum.AFTER_FILE, recordId)
                || hasApplicationPhotoAfterSave(inspectionTaskDto.getCommonFileListBeforeDTO(), ApplicationTypeEnum.BEFORE_FILE, recordId);
    }
    private boolean hasApplicationPhotoAfterSave(List<StorageBlobDTO> requestPhotos, ApplicationTypeEnum applicationType, Long recordId) {
        if (requestPhotos != null) {
            return !requestPhotos.isEmpty();
        }
        if (recordId == null) {
            return false;
        }
        return CollectionUtils.isNotEmpty(fileUtil.getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(applicationType, RecordTypeEnum.INSPECTION_TASK, recordId));
    }
    private void fillAcceptanceInfo(InspectionTask inspectionTask, InspectionTask oldInspectionTask) {
        Long acceptanceUserId = inspectionTask.getAcceptanceUserId();
        if (acceptanceUserId == null && oldInspectionTask != null) {
            acceptanceUserId = oldInspectionTask.getAcceptanceUserId();
        }
        if (acceptanceUserId != null) {
            inspectionTask.setAcceptanceUserId(acceptanceUserId);
        }
        String acceptanceName = inspectionTask.getAcceptanceName();
        if (StringUtils.isBlank(acceptanceName) && acceptanceUserId != null) {
            SysUser acceptanceUser = sysUserMapper.selectUserById(acceptanceUserId);
            if (acceptanceUser != null) {
                acceptanceName = acceptanceUser.getNickName();
            }
        }
        if (StringUtils.isBlank(acceptanceName) && oldInspectionTask != null) {
            acceptanceName = oldInspectionTask.getAcceptanceName();
        }
        inspectionTask.setAcceptanceName(acceptanceName);
    }
    private Long syncRepairOrderIfAbnormal(InspectionTask inspectionTask, InspectionTask oldInspectionTask) {
        if (!INSPECTION_RESULT_ABNORMAL.equals(inspectionTask.getInspectionResult())) {
            return inspectionTask.getDeviceRepairId();
        }
        Long linkedRepairId = inspectionTask.getDeviceRepairId();
        if (linkedRepairId == null && oldInspectionTask != null) {
            linkedRepairId = oldInspectionTask.getDeviceRepairId();
        }
        if (linkedRepairId != null) {
            DeviceRepair updateRepair = new DeviceRepair();
            updateRepair.setId(linkedRepairId);
            updateRepair.setRemark(inspectionTask.getAbnormalDescription());
            deviceRepairMapper.updateById(updateRepair);
            return linkedRepairId;
        }
        DeviceRepair deviceRepair = buildDeviceRepair(inspectionTask, oldInspectionTask);
        deviceRepairMapper.insert(deviceRepair);
        return deviceRepair.getId();
    }
    private DeviceRepair buildDeviceRepair(InspectionTask inspectionTask, InspectionTask oldInspectionTask) {
        DeviceRepair deviceRepair = new DeviceRepair();
        Long deviceLedgerId = resolveDeviceLedgerId(inspectionTask, oldInspectionTask);
        DeviceLedger deviceLedger = resolveDeviceLedger(deviceLedgerId);
        deviceRepair.setDeviceLedgerId(deviceLedgerId);
        deviceRepair.setDeviceName(resolveDeviceName(inspectionTask, oldInspectionTask, deviceLedger));
        deviceRepair.setDeviceModel(deviceLedger == null ? null : deviceLedger.getDeviceModel());
        deviceRepair.setRepairName(resolveRepairReporter(inspectionTask, oldInspectionTask));
        deviceRepair.setRepairTime(new Date());
        deviceRepair.setRemark(inspectionTask.getAbnormalDescription());
        deviceRepair.setMachineryCategory(deviceLedger == null ? null : deviceLedger.getType());
        deviceRepair.setStatus(REPAIR_STATUS_PENDING);
        return deviceRepair;
    }
    private Long resolveDeviceLedgerId(InspectionTask inspectionTask, InspectionTask oldInspectionTask) {
        Integer taskId = inspectionTask.getTaskId();
        if (taskId == null && oldInspectionTask != null) {
            taskId = oldInspectionTask.getTaskId();
        }
        if (taskId == null || taskId <= 0) {
            return null;
        }
        return taskId.longValue();
    }
    private DeviceLedger resolveDeviceLedger(Long deviceLedgerId) {
        if (deviceLedgerId == null) {
            return null;
        }
        return deviceLedgerMapper.selectById(deviceLedgerId);
    }
    private String resolveDeviceName(InspectionTask inspectionTask, InspectionTask oldInspectionTask, DeviceLedger deviceLedger) {
        String taskName = inspectionTask.getTaskName();
        if (StringUtils.isBlank(taskName) && oldInspectionTask != null) {
            taskName = oldInspectionTask.getTaskName();
        }
        if (StringUtils.isNotBlank(taskName)) {
            return taskName;
        }
        return deviceLedger == null ? null : deviceLedger.getDeviceName();
    }
    private String resolveRepairReporter(InspectionTask inspectionTask, InspectionTask oldInspectionTask) {
        String reporter = inspectionTask.getInspector();
        if (StringUtils.isBlank(reporter) && oldInspectionTask != null) {
            reporter = oldInspectionTask.getInspector();
        }
        if (StringUtils.isNotBlank(reporter)) {
            return reporter;
        }
        String inspectorNameByUserId = resolveInspectorNameByUserId(inspectionTask.getInspectorId());
        if (StringUtils.isBlank(inspectorNameByUserId) && oldInspectionTask != null) {
            inspectorNameByUserId = resolveInspectorNameByUserId(oldInspectionTask.getInspectorId());
        }
        if (StringUtils.isNotBlank(inspectorNameByUserId)) {
            return inspectorNameByUserId;
        }
        try {
            return SecurityUtils.getUsername();
        } catch (Exception ignored) {
            return "system";
        }
    }
    private String resolveInspectorNameByUserId(String inspectorIds) {
        if (StringUtils.isBlank(inspectorIds)) {
            return null;
        }
        String firstInspectorId = Arrays.stream(inspectorIds.split(","))
                .map(String::trim)
                .filter(StringUtils::isNotBlank)
                .findFirst()
                .orElse(null);
        if (!StringUtils.isNumeric(firstInspectorId)) {
            return null;
        }
        SysUser sysUser = sysUserMapper.selectUserById(Long.parseLong(firstInspectorId));
        return sysUser == null ? null : sysUser.getNickName();
    }
    private void saveInspectionAttachments(Long inspectionTaskId, InspectionTaskDto inspectionTaskDto) {
        saveAttachmentIfPresent(inspectionTaskId, ApplicationTypeEnum.FILE, inspectionTaskDto.getCommonFileListDTO());
        saveAttachmentIfPresent(inspectionTaskId, ApplicationTypeEnum.AFTER_FILE, inspectionTaskDto.getCommonFileListAfterDTO());
        saveAttachmentIfPresent(inspectionTaskId, ApplicationTypeEnum.BEFORE_FILE, inspectionTaskDto.getCommonFileListBeforeDTO());
    }
    private void saveAttachmentIfPresent(Long inspectionTaskId, ApplicationTypeEnum applicationTypeEnum, List<StorageBlobDTO> storageBlobDTOS) {
        if (storageBlobDTOS == null) {
            return;
        }
        fileUtil.saveStorageAttachment(applicationTypeEnum, RecordTypeEnum.INSPECTION_TASK, inspectionTaskId, storageBlobDTOS);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int delByIds(Long[] ids) {
src/main/java/com/ruoyi/inspectiontask/service/impl/TimingTaskJob.java
@@ -3,7 +3,7 @@
import com.ruoyi.inspectiontask.mapper.InspectionTaskMapper;
import com.ruoyi.inspectiontask.pojo.InspectionTask;
import com.ruoyi.inspectiontask.pojo.TimingTask;
import lombok.RequiredArgsConstructor;
import com.ruoyi.common.utils.StringUtils;
import org.quartz.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
@@ -47,6 +47,9 @@
            TimingTask timingTask = tasks.isEmpty() ? null : tasks.get(0);
            if (timingTask == null) {
                throw new JobExecutionException("找不到定时任务: " + taskId);
            }
            if (timingTask.getIsEnabled() != null && timingTask.getIsEnabled() == 0) {
                return;
            }
//            if (!timingTask.isActive()) {
@@ -100,10 +103,15 @@
        // å¤åˆ¶åŸºæœ¬å±žæ€§
        inspectionTask.setTaskName(timingTask.getTaskName());
        inspectionTask.setInspectionProject(timingTask.getInspectionProject());
        inspectionTask.setTaskId(timingTask.getTaskId());
        inspectionTask.setInspectorId(timingTask.getInspectorIds());
        inspectionTask.setInspectionLocation(timingTask.getInspectionLocation());
        inspectionTask.setRemarks("自动生成自定时任务ID: " + timingTask.getId());
        String remarks = "自动生成自定时任务ID: " + timingTask.getId();
        if (StringUtils.isNotBlank(timingTask.getRemarks())) {
            remarks = remarks + ";" + timingTask.getRemarks();
        }
        inspectionTask.setRemarks(remarks);
        inspectionTask.setRegistrantId(timingTask.getRegistrantId());
        inspectionTask.setFrequencyType(timingTask.getFrequencyType());
        inspectionTask.setFrequencyDetail(timingTask.getFrequencyDetail());
src/main/java/com/ruoyi/inspectiontask/service/impl/TimingTaskServiceImpl.java
@@ -35,6 +35,8 @@
    private final TimingTaskMapper timingTaskMapper;
    private final TimingTaskScheduler timingTaskScheduler;
    private final SysUserMapper sysUserMapper;
    private static final int ENABLED = 1;
    private static final int DISABLED = 0;
    @Override
@@ -44,6 +46,12 @@
        LambdaQueryWrapper<TimingTask> queryWrapper = new LambdaQueryWrapper<>();
        if (StringUtils.isNotBlank(timingTask.getTaskName())) {
            queryWrapper.like(TimingTask::getTaskName, timingTask.getTaskName());
        }
        if (StringUtils.isNotBlank(timingTask.getInspectionProject())) {
            queryWrapper.like(TimingTask::getInspectionProject, timingTask.getInspectionProject());
        }
        if (timingTask.getIsEnabled() != null) {
            queryWrapper.eq(TimingTask::getIsEnabled, timingTask.getIsEnabled());
        }
        IPage<TimingTask> taskPage = timingTaskMapper.selectPage(page, queryWrapper);
@@ -115,8 +123,17 @@
    @Override
    @Transactional
    public int addOrEditTimingTask(TimingTaskDto timingTaskDto) throws SchedulerException {
        TimingTask oldTimingTask = null;
        if (Objects.nonNull(timingTaskDto.getId())) {
            oldTimingTask = timingTaskMapper.selectById(timingTaskDto.getId());
            if (oldTimingTask == null) {
                throw new IllegalArgumentException("定时任务不存在");
            }
        }
        TimingTask timingTask = new TimingTask();
        BeanUtils.copyProperties(timingTaskDto, timingTask);
        timingTask.setIsEnabled(resolveEnabledValue(timingTask.getIsEnabled(), oldTimingTask));
        timingTask.setActive(ENABLED == timingTask.getIsEnabled());
        // 1. è§£æžå­—符串为 LocalDate(只包含年月日)
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        LocalDate localDate = LocalDate.now();
@@ -132,13 +149,12 @@
        // è®¾ç½®åˆ›å»ºäººä¿¡æ¯å’Œé»˜è®¤å€¼
        if (Objects.isNull(timingTaskDto.getId())) {
            timingTask.setRegistrationDate(LocalDate.now());
            timingTask.setActive(true);
            // è®¡ç®—首次执行时间
            LocalDateTime firstExecutionTime = calculateFirstExecutionTime(timingTask);
            timingTask.setNextExecutionTime(firstExecutionTime);
            int result = timingTaskMapper.insert(timingTask);
            if (result > 0) {
            if (result > 0 && isEnabled(timingTask.getIsEnabled(), timingTask.isActive())) {
                // æ–°å¢žæˆåŠŸåŽæ·»åŠ åˆ°è°ƒåº¦å™¨
                timingTaskScheduler.scheduleTimingTask(timingTask);
            }
@@ -148,8 +164,17 @@
            int result = timingTaskMapper.updateById(timingTask);
            if (result > 0) {
                boolean oldEnabled = isEnabled(oldTimingTask == null ? null : oldTimingTask.getIsEnabled(), oldTimingTask != null && oldTimingTask.isActive());
                boolean newEnabled = isEnabled(timingTask.getIsEnabled(), timingTask.isActive());
                if (!newEnabled) {
                    timingTaskScheduler.unscheduleTimingTask(timingTask.getId());
                } else if (oldEnabled) {
                // æ›´æ–°æˆåŠŸåŽé‡æ–°è°ƒåº¦ä»»åŠ¡
                timingTaskScheduler.rescheduleTimingTask(timingTask);
                } else {
                    // ä»Žç¦ç”¨æ”¹ä¸ºå¯ç”¨æ—¶é‡æ–°åˆ›å»ºè°ƒåº¦ä»»åŠ¡
                    timingTaskScheduler.scheduleTimingTask(timingTask);
                }
            }
            return result;
        }
@@ -451,6 +476,26 @@
        return days;
    }
    private Integer resolveEnabledValue(Integer requestEnabled, TimingTask oldTimingTask) {
        if (requestEnabled != null) {
            return requestEnabled;
        }
        if (oldTimingTask != null) {
            if (oldTimingTask.getIsEnabled() != null) {
                return oldTimingTask.getIsEnabled();
            }
            return oldTimingTask.isActive() ? ENABLED : DISABLED;
        }
        return ENABLED;
    }
    private boolean isEnabled(Integer enabledValue, boolean activeFallback) {
        if (enabledValue != null) {
            return ENABLED == enabledValue;
        }
        return activeFallback;
    }
    @Override
src/main/java/com/ruoyi/procurementrecord/utils/StockUtils.java
@@ -160,6 +160,7 @@
    }
    //删除出库记录
    public void deleteStockOutRecord(Long recordId, String recordType) {
        StockOutRecord one = stockOutRecordService.getOne(new QueryWrapper<StockOutRecord>()
                .lambda().eq(StockOutRecord::getRecordId, recordId)
src/main/java/com/ruoyi/production/service/impl/ProductionBomStructureServiceImpl.java
@@ -3,25 +3,40 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.production.bean.dto.ProductionBomStructureDto;
import com.ruoyi.production.bean.vo.ProductionBomStructureVo;
import com.ruoyi.production.mapper.ProductionBomStructureMapper;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.mapper.ProductionOrderBomMapper;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingOperationMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingOperationParamMapper;
import com.ruoyi.production.mapper.ProductionProductMainMapper;
import com.ruoyi.production.pojo.ProductionBomStructure;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionOrderBom;
import com.ruoyi.production.pojo.ProductionOrderRouting;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperation;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperationParam;
import com.ruoyi.production.pojo.ProductionProductMain;
import com.ruoyi.production.service.ProductionBomStructureService;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.mapper.TechnologyOperationParamMapper;
import com.ruoyi.technology.mapper.TechnologyParamMapper;
import com.ruoyi.technology.pojo.TechnologyOperation;
import com.ruoyi.technology.pojo.TechnologyOperationParam;
import com.ruoyi.technology.pojo.TechnologyParam;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@@ -40,8 +55,14 @@
    private final ProductionBomStructureMapper productionBomStructureMapper;
    private final ProductionOrderBomMapper productionOrderBomMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOrderRoutingMapper productionOrderRoutingMapper;
    private final ProductionOrderRoutingOperationMapper productionOrderRoutingOperationMapper;
    private final ProductionOrderRoutingOperationParamMapper productionOrderRoutingOperationParamMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionProductMainMapper productionProductMainMapper;
    private final TechnologyOperationMapper technologyOperationMapper;
    private final TechnologyOperationParamMapper technologyOperationParamMapper;
    private final TechnologyParamMapper technologyParamMapper;
    /**
     * æ ¹æ®BOM查询并组装结构树。
@@ -177,12 +198,17 @@
                Wrappers.<ProductionBomStructure>lambdaQuery()
                        .eq(ProductionBomStructure::getProductionOrderBomId, orderBomId)
                        .orderByAsc(ProductionBomStructure::getId));
        //同步需求数量
        syncStructureDemandedQuantity(structureList, orderQuantity);
        Long rootProductModelId = orderBom.getProductModelId() != null ? orderBom.getProductModelId() : productionOrder.getProductModelId();
        //同步生产工艺路线
        syncRoutingOperationsByBom(currentProductionOrderId, productionOrder, orderBom, structureList, rootProductModelId);
        //同步工单
        syncTaskPlanQuantity(
                currentProductionOrderId,
                structureList,
                orderQuantity,
                orderBom.getProductModelId() != null ? orderBom.getProductModelId() : productionOrder.getProductModelId());
                rootProductModelId);
    }
    private void syncStructureDemandedQuantity(List<ProductionBomStructure> structureList, BigDecimal orderQuantity) {
@@ -190,19 +216,22 @@
            return;
        }
        List<ProductionBomStructure> updateList = new ArrayList<>();
        BigDecimal lastProcessDemandedQuantity = orderQuantity;
        for (ProductionBomStructure structure : structureList) {
            if (structure == null || structure.getId() == null) {
                continue;
            }
            BigDecimal demandedQuantity = defaultDecimal(structure.getUnitQuantity()).multiply(orderQuantity);
            if (compareDecimal(structure.getDemandedQuantity(), demandedQuantity) == 0) {
                continue;
            }
            BigDecimal demandedQuantity = lastProcessDemandedQuantity.multiply(defaultDecimal(structure.getUnitQuantity()));
//            if (compareDecimal(structure.getDemandedQuantity(), demandedQuantity) == 0) {
//                continue;
//            }
            ProductionBomStructure update = new ProductionBomStructure();
            update.setId(structure.getId());
            update.setDemandedQuantity(demandedQuantity);
            updateList.add(update);
            structure.setDemandedQuantity(demandedQuantity);
            lastProcessDemandedQuantity = demandedQuantity;
        }
        if (!updateList.isEmpty()) {
            this.updateBatchById(updateList);
@@ -220,7 +249,6 @@
        if (taskList == null || taskList.isEmpty()) {
            return;
        }
        Set<Long> routingOperationIds = taskList.stream()
                .map(ProductionOperationTask::getProductionOrderRoutingOperationId)
                .filter(Objects::nonNull)
@@ -228,23 +256,26 @@
        if (routingOperationIds.isEmpty()) {
            return;
        }
        Map<Long, ProductionOrderRoutingOperation> routingOperationMap = productionOrderRoutingOperationMapper
                .selectBatchIds(routingOperationIds)
                .stream()
                .filter(item -> item != null && item.getId() != null)
                .collect(Collectors.toMap(ProductionOrderRoutingOperation::getId, item -> item, (left, right) -> left));
        Map<String, BigDecimal> demandedQuantityMap = buildOperationDemandedQuantityMap(structureList, rootProductModelId, orderQuantity);
        // Keep task plan quantities aligned with the same order BOM snapshot demand used during snapshot creation.
        Map<String, BigDecimal> demandedQuantityMap = buildOperationDemandedQuantityMap(structureList, rootProductModelId);
        for (ProductionOperationTask task : taskList) {
            if (task == null || task.getId() == null || task.getProductionOrderRoutingOperationId() == null) {
                continue;
            }
            ProductionOrderRoutingOperation routingOperation = routingOperationMap.get(task.getProductionOrderRoutingOperationId());
            if (routingOperation == null || routingOperation.getTechnologyRoutingOperationId() == null) {
            if (routingOperation == null) {
                continue;
            }
            BigDecimal planQuantity = resolveTaskPlanQuantity(routingOperation, demandedQuantityMap, orderQuantity);
            BigDecimal planQuantity = resolveTaskPlanQuantity(
                    routingOperation,
                    demandedQuantityMap,
                    orderQuantity,
                    rootProductModelId);
            if (compareDecimal(task.getPlanQuantity(), planQuantity) == 0) {
                continue;
            }
@@ -255,9 +286,334 @@
        }
    }
    private void syncRoutingOperationsByBom(Long productionOrderId,
                                            ProductionOrder productionOrder,
                                            ProductionOrderBom orderBom,
                                            List<ProductionBomStructure> structureList,
                                            Long rootProductModelId) {
        ProductionOrderRouting orderRouting = getOrCreateOrderRoutingSnapshot(productionOrderId, productionOrder, orderBom, rootProductModelId);
        List<ProductionOrderRoutingOperation> desiredOperationList = buildDesiredRoutingOperationList(structureList, rootProductModelId);
        List<ProductionOrderRoutingOperation> existingOperationList = productionOrderRoutingOperationMapper.selectList(
                Wrappers.<ProductionOrderRoutingOperation>lambdaQuery()
                        .eq(ProductionOrderRoutingOperation::getOrderRoutingId, orderRouting.getId())
                        .eq(ProductionOrderRoutingOperation::getProductionOrderId, productionOrderId)
                        .orderByAsc(ProductionOrderRoutingOperation::getDragSort)
                        .orderByAsc(ProductionOrderRoutingOperation::getId));
        Map<String, Deque<ProductionOrderRoutingOperation>> existingBucketMap = buildExistingRoutingOperationBucketMap(existingOperationList);
        List<ProductionOrderRoutingOperation> finalOperationList = new ArrayList<>();
        for (ProductionOrderRoutingOperation desiredOperation : desiredOperationList) {
            String bucketKey = buildRoutingOperationBucketKey(
                    desiredOperation.getTechnologyOperationId(),
                    desiredOperation.getProductModelId());
            Deque<ProductionOrderRoutingOperation> matchedQueue = existingBucketMap.get(bucketKey);
            ProductionOrderRoutingOperation matchedOperation = matchedQueue == null ? null : matchedQueue.pollFirst();
            if (matchedOperation == null) {
                matchedOperation = insertRoutingOperationSnapshot(orderRouting.getId(), productionOrderId, desiredOperation);
            } else {
                updateRoutingOperationSnapshotIfNecessary(desiredOperation, orderRouting.getId(), productionOrderId, matchedOperation);
            }
            finalOperationList.add(matchedOperation);
        }
        for (Deque<ProductionOrderRoutingOperation> queue : existingBucketMap.values()) {
            while (queue != null && !queue.isEmpty()) {
                removeRoutingOperationSnapshot(queue.pollFirst());
            }
        }
        syncRoutingOperationTasks(productionOrderId, finalOperationList);
    }
    private ProductionOrderRouting getOrCreateOrderRoutingSnapshot(Long productionOrderId,
                                                                   ProductionOrder productionOrder,
                                                                   ProductionOrderBom orderBom,
                                                                   Long rootProductModelId) {
        ProductionOrderRouting orderRouting = productionOrderRoutingMapper.selectOne(
                Wrappers.<ProductionOrderRouting>lambdaQuery()
                        .eq(ProductionOrderRouting::getProductionOrderId, productionOrderId)
                        .orderByDesc(ProductionOrderRouting::getId)
                        .last("limit 1"));
        if (orderRouting == null) {
            orderRouting = new ProductionOrderRouting();
            orderRouting.setProductionOrderId(productionOrderId);
            orderRouting.setProductModelId(rootProductModelId);
            orderRouting.setTechnologyRoutingId(productionOrder == null ? null : productionOrder.getTechnologyRoutingId());
            orderRouting.setBomId(orderBom == null ? null : orderBom.getBomId());
            orderRouting.setOrderBomId(orderBom == null ? null : orderBom.getId());
            productionOrderRoutingMapper.insert(orderRouting);
            return orderRouting;
        }
        ProductionOrderRouting update = new ProductionOrderRouting();
        update.setId(orderRouting.getId());
        boolean changed = false;
        if (!Objects.equals(orderRouting.getProductModelId(), rootProductModelId)) {
            update.setProductModelId(rootProductModelId);
            orderRouting.setProductModelId(rootProductModelId);
            changed = true;
        }
        Long technologyRoutingId = productionOrder == null ? null : productionOrder.getTechnologyRoutingId();
        if (!Objects.equals(orderRouting.getTechnologyRoutingId(), technologyRoutingId)) {
            update.setTechnologyRoutingId(technologyRoutingId);
            orderRouting.setTechnologyRoutingId(technologyRoutingId);
            changed = true;
        }
        Long bomId = orderBom == null ? null : orderBom.getBomId();
        if (!Objects.equals(orderRouting.getBomId(), bomId)) {
            update.setBomId(bomId);
            orderRouting.setBomId(bomId);
            changed = true;
        }
        Long orderBomId = orderBom == null ? null : orderBom.getId();
        if (!Objects.equals(orderRouting.getOrderBomId(), orderBomId)) {
            update.setOrderBomId(orderBomId);
            orderRouting.setOrderBomId(orderBomId);
            changed = true;
        }
        if (changed) {
            productionOrderRoutingMapper.updateById(update);
        }
        return orderRouting;
    }
    private List<ProductionOrderRoutingOperation> buildDesiredRoutingOperationList(List<ProductionBomStructure> structureList,
                                                                                   Long rootProductModelId) {
        if (structureList == null || structureList.isEmpty()) {
            return Collections.emptyList();
        }
        Map<Long, ProductionBomStructure> structureById = structureList.stream()
                .filter(item -> item != null && item.getId() != null)
                .collect(Collectors.toMap(ProductionBomStructure::getId, item -> item, (left, right) -> left));
        Map<String, ProductionBomStructure> uniqueOperationMap = new LinkedHashMap<>();
        for (ProductionBomStructure bomStructure : structureList) {
            if (bomStructure == null || bomStructure.getTechnologyOperationId() == null) {
                continue;
            }
            Long outputProductModelId = resolveOutputProductModelId(resolveOperationOutputNode(bomStructure, structureById), rootProductModelId);
            uniqueOperationMap.putIfAbsent(buildBomOperationDedupKey(bomStructure, outputProductModelId), bomStructure);
        }
        List<ProductionOrderRoutingOperation> desiredOperationList = new ArrayList<>();
        int dragSort = 1;
        for (ProductionBomStructure bomStructure : uniqueOperationMap.values()) {
            Long outputProductModelId = resolveOutputProductModelId(resolveOperationOutputNode(bomStructure, structureById), rootProductModelId);
            TechnologyOperation technologyOperation = getTechnologyOperation(bomStructure.getTechnologyOperationId());
            ProductionOrderRoutingOperation routingOperation = new ProductionOrderRoutingOperation();
            routingOperation.setProductModelId(outputProductModelId);
            routingOperation.setTechnologyOperationId(bomStructure.getTechnologyOperationId());
            routingOperation.setOperationName(technologyOperation == null ? null : technologyOperation.getName());
            routingOperation.setIsQuality(technologyOperation == null ? null : technologyOperation.getIsQuality());
            routingOperation.setIsProduction(technologyOperation == null ? null : technologyOperation.getIsProduction());
            routingOperation.setType(technologyOperation == null ? null : technologyOperation.getType());
            routingOperation.setDragSort(dragSort++);
            desiredOperationList.add(routingOperation);
        }
        return desiredOperationList;
    }
    private Map<String, Deque<ProductionOrderRoutingOperation>> buildExistingRoutingOperationBucketMap(List<ProductionOrderRoutingOperation> existingOperationList) {
        Map<String, Deque<ProductionOrderRoutingOperation>> existingBucketMap = new LinkedHashMap<>();
        if (existingOperationList == null || existingOperationList.isEmpty()) {
            return existingBucketMap;
        }
        for (ProductionOrderRoutingOperation routingOperation : existingOperationList) {
            String bucketKey = buildRoutingOperationBucketKey(
                    routingOperation.getTechnologyOperationId(),
                    routingOperation.getProductModelId());
            existingBucketMap.computeIfAbsent(bucketKey, key -> new ArrayDeque<>()).addLast(routingOperation);
        }
        return existingBucketMap;
    }
    private ProductionOrderRoutingOperation insertRoutingOperationSnapshot(Long orderRoutingId,
                                                                           Long productionOrderId,
                                                                           ProductionOrderRoutingOperation desiredOperation) {
        ProductionOrderRoutingOperation insert = new ProductionOrderRoutingOperation();
        insert.setOrderRoutingId(orderRoutingId);
        insert.setProductionOrderId(productionOrderId);
        insert.setProductModelId(desiredOperation.getProductModelId());
        insert.setTechnologyOperationId(desiredOperation.getTechnologyOperationId());
        insert.setOperationName(desiredOperation.getOperationName());
        insert.setIsQuality(desiredOperation.getIsQuality());
        insert.setIsProduction(desiredOperation.getIsProduction());
        insert.setType(desiredOperation.getType());
        insert.setDragSort(desiredOperation.getDragSort());
        productionOrderRoutingOperationMapper.insert(insert);
        syncRoutingOperationParams(insert.getId(), productionOrderId, insert.getTechnologyOperationId());
        return insert;
    }
    private void updateRoutingOperationSnapshotIfNecessary(ProductionOrderRoutingOperation currentOperation,
                                                           Long orderRoutingId,
                                                           Long productionOrderId,
                                                           ProductionOrderRoutingOperation desiredOperation) {
        if (currentOperation == null || currentOperation.getId() == null) {
            return;
        }
        ProductionOrderRoutingOperation update = new ProductionOrderRoutingOperation();
        update.setId(currentOperation.getId());
        boolean changed = false;
        if (!Objects.equals(currentOperation.getOrderRoutingId(), orderRoutingId)) {
            update.setOrderRoutingId(orderRoutingId);
            currentOperation.setOrderRoutingId(orderRoutingId);
            changed = true;
        }
        if (!Objects.equals(currentOperation.getProductionOrderId(), productionOrderId)) {
            update.setProductionOrderId(productionOrderId);
            currentOperation.setProductionOrderId(productionOrderId);
            changed = true;
        }
        if (!Objects.equals(currentOperation.getProductModelId(), desiredOperation.getProductModelId())) {
            update.setProductModelId(desiredOperation.getProductModelId());
            currentOperation.setProductModelId(desiredOperation.getProductModelId());
            changed = true;
        }
        if (!Objects.equals(currentOperation.getTechnologyOperationId(), desiredOperation.getTechnologyOperationId())) {
            update.setTechnologyOperationId(desiredOperation.getTechnologyOperationId());
            currentOperation.setTechnologyOperationId(desiredOperation.getTechnologyOperationId());
            changed = true;
        }
        if (!Objects.equals(currentOperation.getOperationName(), desiredOperation.getOperationName())) {
            update.setOperationName(desiredOperation.getOperationName());
            currentOperation.setOperationName(desiredOperation.getOperationName());
            changed = true;
        }
        if (!Objects.equals(currentOperation.getIsQuality(), desiredOperation.getIsQuality())) {
            update.setIsQuality(desiredOperation.getIsQuality());
            currentOperation.setIsQuality(desiredOperation.getIsQuality());
            changed = true;
        }
        if (!Objects.equals(currentOperation.getIsProduction(), desiredOperation.getIsProduction())) {
            update.setIsProduction(desiredOperation.getIsProduction());
            currentOperation.setIsProduction(desiredOperation.getIsProduction());
            changed = true;
        }
        if (!Objects.equals(currentOperation.getType(), desiredOperation.getType())) {
            update.setType(desiredOperation.getType());
            currentOperation.setType(desiredOperation.getType());
            changed = true;
        }
        if (!Objects.equals(currentOperation.getDragSort(), desiredOperation.getDragSort())) {
            update.setDragSort(desiredOperation.getDragSort());
            currentOperation.setDragSort(desiredOperation.getDragSort());
            changed = true;
        }
        if (changed) {
            productionOrderRoutingOperationMapper.updateById(update);
        }
    }
    private void removeRoutingOperationSnapshot(ProductionOrderRoutingOperation routingOperation) {
        if (routingOperation == null || routingOperation.getId() == null) {
            return;
        }
        ProductionOperationTask task = productionOperationTaskMapper.selectOne(
                Wrappers.<ProductionOperationTask>lambdaQuery()
                        .eq(ProductionOperationTask::getProductionOrderRoutingOperationId, routingOperation.getId())
                        .last("limit 1"));
        if (task != null) {
            validateTaskCanRemove(task);
            productionOperationTaskMapper.deleteById(task.getId());
        }
        productionOrderRoutingOperationParamMapper.delete(
                Wrappers.<ProductionOrderRoutingOperationParam>lambdaQuery()
                        .eq(ProductionOrderRoutingOperationParam::getProductionOrderRoutingOperationId, routingOperation.getId()));
        productionOrderRoutingOperationMapper.deleteById(routingOperation.getId());
    }
    private void syncRoutingOperationTasks(Long productionOrderId, List<ProductionOrderRoutingOperation> routingOperationList) {
        if (routingOperationList == null || routingOperationList.isEmpty()) {
            return;
        }
        List<Long> routingOperationIdList = routingOperationList.stream()
                .map(ProductionOrderRoutingOperation::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        if (routingOperationIdList.isEmpty()) {
            return;
        }
        Map<Long, ProductionOperationTask> taskByRoutingOperationId = productionOperationTaskMapper.selectList(
                        Wrappers.<ProductionOperationTask>lambdaQuery()
                                .in(ProductionOperationTask::getProductionOrderRoutingOperationId, routingOperationIdList)
                                .orderByAsc(ProductionOperationTask::getId))
                .stream()
                .filter(item -> item != null && item.getProductionOrderRoutingOperationId() != null)
                .collect(Collectors.toMap(
                        ProductionOperationTask::getProductionOrderRoutingOperationId,
                        item -> item,
                        (left, right) -> left,
                        LinkedHashMap::new));
        for (int i = 0; i < routingOperationList.size(); i++) {
            ProductionOrderRoutingOperation routingOperation = routingOperationList.get(i);
            if (routingOperation == null || routingOperation.getId() == null) {
                continue;
            }
            boolean shouldHaveTask = i == routingOperationList.size() - 1 || Boolean.TRUE.equals(routingOperation.getIsProduction());
            ProductionOperationTask existingTask = taskByRoutingOperationId.get(routingOperation.getId());
            if (shouldHaveTask) {
                if (existingTask == null) {
                    ProductionOperationTask task = new ProductionOperationTask();
                    task.setProductionOrderId(productionOrderId);
                    task.setProductionOrderRoutingOperationId(routingOperation.getId());
                    task.setPlanQuantity(BigDecimal.ZERO);
                    task.setCompleteQuantity(BigDecimal.ZERO);
                    task.setWorkOrderNo(generateNextTaskNo());
                    task.setStatus(2);
                    productionOperationTaskMapper.insert(task);
                }
                continue;
            }
            if (existingTask != null) {
                validateTaskCanRemove(existingTask);
                productionOperationTaskMapper.deleteById(existingTask.getId());
            }
        }
    }
    private void validateTaskCanRemove(ProductionOperationTask task) {
        if (task == null || task.getId() == null) {
            return;
        }
        if (defaultDecimal(task.getCompleteQuantity()).compareTo(BigDecimal.ZERO) > 0) {
            throw new ServiceException("工序已产生报工记录,无法根据 BOM å˜æ›´åˆ é™¤å¯¹åº”工序快照");
        }
        long reportCount = productionProductMainMapper.selectCount(
                Wrappers.<ProductionProductMain>lambdaQuery()
                        .eq(ProductionProductMain::getProductionOperationTaskId, task.getId()));
        if (reportCount > 0) {
            throw new ServiceException("工序已产生报工记录,无法根据 BOM å˜æ›´åˆ é™¤å¯¹åº”工单");
        }
    }
    private void syncRoutingOperationParams(Long routingOperationId, Long productionOrderId, Long technologyOperationId) {
        if (routingOperationId == null || technologyOperationId == null) {
            return;
        }
        List<TechnologyOperationParam> operationParamList = technologyOperationParamMapper.selectList(
                Wrappers.<TechnologyOperationParam>lambdaQuery()
                        .eq(TechnologyOperationParam::getTechnologyOperationId, technologyOperationId)
                        .orderByAsc(TechnologyOperationParam::getId));
        for (TechnologyOperationParam operationParam : operationParamList) {
            TechnologyParam technologyParam = technologyParamMapper.selectById(operationParam.getTechnologyParamId());
            if (technologyParam == null) {
                continue;
            }
            ProductionOrderRoutingOperationParam snapshot = new ProductionOrderRoutingOperationParam();
            snapshot.setProductionOrderId(productionOrderId);
            snapshot.setProductionOrderRoutingOperationId(routingOperationId);
            snapshot.setTechnologyOperationId(operationParam.getTechnologyOperationId());
            snapshot.setTechnologyOperationParamId(operationParam.getId());
            snapshot.setParamId(technologyParam.getId());
            snapshot.setParamCode(technologyParam.getParamCode());
            snapshot.setParamName(technologyParam.getParamName());
            snapshot.setParamType(technologyParam.getParamType());
            snapshot.setParamFormat(technologyParam.getParamFormat());
            snapshot.setUnit(technologyParam.getUnit());
            snapshot.setIsRequired(technologyParam.getIsRequired());
            snapshot.setRemark(technologyParam.getRemark());
            snapshot.setStandardValue(operationParam.getStandardValue());
            productionOrderRoutingOperationParamMapper.insert(snapshot);
        }
    }
    private Map<String, BigDecimal> buildOperationDemandedQuantityMap(List<ProductionBomStructure> structureList,
                                                                      Long rootProductModelId,
                                                                      BigDecimal orderQuantity) {
                                                                      Long rootProductModelId) {
        if (structureList == null || structureList.isEmpty()) {
            return Collections.emptyMap();
        }
@@ -265,26 +621,44 @@
                .filter(item -> item != null && item.getId() != null)
                .collect(Collectors.toMap(ProductionBomStructure::getId, item -> item, (left, right) -> left));
        Map<String, BigDecimal> demandedQuantityMap = new HashMap<>();
        Set<String> mergedOutputNodeKeySet = new HashSet<>();
        for (ProductionBomStructure bomStructure : structureList) {
            if (bomStructure == null || bomStructure.getTechnologyOperationId() == null || bomStructure.getUnitQuantity() == null) {
            if (bomStructure == null || bomStructure.getTechnologyOperationId() == null) {
                continue;
            }
            Long outputProductModelId = resolveOutputProductModelId(bomStructure, structureById, rootProductModelId);
            // Resolve the output node first, then read the output node demand for the task plan quantity.
            ProductionBomStructure outputNode = resolveOperationOutputNode(bomStructure, structureById);
            Long outputProductModelId = resolveOutputProductModelId(outputNode, rootProductModelId);
            if (outputProductModelId == null) {
                continue;
            }
            String mergedOutputNodeKey = buildOperationOutputNodeKey(
                    bomStructure.getTechnologyOperationId(),
                    outputNode == null ? null : outputNode.getId(),
                    outputProductModelId);
            if (!mergedOutputNodeKeySet.add(mergedOutputNodeKey)) {
                continue;
            }
            // Multiple input rows can point to the same output node, so only count that output demand once.
            String key = buildOperationDemandedQuantityKey(bomStructure.getTechnologyOperationId(), outputProductModelId);
            demandedQuantityMap.merge(key, bomStructure.getUnitQuantity().multiply(orderQuantity), BigDecimal::add);
            demandedQuantityMap.merge(key, defaultDecimal(outputNode == null ? null : outputNode.getDemandedQuantity()), BigDecimal::add);
        }
        return demandedQuantityMap;
    }
    private BigDecimal resolveTaskPlanQuantity(ProductionOrderRoutingOperation routingOperation,
                                               Map<String, BigDecimal> demandedQuantityMap,
                                               BigDecimal orderQuantity) {
                                               BigDecimal orderQuantity,
                                               Long rootProductModelId) {
        if (routingOperation == null || demandedQuantityMap == null || demandedQuantityMap.isEmpty()) {
            return orderQuantity;
        }
        Long outputProductModelId = routingOperation.getProductModelId() != null
                ? routingOperation.getProductModelId()
                : rootProductModelId;
        String key = buildOperationDemandedQuantityKey(
                routingOperation.getTechnologyOperationId(),
                routingOperation.getProductModelId());
                outputProductModelId);
        BigDecimal planQuantity = demandedQuantityMap.get(key);
        return planQuantity != null ? planQuantity : orderQuantity;
    }
@@ -293,21 +667,64 @@
        return String.valueOf(operationId) + "#" + String.valueOf(outputProductModelId);
    }
    private Long resolveOutputProductModelId(ProductionBomStructure bomStructure,
                                             Map<Long, ProductionBomStructure> structureById,
                                             Long rootProductModelId) {
    private String buildRoutingOperationBucketKey(Long operationId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputProductModelId);
    }
    private String buildBomOperationDedupKey(ProductionBomStructure bomStructure, Long outputProductModelId) {
        Long operationId = bomStructure == null ? null : bomStructure.getTechnologyOperationId();
        Long parentId = bomStructure == null ? null : bomStructure.getParentId();
        return operationId + "#" + outputProductModelId + "#" + parentId;
    }
    private String buildOperationOutputNodeKey(Long operationId, Long outputNodeId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputNodeId) + "#" + String.valueOf(outputProductModelId);
    }
    private ProductionBomStructure resolveOperationOutputNode(ProductionBomStructure bomStructure,
                                                              Map<Long, ProductionBomStructure> structureById) {
        if (bomStructure == null) {
            return null;
        }
        // The root node is the first output node; other rows use their direct parent as the current operation output.
        if (bomStructure.getParentId() == null) {
            return bomStructure;
        }
        ProductionBomStructure parent = structureById.get(bomStructure.getParentId());
        return parent != null ? parent : bomStructure;
    }
    private Long resolveOutputProductModelId(ProductionBomStructure outputNode,
                                             Long rootProductModelId) {
        if (outputNode == null) {
            return rootProductModelId;
        }
        Long parentId = bomStructure.getParentId();
        if (parentId == null) {
            return rootProductModelId != null ? rootProductModelId : bomStructure.getProductModelId();
        return outputNode.getProductModelId() != null ? outputNode.getProductModelId() : rootProductModelId;
        }
        ProductionBomStructure parent = structureById.get(parentId);
        if (parent != null && parent.getProductModelId() != null) {
            return parent.getProductModelId();
    private TechnologyOperation getTechnologyOperation(Long technologyOperationId) {
        if (technologyOperationId == null) {
            return null;
        }
        return rootProductModelId != null ? rootProductModelId : bomStructure.getProductModelId();
        return technologyOperationMapper.selectById(technologyOperationId);
    }
    private String generateNextTaskNo() {
        String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        ProductionOperationTask latestTask = productionOperationTaskMapper.selectOne(
                Wrappers.<ProductionOperationTask>lambdaQuery()
                        .likeRight(ProductionOperationTask::getWorkOrderNo, "GD" + datePrefix)
                        .orderByDesc(ProductionOperationTask::getWorkOrderNo)
                        .last("limit 1"));
        int sequenceNumber = 1;
        if (latestTask != null && latestTask.getWorkOrderNo() != null && latestTask.getWorkOrderNo().startsWith("GD" + datePrefix)) {
            try {
                sequenceNumber = Integer.parseInt(latestTask.getWorkOrderNo().substring(("GD" + datePrefix).length())) + 1;
            } catch (NumberFormatException ignored) {
                sequenceNumber = 1;
            }
        }
        return "GD" + String.format("%s%03d", datePrefix, sequenceNumber);
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
src/main/java/com/ruoyi/production/service/impl/ProductionOperationTaskServiceImpl.java
@@ -23,9 +23,11 @@
import com.ruoyi.production.bean.dto.ProductionOperationTaskDto;
import com.ruoyi.production.bean.vo.ProductionOperationTaskVo;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingOperationMapper;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperation;
import com.ruoyi.production.service.ProductionOperationTaskService;
import com.ruoyi.project.system.domain.SysUser;
import com.ruoyi.project.system.mapper.SysUserMapper;
@@ -48,6 +50,7 @@
    private final SysUserMapper sysUserMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOrderRoutingOperationMapper productionOrderRoutingOperationMapper;
    private final FileUtil fileUtil;
@@ -61,6 +64,7 @@
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§å·¥åºä»»åŠ¡
        Page<ProductionOperationTaskVo> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
        IPage<ProductionOperationTaskVo> result = baseMapper.pageProductionOperationTask(voPage, dto);
        fillOperationTypes(result.getRecords());
        fillUserNames(result.getRecords());
        return result;
    }
@@ -69,6 +73,7 @@
    public List<ProductionOperationTaskVo> listProductionOperationTask(ProductionOperationTaskDto dto) {
        // æŸ¥è¯¢å·¥åºä»»åŠ¡åˆ—è¡¨
        List<ProductionOperationTaskVo> result = BeanUtil.copyToList(this.list(buildQueryWrapper(dto)), ProductionOperationTaskVo.class);
        fillOperationTypes(result);
        fillUserNames(result);
        return result;
    }
@@ -81,6 +86,7 @@
            return null;
        }
        ProductionOperationTaskVo vo = BeanUtil.copyProperties(item, ProductionOperationTaskVo.class);
        fillOperationTypes(Collections.singletonList(vo));
        if (item.getProductionOrderId() != null) {
            ProductionOrder productionOrder = productionOrderMapper.selectById(item.getProductionOrderId());
            if (productionOrder != null) {
@@ -370,6 +376,38 @@
    @Override
    public List<ProductionOperationTaskVo> getOperation(ProductionOperationTaskDto dto) {
        // æŸ¥è¯¢å·¥åºä»»åŠ¡åˆ—è¡¨
        return baseMapper.getOperation(dto);
        List<ProductionOperationTaskVo> result = baseMapper.getOperation(dto);
        fillOperationTypes(result);
        return result;
    }
    private void fillOperationTypes(List<ProductionOperationTaskVo> voList) {
        // å›žå¡«å·¥åºç±»åž‹ï¼ˆ0 è®¡æ—¶ / 1 è®¡ä»¶ï¼‰
        if (voList == null || voList.isEmpty()) {
            return;
        }
        Set<Long> operationIds = voList.stream()
                .filter(Objects::nonNull)
                .map(ProductionOperationTaskVo::getProductionOrderRoutingOperationId)
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        if (operationIds.isEmpty()) {
            return;
        }
        Map<Long, Integer> typeByOperationId = productionOrderRoutingOperationMapper
                .selectBatchIds(new ArrayList<>(operationIds))
                .stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toMap(
                        ProductionOrderRoutingOperation::getId,
                        ProductionOrderRoutingOperation::getType,
                        (left, right) -> left
                ));
        for (ProductionOperationTaskVo vo : voList) {
            if (vo == null || vo.getType() != null || vo.getProductionOrderRoutingOperationId() == null) {
                continue;
            }
            vo.setType(typeByOperationId.get(vo.getProductionOrderRoutingOperationId()));
        }
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java
@@ -245,7 +245,18 @@
                        .eq(TechnologyRoutingOperation::getTechnologyRoutingId, technologyRouting.getId())
                        .orderByDesc(TechnologyRoutingOperation::getDragSort)
                        .orderByDesc(TechnologyRoutingOperation::getId));
        Map<String, BigDecimal> operationDemandedQuantityMap = buildOperationDemandedQuantityMap(technologyRouting, productionOrder);
        // Build task plan quantities from order BOM snapshot demand instead of recomputing from technology BOM units.
        Long rootProductModelId = orderBom != null && orderBom.getProductModelId() != null
                ? orderBom.getProductModelId()
                : productionOrder.getProductModelId();
        List<ProductionBomStructure> orderBomStructureList = orderBom == null || orderBom.getId() == null
                ? Collections.emptyList()
                : productionBomStructureMapper.selectList(
                Wrappers.<ProductionBomStructure>lambdaQuery()
                        .eq(ProductionBomStructure::getProductionOrderBomId, orderBom.getId())
                        .orderByAsc(ProductionBomStructure::getId));
        Map<String, BigDecimal> operationDemandedQuantityMap =
                buildOperationDemandedQuantityMap(orderBomStructureList, rootProductModelId);
        Map<Long, String> operationNameMap = technologyOperationMapper.selectBatchIds(
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
                        routingOperations.stream()
@@ -279,7 +290,11 @@
                ProductionOperationTask task = new ProductionOperationTask();
                task.setProductionOrderRoutingOperationId(targetOperation.getId());
                task.setProductionOrderId(productionOrder.getId());
                task.setPlanQuantity(resolveTaskPlanQuantity(sourceOperation, operationDemandedQuantityMap, productionOrder));
                task.setPlanQuantity(resolveTaskPlanQuantity(
                        sourceOperation,
                        operationDemandedQuantityMap,
                        productionOrder,
                        rootProductModelId));
                task.setCompleteQuantity(BigDecimal.ZERO);
                task.setWorkOrderNo(generateNextTaskNo());
                task.setStatus(2);
@@ -314,47 +329,52 @@
        return syncedParamCount;
    }
    private Map<String, BigDecimal> buildOperationDemandedQuantityMap(TechnologyRouting technologyRouting,
                                                                      ProductionOrder productionOrder) {
        if (technologyRouting == null || technologyRouting.getBomId() == null) {
    private Map<String, BigDecimal> buildOperationDemandedQuantityMap(List<ProductionBomStructure> bomStructures,
                                                                      Long rootProductModelId) {
        if (bomStructures == null || bomStructures.isEmpty()) {
            return Collections.emptyMap();
        }
        BigDecimal orderQuantity = defaultDecimal(productionOrder == null ? null : productionOrder.getQuantity());
        List<TechnologyBomStructure> bomStructures = technologyBomStructureMapper.selectList(
                Wrappers.<TechnologyBomStructure>lambdaQuery()
                        .eq(TechnologyBomStructure::getBomId, technologyRouting.getBomId())
                        .isNotNull(TechnologyBomStructure::getOperationId)
                        .orderByAsc(TechnologyBomStructure::getId));
        if (bomStructures.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<Long, TechnologyBomStructure> structureById = bomStructures.stream()
        Map<Long, ProductionBomStructure> structureById = bomStructures.stream()
                .filter(item -> item != null && item.getId() != null)
                .collect(Collectors.toMap(TechnologyBomStructure::getId, item -> item, (left, right) -> left));
                .collect(Collectors.toMap(ProductionBomStructure::getId, item -> item, (left, right) -> left));
        Map<String, BigDecimal> demandedQuantityMap = new HashMap<>();
        for (TechnologyBomStructure bomStructure : bomStructures) {
            if (bomStructure == null || bomStructure.getOperationId() == null) {
        Set<String> mergedOutputNodeKeySet = new HashSet<>();
        for (ProductionBomStructure bomStructure : bomStructures) {
            if (bomStructure == null || bomStructure.getTechnologyOperationId() == null) {
                continue;
            }
            BigDecimal unitQuantity = bomStructure.getUnitQuantity();
            if (unitQuantity == null) {
            // The BOM row points to the producing operation; task quantity should come from that operation's output node.
            ProductionBomStructure outputNode = resolveOperationOutputNode(bomStructure, structureById);
            Long outputProductModelId = resolveOutputProductModelId(outputNode, rootProductModelId);
            if (outputProductModelId == null) {
                continue;
            }
            Long outputProductModelId = resolveOutputProductModelId(bomStructure, structureById, technologyRouting.getProductModelId());
            String key = buildOperationDemandedQuantityKey(bomStructure.getOperationId(), outputProductModelId);
            demandedQuantityMap.merge(key, unitQuantity.multiply(orderQuantity), BigDecimal::add);
            String mergedOutputNodeKey = buildOperationOutputNodeKey(
                    bomStructure.getTechnologyOperationId(),
                    outputNode == null ? null : outputNode.getId(),
                    outputProductModelId);
            if (!mergedOutputNodeKeySet.add(mergedOutputNodeKey)) {
                continue;
            }
            // demandedQuantity is already the order-level required output quantity for the current output node.
            BigDecimal demandedQuantity = defaultDecimal(outputNode == null ? null : outputNode.getDemandedQuantity());
            String key = buildOperationDemandedQuantityKey(bomStructure.getTechnologyOperationId(), outputProductModelId);
            demandedQuantityMap.merge(key, demandedQuantity, BigDecimal::add);
        }
        return demandedQuantityMap;
    }
    private BigDecimal resolveTaskPlanQuantity(TechnologyRoutingOperation sourceOperation,
                                               Map<String, BigDecimal> operationDemandedQuantityMap,
                                               ProductionOrder productionOrder) {
                                               ProductionOrder productionOrder,
                                               Long rootProductModelId) {
        if (sourceOperation == null || operationDemandedQuantityMap == null || operationDemandedQuantityMap.isEmpty()) {
            return defaultDecimal(productionOrder == null ? null : productionOrder.getQuantity());
        }
        String key = buildOperationDemandedQuantityKey(sourceOperation.getTechnologyOperationId(), sourceOperation.getProductModelId());
        Long outputProductModelId = sourceOperation.getProductModelId() != null
                ? sourceOperation.getProductModelId()
                : rootProductModelId;
        String key = buildOperationDemandedQuantityKey(sourceOperation.getTechnologyOperationId(), outputProductModelId);
        BigDecimal planQuantity = operationDemandedQuantityMap.get(key);
        return planQuantity != null ? planQuantity : defaultDecimal(productionOrder == null ? null : productionOrder.getQuantity());
    }
@@ -363,21 +383,29 @@
        return String.valueOf(operationId) + "#" + String.valueOf(outputProductModelId);
    }
    private Long resolveOutputProductModelId(TechnologyBomStructure bomStructure,
                                             Map<Long, TechnologyBomStructure> structureById,
                                             Long routingProductModelId) {
    private String buildOperationOutputNodeKey(Long operationId, Long outputNodeId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputNodeId) + "#" + String.valueOf(outputProductModelId);
    }
    private ProductionBomStructure resolveOperationOutputNode(ProductionBomStructure bomStructure,
                                                              Map<Long, ProductionBomStructure> structureById) {
        if (bomStructure == null) {
            return routingProductModelId;
            return null;
        }
        Long parentId = bomStructure.getParentId();
        if (parentId == null) {
            return routingProductModelId != null ? routingProductModelId : bomStructure.getProductModelId();
        // The root node is the first output node; child rows use their direct parent as the current operation output.
        if (bomStructure.getParentId() == null) {
            return bomStructure;
        }
        TechnologyBomStructure parent = structureById.get(parentId);
        if (parent != null && parent.getProductModelId() != null) {
            return parent.getProductModelId();
        ProductionBomStructure parent = structureById.get(bomStructure.getParentId());
        return parent != null ? parent : bomStructure;
        }
        return routingProductModelId != null ? routingProductModelId : bomStructure.getProductModelId();
    private Long resolveOutputProductModelId(ProductionBomStructure outputNode,
                                             Long rootProductModelId) {
        if (outputNode == null) {
            return rootProductModelId;
        }
        return outputNode.getProductModelId() != null ? outputNode.getProductModelId() : rootProductModelId;
    }
    private ProductionOrderBom syncProductionOrderBomSnapshot(ProductionOrder productionOrder, TechnologyRouting technologyRouting) {
@@ -409,6 +437,7 @@
        productionOrderBomMapper.insert(orderBom);
        Map<Long, Long> idMap = new HashMap<>();
        BigDecimal lastProcessDemandedQuantity = orderQuantity;
        for (TechnologyBomStructure source : structureList) {
            // å­èŠ‚ç‚¹ parentId éœ€è¦æ˜ å°„成新快照节点 id,才能保留原始 BOM å±‚级。
            ProductionBomStructure target = new ProductionBomStructure();
@@ -418,10 +447,11 @@
            target.setProductModelId(source.getProductModelId());
            target.setTechnologyOperationId(source.getOperationId());
            target.setUnitQuantity(source.getUnitQuantity());
            target.setDemandedQuantity(source.getUnitQuantity().multiply(orderQuantity));
            target.setDemandedQuantity(lastProcessDemandedQuantity.multiply(source.getUnitQuantity()));
            target.setUnit(source.getUnit());
            productionBomStructureMapper.insert(target);
            idMap.put(source.getId(), target.getId());
            lastProcessDemandedQuantity = target.getDemandedQuantity();
        }
        return orderBom;
    }
src/main/java/com/ruoyi/project/system/controller/SysLoginController.java
@@ -73,17 +73,7 @@
    public AjaxResult getInfo()
    {
        LoginUser loginUser = SecurityUtils.getLoginUser();
        SysUser user = userService.selectUserById(loginUser.getUserId());
        if (user == null)
        {
            user = loginUser.getUser();
        }
        else
        {
            loginUser.setUser(user);
            loginUser.setAiEnabled(user.getAiEnabled());
            tokenService.setLoginUser(loginUser);
        }
        SysUser user = loginUser.getUser();
        // èŽ·å–å½“å‰ç™»å½•å…¬å¸
        Long tenantId = loginUser.getTenantId();
        if(null != tenantId){
src/main/java/com/ruoyi/purchase/pojo/ProductRecord.java
@@ -5,6 +5,7 @@
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
@@ -16,7 +17,7 @@
 */
@Data
@TableName("product_record")
public class ProductRecord {
public class ProductRecord implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
src/main/java/com/ruoyi/purchase/pojo/SalesLedgerProductTemplate.java
@@ -69,10 +69,10 @@
    private Integer type;
    @Schema(description = "产品id")
    private Integer productId;
    private Long productId;
    @Schema(description = "型号id")
    private Integer productModelId;
    private Long productModelId;
    private String register;
src/main/java/com/ruoyi/quality/pojo/QualityInspect.java
@@ -96,9 +96,17 @@
    /**
     * æ•°é‡
     */
    @Excel(name = "数量")
    @Excel(name = "总数量")
    private BigDecimal quantity;
    @Excel(name = "合格数量")
    @TableField("qualified_quantity")
    private BigDecimal qualifiedQuantity;
    @Excel(name = "不合格数量")
    @TableField("unqualified_quantity")
    private BigDecimal unqualifiedQuantity;
    /**
     * æ£€æµ‹å•位
     */
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java
@@ -1,6 +1,7 @@
package com.ruoyi.quality.service.impl;
import cn.hutool.core.lang.Assert;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -35,6 +36,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.util.Arrays;
import java.util.HashMap;
@@ -92,17 +94,10 @@
        if (ObjectUtils.isNull(qualityInspect.getCheckResult())) {
            throw new RuntimeException("请先判断是否合格");
        }
        /*判断不合格*/
        if (qualityInspect.getCheckResult().equals("不合格")) {
            QualityUnqualified qualityUnqualified = new QualityUnqualified();
            BeanUtils.copyProperties(qualityInspect, qualityUnqualified);
            qualityUnqualified.setInspectState(0);//待处理
            List<QualityInspectParam> inspectParams = qualityInspectParamService.list(Wrappers.<QualityInspectParam>lambdaQuery().eq(QualityInspectParam::getInspectId, inspect.getId()));
            String text = inspectParams.stream().map(QualityInspectParam::getParameterItem).collect(Collectors.joining(","));
            qualityUnqualified.setDefectivePhenomena(text + "这些指标中存在不合格");//不合格现象
            qualityUnqualified.setInspectId(qualityInspect.getId());
            qualityUnqualifiedMapper.insert(qualityUnqualified);
        } else {
        // åŒºåˆ†åˆæ ¼æ•°é‡ä»¥åŠä¸åˆæ ¼å¤„理进行对应的处理
        Assert.isTrue(qualityInspect.getQuantity().compareTo(qualityInspect.getQualifiedQuantity().add(qualityInspect.getUnqualifiedQuantity())) == 0,"请检查合格数量和不合格数量,需要合格数量+不合格数量与总数保持一致");
        if(qualityInspect.getQualifiedQuantity().compareTo(BigDecimal.ZERO) > 0){
            //合格直接入库
            // stockUtils.addStock(qualityInspect.getProductModelId(), qualityInspect.getQuantity(), StockInQualifiedRecordTypeEnum.QUALITYINSPECT_STOCK_IN.getCode(), qualityInspect.getId());
            //仅添加入库记录
@@ -114,13 +109,25 @@
            }
            stockInventoryDto.setRecordId(qualityInspect.getId());
            stockInventoryDto.setProductModelId(qualityInspect.getProductModelId());
            stockInventoryDto.setQualitity(qualityInspect.getQuantity());
            stockInventoryDto.setQualitity(qualityInspect.getQualifiedQuantity());
            stockInventoryDto.setBatchNo(resolveProductionBatchNo(
                    qualityInspect.getProductMainId(),
                    qualityInspect.getId(),
                    qualityInspect.getProductModelId()));
            stockInventoryService.addStockInRecordOnly(stockInventoryDto);
        }
        if(qualityInspect.getUnqualifiedQuantity().compareTo(BigDecimal.ZERO) > 0){
            QualityUnqualified qualityUnqualified = new QualityUnqualified();
            BeanUtils.copyProperties(qualityInspect, qualityUnqualified);
            qualityUnqualified.setInspectState(0);//待处理
            qualityUnqualified.setQuantity(qualityInspect.getUnqualifiedQuantity());
            List<QualityInspectParam> inspectParams = qualityInspectParamService.list(Wrappers.<QualityInspectParam>lambdaQuery().eq(QualityInspectParam::getInspectId, inspect.getId()));
            String text = inspectParams.stream().map(QualityInspectParam::getParameterItem).collect(Collectors.joining(","));
            qualityUnqualified.setDefectivePhenomena(text + "这些指标中存在不合格");//不合格现象
            qualityUnqualified.setInspectId(qualityInspect.getId());
            qualityUnqualifiedMapper.insert(qualityUnqualified);
        }
        qualityInspect.setInspectState(1);//已提交
        return qualityInspectMapper.updateById(qualityInspect);
    }
src/main/java/com/ruoyi/sales/pojo/SalesQuotationProduct.java
@@ -14,7 +14,12 @@
    private Long id;
    @Schema(description = "销售报价单id")
    private Long salesQuotationId;
    @Schema(description = "产品Id")
    @TableField(value = "product_id")
    private Long productId;
    @Schema(description = "产品规格Id")
    @TableField(value = "product_model_id")
    private Long productModelId;
    @Schema(description = "商品名称")
    private String product;
    @Schema(description = "商品规格")
src/main/java/com/ruoyi/sales/service/impl/SalesQuotationServiceImpl.java
@@ -13,6 +13,7 @@
import com.ruoyi.approve.bean.vo.ApproveProcessVO;
import com.ruoyi.basic.mapper.CustomerMapper;
import com.ruoyi.basic.pojo.Customer;
import com.ruoyi.common.enums.IsDeleteEnum;
import com.ruoyi.common.utils.OrderUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
@@ -138,6 +139,7 @@
        // åˆ é™¤æŠ¥ä»·å®¡æ‰¹
        ApproveProcess one = approveProcessService.getOne(new LambdaQueryWrapper<ApproveProcess>()
                .eq(ApproveProcess::getApproveType, 6)
                .eq(ApproveProcess::getApproveDelete, IsDeleteEnum.NOT_DELETED)
                .eq(ApproveProcess::getApproveReason, salesQuotation.getQuotationNo()));
        if(one != null){
            approveProcessService.delByIds(Collections.singletonList(one.getId()));
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java
@@ -13,7 +13,6 @@
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.procurementrecord.bean.vo.ShippingProductVo;
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.sales.dto.SalesLedgerProductDto;
import com.ruoyi.sales.dto.ShippingApproveDto;
import com.ruoyi.sales.dto.ShippingInfoDto;
import com.ruoyi.sales.dto.ShippingProductDetailDto;
@@ -96,12 +95,6 @@
        if (CollectionUtils.isEmpty(shippingInfos)) return false;
        // åˆ é™¤é™„ä»¶
        commonFileService.deleteByBusinessIds(ids, FileNameType.SHIP.getValue());
        // æ‰£å·²å‘货库存
        for (ShippingInfo shippingInfo : shippingInfos) {
            if ("已发货".equals(shippingInfo.getStatus())) {
                stockUtils.deleteStockOutRecord(shippingInfo.getId(), StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode());
            }
        }
        // åˆ é™¤å‘货审批
        if (CollectionUtils.isNotEmpty(shippingInfos)) {
            for (ShippingInfo shippingInfo : shippingInfos) {
@@ -111,6 +104,8 @@
                    List<Long> list = one.stream().map(ApproveProcess::getId).toList();
                    approveProcessService.delByIds(list);
                }
                // æ‰£å·²å‘货库存
                stockUtils.deleteStockOutRecord(shippingInfo.getId(), StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode());
            }
        }
        //删除发货明细
src/main/java/com/ruoyi/stock/controller/StockInventoryController.java
@@ -51,6 +51,19 @@
        return R.ok(stockInventoryDtoIPage);
    }
    /**
     * æŸ¥è¯¢å¯¹åº”批号和数量
     * @param page
     * @param stockInventoryDto
     * @return
     */
    @GetMapping("/getBatchNoQty")
    @Operation(summary = "查询对应批号和数量")
    public R getBatchNoQty(Page page, StockInventoryDto stockInventoryDto) {
        IPage<StockInventoryDto> stockInventoryDtoIPage = stockInventoryService.getBatchNoQty(page, stockInventoryDto);
        return R.ok(stockInventoryDtoIPage);
    }
    @PostMapping("/addstockInventory")
    @Operation(summary = "新增库存")
    public R addstockInventory(@RequestBody StockInventoryDto stockInventoryDto) {
@@ -85,7 +98,7 @@
    }
    @PostMapping("importStockInventory")
    @PostMapping("/importStockInventory")
    @Operation(summary = "导入库存")
    public R importStockInventory(MultipartFile file) {
        return stockInventoryService.importStockInventory(file);
@@ -105,13 +118,13 @@
        stockInventoryService.exportStockInventory(response, stockInventoryDto);
    }
    @GetMapping("stockInventoryPage")
    @GetMapping("/stockInventoryPage")
    @Operation(summary = "库存报表查询")
    public R stockInventoryPage(Page page, StockInventoryDto stockInventoryDto) {
        return R.ok(stockInventoryService.stockInventoryPage(stockInventoryDto,page));
    }
    @GetMapping("stockInAndOutRecord")
    @GetMapping("/stockInAndOutRecord")
    @Operation(summary = "统计各个产品的入库和出库记录")
    public R stockInAndOutRecord(StockInventoryDto stockInventoryDto,Page page) {
        return R.ok(stockInventoryService.stockInAndOutRecord(stockInventoryDto,page));
@@ -128,7 +141,6 @@
    public R thawStock(@RequestBody StockInventoryDto stockInventoryDto) {
        return R.ok(stockInventoryService.thawStock(stockInventoryDto));
    }
    @GetMapping("/getByModelId")
    @Operation(summary = "根据产品规格ID获取入库记录")
src/main/java/com/ruoyi/stock/dto/StockInventoryDto.java
@@ -76,4 +76,7 @@
    @Schema(description = "不合格库存ID")
    private Long unQualifiedId;
    @Schema(description = "产品id")
    private Long productId;
}
src/main/java/com/ruoyi/stock/execl/StockInRecordExportData.java
@@ -18,11 +18,13 @@
    private String model;
    @Excel(name = "单位")
    private String unit;
    @Excel(name = "批号")
    private String batchNo;
    @Excel(name = "入库来源")
    private String recordType;
    @Excel(name = "入库数量")
    private String stockInNum;
    @Excel(name = "入库时间")
    @Excel(name = "入库时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
src/main/java/com/ruoyi/stock/execl/StockInventoryExportData.java
@@ -19,6 +19,10 @@
    @Excel(name = "单位")
    private String unit;
    @Excel(name = "批号")
    private String batchNo;
    @Excel(name = "合格库存数量")
    private BigDecimal qualifiedQuantity;
src/main/java/com/ruoyi/stock/execl/StockOutRecordExportData.java
@@ -17,11 +17,13 @@
    private String model;
    @Excel(name = "单位")
    private String unit;
    @Excel(name = "批号")
    private String batchNo;
    @Excel(name = "出库来源")
    private String recordType;
    @Excel(name = "出库数量")
    private String stockInNum;
    @Excel(name = "出库时间")
    private String stockOutNum;
    @Excel(name = "出库时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
src/main/java/com/ruoyi/stock/execl/StockUnInventoryExportData.java
@@ -19,6 +19,8 @@
    @Excel(name = "单位")
    private String unit;
    @Excel(name = "批号")
    private String batchNo;
    @Excel(name = "库存数量")
    private BigDecimal qualitity;
src/main/java/com/ruoyi/stock/mapper/StockInventoryMapper.java
@@ -56,4 +56,6 @@
    List<StockInventory> listSelectableBatchNoByProductModelIds(@Param("productModelIds") List<Long> productModelIds);
    List<StockInventory> getByModelId(@Param("productModelId") Long productModelId);
    IPage<StockInventoryDto> getBatchNoQty(Page page, @Param("ew") StockInventoryDto stockInventoryDto);
}
src/main/java/com/ruoyi/stock/service/StockInventoryService.java
@@ -47,4 +47,6 @@
    Boolean thawStock(StockInventoryDto stockInventoryDto);
    List<StockInventory> getByModelId(Long modelId);
    IPage<StockInventoryDto> getBatchNoQty(Page page, StockInventoryDto stockInventoryDto);
}
src/main/java/com/ruoyi/stock/service/impl/StockInRecordServiceImpl.java
@@ -120,7 +120,7 @@
    public void exportStockInRecord(HttpServletResponse response, StockInRecordDto stockInRecordDto) {
        List<StockInRecordExportData> list = stockInRecordMapper.listStockInRecordExportData(stockInRecordDto);
        for (StockInRecordExportData stockInRecordExportData : list) {
            if (stockInRecordExportData.getType().equals("0")) {
            if (!stockInRecordExportData.getType().equals("0")) {
                stockInRecordExportData.setRecordType(EnumUtil.fromCode(StockOutQualifiedRecordTypeEnum.class, Integer.parseInt(stockInRecordExportData.getRecordType())).getValue());
            }else {
                stockInRecordExportData.setRecordType(EnumUtil.fromCode(StockInQualifiedRecordTypeEnum.class, Integer.parseInt(stockInRecordExportData.getRecordType())).getValue());
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java
@@ -29,7 +29,7 @@
import com.ruoyi.stock.service.StockOutRecordService;
import com.ruoyi.stock.service.StockUninventoryService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@@ -51,15 +51,16 @@
 * @since 2026-01-21 04:16:36
 */
@Service
@AllArgsConstructor
@RequiredArgsConstructor
public class StockInventoryServiceImpl extends ServiceImpl<StockInventoryMapper, StockInventory> implements StockInventoryService {
    private  StockInventoryMapper stockInventoryMapper;
    private StockInRecordService stockInRecordService;
    private StockOutRecordService stockOutRecordService;
    private StockUninventoryService stockUninventoryService;
    private SalesLedgerProductMapper salesLedgerProductMapper;
    private ProductModelMapper productModelMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInRecordService stockInRecordService;
    private final StockOutRecordService stockOutRecordService;
    private final StockUninventoryService stockUninventoryService;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final ProductModelMapper productModelMapper;
    @Override
    public IPage<StockInventoryDto> pagestockInventory(Page page, StockInventoryDto stockInventoryDto) {
        return stockInventoryMapper.pagestockInventory(page, stockInventoryDto);
@@ -336,7 +337,7 @@
                        }
                        stockInventoryDto.setProductModelId(matchedProduct.getProductModelId());
                        this.addstockInventory(stockInventoryDto);
                        this.addStockInRecordOnly(stockInventoryDto);
                        successCount++;
                    }
@@ -433,4 +434,9 @@
    public List<StockInventory> getByModelId(Long modelId) {
        return stockInventoryMapper.getByModelId(modelId);
    }
    @Override
    public IPage<StockInventoryDto> getBatchNoQty(Page page, StockInventoryDto stockInventoryDto) {
        return stockInventoryMapper.getBatchNoQty(page, stockInventoryDto);
    }
}
src/main/resources/application-dev.yml
@@ -260,7 +260,7 @@
  upload-dir: D:/ruoyi/prod/uploads # æ­£å¼ç›®å½• åŽæœŸåˆ é™¤
  path: D:/ruoyi/prod/uploads # ä¸Šä¼ ç›®å½•
  urlPrefix: /common # é“¾æŽ¥å‰ç¼€
  domain: http://127.0.0.1:7005 # åŸŸåå‰ç¼€
  domain: http://127.0.0.1:7006 # åŸŸåå‰ç¼€
  expired: 120 # è¿‡æœŸæ—¶é—´(单位:分钟)
  useLimit: 10 # ä½¿ç”¨æ¬¡æ•°
  compress: true # æ˜¯å¦åŽ‹ç¼©
src/main/resources/logback.xml
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- æ—¥å¿—存放路径 -->
    <property name="log.path" value="/home/ruoyi/logs" />
    <property name="log.path" value="./logs" />
    <!-- æ—¥å¿—输出格式 -->
    <property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
src/main/resources/manufacturing-agent-prompt.txt
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
你是企业制造智能助手,覆盖生产现场、计划、工单、设备、质量、物料、异常处理七个域。
工作规则:
1. ç”¨æˆ·æå‡ºâ€œæŸ¥ã€é—®ã€é¢„警、分析”需求时,优先调用工具拿结构化结果,不要臆造业务数据。
2. ç”¨æˆ·æå‡ºâ€œåŠžâ€éœ€æ±‚æ—¶ï¼Œä¼˜å…ˆè¾“å‡ºåŠžç†å»ºè®®åŠ¨ä½œå¡ï¼ˆæŽ¥å£ã€å¿…å¡«å­—æ®µã€ç¤ºä¾‹ï¼‰ï¼Œæ˜Žç¡®éœ€è¦å‰ç«¯äºŒæ¬¡ç¡®è®¤ã€‚
3. å·¥å…·è¿”回 JSON æ—¶ï¼Œç›´æŽ¥è¾“出原始 JSON å­—符串,不要额外包裹 Markdown,不要在前后加解释文字。
4. å›žç­”必须使用中文;若用户问题缺少时间范围、关键字等条件,可先给默认口径并提示可补充条件。
5. è‹¥æ— æ³•从工具结果得到结论,明确说明缺少的筛选条件或业务字段。
src/main/resources/mapper/basic/CustomerMapper.xml
@@ -26,6 +26,7 @@
        from customer c
        left join sys_user u on c.usage_user = u.user_id
        <where>
            and c.usage_status = 1
            <if test="c.customerName != null and c.customerName != ''">
                and customer_name like concat('%', #{c.customerName}, '%')
            </if>
src/main/resources/mapper/collaborativeApproval/SealApplicationManagementMapper.xml
@@ -4,13 +4,27 @@
    <select id="listPage" resultType="com.ruoyi.collaborativeApproval.dto.SealApplicationManagementDTO">
        select sam.*, su.user_name as create_user_name, d.dept_name as department,
        su1.nick_name as approveUserName
        from seal_application_management sam
        left join sys_user su on sam.create_user = su.user_id
        left join sys_user su1 on sam.approve_user_id = su1.user_id
        left join sys_user_dept sud on su.user_id = sud.user_id
        left join sys_dept d on sud.dept_id = d.dept_id
        SELECT
        sam.*,
        su.user_name AS create_user_name,
        GROUP_CONCAT(DISTINCT d.dept_name ORDER BY d.dept_id SEPARATOR ',') AS department,
        su1.nick_name AS approveUserName
        FROM seal_application_management sam
        LEFT JOIN sys_user su
        ON sam.create_user = su.user_id
        LEFT JOIN sys_user su1
        ON sam.approve_user_id = su1.user_id
        LEFT JOIN sys_user_dept sud
        ON su.user_id = sud.user_id
        LEFT JOIN sys_dept d
        ON sud.dept_id = d.dept_id
        <where>
            <if test="ew.applicationNum != null and ew.applicationNum != ''">
                and sam.application_num like concat('%',#{ew.applicationNum},'%')
@@ -22,5 +36,6 @@
                and sam.status = #{ew.status}
            </if>
        </where>
        GROUP BY sam.id
    </select>
</mapper>
src/main/resources/mapper/device/DeviceRepairMapper.xml
@@ -14,6 +14,9 @@
                dr.maintenance_name,
                dr.maintenance_time,
                dr.maintenance_result,
                 dr.acceptance_name,
                 dr.acceptance_time,
                 dr.acceptance_remark,
                dr.status,
                dr.create_time,
                dr.update_time,
@@ -60,6 +63,9 @@
               dr.maintenance_name,
               dr.maintenance_time,
               dr.maintenance_result,
               dr.acceptance_name,
               dr.acceptance_time,
               dr.acceptance_remark,
               dr.status,
               dr.create_time,
               dr.update_time,
src/main/resources/mapper/measuringinstrumentledger/MeasuringInstrumentLedgerMapper.xml
@@ -15,7 +15,9 @@
        next_date,
        record_date,
        CASE
        WHEN most_date &gt;=  DATE_FORMAT(now(),'%Y-%m-%d') THEN 1
        WHEN most_date IS NOT NULL
        AND valid IS NOT NULL
        AND DATE_ADD(most_date, INTERVAL valid DAY) &gt;= CURDATE() THEN 1
        ELSE 2
        END AS status,
        create_user,
@@ -40,10 +42,16 @@
            <if test="req.status != null">
                <choose>
                    <when test="req.status == 1">
                        AND most_date &gt;=  DATE_FORMAT(now(),'%Y-%m-%d')
                        AND most_date IS NOT NULL
                        AND valid IS NOT NULL
                        AND DATE_ADD(most_date, INTERVAL valid DAY) &gt;= CURDATE()
                    </when>
                    <when test="req.status == 2">
                        AND most_date &lt;  DATE_FORMAT(now(),'%Y-%m-%d')
                        AND (
                        most_date IS NULL
                        OR valid IS NULL
                        OR DATE_ADD(most_date, INTERVAL valid DAY) &lt; CURDATE()
                        )
                    </when>
                </choose>
            </if>
src/main/resources/mapper/procurementrecord/ReturnSaleProductMapper.xml
@@ -19,7 +19,7 @@
            sor.outbound_batches,
            sor.stock_out_num,
            sor.batch_no,
               GREATEST(sor.stock_out_num - COALESCE(rsp.num, 0), 0) AS un_quantity,
               GREATEST(sor.stock_out_num - COALESCE(rs1.total_return_num1, 0), 0) AS un_quantity,
               COALESCE(rs.total_return_num, 0)                             AS total_return_num
        FROM return_sale_product rsp
                 LEFT JOIN return_management rm ON rm.id = rsp.return_management_id
@@ -34,6 +34,11 @@
                            FROM return_sale_product
                            WHERE 1 = 1 and return_management_id = #{returnManagementId}
                            GROUP BY stock_out_record_id) rs ON rs.stock_out_record_id = sor.id
                 LEFT JOIN (SELECT stock_out_record_id,
                                   SUM(num) AS total_return_num1
                            FROM return_sale_product
                            WHERE 1 = 1
                            GROUP BY stock_out_record_id) rs1 ON rs1.stock_out_record_id = sor.id
        where rm.id =#{returnManagementId}
    </select>
    <select id="listReturnSaleProduct" resultType="com.ruoyi.procurementrecord.bean.dto.ReturnSaleProductDto">
src/main/resources/mapper/production/ProductionOperationTaskMapper.xml
@@ -159,6 +159,7 @@
    <select id="getOperation" resultType="com.ruoyi.production.bean.vo.ProductionOperationTaskVo">
        select poro.operation_name as operationName,
               max(poro.type) as type,
               count(pot.id) as productionTaskCount,
               sum(ifnull(pot.plan_quantity, 0)) as planQuantity,
               sum(ifnull(pot.complete_quantity, 0)) as completeQuantity,
src/main/resources/mapper/purchase/PurchaseReturnOrdersMapper.xml
@@ -132,7 +132,7 @@
           slp.tax_inclusive_unit_price,
           prop.return_quantity,
           prop.purchase_return_order_id,
           GREATEST(sir.stock_in_num - COALESCE(prop.return_quantity, 0), 0) AS un_quantity,
           GREATEST(sir.stock_in_num - COALESCE(rs1.total_return_num1, 0), 0) AS un_quantity,
           COALESCE(rs.total_return_num, 0)                             AS total_return_num
    from purchase_return_order_products prop
    left join purchase_return_orders pro on prop.purchase_return_order_id = pro.id
@@ -143,6 +143,11 @@
               FROM purchase_return_order_products
               WHERE 1 = 1 and purchase_return_order_id = #{id}
               GROUP BY stock_in_record_id) rs ON rs.stock_in_record_id = sir.id
    LEFT JOIN (SELECT stock_in_record_id,
                      SUM(return_quantity) AS total_return_num1
               FROM purchase_return_order_products
               WHERE 1 = 1 and purchase_return_order_id = #{id}
               GROUP BY stock_in_record_id) rs1 ON rs1.stock_in_record_id = sir.id
    where pro.id = #{id}
    </select>
</mapper>
src/main/resources/mapper/sales/SalesLedgerProductMapper.xml
@@ -48,7 +48,8 @@
        END as has_sufficient_stock,
        (IFNULL(T1.quantity, 0) - IFNULL(t3.shipped_quantity, 0)) as no_quantity,
        CASE
        WHEN (IFNULL(T1.quantity, 0) - IFNULL(t3.shipped_quantity, 0)) > 0 THEN '待发货'
         WHEN IFNULL(t3.shipped_quantity, 0) = 0 THEN '待发货'
         WHEN (IFNULL(T1.quantity, 0) - IFNULL(t3.shipped_quantity, 0)) > 0 THEN '部分发货'
        ELSE '已发货'
        END as shippingStatus
        FROM
@@ -62,6 +63,7 @@
        SELECT sales_ledger_product_id, IFNULL(SUM(spd.quantity), 0) as shipped_quantity
        FROM shipping_info si
        LEFT JOIN shipping_product_detail spd ON si.id = spd.shipping_info_id
        where si.status != '审核拒绝'
        GROUP BY sales_ledger_product_id
        ) t3 ON t3.sales_ledger_product_id = T1.id
        left join product_model pm ON T1.product_model_id = pm.id
src/main/resources/mapper/sales/SalesQuotationMapper.xml
src/main/resources/mapper/stock/StockInventoryMapper.xml
@@ -99,6 +99,138 @@
        INNER JOIN product_tree pt ON p.parent_id = pt.id
        )
        select
        GROUP_CONCAT(DISTINCT batch_no ORDER BY batch_no SEPARATOR ',') as batch_no,
        MAX(qualifiedId) as qualifiedId,
        MAX(unQualifiedId) as unQualifiedId,
        SUM(qualifiedQuantity) as qualifiedQuantity,
        SUM(unQualifiedQuantity) as unQualifiedQuantity,
        SUM(qualifiedLockedQuantity) as qualifiedLockedQuantity,
        SUM(unQualifiedLockedQuantity) as unQualifiedLockedQuantity,
        SUM(qualifiedQuantity - qualifiedLockedQuantity - IFNULL(qualifiedPendingOut, 0)) as qualifiedUnLockedQuantity,
        SUM(unQualifiedQuantity - unQualifiedLockedQuantity - IFNULL(unQualifiedPendingOut, 0)) as unQualifiedUnLockedQuantity,
        SUM(IFNULL(qualifiedPendingOut, 0)) as qualifiedPendingOutQuantity,
        SUM(IFNULL(unQualifiedPendingOut, 0)) as unQualifiedPendingOutQuantity,
        product_model_id,
        MAX(create_time) as create_time,
        MAX(update_time) as update_time,
        MAX(warn_num) as warn_num,
        MAX(version) as version,
        model,
        MAX(remark) as remark,
        unit,
        product_name,
        product_id,
        'combined' as stockType
        from (
        select
        si.batch_no,
        si.id as qualifiedId,
        null as unQualifiedId,
        si.qualitity as qualifiedQuantity,
        0 as unQualifiedQuantity,
        COALESCE(si.locked_quantity, 0) as locked_quantity,
        COALESCE(si.locked_quantity, 0) as qualifiedLockedQuantity,
        0 as unQualifiedLockedQuantity,
        si.product_model_id,
        si.create_time,
        si.update_time,
        COALESCE(si.warn_num, 0) as warn_num,
        si.version,
        (si.qualitity - COALESCE(si.locked_quantity, 0)) as un_locked_quantity,
        pm.model,
        si.remark,
        pm.unit,
        p.product_name,
        p.id as product_id,
        (
        select IFNULL(SUM(sor.stock_out_num), 0)
        from stock_out_record sor
        where sor.product_model_id = si.product_model_id
        and (
        (si.batch_no is null and sor.batch_no is null)
        or si.batch_no = sor.batch_no
        )
        and sor.type = '0'
        and sor.approval_status = 0
        ) as qualifiedPendingOut,
        0 as unQualifiedPendingOut
        from stock_inventory si
        left join product_model pm on si.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        union all
        select
        su.batch_no,
        null as qualifiedId,
        su.id as unQualifiedId,
        0 as qualifiedQuantity,
        su.qualitity as unQualifiedQuantity,
        COALESCE(su.locked_quantity, 0) as locked_quantity,
        0 as qualifiedLockedQuantity,
        COALESCE(su.locked_quantity, 0) as unQualifiedLockedQuantity,
        su.product_model_id,
        su.create_time,
        su.update_time,
        0 as warn_num,
        su.version,
        (su.qualitity - COALESCE(su.locked_quantity, 0)) as un_locked_quantity,
        pm.model,
        su.remark,
        pm.unit,
        p.product_name,
        p.id as product_id,
        0 as qualifiedPendingOut,
        (
        select IFNULL(SUM(sor.stock_out_num), 0)
        from stock_out_record sor
        where sor.product_model_id = su.product_model_id
        and (
        (su.batch_no is null and sor.batch_no is null)
        or su.batch_no = sor.batch_no
        )
        and sor.type = '1'
        and sor.approval_status = 0
        ) as unQualifiedPendingOut
        from stock_uninventory su
        left join product_model pm on su.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        ) as combined
        <where>
            <if test="ew.productName != null and ew.productName !=''">
                and combined.product_name in (
                select distinct p.product_name
                from product p
                left join product_model pm on p.id = pm.product_id
                where p.product_name like concat('%',#{ew.productName},'%')
                or pm.model like concat('%',#{ew.productName},'%')
                )
            </if>
            <if test="ew.topParentProductId != null and ew.topParentProductId > 0">
                and combined.product_id in (select id from product_tree)
            </if>
        </where>
        group by
        product_model_id,
        model,
        unit,
        product_name,
        product_id
    </select>
    <select id="listStockInventoryExportData" resultType="com.ruoyi.stock.execl.StockInventoryExportData">
        WITH RECURSIVE product_tree AS (
        SELECT id
        FROM product
        WHERE id = #{ew.topParentProductId}
        UNION ALL
        SELECT p.id
        FROM product p
        INNER JOIN product_tree pt ON p.parent_id = pt.id
        )
        select
            batch_no,
            MAX(qualifiedId) as qualifiedId,
            MAX(unQualifiedId) as unQualifiedId,
@@ -204,84 +336,6 @@
            </if>
        </where>
        group by batch_no, product_model_id, model, unit, product_name, product_id
    </select>
    <select id="listStockInventoryExportData" resultType="com.ruoyi.stock.execl.StockInventoryExportData">
        WITH RECURSIVE product_tree AS (
        SELECT id
        FROM product
        WHERE id = #{ew.topParentProductId}
        UNION ALL
        SELECT p.id
        FROM product p
        INNER JOIN product_tree pt ON p.parent_id = pt.id
        )
        select
            SUM(qualifiedQuantity) as qualifiedQuantity,
            SUM(unQualifiedQuantity) as unQualifiedQuantity,
            SUM(qualifiedLockedQuantity) as qualifiedLockedQuantity,
            SUM(unQualifiedLockedQuantity) as unQualifiedLockedQuantity,
            model,
            unit,
            product_name,
            MAX(warn_num) as warn_num,
            MAX(remark) as remark,
            MAX(update_time) as update_time
        from (
            select
            si.qualitity as qualifiedQuantity,
            0 as unQualifiedQuantity,
            COALESCE(si.locked_quantity, 0) as qualifiedLockedQuantity,
            0 as unQualifiedLockedQuantity,
            si.product_model_id,
            si.create_time,
            si.update_time,
            COALESCE(si.warn_num, 0) as warn_num,
            si.remark,
            pm.model,
            pm.unit,
            p.product_name,
            p.id as product_id
            from stock_inventory si
            left join product_model pm on si.product_model_id = pm.id
            left join product p on pm.product_id = p.id
            union all
            select
            0 as qualifiedQuantity,
            su.qualitity as unQualifiedQuantity,
            0 as qualifiedLockedQuantity,
            COALESCE(su.locked_quantity, 0) as unQualifiedLockedQuantity,
            su.product_model_id,
            su.create_time,
            su.update_time,
            0 as warn_num,
            su.remark,
            pm.model,
            pm.unit,
            p.product_name,
            p.id as product_id
            from stock_uninventory su
            left join product_model pm on su.product_model_id = pm.id
            left join product p on pm.product_id = p.id
        ) as combined
        <where>
            <if test="ew.productName != null and ew.productName !=''">
                and combined.product_name in (
                select distinct p.product_name
                from product p
                left join product_model pm on p.id = pm.product_id
                where p.product_name like concat('%',#{ew.productName},'%') or pm.model like concat('%',#{ew.productName},'%')
                )
            </if>
            <if test="ew.topParentProductId != null and ew.topParentProductId > 0">
                and combined.product_id in (select id from product_tree)
            </if>
        </where>
        group by product_model_id, model, unit, product_name
    </select>
    <select id="stockInventoryPage" resultType="com.ruoyi.stock.dto.StockInRecordDto">
        select sir.*,si.qualitity as current_stock,
@@ -471,6 +525,141 @@
                    group by spd.stock_inventory_id
                 ) as sd on sd.stock_inventory_id = si.id
        where si.product_model_id = #{productModelId}
        and si.qualitity > IFNULL(sd.qualitity, 0)
    </select>
    <select id="getBatchNoQty" resultType="com.ruoyi.stock.dto.StockInventoryDto">
        select
        batch_no,
        MAX(qualifiedId) as qualifiedId,
        MAX(unQualifiedId) as unQualifiedId,
        SUM(qualifiedQuantity) as qualifiedQuantity,
        SUM(unQualifiedQuantity) as unQualifiedQuantity,
        SUM(qualifiedLockedQuantity) as qualifiedLockedQuantity,
        SUM(unQualifiedLockedQuantity) as unQualifiedLockedQuantity,
        SUM(IFNULL(qualifiedPendingOut, 0)) as qualifiedPendingOutQuantity,
        SUM(IFNULL(unQualifiedPendingOut, 0)) as unQualifiedPendingOutQuantity,
        SUM(qualifiedQuantity - qualifiedLockedQuantity - IFNULL(qualifiedPendingOut, 0)) as qualifiedUnLockedQuantity,
        SUM(unQualifiedQuantity - unQualifiedLockedQuantity - IFNULL(unQualifiedPendingOut, 0)) as unQualifiedUnLockedQuantity,
        product_model_id,
        model,
        unit,
        product_name,
        product_id,
        MAX(create_time) as create_time,
        MAX(update_time) as update_time,
        MAX(warn_num) as warn_num,
        MAX(version) as version,
        MAX(remark) as remark,
        'combined' as stockType
        from (
        select
        si.batch_no,
        si.id as qualifiedId,
        null as unQualifiedId,
        si.qualitity as qualifiedQuantity,
        0 as unQualifiedQuantity,
        COALESCE(si.locked_quantity, 0) as qualifiedLockedQuantity,
        0 as unQualifiedLockedQuantity,
        si.product_model_id,
        pm.model,
        pm.unit,
        p.product_name,
        p.id as product_id,
        si.create_time,
        si.update_time,
        COALESCE(si.warn_num, 0) as warn_num,
        si.version,
        si.remark,
        (
        select IFNULL(SUM(sor.stock_out_num), 0)
        from stock_out_record sor
        where sor.product_model_id = si.product_model_id
        and (
        (si.batch_no is null and sor.batch_no is null)
        or si.batch_no = sor.batch_no
        )
        and sor.type = '0'
        and sor.approval_status = 0
        ) as qualifiedPendingOut,
        0 as unQualifiedPendingOut
        from stock_inventory si
        left join product_model pm on si.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        union all
        select
        su.batch_no,
        null as qualifiedId,
        su.id as unQualifiedId,
        0 as qualifiedQuantity,
        su.qualitity as unQualifiedQuantity,
        0 as qualifiedLockedQuantity,
        COALESCE(su.locked_quantity, 0) as unQualifiedLockedQuantity,
        su.product_model_id,
        pm.model,
        pm.unit,
        p.product_name,
        p.id as product_id,
        su.create_time,
        su.update_time,
        0 as warn_num,
        su.version,
        su.remark,
        0 as qualifiedPendingOut,
        (
        select IFNULL(SUM(sor.stock_out_num), 0)
        from stock_out_record sor
        where sor.product_model_id = su.product_model_id
        and (
        (su.batch_no is null and sor.batch_no is null)
        or su.batch_no = sor.batch_no
        )
        and sor.type = '1'
        and sor.approval_status = 0
        ) as unQualifiedPendingOut
        from stock_uninventory su
        left join product_model pm on su.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        ) as combined
        <where>
            <if test="ew.productModelId != null and ew.productModelId > 0">
                and combined.product_model_id = #{ew.productModelId}
            </if>
            <if test="ew.productId != null and ew.productId > 0">
                and combined.product_id = #{ew.productId}
            </if>
        </where>
        group by
        batch_no,
        product_model_id,
        model,
        unit,
        product_name,
        product_id
        order by
        batch_no
    </select>
</mapper>