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<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 = 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<String> 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<String> chatWithPurchaseVisionModel(String memoryId, String userPrompt, MultipartFile[] files) {
|
return Flux.create(sink -> {
|
try {
|
List<Content> 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<ChatMessage> 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<SalesLedgerProduct>
|
- 不要优先使用 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<String, Object> payload) throws Exception {
|
if (payload.containsKey("purchaseLedgers")) {
|
return processPurchaseLedgerBatch(payload);
|
}
|
|
Map<String, Object> 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<String, Object> payload) throws Exception {
|
List<Map<String, Object>> purchaseLedgers = toMapList(payload.get("purchaseLedgers"));
|
if (purchaseLedgers.isEmpty()) {
|
return AjaxResult.error("purchaseLedgers不能为空");
|
}
|
|
List<Map<String, Object>> topLevelProductData = toMapList(payload.get("productData"));
|
List<Map<String, Object>> results = new ArrayList<>();
|
for (int i = 0; i < purchaseLedgers.size(); i++) {
|
Map<String, Object> 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<SalesLedgerProduct> 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<String, Object> 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<SalesLedgerProduct> matchProductsForLedger(Map<String, Object> ledgerMap,
|
PurchaseLedgerDto dto,
|
List<Map<String, Object>> productData,
|
boolean onlyOneLedger) {
|
List<SalesLedgerProduct> products = new ArrayList<>();
|
for (Map<String, Object> productMap : productData) {
|
if (onlyOneLedger || productBelongsToLedger(productMap, ledgerMap, dto)) {
|
products.add(objectMapper.convertValue(normalizeSalesLedgerProductMap(productMap), SalesLedgerProduct.class));
|
}
|
}
|
return products;
|
}
|
|
private boolean productBelongsToLedger(Map<String, Object> productMap, Map<String, Object> 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<String, Object> normalizePurchaseLedgerMap(Map<String, Object> source) {
|
Map<String, Object> 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<String, Object> source, Map<String, Object> target) {
|
if (target.get("productData") != null) {
|
return;
|
}
|
Map<String, Object> productMap = normalizeSalesLedgerProductMap(source);
|
if (hasImportStyleProductData(productMap)) {
|
target.put("productData", List.of(productMap));
|
}
|
}
|
|
private boolean hasImportStyleProductData(Map<String, Object> productMap) {
|
return hasMapText(productMap, "productCategory")
|
|| hasMapText(productMap, "specificationModel")
|
|| productMap.get("quantity") != null
|
|| productMap.get("taxInclusiveUnitPrice") != null
|
|| productMap.get("taxInclusiveTotalPrice") != null;
|
}
|
|
private boolean hasMapText(Map<String, Object> map, String key) {
|
Object value = map.get(key);
|
return value != null && StringUtils.hasText(String.valueOf(value));
|
}
|
|
private void normalizeNestedProductData(Map<String, Object> target) {
|
Object productDataValue = target.get("productData");
|
if (productDataValue == null) {
|
return;
|
}
|
List<Map<String, Object>> productMaps = toMapList(productDataValue);
|
List<Map<String, Object>> normalizedProducts = new ArrayList<>();
|
for (Map<String, Object> productMap : productMaps) {
|
normalizedProducts.add(normalizeSalesLedgerProductMap(productMap));
|
}
|
target.put("productData", normalizedProducts);
|
}
|
|
private Map<String, Object> normalizeSalesLedgerProductMap(Map<String, Object> source) {
|
Map<String, Object> 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<String, Object> source, Map<String, Object> 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<String, Object> 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<SalesLedgerProduct> 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<String, Object> target) {
|
normalizeDateField(target, "entryDate");
|
normalizeDateField(target, "executionDate");
|
normalizeDateField(target, "createdAt");
|
normalizeDateField(target, "updatedAt");
|
}
|
|
private void normalizeDateField(Map<String, Object> 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<String, Object> source, Map<String, Object> 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<String, Object> source, Map<String, Object> 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<Map<String, Object>> toMapList(Object value) {
|
if (value == null) {
|
return List.of();
|
}
|
return objectMapper.convertValue(value, new TypeReference<List<Map<String, Object>>>() {
|
});
|
}
|
|
private String stringValue(Map<String, Object> 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<String, Object> 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<SupplierManage>()
|
.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<String, Object> payload) {
|
Object recordsValue = payload.get("records");
|
List<PaymentRegistration> records;
|
if (recordsValue == null) {
|
records = Collections.singletonList(objectMapper.convertValue(payload, PaymentRegistration.class));
|
} else {
|
records = objectMapper.convertValue(recordsValue, new TypeReference<List<PaymentRegistration>>() {
|
});
|
}
|
int result = paymentRegistrationService.insertPaymentRegistration(records);
|
return AjaxResult.success("付款登记已处理", result);
|
}
|
|
private AjaxResult processPurchaseReturnOrder(Map<String, Object> payload) {
|
PurchaseReturnOrderDto dto = objectMapper.convertValue(payload, PurchaseReturnOrderDto.class);
|
Boolean result = purchaseReturnOrdersService.add(dto);
|
return AjaxResult.success("采购退货单已处理", result);
|
}
|
}
|