[CLAUDE] Phase3: Workflow MVP — 9-phase state machine + code gen + FE Inbox/Detail

Backend Contracts domain (5 entities):
- Contract aggregate: Phase (9 enum), SlaDeadline, MaHopDong, BypassProcurementAndCCM, DraftData, SlaWarningSent
- ContractApproval: FromPhase → ToPhase, ApproverUserId (null = system auto-approve), Decision, Comment
- ContractComment: thread theo Phase current
- ContractAttachment: FileName + StoragePath + Purpose (DraftExport/ScannedSigned/SealedCopy)
- ContractCodeSequence: Prefix PK + LastSeq — atomic gen

EF configs:
- Unique MaHopDong filtered [MaHopDong] IS NOT NULL
- Indexes: Phase+IsDeleted, SupplierId, ProjectId, SlaDeadline, ContractId+ApprovedAt, ContractId+CreatedAt
- Cascade delete Approvals/Comments/Attachments khi Contract xoa
- Query filter IsDeleted
- Migration AddContractsWorkflow (DB 19 tables)

Workflow service:
- IContractWorkflowService.TransitionAsync:
  - Adjacency check qua Transitions Dict<(from,to), roles[]> (12 transitions)
  - Role guard: user phai co role ∈ allowed
  - Admin bypass (role Admin pass moi check)
  - System bypass (userId=null + Decision=AutoApprove → cho SLA job sau nay)
  - Bypass CCM: BypassProcurementAndCCM=true cho phep DangInKy → DangTrinhKy skip phase 6
  - Gen ma HD khi chuyen DangDongDau (idempotent — khong gen lai neu da co)
  - Reset SlaDeadline = UtcNow + PhaseSla
  - Insert ContractApproval row

Code generator (RG-001):
- 7 format theo ContractType: HDTP / HDGK / NCC / HDDV / MB + 2 framework (year prefix)
- BeginTransactionAsync(Serializable) + ContractCodeSequences UPSERT → atomic
- Idempotent: neu MaHopDong da co thi skip

CQRS (8 feature, ContractFeatures.cs):
- CreateContractCommand + Validator + Handler (set SlaDeadline = +7d)
- UpdateContractDraftCommand (chi khi Phase=DangSoanThao)
- TransitionContractCommand (delegate → WorkflowService)
- AddCommentCommand (phase = hien tai)
- ListContractsQuery (PagedResult + filter phase/supplier/project/search)
- GetMyInboxQuery (map Phase → actor roles, filter theo role user)
- GetContractQuery (detail + approvals + comments + attachments + resolve user names)
- DeleteContractCommand (soft, block > DangInKy)

Controller:
- ContractsController 8 endpoint: GET list/inbox/detail, POST create/transition/comment, PUT update, DELETE

Frontend fe-admin (2 page moi):
- types/contracts.ts: ContractPhase const + Label + Color maps + types
- components/PhaseBadge.tsx
- pages/contracts/ContractsListPage.tsx: filter phase + search + click → detail
- pages/contracts/ContractDetailPage.tsx: 2-col layout (info+comments | timeline), action dialog select target phase + comment

Frontend fe-user (4 page moi + 14 file shared):
- cp 14 file shared tu fe-admin (menuKeys, types/*, DataTable, PhaseBadge, Dialog, Textarea, Select, apiError, usePermission, PermissionGuard)
- AuthContext update: load menu tu /menus/me + cache
- Layout: menu fixed 3 muc + user info + roles display
- InboxPage: list HD cho role user xu ly (sort theo SLA)
- ContractCreatePage: form chon loai + template + NCC + du an + gia tri + bypass CDT
- ContractDetailPage: duplicate fe-admin pattern (convention)
- MyContractsPage: list HD cua toi
- App.tsx: 4 route moi

E2E verified:
- Setup Supplier + Project
- POST /contracts → 201 + phase=2
- POST /contracts/{id}/transitions x7 → di het 9 phase
- Final: MaHopDong = "FLOCK 01/HĐGK/SOL&PVL2026/01" dung format RG-001
- Approvals: 7 rows audit day du

Docs:
- .claude/skills/contract-workflow/SKILL.md: placeholder → full spec voi state machine, SLA table, role matrix, 7 code format, code pointers, API, E2E workflow, pitfalls
- docs/changelog/sessions/2026-04-21-1330-phase3-workflow.md: session log
- docs/STATUS.md: Phase 3 MVP done, next Phase 4
- docs/HANDOFF.md: update phase status + file tree + commit log + testing points
- docs/changelog/migration-todos.md: tick Phase 3 MVP items + add iteration 2 list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 12:26:09 +07:00
parent 5113e4c771
commit 7e957a7654
49 changed files with 4490 additions and 156 deletions

View File

@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
@ -14,6 +15,11 @@ public interface IApplicationDbContext
DbSet<Permission> Permissions { get; }
DbSet<ContractTemplate> ContractTemplates { get; }
DbSet<ContractClause> ContractClauses { get; }
DbSet<Contract> Contracts { get; }
DbSet<ContractApproval> ContractApprovals { get; }
DbSet<ContractComment> ContractComments { get; }
DbSet<ContractAttachment> ContractAttachments { get; }
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,357 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Contracts;
// ========== CREATE draft ==========
public record CreateContractCommand(
ContractType Type,
Guid SupplierId,
Guid ProjectId,
Guid? DepartmentId,
Guid? TemplateId,
decimal GiaTri,
string? TenHopDong,
string? NoiDung,
bool BypassProcurementAndCCM,
string? DraftData) : IRequest<Guid>;
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
{
public CreateContractCommandValidator()
{
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.SupplierId).NotEmpty();
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.GiaTri).GreaterThanOrEqualTo(0);
RuleFor(x => x.TenHopDong).MaximumLength(500);
RuleFor(x => x.NoiDung).MaximumLength(2000);
}
}
public class CreateContractCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IContractWorkflowService workflow) : IRequestHandler<CreateContractCommand, Guid>
{
public async Task<Guid> Handle(CreateContractCommand request, CancellationToken ct)
{
if (!await db.Suppliers.AnyAsync(s => s.Id == request.SupplierId, ct))
throw new NotFoundException("Supplier", request.SupplierId);
if (!await db.Projects.AnyAsync(p => p.Id == request.ProjectId, ct))
throw new NotFoundException("Project", request.ProjectId);
var entity = new Contract
{
Type = request.Type,
Phase = ContractPhase.DangSoanThao,
SupplierId = request.SupplierId,
ProjectId = request.ProjectId,
DepartmentId = request.DepartmentId,
DrafterUserId = currentUser.UserId,
TemplateId = request.TemplateId,
GiaTri = request.GiaTri,
TenHopDong = request.TenHopDong,
NoiDung = request.NoiDung,
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = request.DraftData,
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
};
db.Contracts.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ========== UPDATE draft ==========
public record UpdateContractDraftCommand(
Guid Id,
decimal GiaTri,
string? TenHopDong,
string? NoiDung,
Guid? TemplateId,
string? DraftData) : IRequest;
public class UpdateContractDraftCommandHandler(IApplicationDbContext db) : IRequestHandler<UpdateContractDraftCommand>
{
public async Task Handle(UpdateContractDraftCommand request, CancellationToken ct)
{
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
if (entity.Phase != ContractPhase.DangSoanThao)
throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo.");
entity.GiaTri = request.GiaTri;
entity.TenHopDong = request.TenHopDong;
entity.NoiDung = request.NoiDung;
entity.TemplateId = request.TemplateId;
entity.DraftData = request.DraftData;
await db.SaveChangesAsync(ct);
}
}
// ========== TRANSITION phase ==========
public record TransitionContractCommand(
Guid Id,
ContractPhase TargetPhase,
ApprovalDecision Decision,
string? Comment) : IRequest;
public class TransitionContractCommandValidator : AbstractValidator<TransitionContractCommand>
{
public TransitionContractCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.TargetPhase).IsInEnum();
RuleFor(x => x.Decision).IsInEnum();
RuleFor(x => x.Comment).MaximumLength(1000);
}
}
public class TransitionContractCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IContractWorkflowService workflow) : IRequestHandler<TransitionContractCommand>
{
public async Task Handle(TransitionContractCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
await workflow.TransitionAsync(
entity,
request.TargetPhase,
currentUser.UserId,
currentUser.Roles,
request.Decision,
request.Comment,
ct);
}
}
// ========== ADD comment ==========
public record AddCommentCommand(Guid ContractId, string Content) : IRequest<Guid>;
public class AddCommentCommandValidator : AbstractValidator<AddCommentCommand>
{
public AddCommentCommandValidator()
{
RuleFor(x => x.ContractId).NotEmpty();
RuleFor(x => x.Content).NotEmpty().MaximumLength(2000);
}
}
public class AddCommentCommandHandler(IApplicationDbContext db, ICurrentUser currentUser) : IRequestHandler<AddCommentCommand, Guid>
{
public async Task<Guid> Handle(AddCommentCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var contract = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.ContractId, ct)
?? throw new NotFoundException("Contract", request.ContractId);
var comment = new ContractComment
{
ContractId = request.ContractId,
UserId = currentUser.UserId.Value,
Phase = contract.Phase,
Content = request.Content,
};
db.ContractComments.Add(comment);
await db.SaveChangesAsync(ct);
return comment.Id;
}
}
// ========== LIST contracts (admin view) ==========
public record ListContractsQuery(
ContractPhase? Phase = null,
Guid? SupplierId = null,
Guid? ProjectId = null) : PagedRequest, IRequest<PagedResult<ContractListItemDto>>;
public class ListContractsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListContractsQuery, PagedResult<ContractListItemDto>>
{
public async Task<PagedResult<ContractListItemDto>> Handle(ListContractsQuery request, CancellationToken ct)
{
var q = from c in db.Contracts.AsNoTracking()
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
select new { c, s, p };
if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase);
if (request.SupplierId is not null) q = q.Where(x => x.c.SupplierId == request.SupplierId);
if (request.ProjectId is not null) q = q.Where(x => x.c.ProjectId == request.ProjectId);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
q = q.Where(x =>
(x.c.MaHopDong != null && x.c.MaHopDong.Contains(s)) ||
(x.c.TenHopDong != null && x.c.TenHopDong.Contains(s)) ||
x.s.Name.Contains(s) || x.p.Name.Contains(s));
}
q = request.SortDesc ? q.OrderByDescending(x => x.c.CreatedAt) : q.OrderBy(x => x.c.CreatedAt);
var total = await q.CountAsync(ct);
var items = await q
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new ContractListItemDto(
x.c.Id, x.c.MaHopDong, x.c.TenHopDong, x.c.Type, x.c.Phase,
x.c.SupplierId, x.s.Name,
x.c.ProjectId, x.p.Name,
x.c.GiaTri, x.c.SlaDeadline, x.c.CreatedAt))
.ToListAsync(ct);
return new PagedResult<ContractListItemDto>(items, total, request.Page, request.PageSize);
}
}
// ========== INBOX — HĐ chờ role/tôi xử lý ==========
public record GetMyInboxQuery : IRequest<List<ContractListItemDto>>;
public class GetMyInboxQueryHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<GetMyInboxQuery, List<ContractListItemDto>>
{
// Map phase → role nào được xử lý (xem workflow-contract.md)
private static readonly Dictionary<ContractPhase, string[]> PhaseActorRoles = new()
{
[ContractPhase.DangSoanThao] = [AppRoles.Drafter, AppRoles.DeptManager],
[ContractPhase.DangGopY] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
[ContractPhase.DangDamPhan] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[ContractPhase.DangInKy] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[ContractPhase.DangKiemTraCCM] = [AppRoles.CostControl],
[ContractPhase.DangTrinhKy] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[ContractPhase.DangDongDau] = [AppRoles.HrAdmin],
};
public async Task<List<ContractListItemDto>> Handle(GetMyInboxQuery request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
var userRoles = currentUser.Roles;
var isAdmin = userRoles.Contains(AppRoles.Admin);
// Phase phù hợp với role hiện tại (Admin thấy tất cả phase chưa kết thúc)
var eligiblePhases = isAdmin
? PhaseActorRoles.Keys.ToList()
: PhaseActorRoles
.Where(kv => kv.Value.Any(r => userRoles.Contains(r)))
.Select(kv => kv.Key)
.ToList();
if (eligiblePhases.Count == 0) return [];
var q = from c in db.Contracts.AsNoTracking()
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
where eligiblePhases.Contains(c.Phase)
orderby c.SlaDeadline ?? DateTime.MaxValue
select new ContractListItemDto(
c.Id, c.MaHopDong, c.TenHopDong, c.Type, c.Phase,
c.SupplierId, s.Name, c.ProjectId, p.Name,
c.GiaTri, c.SlaDeadline, c.CreatedAt);
return await q.Take(100).ToListAsync(ct);
}
}
// ========== GET detail ==========
public record GetContractQuery(Guid Id) : IRequest<ContractDetailDto>;
public class GetContractQueryHandler(IApplicationDbContext db, UserManager<User> userManager)
: IRequestHandler<GetContractQuery, ContractDetailDto>
{
public async Task<ContractDetailDto> Handle(GetContractQuery request, CancellationToken ct)
{
var c = await db.Contracts.AsNoTracking()
.Include(x => x.Approvals)
.Include(x => x.Comments)
.Include(x => x.Attachments)
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Resolve user names
var userIds = new HashSet<Guid>();
if (c.DrafterUserId is Guid did) userIds.Add(did);
foreach (var a in c.Approvals) if (a.ApproverUserId is Guid aid) userIds.Add(aid);
foreach (var cm in c.Comments) userIds.Add(cm.UserId);
var users = await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
return new ContractDetailDto(
c.Id, c.MaHopDong, c.TenHopDong, c.NoiDung, c.Type, c.Phase,
c.SupplierId, supplier?.Name ?? "",
c.ProjectId, project?.Name ?? "",
c.DepartmentId, department?.Name,
c.DrafterUserId, c.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData,
c.CreatedAt, c.UpdatedAt,
c.Approvals
.OrderBy(a => a.ApprovedAt)
.Select(a => new ContractApprovalDto(
a.Id, a.FromPhase, a.ToPhase, a.ApproverUserId,
a.ApproverUserId is Guid id && users.TryGetValue(id, out var n) ? n : null,
a.Decision, a.Comment, a.ApprovedAt))
.ToList(),
c.Comments
.OrderBy(cm => cm.CreatedAt)
.Select(cm => new ContractCommentDto(
cm.Id, cm.UserId,
users.TryGetValue(cm.UserId, out var cn) ? cn : "",
cm.Phase, cm.Content, cm.CreatedAt))
.ToList(),
c.Attachments
.OrderBy(att => att.CreatedAt)
.Select(att => new ContractAttachmentDto(
att.Id, att.FileName, att.StoragePath, att.FileSize,
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
.ToList());
}
}
// ========== DELETE (soft) ==========
public record DeleteContractCommand(Guid Id) : IRequest;
public class DeleteContractCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteContractCommand>
{
public async Task Handle(DeleteContractCommand request, CancellationToken ct)
{
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
if (entity.Phase >= ContractPhase.DangInKy)
throw new ConflictException("Không được xóa HĐ đã qua phase 'Đang in ký'.");
db.Contracts.Remove(entity);
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,71 @@
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts.Dtos;
public record ContractListItemDto(
Guid Id,
string? MaHopDong,
string? TenHopDong,
ContractType Type,
ContractPhase Phase,
Guid SupplierId,
string SupplierName,
Guid ProjectId,
string ProjectName,
decimal GiaTri,
DateTime? SlaDeadline,
DateTime CreatedAt);
public record ContractDetailDto(
Guid Id,
string? MaHopDong,
string? TenHopDong,
string? NoiDung,
ContractType Type,
ContractPhase Phase,
Guid SupplierId,
string SupplierName,
Guid ProjectId,
string ProjectName,
Guid? DepartmentId,
string? DepartmentName,
Guid? DrafterUserId,
string? DrafterName,
Guid? TemplateId,
decimal GiaTri,
bool BypassProcurementAndCCM,
DateTime? SlaDeadline,
string? DraftData,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<ContractApprovalDto> Approvals,
List<ContractCommentDto> Comments,
List<ContractAttachmentDto> Attachments);
public record ContractApprovalDto(
Guid Id,
ContractPhase FromPhase,
ContractPhase ToPhase,
Guid? ApproverUserId,
string? ApproverName,
ApprovalDecision Decision,
string? Comment,
DateTime ApprovedAt);
public record ContractCommentDto(
Guid Id,
Guid UserId,
string UserName,
ContractPhase Phase,
string Content,
DateTime CreatedAt);
public record ContractAttachmentDto(
Guid Id,
string FileName,
string StoragePath,
long FileSize,
string ContentType,
AttachmentPurpose Purpose,
string? Note,
DateTime CreatedAt);

View File

@ -0,0 +1,26 @@
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts.Services;
public interface IContractWorkflowService
{
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
// Tự tạo ContractApproval row + update Phase + SlaDeadline + gen mã HĐ nếu cần.
Task TransitionAsync(
Contract contract,
ContractPhase targetPhase,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
CancellationToken ct = default);
// SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA.
TimeSpan? GetPhaseSla(ContractPhase phase);
}
public interface IContractCodeGenerator
{
// Gen mã HĐ theo RG-001 format. Transaction SERIALIZABLE để tránh race.
Task<string> GenerateAsync(Contract contract, string projectCode, string supplierCode, CancellationToken ct = default);
}