31 KiB
sub-invest-be-1 — ĐIỀU TRA BACKEND: nút XÓA phiếu PE ở màn DUYỆT
- run-id:
2026-07-27-S155-pe-delete-approver - vai: investigator-codebase (INTERNAL, read-only)
- quy ước: mọi dòng
[CODE]= đọc thẳng từ file trên đĩa ·[SUY LUẬN]= tôi suy ra, chưa có dòng mã nào nói ·[CHƯA XÁC MINH]= không chứng được - trạng thái file: ĐANG GHI DẦN (ghi ngay khi tìm ra từng mục — chống #53)
Q1 — Đường XÓA hiện có tới đâu?
F1. CÓ endpoint DELETE PE — nhưng không có policy authz [CODE]
src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:146-151
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeletePurchaseEvaluationCommand(id), ct);
return NoContent();
}
- Route đầy đủ:
DELETE /api/purchase-evaluations/{id}(:14[Route("api/purchase-evaluations")]). - Authz = CHỈ class-level
[Authorize]TRẦN (:15) — KHÔNG có[Authorize(Policy = "Pe_*.Delete")]ở action. ⇒ Bất kỳ user đã đăng nhập nào cũng gọi được endpoint này (chỉ bị chặn bởi guard phase ở handler, xem F3). ⇒ Trùng khớp gotcha #82 / bài học S118: menu-ẩn ≠ API-đóng. Đây là 2 tầng độc lập và tầng API hiện mở. - Tôi đã grep toàn controller: không một action nào trong
PurchaseEvaluationsController.cscó[Authorize(Policy=...)](0 hit) — cả file dựa hoàn toàn vào guard trong handler. Đây là thiết kế cố ý đã có từ trước (comment:69-71,:88-90,:120nói rõ "Class [Authorize] any-auth; handler fine-grained Forbidden").
F2. CÓ Command/Handler DeletePurchaseEvaluationCommand [CODE]
src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs:1392-1411
// ========== DELETE ==========
public record DeletePurchaseEvaluationCommand(Guid Id) : IRequest;
public class DeletePurchaseEvaluationCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationCommand>
{
public async Task Handle(DeletePurchaseEvaluationCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao
&& entity.Phase != PurchaseEvaluationPhase.TuChoi)
throw new ConflictException("Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối.");
db.PurchaseEvaluations.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
Ghi chú quan trọng về handler này:
- Handler KHÔNG inject
ICurrentUser⇒ không check ai là người xóa: không check Drafter-owner, không check role, không check "đến lượt bạn". Guard duy nhất là phase. - Handler KHÔNG ghi changelog (đối chiếu Q4 — các thao tác khác đều ghi).
- Handler KHÔNG đụng bảng con (xem Q5).
F3. XÓA hiện tại là SOFT delete — nhưng gián tiếp qua interceptor [CODE]
db.PurchaseEvaluations.Remove(entity) trông như hard-delete, nhưng bị AuditingInterceptor chặn lại và đổi thành soft:
src/Backend/SolutionErp.Infrastructure/Persistence/Interceptors/AuditingInterceptor.cs:54-63
foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = now;
entry.Entity.DeletedBy = userId;
}
}
⇒ Vì PurchaseEvaluation : AuditableEntity, mọi .Remove() trên PE = UPDATE IsDeleted=1 + DeletedAt + DeletedBy (DeletedBy lấy từ ICurrentUser.UserId — nên vết "ai xóa" CÓ, dù handler không tự ghi).
🔴 Đây là indirect-assignment giống hệt lớp bẫy gotcha #81-EXT: đọc handler thì tưởng hard-delete, sự thật là soft.
Q2 — State machine PE cho phép xoá ở trạng thái nào?
F4. Enum ĐỦ 10 giá trị (5 sống + 5 legacy deprecated) [CODE]
src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs:15-27
| Giá trị | Số | Ghi chú trong mã |
|---|---|---|
DangSoanThao |
1 | Nháp |
ChoPurchasing |
2 | [LEGACY] deprecated |
ChoDuAn |
3 | [LEGACY] deprecated |
ChoCCM |
4 | [LEGACY] deprecated |
ChoCEODuyetPA |
5 | [LEGACY] deprecated |
ChoCEODuyetNCC |
6 | [LEGACY] deprecated |
DaDuyet |
7 | Đã duyệt — terminal thành công |
ChoDuyet |
10 | Đã gửi duyệt — generic intermediate |
TraLai |
98 | Trả lại — Phase riêng |
TuChoi |
99 | Từ chối — terminal khoá phiếu |
⚠️ Lưu ý cho spec: 5 phase legacy 2-6 vẫn nằm trong enum và vẫn lọt lưới các query lũy kế (xem F9) — bất kỳ luật xóa nào viết theo dạng "≠ DaDuyet" hay "∈ {…}" phải nói rõ 2-6 rơi vào đâu.
F5. Guard XÓA hiện hành: chặn ChoDuyet — phiếu PE/2026/A/046 KHÔNG xóa được [CODE]
PurchaseEvaluationFeatures.cs:1404-1406 — allow-list = {DangSoanThao, TuChoi}. Phiếu ở ChoDuyet → 409 ConflictException "Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối."
⇒ Đây chính xác là chỗ chặn cái Tra Sol muốn. Đường ống đã có sẵn 100%, chỉ thiếu 1 nhánh cho ChoDuyet + gating quyền. [SUY LUẬN] — mã chỉ nói guard, chuyện "chỉ thiếu 1 nhánh" là đánh giá của tôi.
F6. Guard KHÔNG quan tâm "đã có ≥1 lượt duyệt" [CODE]
Không có điều kiện nào đọc Approvals.Count / CurrentApprovalLevelOrder / CurrentWorkflowStepIndex trong handler xóa (:1399-1410 toàn văn ở F2). Luật hiện hành chỉ nhìn Phase, không nhìn tiến-độ-duyệt.
⇒ Lịch sử duyệt (1) của phiếu A/046 không phải rào cản kỹ thuật; nếu spec muốn cấm xóa sau khi đã có người duyệt thì đó là luật MỚI, không phải luật đang có.
F7. Đối chiếu Contract (module anh em) — luật CHẶT HƠN [CODE]
src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs:567-580 (trích nguyên văn):
public class DeleteContractCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteContractCommand>
{
public async Task Handle(DeleteContractCommand request, CancellationToken ct)
{
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
if (entity.Phase >= ContractPhase.DangInKy)
throw new ConflictException("Không được xóa HĐ đã qua phase 'Đang in ký'.");
db.Contracts.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
⇒ HĐ dùng ngưỡng >= phase, PE dùng allow-list. Cả 2 đều Remove() → soft qua interceptor, đều không ghi changelog, đều không check người.
Q3 — 🔴 LŨY KẾ (câu quan trọng nhất)
File trung tâm: src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs (141 dòng, 2 hàm static).
F8. (a) Điều kiện gom phiếu — có HAI phép cộng, không phải một [CODE]
Window chung cả 2 hàm (:49-51 và :100-102) — y hệt nhau:
var peers = db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.ProjectId == projectId && p.WorkItemId == workItemId
&& p.Id != peId && p.CreatedAt < peCreatedAt);
(A) ComputeAsync — lũy kế "CHÍNH XÁC" (:40-82):
PrevSubmitted(:53-59):Phase == ChoDuyet || Phase == DaDuyet→Count+SUM(BudgetPeriodAmount ?? 0). 🔴 ChoDuyet ĐÃ được cộng ở đây — tức phiếu bấm-sai-gói-thầu đang ăn số ngay ở dòng "chính xác", không phải chỉ ở dòng tạm tính.PrevSelected(:61-71):Phase == DaDuyet AND Suppliers.Any(IsWinner)→ SUMThanhTiencác quoteIsSelected. (ChoDuyet không vào dòng này.)
(B) ComputePendingAsync — lũy kế "TẠM TÍNH" (:92-139):
PendingSubmitted(:104-112):Phase ∉ {DangSoanThao, DaDuyet, ChoDuyet, TuChoi}→ thực tế ={TraLai}+ legacy 2-6. Cố ý loại ChoDuyet để không double-count với (A).PendingSelected(:114-126):Phase ∉ {DangSoanThao, DaDuyet, TuChoi}ANDSuppliers.Any(s => s.IsWinner)→ GỒM ChoDuyet-có-winner (+ TraLai-có-winner + legacy 2-6) → SUMThanhTienquoteIsSelected.PriorPes(:128-133):Phase ∉ {DaDuyet, TuChoi}→ danh sách phiếu "cần lưu ý" (gồm cả Nháp).
🔴 Hệ quả trực tiếp cho đề bài: phiếu ChoDuyet bấm sai gói thầu ăn số ở 2 chỗ: PrevSubmittedTotal (dòng "chính xác") qua BudgetPeriodAmount, và PendingSelectedTotal (dòng "tạm tính") qua tổng báo giá được chọn.
🔴 "Trả lại" KHÔNG triệt tiêu — TraLai rơi khỏi (A) nhưng rơi VÀO (B) cả 2 dòng (PendingSubmitted + PendingSelected nếu còn winner). Đúng như run.md phán đoán, và đây là bằng chứng mã.
F9. (b) KHÔNG có dòng IsDeleted == false nào trong 2 query — lọc đến từ global filter [CODE]
Grep toàn PeBudgetAccumulator.cs: 0 hit IsDeleted trong thân query. Lọc đến từ EF global query filter:
src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs:84
b.HasQueryFilter(x => !x.IsDeleted);
Và chính comment của accumulator đã khai điều này — PeBudgetAccumulator.cs:15-16:
// peers = PurchaseEvaluations cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this
// (HasQueryFilter !IsDeleted tự loại phiếu xoá mềm).
Củng cố: IgnoreQueryFilters = 0 hit trên toàn src/Backend (grep đã chạy) ⇒ không có đường nào lách filter.
F10. (c) Set IsDeleted=true cho phiếu ChoDuyet → TỰ RỚT khỏi cả 2 phép cộng, không cần sửa thêm [CODE] + [SUY LUẬN]
[CODE]: cả 4 phép cộng đều bắt đầu từ db.PurchaseEvaluations (:49, :100) — kể cả 2 join tiền (:64-71, :119-126) vì chúng from p in selectedPeers / pendingSelectedPeers, tức đã bị filter chặn ở gốc. Join sang PurchaseEvaluationSuppliers/Quotes không cần filter riêng (2 bảng này là BaseEntity, không có IsDeleted, xác nhận PurchaseEvaluationSupplier.cs:9 + PurchaseEvaluationQuote.cs:8).
[SUY LUẬN]: ⇒ chỉ cần nới guard phase ở DeletePurchaseEvaluationCommandHandler là số lũy kế tự đúng. Không phải sửa accumulator. (Chưa chạy runtime để chứng — xem "Chưa xác minh" cuối file.)
F11. (e) Snapshot budgetFrozen (Mig 67) — KHÔNG tự sửa, đây là nợ số liệu tiềm ẩn [CODE]
- Phiếu
DaDuyetđọc 11 cột snapshot, không đọc live:PurchaseEvaluationFeatures.cs:929var frozen = e.Phase == DaDuyet && e.ApprovedBudgetSnapshotAt != null;→ nhánh:930-960phục vụ từe.ApprovedBudget*. - Snapshot được chốt 1 lần tại finalize:
PurchaseEvaluationWorkflowService.cs:1018-1023gọiComputeAsyncrồi gánApprovedBudgetPrevSubmittedTotal/Count/PrevSelectedTotal/Count. ⇒ Nếu phiếu X (ChoDuyet, sai gói) đã bị tính vào snapshot của phiếu Y duyệt sau đó, rồi sau này ta xóa X, thì số của Y KHÔNG đổi (đúng ý đồ "đóng băng", nhưng số đó nay dựa trên 1 phiếu không còn tồn tại). ⇒[SUY LUẬN]spec cần chốt: chấp nhận (freeze = record-of-decision) hay phải re-compute snapshot phiếu sau? Tôi nghiêng "chấp nhận + ghi vết", nhưng đây là quyết định của em main/anh, không phải của tôi. ⇒ Ngoài ra:956-959: nhánh frozen cố tình để Pending = 0/null* ⇒ phiếu DaDuyet không hiển thị tạm tính, nên xóa 1 phiếu ChoDuyet không làm đổi màn hình phiếu đã duyệt.
F12. (d) Grep hết consumer — chỉ có 2 hàm accumulator + 4 call-site, KHÔNG có read-site nào khác [CODE]
Lệnh đã chạy (để có thể tái kiểm):
grep -rn "PrevSubmitted\|lũy kế" --include=*.cs src/Backend | grep -v Migrations/→ 23 hit, tất cả thuộc 4 file:PeBudgetAccumulator.cs,PurchaseEvaluationFeatures.cs,PurchaseEvaluationWorkflowService.cs,PurchaseEvaluation.cs(+PurchaseEvaluationConfiguration.cskhai precision).grep -rn "db\.PurchaseEvaluations" --include=*.cs src/Backend→ 31 hit; ngoài 2 dòng accumulator (:49,:100), không hit nào là phép cộng tiền theo peers (còn lại: FirstOrDefault theo Id, list/inbox join, seeder, usage-count workflow).grep -rn "PurchaseEvaluation" src/Backend/SolutionErp.Application/Reports/ .../ReportsController.cs→ 0 hit ⇒ module Báo cáo KHÔNG gom PE.PeWorkItemBudgetFeatures.cs(trang ngân sách gói thầu): đọc/ghi bảngPeWorkItemBudgetsthuần, khôngSumnào trên PE (chỉFirstOrDefaultAsynctheoPeIdở:86,:157,:230).
4 call-site của accumulator:
| # | File:line | Hàm | Dùng để |
|---|---|---|---|
| 1 | PurchaseEvaluationFeatures.cs:966 |
ComputeAsync |
display live rows 1-2 |
| 2 | PurchaseEvaluationFeatures.cs:972 |
ComputePendingAsync |
display live rows tạm tính |
| 3 | PurchaseEvaluationWorkflowService.cs:1018 |
ComputeAsync |
ghi snapshot khi finalize |
| 4 | PurchaseEvaluationWorkflowService.cs:1029 |
ComputePendingAsync |
ghi changelog cảnh báo D4 |
[SUY LUẬN] ⇒ Rủi ro "sót read-site" (bài học cardinality_change_grep_consumers) ở đây THẤP: toàn bộ đường tiền đi qua đúng 1 file 141 dòng. Rủi ro thật nằm ở snapshot đã đóng băng (F11), không nằm ở query.
Q4 — Vết / audit
F13. PE có 2 bảng lịch sử TÁCH VAI (không phải 1) [CODE]
| Bảng | Entity | Vai |
|---|---|---|
PurchaseEvaluationApprovals |
PurchaseEvaluationApproval : BaseEntity (PurchaseEvaluationApproval.cs:7) |
per-approver record — ai duyệt, FromPhase/ToPhase/Decision/Comment. Config PurchaseEvaluationConfiguration.cs:156-170 |
PurchaseEvaluationChangelogs |
PurchaseEvaluationChangelog : BaseEntity (PurchaseEvaluationChangelog.cs:9) |
nhật ký thao tác — EntityType × Action × PhaseAtChange × Summary × ContextNote × FieldChangesJson. Config :172-190 |
(Phân vai này chính là bài học gotcha #79 — Mig 60 backfill soi nhầm Approvals thay vì Changelogs.)
Enum sẵn có:
ChangelogAction(Domain/Contracts/ContractChangelog.cs:38-44):Insert=1, Update=2, **Delete=3**, Transition=4→ đã có sẵnDelete, không cần migration.PurchaseEvaluationEntityType(PurchaseEvaluationChangelog.cs:25-33):Header=1, Supplier=2, Detail=3, Quote=4, Workflow=5, Attachment=6.
F14. Duyệt / Trả lại / Từ chối ghi vết ở đâu [CODE]
Tất cả đi qua 1 helper duy nhất: PurchaseEvaluationWorkflowService.LogTransitionAsync (:1158-1185), được gọi 13 lần (:160, :269, :315, :644, :828, :870, :909, :925, :942, :950, :1146, :1153 + :1027 chú thích). Nội dung ghi (:1175-1185):
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = evaluation.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = toPhase,
UserId = actorUserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Chuyển phase {fromPhase} → {toPhase}",
ContextNote = comment, // ← lý do do người duyệt gõ
});
Kèm notify Drafter ở :1188-1197 (DaDuyet / TuChoi / TraLai).
F15. Handler xóa KHÔNG ghi changelog — vết duy nhất là 3 cột của interceptor, và nó THIẾU 2 thứ [CODE] + [SUY LUẬN]
[CODE] PurchaseEvaluationFeatures.cs:1399-1410: 0 dòng Changelogs.Add, 0 dòng Approvals.Add. Vết duy nhất = IsDeleted / DeletedAt / DeletedBy do AuditingInterceptor.cs:56-62 tự set.
[SUY LUẬN] Vết đó có ai + lúc nào, nhưng thiếu:
- Lý do xóa — không có chỗ chứa (không cột nào ngoài 3 cột trên). Đây là điều bắt buộc phải có cho ca "bắt sai gói thầu" (người khác cần biết vì sao phiếu biến mất).
- Không ai đọc được — 3 cột đó chỉ soi bằng SQL. UI đọc lịch sử qua
ListPurchaseEvaluationChangelogsQuery(PurchaseEvaluationFeatures.cs:1415-1434) mà query đó lọcWHERE PurchaseEvaluationId == id→ phiếu đã xóa không còn mở được ⇒ vết chôn theo phiếu. Người cùng gói thầu (đang nhìn số lũy kế đột nhiên tụt) không có cách nào biết phiếu nào vừa bị rút ra.
F16. Chèn vết ở đâu cho khớp pattern đang có [CODE] (tiền lệ trong repo)
ChangelogAction.Delete đã được dùng ở 5 site PE/HĐ — đây là khuôn có sẵn, chỉ việc mirror:
PurchaseEvaluationSupplierFeatures.cs:164(xóa NCC khỏi phiếu)PurchaseEvaluationDetailFeatures.cs:255(xóa hạng mục) và:392(xóa báo giá)PurchaseEvaluationAttachmentFeatures.cs:173(xóa đính kèm)PeDepartmentOpinionFeatures.cs:143(xóa ý kiến phòng ban)- (HĐ:
ContractAttachmentFeatures.cs:144,ContractDetailsFeatures.cs:467quaChangelogService)
[SUY LUẬN] ⇒ khuôn tự nhiên: trong DeletePurchaseEvaluationCommandHandler, TRƯỚC Remove(), Add 1 row {EntityType=Header (hoặc Workflow), Action=Delete, PhaseAtChange=<phase lúc xóa>, UserId=currentUser, Summary="Xóa phiếu ...", ContextNote=<lý do bắt buộc nhập>}. Handler hiện chưa inject ICurrentUser nên phải thêm (mọi handler PE khác đều đã inject — vd PeWorkItemBudgetFeatures.cs:83).
⚠️ Nhưng row changelog đó cũng chôn theo phiếu (F15 mục 2). Nếu muốn người khác thấy, phải có nơi hiển thị NGOÀI phiếu — [CHƯA XÁC MINH] repo hiện không có bảng audit toàn cục (AuditLogs vẫn nằm ở mục "future" trong skill contract-workflow, tôi đã grep class AuditLog → 0 hit trong src/Backend/SolutionErp.Domain).
Q5 — Quan hệ dữ liệu (câu quan trọng thứ 2)
F17. Bản đồ FK — 6 collection Cascade + 1 Cascade riêng, 0 Restrict phía con [CODE]
PurchaseEvaluationConfiguration.cs:75-80:
b.HasMany(x => x.Suppliers)...OnDelete(DeleteBehavior.Cascade); // :75
b.HasMany(x => x.Details)...OnDelete(DeleteBehavior.Cascade); // :76
b.HasMany(x => x.Approvals)...OnDelete(DeleteBehavior.Cascade); // :77
b.HasMany(x => x.Changelogs)...OnDelete(DeleteBehavior.Cascade); // :78
b.HasMany(x => x.Attachments)...OnDelete(DeleteBehavior.Cascade); // :79
b.HasMany(x => x.DepartmentOpinions)...OnDelete(DeleteBehavior.Cascade); // :80
PurchaseEvaluationLevelOpinionConfiguration.cs:20-28— Cascade về PE, Restrict vềApprovalWorkflowLevel.Quoteskhông FK thẳng lên PE mà quaDetail(PurchaseEvaluationDetailConfiguration.cs:130Cascade) — vàQuote → Supplierlà Restrict (:149-152).- FK ra ngoài:
PE → ApprovalWorkflowRestrict (:70-73).
F18. 🔴 Soft-delete cha ⇒ cascade KHÔNG BAO GIỜ chạy ⇒ con ở lại nguyên vẹn [CODE] + [SUY LUẬN]
[CODE] AuditingInterceptor.cs:58 đổi entry.State = EntityState.Deleted → Modified. EF chỉ phát DELETE (và cascade) cho state Deleted. Với Modified nó phát UPDATE.
[SUY LUẬN] ⇒ Sau khi xóa 1 PE: toàn bộ PurchaseEvaluationSuppliers, Details, Quotes, Approvals, Changelogs, Attachments, DepartmentOpinions, LevelOpinions vẫn nằm trong DB, IsDeleted không tồn tại/không đổi, trỏ về 1 phiếu cha vô hình.
Xác nhận không có cột IsDeleted ở con [CODE]: 7/8 bảng con là BaseEntity (không có IsDeleted): PurchaseEvaluationSupplier.cs:9, Detail.cs:7, Quote.cs:8, Approval.cs:7, Changelog.cs:9, Attachment.cs:15. Chỉ 2 bảng là AuditableEntity: PurchaseEvaluationDepartmentOpinion.cs:24 + PurchaseEvaluationLevelOpinion.cs:24 — nhưng cả 2 không có HasQueryFilter (grep HasQueryFilter chỉ ra 1 hit duy nhất trong file config PE là dòng :84 của bảng cha).
Có lộ số ở query nào khác không? — KHÔNG, nhưng nhờ MAY hơn nhờ thiết kế [CODE]:
Tôi grep toàn bộ read-site đứng thẳng trên bảng con (db.PurchaseEvaluationSuppliers|Quotes|LevelOpinions, 38 hit): mọi hit đều bị chặn bởi 1 trong 2 điều kiện — (i) join/from p in peers bắt nguồn từ db.PurchaseEvaluations (nên dính filter cha) — PeBudgetAccumulator.cs:66/68/121/123, PurchaseEvaluationFeatures.cs:656/783, CreateContractFromEvaluationFeatures.cs:185; hoặc (ii) lọc theo PurchaseEvaluationId == <id phiếu đang mở> / supplierRowIds lấy từ phiếu đó. Không có 1 read-site nào quét ngang bảng con toàn hệ thống.
[SUY LUẬN] ⇒ rác tồn tại thật nhưng hiện không lộ số. Rủi ro là tương lai: ai viết 1 report kiểu SUM(Quotes) GROUP BY project mà không join PE sẽ ăn phải rác này. Đáng ghi thành ràng buộc trong spec, không phải blocker.
F19. PeWorkItemBudgets KHÔNG phải bảng con của PE — xóa phiếu không đụng tới [CODE]
PeWorkItemBudget khoá theo cặp (ProjectId, WorkItemId) (PeWorkItemBudgetConfiguration.cs:27 UNIQUE filtered [IsDeleted]=0), dùng chung cho mọi phiếu cùng gói. Không có FK PE→PeWorkItemBudget (0 hit HasMany/HasOne giữa 2 entity). Handler chỉ resolve nó qua PE (PeWorkItemBudgetFeatures.cs:86/157).
⇒ Xóa phiếu không được xóa/giảm record ngân sách gói thầu. Đúng ý đồ (bảng ngân sách là "tài liệu sống" — comment PeWorkItemBudgetFeatures.cs:19-21).
F20. Đối chiếu: xóa NCC-trong-phiếu là HARD delete [CODE]
PurchaseEvaluationSupplierFeatures.cs:170 db.PurchaseEvaluationSuppliers.Remove(row) — vì PurchaseEvaluationSupplier : BaseEntity (không AuditableEntity) nên interceptor không bắt → xóa cứng thật. Có guard :154 chặn khi còn quote.
⇒ Trong cùng module, Remove() cho ra 2 hành vi khác nhau tuỳ base class. Ai đọc spec phải nói rõ đang nói bảng nào (bẫy #81-EXT lớp indirect-assignment).
Q6 — 🔴 KHẢO CỔ GIT: "chỗ cho huy"
F21. TÌM THẤY. "chỗ cho huy" = nút "Từ chối" ở màn duyệt, bị gỡ 2026-06-12 [CODE]
Commit: 6db195dd4270a8c463305d5051c901e05748c403 — Fri Jun 12 2026 14:30:38 +0700 — phiên S60.
Subject: [CLAUDE] PurchaseEvaluation: go han hanh dong "Tu choi" - chi con Duyet hoac Tra lai (UAT anh Kiet S60 14:14)
Lý do gỡ (verbatim từ mã, PurchaseEvaluationWorkflowService.cs:94-100):
// ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" =====
// "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi." Mọi policy đã bỏ
// TuChoi khỏi NextPhases (FE hết nút); guard này chặn caller direct
// (API forge / client cũ cache) — đứng TRƯỚC mọi branch nên chặn CẢ
// Admin manual override (spec = bỏ hẳn hành động, không escape hatch).
// Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter. Flip lại nếu cần:
// xóa guard này + restore transitions trong PurchaseEvaluationPolicy.
⇒ Khớp từng chữ lời anh: "hôm trước có cái chỗ cho hủy / mọi người nói là ko cần cái đó / giờ lại cần". Người yêu cầu gỡ = anh Kiệt (FDC), ngày 12/06/2026 lúc 14:14, lý do = "Duyệt hoặc Trả về thôi" (giản lược UX), KHÔNG phải vì lỗi kỹ thuật.
Session log: docs/changelog/sessions/2026-06-12-S60-S62-pe-budget-workitem-softwarning.md:23-28.
Diff (6 file): 2 FE PeWorkflowPanel.tsx (filter next.filter(p != TuChoi)), Domain PurchaseEvaluationPolicy.cs (−42 dòng transition), Service (+guard 14 dòng), +2 test spec-change. Test 254 → 256 PASS.
F22. 🔴 Guard S60 VẪN SỐNG và chính nó trỏ người dùng sang "Xóa phiếu" [CODE]
PurchaseEvaluationWorkflowService.cs:101-106:
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{
throw new ConflictException(
"Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " +
"Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp.");
}
🔴 Đây là mắt xích logic của cả đề bài: S60 gỡ "Từ chối" và chuyển hướng sang "Xóa phiếu" — nhưng "Xóa phiếu" lại chỉ chạy được ở Bản nháp (F5). Vậy phiếu đã gửi duyệt rơi vào lỗ: không Từ chối được (gỡ rồi), không Xóa được (guard phase), chỉ Trả lại được — mà Trả lại không triệt tiêu lũy kế (F8). Đúng y điều Tra Sol mô tả: "quay lại không được, phải xóa thì nó mới ko có lũy kế lên".
[SUY LUẬN] ⇒ Yêu cầu S155 không phải feature mới, nó là đóng cái lỗ do S60 mở ra. Ghi chú S60 còn để sẵn đường lui: "Flip lại nếu cần: xóa guard này + restore transitions".
F23. Nguồn gốc nút "Xóa phiếu" hiện tại [CODE]
4678d19[CLAUDE] App+Api: PurchaseEvaluation CQRS + Controller + WorkflowService— commit sinh raDeletePurchaseEvaluationCommand(git log -S).378c993[CLAUDE] FE-Admin+FE-User: PE detail polish B12 — Lưu (no close), **Xóa phiếu**, header bar simplify, NCC name col, **no-delete có quotes**— commit đưa nút Xóa lên UI (phía người soạn).- Không có commit nào GỠ endpoint/handler xóa PE (git log -S
DeletePurchaseEvaluationCommandchỉ ra 2 commit: 1 tạo + 1wal: flushhôm nay).
Lệnh đã chạy đầy đủ (để tái kiểm):
git log --oneline -S "DeletePurchaseEvaluationCommand" -- .
git log --oneline -S "Xóa phiếu"
git log --oneline -S "handleDelete" -- fe-admin fe-user → RỖNG
git log --oneline -i --grep="thu h"
git log --oneline -i --grep="xóa phiếu"
git show --stat 6db195d
grep -rni "thu hồi|thuhoi|recall|withdraw" --include=*.cs --include=*.tsx --include=*.ts src fe-admin/src fe-user/src
[CHƯA XÁC MINH] Tôi không chạy git log --diff-filter=D (xóa nguyên file) vì hướng "Từ chối bị gỡ" đã cho kết quả khớp verbatim lời anh; nếu reviewer muốn loại trừ khả năng có file bị xóa hẳn thì cần chạy thêm.
Q7 — Pattern tham chiếu
F24. KHÔNG có tiền lệ thu-hồi/hủy một chứng-từ ĐANG CHẠY workflow [CODE]
Grep public record Delete.*Command toàn src/Backend/SolutionErp.Application (lọc các module chứng từ) → chỉ 2 lệnh xóa cấp-chứng-từ tồn tại trong repo:
DeletePurchaseEvaluationCommand(PurchaseEvaluationFeatures.cs:1394) — allow-list{Nháp, Từ chối}DeleteContractCommand(ContractFeatures.cs:565) — ngưỡng< DangInKy
0 lệnh xóa/hủy cho Proposal, LeaveRequest, OtRequest, TravelRequest, VehicleBooking, ItTicket — các module này không có đường rút đơn nào cả.
Thứ gần nhất (nhưng KHÔNG phải workflow): CancelMeetingBookingHandler (src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs:457-479) — đáng đọc vì nó là khuôn duy nhất trong repo cho "hủy có vết":
var isOwner = entity.BookedByUserId == userId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép huỷ booking.");
// Status=Cancelled (NOT IsDeleted=true) — preserve history + audit trail.
entity.Status = MeetingBookingStatus.Cancelled;
3 điểm đáng mượn: (1) check người trong handler (owner-or-admin) — thứ handler xóa PE đang thiếu hoàn toàn (F2); (2) chuyển TRẠNG THÁI thay vì IsDeleted, chú thích rõ lý do "preserve history"; (3) DELETE verb ở API nhưng semantics là cancel (MeetingFeatures.cs:25).
[SUY LUẬN] ⇒ Spec S155 phải dựng luật mới, không có khuôn PE/HĐ để copy. Hai hướng khả dĩ, cả hai đều có tiền lệ trong repo:
- (A) Nới guard xóa (rẻ nhất — đường ống đã có, lũy kế tự đúng theo F10) + thêm authz + changelog.
- (B) Khôi phục "Từ chối" (S60 để sẵn đường lui, F22) — nhưng
TuChoiKHÔNG triệt tiêuPrevSubmittedở dòng chính-xác? — có triệt tiêu:ComputeAsyncchỉ nhận{ChoDuyet, DaDuyet}(PeBudgetAccumulator.cs:54-55) vàComputePendingAsyncloạiTuChoiở cả 3 phép (:108,:116,:130) ⇒TuChoirơi khỏi 100% các phép lũy kế. Tức (B) cũng giải quyết được bài toán số liệu, mà giữ được phiếu để tra cứu — hợp ý F15 (vết không bị chôn). Quyết định A/B không thuộc thẩm quyền tôi — nêu để em main/anh chọn.
Tổng kết + những gì CHƯA xác minh
Chưa xác minh (khai rõ, không lấp bằng suy đoán):
- Chưa chạm DB nào (Dev lẫn prod) — mọi kết luận đến từ đọc mã. Cụ thể chưa chứng bằng runtime rằng
IsDeleted=truelàm số lũy kế tụt (F10 là suy luận từ query filter, độ tin cao nhưng chưa có phép đo). - Chưa đếm hiện prod có bao nhiêu phiếu ChoDuyet-có-winner đang ăn lũy kế sai. Lệnh gợi ý cho implementer (Dev):
sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q "SELECT p.MaPhieu,p.Phase,p.ProjectId,p.WorkItemId FROM PurchaseEvaluations p WHERE p.IsDeleted=0 AND p.Phase=10 AND EXISTS(SELECT 1 FROM PurchaseEvaluationSuppliers s WHERE s.PurchaseEvaluationId=p.Id AND s.IsWinner=1)"(prod qua SSH thường chết khi nạp SQL-client — S134/S148, cần implementer làm trực tiếp.) - Chưa chạy
git log --diff-filter=D(F23). - Chưa điều tra FE — thuộc phần
sub-invest-fe-2.md.