diff --git a/src/Backend/SolutionErp.Application/Common/Interfaces/IChangelogService.cs b/src/Backend/SolutionErp.Application/Common/Interfaces/IChangelogService.cs new file mode 100644 index 0000000..b4d59f9 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Common/Interfaces/IChangelogService.cs @@ -0,0 +1,44 @@ +using SolutionErp.Domain.Contracts; + +namespace SolutionErp.Application.Common.Interfaces; + +// Service ghi ContractChangelog. KHÔNG SaveChanges — caller (CQRS handler) +// chịu trách nhiệm save atomic cùng với business changes. Pattern giống +// INotificationService. +public interface IChangelogService +{ + Task LogContractChangeAsync( + Guid contractId, + ChangelogAction action, + string? summary = null, + string? fieldChangesJson = null, + string? contextNote = null, + ContractPhase? phaseAtChange = null, + CancellationToken ct = default); + + Task LogDetailChangeAsync( + Guid contractId, + Guid detailId, + ChangelogAction action, + string? summary = null, + string? fieldChangesJson = null, + ContractPhase? phaseAtChange = null, + CancellationToken ct = default); + + Task LogWorkflowTransitionAsync( + Guid contractId, + ContractPhase fromPhase, + ContractPhase toPhase, + string? comment, + CancellationToken ct = default); + + Task LogCommentAddedAsync(Guid contractId, string content, ContractPhase phase, CancellationToken ct = default); + + Task LogAttachmentAsync( + Guid contractId, + Guid attachmentId, + ChangelogAction action, + string fileName, + ContractPhase phase, + CancellationToken ct = default); +} diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractAttachmentFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractAttachmentFeatures.cs index c8a9c29..8a99687 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractAttachmentFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractAttachmentFeatures.cs @@ -55,7 +55,8 @@ public class UploadContractAttachmentCommandValidator : AbstractValidator + IFileStorage storage, + IChangelogService changelog) : IRequestHandler { public async Task Handle(UploadContractAttachmentCommand request, CancellationToken ct) { @@ -64,7 +65,6 @@ public class UploadContractAttachmentCommandHandler( var attId = Guid.NewGuid(); var safeName = SanitizeFileName(request.FileName); - // Store under contracts/{contractId}/{attId}_{safeName} to keep originals separate var relativePath = $"contracts/{contract.Id}/{attId}_{safeName}"; await storage.SaveAsync(relativePath, request.Content, ct); @@ -73,7 +73,7 @@ public class UploadContractAttachmentCommandHandler( { Id = attId, ContractId = contract.Id, - FileName = request.FileName, // keep original for display + FileName = request.FileName, StoragePath = relativePath, FileSize = request.FileSize, ContentType = request.ContentType, @@ -81,6 +81,7 @@ public class UploadContractAttachmentCommandHandler( Note = request.Note, }; db.ContractAttachments.Add(entity); + await changelog.LogAttachmentAsync(contract.Id, entity.Id, ChangelogAction.Insert, entity.FileName, contract.Phase, ct); await db.SaveChangesAsync(ct); return new ContractAttachmentDto( @@ -128,15 +129,19 @@ public record DeleteContractAttachmentCommand(Guid ContractId, Guid AttachmentId public class DeleteContractAttachmentCommandHandler( IApplicationDbContext db, - IFileStorage storage) : IRequestHandler + IFileStorage storage, + IChangelogService changelog) : IRequestHandler { public async Task Handle(DeleteContractAttachmentCommand request, CancellationToken ct) { var att = await db.ContractAttachments .FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.ContractId == request.ContractId, ct) ?? throw new NotFoundException("Attachment", request.AttachmentId); + var contract = await db.Contracts.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == request.ContractId, ct); db.ContractAttachments.Remove(att); + await changelog.LogAttachmentAsync(request.ContractId, att.Id, ChangelogAction.Delete, att.FileName, contract?.Phase ?? ContractPhase.DangSoanThao, ct); await db.SaveChangesAsync(ct); // Best-effort file delete — if it fails, DB row is already gone (orphan diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs index f7ee156..3198af7 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs @@ -42,7 +42,8 @@ public class CreateContractCommandValidator : AbstractValidator + IContractWorkflowService workflow, + IChangelogService changelog) : IRequestHandler { public async Task Handle(CreateContractCommand request, CancellationToken ct) { @@ -51,8 +52,6 @@ public class CreateContractCommandHandler( if (!await db.Projects.AnyAsync(p => p.Id == request.ProjectId, ct)) throw new NotFoundException("Project", request.ProjectId); - // Pin to currently active WorkflowDefinition for this type. New versions - // created later do not retroactively affect this contract. var activeWfId = await db.WorkflowDefinitions.AsNoTracking() .Where(w => w.ContractType == request.Type && w.IsActive) .Select(w => (Guid?)w.Id) @@ -76,6 +75,14 @@ public class CreateContractCommandHandler( SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), }; db.Contracts.Add(entity); + + await changelog.LogContractChangeAsync( + entity.Id, + ChangelogAction.Insert, + summary: $"Tạo HĐ {entity.TenHopDong ?? "(chưa tên)"}", + phaseAtChange: entity.Phase, + ct: ct); + await db.SaveChangesAsync(ct); return entity.Id; } @@ -91,7 +98,9 @@ public record UpdateContractDraftCommand( Guid? TemplateId, string? DraftData) : IRequest; -public class UpdateContractDraftCommandHandler(IApplicationDbContext db) : IRequestHandler +public class UpdateContractDraftCommandHandler( + IApplicationDbContext db, + IChangelogService changelog) : IRequestHandler { public async Task Handle(UpdateContractDraftCommand request, CancellationToken ct) { @@ -101,11 +110,34 @@ public class UpdateContractDraftCommandHandler(IApplicationDbContext db) : IRequ if (entity.Phase != ContractPhase.DangSoanThao) throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo."); + // Capture diff trước update để log + var changes = new List(); + if (entity.GiaTri != request.GiaTri) + changes.Add(new { Field = "GiaTri", Old = entity.GiaTri, New = request.GiaTri }); + if (entity.TenHopDong != request.TenHopDong) + changes.Add(new { Field = "TenHopDong", Old = entity.TenHopDong, New = request.TenHopDong }); + if (entity.NoiDung != request.NoiDung) + changes.Add(new { Field = "NoiDung", Old = entity.NoiDung, New = request.NoiDung }); + if (entity.TemplateId != request.TemplateId) + changes.Add(new { Field = "TemplateId", Old = entity.TemplateId, New = request.TemplateId }); + entity.GiaTri = request.GiaTri; entity.TenHopDong = request.TenHopDong; entity.NoiDung = request.NoiDung; entity.TemplateId = request.TemplateId; entity.DraftData = request.DraftData; + + if (changes.Count > 0) + { + await changelog.LogContractChangeAsync( + entity.Id, + ChangelogAction.Update, + summary: $"Cập nhật HĐ ({changes.Count} field)", + fieldChangesJson: System.Text.Json.JsonSerializer.Serialize(changes), + phaseAtChange: entity.Phase, + ct: ct); + } + await db.SaveChangesAsync(ct); } } @@ -166,7 +198,10 @@ public class AddCommentCommandValidator : AbstractValidator } } -public class AddCommentCommandHandler(IApplicationDbContext db, ICurrentUser currentUser) : IRequestHandler +public class AddCommentCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser, + IChangelogService changelog) : IRequestHandler { public async Task Handle(AddCommentCommand request, CancellationToken ct) { @@ -184,6 +219,7 @@ public class AddCommentCommandHandler(IApplicationDbContext db, ICurrentUser cur Content = request.Content, }; db.ContractComments.Add(comment); + await changelog.LogCommentAddedAsync(request.ContractId, request.Content, contract.Phase, ct); await db.SaveChangesAsync(ct); return comment.Id; } diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs index e649453..e8b3d13 100644 --- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs +++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs @@ -34,6 +34,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); // Phase 3 iteration 2 — SLA auto-approve background service diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ChangelogService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ChangelogService.cs new file mode 100644 index 0000000..eb867ff --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Services/ChangelogService.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Identity; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; + +namespace SolutionErp.Infrastructure.Services; + +// Impl IChangelogService. Resolve current user qua ICurrentUser (null = system, +// vd SLA auto-approve). Denormalize UserName để log readable mà không cần JOIN +// Users table khi render. +public class ChangelogService : IChangelogService +{ + private readonly IApplicationDbContext _db; + private readonly ICurrentUser _currentUser; + private readonly UserManager _userManager; + + public ChangelogService(IApplicationDbContext db, ICurrentUser currentUser, UserManager userManager) + { + _db = db; + _currentUser = currentUser; + _userManager = userManager; + } + + private async Task<(Guid? Id, string? Name)> ResolveActorAsync() + { + if (_currentUser.UserId is null) return (null, null); + var user = await _userManager.FindByIdAsync(_currentUser.UserId.Value.ToString()); + return (user?.Id, user?.FullName ?? user?.Email); + } + + public async Task LogContractChangeAsync( + Guid contractId, ChangelogAction action, string? summary = null, + string? fieldChangesJson = null, string? contextNote = null, + ContractPhase? phaseAtChange = null, CancellationToken ct = default) + { + var (uid, uname) = await ResolveActorAsync(); + _db.ContractChangelogs.Add(new ContractChangelog + { + ContractId = contractId, + EntityType = ChangelogEntityType.Contract, + EntityId = null, + Action = action, + PhaseAtChange = phaseAtChange, + UserId = uid, + UserName = uname, + Summary = summary, + FieldChangesJson = fieldChangesJson, + ContextNote = contextNote, + }); + } + + public async Task LogDetailChangeAsync( + Guid contractId, Guid detailId, ChangelogAction action, + string? summary = null, string? fieldChangesJson = null, + ContractPhase? phaseAtChange = null, CancellationToken ct = default) + { + var (uid, uname) = await ResolveActorAsync(); + _db.ContractChangelogs.Add(new ContractChangelog + { + ContractId = contractId, + EntityType = ChangelogEntityType.Detail, + EntityId = detailId, + Action = action, + PhaseAtChange = phaseAtChange, + UserId = uid, + UserName = uname, + Summary = summary, + FieldChangesJson = fieldChangesJson, + }); + } + + public async Task LogWorkflowTransitionAsync( + Guid contractId, ContractPhase fromPhase, ContractPhase toPhase, + string? comment, CancellationToken ct = default) + { + var (uid, uname) = await ResolveActorAsync(); + _db.ContractChangelogs.Add(new ContractChangelog + { + ContractId = contractId, + EntityType = ChangelogEntityType.Workflow, + EntityId = null, + Action = ChangelogAction.Transition, + PhaseAtChange = toPhase, + UserId = uid, + UserName = uname ?? "Hệ thống", + Summary = $"Chuyển phase {fromPhase} → {toPhase}", + ContextNote = comment, + }); + } + + public async Task LogCommentAddedAsync( + Guid contractId, string content, ContractPhase phase, CancellationToken ct = default) + { + var (uid, uname) = await ResolveActorAsync(); + _db.ContractChangelogs.Add(new ContractChangelog + { + ContractId = contractId, + EntityType = ChangelogEntityType.Comment, + EntityId = null, + Action = ChangelogAction.Insert, + PhaseAtChange = phase, + UserId = uid, + UserName = uname, + Summary = "Thêm góp ý", + ContextNote = content.Length > 200 ? content[..200] + "..." : content, + }); + } + + public async Task LogAttachmentAsync( + Guid contractId, Guid attachmentId, ChangelogAction action, + string fileName, ContractPhase phase, CancellationToken ct = default) + { + var (uid, uname) = await ResolveActorAsync(); + var verb = action switch + { + ChangelogAction.Insert => "Tải lên", + ChangelogAction.Delete => "Xóa", + _ => "Cập nhật", + }; + _db.ContractChangelogs.Add(new ContractChangelog + { + ContractId = contractId, + EntityType = ChangelogEntityType.Attachment, + EntityId = attachmentId, + Action = action, + PhaseAtChange = phase, + UserId = uid, + UserName = uname, + Summary = $"{verb} file: {fileName}", + }); + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index 0162443..8d8aac8 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -16,7 +16,8 @@ public class ContractWorkflowService( IApplicationDbContext db, IContractCodeGenerator codeGenerator, IDateTime dateTime, - INotificationService notifications) : IContractWorkflowService + INotificationService notifications, + IChangelogService changelog) : IContractWorkflowService { // Expose per-policy SLA via the contract — accepts optional contract so the // caller (CreateContractCommand) can ask for a specific type's SLA even @@ -101,6 +102,9 @@ public class ContractWorkflowService( ApprovedAt = dateTime.UtcNow, }); + // Log workflow transition vào unified Changelog (cho user xem trên tab Lịch sử) + await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, targetPhase, comment); + if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId) { var title = targetPhase switch