# sub-invest-wfver-4 — Quy trình duyệt: sửa TẠI CHỖ vs bắt buộc TẠO VERSION MỚI

> run `2026-07-27-S155-pe-delete-approver` · lát cắt 4 (S1-ter) · READ-ONLY
> Nhãn: `[CODE]` = có file:line kiểm được · `[SUY LUẬN]` = suy từ mã, chưa chạy · `[CHƯA XÁC MINH]` = không chứng được lượt này
> 🔴 File này ghi TỪNG phát hiện ngay lúc tìm ra (chống #53). Return chỉ là tóm tắt.

---

## W1 — Hôm nay admin sửa workflow thì ĐIỀU GÌ thực sự xảy ra?

### F1 [CODE] 🔴 **KHÔNG TỒN TẠI lệnh Update.** Toàn bộ surface admin V2 chỉ có 4 việc.

`src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs` (54 dòng, TOÀN BỘ file):

| Verb | Line | Policy | Việc |
|---|---|---|---|
| `GET /api/approval-workflows-v2` | `:21-26` | `[Authorize]` trần (class) | Overview (đọc) |
| `POST /api/approval-workflows-v2` | `:28-34` | `Workflows.Create` | **Tạo version MỚI** |
| `PATCH /{id}/user-selectable` | `:39-45` | `Workflows.Create` | Bật/tắt cờ ghim cho user pick |
| `DELETE /{id}` | `:47-53` | `Workflows.Create` | Xoá cả quy trình |

**KHÔNG có `PUT` / `PATCH` nào sửa Steps/Levels/Name/CeoApprovalThreshold.** Xác minh bằng lệnh:
```
grep -n "HttpPut|HttpPatch" ApprovalWorkflowsV2Controller.cs   → chỉ 1 hit: user-selectable :39
grep -rn "UpdateAw|EditAw" src/Backend                          → (kết quả ghi ở F3)
```
⇒ Trả lời dứt khoát cho câu *"admin bị chặn thật hay chỉ tưởng"*: **BỊ CHẶN THẬT — không có đường sửa tại chỗ trong mã.** Không phải thói quen/UX.
Ngoại lệ DUY NHẤT sửa được tại chỗ hôm nay = cờ `IsUserSelectable` (`SetAwUserSelectableCommandHandler`, `ApprovalWorkflowV2AdminFeatures.cs:386-398` — load entity, gán 1 field, `SaveChangesAsync`, KHÔNG đụng Version).

### F2 [CODE] `Version` tăng ở ĐÚNG 1 chỗ, tự động, không ai chọn được.

`ApprovalWorkflowV2AdminFeatures.cs:324-328` (`CreateAwDefinitionCommandHandler.Handle`):
```csharp
var nextVersion = await db.ApprovalWorkflows
    .Where(w => w.Code == request.Code)
    .MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++;
```
- Khoá theo `Code` (cùng Code = cùng "quy trình logic"), `MAX(Version)+1`.
- `CreateAwDefinitionCommand` (`:238-248`) **không có field `Version`** ⇒ client KHÔNG truyền được, không ép được.
- ⇒ **"Ép tăng Version" = chính hành vi POST.** Không có trigger riêng nào khác. Mọi thay đổi (kể cả tick 1 cờ) hôm nay **bắt buộc** đi qua POST ⇒ **bắt buộc** đẻ version mới.

### F3 [CODE] POST còn có TÁC DỤNG PHỤ: hạ mọi version đang active của cùng `ApplicableType`.

`ApprovalWorkflowV2AdminFeatures.cs:330-334`:
```csharp
var actives = await db.ApprovalWorkflows
    .Where(w => w.ApplicableType == typeEnum && w.IsActive)
    .ToListAsync(ct);
foreach (var old in actives) old.IsActive = false;
```
+ bản mới `IsActive = true` (`:343`), `IsUserSelectable = true` (`:344`), `ActivatedAt = UtcNow` (`:347`).
⇒ Tick 1 cờ F6 hôm nay = **(a)** đẻ 1 row `ApprovalWorkflows` + N row Step + M row Level mới, **(b)** hạ active bản cũ, **(c)** bản cũ vẫn `IsUserSelectable=true` (không bị hạ) ⇒ dropdown user **dài thêm 1 dòng mỗi lần tick**.

### F4 [CODE] DELETE hiện **vô điều kiện** — note ở `:400-402` tự khai nợ:
```
// Hiện chưa có phiếu nào pin schema mới → unconditional delete OK cho UAT.
// Sau UAT khi link với PE/Contract thật cần check usage trước khi delete.
```
Handler `:406-418` = `Remove(def)` thẳng, **0 usage-check**. (Rào thật nằm ở FK — xem F5.)

### F5 [CODE] Rào thật của DELETE nằm ở **FK Restrict**, không ở mã.

- `PurchaseEvaluationConfiguration.cs:67-73` — `PE.ApprovalWorkflowId` → `ApprovalWorkflows` **`DeleteBehavior.Restrict`**, comment ghi rõ *"Restrict để KHÔNG xóa workflow"*.
- `PurchaseEvaluationLevelOpinionConfiguration.cs:25-28` — `LevelOpinion.ApprovalWorkflowLevelId` → Level **`Restrict`**, comment: *"admin xoá Level chặn nếu opinion tồn tại — bảo vệ data"*.
- `ApprovalWorkflowConfiguration.cs` (Step→Workflow) `Cascade`; (Level→Step) `Cascade`.
⇒ [SUY LUẬN] DELETE workflow đã có phiếu pin sẽ **nổ FK 547 → 500** (không phải 409 lịch sự) vì handler không check usage. Đây là nợ note `:400-402` tự khai.
🔴 **F5 là rào cứng SẴN CÓ, hợp ý owner:** dữ liệu đã ký (`LevelOpinion`) **khoá cứng** Level tương ứng — Level đã có người ký **không xoá được ở tầng DB**, kể cả sau này có lệnh Update.

### F6 [CODE] Kết luận W1 — dứt khoát

| Câu hỏi | Trả lời |
|---|---|
| Có Update tại chỗ? | **KHÔNG** (trừ `IsUserSelectable`) |
| Update sửa tới đâu? | n/a |
| Chỗ ép tăng Version? | `ApprovalWorkflowV2AdminFeatures.cs:325-328`, trigger = **mọi POST** |
| Version set ở đâu? | cùng chỗ, `MAX(Version per Code)+1`, client không truyền được |
| Admin bị chặn thật? | **CHẶN THẬT bằng mã** — không phải UX/thói quen. Muốn tick 1 cờ ⇒ buộc POST ⇒ buộc version mới + hạ active bản cũ |

⇒ Yêu cầu owner (7) **KHÔNG phải nới lỏng luật cũ** mà là **XÂY MỚI 1 lệnh Update chưa từng tồn tại**. Đây là điểm quan trọng nhất của lát cắt này.

---

## W2 — Phiếu ĐANG CHẠY bám vào workflow thế nào?

### F7 [CODE] 🔴 **THAM CHIẾU SỐNG, KHÔNG snapshot.**

`PurchaseEvaluationWorkflowService.cs:671-675` (`ApproveV2Async`) — mỗi lần duyệt **đọc lại workflow từ DB**:
```csharp
var aw = await db.ApprovalWorkflows.AsNoTracking()
    .Include(w => w.Steps.OrderBy(s => s.Order))
        .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
    .FirstOrDefaultAsync(w => w.Id == awId, ct)
```
PE **không copy** Steps/Levels lúc gửi duyệt — pin chỉ là `ApprovalWorkflowId` (1 Guid). ⇒ **Sửa workflow là ăn NGAY vào mọi phiếu đang chạy pin nó.** (Snapshot duy nhất trong PE là **ngân sách** Mig 67, KHÔNG phải workflow.)

### F8 [CODE] Con trỏ **LAI**: Bước = INDEX (mong manh) · Cấp = ORDER-VALUE (bền hơn nhưng vẫn vỡ)

| Con trỏ | Cách dùng | Dòng |
|---|---|---|
| `CurrentWorkflowStepIndex` | `steps[currentIdx]` — **INDEX vào list đã sort theo Order** | `:677`, `:681-686` |
| `CurrentApprovalLevelOrder` | `levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)` — **so khớp giá trị Order** | `:689-695` |

**Hệ quả chứng minh được (không phải đoán):**
- **Chèn/xoá/đổi thứ tự BƯỚC** ⇒ `steps[currentIdx]` trỏ sang **bước khác** ⇒ phiếu đang chờ ở Bước 2 bỗng bị coi là đang ở Bước khác, **im lặng, không lỗi**. Nếu số bước giảm dưới `currentIdx` thì ném `ConflictException` `:683` *"CurrentWorkflowStepIndex=… không hợp lệ"* ⇒ **phiếu KẸT, không ai duyệt được**.
- **Xoá CẤP đang chờ** ⇒ `pendingLevelGroup` null ⇒ `ConflictException` `:695` *"Bước X không có cấp Y"* ⇒ **phiếu KẸT**.
- **Đổi Order của Cấp** ⇒ tương đương xoá+thêm với con trỏ ⇒ kẹt hoặc nhảy sai cấp.
🔴 ⇒ Đây **chính là bằng chứng kỹ thuật** cho ranh giới owner vạch: lớp "đổi CẤU TRÚC" **bắt buộc** version mới. **Chứng minh, không bác bỏ.**

### F9 [CODE] Đổi **người** ở Cấp đang chờ: hiệu lực TỨC THÌ, hai chiều.

`:697-709`:
```csharp
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
if (!allowedUserIds.Contains(actorUserId.Value)) throw new ForbiddenException(...)
```
- **Thêm người** vào Cấp đang chờ ⇒ người mới duyệt được ngay; người cũ **không mất gì**. ⇒ an toàn.
- **Bớt/đổi người** ở Cấp đang chờ ⇒ người bị gỡ gọi Duyệt sẽ **403 Forbidden** với message liệt kê **GUID trần** (`:705` `string.Join(", ", allowedUserIds)` — nối `Guid`, không phải tên) ⇒ [SUY LUẬN] thông báo lỗi khó hiểu cho end-user; nếu Cấp đó rỗng người sau khi bớt ⇒ **phiếu KẸT** (không ai thoả).
- `matchingLevel` `:733-734` = level khớp `ApproverUserId`, **fallback `pendingLevelGroup.First()`** khi Admin duyệt thay ⇒ với Admin, ý kiến ký vào slot **đầu tiên** của Cấp. [SUY LUẬN] nếu admin đổi thứ tự các row cùng Cấp thì "slot đầu tiên" đổi ⇒ chữ ký admin gắn sang người khác.

### F10 [CODE] `PurchaseEvaluationLevelOpinions` = **rào cứng ở tầng DB** cho việc xoá Level đã ký.
UNIQUE `(PurchaseEvaluationId, ApprovalWorkflowLevelId)` (`PurchaseEvaluationLevelOpinionConfiguration.cs:30`) · FK Level **Restrict** (`:25-28`) · FK PE **Cascade** (`:20-23`).
⇒ Level đã có người ký **không thể xoá** (DB chặn). ⇒ [SUY LUẬN] lệnh Update tương lai nếu "xoá row Level rồi thêm lại" (khuôn replace-all) sẽ **nổ FK** trên đúng những workflow đang chạy — **cấm dùng khuôn delete-then-insert**, phải diff theo Id.

### F11 [CODE] Ngoài `ApproveV2Async` còn **3 read-site khác** của con trỏ trong PE — mỗi cái vỡ một kiểu KHÁC nhau.

| Read-site | Dòng | Kiểu vỡ khi cấu trúc đổi |
|---|---|---|
| `ResolveV2InboxIdsAsync` (Hộp thư "Chờ duyệt") | `PurchaseEvaluationFeatures.cs:842-849` — `steps[idx]`, `idx >= steps.Count → continue` | **IM LẶNG**: phiếu **biến mất khỏi inbox**, không lỗi, không ai biết |
| Flow-tree detail (`ComputeLevelStatus`/`ComputeStepStatus`) | `:1135-1158`, `:1161-1191` | Tô sai Done/Current/Pending — hiển thị sai lịch sử |
| `currentApproval` banner "Đến lượt bạn" | `:1223-1243` — guard `idxCur < steps.Count` | Banner rỗng ⇒ FE mất gate `blockedByV2Level` |
| List badge "Kết thúc trước CEO" | `:672-677` + `:1210-1213` | xem F12 |

### F12 [CODE] 🔴 Giả định ẩn: **`Step.Order - 1 == StepIndex`**.
`PurchaseEvaluationFeatures.cs:674-676` so `lv.Step!.Order - 1` với `CurrentWorkflowStepIndex`; comment `:667` tự khai *"rank cấp = Step.Order-1, Order tuần-tự s+1 bởi seed/Designer"*.
⇒ Giả định này chỉ đúng khi **Order liền mạch 1..N**. **Xoá 1 Bước giữa** (để lại lỗ Order) làm index ≠ Order-1 ⇒ badge/heads-up sai **âm thầm**. ⇒ Nếu sau này cho sửa cấu trúc thì **phải re-index Order liên tục**, và việc đó lại dịch con trỏ phiếu đang chạy.

### F13 [CODE] **Blast radius vượt xa PE**: `ApprovalWorkflow` V2 là schema DÙNG CHUNG 6+ module.
`grep -rn "\.Levels" src/Backend` (bỏ Migrations/Configurations) → hit ở: `PurchaseEvaluationFeatures.cs` · `ApprovalWorkflowV2AdminFeatures.cs` · `Office/ProposalFeatures.cs` · `Office/LeaveOtApprovalFeatures.cs` (13 hit) · `Office/TravelVehicleApprovalFeatures.cs` · `Office/WorkflowAppsFeatures.cs` · `ContractFeatures.cs` · `ContractWorkflowService.cs`.
`grep -c CurrentWorkflowStepIndex|CurrentApprovalLevelOrder` = **198 hit / 23 file**.
⇒ Lệnh Update mới **KHÔNG phải việc riêng của PE** — nó chạm đơn nghỉ phép, OT, công tác, đặt xe, đề xuất, hợp đồng. [SUY LUẬN] test phải phủ ít nhất PE + 1 module Office.

---

## W4 — Cấu trúc có đỡ nổi "nhiều người trong một Cấp"? — **CÓ**, và đây là chỗ ranh giới owner **KHÔNG** tự mâu thuẫn

### F14 [CODE] "Nhiều người cùng Cấp" = **nhiều row `ApprovalWorkflowLevel` TRÙNG `Order`** (OR-of-N). Xác nhận 3 nguồn độc lập:
1. Service: `PurchaseEvaluationWorkflowService.cs:689` — `currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order)`; comment `:688` *"Group levels by Order = Cấp. Mỗi Cấp có N approvers (OR-of-N)"*.
2. Match: `:702` `allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet()` ⇒ **bất kỳ ai trong nhóm** duyệt được.
3. Validator: `ApprovalWorkflowV2AdminFeatures.cs:309-314` `HaveNoDuplicateApproverInSameLevel` gom theo **`{Order, ApproverUserId}`** ⇒ **cho phép** nhiều row cùng `Order` miễn khác người. Comment `:252-256` nói thẳng *"Mỗi Cấp có N approver (multiple Level rows cùng Order = same Cấp)"*.
+ FE detail dựng cùng cách: `PurchaseEvaluationFeatures.cs:1164` `GroupBy(l => l.Order)` → `approvers` = list.
⇒ Ảnh prod (Cung ứng — Cấp 2 có 3 tên) **khớp mã**. Số Cấp tối đa = **3** (`MaxLevelsPerStep`, `:257` + `:282`).

### F15 [CODE] 🎁 **Giải nghịch lý owner có thể tự mâu thuẫn** — hoá ra KHÔNG mâu thuẫn, vì con trỏ Cấp khoá theo **giá-trị Order**, không theo row:
- Thêm 1 row Level với `Order` **ĐÃ TỒN TẠI** (= thêm NGƯỜI vào Cấp có sẵn) ⇒ `levelGroups` vẫn đủ khoá `g.Key`, `steps[currentIdx]` không đổi ⇒ **0 con trỏ nào dịch** ⇒ **AN TOÀN THẬT** (`:689-695`).
- Thêm 1 row Level với `Order` **MỚI** (= thêm CẤP) ⇒ đổi `maxLevelOrder`, chèn chặng ⇒ **PHÁ VỠ**.
⇒ Ranh giới đúng chữ owner nếu định nghĩa **"Cấp" = tập các row cùng `Order`**, không phải "1 row". Từ ngữ cần chốt trong spec: *thêm người = thêm row cùng Order sẵn có*.
🔸 Lưu ý ngược lại: **BỚT người** khỏi Cấp = xoá row ⇒ vẫn "an toàn về con trỏ" nhưng có 2 rủi ro thật (F9 người-đang-chờ mất quyền; F10 FK Restrict chặn nếu row đó đã ký) ⇒ **không cùng hạng an toàn với "thêm"**.

### F16 [CODE] ⚠️ Comment entity **SAI/lỗi thời** ngay tại nhà: `ApprovalWorkflow.cs:81-82` viết
> *"Cấp = 1 NV cụ thể. … Approver = ApproverUserId chính xác (**KHÔNG OR-of-many**)"*

trong khi mã (F14) và CLAUDE.md đều là OR-of-N. ⇒ Bẫy cho người đọc sau; nên vá comment khi động vào file này.

---

## 🔴 F17 — GIẢ THUYẾT LEAD: **XÁC NHẬN ĐÚNG.** Cờ mới KHÔNG tới được phiếu đang chạy.

Chuỗi bằng chứng, 4 mắt xích, mỗi mắt có `file:line`:

| # | Mắt xích | Bằng chứng |
|---|---|---|
| 1 | POST tạo **entity MỚI** ⇒ **Id MỚI** | `ApprovalWorkflowV2AdminFeatures.cs:336` `var def = new ApprovalWorkflow {...}` + `:373` `db.ApprovalWorkflows.Add(def)`; `BaseEntity.cs:5` `public Guid Id { get; set; } = Guid.NewGuid();` ⇒ **không** UPDATE row cũ |
| 2 | Phiếu đọc workflow **theo Id đã pin** | `PurchaseEvaluationWorkflowService.cs:674` `FirstOrDefaultAsync(w => w.Id == awId)` — `awId` = `evaluation.ApprovalWorkflowId` (`:292`) |
| 3 | Row cũ **vẫn sống** sau khi tạo bản mới | POST chỉ set `old.IsActive = false` (`:334`) — **không xoá**, không cascade ⇒ phiếu cũ vẫn resolve được (không lỗi, chỉ là **đọc cấu hình CŨ**) |
| 4 | **KHÔNG có đường re-pin** cho phiếu đang chạy | `ApprovalWorkflowId` chỉ ghi ở **2 chỗ**: `PurchaseEvaluationFeatures.cs:149` (create) và `:291` (update) — mà `:249-251` chặn `throw ConflictException("Chỉ sửa được phiếu khi ở phase Nháp hoặc Trả lại.")` ⇒ **phiếu `ChoDuyet` KHÔNG re-pin được** |

**Kiểm thêm — không có backfill nào cứu:**
```
grep -rn "UPDATE.*ApprovalWorkflowId\|SET ApprovalWorkflowId" src/.../Migrations/*.cs   → 0 hit
grep -rn "ApprovalWorkflowId = " src/Backend (trừ Migrations/Configurations)             → PE chỉ 2 write-site nêu trên
```

### ⇒ Hệ quả cho việc đang làm (spec F6 `AllowApproverDelete`)
Admin tick F6 hôm nay ⇒ buộc POST ⇒ workflow **v(n+1)** Id mới ⇒ **đúng những phiếu đang treo cần xoá vẫn pin v(n)** ⇒ `ApproveV2Async`/handler xoá đọc Level của **v(n)** — nơi `AllowApproverDelete = false`.
🔴 **Yêu cầu (5) HỎNG nếu không có (7).** Hai yêu cầu không song song mà **phụ thuộc**: (7) là **điều kiện cần** để (5) chạm được ca UAT gốc (phiếu `PE/2026/A/046` đang treo).
Đường vòng duy nhất nếu KHÔNG làm (7): người soạn **Trả lại → sửa → gửi lại** để re-pin sang v(n+1) (`:249-251` cho `TraLai`) — nhưng gửi lại **chạy quy trình LẠI từ đầu** và **vẫn ăn lũy kế** trong lúc chờ ⇒ **không giải được bài toán gốc**.
[SUY LUẬN] Ngược lại, một lệnh Update **sửa tại chỗ row Level của v(n)** sẽ tới phiếu đang chạy **NGAY** (mắt xích 2 — tham chiếu sống, không cache) — đó chính là cái owner mô tả.

---

## W3 — Bảng phân loại AN TOÀN / NỬA AN TOÀN / PHÁ VỠ

**Quy ước đọc:** "Cấp" = **tập các row `ApprovalWorkflowLevel` cùng `Order`** (F14). Phân loại chỉ xét **sửa tại chỗ trên workflow đang có phiếu chạy**.

### F18 [CODE] Bảng 13 thao tác

| # | Thao tác | Hạng | Lý do kỹ thuật (mã) |
|---|---|---|---|
| 1 | **Thêm người vào Cấp có sẵn** (row mới, `Order` đã tồn tại) | ✅ **AN TOÀN** | Con trỏ Bước = index vào `steps` (không đổi); con trỏ Cấp khớp **giá trị** `g.Key` (`Service:689-695`) ⇒ 0 dịch. Người mới vào `allowedUserIds` ngay (`:702`). Không đụng FK/UNIQUE |
| 2 | **Bật/tắt cờ `Allow*`** (F1-F5, và F6 sắp thêm) | ✅ **AN TOÀN** | Cờ đọc từ `matchingLevel` **tại thời điểm duyệt** (`:799`, `:859`) ⇒ hiệu lực ngay, không đụng con trỏ/khoá. **Đây là ô owner cần cho F6** |
| 3 | **Đổi tên hiển thị** `Workflow.Name` / `Step.Name` / `Level.Name` | ✅ **AN TOÀN** | Chỉ display: `Step.Name` vào message + DTO (`:707`, `Features:1186`); `Level.Name` vào `levelName` (`Features:1176`). Không tham gia quyết định |
| 4 | **Đổi `CeoApprovalThreshold`** | ⚠️ **NỬA — hỏi owner** | Kỹ thuật an toàn (đọc live `aw.CeoApprovalThreshold` `Service:883`, không đụng con trỏ) **NHƯNG đổi nghĩa quyết định đang chờ**: cùng 1 phiếu, hôm nay CCM được duyệt-final, mai thì không. Không vỡ gì — **là câu hỏi chính sách, không phải kỹ thuật** |
| 5 | **Bớt người khỏi Cấp** (xoá 1 row) | ⚠️ **NỬA AN TOÀN** | Con trỏ **không dịch** (F15) NHƯNG: (a) người bị gỡ đang chờ → `ForbiddenException` `:703-708` với message **liệt kê GUID trần** (`:705`); (b) nếu row đó **đã ký** → FK `Restrict` chặn ở DB (`PeLevelOpinionConfiguration:25-28`) → lỗi hạ tầng, không phải 409; (c) bớt HẾT người của Cấp đang chờ ⇒ `allowedUserIds` rỗng ⇒ **phiếu KẸT** |
| 6 | **Đổi `ApproverUserId` của 1 row** | ⚠️ **NỬA AN TOÀN** | = bớt + thêm cùng lúc: người cũ mất quyền tức thì (`:702-708`); nếu row đã ký thì **chữ ký cũ vẫn trỏ row đó** (`LevelOpinion.ApprovalWorkflowLevelId`) ⇒ **lịch sử đổi nghĩa im lặng**: bản ghi "Cấp 2 do A ký" nay hiển thị dưới tên B. FK không chặn (chỉ chặn DELETE). 🔴 **Đây là ô nguy hiểm nhất trong nhóm "sửa người"** |
| 7 | **Thêm Cấp** (`Order` MỚI trong Bước) | ❌ **PHÁ VỠ** | Đổi `maxLevelOrder` (`Service:690`) ⇒ chèn chặng vào giữa luồng đang chạy: phiếu vừa qua Cấp 2 nay bị hỏi Cấp 3 (hoặc ngược lại) ⇒ **đổi nghĩa "đã duyệt xong Bước"**. Ngoài ra `ComputeLevelStatus` (`Features:1135-1148`) tô lại toàn bộ trạng thái đã hiển thị |
| 8 | **Xoá Cấp** | ❌ **PHÁ VỠ** | Nếu là Cấp đang chờ → `pendingLevelGroup` null → `ConflictException` `:695` *"Bước X không có cấp Y"* ⇒ **phiếu KẸT hoàn toàn**. Nếu Cấp đã ký → FK `Restrict` chặn (F10) |
| 9 | **Đổi `Order` của Cấp** | ❌ **PHÁ VỠ** | Con trỏ khoá theo giá-trị `Order` ⇒ đổi số = vừa "xoá" khoá cũ vừa "tạo" khoá mới ⇒ kẹt (`:694-695`) hoặc **nhảy sai cấp im lặng**. Còn vi phạm `HaveSequentialOrders` (`Admin:297-307`) nếu để hở |
| 10 | **Thêm Bước** | ❌ **PHÁ VỠ** | Thêm ở **cuối** thì kéo dài luồng phiếu đang chạy (đổi nghĩa "sắp xong"); thêm ở **giữa** thì `steps[currentIdx]` (`:686`) trỏ sang bước khác — **im lặng, không lỗi**. Phá thêm giả định `Step.Order-1 == index` (F12) nếu Order không re-index |
| 11 | **Xoá Bước** | ❌ **PHÁ VỠ** | `currentIdx >= steps.Count` → `ConflictException` `:683` (**phiếu KẸT**) hoặc trỏ nhầm bước. Inbox thì **im lặng bỏ phiếu** (`Features:844` `continue`) ⇒ phiếu **biến mất khỏi màn Duyệt mà không báo lỗi**. Level con có opinion → FK Restrict chặn cascade |
| 12 | **Đổi `Order` Bước** (reorder) | ❌ **PHÁ VỠ** | Cùng cơ chế #10/#11: index cố định + thứ tự đổi = trỏ sai người, **không có exception** ⇒ dạng hỏng tệ nhất (âm thầm) |
| 13 | **Đổi `DepartmentId` của Bước** | ⚠️ **NỬA — hỏi owner** | BE **không match theo phòng** (V2 match `ApproverUserId`, `:702`) ⇒ kỹ thuật **an toàn**; chỉ đổi nhãn hiển thị (`Features:1188`). **NHƯNG** Designer FE ép *"NV phải thuộc Phòng đã chọn"* (`ApprovalWorkflowsV2Page.tsx:573-575`) ⇒ đổi phòng làm cấu hình **tự mâu thuẫn với chính luật FE**, và [CHƯA XÁC MINH] tôi không tìm thấy nơi nào BE re-validate ⇒ dữ liệu "lệch phòng" sẽ tồn tại im lặng. **Phải hỏi owner** muốn coi đây là gì |

### F19 [SUY LUẬN] Quy tắc rút gọn để spec dùng
> **AN TOÀN ⟺ thao tác KHÔNG làm đổi tập `{Step.Order}` và KHÔNG làm đổi tập `{Level.Order}` của Bước.**
> Mọi thao tác chỉ đụng **nội dung row** (người, cờ, tên) mà giữ nguyên **khung Order** = sửa tại chỗ được. Đụng vào **khung** = version mới.
Đây là phát biểu kiểm được bằng mã (con trỏ chỉ đọc index-of-steps + value-of-level-Order), không phải quy ước cảm tính.

### F20 [SUY LUẬN] Cảnh báo cho người viết lệnh Update
- ❌ **CẤM khuôn "xoá hết Levels rồi insert lại"** (khuôn quen của replace-all): nổ FK `Restrict` trên đúng workflow đang chạy (F10), và **đổi Id Level** ⇒ mọi `LevelOpinion` cũ mồ côi ngữ nghĩa.
- ✅ Phải **diff theo `Level.Id`**: giữ Id cũ cho row còn lại, chỉ Add row mới / Update cờ.
- Nhóm ⚠️ NỬA (#5, #6) nên **chặn ở tầng lệnh khi Level đó đã có `LevelOpinion`** — biến lỗi FK 500 thành 409 có chữ.

---

## W5 — Designer FE

### F21 [CODE] Vị trí + phân bố 2 app (KHÔNG mirror byte-identical như `PeWorkflowPanel`)

| App | File | Vai |
|---|---|---|
| **fe-admin** | `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` (**1033 dòng**) | Designer **DUY NHẤT** (đọc + tạo + ghim + xoá) |
| **fe-user** | `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx` (**321 dòng**) | **CHỈ XEM** — ma trận read-only, header `:1-16` ghi rõ *"User read-only matrix view"*, `FlagRow` `:303-314` có `readOnly` |

⇒ fe-user **không có** Designer. Nhưng nó **liệt kê 8 cờ** ⇒ thêm F6 **vẫn phải chạm 2 app** (xem F24).

### F22 [CODE] Luồng "sửa" trên FE = **luôn là Tạo mới** — FE nói thật, không giấu

- Nút trên card: **`Nhân bản`** (`:479` `onClick={() => onClone(def)}`) · **`Ghim/Bỏ ghim`** (`:484-491`) · **`Xoá version`** (`:493-495`). **KHÔNG có nút "Sửa".**
- `onClone` → `setCloneFrom(d); setDesignerOpen(true)` (`:302`, `:339`) → mở **cùng 1 dialog** với tiêu đề **`Tạo quy trình mới — {label}`** (`:638`), nút **`Lưu + kích hoạt`** (`:644`).
- Clone chép **đủ 8 cờ** từ bản cũ (`:141-160`) ⇒ trải nghiệm "giống như sửa", nhưng POST là `api.post('/approval-workflows-v2', …)` (`:578`) ⇒ **bản mới**.
- Toast sau khi lưu: **`"Đã lưu quy trình mới. Version cũ đã archive."`** (`:611`).
⇒ Trả lời câu *"có nút nào gợi ý sửa mà thực chất tạo mới không?"*: **CÓ — `Nhân bản`**, nhưng FE **có khai báo** (tiêu đề + toast + hint). Đây là chỗ sinh ra thói quen owner muốn bỏ.
🔸 `steps.map((s, i) => ({ order: i + 1, …}))` (`:585-586`) ⇒ FE **luôn re-index Order Bước liên tục 1..N** khi lưu ⇒ giả định F12 được giữ **nhờ FE**, không phải nhờ BE.

### F23 [CODE] 🔴 **KHÔNG có cảnh báo "workflow đang được dùng", KHÔNG disable field khi `IsActive`.**
Lệnh chạy:
```
grep -n "đang được dùng|đang dùng|còn chạy|usage|inUse|disabled=" ApprovalWorkflowsV2Page.tsx
→ 4 hit, TẤT CẢ vô can: :643 disabled={save.isPending} · :744/:753 disabled nút ↑↓ đầu/cuối list · :817 disabled={addDisabled} (sequential gating Cấp)
```
- Xoá version chỉ có `confirm()` chữ trơn: `Xoá version đang áp dụng "{code} v{version}"?` (`:308`) / `Xoá version "{code} v{version}"?` (`:345`) — **không đếm phiếu đang dùng**.
- Đối chiếu: Designer **HĐ V1** (skill `contract-workflow`) có badge *"N HĐ còn chạy"*. **V2 không port tính năng đó** ⇒ [SUY LUẬN] admin xoá nhầm sẽ gặp FK 500 (F5) mà không được cảnh báo trước.
- Admin **CÓ** thấy Version: badge `{code} v{01}` (`:386`) + `Đang áp dụng`/`Archived` (`:388-398`) + hint `"Version auto-tăng mỗi lần lưu"` (`:654`) + khối `Lịch sử versions` (`:320`) và dòng *"Khi tạo version mới, version hiện tại tự động archive"* (`:329`).
⇒ **Admin BIẾT mình vừa tạo bản mới.** Vấn đề owner nêu không phải "không biết" mà là **"không muốn phải làm vậy"**.

### F24 [CODE] 🎯 Chỗ tick F6 `AllowApproverDelete` — **4 neo, 2 app** (spec bám thẳng)

| # | File:line | Việc |
|---|---|---|
| 1 | `fe-admin/.../ApprovalWorkflowsV2Page.tsx:999-1007` | Khối `<label className="col-span-2 …text-emerald-700">` đang tick **F5** `allowApproverFinalize` — **chèn F6 ngay dưới `:1007`**, cùng khuôn `checked={entry.allowApproverDelete}` / `onChange={e => updateField('allowApproverDelete', e.target.checked)}` |
| 2 | cùng file `:54` (type `LevelDto`) · `:110` (type `EditLevelEntry`) · `:159` (clone) · `:177` (default `false`) · `:605` (payload POST) | **5 chỗ khai báo/nối dây** cho mỗi cờ mới — đếm theo dấu vết F5, **không được sót `:605`** (thiếu = tick xong không gửi lên BE, hỏng im lặng) |
| 3 | `fe-user/.../WorkflowMatrixViewPage.tsx:282` | Dòng `<FlagRow active={r.level.allowApproverFinalize} label="Duyệt là KẾT THÚC…" colSpan2 />` — thêm `FlagRow` F6 ngay dưới (+ khai field trong type của file này) |
| 4 | BE DTO/input: `ApprovalWorkflowV2AdminFeatures.cs:41` (`AwLevelDto`) · `:230` (`CreateAwLevelInput`) · `:188` (map ra DTO) · `:368` (map vào entity) | 4 chỗ — **cùng số lượng, cùng vị trí** như `AllowApproverFinalize`. Khuôn đã lặp 5 lần (F1-F5) |

🔸 Nhãn UI hiện tại đều là câu tiếng Việt đầy đủ (vd `"Cho phép duyệt thẳng Cấp cuối khi đang duyệt"` `:995`) ⇒ F6 nên theo cùng giọng, và **label phải khớp byte** giữa fe-admin `:1006` và fe-user `:282` (fe-user comment `:16` tự dặn *"mirror admin Designer line 885-948"* — [SUY LUẬN] số dòng trong comment đó **đã lệch** so với `:932-1007` hiện tại, dấu hiệu mirror thủ công dễ trôi).

### F25 [CODE] Bẫy cho lệnh Update tương lai, nhìn từ FE
`ApprovalWorkflowsV2Page.tsx:568-576` — validate client-side *"NV phải thuộc Phòng đã chọn"* + `:561-567` sequential gating. Nếu lệnh Update mới **không** đi qua Designer (vd 1 nút tick nhanh ngoài card) thì **mất hết** các luật này ⇒ nên **tái dùng validator BE** (`CreateAwDefinitionCommandValidator:250-315`) chứ không viết luật thứ hai.

---

## Tổng kết ngắn

| Câu | Trả lời được? | Chốt |
|---|---|---|
| W1 | ✅ | Không có Update. Admin **bị chặn thật bằng mã**; mọi thay đổi = POST = version mới + hạ active bản cũ |
| W2 | ✅ | **Tham chiếu sống**, không snapshot. Con trỏ **lai**: Bước=index (vỡ âm thầm) · Cấp=Order-value (vỡ thành kẹt). FK Restrict khoá Level đã ký |
| W3 | ✅ (11/13 dứt khoát) | 3 AN TOÀN · 2 NỬA · 6 PHÁ VỠ · **2 phải hỏi owner** (#4 `CeoApprovalThreshold`, #13 `DepartmentId`) |
| W4 | ✅ | OR-of-N là thật; **owner KHÔNG tự mâu thuẫn** nếu định nghĩa Cấp = tập row cùng `Order` |
| W5 | ✅ | Designer chỉ ở fe-admin; "Nhân bản" = tạo mới (có khai báo); **0 cảnh báo đang-dùng**; F6 có 4 neo/2 app |
| 🔴 Giả thuyết lead | ✅ **XÁC NHẬN** | (5) **hỏng nếu không có** (7) — xem F17 |

**Câu chưa trả lời được: 0/5** (2 ô trong W3 là *"phải hỏi owner"* theo đúng luật, không phải thiếu bằng chứng).

<!-- END sub-invest-wfver-4 · TOTAL=25 PHAT-HIEN -->



