From 64a758a7014184e6733cb05dfe56a39873ce957d Mon Sep 17 00:00:00 2001 From: "seungmuk.oh" <247753927+seungmuk-oh_Vatech@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:08:26 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=82=AC=ED=95=AD=EC=9D=84=20=EC=B6=94=EC=A0=81=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84.=20=20-=20DB=20=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=EB=B0=8F=20Excel=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VTSFetcher/App.xaml.cs | 1 + VTSFetcher/Repositories/AnytypeVtsContext.cs | 3 +- .../Repositories/AnytypeVtsRepository.cs | 263 ++++++++++++++++++ .../Repositories/Entities/IssueStatus.cs | 18 ++ .../Repositories/Entities/IssueWithStatus.cs | 19 ++ VTSFetcher/Services/ExcelExportService.cs | 62 +++++ VTSFetcher/VTSFetcher.csproj | 1 + VTSFetcher/ViewModels/MainViewModel.cs | 101 ++++++- VTSFetcher/Views/MainWindow.xaml.cs | 16 ++ 9 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 VTSFetcher/Repositories/Entities/IssueStatus.cs create mode 100644 VTSFetcher/Repositories/Entities/IssueWithStatus.cs create mode 100644 VTSFetcher/Services/ExcelExportService.cs diff --git a/VTSFetcher/App.xaml.cs b/VTSFetcher/App.xaml.cs index 2f7151f..ac4d716 100644 --- a/VTSFetcher/App.xaml.cs +++ b/VTSFetcher/App.xaml.cs @@ -68,6 +68,7 @@ public partial class App : System.Windows.Application services.AddSingleton(); services.AddDbContext(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); diff --git a/VTSFetcher/Repositories/AnytypeVtsContext.cs b/VTSFetcher/Repositories/AnytypeVtsContext.cs index cdd8588..e418fdf 100644 --- a/VTSFetcher/Repositories/AnytypeVtsContext.cs +++ b/VTSFetcher/Repositories/AnytypeVtsContext.cs @@ -10,8 +10,9 @@ namespace VTSFetcher.Repositories; public partial class AnytypeVtsContext : DbContext { private readonly AppSettings _appSettings; - public DbSet VtsIssues { get;set; } + public DbSet VtsIssues { get; set; } public DbSet Configs { get; set; } + public DbSet IssueStatuses { get; set; } public string DbPath { get; } public AnytypeVtsContext(IOptions options) diff --git a/VTSFetcher/Repositories/AnytypeVtsRepository.cs b/VTSFetcher/Repositories/AnytypeVtsRepository.cs index e3c132d..564a87e 100644 --- a/VTSFetcher/Repositories/AnytypeVtsRepository.cs +++ b/VTSFetcher/Repositories/AnytypeVtsRepository.cs @@ -1,6 +1,9 @@ using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; +using System.Transactions; +using System.Windows.Forms; using VTSFetcher.Repositories.Entities; namespace VTSFetcher.Repositories @@ -8,11 +11,69 @@ namespace VTSFetcher.Repositories public class AnytypeVtsRepository { private readonly IServiceScopeFactory _scopeFactory; + private IServiceScope _transactionScope; + private AnytypeVtsContext _transactionContext; + private IDbContextTransaction _transaction; + public AnytypeVtsRepository(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; + + // 데이터베이스 및 테이블 생성 + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); } + #region Transaction + public void BeginTransaction() + { + _transactionScope = _scopeFactory.CreateScope(); + _transactionContext = _transactionScope.ServiceProvider.GetRequiredService(); + _transaction = _transactionContext.Database.BeginTransaction(); + } + + public void CommitTransaction() + { + try + { + _transactionContext?.SaveChanges(); + _transaction?.Commit(); + } + finally + { + _transaction?.Dispose(); + _transactionContext?.Dispose(); + _transactionScope?.Dispose(); + _transaction = null; + _transactionContext = null; + _transactionScope = null; + } + } + + public void RollbackTransaction() + { + try + { + _transaction?.Rollback(); + } + finally + { + _transaction?.Dispose(); + _transactionContext?.Dispose(); + _transactionScope?.Dispose(); + _transaction = null; + _transactionContext = null; + _transactionScope = null; + } + } + + private AnytypeVtsContext GetContext() + { + return _transactionContext ?? throw new InvalidOperationException("No active transaction. Call BeginTransaction first."); + } + #endregion Transaction + #region Configs public Dictionary GetConfigs() { @@ -52,6 +113,27 @@ namespace VTSFetcher.Repositories .ToListAsync(); return result; } + public async Task> GetTodayNewVtsIssuesAsync() + { + var today = DateTimeOffset.Now.Date; + var tomorrow = today.AddDays(1); + var todayOffset = new DateTimeOffset(today); + var tomorrowOffset = new DateTimeOffset(tomorrow); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var existingKeys = await db.IssueStatuses.Select(s => s.Key).ToListAsync(); + var todayIssues = await db.VtsIssues + .Where(v => v.Updated >= todayOffset && v.Updated < tomorrowOffset) + .ToListAsync(); + + var result = todayIssues + .Where(v => !existingKeys.Contains(v.Key)) + .ToList(); + + return result; + } public void UpsertVtsIssues(List vtsIssues, List excludedColumns) { @@ -78,5 +160,186 @@ namespace VTSFetcher.Repositories return result; } #endregion VtsIsseus + + #region IssueStatuses + public async Task> GetIssues4StatusAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var result = await db.VtsIssues + .Where(i => i.Type.ToLower() != "sub-task" + && i.Type.ToLower() != "epic" + && i.Status.ToLower() != "closed" + && i.Status.ToLower() != "qa passed") + .ToListAsync(); + return result; + } + public async Task> GetIssueStatusesAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var result = await db.IssueStatuses.ToListAsync(); + return result; + } + public async Task DeleteOldIssueStatusesAsync(string status, int daysOld) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var cutoffDate = DateTimeOffset.Now.AddDays(-daysOld); + + var deletedCount = await db.IssueStatuses + .Where(i => i.Status.ToLower() == status.ToLower() + && i.Changed < cutoffDate) + .ExecuteDeleteAsync(); + + return deletedCount; + } + public async Task InsertIssueStatusesAsync(List statuses) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.IssueStatuses.AddRange(statuses); + await db.SaveChangesAsync(); + } + public async Task UpdateIssueStatusesAsync(List statuses) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.IssueStatuses.UpdateRange(statuses); + await db.SaveChangesAsync(); + } + public async Task> GetIssuesWithStatusDetailAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var result = await ( + from status in db.IssueStatuses + join issue in db.VtsIssues + on status.Key equals issue.Key + select new IssueWithStatus + { + Key = issue.Key, + Summary = issue.Summary, + Parent = issue.Parent, + Type = issue.Type, + CurrentStatus = issue.Status, + Assignee = issue.Assignee, + Manager = issue.Manager, + Due = issue.Due, + Updated = issue.Updated, + PreviousStatus = status.Status, + Changed = status.Changed, + Duration = status.Duration + } + ).ToListAsync(); + + return result; + } + #endregion IssueStatuses + + #region IssueStatuses - Transaction Support + public List GetIssueStatusesInTransaction() + { + var db = GetContext(); + var result = db.IssueStatuses.ToList(); + return result; + } + + public void UpdateIssueStatusesInTransaction(List statuses) + { + var db = GetContext(); + foreach (var status in statuses) + { + // 이미 추적중인 엔티티가 있는지 확인 + var existingEntity = db.IssueStatuses.Local + .FirstOrDefault(e => e.Key == status.Key); + + if (existingEntity != null) + { + // 이미 추적중이면 값만 업데이트 + existingEntity.Status = status.Status; + existingEntity.Changed = status.Changed; + existingEntity.Duration = status.Duration; + } + else + { + // 추적중이지 않으면 Attach 후 Modified 상태로 변경 + db.IssueStatuses.Attach(status); + db.Entry(status).State = EntityState.Modified; + } + } + } + + public List GetIssuesWithStatusDetailInTransaction() + { + var db = GetContext(); + var result = ( + from status in db.IssueStatuses + join issue in db.VtsIssues + on status.Key equals issue.Key + select new IssueWithStatus + { + Key = issue.Key, + Summary = issue.Summary, + Parent = issue.Parent, + Type = issue.Type, + CurrentStatus = issue.Status, + Assignee = issue.Assignee, + Manager = issue.Manager, + Due = issue.Due, + Updated = issue.Updated, + PreviousStatus = status.Status, + Changed = status.Changed, + Duration = status.Duration + } + ).ToList(); + return result; + } + + public List GetTodayNewVtsIssuesInTransaction() + { + var today = DateTimeOffset.Now.Date; + var tomorrow = today.AddDays(1); + var todayOffset = new DateTimeOffset(today); + var tomorrowOffset = new DateTimeOffset(tomorrow); + + var db = GetContext(); + + var existingKeys = db.IssueStatuses.Select(s => s.Key).ToList(); + var todayIssues = db.VtsIssues + .AsEnumerable() + .Where(v => v.Updated >= todayOffset && v.Updated < tomorrowOffset) + .ToList(); + + var result = todayIssues + .Where(v => !existingKeys.Contains(v.Key)) + .ToList(); + + return result; + } + + public void InsertIssueStatusesInTransaction(List statuses) + { + var db = GetContext(); + db.IssueStatuses.AddRange(statuses); + // SaveChanges는 CommitTransaction에서 일괄 처리 + } + + public int DeleteOldIssueStatusesInTransaction(string status, int daysOld) + { + var db = GetContext(); + var cutoffDate = DateTimeOffset.Now.AddDays(-daysOld); + + var itemsToDelete = db.IssueStatuses + .AsEnumerable() // 클라이언트 평가로 전환 + .Where(i => i.Status.Equals(status, StringComparison.OrdinalIgnoreCase) + && i.Changed < cutoffDate) + .ToList(); + db.IssueStatuses.RemoveRange(itemsToDelete); + return itemsToDelete.Count; + } + #endregion IssueStatuses - Transaction Support } } diff --git a/VTSFetcher/Repositories/Entities/IssueStatus.cs b/VTSFetcher/Repositories/Entities/IssueStatus.cs new file mode 100644 index 0000000..5715a22 --- /dev/null +++ b/VTSFetcher/Repositories/Entities/IssueStatus.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace VTSFetcher.Repositories.Entities; + +public class IssueStatus +{ + [Key] + public string Key { get; set; } + public string Status { get; set; } + public DateTimeOffset Changed { get; set; } + public int Duration { get; set; } +} diff --git a/VTSFetcher/Repositories/Entities/IssueWithStatus.cs b/VTSFetcher/Repositories/Entities/IssueWithStatus.cs new file mode 100644 index 0000000..03053a3 --- /dev/null +++ b/VTSFetcher/Repositories/Entities/IssueWithStatus.cs @@ -0,0 +1,19 @@ +namespace VTSFetcher.Repositories.Entities; + +public class IssueWithStatus +{ + public string Key { get; set; } + public string Summary { get; set; } + public string? Parent { get; set; } + public string Type { get; set; } + public string CurrentStatus { get; set; } + public string Assignee { get; set; } + public string Manager { get; set; } + public DateTimeOffset Due { get; set; } + public DateTimeOffset Updated { get; set; } + + // IssueStatus 정보 + public string? PreviousStatus { get; set; } + public DateTimeOffset? Changed { get; set; } + public int Duration { get; set; } +} \ No newline at end of file diff --git a/VTSFetcher/Services/ExcelExportService.cs b/VTSFetcher/Services/ExcelExportService.cs new file mode 100644 index 0000000..ebde78e --- /dev/null +++ b/VTSFetcher/Services/ExcelExportService.cs @@ -0,0 +1,62 @@ +using ClosedXML.Excel; +using Serilog; +using System.Globalization; +using VTSFetcher.Repositories.Entities; + +namespace VTSFetcher.Services; + +public class ExcelExportService +{ + public bool ExportIssueStatusesToExcel(List issues, string filePath) + { + try + { + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("IssueStatus"); + + // 헤더 설정 + worksheet.Cell(1, 1).Value = "Assignee"; + worksheet.Cell(1, 2).Value = "Key"; + worksheet.Cell(1, 3).Value = "Summary"; + worksheet.Cell(1, 4).Value = "Status"; + worksheet.Cell(1, 5).Value = "Duration"; + worksheet.Cell(1, 6).Value = "Changed"; + + // 헤더 스타일 + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.LightGray; + + // Assignee로 정렬 + var sortedIssues = issues.OrderBy(i => i.Assignee).ThenBy(i => i.CurrentStatus).ToList(); + + // 데이터 입력 + for (int i = 0; i < sortedIssues.Count; i++) + { + var issue = sortedIssues[i]; + int row = i + 2; + + worksheet.Cell(row, 1).Value = issue.Assignee; + worksheet.Cell(row, 2).Value = issue.Key; + worksheet.Cell(row, 3).Value = issue.Summary; + worksheet.Cell(row, 4).Value = issue.CurrentStatus; + worksheet.Cell(row, 5).Value = issue.Duration; + worksheet.Cell(row, 6).Value = issue.Changed?.ToString("yyMMdd HH:mm:ss", CultureInfo.InvariantCulture); + } + + // 열 너비 자동 조정 + worksheet.Columns().AdjustToContents(); + + // 파일 저장 + workbook.SaveAs(filePath); + + Log.Information("Excel file exported successfully: {file_path}", filePath); + return true; + } + catch (Exception ex) + { + Log.Error("Failed to export Excel file: {error_message}", ex.Message); + return false; + } + } +} diff --git a/VTSFetcher/VTSFetcher.csproj b/VTSFetcher/VTSFetcher.csproj index f6bf007..439ee98 100644 --- a/VTSFetcher/VTSFetcher.csproj +++ b/VTSFetcher/VTSFetcher.csproj @@ -15,6 +15,7 @@ + diff --git a/VTSFetcher/ViewModels/MainViewModel.cs b/VTSFetcher/ViewModels/MainViewModel.cs index 4811bea..20eba8e 100644 --- a/VTSFetcher/ViewModels/MainViewModel.cs +++ b/VTSFetcher/ViewModels/MainViewModel.cs @@ -3,6 +3,7 @@ using Serilog; using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.Intrinsics.X86; @@ -27,10 +28,12 @@ public class MainViewModel : INotifyPropertyChanged private readonly VtsService _vts; private readonly AnytypeService _anytype; private readonly SlackService _slack; + private readonly ExcelExportService _excelExport; private readonly Dictionary _configs; private readonly DispatcherTimer _fetchTimer; private readonly DispatcherTimer _publishTimer; private readonly DispatcherTimer _slackTimer; + private readonly DispatcherTimer _statusTimer; private readonly CancellationTokenSource _cts; private ConcurrentQueue _publishedIssuesQueue; @@ -80,16 +83,20 @@ public class MainViewModel : INotifyPropertyChanged private bool _isWorking = true; public ICommand FetchNowCommand { get; } + public ICommand SaveStatusCommand { get; } + public ICommand ReportCommand { get; } #endregion Fields public MainViewModel(IOptions options, AnytypeVtsRepository repo, - VtsService vtsService, AnytypeService anytypeService, SlackService slack) + VtsService vtsService, AnytypeService anytypeService, SlackService slack, + ExcelExportService excelExport) { _appSettings = options.Value; _repo = repo; _vts = vtsService; _anytype = anytypeService; _slack = slack; + _excelExport = excelExport; _configs = _repo.GetConfigs(); _lastFetchTimeOffset = DateTimeOffset.Parse(_configs[Config.LastFetchTimeKey]); @@ -112,7 +119,18 @@ public class MainViewModel : INotifyPropertyChanged _slackTimer.Tick += SlackTick; _slackTimer.Start(); + //_statusTimer = new DispatcherTimer(); + //_statusTimer.Interval = TimeSpan.FromSeconds(30); + //_statusTimer.Tick += StatusTick; + //_statusTimer.Start(); + FetchNowCommand = new RelayCommand(_ => FetchNow()); + SaveStatusCommand = new RelayCommand(_ => { + UpdateStatusData(); + }); + ReportCommand = new RelayCommand(_ => { + GenerateReport(); + } ); _cts = new CancellationTokenSource(); _publishedIssuesQueue = new(); @@ -273,6 +291,87 @@ public class MainViewModel : INotifyPropertyChanged } #endregion FetchEvents + #region SaveStatusEvents + private void StatusTick(object? sender, EventArgs e) + { + var now = DateTime.Now; + if (now.Hour == 21 && now.Minute == 0) + { + UpdateStatusData(); + } + } + private bool UpdateStatusData() + { + Log.Debug("Start CreateReport"); + try + { + _repo.BeginTransaction(); + + var issueStatuses = _repo.GetIssueStatusesInTransaction(); + var issuesWithStatus = _repo.GetIssuesWithStatusDetailInTransaction(); + var changedIssueStatuses = new List(); + foreach (var issue in issuesWithStatus) + { + var issueStatus = issueStatuses.Find(s => s.Key == issue.Key); + if (issueStatus == null) continue; + if (issueStatus.Status.ToLower() == issue.CurrentStatus.ToLower()) + { + issueStatus.Duration++; + } + else + { + issueStatus.Status = issue.CurrentStatus; + issueStatus.Duration = 0; + issueStatus.Changed = issue.Updated; + } + } + _repo.UpdateIssueStatusesInTransaction(issueStatuses); + + // Add new issues. + var todayIssues = _repo.GetTodayNewVtsIssuesInTransaction(); + var newIssueStatuses = new List(); + foreach (var issue in todayIssues) + { + newIssueStatuses.Add(new IssueStatus + { + Key = issue.Key, + Changed = issue.Updated, + Duration = 0, + Status = issue.Status + }); + } + _repo.InsertIssueStatusesInTransaction(newIssueStatuses); + + // Remove old resolved issues + var deletedResolvedStatusCount = _repo.DeleteOldIssueStatusesInTransaction("Resolved", 5); + var deletedClosedStatusCount = _repo.DeleteOldIssueStatusesInTransaction("Closed", 1); + var deletedQaPassedStatusCount = _repo.DeleteOldIssueStatusesInTransaction("QA PASSED", 1); + _repo.CommitTransaction(); + } + catch(Exception ex) + { + _repo.RollbackTransaction(); + Log.Error("Unhandled exception occured during creating report: {error_message}", ex.Message); + return false; + } + return true; + } + private void GenerateReport() + { + // Generate report + var issues = _repo.GetIssuesWithStatusDetailAsync().Result; + var fileName = $"{DateTime.Now:yyyy-MM-dd}.xlsx"; + var fileFolder = Path.Combine(AppContext.BaseDirectory, "Reports"); + var filePath = Path.Combine(fileFolder, fileName); + var result = _excelExport.ExportIssueStatusesToExcel(issues, filePath); + if (result) + Log.Information("Successfully exported to Excel: {file_path}", filePath); + else + Log.Error("Failed to export to Excel: {file_path}", filePath); + + } + #endregion SaveStatusEvents + #region PublishEvents private void PublishTimerTick(object? sender, EventArgs e) { diff --git a/VTSFetcher/Views/MainWindow.xaml.cs b/VTSFetcher/Views/MainWindow.xaml.cs index b5a03f5..3259816 100644 --- a/VTSFetcher/Views/MainWindow.xaml.cs +++ b/VTSFetcher/Views/MainWindow.xaml.cs @@ -26,6 +26,8 @@ public partial class MainWindow : Window var contextMenu = new ContextMenuStrip(); contextMenu.Items.Add("Open", null, (s, e) => ShowWindow()); contextMenu.Items.Add("Fetch", null, (s, e) => FetchNow()); + contextMenu.Items.Add("Save status", null, (s, e) => SaveStatusNow()); + contextMenu.Items.Add("Report", null, (s, e) => ReportNow()); contextMenu.Items.Add("Exit", null, (s, e) => ExitApp()); _notifyIcon.ContextMenuStrip = contextMenu; @@ -47,6 +49,20 @@ public partial class MainWindow : Window vm.FetchNowCommand.Execute(null); } } + private void SaveStatusNow() + { + if (DataContext is MainViewModel vm) + { + vm.SaveStatusCommand.Execute(null); + } + } + private void ReportNow() + { + if (DataContext is MainViewModel vm) + { + vm.ReportCommand.Execute(null); + } + } private void ExitApp() { _exitFlag = true;