diff --git a/BMA.EHR.Application/Repositories/Reports/RetireReportRepository.cs b/BMA.EHR.Application/Repositories/Reports/RetireReportRepository.cs index ecb65e2f..6ad5a61a 100644 --- a/BMA.EHR.Application/Repositories/Reports/RetireReportRepository.cs +++ b/BMA.EHR.Application/Repositories/Reports/RetireReportRepository.cs @@ -192,7 +192,7 @@ namespace BMA.EHR.Application.Repositories.Reports }).ToList(); } string SignDate = retireHistorys.SignDate != null ? DateTime.Parse(retireHistorys.SignDate.ToString()).ToThaiFullDate().ToString().ToThaiNumber() : "-"; - return new { SignDate, retireHistorys.Detail, retireHistorys.Id, retireHistorys.CreatedAt, Year = retireHistorys.Year.ToThaiYear().ToString().ToThaiNumber(), retireHistorys.Round, retireHistorys.Type, retireHistorys.TypeReport, Total = retireHistorys.Total.ToString().ToThaiNumber(), profiles = mapProfiles }; + return new { SignDate, Detail = retireHistorys.Detail.ToThaiNumber(), retireHistorys.Id, retireHistorys.CreatedAt, Year = retireHistorys.Year.ToThaiYear().ToString().ToThaiNumber(), retireHistorys.Round, retireHistorys.Type, retireHistorys.TypeReport, Total = retireHistorys.Total.ToString().ToThaiNumber(), profiles = mapProfiles }; } } else @@ -312,7 +312,7 @@ namespace BMA.EHR.Application.Repositories.Reports root = (isDuplicateRoot ? "" : profile.root + "\n") + (isDuplicateHospital || !hospital.ToObject>().Contains(profile.child1) ? "" : profile.child1 + "\n") + (isDuplicatePosType ? "" : $"ตำแหน่งประเภท{profile.posTypeName}" + "\n") + - (isDuplicatePosLevel ? "" : $"ระดับ{profile.posLevelName}"), + (isDuplicatePosLevel ? "" : $"ระดับ{profile.posLevelName}").ToThaiNumber(), child = (profile.posExecutiveName == null ? "" : profile.posExecutiveName + "\n") + (profile.child4 == null ? "" : profile.child4 + "\n") + (profile.child3 == null ? "" : profile.child3 + "\n") + @@ -326,7 +326,7 @@ namespace BMA.EHR.Application.Repositories.Reports }).ToList(); } string SignDate = retire.SignDate != null ? DateTime.Parse(retire.SignDate.ToString()).ToThaiFullDate().ToString().ToThaiNumber() : "-"; - return new { SignDate, retire.Detail, retire.Id, retire.CreatedAt, Year = retire.Year.ToThaiYear().ToString().ToThaiNumber(), retire.Round, retire.Type, retire.TypeReport, Total = profile_retire.Count.ToString().ToThaiNumber(), profiles = mapProfiles }; + return new { SignDate, Detail = retire.Detail.ToThaiNumber(), retire.Id, retire.CreatedAt, Year = retire.Year.ToThaiYear().ToString().ToThaiNumber(), retire.Round, retire.Type, retire.TypeReport, Total = profile_retire.Count.ToString().ToThaiNumber(), profiles = mapProfiles }; } } #endregion diff --git a/BMA.EHR.Leave/Controllers/LeaveController.cs b/BMA.EHR.Leave/Controllers/LeaveController.cs index 1bd92859..c77bad95 100644 --- a/BMA.EHR.Leave/Controllers/LeaveController.cs +++ b/BMA.EHR.Leave/Controllers/LeaveController.cs @@ -3246,23 +3246,25 @@ namespace BMA.EHR.Leave.Service.Controllers } else if (actRole == "BROTHER") { - actNodeId = act.child3DnaId != null ? + actNodeId = act.child4DnaId != null ? act.child3DnaId.Value.ToString("D") : - act.child2DnaId != null ? + act.child3DnaId != null ? act.child2DnaId.Value.ToString("D") : - act.child1DnaId != null ? - act.rootDnaId!.Value.ToString("D") : + act.child2DnaId != null ? + act.child1DnaId!.Value.ToString("D") : + act.child1DnaId != null ? + act.rootDnaId.Value.ToString("D") : act.rootDnaId != null ? act.rootDnaId.Value.ToString("D") : ""; actNode = act.child4DnaId != null ? 4 : act.child3DnaId != null ? - 4 : - act.child2DnaId != null ? 3 : - act.child1DnaId != null ? + act.child2DnaId != null ? 2 : + act.child1DnaId != null ? + 1 : act.rootDnaId != null ? 0 : null; diff --git a/BMA.EHR.Leave/Controllers/LeaveRequestController.cs b/BMA.EHR.Leave/Controllers/LeaveRequestController.cs index 4a28e6b9..daa2c07c 100644 --- a/BMA.EHR.Leave/Controllers/LeaveRequestController.cs +++ b/BMA.EHR.Leave/Controllers/LeaveRequestController.cs @@ -1439,23 +1439,25 @@ namespace BMA.EHR.Leave.Service.Controllers } else if (actRole == "BROTHER") { - actNodeId = act.child3DnaId != null ? + actNodeId = act.child4DnaId != null ? act.child3DnaId.Value.ToString("D") : - act.child2DnaId != null ? + act.child3DnaId != null ? act.child2DnaId.Value.ToString("D") : - act.child1DnaId != null ? - act.rootDnaId!.Value.ToString("D") : + act.child2DnaId != null ? + act.child1DnaId!.Value.ToString("D") : + act.child1DnaId != null ? + act.rootDnaId.Value.ToString("D") : act.rootDnaId != null ? act.rootDnaId.Value.ToString("D") : ""; actNode = act.child4DnaId != null ? 4 : act.child3DnaId != null ? - 4 : - act.child2DnaId != null ? 3 : - act.child1DnaId != null ? + act.child2DnaId != null ? 2 : + act.child1DnaId != null ? + 1 : act.rootDnaId != null ? 0 : null; @@ -1917,23 +1919,25 @@ namespace BMA.EHR.Leave.Service.Controllers } else if (actRole == "BROTHER") { - actNodeId = act.child3DnaId != null ? + actNodeId = act.child4DnaId != null ? act.child3DnaId.Value.ToString("D") : - act.child2DnaId != null ? + act.child3DnaId != null ? act.child2DnaId.Value.ToString("D") : - act.child1DnaId != null ? - act.rootDnaId!.Value.ToString("D") : + act.child2DnaId != null ? + act.child1DnaId!.Value.ToString("D") : + act.child1DnaId != null ? + act.rootDnaId.Value.ToString("D") : act.rootDnaId != null ? act.rootDnaId.Value.ToString("D") : ""; actNode = act.child4DnaId != null ? 4 : act.child3DnaId != null ? - 4 : - act.child2DnaId != null ? 3 : - act.child1DnaId != null ? + act.child2DnaId != null ? 2 : + act.child1DnaId != null ? + 1 : act.rootDnaId != null ? 0 : null; @@ -2220,23 +2224,25 @@ namespace BMA.EHR.Leave.Service.Controllers } else if (actRole == "BROTHER") { - actNodeId = act.child3DnaId != null ? + actNodeId = act.child4DnaId != null ? act.child3DnaId.Value.ToString("D") : - act.child2DnaId != null ? + act.child3DnaId != null ? act.child2DnaId.Value.ToString("D") : - act.child1DnaId != null ? - act.rootDnaId!.Value.ToString("D") : + act.child2DnaId != null ? + act.child1DnaId!.Value.ToString("D") : + act.child1DnaId != null ? + act.rootDnaId.Value.ToString("D") : act.rootDnaId != null ? act.rootDnaId.Value.ToString("D") : ""; actNode = act.child4DnaId != null ? 4 : act.child3DnaId != null ? - 4 : - act.child2DnaId != null ? 3 : - act.child1DnaId != null ? + act.child2DnaId != null ? 2 : + act.child1DnaId != null ? + 1 : act.rootDnaId != null ? 0 : null; diff --git a/BMA.EHR.Retirement.Service/BMA.EHR.Retirement.Service.csproj b/BMA.EHR.Retirement.Service/BMA.EHR.Retirement.Service.csproj index 255f2a75..6a820e15 100644 --- a/BMA.EHR.Retirement.Service/BMA.EHR.Retirement.Service.csproj +++ b/BMA.EHR.Retirement.Service/BMA.EHR.Retirement.Service.csproj @@ -40,10 +40,17 @@ + + + + PreserveNewest + + + diff --git a/BMA.EHR.Retirement.Service/Controllers/RetirementController.cs b/BMA.EHR.Retirement.Service/Controllers/RetirementController.cs index ad3105ba..b7a8b8df 100644 --- a/BMA.EHR.Retirement.Service/Controllers/RetirementController.cs +++ b/BMA.EHR.Retirement.Service/Controllers/RetirementController.cs @@ -7,6 +7,7 @@ using BMA.EHR.Domain.Models.Retirement; using BMA.EHR.Domain.Shared; using BMA.EHR.Infrastructure.Persistence; using BMA.EHR.Retirement.Service.Requests; +using BMA.EHR.Retirement.Service.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -37,6 +38,7 @@ namespace BMA.EHR.Retirement.Service.Controllers private readonly PermissionRepository _permission; private readonly DisciplineDbContext _contextDiscipline; private readonly RetireReportRepository _service; + private readonly RetirementReportService _reportService; public RetirementController(RetirementRepository repository, NotificationRepository repositoryNoti, ApplicationDBContext context, @@ -46,7 +48,8 @@ namespace BMA.EHR.Retirement.Service.Controllers IHttpContextAccessor httpContextAccessor, PermissionRepository permission, DisciplineDbContext contextDiscipline, - RetireReportRepository service) + RetireReportRepository service, + RetirementReportService reportService) { _repository = repository; _repositoryNoti = repositoryNoti; @@ -58,6 +61,7 @@ namespace BMA.EHR.Retirement.Service.Controllers _permission = permission; _contextDiscipline = contextDiscipline; _service = service; + _reportService = reportService; } #region " Properties " @@ -2213,5 +2217,83 @@ namespace BMA.EHR.Retirement.Service.Controllers } } #endregion + + #region รายงานรายชื่อผู้เกษียณอายุราชการ ข้าราชการ & ลูกจ้างประจำ + /// + /// รายงานรายชื่อผู้เกษียณอายุราชการ ข้าราชการ & ลูกจ้างประจำ + /// + /// Id ของรอบเกษียณ + /// pdf, docx + /// + /// เมื่อทำการอ่านข้อมูลจาก Relational Database สำเร็จ + /// ไม่ได้ Login เข้าระบบ + /// เมื่อเกิดข้อผิดพลาดในการทำงาน + [HttpGet("report/{exportType}/{Id}")] + public async Task> GetReportProfileRetirement([FromRoute] Guid Id, string exportType = "pdf") + { + var retire = await _service.GetProfileRetirementdAsync(Id, token); + if (retire != null) + { + var reportfile = string.Empty; + exportType = exportType.Trim(); + + switch (retire.GetType().GetProperty("Type").GetValue(retire)) + { + case "OFFICER": + if (string.IsNullOrEmpty(retire.GetType().GetProperty("TypeReport").GetValue(retire))) + { + reportfile = $"retire-1"; + } + else if (retire.GetType().GetProperty("TypeReport").GetValue(retire) == "ADD" || retire.GetType().GetProperty("TypeReport").GetValue(retire) == "EDIT") + { + reportfile = $"retire-2"; + } + else if (retire.GetType().GetProperty("TypeReport").GetValue(retire) == "REMOVE") + { + reportfile = $"retire-3"; + } + else + { + return Error(retire.GetType().GetProperty("TypeReport").GetValue(retire)); + } + break; + case "EMPLOYEE": + if (string.IsNullOrEmpty(retire.GetType().GetProperty("TypeReport").GetValue(retire))) + { + reportfile = $"retire-emp-1"; + } + else if (retire.GetType().GetProperty("TypeReport").GetValue(retire) == "ADD" || retire.GetType().GetProperty("TypeReport").GetValue(retire) == "EDIT") + { + reportfile = $"retire-emp-2"; + } + else if (retire.GetType().GetProperty("TypeReport").GetValue(retire) == "REMOVE") + { + reportfile = $"retire-emp-3"; + } + else + { + return Error(retire.GetType().GetProperty("TypeReport").GetValue(retire)); + } + break; + default: + return Error(retire.GetType().GetProperty("Type").GetValue(retire)); + } + + var reportBytes = await _reportService.GenerateReportAsync(reportfile, retire, exportType); + + var fileName = $"reportRetirement-{DateTime.Now:yyyyMMdd-HHmmss}.{exportType}"; + var contentType = exportType.Trim().ToLower() == "pdf" + ? "application/pdf" + : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + return File(reportBytes, contentType, fileName); + + } + else + { + return NotFound(); + } + } + #endregion } } diff --git a/BMA.EHR.Retirement.Service/Program.cs b/BMA.EHR.Retirement.Service/Program.cs index ee807e0a..a55f9674 100644 --- a/BMA.EHR.Retirement.Service/Program.cs +++ b/BMA.EHR.Retirement.Service/Program.cs @@ -3,6 +3,7 @@ using BMA.EHR.Domain.Middlewares; using BMA.EHR.Infrastructure; using BMA.EHR.Infrastructure.Persistence; using BMA.EHR.Retirement.Service; +using BMA.EHR.Retirement.Service.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -86,6 +87,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddApplication(); builder.Services.AddLeaveApplication(); builder.Services.AddPersistence(builder.Configuration); + builder.Services.AddScoped(); builder.Services.AddLeavePersistence(builder.Configuration); builder.Services.AddHttpClient(); diff --git a/BMA.EHR.Retirement.Service/Services/RetirementReportService.cs b/BMA.EHR.Retirement.Service/Services/RetirementReportService.cs new file mode 100644 index 00000000..82bf2b27 --- /dev/null +++ b/BMA.EHR.Retirement.Service/Services/RetirementReportService.cs @@ -0,0 +1,638 @@ +using BMA.EHR.Application.Responses; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace BMA.EHR.Retirement.Service.Services +{ + public class RetirementReportService + { + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + /// + /// Initializes a new instance of the RetirementReportService class. + /// + public RetirementReportService( + IWebHostEnvironment environment, + ILogger logger, + IConfiguration configuration) + { + _environment = environment; + _logger = logger; + _configuration = configuration; + } + + #region Public Methods + + /// + /// สร้างรายงานจาก Template (.docx) + /// + public async Task GenerateReportAsync(string templateName, dynamic data, string exportType) + { + try + { + var templatePath = GetTemplatePath(templateName); + var docxBytes = await ProcessTemplateAsync(templatePath, data); + + return exportType.ToLower() == "pdf" + ? await ConvertToPdfAsync(docxBytes) + : docxBytes; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating report"); + throw; + } + } + + #endregion + + #region Template Processing + + private string GetTemplatePath(string templateName) + { + var path = Path.Combine(_environment.ContentRootPath, "Templates", $"{templateName}.docx"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Template not found: {templateName}"); + return path; + } + + private async Task ProcessTemplateAsync(string templatePath, dynamic data) + { + using var templateStream = File.OpenRead(templatePath); + using var outputStream = new MemoryStream(); + await templateStream.CopyToAsync(outputStream); + outputStream.Position = 0; + + using (var wordDoc = WordprocessingDocument.Open(outputStream, true)) + { + var mainPart = wordDoc.MainDocumentPart; + if (mainPart == null) return Array.Empty(); + + ReplacePlaceholders(mainPart, data); + wordDoc.Save(); + } + + return outputStream.ToArray(); + } + + private void ReplacePlaceholders(MainDocumentPart mainPart, dynamic data) + { + var document = mainPart.Document; + if (document == null) return; + + var processor = CreateDataProcessor(data); + processor.Process(document, new Action(FillTableRows)); + } + + #endregion + + #region Data Processing Strategy + + private IDataProcessor CreateDataProcessor(dynamic data) + { + var dataType = data.GetType(); + var isDictionary = dataType.IsGenericType && + dataType.GetGenericTypeDefinition() == typeof(Dictionary<,>); + + return isDictionary + ? new DictionaryDataProcessor(data) + : new ObjectDataProcessor(data); + } + + #endregion + + #region Table Processing + + private void FillTableRows(Document document, System.Collections.IEnumerable profiles) + { + var table = document.Descendants().FirstOrDefault(); + if (table == null) return; + + var rows = table.Elements().ToList(); + if (rows.Count == 0) return; + + var strategy = CreateTableStrategy(rows); + strategy.Process(table, rows, profiles); + } + + private static ITableStrategy CreateTableStrategy(List rows) + { + // retire-1 format: 1 row, 1 cell, 1 paragraph + if (IsSingleParagraphFormat(rows)) + return new SingleParagraphTableStrategy(); + + // retire-3 format: 2+ rows (header + template) + return new MultiRowTableStrategy(); + } + + private static bool IsSingleParagraphFormat(List rows) => + rows.Count == 1 && + rows[0].Elements().Count() == 1 && + rows[0].Elements().First().Elements().Count() == 1; + + #endregion + + #region PDF Conversion + + private async Task ConvertToPdfAsync(byte[] docxBytes) + { + var tempDocx = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.docx"); + var tempPdf = Path.ChangeExtension(tempDocx, ".pdf"); + + try + { + await File.WriteAllBytesAsync(tempDocx, docxBytes); + await ConvertToPdfInternalAsync(tempDocx, tempPdf); + return await File.ReadAllBytesAsync(tempPdf); + } + finally + { + if (File.Exists(tempDocx)) File.Delete(tempDocx); + if (File.Exists(tempPdf)) File.Delete(tempPdf); + } + } + + private async Task ConvertToPdfInternalAsync(string docxPath, string pdfPath) + { + try + { + var useDocker = _configuration.GetValue("LibreOffice:UseDocker", false); + var timeout = _configuration.GetValue("LibreOffice:Timeout", 180000); + + if (useDocker) + { + await ConvertToPdfViaDockerAsync(docxPath, pdfPath, timeout); + } + else + { + // // PROD: Disabled local LibreOffice conversion + // await ConvertToPdfLocallyAsync(docxPath, pdfPath, timeout); + throw new NotSupportedException("LibreOffice conversion is disabled."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error converting to PDF"); + throw; + } + } + + private async Task ConvertToPdfViaDockerAsync(string docxPath, string pdfPath, int timeout) + { + var inputDir = _configuration["LibreOffice:InputDirectory"] ?? "/app/libreoffice/input"; + var outputDir = _configuration["LibreOffice:OutputDirectory"] ?? "/app/libreoffice/output"; + var fileName = Path.GetFileName(docxPath); + var pdfName = Path.ChangeExtension(fileName, ".pdf"); + + // Ensure directories exist + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(outputDir); + + // Copy file to input folder (LibreOffice watcher will pick it up) + var inputPath = Path.Combine(inputDir, fileName).Replace('\\', '/'); + var outputPath = Path.Combine(outputDir, pdfName).Replace('\\', '/'); + + _logger.LogInformation("📤 Sending file to LibreOffice: {FileName}", fileName); + await File.WriteAllBytesAsync(inputPath, await File.ReadAllBytesAsync(docxPath)); + + // Wait for LibreOffice to convert (file watcher handles it) + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var pollInterval = TimeSpan.FromMilliseconds(500); + + while (stopwatch.ElapsedMilliseconds < timeout) + { + if (File.Exists(outputPath)) + { + _logger.LogInformation("✅ PDF received: {PdfName} (took {ElapsedMs}ms)", pdfName, stopwatch.ElapsedMilliseconds); + + await File.WriteAllBytesAsync(pdfPath, await File.ReadAllBytesAsync(outputPath)); + + // Cleanup + try + { + if (File.Exists(outputPath)) File.Delete(outputPath); + _logger.LogDebug("🗑️ Cleaned up output file: {PdfName}", pdfName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cleanup output file"); + } + + return; + } + + await Task.Delay(pollInterval); + } + + throw new TimeoutException($"LibreOffice conversion timed out after {timeout}ms. File not found: {outputPath}"); + } + + // // PROD: Disabled local LibreOffice conversion + // private async Task ConvertToPdfLocallyAsync(string docxPath, string pdfPath, int timeout) + // { + // var libreOfficePath = _configuration["LibreOffice:Path"] ?? GetDefaultLibreOfficePath(); + // var arguments = _configuration["LibreOffice:Arguments"] ?? "--headless --convert-to pdf --nologo --norestore"; + // var outputDir = Path.GetDirectoryName(pdfPath); + + // if (string.IsNullOrEmpty(outputDir)) + // { + // throw new DirectoryNotFoundException("Output directory cannot be determined"); + // } + + // var psi = new ProcessStartInfo + // { + // FileName = libreOfficePath, + // Arguments = $"{arguments} --outdir \"{outputDir}\" \"{docxPath}\"", + // UseShellExecute = false, + // RedirectStandardOutput = true, + // RedirectStandardError = true, + // CreateNoWindow = true + // }; + + // using var process = Process.Start(psi); + // var exited = process.WaitForExit(timeout); + + // if (!exited) + // { + // process.Kill(entireProcessTree: true); + // throw new TimeoutException($"LibreOffice conversion timed out after {timeout}ms"); + // } + + // if (process.ExitCode != 0) + // { + // var error = await process.StandardError.ReadToEndAsync(); + // throw new Exception($"LibreOffice conversion failed: {error}"); + // } + // } + + // // PROD: Disabled local LibreOffice path detection + // private static string GetDefaultLibreOfficePath() + // { + // if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + // { + // var possiblePaths = new[] + // { + // @"C:\Program Files\LibreOffice\program\soffice.exe", + // @"C:\Program Files (x86)\LibreOffice\program\soffice.exe", + // @"C:\Program Files\LibreOffice\program\soffice.com" + // }; + + // return possiblePaths.FirstOrDefault(File.Exists) + // ?? throw new FileNotFoundException("LibreOffice not found. Please install LibreOffice or configure the path in appsettings.json"); + // } + + // // Linux/Docker: use default path + // return "libreoffice"; + // } + + #endregion + } + + #region Data Processor Interfaces & Implementations + + internal interface IDataProcessor + { + void Process(Document document, Action tableFiller); + } + + internal class DictionaryDataProcessor : IDataProcessor + { + private readonly dynamic _data; + + public DictionaryDataProcessor(dynamic data) + { + _data = data; + } + + public void Process(Document document, Action tableFiller) + { + var keys = _data.Keys as System.Collections.ICollection; + if (keys == null) return; + + System.Collections.IEnumerable? profiles = null; + + foreach (string key in keys) + { + if (key.Equals("profiles", StringComparison.OrdinalIgnoreCase)) + { + profiles = _data[key] as System.Collections.IEnumerable; + continue; + } + + var valueObj = _data[key]; + if (valueObj != null && typeof(System.Collections.IEnumerable).IsAssignableFrom(valueObj.GetType()) && + valueObj.GetType() != typeof(string)) + { + continue; + } + + var value = valueObj?.ToString() ?? string.Empty; + var placeholder = $"{{{{{key}}}}}"; + TextReplacer.ReplaceAll(document, placeholder, value); + } + + if (profiles != null) + { + tableFiller(document, profiles); + } + } + } + + internal class ObjectDataProcessor : IDataProcessor + { + private readonly dynamic _data; + + public ObjectDataProcessor(dynamic data) + { + _data = data; + } + + public void Process(Document document, Action tableFiller) + { + var dataType = _data.GetType(); + var allProps = dataType.GetProperties(); + var validProps = new List(); + + foreach (var p in allProps) + { + if (p.GetIndexParameters().Length == 0) + { + validProps.Add(p); + } + } + + System.Collections.IEnumerable? profiles = null; + + foreach (var prop in validProps) + { + var propType = prop.PropertyType; + bool isEnumerable = typeof(System.Collections.IEnumerable).IsAssignableFrom(propType); + bool isString = propType == typeof(string); + + if (isEnumerable && !isString) + { + if (prop.Name.Equals("profiles", StringComparison.OrdinalIgnoreCase)) + { + profiles = prop.GetValue(_data) as System.Collections.IEnumerable; + } + continue; + } + + var value = prop.GetValue(_data)?.ToString() ?? string.Empty; + var placeholder = $"{{{{{prop.Name}}}}}"; + TextReplacer.ReplaceAll(document, placeholder, value); + } + + if (profiles != null) + { + tableFiller(document, profiles); + } + } + } + + #endregion + + #region Text Replacer + + internal static class TextReplacer + { + public static void ReplaceAll(Document document, string oldValue, string newValue) + { + bool found = false; + + // Method 1: Check within single Run + foreach (var run in document.Descendants()) + { + var textElements = run.Elements().ToList(); + if (textElements.Count == 0) continue; + + var combinedText = string.Concat(textElements.Select(t => t.Text)); + if (combinedText.Contains(oldValue)) + { + found = true; + var replacedText = combinedText.Replace(oldValue, newValue); + textElements[0].Text = replacedText; + for (int i = 1; i < textElements.Count; i++) + { + textElements[i].Text = string.Empty; + } + } + } + + // Method 2: Check across all Runs in Paragraph + foreach (var para in document.Descendants()) + { + var allRuns = para.Elements().ToList(); + if (allRuns.Count == 0) continue; + + var combinedParaText = string.Concat(allRuns.SelectMany(r => r.Elements().Select(t => t.Text))); + if (combinedParaText.Contains(oldValue)) + { + found = true; + var replacedText = combinedParaText.Replace(oldValue, newValue); + + var firstRunTexts = allRuns[0].Elements().ToList(); + if (firstRunTexts.Count > 0) + { + firstRunTexts[0].Text = replacedText; + for (int i = 1; i < firstRunTexts.Count; i++) + { + firstRunTexts[i].Text = string.Empty; + } + } + + for (int i = 1; i < allRuns.Count; i++) + { + foreach (var t in allRuns[i].Elements()) + { + t.Text = string.Empty; + } + } + } + } + + // Fallback: Check individual Text elements + if (!found) + { + foreach (var text in document.Descendants()) + { + if (!string.IsNullOrEmpty(text.Text) && text.Text.Contains(oldValue)) + { + found = true; + text.Text = text.Text.Replace(oldValue, newValue); + } + } + } + } + + public static void ReplaceInRow(TableRow row, string oldValue, string newValue) + { + bool found = false; + + foreach (var cell in row.Descendants()) + { + foreach (var para in cell.Elements()) + { + found = ReplaceInParagraph(para, oldValue, newValue) || found; + } + } + + // Fallback: Check individual Text elements + if (!found) + { + foreach (var text in row.Descendants()) + { + if (!string.IsNullOrEmpty(text.Text) && text.Text.Contains(oldValue)) + { + found = true; + text.Text = text.Text.Replace(oldValue, newValue); + } + } + } + } + + public static bool ReplaceInParagraph(Paragraph paragraph, string oldValue, string newValue) + { + bool found = false; + + var allTexts = paragraph.Descendants().ToList(); + if (allTexts.Count == 0) return false; + + var combinedParaText = string.Concat(allTexts.Select(t => t.Text)); + + if (combinedParaText.Contains(oldValue)) + { + found = true; + var replacedText = combinedParaText.Replace(oldValue, newValue); + allTexts[0].Text = replacedText; + + for (int i = 1; i < allTexts.Count; i++) + { + allTexts[i].Text = string.Empty; + } + } + + // Fallback: Check individual Text elements + if (!found) + { + foreach (var text in allTexts) + { + if (!string.IsNullOrEmpty(text.Text) && text.Text.Contains(oldValue)) + { + found = true; + text.Text = text.Text.Replace(oldValue, newValue); + } + } + } + + return found; + } + } + + #endregion + + #region Table Strategy Interfaces & Implementations + + internal interface ITableStrategy + { + void Process(Table table, List rows, System.Collections.IEnumerable profiles); + } + + internal class SingleParagraphTableStrategy : ITableStrategy + { + public void Process(Table table, List rows, System.Collections.IEnumerable profiles) + { + var cell = rows[0].Elements().First(); + var templatePara = cell.Elements().First(); + + var profileList = profiles.Cast().ToList(); + + foreach (var profile in profileList) + { + var props = profile.GetType() + .GetProperties() + .Where(p => p.GetIndexParameters().Length == 0) + .ToList(); + + var newPara = (Paragraph)templatePara.CloneNode(true); + + foreach (var prop in props) + { + var value = prop.GetValue(profile)?.ToString() ?? string.Empty; + var placeholder = $"{{{{{prop.Name}}}}}"; + TextReplacer.ReplaceInParagraph(newPara, placeholder, value); + } + + cell.Append(newPara); + } + + templatePara.Remove(); + } + } + + internal class MultiRowTableStrategy : ITableStrategy + { + public void Process(Table table, List rows, System.Collections.IEnumerable profiles) + { + var templateRowIndex = rows.Count >= 2 ? 1 : 0; + var templateRow = rows[templateRowIndex]; + templateRow.Remove(); + + var profileList = profiles.Cast().ToList(); + + // Process header row if exists + if (rows.Count >= 2) + { + ProcessHeaderRow(rows[0], profileList); + } + + // Process template rows + foreach (var profile in profileList) + { + var newRow = (TableRow)templateRow.CloneNode(true); + var props = profile.GetType() + .GetProperties() + .Where(p => p.GetIndexParameters().Length == 0) + .ToList(); + + foreach (var prop in props) + { + var value = prop.GetValue(profile)?.ToString() ?? string.Empty; + var placeholder = $"{{{{{prop.Name}}}}}"; + TextReplacer.ReplaceInRow(newRow, placeholder, value); + } + + table.AppendChild(newRow); + } + } + + private static void ProcessHeaderRow(TableRow headerRow, List profileList) + { + var firstProfile = profileList.FirstOrDefault(); + if (firstProfile == null) return; + + var props = firstProfile.GetType() + .GetProperties() + .Where(p => p.GetIndexParameters().Length == 0) + .ToList(); + + foreach (var prop in props) + { + var value = prop.GetValue(firstProfile)?.ToString() ?? string.Empty; + var placeholder = $"{{{{{prop.Name}}}}}"; + + if (!string.IsNullOrWhiteSpace(value)) + { + TextReplacer.ReplaceInRow(headerRow, placeholder, value); + } + } + } + } + + #endregion +} diff --git a/BMA.EHR.Retirement.Service/Templates/retire-1.docx b/BMA.EHR.Retirement.Service/Templates/retire-1.docx new file mode 100644 index 00000000..aae58587 Binary files /dev/null and b/BMA.EHR.Retirement.Service/Templates/retire-1.docx differ diff --git a/BMA.EHR.Retirement.Service/Templates/retire-2.docx b/BMA.EHR.Retirement.Service/Templates/retire-2.docx new file mode 100644 index 00000000..4c9db823 Binary files /dev/null and b/BMA.EHR.Retirement.Service/Templates/retire-2.docx differ diff --git a/BMA.EHR.Retirement.Service/Templates/retire-3.docx b/BMA.EHR.Retirement.Service/Templates/retire-3.docx new file mode 100644 index 00000000..4257c5f6 Binary files /dev/null and b/BMA.EHR.Retirement.Service/Templates/retire-3.docx differ diff --git a/BMA.EHR.Retirement.Service/Templates/retire-emp-1.docx b/BMA.EHR.Retirement.Service/Templates/retire-emp-1.docx new file mode 100644 index 00000000..28e5c9ea Binary files /dev/null and b/BMA.EHR.Retirement.Service/Templates/retire-emp-1.docx differ diff --git a/BMA.EHR.Retirement.Service/Templates/retire-emp-2.docx b/BMA.EHR.Retirement.Service/Templates/retire-emp-2.docx new file mode 100644 index 00000000..3fd290de Binary files /dev/null and b/BMA.EHR.Retirement.Service/Templates/retire-emp-2.docx differ diff --git a/BMA.EHR.Retirement.Service/Templates/retire-emp-3.docx b/BMA.EHR.Retirement.Service/Templates/retire-emp-3.docx new file mode 100644 index 00000000..622f5b8e Binary files /dev/null and b/BMA.EHR.Retirement.Service/Templates/retire-emp-3.docx differ