diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md new file mode 100644 index 0000000..4e1662d --- /dev/null +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md @@ -0,0 +1,167 @@ +# 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. + + + +