Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-implementer-backend-5.md
2026-07-27 12:24:07 +07:00

7.2 KiB
Raw Blame History

sub-implementer-backend-5 — WAVE 5 BE: vá F1 + F2 (2 FLAG HIGH mất dữ liệu)

Ghi TRONG LÚC LÀM, flush từng phần, tự wc -c kiểm byte thật.

  • Ngày: 2026-07-27 · run 2026-07-27-S155-pe-delete-approver
  • Vật sửa: src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs
  • Nền đo trước wave: dotnet build 0W/0E · dotnet test 549 PASS / 0 FAIL

§0 — ĐỌC NGUYÊN VĂN F1 + F2 (reviewer-diff-dot1.md:33-80)

F1: PurgeOrphanOpinionsAsync (:886-918) dùng ExecuteDeleteAsync = auto-commit NGAY khi không có transaction ambient. Gọi tại :681-683, SaveChangesAsync mãi :822. 4 nguồn ném nằm GIỮA (ResolveUserNamesAsync:686 round-trip · Remove():771 severed-association · db.PurchaseEvaluations…ToListAsync:794-800 round-trip · ghi changelog N phiếu trong SaveChanges). Tiêu chí nghiệm thu: test giả lập SaveChanges ném SAU purge ⇒ số row opinion KHÔNG đổi.

F2: điều kiện purge :889-891 (+6 khối lặp tới :915) rộng hơn nhãn "mồ côi" ở CẢ HAI vế — (a) !db.PurchaseEvaluations.Any(p => p.Id == … && !p.IsDeleted) coi phiếu XOÁ MỀM ngang phiếu không tồn tại; (b) o.IsDeleted — opinion xoá mềm trên phiếu CÒN SỐNG = bản ghi kiểm toán. Phương án (a) của reviewer: chỉ purge khi phiếu cha thật sự KHÔNG CÒN ROW (IgnoreQueryFilters().Any(...)), phiếu xoá mềm thì CHẶN admin như phiếu sống.


§1 — ĐO NỀN TRƯỚC KHI SỬA (không tin doc, chạm đĩa)

Sự thật Bằng chứng
IApplicationDbContext KHÔNG expose Database grep -n "Database|BeginTransaction" src/.../Common/Interfaces/IApplicationDbContext.cs0 hit
Handler đã có tiền lệ ép kiểu ((DbContext)db) ApprovalWorkflowV2AdminFeatures.cs:941 (trong DetachTrackedOpinionsOn)
Tiền lệ transaction repo ContractCodeGenerator.cs:35-59var context = (DbContext)db; await using var tx = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + try/Commit/catch/Rollback
Microsoft.EntityFrameworkCore.Relational CÓ trong Application SolutionErp.Application.csproj<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
7 bảng *LevelOpinion KHÔNG có global query filter grep -rn "HasQueryFilter" Configurations/ → chỉ 1 hit PurchaseEvaluationConfiguration.cs:84 (bảng CHA). ⇒ vế o.IsDeleted trong purge là THẬT (không phải dead code)
FK opinion→cha = Cascade, opinion→Level = Restrict PurchaseEvaluationLevelOpinionConfiguration.cs:20-28
SQLite fixture BẬT FK enforcement thật test cũ UpdateAwDefinitionTests.cs:562-565 assert DbUpdateException khi Remove(level) có opinion ⇒ FK Restrict fire thật trong SQLite
Fixture SQLite hỗ trợ transaction SqliteDbFixture.cs:36-40 comment + UseSqlite(shared connection)

Hệ quả SUY RA TỪ ĐO (quan trọng cho thiết kế)

FK cha→opinion là Cascade ⇒ xoá cứng phiếu cha thì opinion cũng đi theo ⇒ "mồ côi thật" (cha KHÔNG CÒN ROW) gần như không sinh ra được qua đường ứng dụng. Nghĩa là sau khi thu hẹp theo F2, PurgeOrphanOpinionsAsync gần như là lớp phòng-vệ-chiều-sâu cho dữ liệu lệch sinh ngoài luồng (sửa DB tay / import / FK tắt), KHÔNG còn là đường chạy thường ngày. Đúng ý F2: guard chặn HẾT những gì còn cha, purge chỉ dọn thứ đã không thể khôi phục.


§2 — VÁ F1 (rào giao dịch) — ĐÃ LAND

Kỹ thuật chọn: await using var tx + CommitAsync cuối, KHÔNG try/catch — đúng khuôn nhà (LeaveOtApprovalFeatures.cs:34-44, ProposalFeatures.cs:386, TravelVehicleApprovalFeatures.cs:822). Dispose của IDbContextTransaction rollback nếu chưa Commit ⇒ diff là CHÈN-THÊM, 0 dòng thân method bị thụt lề lại (nếu bọc try/catch thì ~150 dòng phải re-indent = nhiễu review, khó soi).

  • Mở rào: ngay TRƯỚC PurgeOrphanOpinionsAsync (thao tác không-hoàn-tác đầu tiên). Guard phía trên chỉ ĐỌC AsNoTracking rồi ném, không cần nằm trong rào; mở sớm hơn chỉ kéo dài giữ khoá.
  • KHÔNG dùng IsolationLevel.Serializable như 4 code-generator: ở đó Serializable chống ĐUA cấp-số; ở đây yêu cầu là NGUYÊN TỬ, siết isolation chỉ làm màn quản trị dễ deadlock.
  • ExecuteDeleteAsync tự enlist vào transaction đang mở của context ⇒ 7 câu DELETE nay cùng sống/chết với SaveChangesAsync.

§3 — VÁ F2 (thu hẹp purge + chặn hồ sơ xoá mềm) — ĐÃ LAND

Phân hoạch KÍN, không còn kẽ (đây là điểm thiết kế chính):

Trạng thái chữ ký Xử lý sau vá Ai lo
cha SỐNG + ý kiến SỐNG chặn 409 (thông điệp CŨ, giữ nguyên) CountLiveOpinionsAsync (tầng 1)
cha XOÁ MỀM (row còn) chặn 409 + nêu TÊN hồ sơ ← MỚI ListRetainedOpinionsAsync (tầng 2)
ý kiến XOÁ MỀM, cha sống chặn 409 + nêu TÊN hồ sơ ← MỚI ListRetainedOpinionsAsync (tầng 2)
cha KHÔNG CÒN ROW purge (xoá cứng) — đường duy nhất còn lại PurgeOrphanOpinionsAsync
  • Áp CẢ 7 bảng chữ ký (PE · HĐ · Đề xuất · Nghỉ phép · Tăng ca · Công tác · Đơn xe), tầng 2 JOIN sang cha để lấy mã hồ sơ: MaPhieu · MaHopDong · MaDeXuat · MaDonTu×4.
  • Guard tầng 2 áp cho cả toRemove lẫn approverChanged — reviewer viết "chặn admin như phiếu sống", mà phiếu sống thì cả bỏ-người lẫn đổi-người đều bị chặn. Đổi người trên dòng còn giữ chữ ký của hồ sơ xoá mềm = khi khôi phục, ý kiến người A hoá thành của người B.
  • IgnoreQueryFilters() BẮT BUỘC ở tầng 2 và ở purge: PE/Contract có global filter !IsDeleted (PurchaseEvaluationConfiguration.cs:84) — không tắt thì hồ sơ xoá mềm biến mất khỏi JOIN và ta lại kết luận "mồ côi" = đúng cái sai đang chữa.
  • Sửa luôn dòng changelog "Dọn N chữ ký mồ côi (thuộc phiếu đã xoá)""(hồ sơ mang chữ ký đã KHÔNG CÒN trong hệ thống)" — nhãn cũ nay MÔ TẢ SAI việc đang làm.

Đo ngay sau khi vá (trước khi đụng test)

dotnet build SolutionErp.slnx0 Warning / 0 Error. dotnet test --filter UpdateAwDefinitionTests14 PASS / 1 FAIL. Đúng 1 fail, đúng cái phải fail: F14_F5a_OpinionBelongsToDeletedPe_RemovalAllowed_AndOrphanPurged — test này khẳng định CHÍNH hành vi mà F2 gọi là sai ("phiếu xoá mềm ⇒ cho gỡ + purge chữ ký"). Thông điệp thật nhận được: … còn 1 chữ ký/ý kiến đang LƯU, thuộc: Phiếu duyệt NCC PE-F14b-001 — đã xoá … ⇒ chứng minh 3 việc cùng lúc: guard tầng 2 CHẠY · IgnoreQueryFilters() DỊCH ĐƯỢC trên SQLite · thông điệp NÊU ĐÚNG TÊN hồ sơ. 0 test nào khác đỏ ⇒ vá không lan.