wal: flush 20260727T0959

This commit is contained in:
pqhuy1987
2026-07-27 09:59:49 +07:00
parent e981d34fdc
commit b0cdd02468
3 changed files with 230 additions and 13 deletions

View File

@ -176,3 +176,102 @@ Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ lu
## 6. NGOÀI PHẠM VI
Khôi phục phiếu (owner chốt (6)) · mở lại **chi tiết** phiếu đã xóa (D4 — cần `IgnoreQueryFilters` thứ 2) · khôi phục nút Từ chối · vá N1N4.
---
# PHẦN II — Hạng mục F: lệnh **UPDATE quy trình duyệt** (yêu cầu owner (7))
> Nguồn: `sub-invest-wfver-4.md` (25 phát hiện). Lead verify độc lập F1/F3/F7/F14/F17.
## F.0 🔴 QUAN HỆ PHỤ THUỘC — đọc trước tiên
**Hạng mục A-E (quyền xóa) KHÔNG CHẠY nếu thiếu F.** Không phải "làm cùng thì tiện" mà là **phụ thuộc cứng**. 4 mắt xích:
1. POST tạo `new ApprovalWorkflow{...}` (`ApprovalWorkflowV2AdminFeatures.cs:336`) → `BaseEntity` sinh `Guid.NewGuid()`**Id MỚI**; Steps/Levels cũng row mới.
2. Phiếu đọc workflow **theo Id đã pin**: `PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId`**tham chiếu SỐNG, không snapshot**.
3. Bản cũ chỉ bị `IsActive=false` (`:334`) ⇒ **vẫn resolve được** ⇒ phiếu chạy tiếp trên cấu hình **CŨ**, im lặng, không lỗi.
4. **Không có đường re-pin:** `PE.ApprovalWorkflowId` chỉ ghi ở `PurchaseEvaluationFeatures.cs:149` (create) / `:291` (update), mà update chặn `:249-251` *"Chỉ sửa được phiếu khi ở phase Nháp hoặc Trả lại"*. `UPDATE…ApprovalWorkflowId` trong Migrations = **0 hit**.
⇒ Tick `AllowApproverDelete` hôm nay ⇒ sinh v(n+1) ⇒ **phiếu đang treo vẫn đọc v(n)****không có cờ** ⇒ quyền xóa **không tới đúng phiếu cần xóa**.
⇒ Đường vòng duy nhất (Trả lại → gửi lại) chạy quy trình **lại từ đầu****vẫn ăn lũy kế****không giải được ca UAT gốc**.
## F.1 Hiện trạng: KHÔNG có lệnh Update nào
Mặt admin V2 chỉ 4 việc; **không** Update (trừ bật/tắt `IsUserSelectable`). Mọi POST **tự tăng** `Version = MAX(Version per Code)+1` (`:325-328`), client không truyền được, **kèm tác dụng phụ hạ `IsActive`** mọi bản cùng `ApplicableType` (`:331-334`).
⇒ (7) = **XÂY MỚI một lệnh chưa từng tồn tại**, không phải nới rào.
FE: Designer chỉ ở **`fe-admin/…/ApprovalWorkflowsV2Page.tsx`** (fe-user `WorkflowMatrixViewPage.tsx` chỉ-xem). **Không có nút "Sửa"** — chỉ **"Nhân bản"** (`:479`) mở dialog *"Tạo quy trình mới"* (`:638`), toast *"Version cũ đã archive"* (`:611`). Admin **có** thấy Version + trạng thái.
🔴 **0 cảnh báo "đang được dùng", 0 disable khi `IsActive`.** Designer HĐ **V1 CÓ** badge *"N HĐ còn chạy"***V2 không port**.
## F.2 🔴 QUY TẮC PHÂN LOẠI (rút gọn, kiểm được bằng máy)
**AN TOÀN ⟺ thoả CẢ HAI phép thử:**
> **(T1) Khung không đổi** — tập `{Step.Order}` và tập `{Level.Order}` giữ nguyên.
> **(T2) Luật chơi không đổi** — không đụng trường quyết định **đường đi** của phiếu. Hiện tập này = `{CeoApprovalThreshold}`.
Lý do **(T1)**: con trỏ phiếu là **LAI**`CurrentWorkflowStepIndex` dùng **INDEX** vào list đã sort (`:686`); `CurrentApprovalLevelOrder` so **GIÁ TRỊ Order** (`:694`). Giữ nguyên 2 tập Order ⇒ **không con trỏ nào dịch**.
🔴 Lý do **(T2)** — **owner chốt 2026-07-27, và nó SỬA quy tắc chứ không chỉ điền một ô.** Bản đầu của quy tắc chỉ có (T1), nên `CeoApprovalThreshold` (0 đụng Order) **tự động rơi vào AN TOÀN**. Owner xếp nó **PHÁ VỠ** ⇒ lộ ra rằng "không dịch con trỏ" **chưa đủ** để gọi là an toàn: một thay đổi có thể **không làm kẹt phiếu nào** mà vẫn **đổi luật chơi giữa chừng** (phiếu đang ở nhánh "không cần CEO" bỗng thành "cần CEO"). Đó là an toàn **kỹ thuật** nhưng không an toàn **về niềm tin** — người đã duyệt thì duyệt theo ngưỡng họ nhìn thấy lúc đó.
**Bài học mang theo:** thêm trường mới vào workflow phải hỏi **cả hai** câu, không chỉ câu con trỏ. Trường nào lái đường đi của phiếu thì thuộc (T2) dù không đụng Order.
### Bảng 13 thao tác
| # | Thao tác | Hạng | Lý do (mã) |
|---|---|---|---|
| 1 | **Thêm người vào Cấp có sẵn** (row trùng `Order`) | ✅ AN TOÀN | `levelGroups` giữ nguyên khoá; người mới duyệt được ngay, người cũ không mất gì (`:689-695`, `:702`) |
| 2 | **Bật/tắt cờ `Allow*`** (gồm **F6**) | ✅ AN TOÀN | thuộc tính row, 0 đụng Order |
| 3 | **Đổi tên hiển thị** | ✅ AN TOÀN | display-only |
| 4 | **Bớt người khỏi Cấp** | ⚠️ NỬA | con trỏ không dịch, NHƯNG người bị gỡ đang chờ → **403** in **GUID trần** (`:705`); row đã ký → **FK `Restrict` chặn**; Cấp rỗng người ⇒ **phiếu KẸT** |
| 5 | **Đổi `ApproverUserId`** | ⚠️ NỬA | như #4 **+** chữ ký cũ (`LevelOpinion` khoá theo `LevelId`) **đổi nghĩa IM LẶNG** — ý kiến người A hoá thành của người B |
| 6 | Thêm Cấp (`Order` mới) | ❌ PHÁ VỠ | đổi `maxLevelOrder`, chèn chặng |
| 7 | Xoá Cấp | ❌ PHÁ VỠ | `pendingLevelGroup` null → `ConflictException` `:695`**phiếu KẸT** |
| 8 | Đổi `Order` Cấp | ❌ PHÁ VỠ | tương đương xoá+thêm với con trỏ |
| 9 | Thêm Bước | ❌ PHÁ VỠ | `steps[currentIdx]` trỏ **sang bước khác, ÂM THẦM** (`:686`) |
| 10 | Xoá Bước | ❌ PHÁ VỠ | số bước < `currentIdx``ConflictException` `:683`**phiếu KẸT** |
| 11 | Đổi `Order` Bước | ❌ PHÁ VỠ | như #9 |
| 12 | Đổi `CeoApprovalThreshold` | ❌ **PHÁ VỠ** *(owner chốt)* | Qua **(T1)** nhưng **trượt (T2)** — đổi luật chơi giữa chừng: phiếu đang ở nhánh "không cần CEO" bỗng thành "cần CEO". Người đã duyệt thì duyệt theo ngưỡng họ **nhìn thấy lúc đó** |
| 13 | Đổi `Step.DepartmentId` | ✅ **AN TOÀN** *(owner chốt)* | BE **không** dùng field này để khớp người duyệt (chỉ hint) ⇒ 0 phiếu kẹt. 🔸 Kèm việc: Designer FE **ép nhập** (`:573-575`) trong khi BE coi là optional — lệch sẵn có, xem F-17 |
🔴 **4 read-site con trỏ, mỗi cái vỡ MỘT KIỂU:**
| Read-site | Kiểu vỡ |
|---|---|
| `ResolveV2InboxIdsAsync` — hộp thư "Chờ duyệt" (`PurchaseEvaluationFeatures.cs:842-849`) | 🔴 **IM LẶNG**: `idx >= steps.Count → continue` ⇒ phiếu **biến mất khỏi màn Duyệt**, không lỗi |
| Flow-tree detail (`:1135-1191`) | tô sai Done/Current/Pending |
| Banner "Đến lượt bạn" (`:1223-1243`) | banner rỗng ⇒ FE mất gate `blockedByV2Level` |
| Badge "Kết thúc trước CEO" (`:672-677`) | giả định ẩn **`Step.Order - 1 == StepIndex`** — chỉ đúng khi Order liền mạch 1..N |
## F.3 ⚠️ BLAST RADIUS — không phải việc riêng của PE
`ApprovalWorkflow` V2 là schema **DÙNG CHUNG ≥6 module**: Proposal · Leave/OT · Travel/Vehicle · WorkflowApps · Contract (+PE). Hai con trỏ có **198 hit / 23 file**.
⇒ Test **bắt buộc phủ PE + ≥1 module Văn phòng số**.
## F.4 ✅ CHECKLIST hạng mục F
- [ ] **F-1.** `UpdateAwDefinitionCommand` mới — **CHỈ** nhận trường hạng ✅ (#1 #2 #3). Trường hạng ❌ gửi lên ⇒ **từ chối kèm thông điệp bảo tạo version mới**
- [ ] **F-2.** 🔴 Validator server-side thi hành **F.2 — CẢ HAI phép thử**: **(T1)** so tập `{Step.Order}` + `{Level.Order}` trước/sau · **(T2)** chặn thay đổi `CeoApprovalThreshold`. Vi phạm bất kỳ ⇒ **409**. Răng thật, KHÔNG dựa FE ẩn nút
- [ ] **F-17.** Designer FE **ép nhập** `Step.DepartmentId` (`:573-575`) trong khi BE coi là **optional** (chỉ hint) — lệch sẵn có, nay owner cho sửa field này tại chỗ nên phải chốt: nới FE cho optional, hay giữ ép. Hỏi lại nếu không rõ, ĐỪNG tự đổi
- [ ] **F-3.** 🔴 **CẤM delete-then-insert Levels****diff theo `Level.Id`**. Replace-all sẽ **nổ FK `Restrict`** trên workflow đang chạy + mồ côi `LevelOpinion`
- [ ] **F-4.** Update **KHÔNG** tăng `Version`, **KHÔNG** đụng `IsActive`, **KHÔNG** đổi `Id` — giữ Id là toàn bộ mục đích
- [ ] **F-5.** Hạng ⚠️ NỬA (#4 #5): cho phép nhưng **cảnh báo trước** + chặn khi row đã có `LevelOpinion` (đón trước FK) + chặn khi Cấp sẽ **rỗng người**
- [ ] **F-6.** Sửa message 403 `:705` — đang in **GUID trần**, đổi sang **tên người**
- [ ] **F-7.** Designer fe-admin: thêm nút **"Sửa"** (khác "Nhân bản"), chỉ mở trường hạng ✅
- [ ] **F-8.** Port badge **"N phiếu còn chạy"** từ Designer HĐ V1 sang V2 + cảnh báo trước khi sửa
- [ ] **F-9.** `DeleteAwDefinitionCommandHandler` — thêm **usage-check** (nợ tự khai `:400-402`); hiện xoá nhầm ⇒ **FK 547 → 500**
- [ ] **F-10.** Vá comment **SAI** `ApprovalWorkflow.cs:81-82` (*"KHÔNG OR-of-many"*) — mã + CLAUDE.md đều **OR-of-N**
- [ ] **F-11.** 🔴 Tick F6 ở Designer: `fe-admin:999-1007` (dưới F5) + **5 dây** `:54 / :110 / :159 / :177 / :605` — ⚠️ **thiếu `:605` = tick xong KHÔNG gửi lên BE** (tick vô hình) + `fe-user:282` + BE `:41 / :230 / :188 / :368`
### Test hạng mục F
- [ ] **F-12.** Update hạng ✅ trên workflow **đang có phiếu chạy** ⇒ phiếu **nhận thay đổi ngay** (bài kiểm chứng F.0)
- [ ] **F-13.** Update đụng tập Order ⇒ **409**, workflow **không đổi**
- [ ] **F-14.** Bớt người mà row đã có `LevelOpinion` ⇒ chặn **lịch sự** (409), KHÔNG nổ FK 547
- [ ] **F-15.** 🔴 Tick F6 bằng Update ⇒ phiếu `ChoDuyet` **đang treo từ trước** xoá được (nối F với A-E)
- [ ] **F-16.** Regression ≥1 module Văn phòng số (Leave/OT hoặc Proposal)
## F.5 Nợ khai thêm
| # | Nợ | Ghi chú |
|---|---|---|
| N5 | Designer V2 **0 cảnh báo đang-dùng, 0 disable** khi `IsActive` — V1 có, V2 không port | F-8 vá |
| N6 | `DeleteAwDefinition` 0 usage-check ⇒ FK 547 → 500 | F-9 vá |
| N7 | Comment `ApprovalWorkflow.cs:81-82` sai (OR-of-N) | F-10 vá |

View File

@ -162,6 +162,128 @@ UNIQUE `(PurchaseEvaluationId, ApprovalWorkflowLevelId)` (`PurchaseEvaluationLev
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.
<!-- checkpoint W1+W2+W4 -->
---
## 🔴 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ẫ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``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 -->