---
name: ef-core-migration
description: Tạo/sửa/revert EF Core 10 migration cho SOLUTION_ERP. Dùng khi thêm entity mới, thay đổi schema, rollback migration, debug DesignTimeDbContextFactory fail. Số migration hiện tại + cái mới nhất → CHỈ đọc từ 2 nguồn SỐNG - docs/STATUS.md canonical HOẶC `ls src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/*.cs`. Bảng history bên dưới là KHO LỊCH SỬ, KHÔNG phải nguồn "mới nhất". Snapshot + Designer + Migration 3-file rule bắt buộc commit đủ.
when-to-use:
  - "thêm migration"
  - "EF Core migration"
  - "dotnet ef migrations add"
  - "revert migration"
  - "schema DB update"
  - "DbContext change"
  - "snapshot lỗi"
  - "DesignTimeDbContextFactory"
---

# EF Core Migration — SOLUTION_ERP

> **Context:** .NET 10 + EF Core 10 + SQL Server. DbContext: `ApplicationDbContext` ở `Infrastructure/Persistence/`. Startup: `SolutionErp.Api`.

## Migration history — 🔴 KHO LỊCH SỬ, **KHÔNG** phải nguồn "mới nhất"

> 🔴 **Cả hai con số đều đọc từ NGUỒN SỐNG, CẤM đọc từ bảng dưới:**
> - **số hiện tại** → [`docs/STATUS.md`](../../../docs/STATUS.md) row `Migrations` (canonical)
> - **cái mới nhất** → `ls src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/*.cs` (trừ `Designer`/`ModelSnapshot`), lấy row cuối
>
> 🧊 **Câu cũ ở dòng này ghi *"mới nhất = row cuối bảng"* — SAI và tự mâu thuẫn** với chính vế đầu (vế đầu trỏ canonical, vế sau lại tự nhận là nguồn). Bắt @S181 bằng **thách-CLEAN của `ring1-audit`**: bảng dừng ở **Mig 71** trong khi đĩa + `STATUS` đều **72** (`AddMaHangMucToContractSigningPlanLines`, 2026-08-06) — control dương cùng phép: `Mig 71` = 67 hit toàn kho ⇒ thước sống, 0-hit của `Mig 72` **trong tệp này** là vắng thật. ⇒ Bảng dưới **chỉ để tra lịch sử**; nó **sẽ luôn tụt sau** và điều đó **hợp lệ** — cái sai là câu tự nhận nó là mới nhất.

| # | Name | Tables added / changed |
|---|---|---|
| 1 | `Init` | 7 Identity tables (AspNetUsers/Roles/UserRoles/...) |
| 2 | `AddMasterData` | Suppliers, Projects, Departments |
| 3 | `AddPermissions` | MenuItems, Permissions |
| 4 | `AddForms` | ContractTemplates, ContractClauses |
| 5 | `AddContractsWorkflow` | Contracts, ContractApprovals, ContractComments, ContractAttachments, ContractCodeSequences |
| 6 | `AddNotifications` | Notifications |
| 7 | `AddWorkflowTypeAssignments` | WorkflowTypeAssignments (legacy admin override) |
| 8 | `AddVersionedWorkflows` | WorkflowDefinitions, WorkflowSteps, WorkflowStepApprovers + `Contracts.WorkflowDefinitionId` FK |
| 9 | `AddContractDetailsAndChangelog` | 7 per-type Details (ThauPhu/GiaoKhoan/NhaCungCap/DichVu/MuaBan/NguyenTacNcc/NguyenTacDv) + ContractChangelogs |
| 10 | `AddMasterCatalogs` | UnitsOfMeasure, MaterialItems, ServiceItems, WorkItems |
| 11 | `AddRoleShortNameAndUserDepartment` | +Role.ShortName + User.DepartmentId/Position (cột thêm, không bảng mới) |
| **12** | **`AddPurchaseEvaluations`** | **10 bảng module Duyệt NCC: PurchaseEvaluations + Suppliers + Details + Quotes + Approvals + Changelogs + Attachments + WorkflowDefinitions/Steps/StepApprovers** |
| **13** | **`AddPurchaseEvaluationCodeSequences`** | **1 bảng `PurchaseEvaluationCodeSequences` (Prefix PK, LastSeq) — atomic sequence cho MaPhieu format `PE/{YYYY}/{A\|B}/{Seq:D3}`** |
| **14** | **`AddBudgets`** | **⚠️ DROPPED Mig 50 (S61) — module Ngân sách cũ đã XÓA, thay bằng `PeWorkItemBudgets` per-gói-thầu. Bản gốc: 4 bảng Budgets + BudgetDetails + BudgetApprovals + BudgetChangelogs + nullable FK index `Contracts.BudgetId` & `PurchaseEvaluations.BudgetId` (append-only history, giữ row đánh dấu superseded).** |
| **15** | **`AddPurchaseEvaluationDepartmentOpinions`** | **1 bảng `PurchaseEvaluationDepartmentOpinions` (UNIQUE PEId+Kind, max 1 row mỗi PeDepartmentKind per phiếu — Phê duyệt/Ccm/MuaHang/SmPm)** |
| **16** | **`AddTwoStageDeptApprovalAndSmartReject`** | **3 bảng `Contract/PurchaseEvaluation/Budget DepartmentApprovals` (UNIQUE TargetId+Phase+Dept+Stage cho 2-stage NV.Review → TPB.Confirm per phòng × phase) + 4 ALTER (`Users.CanBypassReview` bit cho NV bypass + 3 `RejectedFromPhase` int cho smart reject jump-back). Phase 9 — đóng bug "NV duyệt được hết phase" anh Kiệt (FDC) báo. Logic 2-stage trong PurchaseEvaluationWorkflowService chỉ áp PE; HĐ + Budget defer.** |
| **17** | **`AddManualBudgetFieldsToPeAndContract`** | **4 ALTER (PE + HĐ × `BudgetManualName` nvarchar(200) + `BudgetManualAmount` decimal(18,2)) — manual budget fallback khi user không link Budget entity approved. KHÔNG XOR với BudgetId, cả 2 cùng null OK. Carry-forward `pe.BudgetManualName/Amount → contract` ở `CreateContractFromEvaluation`. Phase 9 — Session 11 (2026-05-07).** |
| **18** | **`AddPeWorkflowInnerStepsAndPositionLevel`** | **N-stage workflow PE — 1 CREATE TABLE `PurchaseEvaluationWorkflowStepInnerSteps` (Order, DepartmentId, PositionLevel, Name, SlaDays, IsRequired) + 2 ALTER (`Users.PositionLevel int?` 1=NV/2=PP/3=TP + `PEDeptApproval.InnerStepId Guid?`) + 3 IX + FK Cascade Step / Restrict Dept+InnerStep. Phase 9+ — Session 12 (2026-05-07).** |
| **19** | **`AlterPeDeptApprovalsUniqueFilteredForInnerSteps`** | **Filtered unique split: drop UNIQUE cũ Mig 16 → 2 filtered: legacy `WHERE InnerStepId IS NULL` (Stage Review/Confirm) + N-stage `WHERE InnerStepId IS NOT NULL` (per inner step). Tránh conflict 2 inner step cùng dept Stage=Confirm. Session 12.** |
| **20** | **`AddContractWorkflowInnerStepsAndAlterDeptApprovalUnique`** | **N-stage workflow Contract mirror PE Mig 18+19 — GỘP 1 migration: CREATE TABLE `WorkflowStepInnerSteps` + ALTER `ContractDeptApproval.InnerStepId` + DropIndex old + Recreate filtered legacy/N-stage + 3 IX + FK. Session 13 (2026-05-07).** |
| **21** | **`RefactorWorkflowToFlatModel`** | **🎯 DRASTIC REFACTOR (Session 16, 2026-05-08): bỏ phase enum legacy, dùng ChoDuyet=10 đơn nhất + currentStepIndex tracking. Workflow flat (Phòng × Cấp + Approvers). 4 ALTER (PE/Contract +CurrentWorkflowStepIndex/RejectedAtStepIndex) + 2 ALTER (WorkflowStep +DepartmentId/PositionLevel PE+Contract) + DROP TABLE × 2 (PE+Contract WorkflowStepInnerSteps Mig 18+20) + DROP COLUMN InnerStepId × 2 + DROP filtered indexes Mig 19/20 + RESTORE simple unique non-filtered × 2.** |
| **22** | **`AddApprovalWorkflowsV2`** | **🎯 V2 schema mới (Session 17, 2026-05-08) — riêng cho UAT trước khi drop legacy V1. 3 entity ApprovalWorkflow + Step + Level + enum ApplicableType (DuyetNcc=1 / DuyetNccPhuongAn=2 / Contract=3). 3 CREATE TABLE + UNIQUE (Code, Version) + FK Cascade Step→Workflow + Level→Step + FK Restrict Department + ApproverUserId. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (1 NV cụ thể qua ApproverUserId, OR-of-N rows cùng Order = same Cấp). DbInitializer +menu V2 + leaf `AwV2_DuyetNcc/DuyetNccPhuongAn`.** |
| **23** | **`AddApprovalWorkflowIdToPurchaseEvaluation`** | **Pin V2 vào PE — `PE.ApprovalWorkflowId Guid?` + EF FK Restrict. Workspace Select bắt buộc workflow lúc create + Validate `ApplicableType` match `PE.Type`. Session 17.** |
| **24** | **`AddCurrentApprovalLevelOrderToPe`** | **Service V2 wire — `PE.CurrentApprovalLevelOrder int?` track Cấp đang chờ trong Step hiện tại khi pin ApprovalWorkflowId. Null khi V1 legacy hoặc terminal. Service `ApproveV2Async` group Levels by Order = Cấp (OR-of-N), match `actor.Id ∈ ApproverUserId`, advance levelOrder++ trong Step → idx++ + reset levelOrder=1 → DaDuyet. Synthetic Policy `ForV2Schema()` cho FE nextPhases. Session 17.** |
| **25** | **`AddIsUserSelectableToApprovalWorkflows`** | **ALTER `ApprovalWorkflows` +`IsUserSelectable bit NOT NULL DEFAULT 0` — admin pin/unpin workflow cho user pick lúc create phiếu (multi-select, độc lập IsActive — multiple version cùng selectable). Backfill `WHERE IsActive=1 SET 1` giữ behavior cũ (active workflows vẫn pickable). Designer +badge "Cho user chọn" + button Ghim/Bỏ ghim + mutation toggle. Workspace dropdown filter `isUserSelectable=true` only. API `PATCH /api/approval-workflows-v2/{id}/user-selectable` admin-only. Session 18 (2026-05-08).** |
| **26** | **`AddPeLevelOpinionsForV2`** | **🎯 Section 5 dynamic theo Workflow V2 (Session 19, 2026-05-09) — bảng mới `PurchaseEvaluationLevelOpinions` UNIQUE composite (PEId, ApprovalWorkflowLevelId), FK Cascade Pe + Restrict Level. Comment nvarchar(2000) + SignedAt + SignedByUserId + SignedByFullName denorm. Service `ApproveV2Async` UPSERT auto khi NV duyệt (Q1=1B sync gắn với Duyệt, KHÔNG endpoint CRUD rời). Match level theo ApproverUserId; Admin override fallback first level (FE banner "Admin duyệt thay" khi SignedByUserId !== ApproverUserId). Comment empty → "(duyệt — không ý kiến)" placeholder Q4 bonus. Phiếu V1 legacy fallback Mig 15 4 box readOnly. Mig 15 deprecated cho V2 phiếu (drop sau UAT confirm Mig 27+).** |
| **27** | **`AddVisibilityAndDisplayLabelToMenuItems`** | **2 ALTER `MenuItems` (+`IsVisible bit`=1 +`DisplayLabel nvarchar(200)?`) — admin ẩn/hiện + đổi tên menu eOffice (fe-user). Không bảng mới. Session 20.** |
| **28** | **`AddAdvancedOptionsToApprovalWorkflows`** | **ALTER `ApprovalWorkflows` — advanced options (skip/optional level config). Session 22.** |
| **29** | **`RefactorAdvancedOptionsToPerLevelAndDrafterUser`** | **Refactor advanced options → per-`ApprovalWorkflowLevel` + DrafterUser scope. Session 22.** |
| **30** | **`AddAllowApproverEditBudgetToLevels`** | **ALTER `ApprovalWorkflowLevels` +`AllowApproverEditBudget bit`. Session 22.** |
| **31** | **`RefactorSkipToFinalToApproverLevel`** | **Per-Approver-slot skip-to-final (Level → Approver slot swap). Session 23.** |
| **32** | **`AddApprovalWorkflowToContract`** | **🎯 Contract V2 wire (Plan B S29) — 2 ALTER `Contracts` (+`ApprovalWorkflowId Guid?` +`CurrentApprovalLevelOrder int?`) cookie-cutter mirror PE Mig 23+24. Không bảng mới.** |
| **33** | **`AddContractLevelOpinions`** | **1 bảng `ContractLevelOpinions` (UNIQUE composite ContractId+LevelId, FK Cascade Contract + Restrict Level) — mirror PE Mig 26. Session 29.** |
| **34** | **`AddEmployeeProfiles`** | **HRM core — `EmployeeProfiles` + N satellite tables cùng parent (Pattern 12-ter). Session 34.** |
| **35** | **`AddHrmConfigs`** | **HRM config catalogs (LeaveTypes / Holidays UNIQUE composite / declarative KIND_CONFIG). Session 35.** |
| **36** | **`AddMeetingRooms`** | **Office — `MeetingRooms` + booking. Session 36-37.** |
| **37** | **`ExtendApplicableTypeForWorkflowApps`** | **Enum extend `ApplicableType` (+Proposal +WorkflowApps values). Không bảng mới. Session 37-38.** |
| **38** | **`AddProposals`** | **Module Đề xuất (Proposal) — tables + workflow V2 mirror PE/Contract. Session 37.** |
| **39** | **`AddWorkflowApps`** | **Workflow Apps skeleton — Leave/OT/Travel/Vehicle/Ticket request tables (Phase 10 Plan G-O4/O5). Session 38.** |
| **40** | **`AddAttendances`** | **Chấm công (Attendance) tables + Dashboard NS skeleton. Session 38.** |
| **41** | **`WireWorkflowAppsApprovalV2`** | **🎯 Phase 11 P11-A (S42) — +4 `{Leave,Ot,Travel,Vehicle}LevelOpinions` (UNIQUE composite + Cascade/Restrict) + `WorkflowAppCodeSequences` (atomic MaDonTu) + 4 ALTER `RejectedFromStatus` + enum `TravelRequest=9`.** |
| **42** | **`AddLeaveBalances`** | **🎯 Phase 11 P11-B (S43) — 1 bảng `LeaveBalances` (User×LeaveType×Year + Entitled/Used/Adjustment, UNIQUE composite + FK LeaveTypes Restrict). Trừ phép tự động khi đơn nghỉ duyệt cuối.** |
| **43** | **`FilterHolidayUniqueIndexByIsDeleted`** | **🎯 S45 — DropIndex + CreateIndex `IX_Holidays_Year_Date` UNIQUE filtered `WHERE [IsDeleted]=0` (was unfiltered). Fix reachable 500 khi recreate ngày lễ trên slot soft-deleted. Không bảng mới (table vẫn 91). gotcha #57 — soft-delete UNIQUE phải filter.** |
| **44** | **`AddVehicleAndDriverCatalogs`** | **🎯 Phase 11 P11-C (S51) — 2 catalog `Vehicles` + `Drivers` (`AuditableEntity`, filtered-unique Code day-1). Extend HrmConfigs declarative. Tables 91→92.** |
| **45** | **`FilterHrmCatalogUniqueIndexesByIsDeleted`** | **🎯 S51 — LeaveType + ShiftPattern + OtPolicy Code UNIQUE filtered `WHERE [IsDeleted]=0` (3 HRM catalog, gotcha #57 EXT). Index-only.** |
| **46** | **`AddSlaFieldsToItTicket`** | **🎯 Phase 11 P11-D (S52) — ItTickets +SlaDueAt/SlaWarnedSent/SlaBreached (SLA timer). Column-only, no new table.** |
| **47** | **`FilterMasterCatalogUniqueIndexesByIsDeleted`** | **🎯 S53 — Department + Supplier + Project Code UNIQUE filtered `WHERE [IsDeleted]=0` (gotcha #57 EXT Master, 6× cumulative). Index-only.** |
| **48** | **`AddProjectMasterFields`** | **🎯 S55 — Project +4 cột nullable (Year int · Investor 250 · Location 500 · Package 300). AddColumn-only, no new table. Kèm `SeedRealMasterDataAsync` ungated nạp 62 dự án + 71 hạng mục + 3 NCC real từ Excel.** |
| **49** | **`AddWorkItemToPurchaseEvaluation`** | **🎯 S57bis — PE.WorkItemId `Guid?` loose-Guid (KHÔNG FK vật lý — convention PE: ProjectId/SelectedSupplierId đều loose) + `IX_PurchaseEvaluations_WorkItemId`. AddColumn+CreateIndex-only, no new table. Guard = validator NotEmpty (create) + handler AnyAsync IsActive→Conflict.** |
| **50** | **`ReplaceBudgetModuleWithPeWorkItemBudgets`** | **🎯 S61 (2026-06-13) — bảng mới `PeWorkItemBudgets` (1 record/cặp Dự án × Hạng mục, UNIQUE filtered `[IsDeleted]=0`) + **DROP module Budget cũ** (Budgets/BudgetDetails/BudgetApprovals/BudgetChangelogs…) + PE/Contracts DROP `BudgetId` + **backfill `BudgetManualAmount→BudgetPeriodAmount` TRƯỚC DropColumn** (phiếu UAT giữ số) + DELETE menu/permission `Bg_*` IN-list children-first. ⚠️ database-agent advise: KHÔNG FK vật lý PE/Contracts→Budgets → no DropForeignKey · DropIndex TRƯỚC DropColumn (SQL 5074) · IN-list thay LIKE `Bg_%`. Ngân sách giờ per-gói-thầu nhập theo role PRO/CCM. **gotcha #63** (EF scaffold RenameColumn SAI-semantics → Add+UPDATE+Drop) + **#64** (`dotnet ef database update` áp Design-DB 0-rows ≠ Dev-DB → backfill chạy thật lần đầu trên prod).** |
| **51** | **`AddDepartmentParentId`** | **🎯 S65 — Department.ParentId `Guid?` loose-Guid (KHÔNG FK vật lý — org-tree phân cấp; `GET /departments/tree` ráp cây in-memory + rollup count theo `User.DepartmentId` + cycle-guard HashSet chặn tự-cha + vòng A→B→A). AddColumn-only, no new table.** |
| **52** | **`AddHoSoLinkToPurchaseEvaluation`** | **🎯 S65 — PE.HoSoLink `nvarchar(1000)?` hyperlink NAS (mục "e. Link hồ sơ", FE `<a target=_blank rel=noopener>` null-safe). AddColumn-only, no new table.** |
| **53** | **`AddPeUrgentAndCeoApprovalThreshold`** | **🎯 S69 — PE +`IsUrgentByPro`/`IsUrgentByCcm` (bit, cờ gấp PRO đỏ/CCM xanh) + ApprovalWorkflow +`CeoApprovalThreshold` `decimal(18,2)?` (ngưỡng gói CEO — CCM role CostControl duyệt-final khi winnerQuoteTotal < ngưỡng). 3 AddColumn, no new table. anh Kiệt FDC.** |
| **54** | **`AddPeSuggestedAndApprovedPrice`** | **🎯 S73 — PE +5 cột giá đề xuất (ProSuggestedMin/Max + CcmSuggested + ApprovedPriceAmount + ApprovedPriceSource) additive-nullable — CEO chọn giá chốt + CCM duyệt-done opt-in. anh Kiệt FDC go-live 22/06.** |
| **55** | **`AddCcmNoteToPeWorkItemBudget`** | **🎯 S74 — PeWorkItemBudget +`CcmNote nvarchar(1000)?` (ô "Ghi chú từ CCM" mirror ProNote). AddColumn-only, no new table.** |
| **56** | **`AddProBudgetSplitToPeWorkItemBudget`** | **🎯 S76 — PeWorkItemBudget +`ProInitialAmount`+`ProAdjustmentAmount` decimal(18,2)? (cột PRO "Ban hành lần đầu"+"V0/hiệu chỉnh", mirror CCM Initial/Adjustment). AddColumn additive + `Sql()` data-migrate `ProInitial=ProEstimate WHERE ProEstimate NOT NULL` (4 rows prod, gotcha #64). no new table. Form ngân sách → MA TRẬN 3 cột Dự án/PRO/CCM (bảng lưới `<table>`).** |
| **57** | **`AddPeSuggestedPriceNotes`** | **🎯 S77 — PurchaseEvaluations +`ProSuggestedPriceNote`+`CcmSuggestedPriceNote` nvarchar(1000)? (ghi chú kèm giá đề xuất PRO/CCM, mirror ProNote/CcmNote). 2 AddColumn additive-nullable, no new table.** |
| **58** | **`AddPeMultiWinnerAndLevelFinalize`** | **🎯 S87 — PE multi-winner + per-Level finalize: PurchaseEvaluationSuppliers +`IsWinner bit` (nhiều NCC trúng thầu, thay winner đơn lẻ `SelectedSupplierId`) + ApprovalWorkflowLevels +`AllowApproverFinalize bit` (per-Cấp kết-thúc-tại-đây, không lên CEO) + `Sql()` backfill `IsWinner=1 WHERE SupplierId = pe.SelectedSupplierId` (phiếu UAT giữ winner cũ, idempotent). 2 AddColumn, no new table.** |
| **59** | **`AddCcmBudgetPeriodToPurchaseEvaluation`** | **🎯 S87 — PurchaseEvaluations +`CcmBudgetPeriodAmount` decimal(18,2)? (ngân sách CCM cột Section-B kỳ này, per-PE, mirror `BudgetPeriodAmount` phía PRO). 1 AddColumn additive-nullable, no new table.** |
| **60** | **`AddPeEndedByLevelFinalize`** | **🎯 S97 — PE +`EndedByLevelFinalize bit NOT NULL DEFAULT 0` — cờ RUNTIME "phiếu ĐÃ kết thúc sớm bằng level-finalize" (set-true CHỈ nhánh finalize trong service); display `EndsBeforeCeo` cho phiếu DaDuyet đọc RUNTIME thay suy-CONFIG (gotcha #78 — phiếu opt-out lên CEO hết báo sai "không qua CEO"). Kèm `Sql()` backfill marker ⚠️ soi NHẦM bảng `Approvals` → match 0 (xem Mig 61). 1 AddColumn, no new table.** |
| **61** | **`BackfillEndedByLevelFinalizeFromChangelog`** | **🎯 S97 — DATA-ONLY no-schema (đính chính Mig 60): backfill query ĐÚNG bảng `PurchaseEvaluationChangelogs.ContextNote LIKE N'%Duyệt KẾT THÚC tại%'` + PhaseAtChange=7. PE 2 bảng lịch-sử TÁCH VAI: `Approvals` = per-approver record vs `Changelogs.ContextNote` = transition+marker (`LogTransitionAsync`). Idempotent, SQL-Server-only, prod-verified A/005/A/008 flag 0→1 + A/010 control unchanged (gotcha #79 — xác định đúng bảng-writer + verify marker-count prod TRƯỚC khi tin backfill; backfill=0-rows = cờ đỏ).** |
| **62** | **`ExpandSupplierFields`** | **🎯 S104 — Supplier 9→27 field (Phase A của 30-col NCC Excel import, anh Kiệt FDC): 18 AddColumn nullable + 18 DropColumn Down + `SupplierType +CaHai=6` append-only + enum `SupplierStatus` mới + guarded un-cram `Sql()` (clear legacy crammed Note 4 seeded rows exact-match, reversible). **No new table (89 giữ — 18 AddColumn/0 CreateTable)**. Seed fill-nulls-if-exists 30-field TRUONGGIANG/TANPHU/TGN/DONGDUONG idempotent. Commit `778fc98` shipped UN-closed-out → S104 docs-reconcile; prod-verified Run #488 PASS; 440 test #65-no-bite dù đổi chữ ký Create/UpdateSupplierCommand. Phase B (Excel-auto-map) defer.** |
| **63** | **`AddSupplierImportSourceFields`** | **🎯 S112 — Supplier +`SourceUpdatedAt datetime2?` + `SourceUpdatedBy nvarchar(200)?` (import-provenance cột 29/30 file Excel "Database NCC" — Phase B Excel-import Phase B). 2 AddColumn nullable + 2 DropColumn Down (reversible). **No new table (89 giữ)**. Prod-verified Run #496 PASS `__EFMigrationsHistory` top + 2/2-col present; commit `e100ef0`.** |
| **64** | **`AddSupplierPublishState`** | **🎯 S113 — Supplier +`IsPublic` bit NOT NULL default 0 (cổng công-bố "thiếu Mã NCC → nháp/ẩn" R4): AddColumn + `Sql("UPDATE Suppliers SET IsPublic=1")` backfill 37/37 prod→public + DropIndex→CreateIndex đổi Code filtered-unique `WHERE [IsDeleted]=0 AND [Code]<>''` (cho phép nhiều nháp Code=''). **No new table (89 giữ)**. Reversible Down (⚠️ edge: rollback sau khi có ≥2 nháp Code='' sẽ kẹt CreateIndex filter cũ). Prod-verified Run #497; commit `5fa11b5`. Kèm gotcha #80 (null-Code NRE NewSupplier).** |
| **65** | **`BackfillPeQuoteIsSelectedAndWinnerInvariant`** | **🎯 S114 — DATA-ONLY no-schema (multi-NCC per hạng mục anh Kiệt): backfill **2-CHIỀU** thiết lập bất-biến `Supplier.IsWinner == Any(Quote.IsSelected)`. CHIỀU-1 `IsWinner→IsSelected` (phiếu cũ whole-supplier winner Mig58 → set IsSelected mọi quote NCC, **preserve tổng** winnerQuoteTotal) + CHIỀU-2 `IsSelected→IsWinner` (dormant/seed EXISTS). Idempotent (`WHERE ...=0`), SQL-Server-only (SQLite EnsureCreated bỏ qua migration), Down **no-op** (data backfill không reversible, mirror Mig 61). **No new table (89 giữ)** — snapshot UNCHANGED (0 CreateColumn). Prod-verified bundle rotate ×2 `D51OYyGV`/`BVdssm5S`; commit `d436af2`. Kèm **gotcha #81** (delete-writer sót re-derive IsWinner → phantom HĐ giaTri=0 — verify-bắt).** |
| **66** | **`AddPeApprovedPriceRange`** | **🎯 S117 — PE +`ApprovedPriceMaxAmount decimal(18,2)?` (feature "duyệt theo khoảng Min–Max" anh Kiệt FDC "chọn cả 2 min và max"): source `"ProMinMax"` = Amount(Min)+MaxAmount(Max), bất-biến `MaxAmount != null ⟺ Source=="ProMinMax"` + Min≤Max (guard service `ApplyApprovedPriceOnFinalize` + FluentValidation). 1 AddColumn nullable, **no backfill** (phiếu cũ Source-đơn Max=null nguyên vẹn), **no new table (89 giữ)**, reversible Down (DropColumn). HĐ.GiaTri KHÔNG đọc giá chốt (=SUM báo giá NCC được chọn) → 0 tác động HĐ (range = record-of-decision hiển thị). Prod-verified Run #387 (`ApprovedPriceMaxAmount` col decimal(18,2) + `__EFMigrationsHistory` top); commit `316a82f`.** |
| **67** | **`AddPeApprovedBudgetSnapshot`** | **🎯 S133 — PE freeze ngân sách phiếu DaDuyet (UAT anh Kiệt: cập nhật NS gói thầu làm phiếu ĐÃ DUYỆT đổi số theo): snapshot **11 cột** AddColumn, chốt tại finalize choke-point + site-5 admin-override + backfill `Sql()` OUTER APPLY 18/18 phiếu prod; **no new table (89 giữ)**; display-gate `budgetFrozen` — phiếu DaDuyet đọc snapshot 🔒 badge, phiếu chưa duyệt đọc live "bản cập nhật gần nhất"; reviewer bắt **#81-EXT indirect-assignment** (2 site gán Phase thẳng bypass choke-point → FIXED + test site-5); test 509→520 (+11 `PeBudgetFreezeTests`). Prod-verified Run #398 (mig top `20260717032812` + backfill 18/18); commit `d4fbced`.** *(row bổ sung @S151 — ring1 TRƯỢT-1 bắt bảng dừng ở 66 trong khi canonical Mig 67)* |
| **68** | **`AddPeAllowApproverDelete`** | **🎯 S157 — PE nút XÓA phiếu ở màn duyệt: +`AllowApproverDelete bit` trên **`ApprovalWorkflowLevels`** (config PER-CẤP-DUYỆT, KHÔNG phải cột trên PE — admin bật cấp nào được xóa phiếu ngay màn duyệt). 1 AddColumn, **no new table (89 giữ)**, reversible Down (DropColumn). Ship 2 đợt `b1bae77`+`4464a46`+`f4494cf`, cicd PASS, test 532→562; byte-verify marker `allowApproverDelete`/`by-approver`/`deleted=1` (S156).** |
| **69** | **`AddContractSigningPlans`** | **🎯 S161 — module MỚI Kế hoạch Ký kết HĐ (KHKK): **7 `CreateTable`** `ContractSigningPlans` + `~Approvals` + `~Attachments` + `~Changelogs` + `~DossierItems` + `~LevelOpinions` + `~Lines` — **89→96 bảng**, mirror khuôn PE/Contract V2 (Approvals/Changelogs/LevelOpinions tách vai). Menu-key `KeHoachKyKet` sinh **policy** KHÔNG sinh menu-row; 15 policy CRUD+Dossier (W2); duyệt V2 3-trạm (W3); 🔴 gotcha **#85** gate FE dùng đúng key policy endpoint, KHÔNG OR key con. Commit `a8027c0` (W1/5 wave S161).** |
| **70** | **`AddContractCatalogEntries`** | **🎯 S164/S165 — K1 chuỗi SP-002: danh mục hạng mục HĐ (86 dòng B1-*/A1-*… nhóm duyệt N1-N8). **1 `CreateTable`** `ContractCatalogEntries` ⇒ **96→97 bảng**. Seed ungated idempotent qua `DbInitializer` (khuôn real-data S55). Commit `50e6d8c`.** |
| **71** | **`AddKhkkApprovalGroupCatalogAndFinalizeRuntime`** | **🎯 S164/S165 — K2+K3: phiếu KHKK +`ApprovalGroup` (N1-N8, backfill=1) + `Line.CatalogEntryId`/`TenHangMuc` (loose-Guid, denorm-tại-thời-điểm-gán) + `EndedByLevelFinalize` runtime-flag + index-swap unique 3-cột. **AddColumn ×4 + index, 0 `CreateTable`** (97 giữ). Submit-guard chặn line chưa gán hạng mục; đường GÁN land @S168 (`UpdateContractSigningPlanLineCommand` + `PUT …/lines/{lineId}`, commit `0bfadc2`). Commit `0779f2d`.** |


Total: **97 bảng** `sys.tables` (= 96 dbo-app + `__EFMigrationsHistory`; số canonical → `docs/STATUS.md`; last schema-changing **Mig 70 `AddContractCatalogEntries` +1 bảng 96→97 (S164/S165 K1 SP-002, commit `50e6d8c`)** — Mig 71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` (K2) = AddColumn ×4 + index-swap unique 3-cột + backfill, 0 CreateTable; trước đó Mig 69 +7 bảng KHKK 89→96, Mig 50 net 93→88-app, Mig 51-68 đều AddColumn/index/data-only không đổi số bảng). Xem `docs/database/schema-diagram.md` migration table + §11-15 module ERD (§16+ Mig 27-66 chi tiết pending).

## N-stage workflow pattern (Mig 18-20 — Session 12-13)

Architecture decision đáng ghi cho session sau:

**Pattern:** Mỗi WorkflowStep cha (= 1 phase) cấu hình động chuỗi InnerSteps con theo Department × PositionLevel với Order sequential. Approver cần khớp DeptId + PositionLevel + Order tiếp theo chưa duyệt. CanBypassReview cho user level cao hơn skip cấp dưới cùng dept.

**Per-module migration** (PE first, mirror sang Contract sau):
- PE: 2 migration tách (Mig 18 schema + Mig 19 alter index) — vì index issue phát hiện sau khi schema đã commit
- Contract: 1 migration GỘP (Mig 20) — gộp CREATE TABLE + ALTER + DropIndex + Recreate filtered. Cleaner cho mirror.

**Filtered unique trick:** legacy 2-stage rows (Mig 16, Stage=Review/Confirm) + N-stage rows (per InnerStep) cùng table `*DepartmentApprovals` → cần unique split:
- `UX_*_Phase_Dept_Stage WHERE InnerStepId IS NULL` (legacy, prevent duplicate Review/Confirm cùng phase × dept)
- `UX_*_Phase_InnerStep WHERE InnerStepId IS NOT NULL` (N-stage, prevent duplicate per inner step)

**Backward compat 100%:** workflow no InnerSteps configured → service fallback legacy 2-stage logic Mig 16. Data legacy InnerStepId=null vẫn enforce unique cũ qua filtered index.

**~~Budget defer~~ 🧊 MODULE ĐÃ XÓA (Mig 50, S61):** khối dưới viết khi Budget còn sống — nay `BudgetPolicy` = 0-hit toàn `*.cs`, module thay bằng `PeWorkItemBudgets` (ngân-sách per-gói-thầu). Giữ nguyên văn làm bia-mộ, KHÔNG còn là việc: ~~Budget chưa có versioned WorkflowDefinition entity (hardcoded `BudgetPolicy.Default`). Để mirror N-stage Budget cần migration `AddBudgetVersionedWorkflow` trước (4 bảng + ALTER `Budget.WorkflowDefinitionId?`). Defer cho user quyết riêng.~~

**Phase 7 pending:**
- `AddPePaymentTermFields` — tách `PurchaseEvaluations.PaymentTerms` JSON thành 6 column riêng (Session 11+++++ đã thay UI Workspace `<Textarea>` JSON → `<Select>` 8 preset, BE schema vẫn nvarchar(max). Migration tách field defer cho khi BE cần aggregate/filter từng terms riêng)
- `AddPeDepartmentOpinions` — thêm 4 field Ý kiến phòng ban (Phê duyệt/CCM/MuaHàng/SM-PM) vào header hoặc table riêng (đã làm Mig 15 — bảng riêng UNIQUE PEId+Kind)

**~~Phase 8 pending (Budget refinement)~~ 🧊 HẾT HIỆU-LỰC (Mig 50, S61 — module Budget DROP, 2 mục dưới không bao giờ làm):**
- ~~`AddBudgetCodeSequences` — atomic sequence khi format MaNganSach chốt (mirror Contract/PE pattern, hiện Random.Shared)~~
- ~~`AddBudgetVersionedWorkflow` — nếu Solutions cần admin config UI (3 bảng `BudgetWorkflowDefinitions/Steps/StepApprovers` + `Budgets.WorkflowDefinitionId?`)~~

**Phase 8 update (2026-04-29 Session 5):**
- Migration 15 `AddPurchaseEvaluationDepartmentOpinions` — 1 bảng riêng (UNIQUE PEId+Kind), max 4 row mỗi phiếu cho 4 phòng ban (Phê duyệt/Ccm/MuaHang/SmPm). UPDATE in-place khi user đổi ý, audit qua Changelog.
- Tests: `tests/SolutionErp.Domain.Tests/` (58 test policy state machine) + `tests/SolutionErp.Infrastructure.Tests/` (96 test = codegen + 2-stage/N-stage + AuthorizePolicy + V2 actor scope + WorkflowApps ApproveV2 + LeaveBalance/guard). **Total 154 test pass** (current S43; S22-S25 baseline was 111). S22-S23 +20 cumulative regression: ReturnMode + DraftGuard + Reflection AuthorizePolicy + V2 actor scope reject + per-NV lookup discrimination (Plan N+O). Mig 21 drop 19 legacy (PE 2-stage S9 + PE N-stage S12 + Contract N-stage S13 + PE Reject S14) — flat workflow stabilized post-S22 V2 ground truth.
- CI optimize 3 fix (29/04):
  - Manual checkout bypass github.com (gotcha #39) — fix TCP timeout 21s
  - Path filter docs-only skip (gotcha #41) — commit MD-only KHÔNG trigger CI
  - npm junction cache (gotcha #40) — rolled back, debug session sau
- Migration verify pattern thêm: chạy `dotnet test SolutionErp.slnx` local trước mỗi migration commit để chống regression policy/codegen.

**Phase 7 update (2026-04-28):**
- Migration 14 `AddBudgets` — ⚠️ **DROPPED Mig 50 (S61)** — module Budget cũ đã XÓA, thay bằng `PeWorkItemBudgets`. (Bản gốc: 4 bảng Budgets/Details/Approvals/Changelogs + 2 cột `BudgetId?` thêm vào Contracts & PurchaseEvaluations. Workflow hardcoded `BudgetPolicy.Default` chưa versioned.)
- 14 demo user thật `@solutions.com.vn` qua `SeedDemoUsersAsync` reconcile (PRO 5 + CCM 7 + ISO 1 + CEO 1).

**Phase 6 update (2026-04-24):**
- Enum `PurchaseEvaluationAttachmentPurpose.ComparisonTable = 4` mới (file bảng so sánh tổng).
  Int column, không cần migration.
- `BackfillUserEmailDomainAsync` trong DbInitializer — rename in-place 4 field
  Email/NormalizedEmail/UserName/NormalizedUserName. Idempotent.

## Commands (chạy từ root repo)

### Thêm migration mới

```powershell
# Sau khi chỉnh Domain entity + EF config + DbContext DbSet
dotnet ef migrations add <MigrationName> `
  --project src/Backend/SolutionErp.Infrastructure `
  --startup-project src/Backend/SolutionErp.Api `
  --output-dir Persistence/Migrations
```

**Naming convention:** PascalCase, verb prefix:
- `Add<Thing>` — thêm entity / bảng
- `Update<Thing>` — thay đổi schema
- `Remove<Thing>` — xóa column / table
- `Rename<Thing>` — đổi tên

### Apply migration to DB

```powershell
# Dev: LocalDB SolutionErp_Dev
dotnet ef database update `
  --project src/Backend/SolutionErp.Infrastructure `
  --startup-project src/Backend/SolutionErp.Api

# Hoặc đơn giản: chạy API → DbInitializer tự Migrate
dotnet run --project src/Backend/SolutionErp.Api
```

### Revert 1 migration (rollback)

```powershell
# Rollback về migration trước đó (ví dụ về sau AddWorkflowTypeAssignments)
dotnet ef database update AddWorkflowTypeAssignments `
  --project src/Backend/SolutionErp.Infrastructure `
  --startup-project src/Backend/SolutionErp.Api

# Xóa file migration (3 file, xem dưới)
dotnet ef migrations remove `
  --project src/Backend/SolutionErp.Infrastructure `
  --startup-project src/Backend/SolutionErp.Api
```

### Gen SQL script (review trước khi apply prod)

```powershell
# Script từ 1 migration → latest
dotnet ef migrations script AddWorkflowTypeAssignments `
  --project src/Backend/SolutionErp.Infrastructure `
  --startup-project src/Backend/SolutionErp.Api `
  --output tmp/migration-7-to-8.sql

# Idempotent (check if exists trước mỗi operation)
dotnet ef migrations script --idempotent `
  --project src/Backend/SolutionErp.Infrastructure `
  --startup-project src/Backend/SolutionErp.Api
```

## 3-file rule (BẮT BUỘC commit đủ)

Mỗi `migrations add` sinh **3 file** — phải commit đủ:

```
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/
├── {Timestamp}_{Name}.cs              ← migration logic (Up/Down)
├── {Timestamp}_{Name}.Designer.cs     ← model snapshot tại thời điểm migration
└── ApplicationDbContextModelSnapshot.cs  ← current model state (được overwrite mỗi migration)
```

**Gotcha #17:** Thiếu `ModelSnapshot.cs` → `migrations add` kế tiếp sẽ sinh duplicate columns. Thiếu `Designer.cs` → không revert được.

## Pitfalls thường gặp

### P1 — DesignTime DbContext resolve fail

**Triệu chứng:** `dotnet ef migrations add` → `Unable to resolve service for type 'DbContextOptions<ApplicationDbContext>'`.

**Nguyên nhân:** EF tooling chạy đứng ngoài runtime DI, cần factory riêng.

**Fix:** Đã có `src/Backend/SolutionErp.Infrastructure/Persistence/DesignTimeDbContextFactory.cs`. Nếu connection string thay đổi → update factory, không chỉ `appsettings.json`.

### P2 — Table rename / column rename gen ra DROP + CREATE

**Triệu chứng:** Migration sinh `DropColumn` + `AddColumn` thay vì `RenameColumn` → mất data.

**Fix:** Sửa migration thủ công sang `migrationBuilder.RenameColumn(...)`. Hoặc dùng `[Column("newname")]` attribute để EF tự detect rename.

### P3 — Query filter (soft delete) khi thêm FK

**Triệu chứng:** Warning `The entity type 'X' has a global query filter but referencing entity 'Y' doesn't. This may lead to inconsistent results`.

**Fix:** Entity reference cũng phải có query filter:
```csharp
builder.HasQueryFilter(x => !x.IsDeleted);
```

Hoặc dùng `.IgnoreQueryFilters()` cho query cần bypass.

### P4 — FK cascade/restrict khác với expectation

**Triệu chứng:** Migration apply OK nhưng DELETE parent → cascade sang child (hoặc ngược lại restrict block).

**Fix:** Explicit config trong `IEntityTypeConfiguration<T>`:
```csharp
builder.HasOne(x => x.Contract)
    .WithMany(c => c.Approvals)
    .HasForeignKey(x => x.ContractId)
    .OnDelete(DeleteBehavior.Cascade);  // hoặc Restrict, SetNull
```

**Case study quan trọng:** `Contracts.WorkflowDefinitionId` → `WorkflowDefinitions.Id` **PHẢI dùng `Restrict`** để protect HĐ cũ khi admin archive version (xem `workflow-contract.md` invariants).

### P5 — Nullable reference type gen column NOT NULL

**Triệu chứng:** Property `public string? Description` → migration gen `NOT NULL`.

**Fix:** Check `builder.HasQueryFilter` chỗ config, hoặc explicit `builder.Property(x => x.Description).IsRequired(false)`.

### P6 — `AddVersionedWorkflows` duplicate seed

**Case study hiện tại:** Nếu thêm migration mới sau `AddVersionedWorkflows`, cẩn thận KHÔNG trigger `DbInitializer.SeedWorkflowDefinitionsAsync` 2 lần. Check `if (!db.WorkflowDefinitions.Any())` trong DbInitializer.

## Workflow khi thêm entity mới (example: add AuditLog)

```
1. Domain/AuditLogs/AuditLog.cs — tạo entity (BaseEntity hoặc AuditableEntity)
2. Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs
   - ToTable("AuditLogs")
   - Unique index trên (EntityType, EntityId) nếu cần
3. Infrastructure/Persistence/ApplicationDbContext.cs
   - public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
4. Application/Common/Interfaces/IApplicationDbContext.cs
   - DbSet<AuditLog> AuditLogs { get; }
5. dotnet ef migrations add AddAuditLogs
6. Review file .cs sinh ra → OK
7. dotnet ef database update → LocalDB apply
8. Test: dotnet run → check bảng được tạo
9. Commit [CLAUDE] Domain+Infra: add AuditLog entity + migration
10. ⚠️ UPDATE docs/database/schema-diagram.md (ERD + migration table)
11. UPDATE docs/STATUS.md nếu là work lớn
```

## Apply prod (VPS)

```powershell
# Trên VPS, sau khi deploy code:
# Option 1: auto migrate — DbInitializer.MigrateAsync() chạy khi API startup
# Option 2: manual script
dotnet ef migrations script <LastApplied> --idempotent --output migrate.sql
# Review migrate.sql → chạy qua sqlcmd:
sqlcmd -S .\SQLEXPRESS -d SolutionErp -U vrapp -P <pw> -i migrate.sql
```

**Prod safety:**
- Backup trước: `scripts/backup-sql.ps1`
- Dry-run idempotent script local trước
- Test rollback plan: script from-N-to-M-1 sẵn

## Code pointers

- `src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs` — DbSet toàn bộ bảng app (số bảng/migration → `docs/STATUS.md`)
- `src/Backend/SolutionErp.Infrastructure/Persistence/DesignTimeDbContextFactory.cs` — EF tooling factory
- `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs` — seed + warn + migrate runtime + backfill (idempotent reconcile pattern)
- `src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/` — IEntityTypeConfiguration<T> per entity
- `src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs` — interface Application layer

## Related

- `docs/database/database-guide.md` — conventions + migration workflow chi tiết
- `docs/database/schema-diagram.md` — **ERD** (số bảng → `docs/STATUS.md`) + §11 PE + §12 ~~Budget~~ (REMOVED Mig 50) + §13 PEDeptOpinions (Mig 15) + §14 ApprovalWorkflow V2 (Mig 22-25) + §15 PE Level Opinions V2 (Mig 26); §16+ Mig 27-66 detail pending (xem migration table)
- `docs/gotchas.md` #7, #17, #38 — migration pitfalls + Identity 4-field rename
