package com.ruoyi.ai.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.ruoyi.ai.assistant.PurchaseAgent; import com.ruoyi.ai.assistant.PurchaseIntentExecutor; import com.ruoyi.ai.bean.ChatForm; import com.ruoyi.ai.bean.PurchaseAiConfirmRequest; import com.ruoyi.ai.context.AiSessionUserContext; import com.ruoyi.ai.service.AiChatSessionService; import com.ruoyi.ai.service.AiFileTextExtractor; import com.ruoyi.ai.store.MongoChatMemoryStore; import com.ruoyi.basic.mapper.SupplierManageMapper; import com.ruoyi.basic.pojo.SupplierManage; 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 com.ruoyi.purchase.dto.PurchaseLedgerDto; import com.ruoyi.purchase.dto.PurchaseReturnOrderDto; import com.ruoyi.purchase.pojo.PaymentRegistration; import com.ruoyi.purchase.service.IPaymentRegistrationService; import com.ruoyi.purchase.service.IPurchaseLedgerService; import com.ruoyi.purchase.service.PurchaseReturnOrdersService; import com.ruoyi.sales.pojo.SalesLedgerProduct; import dev.langchain4j.data.image.Image; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.ImageContent; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.StreamingChatLanguageModel; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.web.bind.annotation.RequestParam; 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 org.springframework.web.multipart.MultipartFile; import reactor.core.publisher.Flux; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Base64; import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.UUID; @Tag(name = "采购智能体") @RestController @RequestMapping("/purchase-ai") public class PurchaseAiController extends BaseController { private static final String PURCHASE_FILE_ANALYZE_MEMORY_PREFIX = "purchase-file-analyze::"; private static final int MAX_FILE_COUNT = 10; private static final int MAX_SINGLE_FILE_TEXT_LENGTH = 8000; private static final int MAX_TOTAL_FILE_TEXT_LENGTH = 30000; private final PurchaseAgent purchaseAgent; private final PurchaseIntentExecutor purchaseIntentExecutor; private final AiSessionUserContext aiSessionUserContext; private final MongoChatMemoryStore mongoChatMemoryStore; private final AiChatSessionService aiChatSessionService; private final AiFileTextExtractor aiFileTextExtractor; private final ObjectMapper objectMapper; private final IPurchaseLedgerService purchaseLedgerService; private final IPaymentRegistrationService paymentRegistrationService; private final PurchaseReturnOrdersService purchaseReturnOrdersService; private final SupplierManageMapper supplierManageMapper; private final StreamingChatLanguageModel purchaseVisionStreamingChatModel; public PurchaseAiController(PurchaseAgent purchaseAgent, PurchaseIntentExecutor purchaseIntentExecutor, AiSessionUserContext aiSessionUserContext, MongoChatMemoryStore mongoChatMemoryStore, AiChatSessionService aiChatSessionService, AiFileTextExtractor aiFileTextExtractor, ObjectMapper objectMapper, IPurchaseLedgerService purchaseLedgerService, IPaymentRegistrationService paymentRegistrationService, PurchaseReturnOrdersService purchaseReturnOrdersService, SupplierManageMapper supplierManageMapper, @Qualifier("purchaseVisionStreamingChatModel") StreamingChatLanguageModel purchaseVisionStreamingChatModel) { this.purchaseAgent = purchaseAgent; this.purchaseIntentExecutor = purchaseIntentExecutor; this.aiSessionUserContext = aiSessionUserContext; this.mongoChatMemoryStore = mongoChatMemoryStore; this.aiChatSessionService = aiChatSessionService; this.aiFileTextExtractor = aiFileTextExtractor; this.objectMapper = objectMapper; this.purchaseLedgerService = purchaseLedgerService; this.paymentRegistrationService = paymentRegistrationService; this.purchaseReturnOrdersService = purchaseReturnOrdersService; this.supplierManageMapper = supplierManageMapper; this.purchaseVisionStreamingChatModel = purchaseVisionStreamingChatModel; } @Operation(summary = "采购对话") @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8") public Flux 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 = purchaseIntentExecutor.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 purchaseAgent.chat(memoryId, userMessage) .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser)) .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser)); } @Operation(summary = "采购多文件分析") @PostMapping(value = "/analyze-files", consumes = "multipart/form-data", produces = "text/stream;charset=utf-8") public Flux analyzeFiles(@RequestParam("files") MultipartFile[] files, @RequestParam(value = "message", required = false) String message, @RequestParam(value = "memoryId", required = false) String memoryId) { if (files == null || files.length == 0) { return Flux.just("files不能为空"); } if (files.length > MAX_FILE_COUNT) { return Flux.just("一次最多分析" + MAX_FILE_COUNT + "个文件"); } String rawMemoryId = StringUtils.hasText(memoryId) ? memoryId : UUID.randomUUID().toString(); String finalMemoryId = rawMemoryId.startsWith(PURCHASE_FILE_ANALYZE_MEMORY_PREFIX) ? rawMemoryId : PURCHASE_FILE_ANALYZE_MEMORY_PREFIX + rawMemoryId; LoginUser loginUser = SecurityUtils.getLoginUser(); aiSessionUserContext.bind(finalMemoryId, loginUser); String finalMessage = StringUtils.hasText(message) ? message : "请分析这些采购文件,提取可用于业务处理的数据,并整理成待客户确认的格式"; String fileContent; try { fileContent = buildMultiFileContent(files); } catch (IllegalArgumentException ex) { return Flux.just(ex.getMessage()); } catch (IOException ex) { return Flux.just("文件读取失败"); } if (!StringUtils.hasText(fileContent)) { return Flux.just("未提取到有效文件内容"); } String userPrompt = buildPurchaseFileAnalyzePrompt(finalMessage, fileContent); aiChatSessionService.touchSession(finalMemoryId, loginUser, "采购多文件分析: " + finalMessage); if (containsImageFile(files)) { return chatWithPurchaseVisionModel(finalMemoryId, userPrompt, files) .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser)) .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser)); } return Flux.defer(() -> purchaseAgent.chat(finalMemoryId, userPrompt)) .onErrorResume(NoSuchElementException.class, ex -> { mongoChatMemoryStore.deleteMessages(finalMemoryId); return purchaseAgent.chat(finalMemoryId, userPrompt); }) .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser)) .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser)); } @Operation(summary = "采购多文件分析确认处理") @PostMapping("/analyze-files/confirm") public AjaxResult confirmAnalyzeResult(@RequestBody PurchaseAiConfirmRequest request) { if (request == null || !StringUtils.hasText(request.getBusinessType())) { return AjaxResult.error("businessType不能为空"); } if (request.getPayload() == null || request.getPayload().isEmpty()) { return AjaxResult.error("payload不能为空"); } try { String businessType = request.getBusinessType().trim(); return switch (businessType) { case "purchase_ledger" -> processPurchaseLedger(request.getPayload()); case "payment_registration" -> processPaymentRegistration(request.getPayload()); case "purchase_return_order" -> processPurchaseReturnOrder(request.getPayload()); default -> AjaxResult.error("暂不支持该业务类型: " + businessType); }; } catch (Exception ex) { return AjaxResult.error(toCustomerMessage(ex)); } } @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())); } private String buildMultiFileContent(MultipartFile[] files) throws IOException { StringBuilder builder = new StringBuilder(); int totalLength = 0; for (MultipartFile file : files) { String text = aiFileTextExtractor.extractText(file); if (!StringUtils.hasText(text)) { continue; } String limitedText = text.length() > MAX_SINGLE_FILE_TEXT_LENGTH ? text.substring(0, MAX_SINGLE_FILE_TEXT_LENGTH) : text; if (totalLength + limitedText.length() > MAX_TOTAL_FILE_TEXT_LENGTH) { int remain = MAX_TOTAL_FILE_TEXT_LENGTH - totalLength; if (remain <= 0) { break; } limitedText = limitedText.substring(0, remain); } builder.append("\n--- 文件: ") .append(file.getOriginalFilename()) .append(" ---\n") .append(limitedText) .append('\n'); totalLength += limitedText.length(); } return builder.toString(); } private boolean containsImageFile(MultipartFile[] files) { for (MultipartFile file : files) { if (aiFileTextExtractor.isImageFile(file)) { return true; } } return false; } private Flux chatWithPurchaseVisionModel(String memoryId, String userPrompt, MultipartFile[] files) { return Flux.create(sink -> { try { List contents = new ArrayList<>(); contents.add(TextContent.from(userPrompt)); for (MultipartFile file : files) { if (!aiFileTextExtractor.isImageFile(file)) { continue; } contents.add(TextContent.from("下面这张图片文件名:" + file.getOriginalFilename())); contents.add(ImageContent.from(Image.builder() .base64Data(Base64.getEncoder().encodeToString(file.getBytes())) .mimeType(resolveImageMimeType(file)) .build())); } List messages = List.of( SystemMessage.from("你是采购业务文件分析助手。请从文本和图片中识别采购台账、采购产品明细、付款或退货信息,只输出合法 JSON。"), UserMessage.from(contents) ); purchaseVisionStreamingChatModel.chat(messages, new StreamingChatResponseHandler() { @Override public void onPartialResponse(String partialResponse) { sink.next(partialResponse); } @Override public void onCompleteResponse(ChatResponse completeResponse) { sink.complete(); } @Override public void onError(Throwable error) { sink.error(error); } }); } catch (Exception ex) { sink.next("图片文件读取失败,请确认图片格式为 png、jpg、jpeg、webp 或 bmp,且大小不超过10MB"); sink.complete(); } }); } private String resolveImageMimeType(MultipartFile file) { String contentType = file.getContentType(); if (StringUtils.hasText(contentType) && contentType.startsWith("image/")) { return contentType; } String filename = file.getOriginalFilename(); String ext = ""; if (StringUtils.hasText(filename) && filename.contains(".")) { ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); } return switch (ext) { case "jpg", "jpeg" -> "image/jpeg"; case "webp" -> "image/webp"; case "bmp" -> "image/bmp"; default -> "image/png"; }; } private String buildPurchaseFileAnalyzePrompt(String message, String fileContent) { return """ 你是采购业务文件分析助手。请严格根据用户上传的多个文件和用户要求提取采购业务数据。 用户要求: %s 输出要求: 1. 只输出合法 JSON,不要 Markdown,不要额外解释。 2. JSON 顶层字段固定为: - success: boolean - businessType: purchase_ledger | payment_registration | purchase_return_order | unknown - action: confirm_required - description: 中文说明 - confidence: 0到1的小数 - missingFields: 缺失字段中文名称数组,面向客户展示,不要输出英文字段名 - warnings: 风险提示数组 - payload: 待客户确认的数据,字段名必须使用后端 DTO 字段名 - preview: 给客户确认用的中文摘要数组 3. 如果可判断为采购台账,businessType 使用 purchase_ledger,payload.purchaseLedgers 为采购订单/采购台账数组: - purchaseLedgers: 采购订单/采购台账数组,每条记录字段名必须与 PurchaseLedgerDto 保持一致 - 产品明细必须放在每条采购台账记录的 productData 字段中,productData 类型为 List - 不要优先使用 payload 顶层 productData;顶层 productData 仅作为旧格式兼容 - 文件里的“采购单号”就是“采购合同号”,统一映射为 purchaseContractNumber - 文件里的“销售单号”就是“销售合同号”,统一映射为 salesContractNo - 所有日期字段必须使用 yyyy-MM-dd,例如 2026-04-30;不要输出 4/30/26、2026/4/30、2026年4月30日 或带时分秒的格式 - 采购台账不需要在 payload 中传审批人,不要输出 approveUserIds、approverId - missingFields 只填写业务必填但无法识别的字段,不要把 PurchaseLedgerDto 的所有空字段都列为缺失;缺失项必须写中文,例如“供应商名称”“含税单价”,不要写 supplierId、taxInclusiveUnitPrice - 采购台账主表必填字段仅按这些判断: purchaseContractNumber、supplierName 或 supplierId - productData 每条产品必填字段: productCategory、specificationModel、unit、quantity、taxInclusiveUnitPrice 或 taxInclusiveTotalPrice;如果只有含税总价和数量,必须计算 taxInclusiveUnitPrice;如果只有含税单价和数量,必须计算 taxInclusiveTotalPrice - 产品字段按采购导入接口 PurchaseLedgerProductImportDto 对齐: 采购单号、产品大类、规格型号、单位、数量、税率、含税单价、含税总价、发票类型、是否质检 - 采购产品 type 固定为 2 - purchaseLedgers 每条记录只使用这些 PurchaseLedgerDto 字段名: entryDateStart, entryDateEnd, id, purchaseContractNumber, supplierId, supplierName, isWhite, recorderId, recorderName, salesContractNo, salesContractNoId, projectName, entryDate, executionDate, remarks, attachmentMaterials, createdAt, updatedAt, salesLedgerId, hasChildren, Type, productData, tempFileIds, SalesLedgerFiles, phoneNumber, businessPersonId, productId, productModelId, invoiceNumber, invoiceAmount, ticketRegistrationId, contractAmount, receiptPaymentAmount, unReceiptPaymentAmount, type, paymentMethod, approvalStatus, templateName - productData 每条产品只使用这些 SalesLedgerProduct 字段名: productCategory, specificationModel, unit, quantity, taxRate, taxInclusiveUnitPrice, taxInclusiveTotalPrice, taxExclusiveTotalPrice, invoiceType, productId, productModelId, isChecked, type 4. 如果可判断为付款登记,businessType 使用 payment_registration,payload.records 为付款登记数组,字段尽量包含 purchaseLedgerId、salesLedgerProductId、currentPaymentAmount、paymentMethod、paymentDate。 5. 如果可判断为采购退货,businessType 使用 purchase_return_order,payload 按 PurchaseReturnOrderDto 组织,明细放 purchaseReturnOrderProductsDtos。 6. 缺少业务处理必须字段时,不要编造 ID,把字段放入 missingFields,并仍返回可确认的草稿数据。 7. 所有中文内容直接保留,不要转义成 Unicode。 文件内容: %s """.formatted(message, fileContent); } private AjaxResult processPurchaseLedger(Map payload) throws Exception { if (payload.containsKey("purchaseLedgers")) { return processPurchaseLedgerBatch(payload); } Map normalizedPayload = normalizePurchaseLedgerMap(payload); PurchaseLedgerDto dto = objectMapper.convertValue(normalizedPayload, PurchaseLedgerDto.class); AjaxResult ledgerResult = validatePurchaseLedger(dto, 0); if (ledgerResult != null) { return ledgerResult; } AjaxResult supplierResult = fillSupplierIdByName(dto); if (supplierResult != null) { return supplierResult; } AjaxResult productResult = validatePurchaseProducts(dto.getProductData(), 0); if (productResult != null) { return productResult; } int result = purchaseLedgerService.addOrEditPurchase(dto); return AjaxResult.success("采购台账已处理", result); } private AjaxResult processPurchaseLedgerBatch(Map payload) throws Exception { List> purchaseLedgers = toMapList(payload.get("purchaseLedgers")); if (purchaseLedgers.isEmpty()) { return AjaxResult.error("purchaseLedgers不能为空"); } List> topLevelProductData = toMapList(payload.get("productData")); List> results = new ArrayList<>(); for (int i = 0; i < purchaseLedgers.size(); i++) { Map ledgerMap = normalizePurchaseLedgerMap(purchaseLedgers.get(i)); PurchaseLedgerDto dto = objectMapper.convertValue(ledgerMap, PurchaseLedgerDto.class); AjaxResult ledgerResult = validatePurchaseLedger(dto, i); if (ledgerResult != null) { return ledgerResult; } AjaxResult supplierResult = fillSupplierIdByName(dto); if (supplierResult != null) { return supplierResult; } List products = dto.getProductData(); if (products == null || products.isEmpty()) { products = matchProductsForLedger(ledgerMap, dto, topLevelProductData, purchaseLedgers.size() == 1); dto.setProductData(products); } AjaxResult productResult = validatePurchaseProducts(products, i); if (productResult != null) { return productResult; } int result = purchaseLedgerService.addOrEditPurchase(dto); Map item = new LinkedHashMap<>(); item.put("index", i); item.put("purchaseContractNumber", dto.getPurchaseContractNumber()); item.put("supplierId", dto.getSupplierId()); item.put("supplierName", dto.getSupplierName()); item.put("productCount", products.size()); item.put("result", result); results.add(item); } return AjaxResult.success("采购台账已批量处理", results); } private List matchProductsForLedger(Map ledgerMap, PurchaseLedgerDto dto, List> productData, boolean onlyOneLedger) { List products = new ArrayList<>(); for (Map productMap : productData) { if (onlyOneLedger || productBelongsToLedger(productMap, ledgerMap, dto)) { products.add(objectMapper.convertValue(normalizeSalesLedgerProductMap(productMap), SalesLedgerProduct.class)); } } return products; } private boolean productBelongsToLedger(Map productMap, Map ledgerMap, PurchaseLedgerDto dto) { Long productPurchaseLedgerId = longValue(productMap, "purchaseLedgerId", "purchaseId", "采购订单id", "采购台账id"); if (productPurchaseLedgerId != null && dto.getId() != null && productPurchaseLedgerId.equals(dto.getId())) { return true; } Long productSalesLedgerId = longValue(productMap, "salesLedgerId"); if (productSalesLedgerId != null && dto.getId() != null && productSalesLedgerId.equals(dto.getId())) { return true; } String productContractNo = stringValue(productMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号"); if (StringUtils.hasText(productContractNo) && StringUtils.hasText(dto.getPurchaseContractNumber()) && productContractNo.trim().equals(dto.getPurchaseContractNumber().trim())) { return true; } String ledgerContractNo = stringValue(ledgerMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号"); if (StringUtils.hasText(productContractNo) && StringUtils.hasText(ledgerContractNo) && productContractNo.trim().equals(ledgerContractNo.trim())) { return true; } String productSalesContractNo = stringValue(productMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号"); if (StringUtils.hasText(productSalesContractNo) && StringUtils.hasText(dto.getSalesContractNo()) && productSalesContractNo.trim().equals(dto.getSalesContractNo().trim())) { return true; } String ledgerSalesContractNo = stringValue(ledgerMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号"); if (StringUtils.hasText(productSalesContractNo) && StringUtils.hasText(ledgerSalesContractNo) && productSalesContractNo.trim().equals(ledgerSalesContractNo.trim())) { return true; } String productSupplierName = stringValue(productMap, "supplierName", "供应商名称"); return StringUtils.hasText(productSupplierName) && StringUtils.hasText(dto.getSupplierName()) && productSupplierName.trim().equals(dto.getSupplierName().trim()); } private Map normalizePurchaseLedgerMap(Map source) { Map target = new LinkedHashMap<>(); copyPurchaseLedgerDtoFields(source, target); putDtoFieldIfPresent(source, target, "entryDateStart", "录入开始日期", "录入日期开始"); putDtoFieldIfPresent(source, target, "entryDateEnd", "录入结束日期", "录入日期结束"); putDtoFieldIfPresent(source, target, "id", "采购台账id", "采购订单id", "主键"); putDtoFieldIfPresent(source, target, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号"); putDtoFieldIfPresent(source, target, "supplierId", "供应商id", "供应商ID", "供应商名称id", "供应商名称ID"); putDtoFieldIfPresent(source, target, "supplierName", "供应商", "供应商名称"); putDtoFieldIfPresent(source, target, "isWhite", "是否白名单"); putDtoFieldIfPresent(source, target, "recorderId", "录入人id", "录入人ID", "录入人姓名id", "录入人姓名ID"); putDtoFieldIfPresent(source, target, "recorderName", "录入人", "录入人姓名"); putDtoFieldIfPresent(source, target, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号"); putDtoFieldIfPresent(source, target, "salesContractNoId", "销售合同号id", "销售合同号ID", "销售单号id", "销售单号ID"); putDtoFieldIfPresent(source, target, "projectName", "项目", "项目名称"); putDtoFieldIfPresent(source, target, "entryDate", "录入日期"); putDtoFieldIfPresent(source, target, "executionDate", "签订日期", "合同签订日期"); putDtoFieldIfPresent(source, target, "remarks", "备注", "说明"); putDtoFieldIfPresent(source, target, "attachmentMaterials", "附件材料", "附件材料路径或名称"); putDtoFieldIfPresent(source, target, "createdAt", "创建时间", "记录创建时间"); putDtoFieldIfPresent(source, target, "updatedAt", "更新时间", "记录最后更新时间"); putDtoFieldIfPresent(source, target, "salesLedgerId", "销售台账id", "销售台账ID", "关联销售台账主表主键"); putDtoFieldIfPresent(source, target, "hasChildren", "是否有子级", "是否有明细"); putDtoFieldIfPresent(source, target, "Type", "台账类型", "业务类型"); putDtoFieldIfPresent(source, target, "productData", "products", "产品明细", "采购产品明细"); putDtoFieldIfPresent(source, target, "tempFileIds", "临时文件id", "临时文件ID", "临时文件ids"); putDtoFieldIfPresent(source, target, "SalesLedgerFiles", "附件列表", "销售台账附件"); putDtoFieldIfPresent(source, target, "phoneNumber", "业务员手机号", "手机号"); putDtoFieldIfPresent(source, target, "businessPersonId", "业务员id", "业务员ID"); putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID"); putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID"); putDtoFieldIfPresent(source, target, "invoiceNumber", "发票号", "发票号码"); putDtoFieldIfPresent(source, target, "invoiceAmount", "发票金额", "发票金额(元)"); putDtoFieldIfPresent(source, target, "ticketRegistrationId", "来票登记id", "来票登记ID"); putDtoFieldIfPresent(source, target, "contractAmount", "合同金额", "合同金额(产品含税总价)"); putDtoFieldIfPresent(source, target, "receiptPaymentAmount", "来票金额", "已来票金额", "已来票金额(元)"); putDtoFieldIfPresent(source, target, "unReceiptPaymentAmount", "未来票金额", "未来票金额(元)"); putDtoFieldIfPresent(source, target, "type", "文件类型"); putDtoFieldIfPresent(source, target, "paymentMethod", "付款方式"); putDtoFieldIfPresent(source, target, "approvalStatus", "审批状态"); putDtoFieldIfPresent(source, target, "templateName", "模板名称"); target.remove("approveUserIds"); target.remove("approverId"); normalizeNestedProductData(target); attachImportStyleProductData(source, target); if (target.get("type") == null) { target.put("type", 2); } target.putIfAbsent("entryDate", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)); normalizePurchaseLedgerDateFields(target); return target; } private void attachImportStyleProductData(Map source, Map target) { if (target.get("productData") != null) { return; } Map productMap = normalizeSalesLedgerProductMap(source); if (hasImportStyleProductData(productMap)) { target.put("productData", List.of(productMap)); } } private boolean hasImportStyleProductData(Map productMap) { return hasMapText(productMap, "productCategory") || hasMapText(productMap, "specificationModel") || productMap.get("quantity") != null || productMap.get("taxInclusiveUnitPrice") != null || productMap.get("taxInclusiveTotalPrice") != null; } private boolean hasMapText(Map map, String key) { Object value = map.get(key); return value != null && StringUtils.hasText(String.valueOf(value)); } private void normalizeNestedProductData(Map target) { Object productDataValue = target.get("productData"); if (productDataValue == null) { return; } List> productMaps = toMapList(productDataValue); List> normalizedProducts = new ArrayList<>(); for (Map productMap : productMaps) { normalizedProducts.add(normalizeSalesLedgerProductMap(productMap)); } target.put("productData", normalizedProducts); } private Map normalizeSalesLedgerProductMap(Map source) { Map target = new LinkedHashMap<>(); copySalesLedgerProductFields(source, target); putDtoFieldIfPresent(source, target, "productCategory", "产品大类", "产品名称", "产品", "品名", "物料名称"); putDtoFieldIfPresent(source, target, "specificationModel", "规格型号", "型号", "规格", "产品规格"); putDtoFieldIfPresent(source, target, "unit", "单位"); putDtoFieldIfPresent(source, target, "quantity", "数量", "采购数量"); putDtoFieldIfPresent(source, target, "taxRate", "税率"); putDtoFieldIfPresent(source, target, "taxInclusiveUnitPrice", "含税单价", "单价", "采购单价", "含税价格"); putDtoFieldIfPresent(source, target, "taxInclusiveTotalPrice", "含税总价", "总价", "采购金额", "金额", "合同金额"); putDtoFieldIfPresent(source, target, "taxExclusiveTotalPrice", "不含税总价"); putDtoFieldIfPresent(source, target, "invoiceType", "发票类型", "发票类别"); putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID"); putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID", "型号id", "型号ID"); putDtoFieldIfPresent(source, target, "isChecked", "是否质检", "是否质检验", "质检"); putDtoFieldIfPresent(source, target, "type", "台账类型"); normalizeProductAmounts(target); target.putIfAbsent("type", 2); return target; } private void copySalesLedgerProductFields(Map source, Map target) { String[] productFields = { "id", "salesLedgerId", "warnNum", "productCategory", "specificationModel", "unit", "speculativeTradingName", "quantity", "minStock", "taxRate", "taxInclusiveUnitPrice", "taxInclusiveTotalPrice", "taxExclusiveTotalPrice", "invoiceType", "type", "ticketsNum", "ticketsAmount", "futureTickets", "futureTicketsAmount", "invoiceNum", "noInvoiceNum", "invoiceAmount", "noInvoiceAmount", "productId", "productModelId", "register", "registerDate", "approveStatus", "pendingInvoiceTotal", "invoiceTotal", "pendingTicketsTotal", "ticketsTotal", "isChecked", "isProduction" }; for (String field : productFields) { if (source.containsKey(field)) { target.put(field, source.get(field)); } } } private void normalizeProductAmounts(Map target) { BigDecimal quantity = decimalValue(target.get("quantity")); BigDecimal unitPrice = decimalValue(target.get("taxInclusiveUnitPrice")); BigDecimal totalPrice = decimalValue(target.get("taxInclusiveTotalPrice")); if (unitPrice == null && totalPrice != null && quantity != null && quantity.compareTo(BigDecimal.ZERO) != 0) { target.put("taxInclusiveUnitPrice", totalPrice.divide(quantity, 6, RoundingMode.HALF_UP)); } if (totalPrice == null && unitPrice != null && quantity != null) { target.put("taxInclusiveTotalPrice", unitPrice.multiply(quantity)); } BigDecimal taxRate = decimalValue(target.get("taxRate")); totalPrice = decimalValue(target.get("taxInclusiveTotalPrice")); if (target.get("taxExclusiveTotalPrice") == null && totalPrice != null && taxRate != null) { BigDecimal divisor = BigDecimal.ONE.add(taxRate.divide(new BigDecimal("100"), 6, RoundingMode.HALF_UP)); target.put("taxExclusiveTotalPrice", totalPrice.divide(divisor, 2, RoundingMode.HALF_UP)); } } private AjaxResult validatePurchaseProducts(List products, int ledgerIndex) { if (products == null || products.isEmpty()) { return null; } for (int i = 0; i < products.size(); i++) { SalesLedgerProduct product = products.get(i); String prefix = "第" + (ledgerIndex + 1) + "个采购台账的第" + (i + 1) + "条产品"; if (!StringUtils.hasText(product.getProductCategory())) { return AjaxResult.error(prefix + "缺少产品名称,请补充后再确认"); } if (!StringUtils.hasText(product.getSpecificationModel())) { return AjaxResult.error(prefix + "缺少规格型号,请补充后再确认"); } if (!StringUtils.hasText(product.getUnit())) { return AjaxResult.error(prefix + "缺少单位,请补充后再确认"); } if (product.getQuantity() == null) { return AjaxResult.error(prefix + "缺少数量"); } if (product.getTaxInclusiveUnitPrice() == null) { return AjaxResult.error(prefix + "缺少含税单价,请补充后再确认"); } if (product.getTaxInclusiveTotalPrice() == null) { return AjaxResult.error(prefix + "缺少含税总价,请补充后再确认"); } } return null; } private AjaxResult validatePurchaseLedger(PurchaseLedgerDto dto, int ledgerIndex) { String prefix = "第" + (ledgerIndex + 1) + "个采购台账"; if (!StringUtils.hasText(dto.getPurchaseContractNumber())) { return AjaxResult.error(prefix + "缺少采购合同号,请补充后再确认"); } if (dto.getSupplierId() == null && !StringUtils.hasText(dto.getSupplierName())) { return AjaxResult.error(prefix + "缺少供应商名称,请补充后再确认"); } return null; } private void normalizePurchaseLedgerDateFields(Map target) { normalizeDateField(target, "entryDate"); normalizeDateField(target, "executionDate"); normalizeDateField(target, "createdAt"); normalizeDateField(target, "updatedAt"); } private void normalizeDateField(Map target, String fieldName) { Object value = target.get(fieldName); if (value == null) { return; } String normalizedDate = normalizeDateValue(value); if (StringUtils.hasText(normalizedDate)) { target.put(fieldName, normalizedDate); } } private String normalizeDateValue(Object value) { if (value instanceof Date date) { return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE); } if (value instanceof Number number) { return LocalDate.of(1899, 12, 30) .plusDays(number.longValue()) .format(DateTimeFormatter.ISO_LOCAL_DATE); } String text = String.valueOf(value).trim(); if (!StringUtils.hasText(text)) { return null; } if (text.length() >= 10 && text.charAt(4) == '-' && text.charAt(7) == '-') { return text.substring(0, 10); } String normalizedText = text.replace("年", "-") .replace("月", "-") .replace("日", "") .replace(".", "-") .replace("/", "-") .trim(); DateTimeFormatter[] formatters = { DateTimeFormatter.ofPattern("yyyy-M-d"), DateTimeFormatter.ofPattern("M-d-yyyy"), DateTimeFormatter.ofPattern("M-d-yy") }; for (DateTimeFormatter formatter : formatters) { try { return LocalDate.parse(normalizedText, formatter).format(DateTimeFormatter.ISO_LOCAL_DATE); } catch (DateTimeParseException ignored) { // Try the next supported input pattern. } } return text; } private void copyPurchaseLedgerDtoFields(Map source, Map target) { String[] dtoFields = { "entryDateStart", "entryDateEnd", "id", "purchaseContractNumber", "supplierId", "supplierName", "isWhite", "recorderId", "recorderName", "salesContractNo", "salesContractNoId", "projectName", "entryDate", "executionDate", "remarks", "attachmentMaterials", "createdAt", "updatedAt", "salesLedgerId", "hasChildren", "Type", "productData", "tempFileIds", "SalesLedgerFiles", "phoneNumber", "businessPersonId", "productId", "productModelId", "invoiceNumber", "invoiceAmount", "ticketRegistrationId", "contractAmount", "receiptPaymentAmount", "unReceiptPaymentAmount", "type", "paymentMethod", "approvalStatus", "templateName" }; for (String field : dtoFields) { if (source.containsKey(field)) { target.put(field, source.get(field)); } } } private void putDtoFieldIfPresent(Map source, Map target, String dtoField, String... aliases) { if (target.containsKey(dtoField) && target.get(dtoField) != null) { return; } for (String alias : aliases) { Object value = source.get(alias); if (value != null && StringUtils.hasText(String.valueOf(value))) { target.put(dtoField, value); return; } } } private List> toMapList(Object value) { if (value == null) { return List.of(); } return objectMapper.convertValue(value, new TypeReference>>() { }); } private String stringValue(Map map, String... keys) { for (String key : keys) { Object value = map.get(key); if (value != null && StringUtils.hasText(String.valueOf(value))) { return String.valueOf(value); } } return null; } private Long longValue(Map map, String... keys) { String value = stringValue(map, keys); if (!StringUtils.hasText(value)) { return null; } try { return Long.parseLong(value.trim()); } catch (NumberFormatException ignored) { return null; } } private BigDecimal decimalValue(Object value) { if (value == null) { return null; } if (value instanceof BigDecimal decimal) { return decimal; } if (value instanceof Number number) { return new BigDecimal(String.valueOf(number)); } String text = String.valueOf(value) .replace(",", "") .replace(",", "") .replace("元", "") .replace("¥", "") .trim(); if (!StringUtils.hasText(text)) { return null; } try { return new BigDecimal(text); } catch (NumberFormatException ignored) { return null; } } private String toCustomerMessage(Exception ex) { String message = ex.getMessage(); if (!StringUtils.hasText(message)) { return "处理失败,请检查确认数据后重试"; } if (message.contains("tax_inclusive_unit_price")) { return "处理失败:产品明细缺少含税单价,请补充后再确认"; } if (message.contains("tax_inclusive_total_price")) { return "处理失败:产品明细缺少含税总价,请补充后再确认"; } if (message.contains("entryDate")) { return "处理失败:录入日期格式不正确,请使用 yyyy-MM-dd,例如 2026-04-30"; } if (message.contains("supplier")) { return "处理失败:供应商信息不完整,请确认供应商名称或供应商ID"; } if (message.contains("SQL") || message.contains("java.") || message.contains("Exception")) { return "处理失败:确认数据不完整或格式不正确,请检查必填字段后重试"; } return "处理失败:" + message; } private AjaxResult fillSupplierIdByName(PurchaseLedgerDto dto) { if (dto.getSupplierId() != null) { return null; } if (!StringUtils.hasText(dto.getSupplierName())) { return AjaxResult.error("供应商ID不能为空;未识别到供应商名称,无法自动匹配供应商ID"); } SupplierManage supplier = supplierManageMapper.selectOne(new LambdaQueryWrapper() .eq(SupplierManage::getSupplierName, dto.getSupplierName().trim()) .last("limit 1")); if (supplier == null) { return AjaxResult.error("未找到供应商:" + dto.getSupplierName() + ",请先维护供应商或手动选择供应商ID"); } dto.setSupplierId(supplier.getId()); return null; } private AjaxResult processPaymentRegistration(Map payload) { Object recordsValue = payload.get("records"); List records; if (recordsValue == null) { records = Collections.singletonList(objectMapper.convertValue(payload, PaymentRegistration.class)); } else { records = objectMapper.convertValue(recordsValue, new TypeReference>() { }); } int result = paymentRegistrationService.insertPaymentRegistration(records); return AjaxResult.success("付款登记已处理", result); } private AjaxResult processPurchaseReturnOrder(Map payload) { PurchaseReturnOrderDto dto = objectMapper.convertValue(payload, PurchaseReturnOrderDto.class); Boolean result = purchaseReturnOrdersService.add(dto); return AjaxResult.success("采购退货单已处理", result); } }