- Tổng hợp ngân sách trình ký
+
+ Tổng hợp ngân sách trình ký
+ {bs.budgetFrozen && (
+
+ 🔒 Ngân sách chốt tại thời điểm duyệt
+
+ )}
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts
index df46f91..2bf7736 100644
--- a/fe-user/src/types/purchaseEvaluation.ts
+++ b/fe-user/src/types/purchaseEvaluation.ts
@@ -330,6 +330,7 @@ export type PeBudgetSummary = {
previousSelectedTotal: number // SUM quote ThanhTien NCC trúng (IsWinner) WHERE Phase=DaDuyet
previousSelectedCount: number
currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn)
+ budgetFrozen: boolean // [S133] true = phiếu DaDuyet, số NS đã chốt snapshot
}
// Mirror BE PeDepartmentKind enum
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
index 54e64ea..7272075 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
@@ -346,4 +346,8 @@ public record PeBudgetSummaryDto(
decimal CurrentProposalTotal,
// [S76] PRO column split — ban hành + hiệu chỉnh riêng cho PRO (mirror CCM Initial/Adjustment).
decimal? ProInitialAmount,
- decimal? ProAdjustmentAmount);
+ decimal? ProAdjustmentAmount,
+ // [S133] true = phiếu DaDuyet serve từ snapshot ngân sách (bất biến, đọc cột ApprovedBudget*
+ // thay record PeWorkItemBudgets live); false = phiếu chưa duyệt đọc LIVE. FE badge "đã chốt" +
+ // ẩn nút sửa. Append CUỐI record (default false) → construction site cũ không gãy.
+ bool BudgetFrozen = false);
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs
new file mode 100644
index 0000000..b78d807
--- /dev/null
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs
@@ -0,0 +1,72 @@
+using Microsoft.EntityFrameworkCore;
+using SolutionErp.Application.Common.Interfaces;
+using SolutionErp.Domain.PurchaseEvaluations;
+
+namespace SolutionErp.Application.PurchaseEvaluations;
+
+// [S133 pe-budget-freeze] Lũy kế ngân sách "phiếu TRƯỚC cùng cặp (ProjectId, WorkItemId)"
+// + "giá trị kỳ này" (own quotes IsSelected). Extract BEHAVIOR-PRESERVING từ
+// GetPurchaseEvaluationQuery :917-951 để DÙNG CHUNG cho:
+// (1) live display (GetPurchaseEvaluationQuery nhánh chưa-duyệt — giữ nguyên số),
+// (2) snapshot khi finalize (ApplyBudgetSnapshotOnFinalizeAsync — chốt 4 số lũy kế).
+//
+// Semantics GIỮ NGUYÊN (single source of truth = query handler cũ):
+// peers = PurchaseEvaluations cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this
+// (HasQueryFilter !IsDeleted tự loại phiếu xoá mềm).
+// - PrevSubmitted* : peers Phase ∈ (ChoDuyet, DaDuyet) → Count + SUM(BudgetPeriodAmount ?? 0).
+// - PrevSelected* : peers Phase = DaDuyet AND có ≥1 Supplier IsWinner (existence);
+// total = SUM ThanhTien quote IsSelected của các peer đó.
+// - CurrentProposalTotal : SUM ThanhTien quote IsSelected của CHÍNH phiếu này (own supplier rows).
+public readonly record struct PeBudgetAccumulation(
+ decimal PrevSubmittedTotal,
+ int PrevSubmittedCount,
+ decimal PrevSelectedTotal,
+ int PrevSelectedCount,
+ decimal CurrentProposalTotal);
+
+public static class PeBudgetAccumulator
+{
+ public static async Task
ComputeAsync(
+ IApplicationDbContext db,
+ Guid projectId,
+ Guid workItemId,
+ Guid peId,
+ DateTime peCreatedAt,
+ IReadOnlyList currentSupplierRowIds,
+ CancellationToken ct)
+ {
+ var peers = db.PurchaseEvaluations.AsNoTracking()
+ .Where(p => p.ProjectId == projectId && p.WorkItemId == workItemId
+ && p.Id != peId && p.CreatedAt < peCreatedAt);
+
+ var submitted = await peers
+ .Where(p => p.Phase == PurchaseEvaluationPhase.ChoDuyet
+ || p.Phase == PurchaseEvaluationPhase.DaDuyet)
+ .Select(p => p.BudgetPeriodAmount)
+ .ToListAsync(ct);
+ var prevSubmittedCount = submitted.Count;
+ var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
+
+ var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
+ && p.Suppliers.Any(s => s.IsWinner));
+ var prevSelectedCount = await selectedPeers.CountAsync(ct);
+ var prevSelectedTotal = await (
+ from p in selectedPeers
+ join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
+ on p.Id equals s.PurchaseEvaluationId
+ join q in db.PurchaseEvaluationQuotes.AsNoTracking()
+ on s.Id equals q.PurchaseEvaluationSupplierId
+ where q.IsSelected
+ select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
+
+ var currentProposalTotal = currentSupplierRowIds.Count == 0 ? 0m
+ : await db.PurchaseEvaluationQuotes.AsNoTracking()
+ .Where(q => currentSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
+ .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
+
+ return new PeBudgetAccumulation(
+ prevSubmittedTotal, prevSubmittedCount,
+ prevSelectedTotal, prevSelectedCount,
+ currentProposalTotal);
+ }
+}
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkItemBudgetFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkItemBudgetFeatures.cs
index 85524ae..01641da 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkItemBudgetFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkItemBudgetFeatures.cs
@@ -230,6 +230,12 @@ public class SetPeCcmBudgetPeriodCommandHandler(
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
+ // [S133] Phiếu KẾT THÚC (DaDuyet / TuChoi) = ngân sách BẤT BIẾN — chặn CẢ Admin (ngay sau
+ // load + NotFound, TRƯỚC role-gate). Số NS đã chốt snapshot tại thời điểm duyệt.
+ if (pe.Phase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi)
+ throw new ConflictException(
+ "Phiếu đã kết thúc — ngân sách đã chốt tại thời điểm duyệt, không sửa được nữa.");
+
// Fail-closed TRƯỚC mọi side-effect.
if (!currentUser.Roles.Contains(AppRoles.Admin)
&& !currentUser.Roles.Contains(AppRoles.CostControl))
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
index 16d3943..3830f5f 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
@@ -360,6 +360,12 @@ public class AdjustPurchaseEvaluationBudgetCommandHandler(
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
+ // [S133] Phiếu KẾT THÚC (DaDuyet / TuChoi) = ngân sách BẤT BIẾN — chặn CẢ Admin (đặt TRƯỚC
+ // toàn khối phân-quyền, áp mọi role). Số NS đã chốt snapshot tại thời điểm duyệt.
+ if (entity.Phase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi)
+ throw new ConflictException(
+ "Phiếu đã kết thúc — ngân sách đã chốt tại thời điểm duyệt, không sửa được nữa.");
+
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
var isDrafter = currentUser.UserId is Guid uid && entity.DrafterUserId == uid;
var actorTag = string.Empty;
@@ -908,68 +914,75 @@ public class GetPurchaseEvaluationQueryHandler(
var canEditPro = isAdmin || currentUser.Roles.Contains(AppRoles.Procurement);
var canEditCcm = isAdmin || currentUser.Roles.Contains(AppRoles.CostControl);
+ // pairRec load NGUYÊN cho MỌI nhánh — nhánh frozen chỉ dùng .Id (FE link không gãy),
+ // KHÔNG dùng số của nó (số đọc từ cột snapshot ApprovedBudget*).
var pairRec = await db.PeWorkItemBudgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.ProjectId == e.ProjectId && b.WorkItemId == wiKey, ct);
- // Lũy kế phiếu TRƯỚC cùng cặp. Row 1 = đã trình (ChoDuyet + DaDuyet);
- // Row 2 = đã chọn thầu (DaDuyet + có đơn vị thắng). TraLai/DangSoanThao
- // KHÔNG tính (quay về soạn = chưa trình).
- var peers = db.PurchaseEvaluations.AsNoTracking()
- .Where(p => p.ProjectId == e.ProjectId && p.WorkItemId == wiKey
- && p.Id != e.Id && p.CreatedAt < e.CreatedAt);
-
- var submitted = await peers
- .Where(p => p.Phase == PurchaseEvaluationPhase.ChoDuyet
- || p.Phase == PurchaseEvaluationPhase.DaDuyet)
- .Select(p => p.BudgetPeriodAmount)
- .ToListAsync(ct);
- var prevSubmittedCount = submitted.Count;
- var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
-
- // "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị IsWinner (existence — IsWinner
- // là DERIVED == Any quote IsSelected, giữ đúng ngữ nghĩa "có chọn thầu"; A3(ii)).
- var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
- && p.Suppliers.Any(s => s.IsWinner));
- var prevSelectedCount = await selectedPeers.CountAsync(ct);
- // [multi-NCC A3(i)[5]] Tổng "đã chọn thầu" = SUM ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected
- // = nguồn-sự-thật per Detail×Supplier) của các phiếu DaDuyet trước — thay whole-supplier IsWinner.
- var prevSelectedTotal = await (
- from p in selectedPeers
- join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
- on p.Id equals s.PurchaseEvaluationId
- join q in db.PurchaseEvaluationQuotes.AsNoTracking()
- on s.Id equals q.PurchaseEvaluationSupplierId
- where q.IsSelected
- select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
-
- // Row 4 "Giá trị kỳ này" = SUM báo giá ĐƯỢC CHỌN (Quote.IsSelected) phiếu này
- // [multi-NCC A3(i)[5]] (khớp winnerQuoteTotal :1189 — nguồn-sự-thật per Detail×Supplier).
var curSupplierRowIds = e.Suppliers.Select(s => s.Id).ToList();
- var currentProposalTotal = curSupplierRowIds.Count == 0 ? 0m
- : await db.PurchaseEvaluationQuotes.AsNoTracking()
- .Where(q => curSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
- .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
- // [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho
- // Block B công thức = CCM nếu CCM đã nhập, else PRO (FullIsEstimate=true → FE
- // badge "ngân sách PRO"). PRO full = ProInitial + ProAdjust (migrate từ
- // ProEstimate cũ qua Mig 56). Cả 2 trống → full 0, không badge.
- var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
- var hasPro = pairRec?.ProInitialAmount is not null || pairRec?.ProAdjustmentAmount is not null;
- var proFull = (pairRec?.ProInitialAmount ?? 0m) + (pairRec?.ProAdjustmentAmount ?? 0m);
- var fullAmount = hasCcm
- ? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
- : proFull;
+ // [S133] Display-gate: phiếu DaDuyet ĐÃ chốt snapshot → serve BẤT BIẾN từ 11 cột
+ // ApprovedBudget* (KHÔNG chạy theo record PeWorkItemBudgets live — record dùng chung
+ // vẫn sửa được cho phiếu chưa duyệt). Fallback: DaDuyet mà SnapshotAt null → rơi xuống
+ // nhánh live như cũ — defense-in-depth; mọi đường tạo DaDuyet đã set SnapshotAt
+ // (4 site finalize + admin-override site-5 + seeder demo + backfill Mig 67 — review S133).
+ var frozen = e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ApprovedBudgetSnapshotAt != null;
+ if (frozen)
+ {
+ // Row 4 "Giá trị kỳ này" — SPEC S133: VẪN tính LIVE từ own quotes (KHÔNG snapshot).
+ var currentProposalTotal = curSupplierRowIds.Count == 0 ? 0m
+ : await db.PurchaseEvaluationQuotes.AsNoTracking()
+ .Where(q => curSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
+ .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
- peBudgetSummary = new PeBudgetSummaryDto(
- pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
- pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.CcmNote,
- fullAmount, !hasCcm && hasPro,
- canEditPro, canEditCcm,
- prevSubmittedTotal, prevSubmittedCount,
- prevSelectedTotal, prevSelectedCount,
- currentProposalTotal,
- pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount);
+ // Full mỗi cột từ SNAPSHOT (mirror công thức :957-962). Ccm* = cột CCM đã chốt.
+ var sHasCcm = e.ApprovedBudgetCcmInitialAmount is not null || e.ApprovedBudgetCcmAdjustmentAmount is not null;
+ var sHasPro = e.ApprovedBudgetProInitialAmount is not null || e.ApprovedBudgetProAdjustmentAmount is not null;
+ var sProFull = (e.ApprovedBudgetProInitialAmount ?? 0m) + (e.ApprovedBudgetProAdjustmentAmount ?? 0m);
+ var sFullAmount = sHasCcm
+ ? (e.ApprovedBudgetCcmInitialAmount ?? 0m) + (e.ApprovedBudgetCcmAdjustmentAmount ?? 0m)
+ : sProFull;
+
+ peBudgetSummary = new PeBudgetSummaryDto(
+ pairRec?.Id, null, e.ApprovedBudgetProNote,
+ e.ApprovedBudgetCcmInitialAmount, e.ApprovedBudgetCcmAdjustmentAmount, e.ApprovedBudgetCcmNote,
+ sFullAmount, !sHasCcm && sHasPro,
+ false, false, // phiếu đã chốt → KHÔNG cho sửa PRO/CCM
+ e.ApprovedBudgetPrevSubmittedTotal ?? 0m, e.ApprovedBudgetPrevSubmittedCount ?? 0,
+ e.ApprovedBudgetPrevSelectedTotal ?? 0m, e.ApprovedBudgetPrevSelectedCount ?? 0,
+ currentProposalTotal,
+ e.ApprovedBudgetProInitialAmount, e.ApprovedBudgetProAdjustmentAmount,
+ BudgetFrozen: true);
+ }
+ else
+ {
+ // Lũy kế phiếu TRƯỚC cùng cặp + "giá trị kỳ này" (behavior-preserving qua accumulator —
+ // extract nguyên semantics :917-951). Row 1 = đã trình (ChoDuyet + DaDuyet); Row 2 =
+ // đã chọn thầu (DaDuyet + có đơn vị IsWinner). TraLai/DangSoanThao KHÔNG tính.
+ var acc = await PeBudgetAccumulator.ComputeAsync(
+ db, e.ProjectId, wiKey, e.Id, e.CreatedAt, curSupplierRowIds, ct);
+
+ // [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho Block B =
+ // CCM nếu đã nhập, else PRO (FullIsEstimate=true → FE badge "ngân sách PRO"). PRO full =
+ // ProInitial + ProAdjust (migrate từ ProEstimate cũ qua Mig 56). Cả 2 trống → full 0.
+ var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
+ var hasPro = pairRec?.ProInitialAmount is not null || pairRec?.ProAdjustmentAmount is not null;
+ var proFull = (pairRec?.ProInitialAmount ?? 0m) + (pairRec?.ProAdjustmentAmount ?? 0m);
+ var fullAmount = hasCcm
+ ? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
+ : proFull;
+
+ peBudgetSummary = new PeBudgetSummaryDto(
+ pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
+ pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.CcmNote,
+ fullAmount, !hasCcm && hasPro,
+ canEditPro, canEditCcm,
+ acc.PrevSubmittedTotal, acc.PrevSubmittedCount,
+ acc.PrevSelectedTotal, acc.PrevSelectedCount,
+ acc.CurrentProposalTotal,
+ pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount,
+ BudgetFrozen: false);
+ }
}
// Load supplier names for PE suppliers + approver names
diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
index 6760ab7..7cc57dd 100644
--- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
+++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
@@ -111,6 +111,26 @@ public class PurchaseEvaluation : AuditableEntity
// cho phiếu DaDuyet để phiếu-đã-lên-CEO KHÔNG còn hiện sai "không qua CEO" (bug anh Kiệt S97).
public bool EndedByLevelFinalize { get; set; }
+ // [S133 2026-07-17 — anh Kiệt FDC] BẤT BIẾN ngân sách phiếu ĐÃ DUYỆT: snapshot bộ số
+ // NS-gói-thầu (matrix PRO/CCM từ PeWorkItemBudgets + 4 số lũy kế phiếu-trước) vào 11 cột
+ // này TẠI thời điểm chuyển DaDuyet (ApplyBudgetSnapshotOnFinalizeAsync gọi ở MỌI nhánh
+ // finalize). Phiếu DaDuyet serve từ snapshot (display-gate `frozen` — GetPurchaseEvaluationQuery),
+ // phiếu chưa duyệt đọc LIVE record PeWorkItemBudgets. PeWorkItemBudgets + cơ chế nhập PRO/CCM
+ // GIỮ NGUYÊN (record dùng chung mọi phiếu cùng cặp — sửa được tự do khi phiếu chưa duyệt).
+ // SnapshotAt != null (trên phiếu DaDuyet) = cờ "đã chốt". Backfill Mig 67 cho DaDuyet cũ = live.
+ // NAMING: Ccm* = cột CCM (PeWorkItemBudget.InitialAmount/AdjustmentAmount); Pro* = cột PRO.
+ public decimal? ApprovedBudgetProInitialAmount { get; set; }
+ public decimal? ApprovedBudgetProAdjustmentAmount { get; set; }
+ public string? ApprovedBudgetProNote { get; set; }
+ public decimal? ApprovedBudgetCcmInitialAmount { get; set; }
+ public decimal? ApprovedBudgetCcmAdjustmentAmount { get; set; }
+ public string? ApprovedBudgetCcmNote { get; set; }
+ public decimal? ApprovedBudgetPrevSubmittedTotal { get; set; }
+ public int? ApprovedBudgetPrevSubmittedCount { get; set; }
+ public decimal? ApprovedBudgetPrevSelectedTotal { get; set; }
+ public int? ApprovedBudgetPrevSelectedCount { get; set; }
+ public DateTime? ApprovedBudgetSnapshotAt { get; set; }
+
public List Suppliers { get; set; } = new();
public List Details { get; set; } = new();
public List Quotes { get; set; } = new();
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
index 4317f5c..2a3ffb3 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
@@ -42,6 +42,16 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration x.CcmSuggestedPriceNote).HasMaxLength(1000);
// [S97 2026-07-01] Runtime finalize-fact — bit NOT NULL default false (mirror IsUrgent* bool).
b.Property(x => x.EndedByLevelFinalize).HasDefaultValue(false);
+ // [S133 Mig 67] Snapshot ngân sách phiếu DaDuyet — 6 decimal (18,2) + 2 note (1000)
+ // mirror precision cột ApprovedPrice*/PeWorkItemBudget. Tất cả nullable (backfill Mig 67).
+ b.Property(x => x.ApprovedBudgetProInitialAmount).HasPrecision(18, 2);
+ b.Property(x => x.ApprovedBudgetProAdjustmentAmount).HasPrecision(18, 2);
+ b.Property(x => x.ApprovedBudgetCcmInitialAmount).HasPrecision(18, 2);
+ b.Property(x => x.ApprovedBudgetCcmAdjustmentAmount).HasPrecision(18, 2);
+ b.Property(x => x.ApprovedBudgetPrevSubmittedTotal).HasPrecision(18, 2);
+ b.Property(x => x.ApprovedBudgetPrevSelectedTotal).HasPrecision(18, 2);
+ b.Property(x => x.ApprovedBudgetProNote).HasMaxLength(1000);
+ b.Property(x => x.ApprovedBudgetCcmNote).HasMaxLength(1000);
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
index 53e23e8..bfbe0fb 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
@@ -1322,6 +1322,24 @@ public static class DbInitializer
}
pe.Phase = current;
+ // [S133] Demo phiếu DaDuyet cũng phải có snapshot NS: fresh-DB migrate chạy TRƯỚC seed
+ // → backfill Mig 67 no-op trên bảng rỗng; thiếu block này demo DaDuyet hiển thị live-drift
+ // (reviewer S133 catch — mirror helper ApplyBudgetSnapshotOnFinalizeAsync, null-safe).
+ if (current == PurchaseEvaluationPhase.DaDuyet)
+ {
+ var pairDemo = pe.WorkItemId is Guid wiDemo
+ ? await db.PeWorkItemBudgets
+ .FirstOrDefaultAsync(b => b.ProjectId == pe.ProjectId && b.WorkItemId == wiDemo)
+ : null;
+ pe.ApprovedBudgetProInitialAmount = pairDemo?.ProInitialAmount;
+ pe.ApprovedBudgetProAdjustmentAmount = pairDemo?.ProAdjustmentAmount;
+ pe.ApprovedBudgetProNote = pairDemo?.ProNote;
+ pe.ApprovedBudgetCcmInitialAmount = pairDemo?.InitialAmount;
+ pe.ApprovedBudgetCcmAdjustmentAmount = pairDemo?.AdjustmentAmount;
+ pe.ApprovedBudgetCcmNote = pairDemo?.CcmNote;
+ pe.ApprovedBudgetSnapshotAt = nowUtc;
+ }
+
// Set winner nếu DaDuyet
if (current == PurchaseEvaluationPhase.DaDuyet
&& winnerSupplierCode is not null
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260717032812_AddPeApprovedBudgetSnapshot.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260717032812_AddPeApprovedBudgetSnapshot.Designer.cs
new file mode 100644
index 0000000..16c90fa
--- /dev/null
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260717032812_AddPeApprovedBudgetSnapshot.Designer.cs
@@ -0,0 +1,6405 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SolutionErp.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace SolutionErp.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260717032812_AddPeApprovedBudgetSnapshot")]
+ partial class AddPeApprovedBudgetSnapshot
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.6")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("RoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("UserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("UserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ActivatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApplicableType")
+ .HasColumnType("int");
+
+ b.Property("CeoApprovalThreshold")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsUserSelectable")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Version")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicableType", "IsActive");
+
+ b.HasIndex("Code", "Version")
+ .IsUnique();
+
+ b.ToTable("ApprovalWorkflows", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("AllowApproverEditBudget")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowApproverEditDetails")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowApproverFinalize")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowApproverSkipToFinal")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowReturnOneLevel")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowReturnOneStep")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowReturnToAssignee")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowReturnToDrafter")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(true);
+
+ b.Property("ApprovalWorkflowStepId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Name")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApproverUserId");
+
+ b.HasIndex("ApprovalWorkflowStepId", "Order");
+
+ b.ToTable("ApprovalWorkflowLevels", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovalWorkflowId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DepartmentId");
+
+ b.HasIndex("ApprovalWorkflowId", "Order");
+
+ b.ToTable("ApprovalWorkflowSteps", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovalWorkflowId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetManualAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("BudgetManualName")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("BypassProcurementAndCCM")
+ .HasColumnType("bit");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CurrentApprovalLevelOrder")
+ .HasColumnType("int");
+
+ b.Property("CurrentWorkflowStepIndex")
+ .HasColumnType("int");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DraftData")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DrafterUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("GiaTri")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("MaHopDong")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("NoiDung")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("ProjectId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("RejectedAtStepIndex")
+ .HasColumnType("int");
+
+ b.Property("RejectedFromPhase")
+ .HasColumnType("int");
+
+ b.Property("SlaDeadline")
+ .HasColumnType("datetime2");
+
+ b.Property("SlaWarningSent")
+ .HasColumnType("bit");
+
+ b.Property("SupplierId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("TemplateId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("TenHopDong")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("WorkflowDefinitionId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApprovalWorkflowId");
+
+ b.HasIndex("MaHopDong")
+ .IsUnique()
+ .HasFilter("[MaHopDong] IS NOT NULL");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("SlaDeadline");
+
+ b.HasIndex("SupplierId");
+
+ b.HasIndex("Phase", "IsDeleted");
+
+ b.ToTable("Contracts", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Decision")
+ .HasColumnType("int");
+
+ b.Property("FromPhase")
+ .HasColumnType("int");
+
+ b.Property("ToPhase")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "ApprovedAt");
+
+ b.ToTable("ContractApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("FileSize")
+ .HasColumnType("bigint");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Purpose")
+ .HasColumnType("int");
+
+ b.Property("StoragePath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId");
+
+ b.ToTable("ContractAttachments", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Action")
+ .HasColumnType("int");
+
+ b.Property("ContextNote")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityType")
+ .HasColumnType("int");
+
+ b.Property("FieldChangesJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhaseAtChange")
+ .HasColumnType("int");
+
+ b.Property("Summary")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserName")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "CreatedAt");
+
+ b.HasIndex("ContractId", "EntityType");
+
+ b.ToTable("ContractChangelogs", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b =>
+ {
+ b.Property("Prefix")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("LastSeq")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Prefix");
+
+ b.ToTable("ContractCodeSequences", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "CreatedAt");
+
+ b.ToTable("ContractComments", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverRoleSnapshot")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsBypassed")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("PhaseAtApproval")
+ .HasColumnType("int");
+
+ b.Property("Stage")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApproverUserId");
+
+ b.HasIndex("ContractId");
+
+ b.HasIndex("DepartmentId");
+
+ b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage")
+ .IsUnique()
+ .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage");
+
+ b.ToTable("ContractDepartmentApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovalWorkflowLevelId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("SignedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("SignedByFullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("SignedByUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApprovalWorkflowLevelId");
+
+ b.HasIndex("ContractId", "ApprovalWorkflowLevelId")
+ .IsUnique();
+
+ b.ToTable("ContractLevelOpinions", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DenNgay")
+ .HasColumnType("datetime2");
+
+ b.Property("DonGia")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonViTinh")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GhiChu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("MaDichVu")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("MoTa")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("TenDichVu")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("ThanhTien")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("ThoiGian")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TuNgay")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "Order");
+
+ b.ToTable("DichVuDetails", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DonGia")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonViTinh")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GhiChu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("KhoiLuong")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("MaCongViec")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("TenCongViec")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("ThanhTien")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("ThoiGianHoanThanh")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("YeuCauKyThuat")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "Order");
+
+ b.ToTable("GiaoKhoanDetails", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DonGia")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonViTinh")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GhiChu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("MaSP")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("MoTa")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("SoLuong")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TenSP")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("ThanhTien")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("ThueVAT")
+ .HasPrecision(5, 2)
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("XuatXu")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "Order");
+
+ b.ToTable("MuaBanDetails", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DonGiaToiDa")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonGiaToiThieu")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonViTinh")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GhiChu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("LoaiDichVu")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("PhamViDichVu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("SLA")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("TenDichVu")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("ThanhTien")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "Order");
+
+ b.ToTable("NguyenTacDvDetails", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property