20 KiB
SUB-REVIEWER — W1 KHKK adversarial diff review (S161, 2026-07-29)
Ghi-đĩa-TRONG-lúc-làm (chống #53 — 3 vai trước chết trong wave này). File = thứ sống sót. Scope: toàn bộ M/?? trong
git status(trừ.claude/). 12 file M (+990/−19) + 20 file ?? (~8.2K dòng, trong đó Designer.cs 6.960 = sinh máy). KHÔNG phủ lại: FK/index/maxLength/parity 2 DB (database-agent 8/8) · fault-inject test (test-specialist 4 test) · suite 566/0 + build 0W/0E (lead).
TIẾN TRÌNH
- Trục 1 — seeder
SeedKeHoachKyKetAccessAsync(5 sub-check a→e) - Trục 5 — MenuKeys.All (vị trí + tác dụng phụ)
- Trục 2 — CodeGenerator twin-diff + DI
- Trục 3 — 3 site AWV2AdminFeatures + grep-cùng-lớp
- Trục 4 — FE containment 2 lớp
- Trục 6 — boundary
TRỤC 1 — Seeder (DbInitializer.cs)
(a) Call-site thứ tự — PASS. RevokeTemporarilyHiddenModulesAsync :2096 → SeedProcurementMasterAccessAsync :2102 → SeedKeHoachKyKetAccessAsync :2109. Đứng SAU revoke ⇒ thắng revoke. Cũng SAU SeedAllRolesReviewReadPermissionsAsync :2089 (nhánh read-only skip-existing) ⇒ không bị nhánh nào hạ cờ về sau. Không có seeder nào chạy sau :2109 (2 call S65/S69 đã comment-out :2119/:2128).
(e) Revoker KHÔNG phủ Khkk_/KeHoachKyKet — PASS (rủi ro 2-chiều gotcha #84 KHÔNG hiện thực). Đọc thân RevokeTemporarilyHiddenModulesAsync:2296-2310: predicate CHỈ 3 nhánh MenuKey.StartsWith("Hrm") || StartsWith("Off") || == MenuKeys.Personal. Nhánh S92 (Contracts/Master/Catalog) đã GỠ @S159. Không prefix nào ăn Khkk_* hay KeHoachKyKet ⇒ grant không bị lật ở restart kế. (Ghi chú: thứ tự :2109 sau :2096 vẫn giữ đúng phòng khi revoker sau này mở rộng prefix.)
(b) UPGRADE-if-exists — PASS. :2414-2470: existing.TryGetValue((role.Id,key), out row) → nâng từng cờ if(!row.CanX) → continue; nhánh else db.Permissions.Add(...) chỉ chạy khi row CHƯA có (fresh DB). Đúng khuôn SeedProcurementMasterAccessAsync:2375-2394, KHÔNG phải insert-only NO-OP (F-S1 đóng). Khác khuôn gốc 2 điểm CÓ CHỦ ĐÍCH: (i) key dictionary là tuple (RoleId, MenuKey) vì quét 13 role thay vì 1 → đúng, vì .Where(p => keys.Contains(p.MenuKey)) không lọc role nên ToDictionary(p => p.MenuKey) kiểu cũ sẽ ném ArgumentException trùng khoá — implementer tránh được bẫy này; (ii) thêm vế CanDelete.
(c) CanDelete chỉ Drafter+Admin, KHÔNG hạ cờ — PASS. allowDelete = role.Name == AppRoles.Admin || role.Name == AppRoles.Drafter. Mọi phép gán đều dạng if (!row.CanX) row.CanX = true ⇒ monotonic tăng, 0 câu nào set = false trên nhánh update. Nhánh insert đặt CanDelete = allowDelete (role khác = false) — đúng.
(d) 7 key khớp bộ seed menu-row — PASS (so từng chuỗi). Seeder :2419-2424 = MenuKeys.KeHoachKyKet + Khkk_WfView, Khkk_List, Khkk_Create, Khkk_Pending, Khkk_Approved, Khkk_Deleted. Menu-row :1779-1786 = Khkk_G1 + đúng 6 chuỗi trên. Khkk_G1 CỐ Ý loại (group điều hướng, CanRead-all đã có) — khai trong comment :2417-2418. 0 typo. Phép đếm máy: tập literal Khkk_* trong TOÀN file = đúng 7 chuỗi {G1, WfView, List, Create, Pending, Approved, Deleted} — không có key thứ 8 lạc đâu đó.
2 rủi ro phụ đã truy và ĐÓNG (không thành flag):
existing.ToDictionary((RoleId,MenuKey))có thể némArgumentExceptionnếu DB có row trùng ⇒ đã kiểmPermissionConfiguration.cs:26cóHasIndex(RoleId, MenuKey).IsUnique()⇒ không thể trùng.- Nhánh INSERT (fresh DB) cần MenuItems tồn tại trước vì FK
Permission.MenuKey → MenuItems.Key(PermissionConfiguration.cs:21-24) ⇒SeedMenuTreeAsync:109chạy TRƯỚCSeedAdminPermissionsAsync:110(ungated) ⇒ FK an toàn.
F-1 (MAJOR, evidence-gap KHÔNG phải lỗi mã) — acceptance §③-B "đo CỜ" hiện KHÔNG đo được ở đâu. Đo đĩa+DB @review: SolutionErp_Dev có sys.tables=96, mig cuối 20260729122015_AddContractSigningPlans (Mig 69 đã apply), nhưng SELECT COUNT(*) FROM Permissions WHERE MenuKey LIKE 'Khkk%' OR MenuKey='KeHoachKyKet' = 0 và MenuItems cũng 0 row Khkk (menus=100 vs prod 142) ⇒ DB Dev CHƯA từng chạy app sau S159 ⇒ cả 2 nhánh (upgrade trên prod · insert trên fresh) chưa nhánh nào chạy thật lần nào. Không chặn commit (code đúng khuôn, test-specialist có test seeder-reflection), nhưng acceptance F-04 chỉ đóng được sau restart prod — đừng tick trước.
TRỤC 5 — MenuKeys.All
Vị trí + đúng array — PASS (đo máy, không đọc mắt). Parse public static readonly string[] All = [...] → 55 entry, có KeHoachKyKet, KHÔNG có HopDongCung (GĐ4 vẫn ngoài All — đúng chủ đích W1). Dòng thêm = MenuKeys.cs:179, nằm TRONG array (giữa Personal :178 và System :180), không phải trong comment. Đúng 1 key vào All trong cả diff — 0 key ngoài chủ đích.
Tác dụng phụ — quét 7 call-site MenuKeys.All toàn repo: Program.cs:82 (sinh 4 policy — chủ đích) · DbInitializer.cs:2057 (Admin full-CRUD; prod row đã có ⇒ skip-existing, nhưng seeder mới :2109 nâng đủ cờ cho CẢ Admin ⇒ không hở) · DbInitializer.cs:2209 (.Where(InReviewScope) = StartsWith("Pe_") ⇒ KeHoachKyKet không lọt) · AuthorizePolicyRegressionTests.cs:138 (lưới policy-∈-All — nay hợp lệ) · 3 chỗ còn lại là comment. ⇒ 0 tác dụng phụ ngoài ý muốn.
Gotcha #35 (root có con phải extend 3 chỗ GetMyMenuTreeQuery) — KHÔNG áp. Đọc GetMyMenuTreeQuery.cs:56-84: 4 inherit-root hardcode (Contracts/Workflows/PurchaseEvaluations/PeWorkflows). KeHoachKyKet KHÔNG cần vào đó vì 7 row Khkk_* có Permission TƯỜNG MINH (không sống nhờ kế thừa). Đúng thiết kế — nhưng nghĩa là quên grant 1 leaf = leaf đó biến mất im lặng, không có lưới kế thừa đỡ.
F-2 (MAJOR) — canonical drift docs/STATUS.md chưa cập nhật, mà chính row đó tự ràng buộc. docs/STATUS.md:464 | Menu keys | **54** | và :463 | Policies | **216** |, trong đó :463 viết nguyên văn "row này BUỘC đổi CÙNG row Menu keys: thêm 1 menu key ⇒ 55 × 4 = 220". Đo mã: |All| = 55 ⇒ Policies thật = 220. Diff KHÔNG đụng docs/ ⇒ commit này làm lệch cả 2 số canonical + governance-detectors.ps1 Get-StatusValue resolve số cũ. Fix 1 dòng: sửa Menu keys → 55 + Policies → 220 cùng commit (đếm consts hiện = 56 public const string, ≠ |All| vì HopDongCung cố ý ngoài All — nếu row Menu keys neo theo const thì ghi rõ thước).
F-3 (MINOR) — comment nguồn của chính const nay SAI. MenuKeys.cs:37-40 vẫn viết "2 root PLACEHOLDER … CỐ Ý NGOÀI All — chưa có module ⇒ không sinh policy per-action", áp cho CẢ KeHoachKyKet lẫn HopDongCung. Sau diff mệnh đề này chỉ còn đúng cho HopDongCung. Cùng lớp: DbInitializer.cs:1772 "Ngoài MenuKeys.All". Fix: tách câu — KeHoachKyKet ĐÃ vào All @S161, HopDongCung còn ngoài.
TRỤC 2 — ContractSigningPlanCodeGenerator + DI
Twin-diff vs PurchaseEvaluationCodeGenerator — PASS, lệch đúng 3 điểm CHỦ ĐÍCH. So từng câu (ContractSigningPlanCodeGenerator.cs:16-51 ⟂ PurchaseEvaluationCodeGenerator.cs:14-54): cùng IsolationLevel.Serializable + FirstOrDefaultAsync(s => s.Prefix == prefix) + nhánh null LastSeq = 1 / nhánh có LastSeq += 1 + UpdatedAt + SaveChanges → Commit → return $"{prefix}/{seq.LastSeq:D3}" + catch { Rollback; throw; }. Lệch: (1) prefix KHKK/{year} thay PE/{year}/{typeLetter} — bỏ tham số entity nên GenerateAsync(CancellationToken) không nhận entity (hợp lý: mã không phụ thuộc phiếu); (2) bảng dùng chung WorkflowAppCodeSequences thay bảng riêng; (3) không có typeLetter switch. D3 giữ nguyên ⇒ khớp ^KHKK/\d{4}/\d{3}$.
DI — PASS. DependencyInjection.cs:42 services.AddScoped<IContractSigningPlanCodeGenerator, ContractSigningPlanCodeGenerator>(); đặt ngay cạnh IPurchaseEvaluationCodeGenerator :40, cùng scope Scoped (đúng — service chạm DbContext scoped). Namespace using SolutionErp.Application.ContractSigningPlans.Services; :7 đã thêm.
Bảng dùng chung — kiểm tương thích: WorkflowAppCodeSequenceConfiguration.cs:17 Prefix PK HasMaxLength(20); "KHKK/2026" = 9 ký tự ⇒ OK, và không đụng prefix 4 module Office (DT/LR|DT/OT|DT/CT|DX/XE). Semantics khớp helper cũ TravelVehicleCodeGen.GenerateMaDonTuAsync (TravelVehicleApprovalFeatures.cs:818-834): helper tạo row LastSeq=0 rồi ++, bản mới tạo thẳng =1 — cùng kết quả 001, không lệch. Không tái dùng helper được vì nó internal static trong assembly Application (Infrastructure không thấy) ⇒ trùng lặp là BẮT BUỘC, không phải cẩu thả.
F-4 (LOW, kế thừa từ twin — KHÔNG phải hồi quy mới): dateTime.UtcNow.Year ⇒ đêm 31/12 giờ VN (UTC+7) sinh mã mang năm CŨ; và :D3 tràn thành 4 chữ số từ phiếu thứ 1000 (test regex \d{3} sẽ đỏ). Cả 2 y hệt PE codegen đang chạy prod ⇒ ghi nhận, không chặn W1.
Ghi nhận (không tính flag): generator hiện CHƯA có call-site sản xuất nào (grep chỉ ra DI + test) — đúng phạm vi W1 (schema-first), W2 mới gọi. Cũng lưu ý cho W2: BeginTransactionAsync sẽ ném nếu handler đã mở transaction ngoài.
TRỤC 3 — 3 site ApprovalWorkflowV2AdminFeatures.cs
Mirror khuôn VehicleBooking từng mệnh đề — PASS 3/3 (so sát dòng-kề-dòng):
- Site 1
CountLiveOpinionsAsync:945-949⟂ twin:935-939: đủ.AsNoTracking()·!o.IsDeleted·db.ContractSigningPlans.Any(p => p.Id == o.ContractSigningPlanId && !p.IsDeleted)viết TƯỜNG MINH (đúng ý comment:889— không dựa query-filter) ·GroupBy+LevelOpinionCount. KHÔNG dùngIgnoreQueryFilters— ĐÚNG (site này chỉ đếm phiếu SỐNG). - Site 2
ListRetainedOpinionsAsync:1017-1022⟂ twin:1009-1015: đủ.AsNoTracking().IgnoreQueryFilters()(bắt buộc — header KHKK CÓHasQueryFilter(!IsDeleted), nếu thiếu thì phiếu xoá mềm rơi khỏi JOIN ⇒ bị kết luận "mồ côi" ⇒ xoá cứng nhầm) ·.Join(db.ContractSigningPlans, …)·.Where(x => x.o.IsDeleted || x.p.IsDeleted)· nhãn module"Kế hoạch ký kết HĐ"+ mãx.p.MaKeHoach+x.p.IsDeleted. - Site 3
PurgeOrphanOpinionsAsync:1083-1086⟂ twin:1078-1081: đủ!db.ContractSigningPlans.IgnoreQueryFilters().Any(...)(KHÔNG có vếo.IsDeleted— đúng bản thu hẹp F2, giữ chữ ký thu-hồi trên phiếu còn sống) ·ExecuteDeleteAsync· cộng vàon.
Site thứ 4 cùng lớp — quét 2 hướng, KẾT LUẬN: không có site enumeration nào bị bỏ sót.
grep -n "LevelOpinions"trong file = 24 dòng mã = 8 module × 3 site (PE·Contract·Proposal·Leave·Ot·Travel·Vehicle·ContractSigningPlan) — không lệch.- Quét token-song-sinh
VehicleBookingLevelOpinionstoàn repo (trừMigrations/) ra 6 file: AdminFeatures (3 site ✓) ·IApplicationDbContext.cs(DbSet — đã thêm:152✓) ·ApplicationDbContext.cs(impl ✓) ·VehicleBookingLevelOpinionConfiguration.cs(module-riêng) ·TravelVehicleApprovalFeatures.cs(đường Approve của MODULE đó — bản KHKK là việc W2) ·WorkflowAppApproveV2Tests.cs(test module đó). ⇒ 0 site liệt-kê-thủ-công còn thiếu.
F-5 (MINOR) — 2 con SỐ trong chú thích cùng file nay SAI, do chính diff này. :886-887 "quét CẢ 7 bảng LevelOpinion đang FK vào ApprovalWorkflowLevels (PE + HĐ + 5 module Văn phòng số)" và :961 "giữ 7 khối cùng một hình dạng" — sau diff là 8. Đây đúng lớp "chú thích mang số = khẳng định đo được" (bài S159): người sửa sau đếm 7 rồi dừng ⇒ bỏ sót đúng module mới. Fix: sửa 7→8 + thêm "+ KHKK" vào ngoặc liệt kê.
F-6b (LOW, kế thừa) — site GHI VẾT changelog :847-877 KHÔNG phủ type=10. Điều kiện def.ApplicableType is DuyetNcc or DuyetNccPhuongAn ⇒ admin sửa quy trình KHKK tại chỗ sẽ KHÔNG ghi changelog vào phiếu KHKK đang treo, dù module MỚI CÓ bảng ContractSigningPlanChangelogs (entity #6). Hiện Contract cũng đang bị bỏ y vậy nên đây là hố có sẵn, không phải hồi quy — nhưng chú thích :843-846 chỉ khai giới hạn cho "5 module Văn phòng số", nay hụt thêm KHKK ⇒ nên khai đủ hoặc wire ở W2/W3.
TRỤC 4 — FE ApprovalWorkflowsV2Page.tsx (fe-admin)
(a) copyFromDefinition — containment ĐỦ, và đúng chỗ. copyFromDefinition:204-205 vẫn bê allowApproverSkipToFinal/Finalize từ nguồn (clone/edit) vào state — nhưng cả 2 đường RA đều đã bịt: levelFlagsPayload(e, lockEarlyFinalize) ép false (:255-256) và cả 2 call-site truyền cờ (:783 POST-create, :815 PUT-sửa-tại-chỗ). grep levelFlagsPayload = 1 định nghĩa + đúng 2 call-site ⇒ không có đường thứ 3 bỏ sót. Kịch bản độc: clone 1 quy trình type-3 có finalize=true sang type-10 ⇒ state true nhưng payload false ⇒ DB false, và render cũng hiện untick (checked={earlyFinalizeLocked ? false : …}) ⇒ màn hình khớp dữ liệu. applicableType là prop của definition đang mở (:441 truyền type.applicableType), không phải state trôi ⇒ khoá đúng đối tượng.
(b) DEFAULT_CODE_BY_TYPE[10] = 'QT-KHKK-V2-001' — suy diễn HỢP LỆ. Khớp khuôn 3 dòng có sẵn (QT-DN-V2-001 / QT-DN-PA-V2-001 / QT-HD-V2-001 = QT-{viết tắt}-V2-001), chỉ là giá trị điền sẵn ô Mã (:645 defaultCode = … ?? 'QT-V2-001'), admin sửa được. Không khai thì rơi fallback chung — nghĩa là không khai cũng không vỡ, khai chỉ để đẹp. Không có rủi ro.
(c) Regression type≠10 — 0 pixel, xác minh từng nhánh. Khi earlyFinalizeLocked=false: className 2 label trả về đúng chuỗi cũ (…text-slate-700 và …text-emerald-700), title={undefined} (React bỏ attribute), checked={false ? false : entry.x} ≡ entry.x, disabled={false} (bỏ attribute), khối ghi chú bọc {earlyFinalizeLocked && …} không render. ⇒ Designer type 1/2/3 không đổi gì.
F-7 (MAJOR) — nhãn tiếng Việt của type 10 KHÔNG TỒN TẠI ở BE ⇒ UI hiện tên enum tiếng Anh, và tên đó bị LƯU vào DB. FE cố ý bỏ bảng nhãn và khai rõ (ApprovalWorkflowsV2Page.tsx:159-161: "nhãn hiển thị lấy từ BE qua applicableTypeLabel"), nhưng BE AwLabels.Type (ApprovalWorkflowV2AdminFeatures.cs:90-95) chỉ có 3 entry (DuyetNcc/DuyetNccPhuongAn/Contract); :206 fallback type.ToString(). Hệ quả tại chính màn W1 phải nghiệm thu: tiêu đề trang :302 → "Quy trình duyệt (Mới): ContractSigningPlan" · card :320 → "ContractSigningPlan" · và nặng nhất :647 useState(\Quy trình ${applicableTypeLabel}`)⇒ admin bấm Lưu là **ghi vào cột Name của DB chuỗi "Quy trình ContractSigningPlan"**. Vi phạm quy ước "UI 100% tiếng Việt". Đây là lớp *hợp-đồng-đứt-giữa-2-bờ*: FE khai bên kia lo, bên kia không có, màtsc+ build + 566 test đều xanh. Fix 1 dòng: thêm[ApprovalWorkflowApplicableType.ContractSigningPlan] = "Kế hoạch ký kết HĐ"vào:94. (Chuỗi tiếng Việt đã có sẵn trong CÙNG file ở :1021choRetainedOpinionRef` ⇒ càng lộ chỗ thiếu.)
F-8 (MINOR) — "containment 2 lớp" thực chất là 2 lớp CỦA CÙNG 1 client; BE không có rào. CreateAwDefinitionCommand gán thẳng AllowApproverSkipToFinal/Finalize từ request (:373-374) và validator không ràng theo ApplicableType (grep 2 cờ trong file: chỉ có rào ở đường PUT :584-616 và ép false cho dòng MỚI :802-803). ⇒ 1 lệnh curl POST /api/approval-workflows-v2 với applicableType=10, allowApproverFinalize=true vẫn tạo được đúng cái "cờ chết" mà F-B3 muốn diệt. Spec §②-10 chỉ yêu cầu FE nên KHÔNG tính lệch-spec, nhưng nếu muốn containment thật thì thêm rule validator: type 10 ⇒ 2 cờ phải false.
F-9 (LOW) — neo dòng trong chú thích mới trỏ dòng TRƯỚC commit. :157-158 viết "rơi về fallback chung 'QT-V2-001' (:614)"; sau chính diff này fallback nằm ở :645 (:614 giờ là dòng destructuring prop applicableType). Người đọc sau nhảy tới :614 sẽ thấy thứ khác. Fix: đổi (:614) → (:645) hoặc bỏ số dòng, trỏ tên biến defaultCode.
Ghi nhận i-1 (không phải lỗi diff) — đường tới Designer type 10 CHỈ có URL gõ tay. Xác minh độc lập: Layout.tsx:163-167 allowlist AwV2_* chỉ nhận DuyetNcc|DuyetNccPhuongAn|Contract; landing card :317-333 không phải link (mô tả :309 bảo "chọn loại quy trình từ menu bên trái"). Route App.tsx:76 /system/approval-workflows-v2/:typeCode có ⇒ deep-link /system/approval-workflows-v2/ContractSigningPlan chạy được nhờ TYPE_CODE_TO_INT. Đúng spec "menu KHÔNG đổi" — nhưng acceptance §③-B "Admin tạo được quy trình type=10 3 Bước" phải nghiệm thu bằng deep-link đó, đừng chờ thấy nó trên sidebar.
Mirror 2-app (§3.9) — N-A. fe-user/src/pages/system/ không tồn tại; grep ApprovalWorkflow fe-user/src chỉ ra màn xem read-only của module khác. Designer là admin-only ⇒ không cần mirror.
TRỤC 6 — Boundary
3 file phải 0-diff — PASS. git status --porcelain không liệt ContractCodeGenerator.cs (quyết "bỏ MB" KHÔNG đụng codegen ✓), ContractWorkflowService.cs, ContractFeatures.cs (O-A để mở ✓). Toàn bộ 12 file M + 20 file ?? đều nằm trong phạm vi W1 (Domain/Config/Mig/DbSet/MenuKeys/DI/Seeder/AWV2-3-site/FE-Designer/test). 0 file lạc.
Mig 3-file (#17) — ĐỦ nhưng xem F-0. 20260729122015_AddContractSigningPlans.cs (372 dòng) + .Designer.cs (6.960) + ApplicationDbContextModelSnapshot.cs (+550, tracked-M) = 3/3.
F-0 (HIGH — GATE THỦ TỤC, phải xử lý TRƯỚC commit). 2/3 file migration đang UNTRACKED (??) trong khi snapshot đã tracked-M. Đây đúng vết S155: git commit -a (hoặc -am) chỉ nạp file đã theo dõi ⇒ commit sẽ mang snapshot + 7 DbSet + 7 EF Config + Domain nhưng THIẾU migration ⇒ CI/prod dựng model có 7 bảng mà DB không có bảng ⇒ Invalid object name 'ContractSigningPlans'; build + 566 test local VẪN XANH nên không lộ. Tổng cộng 20 đường dẫn ?? (2 thư mục Domain/Application + 7 Configuration + 2 Migration + 1 Service + 1 test) phải git add tường minh. Nghiệm thu 1 lệnh: git status --porcelain -- src tests | grep '^??' phải rỗng ngay trước git commit.
TIẾN TRÌNH (cập nhật cuối)
- Trục 1 · [x] Trục 5 · [x] Trục 2 · [x] Trục 3 · [x] Trục 4 · [x] Trục 6
VERDICT: PASS_WITH_FLAGS — 10 issue (1 HIGH-gate · 3 MAJOR · 4 MINOR · 2 LOW-kế-thừa). Không có lỗi logic nào chặn commit; F-0 là điều kiện thủ tục BẮT BUỘC, F-7 nên fix cùng commit (1 dòng).