[CLAUDE] Domain+App+Infra+Admin: W1 KHKK — Mig 69 ContractSigningPlans 7 bang + policy KeHoachKyKet + seeder UPGRADE-if-exists + CodeGen KHKK + Designer type-10
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m40s

- Domain/ContractSigningPlans: 7 entity + 3 enum (Phase 1/2/3/98/99; DossierItem Kind/Status start=1); ApplicableType += ContractSigningPlan=10 (slot 10, append-only)
- Mig 69 AddContractSigningPlans: 7 CreateTable / 20 CreateIndex / 0 ALTER; UNIQUE filtered (PlanId,SupplierId) WHERE IsDeleted=0 (#57); FK 6 con Cascade 1-hop + 2 Restrict, 0 multiple-cascade-path; database-agent review PASS 8/8 + DB11
- MenuKeys.All += KeHoachKyKet (policy KeHoachKyKet.* x4 sinh runtime; menu-row prod KHONG doi — 142 giu); STATUS canonical 54->55 / 216->220
- DbInitializer.SeedKeHoachKyKetAccessAsync: UPGRADE-if-exists (F-S1 — khong insert-NO-OP) + vế CanDelete (Drafter/Admin), 13/13 role CanCreate+CanUpdate, chay SAU revoke; revoker khong phu Khkk_* (reviewer Truc-1 PASS)
- ContractSigningPlanCodeGenerator: KHKK/{YYYY}/{Seq:D3} (owner OK @S161) qua WorkflowAppCodeSequences dung chung, SERIALIZABLE
- AWV2AdminFeatures: 3 site count/retained/orphan + nhanh ContractSigningPlanLevelOpinions (chong no FK 500 khi xoa Level); +label VN type-10 (F-7); +ep false 2 co early-finalize server-side cho type-10 (F-8 — containment tang BE, FE da ep o levelFlagsPayload)
- fe-admin Designer: deep-link type-10 + an 2 checkbox early-finalize (anh phan "PHA VO" S155) — containment 2 lop render+payload
- tests: +4 (566 total, 0 fail) — schema-invariant 7 bang + codegen format (fault-inject 2 chieu chung co rang)

Reviewer: PASS_WITH_FLAGS 10 issue -> 8 fixed cung commit, 2 giu trang thai (F-1 acceptance đo-CO cho prod; F-4 ke thua y PE prod)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 20:04:39 +07:00
parent 909134c922
commit a8027c04fe
30 changed files with 8987 additions and 22 deletions

View File

@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Hrm.Services;
using SolutionErp.Application.Master.Suppliers.Import;
@ -37,6 +38,8 @@ public static class DependencyInjection
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<IPurchaseEvaluationWorkflowService, PurchaseEvaluationWorkflowService>();
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
// [W1 Mig 69 — S161] mã phiếu KHKK/{YYYY}/{Seq:D3} qua WorkflowAppCodeSequences dùng chung.
services.AddScoped<IContractSigningPlanCodeGenerator, ContractSigningPlanCodeGenerator>();
services.AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();

View File

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details;
using SolutionErp.Domain.Forms;
@ -126,6 +127,15 @@ public class ApplicationDbContext
// Phase 11 P11-B Wave 1 (Mig 42) — Số dư phép theo năm.
public DbSet<LeaveBalance> LeaveBalances => Set<LeaveBalance>();
// [W1 Mig 69 — S161] GĐ2 "Kế hoạch ký kết HĐ" — 7 bảng (mirror IApplicationDbContext).
public DbSet<ContractSigningPlan> ContractSigningPlans => Set<ContractSigningPlan>();
public DbSet<ContractSigningPlanLine> ContractSigningPlanLines => Set<ContractSigningPlanLine>();
public DbSet<ContractSigningPlanDossierItem> ContractSigningPlanDossierItems => Set<ContractSigningPlanDossierItem>();
public DbSet<ContractSigningPlanLevelOpinion> ContractSigningPlanLevelOpinions => Set<ContractSigningPlanLevelOpinion>();
public DbSet<ContractSigningPlanAttachment> ContractSigningPlanAttachments => Set<ContractSigningPlanAttachment>();
public DbSet<ContractSigningPlanChangelog> ContractSigningPlanChangelogs => Set<ContractSigningPlanChangelog>();
public DbSet<ContractSigningPlanApproval> ContractSigningPlanApprovals => Set<ContractSigningPlanApproval>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);

View File

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [W1 Mig 69 — S161] Lịch sử transition (1 row / lần chuyển phase).
// Khuôn PurchaseEvaluationApprovalConfiguration (`PurchaseEvaluationConfiguration.cs:156-170`)
// + index twin (PlanId, ApprovedAt).
//
// BaseEntity — append-only, KHÔNG soft-delete ⇒ KHÔNG HasQueryFilter (parity PE/Contract).
// `ApprovalWorkflowLevelId` = loose-Guid KHÔNG FK vật lý: FK Restrict ở đây sẽ làm admin xoá
// Level nổ 500 vì lịch sử duyệt, mà 3 site dọn dẹp trong ApprovalWorkflowV2AdminFeatures chỉ
// biết họ bảng *LevelOpinions (không biết *Approvals) — xem chú thích trong entity.
public class ContractSigningPlanApprovalConfiguration
: IEntityTypeConfiguration<ContractSigningPlanApproval>
{
public void Configure(EntityTypeBuilder<ContractSigningPlanApproval> b)
{
b.ToTable("ContractSigningPlanApprovals");
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.ContractSigningPlanId, x.ApprovedAt });
b.HasIndex(x => x.ApprovalWorkflowLevelId);
b.HasOne(x => x.ContractSigningPlan)
.WithMany(p => p.Approvals)
.HasForeignKey(x => x.ContractSigningPlanId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [W1 Mig 69 — S161] File đính kèm phiếu Kế hoạch ký kết.
// Độ dài chuỗi theo spec §②-4 (`:134-137`): FileName 500 / StoragePath 1000 / ContentType 200
// — khuôn ProposalAttachmentConfiguration.cs:15-17 (RỘNG hơn PE 255/500/100 vì PE là bản cũ).
//
// FK Cascade Plan = đường cascade DUY NHẤT. `ContractSigningPlanDossierItemId` CỐ Ý chỉ
// HasIndex, KHÔNG FK vật lý — khuôn PurchaseEvaluationAttachmentConfiguration
// (`PurchaseEvaluationConfiguration.cs:199-200` chỉ HasIndex cho SupplierId):
// Plan→DossierItem→Attachment + Plan→Attachment cùng Cascade = MULTIPLE CASCADE PATHS,
// SQL Server từ chối tạo bảng.
public class ContractSigningPlanAttachmentConfiguration
: IEntityTypeConfiguration<ContractSigningPlanAttachment>
{
public void Configure(EntityTypeBuilder<ContractSigningPlanAttachment> b)
{
b.ToTable("ContractSigningPlanAttachments");
b.HasKey(x => x.Id);
b.Property(x => x.FileName).HasMaxLength(500).IsRequired();
b.Property(x => x.StoragePath).HasMaxLength(1000).IsRequired();
b.Property(x => x.ContentType).HasMaxLength(200).IsRequired();
b.Property(x => x.Purpose).HasConversion<int>();
b.Property(x => x.Note).HasMaxLength(1000);
b.HasIndex(x => x.ContractSigningPlanId);
b.HasIndex(x => x.ContractSigningPlanDossierItemId);
b.HasOne(x => x.ContractSigningPlan)
.WithMany(p => p.Attachments)
.HasForeignKey(x => x.ContractSigningPlanId)
.OnDelete(DeleteBehavior.Cascade);
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [W1 Mig 69 — S161] Nhật ký thay đổi phiếu Kế hoạch ký kết — CÓ TỪ NGÀY 1
// (tiền lệ PE Mig 60/61: thiếu changelog thì không backfill lại được lịch sử).
//
// Khuôn PurchaseEvaluationChangelogConfiguration (`PurchaseEvaluationConfiguration.cs:172-190`),
// độ dài chuỗi theo spec §②-4: Summary 1000 (PE để 500) · ContextNote 2000 · UserName 200 ·
// FieldChangesJson nvarchar(max) (JSON tự do, KHÔNG index).
//
// BaseEntity — KHÔNG soft-delete ⇒ KHÔNG HasQueryFilter (append-only audit, parity PE/Contract).
// Index twin (PlanId, EntityType) = vế mà `LogTransitionAsync` copy sang cần để lọc theo phạm vi.
public class ContractSigningPlanChangelogConfiguration
: IEntityTypeConfiguration<ContractSigningPlanChangelog>
{
public void Configure(EntityTypeBuilder<ContractSigningPlanChangelog> b)
{
b.ToTable("ContractSigningPlanChangelogs");
b.HasKey(x => x.Id);
b.Property(x => x.EntityType).HasConversion<int>();
b.Property(x => x.Action).HasConversion<int>();
b.Property(x => x.PhaseAtChange).HasConversion<int>();
b.Property(x => x.UserName).HasMaxLength(200);
b.Property(x => x.Summary).HasMaxLength(1000);
b.Property(x => x.ContextNote).HasMaxLength(2000);
b.Property(x => x.FieldChangesJson).HasColumnType("nvarchar(max)");
b.HasIndex(x => new { x.ContractSigningPlanId, x.CreatedAt });
b.HasIndex(x => new { x.ContractSigningPlanId, x.EntityType });
b.HasOne(x => x.ContractSigningPlan)
.WithMany(p => p.Changelogs)
.HasForeignKey(x => x.ContractSigningPlanId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [W1 Mig 69 — S161 2026-07-29] Header "Kế hoạch ký kết HĐ" (GĐ2).
// Khuôn PurchaseEvaluationConfiguration.cs (HasQueryFilter :84 + FK Restrict workflow :70-73).
//
// loose-Guid KHÔNG FK vật lý: PurchaseEvaluationId / ProjectId / DepartmentId /
// DrafterUserId — chỉ HasIndex (convention PE + Mig 49).
public class ContractSigningPlanConfiguration : IEntityTypeConfiguration<ContractSigningPlan>
{
public void Configure(EntityTypeBuilder<ContractSigningPlan> b)
{
b.ToTable("ContractSigningPlans");
b.HasKey(x => x.Id);
b.Property(x => x.MaKeHoach).HasMaxLength(50); // "KHKK/2026/001"
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.HoSoLink).HasMaxLength(1000);
b.Property(x => x.GhiChu).HasMaxLength(2000);
// Mã phiếu UNIQUE khi đã gen (gen lúc Submit ⇒ nháp còn null) — mirror
// PurchaseEvaluationConfiguration.cs:56 / ProposalConfiguration.cs:23.
b.HasIndex(x => x.MaKeHoach).IsUnique().HasFilter("[MaKeHoach] IS NOT NULL");
b.HasIndex(x => x.PurchaseEvaluationId);
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.DrafterUserId);
b.HasIndex(x => new { x.Phase, x.IsDeleted });
b.HasIndex(x => x.ApprovalWorkflowId);
// FK Restrict sang bảng V2 dùng chung: KHÔNG cho xoá quy trình khi còn phiếu pin
// (mirror PE `PurchaseEvaluationConfiguration.cs:70-73`).
b.HasOne<SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow>()
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowId)
.OnDelete(DeleteBehavior.Restrict);
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [W1 Mig 69 — S161] Danh mục căn cứ (b.8-9): mẫu vật liệu / shopdrawing + kết quả TVGS.
public class ContractSigningPlanDossierItemConfiguration
: IEntityTypeConfiguration<ContractSigningPlanDossierItem>
{
public void Configure(EntityTypeBuilder<ContractSigningPlanDossierItem> b)
{
b.ToTable("ContractSigningPlanDossierItems");
b.HasKey(x => x.Id);
b.Property(x => x.Kind).HasConversion<int>();
b.Property(x => x.Status).HasConversion<int>();
b.Property(x => x.Name).HasMaxLength(500).IsRequired();
b.Property(x => x.TvgsName).HasMaxLength(200);
b.Property(x => x.Note).HasMaxLength(1000);
b.HasIndex(x => x.ContractSigningPlanId);
b.HasIndex(x => x.SupplierId);
b.HasOne(x => x.ContractSigningPlan)
.WithMany(p => p.DossierItems)
.HasForeignKey(x => x.ContractSigningPlanId)
.OnDelete(DeleteBehavior.Cascade);
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [W1 Mig 69 — S161] Cookie-cutter mirror ContractLevelOpinionConfiguration.cs (Mig 33)
// — twin thứ 8 của họ `*LevelOpinion`.
//
// UNIQUE (PlanId, LevelId) = 1 row/level/phiếu; UPSERT (không soft-delete) ⇒ index
// KHÔNG filter, ĐÚNG như 7 twin còn lại (khác Lines: ở đó có xoá-mềm-rồi-thêm-lại thật).
// FK Cascade Plan + Restrict Level (bảo vệ chữ ký khi admin sửa quy trình).
// CỐ Ý KHÔNG HasQueryFilter — xem chú thích trong entity (site dọn chữ ký MỒ CÔI phải
// thấy được row soft-deleted, nếu không FK Restrict sẽ nổ 500 lúc admin xoá Cấp).
public class ContractSigningPlanLevelOpinionConfiguration
: IEntityTypeConfiguration<ContractSigningPlanLevelOpinion>
{
public void Configure(EntityTypeBuilder<ContractSigningPlanLevelOpinion> e)
{
e.ToTable("ContractSigningPlanLevelOpinions");
e.HasKey(x => x.Id);
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.ContractSigningPlan)
.WithMany(p => p.LevelOpinions)
.HasForeignKey(x => x.ContractSigningPlanId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ContractSigningPlanId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [W1 Mig 69 — S161] Dòng giá per-NCC.
// 🔴 UNIQUE (PlanId, SupplierId) PHẢI filtered `WHERE [IsDeleted] = 0` (gotcha #57 —
// 7× cumulative: Mig 43/45/47/64). Xoá mềm 1 NCC rồi thêm lại NCC đó = phải được.
public class ContractSigningPlanLineConfiguration : IEntityTypeConfiguration<ContractSigningPlanLine>
{
public void Configure(EntityTypeBuilder<ContractSigningPlanLine> b)
{
b.ToTable("ContractSigningPlanLines");
b.HasKey(x => x.Id);
b.Property(x => x.PeReferenceAmount).HasPrecision(18, 2);
b.Property(x => x.ProposedAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedAmount).HasPrecision(18, 2);
b.Property(x => x.Note).HasMaxLength(1000);
b.HasIndex(x => new { x.ContractSigningPlanId, x.SupplierId })
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.HasIndex(x => x.SupplierId);
b.HasIndex(x => x.ContractId);
b.HasOne(x => x.ContractSigningPlan)
.WithMany(p => p.Lines)
.HasForeignKey(x => x.ContractSigningPlanId)
.OnDelete(DeleteBehavior.Cascade);
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -1769,8 +1769,9 @@ public static class DbInitializer
(MenuKeys.CatalogWorkItems,"Hạng mục công việc", MenuKeys.Catalogs, 4, "ListChecks"),
// [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). Ngoài MenuKeys.All,
// FE staticMap trỏ /dashboard. Prod đã INSERT trực tiếp cùng ngày (idempotent ở đây).
// [S159] 2 root PLACEHOLDER GĐ2/GĐ4 — menu = 4 giai đoạn (owner). FE staticMap trỏ
// /dashboard. Prod đã INSERT trực tiếp cùng ngày (idempotent ở đây).
// [S161 W1 — F-3] `KeHoachKyKet` nay ĐÃ VÀO MenuKeys.All (policy sống); `HopDongCung` vẫn ngoài.
(MenuKeys.KeHoachKyKet, "Kế hoạch ký kết HĐ", null, 26, "FileSignature"),
(MenuKeys.HopDongCung, "Hợp đồng cứng", null, 32, "Stamp"),
// [S159-đợt3] skeleton menu GĐ2/GĐ4 (owner vẽ: "viết hoa + phân list như
@ -2101,6 +2102,13 @@ public static class DbInitializer
// MỌI role khác (seeder chỉ đụng role Procurement). Dashboard/Reports KHÔNG cấp (anh chốt bỏ).
await SeedProcurementMasterAccessAsync(db, roleManager, logger);
// [W1 Mig 69 — S161 2026-07-29] GĐ2 "Kế hoạch ký kết HĐ" golive: nâng quyền GHI
// (CanCreate+CanUpdate 13/13 role — O-A "hiển thị hết để góp ý"; CanDelete chỉ
// Drafter+Admin) trên root KeHoachKyKet + 6 leaf Khkk_*. CHẠY SAU revoke để THẮNG.
// 🔴 UPGRADE-IF-EXISTS bắt buộc (review F-S1 4-lane hội tụ): row đã seed CanRead-only
// @S159 ⇒ nhánh insert skip-existing = NO-OP im lặng ⇒ 403 giết dry-run (gotcha #44).
await SeedKeHoachKyKetAccessAsync(db, roleManager, logger);
// [S65 2026-06-16] CHẠY SAU revoke để THẮNG: mở lại quyền XEM "Hồ sơ Nhân sự"
// (+ root nhóm "Nhân sự") cho MỌI role — anh chốt public module Nhân sự cho
// user thường tra cứu hồ sơ. Dashboard NS (Hrm_Dashboard) + 6 catalog
@ -2397,6 +2405,72 @@ public static class DbInitializer
}
}
// [W1 Mig 69 — S161 2026-07-29] GĐ2 "Kế hoạch ký kết HĐ" — nâng quyền GHI cho MỌI role
// trên root KeHoachKyKet + 6 leaf Khkk_* (O-A anh chốt "hiển thị hết để góp ý" ⇒ 13/13
// role CanCreate+CanUpdate; CanDelete CHỈ Drafter+Admin — spec W1 §②-8).
// Khuôn = SeedProcurementMasterAccessAsync (S118) MỞ RỘNG thêm vế CanDelete (khuôn gốc
// không có — chốt-cuối S160 dặn). UPGRADE-ONLY: row tồn tại → NÂNG cờ desired, KHÔNG hạ
// cờ nào; row chưa có (fresh DB) → tạo đủ cờ. CẤM SQL tay (#84 — grant tay bị revoker lật).
// Thu hồi khi cần: xóa call ở SeedAsync — revoke sẽ tự che lại lần seed kế.
private static async Task SeedKeHoachKyKetAccessAsync(
ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
{
// Root policy-key + 6 leaf menu-key (khớp bộ seed menu-row S159; Khkk_G1 = group
// điều hướng, CanRead-all đã có từ nhánh read-only — KHÔNG cần cờ ghi).
var keys = new[]
{
MenuKeys.KeHoachKyKet,
"Khkk_WfView", "Khkk_List", "Khkk_Create", "Khkk_Pending",
"Khkk_Approved", "Khkk_Deleted",
};
var roles = await roleManager.Roles.ToListAsync();
var existing = (await db.Permissions
.Where(p => keys.Contains(p.MenuKey))
.ToListAsync())
.ToDictionary(p => (p.RoleId, p.MenuKey));
var added = 0;
var upgraded = 0;
foreach (var role in roles)
{
var allowDelete = role.Name == AppRoles.Admin || role.Name == AppRoles.Drafter;
foreach (var key in keys)
{
if (existing.TryGetValue((role.Id, key), out var row))
{
var changed = false;
if (!row.CanRead) { row.CanRead = true; changed = true; }
if (!row.CanCreate) { row.CanCreate = true; changed = true; }
if (!row.CanUpdate) { row.CanUpdate = true; changed = true; }
if (allowDelete && !row.CanDelete) { row.CanDelete = true; changed = true; }
if (changed) upgraded++;
continue;
}
db.Permissions.Add(new Permission
{
RoleId = role.Id,
MenuKey = key,
CanRead = true,
CanCreate = true,
CanUpdate = true,
CanDelete = allowDelete,
});
added++;
}
}
if (added > 0 || upgraded > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Seeded KeHoachKyKet access: {Added} added + {Upgraded} upgraded " +
"(W1 GĐ2 golive — 13/13 role C+U, Delete=Drafter/Admin; UPGRADE-if-exists F-S1)",
added, upgraded);
}
}
// [S65 2026-06-16] Mở quyền XEM (Read-only) "Hồ sơ Nhân sự" cho MỌI role: anh chốt
// public module Nhân sự (trọng tâm Hồ sơ NS) để user thường tra cứu hồ sơ nhân sự.
// CHẠY SAU RevokeTemporarilyHiddenModulesAsync (SeedAsync) để THẮNG revoke: revoke

View File

@ -0,0 +1,372 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddContractSigningPlans : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContractSigningPlans",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaKeHoach = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
PurchaseEvaluationId = 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: false),
Phase = table.Column<int>(type: "int", nullable: false),
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CurrentWorkflowStepIndex = table.Column<int>(type: "int", nullable: true),
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
SlaDeadline = table.Column<DateTime>(type: "datetime2", nullable: true),
HoSoLink = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
GhiChu = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, 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_ContractSigningPlans", x => x.Id);
table.ForeignKey(
name: "FK_ContractSigningPlans_ApprovalWorkflows_ApprovalWorkflowId",
column: x => x.ApprovalWorkflowId,
principalTable: "ApprovalWorkflows",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ContractSigningPlanApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractSigningPlanId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FromPhase = table.Column<int>(type: "int", nullable: false),
ToPhase = table.Column<int>(type: "int", nullable: false),
Decision = table.Column<int>(type: "int", nullable: false),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
ApprovedByUserId = table.Column<Guid>(type: "uniqueidentifier", 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_ContractSigningPlanApprovals", x => x.Id);
table.ForeignKey(
name: "FK_ContractSigningPlanApprovals_ContractSigningPlans_ContractSigningPlanId",
column: x => x.ContractSigningPlanId,
principalTable: "ContractSigningPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractSigningPlanAttachments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractSigningPlanId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractSigningPlanDossierItemId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
FileName = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
StoragePath = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
FileSize = table.Column<long>(type: "bigint", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Purpose = table.Column<int>(type: "int", nullable: false),
Note = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, 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_ContractSigningPlanAttachments", x => x.Id);
table.ForeignKey(
name: "FK_ContractSigningPlanAttachments_ContractSigningPlans_ContractSigningPlanId",
column: x => x.ContractSigningPlanId,
principalTable: "ContractSigningPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractSigningPlanChangelogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractSigningPlanId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EntityType = table.Column<int>(type: "int", nullable: false),
EntityId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Action = table.Column<int>(type: "int", nullable: false),
PhaseAtChange = table.Column<int>(type: "int", nullable: true),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Summary = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
FieldChangesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ContextNote = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, 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_ContractSigningPlanChangelogs", x => x.Id);
table.ForeignKey(
name: "FK_ContractSigningPlanChangelogs_ContractSigningPlans_ContractSigningPlanId",
column: x => x.ContractSigningPlanId,
principalTable: "ContractSigningPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractSigningPlanDossierItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractSigningPlanId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Name = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
TvgsName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
TvgsResultAt = table.Column<DateTime>(type: "datetime2", nullable: true),
Note = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, 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_ContractSigningPlanDossierItems", x => x.Id);
table.ForeignKey(
name: "FK_ContractSigningPlanDossierItems_ContractSigningPlans_ContractSigningPlanId",
column: x => x.ContractSigningPlanId,
principalTable: "ContractSigningPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractSigningPlanLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractSigningPlanId = 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_ContractSigningPlanLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_ContractSigningPlanLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ContractSigningPlanLevelOpinions_ContractSigningPlans_ContractSigningPlanId",
column: x => x.ContractSigningPlanId,
principalTable: "ContractSigningPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractSigningPlanLines",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractSigningPlanId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SupplierId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PeReferenceAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
ProposedAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
ApprovedAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Note = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, 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_ContractSigningPlanLines", x => x.Id);
table.ForeignKey(
name: "FK_ContractSigningPlanLines_ContractSigningPlans_ContractSigningPlanId",
column: x => x.ContractSigningPlanId,
principalTable: "ContractSigningPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanApprovals_ApprovalWorkflowLevelId",
table: "ContractSigningPlanApprovals",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanApprovals_ContractSigningPlanId_ApprovedAt",
table: "ContractSigningPlanApprovals",
columns: new[] { "ContractSigningPlanId", "ApprovedAt" });
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanAttachments_ContractSigningPlanDossierItemId",
table: "ContractSigningPlanAttachments",
column: "ContractSigningPlanDossierItemId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanAttachments_ContractSigningPlanId",
table: "ContractSigningPlanAttachments",
column: "ContractSigningPlanId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanChangelogs_ContractSigningPlanId_CreatedAt",
table: "ContractSigningPlanChangelogs",
columns: new[] { "ContractSigningPlanId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanChangelogs_ContractSigningPlanId_EntityType",
table: "ContractSigningPlanChangelogs",
columns: new[] { "ContractSigningPlanId", "EntityType" });
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanDossierItems_ContractSigningPlanId",
table: "ContractSigningPlanDossierItems",
column: "ContractSigningPlanId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanDossierItems_SupplierId",
table: "ContractSigningPlanDossierItems",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanLevelOpinions_ApprovalWorkflowLevelId",
table: "ContractSigningPlanLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanLevelOpinions_ContractSigningPlanId_ApprovalWorkflowLevelId",
table: "ContractSigningPlanLevelOpinions",
columns: new[] { "ContractSigningPlanId", "ApprovalWorkflowLevelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanLines_ContractId",
table: "ContractSigningPlanLines",
column: "ContractId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId",
table: "ContractSigningPlanLines",
columns: new[] { "ContractSigningPlanId", "SupplierId" },
unique: true,
filter: "[IsDeleted] = 0");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanLines_SupplierId",
table: "ContractSigningPlanLines",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlans_ApprovalWorkflowId",
table: "ContractSigningPlans",
column: "ApprovalWorkflowId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlans_DepartmentId",
table: "ContractSigningPlans",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlans_DrafterUserId",
table: "ContractSigningPlans",
column: "DrafterUserId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlans_MaKeHoach",
table: "ContractSigningPlans",
column: "MaKeHoach",
unique: true,
filter: "[MaKeHoach] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlans_Phase_IsDeleted",
table: "ContractSigningPlans",
columns: new[] { "Phase", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlans_ProjectId",
table: "ContractSigningPlans",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlans_PurchaseEvaluationId",
table: "ContractSigningPlans",
column: "PurchaseEvaluationId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContractSigningPlanApprovals");
migrationBuilder.DropTable(
name: "ContractSigningPlanAttachments");
migrationBuilder.DropTable(
name: "ContractSigningPlanChangelogs");
migrationBuilder.DropTable(
name: "ContractSigningPlanDossierItems");
migrationBuilder.DropTable(
name: "ContractSigningPlanLevelOpinions");
migrationBuilder.DropTable(
name: "ContractSigningPlanLines");
migrationBuilder.DropTable(
name: "ContractSigningPlans");
}
}
}

View File

@ -312,6 +312,459 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ApprovalWorkflowSteps", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
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<int?>("CurrentWorkflowStepIndex")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("DrafterUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("GhiChu")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("HoSoLink")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaKeHoach")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
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("MaKeHoach")
.IsUnique()
.HasFilter("[MaKeHoach] IS NOT NULL");
b.HasIndex("ProjectId");
b.HasIndex("PurchaseEvaluationId");
b.HasIndex("Phase", "IsDeleted");
b.ToTable("ContractSigningPlans", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<Guid>("ContractSigningPlanId")
.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("ApprovalWorkflowLevelId");
b.HasIndex("ContractSigningPlanId", "ApprovedAt");
b.ToTable("ContractSigningPlanApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid?>("ContractSigningPlanDossierItemId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ContractSigningPlanId")
.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<long>("FileSize")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Note")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("Purpose")
.HasColumnType("int");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractSigningPlanDossierItemId");
b.HasIndex("ContractSigningPlanId");
b.ToTable("ContractSigningPlanAttachments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanChangelog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("Action")
.HasColumnType("int");
b.Property<string>("ContextNote")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<Guid>("ContractSigningPlanId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("EntityId")
.HasColumnType("uniqueidentifier");
b.Property<int>("EntityType")
.HasColumnType("int");
b.Property<string>("FieldChangesJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("PhaseAtChange")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("ContractSigningPlanId", "CreatedAt");
b.HasIndex("ContractSigningPlanId", "EntityType");
b.ToTable("ContractSigningPlanChangelogs", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanDossierItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ContractSigningPlanId")
.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<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Note")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid?>("SupplierId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TvgsName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("TvgsResultAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractSigningPlanId");
b.HasIndex("SupplierId");
b.ToTable("ContractSigningPlanDossierItems", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanLevelOpinion", 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<Guid>("ContractSigningPlanId")
.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<bool>("IsDeleted")
.HasColumnType("bit");
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("ContractSigningPlanId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("ContractSigningPlanLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("ApprovedAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ContractSigningPlanId")
.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<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Note")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<decimal>("PeReferenceAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("ProposedAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<Guid>("SupplierId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractId");
b.HasIndex("SupplierId");
b.HasIndex("ContractSigningPlanId", "SupplierId")
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("ContractSigningPlanLines", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Property<Guid>("Id")
@ -5683,6 +6136,88 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("ApprovalWorkflow");
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
.WithMany()
.HasForeignKey("ApprovalWorkflowId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanApproval", b =>
{
b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan")
.WithMany("Approvals")
.HasForeignKey("ContractSigningPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ContractSigningPlan");
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanAttachment", b =>
{
b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan")
.WithMany("Attachments")
.HasForeignKey("ContractSigningPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ContractSigningPlan");
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanChangelog", b =>
{
b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan")
.WithMany("Changelogs")
.HasForeignKey("ContractSigningPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ContractSigningPlan");
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanDossierItem", b =>
{
b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan")
.WithMany("DossierItems")
.HasForeignKey("ContractSigningPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ContractSigningPlan");
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan")
.WithMany("LevelOpinions")
.HasForeignKey("ContractSigningPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ContractSigningPlan");
b.Navigation("Level");
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanLine", b =>
{
b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan")
.WithMany("Lines")
.HasForeignKey("ContractSigningPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ContractSigningPlan");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
@ -6275,6 +6810,21 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Levels");
});
modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", b =>
{
b.Navigation("Approvals");
b.Navigation("Attachments");
b.Navigation("Changelogs");
b.Navigation("DossierItems");
b.Navigation("LevelOpinions");
b.Navigation("Lines");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Navigation("Approvals");

View File

@ -0,0 +1,52 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Services;
// [W1 Mig 69 — S161 2026-07-29] Mã phiếu KHKK — format KHKK/{YYYY}/{Seq:D3} (anh OK @S161).
// Mirror PurchaseEvaluationCodeGenerator (SERIALIZABLE + LastSeq += 1), khác 1 điểm:
// sequence sống trong bảng WorkflowAppCodeSequences DÙNG CHUNG 4 module Office
// (per-prefix row "KHKK/{YYYY}" — KHÔNG bảng sequence riêng, spec W1 §②-9).
public class ContractSigningPlanCodeGenerator(IApplicationDbContext db, IDateTime dateTime)
: IContractSigningPlanCodeGenerator
{
public async Task<string> GenerateAsync(CancellationToken ct = default)
{
var year = dateTime.UtcNow.Year;
var prefix = $"KHKK/{year}";
// Transaction SERIALIZABLE chống race 2 người Submit cùng lúc (mirror PE codegen).
var context = (DbContext)db;
await using var tx = await context.Database
.BeginTransactionAsync(IsolationLevel.Serializable, ct);
try
{
var seq = await db.WorkflowAppCodeSequences
.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
if (seq is null)
{
seq = new WorkflowAppCodeSequence
{
Prefix = prefix, LastSeq = 1, UpdatedAt = dateTime.UtcNow,
};
db.WorkflowAppCodeSequences.Add(seq);
}
else
{
seq.LastSeq += 1;
seq.UpdatedAt = dateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return $"{prefix}/{seq.LastSeq:D3}";
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}
}