# LĂNG KÍNH 3/4 — SCHEMA 7 BẢNG (reviewer, S157)

Đích chấm: `spec-ke-hoach-ky-ket-hd-28-07-2026.md` §②.2 (dòng 113-136).
Nguồn đối chiếu ĐÃ ĐỌC: `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs` ·
`src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs` (+ grep xác minh trên config/service/enum).

## Verdict

**GO-WITH-FIXES** — hình dạng 7 bảng ĐÚNG HƯỚNG (loose-Guid, decimal(18,2), query-filter, FK Cascade/Restrict
đều khớp twin thật), nhưng **5 điểm HIGH phải đóng TRƯỚC Wave 1** vì chúng làm migration/CQRS/FE sai IM LẶNG
(build vẫn xanh): thiếu bảng lịch-sử-transition mà chính service được lệnh copy có ghi vào, thiếu đăng ký ở 3 site
cross-module `ApprovalWorkflowV2AdminFeatures`, thiếu 4 cột Changelog, không khai base-class cho bảng #2-#7, và
bộ số enum phase đụng khuôn FE đang được copy.

Phân bố: **5 HIGH · 7 MAJOR · 3 MEDIUM · 7 CỦNG CỐ = 22 điểm**.

## Bảng THỪA

**T1 [MAJOR] — #7 `ContractSigningPlanCodeSequences` gộp được, KHÔNG mất gì.**
Repo đã có **4 bảng giống hệt nhau về cấu trúc** `(Prefix string PK · LastSeq int · UpdatedAt)`:
`Contracts/ContractCodeSequence.cs:6-11` · `Office/ProposalCodeSequence.cs:8-13` ·
`Office/WorkflowAppCodeSequence.cs:14-19` · (+ `PurchaseEvaluationCodeSequence`, mirror khai ở
`ProposalCodeSequence.cs:4`). Trong đó `WorkflowAppCodeSequence` **đã là bảng DÙNG CHUNG cho 4 module Office**,
phân biệt nhau bằng Prefix có không-gian-tên: `"DT/LR/2026"` (nghỉ phép) · `"DT/OT/2026"` (OT) ·
`"DT/CT/2026"` (công tác) · `"DX/XE/2026"` (đặt xe) — xem `WorkflowAppCodeSequence.cs:8-11`.
Prefix `"KHKK/{YYYY}"` nhét vào bảng đó là **0 va chạm** (khoá là chuỗi, đã namespaced sẵn).
⇒ Cắt #7 ⇒ còn **6 bảng**, giảm 1 CreateTable + 1 configuration + 1 DbSet, **không mất tính atomic**.

**T2 [MAJOR] — lý do bác biến-thể-5-bảng đứng NỬA, đổ NỬA.**
- Nửa ĐỨNG: "giữ #6 Changelogs" có bằng chứng thật — Mig 60/61 phải backfill từ changelog (CLAUDE.md ghi
  Mig 60 soi nhầm bảng Approvals → Mig 61 mới đúng Changelogs). Thứ tự ưu tiên spec đưa ("cắt #7 trước,
  giữ #6") là ĐÚNG.
- Nửa ĐỔ: mệnh đề *"mất mã phiếu atomic"* **không suy ra được**. Tính atomic đến từ **transaction
  SERIALIZABLE trong CodeGen service**, không đến từ việc bảng đó thuộc riêng module — nói rõ trong chính
  comment của cả 3 bảng: `ContractCodeSequence.cs:5` · `ProposalCodeSequence.cs:7` ·
  `WorkflowAppCodeSequence.cs:12-13`. Vậy "cắt #7" ⇏ "mã tay"; nó chỉ là "dùng chung bảng sequence sẵn có".
- Các bảng còn lại **KHÔNG gộp được** (đã xét từng cặp): #2 Lines (multi-winner, per-NCC) không nhét vào
  header; #3 DossierItems không nhét vào #5 Attachments vì trạng thái `ChuaNop` là hàng **không có file** —
  gộp là phải đẻ attachment giả; #4 LevelOpinions bị máy-móc admin V2 truy vấn theo `(phiếu × Level)` nên
  phải là bảng riêng có UNIQUE.

## Bảng THIẾU

**H1 [HIGH] — không có `ContractSigningPlanApprovals` (lịch sử transition), trong khi service được lệnh copy CÓ ghi vào bảng đó.**
`ContractWorkflowService.cs:151` ghi thẳng: `db.ContractApprovals.Add(new ContractApproval { ContractId = …,
FromPhase = fromPhase, ToPhase = fromPhase, … })`. Twin PE cũng có nhánh này: `PurchaseEvaluation.cs:137`
(`List<PurchaseEvaluationApproval> Approvals`) + index chuyên dụng `(PurchaseEvaluationId, ApprovedAt)` ở
`PurchaseEvaluationConfiguration.cs:168`; `PurchaseEvaluationApproval.cs:6-7` ghi rõ *"Lịch sử phê duyệt —
giống ContractApproval pattern"*. Spec §2.1 bắt copy `ApproveV2Async:217-394` ⇒ đoạn copy sẽ trỏ vào một
bảng KHÔNG TỒN TẠI trong danh sách 7 bảng.
⇒ Chọn 1 trong 2 và **ghi vào spec**: (i) thêm bảng #8 `…Approvals`, hoặc (ii) tuyên bố "Changelog gánh vai
Approvals" **kèm** bù cột `FromPhase/ToPhase/Decision/Comment/ApprovalWorkflowLevelId` (hiện #6 chỉ có MỘT
trường phase là `PhaseAtChange`, không có cặp from→to, không có Decision).

**H2 [HIGH] — module thứ 8 phải đăng ký ở 3 site liệt-kê-thủ-công trong `ApprovalWorkflowV2AdminFeatures.cs`; spec chỉ nói chung chung.**
File này liệt kê **từng bảng `*LevelOpinions` một** ở 3 chỗ:
`:911` + `:929` (Merge "chữ ký còn sống" — `db.ProposalLevelOpinions…` / `db.TravelRequestLevelOpinions…`) ·
`:971` + `:992` (rows.AddRange dựng `RetainedOpinionRef`) · `:1044` + `:1056` (dọn chữ ký MỒ CÔI).
Chuỗi guard S155 đứng trên dữ liệu này: `:651` *"F-5/F-5a — chặn LỊCH SỰ trước khi FK nổ"* và `:733`
*"Dọn rác chữ ký MỒ CÔI trước khi xoá dòng"*.
⇒ Nếu `ContractSigningPlanLevelOpinions` không được thêm vào **cả 3 site**, admin sửa quy trình `type = 10`
sẽ **không nhìn thấy** chữ ký của phiếu KHKK, xoá Level đang có chữ ký → FK Restrict nổ **500** — đúng lớp lỗi
S155 vừa vá xong. Spec §2.4 chỉ viết *"các site BE `ApprovalWorkflowV2AdminFeatures.cs`"* (không nêu 3 site này,
chỉ nêu map typeCode) ⇒ chưa đủ để người làm không sót.

## Cột sai

**C1 [HIGH] — #6 Changelogs thiếu 4 cột so với twin.**
`PurchaseEvaluationChangelog.cs:14-22` có: `EntityType` · `EntityId` · `Action` · `PhaseAtChange` · `UserId` ·
`UserName` · `Summary` · `FieldChangesJson` · `ContextNote`. Spec #6 chỉ liệt `Action · PhaseAtChange · UserId ·
Summary · ContextNote` ⇒ **thiếu `EntityType`, `EntityId`, `UserName`, `FieldChangesJson`**.
Hệ quả đo được: index twin `(PurchaseEvaluationId, EntityType)` (`PurchaseEvaluationConfiguration.cs:188`) mất
chỗ dựa; `LogTransitionAsync` (`ContractWorkflowService.cs:405`) gọi `changelog.LogWorkflowTransitionAsync(...)`
— copy service changelog sang module mới sẽ không biên dịch nếu entity thiếu trường; và mất khả năng ghi diff
trường (`FieldChangesJson`, twin khai `nvarchar(max)` ở `PurchaseEvaluationConfiguration.cs:185`).

**C2 [MAJOR] — `Status int` bắt đầu từ **0** (`ChuaNop=0`) là lệch quy ước + bẫy JavaScript.**
Mọi enum trong repo bắt đầu ở 1: `Office/Enums.cs:9` (`Confirmed=1`) · `:19` (`Nhap=1`) · `:31` (`Nhap=1`) ·
`:41` (`Hardware=1`) · `:50` (`Low=1`) · `:58` (`Open=1`); `ApprovalWorkflow.cs:55` (`DuyetNcc=1`).
Số 0 là giá trị **falsy** trong JS ⇒ `status ? A : B`, `if (filter.status)`, `?status=0` trên query-string đều
rơi im lặng ở FE. Đề nghị `ChuaNop = 1` (giữ `Khac = 99` của `Kind` — cái đó ĐÚNG khuôn `ItTicketCategory.Other = 99`,
`Office/Enums.cs:45`).

**C3 [MAJOR] — ~13 cột chuỗi không khai độ dài ⇒ EF sinh `nvarchar(max)`.**
Spec chỉ khai 2 độ dài (`HoSoLink 1000`, `Comment 2000`). Không khai: `MaKeHoach`, `GhiChu`, `Name` (#3), `Note`
(#2/#3), `TvgsName`, `Summary`, `ContextNote`, `SignedByFullName`, `FileName/Path/ContentType` (#5), `Prefix` (#7).
Twin khai đủ: `PurchaseEvaluationConfiguration.cs:22` (`HoSoLink` 1000) · `:184` (`ContextNote` 2000) ·
`ContractLevelOpinionConfiguration.cs:21-22` (`Comment` 2000, `SignedByFullName` 200 **IsRequired**).
`nvarchar(max)` không index được và không so sánh được trong filtered-unique.

**C4 [MAJOR] — `SlaDeadline?` chưa có người đọc ⇒ đúng lớp "cột có mà 0 ai đọc" mà chính spec cấm (dòng 136).**
Thiếu `SlaWarningSent` (twin `PurchaseEvaluation.cs:26`) và thiếu index `SlaDeadline` (twin
`PurchaseEvaluationConfiguration.cs:62`). Job SLA hiện chỉ đọc/ghi cờ này trên nhánh Contract:
`SlaExpiryJob.cs:139` (`.Where(c => !c.SlaWarningSent …)`) và `:169` (`c.SlaWarningSent = true;`).
⇒ Hoặc bỏ `SlaDeadline`, hoặc thêm cờ + index + khai rõ wave nào đăng ký module vào `SlaExpiryJob`.

**C5 [MAJOR] — header thiếu `DepartmentId`, làm vế "DeptManager" của guard trình mất chỗ neo.**
Twin có: `PurchaseEvaluation.cs:17` (`Guid? DepartmentId`). Guard spec §2.3 (dòng 164) =
`CreatedBy == actor ∨ DeptManager ∨ Admin`. Không có phòng trên phiếu thì "DeptManager" không so được với gì
⇒ **bất kỳ ai mang vai DeptManager trình được phiếu của phòng khác**. `ApprovalWorkflowStep.DepartmentId`
(`ApprovalWorkflow.cs:75`) chỉ là *hint* của quy trình, không thay được phòng của phiếu.

**C6 [MAJOR] — không có đường quay ngược Kế-hoạch → Hợp-đồng sau b.13.**
Twin có `PurchaseEvaluation.ContractId` (`:31`) + index (`PurchaseEvaluationConfiguration.cs:65`).
Ở đây HĐ sinh **theo từng NCC thắng**, mà #2 Lines cũng **theo từng NCC** ⇒ chỗ tự nhiên là
`ContractSigningPlanLines.ContractId?` (+ IX). Thiếu nó thì: không trả lời được "dòng này đã ra HĐ chưa",
không chặn được tạo HĐ hai lần cho cùng một NCC, và §2.5(a) không có chỗ ghi kết quả bắc cầu.
*(Lưu ý: đây KHÁC với "cột `SigningPlanId` trên PE" mà spec bác — bác cột đó là đúng, xem G6.)*

**C7 [MEDIUM] — `PeReferenceAmount`: GIỮ snapshot (đúng), nhưng thiếu mốc thời gian + luật ghi lại.**
Xem G5 để biết vì sao snapshot thắng "đọc live". Hai chỗ hở: (i) không có `PeReferenceAmountAt` —
twin dùng `ApprovedBudgetSnapshotAt` làm **cờ "đã chốt"** (`PurchaseEvaluation.cs:132`, ghi rõ ở `:120`);
(ii) spec nói *"snapshot 1 lần"* lúc tạo nhưng không nói điều gì xảy ra khi Drafter **re-pin workflow** hay
sửa nháp (§2.3 cho phép re-pin) — có sinh lại Lines không, có tính lại số không.

## Ràng buộc sai

**R1 [HIGH] — không khai lớp cơ sở (`BaseEntity` vs `AuditableEntity`) cho bảng #2-#7, trong khi #2 lại đặt UNIQUE lọc `WHERE IsDeleted=0`.**
Bộ lọc đó **đòi** cột `IsDeleted`, tức #2 buộc phải là `AuditableEntity`. Nhưng twin gần nhất của #2 là
`PurchaseEvaluationSupplier.cs:9` — `: BaseEntity` (không có xoá mềm) — và UNIQUE của nó **không lọc**:
`PurchaseEvaluationConfiguration.cs:105` `HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique()`.
⇒ Nếu người làm mirror twin (BaseEntity) thì migration sinh filter trỏ vào cột không tồn tại; nếu mirror spec
(AuditableEntity) thì lệch khuôn con-của-phiếu. Spec phải khai **base class cho từng bảng**.

**R2 [MEDIUM] — bất nhất nội bộ #2 (lọc) vs #4 (không lọc), và #4 kế thừa một vi phạm #57 tiềm ẩn.**
#4 không lọc là **khớp cả 6 twin**: `ContractLevelOpinionConfiguration.cs:34` · `ProposalLevelOpinionConfiguration.cs:28` ·
`TravelRequestLevelOpinionConfiguration.cs:28` · `OtRequestLevelOpinionConfiguration.cs:28` (+ Leave, PE).
Nhưng các entity đó **là** `AuditableEntity`: `PurchaseEvaluationLevelOpinion.cs:24`, `TravelRequestLevelOpinion.cs:16`
⇒ cả họ đang ở thế "xoá mềm + UNIQUE không lọc" = gotcha #57 dạng ngủ (chỉ chưa cắn vì đường ghi là UPSERT,
không soft-delete). Kế thừa thì kế thừa **có ý thức**: ghi 1 dòng lý do trong spec, nếu không lần sau ai đó
thêm đường soft-delete chữ ký là kẹt không ký lại được.

**R3 [MEDIUM] — loose-Guid đúng convention nhưng thiếu index đi kèm.**
Spec chỉ khai IX cho `PurchaseEvaluationId`. Twin đánh index cho **mọi** loose-Guid dùng để lọc:
`ProjectId` (`:58`) · `WorkItemId` (`:61`) · `ApprovalWorkflowId` (`:64`) · `ContractId` (`:65`) ·
`SlaDeadline` (`:62`); và ở bảng con: `SupplierId` (`:106`), `PurchaseEvaluationSupplierId` (`:206`).
⇒ Bổ sung IX: `ProjectId`, `ApprovalWorkflowId`, `DrafterUserId` (header) + `SupplierId` (#2) + `PlanId` (#4/#6/#7 theo cặp).

## Điểm CỦNG CỐ

**G1 — trích dẫn `PurchaseEvaluationConfiguration.cs:84` CHÍNH XÁC ĐẾN DÒNG:** `b.HasQueryFilter(x => !x.IsDeleted);`
nằm đúng dòng 84. (Đã kiểm bằng cách đọc file, không tin lời spec.)

**G2 — `decimal(18,2)` đúng quy ước tiền tệ của repo:** `PurchaseEvaluationConfiguration.cs:23-30`
(`HasColumnType("decimal(18,2)")` cho các cột ngân sách/giá).

**G3 — "loose-Guid không FK vật lý sang PE/Supplier/Project" là quy ước THẬT, không phải suy diễn:**
`PurchaseEvaluation.cs:16` (comment `WorkItemId` — *"scalar loose-Guid (KHÔNG navigation, KHÔNG FK vật lý —
convention PE giống ProjectId/SelectedSupplierId)"*) + `PurchaseEvaluationConfiguration.cs:59-61`
(*"index lọc query, KHÔNG HasOne/FK vật lý (convention PE: chỉ ApprovalWorkflowId có FK)"*) + `:72-73`
(FK duy nhất ra ngoài module là `ApprovalWorkflowId`, `DeleteBehavior.Restrict`).

**G4 — bảng #4 mirror ĐÚNG 100% khuôn `ContractLevelOpinions`:** FK Cascade phiếu
(`ContractLevelOpinionConfiguration.cs:25-27`) + FK **Restrict** Level (`:30-32`) + UNIQUE `(phiếu, Level)` (`:34`)
+ `Comment` 2000 (`:21`). Đây là phần chắc tay nhất của §②.2. *(Kèm nghĩa vụ H2 — chọn Restrict thì phải vào 3 site admin.)*

**G5 — `PeReferenceAmount` snapshot: BÁC phương án "đọc live", giữ snapshot.** Ba căn cứ:
(i) tiền lệ cùng repo — `ApprovedPriceAmount` (`PurchaseEvaluation.cs:97`) và 11 cột `ApprovedBudget*`
(`:122-132`, Mig 67) đều là ảnh chụp tại thời điểm chốt;
(ii) nguồn live **biến động được dưới chân phiếu KHKK**: cờ F3 `AllowApproverEditDetails` cho người duyệt sửa
Hạng mục/NCC/Báo giá lúc đang duyệt (`ApprovalWorkflowLevel.cs:127-129`), cờ F6 `AllowApproverDelete` cho xoá
mềm phiếu (`:156-169`) — mà phiếu PE xoá mềm thì `HasQueryFilter` làm số live **biến mất**, snapshot thì còn;
(iii) spec **đã tự cài máy dò lệch** ở §2.5(a) (ghi ContextNote *"giá KH x vs SUM-PE y"*) ⇒ snapshot không
biến thành số mồ côi không ai đối chiếu.

**G6 — "KHÔNG thêm cột `SigningPlanId` lên PE" là quyết định ĐÚNG** (tra ngược bằng `Plans.Where(PeId == x)`);
đây đúng là lớp lỗi cột-phái-sinh-kẹt-stale (gotcha #81, `docs/gotchas.md:1343`).

**G7 (câu e, vế 2) — `ApplicableType = 10` TRỐNG THẬT, xác nhận bằng đọc mã chứ không bằng lời spec:**
`ApprovalWorkflow.cs:53-67` — enum dừng ở `TravelRequest = 9`, không có giá trị 10. Quét cả `src/` với mẫu
`= 10,` chỉ ra **một** kết quả duy nhất và nó thuộc enum KHÁC: `PurchaseEvaluationPhase.cs:24` `ChoDuyet = 10`.
⇒ Claim của spec ĐÚNG, append-only an toàn.

## Enum — điểm HIGH riêng

**E1 [HIGH] — `ContractSigningPlanPhase = 1/2/3/98/99` là bộ số THỨ BA, và nó đụng đúng khuôn FE mà §2.4 bắt copy.**
Repo đang sống hai quy ước:
- Họ PE/Contract: `PurchaseEvaluationPhase.cs:24` `ChoDuyet = 10` (+ TraLai 98 / TuChoi 99).
- Họ Office/Proposal — **chính là khuôn FE spec chọn** (§2.4: *"khuôn bộ Proposal ~978 LOC/app"*):
  `Office/Enums.cs:17-24` `ProposalStatus { Nhap=1, DaGuiDuyet=2, TraLai=3, TuChoi=4, DaDuyet=5 }` và
  `:29-36` `WorkflowAppStatus` y hệt, **dùng chung cho 5 module** (LeaveRequest + OtRequest + TravelRequest +
  VehicleBooking + ItTicket — `:27`).

Va chạm cụ thể, đo được: **giá trị 3** = `DaDuyet` ở enum mới, nhưng = `TraLai` ở khuôn FE Proposal đang được
copy. Bảng map badge/nhãn trạng thái copy nguyên si sẽ **render sai mà không có lỗi biên dịch** — đúng lớp
"xanh-giả". Lý do spec đưa (*"giữ 98/99 cho quen mắt FE badge"*) chỉ đúng với FE PE/Contract, **không đúng với
FE khuôn đã chọn**.
⇒ Hai lối ra, chọn 1 và ghi vào spec: (a) dùng đúng bộ `WorkflowAppStatus` 1-5 (hết va chạm, thêm cơ hội tái
dùng chip/badge có sẵn), hoặc (b) giữ 1/2/3/98/99 nhưng **khai nghĩa vụ viết lại toàn bộ map trạng thái** khi
copy trang Proposal, kèm 1 dòng kiểm ở checklist wave.

<!-- END · TOTAL=22 DIEM · VERDICT=GO-WITH-FIXES (5 HIGH chặn Wave 1) -->
