已添加2个文件
已修改16个文件
1015 ■■■■■ 文件已修改
doc/20260508_采购多文件分析附件存储与历史回显联调说明.md 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/AccountSubjectController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/dto/AiChatMessageDto.java 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/mongodbBean/ChatMessages.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/impl/AiChatSessionServiceImpl.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/store/MongoChatMemoryStore.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/enums/StockInQualifiedRecordTypeEnum.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/enums/StockOutQualifiedRecordTypeEnum.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java 243 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionPlanServiceImpl.java 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/dto/StaffLeaveDto.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev-pro.yml 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev.yml 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-wtxxjc.yml 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/staff/StaffLeaveMapper.xml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/staff/StaffOnJobMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260508_²É¹º¶àÎļþ·ÖÎö¸½¼þ´æ´¢ÓëÀúÊ·»ØÏÔÁªµ÷˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,149 @@
# é‡‡è´­å¤šæ–‡ä»¶åˆ†æžé™„件存储与历史回显联调说明
> æ›´æ–°æ—¶é—´ï¼š2026-05-08
> é€‚用范围:采购智能体多文件分析 + åŽ†å²ä¼šè¯é™„ä»¶å›žæ˜¾
## 1. å˜æ›´èƒŒæ™¯
后端已补齐以下能力:
1. `POST /purchase-ai/analyze-files` ä¸Šä¼ æ—¶å…ˆå­˜é™„件到服务器(公共访问)。
2. æŒ‰æ–‡ä»¶ç±»åž‹è¿”回可访问路径:
   - å›¾ç‰‡ / PDF:优先预览路径
   - å…¶å®ƒæ–‡ä»¶ï¼šä¼˜å…ˆä¸‹è½½è·¯å¾„
3. ç”¨æˆ·æé—®ä¸Žé™„件路径分开存入 Mongo。
4. åŽ†å²æ¶ˆæ¯æŽ¥å£å¯æŒ‰â€œæ¶ˆæ¯ç»´åº¦â€å›žä¼ é™„ä»¶è·¯å¾„ï¼Œå‰ç«¯å¯ç›´æŽ¥å›žæ˜¾é™„ä»¶å¡ç‰‡ã€‚
---
## 2. æŽ¥å£è¡Œä¸º
### 2.1 å¤šæ–‡ä»¶åˆ†æžæŽ¥å£
```http
POST /purchase-ai/analyze-files
Content-Type: multipart/form-data
```
请求参数:
- `files`: `MultipartFile[]`(必填)
- `message`: `string`(可选)
- `memoryId`: `string`(可选)
后端处理流程:
1. è°ƒç”¨ `StorageBlobService.upload(files, true)` ä¸Šä¼ å¹¶ä¿å­˜é™„件。
2. ç”Ÿæˆé™„件访问路径(预览 / ä¸‹è½½ï¼‰ã€‚
3. å°†â€œæœ¬æ¬¡æé—® + æœ¬æ¬¡é™„件路径列表”写入 Mongo。
4. ç»§ç»­æ‰§è¡ŒåŽŸæœ‰æ–‡ä»¶è§£æžå’Œ AI åˆ†æžæµç¨‹ã€‚
可能错误(流式文本):
- `文件上传失败`
- `会话文件信息保存失败`
### 2.2 åŽ†å²æ¶ˆæ¯æŽ¥å£ï¼ˆå‰ç«¯é‡ç‚¹ï¼‰
```http
GET /purchase-ai/history/messages/{memoryId}
```
消息对象新增可选字段 `filePaths`(仅用户消息可能有值):
```json
{
  "role": "user | assistant | system | tool | unknown",
  "content": "消息文本",
  "filePaths": [
    "/common/preview/xxx?publicKey=...",
    "/common/download/yyy?publicKey=..."
  ]
}
```
说明:
1. `filePaths` å¯èƒ½ç¼ºå¤±æˆ–为空(老会话 / æ™®é€šå¯¹è¯ï¼‰ã€‚
2. å•条用户消息可能包含多个附件路径。
3. è¯¥å­—段已按消息维度对齐,可直接用于历史回显。
---
## 3. å‰ç«¯æ”¹é€ å»ºè®®ï¼ˆæŒ‰å½“前实现落地)
### 3.1 åŽ†å²å“åº”æ¨¡åž‹
建议在历史消息原始结构中保留 `filePaths`:
```ts
type AiHistoryMessage = {
  role: string;
  content: string;
  filePaths?: string[];
};
```
### 3.2 UI æ¶ˆæ¯æ¨¡åž‹æ˜ å°„
若前端页面用的是 `localUploadFiles` æ¸²æŸ“附件卡片(如 `AIChatSidebar` å½“前实现),历史消息需做一次映射:
```ts
type LocalUploadFileItem = {
  previewId: string;
  name: string;
  size: number;
  type: string;
  isImage: boolean;
  previewUrl: string;
  rawFile: null;
};
```
映射规则建议:
1. ä»… `role === 'user'` ä¸” `filePaths?.length > 0` æ—¶ç”Ÿæˆ `localUploadFiles`。
2. `previewId` ç”¨ `${memoryId}-${messageIndex}-${fileIndex}` ç”Ÿæˆç¨³å®šå€¼ã€‚
3. `name` å¯ä»Ž URL è·¯å¾„解析;解析失败用 `file-{n}`。
4. `isImage` å¯æŒ‰æ‰©å±•名判断(`png/jpg/jpeg/gif/webp/bmp/svg`)。
5. å›¾ç‰‡çš„ `previewUrl` ç›´æŽ¥ä½¿ç”¨è·¯å¾„;非图片可置空并走图标展示。
### 3.3 æ¶ˆæ¯æ¸²æŸ“
对于用户消息:
1. æ­£å¸¸æ¸²æŸ“ `content`。
2. è‹¥å­˜åœ¨ `localUploadFiles`(或直接使用 `filePaths`),在消息下方渲染附件列表/卡片。
3. é“¾æŽ¥ç›´æŽ¥ä½¿ç”¨åŽç«¯è¿”回路径,不再拼接或二次改写。
> åŽç«¯å·²å®Œæˆâ€œé¢„览/下载路径”选择,前端只负责展示与打开。
### 3.4 å…¼å®¹è¦æ±‚
- `filePaths` ç¼ºå¤±ï¼šä¸æŠ¥é”™ï¼Œä¸æ¸²æŸ“附件区域。
- è€ä¼šè¯ï¼šç»§ç»­æŒ‰ `role/content` å±•示,不影响历史记录查看。
- å¤šé™„件:保持顺序展示,避免打乱用户上传顺序。
---
## 4. Mongo å­—段说明(后端已实现)
`chat_messages` æ–‡æ¡£æ–°å¢ž/使用字段:
- `analyzeUserQuestions: string[]`
- `analyzeFilePaths: string[]`(兼容旧字段)
- `analyzeFilePathGroups: string[][]`(推荐,按提问分组)
历史消息回传时,后端优先读取 `analyzeFilePathGroups`,并兼容 `analyzeFilePaths`。
---
## 5. è”调验收清单
1. æ–°å»ºä¼šè¯ï¼Œä¸Šä¼  1 å¼ å›¾ç‰‡ + 1 ä¸ª Excel,发送分析请求。
2. è°ƒç”¨ `GET /purchase-ai/history/messages/{memoryId}`,确认用户消息含 `filePaths` ä¸”数量正确。
3. åˆ·æ–°é¡µé¢åŽé‡æ–°è¿›å…¥åŒä¸€ä¼šè¯ï¼Œç¡®è®¤é™„件卡片可回显。
4. åˆ†åˆ«éªŒè¯ï¼š
   - å›¾ç‰‡/PDF å¯é¢„览访问
   - å…¶å®ƒæ–‡ä»¶å¯ä¸‹è½½è®¿é—®
5. éªŒè¯è€ä¼šè¯ï¼ˆæ—  `filePaths`)可正常展示,页面不报错。
src/main/java/com/ruoyi/account/controller/AccountSubjectController.java
@@ -31,7 +31,7 @@
public class AccountSubjectController {
    private final AccountSubjectService accountSubjectService;
    @GetMapping("list")
    @GetMapping("/list")
    @Log(title = "总账科目数据集合", businessType = BusinessType.OTHER)
    @Operation(summary = "总账科目分页查询")
    public R<IPage<AccountSubjectVo>> AccountSubjectDtoList(Page<AccountSubjectDto> page, AccountSubjectDto accountSubjectDto) {
src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java
@@ -11,8 +11,10 @@
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.service.AiFileTextExtractor;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.basic.dto.StorageBlobVO;
import com.ruoyi.basic.mapper.SupplierManageMapper;
import com.ruoyi.basic.pojo.SupplierManage;
import com.ruoyi.basic.service.StorageBlobService;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
@@ -50,10 +52,14 @@
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Base64;
import java.util.Arrays;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@@ -63,9 +69,11 @@
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.nio.file.Files;
@Tag(name = "采购智能体")
@RestController
@@ -87,6 +95,7 @@
    private final IPurchaseLedgerService purchaseLedgerService;
    private final IPaymentRegistrationService paymentRegistrationService;
    private final PurchaseReturnOrdersService purchaseReturnOrdersService;
    private final StorageBlobService storageBlobService;
    private final SupplierManageMapper supplierManageMapper;
    private final StreamingChatLanguageModel purchaseVisionStreamingChatModel;
@@ -96,12 +105,13 @@
                                MongoChatMemoryStore mongoChatMemoryStore,
                                AiChatSessionService aiChatSessionService,
                                AiFileTextExtractor aiFileTextExtractor,
                                ObjectMapper objectMapper,
                                IPurchaseLedgerService purchaseLedgerService,
                                IPaymentRegistrationService paymentRegistrationService,
                                PurchaseReturnOrdersService purchaseReturnOrdersService,
                                SupplierManageMapper supplierManageMapper,
                                @Qualifier("purchaseVisionStreamingChatModel") StreamingChatLanguageModel purchaseVisionStreamingChatModel) {
                                 ObjectMapper objectMapper,
                                 IPurchaseLedgerService purchaseLedgerService,
                                 IPaymentRegistrationService paymentRegistrationService,
                                 PurchaseReturnOrdersService purchaseReturnOrdersService,
                                 StorageBlobService storageBlobService,
                                 SupplierManageMapper supplierManageMapper,
                                 @Qualifier("purchaseVisionStreamingChatModel") StreamingChatLanguageModel purchaseVisionStreamingChatModel) {
        this.purchaseAgent = purchaseAgent;
        this.purchaseIntentExecutor = purchaseIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
@@ -112,6 +122,7 @@
        this.purchaseLedgerService = purchaseLedgerService;
        this.paymentRegistrationService = paymentRegistrationService;
        this.purchaseReturnOrdersService = purchaseReturnOrdersService;
        this.storageBlobService = storageBlobService;
        this.supplierManageMapper = supplierManageMapper;
        this.purchaseVisionStreamingChatModel = purchaseVisionStreamingChatModel;
    }
@@ -172,6 +183,19 @@
                ? message
                : "请分析这些采购文件,提取可用于业务处理的数据,并整理成待客户确认的格式";
        List<String> filePaths;
        try {
            List<StorageBlobVO> uploadedFiles = storageBlobService.upload(copyFilesForUpload(files), true);
            filePaths = resolveFileAccessPaths(uploadedFiles);
        } catch (Exception ex) {
            return Flux.just("文件上传失败");
        }
        try {
            mongoChatMemoryStore.appendAnalyzeFileContext(finalMemoryId, finalMessage, filePaths);
        } catch (Exception ex) {
            return Flux.just("会话文件信息保存失败");
        }
        String fileContent;
        try {
            fileContent = buildMultiFileContent(files);
@@ -189,7 +213,7 @@
        aiChatSessionService.touchSession(finalMemoryId, loginUser, "采购多文件分析: " + finalMessage);
        if (containsImageFile(files)) {
            return chatWithPurchaseVisionModel(finalMemoryId, userPrompt, files)
            return chatWithPurchaseVisionModel(finalMemoryId, finalMessage, userPrompt, files)
                    .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                    .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
        }
@@ -282,8 +306,121 @@
        return false;
    }
    private Flux<String> chatWithPurchaseVisionModel(String memoryId, String userPrompt, MultipartFile[] files) {
    private List<String> resolveFileAccessPaths(List<StorageBlobVO> uploadedFiles) {
        if (StringUtils.isEmpty(uploadedFiles)) {
            return Collections.emptyList();
        }
        List<String> filePaths = new ArrayList<>();
        for (StorageBlobVO uploadedFile : uploadedFiles) {
            if (uploadedFile == null) {
                continue;
            }
            String selectedPath;
            if (shouldUsePreviewPath(uploadedFile)) {
                selectedPath = StringUtils.hasText(uploadedFile.getPreviewURL())
                        ? uploadedFile.getPreviewURL()
                        : uploadedFile.getDownloadURL();
            } else {
                selectedPath = StringUtils.hasText(uploadedFile.getDownloadURL())
                        ? uploadedFile.getDownloadURL()
                        : uploadedFile.getPreviewURL();
            }
            if (StringUtils.hasText(selectedPath)) {
                filePaths.add(selectedPath);
            }
        }
        return filePaths;
    }
    private boolean shouldUsePreviewPath(StorageBlobVO uploadedFile) {
        String contentType = uploadedFile.getContentType();
        if (StringUtils.hasText(contentType)) {
            String normalized = contentType.toLowerCase(Locale.ROOT);
            if (normalized.startsWith("image/") || "application/pdf".equals(normalized)) {
                return true;
            }
        }
        String filename = uploadedFile.getOriginalFilename();
        if (!StringUtils.hasText(filename) || !filename.contains(".")) {
            return false;
        }
        String ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
        return StringUtils.inStringIgnoreCase(ext, "png", "jpg", "jpeg", "webp", "bmp", "pdf");
    }
    private List<MultipartFile> copyFilesForUpload(MultipartFile[] files) throws IOException {
        List<MultipartFile> copies = new ArrayList<>();
        for (MultipartFile file : files) {
            copies.add(new InMemoryMultipartFile(
                    file.getName(),
                    file.getOriginalFilename(),
                    file.getContentType(),
                    file.getBytes()
            ));
        }
        return copies;
    }
    private static final class InMemoryMultipartFile implements MultipartFile {
        private final String name;
        private final String originalFilename;
        private final String contentType;
        private final byte[] bytes;
        private InMemoryMultipartFile(String name, String originalFilename, String contentType, byte[] bytes) {
            this.name = name;
            this.originalFilename = originalFilename;
            this.contentType = contentType;
            this.bytes = bytes == null ? new byte[0] : bytes;
        }
        @Override
        public String getName() {
            return name;
        }
        @Override
        public String getOriginalFilename() {
            return originalFilename;
        }
        @Override
        public String getContentType() {
            return contentType;
        }
        @Override
        public boolean isEmpty() {
            return bytes.length == 0;
        }
        @Override
        public long getSize() {
            return bytes.length;
        }
        @Override
        public byte[] getBytes() {
            return bytes.clone();
        }
        @Override
        public InputStream getInputStream() {
            return new ByteArrayInputStream(bytes);
        }
        @Override
        public void transferTo(File dest) throws IOException, IllegalStateException {
            Files.write(dest.toPath(), bytes);
        }
    }
    private Flux<String> chatWithPurchaseVisionModel(String memoryId,
                                                     String userMessage,
                                                     String userPrompt,
                                                     MultipartFile[] files) {
        return Flux.create(sink -> {
            StringBuilder assistantReply = new StringBuilder();
            try {
                List<Content> contents = new ArrayList<>();
                contents.add(TextContent.from(userPrompt));
@@ -302,14 +439,21 @@
                        SystemMessage.from("你是采购业务文件分析助手。请从文本和图片中识别采购台账、采购产品明细、付款或退货信息,只输出合法 JSON。"),
                        UserMessage.from(contents)
                );
                safeAppendMessages(memoryId, List.of(UserMessage.from("采购多文件分析: " + userMessage)));
                purchaseVisionStreamingChatModel.chat(messages, new StreamingChatResponseHandler() {
                    @Override
                    public void onPartialResponse(String partialResponse) {
                        sink.next(partialResponse);
                        if (partialResponse != null) {
                            assistantReply.append(partialResponse);
                            sink.next(partialResponse);
                        }
                    }
                    @Override
                    public void onCompleteResponse(ChatResponse completeResponse) {
                        if (StringUtils.hasText(assistantReply.toString())) {
                            safeAppendMessages(memoryId, List.of(AiMessage.from(assistantReply.toString())));
                        }
                        sink.complete();
                    }
@@ -325,6 +469,16 @@
        });
    }
    private void safeAppendMessages(String memoryId, List<ChatMessage> messages) {
        if (!StringUtils.hasText(memoryId) || StringUtils.isEmpty(messages)) {
            return;
        }
        try {
            mongoChatMemoryStore.appendMessages(memoryId, messages);
        } catch (Exception ignored) {
        }
    }
    private String resolveImageMimeType(MultipartFile file) {
        String contentType = file.getContentType();
        if (StringUtils.hasText(contentType) && contentType.startsWith("image/")) {
src/main/java/com/ruoyi/ai/dto/AiChatMessageDto.java
@@ -1,15 +1,28 @@
package com.ruoyi.ai.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AiChatMessageDto {
    private String role;
    private String content;
    private List<String> filePaths;
    public AiChatMessageDto(String role, String content) {
        this.role = role;
        this.content = content;
    }
    public AiChatMessageDto(String role, String content, List<String> filePaths) {
        this.role = role;
        this.content = content;
        this.filePaths = filePaths;
    }
}
src/main/java/com/ruoyi/ai/mongodbBean/ChatMessages.java
@@ -9,6 +9,7 @@
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.Date;
import java.util.List;
@Data
@AllArgsConstructor
@@ -24,6 +25,21 @@
    private String content;
    /**
     * å¤šæ–‡ä»¶åˆ†æžç”¨æˆ·æé—®ä¿¡æ¯ï¼ˆä¸Žæ–‡ä»¶è·¯å¾„分开存储)
     */
    private List<String> analyzeUserQuestions;
    /**
     * å¤šæ–‡ä»¶åˆ†æžä¸Šä¼ æ–‡ä»¶è·¯å¾„(图片和 pdf ä½¿ç”¨é¢„览地址,其他使用下载地址)
     */
    private List<String> analyzeFilePaths;
    /**
     * å¤šæ–‡ä»¶åˆ†æžæ¯æ¬¡æé—®å¯¹åº”的文件路径分组
     */
    private List<List<String>> analyzeFilePathGroups;
    private Date createTime;
    private Date updateTime;
src/main/java/com/ruoyi/ai/service/impl/AiChatSessionServiceImpl.java
@@ -114,7 +114,10 @@
            return new LinkedList<>();
        }
        List<ChatMessage> messages = mongoChatMemoryStore.getMessages(memoryId);
        return messages.stream().map(this::convertMessage).collect(Collectors.toList());
        List<AiChatMessageDto> messageDtos = messages.stream().map(this::convertMessage).collect(Collectors.toList());
        List<List<String>> analyzeFilePathGroups = mongoChatMemoryStore.getAnalyzeFilePathGroups(memoryId);
        attachAnalyzeFilePaths(messageDtos, analyzeFilePathGroups);
        return messageDtos;
    }
    @Override
@@ -188,4 +191,22 @@
        }
        return new AiChatMessageDto("unknown", String.valueOf(message));
    }
    private void attachAnalyzeFilePaths(List<AiChatMessageDto> messages,
                                        List<List<String>> analyzeFilePathGroups) {
        if (StringUtils.isEmpty(messages) || StringUtils.isEmpty(analyzeFilePathGroups)) {
            return;
        }
        int analyzeIndex = 0;
        for (AiChatMessageDto message : messages) {
            if (!"user".equals(message.getRole()) || analyzeIndex >= analyzeFilePathGroups.size()) {
                continue;
            }
            List<String> filePaths = analyzeFilePathGroups.get(analyzeIndex);
            if (!StringUtils.isEmpty(filePaths)) {
                message.setFilePaths(filePaths);
            }
            analyzeIndex++;
        }
    }
}
src/main/java/com/ruoyi/ai/store/MongoChatMemoryStore.java
@@ -11,6 +11,8 @@
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.LinkedList;
@@ -24,8 +26,7 @@
    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdString(memoryId)));
        ChatMessages chatMessages = mongoTemplate.findOne(query, ChatMessages.class);
        ChatMessages chatMessages = findChatMessages(memoryId);
        if (chatMessages == null || chatMessages.getContent() == null) {
            return new LinkedList<>();
        }
@@ -56,7 +57,75 @@
        updateMessages(memoryId, messages);
    }
    public void appendAnalyzeFileContext(Object memoryId, String userQuestion, List<String> filePaths) {
        String memoryIdValue = memoryIdString(memoryId);
        if (!StringUtils.hasText(memoryIdValue)) {
            return;
        }
        List<String> validFilePaths = new LinkedList<>();
        if (!CollectionUtils.isEmpty(filePaths)) {
            for (String filePath : filePaths) {
                if (StringUtils.hasText(filePath)) {
                    validFilePaths.add(filePath);
                }
            }
        }
        if (!StringUtils.hasText(userQuestion) && validFilePaths.isEmpty()) {
            return;
        }
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdValue));
        Update update = new Update();
        update.set("memoryId", memoryIdValue);
        update.set("updateTime", new Date());
        update.setOnInsert("createTime", new Date());
        if (StringUtils.hasText(userQuestion)) {
            update.push("analyzeUserQuestions", userQuestion);
        }
        if (!validFilePaths.isEmpty()) {
            update.push("analyzeFilePaths").each(validFilePaths.toArray());
            update.push("analyzeFilePathGroups", validFilePaths);
        }
        mongoTemplate.upsert(query, update, ChatMessages.class);
    }
    public List<String> getAnalyzeUserQuestions(Object memoryId) {
        ChatMessages chatMessages = findChatMessages(memoryId);
        if (chatMessages == null || CollectionUtils.isEmpty(chatMessages.getAnalyzeUserQuestions())) {
            return new LinkedList<>();
        }
        return new LinkedList<>(chatMessages.getAnalyzeUserQuestions());
    }
    public List<List<String>> getAnalyzeFilePathGroups(Object memoryId) {
        ChatMessages chatMessages = findChatMessages(memoryId);
        if (chatMessages == null) {
            return new LinkedList<>();
        }
        if (CollectionUtils.isEmpty(chatMessages.getAnalyzeFilePathGroups())) {
            if (CollectionUtils.isEmpty(chatMessages.getAnalyzeFilePaths())) {
                return new LinkedList<>();
            }
            List<List<String>> fallback = new LinkedList<>();
            fallback.add(new LinkedList<>(chatMessages.getAnalyzeFilePaths()));
            return fallback;
        }
        List<List<String>> groups = new LinkedList<>();
        for (List<String> group : chatMessages.getAnalyzeFilePathGroups()) {
            if (CollectionUtils.isEmpty(group)) {
                groups.add(new LinkedList<>());
            } else {
                groups.add(new LinkedList<>(group));
            }
        }
        return groups;
    }
    private String memoryIdString(Object memoryId) {
        return memoryId == null ? "" : memoryId.toString();
    }
    private ChatMessages findChatMessages(Object memoryId) {
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdString(memoryId)));
        return mongoTemplate.findOne(query, ChatMessages.class);
    }
}
src/main/java/com/ruoyi/common/enums/StockInQualifiedRecordTypeEnum.java
@@ -23,8 +23,9 @@
    SALE_SHIP_STOCK_OUT("13", "销售-发货出库"),
    RETURN_HE_IN("14", "销售退货-合格入库"),
    RETURN_UNSTOCK_IN("15", "销售退货-不合格入库"),
    PICK_RETURN_IN("20", "销售退货-合格入库"),
    PURCHASE_RETURN_STOCK_OUT("21", "采购退货");
    PICK_RETURN_IN("20", "领料退料-合格入库"),
    PURCHASE_RETURN_STOCK_OUT("21", "采购退货"),
    FEED_RETURN_IN("22", "生产退料-合格入库");
src/main/java/com/ruoyi/common/enums/StockOutQualifiedRecordTypeEnum.java
@@ -9,7 +9,9 @@
    PRODUCTION_REPORT_STOCK_OUT("3", "生产报工-出库"),
    SALE_STOCK_OUT("8", "销售-出库"),
    PURCHASE_RETURN_STOCK_OUT("9", "采购退货"),
    SALE_SHIP_STOCK_OUT("13", "销售-发货出库");
    SALE_SHIP_STOCK_OUT("13", "销售-发货出库"),
    PICK_STOCK_OUT("14", "生产领料出库"),
    FEED_STOCK_OUT("15", "生产补料出库");
    private final String code;
    private final String value;
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java
@@ -3,7 +3,9 @@
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.enums.ReviewStatusEnum;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.production.bean.dto.ProductionOrderPickDto;
@@ -18,8 +20,12 @@
import com.ruoyi.production.service.ProductionOrderPickService;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInRecord;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.pojo.StockOutRecord;
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockInRecordService;
import com.ruoyi.stock.service.StockOutRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -39,12 +45,18 @@
    private static final byte PICK_TYPE_NORMAL = 1;
    private static final byte PICK_TYPE_FEEDING = 2;
    private static final String PICK_STOCK_OUT_RECORD_TYPE = StockOutQualifiedRecordTypeEnum.PICK_STOCK_OUT.getCode();
    private static final String FEED_STOCK_OUT_RECORD_TYPE = StockOutQualifiedRecordTypeEnum.FEED_STOCK_OUT.getCode();
    private static final String PICK_RETURN_IN_RECORD_TYPE = StockInQualifiedRecordTypeEnum.PICK_RETURN_IN.getCode();
    private static final String FEED_RETURN_IN_RECORD_TYPE = StockInQualifiedRecordTypeEnum.FEED_RETURN_IN.getCode();
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInventoryService stockInventoryService;
    private final StockInRecordService stockInRecordService;
    private final StockOutRecordService stockOutRecordService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -52,8 +64,9 @@
        // é¢†æ–™æ–°å¢žæ€»æµç¨‹ï¼š
        // 1) è§£æžå‰ç«¯è¡Œæ•°æ®å¹¶é€è¡Œåˆå¹¶å‚æ•°ï¼›
        // 2) æ ¡éªŒå‚数与批次;
        // 3) å…ˆæ‰£å‡åº“存,再落库领料主记录;
        // 4) å†™å…¥é¢†æ–™æµæ°´ï¼Œè®°å½•数量变化轨迹。
        // 3) å…ˆä¿å­˜é¢†æ–™ä¸»è®°å½•ï¼›
        // 4) å†èµ°â€œå‡ºåº“申请 + å®¡æ‰¹é€šè¿‡â€å®Œæˆåº“存扣减;
        // 5) å†™å…¥é¢†æ–™æµæ°´ï¼Œè®°å½•数量变化轨迹。
        List<ProductionOrderPickDto> pickItems = resolvePickItems(dto);
        // é€è¡Œå¤„理领料数据,行号用于拼装精确的报错信息。
        for (int i = 0; i < pickItems.size(); i++) {
@@ -66,7 +79,6 @@
            List<String> batchNoList = resolveBatchNoList(resolvedDto);
            String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
            String storedBatchNo = formatBatchNoStorage(batchNoList);
            subtractInventory(resolvedDto.getProductModelId(), storedBatchNo, resolvedDto.getPickQuantity(), rowNo);
            // ä¿å­˜é¢†æ–™ä¸»è®°å½•快照。
            ProductionOrderPick orderPick = new ProductionOrderPick();
@@ -82,6 +94,9 @@
            orderPick.setReturned(false);
            // æ–°å¢žä¸»è®°å½•。
            baseMapper.insert(orderPick);
            // å…ˆæ–°å¢žå‡ºåº“申请,再审批通过,完成库存扣减。
            subtractInventory(orderPick.getId(), resolvedDto.getProductModelId(), storedBatchNo, resolvedDto.getPickQuantity(), rowNo, PICK_STOCK_OUT_RECORD_TYPE);
            // è®°å½•本次领料流水(before=0,after=本次领料量)。
            insertPickRecord(orderPick.getId(),
@@ -201,7 +216,12 @@
            }
            String oldBatchNo = resolveInventoryBatchNoFromStored(existingPick.getBatchNo());
            BigDecimal oldQuantity = defaultDecimal(existingPick.getQuantity());
            addInventory(existingPick.getProductModelId(), oldBatchNo, oldQuantity);
            addInventory(existingPick.getId(), existingPick.getProductModelId(), oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            // åˆ é™¤å…³è”领料流水,避免遗留无主记录。
            productionOrderPickRecordMapper.delete(
                    Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                            .eq(ProductionOrderPickRecord::getPickId, existingPick.getId())
            );
            int affected = baseMapper.deleteById(deleteId);
            if (affected <= 0) {
                throw new ServiceException("删除领料记录失败,ID=" + deleteId);
@@ -240,7 +260,12 @@
        for (ProductionOrderPick missingPick : missingPickList) {
            String oldBatchNo = resolveInventoryBatchNoFromStored(missingPick.getBatchNo());
            BigDecimal oldQuantity = defaultDecimal(missingPick.getQuantity());
            addInventory(missingPick.getProductModelId(), oldBatchNo, oldQuantity);
            addInventory(missingPick.getId(), missingPick.getProductModelId(), oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            // åˆ é™¤å…³è”领料流水,避免遗留无主记录。
            productionOrderPickRecordMapper.delete(
                    Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                            .eq(ProductionOrderPickRecord::getPickId, missingPick.getId())
            );
            int affected = baseMapper.deleteById(missingPick.getId());
            if (affected <= 0) {
                throw new ServiceException("删除未回传领料记录失败,ID=" + missingPick.getId());
@@ -261,11 +286,11 @@
    }
    private void addNewPickInUpdate(ProductionOrderPickDto dto, int rowNo) {
        // æ›´æ–°åœºæ™¯ä¸‹æ–°å¢žä¸€æ¡é¢†æ–™ï¼šæ‰£åº“å­˜ -> æ–°å¢žä¸»è®°å½• -> å†™æµæ°´ã€‚
        // æ›´æ–°åœºæ™¯ä¸‹æ–°å¢žä¸€æ¡é¢†æ–™ï¼š
        // æ–°å¢žä¸»è®°å½• -> å‡ºåº“申请并审批 -> å†™æµæ°´ã€‚
        List<String> batchNoList = resolveBatchNoList(dto);
        String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
        String storedBatchNo = formatBatchNoStorage(batchNoList);
        subtractInventory(dto.getProductModelId(), storedBatchNo, dto.getPickQuantity(), rowNo);
        ProductionOrderPick orderPick = new ProductionOrderPick();
        orderPick.setProductionOrderId(dto.getProductionOrderId());
@@ -279,6 +304,9 @@
        orderPick.setBom(dto.getBom());
        orderPick.setReturned(false);
        baseMapper.insert(orderPick);
        // å…ˆæ–°å¢žå‡ºåº“申请,再审批通过,完成库存扣减。
        subtractInventory(orderPick.getId(), dto.getProductModelId(), storedBatchNo, dto.getPickQuantity(), rowNo, PICK_STOCK_OUT_RECORD_TYPE);
        insertPickRecord(orderPick.getId(),
                dto.getProductionOrderId(),
@@ -337,7 +365,7 @@
                : formatBatchNoStorage(batchNoList);
        BigDecimal feedingQuantity = dto.getFeedingQuantity();
        subtractInventory(productModelId, inventoryBatchNo, feedingQuantity, rowNo);
        subtractInventory(oldPick.getId(), productModelId, inventoryBatchNo, feedingQuantity, rowNo, FEED_STOCK_OUT_RECORD_TYPE);
        // è®¡ç®—补料前后数量并写补料流水。
        BigDecimal beforeFeedingQty = sumFeedingQuantity(dto.getProductionOrderId(), oldPick.getId());
@@ -393,19 +421,36 @@
    }
    private void updateReturnPick(ProductionOrderPickDto dto, ProductionOrderPick oldPick, int rowNo) {
        // é€€æ–™æ›´æ–°åªæ”¹ä¸»é¢†æ–™è®°å½•中的退料字段与实际量。
        // é€€æ–™æ›´æ–°ï¼š
        // 1) returnQty æŒ‰â€œæœ¬æ¬¡é€€æ–™é‡â€å¤„理;
        // 2) æœ¬æ¬¡é€€æ–™é‡å›žè¡¥åˆ°â€œç”Ÿäº§é€€æ–™å…¥åº“”;
        // 3) ç´¯åŠ ä¸»è®°å½•é€€æ–™æ€»é‡å¹¶é‡ç®—å®žé™…é‡ã€‚
        BigDecimal oldReturnQty = defaultDecimal(oldPick.getReturnQty());
        BigDecimal currentReturnQty = defaultDecimal(dto.getReturnQty());
        BigDecimal totalReturnQty = oldReturnQty.add(currentReturnQty);
        if (currentReturnQty.compareTo(BigDecimal.ZERO) > 0) {
            String returnBatchNo = resolveInventoryBatchNoFromStored(oldPick.getBatchNo());
            addInventory(oldPick.getId(), oldPick.getProductModelId(), returnBatchNo, currentReturnQty, FEED_RETURN_IN_RECORD_TYPE);
        }
        BigDecimal actualQty = defaultDecimal(oldPick.getQuantity())
                .add(defaultDecimal(oldPick.getFeedingQty()))
                .subtract(totalReturnQty);
        if (actualQty.compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行退料失败:累计退料数量不能大于可用数量");
        }
        ProductionOrderPick updatePick = new ProductionOrderPick();
        updatePick.setId(oldPick.getId());
        updatePick.setReturnQty(dto.getReturnQty());
        updatePick.setActualQty(dto.getActualQty());
        updatePick.setReturned(true);
        updatePick.setReturnQty(totalReturnQty);
        updatePick.setActualQty(actualQty);
        updatePick.setReturned(totalReturnQty.compareTo(BigDecimal.ZERO) > 0);
        int affected = baseMapper.updateById(updatePick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "行退料失败:更新领料主记录失败");
        }
        oldPick.setReturnQty(updatePick.getReturnQty());
        oldPick.setActualQty(updatePick.getActualQty());
        oldPick.setReturned(true);
        oldPick.setReturned(updatePick.getReturned());
    }
    private void updateExistingPick(ProductionOrderPickDto dto,
@@ -431,21 +476,30 @@
        String newStoredBatchNo = formatBatchNoStorage(newBatchNoList);
        BigDecimal newQuantity = dto.getPickQuantity();
        // åˆ¤æ–­è§„æ ¼+批次是否变化,决定库存处理策略。
        // åˆ¤æ–­è§„æ ¼+批次或数量是否变化,并按场景处理库存:
        // 1) åŒè§„格同批次:按差值处理(增量扣减 / å‡é‡å›žé€€ï¼‰ï¼›
        // 2) è§„格或批次变化:回退旧领料后再重提新领料。
        boolean sameStockKey = Objects.equals(oldProductModelId, newProductModelId)
                && Objects.equals(oldBatchNo, newBatchNo);
        boolean quantityChanged = oldQuantity.compareTo(newQuantity) != 0;
        boolean needReissuePickRecord = !sameStockKey || quantityChanged;
        if (sameStockKey) {
            // è§„格与批次不变:只按差值增减库存。
            BigDecimal delta = newQuantity.subtract(oldQuantity);
            if (delta.compareTo(BigDecimal.ZERO) > 0) {
                subtractInventory(newProductModelId, newStoredBatchNo, delta, rowNo);
            } else if (delta.compareTo(BigDecimal.ZERO) < 0) {
                addInventory(oldProductModelId, oldBatchNo, delta.abs());
            BigDecimal deltaQuantity = newQuantity.subtract(oldQuantity);
            if (deltaQuantity.compareTo(BigDecimal.ZERO) > 0) {
                // æ•°é‡å¢žåŠ ï¼Œåªæ‰£å‡æ–°å¢žéƒ¨åˆ†ã€‚
                subtractInventory(oldPick.getId(), newProductModelId, newStoredBatchNo, deltaQuantity, rowNo, PICK_STOCK_OUT_RECORD_TYPE);
            } else if (deltaQuantity.compareTo(BigDecimal.ZERO) < 0) {
                // æ•°é‡å‡å°‘,只回退差值部分。
                addInventory(oldPick.getId(), oldProductModelId, oldBatchNo, deltaQuantity.abs(), PICK_RETURN_IN_RECORD_TYPE);
            }
        } else {
            // è§„格或批次变化:先回补旧库存,再扣减新库存。
            addInventory(oldProductModelId, oldBatchNo, oldQuantity);
            subtractInventory(newProductModelId, newStoredBatchNo, newQuantity, rowNo);
            // è§„格或批次变化:先全量回退旧领料,再全量扣减新领料。
            addInventory(oldPick.getId(), oldProductModelId, oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            subtractInventory(oldPick.getId(), newProductModelId, newStoredBatchNo, newQuantity, rowNo, PICK_STOCK_OUT_RECORD_TYPE);
        }
        if (needReissuePickRecord) {
            // æ­£å¸¸é¢†æ–™æµæ°´æŒ‰â€œæœ€æ–°é¢†æ–™é‡â€é‡å»ºï¼Œé¿å…ä¿ç•™åŽ†å²æ—§å€¼ã€‚
            deleteNormalPickRecord(oldPick.getId());
        }
        oldPick.setProductModelId(newProductModelId);
@@ -454,6 +508,9 @@
        oldPick.setRemark(dto.getRemark());
        oldPick.setOperationName(dto.getOperationName());
        oldPick.setTechnologyOperationId(dto.getTechnologyOperationId());
        // æ™®é€šæ›´æ–°ä¹Ÿè¦åŒæ­¥é‡ç®—实际用量,避免沿用旧值。
        // è§„则:实际用量 = é¢†æ–™æ•°é‡ + è¡¥æ–™æ•°é‡ - é€€æ–™æ•°é‡ã€‚
        oldPick.setActualQty(calculateActualQty(oldPick, oldPick.getFeedingQty()));
        if (dto.getDemandedQuantity() != null) {
            oldPick.setDemandedQuantity(dto.getDemandedQuantity());
        }
@@ -465,16 +522,15 @@
            throw new ServiceException("第" + rowNo + "行更新失败:更新领料记录失败");
        }
        // å†™å…¥æ›´æ–°æµæ°´ï¼Œä¿ç•™æœ¬æ¬¡æ•°é‡å˜åŒ–轨迹。
        BigDecimal recordQuantity = sameStockKey ? oldQuantity.subtract(newQuantity).abs() : newQuantity;
        if (recordQuantity.compareTo(BigDecimal.ZERO) > 0 || oldQuantity.compareTo(newQuantity) != 0 || !sameStockKey) {
        // å¦‚果发生领料重提,补写一条新的正常领料流水。
        if (needReissuePickRecord) {
            insertPickRecord(oldPick.getId(),
                    dto.getProductionOrderId(),
                    dto.getProductionOperationTaskId(),
                    newProductModelId,
                    newBatchNo,
                    recordQuantity,
                    oldQuantity,
                    newQuantity,
                    BigDecimal.ZERO,
                    newQuantity,
                    dto.getPickType(),
                    dto.getRemark(),
@@ -509,11 +565,28 @@
        productionOrderPickRecordMapper.insert(pickRecord);
    }
    private void subtractInventory(Long productModelId, String batchNo, BigDecimal quantity, int rowNo) {
    private void deleteNormalPickRecord(Long pickId) {
        // åˆ é™¤è¯¥é¢†æ–™å•历史上的“正常领料”流水,保留补料/退料流水。
        if (pickId == null) {
            return;
        }
        productionOrderPickRecordMapper.delete(
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getPickId, pickId)
                        .eq(ProductionOrderPickRecord::getPickType, PICK_TYPE_NORMAL)
        );
    }
    private void subtractInventory(Long recordId,
                                   Long productModelId,
                                   String batchNo,
                                   BigDecimal quantity,
                                   int rowNo,
                                   String stockOutRecordType) {
        // æ‰£å‡åº“存总流程:
        // 1) è§£æžæ‰¹æ¬¡åˆ—表;
        // 2) è®¡ç®—每个批次可用量与总可用量;
        // 3) æŒ‰æ‰¹æ¬¡é¡ºåºé€ç¬”扣减,直到扣完目标数量;
        // 3) æŒ‰æ‰¹æ¬¡é¡ºåºé€ç¬”“新增出库记录并审批通过”,直到扣完目标数量;
        // 4) ä»»ä¸€æ­¥å¤±è´¥å³æŠ›é”™å¹¶å›žæ»šäº‹åŠ¡ã€‚
        BigDecimal deductQuantity = defaultDecimal(quantity);
        // é¢†æ–™æ•°é‡å°äºŽç­‰äºŽ0时,不需要执行库存扣减。
@@ -562,14 +635,7 @@
                continue;
            }
            BigDecimal currentDeductQuantity = remainingQuantity.min(availableQuantity);
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(entry.getKey());
            stockInventoryDto.setQualitity(currentDeductQuantity);
            int affected = stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto);
            if (affected <= 0) {
                throw new ServiceException("第" + rowNo + "行扣减库存失败:库存更新失败");
            }
            createAndApproveStockOutRecord(recordId, productModelId, entry.getKey(), currentDeductQuantity, rowNo, stockOutRecordType);
            remainingQuantity = remainingQuantity.subtract(currentDeductQuantity);
        }
@@ -578,19 +644,97 @@
        }
    }
    private void addInventory(Long productModelId, String batchNo, BigDecimal quantity) {
        // å›žè¡¥åº“存(用于删除领料、改小领料、切换批次等场景)。
    private void createAndApproveStockOutRecord(Long recordId,
                                                Long productModelId,
                                                String batchNo,
                                                BigDecimal quantity,
                                                int rowNo,
                                                String stockOutRecordType) {
        // åº“存扣减改为两步:
        // 1) å…ˆè°ƒç”¨ addStockOutRecordOnly æ–°å¢žå¾…审批出库记录;
        // 2) å†è°ƒç”¨å‡ºåº“审批,审批状态固定传 1(通过)。
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryDto.setRecordType(stockOutRecordType);
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(quantity);
            stockInventoryService.addStockOutRecordOnly(stockInventoryDto);
            LambdaQueryWrapper<StockOutRecord> recordWrapper = Wrappers.<StockOutRecord>lambdaQuery()
                    .eq(StockOutRecord::getRecordId, stockInventoryDto.getRecordId())
                    .eq(StockOutRecord::getRecordType, stockOutRecordType)
                    .eq(StockOutRecord::getProductModelId, productModelId)
                    .eq(StockOutRecord::getType, "0")
                    .orderByDesc(StockOutRecord::getId)
                    .last("limit 1");
            if (StringUtils.isEmpty(batchNo)) {
                recordWrapper.isNull(StockOutRecord::getBatchNo);
            } else {
                recordWrapper.eq(StockOutRecord::getBatchNo, batchNo);
            }
            StockOutRecord stockOutRecord = stockOutRecordService.getOne(recordWrapper, false);
            if (stockOutRecord == null || stockOutRecord.getId() == null) {
                throw new ServiceException("第" + rowNo + "行扣减库存失败:未找到对应出库申请记录");
            }
            stockOutRecordService.batchApprove(
                    Collections.singletonList(stockOutRecord.getId()),
                    ReviewStatusEnum.APPROVED.getCode()
            );
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("第" + rowNo + "行扣减库存失败:" + ex.getMessage());
        }
    }
    private void addInventory(Long recordId,
                              Long productModelId,
                              String batchNo,
                              BigDecimal quantity,
                              String stockInRecordType) {
        // å›žè¡¥åº“存改为两步:
        // 1) å…ˆæ–°å¢žå…¥åº“申请;
        // 2) å†å®¡æ‰¹é€šè¿‡ï¼Œç¡®ä¿åº“存立刻回补生效。
        BigDecimal addQuantity = defaultDecimal(quantity);
        if (addQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setProductModelId(productModelId);
        stockInventoryDto.setBatchNo(batchNo);
        stockInventoryDto.setQualitity(addQuantity);
        stockInventoryDto.setRecordType(String.valueOf(StockInQualifiedRecordTypeEnum.PICK_RETURN_IN.getCode()));
        stockInventoryDto.setRecordId(0L);
        stockInventoryService.addStockInRecordOnly(stockInventoryDto);
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(addQuantity);
            stockInventoryDto.setRecordType(stockInRecordType);
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryService.addStockInRecordOnly(stockInventoryDto);
            LambdaQueryWrapper<StockInRecord> recordWrapper = Wrappers.<StockInRecord>lambdaQuery()
                    .eq(StockInRecord::getRecordId, stockInventoryDto.getRecordId())
                    .eq(StockInRecord::getRecordType, stockInventoryDto.getRecordType())
                    .eq(StockInRecord::getProductModelId, productModelId)
                    .eq(StockInRecord::getType, "0")
                    .orderByDesc(StockInRecord::getId)
                    .last("limit 1");
            if (StringUtils.isEmpty(batchNo)) {
                recordWrapper.isNull(StockInRecord::getBatchNo);
            } else {
                recordWrapper.eq(StockInRecord::getBatchNo, batchNo);
            }
            StockInRecord stockInRecord = stockInRecordService.getOne(recordWrapper, false);
            if (stockInRecord == null || stockInRecord.getId() == null) {
                throw new ServiceException("回补库存失败:未找到对应入库申请记录");
            }
            stockInRecordService.batchApprove(
                    Collections.singletonList(stockInRecord.getId()),
                    ReviewStatusEnum.APPROVED.getCode()
            );
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("回补库存失败:" + ex.getMessage());
        }
    }
    private List<ProductionOrderPickDto> resolvePickItems(ProductionOrderPickDto dto) {
@@ -769,7 +913,7 @@
    }
    private void validateReturnParam(ProductionOrderPickDto dto, int rowNo) {
        // æ ¡éªŒé€€æ–™å‚数(订单、领料ID、退料量、实际量)。
        // æ ¡éªŒé€€æ–™å‚数(订单、领料ID、退料量)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
@@ -778,9 +922,6 @@
        }
        if (dto.getReturnQty() == null || dto.getReturnQty().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行参数错误:退料数量不能小于0");
        }
        if (dto.getActualQty() == null || dto.getActualQty().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行参数错误:实际数量不能小于0");
        }
    }
src/main/java/com/ruoyi/production/service/impl/ProductionPlanServiceImpl.java
@@ -101,12 +101,13 @@
            throw new BaseException("合并失败,所选生产计划的产品型号不一致");
        }
        // å·²ä¸‹å‘或部分下发的计划不允许再次合并
        boolean hasIssuedPlan = planLists.stream()
        // ä»…“已下发”计划不允许再次参与合并下发;
        // â€œå¾…下发/部分下发”允许继续下发剩余数量。
        boolean hasFullyIssuedPlan = planLists.stream()
                .anyMatch(item -> item.getStatus() != null
                        && (item.getStatus() == PLAN_STATUS_PARTIAL || item.getStatus() == PLAN_STATUS_ISSUED));
        if (hasIssuedPlan) {
            throw new BaseException("合并失败,所选生产计划存在已下发或部分下发的数据");
                        && item.getStatus() == PLAN_STATUS_ISSUED);
        if (hasFullyIssuedPlan) {
            throw new BaseException("合并失败,所选生产计划存在已下发的数据");
        }
        // è®¡ç®—本次可下发的剩余需求总量
src/main/java/com/ruoyi/staff/dto/StaffLeaveDto.java
@@ -99,4 +99,9 @@
     * ç¦»èŒåŽŸå› æ–‡æœ¬
     */
    private String reasonText;
    /**
     * æ°‘族
     */
    private String nation;
}
src/main/resources/application-dev-pro.yml
@@ -71,9 +71,9 @@
    druid:
      # ä¸»åº“数据源
      master:
        url: jdbc:mysql://localhost:3306/product-inventory-management-new-pro?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        url: jdbc:mysql://1.15.17.182:9999/product-inventory-management-wtxxjc?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        username: root
        password: 123456
        password: xd@123456..
      # ä»Žåº“数据源
      slave:
        # ä»Žæ•°æ®æºå¼€å…³/默认关闭
@@ -155,7 +155,7 @@
      database: 0
      # å¯†ç 
      #    password: root2022!
      password:
      password: 123456
      # è¿žæŽ¥è¶…æ—¶æ—¶é—´
      timeout: 10s
src/main/resources/application-dev.yml
@@ -254,8 +254,18 @@
  # æ˜¯å¦å…è®¸ç”Ÿæˆæ–‡ä»¶è¦†ç›–到本地(自定义路径),默认不允许
  allowOverwrite: false
# æ–‡ä»¶ä¸Šä¼ é…ç½®
file:
  temp-dir: D:/ruoyi/temp/uploads   # ä¸´æ—¶ç›®å½•
  upload-dir: D:/ruoyi/prod/uploads # æ­£å¼ç›®å½•
  temp-dir: D:/ruoyi/temp/uploads   # ä¸´æ—¶ç›®å½• åŽæœŸåˆ é™¤
  upload-dir: D:/ruoyi/prod/uploads # æ­£å¼ç›®å½• åŽæœŸåˆ é™¤
  path: D:/ruoyi/prod/uploads # ä¸Šä¼ ç›®å½•
  urlPrefix: /common # é“¾æŽ¥å‰ç¼€
  domain: http://127.0.0.1:7005 # åŸŸåå‰ç¼€
  expired: 120 # è¿‡æœŸæ—¶é—´(单位:分钟)
  useLimit: 10 # ä½¿ç”¨æ¬¡æ•°
  compress: true # æ˜¯å¦åŽ‹ç¼©
  needCompressSize: 10MB # åŽ‹ç¼©é˜ˆå€¼
  compressQuality: 0.5 # åŽ‹ç¼©è´¨é‡(0.0-1.0)
knowledge:
  one: D:\新疆大罗素企业产品体系说明文档.md
src/main/resources/application-wtxxjc.yml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,266 @@
# é¡¹ç›®ç›¸å…³é…ç½®
ruoyi:
  # åç§°
  name: RuoYi
  # ç‰ˆæœ¬
  version: 3.8.9
  # ç‰ˆæƒå¹´ä»½
  copyrightYear: 2025
  # æ–‡ä»¶è·¯å¾„ ç¤ºä¾‹ï¼ˆ Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
  profile: /javaWork/product-inventory-management/file
  # èŽ·å–ip地址开关
  addressEnabled: false
  # éªŒè¯ç ç±»åž‹ math æ•°å­—计算 char å­—符验证
  captchaType: math
  # ååŒå®¡æ‰¹ç¼–号前缀(配置文件后缀命名)
  approvalNumberPrefix: YZFX
  # ä¸ªæŽ¨ Unipush é…ç½®
  getui:
    appId: PfjyAAE0FK64FaO1w2CMb1
    appKey: zTMb831OEL6J4GK1uE3Ob4
    masterSecret: K1GFtsv42v61tXGnF7SGE5
    domain: https://restapi.getui.cn/v2/
    # ç¦»çº¿æŽ¨é€ä½¿ç”¨çš„包名/组件名
    intentComponent: uni.app.UNI099A590/io.dcloud.PandoraEntry
# å¼€å‘环境配置
server:
  # æœåŠ¡å™¨çš„HTTP端口,默认为8080
  port: 9021
  servlet:
    # åº”用的访问路径
    context-path: /
  tomcat:
    # tomcat的URI编码
    uri-encoding: UTF-8
    # è¿žæŽ¥æ•°æ»¡åŽçš„æŽ’队数,默认为100
    accept-count: 1000
    threads:
      # tomcat最大线程数,默认为200
      max: 800
      # Tomcat启动初始化的线程数,默认值10
      min-spare: 100
# æ—¥å¿—配置
logging:
  level:
    com.ruoyi: warn
    org.springframework: warn
minio:
  endpoint: http://114.132.189.42/
  port: 7019
  secure: false
  accessKey: admin
  secretKey: 12345678
  preview-expiry: 24 # é¢„览地址默认24小时
  default-bucket: jxc
# ç”¨æˆ·é…ç½®
user:
  password:
    # å¯†ç æœ€å¤§é”™è¯¯æ¬¡æ•°
    maxRetryCount: 5
    # å¯†ç é”å®šæ—¶é—´ï¼ˆé»˜è®¤10分钟)
    lockTime: 10
# Spring配置
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    druid:
      # ä¸»åº“数据源
      master:
        url: jdbc:mysql://1.15.17.182:9999/product-inventory-management-wtxxjc?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        username: root
        password: xd@123456..
      # ä»Žåº“数据源
      slave:
        # ä»Žæ•°æ®æºå¼€å…³/默认关闭
        enabled: false
        url:
        username:
        password:
      # åˆå§‹è¿žæŽ¥æ•°
      initialSize: 5
      # æœ€å°è¿žæŽ¥æ± æ•°é‡
      minIdle: 10
      # æœ€å¤§è¿žæŽ¥æ± æ•°é‡
      maxActive: 20
      # é…ç½®èŽ·å–è¿žæŽ¥ç­‰å¾…è¶…æ—¶çš„æ—¶é—´
      maxWait: 60000
      # é…ç½®è¿žæŽ¥è¶…æ—¶æ—¶é—´
      connectTimeout: 30000
      # é…ç½®ç½‘络超时时间
      socketTimeout: 60000
      # é…ç½®é—´éš”多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
      timeBetweenEvictionRunsMillis: 60000
      # é…ç½®ä¸€ä¸ªè¿žæŽ¥åœ¨æ± ä¸­æœ€å°ç”Ÿå­˜çš„æ—¶é—´ï¼Œå•位是毫秒
      minEvictableIdleTimeMillis: 300000
      # é…ç½®ä¸€ä¸ªè¿žæŽ¥åœ¨æ± ä¸­æœ€å¤§ç”Ÿå­˜çš„æ—¶é—´ï¼Œå•位是毫秒
      maxEvictableIdleTimeMillis: 900000
      # é…ç½®æ£€æµ‹è¿žæŽ¥æ˜¯å¦æœ‰æ•ˆ
      validationQuery: SELECT 1 FROM DUAL
      testWhileIdle: true
      testOnBorrow: false
      testOnReturn: false
      webStatFilter:
        enabled: true
      statViewServlet:
        enabled: true
        # è®¾ç½®ç™½åå•,不填则允许所有访问
        allow:
        url-pattern: /druid/*
        # æŽ§åˆ¶å°ç®¡ç†ç”¨æˆ·åå’Œå¯†ç 
        login-username: ruoyi
        login-password: 123456
      filter:
        stat:
          enabled: true
          # æ…¢SQL记录
          log-slow-sql: true
          slow-sql-millis: 1000
          merge-sql: true
        wall:
          config:
            multi-statement-allow: true
  # èµ„源信息
  messages:
    # å›½é™…化资源文件路径
    basename: i18n/messages
  # æ–‡ä»¶ä¸Šä¼ 
  servlet:
    multipart:
      # å•个文件大小
      max-file-size: 1GB
      # è®¾ç½®æ€»ä¸Šä¼ çš„æ–‡ä»¶å¤§å°
      max-request-size: 2GB
  # æœåŠ¡æ¨¡å—
  devtools:
    restart:
      # çƒ­éƒ¨ç½²å¼€å…³
      enabled: false
  # redis é…ç½®
  data:
    mongodb:
      uri: mongodb://114.132.189.42:9028/chat_memory_db
    # redis é…ç½®
    redis:
      # åœ°å€
      #      host: 127.0.0.1
      host: 172.17.0.1
      # ç«¯å£ï¼Œé»˜è®¤ä¸º6379
      port: 6379
      # æ•°æ®åº“索引
      database: 0
      # å¯†ç 
      #    password: root2022!
      password:
      # è¿žæŽ¥è¶…æ—¶æ—¶é—´
      timeout: 10s
      lettuce:
        pool:
          # è¿žæŽ¥æ± ä¸­çš„æœ€å°ç©ºé—²è¿žæŽ¥
          min-idle: 0
          # è¿žæŽ¥æ± ä¸­çš„æœ€å¤§ç©ºé—²è¿žæŽ¥
          max-idle: 8
          # è¿žæŽ¥æ± çš„æœ€å¤§æ•°æ®åº“连接数
          max-active: 8
          # #连接池最大阻塞等待时间(使用负值表示没有限制)
          max-wait: -1ms
  # Quartz定时任务配置(新增部分)
  quartz:
    job-store-type: jdbc  # ä½¿ç”¨æ•°æ®åº“存储
    jdbc:
      initialize-schema: never  # é¦–次运行时自动创建表结构,成功后改为never
      schema: classpath:org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql  # MySQL表结构脚本
    properties:
      org:
        quartz:
          scheduler:
            instanceName: RuoYiScheduler
            instanceId: AUTO
          jobStore:
            class: org.quartz.impl.jdbcjobstore.JobStoreTX
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate  # MySQL适配
            tablePrefix: qrtz_  # è¡¨åå‰ç¼€ï¼Œä¸Žè„šæœ¬ä¸€è‡´
            isClustered: false  # å•节点模式(集群需改为true)
            clusterCheckinInterval: 10000
            txIsolationLevelSerializable: true
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            threadCount: 10  # çº¿ç¨‹æ± å¤§å°
            threadPriority: 5
            makeThreadsDaemons: true
          updateCheck: false  # å…³é—­ç‰ˆæœ¬æ£€æŸ¥
# token配置
token:
  # ä»¤ç‰Œè‡ªå®šä¹‰æ ‡è¯†
  header: Authorization
  # ä»¤ç‰Œå¯†é’¥
  secret: xpAVjhCjQDaDB7mjPAzMDSbQWXNu2zYkTdDNUsPMS5Xx8QMmQVYN7n74eZrYJxDJ
  # ä»¤ç‰Œæœ‰æ•ˆæœŸï¼ˆé»˜è®¤30分钟)
  expireTime: 450
# MyBatis Plus配置
mybatis-plus:
  # æœç´¢æŒ‡å®šåŒ…别名   æ ¹æ®è‡ªå·±çš„项目来
  typeAliasesPackage: com.ruoyi.**.pojo
  # é…ç½®mapper的扫描,找到所有的mapper.xml映射文件
  mapperLocations: classpath*:mapper/**/*Mapper.xml
  # åŠ è½½å…¨å±€çš„é…ç½®æ–‡ä»¶
  configLocation: classpath:mybatis/mybatis-config.xml
  global-config:
    enable-sql-runner: true
    db-config:
      id-type: auto
# PageHelper分页插件
pagehelper:
  helperDialect: mysql
  supportMethodsArguments: true
  params: count=countSql
# Swagger配置
swagger:
  # æ˜¯å¦å¼€å¯swagger
  enabled: true
  # è¯·æ±‚前缀
  pathMapping: /dev-api
# é˜²æ­¢XSS攻击
xss:
  # è¿‡æ»¤å¼€å…³
  enabled: true
  # æŽ’除链接(多个用逗号分隔)
  excludes: /system/notice
  # åŒ¹é…é“¾æŽ¥
  urlPatterns: /system/*,/monitor/*,/tool/*
# ä»£ç ç”Ÿæˆ
gen:
  # ä½œè€…
  author: ruoyi
  # é»˜è®¤ç”ŸæˆåŒ…路径 system éœ€æ”¹æˆè‡ªå·±çš„æ¨¡å—名称 å¦‚ system monitor tool
  packageName: com.ruoyi.project.system
  # è‡ªåŠ¨åŽ»é™¤è¡¨å‰ç¼€ï¼Œé»˜è®¤æ˜¯true
  autoRemovePre: false
  # è¡¨å‰ç¼€ï¼ˆç”Ÿæˆç±»åä¸ä¼šåŒ…含表前缀,多个用逗号分隔)
  tablePrefix: sys_
  # æ˜¯å¦å…è®¸ç”Ÿæˆæ–‡ä»¶è¦†ç›–到本地(自定义路径),默认不允许
  allowOverwrite: false
# æ–‡ä»¶ä¸Šä¼ é…ç½®
file:
  temp-dir: /javaWork/product-inventory-management/file/temp/uploads   # ä¸´æ—¶ç›®å½•
  upload-dir: /javaWork/product-inventory-management/file/prod/uploads # æ­£å¼ç›®å½•
  path: /javaWork/product-inventory-management/file # ä¸Šä¼ ç›®å½•
  urlPrefix: /prod-api/common # é“¾æŽ¥å‰ç¼€
  domain: http://1.15.17.182:9049 # åŸŸåå‰ç¼€
  expired: 120 # è¿‡æœŸæ—¶é—´(单位:分钟)
  useLimit: 10 # ä½¿ç”¨æ¬¡æ•°
  compress: true # æ˜¯å¦åŽ‹ç¼©
  needCompressSize: 10MB # åŽ‹ç¼©é˜ˆå€¼
  compressQuality: 0.5 # åŽ‹ç¼©è´¨é‡(0.0-1.0)
src/main/resources/application.yml
@@ -3,7 +3,7 @@
  main:
    allow-circular-references: true
  profiles:
    active: dev
    active: dev-pro
langchain4j:
  mcp:
    # MCP æœåŠ¡ç«¯åœ°å€ï¼ˆæ ¹æ®å®žé™…éƒ¨ç½²çš„ MCP æœåŠ¡è°ƒæ•´ï¼‰
src/main/resources/mapper/staff/StaffLeaveMapper.xml
@@ -9,6 +9,7 @@
        soj.staff_no as staffNo,
        soj.sex as sex,
        soj.native_place as nativePlace,
        soj.nation as nation,
        soj.adress as adress,
        soj.first_study as firstStudy,
        soj.profession as profession,
@@ -30,6 +31,9 @@
        <if test="c.staffName != null and c.staffName != '' ">
            AND soj.staff_name LIKE CONCAT('%',#{c.staffName},'%')
        </if>
        <if test="c.nation != null and c.nation != '' ">
            AND soj.nation = #{c.nation}
        </if>
    </select>
    <select id="staffLeaveList" resultType="com.ruoyi.staff.dto.StaffLeaveDto">
        SELECT
src/main/resources/mapper/staff/StaffOnJobMapper.xml
@@ -17,6 +17,9 @@
        <if test="staffOnJob.staffState != null">
            AND staff_state = #{staffOnJob.staffState}
        </if>
        <if test="staffOnJob.nation != null">
            AND nation = #{staffOnJob.nation}
        </if>
        <if test="staffOnJob.staffName != null and staffOnJob.staffName != '' ">
            AND staff_name LIKE CONCAT('%',#{staffOnJob.staffName},'%')
        </if>