[CLAUDE] App+Infra: IChangelogService + log Workflow/Contract/Comment/Attachment
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m26s

User decision B (log cả 3): mọi thay đổi liên quan HĐ ghi vào unified
ContractChangelogs để render tab Lịch sử FE.

## IChangelogService (Application/Common/Interfaces/)

5 methods:
- LogContractChangeAsync — Header insert/update
- LogDetailChangeAsync — line item insert/update/delete
- LogWorkflowTransitionAsync — phase change (parallel với ContractApprovals)
- LogCommentAddedAsync — góp ý mới
- LogAttachmentAsync — upload/delete file

KHÔNG SaveChanges trong service — caller chịu trách nhiệm save atomic
cùng business changes (pattern giống INotificationService).

## ChangelogService impl

- Resolve actor qua ICurrentUser → UserManager.FindByIdAsync
- Denormalize UserName (FullName ?? Email) cho log readable
- null UserId = system action (vd SLA auto-approve)
- DI: AddScoped trong DependencyInjection.cs

## Wiring vào handlers hiện tại

- ContractWorkflowService.TransitionAsync — LogWorkflowTransitionAsync
  sau khi insert ContractApproval
- CreateContractCommandHandler — LogContractChangeAsync(Insert)
- UpdateContractDraftCommandHandler — diff GiaTri/TenHopDong/NoiDung/
  TemplateId trước update, log update với fieldChangesJson nếu có thay đổi
- AddCommentCommandHandler — LogCommentAddedAsync
- UploadContractAttachmentCommandHandler — LogAttachmentAsync(Insert)
- DeleteContractAttachmentCommandHandler — LogAttachmentAsync(Delete)

Build: dotnet build BE pass (0 error, 2 pre-existing warning trong
DocxRenderer.cs)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 10:12:51 +07:00
parent 70810e1b34
commit 71c035d31e
6 changed files with 232 additions and 10 deletions

View File

@ -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);
}

View File

@ -55,7 +55,8 @@ public class UploadContractAttachmentCommandValidator : AbstractValidator<Upload
public class UploadContractAttachmentCommandHandler( public class UploadContractAttachmentCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
IFileStorage storage) : IRequestHandler<UploadContractAttachmentCommand, ContractAttachmentDto> IFileStorage storage,
IChangelogService changelog) : IRequestHandler<UploadContractAttachmentCommand, ContractAttachmentDto>
{ {
public async Task<ContractAttachmentDto> Handle(UploadContractAttachmentCommand request, CancellationToken ct) public async Task<ContractAttachmentDto> Handle(UploadContractAttachmentCommand request, CancellationToken ct)
{ {
@ -64,7 +65,6 @@ public class UploadContractAttachmentCommandHandler(
var attId = Guid.NewGuid(); var attId = Guid.NewGuid();
var safeName = SanitizeFileName(request.FileName); var safeName = SanitizeFileName(request.FileName);
// Store under contracts/{contractId}/{attId}_{safeName} to keep originals separate
var relativePath = $"contracts/{contract.Id}/{attId}_{safeName}"; var relativePath = $"contracts/{contract.Id}/{attId}_{safeName}";
await storage.SaveAsync(relativePath, request.Content, ct); await storage.SaveAsync(relativePath, request.Content, ct);
@ -73,7 +73,7 @@ public class UploadContractAttachmentCommandHandler(
{ {
Id = attId, Id = attId,
ContractId = contract.Id, ContractId = contract.Id,
FileName = request.FileName, // keep original for display FileName = request.FileName,
StoragePath = relativePath, StoragePath = relativePath,
FileSize = request.FileSize, FileSize = request.FileSize,
ContentType = request.ContentType, ContentType = request.ContentType,
@ -81,6 +81,7 @@ public class UploadContractAttachmentCommandHandler(
Note = request.Note, Note = request.Note,
}; };
db.ContractAttachments.Add(entity); db.ContractAttachments.Add(entity);
await changelog.LogAttachmentAsync(contract.Id, entity.Id, ChangelogAction.Insert, entity.FileName, contract.Phase, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return new ContractAttachmentDto( return new ContractAttachmentDto(
@ -128,15 +129,19 @@ public record DeleteContractAttachmentCommand(Guid ContractId, Guid AttachmentId
public class DeleteContractAttachmentCommandHandler( public class DeleteContractAttachmentCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
IFileStorage storage) : IRequestHandler<DeleteContractAttachmentCommand> IFileStorage storage,
IChangelogService changelog) : IRequestHandler<DeleteContractAttachmentCommand>
{ {
public async Task Handle(DeleteContractAttachmentCommand request, CancellationToken ct) public async Task Handle(DeleteContractAttachmentCommand request, CancellationToken ct)
{ {
var att = await db.ContractAttachments var att = await db.ContractAttachments
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.ContractId == request.ContractId, ct) .FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.ContractId == request.ContractId, ct)
?? throw new NotFoundException("Attachment", request.AttachmentId); ?? throw new NotFoundException("Attachment", request.AttachmentId);
var contract = await db.Contracts.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == request.ContractId, ct);
db.ContractAttachments.Remove(att); db.ContractAttachments.Remove(att);
await changelog.LogAttachmentAsync(request.ContractId, att.Id, ChangelogAction.Delete, att.FileName, contract?.Phase ?? ContractPhase.DangSoanThao, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// Best-effort file delete — if it fails, DB row is already gone (orphan // Best-effort file delete — if it fails, DB row is already gone (orphan

View File

@ -42,7 +42,8 @@ public class CreateContractCommandValidator : AbstractValidator<CreateContractCo
public class CreateContractCommandHandler( public class CreateContractCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
ICurrentUser currentUser, ICurrentUser currentUser,
IContractWorkflowService workflow) : IRequestHandler<CreateContractCommand, Guid> IContractWorkflowService workflow,
IChangelogService changelog) : IRequestHandler<CreateContractCommand, Guid>
{ {
public async Task<Guid> Handle(CreateContractCommand request, CancellationToken ct) public async Task<Guid> Handle(CreateContractCommand request, CancellationToken ct)
{ {
@ -51,8 +52,6 @@ public class CreateContractCommandHandler(
if (!await db.Projects.AnyAsync(p => p.Id == request.ProjectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == request.ProjectId, ct))
throw new NotFoundException("Project", request.ProjectId); 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() var activeWfId = await db.WorkflowDefinitions.AsNoTracking()
.Where(w => w.ContractType == request.Type && w.IsActive) .Where(w => w.ContractType == request.Type && w.IsActive)
.Select(w => (Guid?)w.Id) .Select(w => (Guid?)w.Id)
@ -76,6 +75,14 @@ public class CreateContractCommandHandler(
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
}; };
db.Contracts.Add(entity); 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); await db.SaveChangesAsync(ct);
return entity.Id; return entity.Id;
} }
@ -91,7 +98,9 @@ public record UpdateContractDraftCommand(
Guid? TemplateId, Guid? TemplateId,
string? DraftData) : IRequest; string? DraftData) : IRequest;
public class UpdateContractDraftCommandHandler(IApplicationDbContext db) : IRequestHandler<UpdateContractDraftCommand> public class UpdateContractDraftCommandHandler(
IApplicationDbContext db,
IChangelogService changelog) : IRequestHandler<UpdateContractDraftCommand>
{ {
public async Task Handle(UpdateContractDraftCommand request, CancellationToken ct) public async Task Handle(UpdateContractDraftCommand request, CancellationToken ct)
{ {
@ -101,11 +110,34 @@ public class UpdateContractDraftCommandHandler(IApplicationDbContext db) : IRequ
if (entity.Phase != ContractPhase.DangSoanThao) if (entity.Phase != ContractPhase.DangSoanThao)
throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo."); 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<object>();
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.GiaTri = request.GiaTri;
entity.TenHopDong = request.TenHopDong; entity.TenHopDong = request.TenHopDong;
entity.NoiDung = request.NoiDung; entity.NoiDung = request.NoiDung;
entity.TemplateId = request.TemplateId; entity.TemplateId = request.TemplateId;
entity.DraftData = request.DraftData; 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); await db.SaveChangesAsync(ct);
} }
} }
@ -166,7 +198,10 @@ public class AddCommentCommandValidator : AbstractValidator<AddCommentCommand>
} }
} }
public class AddCommentCommandHandler(IApplicationDbContext db, ICurrentUser currentUser) : IRequestHandler<AddCommentCommand, Guid> public class AddCommentCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IChangelogService changelog) : IRequestHandler<AddCommentCommand, Guid>
{ {
public async Task<Guid> Handle(AddCommentCommand request, CancellationToken ct) public async Task<Guid> Handle(AddCommentCommand request, CancellationToken ct)
{ {
@ -184,6 +219,7 @@ public class AddCommentCommandHandler(IApplicationDbContext db, ICurrentUser cur
Content = request.Content, Content = request.Content,
}; };
db.ContractComments.Add(comment); db.ContractComments.Add(comment);
await changelog.LogCommentAddedAsync(request.ContractId, request.Content, contract.Phase, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return comment.Id; return comment.Id;
} }

View File

@ -34,6 +34,7 @@ public static class DependencyInjection
services.AddScoped<IContractWorkflowService, ContractWorkflowService>(); services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>(); services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
services.AddScoped<INotificationService, NotificationService>(); services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IChangelogService, ChangelogService>();
services.AddSingleton<IFileStorage, LocalFileStorage>(); services.AddSingleton<IFileStorage, LocalFileStorage>();
// Phase 3 iteration 2 — SLA auto-approve background service // Phase 3 iteration 2 — SLA auto-approve background service

View File

@ -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<User> _userManager;
public ChangelogService(IApplicationDbContext db, ICurrentUser currentUser, UserManager<User> 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}",
});
}
}

View File

@ -16,7 +16,8 @@ public class ContractWorkflowService(
IApplicationDbContext db, IApplicationDbContext db,
IContractCodeGenerator codeGenerator, IContractCodeGenerator codeGenerator,
IDateTime dateTime, IDateTime dateTime,
INotificationService notifications) : IContractWorkflowService INotificationService notifications,
IChangelogService changelog) : IContractWorkflowService
{ {
// Expose per-policy SLA via the contract — accepts optional contract so the // Expose per-policy SLA via the contract — accepts optional contract so the
// caller (CreateContractCommand) can ask for a specific type's SLA even // caller (CreateContractCommand) can ask for a specific type's SLA even
@ -101,6 +102,9 @@ public class ContractWorkflowService(
ApprovedAt = dateTime.UtcNow, 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) if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)
{ {
var title = targetPhase switch var title = targetPhase switch