Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md
2026-07-27 09:59:49 +07:00

29 KiB

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):

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:

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-73PE.ApprovalWorkflowIdApprovalWorkflows DeleteBehavior.Restrict, comment ghi rõ "Restrict để KHÔNG xóa workflow".
  • PurchaseEvaluationLevelOpinionConfiguration.cs:25-28LevelOpinion.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:

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ƯỚCsteps[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:

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-849steps[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"? — , 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:689currentStep.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ịchAN 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ỚIId 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ừ đầuvẫ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.CountConflictException :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-314readOnly

⇒ 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".
  • onClonesetCloneFrom(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 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 AllowApproverDelete4 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 allowApproverFinalizechè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).