hrms-api-backend/BMA.EHR.Retirement.Service/Services/RetirementReportService.cs
harid 2e9db2d42c
All checks were successful
Build & Deploy Retirement Service / build (push) Successful in 1m50s
รายงานประกาศเกษียณ #2262, #2261
2026-04-20 12:37:06 +07:00

656 lines
23 KiB
C#

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<RetirementReportService> _logger;
private readonly IConfiguration _configuration;
/// <summary>
/// Initializes a new instance of the RetirementReportService class.
/// </summary>
public RetirementReportService(
IWebHostEnvironment environment,
ILogger<RetirementReportService> logger,
IConfiguration configuration)
{
_environment = environment;
_logger = logger;
_configuration = configuration;
}
#region Public Methods
/// <summary>
/// สร้างรายงานจาก Template (.docx)
/// </summary>
public async Task<byte[]> 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<byte[]> 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<byte>();
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<Document, System.Collections.IEnumerable>(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<Table>().FirstOrDefault();
if (table == null) return;
var rows = table.Elements<TableRow>().ToList();
if (rows.Count == 0) return;
var strategy = CreateTableStrategy(rows);
strategy.Process(table, rows, profiles);
}
private static ITableStrategy CreateTableStrategy(List<TableRow> 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<TableRow> rows) =>
rows.Count == 1 &&
rows[0].Elements<TableCell>().Count() == 1 &&
rows[0].Elements<TableCell>().First().Elements<Paragraph>().Count() == 1;
#endregion
#region PDF Conversion
private async Task<byte[]> 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<bool>("LibreOffice:UseDocker", false);
var timeout = _configuration.GetValue<int>("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 container = _configuration["LibreOffice:DockerContainer"] ?? "libreoffice";
var workingDir = _configuration["LibreOffice:WorkingDirectory"] ?? "/app/libreoffice/files";
var dockerFilesPath = _configuration["LibreOffice:DockerFilesPath"] ?? "/files";
var arguments = _configuration["LibreOffice:Arguments"] ?? "--headless --convert-to pdf --nologo --norestore";
// Ensure working directory exists
if (!Directory.Exists(workingDir))
{
Directory.CreateDirectory(workingDir);
}
// Copy file to shared directory
var fileName = Path.GetFileName(docxPath);
var sharedDocxPath = Path.Combine(workingDir, fileName);
var sharedPdfName = Path.ChangeExtension(fileName, ".pdf");
var sharedPdfPath = Path.Combine(workingDir, sharedPdfName);
await File.WriteAllBytesAsync(sharedDocxPath, await File.ReadAllBytesAsync(docxPath));
// Run LibreOffice inside Docker container
var dockerCmd = $"docker exec {container} libreoffice {arguments} --outdir {dockerFilesPath} {dockerFilesPath}/{fileName}";
var psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"{dockerCmd}\"",
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 Docker conversion timed out after {timeout}ms");
}
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync();
throw new Exception($"LibreOffice Docker conversion failed: {error}");
}
// Copy result back
if (!File.Exists(sharedPdfPath))
{
throw new FileNotFoundException($"PDF not generated in shared directory: {sharedPdfPath}");
}
await File.WriteAllBytesAsync(pdfPath, await File.ReadAllBytesAsync(sharedPdfPath));
// Cleanup shared files
try
{
if (File.Exists(sharedDocxPath)) File.Delete(sharedDocxPath);
if (File.Exists(sharedPdfPath)) File.Delete(sharedPdfPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup shared files");
}
}
// 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<Document, System.Collections.IEnumerable> tableFiller);
}
internal class DictionaryDataProcessor : IDataProcessor
{
private readonly dynamic _data;
public DictionaryDataProcessor(dynamic data)
{
_data = data;
}
public void Process(Document document, Action<Document, System.Collections.IEnumerable> 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<Document, System.Collections.IEnumerable> tableFiller)
{
var dataType = _data.GetType();
var allProps = dataType.GetProperties();
var validProps = new List<PropertyInfo>();
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<Run>())
{
var textElements = run.Elements<Text>().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<Paragraph>())
{
var allRuns = para.Elements<Run>().ToList();
if (allRuns.Count == 0) continue;
var combinedParaText = string.Concat(allRuns.SelectMany(r => r.Elements<Text>().Select(t => t.Text)));
if (combinedParaText.Contains(oldValue))
{
found = true;
var replacedText = combinedParaText.Replace(oldValue, newValue);
var firstRunTexts = allRuns[0].Elements<Text>().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<Text>())
{
t.Text = string.Empty;
}
}
}
}
// Fallback: Check individual Text elements
if (!found)
{
foreach (var text in document.Descendants<Text>())
{
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<TableCell>())
{
foreach (var para in cell.Elements<Paragraph>())
{
found = ReplaceInParagraph(para, oldValue, newValue) || found;
}
}
// Fallback: Check individual Text elements
if (!found)
{
foreach (var text in row.Descendants<Text>())
{
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<Text>().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<TableRow> rows, System.Collections.IEnumerable profiles);
}
internal class SingleParagraphTableStrategy : ITableStrategy
{
public void Process(Table table, List<TableRow> rows, System.Collections.IEnumerable profiles)
{
var cell = rows[0].Elements<TableCell>().First();
var templatePara = cell.Elements<Paragraph>().First();
var profileList = profiles.Cast<object>().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<TableRow> rows, System.Collections.IEnumerable profiles)
{
var templateRowIndex = rows.Count >= 2 ? 1 : 0;
var templateRow = rows[templateRowIndex];
templateRow.Remove();
var profileList = profiles.Cast<object>().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<object> 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
}