[CLAUDE] App+Infra: IChangelogService + log Workflow/Contract/Comment/Attachment
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m26s
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:
@ -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);
|
||||
}
|
||||
@ -55,7 +55,8 @@ public class UploadContractAttachmentCommandValidator : AbstractValidator<Upload
|
||||
|
||||
public class UploadContractAttachmentCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
IFileStorage storage) : IRequestHandler<UploadContractAttachmentCommand, ContractAttachmentDto>
|
||||
IFileStorage storage,
|
||||
IChangelogService changelog) : IRequestHandler<UploadContractAttachmentCommand, ContractAttachmentDto>
|
||||
{
|
||||
public async Task<ContractAttachmentDto> 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<DeleteContractAttachmentCommand>
|
||||
IFileStorage storage,
|
||||
IChangelogService changelog) : IRequestHandler<DeleteContractAttachmentCommand>
|
||||
{
|
||||
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
|
||||
|
||||
@ -42,7 +42,8 @@ public class CreateContractCommandValidator : AbstractValidator<CreateContractCo
|
||||
public class CreateContractCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser,
|
||||
IContractWorkflowService workflow) : IRequestHandler<CreateContractCommand, Guid>
|
||||
IContractWorkflowService workflow,
|
||||
IChangelogService changelog) : IRequestHandler<CreateContractCommand, Guid>
|
||||
{
|
||||
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))
|
||||
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<UpdateContractDraftCommand>
|
||||
public class UpdateContractDraftCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
IChangelogService changelog) : IRequestHandler<UpdateContractDraftCommand>
|
||||
{
|
||||
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<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.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<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)
|
||||
{
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user