[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

@ -0,0 +1,72 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/contracts")]
[Authorize]
public class ContractsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<ContractListItemDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] bool sortDesc = true,
[FromQuery] ContractPhase? phase = null,
[FromQuery] Guid? supplierId = null,
[FromQuery] Guid? projectId = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListContractsQuery(phase, supplierId, projectId) { Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
[HttpGet("inbox")]
public async Task<ActionResult<List<ContractListItemDto>>> Inbox(CancellationToken ct)
=> Ok(await mediator.Send(new GetMyInboxQuery(), ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<ContractDetailDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetContractQuery(id), ct));
[HttpPost]
public async Task<ActionResult<object>> Create([FromBody] CreateContractCommand cmd, CancellationToken ct)
{
var id = await mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateContractDraftCommand cmd, CancellationToken ct)
{
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
await mediator.Send(cmd, ct);
return NoContent();
}
[HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionContractBody body, CancellationToken ct)
{
await mediator.Send(new TransitionContractCommand(id, body.TargetPhase, body.Decision, body.Comment), ct);
return NoContent();
}
[HttpPost("{id:guid}/comments")]
public async Task<ActionResult<object>> AddComment(Guid id, [FromBody] AddCommentBody body, CancellationToken ct)
{
var commentId = await mediator.Send(new AddCommentCommand(id, body.Content), ct);
return Ok(new { id = commentId });
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteContractCommand(id), ct);
return NoContent();
}
}
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);
public record AddCommentBody(string Content);

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

View File

@ -0,0 +1,28 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// Aggregate root cho quy trình trình ký HĐ.
// State machine xem docs/workflow-contract.md — 9 phase + 1 TuChoi.
public class Contract : AuditableEntity
{
public string? MaHopDong { get; set; } // Gen khi chuyển phase DangDongDau (RG-001)
public ContractType Type { get; set; }
public ContractPhase Phase { get; set; } = ContractPhase.DangSoanThao;
public Guid SupplierId { get; set; }
public Guid ProjectId { get; set; }
public Guid? DepartmentId { get; set; }
public Guid? DrafterUserId { get; set; } // Người soạn thảo
public Guid? TemplateId { get; set; } // Template dùng để render
public decimal GiaTri { get; set; }
public string? TenHopDong { get; set; }
public string? NoiDung { get; set; }
public bool BypassProcurementAndCCM { get; set; } // HĐ Chủ đầu tư → skip CCM
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
public string? DraftData { get; set; } // JSON field values (render template)
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
public List<ContractApproval> Approvals { get; set; } = new();
public List<ContractComment> Comments { get; set; } = new();
public List<ContractAttachment> Attachments { get; set; } = new();
}

View File

@ -0,0 +1,18 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// Lịch sử phê duyệt: mỗi lần chuyển phase tạo 1 row.
// ApproverUserId = null nếu là auto-approve do SLA expired.
public class ContractApproval : BaseEntity
{
public Guid ContractId { get; set; }
public ContractPhase FromPhase { get; set; }
public ContractPhase ToPhase { get; set; }
public Guid? ApproverUserId { get; set; }
public ApprovalDecision Decision { get; set; }
public string? Comment { get; set; }
public DateTime ApprovedAt { get; set; }
public Contract? Contract { get; set; }
}

View File

@ -0,0 +1,24 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
public enum AttachmentPurpose
{
DraftExport = 1, // File render từ template (.docx/.xlsx) ở phase DangSoanThao
ScannedSigned = 2, // Scan HĐ có chữ ký NCC ở phase DangInKy
SealedCopy = 3, // Scan HĐ đã đóng dấu ở phase DangDongDau
Other = 99,
}
public class ContractAttachment : BaseEntity
{
public Guid ContractId { get; set; }
public string FileName { get; set; } = string.Empty;
public string StoragePath { get; set; } = string.Empty; // relative path under wwwroot/uploads/
public long FileSize { get; set; }
public string ContentType { get; set; } = string.Empty;
public AttachmentPurpose Purpose { get; set; }
public string? Note { get; set; }
public Contract? Contract { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace SolutionErp.Domain.Contracts;
// Sequence generator cho mã HĐ theo RG-001.
// Prefix = phần đầu mã (vd "FLOCK 01/HĐGK/SOL&PVL"). LastSeq tăng dần.
// Update atomic qua transaction SERIALIZABLE.
public class ContractCodeSequence
{
public string Prefix { get; set; } = string.Empty;
public int LastSeq { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@ -0,0 +1,14 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// Thread góp ý — dùng chủ yếu ở phase DangGopY nhưng có thể ở bất kỳ phase nào.
public class ContractComment : BaseEntity
{
public Guid ContractId { get; set; }
public Guid UserId { get; set; }
public ContractPhase Phase { get; set; }
public string Content { get; set; } = string.Empty;
public Contract? Contract { get; set; }
}

View File

@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Forms;
@ -23,6 +24,8 @@ public static class DependencyInjection
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddSingleton<IFormRenderer, FormRenderer>();
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<AuditingInterceptor>();

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
@ -19,6 +20,11 @@ public class ApplicationDbContext
public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<ContractTemplate> ContractTemplates => Set<ContractTemplate>();
public DbSet<ContractClause> ContractClauses => Set<ContractClause>();
public DbSet<Contract> Contracts => Set<Contract>();
public DbSet<ContractApproval> ContractApprovals => Set<ContractApproval>();
public DbSet<ContractComment> ContractComments => Set<ContractComment>();
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class ContractConfiguration : IEntityTypeConfiguration<Contract>
{
public void Configure(EntityTypeBuilder<Contract> b)
{
b.ToTable("Contracts");
b.HasKey(x => x.Id);
b.Property(x => x.MaHopDong).HasMaxLength(100);
b.Property(x => x.Type).HasConversion<int>();
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.GiaTri).HasPrecision(18, 2);
b.Property(x => x.TenHopDong).HasMaxLength(500);
b.Property(x => x.NoiDung).HasMaxLength(2000);
b.Property(x => x.DraftData).HasColumnType("nvarchar(max)");
b.HasIndex(x => x.MaHopDong).IsUnique().HasFilter("[MaHopDong] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });
b.HasIndex(x => x.SupplierId);
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.SlaDeadline);
b.HasMany(x => x.Approvals).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Comments).WithOne(c => c.Contract).HasForeignKey(c => c.ContractId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Attachments).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(DeleteBehavior.Cascade);
b.HasQueryFilter(x => !x.IsDeleted);
}
}
public class ContractApprovalConfiguration : IEntityTypeConfiguration<ContractApproval>
{
public void Configure(EntityTypeBuilder<ContractApproval> b)
{
b.ToTable("ContractApprovals");
b.HasKey(x => x.Id);
b.Property(x => x.FromPhase).HasConversion<int>();
b.Property(x => x.ToPhase).HasConversion<int>();
b.Property(x => x.Decision).HasConversion<int>();
b.Property(x => x.Comment).HasMaxLength(1000);
b.HasIndex(x => new { x.ContractId, x.ApprovedAt });
}
}
public class ContractCommentConfiguration : IEntityTypeConfiguration<ContractComment>
{
public void Configure(EntityTypeBuilder<ContractComment> b)
{
b.ToTable("ContractComments");
b.HasKey(x => x.Id);
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.Content).HasMaxLength(2000).IsRequired();
b.HasIndex(x => new { x.ContractId, x.CreatedAt });
}
}
public class ContractAttachmentConfiguration : IEntityTypeConfiguration<ContractAttachment>
{
public void Configure(EntityTypeBuilder<ContractAttachment> b)
{
b.ToTable("ContractAttachments");
b.HasKey(x => x.Id);
b.Property(x => x.FileName).HasMaxLength(255).IsRequired();
b.Property(x => x.StoragePath).HasMaxLength(500).IsRequired();
b.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
b.Property(x => x.Purpose).HasConversion<int>();
b.Property(x => x.Note).HasMaxLength(500);
b.HasIndex(x => x.ContractId);
}
}
public class ContractCodeSequenceConfiguration : IEntityTypeConfiguration<ContractCodeSequence>
{
public void Configure(EntityTypeBuilder<ContractCodeSequence> b)
{
b.ToTable("ContractCodeSequences");
b.HasKey(x => x.Prefix);
b.Property(x => x.Prefix).HasMaxLength(200);
}
}

View File

@ -0,0 +1,203 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddContractsWorkflow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContractCodeSequences",
columns: table => new
{
Prefix = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
LastSeq = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractCodeSequences", x => x.Prefix);
});
migrationBuilder.CreateTable(
name: "Contracts",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaHopDong = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Type = table.Column<int>(type: "int", nullable: false),
Phase = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProjectId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DrafterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
TemplateId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
GiaTri = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
TenHopDong = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
NoiDung = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
BypassProcurementAndCCM = table.Column<bool>(type: "bit", nullable: false),
SlaDeadline = table.Column<DateTime>(type: "datetime2", nullable: true),
DraftData = table.Column<string>(type: "nvarchar(max)", nullable: true),
SlaWarningSent = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Contracts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ContractApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FromPhase = table.Column<int>(type: "int", nullable: false),
ToPhase = table.Column<int>(type: "int", nullable: false),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Decision = table.Column<int>(type: "int", nullable: false),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractApprovals", x => x.Id);
table.ForeignKey(
name: "FK_ContractApprovals_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractAttachments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FileName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
StoragePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
FileSize = table.Column<long>(type: "bigint", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Purpose = table.Column<int>(type: "int", nullable: false),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractAttachments", x => x.Id);
table.ForeignKey(
name: "FK_ContractAttachments_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractComments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Phase = table.Column<int>(type: "int", nullable: false),
Content = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractComments", x => x.Id);
table.ForeignKey(
name: "FK_ContractComments_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ContractApprovals_ContractId_ApprovedAt",
table: "ContractApprovals",
columns: new[] { "ContractId", "ApprovedAt" });
migrationBuilder.CreateIndex(
name: "IX_ContractAttachments_ContractId",
table: "ContractAttachments",
column: "ContractId");
migrationBuilder.CreateIndex(
name: "IX_ContractComments_ContractId_CreatedAt",
table: "ContractComments",
columns: new[] { "ContractId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Contracts_MaHopDong",
table: "Contracts",
column: "MaHopDong",
unique: true,
filter: "[MaHopDong] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Contracts_Phase_IsDeleted",
table: "Contracts",
columns: new[] { "Phase", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_Contracts_ProjectId",
table: "Contracts",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_Contracts_SlaDeadline",
table: "Contracts",
column: "SlaDeadline");
migrationBuilder.CreateIndex(
name: "IX_Contracts_SupplierId",
table: "Contracts",
column: "SupplierId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContractApprovals");
migrationBuilder.DropTable(
name: "ContractAttachments");
migrationBuilder.DropTable(
name: "ContractCodeSequences");
migrationBuilder.DropTable(
name: "ContractComments");
migrationBuilder.DropTable(
name: "Contracts");
}
}
}

View File

@ -125,6 +125,255 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("BypassProcurementAndCCM")
.HasColumnType("bit");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DraftData")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("DrafterUserId")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("GiaTri")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaHopDong")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("NoiDung")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
b.Property<bool>("SlaWarningSent")
.HasColumnType("bit");
b.Property<Guid>("SupplierId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("TemplateId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TenHopDong")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MaHopDong")
.IsUnique()
.HasFilter("[MaHopDong] IS NOT NULL");
b.HasIndex("ProjectId");
b.HasIndex("SlaDeadline");
b.HasIndex("SupplierId");
b.HasIndex("Phase", "IsDeleted");
b.ToTable("Contracts", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<Guid>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Decision")
.HasColumnType("int");
b.Property<int>("FromPhase")
.HasColumnType("int");
b.Property<int>("ToPhase")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractId", "ApprovedAt");
b.ToTable("ContractApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Purpose")
.HasColumnType("int");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractId");
b.ToTable("ContractAttachments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b =>
{
b.Property<string>("Prefix")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("LastSeq")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Prefix");
b.ToTable("ContractCodeSequences", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<Guid>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractId", "CreatedAt");
b.ToTable("ContractComments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
{
b.Property<Guid>("Id")
@ -681,6 +930,39 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("Approvals")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("Attachments")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("Comments")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
@ -710,6 +992,15 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Role");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Navigation("Approvals");
b.Navigation("Attachments");
b.Navigation("Comments");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.Navigation("Children");

View File

@ -0,0 +1,61 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Services;
public class ContractCodeGenerator(IApplicationDbContext db, IDateTime dateTime) : IContractCodeGenerator
{
public async Task<string> GenerateAsync(Contract contract, string projectCode, string supplierCode, CancellationToken ct = default)
{
// Format theo RG-001 (xem docs/forms-spec.md):
// HĐTP: {Project}/HĐTP/SOL&{Partner}/{Seq}
// HĐGK: {Project}/HĐGK/SOL&{Partner}/{Seq}
// NCC: {Project}/NCC/SOL&{Partner}/{Seq} (hoặc {Year}/NCC/SOL&{Partner}/{Seq} cho framework)
// HĐDV: {Project}/HĐDV/SOL&{Partner}/{Seq} (hoặc {Year}/HĐDV/...)
// HĐ Mua bán: dùng PO format
var typeCode = contract.Type switch
{
ContractType.HopDongThauPhu => "HĐTP",
ContractType.HopDongGiaoKhoan => "HĐGK",
ContractType.HopDongNhaCungCap => "NCC",
ContractType.HopDongDichVu => "HĐDV",
ContractType.HopDongMuaBan => "MB",
ContractType.HopDongNguyenTacNCC => "NCC",
ContractType.HopDongNguyenTacDichVu => "HĐDV",
_ => "HĐ",
};
var isFramework = contract.Type is ContractType.HopDongNguyenTacNCC or ContractType.HopDongNguyenTacDichVu;
var scope = isFramework ? dateTime.UtcNow.Year.ToString() : projectCode;
var prefix = $"{scope}/{typeCode}/SOL&{supplierCode}";
// Transaction SERIALIZABLE + UPDATE với lock
var context = (DbContext)db;
await using var tx = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
try
{
var seq = await db.ContractCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
if (seq is null)
{
seq = new ContractCodeSequence { Prefix = prefix, LastSeq = 1, UpdatedAt = dateTime.UtcNow };
db.ContractCodeSequences.Add(seq);
}
else
{
seq.LastSeq += 1;
seq.UpdatedAt = dateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return $"{prefix}/{seq.LastSeq:D2}";
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}
}

View File

@ -0,0 +1,120 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Infrastructure.Services;
public class ContractWorkflowService(
IApplicationDbContext db,
IContractCodeGenerator codeGenerator,
IDateTime dateTime) : IContractWorkflowService
{
// Map (from, to) → roles được phép chuyển. Xem docs/workflow-contract.md §5.
// Admin luôn bypass (check trong Handler trước khi gọi service).
private static readonly Dictionary<(ContractPhase From, ContractPhase To), string[]> Transitions = new()
{
[(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
[(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
// Bypass CCM cho HĐ Chủ đầu tư — xử lý riêng trong CanTransition
[(ContractPhase.DangInKy, ContractPhase.DangTrinhKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl],
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl],
[(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin],
};
private static readonly Dictionary<ContractPhase, TimeSpan?> PhaseSla = new()
{
[ContractPhase.DangSoanThao] = TimeSpan.FromDays(7),
[ContractPhase.DangGopY] = TimeSpan.FromDays(7),
[ContractPhase.DangDamPhan] = TimeSpan.FromDays(7),
[ContractPhase.DangInKy] = TimeSpan.FromDays(1),
[ContractPhase.DangKiemTraCCM] = TimeSpan.FromDays(3),
[ContractPhase.DangTrinhKy] = TimeSpan.FromDays(1),
[ContractPhase.DangDongDau] = null,
[ContractPhase.DaPhatHanh] = null,
[ContractPhase.TuChoi] = null,
[ContractPhase.DangChon] = null,
};
public TimeSpan? GetPhaseSla(ContractPhase phase) => PhaseSla.GetValueOrDefault(phase);
public async Task TransitionAsync(
Contract contract,
ContractPhase targetPhase,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
CancellationToken ct = default)
{
if (contract.Phase == targetPhase)
throw new ConflictException("HĐ đã ở phase đích.");
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
if (!isAdmin && !isSystem)
{
if (!Transitions.TryGetValue((contract.Phase, targetPhase), out var allowedRoles))
throw new ForbiddenException($"Không thể chuyển {contract.Phase} → {targetPhase}.");
// Bypass rule: nếu BypassProcurementAndCCM + đang ở DangInKy → chỉ cho chuyển DangTrinhKy (skip CCM)
if (!contract.BypassProcurementAndCCM
&& contract.Phase == ContractPhase.DangInKy
&& targetPhase == ContractPhase.DangTrinhKy)
{
throw new ForbiddenException("Chỉ HĐ với Chủ đầu tư mới được bỏ qua phase CCM.");
}
if (!actorRoles.Any(r => allowedRoles.Contains(r)))
throw new ForbiddenException($"Role của bạn ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}.");
}
var fromPhase = contract.Phase;
// Gen mã HĐ khi chuyển sang DangDongDau (BOD ký xong)
if (targetPhase == ContractPhase.DangDongDau && string.IsNullOrEmpty(contract.MaHopDong))
{
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
?? throw new NotFoundException("Supplier", contract.SupplierId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
?? throw new NotFoundException("Project", contract.ProjectId);
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
}
// Reset SlaWarningSent khi chuyển phase
contract.SlaWarningSent = false;
contract.Phase = targetPhase;
var sla = GetPhaseSla(targetPhase);
contract.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.Id,
FromPhase = fromPhase,
ToPhase = targetPhase,
ApproverUserId = actorUserId,
Decision = decision,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
}
}