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

Merge branch 'develop' of http://132.232.92.186:3000/yuanrf/OA2023 into develop

yuanrf 1 неделя назад
Родитель
Сommit
5226fb7dc0

+ 271 - 72
OASystem/OASystem.Api/Controllers/ResourceController.cs

@@ -1,9 +1,11 @@
 using Aspose.Cells;
+using Aspose.Cells.Drawing;
 using Aspose.Words;
 using Aspose.Words.Tables;
 using Azure;
 using EyeSoft.Extensions;
 using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Graph.Models;
 using Newtonsoft.Json.Serialization;
 using NodaTime;
 using NPOI.SS.Formula.Functions;
@@ -30,6 +32,7 @@ using static OASystem.API.OAMethodLib.GeneralMethod;
 using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
 using static OASystem.API.OAMethodLib.JWTHelper;
 using static OpenAI.GPT3.ObjectModels.Models;
+using Workbook = Aspose.Cells.Workbook;
 
 namespace OASystem.API.Controllers
 {
@@ -1859,8 +1862,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                     item.DelegationStr = groupNameStr;
                 }
 
-
-
                 //获取模板
                 string tempPath = (AppSettingsHelper.Get("ExcelBasePath") + "Template/商邀资料模板.xls");
                 var designer = new WorkbookDesigner();
@@ -1884,38 +1885,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             }
         }
 
-        //[HttpPost]
-        //public  IActionResult EncipherInvitationOfficialActivityData()
-        //{
-
-        //    var jw = JsonView(false);
-        //    var List_DB = _sqlSugar.Queryable<Res_InvitationOfficialActivityData>().ToList();
-        //    try
-        //    {
-        //        //foreach (var item in List_DB)
-        //        //{
-        //        //    EncryptionProcessor.DecryptProperties(item);
-        //        //}
-
-        //        _sqlSugar.BeginTran();
-        //        foreach (var item in List_DB)
-        //        {
-        //            EncryptionProcessor.EncryptProperties(item);
-        //        }
-
-        //        var updateRow =  _sqlSugar.Updateable<Res_InvitationOfficialActivityData>(List_DB).ExecuteCommand();
-        //        jw = JsonView(true, "success", "修改行数:" + updateRow);
-        //        _sqlSugar.CommitTran();
-        //    }
-        //    catch (Exception ex)
-        //    {
-        //        _sqlSugar.RollbackTran();
-        //        jw.Msg = ex.Message;
-        //    }
-
-        //    return Ok(jw);
-        //}
-
         /// <summary>
         /// 根据商邀资料Id查询信息
         /// </summary>
@@ -2322,60 +2291,71 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
         [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
         public async Task<IActionResult> InvitationAIItemByName(string name)
         {
-            // 基础校验
-            if (string.IsNullOrWhiteSpace(name))
-                return Ok(JsonView(false, "请传入有效的名称!"));
+            if (string.IsNullOrWhiteSpace(name)) return Ok(JsonView(false, "名称无效"));
 
+            // 1. 获取主表记录
             var info = await _sqlSugar.Queryable<Res_InvitationAI>()
-                .Where(x => x.IsDel == 0 && x.InvName == name)
-                .FirstAsync();
+                .FirstAsync(x => x.IsDel == 0 && x.InvName == name);
 
-            var groupInfo = new Grp_DelegationInfo();
+            Grp_DelegationInfo groupInfo = null;
+            string baseUrl = AppSettingsHelper.Get("OfficeBaseUrl")?.TrimEnd('/');
 
+            // 2. 分支处理:若无主表记录,尝试从团组表同步基本信息
             if (info == null)
             {
-                groupInfo = await _sqlSugar.Queryable<Grp_DelegationInfo>().Where(x => x.IsDel == 0 && x.TeamName.Equals(name)).FirstAsync();
-
-                var entry = new EntryInfo() {
-                    OriginUnit = groupInfo?.ClientUnit ?? "",
-                    TargetCountry = _delegationInfoRep.GroupSplitCountry(groupInfo?.VisitCountry ?? ""),
-                };
+                groupInfo = await _sqlSugar.Queryable<Grp_DelegationInfo>()
+                    .FirstAsync(x => x.IsDel == 0 && x.TeamName == name);
 
-                return Ok(JsonView(true, $"暂无数据",new {
+                return Ok(JsonView(true, "暂无数据", new
+                {
                     Id = 0,
-                    GroupId= groupInfo?.Id ?? 0,
+                    GroupId = groupInfo?.Id ?? 0,
                     InvName = name,
                     AiCrawledDetails = new List<InvitationAIInfo>(),
-                    Entry = entry
+                    Entry = new
+                    {
+                        OriginUnit = groupInfo?.ClientUnit ?? "",
+                        TargetCountry = _delegationInfoRep.GroupSplitCountry(groupInfo?.VisitCountry ?? "")
+                    }
                 }));
             }
 
-            if (info.GroupId != 0)
+            // 3. 补全关联团组信息
+            if (info.GroupId > 0)
             {
-                groupInfo = await _sqlSugar.Queryable<Grp_DelegationInfo>().Where(x => x.IsDel == 0 && x.Id == info.GroupId).FirstAsync();
+                groupInfo = await _sqlSugar.Queryable<Grp_DelegationInfo>().InSingleAsync(info.GroupId);
             }
-            // 设置国家、单位默认值
+
+            // 4. 数据填充(使用 ??= 语法确保 Null 安全)
+            info.EntryInfo ??= new EntryInfo();
             if (string.IsNullOrEmpty(info.EntryInfo.OriginUnit))
-            {
                 info.EntryInfo.OriginUnit = groupInfo?.ClientUnit ?? "";
-            }
 
-            if (info.EntryInfo.TargetCountry == null || info.EntryInfo.TargetCountry.Count < 1)
+            if (info.EntryInfo.TargetCountry?.Any() != true)
+                info.EntryInfo.TargetCountry = _delegationInfoRep.GroupSplitCountry(groupInfo?.VisitCountry ?? "");
+
+            // 5. 排序:ThenByDescending 确保多级排序生效
+            info.AiCrawledDetails = info.AiCrawledDetails
+                .OrderByDescending(x => x.IsChecked)
+                .ThenByDescending(x => x.OperatedAt)
+                .ToList();
+
+            // 6. 路径映射:将相对路径转换为带域名的全路径
+            foreach (var detail in info.AiCrawledDetails.Where(d => d.EmailInfo?.AttachmentPaths?.Any() == true))
             {
-                info.EntryInfo.TargetCountry = _delegationInfoRep.GroupSplitCountry(groupInfo?.TeamName ?? "");
+                detail.EmailInfo.AttachmentPaths = detail.EmailInfo.AttachmentPaths
+                    .Select(path => path.StartsWith("http") ? path : $"{baseUrl}/{path.TrimStart('/')}")
+                    .ToList();
             }
 
-            info.AiCrawledDetails = info.AiCrawledDetails.OrderByDescending(x => x.IsChecked).OrderByDescending(x => x.OperatedAt).ToList();
-
-            var view = new
+            return Ok(JsonView(true, "查询成功!", new
             {
                 info.Id,
                 info.GroupId,
                 info.InvName,
                 info.AiCrawledDetails,
-                Entry = info.EntryInfo,
-            };
-            return Ok(JsonView(true, $"查询成功!", view));
+                Entry = info.EntryInfo
+            }));
         }
 
         /// <summary>
@@ -3596,7 +3576,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             // A4页面设置
             double usableWidth = 495;
 
-            foreach (Section sec in doc.Sections)
+            foreach (Aspose.Words.Section sec in doc.Sections)
             {
                 sec.PageSetup.PaperSize = Aspose.Words.PaperSize.A4;
                 sec.PageSetup.LeftMargin = 50;
@@ -4259,7 +4239,227 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
         }
 
         /// <summary>
-        /// 商邀资料AI 发送邮件(默认当前用户企业微信邮件)
+        /// 商邀资料AI 邮箱附件上传
+        /// </summary>
+        [HttpPost]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> InvitationAIFileSave([FromForm] InvitationAIFileSaveDto dto)
+        {
+            // 1. 炼金前置:严格参数校验
+            if (dto.Id < 1) return Ok(JsonView(false, "请选择保存的团组"));
+            if (dto.CurrUserId < 1) return Ok(JsonView(false, "请传入用户Id"));
+            if (string.IsNullOrEmpty(dto.Guid)) return Ok(JsonView(false, "请传入Guid"));
+            if (dto.Attachments == null || !dto.Attachments.Any()) return Ok(JsonView(false, "请传入附件"));
+
+            // 2. 数据获取:利用 SqlSugar 异步查询
+            var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>()
+                .InSingleAsync(dto.Id);
+
+            if (invAiInfo?.AiCrawledDetails == null) return Ok(JsonView(false, "邀请方信息不存在"));
+
+            // 3. 业务定位:定位到具体需要编辑的 Guid 记录
+            var editInfo = invAiInfo.AiCrawledDetails.FirstOrDefault(x => x.Guid == dto.Guid);
+            if (editInfo == null) return Ok(JsonView(false, "未找到匹配的 Guid 记录"));
+
+            // 4. 追踪信息更新
+            var opUserName = await _sqlSugar.Queryable<Sys_Users>()
+                .Where(x => x.Id == dto.CurrUserId && x.IsDel == 0)
+                .Select(x => x.CnName)
+                .FirstAsync() ?? "-";
+
+            // 更新操作人与时间 (利用引用类型特性)
+            editInfo.Operator = opUserName;
+            editInfo.OperatedAt = DateTime.Now;
+
+            // 初始化 EmailInfo 确保不为 null
+            editInfo.EmailInfo ??= new EmailInfo();
+            editInfo.EmailInfo.Operator = opUserName;
+            editInfo.EmailInfo.OperatedAt = DateTime.Now;
+            editInfo.EmailInfo.AttachmentPaths ??= new List<string>();
+
+            // 5. 构建绝对路径与相对路径
+            string dirName = $"{editInfo.NameEn?.Trim() ?? "Default"}_{dto.Guid}";
+            string baseDir = AppSettingsHelper.Get("InvitationAIAssistBasePath");
+            string ftpBase = AppSettingsHelper.Get("InvitationAIAssistFtpPath");
+
+            string absolutePath = Path.Combine(baseDir, dirName);
+            string relativePathPrefix = $"{ftpBase}{dirName}/";
+
+            // 核心修复:确保父级目录递归创建,防止 DirectoryNotFoundException
+            if (!Directory.Exists(absolutePath))
+            {
+                Directory.CreateDirectory(absolutePath);
+            }
+
+            var newSavedRelativePaths = new List<string>();
+
+            // 6. 持续文件存储与实时验证
+            foreach (var file in dto.Attachments)
+            {
+                // 文件名安全过滤
+                string safeName = string.Join("_", file.FileName.Split(Path.GetInvalidFileNameChars()));
+                string fullPath = Path.Combine(absolutePath, safeName);
+
+                // 重名冲突处理:文件名(n).ext
+                if (System.IO.File.Exists(fullPath))
+                {
+                    string fileNameOnly = Path.GetFileNameWithoutExtension(safeName);
+                    string extension = Path.GetExtension(safeName);
+                    int count = 1;
+                    while (System.IO.File.Exists(fullPath))
+                    {
+                        safeName = $"{fileNameOnly}({count++}){extension}";
+                        fullPath = Path.Combine(absolutePath, safeName);
+                    }
+                }
+
+                try
+                {
+                    // 写入流:使用作用域隔离,确保退出 using 时立即释放句柄
+                    using (var stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None))
+                    {
+                        await file.CopyToAsync(stream);
+                        await stream.FlushAsync(); // 强制落盘
+                    }
+
+                    // 实时验证:物理验证 + 逻辑校验
+                    var fileInfo = new FileInfo(fullPath);
+                    if (fileInfo.Exists && fileInfo.Length == file.Length)
+                    {
+                        newSavedRelativePaths.Add($"{relativePathPrefix}{safeName}");
+                    }
+                }
+                catch (Exception ex)
+                {
+                    // 发生 IO 异常时清理残余文件并抛出,触发外层处理
+                    if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath);
+                    // 此处记录日志:
+                    _logger.LogError(ex, "File Save Error");
+                    continue; // 跳过失败的文件,继续下一个
+                }
+            }
+
+            if (!newSavedRelativePaths.Any()) return Ok(JsonView(false, "所有文件上传均失败"));
+
+            // 7. 数据合并与持久化:
+            // 将新路径与旧路径合并,并去重
+            var updatedPaths = editInfo.EmailInfo.AttachmentPaths;
+            updatedPaths.AddRange(newSavedRelativePaths);
+            editInfo.EmailInfo.AttachmentPaths = updatedPaths.Distinct().ToList();
+
+            // 重新排序整体列表
+            invAiInfo.AiCrawledDetails = invAiInfo.AiCrawledDetails
+                .OrderByDescending(x => x.OperatedAt)
+                .ToList();
+
+            // SqlSugar 高效部分列更新
+            var isSuccess = await _sqlSugar.Updateable(invAiInfo)
+                .UpdateColumns(x => x.AiCrawledDetails)
+                .ExecuteCommandAsync() > 0;
+
+            if (!isSuccess) return Ok(JsonView(false, "数据库更新失败"));
+
+            // 8. 返回前端数据 (支持 lowerCamelCase)
+            var officeBaseUrl = AppSettingsHelper.Get("OfficeBaseUrl");
+            var finalResultUrls = editInfo.EmailInfo.AttachmentPaths
+                .Select(path => $"{officeBaseUrl}{path}")
+                .ToList();
+
+            return Ok(JsonView(true, "邮件附件保存成功", finalResultUrls));
+        }
+
+        /// <summary>
+        /// 商邀资料AI 邮箱附件删除
+        /// </summary>
+        [HttpPost]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> InvitationAIFileDel([FromBody] InvitationAIFileDelDto dto)
+        {
+            // 1. 基础校验
+            if (dto.Id < 1) return Ok(JsonView(false, "请选择团组"));
+            if (string.IsNullOrEmpty(dto.Guid)) return Ok(JsonView(false, "请传入Guid"));
+            if (dto.AttachmentNames == null || !dto.AttachmentNames.Any()) return Ok(JsonView(false, "请传入待删除附件名称"));
+
+            // 2. 数据获取
+            var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().InSingleAsync(dto.Id);
+            if (invAiInfo?.AiCrawledDetails == null) return Ok(JsonView(false, "业务数据不存在"));
+
+            var editInfo = invAiInfo.AiCrawledDetails.FirstOrDefault(x => x.Guid == dto.Guid);
+            if (editInfo?.EmailInfo?.AttachmentPaths == null) return Ok(JsonView(false, "未找到对应的附件记录"));
+
+            // 3. 操作人追踪
+            var opUserName = await _sqlSugar.Queryable<Sys_Users>()
+                .Where(x => x.Id == dto.CurrUserId && x.IsDel == 0)
+                .Select(x => x.CnName).FirstAsync() ?? "System";
+
+            // 更新追踪状态
+            editInfo.Operator = editInfo.EmailInfo.Operator = opUserName;
+            editInfo.OperatedAt = editInfo.EmailInfo.OperatedAt = DateTime.Now;
+
+            // 4. 路径
+            string dirName = $"{editInfo.NameEn?.Trim() ?? "Default"}_{dto.Guid}";
+            string absoluteDir = Path.Combine(AppSettingsHelper.Get("InvitationAIAssistBasePath"), dirName);
+
+            // 获取当前数据库中的相对路径列表
+            var currentPaths = editInfo.EmailInfo.AttachmentPaths;
+            bool isChanged = false;
+
+            // 5. 核心删除逻辑
+            foreach (var fileName in dto.AttachmentNames)
+            {
+                // 查找数据库中是否存在包含该文件名的路径 (忽略大小写比较)
+                var targetRelativePath = currentPaths.FirstOrDefault(p => p.EndsWith("/" + fileName) || p.Equals(fileName));
+
+                if (targetRelativePath != null)
+                {
+                    // A. 从数据库记录中移除
+                    currentPaths.Remove(targetRelativePath);
+                    isChanged = true;
+
+                    // B. 物理文件删除逻辑
+                    // 注意:fullPath 必须是 [基础路径] + [目录名] + [纯文件名]
+                    string fullPath = Path.Combine(absoluteDir, fileName);
+
+                    try
+                    {
+                        if (System.IO.File.Exists(fullPath))
+                        {
+                            System.IO.File.Delete(fullPath);
+                        }
+                    }
+                    catch (Exception ex)
+                    {
+                        // 记录 IO 异常但不中断流程
+                        _logger.LogWarning($"物理文件删除失败: {fullPath}, {ex.Message}");
+                    }
+                }
+            }
+
+            if (!isChanged) return Ok(JsonView(false, "未找到匹配的可删除附件"));
+
+            // 6. 数据同步与持久化
+            editInfo.EmailInfo.AttachmentPaths = currentPaths;
+
+            // 排序 (利用引用类型,无需手动 Add/Remove)
+            invAiInfo.AiCrawledDetails = invAiInfo.AiCrawledDetails.OrderByDescending(x => x.OperatedAt).ToList();
+
+            var isUpd = await _sqlSugar.Updateable(invAiInfo)
+                .UpdateColumns(x => x.AiCrawledDetails)
+                .ExecuteCommandAsync() > 0;
+
+            if (!isUpd) return Ok(JsonView(false, "数据库记录更新失败"));
+
+            // 7. 返回剩余附件的完整访问地址
+            var officeBaseUrl = AppSettingsHelper.Get("OfficeBaseUrl");
+            var remainingUrls = editInfo.EmailInfo.AttachmentPaths
+                .Select(path => $"{officeBaseUrl}{path}")
+                .ToList();
+
+            return Ok(JsonView(true, "附件删除成功", remainingUrls));
+        }
+
+        /// <summary>
+        /// 商邀资料AI 发送邮件(Hotmail)
         /// </summary>
         /// <returns></returns>
         [HttpPost]
@@ -4301,7 +4501,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             {
                 int seedStatus = 5;
 
-
                 if (string.IsNullOrEmpty(item.EmailInfo?.EmailTitle) || string.IsNullOrEmpty(item.EmailInfo?.EmailContent))
                 {
                     msgSb.Append($"跳过:{item.NameCn} (邮件标题或内容缺失)");
@@ -4314,12 +4513,12 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                     {
                         Subject = item.EmailInfo.EmailTitle,
                         Content = item.EmailInfo.EmailContent,
-                        To = item.Email
+                        To = item.Email,
+                        AttachmentPaths = item.EmailInfo.AttachmentPaths,
                     };
 
                     var res = await _hotmailService.SendMailAsync(hotmailConfig.UserName, req);
 
-
                     if (res.IsSuccess)
                     {
                         seedStatus = 4;
@@ -4337,8 +4536,8 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                 }
                 catch (Exception ex)
                 {
-                    _logger.LogError(ex, "商邀AI调用企业微信邮件API失败。");
-                    msgSb.AppendLine($"{item.NameCn}({item.Email}):邮件发送失败(msg:商邀AI调用企业微信邮件API失败。)");
+                    _logger.LogError(ex, "商邀AI调用Hotmail邮件API失败。");
+                    msgSb.AppendLine($"{item.NameCn}({item.Email}):邮件发送失败(msg:商邀AI调用Hotmail邮件API失败。)");
                 }
             }
 
@@ -4354,7 +4553,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             var update = await _sqlSugar.Updateable(invAiInfo).UpdateColumns(x => x.AiCrawledDetails).ExecuteCommandAsync();
             if (update < 1)
             {
-                return Ok(JsonView(false, $"邮件AI发送,数据保存失败!"));
+                return Ok(JsonView(false, $"Hotmail邮件发送,数据保存失败!"));
             }
 
             return Ok(JsonView(true, msgSb.ToString()));
@@ -4397,7 +4596,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                     .Select(x => new { x.Email, x.CnName })
                     .FirstAsync();
 
-
                 if (invAiInfo?.AiCrawledDetails == null)
                 {
                     await HttpContext.SendSseStepAsync(-1, "未找到有效的邀请方数据。");
@@ -4442,7 +4640,8 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                         var req = new MailDto() {
                             Subject = item.EmailInfo.EmailTitle,
                             Content = item.EmailInfo.EmailContent,
-                            To = item.Email
+                            To = item.Email,
+                            AttachmentPaths = item.EmailInfo.AttachmentPaths
                         };
 
                         var res = await _hotmailService.SendMailAsync(hotmailConfig.UserName, req);

+ 63 - 88
OASystem/OASystem.Api/Controllers/StatisticsController.cs

@@ -3668,35 +3668,24 @@ ORDER BY
 
         }
 
-        //SubmitAndValidateExpensesAsync
-
         /// <summary>
         /// 团组报表 - 会务超支验证excel下载
         /// </summary>
         /// <param name="dto"></param>
         /// <returns></returns>
         [HttpPost("PostGroupStatementValidateExpenses")]
-        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
         public async Task<IActionResult> PostGroupStatementValidateExpenses(PostGroupStatementValidateExpensesDto dto)
         {
-            // 1. 严格参数校验
-            if (dto.GroupId <= 0 || dto.CurrUserId <= 0)
-                return Ok(JsonView(false, "参数非法"));
-
-            // 2. 业务校验
-            var isConfGroup = await _sqlSugar.Queryable<Grp_DelegationInfo, Sys_SetData>(
-                    (g, s) => g.TeamDid == s.Id 
-                )
-                .Where((g, s) => g.IsDel == 0
-                              && g.Id == dto.GroupId
-                              && s.STid == 10
-                              && s.Name.Contains("会务活动"))
-                .AnyAsync(); 
+            // 1. 参数与业务校验 
+            if (dto.GroupId <= 0 || dto.CurrUserId <= 0) return Ok(JsonView(false, "参数非法"));
+
+            var isConfGroup = await _sqlSugar.Queryable<Grp_DelegationInfo, Sys_SetData>((g, s) => g.TeamDid == s.Id)
+                .Where((g, s) => g.IsDel == 0 && g.Id == dto.GroupId && s.STid == 10 && s.Name.Contains("会务活动"))
+                .AnyAsync();
 
             if (!isConfGroup) return Ok(JsonView(false, "当前团组非会务团组,禁止操作"));
 
-            // 3. 异步并行查询:获取实际支付(Fee)与预算成本(Expense)
-            // 实际支付列表
+            // 2. 数据查询与处理
             var feeList = await _sqlSugar.Queryable<Grp_DecreasePayments>()
                 .InnerJoin<Grp_CreditCardPayment>((x, y) => x.Id == y.CId && x.DiId == y.DIId && y.CTable == 98)
                 .LeftJoin<Sys_Users>((x, y, z) => x.CreateUserId == z.Id)
@@ -3715,7 +3704,6 @@ ORDER BY
 
             if (!feeList.Any()) return Ok(JsonView(false, "未检索到实际费用记录"));
 
-            // 成本预算列表
             var expenseData = await _sqlSugar.Queryable<Grp_ConferenceAffairsCostChild>()
                 .InnerJoin<Grp_ConferenceAffairsCost>((x, y) => x.ConferenceAffairsCostId == y.Id)
                 .Where((x, y) => y.IsDel == 0 && y.Diid == dto.GroupId && x.ReviewStatus == 1)
@@ -3724,18 +3712,13 @@ ORDER BY
 
             var expenseDict = expenseData.ToDictionary(k => k.PriceName, v => v.Total);
 
-            if (!feeList.Any()) return Ok(JsonView(false, "未检索到实际费用记录"));
-
-            feeList = feeList.OrderBy(x => x.PriceName).ThenBy(x => x.ApplicantTime).ToList();
-
+            // 数据转换逻辑
             int index = 1;
-            // 4. 数据处理
-            foreach (var item in feeList)
+            foreach (var item in feeList.OrderBy(x => x.PriceName).ThenBy(x => x.ApplicantTime))
             {
-                item.Index = index;
+                item.Index = index++;
                 item.AuditStatus = MapAuditStatus(item.AuditStatus);
                 item.PayLabel = item.PayLabel == "1" ? "已支付" : "未支付";
-
                 if (expenseDict.TryGetValue(item.PriceName, out decimal budget))
                 {
                     item.ExpenseEntry = budget;
@@ -3743,36 +3726,39 @@ ORDER BY
                 }
                 else
                 {
-                    if (item.FeeEntry <= 0) item.OverspentLabel = "未超支";
-                    else item.OverspentLabel = "成本未录入项(系统判定超支)";
+                    item.OverspentLabel = item.FeeEntry <= 0 ? "未超支" : "成本未录入项(系统判定超支)";
                 }
-                index++;
             }
 
-            // 导出动作与异常处理
+            // 3. 路径与导出
             try
             {
-                var groupName = _sqlSugar.Queryable<Grp_DelegationInfo>().First(x => x.Id == dto.GroupId)?.TeamName ?? "未知团组";
+                var groupName = await _sqlSugar.Queryable<Grp_DelegationInfo>().Where(x => x.Id == dto.GroupId).Select(x => x.TeamName).FirstAsync() ?? "未知团组";
+
+                string baseUrl = AppSettingsHelper.Get("ExcelBaseUrl").TrimEnd('/');
+                string basePath = AppSettingsHelper.Get("ExcelBasePath").TrimEnd('\\').TrimEnd('/');
+                string fptPath = AppSettingsHelper.Get("ExcelFtpPath");
+                string dirName = "GroupConf";
 
-                string fileName = $"会务校验_{groupName}_{DateTime.Now:yyyyMMddHHmm}.xlsx";
+                // 目录不存在时创建
+                string targetDir = Path.Combine(basePath, dirName);
+                if (!Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir);
 
-                // Excel导出:调用通用方法,传入数据列表与文件名
-                byte[] fileData = ExportToExcel(feeList,"超支验证明细");
+                string fileName = $"{groupName}_会务校验_{DateTime.Now:yyyyMMddHHmm}.xlsx";
+                string fullPath = Path.Combine(targetDir, fileName);
 
-                if (fileData == null || fileData.Length == 0)
+                // 生成文件
+                var isSuccess = await ExportToExcel(feeList, fullPath, "超支验证明细");
+
+                if (isSuccess)
                 {
-                    return Ok(JsonView(false, "生成文件流失败,请检查数据源"));
+                    return Ok(JsonView(true, "操作成功", new { url = $"{baseUrl}{fptPath}{dirName}/{fileName}" }));
                 }
-
-                return File(
-                    fileData,
-                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
-                    fileName
-                );
+                return Ok(JsonView(false, "文件生成失败"));
             }
             catch (Exception ex)
             {
-                return Ok(JsonView(false, $"导出过程中发生系统错误:{ex.Message}"));
+                return Ok(JsonView(false, $"导出异常:{ex.Message}"));
             }
         }
 
@@ -3785,72 +3771,61 @@ ORDER BY
             _ => "未知"
         };
 
-        public static byte[] ExportToExcel<T>(IEnumerable<T> data, string sheetName)
+        public static async Task<bool> ExportToExcel<T>(IEnumerable<T> data, string absolutePath, string sheetName)
         {
             Workbook workbook = new Workbook();
             Worksheet sheet = workbook.Worksheets[0];
             sheet.Name = sheetName;
-
             PropertyInfo[] props = typeof(T).GetProperties();
 
-            // 定义全局字体名称
-            const string MicrosoftYaHei = "微软雅黑";
+            // 1. 表头处理
+            Style headerStyle = workbook.CreateStyle();
+            headerStyle.Font.Name = "微软雅黑";
+            headerStyle.Font.IsBold = true;
+            headerStyle.Pattern = BackgroundType.Solid;
+            headerStyle.ForegroundColor = System.Drawing.Color.LightGray;
+            headerStyle.HorizontalAlignment = TextAlignmentType.Center;
 
-            // --- 1. 表头处理 ---
             for (int i = 0; i < props.Length; i++)
             {
-                var displayAttr = props[i].GetCustomAttribute<DisplayAttribute>();
-                string headerName = displayAttr?.Name ?? props[i].Name;
-
-                Aspose.Cells.Cell cell = sheet.Cells[0, i];
-                cell.PutValue(headerName);
-
-                // 设置表头样式:加粗 + 微软雅黑
-                Style style = cell.GetStyle();
-                style.Font.Name = MicrosoftYaHei;
-                style.Font.IsBold = true;
-                style.Font.Size = 11;
-                style.BackgroundColor = System.Drawing.Color.FromArgb(235, 235, 235);
-                style.Pattern = BackgroundType.Solid;
-                style.HorizontalAlignment = TextAlignmentType.Center;
-                cell.SetStyle(style);
+                var attr = props[i].GetCustomAttribute<DisplayAttribute>();
+                sheet.Cells[0, i].PutValue(attr?.Name ?? props[i].Name);
+                sheet.Cells[0, i].SetStyle(headerStyle);
             }
 
-            // --- 2. 数据处理 ---
-            var dataList = data.ToList();
-            for (int rowIndex = 0; rowIndex < dataList.Count; rowIndex++)
+            // 2. 数据处理
+            var list = data.ToList();
+            for (int r = 0; r < list.Count; r++)
             {
-                var item = dataList[rowIndex];
-                for (int colIndex = 0; colIndex < props.Length; colIndex++)
+                for (int c = 0; c < props.Length; c++)
                 {
-                    var value = props[colIndex].GetValue(item);
-                    Aspose.Cells.Cell cell = sheet.Cells[rowIndex + 1, colIndex];
-                    cell.PutValue(value);
+                    var val = props[c].GetValue(list[r]);
+                    Aspose.Cells.Cell cell = sheet.Cells[r + 1, c];
+                    cell.PutValue(val);
 
-                    // 设置单元格基础样式:微软雅黑
-                    Style cellStyle = cell.GetStyle();
-                    cellStyle.Font.Name = MicrosoftYaHei;
-                    cellStyle.Font.Size = 10;
-
-                    // 成功/错误验证:高亮逻辑
-                    if (props[colIndex].Name == "OverspentLabel" && value?.ToString()?.Contains("超支") == true)
+                    // 高亮逻辑:仅在超支单元格设置红色
+                    if (props[c].Name == "OverspentLabel" && val?.ToString()?.Contains("超支") == true)
                     {
-                        cellStyle.Font.Color = System.Drawing.Color.Red;
-                        cellStyle.Font.IsBold = true;
+                        Style s = cell.GetStyle();
+                        s.Font.Color = System.Drawing.Color.Red;
+                        s.Font.IsBold = true;
+                        cell.SetStyle(s);
                     }
-
-                    cell.SetStyle(cellStyle);
                 }
             }
-
             sheet.AutoFitColumns();
 
-            // --- 3. 转化为文件流 ---
-            using (MemoryStream ms = new MemoryStream())
+            // 3. 物理保存
+            try
             {
-                workbook.Save(ms, SaveFormat.Xlsx);
-                return ms.ToArray();
+                using (FileStream fs = new FileStream(absolutePath, FileMode.Create, FileAccess.Write, FileShare.None))
+                {
+                    workbook.Save(fs, SaveFormat.Xlsx);
+                    await fs.FlushAsync();
+                }
+                return System.IO.File.Exists(absolutePath);
             }
+            catch { return false; }
         }
 
         #endregion

+ 41 - 3
OASystem/OASystem.Api/OAMethodLib/Hotmail/HotmailService.cs

@@ -3,6 +3,7 @@ using Microsoft.Graph;
 using Microsoft.Graph.Models;
 using Microsoft.Graph.Models.ODataErrors;
 using Microsoft.Kiota.Abstractions.Authentication;
+using MimeKit;
 using System.Collections.Concurrent;
 using System.Text.Json;
 using System.Text.Json.Serialization;
@@ -185,6 +186,36 @@ namespace OASystem.API.OAMethodLib.Hotmail
             {
                 var client = await GetClientAsync(fromEmail, _sqlSugar);
 
+                // 1. 构建附件列表
+                var graphAttachments = new List<Attachment>();
+                if (mail.AttachmentPaths?.Any() == true)
+                {
+                    // 获取物理根路径
+                    string baseDir = AppSettingsHelper.Get("InvitationAIAssistBasePath");
+
+                    foreach (var path in mail.AttachmentPaths)
+                    {
+                        // 相对路径,需要提取出文件名并拼接物理全路径
+                        string fileName = Path.GetFileName(path);
+
+                        string directoryPath = Path.GetDirectoryName(path);
+                        string dirName = Path.GetFileName(directoryPath);
+
+                        string fullPath = Path.Combine(baseDir, dirName, fileName);
+
+                        if (System.IO.File.Exists(fullPath))
+                        {
+                            byte[] contentBytes = await System.IO.File.ReadAllBytesAsync(fullPath);
+                            graphAttachments.Add(new FileAttachment
+                            {
+                                Name = fileName,
+                                ContentBytes = contentBytes,
+                                ContentType = MimeTypes.GetMimeType(fileName) 
+                            });
+                        }
+                    }
+                }
+
                 var requestBody = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody
                 {
                     Message = new Message
@@ -196,9 +227,10 @@ namespace OASystem.API.OAMethodLib.Hotmail
                             ContentType = BodyType.Html
                         },
                         ToRecipients = new List<Recipient>
-                {
-                    new Recipient { EmailAddress = new EmailAddress { Address = mail.To } }
-                }
+                        {
+                            new Recipient { EmailAddress = new EmailAddress { Address = mail.To } }
+                        },
+                        Attachments = graphAttachments
                     }
                 };
 
@@ -518,6 +550,12 @@ namespace OASystem.API.OAMethodLib.Hotmail
             [JsonPropertyName("content")]
             public string? Content { get; set; }
 
+            /// <summary>
+            /// 附件地址
+            /// </summary>
+            [JsonPropertyName("attachments")]
+            public List<string> AttachmentPaths { get; set; } = new List<string>();
+
             /// <summary>
             /// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性
             /// </summary>

+ 28 - 1
OASystem/OASystem.Domain/Dtos/Resource/InvitationAI.cs

@@ -1,4 +1,5 @@
-using OASystem.Domain.Entities.Resource;
+using Microsoft.AspNetCore.Http;
+using OASystem.Domain.Entities.Resource;
 
 namespace OASystem.Domain.Dtos.Resource
 {
@@ -89,6 +90,32 @@ namespace OASystem.Domain.Dtos.Resource
         public string EmailContent { get; set; }
     }
 
+    public class InvitationAIFileSaveDto : InvitationAISearchDto
+    {
+        /// <summary>
+        /// Guid
+        /// </summary>
+        public string Guid { get; set; }
+
+        /// <summary>
+        /// 附件
+        /// </summary>
+        public List<IFormFile> Attachments { get; set; }
+    }
+
+
+    public class InvitationAIFileDelDto : InvitationAISearchDto
+    {
+        /// <summary>
+        /// Guid
+        /// </summary>
+        public string Guid { get; set; }
+
+        /// <summary>
+        /// 附件Names
+        /// </summary>
+        public List<string> AttachmentNames { get; set; }
+    }
 
     public class InvitationAIGenerateEmailDto: InvitationAISearchDto
     {

+ 5 - 0
OASystem/OASystem.Domain/Entities/Resource/Res_InvitationAI.cs

@@ -210,6 +210,11 @@ namespace OASystem.Domain.Entities.Resource
         /// </summary>
         public string EmailContent { get; set; }
 
+        /// <summary>
+        /// 附件地址
+        /// </summary>
+        public List<string> AttachmentPaths { get; set; } = new List<string>();
+
         /// <summary>
         /// 发送状态
         /// 1.未开始