liyong
4 小时以前 1ca5584d7e3200a9af65a099bd26d3593e2ba702
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
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);
    }
}