[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

@ -92,6 +92,9 @@ internal static class AwLabels
[ApprovalWorkflowApplicableType.DuyetNcc] = "Duyệt NCC",
[ApprovalWorkflowApplicableType.DuyetNccPhuongAn] = "Duyệt NCC + Giải pháp",
[ApprovalWorkflowApplicableType.Contract] = "Hợp đồng",
// [W1 S161 — reviewer F-7] Thiếu entry = fallback ToString() tiếng Anh lên tiêu đề
// Designer VÀ ghi vào cột Name DB (useState `Quy trình ${label}` phía FE).
[ApprovalWorkflowApplicableType.ContractSigningPlan] = "Kế hoạch ký kết HĐ",
};
}
@ -370,8 +373,12 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowReturnToDrafter = l.AllowReturnToDrafter,
AllowApproverEditDetails = l.AllowApproverEditDetails,
AllowApproverEditBudget = l.AllowApproverEditBudget,
AllowApproverSkipToFinal = l.AllowApproverSkipToFinal,
AllowApproverFinalize = l.AllowApproverFinalize,
// [W1 S161 — reviewer F-8, F-B3 containment TẦNG BE] type=10 KHKK: 2 đường
// early-finalize anh phán "PHÁ VỠ" (S155), W3 không port code path ⇒ ép false
// server-side (FE đã ép ở levelFlagsPayload — đây chặn curl bypass). PUT
// sửa-tại-chỗ đã có echo-so-BẰNG chặn đổi cờ ⇒ chỉ cần guard ở CREATE.
AllowApproverSkipToFinal = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverSkipToFinal,
AllowApproverFinalize = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverFinalize,
AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6
}).ToList(),
})
@ -842,7 +849,9 @@ public class UpdateAwDefinitionCommandHandler(
// được "quy trình vừa bị sửa lúc nào, ai sửa, vì sao".
// ⚠️ KHAI GIỚI HẠN: repo CHƯA có bảng changelog cho quy trình duyệt, mà
// đợt 1 bị chốt "no new table" ⇒ vết cho workflow thuộc 5 module Văn
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) hiện CHỈ có UpdatedAt/
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) + KHKK (W1 S161 — reviewer F-6b:
// `ContractSigningPlanChangelogs` là changelog PHIẾU, không phải changelog QUY-TRÌNH;
// wire nếu cần ở W2/W3) hiện CHỈ có UpdatedAt/
// UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng.
if (changes.Count > 0 &&
def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc
@ -883,8 +892,8 @@ public class UpdateAwDefinitionCommandHandler(
await tx.CommitAsync(ct);
}
// Đếm chữ ký CÒN HIỆU LỰC trên các dòng Cấp — quét CẢ 7 bảng LevelOpinion đang
// FK vào ApprovalWorkflowLevels (PE + HĐ + 5 module Văn phòng số). Chỉ soi PE là
// Đếm chữ ký CÒN HIỆU LỰC trên các dòng Cấp — quét CẢ 8 bảng LevelOpinion đang
// FK vào ApprovalWorkflowLevels (PE + HĐ + 5 module Văn phòng số + KHKK W1). Chỉ soi PE là
// vẫn nổ FK 547 khi quy trình thuộc module khác (§F.3 blast radius ≥6 module).
// 🔴 `!p.IsDeleted` viết TƯỜNG MINH: chỉ PE + Contract có global query filter,
// 5 bảng Office KHÔNG có ⇒ dựa filter là đúng PE mà sai 5 module kia.
@ -938,6 +947,16 @@ public class UpdateAwDefinitionCommandHandler(
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
// [W1 Mig 69 — S161] GĐ2 Kế hoạch ký kết HĐ (type=10). Site liệt-kê-thủ-công:
// module mới có LevelOpinions PHẢI vào CẢ 3 danh sách trong file này
// (count-sống · retained-ref · orphan-delete) — thiếu 1 = admin xoá Level nổ FK 500.
// Tự kiểm: grep -n "LevelOpinions.AsNoTracking\|LevelOpinions$" file này.
Merge(await db.ContractSigningPlanLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.ContractSigningPlans.Any(p => p.Id == o.ContractSigningPlanId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
return acc;
}
@ -948,7 +967,7 @@ public class UpdateAwDefinitionCommandHandler(
// 🔴 `IgnoreQueryFilters()` LÀ BẮT BUỘC, không phải trang trí: PE và Contract có global
// filter `!IsDeleted` (`PurchaseEvaluationConfiguration.cs:84`). Không tắt filter thì hồ
// sơ xoá mềm biến mất khỏi phép JOIN ⇒ ta lại kết luận "mồ côi" ⇒ đúng cái sai đang chữa.
// (5 bảng Văn phòng số KHÔNG có filter — gọi vẫn vô hại, giữ 7 khối cùng một hình dạng.)
// (5 bảng Văn phòng số + KHKK-LevelOpinions KHÔNG có filter — gọi vẫn vô hại, giữ 8 khối cùng một hình dạng.)
private async Task<Dictionary<Guid, List<RetainedOpinionRef>>> ListRetainedOpinionsAsync(
List<Guid> levelIds, CancellationToken ct)
{
@ -1003,6 +1022,14 @@ public class UpdateAwDefinitionCommandHandler(
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn xe", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
// [W1 Mig 69 — S161] GĐ2 Kế hoạch ký kết HĐ — mirror nhánh trên (site 2/3).
rows.AddRange(await db.ContractSigningPlanLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.ContractSigningPlans, o => o.ContractSigningPlanId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Kế hoạch ký kết HĐ", x.p.MaKeHoach, x.p.IsDeleted))
.ToListAsync(ct));
return rows.GroupBy(r => r.LevelId).ToDictionary(g => g.Key, g => g.ToList());
}
@ -1061,6 +1088,11 @@ public class UpdateAwDefinitionCommandHandler(
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.VehicleBookings.IgnoreQueryFilters().Any(p => p.Id == o.VehicleBookingId))
.ExecuteDeleteAsync(ct);
// [W1 Mig 69 — S161] GĐ2 Kế hoạch ký kết HĐ — mirror nhánh trên (site 3/3).
n += await db.ContractSigningPlanLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.ContractSigningPlans.IgnoreQueryFilters().Any(p => p.Id == o.ContractSigningPlanId))
.ExecuteDeleteAsync(ct);
return n;
}

View File

@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details;
using SolutionErp.Domain.Forms;
@ -141,5 +142,17 @@ public interface IApplicationDbContext
// Phase 11 P11-B Wave 1 (Mig 42) — Số dư phép theo năm (NV × LoạiPhép × Năm).
DbSet<LeaveBalance> LeaveBalances { get; }
// [W1 Mig 69 — S161] GĐ2 "Kế hoạch ký kết HĐ" (ContractSigningPlan) — 7 bảng.
// Workflow V2 ApplicableType = ContractSigningPlan = 10 (`ApprovalWorkflow.cs:66`).
// Mã phiếu "KHKK/{YYYY}/{Seq:D3}" atomic qua `WorkflowAppCodeSequences` DÙNG CHUNG (:136),
// KHÔNG bảng sequence riêng.
DbSet<ContractSigningPlan> ContractSigningPlans { get; }
DbSet<ContractSigningPlanLine> ContractSigningPlanLines { get; }
DbSet<ContractSigningPlanDossierItem> ContractSigningPlanDossierItems { get; }
DbSet<ContractSigningPlanLevelOpinion> ContractSigningPlanLevelOpinions { get; }
DbSet<ContractSigningPlanAttachment> ContractSigningPlanAttachments { get; }
DbSet<ContractSigningPlanChangelog> ContractSigningPlanChangelogs { get; }
DbSet<ContractSigningPlanApproval> ContractSigningPlanApprovals { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,14 @@
namespace SolutionErp.Application.ContractSigningPlans.Services;
// [W1 Mig 69 — S161 2026-07-29] Atomic codegen mã phiếu "Kế hoạch ký kết HĐ" (GĐ2)
// — mirror IPurchaseEvaluationCodeGenerator.
// Format anh chốt @S161: KHKK/{YYYY}/{Seq:D3} (VD: KHKK/2026/001, KHKK/2026/002).
// - Sequence per-năm qua bảng WorkflowAppCodeSequences DÙNG CHUNG (per-prefix row,
// IApplicationDbContext) — KHÔNG bảng sequence riêng (spec W1 §②-9, #8-CẮT spec cũ).
// - Seq reset đầu năm tự nhiên (key Prefix "KHKK/{YYYY}" mới).
// Caller: W2 Submit (gen lúc trình duyệt, KHÔNG lúc tạo nháp — mirror quyết spec cũ).
public interface IContractSigningPlanCodeGenerator
{
// Gen mã atomic — transaction SERIALIZABLE chống race (mirror PE/Contract codegen).
Task<string> GenerateAsync(CancellationToken ct = default);
}

View File

@ -64,6 +64,10 @@ public enum ApprovalWorkflowApplicableType
VehicleBooking = 7, // G-O5 — Đặt xe công
ItTicket = 8, // G-O6 — Ticket CNTT
TravelRequest = 9, // G-O4 — Đơn công tác (Travel) — Phase 11 P11-A
// [W1 Mig 69 — S161 2026-07-29] GĐ2 "Kế hoạch ký kết HĐ" (ContractSigningPlan).
// Append-only: slot 10 re-verify TRỐNG ngay trước khi viết (enum trước dừng ở 9).
ContractSigningPlan = 10, // GĐ2 — Kế hoạch ký kết HĐ (NCC/Thầu phụ)
}
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.

View File

@ -0,0 +1,48 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.ContractSigningPlans;
// [W1 Mig 69 — S161 2026-07-29] Aggregate root "Kế hoạch ký kết HĐ" (GĐ2, tờ FO-002.01).
// Nối tiếp phiếu Duyệt NCC (PE) đã DaDuyet → chốt giá per-NCC trước khi sinh HĐ (GĐ3).
//
// Cookie-cutter mirror PurchaseEvaluation (con-trỏ ĐÔI StepIndex + LevelOrder cho
// workflow V2 OR-of-N) — KHÔNG mirror Proposal (Proposal chỉ có LevelOrder, flatten
// SelectMany làm OR-of-N thành AND, xem spec §2.1).
//
// Convention loose-Guid: PurchaseEvaluationId / ProjectId / DepartmentId / DrafterUserId
// KHÔNG FK vật lý (mirror PE `PurchaseEvaluation.cs:15-18` + Mig 49). FK vật lý CHỈ:
// nội bộ module (Lines/DossierItems/... Cascade) + ApprovalWorkflowId → bảng V2 dùng chung.
public class ContractSigningPlan : AuditableEntity
{
public string? MaKeHoach { get; set; } // "KHKK/2026/001" — gen atomic lúc Submit (W2)
public Guid PurchaseEvaluationId { get; set; } // loose-Guid + IX — phiếu PE nguồn (DaDuyet)
public Guid ProjectId { get; set; } // denorm để lọc list (loose-Guid, mirror PE)
public Guid? DepartmentId { get; set; } // [C5 review-schema] phòng của phiếu — vế `DeptManager`
// của guard trình so với cột NÀY (Step.DepartmentId chỉ là
// hint của quy trình, KHÔNG thay được phòng của phiếu).
public Guid DrafterUserId { get; set; } // BCH soạn kế hoạch
public ContractSigningPlanPhase Phase { get; set; } = ContractSigningPlanPhase.DangSoanThao;
// Workflow V2 pin (ApplicableType = ContractSigningPlan = 10). Con-trỏ ĐÔI mirror PE:
// StepIndex 0-based (Bước) + LevelOrder 1-based (Cấp trong Bước). Null khi terminal.
public Guid? ApprovalWorkflowId { get; set; }
public int? CurrentWorkflowStepIndex { get; set; }
public int? CurrentApprovalLevelOrder { get; set; }
// 🔸 SLA = FE hiển thị THAM KHẢO (O-2 owner chốt). KHÔNG đăng ký SlaExpiryJob,
// KHÔNG có cờ SlaWarningSent — cố ý khai rõ để cột này không rơi vào lớp
// "cột có mà 0 ai đọc"; người đọc nó là FE, không phải job.
public DateTime? SlaDeadline { get; set; }
public string? HoSoLink { get; set; } // hyperlink thư mục hồ sơ NAS (mirror PE HoSoLink)
public string? GhiChu { get; set; }
public List<ContractSigningPlanLine> Lines { get; set; } = new();
public List<ContractSigningPlanDossierItem> DossierItems { get; set; } = new();
public List<ContractSigningPlanAttachment> Attachments { get; set; } = new();
public List<ContractSigningPlanChangelog> Changelogs { get; set; } = new();
public List<ContractSigningPlanApproval> Approvals { get; set; } = new();
public List<ContractSigningPlanLevelOpinion> LevelOpinions { get; set; } = new();
}

View File

@ -0,0 +1,31 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse ApprovalDecision enum
namespace SolutionErp.Domain.ContractSigningPlans;
// [W1 Mig 69 — S161] Lịch sử transition (1 row / lần chuyển phase).
// 🔴 THÊM sau review (lens-schema H1): service được lệnh copy CÓ ghi vào bảng này
// (`ContractWorkflowService.cs:151` `db.ContractApprovals.Add(...)`), twin PE
// `PurchaseEvaluation.cs:137` + index (PeId, ApprovedAt). Thiếu ⇒ đoạn copy trỏ
// bảng KHÔNG TỒN TẠI.
//
// BaseEntity (append-only, KHÔNG soft-delete) — mirror PurchaseEvaluationApproval.
//
// `ApprovalWorkflowLevelId` = loose-Guid CỐ Ý KHÔNG FK vật lý: nếu để FK Restrict thì
// admin xoá Level sẽ nổ 500 vì lịch sử duyệt, mà 3 site dọn dẹp trong
// ApprovalWorkflowV2AdminFeatures chỉ biết bảng *LevelOpinions (không biết *Approvals).
public class ContractSigningPlanApproval : BaseEntity
{
public Guid ContractSigningPlanId { get; set; }
public ContractSigningPlanPhase FromPhase { get; set; }
public ContractSigningPlanPhase ToPhase { get; set; }
public ApprovalDecision Decision { get; set; }
public string? Comment { get; set; }
public Guid? ApprovalWorkflowLevelId { get; set; } // Cấp đã ký (loose-Guid, no FK)
public Guid? ApprovedByUserId { get; set; } // null = system/SLA auto
public DateTime ApprovedAt { get; set; }
public ContractSigningPlan? ContractSigningPlan { get; set; }
}

View File

@ -0,0 +1,34 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.ContractSigningPlans;
// 🔴 Bắt đầu từ 1 (0 falsy JS); `Other = 99` khuôn PurchaseEvaluationAttachmentPurpose.
// Append-only khi cần thêm mục đích mới.
public enum ContractSigningPlanAttachmentPurpose
{
DossierScan = 1, // Bản scan căn cứ (mẫu vật liệu / shopdrawing / kết quả TVGS)
PlanDocument = 2, // Bản kế hoạch ký kết đã ký/xuất
Other = 99,
}
// [W1 Mig 69 — S161] File đính kèm phiếu Kế hoạch ký kết.
// FK Cascade Plan (1 đường cascade DUY NHẤT). `ContractSigningPlanDossierItemId`
// optional = loose-Guid + index, CỐ Ý KHÔNG FK vật lý — khuôn
// `PurchaseEvaluationAttachment.PurchaseEvaluationSupplierId` (config chỉ HasIndex).
// Lý do kỹ thuật: Plan→DossierItem→Attachment + Plan→Attachment cùng Cascade sẽ là
// MULTIPLE CASCADE PATHS ⇒ SQL Server từ chối tạo bảng.
// Khuôn ProposalAttachment.cs + PurchaseEvaluationAttachment.cs.
public class ContractSigningPlanAttachment : AuditableEntity
{
public Guid ContractSigningPlanId { get; set; }
public Guid? ContractSigningPlanDossierItemId { get; set; }
public string FileName { get; set; } = string.Empty;
public string StoragePath { get; set; } = string.Empty;
public long FileSize { get; set; }
public string ContentType { get; set; } = string.Empty;
public ContractSigningPlanAttachmentPurpose Purpose { get; set; } = ContractSigningPlanAttachmentPurpose.Other;
public string? Note { get; set; }
public ContractSigningPlan? ContractSigningPlan { get; set; }
}

View File

@ -0,0 +1,40 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse ChangelogAction enum
namespace SolutionErp.Domain.ContractSigningPlans;
// [W1 Mig 69 — S161] Phạm vi thay đổi được ghi nhật ký. Bắt đầu từ 1 (0 falsy JS).
public enum ContractSigningPlanEntityType
{
Header = 1,
Line = 2,
DossierItem = 3,
Workflow = 4,
Attachment = 5,
}
// [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ử).
//
// 🔴 4 cột EntityType / EntityId / UserName / FieldChangesJson là BẮT BUỘC (review
// lens-schema C1): `LogTransitionAsync` khuôn `ContractWorkflowService.cs:405` gọi
// `changelog.LogWorkflowTransitionAsync` cần đủ chữ ký này, thiếu ⇒ đoạn copy KHÔNG
// BIÊN DỊCH + mất index twin (PlanId, EntityType).
//
// BaseEntity (KHÔNG soft-delete) — append-only audit, mirror PurchaseEvaluationChangelog.
public class ContractSigningPlanChangelog : BaseEntity
{
public Guid ContractSigningPlanId { get; set; }
public ContractSigningPlanEntityType EntityType { get; set; }
public Guid? EntityId { get; set; }
public ChangelogAction Action { get; set; }
public ContractSigningPlanPhase? PhaseAtChange { get; set; }
public Guid? UserId { get; set; }
public string? UserName { get; set; }
public string? Summary { get; set; }
public string? FieldChangesJson { get; set; }
public string? ContextNote { get; set; }
public ContractSigningPlan? ContractSigningPlan { get; set; }
}

View File

@ -0,0 +1,41 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.ContractSigningPlans;
// [W1 Mig 69 — S161] Loại căn cứ (mục b.8-9 tờ FO-002.01).
// 🔴 Bắt đầu từ 1 — 0 falsy trong JS. `Khac = 99` theo khuôn `ItTicketCategory.Other = 99`.
public enum DossierItemKind
{
MauVatLieu = 1, // Mẫu vật liệu trình TVGS
Shopdrawing = 2, // Bản vẽ shopdrawing
Khac = 99,
}
// [W1 Mig 69 — S161] Trạng thái 1 căn cứ.
// 🔴 ChuaNop = 1 (KHÔNG 0): mọi enum repo bắt đầu ở 1 (`Office/Enums.cs:9,19,31,41,50,58`;
// `ApprovalWorkflow.cs:55`) và 0 là falsy JS ⇒ `status ? A : B` / `?status=0` rơi im lặng ở FE.
public enum DossierItemStatus
{
ChuaNop = 1,
DaNop = 2,
TvgsDuyet = 3,
TvgsBac = 4,
}
// [W1 Mig 69 — S161] Danh mục căn cứ kèm kế hoạch ký kết — INPUT của con số đang duyệt.
// CRUD chỉ mở ở phase {DangSoanThao, TraLai} (W2).
public class ContractSigningPlanDossierItem : AuditableEntity
{
public Guid ContractSigningPlanId { get; set; }
public DossierItemKind Kind { get; set; } = DossierItemKind.Khac;
public Guid? SupplierId { get; set; } // loose-Guid — căn cứ gắn NCC nào (null = chung)
public string Name { get; set; } = string.Empty; // "Mẫu thép D10 Hoà Phát"
public DossierItemStatus Status { get; set; } = DossierItemStatus.ChuaNop;
public string? TvgsName { get; set; } // tên TVGS xử lý
public DateTime? TvgsResultAt { get; set; } // ngày TVGS duyệt/bác
public string? Note { get; set; }
public ContractSigningPlan? ContractSigningPlan { get; set; }
}

View File

@ -0,0 +1,33 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.ContractSigningPlans;
// [W1 Mig 69 — S161] Ý kiến cấp duyệt V2 cho Kế hoạch ký kết.
// Cookie-cutter mirror ContractLevelOpinion (Mig 33) / PurchaseEvaluationLevelOpinion (Mig 26)
// — twin thứ 8 của họ `*LevelOpinion`.
//
// Mỗi row = 1 (Plan × ApprovalWorkflowLevel). Service ApproveV2Async UPSERT sau khi
// duyệt xong Cấp hiện tại (latest-write-wins). Reject (TraLai/TuChoi) KHÔNG sync.
//
// UNIQUE composite (PlanId, LevelId) — 1 row/level/phiếu.
// FK Cascade Plan (xoá phiếu → wipe) + Restrict Level (admin xoá Level bị chặn khi
// còn chữ ký — bảo vệ data; 3 site liệt-kê-thủ-công trong ApprovalWorkflowV2AdminFeatures
// PHẢI biết bảng này, thiếu ⇒ admin xoá Level có chữ ký nổ FK 500).
//
// 🔴 CỐ Ý KHÔNG có HasQueryFilter (parity 7 twin còn lại): site "dọn chữ ký MỒ CÔI"
// (`ApprovalWorkflowV2AdminFeatures.cs` ExecuteDeleteAsync) phải THẤY được row đã
// soft-delete, nếu bị filter che thì row mồ côi sống sót và FK Restrict lại nổ 500.
public class ContractSigningPlanLevelOpinion : AuditableEntity
{
public Guid ContractSigningPlanId { 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ẬT (có thể Admin duyệt thay)
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm (KHÔNG nav sang User)
public ContractSigningPlan? ContractSigningPlan { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

@ -0,0 +1,29 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.ContractSigningPlans;
// [W1 Mig 69 — S161] Dòng giá per-NCC-trúng-thầu của Kế hoạch ký kết.
// 1 row = 1 NCC (winner của PE nguồn). HĐ (GĐ3) sinh PER-NCC ⇒ dòng này là chỗ tự
// nhiên để giữ đường quay ngược Kế-hoạch → HĐ (`ContractId`, twin `PurchaseEvaluation.ContractId`).
//
// AuditableEntity (soft delete) — BẮT BUỘC vì UNIQUE (PlanId, SupplierId) phải là
// filtered `WHERE IsDeleted = 0` (gotcha #57: xoá mềm 1 NCC rồi thêm lại NCC đó
// sẽ đụng unique nếu index không filter).
public class ContractSigningPlanLine : AuditableEntity
{
public Guid ContractSigningPlanId { get; set; }
public Guid SupplierId { get; set; } // loose-Guid (KHÔNG FK vật lý sang Suppliers)
// SNAPSHOT tổng báo giá của NCC này trên phiếu PE (SUM Quote.IsSelected) tại thời
// điểm TẠO kế hoạch — chụp 1 lần, KHÔNG đọc live (PE sửa sau không được làm trôi
// số của kế hoạch đã lập; cùng triết lý freeze ngân sách Mig 67).
public decimal PeReferenceAmount { get; set; }
public decimal ProposedAmount { get; set; } // BCH đề xuất
public decimal? ApprovedAmount { get; set; } // CHỈ ghi tại choke-point finalize (W3)
public Guid? ContractId { get; set; } // [C6 review-schema] HĐ sinh ra từ dòng này (W5)
public string? Note { get; set; }
public ContractSigningPlan? ContractSigningPlan { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace SolutionErp.Domain.ContractSigningPlans;
// [W1 Mig 69 — S161 2026-07-29] Trạng thái phiếu "Kế hoạch ký kết HĐ" (GĐ2).
//
// Giá trị SẠCH, KHÔNG copy 7/98 của PurchaseEvaluationPhase (sẹo lịch sử enum PE —
// phiếu mới không nợ data cũ). Giữ 98/99 cho TraLai/TuChoi để quen mắt FE badge.
// 🔴 Bắt đầu từ 1 — 0 là falsy trong JS (`status ? A : B`, `?phase=0` rơi im lặng ở FE).
public enum ContractSigningPlanPhase
{
DangSoanThao = 1, // Nháp — Drafter (BCH) soạn
ChoDuyet = 2, // Đã gửi duyệt — chạy workflow V2 (ApplicableType=10)
DaDuyet = 3, // Terminal — chốt giá (ApprovedAmount)
TraLai = 98, // Approver trả lại cho người soạn (phase RIÊNG, mirror PE)
TuChoi = 99, // Terminal — từ chối
}

View File

@ -35,9 +35,10 @@ public static class MenuKeys
["ThauPhu", "GiaoKhoan", "NhaCungCap", "DichVu", "MuaBan", "NguyenTacNcc", "NguyenTacDv"];
// [S159 2026-07-29] 2 root PLACEHOLDER giai đoạn 2/4 (owner: menu hiển thị đúng
// 4 giai đoạn như tab strip). CỐ Ý NGOÀI `All` — chưa có module ⇒ không sinh
// policy per-action (giữ canonical Menu keys/Policies không drift); FE trỏ về
// /dashboard. Wave-1 GĐ2 (spec KHKK) phải TÁI DÙNG/thay root `KeHoachKyKet` này.
// 4 giai đoạn như tab strip). [S161 W1 — reviewer F-3] TÁCH TRẠNG THÁI 2 key:
// - `KeHoachKyKet` ĐÃ VÀO `All` @S161 (W1 KHKK) ⇒ 4 policy KeHoachKyKet.* sinh runtime.
// - `HopDongCung` VẪN NGOÀI `All` — chưa có module ⇒ không sinh policy (GĐ4 = 1-mốc
// upload trên Contract, có thể không bao giờ cần policy riêng).
public const string KeHoachKyKet = "KeHoachKyKet";
public const string HopDongCung = "HopDongCung";
@ -176,6 +177,7 @@ public static class MenuKeys
OffDonTu, OffDonTuLeave, OffDonTuOt, OffDonTuTravel, // Phase 10.3 G-O4 — Đơn từ
OffDatXe, OffItTicket, OffChamCong, OffAttendanceReport, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3 + P11-E report
Personal, // [S57] nhân (Puro grouping Chấm công re-parent)
KeHoachKyKet, // [W1 Mig 69 — S161] GĐ2 KHKK: const :41 vào All ⇒ sinh policy KeHoachKyKet.{Read,Create,Update,Delete}
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

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