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ó fieldVersion⇒ 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 rowApprovalWorkflows+ N row Step + M row Level mới, (b) hạ active bản cũ, (c) bản cũ vẫnIsUserSelectable=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→ApprovalWorkflowsDeleteBehavior.Restrict, comment ghi rõ "Restrict để KHÔNG xóa workflow".PurchaseEvaluationLevelOpinionConfiguration.cs:25-28—LevelOpinion.ApprovalWorkflowLevelId→ LevelRestrict, 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-402tự 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ƯỚ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ướicurrentIdxthì némConflictException:683"CurrentWorkflowStepIndex=… không hợp lệ" ⇒ phiếu KẸT, không ai duyệt được. - Xoá CẤP đang chờ ⇒
pendingLevelGroupnull ⇒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 (
:705string.Join(", ", allowedUserIds)— nốiGuid, 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ớpApproverUserId, fallbackpendingLevelGroup.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:
- 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)". - Match:
:702allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet()⇒ bất kỳ ai trong nhóm duyệt được. - Validator:
ApprovalWorkflowV2AdminFeatures.cs:309-314HaveNoDuplicateApproverInSameLevelgom theo{Order, ApproverUserId}⇒ cho phép nhiều row cùngOrdermiễn khác người. Comment:252-256nó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:1164GroupBy(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) ⇒levelGroupsvẫ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
OrderMỚI (= thêm CẤP) ⇒ đổimaxLevelOrder, 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ùngOrder, 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
Restricttrên đúng workflow đang chạy (F10), và đổi Id Level ⇒ mọiLevelOpinioncũ 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(:479onClick={() => 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útLư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ốiLị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).