Просмотр исходного кода

优化团组费用审核与AI商邀检索健壮性

本次提交:
- 团组费用审核支持按费用类型筛选,优化总览实现与权限校验,完善消息推送逻辑;
- AI商邀检索引入失败审计机制,统一解析与前端提示,Prompt结构化,SSE推送更精细;
- 通用方法新增AI失败审计结构与辅助方法,Prompt生成逻辑重构;
- 优化异常处理与事务回滚,补充注释说明,提升健壮性与可维护性。
Lyyyi дней назад: 4
Родитель
Сommit
9835411dea

+ 165 - 149
OASystem/OASystem.Api/Controllers/GroupsController.cs

@@ -10907,10 +10907,21 @@ FROM
                 var clientNameList = GetSimplClientList(_dto.DiId);
 
                 #region 费用清单
+                // 传入审核状态
+                int auditStatus = _dto.AuditStatus;
+                // 审核通过 包含 手动审核通过、自动审核通过
+                int approvedStatus = 1;
+                // 全部状态 包含 审核通过、审核不通过、待审核
+                int allStatus = -1;
+                // 审核通过的审核类型Id列表(IsAuditGM字段值列表)
+                var approvedAuditIds = new List<int>() { 1, 3 };
+
                 var exp = Expressionable.Create<Grp_CreditCardPayment>();
-                exp.AndIF(_dto.AuditStatus != -1, it => it.IsAuditGM == _dto.AuditStatus);
                 exp.AndIF(_dto.Label != -1, it => it.CTable == _dto.Label);
 
+                if (auditStatus == approvedStatus) exp.And(it => approvedAuditIds.Contains(it.IsAuditGM));
+                else if (auditStatus != allStatus) exp.And(it => it.IsAuditGM == auditStatus);
+
                 var entityList = _groupRepository.Query<Grp_CreditCardPayment>(s => s.DIId == _dto.DiId && s.IsDel == 0 && s.CreateUserId > 0).Where(exp.ToExpression()).ToList();
 
                 var detailList = new List<Grp_CreditCardPaymentDetailView>();
@@ -11155,8 +11166,6 @@ FROM
                                          checkOut = Convert.ToDateTime(hotelReservations.CheckOutDate);
                                 int hotel_days = (int)(checkOut - checkIn).TotalDays;
 
-
-
                                 string roomFeeStr = "", roomFeestr1 = "";
 
                                 //是否比较房型价格
@@ -11784,6 +11793,13 @@ FROM
                 _view.TotalStr4 = $"{auditedFundsStr[..^1]}<br/>{unAuditedFundsStr[..^1]}";
                 //_view.TotalStr5 = unAuditedFundsStr.Substring(0, unAuditedFundsStr.Length - 1);
 
+                // 单独处理手机端费用项合计
+                if (_dto.PortType == 3)
+                {
+                    var result = await GetExpenseAuditOverviewCore(_dto.DiId, _dto.AuditStatus);
+                    _view.Overview = result.Where(x => x.FeeTypeId == _dto.Label).FirstOrDefault();
+                }
+
                 var _view1 = new
                 {
                     PageFuncAuth = pageFunAuthView,
@@ -11802,138 +11818,149 @@ FROM
         /// <summary>
         /// 获取团组费用审核 总览信息
         /// </summary>
-        /// <param name="_dto"></param>
+        /// <param name="dto"></param>
         /// <returns></returns>
         [HttpPost]
         [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
-        public async Task<IActionResult> GetExpenseAuditOverview(ExpenseAuditOverviewDto _dto)
+        public async Task<IActionResult> GetExpenseAuditOverview(ExpenseAuditOverviewDto dto)
         {
-            try
-            {
-                #region  参数验证
-                if (_dto.UserId < 1) return Ok(JsonView(false, "员工Id为空"));
-                if (_dto.PageId < 1) return Ok(JsonView(false, "页面Id为空"));
-                if (_dto.DiId < 1) return Ok(JsonView(false, "团组Id为空"));
-
-                #region 页面操作权限验证
-                var pageFunAuthView = await GeneralMethod.PostUserPageFuncDatas(_dto.UserId, _dto.PageId);
+            #region  参数验证
+            if (dto.UserId < 1) return Ok(JsonView(false, "员工Id为空"));
+            if (dto.PageId < 1) return Ok(JsonView(false, "页面Id为空"));
+            if (dto.DiId < 1) return Ok(JsonView(false, "团组Id为空"));
 
-                if (pageFunAuthView.CheckAuth == 0) return Ok(JsonView(false, "您没有查看权限"));
-                #endregion
+            #region 页面操作权限验证
+            var pageFunAuthView = await GeneralMethod.PostUserPageFuncDatas(dto.UserId, dto.PageId);
 
-                #endregion
+            if (pageFunAuthView.CheckAuth == 0) return Ok(JsonView(false, "您没有查看权限"));
+            #endregion
 
-                // 设置包含项、排序
-                var setDataTypeIds = new List<int>()
-                {
-                    80, 85, 76, 79, 81, 82, 98, 285, 1015
-                };
+            #endregion
 
-                // 初始化费用类型
-                var view = await _sqlSugar.Queryable<Sys_SetData>()
-                    .Where(x => x.STid == 16 && x.IsDel == 0 && setDataTypeIds.Contains(x.Id))
-                    .Select(x => new ExpenseAuditOverview()
-                    {
-                        FeeTypeId = x.Id,
-                        FeeTypeName = x.Name,
-                    })
-                    .ToListAsync();
+            var view = await GetExpenseAuditOverviewCore(dto.DiId, dto.AuditStatus);
 
-                // 创建顺序映射字典
-                var orderMap = new Dictionary<int, int>();
-                for (int i = 0; i < setDataTypeIds.Count; i++)
+            // 返回单项
+            if (dto.FeeType != -1)
+            {
+                return Ok(JsonView(new
                 {
-                    orderMap[setDataTypeIds[i]] = i;
-                }
+                    pageFuncAuth = pageFunAuthView,
+                    overview = view.Where(x => x.FeeTypeId == dto.FeeType).FirstOrDefault()
+                }));
+            }
 
-                // 按指定顺序排序(使用字典映射)
-                view = view
-                    .OrderBy(x => orderMap.ContainsKey(x.FeeTypeId) ? orderMap[x.FeeTypeId] : int.MaxValue)
-                    .ThenBy(x => x.FeeTypeId)
-                    .ToList();
+            // 返回多项
+            return Ok(JsonView(new
+            {
+                pageFuncAuth = pageFunAuthView,
+                overview = view
+            }));
+        }
 
-                // 审核集合 0 未审核 1已通过 2未通过 3 自动审核
-                var auditStatus = new List<int>();
-                if (_dto.AuditStatus == 0) auditStatus.Add(0);  // 待审核
-                else if (_dto.AuditStatus == 1) auditStatus.AddRange(new List<int> { 1, 3 }); // 已审核
-                else if (_dto.AuditStatus == 2) auditStatus.Add(2); // 未通过
-
-                var cardPayments = await _sqlSugar.Queryable<Grp_CreditCardPayment>()
-                    .LeftJoin<Sys_SetData>((x, y) => x.PaymentCurrency == y.Id)
-                    .Where((x, y) => x.IsDel == 0 && x.DIId == _dto.DiId)
-                    .WhereIF(auditStatus.Any(), (x, y) => auditStatus.Contains(x.IsAuditGM))
-                    .Select((x, y) => new {
-                        x.Id,
-                        x.DIId,
-                        x.CId,
-                        x.CTable,
-                        x.PayMoney,
-                        x.PaymentCurrency,
-                        PayCurrencyName = y.Name,
-                        x.DayRate,
-                        x.PayPercentage,
-                        x.IsAuditGM,
-                    })
-                    .ToListAsync();
+        /// <summary>
+        /// 获取费用审核总览
+        /// </summary>
+        private async Task<List<ExpenseAuditOverview>> GetExpenseAuditOverviewCore(int diId, int auditStatus)
+        {
+            // 设置包含项、排序
+            var setDataTypeIds = new List<int> { 80, 85, 76, 79, 81, 82, 98, 285, 1015 };
 
-                foreach (var item in view)
+            // 初始化费用类型
+            var view = await _sqlSugar.Queryable<Sys_SetData>()
+                .Where(x => x.STid == 16 && x.IsDel == 0 && setDataTypeIds.Contains(x.Id))
+                .Select(x => new ExpenseAuditOverview()
                 {
-                    var currTypeDatas = cardPayments.Where(x => x.CTable == item.FeeTypeId).ToList();
+                    FeeTypeId = x.Id,
+                    FeeTypeName = x.Name,
+                })
+                .ToListAsync();
 
-                    if (!currTypeDatas.Any()) continue;
+            // 创建顺序映射字典
+            var orderMap = setDataTypeIds.Select((id, index) => new { id, index })
+                .ToDictionary(x => x.id, x => x.index);
 
-                    item.TotalRecords = currTypeDatas.Count;
-                    item.PendingCount = currTypeDatas.Count(x => x.IsAuditGM == 0);
+            // 按指定顺序排序
+            view = view
+                .OrderBy(x => orderMap.ContainsKey(x.FeeTypeId) ? orderMap[x.FeeTypeId] : int.MaxValue)
+                .ThenBy(x => x.FeeTypeId)
+                .ToList();
 
-                    item.TotalPayableAmounts = currTypeDatas.GroupBy(x => x.PayCurrencyName)
-                        .Select(g => new AuditOverviewFeeInfo()
-                        {
-                            Amount = g.Sum(x => x.PayMoney),
-                            Currency = g.Key
-                        })
-                        .ToList();
+            // 审核状态处理
+            var auditStatusList = new List<int>();
+            if (auditStatus == 0) auditStatusList.Add(0);
+            else if (auditStatus == 1) auditStatusList.AddRange(new List<int> { 1, 3 });
+            else if (auditStatus == 2) auditStatusList.Add(2);
+
+            // 查询信用卡支付记录
+            var cardPayments = await _sqlSugar.Queryable<Grp_CreditCardPayment>()
+                .LeftJoin<Sys_SetData>((x, y) => x.PaymentCurrency == y.Id)
+                .Where((x, y) => x.IsDel == 0 && x.DIId == diId)
+                .WhereIF(auditStatusList.Any(), (x, y) => auditStatusList.Contains(x.IsAuditGM))
+                .Select((x, y) => new
+                {
+                    x.Id,
+                    x.DIId,
+                    x.CId,
+                    x.CTable,
+                    x.PayMoney,
+                    x.PaymentCurrency,
+                    PayCurrencyName = y.Name,
+                    x.DayRate,
+                    x.PayPercentage,
+                    x.IsAuditGM,
+                })
+                .ToListAsync();
 
-                    item.RemainingBalanceAmounts = currTypeDatas.Where(x => x.IsAuditGM == 0)
-                        .GroupBy(x => x.PayCurrencyName)
-                        .Select(g => new AuditOverviewFeeInfo()
-                        {
-                            Amount = g.Sum(x => x.PayMoney * (x.PayPercentage / 100)),
-                            Currency = g.Key
-                        })
-                        .ToList();
+            // 计算各项金额
+            foreach (var item in view)
+            {
+                var currTypeDatas = cardPayments.Where(x => x.CTable == item.FeeTypeId).ToList();
+                if (!currTypeDatas.Any()) continue;
 
-                    item.ApprovedExpenseAmounts = currTypeDatas.Where(x => x.IsAuditGM == 1 || x.IsAuditGM == 3)
-                        .GroupBy(x => x.PayCurrencyName)
-                        .Select(g => new AuditOverviewFeeInfo()
-                        {
-                            Amount = g.Sum(x => x.PayMoney * (x.PayPercentage / 100)),
-                            Currency = g.Key
-                        })
-                        .ToList();
+                item.TotalRecords = currTypeDatas.Count;
+                item.PendingCount = currTypeDatas.Count(x => x.IsAuditGM == 0);
 
-                    item.PendingExpenseAmounts = currTypeDatas.Where(x => x.IsAuditGM == 0)
-                        .GroupBy(x => x.PayCurrencyName)
-                        .Select(g => new AuditOverviewFeeInfo()
-                        {
-                            Amount = g.Sum(x => x.PayMoney * (x.PayPercentage / 100)),
-                            Currency = g.Key
-                        })
-                        .ToList();
-                }
+                item.TotalPayableAmounts = currTypeDatas
+                    .GroupBy(x => x.PayCurrencyName)
+                    .Select(g => new AuditOverviewFeeInfo()
+                    {
+                        Amount = g.Sum(x => x.PayMoney),
+                        Currency = g.Key
+                    })
+                    .ToList();
 
-                var res = new
-                {
-                    PageFuncAuth = pageFunAuthView,
-                    Overview = view
-                };
+                item.RemainingBalanceAmounts = currTypeDatas
+                    .Where(x => x.IsAuditGM == 0)
+                    .GroupBy(x => x.PayCurrencyName)
+                    .Select(g => new AuditOverviewFeeInfo()
+                    {
+                        Amount = g.Sum(x => x.PayMoney * (x.PayPercentage / 100)),
+                        Currency = g.Key
+                    })
+                    .ToList();
 
-                return Ok(JsonView(res));
-            }
-            catch (Exception ex)
-            {
+                item.ApprovedExpenseAmounts = currTypeDatas
+                    .Where(x => x.IsAuditGM == 1 || x.IsAuditGM == 3)
+                    .GroupBy(x => x.PayCurrencyName)
+                    .Select(g => new AuditOverviewFeeInfo()
+                    {
+                        Amount = g.Sum(x => x.PayMoney * (x.PayPercentage / 100)),
+                        Currency = g.Key
+                    })
+                    .ToList();
 
-                return Ok(JsonView(false, ex.Message));
+                item.PendingExpenseAmounts = currTypeDatas
+                    .Where(x => x.IsAuditGM == 0)
+                    .GroupBy(x => x.PayCurrencyName)
+                    .Select(g => new AuditOverviewFeeInfo()
+                    {
+                        Amount = g.Sum(x => x.PayMoney * (x.PayPercentage / 100)),
+                        Currency = g.Key
+                    })
+                    .ToList();
             }
+
+            return view;
         }
 
         /// <summary>
@@ -11968,6 +11995,7 @@ FROM
             var creditCurrencyDatas = _grpScheduleRep._sqlSugar.Queryable<Sys_SetData>().Where(it => it.IsDel == 0 && it.STid == 66).ToList();
             var groupDatas = _grpScheduleRep._sqlSugar.Queryable<Grp_DelegationInfo>().Where(it => it.IsDel == 0).ToList();
 
+            var feeTypeId = creditDatas.Select(it => it.CTable).FirstOrDefault();
             var msgDatas = new List<dynamic>();
             var dic_ccp_user = new Dictionary<int, int>() { };
             foreach (var item in idList)
@@ -12045,30 +12073,33 @@ FROM
             if (rst == 0)
             {
                 _groupRepository.CommitTran();
-                foreach (var item in msgDatas)
-                {
-                    //发送消息
-                    GeneralMethod.MessageIssueAndNotification(MessageTypeEnum.GroupExpenseAudit, item.MsgTitle, item.MsgContent, new List<int>() { item.UserId }, item.DiId);
-                }
 
                 #region 应用推送
-                try
+                _ = Task.Run(async () =>
                 {
-                    foreach (var ccpId in dic_ccp_user.Keys)
+                    try
                     {
-                        var templist = new List<string>() { dic_ccp_user[ccpId].ToString() };
-                        await AppNoticeLibrary.SendUserMsg_GroupStatus_AuditFee(ccpId, templist, QiyeWeChatEnum.CaiWuChat);
+                        foreach (var item in msgDatas)
+                        {
+                            //发送消息
+                            GeneralMethod.MessageIssueAndNotification(MessageTypeEnum.GroupExpenseAudit, item.MsgTitle, item.MsgContent, new List<int>() { item.UserId }, item.DiId);
+                        }
+
+                        foreach (var ccpId in dic_ccp_user.Keys)
+                        {
+                            var templist = new List<string>() { dic_ccp_user[ccpId].ToString() };
+                            await AppNoticeLibrary.SendUserMsg_GroupStatus_AuditFee(ccpId, templist, QiyeWeChatEnum.CaiWuChat);
+                        }
                     }
-                }
-                catch (Exception)
-                {
-                }
+                    catch (Exception)
+                    {
+                    }
+                });
                 #endregion
 
-
                 return Ok(JsonView(true, "操作成功!"));
             }
-
+            _groupRepository.RollbackTran();
             return Ok(JsonView(false, "操作失败!"));
         }
 
@@ -12116,19 +12147,12 @@ FROM
         [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
         public async Task<IActionResult> AirTicketResSelect(AirTicketResDto dto)
         {
-            try
-            {
-                Result groupData = await _airTicketResRep.AirTicketResSelect(dto);
-                if (groupData.Code != 0)
-                {
-                    return Ok(JsonView(false, groupData.Msg));
-                }
-                return Ok(JsonView(true, groupData.Msg, groupData.Data));
-            }
-            catch (Exception ex)
+            Result groupData = await _airTicketResRep.AirTicketResSelect(dto);
+            if (groupData.Code != 0)
             {
-                return Ok(JsonView(false, ex.Message));
+                return Ok(JsonView(false, groupData.Msg));
             }
+            return Ok(JsonView(true, groupData.Msg, groupData.Data));
         }
 
         /// <summary>
@@ -12140,20 +12164,12 @@ FROM
         [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
         public async Task<IActionResult> AirTicketResList(AirTicketResDto dto)
         {
-            try
-            {
-                Result groupData = await _airTicketResRep.AirTicketResList(dto);
-                if (groupData.Code != 0)
-                {
-                    return Ok(JsonView(false, groupData.Msg));
-                }
-                return Ok(JsonView(true, groupData.Msg, groupData.Data));
-            }
-            catch (Exception ex)
+            Result groupData = await _airTicketResRep.AirTicketResList(dto);
+            if (groupData.Code != 0)
             {
-                return Ok(JsonView(false, ex.Message));
-                throw;
+                return Ok(JsonView(false, groupData.Msg));
             }
+            return Ok(JsonView(true, groupData.Msg, groupData.Data));
         }
 
         /// <summary>

+ 159 - 77
OASystem/OASystem.Api/Controllers/ResourceController.cs

@@ -2793,28 +2793,50 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                     // 任务配置计算
                     var countryTasks = QuotaScheduler.GenerateTasks(aiTasks, entryInfo.Industries, entryInfo.ScaleTypes);
 
-                    var countryTasksGroupBy = countryTasks.GroupBy(x => x.Region).Select(g => new
-                    {
-                        Region = g.Key,
-                        Counrt = g.Count()
-                    }).ToList();
+                    var countryTasksGroupBy = countryTasks.GroupBy(x => x.Region)
+                        .Select(g => new
+                        {
+                            Region = g.Key,
+                            Counrt = g.Count()
+                        })
+                        .ToList();
 
-                    await HttpContext.SendSseStepAsync(60, $"AI 正在跨境检索缺失的 {string.Join(", ", countryTasksGroupBy.Select(x => $"{x.Region}({x.Counrt}条)"))} 单位资料...");
+                    await HttpContext.SendSseStepAsync(60, $"AI 正在跨境检索 {string.Join(", ", countryTasksGroupBy.Select(x => $"{x.Region}({x.Counrt}条)"))} 单位资料...");
                     string searchQuestion = BuildHunyuanPrompt(aiTasks, countryTasks, entryInfo);
 
                     _logger.LogInformation(@"公务名称:{InvName};  混元AI查询提示词:{searchQuestion}", invAiInfo.InvName, searchQuestion);
                     string searchRaw = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(searchQuestion);
 
-                    var aiParsed = CleanAndParseJson<List<InvitationAIInfo>>(searchRaw);
-                    if (aiParsed != null)
+                    var audit = ParseHunyuanResult<InvitationAIInfo>(searchRaw);
+
+                    if (audit.IsSuccess && audit.Data != null)
                     {
-                        hunyuanAIInvDatas = aiParsed.Select(x => {
+                        hunyuanAIInvDatas = audit.Data.Select(x =>
+                        {
                             x.Guid = Guid.NewGuid().ToString("N");
                             x.Source = 1;
                             x.Operator = operatorName;
                             x.OperatedAt = DateTime.Now;
                             return x;
                         }).ToList();
+
+                        await HttpContext.SendSseStepAsync(80,
+                            $"AI 已完成跨境检索,共获取 {hunyuanAIInvDatas.Count} 条单位资料");
+                    }
+                    else
+                    {
+                        _logger.LogWarning(
+                            "公务名称:{InvName};AI 检索失败;Step={Step};Reason={Reason}",
+                            invAiInfo.InvName,
+                            audit.AuditStep,
+                            audit.AuditReason
+                        );
+
+                        await HttpContext.SendSseStepAsync(80,
+                            $"{GetFrontendAuditTitle(audit.AuditStep!)}:" +
+                            $"{GetFrontendAuditReason(audit.AuditReason!)}");
+
+                        hunyuanAIInvDatas = new();
                     }
                 }
                 #endregion
@@ -2834,7 +2856,15 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                 #endregion
 
                 // 5. 终焉推送
-                await HttpContext.SendSseStepAsync(100, "操作成功!资料已全部就绪。", new
+                string finalMsg = "操作成功!资料已全部就绪。";
+
+                // 如果 AI 全部失败,但本地有数据,仍可成功
+                if (!finalResult.Any())
+                {
+                    finalMsg = "本次未检索到符合条件的境外单位,建议调整国家或行业范围。";
+                }
+
+                await HttpContext.SendSseStepAsync(100, finalMsg, new
                 {
                     invAiInfo.Id,
                     AiCrawledDetails = finalResult.OrderByDescending(x => x.Source).ThenBy(x => x.Region).ToList()
@@ -2919,37 +2949,75 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
 
                 await HttpContext.SendSseStepAsync(30, "AI 正在深度检索跨境商邀数据,请稍候...");
 
-                // 调用 AI (Progress: 30% - 80%)
-                string aiRawResponse = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(question);
+                // ====== 调用 AI ======
+                await HttpContext.SendSseStepAsync(70, "AI 正在跨境检索单位资料...");
 
-                await HttpContext.SendSseStepAsync(85, "数据已捕获,正在进行格式校验与去重...");
+                string searchRaw = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(question);
 
-                // 5. 解析与清洗数据
-                var hunyuanAIInvDatas = ProcessAIResponse(aiRawResponse);
-                string operatorName = await _sqlSugar.Queryable<Sys_Users>().Where(x => x.Id == dto.CurrUserId).Select(x => x.CnName).FirstAsync() ?? "-";
+                await HttpContext.SendSseStepAsync(80, "数据已捕获,正在进行格式校验与去重...");
 
-                foreach (var x in hunyuanAIInvDatas)
+                // ====== 解析 AI ======
+                var operatorName = await _sqlSugar.Queryable<Sys_Users>()
+                    .Where(x => x.Id == dto.CurrUserId)
+                    .Select(x => x.CnName)
+                    .FirstAsync() ?? "-";
+
+                var audit = ParseHunyuanResult<InvitationAIInfo>(searchRaw);
+
+                // AI 检索失败
+                if (!audit.IsSuccess)
                 {
-                    x.Guid = Guid.NewGuid().ToString("N");
-                    x.Source = 1;
-                    x.Operator = operatorName;
-                    x.OperatedAt = DateTime.Now;
-                }
+                    _logger.LogWarning(
+                        "公务名称:{InvName};AI 检索失败;Step={Step};Reason={Reason}",
+                        invAiInfo.InvName,
+                        audit.AuditStep,
+                        audit.AuditReason
+                    );
 
-                // 6. 数据库操作 (Progress: 95%)
-                invAiInfo.AiCrawledDetails.AddRange(hunyuanAIInvDatas);
-                invAiInfo.AiCrawledDetails = invAiInfo.AiCrawledDetails.OrderByDescending(x => x.OperatedAt).ToList();
+                    await HttpContext.SendSseStepAsync(-1,
+                        $"{GetFrontendAuditTitle(audit.AuditStep!)}:" +
+                        $"{GetFrontendAuditReason(audit.AuditReason!)}");
 
-                var update = await _sqlSugar.Updateable(invAiInfo).UpdateColumns(x => x.AiCrawledDetails).ExecuteCommandAsync();
+                    return; 
+                }
 
-                if (update > 0)
+                // ====== AI 成功但无数据 ======
+                var hunyuanAIInvDatas = audit.Data!
+                    .Select(x =>
+                    {
+                        x.Guid = Guid.NewGuid().ToString("N");
+                        x.Source = 1;
+                        x.Operator = operatorName;
+                        x.OperatedAt = DateTime.Now;
+                        return x;
+                    })
+                    .ToList();
+
+                if (!hunyuanAIInvDatas.Any())
                 {
-                    await HttpContext.SendSseStepAsync(100, $"AI 续写成功!新增 {hunyuanAIInvDatas.Count} 条数据", invAiInfo.AiCrawledDetails);
+                    await HttpContext.SendSseStepAsync(-1,"本次未检索到符合条件的境外单位,建议调整国家或行业范围。");
+                    return;
                 }
-                else
+
+                // ====== 数据库写入 ======
+                invAiInfo.AiCrawledDetails ??= new();
+                invAiInfo.AiCrawledDetails.AddRange(hunyuanAIInvDatas);
+                invAiInfo.AiCrawledDetails =
+                    invAiInfo.AiCrawledDetails.OrderByDescending(x => x.OperatedAt).ToList();
+
+                var updated = await _sqlSugar.Updateable(invAiInfo)
+                    .UpdateColumns(x => x.AiCrawledDetails)
+                    .ExecuteCommandAsync();
+
+                if (updated <= 0)
                 {
-                    await HttpContext.SendSseStepAsync(-1, "数据库更新失败");
+                    await HttpContext.SendSseStepAsync(-1, "数据库更新失败,请联系管理员");
+                    return;
                 }
+
+                await HttpContext.SendSseStepAsync(100,
+                    $"AI 续写成功!新增 {hunyuanAIInvDatas.Count} 条数据",
+                    invAiInfo.AiCrawledDetails);
             }
             catch (Exception ex)
             {
@@ -2962,21 +3030,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             }
         }
 
-        private List<InvitationAIInfo> ProcessAIResponse(string response)
-        {
-            if (string.IsNullOrWhiteSpace(response)) return new List<InvitationAIInfo>();
-            string cleanJson = response.Trim();
-            if (cleanJson.Contains("```json"))
-            {
-                cleanJson = Regex.Match(cleanJson, @"```json([\s\S]*?)```").Groups[1].Value.Trim();
-            }
-            else if (cleanJson.Contains("```"))
-            {
-                cleanJson = Regex.Match(cleanJson, @"```([\s\S]*?)```").Groups[1].Value.Trim();
-            }
-            return JsonConvert.DeserializeObject<List<InvitationAIInfo>>(cleanJson) ?? new List<InvitationAIInfo>();
-        }
-
         /// <summary>
         /// 商邀资料AI 文件生成(基于混元AI已爬取数据进行格式化输出,供用户下载使用)
         /// </summary>
@@ -4377,23 +4430,39 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                         Counrt = g.Count()
                     }).ToList();
 
-                    await HttpContext.SendSseStepAsync(60, $"AI 正在跨境检索缺失的 {string.Join(", ", countryTasksGroupBy.Select(x => $"{x.Region}({x.Counrt}条)"))} 单位资料...");
+                    await HttpContext.SendSseStepAsync(60, $"AI 正在跨境检索 {string.Join(", ", countryTasksGroupBy.Select(x => $"{x.Region}({x.Counrt}条)"))} 单位资料...");
                     string searchQuestion = BuildHunyuanPrompt(aiTasks, countryTasks, entryInfo);
 
                     _logger.LogInformation(@"公务名称:{InvName};  混元AI查询提示词:{searchQuestion}", invAiInfo.InvName, searchQuestion);
                     string searchRaw = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(searchQuestion);
 
-                    var aiParsed = CleanAndParseJson<List<InvitationAI_NoGroupInfo>>(searchRaw);
-                    if (aiParsed != null)
+                    var audit = ParseHunyuanResult<InvitationAI_NoGroupInfo>(searchRaw);
+
+                    if (!audit.IsSuccess)
                     {
-                        hunyuanAIInvDatas = aiParsed.Select(x => {
+                        _logger.LogWarning(
+                            "公务名称:{InvName};AI 检索失败;Step={Step};Reason={Reason}",
+                            invAiInfo.InvName,
+                            audit.AuditStep,
+                            audit.AuditReason
+                        );
+
+                        await HttpContext.SendSseStepAsync(-1,
+                            $"{GetFrontendAuditTitle(audit.AuditStep!)}:" +
+                            $"{GetFrontendAuditReason(audit.AuditReason!)}");
+                        return;
+                    }
+
+                    hunyuanAIInvDatas = audit.Data!
+                        .Select(x =>
+                        {
                             x.Guid = Guid.NewGuid().ToString("N");
                             x.Source = 1;
                             x.Operator = operatorName;
                             x.OperatedAt = DateTime.Now;
                             return x;
-                        }).ToList();
-                    }
+                        })
+                        .ToList();
                 }
                 #endregion
 
@@ -4412,11 +4481,21 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                 #endregion
 
                 // 5. 终焉推送
-                await HttpContext.SendSseStepAsync(100, "操作成功!资料已全部就绪。", new
+                if (!finalResult.Any())
                 {
-                    invAiInfo.Id,
-                    AiCrawledDetails = finalResult.OrderByDescending(x => x.Source).ThenBy(x => x.Region).ToList()
-                });
+                    await HttpContext.SendSseStepAsync(-1, "本次未检索到符合条件的境外单位,建议调整国家或行业范围。");
+                    return;
+                }
+
+                await HttpContext.SendSseStepAsync(100,
+                    "操作成功!资料已全部就绪。",
+                    new
+                    {
+                        invAiInfo.Id,
+                        AiCrawledDetails = finalResult
+                            .OrderByDescending(x => x.Source)
+                            .ThenBy(x => x.Region)
+                    });
             }
             catch (Exception ex)
             {
@@ -4486,17 +4565,33 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                 await HttpContext.SendSseStepAsync(85, "数据已捕获,正在进行格式校验与去重...");
 
                 // 5. 解析与清洗数据
-                var hunyuanAIInvDatas = ProcessAI_NoGroupResponse(aiRawResponse);
                 string operatorName = await _sqlSugar.Queryable<Sys_Users>().Where(x => x.Id == dto.CurrUserId).Select(x => x.CnName).FirstAsync() ?? "-";
 
-                foreach (var x in hunyuanAIInvDatas)
+                var audit = ParseHunyuanResult<InvitationAI_NoGroupInfo>(aiRawResponse);
+
+                if (!audit.IsSuccess)
                 {
-                    x.Guid = Guid.NewGuid().ToString("N");
-                    x.Source = 1;
-                    x.Operator = operatorName;
-                    x.OperatedAt = DateTime.Now;
+                    await HttpContext.SendSseStepAsync(-1, $"AI 续写失败:{GetFrontendAuditReason(audit.AuditReason!)}");
+                    return;
+                }
+
+                if (audit.Data == null || !audit.Data.Any())
+                {
+                    await HttpContext.SendSseStepAsync(-1, "AI 未生成任何新单位,建议调整国家或行业范围。");
+                    return;
                 }
 
+                var hunyuanAIInvDatas = audit.Data
+                    .Select(x =>
+                    {
+                        x.Guid = Guid.NewGuid().ToString("N");
+                        x.Source = 1;
+                        x.Operator = operatorName;
+                        x.OperatedAt = DateTime.Now;
+                        return x;
+                    })
+                    .ToList();
+
                 // 6. 数据库操作 (Progress: 95%)
                 invAiInfo.AiCrawledDetails.AddRange(hunyuanAIInvDatas);
                 invAiInfo.AiCrawledDetails = invAiInfo.AiCrawledDetails.OrderByDescending(x => x.OperatedAt).ToList();
@@ -4505,7 +4600,9 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
 
                 if (update > 0)
                 {
-                    await HttpContext.SendSseStepAsync(100, $"AI 续写成功!新增 {hunyuanAIInvDatas.Count} 条数据", invAiInfo.AiCrawledDetails);
+                    await HttpContext.SendSseStepAsync(100,
+                        $"AI 续写成功!新增 {hunyuanAIInvDatas.Count} 条数据",
+                        invAiInfo.AiCrawledDetails);
                 }
                 else
                 {
@@ -4523,21 +4620,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             }
         }
 
-        private List<InvitationAI_NoGroupInfo> ProcessAI_NoGroupResponse(string response)
-        {
-            if (string.IsNullOrWhiteSpace(response)) return new List<InvitationAI_NoGroupInfo>();
-            string cleanJson = response.Trim();
-            if (cleanJson.Contains("```json"))
-            {
-                cleanJson = Regex.Match(cleanJson, @"```json([\s\S]*?)```").Groups[1].Value.Trim();
-            }
-            else if (cleanJson.Contains("```"))
-            {
-                cleanJson = Regex.Match(cleanJson, @"```([\s\S]*?)```").Groups[1].Value.Trim();
-            }
-            return JsonConvert.DeserializeObject<List<InvitationAI_NoGroupInfo>>(cleanJson) ?? new List<InvitationAI_NoGroupInfo>();
-        }
-
         /// <summary>
         /// 商邀资料AI-无团组版  
         /// 文件生成(基于混元AI已爬取数据进行格式化输出,供用户下载使用)

+ 217 - 80
OASystem/OASystem.Api/OAMethodLib/GeneralMethod.cs

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.SignalR;
 using Microsoft.Graph.Models;
 using Microsoft.International.Converters.PinYinConverter;
 using NodaTime;
+using NPOI.SS.Formula.Functions;
 using OASystem.API.OAMethodLib.File;
 using OASystem.API.OAMethodLib.Hub.HubClients;
 using OASystem.API.OAMethodLib.Hub.Hubs;
@@ -545,22 +546,37 @@ namespace OASystem.API.OAMethodLib
 
             // 记录不存在
             if (ccpInfo == null)
+            {
+                // OP费用特殊处理:返回可操作(允许新增?)
+                if (feeType == 79)
+                    return (true, null); // 或者根据业务需求返回 (false, "当前费用不存在,不可操作!")
+
                 return (false, "当前费用不存在,不可操作!");
+            }
 
-            // 自动审核 费用类型为签证时不校验
-            if (feeType != 80)
+            // 费用金额未填写(允许操作)
+            if (ccpInfo.PayMoney == 0.00m)
             {
-                if (ccpInfo.IsAuditGM == 3)
-                    return (false, "当前费用已自动审核,不可操作!");
+                return (true, null);
             }
 
-            // 已审核
+            // 自动审核校验(签证类型不校验)
+            if (feeType != 80 && ccpInfo.IsAuditGM == 3)
+            {
+                return (false, "当前费用已自动审核,不可操作!");
+            }
+
+            // 已审核校验
             if (ccpInfo.IsAuditGM == 1)
+            {
                 return (false, "当前费用已审核,不可操作!");
+            }
 
-            // 已付款
+            // 已付款校验
             if (ccpInfo.IsPay == 1)
+            {
                 return (false, "当前费用已付款,不可操作!");
+            }
 
             // 验证通过
             return (true, null);
@@ -1717,88 +1733,209 @@ namespace OASystem.API.OAMethodLib
 
             return $@"
 # [SYSTEM_ROLE]
-你是精通全球实时经贸情报的【顶级商务咨询顾问】。
-你具备资深 .NET 6 架构思维,输出 JSON 必须 100% 兼容强类型反序列化。遵循“无存证不输出、官网优先溯源、AB管道证伪、零虚构熔断”的最高数据纯度准则。
+顶级商务咨询顾问。精通全球实时经贸情报。输出 JSON 必须 100% 兼容强类型反序列化。遵循:无存证不输出、官网优先、AB管道证伪、零虚构熔断。
 
-# [CONTEXT_ANALYSIS]
-• 当前核查基准时间 (CurrentDate): {DateTime.Now:yyyy-MM-dd} (锁定动态年份为绝对时空锚点)
-• 发起单位 (OriginUnit): {entryInfo.OriginUnit}
-• 业务硬性约束 (BusinessConstraints): {businessConstraints}
-• 核心驱动配置 (CountryTasks): {JsonConvert.SerializeObject(countryTasks)}
-• 任务配额分配 (Tasks): {JsonConvert.SerializeObject(tasks)}
-
-# [REALITY_CHECK_RULES - 严禁虚构与存证审计标准]
-
-## 1. 企业存在性“三位一体”核验 (Anti-Hallucination)
-- **核心原则**:严禁推测、编造、组合不存在的企业名称。必须严格按照 `CountryTasks` 定义的区域执行检索。
-- **验证链条**:
-  1. **官网验证**:SiteUrl 必须真实可访问,且内容与企业业务高度吻合。
-  2. **管道 A (官方公示)**:必须在目标国工商注册局、证监会或经商处有备案。
-  3. **管道 B (公正存证)**:必须在 LinkedIn (官方认证页)、Bloomberg 或 Crunchbase 有实时动态。
-- **熔断判定**:若以上三项均无法交叉证实企业真实存在性,该条目必须立即废弃。
-
-## 2. 软 404 与“FoundPage”假死内容熔断
-- **实质内容审计**:即便 URL 响应 200,若页面包含以下特征,必须判定为**死链**并熔断:
-  - **特征词**:`404 Not Found`, `Page Not Found`, `Oops`, `Seite nicht gefunden`, `foundpage`, `链接不存在`.
-- **主站熔断**:若 `SiteUrl` 触碰报错特征,该企业条目整体作废;若仅 `PostUrl` 触碰,则动态字段返回 `[]`。
-
-## 3. 信息溯源:官网优先与 AB 管道补位
-- **首要来源**:新闻 (PostUrl)、联系人 (Contact) 与邮件 (Email) 必须优先从企业官网提取。
-- **补位机制**:仅当官网无法获取联系信息时,允许从 AB 管道(管道B认证页)提取 {DateTime.Now.AddYears(-3).Year}-{DateTime.Now.Year} 年间的实时活跃数据。
-
-## 4. 属地物理地址强校验 (GEO_ADDRESS_STRICTNESS)
-- **物理属地锁定原则**:企业必须是在目标国家**实际运营、注册并拥有实体办公场所**的主体,严禁接受虚拟办公室、共享注册地址或仅用于税务/法律注册的空壳地址。
-- **地址真实性审计流程**:
-  1. **地理一致性校验**:`Address` 字段必须位于 `CountryTasks` 指定的国家境内,且与官网“Contact / Imprint / About Us”页面披露的实体地址完全一致。
-  2. **地图实体验证(AB 管道)**:必须通过 Google Maps / OpenStreetMap 等权威地理服务验证该地址对应真实建筑,且建筑内确实挂有该企业标识(或街景证据)。
-  3. **虚拟地址熔断**:若地址被识别为虚拟办公、信箱服务(P.O. Box)或商业中心,该条目**整条作废**。
-- **输出规范**:`Address` 必须返回**可直接复制粘贴到 Google Maps、百度地图、Apple 地图等主流地图服务中进行搜索和导航的完整物理地址**。格式要求:街道地址 + 城市 + 州/省 + 邮政编码 + 国家。地址中必须包含可被地图服务准确识别的**邮政编码**。
-
-# [BUSINESS_CONSTRAINTS_SCHEMA]
-- **零虚构原则**:绝对禁止编造任何不存在的企业、人名、链接或动态。
-- **行业约束**:Industry 必须严格锁定在业务定义的范围内,严禁跨类延伸。
-- **规模匹配**:Scale 必须严格匹配“单位规模”集合定义。
-- **英文命名清洗规则 (NAMING_CONVENTION_EN_CLEAN)**:在 `NameEn` 字段中,**严禁出现任何法律实体后缀标识**。包括但不限于:`PTY LTD`, `LTD`, `LIMITED`, `LLC`, `INC`, `CORP`, `PLC`, `GMBH`, `AG`, `SAS`, `SARL`, `BV`, `NV` 等。`NameEn` 应仅保留企业**核心品牌名或商号 (Trading Name)**,确保与官网首页主 Title 保持一致。若清洗后 `NameEn` 为空或仅剩通用词,该条目视为**无效命名并整体废弃**。
-
-# [QUOTA_SCHEDULING - 动态再分配算法]
-- **名额平移**:若 `CountryTasks` 指定的某国家因“官网失效/无法证伪”导致有效条目不足,允许实际输出数 < 理论配额。
-- **禁止凑数**:名额自动向质量更高、官网更活跃的国家转移,严禁为了填满 JSON 而降低审计标准。
-
-# [THOUGHT_PROCESS_LOGIC]
-1. 解析 `CountryTasks` 与 `BusinessConstraints` -> 2. 执行 {DateTime.Now.Year} 实时检索 -> 3. **执行存在性三位一体核验(证伪熔断)** -> 4. **扫描官网内容(识别并过滤软404)** -> 5. **属地物理地址强校验** -> 6. **英文命名清洗** -> 7. **以官网为核心、AB管道为备份提取字段** -> 8. 生成纯净 JSON。
-
-# [STRICT_DATA_CONTRACT]
-属性命名遵循 PascalCase,禁止新增字段:
+# [CONTEXT]
+CurrentDate: {DateTime.Now:yyyy-MM-dd}
+OriginUnit: {entryInfo.OriginUnit}
+BusinessConstraints: {businessConstraints}
+CountryTasks: {JsonConvert.SerializeObject(countryTasks)}
+Tasks: {JsonConvert.SerializeObject(tasks)}
+
+# [REALITY_CHECK_RULES]
+
+## 1. 企业存在性三位一体核验
+必须同时满足:
+- 官网可访问且业务匹配
+- 管道A:目标国工商/证券/经商处备案
+- 管道B:LinkedIn(认证页)/Bloomberg/Crunchbase 近3年动态
+→ 任一缺失 → 整条作废
+
+## 2. 实时可达性检测
+SiteUrl / PostUrl 必须通过:
+- DNS 成功
+- HTTP 200,响应 < 5s
+- HTML ≥ 500 字符
+- 不含:404/not found/page not found/under maintenance/coming soon/site is temporarily down
+→ SiteUrl 不通过 → 整条作废
+→ PostUrl 不通过 → PostUrl 返回 []
+
+## 3. 软 404 熔断
+URL 含以下关键词 → 死链:
+404 Not Found, Page Not Found, Oops, Seite nicht gefunden, foundpage, 链接不存在
+→ SiteUrl 命中 → 整条作废
+→ PostUrl 命中 → PostUrl 返回 []
+
+## 4. 属地物理地址强校验
+- 地址必须在 CountryTasks 指定国家
+- 与官网 Contact/Imprint/About Us 完全一致
+- Google Maps / OpenStreetMap 可验证真实建筑
+- 非虚拟办公室 / P.O. Box / 商业中心
+→ 不通过 → 整条作废
+
+## 5. 英文命名清洗
+NameEn 严禁包含:
+LTD, LLC, JSC, AO, TOO, IP, PLC, INC, CORP, GMBH, AG, BV, NV 等
+→ 仅保留核心品牌名,与官网首页 Title 一致
+→ 清洗后为空或通用词 → 整条作废
+
+## 6. 链接健康评分 ≥ 90
+DNS(20)+HTTP200(30)+HTML≥500(20)+无软死链(20)+归属企业一致(10)
+→ <90 → 对应 URL 直接丢弃
+
+## 7. 零容忍熔断
+触发任一即终止条目:
+- 官网不可达
+- 地址无法地图验证
+- 企业无法三位一体证伪
+- 任何字段推测性补全
+→ 严禁为填满配额降低标准
+
+## 8. 时效约束
+- 所有数据必须是 {DateTime.Now:yyyy-MM-dd} 当天检测结果
+- 禁止缓存 / 历史快照 / 旧索引
+
+# [BUSINESS_CONSTRAINTS]
+- 零虚构:禁止编造企业/人名/链接/动态
+- Industry 严格锁定
+- Scale 严格匹配
+- NameEn 执行命名清洗
+
+# [QUOTA]
+- 有效条目不足允许 < 配额
+- 禁止凑数,禁止降级标准
+
+# [EXECUTION_PIPELINE]
+⛔ 严格顺序执行,禁止跳步、禁止提前生成企业名称或 JSON:
+1. 解析 CountryTasks + BusinessConstraints
+2. 检索候选企业(仅目标国)
+3. 三位一体核验(官网 + 管道A + 管道B)
+4. 官网实时可达性检测(DNS/HTTP/HTML)
+5. 软 404 与链接健康评分 ≥ 90
+6. 属地物理地址强校验(地图实体验证)
+7. 英文命名清洗
+8. 提取字段(官网优先,AB 管道补位)
+9. 最终零容忍熔断检查
+10. 输出纯净 JSON 或失败审计
+
+# [FAILURE_AUDIT_HOOK]
+若最终输出为空数组 [],必须改用以下格式输出(仅字符串,不含 JSON):
+AuditReason::StepX::{{失败原因}}
+
+示例:
+AuditReason::Step3::三位一体核验失败,无工商备案
+AuditReason::Step4::官网不可达,DNS解析失败
+AuditReason::Step6::地址无法在Google Maps验证为真实建筑
+
+# [OUTPUT_SCHEMA]
+PascalCase,禁止新增字段:
 [
-  {{
-    ""Region"": ""string"",
-    ""Industry"": ""string"",
-    ""Scale"": ""string"",
-    ""NameCn"": ""string"",
-    ""NameEn"": ""string"",
-    ""Address"": ""string"",
-    ""Scope"": ""string"",
-    ""Contact"": ""string"",
-    ""Phone"": ""string"",
-    ""Email"": ""string"",
-    ""SiteUrl"": ""string"",
-    ""PostUrl"": [ {{ ""Date"": ""yyyy-MM-dd"", ""Description"": ""string"", ""Url"": ""string"" }} ],
-    ""RecLevel"": ""Core|Backup"",
-    ""IntgAdvice"": ""string""
-  }}
+  {{{{
+    ""Region"":""string"",
+    ""Industry"":""string"",
+    ""Scale"":""string"",
+    ""NameCn"":""string"",
+    ""NameEn"":""string"",
+    ""Address"":""string"",
+    ""Scope"":""string"",
+    ""Contact"":""string"",
+    ""Phone"":""string"",
+    ""Email"":""string"",
+    ""SiteUrl"":""string"",
+    ""PostUrl"":[{{{{""Date"":""yyyy-MM-dd"",""Description"":""string"",""Url"":""string""}}}}],
+    ""RecLevel"":""Core|Backup"",
+    ""IntgAdvice"":""string""
+  }}}}
 ]
 
-# [HUNYUAN_OUTPUT_GUARDRAILS]
-你是一个严格的 JSON 生成器。
-1. 最高禁令:绝对禁止编造任何不存在的企业、人名、链接或动态。
-2. 输出洁癖:不输出 Markdown 标记、不输出任何解释文字或思考过程。
-3. 边界控制:首字符 [,末字符 ]。结果为空输出 []。
+# [OUTPUT_RULES]
+- 非空结果:仅输出 JSON
+- 空结果:仅输出 AuditReason 字符串
+- 不输出 Markdown / 解释 / 思考过程
+- JSON 首字符 [,末字符 ]
+- 空结果输出 []
+- 禁止在同一响应中同时包含 JSON 与 AuditReason 文本
 
-# [EXECUTION]
-立即执行:以动态年份为锚点,执行严苛的企业真实性审计与软 404 熔断逻辑,严禁虚构任何信息,按契约输出纯净 JSON。
+# [EXECUTE]
+立即执行:以 {DateTime.Now.Year} 为锚点,严格按 EXECUTION_PIPELINE 执行,任何步骤失败即终止并输出 AuditReason
 ";
         }
 
+        /// <summary>
+        /// 失败审计结构
+        /// </summary>
+        /// <param name="IsSuccess"></param>
+        /// <param name="Data"></param>
+        /// <param name="AuditStep"></param>
+        /// <param name="AuditReason"></param>
+        public record HunyuanAuditResult<T>(
+            bool IsSuccess,
+            List<T>? Data,
+            string? AuditStep,
+            string? AuditReason
+        );
+
+        /// <summary>
+        /// 失败审计解析器
+        /// </summary>
+        /// <param name="raw"></param>
+        /// <returns></returns>
+        public static HunyuanAuditResult<T> ParseHunyuanResult<T>(string raw)
+        {
+            if (string.IsNullOrWhiteSpace(raw))
+                return new(false, null, "Unknown", "AI 返回空内容");
+
+            // 失败审计(Prompt 约定)
+            if (raw.TrimStart().StartsWith("AuditReason::"))
+            {
+                var parts = raw.Split("::", 3, StringSplitOptions.RemoveEmptyEntries);
+                return new(
+                    false,
+                    null,
+                    parts.ElementAtOrDefault(1) ?? "Unknown",
+                    parts.ElementAtOrDefault(2) ?? "未说明原因"
+                );
+            }
+
+            try
+            {
+                var data = JsonConvert.DeserializeObject<List<T>>(raw);
+                return new(true, data, null, null);
+            }
+            catch
+            {
+                return new(false, null, "Parse", "JSON 解析失败,原始内容非预期格式");
+            }
+        }
+
+        /// <summary>
+        /// 步骤错误提示
+        /// </summary>
+        /// <param name="step"></param>
+        /// <returns></returns>
+        public static string GetFrontendAuditTitle(string step) => step switch
+        {
+            "Step3" => "未找到符合条件的境外单位备案信息",
+            "Step4" => "境外单位官网暂不可访问",
+            "Step6" => "境外单位办公地址无法核验",
+            "Step10" => "本次未检索到符合条件的境外单位",
+            "Parse" => "AI 返回数据格式异常",
+            _ => "境外单位检索未完成"
+        };
+
+        /// <summary>
+        /// 审计失败原因
+        /// </summary>
+        /// <param name="reason"></param>
+        /// <returns></returns>
+        public static string GetFrontendAuditReason(string reason) => reason switch
+        {
+            "三位一体核验失败,无工商备案" => "未在目标国官方系统中查到注册信息",
+            "官网不可达,DNS解析失败" => "官网暂时无法访问",
+            "地址无法在Google Maps验证为真实建筑" => "办公地址无法确认实际存在",
+            _ => reason
+        };
+
         #endregion
 
         #region 基础数据 整合

+ 8 - 1
OASystem/OASystem.Domain/Dtos/Groups/GrpCreditCardPaymentDto.cs

@@ -22,7 +22,6 @@
         public string? SearchCriteria { get; set; }
     }
 
-
     public class Search_GrpCreditCardPaymentDto : UserPageFuncDtoBase
     {
         /// <summary>
@@ -52,6 +51,14 @@
         /// 审核状态 0/1/2,未审核/审核通过/审核不通过 ,-1:所有
         /// </summary>
         public int AuditStatus { get; set; }
+
+        /// <summary>
+        /// 费用类型
+        /// -1 查询全部
+        /// 其他值 查询指定项
+        /// </summary>
+        public int FeeType { get; set; } = -1;
+
     }
 
     public class Edit_GrpCreditCardPaymentDto : UserPageFuncDtoBase

+ 2 - 0
OASystem/OASystem.Domain/ViewModels/Groups/Grp_CreditCardPaymentView.cs

@@ -101,6 +101,8 @@ namespace OASystem.Domain.ViewModels.Groups
         public string TotalStr2 { get; set; }
         public string TotalStr3 { get; set; }
         public string TotalStr4 { get; set; }
+
+        public ExpenseAuditOverview Overview { get; set; }
         //public string TotalStr5 { get; set; }
     }