[CLAUDE] Contract: K1 SP-002 danh muc loai HD - ContractCatalogEntries (Mig 70) + seed 86 + CQRS + FE kind-5 x2 app + 8 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m31s

Gate: reviewer PASS-WITH-FLAGS 9f (sub-reviewer-gate-k1.md). Xu ly: F4
IncludeInactive mac-dinh-loai (spec K1.c) + test T5b; F7 comment 5-kind;
F1 OG-6 doi nhan soat-SAU @PAUSE-2 (seeder skip-if-exists giu sua-tay CRUD
qua restart - update-branch co y KHONG co); F3 authz mirror khuon Catalogs,
gioi-han khai: 4 policy ContractCatalog.* sinh ra nhung chua endpoint nao
tieu thu (display-layer khac API-layer, #82); F6 ten index IX_ giu EF-default
(NIT). Suite 598/0 (45D+553I). Mig 70 ap SQL Server that OK. Drift DELTA:
MenuKeys.All 55->56, policies 220->224, bang 96->97.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 15:45:50 +07:00
parent c0a96e54eb
commit 50e6d8c382
20 changed files with 8566 additions and 80 deletions

View File

@ -0,0 +1,62 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Master.Catalogs;
namespace SolutionErp.Api.Controllers;
// [K1 — S164, Mig 70] API "Danh mục Hợp đồng" (SOL-CCM-SP-002, 86 dòng × 8 nhóm duyệt).
//
// Route nằm DƯỚI `api/catalogs/` để đồng bộ với 4 catalog cũ (FE dùng chung trang
// KIND_CONFIG theo `:kind` → kind mới = `contract-catalog`), nhưng tách CONTROLLER
// riêng cho gọn (CatalogsController đã 4 kind × 4 verb).
//
// AUTHZ (QUYẾT-1 S164 — mirror y CatalogsController.cs:12-14, KHÔNG policy key mới):
// - class `[Authorize]` = mọi user đăng nhập ĐỌC được → picker chọn hạng mục ở
// màn tạo phiếu KHKK (K2) không bị 403 vì thiếu grant leaf (gotcha #44/#85).
// - mọi action GHI `[Authorize(Roles = "Admin")]` — sửa danh mục là việc quản trị,
// KHÔNG phải quyền của drafter phiếu.
[ApiController]
[Route("api/catalogs/contract-catalog")]
[Authorize]
public class ContractCatalogController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<List<ContractCatalogEntryDto>> List(
[FromQuery] string? groupCode,
[FromQuery] int? approvalGroup,
[FromQuery] bool includeInactive,
[FromQuery] string? q,
CancellationToken ct)
=> await mediator.Send(new ListContractCatalogQuery(groupCode, approvalGroup, includeInactive, q), ct);
[HttpGet("{id:guid}")]
public async Task<ContractCatalogEntryDto> Get(Guid id, CancellationToken ct)
=> await mediator.Send(new GetContractCatalogEntryQuery(id), ct);
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Create([FromBody] CreateContractCatalogEntryCommand body, CancellationToken ct)
{
var id = await mediator.Send(body, ct);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateContractCatalogEntryCommand body, CancellationToken ct)
{
if (id != body.Id) return BadRequest("Id mismatch");
await mediator.Send(body, ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteContractCatalogEntryCommand(id), ct);
return NoContent();
}
}

View File

@ -24,6 +24,8 @@ public interface IApplicationDbContext
DbSet<MaterialItem> MaterialItems { get; }
DbSet<ServiceItem> ServiceItems { get; }
DbSet<WorkItem> WorkItems { get; }
// [K1 S164 — Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt)
DbSet<ContractCatalogEntry> ContractCatalogEntries { get; }
DbSet<MenuItem> MenuItems { get; }
DbSet<Permission> Permissions { get; }
DbSet<User> Users { get; }

View File

@ -0,0 +1,226 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Master.Catalogs;
namespace SolutionErp.Application.Master.Catalogs;
// [K1 — S164, Mig 70] CRUD CQRS cho "Danh mục Hợp đồng" (SOL-CCM-SP-002, 86 dòng).
// File RIÊNG cạnh CatalogsFeatures.cs (mega-file 4 catalog cũ) vì shape khác hẳn:
// có GroupCode + ApprovalGroup (1-8 = nhóm duyệt N1..N8) + 2 cột vai-ký dạng TEXT.
//
// Endpoints (ContractCatalogController, route `api/catalogs/contract-catalog`):
// GET /api/catalogs/contract-catalog — list (filter groupCode/approvalGroup/q;
// mặc định CHỈ trả IsActive=true — spec K1.c `IncludeInactive?` mặc-định-loại;
// truyền includeInactive=true để thấy cả dòng ngừng hiệu lực — gate-K1 F4 @S165)
// GET /api/catalogs/contract-catalog/{id} — get by id
// POST /api/catalogs/contract-catalog — create (Admin)
// PUT /api/catalogs/contract-catalog/{id} — update (Admin)
// DELETE /api/catalogs/contract-catalog/{id} — soft delete (Admin)
//
// KHÔNG paging: 86 dòng, dùng chung cho trang quản trị + picker chọn hạng mục (K2).
// Dup-Code check chạy trên ACTIVE-set (query filter !IsDeleted) — khớp UNIQUE index
// filtered `[IsDeleted] = 0` ⇒ Code của row đã xoá mềm được tái sử dụng (gotcha #57).
// ===== DTO =====
public record ContractCatalogEntryDto(
Guid Id,
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder);
// ===== List =====
public record ListContractCatalogQuery(
string? GroupCode = null,
int? ApprovalGroup = null,
bool IncludeInactive = false,
string? Q = null) : IRequest<List<ContractCatalogEntryDto>>;
public class ListContractCatalogHandler(IApplicationDbContext db)
: IRequestHandler<ListContractCatalogQuery, List<ContractCatalogEntryDto>>
{
public async Task<List<ContractCatalogEntryDto>> Handle(ListContractCatalogQuery req, CancellationToken ct)
{
var q = db.ContractCatalogEntries.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.GroupCode))
q = q.Where(x => x.GroupCode == req.GroupCode);
if (req.ApprovalGroup is int g)
q = q.Where(x => x.ApprovalGroup == g);
if (!req.IncludeInactive)
q = q.Where(x => x.IsActive);
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s)
|| x.TenVi.ToLower().Contains(s)
|| (x.TenEn != null && x.TenEn.ToLower().Contains(s)));
}
return await q.OrderBy(x => x.GroupCode).ThenBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new ContractCatalogEntryDto(
x.Id, x.Code, x.TenVi, x.TenEn, x.GroupCode, x.ApprovalGroup,
x.SignerRole, x.InitialsChain, x.NduqNote, x.IsActive, x.SortOrder))
.ToListAsync(ct);
}
}
// ===== Get by id =====
public record GetContractCatalogEntryQuery(Guid Id) : IRequest<ContractCatalogEntryDto>;
public class GetContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<GetContractCatalogEntryQuery, ContractCatalogEntryDto>
{
public async Task<ContractCatalogEntryDto> Handle(GetContractCatalogEntryQuery req, CancellationToken ct)
=> await db.ContractCatalogEntries.AsNoTracking()
.Where(x => x.Id == req.Id)
.Select(x => new ContractCatalogEntryDto(
x.Id, x.Code, x.TenVi, x.TenEn, x.GroupCode, x.ApprovalGroup,
x.SignerRole, x.InitialsChain, x.NduqNote, x.IsActive, x.SortOrder))
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
}
// ===== Create =====
public record CreateContractCatalogEntryCommand(
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder) : IRequest<Guid>;
// MaxLength khớp ĐÚNG EF config (ContractCatalogEntryConfiguration.cs:18-24) —
// EF là source of truth, KHÔNG lấy số từ spec.
public class CreateContractCatalogEntryValidator : AbstractValidator<CreateContractCatalogEntryCommand>
{
public CreateContractCatalogEntryValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.TenVi).NotEmpty().MaximumLength(500);
RuleFor(x => x.TenEn).MaximumLength(500);
RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(10);
RuleFor(x => x.ApprovalGroup).InclusiveBetween(1, 8)
.WithMessage("Nhóm duyệt phải từ 1 đến 8 (N1..N8).");
RuleFor(x => x.SignerRole).NotEmpty().MaximumLength(100);
RuleFor(x => x.InitialsChain).MaximumLength(300);
RuleFor(x => x.NduqNote).MaximumLength(500);
}
}
public class CreateContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<CreateContractCatalogEntryCommand, Guid>
{
public async Task<Guid> Handle(CreateContractCatalogEntryCommand req, CancellationToken ct)
{
if (await db.ContractCatalogEntries.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
var entity = new ContractCatalogEntry
{
Code = req.Code,
TenVi = req.TenVi,
TenEn = req.TenEn,
GroupCode = req.GroupCode,
ApprovalGroup = req.ApprovalGroup,
SignerRole = req.SignerRole,
InitialsChain = req.InitialsChain,
NduqNote = req.NduqNote,
IsActive = req.IsActive,
SortOrder = req.SortOrder,
};
db.ContractCatalogEntries.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ===== Update =====
public record UpdateContractCatalogEntryCommand(
Guid Id,
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder) : IRequest;
public class UpdateContractCatalogEntryValidator : AbstractValidator<UpdateContractCatalogEntryCommand>
{
public UpdateContractCatalogEntryValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.TenVi).NotEmpty().MaximumLength(500);
RuleFor(x => x.TenEn).MaximumLength(500);
RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(10);
RuleFor(x => x.ApprovalGroup).InclusiveBetween(1, 8)
.WithMessage("Nhóm duyệt phải từ 1 đến 8 (N1..N8).");
RuleFor(x => x.SignerRole).NotEmpty().MaximumLength(100);
RuleFor(x => x.InitialsChain).MaximumLength(300);
RuleFor(x => x.NduqNote).MaximumLength(500);
}
}
public class UpdateContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<UpdateContractCatalogEntryCommand>
{
public async Task Handle(UpdateContractCatalogEntryCommand req, CancellationToken ct)
{
var entity = await db.ContractCatalogEntries.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
if (entity.Code != req.Code && await db.ContractCatalogEntries.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.TenVi = req.TenVi;
entity.TenEn = req.TenEn;
entity.GroupCode = req.GroupCode;
entity.ApprovalGroup = req.ApprovalGroup;
entity.SignerRole = req.SignerRole;
entity.InitialsChain = req.InitialsChain;
entity.NduqNote = req.NduqNote;
entity.IsActive = req.IsActive;
entity.SortOrder = req.SortOrder;
await db.SaveChangesAsync(ct);
}
}
// ===== Delete (soft) =====
public record DeleteContractCatalogEntryCommand(Guid Id) : IRequest;
public class DeleteContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<DeleteContractCatalogEntryCommand>
{
public async Task Handle(DeleteContractCatalogEntryCommand req, CancellationToken ct)
{
var entity = await db.ContractCatalogEntries.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
db.ContractCatalogEntries.Remove(entity); // Soft delete via AuditingInterceptor
await db.SaveChangesAsync(ct);
}
}

View File

@ -25,6 +25,10 @@ public static class MenuKeys
public const string CatalogMaterials = "CatalogMaterials";
public const string CatalogServices = "CatalogServices";
public const string CatalogWorkItems = "CatalogWorkItems";
// [K1 — S164, Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt).
// 🔴 VÀO `All` (lệnh lead S164) ⇒ sinh 4 policy ContractCatalog.{Read,Create,Update,Delete}
// ⇒ 2 số canonical `docs/STATUS.md` đổi: Menu keys +1 · Policies +4 (DERIVED |All|×|Actions|).
public const string ContractCatalog = "ContractCatalog";
// Per-contract-type menu groups + 3 action leaves each.
// Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from
@ -166,6 +170,7 @@ public static class MenuKeys
Dashboard,
Master, Suppliers, Projects, Departments,
Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems,
ContractCatalog, // [K1 Mig 70 — S164] Danh mục Hợp đng SP-002
Contracts, Forms, Reports,
PurchaseEvaluations,
Hrm, HrmHoSo, // Mig 34 — Phase 10.1

View File

@ -0,0 +1,25 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Master.Catalogs;
// [K1 — S164, Mig 70] Master data "Danh mục Hợp đồng" theo quy trình SOL-CCM-SP-002.v01
// (bảng 5.1 A1..C3 + mục 5.2 CEO = 86 dòng). Mỗi dòng = 1 loại hạng mục hợp đồng,
// gắn 1 trong 8 NHÓM DUYỆT (N1..N8) quyết định workflow KHKK nào áp cho phiếu.
//
// Vai ký lưu dạng TEXT (SignerRole + InitialsChain) — 2 TẦNG cố ý (QĐ-4 spec S164):
// TEXT ở đây = chuỗi in trên khối ký giai đoạn 4 (bản cứng)
// ⟂ ApprovalWorkflowLevel.ApproverUserId = NGƯỜI THẬT duyệt trên hệ thống.
// Limitation đã khai: TEXT KHÔNG ràng buộc máy với người-duyệt-thật.
public class ContractCatalogEntry : AuditableEntity
{
public string Code { get; set; } = ""; // Mã dòng danh mục ("A1-01" … "CEO-01") — UNIQUE filtered [IsDeleted]=0
public string TenVi { get; set; } = ""; // Tên tiếng Việt
public string? TenEn { get; set; } // Tên tiếng Anh
public string GroupCode { get; set; } = ""; // Section SP-002: A1..A4, B1..B3, C1..C3, CEO
public int ApprovalGroup { get; set; } // 1..8 → nhóm duyệt N1..N8 (validator range, không CHECK constraint)
public string SignerRole { get; set; } = ""; // Vai KÝ CHÍNH dạng TEXT (TP.CCM / TP.PRO / PD-PM / CEO)
public string? InitialsChain { get; set; } // Chuỗi ký nháy Ô1→Ô4 ("TBP.CCM → NV.CCM → TBP.EQU → NV.EQU"), bỏ N/A
public string? NduqNote { get; set; } // Tham khảo Nghị định ủy quyền (OG-3) — seed null, nhập tay sau
public bool IsActive { get; set; } = true; // Ẩn khỏi picker mà không cần xoá
public int SortOrder { get; set; } // Thứ tự trong section (= STT trên bảng gốc)
}

View File

@ -28,6 +28,8 @@ public class ApplicationDbContext
public DbSet<MaterialItem> MaterialItems => Set<MaterialItem>();
public DbSet<ServiceItem> ServiceItems => Set<ServiceItem>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
// [K1 S164 — Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt)
public DbSet<ContractCatalogEntry> ContractCatalogEntries => Set<ContractCatalogEntry>();
public DbSet<MenuItem> MenuItems => Set<MenuItem>();
public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<ContractTemplate> ContractTemplates => Set<ContractTemplate>();

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Master.Catalogs;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [K1 — S164, Mig 70] EF config cho danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng).
// File RIÊNG (không nhét vào CatalogsConfiguration.cs) — mirror khuôn PE/KHKK
// 1-entity-1-file cho entity mới. Pattern: Code unique FILTERED [IsDeleted]=0
// (gotcha #57 — soft-delete phải filter) + query filter soft-delete + index
// ApprovalGroup cho picker K2 lọc theo nhóm phiếu.
public class ContractCatalogEntryConfiguration : IEntityTypeConfiguration<ContractCatalogEntry>
{
public void Configure(EntityTypeBuilder<ContractCatalogEntry> b)
{
b.ToTable("ContractCatalogEntries");
b.HasKey(x => x.Id);
b.Property(x => x.Code).HasMaxLength(20).IsRequired();
b.Property(x => x.TenVi).HasMaxLength(500).IsRequired();
b.Property(x => x.TenEn).HasMaxLength(500);
b.Property(x => x.GroupCode).HasMaxLength(10).IsRequired();
b.Property(x => x.SignerRole).HasMaxLength(100).IsRequired();
b.Property(x => x.InitialsChain).HasMaxLength(300);
b.Property(x => x.NduqNote).HasMaxLength(500);
b.Property(x => x.IsActive).HasDefaultValue(true);
b.Property(x => x.SortOrder).HasDefaultValue(0);
b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0");
b.HasIndex(x => x.ApprovalGroup);
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -126,6 +126,9 @@ public static class DbInitializer
// S55 (2026-06-09) — Master data thật của công ty (62 dự án + 71 hạng mục +
// 3 NCC). NOT gated DemoSeed — coexist với demo data qua per-code idempotent.
await SeedRealMasterDataAsync(db, logger);
// [K1 — S164, Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng, 8 nhóm duyệt).
// NOT gated DemoSeed — master data thật, prod cần ngay deploy đầu (gotcha #51).
await SeedContractCatalogAsync(db, logger);
// Backfill mã HĐ cho HĐ legacy chưa có (sau khi đổi policy gen-tại-create).
// Idempotent: chỉ HĐ MaHopDong IS NULL được gen.
@ -1767,6 +1770,10 @@ public static class DbInitializer
(MenuKeys.CatalogMaterials,"Vật tư / SP", MenuKeys.Catalogs, 2, "Package"),
(MenuKeys.CatalogServices, "Dịch vụ", MenuKeys.Catalogs, 3, "Wrench"),
(MenuKeys.CatalogWorkItems,"Hạng mục công việc", MenuKeys.Catalogs, 4, "ListChecks"),
// [K1 Mig 70 — S164] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt).
// Leaf thứ 5 dưới nhóm "Danh mục chi tiết". Key CÓ trong MenuKeys.All ⇒ Admin
// được grant tự động qua loop SeedAdminPermissionsAsync :2058.
(MenuKeys.ContractCatalog, "Danh mục Hợp đồng", MenuKeys.Catalogs, 5, "FileSpreadsheet"),
// [S57] Order 30→31: nhường slot 30 cho nhóm "Cá nhân" (đứng ngay sau Văn phòng số = 29, mirror Puro).
(MenuKeys.Contracts, "Duyệt hợp đồng", null, 31, "FileText"), // [S159] rename theo 4-GĐ (owner tab strip)
// [S159] 2 root PLACEHOLDER GĐ2/GĐ4 — menu = 4 giai đoạn (owner). FE staticMap trỏ
@ -3137,6 +3144,185 @@ public static class DbInitializer
await db.SaveChangesAsync();
}
// [K1 — S164, Mig 70] Danh mục Hợp đồng theo quy trình SOL-CCM-SP-002.v01
// (bảng 5.1 mục A1..C3 = 85 dòng + mục 5.2 CEO 1 dòng = 86 row). Mỗi dòng gắn 1
// trong 8 NHÓM DUYỆT N1..N8 — nhóm quyết định workflow KHKK nào áp cho phiếu (K2/K3).
//
// NOT gated DemoSeed:Disabled — đây là INFRASTRUCTURE master data thật (mirror
// SeedRealMasterDataAsync, xem note :2893 gotcha #51), phải có mặt prod ngay deploy đầu.
//
// Idempotent PER-CODE + `IgnoreQueryFilters()` — né #75/#76 (resurrect): row đã
// xoá-mềm VẪN tính là "đã tồn tại" ⇒ KHÔNG re-create mỗi restart. Cố ý LÀM ĐÚNG
// HƠN khuôn :2970 (khuôn dò `db.Projects.Select(p => p.Code)` KHÔNG IgnoreQueryFilters
// ⇒ row soft-deleted vô hình → hồi sinh mỗi lần boot).
//
// SignerRole + InitialsChain = TEXT denorm theo nhóm (QĐ-4 spec S164: TEXT in trên
// khối ký bản cứng GĐ4 ⟂ ApprovalWorkflowLevel.ApproverUserId = người duyệt THẬT
// trên hệ thống). Lấy từ lookup 8-nhóm rồi ghi vào TỪNG row (denorm per-row) —
// chép tay cùng chuỗi 86 lần dễ lệch ký tự.
private static async Task SeedContractCatalogAsync(ApplicationDbContext db, ILogger logger)
{
// 8 nhóm duyệt — (Ký chính, Ký nháy Ô1→Ô4). Ô "N/A" trong bảng gốc đã BỎ.
var signByGroup = new Dictionary<int, (string SignerRole, string InitialsChain)>
{
[1] = ("TP.CCM", "TBP.CCM → NV.CCM → TBP.EQU → NV.EQU"),
[2] = ("TP.CCM", "TBP.CCM → PD/PM → SM → QS"),
[3] = ("TP.CCM", "TBP.CCM → TPB/TBP → TBP/NV.PB"),
[4] = ("TP.PRO", "TBP.PRO → NV.PRO"),
[5] = ("TP.PRO", "TP.CCM → TBP.PRO → PD/PM/SM → QS"),
[6] = ("TP.PRO", "TP.CCM → TBP.PRO → PD/PM/SM → MEP SM"),
[7] = ("PD/PM", "TP.CCM → SM → QS"),
[8] = ("CEO", "TP.CCM → TP.PRO"),
};
// (Code, TenVi, TenEn, GroupCode, ApprovalGroup 1-8, SortOrder = STT trong section)
// Nguồn: transcribe 7 ảnh SOL-CCM-SP-002.v01 trang 5-9 (OG-6 owner soát).
var entries = new (string Code, string TenVi, string TenEn, string GroupCode, int ApprovalGroup, int SortOrder)[]
{
// ----- A1 · Thiết bị, máy móc, vật tư phụ (5) → N1 -----
("A1-01", "Thuê thiết bị/máy móc/dụng cụ thi công/thiết bị an toàn lao động/thiết bị văn phòng (máy photo, máy in)", "Rental of construction equipment, machinery, and tools; occupational safety equipment; and office equipment (photocopiers, printers)", "A1", 1, 1),
("A1-02", "Thuê container văn phòng, văn phòng BCH CT, nhà vệ sinh, nhà bảo vệ", "Rental of office containers, site offices, toilets, and guardhouses", "A1", 1, 2),
("A1-03", "Cung cấp vật tư phụ phục vụ công tác tạm/tiện ích công trường", "Supply of materials for temporary works and site utilities", "A1", 1, 3),
("A1-04", "Cung cấp vật tư phụ thi công: nylon bảo dưỡng, Sika, khoan cấy, …", "Supply of auxiliary construction materials: curing nylon sheets, Sika products, chemical anchors, etc", "A1", 1, 4),
("A1-05", "Cung cấp vật tư biện pháp: sika, lưới mắt cáo, thanh trương nở, …", "Supply of method-related materials: Sika products, wire mesh, expansion bars, etc", "A1", 1, 5),
// ----- A2 · Kết cấu, hạ tầng, phá dỡ (1) → N2 -----
("A2-01", "Thầu phụ hạ tầng & phá dỡ", "Infrastructure and demolition subcontracting works", "A2", 2, 1),
// ----- A3 · Dịch vụ hỗ trợ thi công (9) → N2 -----
("A3-01", "Dịch vụ ca cẩu", "Crane rental services", "A3", 2, 1),
("A3-02", "Dịch vụ vận chuyển", "Transportation services", "A3", 2, 2),
("A3-03", "Trắc đạc", "Surveying services", "A3", 2, 3),
("A3-04", "Thí nghiệm", "Testing services", "A3", 2, 4),
("A3-05", "Bảo vệ", "Security services", "A3", 2, 5),
("A3-06", "Dịch vụ giấy phép, tư vấn, thiết kế (kể cả dịch vụ tư vấn MEP)", "Licensing, consultancy, and design services (including MEP consultancy services)", "A3", 2, 6),
("A3-07", "Điện tạm", "Temporary power supply", "A3", 2, 7),
("A3-08", "Cấp và thoát nước tạm", "Temporary water supply and drainage", "A3", 2, 8),
("A3-09", "Cung cấp tiện ích công trường: tủ hồ sơ, bàn ghế, internet, văn phòng phẩm, thiết bị bảo hộ lao động (PPE), nước uống, dịch vụ y tế, bảng biểu an toàn, …", "Supply of site utilities: filing cabinets, desks and chairs, internet, stationery, personal protective equipment (PPE), drinking water, medical services, safety signage, etc.", "A3", 2, 9),
// ----- A4 · Hoạt động văn phòng (5) → N3 -----
("A4-01", "Thuê văn phòng", "Office leasing", "A4", 3, 1),
("A4-02", "Văn phòng phẩm", "Stationery", "A4", 3, 2),
("A4-03", "Bảo hiểm cháy nổ văn phòng", "Office fire and explosion insurance", "A4", 3, 3),
("A4-04", "Thiết bị máy tính, phần mềm", "Computer equipment and software", "A4", 3, 4),
("A4-05", "Các hợp đồng khác liên quan đến hoạt động văn phòng", "Other contracts related to office operations", "A4", 3, 5),
// ----- B1 · Vật tư (23) → N4 -----
("B1-01", "Đá", "Stone", "B1", 4, 1),
("B1-02", "Cát", "Sand", "B1", 4, 2),
("B1-03", "Cừ tràm", "Mangrove piles", "B1", 4, 3),
("B1-04", "Bê tông", "Concrete", "B1", 4, 4),
("B1-05", "Xi măng", "Cement", "B1", 4, 5),
("B1-06", "Thép", "Steel", "B1", 4, 6),
("B1-07", "Ván phủ phim", "Film-faced plywood", "B1", 4, 7),
("B1-08", "Gạch ốp lát", "Floor and wall tiles", "B1", 4, 8),
("B1-09", "Gạch xây", "Bricks", "B1", 4, 9),
("B1-10", "Nylon", "Nylon sheets", "B1", 4, 10),
("B1-11", "Vữa grouting", "Grouting", "B1", 4, 11),
("B1-12", "Chất tăng cứng bề mặt", "Hardener", "B1", 4, 12),
("B1-13", "Sơn nước", "Emulsion paint", "B1", 4, 13),
("B1-14", "Sơn dầu", "Oil-based paint", "B1", 4, 14),
("B1-15", "Bột matic", "Matic", "B1", 4, 15),
("B1-16", "Vật liệu hoàn thiện sàn", "Floor finishing materials", "B1", 4, 16),
("B1-17", "Thanh cản nước", "Waterbar", "B1", 4, 17),
("B1-18", "Vật liệu chống thấm", "Waterproofing", "B1", 4, 18),
("B1-19", "Đá granite", "Granite stone", "B1", 4, 19),
("B1-20", "Thiết bị vệ sinh", "Sanitary equipment", "B1", 4, 20),
("B1-21", "Vật liệu hoàn thiện", "Finishing materials", "B1", 4, 21),
("B1-22", "Vật tư, thiết bị MEP (cáp điện, tủ điện, máy móc của hệ MEP)", "MEP materials and equipment (electrical cables, panels, machinery for MEP systems)", "B1", 4, 22),
("B1-23", "Vật tư chính khác", "Other main materials", "B1", 4, 23),
// ----- B2 · Thầu phụ xây dựng (14) → N5 -----
// ⚠️ Ảnh gốc đánh số 1-13 nhưng TRÙNG số "7" ×2 (kim loại + cảnh quan);
// lead transcribe đánh lại 01-14 ⇒ B2 = 14 dòng (KHÔNG phải 13). Giữ AS-IS.
("B2-01", "Ép cọc, cừ Larsen", "Pile works and sheet pile", "B2", 5, 1),
("B2-02", "Trần thạch cao", "Gypsum ceiling works", "B2", 5, 2),
("B2-03", "Đá granite", "Granite", "B2", 5, 3),
("B2-04", "Kết cấu thép", "Steel structure", "B2", 5, 4),
("B2-05", "Cửa đi, cửa sổ", "Doors and windows", "B2", 5, 5),
("B2-06", "Cửa chống cháy, thoát hiểm", "Fire-rated and emergency exit doors", "B2", 5, 6),
("B2-07", "Công tác kim loại", "Metal", "B2", 5, 7), // ảnh: số 7 thứ nhất
("B2-08", "Cảnh quan", "Landscaping", "B2", 5, 8), // ảnh: số 7 thứ hai ⚠️
("B2-09", "Công tác kính", "Glazing", "B2", 5, 9), // ảnh: số 8
("B2-10", "Rèm cửa", "Curtain", "B2", 5, 10), // ảnh: 9
("B2-11", "Nội thất", "Interior", "B2", 5, 11), // ảnh: 10
("B2-12", "Chống thấm", "Waterproofing", "B2", 5, 12), // ảnh: 11
("B2-13", "Hoàn thiện sàn nâng", "Raised floor finishing", "B2", 5, 13),// ảnh: 12
("B2-14", "Thầu phụ xây dựng khác", "Other civil subcontractors", "B2", 5, 14), // ảnh: 13
// ----- B3 · Thầu phụ MEP (15) → N6 -----
("B3-01", "Hệ thống điện trung thế", "Medium-voltage power systems", "B3", 6, 1),
("B3-02", "Máy phát điện", "Generator systems", "B3", 6, 2),
("B3-03", "Hệ thống điện hạ thế và chiếu sáng", "Low-voltage power and lighting systems", "B3", 6, 3),
("B3-04", "Hệ thống điện nhẹ ELV", "Extra Low Voltage (ELV) systems", "B3", 6, 4),
("B3-05", "Hệ thống lạnh (Máy lạnh, Chiller, Cooling Tower, Process cooling...)", "Cooling systems (air conditioning, chillers, cooling towers, process cooling, etc.)", "B3", 6, 5),
("B3-06", "Hệ thống thông gió", "Ventilation systems", "B3", 6, 6),
("B3-07", "Hệ thống cấp thoát nước", "Water supply and drainage systems", "B3", 6, 7),
("B3-08", "Hệ thống xử lý nước", "Water treatment systems", "B3", 6, 8),
("B3-09", "Hệ thống chữa cháy", "Firefighting systems", "B3", 6, 9),
("B3-10", "Hệ thống báo cháy", "Fire alarm systems", "B3", 6, 10),
("B3-11", "Hệ thống chống sét", "Lightning protection systems", "B3", 6, 11),
("B3-12", "Hệ thống Gas", "Gas systems", "B3", 6, 12),
("B3-13", "Hệ thống khí nén", "Compressed air systems", "B3", 6, 13),
("B3-14", "Thiết bị bếp", "Kitchen equipment systems", "B3", 6, 14),
("B3-15", "Thầu phụ MEP khác", "Other MEP subcontractors", "B3", 6, 15),
// ----- C1 · Kết cấu, công tác tạm (6) → N7 -----
("C1-01", "Nhân công công tác tạm, vệ sinh công nghiệp", "Labor for temporary works and industrial cleaning", "C1", 7, 1),
("C1-02", "Nhân công khoán đội, công nhật", "Construction teams and daily workers", "C1", 7, 2),
("C1-03", "Nhân công thầu phụ", "Subcontractor labor", "C1", 7, 3),
("C1-04", "Thầu phụ trọn gói", "Lump-sum subcontractors", "C1", 7, 4),
("C1-05", "Thầu phụ giàn giáo", "Scaffolding subcontractors", "C1", 7, 5),
("C1-06", "Cơ giới, ca máy", "Machinery and equipment shifts", "C1", 7, 6),
// ----- C2 · Xây tô, sơn nước, hoàn thiện, ốp lát (3) → N7 -----
("C2-01", "Thi công sơn nước", "Painting", "C2", 7, 1),
("C2-02", "Thi công công tác hoàn thiện (xây tô, ốp lát)", "Finishing works (masonry, plastering, tiling)", "C2", 7, 2),
("C2-03", "Thi công defect, bó vỉa, xoa nền, …", "Defect rectification works, kerb works, floor finishing/polishing, etc.", "C2", 7, 3),
// ----- C3 · Khác (4) → N7 -----
("C3-01", "Thuê nhà ở cho BCH CT", "House rental", "C3", 7, 1),
("C3-02", "Bảo hiểm tai nạn 24/24", "24/7 accident insurance", "C3", 7, 2),
("C3-03", "Ban quản lý khu công nghiệp", "Industrial Park Management Board", "C3", 7, 3),
("C3-04", "Mua bán điện ngoài mục đích sinh hoạt với Công ty Điện lực", "Electricity supply agreement for non-residential use with EVN", "C3", 7, 4),
// ----- 5.2 · CEO (1) → N8 -----
("CEO-01", "Tất cả Hợp đồng chưa Ủy quyền", "All Contracts Not Subject to Authorization", "CEO", 8, 1),
};
var existingCodes = await db.ContractCatalogEntries
.IgnoreQueryFilters() // né #75/#76 — row soft-deleted vẫn chặn re-create
.Select(x => x.Code)
.ToListAsync();
var added = 0;
foreach (var (code, tenVi, tenEn, groupCode, approvalGroup, sortOrder) in entries)
{
if (existingCodes.Contains(code)) continue;
var sign = signByGroup[approvalGroup];
db.ContractCatalogEntries.Add(new ContractCatalogEntry
{
Code = code,
TenVi = tenVi,
TenEn = tenEn,
GroupCode = groupCode,
ApprovalGroup = approvalGroup,
SignerRole = sign.SignerRole,
InitialsChain = sign.InitialsChain,
NduqNote = null, // [OG-3] ảnh KHÔNG có số NĐUQ per-dòng → nhập tay sau
IsActive = true,
SortOrder = sortOrder,
});
added++;
}
if (added > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} contract catalog entries (S164 SP-002)", added);
}
}
private static async Task SeedContractTemplatesAsync(ApplicationDbContext db, ILogger logger)
{
// Chỉ IsActive=true nếu file thực tế tồn tại trong wwwroot/templates/.

View File

@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddContractCatalogEntries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContractCatalogEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
TenVi = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
TenEn = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
GroupCode = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
ApprovalGroup = table.Column<int>(type: "int", nullable: false),
SignerRole = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
InitialsChain = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
NduqNote = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
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_ContractCatalogEntries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ContractCatalogEntries_ApprovalGroup",
table: "ContractCatalogEntries",
column: "ApprovalGroup");
migrationBuilder.CreateIndex(
name: "IX_ContractCatalogEntries_Code",
table: "ContractCatalogEntries",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContractCatalogEntries");
}
}
}

View File

@ -3287,6 +3287,89 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.ContractCatalogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ApprovalGroup")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
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>("GroupCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("InitialsChain")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("NduqNote")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("SignerRole")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("TenEn")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("TenVi")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalGroup");
b.HasIndex("Code")
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("ContractCatalogEntries", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.MaterialItem", b =>
{
b.Property<Guid>("Id")