[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S37 Mig 37 enum + Plan G-O3 Đề xuất full-stack
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s

Phase 10.3 G-O3 Đề xuất (Proposal) — Mig 37 enum extend +5 values + Mig 38
Proposal schema + BE CQRS 8 endpoint + FE 2 app SHA256 IDENTICAL.

Mig 37 (em main solo): extend ApprovalWorkflowApplicableType enum +5 values
  ProposalGeneral=4 / LeaveRequest=5 / OtRequest=6 / VehicleBooking=7 / ItTicket=8
  cookie-cutter Mig 22 pattern (Up/Down empty — enum mức Domain).

Mig 38 (em main solo): 4 entity Proposal (Code DX/YYYY/NNN) + ProposalAttachment
  + ProposalLevelOpinion (UNIQUE composite PEId+LevelId mirror PE Mig 26) +
  ProposalCodeSequence (Prefix PK atomic seq). 4 EF Config + 2 DbContext mod.

BE CQRS (em main solo ~700 LOC ProposalFeatures.cs sau Implementer truncate phase
exploration gotcha #53 5th + 529 Overload):
  - 4 Header handler (List paged + GetById detail + Create + UpdateDraft owner-OR-admin)
  - 4 Workflow handler (Submit gen MaDeXuat atomic + Approve UPSERT LevelOpinion advance + Reject + Return)
  - SERIALIZABLE transaction CodeGen
  - DTOs nested LevelOpinion với Step+Level metadata JOIN

ProposalsController 8 endpoint /api/proposals (List/GetById/Create/Update/Submit/Approve/Reject/Return)
class-level [Authorize] + handler-level owner-OR-admin guard.

DbInitializer: SeedSampleProposalWorkflowV2Async ~40 LOC seed QT-DX-V2-001 IsUserSelectable=true
NOT gated DemoSeed per gotcha #51. SeedMenuTreeAsync +4 row (Off_DeXuat sub-group + 3 leaf).

FE 2 app (em main solo + Implementer 529 fail fallback):
  - types/proposal.ts × 2 SHA256 IDENTICAL 95607052ff1138f2
  - ProposalsListPage.tsx × 2 IDENTICAL 603f0d9cf74cd09a — table 6 cột + Status badge + filter
  - ProposalCreatePage.tsx × 2 IDENTICAL 6aed3a76563dd576 — Form Header card
  - ProposalDetailPage.tsx × 2 IDENTICAL 3dc229ea8dcc9bc0 — 3 Section + WorkflowActions
  - Pattern 16-bis 8× cumulative (App.tsx + menuKeys + Layout staticMap 3 entry)

Verify:
- dotnet build PASS 0 error 2 warning pre-existing DocxRenderer
- dotnet test 130/130 PASS baseline preserve
- npm build × 2 PASS (fe-admin 14.72s + fe-user 6.40s)
- SHA256 verify 4 file × 2 app all IDENTICAL

Pattern reinforced cumulative S37:
- Pattern 12-bis cross-module mirror 11× (PE V2 → Proposal V2 ApproveV2)
- Pattern 16-bis 4-place mirror cross-app 8×
- gotcha #53 5th occurrence Implementer mid-exploration truncation + 529 Overload 1× — em main solo fallback proven

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-28 15:51:14 +07:00
parent 37593f95b5
commit de1c378279
35 changed files with 13650 additions and 0 deletions

View File

@ -0,0 +1,86 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
// Phase 10.3 G-O3 (S37 2026-05-28) — Đề xuất REST endpoint.
// Class-level [Authorize] — any authenticated user can list/create/view.
// Per-action ownership/role enforcement trong handler (Drafter OR Admin for write;
// Approver-of-current-Level OR Admin for approve/reject/return).
[ApiController]
[Route("api/proposals")]
[Authorize]
public class ProposalsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetList(
[FromQuery] int? status,
[FromQuery] Guid? departmentId,
[FromQuery] Guid? drafterUserId,
[FromQuery] bool inboxOnly = false,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? search = null)
=> Ok(await mediator.Send(new GetProposalsQuery(status, departmentId, drafterUserId, inboxOnly, page, pageSize, search)));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var dto = await mediator.Send(new GetProposalByIdQuery(id));
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateProposalCommand cmd)
{
var id = await mediator.Send(cmd);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateProposalDraftBody body)
{
await mediator.Send(new UpdateProposalDraftCommand(id, body.Title, body.Description,
body.AmountEstimate, body.DepartmentId, body.ApprovalWorkflowId));
return NoContent();
}
[HttpPost("{id:guid}/submit")]
public async Task<IActionResult> Submit(Guid id)
{
await mediator.Send(new SubmitProposalCommand(id));
return NoContent();
}
[HttpPost("{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new ApproveProposalCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/reject")]
public async Task<IActionResult> Reject(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new RejectProposalCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/return")]
public async Task<IActionResult> Return(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new ReturnProposalCommand(id, body.Comment));
return NoContent();
}
public record UpdateProposalDraftBody(
string Title,
string? Description,
decimal? AmountEstimate,
Guid? DepartmentId,
Guid? ApprovalWorkflowId);
public record ApprovalActionBody(string? Comment);
}

View File

@ -111,5 +111,13 @@ public interface IApplicationDbContext
DbSet<MeetingBooking> MeetingBookings { get; }
DbSet<MeetingBookingAttendee> MeetingBookingAttendees { get; }
// Phase 10.3 G-O3 (Mig 38 — S37) — Đề xuất (Proposal) cookie-cutter mirror PE.
// Workflow V2 dynamic ApplicableType=ProposalGeneral=4 (Mig 37 enum extend).
// CodeGen "DX/YYYY/NNN" atomic via ProposalCodeSequences.
DbSet<Proposal> Proposals { get; }
DbSet<ProposalAttachment> ProposalAttachments { get; }
DbSet<ProposalLevelOpinion> ProposalLevelOpinions { get; }
DbSet<ProposalCodeSequence> ProposalCodeSequences { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,556 @@
using System.Data;
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Office;
namespace SolutionErp.Application.Office;
// Phase 10.3 G-O3 (S37 2026-05-28) — Đề xuất (Proposal) CQRS + Workflow V2 ApproveV2.
// Cookie-cutter mirror PurchaseEvaluation Plan B Chunk D pattern (Mig 22-26 V2).
// ApplicableType=ProposalGeneral=4 (Mig 37 enum extend).
//
// 8 endpoint qua ProposalsController:
// GET /api/proposals — list paged (filter status/dept/drafter/inbox)
// GET /api/proposals/{id} — detail Include Attachments + LevelOpinions + Workflow
// POST /api/proposals — create Status=Nhap (MaDeXuat null tới Submit)
// PUT /api/proposals/{id} — update draft (Nhap or TraLai only)
// POST /api/proposals/{id}/submit — gen MaDeXuat atomic + Status=DaGuiDuyet
// POST /api/proposals/{id}/approve — ApproveV2: UPSERT LevelOpinion + advance level/terminal
// POST /api/proposals/{id}/reject — Status=TuChoi terminal (no opinion sync)
// POST /api/proposals/{id}/return — Status=TraLai (no opinion sync, Drafter resubmit từ Cấp 1)
// ===== DTOs =====
public record ProposalListItemDto(
Guid Id,
string? MaDeXuat,
string Title,
decimal? AmountEstimate,
int Status,
Guid? DepartmentId,
string? DepartmentName,
Guid DrafterUserId,
string? DrafterFullName,
Guid? ApprovalWorkflowId,
string? WorkflowCode,
int? CurrentApprovalLevelOrder,
DateTime CreatedAt);
public record ProposalDetailDto(
Guid Id,
string? MaDeXuat,
string Title,
string? Description,
decimal? AmountEstimate,
int Status,
Guid? DepartmentId,
string? DepartmentName,
Guid DrafterUserId,
string? DrafterFullName,
Guid? ApprovalWorkflowId,
string? WorkflowCode,
string? WorkflowName,
int? CurrentApprovalLevelOrder,
int? RejectedFromStatus,
DateTime CreatedAt,
List<ProposalAttachmentDto> Attachments,
List<ProposalLevelOpinionDto> LevelOpinions);
public record ProposalAttachmentDto(
Guid Id,
string FileName,
string FilePath,
long FileSize,
string? MimeType,
Guid UploadedByUserId,
string UploadedByFullName,
DateTime CreatedAt);
public record ProposalLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int? StepOrder,
string? StepName,
int? LevelOrder,
Guid? ApproverUserId,
string? Comment,
DateTime SignedAt,
Guid SignedByUserId,
string SignedByFullName);
// ===== Region 1: Header CRUD =====
public record GetProposalsQuery(
int? Status,
Guid? DepartmentId,
Guid? DrafterUserId,
bool InboxOnly = false,
int Page = 1,
int PageSize = 50,
string? Search = null) : IRequest<PagedResult<ProposalListItemDto>>;
public class GetProposalsHandler(IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<GetProposalsQuery, PagedResult<ProposalListItemDto>>
{
public async Task<PagedResult<ProposalListItemDto>> Handle(GetProposalsQuery req, CancellationToken ct)
{
var page = req.Page < 1 ? 1 : req.Page;
var pageSize = req.PageSize switch { < 1 => 50, > 200 => 200, _ => req.PageSize };
var q = db.Proposals.AsNoTracking().Where(p => !p.IsDeleted);
if (req.Status.HasValue)
q = q.Where(p => (int)p.Status == req.Status.Value);
if (req.DepartmentId.HasValue)
q = q.Where(p => p.DepartmentId == req.DepartmentId.Value);
if (req.DrafterUserId.HasValue)
q = q.Where(p => p.DrafterUserId == req.DrafterUserId.Value);
// Inbox = DaGuiDuyet phiếu có Cấp hiện tại match current user trong Workflow.
// Lite version: filter Status=DaGuiDuyet client-side resolve approver via Workflow join below.
if (req.InboxOnly && currentUser.UserId.HasValue)
{
q = q.Where(p => p.Status == ProposalStatus.DaGuiDuyet);
}
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim();
q = q.Where(p => p.Title.Contains(s) || (p.MaDeXuat != null && p.MaDeXuat.Contains(s)));
}
var total = await q.CountAsync(ct);
var items = await q.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProposalListItemDto(
p.Id,
p.MaDeXuat,
p.Title,
p.AmountEstimate,
(int)p.Status,
p.DepartmentId,
db.Departments.Where(d => d.Id == p.DepartmentId).Select(d => d.Name).FirstOrDefault(),
p.DrafterUserId,
db.Users.Where(u => u.Id == p.DrafterUserId).Select(u => u.FullName).FirstOrDefault(),
p.ApprovalWorkflowId,
db.ApprovalWorkflows.Where(w => w.Id == p.ApprovalWorkflowId).Select(w => w.Code).FirstOrDefault(),
p.CurrentApprovalLevelOrder,
p.CreatedAt))
.ToListAsync(ct);
return new PagedResult<ProposalListItemDto>(items, total, page, pageSize);
}
}
public record GetProposalByIdQuery(Guid Id) : IRequest<ProposalDetailDto?>;
public class GetProposalByIdHandler(IApplicationDbContext db)
: IRequestHandler<GetProposalByIdQuery, ProposalDetailDto?>
{
public async Task<ProposalDetailDto?> Handle(GetProposalByIdQuery req, CancellationToken ct)
{
var p = await db.Proposals.AsNoTracking()
.Include(x => x.Attachments)
.Include(x => x.LevelOpinions)
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) return null;
var deptName = await db.Departments.AsNoTracking()
.Where(d => d.Id == p.DepartmentId).Select(d => d.Name).FirstOrDefaultAsync(ct);
var drafterName = await db.Users.AsNoTracking()
.Where(u => u.Id == p.DrafterUserId).Select(u => u.FullName).FirstOrDefaultAsync(ct);
string? wfCode = null;
string? wfName = null;
if (p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => new { w.Code, w.Name })
.FirstOrDefaultAsync(ct);
wfCode = wf?.Code;
wfName = wf?.Name;
}
// Build LevelOpinions với Step+Level metadata via JOIN
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
.Select(s => new
{
s.Id,
s.Order,
s.Name,
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
.ToList(),
})
.ToListAsync(ct);
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
.ToDictionary(x => x.Level.Id);
var opinions = p.LevelOpinions
.Select(o =>
{
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
return new ProposalLevelOpinionDto(
o.Id,
o.ApprovalWorkflowLevelId,
lvl?.Step.Order,
lvl?.Step.Name,
lvl?.Level.Order,
lvl?.Level.ApproverUserId,
o.Comment,
o.SignedAt,
o.SignedByUserId,
o.SignedByFullName);
})
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
.ToList();
var attachments = p.Attachments.Select(a => new ProposalAttachmentDto(
a.Id, a.FileName, a.FilePath, a.FileSize, a.MimeType,
a.UploadedByUserId, a.UploadedByFullName, a.CreatedAt)).ToList();
return new ProposalDetailDto(
p.Id, p.MaDeXuat, p.Title, p.Description, p.AmountEstimate, (int)p.Status,
p.DepartmentId, deptName, p.DrafterUserId, drafterName,
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
p.CreatedAt, attachments, opinions);
}
}
public record CreateProposalCommand(
string Title,
string? Description,
decimal? AmountEstimate,
Guid? DepartmentId,
Guid? ApprovalWorkflowId) : IRequest<Guid>;
public class CreateProposalValidator : AbstractValidator<CreateProposalCommand>
{
public CreateProposalValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).MaximumLength(5000);
RuleFor(x => x.AmountEstimate).GreaterThanOrEqualTo(0).When(x => x.AmountEstimate.HasValue);
}
}
public class CreateProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<CreateProposalCommand, Guid>
{
public async Task<Guid> Handle(CreateProposalCommand req, CancellationToken ct)
{
if (currentUser.UserId is null)
throw new UnauthorizedException();
// Verify workflow ApplicableType=ProposalGeneral=4 nếu pin
if (req.ApprovalWorkflowId.HasValue)
{
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.ProposalGeneral)
throw new ConflictException("Quy trình duyệt không thuộc loại Đề xuất.");
}
var entity = new Proposal
{
Title = req.Title.Trim(),
Description = req.Description?.Trim(),
AmountEstimate = req.AmountEstimate,
DepartmentId = req.DepartmentId,
DrafterUserId = currentUser.UserId.Value,
ApprovalWorkflowId = req.ApprovalWorkflowId,
Status = ProposalStatus.Nhap,
CreatedAt = clock.UtcNow,
CreatedBy = currentUser.UserId,
};
db.Proposals.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateProposalDraftCommand(
Guid Id,
string Title,
string? Description,
decimal? AmountEstimate,
Guid? DepartmentId,
Guid? ApprovalWorkflowId) : IRequest;
public class UpdateProposalDraftValidator : AbstractValidator<UpdateProposalDraftCommand>
{
public UpdateProposalDraftValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).MaximumLength(5000);
RuleFor(x => x.AmountEstimate).GreaterThanOrEqualTo(0).When(x => x.AmountEstimate.HasValue);
}
}
public class UpdateProposalDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<UpdateProposalDraftCommand>
{
public async Task Handle(UpdateProposalDraftCommand req, CancellationToken ct)
{
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
// Only Drafter (or admin) can edit, only when Nhap/TraLai
var isOwner = p.DrafterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người soạn hoặc Admin được sửa đề xuất.");
if (p.Status != ProposalStatus.Nhap && p.Status != ProposalStatus.TraLai)
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
// Verify workflow if changed
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
{
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.ProposalGeneral)
throw new ConflictException("Quy trình duyệt không thuộc loại Đề xuất.");
}
p.Title = req.Title.Trim();
p.Description = req.Description?.Trim();
p.AmountEstimate = req.AmountEstimate;
p.DepartmentId = req.DepartmentId;
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
// ===== Region 2: Workflow Actions =====
public record SubmitProposalCommand(Guid Id) : IRequest;
public class SubmitProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<SubmitProposalCommand>
{
public async Task Handle(SubmitProposalCommand req, CancellationToken ct)
{
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
var isOwner = p.DrafterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người soạn hoặc Admin được gửi duyệt.");
if (p.Status != ProposalStatus.Nhap && p.Status != ProposalStatus.TraLai)
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
if (!p.ApprovalWorkflowId.HasValue)
throw new ConflictException("Chưa chọn quy trình duyệt.");
// Gen MaDeXuat nếu null (lần Submit đầu tiên)
if (string.IsNullOrEmpty(p.MaDeXuat))
{
p.MaDeXuat = await GenerateMaDeXuatAsync(db, clock.Now.Year, clock, ct);
}
p.Status = ProposalStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = 1;
p.RejectedFromStatus = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
internal static async Task<string> GenerateMaDeXuatAsync(
IApplicationDbContext db, int year, IDateTime clock, CancellationToken ct)
{
var prefix = $"DX/{year}";
var dbContext = (DbContext)db;
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
var seq = await db.ProposalCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
if (seq is null)
{
seq = new ProposalCodeSequence { Prefix = prefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
db.ProposalCodeSequences.Add(seq);
}
seq.LastSeq++;
seq.UpdatedAt = clock.UtcNow;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return $"{prefix}/{seq.LastSeq:D3}";
}
}
public record ApproveProposalCommand(Guid Id, string? Comment) : IRequest;
public class ApproveProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<ApproveProposalCommand>
{
public async Task Handle(ApproveProposalCommand req, CancellationToken ct)
{
if (currentUser.UserId is null) throw new UnauthorizedException();
var p = await db.Proposals
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
if (p.Status != ProposalStatus.DaGuiDuyet)
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
// Tìm Step hiện tại = chứa Level Order == CurrentApprovalLevelOrder
// Multi-step workflow: traverse step-by-step. Lite version: assume 1 step per workflow
// hoặc CurrentApprovalLevelOrder reset mỗi step (mirror PE design).
// Đây dùng global level order trong workflow (sum tất cả level cross steps).
var allLevels = wf.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
.ToList();
if (allLevels.Count == 0)
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentSlot is null)
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
// Verify actor match — must be ApproverUserId hoặc Admin override
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
// UPSERT LevelOpinion
var existing = await db.ProposalLevelOpinions
.FirstOrDefaultAsync(o => o.ProposalId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
? "(duyệt — không ý kiến)"
: req.Comment.Trim();
if (existing is null)
{
db.ProposalLevelOpinions.Add(new ProposalLevelOpinion
{
ProposalId = p.Id,
ApprovalWorkflowLevelId = currentSlot.Level.Id,
Comment = commentFinal,
SignedAt = clock.UtcNow,
SignedByUserId = currentUser.UserId.Value,
SignedByFullName = currentUser.FullName ?? "(unknown)",
CreatedAt = clock.UtcNow,
CreatedBy = currentUser.UserId,
});
}
else
{
existing.Comment = commentFinal;
existing.SignedAt = clock.UtcNow;
existing.SignedByUserId = currentUser.UserId.Value;
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
existing.UpdatedAt = clock.UtcNow;
existing.UpdatedBy = currentUser.UserId;
}
// Advance level OR terminal
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
{
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
}
else
{
p.Status = ProposalStatus.DaDuyet;
p.CurrentApprovalLevelOrder = null;
}
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record RejectProposalCommand(Guid Id, string? Comment) : IRequest;
public class RejectProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<RejectProposalCommand>
{
public async Task Handle(RejectProposalCommand req, CancellationToken ct)
{
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
if (p.Status != ProposalStatus.DaGuiDuyet)
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
// Verify actor (lite — only check if has Admin role or matches any level approver)
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != currentUser.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = ProposalStatus.TuChoi;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ReturnProposalCommand(Guid Id, string? Comment) : IRequest;
public class ReturnProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<ReturnProposalCommand>
{
public async Task Handle(ReturnProposalCommand req, CancellationToken ct)
{
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
if (p.Status != ProposalStatus.DaGuiDuyet)
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != currentUser.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = ProposalStatus.TraLai;
p.RejectedFromStatus = ProposalStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}

View File

@ -47,6 +47,14 @@ public enum ApprovalWorkflowApplicableType
DuyetNcc = 1, // PE module — Duyệt NCC (default test target)
DuyetNccPhuongAn = 2, // PE — Duyệt NCC + Giải pháp
Contract = 3, // HĐ general (any ContractType)
// Phase 10.3 Workflow Apps (S37 — Mig 37 enum extend). Cookie-cutter Mig 22 V2 pattern.
// Mỗi module có 1 workflow per ApplicableType, admin Designer config + pin per draft.
ProposalGeneral = 4, // G-O3 — Đề xuất (Proposal)
LeaveRequest = 5, // G-O4 — Đơn nghỉ phép
OtRequest = 6, // G-O4 — Đơn OT
VehicleBooking = 7, // G-O5 — Đặt xe công
ItTicket = 8, // G-O6 — Ticket CNTT
}
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.

View File

@ -105,6 +105,12 @@ public static class MenuKeys
public const string OffPhongHopManage = "Off_PhongHop_Manage"; // Quản lý phòng (Admin CRUD MeetingRoom)
public const string OffPhongHopBook = "Off_PhongHop_Book"; // Đặt phòng (Create/Update/Cancel Booking)
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28) — Đề xuất (Proposal) workflow V2.
public const string OffDeXuat = "Off_DeXuat"; // sub-group Đề xuất
public const string OffDeXuatList = "Off_DeXuat_List"; // Danh sách đề xuất
public const string OffDeXuatCreate = "Off_DeXuat_Create"; // Tạo đề xuất mới
public const string OffDeXuatInbox = "Off_DeXuat_Inbox"; // Inbox phê duyệt
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
@ -133,6 +139,7 @@ public static class MenuKeys
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -10,3 +10,15 @@ public enum MeetingBookingStatus
Cancelled = 2, // Đã huỷ
Completed = 3, // Đã kết thúc (auto-set khi EndAt < Now via job/manual)
}
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28) — Trạng thái Đề xuất (Proposal).
// 5-state mirror PE/Contract pattern (Nháp / Đã gửi duyệt / Trả lại / Từ chối / Đã duyệt).
// Workflow V2 dynamic theo ApprovalWorkflow.ApplicableType=ProposalGeneral=4.
public enum ProposalStatus
{
Nhap = 1, // Drafter đang soạn, chưa gửi duyệt
DaGuiDuyet = 2, // Trong workflow approve flow (CurrentApprovalLevelOrder)
TraLai = 3, // Approver trả về Drafter sửa → resubmit lại từ Cấp 1
TuChoi = 4, // Terminal — không thể edit/resubmit
DaDuyet = 5, // Terminal — workflow complete tất cả Cấp
}

View File

@ -0,0 +1,38 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28) — Đề xuất (Proposal) aggregate root.
// Mirror PE Plan B Mig 22-26 pattern cookie-cutter: Workflow V2 dynamic (ApplicableType=4) +
// LevelOpinions UPSERT auto via ApproveV2Async + atomic CodeGen DX/YYYY/NNN.
// Reference: PurchaseEvaluation.cs (PE module flagship V2 mirror source).
public class Proposal : AuditableEntity
{
public string? MaDeXuat { get; set; } // Auto-gen "DX/YYYY/NNN" qua ProposalCodeSequence
public string Title { get; set; } = string.Empty; // "Đề xuất mua máy tính", "Đề xuất tăng ngân sách"
public string? Description { get; set; } // Nội dung chi tiết (max 5000)
// Tổng số tiền dự kiến (đ). Optional — có đề xuất không số tiền (vd policy).
public decimal? AmountEstimate { get; set; }
public ProposalStatus Status { get; set; } = ProposalStatus.Nhap;
public Guid? DepartmentId { get; set; } // Phòng ban đề xuất (org filter)
public Guid DrafterUserId { get; set; } // User tạo đề xuất
// Workflow V2 pin (cookie-cutter PE Mig 22-23-24).
// ApplicableType phải = ProposalGeneral=4 (Mig 37 enum extend).
public Guid? ApprovalWorkflowId { get; set; }
public int? CurrentApprovalLevelOrder { get; set; } // Cấp đang chờ (1-based) khi DaGuiDuyet
// SLA (mirror PE)
public DateTime? SlaDeadline { get; set; }
public bool SlaWarningSent { get; set; }
// Smart reject (mirror PE Mig 16)
public ProposalStatus? RejectedFromStatus { get; set; }
public List<ProposalAttachment> Attachments { get; set; } = new();
public List<ProposalLevelOpinion> LevelOpinions { get; set; } = new();
}

View File

@ -0,0 +1,20 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O3 (Mig 38 — S37) — Attachment cho Đề xuất.
// FK Cascade Proposal (xoá Đề xuất → wipe Attachments).
// Mirror PurchaseEvaluationAttachment pattern.
public class ProposalAttachment : AuditableEntity
{
public Guid ProposalId { get; set; }
public Proposal Proposal { get; set; } = null!;
public string FileName { get; set; } = string.Empty; // Original file name display
public string FilePath { get; set; } = string.Empty; // Server file path / blob URL
public long FileSize { get; set; } // Bytes
public string? MimeType { get; set; } // "application/pdf", "image/png"
public Guid UploadedByUserId { get; set; } // Drafter or approver tải lên
public string UploadedByFullName { get; set; } = string.Empty; // denorm
}

View File

@ -0,0 +1,13 @@
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O3 (Mig 38 — S37) — Sequence generator cho mã Đề xuất (MaDeXuat).
// Mirror PurchaseEvaluationCodeSequence pattern (Prefix string PK + LastSeq atomic).
// Format: "DX/YYYY/NNN" — vd "DX/2026/001" → "DX/2026/002" → ...
// Prefix = "DX/{YYYY}" cho từng năm. LastSeq reset đầu năm tự nhiên (key mới).
// Update atomic qua SERIALIZABLE transaction trong CodeGen service.
public class ProposalCodeSequence
{
public string Prefix { get; set; } = string.Empty; // PK — "DX/2026"
public int LastSeq { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@ -0,0 +1,28 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O3 (Mig 38 — S37) — Ý kiến cấp duyệt V2 dynamic cho Proposal.
// Cookie-cutter mirror PurchaseEvaluationLevelOpinion (Mig 26).
//
// Mỗi row = 1 (Proposal × ApprovalWorkflowLevel). Service ApproveV2Async sau
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
// Reject (TraLai/TuChoi) KHÔNG sync.
//
// UNIQUE composite (ProposalId, LevelId) — 1 row / level / proposal.
// FK Cascade Proposal (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
public class ProposalLevelOpinion : AuditableEntity
{
public Guid ProposalId { get; set; }
public Guid ApprovalWorkflowLevelId { get; set; }
public string? Comment { get; set; } // max 2000 hoặc placeholder
public DateTime SignedAt { get; set; }
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
public Proposal? Proposal { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

@ -102,6 +102,12 @@ public class ApplicationDbContext
public DbSet<MeetingBooking> MeetingBookings => Set<MeetingBooking>();
public DbSet<MeetingBookingAttendee> MeetingBookingAttendees => Set<MeetingBookingAttendee>();
// Phase 10.3 G-O3 (Mig 38 — S37) — Đề xuất + Attachment + LevelOpinion + CodeSequence.
public DbSet<Proposal> Proposals => Set<Proposal>();
public DbSet<ProposalAttachment> ProposalAttachments => Set<ProposalAttachment>();
public DbSet<ProposalLevelOpinion> ProposalLevelOpinions => Set<ProposalLevelOpinion>();
public DbSet<ProposalCodeSequence> ProposalCodeSequences => Set<ProposalCodeSequence>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);

View File

@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 38 G-O3 (S37) — Attachment cho Đề xuất.
// FK Cascade Proposal (wipe khi xoá Đề xuất).
public class ProposalAttachmentConfiguration : IEntityTypeConfiguration<ProposalAttachment>
{
public void Configure(EntityTypeBuilder<ProposalAttachment> e)
{
e.ToTable("ProposalAttachments");
e.Property(x => x.FileName).HasMaxLength(500).IsRequired();
e.Property(x => x.FilePath).HasMaxLength(1000).IsRequired();
e.Property(x => x.MimeType).HasMaxLength(200);
e.Property(x => x.UploadedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.Proposal)
.WithMany(p => p.Attachments)
.HasForeignKey(x => x.ProposalId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.ProposalId);
}
}

View File

@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 38 G-O3 (S37) — Sequence generator cho mã Đề xuất.
// Mirror PurchaseEvaluationCodeSequenceConfiguration pattern.
// PK = Prefix (string) — "DX/2026". LastSeq tăng atomic.
public class ProposalCodeSequenceConfiguration : IEntityTypeConfiguration<ProposalCodeSequence>
{
public void Configure(EntityTypeBuilder<ProposalCodeSequence> e)
{
e.ToTable("ProposalCodeSequences");
e.HasKey(x => x.Prefix);
e.Property(x => x.Prefix).HasMaxLength(20); // "DX/2026" max 7 chars OK
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 38 G-O3 (S37) — Đề xuất aggregate root.
// Cookie-cutter mirror PurchaseEvaluationConfiguration pattern.
public class ProposalConfiguration : IEntityTypeConfiguration<Proposal>
{
public void Configure(EntityTypeBuilder<Proposal> e)
{
e.ToTable("Proposals");
e.Property(x => x.MaDeXuat).HasMaxLength(50); // "DX/2026/001"
e.Property(x => x.Title).HasMaxLength(300).IsRequired();
e.Property(x => x.Description).HasMaxLength(5000);
e.Property(x => x.AmountEstimate).HasColumnType("decimal(18,2)");
e.Property(x => x.Status).HasConversion<int>();
e.Property(x => x.RejectedFromStatus).HasConversion<int>();
// MaDeXuat optional UNIQUE (gen sau Submit lần đầu)
e.HasIndex(x => x.MaDeXuat).IsUnique().HasFilter("[MaDeXuat] IS NOT NULL");
// Index Status + Drafter cho list filter/inbox
e.HasIndex(x => x.Status);
e.HasIndex(x => x.DrafterUserId);
e.HasIndex(x => x.DepartmentId);
e.HasIndex(x => x.ApprovalWorkflowId);
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 38 G-O3 (S37) — Ý kiến cấp duyệt V2 dynamic. UPSERT auto từ ApproveV2Async.
// Cookie-cutter mirror PurchaseEvaluationLevelOpinionConfiguration (Mig 26).
public class ProposalLevelOpinionConfiguration : IEntityTypeConfiguration<ProposalLevelOpinion>
{
public void Configure(EntityTypeBuilder<ProposalLevelOpinion> e)
{
e.ToTable("ProposalLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.Proposal)
.WithMany(p => p.LevelOpinions)
.HasForeignKey(x => x.ProposalId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ProposalId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -132,6 +132,11 @@ public static class DbInitializer
// Idempotent: skip nếu workflow QT-HD-V2-001 đã tồn tại.
await SeedSampleContractWorkflowV2Async(db, userManager, logger);
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28). Infrastructure seed sample workflow
// ApplicableType=ProposalGeneral=4 cho Drafter Workspace dropdown.
// NOT gated DemoSeed per gotcha #51 INFRASTRUCTURE seed.
await SeedSampleProposalWorkflowV2Async(db, userManager, logger);
await WarnDefaultAdminPasswordAsync(userManager, logger);
}
@ -239,6 +244,56 @@ public static class DbInitializer
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Contract: QT-HD-V2-001 v01");
}
// Phase 10.3 G-O3 (Mig 38 — S37) — Sample workflow Proposal cho UAT.
// Mirror SeedSampleContractWorkflowV2Async pattern. INFRASTRUCTURE seed NOT gated.
private static async Task SeedSampleProposalWorkflowV2Async(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
var hasAnyProposal = await db.ApprovalWorkflows
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.ProposalGeneral);
if (hasAnyProposal) return;
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
if (approver is null)
{
logger.LogWarning("SeedSampleProposalWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
return;
}
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
var wf = new ApprovalWorkflow
{
Code = "QT-DX-V2-001",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.ProposalGeneral,
Name = "Quy trình duyệt Đề xuất mẫu V2",
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
IsActive = true,
IsUserSelectable = true,
ActivatedAt = DateTime.UtcNow,
};
var step = new ApprovalWorkflowStep
{
ApprovalWorkflow = wf,
Order = 1,
Name = "Bước 1 - Phòng CCM",
DepartmentId = ccmDept?.Id,
};
var level = new ApprovalWorkflowLevel
{
Step = step,
Order = 1,
Name = "Cấp 1",
ApproverUserId = approver.Id,
};
wf.Steps.Add(step);
step.Levels.Add(level);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync();
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Proposal: QT-DX-V2-001 v01");
}
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
@ -1507,6 +1562,11 @@ public static class DbInitializer
(MenuKeys.OffPhongHopView, "Xem lịch", MenuKeys.OffPhongHop, 1, "CalendarDays"),
(MenuKeys.OffPhongHopManage, "Quản lý phòng", MenuKeys.OffPhongHop, 2, "Building2"),
(MenuKeys.OffPhongHopBook, "Đặt phòng", MenuKeys.OffPhongHop, 3, "CalendarPlus"),
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28). Sub-group "Đề xuất" + 3 leaf.
(MenuKeys.OffDeXuat, "Đề xuất", MenuKeys.Off, 3, "FileSignature"),
(MenuKeys.OffDeXuatList, "Danh sách", MenuKeys.OffDeXuat, 1, "List"),
(MenuKeys.OffDeXuatCreate, "Tạo mới", MenuKeys.OffDeXuat, 2, "Plus"),
(MenuKeys.OffDeXuatInbox, "Inbox duyệt", MenuKeys.OffDeXuat, 3, "Inbox"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class ExtendApplicableTypeForWorkflowApps : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@ -0,0 +1,184 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddProposals : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ProposalCodeSequences",
columns: table => new
{
Prefix = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
LastSeq = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProposalCodeSequences", x => x.Prefix);
});
migrationBuilder.CreateTable(
name: "Proposals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaDeXuat = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
Title = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", maxLength: 5000, nullable: true),
AmountEstimate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DrafterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
SlaDeadline = table.Column<DateTime>(type: "datetime2", nullable: true),
SlaWarningSent = table.Column<bool>(type: "bit", nullable: false),
RejectedFromStatus = table.Column<int>(type: "int", 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),
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_Proposals", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ProposalAttachments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProposalId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FileName = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
FilePath = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
FileSize = table.Column<long>(type: "bigint", nullable: false),
MimeType = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
UploadedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UploadedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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_ProposalAttachments", x => x.Id);
table.ForeignKey(
name: "FK_ProposalAttachments_Proposals_ProposalId",
column: x => x.ProposalId,
principalTable: "Proposals",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProposalLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProposalId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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_ProposalLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_ProposalLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ProposalLevelOpinions_Proposals_ProposalId",
column: x => x.ProposalId,
principalTable: "Proposals",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ProposalAttachments_ProposalId",
table: "ProposalAttachments",
column: "ProposalId");
migrationBuilder.CreateIndex(
name: "IX_ProposalLevelOpinions_ApprovalWorkflowLevelId",
table: "ProposalLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_ProposalLevelOpinions_ProposalId_ApprovalWorkflowLevelId",
table: "ProposalLevelOpinions",
columns: new[] { "ProposalId", "ApprovalWorkflowLevelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Proposals_ApprovalWorkflowId",
table: "Proposals",
column: "ApprovalWorkflowId");
migrationBuilder.CreateIndex(
name: "IX_Proposals_DepartmentId",
table: "Proposals",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_Proposals_DrafterUserId",
table: "Proposals",
column: "DrafterUserId");
migrationBuilder.CreateIndex(
name: "IX_Proposals_MaDeXuat",
table: "Proposals",
column: "MaDeXuat",
unique: true,
filter: "[MaDeXuat] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Proposals_Status",
table: "Proposals",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ProposalAttachments");
migrationBuilder.DropTable(
name: "ProposalCodeSequences");
migrationBuilder.DropTable(
name: "ProposalLevelOpinions");
migrationBuilder.DropTable(
name: "Proposals");
}
}
}

View File

@ -3623,6 +3623,227 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("MeetingRooms", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("AmountEstimate")
.HasColumnType("decimal(18,2)");
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentApprovalLevelOrder")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(5000)
.HasColumnType("nvarchar(max)");
b.Property<Guid>("DrafterUserId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaDeXuat")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
b.Property<bool>("SlaWarningSent")
.HasColumnType("bit");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowId");
b.HasIndex("DepartmentId");
b.HasIndex("DrafterUserId");
b.HasIndex("MaDeXuat")
.IsUnique()
.HasFilter("[MaDeXuat] IS NOT NULL");
b.HasIndex("Status");
b.ToTable("Proposals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
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<string>("FileName")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MimeType")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("ProposalId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("UploadedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("UploadedByUserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.ToTable("ProposalAttachments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalCodeSequence", b =>
{
b.Property<string>("Prefix")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int>("LastSeq")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Prefix");
b.ToTable("ProposalCodeSequences", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalLevelOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
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<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid>("ProposalId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("SignedAt")
.HasColumnType("datetime2");
b.Property<string>("SignedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("SignedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowLevelId");
b.HasIndex("ProposalId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("ProposalLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Property<Guid>("Id")
@ -4887,6 +5108,36 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Booking");
});
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b =>
{
b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal")
.WithMany("Attachments")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
});
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal")
.WithMany("LevelOpinions")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
b.Navigation("Proposal");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
@ -5126,6 +5377,13 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Attendees");
});
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
{
b.Navigation("Attachments");
b.Navigation("LevelOpinions");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Navigation("Approvals");